polish(picture): clean borderless lightbox — image-first, meta in the corner

Feedback on the previous lightbox: the image was capped at 60vh
inside a bordered card, with metadata + action buttons stacked
underneath in their own panel. Result: ~40% of the modal surface
was chrome around a small picture, and the eye landed on the card
edge before the actual generation. The user asked for image-first:
fill the height, no module border, gray text in the bottom-right
corner.

New layout:

- Borderless backdrop. The card frame is gone; the image sits
  directly on a near-black overlay (rgba(0,0,0,0.92)) so nothing
  competes for the eye.
- Image fills the available area via `max-h-full max-w-full
  object-contain` inside a flex-center wrapper, with 6/10-rem
  outer padding so the picture doesn't kiss the viewport edges
  on small screens.
- Metadata moves to a small grey overlay in the bottom-right
  corner — prompt on top, then a single inline detail line
  (model · dimensions · date) separated by middle dots. Right-
  aligned so longer prompts wrap toward the image edge instead
  of the centre.
- Close becomes a circular icon-button in the top-right (X), no
  longer a footer button. ESC + backdrop-click still close.
- Caller-supplied actions (the `actions` snippet) move to the
  bottom-left so they don't fight the meta block visually.

Colour treatment uses literal white-alpha + black-alpha values
in a scoped `<style>` block instead of theme tokens. The lightbox
always sits on a literal near-black backdrop regardless of which
theme is active, so theme-aware muted tokens would render too
dark in light themes. The validator's brand-literal escape hatch
(see scripts/validate-theme-utilities.mjs comment) covers this
exact case.

Empty-state (publicUrl missing) gets a small SquaresFour icon in
a soft white-alpha tile so the modal still has a visible centre
when an image fails to load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 14:10:11 +02:00
parent d62ae8f1e3
commit bd559e739e

View file

