polish(wardrobe): unify hover vocabulary + lift the Try-On CTA

The garment detail page used three different hover dialects — the
model picker reacted with a primary-tinted bg + border (feels like a
button), the secondary action buttons had a plain muted-grey hover,
the edit pencil was invisible until hover, and the hero photo was
entirely static. Result: the model picker was the only place that
telegraphed "click me". Everything else felt flat.

Align on one vocabulary across the page: primary-tinted border +
primary/5 bg on hover for anything interactive.

- Hero photo is now a `<button>` that opens the existing ImageLightbox
  with the garment's full-res mana-media URL (synthesised as a minimal
  picture.Image — prompt=name, no model/dims/date noise). Hover adds
  the primary-tinted border + a subtle shadow-md + a 1% scale on the
  `<img>` for depth.
- Edit pencil becomes a labelled button "Bearbeiten" with the same
  primary hover. No more hover-to-discover — editing reads as a
  first-class action.
- "Heute getragen", "Archivieren" drop the plain muted hover for the
  primary-tinted one. "Löschen" keeps its destructive-red tint but
  adds border-error/50 on hover so it feels as interactive as the
  others.

Try-On CTA now reads as the most important action:
- rounded-lg + px-5 py-3.5 + text-base + font-semibold (was
  rounded-md + px-4 py-2 + text-sm + font-medium).
- shadow-md shadow-primary/20 at rest → shadow-lg shadow-primary/30
  on hover, combined with -translate-y-0.5 for a subtle lift.
- active:translate-y-0 + shadow-sm makes the press feel tactile.
- Sparkle icon bumped 16 → 18, spinner likewise.

Applied to both GarmentTryOnButton (solo) and TryOnButton (outfit)
so the two surfaces share CTA weight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:31:11 +02:00
parent 201a085872
commit 0ee3b145f0
3 changed files with 72 additions and 23 deletions

View file

