art: desktop zoom in the lightbox (pan-by-cursor), mobile keeps pinch

The full-screen lightbox showed the framed piece capped at ~66vh, so on desktop it was
barely larger than the page view and there was no way to inspect detail (mobile can pinch).
Add a "Zoom in" affordance: it swaps to a magnified inspection view (full-res image scaled
2.5×) where moving the cursor pans via transform-origin; click or Escape steps back to the
framed view, Escape/✕/backdrop close. Restructured the lightbox from a single <button> to a
dialog (backdrop button + close button + stage) so the controls are valid/accessible. Zoom
button hidden on touch (hover:none) — native pinch covers mobile. Uses the already-cached
full-res copy (/api/art/image/<id>?size=full); fade-in, frame/thickness, rotate-on-portrait
all preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-30 14:52:10 -04:00
parent d79c52dab4
commit f3005e626d
+75 -13
View File
@@ -27,9 +27,25 @@
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image. // screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
let landscape = $state(false); let landscape = $state(false);
function onKey(e) { // Desktop zoom: inside the lightbox, magnify the artwork and pan by moving the cursor
if (e.key === 'Escape' && zoom) zoom = false; // (transform-origin tracks the pointer). Mobile keeps native pinch — the Zoom button is
// hidden there. ox/oy are the transform-origin in %.
let zoomed = $state(false);
let ox = $state(50), oy = $state(50);
function enterZoom() { ox = 50; oy = 50; zoomed = true; }
function panZoom(e) {
const r = e.currentTarget.getBoundingClientRect();
ox = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100));
oy = Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100));
} }
function onKey(e) {
if (e.key !== 'Escape') return;
if (zoomed) zoomed = false; // Escape steps out of zoom first, then closes
else if (zoom) zoom = false;
}
// Leaving the lightbox always drops zoom too, so re-opening starts framed.
$effect(() => { if (!zoom) zoomed = false; });
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely. // Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); }); $effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
@@ -218,15 +234,33 @@
</div> </div>
{#if zoom && art} {#if zoom && art}
<button class="lightbox" class:rotate={landscape} bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork"> <div class="lightbox" class:rotate={landscape && !zoomed} class:zoomed bind:this={lightboxEl}
<span class="lb-stage"> tabindex="-1" role="dialog" aria-modal="true" aria-label="{art.title}, full screen">
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}"> <button class="lb-backdrop" onclick={() => (zoom = false)} aria-label="Close artwork"></button>
{#if isWood}{@render woodRails()}{/if} <button class="lb-close" onclick={() => (zoom = false)} aria-label="Close">
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
{#if zoomed}
<!-- magnified inspection view: move the cursor to pan, click to zoom back out -->
<button class="lb-zoom" onpointermove={panZoom} onclick={() => (zoomed = false)} aria-label="Zoom out">
<img src={art.image_url_large || art.image_url} alt={art.title}
style="transform-origin:{ox}% {oy}%" draggable="false" />
</button>
<span class="lb-hint">Move to look closer · click to zoom out</span>
{:else}
<span class="lb-stage">
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
{#if isWood}{@render woodRails()}{/if}
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
</span>
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
<button class="lb-zoombtn" onclick={enterZoom}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M21 21l-3.5-3.5M11 8v6M8 11h6" /></svg>
Zoom in
</button>
</span> </span>
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span> {/if}
</span> </div>
</button>
{/if} {/if}
<style> <style>
@@ -491,20 +525,48 @@
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; } .note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
.lightbox { .lightbox {
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out; position: fixed; inset: 0; z-index: 50;
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black /* A soft, top-lit gallery wall — lighter than the page so every frame (Black
included) reads, like a piece hung on a real wall. */ included) reads, like a piece hung on a real wall. */
background: linear-gradient(180deg, #efe9dd, #e2dbcc); background: linear-gradient(180deg, #efe9dd, #e2dbcc);
display: flex; flex-direction: column; display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 2.5vmin; align-items: center; justify-content: center; padding: 2.5vmin;
} }
/* full-bleed click target behind the content — clicking the wall closes */
.lb-backdrop { position: absolute; inset: 0; z-index: 0; border: none; background: none; padding: 0; cursor: zoom-out; }
.lb-close {
position: absolute; top: clamp(10px, 2vw, 20px); right: clamp(10px, 2vw, 20px); z-index: 3;
width: 40px; height: 40px; border-radius: 999px; border: 1px solid rgba(40, 30, 20, 0.12);
background: rgba(255, 255, 255, 0.72); color: #3a3a3a; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.lb-close:hover { background: #fff; }
/* frame + caption travel together so a rotated view turns as one piece */ /* frame + caption travel together so a rotated view turns as one piece */
.lb-stage { display: flex; flex-direction: column; align-items: center; gap: 12px; } .lb-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
/* magnified inspection view (desktop): cursor pans via transform-origin */
.lb-zoom {
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
border: none; background: none; padding: 0; cursor: zoom-out;
display: flex; align-items: center; justify-content: center;
}
.lb-zoom img {
max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain;
transform: scale(2.5); will-change: transform; user-select: none; -webkit-user-drag: none;
}
.lb-hint { position: relative; z-index: 1; margin-top: 10px; color: #5b636e; font-size: 0.85rem; }
.lb-zoombtn {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
border: 1px solid #d9cdeb; background: #fff; color: var(--accent);
border-radius: 999px; padding: 8px 16px; font-family: inherit; font-size: 0.9rem; font-weight: 600;
transition: background 0.14s ease, color 0.14s ease;
}
.lb-zoombtn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
@media (hover: none) { .lb-zoombtn { display: none; } } /* mobile keeps native pinch-zoom */
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the /* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
framed piece — including the bottom rail — always fits on screen. */ framed piece — including the bottom rail — always fits on screen. */
.lb-frame { .lb-frame {
cursor: zoom-out; max-width: 92vw; max-width: 92vw;
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1)); --rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5)); --mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
} }