web: lightbox collection mode — swipe/arrows between photos (PR 2 of 3) (#1426)
* docs: Media, Links & Docs design spec Approved design for the per-thread media/links/docs view: full-pane overlay off the thread kebab, client-side extraction over decrypted payloads (zero server changes), auto-backfill on open, lightbox collection swipe, and the iOS/Mac parity plan. Pinned messages named as a follow-up with an extensible tab bar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: Media/Links/Docs web implementation plan (PRs 1-2) Task-by-task plan for the extractor + overlay PR and the lightbox collection-swipe PR, grounded in the real seams (store.js backfill, searchui decrypt/jump, shell host bridge, kebab wiring). Native plan follows once web lands, using the extractor tests as parity fixtures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: threadmedia.js — pure media/links/docs extractor + index * web: export jumpToMessage + fetchMessagePage for the media overlay * web: mediaview.js — the Media/Links/Docs overlay Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: mediaview.js — fix nested-interactive Show-in-chat button The overlay's per-item jump button was appended INSIDE each tile <button> and link/doc <a> — invalid HTML (interactive content can't contain interactive descendants), flagged nested-interactive by axe/Lighthouse, and flattened or hidden by screen readers. Wrap each item in a non-interactive .media-item div and append the jump button as a sibling of the primary control instead; adjust .media-jump positioning/hover- reveal CSS to key off the wrapper rather than the (no longer ancestor) tile/row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: Media/Links/Docs kebab entry + shell wiring + changelog * web: lightbox collection nav decision helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: lightbox collection mode — swipe/arrows between photos * web: lightbox navTo — generation guard against close/nav races * web: media grid opens the lightbox in collection mode * web: media overlay fix wave — escape guard, observer lifecycle, live repaint, focus (final review) - onOverlayKey: ignore Escape when a stacked .lightbox/.msg-sheet owns it, same guard chat.js uses, so closing the top layer doesn't also close the media overlay underneath. - tileObserver: disconnect+null before every full repaint (tab switch, invalidation, each backfill page) and on closeMediaView, so detached tiles aren't retained across pages. - mediaInvalidate repaints an open overlay on the same thread so deletes/ edits drop live, matching the spec. Added mediaModerationChanged(), called from shell.js's existing onModerationChange sink (mediaview.js can't register its own — that sink is single-slot and would replace shell's) so mutes/blocks repaint too. - open.repaint now captures/restores focus by a stable key (tab id, back/ stop button, or an item's data-sid) across the DOM rebuild each backfill page does, falling back to the back button only when the focused element is gone and focus fell to <body>. - style.css: .media-stop gets min-height: 24px (borderline target size). - spec: record v1 web reality for video tiles — poster still in the lightbox, not the in-grid player; playing rides Show in chat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: media grid collection tap + lightbox navTo — final review fixes - mediaview.js grid tap handler: guard items[index] with index < 0 → return before awaiting .load() — a tile can outlive its index entry when the message is deleted or its sender muted between paint and the tap resolving. - lightbox.js navTo: add closing to the entry guard (!coll || navBusy || closing) so a nav that starts mid-close doesn't race the fly-out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+211
−11
package.json+1 −1test/lightbox.test.mjs+30 −0web/static/lightbox.js+129 −8web/static/mediaview.js+19 −2web/static/style.css+32 −0
diff --git a/package.json b/package.json| index df634d7..598a3ae 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/threadmedia.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs" |
| + "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/threadmedia.test.mjs && node test/lightbox.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs" |
| } |
| } |
diff --git a/test/lightbox.test.mjs b/test/lightbox.test.mjs| new file mode 100644 |
| index 0000000..04eaf1f |
| --- /dev/null |
| +++ b/test/lightbox.test.mjs |
| @@ -0,0 +1,30 @@ |
| +// Collection-swipe decision logic for the lightbox (web/static/lightbox.js). |
| +import assert from "node:assert/strict"; |
| +import { stepIndex, dragIntent } from "../web/static/lightbox.js"; |
| + |
| +let failures = 0; |
| +function test(name, fn) { |
| + try { fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + e.message); failures++; } |
| +} |
| + |
| +test("stepIndex clamps at both ends, no wrap", () => { |
| + assert.equal(stepIndex(0, -1, 5), 0); |
| + assert.equal(stepIndex(4, 1, 5), 4); |
| + assert.equal(stepIndex(2, 1, 5), 3); |
| +}); |
| + |
| +test("a mostly-horizontal committed drag navigates against the drag direction", () => { |
| + assert.equal(dragIntent(-120, 10, -0.8, 0), "nav-next"); // dragged left → next |
| + assert.equal(dragIntent(120, -10, 0.8, 0), "nav-prev"); |
| +}); |
| + |
| +test("a fast horizontal flick navigates even at small distance", () => { |
| + assert.equal(dragIntent(-40, 5, -0.7, 0), "nav-next"); |
| +}); |
| + |
| +test("a mostly-vertical drag still dismisses; a small slow drag settles", () => { |
| + assert.equal(dragIntent(20, 200, 0, 0.9), "dismiss"); |
| + assert.equal(dragIntent(10, 8, 0.01, 0.01), "settle"); |
| +}); |
| + |
| +process.exit(failures ? 1 : 0); |
diff --git a/web/static/lightbox.js b/web/static/lightbox.js| index fa8bdd9..9ed25d7 100644 |
| --- a/web/static/lightbox.js |
| +++ b/web/static/lightbox.js |
| @@ -4,7 +4,22 @@ |
| // avatar's full photo). Self-contained: everything it needs rides in the call |
| // (the image URL + the source element whose corner radius the fly-out must |
| // land on). Extracted from the twin verbatim copies both files carried. |
| -export function openLightbox(url, source) { |
| + |
| +// Pure decision helpers for collection mode, exported for Node tests. |
| +// A horizontal gesture navigates; vertical keeps the flick-dismiss. Thresholds |
| +// mirror the dismiss fling's own (speed 0.55 px/ms, reach 130px). |
| +export function stepIndex(i, delta, len) { |
| + return Math.max(0, Math.min(len - 1, i + delta)); |
| +} |
| + |
| +export function dragIntent(tx, ty, vx, vy) { |
| + const horizontal = Math.abs(tx) > Math.abs(ty) * 1.5; |
| + if (horizontal && (Math.abs(tx) > 80 || Math.abs(vx) > 0.5)) return tx < 0 ? "nav-next" : "nav-prev"; |
| + if (Math.hypot(vx, vy) >= 0.55 || Math.hypot(tx, ty) >= 130) return "dismiss"; |
| + return "settle"; |
| +} |
| + |
| +export function openLightbox(url, source, opts = {}) { |
| const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| const back = document.createElement("div"); |
| back.className = "lightbox"; |
| @@ -20,6 +35,31 @@ export function openLightbox(url, source) { |
| back.appendChild(backdrop); |
| back.appendChild(img); |
| + // ---- collection controls: prev/next buttons, only when opts.collection ---- |
| + // Built here (gated on the raw opts.collection, not the `coll` binding below, |
| + // which doesn't exist yet) but wired to `navTo`/`paintNav` by closure — those |
| + // are defined further down and only ever called after a user interaction, by |
| + // which point the bindings are live (same forward-reference pattern `onKey` |
| + // already uses to call `animateClose`). |
| + let navPrev = null; |
| + let navNext = null; |
| + if (opts.collection) { |
| + navPrev = document.createElement("button"); |
| + navPrev.type = "button"; |
| + navPrev.className = "lightbox-nav lightbox-nav-prev"; |
| + navPrev.setAttribute("aria-label", "Previous photo"); |
| + navPrev.textContent = "‹"; // ‹ |
| + navPrev.onclick = () => navTo(-1); |
| + back.appendChild(navPrev); |
| + navNext = document.createElement("button"); |
| + navNext.type = "button"; |
| + navNext.className = "lightbox-nav lightbox-nav-next"; |
| + navNext.setAttribute("aria-label", "Next photo"); |
| + navNext.textContent = "›"; // › |
| + navNext.onclick = () => navTo(1); |
| + back.appendChild(navNext); |
| + } |
| + |
| let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch |
| let closing = false; |
| const close = () => { |
| @@ -29,6 +69,8 @@ export function openLightbox(url, source) { |
| }; |
| const onKey = (e) => { |
| if (e.key === "Escape") animateClose(); |
| + else if (coll && e.key === "ArrowLeft") navTo(-1); |
| + else if (coll && e.key === "ArrowRight") navTo(1); |
| }; |
| // Tapping empty space (not the photo) closes — unless a gesture just ran, whose |
| // synthesized click we swallow. |
| @@ -51,9 +93,11 @@ export function openLightbox(url, source) { |
| // transform scales the element, the CSS radius that *renders* as `r` at scale `s` |
| // is `r / s`. |
| const restRadius = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 8; |
| - const sourceRadius = (rect) => { |
| - if (!source) return restRadius; |
| - const raw = getComputedStyle(source).borderTopLeftRadius; |
| + // Takes the element explicitly (rather than closing over `source`) so both the |
| + // two-arg path and collection mode's per-item `liveSource()` can share it. |
| + const sourceRadius = (rect, el) => { |
| + if (!el) return restRadius; |
| + const raw = getComputedStyle(el).borderTopLeftRadius; |
| const r = raw.endsWith("%") ? (parseFloat(raw) / 100) * Math.min(rect.width, rect.height) : parseFloat(raw); |
| return r || 0; |
| }; |
| @@ -113,6 +157,22 @@ export function openLightbox(url, source) { |
| let lastTapX = 0; |
| let lastTapY = 0; |
| + // ---- collection mode (opts.collection): swipe/arrow between items ------- |
| + const coll = opts.collection || null; |
| + let collIndex = coll ? coll.index : 0; |
| + const liveSource = () => { |
| + // The fly-out targets the CURRENT item's tile when the grid still shows |
| + // it; falls back to the original tap's element (or none — plain fade). |
| + if (!coll) return source; |
| + const el = coll.items[collIndex].source && coll.items[collIndex].source(); |
| + return el && el.isConnected ? el : source; |
| + }; |
| + const paintNav = () => { |
| + if (!coll) return; |
| + navPrev.classList.toggle("hidden", collIndex === 0); |
| + navNext.classList.toggle("hidden", collIndex === coll.items.length - 1); |
| + }; |
| + |
| const render = () => { |
| raf = 0; |
| let t = `translate3d(${tx}px, ${ty}px, 0)`; |
| @@ -135,7 +195,8 @@ export function openLightbox(url, source) { |
| openStarted = true; |
| img.style.visibility = ""; |
| setBase(); |
| - const S = source && source.getBoundingClientRect(); |
| + const src = liveSource(); |
| + const S = src && src.getBoundingClientRect(); |
| if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) { |
| backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s"; |
| backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear |
| @@ -146,7 +207,7 @@ export function openLightbox(url, source) { |
| const fromY = S.top + S.height / 2 - baseCY; |
| img.style.transition = "none"; |
| img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`; |
| - img.style.borderRadius = sourceRadius(S) / s + "px"; // match the thumbnail's rounding to start |
| + img.style.borderRadius = sourceRadius(S, src) / s + "px"; // match the thumbnail's rounding to start |
| void img.offsetWidth; // reflow so the changes below animate from here |
| img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1), border-radius 0.3s ease"; |
| img.style.transform = "translate3d(0,0,0)"; |
| @@ -159,6 +220,15 @@ export function openLightbox(url, source) { |
| img.addEventListener("load", runOpenAnim, { once: true }); |
| img.addEventListener("error", runOpenAnim, { once: true }); |
| } |
| + if (coll) { |
| + paintNav(); |
| + // Preload both neighbours once up front so a swipe right after open usually |
| + // lands on an already-decrypted URL instead of waiting on `.load()`. |
| + const prev = stepIndex(collIndex, -1, coll.items.length); |
| + if (prev !== collIndex) coll.items[prev].load(); |
| + const next = stepIndex(collIndex, 1, coll.items.length); |
| + if (next !== collIndex) coll.items[next].load(); |
| + } |
| // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove. |
| // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen. |
| @@ -170,14 +240,15 @@ export function openLightbox(url, source) { |
| cancelAnimationFrame(raf); |
| raf = 0; |
| } |
| - const S = source && source.getBoundingClientRect(); |
| + const src = liveSource(); |
| + const S = src && src.getBoundingClientRect(); |
| if (!haveBase || !S || !S.width || reduceMotion) return close(); |
| const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight); |
| const toX = S.left + S.width / 2 - baseCX; |
| const toY = S.top + S.height / 2 - baseCY; |
| img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1), border-radius 0.28s ease"; |
| img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`; |
| - img.style.borderRadius = sourceRadius(S) / s + "px"; // shrink to the thumbnail's rounding, no snap |
| + img.style.borderRadius = sourceRadius(S, src) / s + "px"; // shrink to the thumbnail's rounding, no snap |
| backdrop.style.transition = "opacity 0.28s"; |
| backdrop.style.opacity = "0"; |
| setTimeout(close, 300); |
| @@ -229,6 +300,45 @@ export function openLightbox(url, source) { |
| backdrop.style.opacity = "1"; |
| }; |
| + // Move to a neighbour: quick fade + src swap on the ONE img element (the |
| + // FLIP fly-in stays an open/close-only effect), neighbours preloading on |
| + // approach so a swipe usually lands on a ready decrypted URL. |
| + // |
| + // navBusy only blocks a second nav while the first is still awaiting |
| + // .load() — it clears before the 150ms fade/swap phase, so a nav that |
| + // starts in that window (arrow-key repeat, a fast double-swipe) can still |
| + // race its own deferred swap against an earlier one, and a dismiss |
| + // (Escape/backdrop/tap) can land mid-fly-out on a still-scheduled swap. |
| + // A monotonic generation counter is the single guard for both: each |
| + // accepted call claims the current generation, and the deferred callback |
| + // only applies if no later nav (and no close) has superseded it since. |
| + let navBusy = false; |
| + let navGen = 0; |
| + const navTo = async (delta) => { |
| + if (!coll || navBusy || closing) return; |
| + const next = stepIndex(collIndex, delta, coll.items.length); |
| + if (next === collIndex) return springBack(); |
| + navBusy = true; |
| + const gen = ++navGen; |
| + const url2 = await coll.items[next].load(); |
| + navBusy = false; |
| + if (!url2 || closing || gen !== navGen) return springBack(); |
| + collIndex = next; |
| + scale = 1; tx = 0; ty = 0; rot = 0; |
| + img.style.transition = reduceMotion ? "none" : "opacity 0.15s"; |
| + img.style.opacity = "0"; |
| + setTimeout(() => { |
| + if (closing || gen !== navGen) return; // superseded by a close or a newer nav |
| + img.src = url2; |
| + img.style.transform = "translate3d(0,0,0)"; |
| + img.style.opacity = "1"; |
| + setBase(); |
| + paintNav(); |
| + const n = stepIndex(collIndex, delta, coll.items.length); |
| + if (n !== collIndex) coll.items[n].load(); // preload the next neighbour |
| + }, reduceMotion ? 0 : 150); |
| + }; |
| + |
| // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any |
| // rotation the pinch left behind. |
| const settleZoom = () => { |
| @@ -358,6 +468,17 @@ export function openLightbox(url, source) { |
| armSuppress(); |
| return; |
| } |
| + // In a collection, a strongly horizontal committed drag navigates instead of |
| + // dismissing (mirrors the dismiss fling's own speed/reach thresholds — see |
| + // dragIntent). Reaching here already means !pinched && scale === 1, but the |
| + // guard is spelled out so this can't fire outside that state if the checks |
| + // above it ever change. |
| + const intent = coll && !pinched && scale === 1 ? dragIntent(tx, ty, vx, vy) : null; |
| + if (intent === "nav-next" || intent === "nav-prev") { |
| + armSuppress(); |
| + navTo(intent === "nav-next" ? 1 : -1); |
| + return; |
| + } |
| // Clean 1× drag: spring back, or fling off with momentum. |
| const speed = Math.hypot(vx, vy); // px/ms |
| if (speed < 0.55 && Math.hypot(tx, ty) < 130) { |
diff --git a/web/static/mediaview.js b/web/static/mediaview.js| index f97b956..f19ddf0 100644 |
| --- a/web/static/mediaview.js |
| +++ b/web/static/mediaview.js |
| @@ -344,9 +344,26 @@ function renderMediaTab(body, entries, tid) { |
| const url = await deps.loadBlob(tid, att); |
| if (url) img.src = url; |
| }); |
| + tile.dataset.sid = e.sid; |
| + tile.dataset.att = a.id; |
| tile.onclick = async () => { |
| - const url = await deps.loadBlob(tid, isVideo && a.poster ? { id: a.poster, type: "image/jpeg" } : a); |
| - if (url) openLightbox(url, img); // PR 2 upgrades this to collection swipe |
| + // The whole tab's current media list becomes the swipe collection — |
| + // videos ride their poster still, same as the single-tap path below. |
| + const all = mediaIdx.tab(tid, "media", { hiddenUids: hiddenUids() }); |
| + const items = all.map((x) => { |
| + const xa = x.att; |
| + const still = (xa.type || "").startsWith("video/") && xa.poster ? { id: xa.poster, type: "image/jpeg" } : xa; |
| + return { |
| + load: () => deps.loadBlob(tid, still), |
| + source: () => document.querySelector(`.media-tile[data-sid="${x.sid}"][data-att="${xa.id}"] img`), |
| + }; |
| + }); |
| + const index = all.findIndex((x) => x.sid === e.sid && x.att.id === a.id); |
| + // A tile can outlive its index entry — e.g. the message got deleted |
| + // or its sender muted between paint and this tap resolving. |
| + if (index < 0) return; |
| + const url = await items[index].load(); |
| + if (url) openLightbox(url, img, { collection: { items, index } }); |
| }; |
| // The jump button is a SIBLING of the tile, never a descendant — a |
| // <button> inside a <button> is invalid HTML (nested-interactive) and |
diff --git a/web/static/style.css b/web/static/style.css| index ecc265b..14ced3a 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -709,6 +709,38 @@ select:disabled { opacity: 0.5; cursor: default; } |
| -webkit-user-drag: none; |
| will-change: transform; |
| } |
| +/* collection-mode prev/next controls (openLightbox opts.collection). The |
| + backdrop is always dark regardless of theme (see .lightbox-backdrop above), |
| + so these match it directly rather than the light/dark panel variables. */ |
| +.lightbox-nav { |
| + position: fixed; |
| + top: 50%; |
| + transform: translateY(-50%); |
| + z-index: 2; /* above the image */ |
| + width: 44px; |
| + height: 44px; |
| + min-width: 44px; |
| + min-height: 44px; |
| + display: grid; |
| + place-items: center; |
| + border: none; |
| + border-radius: 50%; |
| + background: rgba(0, 0, 0, 0.4); |
| + color: #fff; |
| + font-size: 28px; |
| + line-height: 1; |
| + cursor: pointer; |
| + transition: background 0.15s ease; |
| +} |
| +.lightbox-nav:hover { background: rgba(0, 0, 0, 0.6); } |
| +.lightbox-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| +.lightbox-nav-prev { left: 12px; } |
| +.lightbox-nav-next { right: 12px; } |
| +/* touch already navigates via swipe (dragIntent) — the buttons would only |
| + duplicate the gesture and eat thumb space at the edges, so hide them */ |
| +@media (pointer: coarse) { |
| + .lightbox-nav { display: none; } |
| +} |
| /* the lightbox open/close is driven in JS (a FLIP fly-in/out from the thumbnail) */ |
| @keyframes fade-in { from { opacity: 0; } } |