@ -163,25 +163,29 @@
disabled={running}
/>
<!-- Primary CTA: lifted + shadowed so it reads as the most
important action on the page. Hover raises the button
subtly (translate + stronger shadow); active-press sinks
it back flat for tactile feedback. -->
<button
type="button"
onclick={handleClick}
disabled={running || !canTryOn}
class="flex w-full flex-col items-center justify-center gap-0.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-full flex-col items-center justify-center gap-0.5 rounded-lg bg-primary px-5 py-3.5 text-base font-semibold text-primary-foreground shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5 hover:bg-primary/95 hover:shadow-lg hover:shadow-primary/30 active:translate-y-0 active:shadow-sm disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-md"
>
{#if running}
<span class="flex items-center gap-2">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
class="h-5 w-5 animate-spin rounded-full border-2 border-current border-r-transparent"
></span>
Rendere…
</span>
{:else}
<span class="flex items-center gap-2">
<Sparkle size={16} weight="fill" />
<Sparkle size={18} weight="fill" />
An mir anprobieren
</span>
<span class="text-xs font-normal opacity-75">{estimatedCredits} Credits</span>
<span class="text-xs font-normal opacity-80">{estimatedCredits} Credits</span>
{/if}
</button>

View file

@ -160,25 +160,28 @@
disabled={running}
/>
<!-- Primary CTA: matches GarmentTryOnButton's lift + shadow
treatment so both surfaces use the same visual weight for
"produce the generation". -->
<button
type="button"
onclick={handleClick}
disabled={running || !canTryOn}
class="flex w-full flex-col items-center justify-center gap-0.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-full flex-col items-center justify-center gap-0.5 rounded-lg bg-primary px-5 py-3.5 text-base font-semibold text-primary-foreground shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5 hover:bg-primary/95 hover:shadow-lg hover:shadow-primary/30 active:translate-y-0 active:shadow-sm disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-md"
>
{#if running}
<span class="flex items-center gap-2">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
class="h-5 w-5 animate-spin rounded-full border-2 border-current border-r-transparent"
></span>
Rendere…
</span>
{:else}
<span class="flex items-center gap-2">
<Sparkle size={16} weight="fill" />
<Sparkle size={18} weight="fill" />
Anprobieren
</span>
<span class="text-xs font-normal opacity-75">{estimatedCredits} Credits</span>
<span class="text-xs font-normal opacity-80">{estimatedCredits} Credits</span>
{/if}
</button>

View file

@ -42,9 +42,32 @@
let saving = $state(false);
let markingWorn = $state(false);
// Lightbox state for the Anproben-Strip. Null = closed, Image = open.
// Lightbox state — shared between the Anproben-Strip (try-on thumbs)
// and the hero-photo. Null = closed, Image = open.
let lightboxImage = $state<Image | null>(null);
// The hero-photo is a garment row, not a picture.Image — synthesise
// the shape the lightbox expects so clicking the photo opens the
// full-resolution mana-media URL with the garment's name as
// prompt-caption. No model / dims / date are rendered (all optional
// in the lightbox), keeping the modal clean for a plain clothing
// photo.
function openPhotoLightbox() {
if (!garment || !garment.mediaIds[0]) return;
lightboxImage = {
id: garment.id,
prompt: garment.name,
storagePath: garment.mediaIds[0],
filename: garment.name,
publicUrl: garmentPhotoUrl(garment.mediaIds[0], 'large'),
visibility: 'private',
isFavorite: false,
downloadCount: 0,
createdAt: garment.createdAt,
updatedAt: garment.updatedAt,
};
}
async function handleMarkWorn() {
if (!garment) return;
markingWorn = true;
@ -98,16 +121,27 @@
{/if}
{:else}
<div class="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<!-- Photo -->
<div class="overflow-hidden rounded-2xl border border-border bg-muted">
{#if garment.mediaIds[0]}
<!-- Photo — clickable: opens the lightbox with the full-res
image so the user can inspect detail at the original
resolution. Hover state mirrors the Try-On thumbnail
strip + model picker (primary-tinted border) so the
whole page uses one interaction vocabulary. -->
{#if garment.mediaIds[0]}
<button
type="button"
onclick={openPhotoLightbox}
aria-label="Foto vergrößern"
class="group block overflow-hidden rounded-2xl border border-border bg-muted transition-all hover:border-primary/50 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
<img
src={garmentPhotoUrl(garment.mediaIds[0], 'large')}
alt={garment.name}
class="h-full w-full object-cover"
class="h-full w-full object-cover transition-transform group-hover:scale-[1.01]"
/>
{/if}
</div>
</button>
{:else}
<div class="rounded-2xl border border-border bg-muted"></div>
{/if}
<!-- Metadata / Edit -->
<div class="space-y-4">
@ -120,14 +154,19 @@
<h1 class="text-lg font-semibold text-foreground">{garment.name}</h1>
<p class="text-sm text-muted-foreground">{CATEGORY_LABELS[garment.category]}</p>
</div>
<!-- Edit affordance uses the same primary-tinted hover as
the Try-On thumbs / model picker so interactive elements
on the page share one hover vocabulary. Label is
always visible (not hover-to-discover) so editing
reads as a first-class action. -->
<button
type="button"
onclick={() => (editing = true)}
aria-label="Bearbeiten"
title="Bearbeiten"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
class="flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
>
<PencilSimple size={16} />
<PencilSimple size={14} />
Bearbeiten
</button>
</header>
@ -195,23 +234,26 @@
<!-- Try-on — "wie sähe das an mir aus" -->
<GarmentTryOnButton {garment} />
<!-- Wear-tracking -->
<!-- Wear-tracking — same primary-tinted hover as edit /
model picker / try-on thumbs. -->
<button
type="button"
onclick={handleMarkWorn}
disabled={markingWorn}
class="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
class="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-background"
>
<CheckCircle size={14} />
{markingWorn ? 'Gespeichert…' : 'Heute getragen'}
</button>
<!-- Secondary actions -->
<!-- Secondary actions. Archive keeps the primary-tint hover;
Löschen stays destructive-red so the action reads as
dangerous even at a glance. -->
<div class="flex gap-2">
<button
type="button"
onclick={handleArchive}
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<Archive size={14} />
{garment.isArchived ? 'Wieder aktiv' : 'Archivieren'}
@ -219,7 +261,7 @@
<button
type="button"
onclick={handleDelete}
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:border-error/50 hover:bg-error/10"
>
<Trash size={14} />
Löschen