@ -7,34 +7,40 @@
keeps ownership because the component is typed against
`picture.types.Image` and speaks prompt/model/dims vocabulary.
The lightbox is dumb: it accepts an optional `image` and an
`onClose` callback. Pass a non-null image to open, null/undefined
to hide. Module-specific actions (Favorit-Toggle, Archiv, Download,
"In Picture öffnen" …) go through the `actions` snippet so each
caller wires only what makes sense there. A plain backdrop click
and ESC both close the modal.
Design: borderless, image-first. The picture fills the entire
viewport (object-contain so aspect ratio is preserved). No card
chrome — the dark backdrop alone frames the image. Metadata sits
in the bottom-right corner as small grey text overlay so the eye
goes to the image first; the only persistent control is a close
affordance in the top-right. Module-specific buttons (Favorit /
Archiv / "In Picture öffnen") render in the bottom-left via the
`actions` snippet. Backdrop click and ESC both close.
Colour note: text uses literal white-alpha + black-alpha utilities
via a scoped `<style>` block instead of theme tokens. The lightbox
always renders against a near-black backdrop regardless of the
active theme, so a theme-aware muted-foreground would render too
dark in light themes. Matches the validator's brand-literal escape
hatch (see `scripts/validate-theme-utilities.mjs`).
-->
<script lang="ts">
import { formatDate } from '$lib/i18n/format';
import { onMount, type Snippet } from 'svelte';
import { SquaresFour } from '@mana/shared-icons';
import { SquaresFour, X } from '@mana/shared-icons';
import type { Image } from '../types';
interface Props {
/** Non-null to render, null/undefined to hide. */
image: Image | null | undefined;
onClose: () => void;
/** Caller-specific controls rendered to the left of "Schließen".
* Picture gallery uses this for Favorit / Archiv; wardrobe uses
* it for a deep-link to the Picture gallery. */
/** Caller-specific controls rendered in the bottom-left. Picture
* gallery uses this for Favorit / Archiv; wardrobe uses it for
* a deep-link to the Picture gallery. */
actions?: Snippet;
}
let { image, onClose, actions }: Props = $props();
// Escape-to-close. Rebind on mount only once — the handler itself
// checks `image` at call time so we don't need to wire it to the
// prop's lifecycle.
onMount(() => {
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && image) {
@ -55,62 +61,108 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
class="lightbox-root fixed inset-0 z-50 flex items-center justify-center p-6 sm:p-10"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label="Bildvorschau"
tabindex="-1"
>
<div
class="relative max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-border bg-card"
>
<div class="relative flex items-center justify-center bg-black">
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
class="max-h-[60vh] w-full object-contain"
/>
{:else}
<div class="flex h-64 items-center justify-center">
<SquaresFour size={64} class="text-muted-foreground/30" />
</div>
{/if}
<!-- Image: fills the available area, centred, aspect-preserving. -->
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
class="block max-h-full max-w-full object-contain"
/>
{:else}
<div class="lightbox-empty flex h-64 w-64 items-center justify-center rounded-2xl">
<SquaresFour size={64} />
</div>
{/if}
<div class="p-4">
<p class="text-sm text-foreground">{image.prompt}</p>
{#if image.model}
<p class="mt-1 text-xs text-muted-foreground">Modell: {image.model}</p>
{/if}
<!-- Close: top-right, minimal. -->
<button
type="button"
onclick={onClose}
aria-label="Schließen"
title="Schließen (Esc)"
class="lightbox-close absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full"
>
<X size={18} weight="bold" />
</button>
<!-- Caller-supplied actions: bottom-left. -->
{#if actions}
<div class="lightbox-actions absolute bottom-6 left-6 flex gap-2">
{@render actions()}
</div>
{/if}
<!-- Metadata: bottom-right, small, grey. Right-aligned so the
longest line (the prompt) wraps naturally toward the image
edge. `pointer-events-none` keeps clicks falling through to
the backdrop dismiss. -->
<div class="lightbox-meta pointer-events-none absolute bottom-6 right-6 max-w-md text-right">
<p class="lightbox-meta-prompt">{image.prompt}</p>
<div class="lightbox-meta-detail">
{#if image.model}<span>{image.model}</span>{/if}
{#if image.width && image.height}
<p class="text-xs text-muted-foreground">
{image.width} × {image.height}
</p>
<span>{image.width}×{image.height}</span>
{/if}
<p class="text-xs text-muted-foreground">
<span>
{formatDate(new Date(image.createdAt), {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
<div class="mt-3 flex gap-2">
{#if actions}
{@render actions()}
{/if}
<div class="flex-1"></div>
<button
type="button"
onclick={onClose}
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
>
Schließen
</button>
</div>
</span>
</div>
</div>
</div>
{/if}
<style>
/* The lightbox always renders against a literal near-black backdrop,
regardless of the active theme — using theme-aware tokens here
would tint or wash out unpredictably under non-dark themes. The
validator's brand-literal escape hatch (see
scripts/validate-theme-utilities.mjs) covers exactly this case. */
.lightbox-root {
background-color: rgba(0, 0, 0, 0.92);
}
.lightbox-empty {
color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.04);
}
.lightbox-close {
color: rgba(255, 255, 255, 0.7);
background-color: rgba(255, 255, 255, 0.08);
transition:
background-color 0.15s,
color 0.15s;
}
.lightbox-close:hover {
color: rgba(255, 255, 255, 1);
background-color: rgba(255, 255, 255, 0.15);
}
.lightbox-meta-prompt {
font-size: 0.8125rem;
line-height: 1.4;
color: rgba(255, 255, 255, 0.55);
}
.lightbox-meta-detail {
margin-top: 0.25rem;
display: flex;
gap: 0.625rem;
justify-content: flex-end;
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
}
.lightbox-meta-detail span:not(:first-child)::before {
content: '·';
margin-right: 0.625rem;
color: rgba(255, 255, 255, 0.25);
}
</style>