{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dk-media-viewer",
  "title": "DKMediaViewer",
  "description": "A minimalist, performant, and elegant photo and video viewer for React. Masonry grid, fly-in lightbox, a crossfading carousel, captions and camera EXIF data, light and dark modes, reduced-motion support, keyboard navigation, and one easter egg.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/dk-media-viewer/dk-media-viewer.tsx",
      "content": "'use client'\n\n/* DKMediaViewer — the masonry grid + the lightbox it opens.\n *\n *   <DKMediaViewer items={items} />\n *\n * Items are plain data (see types.ts) — hand-written, CMS-mapped, or generated with\n * `npx @diklein/dkmediaviewer scan ./public/photos`. Photos render as lazy <img>s (or through your own\n * renderImage slot, e.g. next/image); videos autoplay muted in the grid and hand off\n * frame-accurately to the lightbox player when clicked. */\n\nimport { useState, useEffect, useRef } from 'react'\nimport type { DKMediaItem } from './types'\nimport { useDKLightbox } from './dk-lightbox'\n\nexport interface DKMediaViewerProps {\n  items: DKMediaItem[]\n  /** Extra classes on the outer grid wrapper. */\n  className?: string\n  /** A sharper large variant for the lightbox to fade in over the base image (and to prefetch\n   *  on hover and around the open item) — e.g. a CDN resize URL. Default: none; the lightbox\n   *  shows item.src, which for local files is already the full image. */\n  getHiResSrc?: (item: DKMediaItem) => string\n  /** Render the grid image yourself (e.g. with next/image). Apply ctx.className to the visible\n   *  image element and honor ctx.sizes/ctx.priority; the lightbox still works untouched — it\n   *  reads the rendered <img>'s decoded file at click time. */\n  renderImage?: (\n    item: DKMediaItem,\n    ctx: { index: number; priority: boolean; sizes: string; className: string },\n  ) => React.ReactNode\n  /** The 1px photo edge outline in the lightbox. Default true (photographs). */\n  showOutline?: boolean\n  /** Reserve the lightbox caption/exif rail. Default: on when any item carries one. */\n  hasCaptions?: boolean\n  /** Open from `?photo=<index>` on mount, for shareable deep links. Default off. */\n  deepLink?: boolean\n}\n\nconst GRID_SIZES = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'\nconst IMG_CLASS = 'dk-img-outline w-full h-auto transition-opacity duration-200 group-hover:opacity-85'\n\nexport function DKMediaViewer({\n  items,\n  className,\n  getHiResSrc,\n  renderImage,\n  showOutline = true,\n  hasCaptions,\n  deepLink = false,\n}: DKMediaViewerProps) {\n  const [numCols, setNumCols] = useState(3)\n  const prefetched = useRef<Set<string>>(new Set())\n  // Thumbnail registry: close hands focus back to the item the viewer ended on, and deep links\n  // need a zoom origin — both resolved through here.\n  const thumbEls = useRef<Map<number, HTMLButtonElement>>(new Map())\n\n  const { open, lightbox } = useDKLightbox(items, {\n    getHiResSrc,\n    showOutline,\n    hasCaptions,\n    deepLink,\n    getOriginEl: (i) => thumbEls.current.get(i) ?? null,\n  })\n\n  // Warm the hi-res layer on hover/focus so it is already downloading by the time the lightbox\n  // opens — the browser cache hit makes the sharp layer's fade-in instant instead of a re-fetch\n  // after the fly-in lands. No-op without getHiResSrc (the lightbox then reuses the grid's file).\n  const prefetchHiRes = (item: DKMediaItem) => {\n    if (!getHiResSrc) return\n    const hi = getHiResSrc(item)\n    if (!hi || hi === item.src || prefetched.current.has(hi)) return\n    prefetched.current.add(hi)\n    const img = new window.Image()\n    img.src = hi\n  }\n\n  useEffect(() => {\n    const sm = window.matchMedia('(max-width: 639px)')\n    const md = window.matchMedia('(max-width: 1023px)')\n\n    const update = () => {\n      if (sm.matches) setNumCols(1)\n      else if (md.matches) setNumCols(2)\n      else setNumCols(3)\n    }\n\n    update()\n    sm.addEventListener('change', update)\n    md.addEventListener('change', update)\n    return () => {\n      sm.removeEventListener('change', update)\n      md.removeEventListener('change', update)\n    }\n  }, [])\n\n  if (items.length === 0) return null\n\n  const columns: Array<Array<{ item: DKMediaItem; originalIndex: number }>> =\n    Array.from({ length: numCols }, () => [])\n  items.forEach((item, i) => columns[i % numCols].push({ item, originalIndex: i }))\n\n  return (\n    <>\n      {/* data-dk-scope: the lightbox pauses playing <video>s inside this wrapper while open. */}\n      <div data-dk-scope className={`dk-scope flex gap-4${className ? ` ${className}` : ''}`}>\n        {columns.map((colItems, colIndex) => (\n          <div key={colIndex} className=\"flex-1 flex flex-col gap-4\">\n            {colItems.map(({ item, originalIndex }) => (\n              <button\n                key={item.src}\n                ref={(el) => {\n                  if (el) thumbEls.current.set(originalIndex, el)\n                  else thumbEls.current.delete(originalIndex)\n                }}\n                onMouseEnter={() => prefetchHiRes(item)}\n                onFocus={() => prefetchHiRes(item)}\n                onClick={(e) => open(originalIndex, e.currentTarget, e)}\n                // No focus-visible override: the host's global :focus-visible styling (or the\n                // browser default) gives keyboard users their ring — opting out leaves them\n                // tabbing blind.\n                className=\"dk-cv-auto block w-full cursor-zoom-in group\"\n                aria-label={item.alt ?? item.caption ?? (item.videoSrc ? 'Play video' : 'Open photo')}\n              >\n                {item.videoSrc ? (\n                  // Clips autoplay muted in the grid (poster = item.src covers the load). The\n                  // lightbox resumes from this element's exact frame — see useDKLightbox's capture.\n                  <video\n                    src={item.videoSrc}\n                    poster={item.src}\n                    muted\n                    loop\n                    playsInline\n                    autoPlay\n                    preload=\"metadata\"\n                    className={IMG_CLASS}\n                    style={\n                      item.width && item.height\n                        ? { aspectRatio: `${item.width} / ${item.height}` }\n                        : undefined\n                    }\n                  />\n                ) : renderImage ? (\n                  renderImage(item, {\n                    index: originalIndex,\n                    priority: originalIndex < 3,\n                    sizes: GRID_SIZES,\n                    className: IMG_CLASS,\n                  })\n                ) : (\n                  <img\n                    src={item.src}\n                    alt={item.alt ?? item.caption ?? ''}\n                    width={item.width}\n                    height={item.height}\n                    sizes={GRID_SIZES}\n                    className={IMG_CLASS}\n                    loading={originalIndex < 3 ? 'eager' : 'lazy'}\n                    decoding=\"async\"\n                    // Blur-up stand-in: the item's dominant color fills the box until the image\n                    // paints, then clears (so transparent images don't keep a colored tile).\n                    style={item.color ? { backgroundColor: item.color } : undefined}\n                    onLoad={(e) => { e.currentTarget.style.backgroundColor = '' }}\n                  />\n                )}\n              </button>\n            ))}\n          </div>\n        ))}\n      </div>\n      {lightbox}\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dk-media-viewer/dk-media-viewer.tsx"
    },
    {
      "path": "registry/dk-media-viewer/dk-lightbox.tsx",
      "content": "'use client'\n\n/* DKLightbox — the fly-in modal viewer, plus useDKLightbox(), the controller hook the grid\n * (dk-media-viewer) and the carousel (dk-carousel) both drive it through.\n *\n * Extracted from diklein.com, where every timing decision below was measured against a real\n * Safari/Chrome flash or jitter before it earned its comment. The war stories are kept: they\n * are the reason the code is shaped the way it is, and deleting them invites regressing it. */\n\nimport { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'\nimport { flushSync } from 'react-dom'\nimport { createPortal } from 'react-dom'\nimport {\n  LazyMotion,\n  domAnimation,\n  m,\n  useMotionValue,\n  useTransform,\n  animate,\n  useReducedMotion,\n} from 'motion/react'\nimport type { DKMediaItem } from './types'\nimport { formatExif } from './types'\n\n/* ------------------------------------------------------------------------------------------------\n * The lightbox's working item: the public DKMediaItem plus runtime capture the opener takes at\n * click time. None of this is hand-written by users — useDKLightbox fills it in.\n * ---------------------------------------------------------------------------------------------- */\nexport interface DKLightboxItem extends DKMediaItem {\n  /** The origin <img>'s decoded file (its currentSrc) — the fly-in clone and the slide's base\n   *  layer paint this straight from cache, so the open never waits on a re-fetch. */\n  flightSrc?: string\n  /** A canvas snapshot of the exact video frame that was clicked — the clone's guaranteed paint\n   *  while its own <video> is still seeking. */\n  flightPoster?: string\n  /** The clip's playhead at click; the clone and the modal player both resume from this frame. */\n  videoTime?: number\n  /** Mirror a progress bar under the clip in the modal. */\n  videoProgress?: boolean\n}\n\n// Should the clicked clip keep PLAYING while it flies into the modal?\n//\n// TRUE (the shipped behaviour): the flying clone is a second live <video> that resumes from the\n// origin's exact frame, so the clip never stops moving as it travels into the modal.\n//\n// FALSE: the clone is the FROZEN frame instead (flightPoster, a canvas snapshot of the clicked\n// frame). Identical pixels, no video element inside the animating layer; the clip just does not\n// advance during the flight. The handoff is frame-accurate either way, because the modal clip\n// starts from item.videoTime — the same frame the clone starts from — and both play at 1x, so\n// they stay together without anything having to reconcile them (see LightboxVideo).\nconst FLY_LIVE_VIDEO = true\n\n/* THE HOUSE SPRING. ζ ≈ 0.86, ωₙ ≈ 30: about half a percent of overshoot, settled in ~150ms —\n * present, not gratuitous. Overshoot is a percentage of the distance travelled, so one spring\n * serves an 8px settle and an 800px flight alike; the drag settle, the fly-in open and the\n * left/right nav all share it deliberately, so the same gesture always feels like the same\n * object. */\nconst SPRING = { type: 'spring', stiffness: 620, damping: 36, mass: 0.7 } as const\nconst OPEN_SPRING = SPRING // the fly-in from the thumbnail\nconst NAV_SPRING = SPRING // left / right between assets\n/* prefers-reduced-motion: the MOVE is kept — it carries spatial meaning — but the decorative\n * overshoot is removed. Critically damped: it arrives and stops. */\nconst NAV_SPRING_REDUCED = { type: 'spring', stiffness: 300, damping: 40, mass: 1 } as const\n/* Shift-click / shift-arrow: the same character stretched to ~3s, so every phase of the\n * entrance can be inspected frame by frame. (Mac OS X 10.3 shipped shift-click-minimize as a\n * slow-motion genie effect. Some of us never got over it.) */\nconst SPRING_SLOW = { type: 'spring', duration: 3, bounce: 0.2 } as const\nconst SLOW_OPEN_SPRING = SPRING_SLOW\nconst SLOW_NAV_SPRING = SPRING_SLOW\nconst CLOSE_DRAG = 100 // swipe the image down past this (px) to dismiss\n/* The veil (the in-document layer the iOS bottom bar blurs — see the backdrop effect) never\n * goes below THIS opacity while mounted: at exactly 0 an element paints nothing and Safari\n * tears its layers down, and rebuilding is the lazy path that popped instead of fading. */\nconst VEIL_MIN_OPACITY = 0.002\n\n// Contain-fit a photo of intrinsic (w×h) inside a container rect → the image's actual on-screen\n// rectangle. The flying-clone open flies to THIS rect (not the container's), so start and end\n// share the photo's aspect ratio and the clone scales uniformly — a pure GPU transform, no\n// distortion (which independent width/height or scaleX≠scaleY would cause).\nfunction containRect(\n  c: { left: number; top: number; width: number; height: number },\n  w: number,\n  h: number,\n) {\n  if (!w || !h) return { left: c.left, top: c.top, width: c.width, height: c.height }\n  const ar = w / h\n  let iw = c.width\n  let ih = c.width / ar\n  if (ih > c.height) { ih = c.height; iw = c.height * ar }\n  return { left: c.left + (c.width - iw) / 2, top: c.top + (c.height - ih) / 2, width: iw, height: ih }\n}\n\nfunction IconClose() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M2 2L14 14M14 2L2 14\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n    </svg>\n  )\n}\n\nfunction IconArrow({ dir }: { dir: 'left' | 'right' }) {\n  return (\n    <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" fill=\"none\" aria-hidden=\"true\">\n      {dir === 'left'\n        ? <path d=\"M11 3L5 9L11 15\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        : <path d=\"M7 3L13 9L7 15\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      }\n    </svg>\n  )\n}\n\nexport interface DKLightboxProps {\n  item: DKLightboxItem\n  index: number\n  total: number\n  onClose: () => void\n  onPrev: () => void\n  onNext: () => void\n  originRect: DOMRect | null\n  prevItem: DKLightboxItem | null\n  nextItem: DKLightboxItem | null\n  /** Reserve space for a caption/exif line under the image. When no item in the gallery carries\n   *  either, turn this off — the image then gets the freed vertical space. */\n  hasCaptions?: boolean\n  /** The 1px edge outline on the photo (matches the grid's .dk-img-outline treatment and keeps\n   *  near-white / near-black photographs from merging into the scrim). Turn off for assets that\n   *  are not photographs — on screenshots and diagrams it reads as a stray border flashing in\n   *  and out as the overlay opens. */\n  showOutline?: boolean\n  /** Shift-click open: stretch the whole entrance to ~3s for frame-by-frame inspection. */\n  slowMo?: boolean\n  /** A sharper large variant to fade in over the base image (e.g. a CDN resize URL). Default:\n   *  none — the lightbox shows item.src, which is already the full file for local photos. */\n  getHiResSrc?: (item: DKMediaItem) => string\n  /** Fired ONCE, when the fly-in clone paints its first real frame (image decoded / first\n   *  video frame). The opener hides the clicked origin element on this signal rather than at\n   *  click, so the asset never blinks out before its flying copy is visibly on screen. */\n  onFlightPainted?: () => void\n  /** Fired ONCE, when the open has fully settled (the flying clone has been dropped and the\n   *  modal owns the screen). The opener RESTORES the hidden origin element on this signal —\n   *  the modal covers it completely, and having it back means a later swipe-down reveals the\n   *  asset you tapped instead of a blank hole in the page. */\n  onSettled?: () => void\n}\n\n/** Loading shimmer sized to the asset's contained (letterboxed) rect rather than the whole\n *  slide — so while a wide clip or tall photo loads, the placeholder matches the shape that\n *  will appear, not the entire modal. Falls back to filling the slide if the aspect is\n *  unknown (dimensions weren't supplied). */\n// Measures a photo's object-contain rect (w x h fitted inside the host box), tracking resize.\n// Shared by the loading shimmer and the persistent edge outline so both hug the photo exactly.\nfunction useContainRect(w?: number, h?: number) {\n  const ref = useRef<HTMLDivElement>(null)\n  const [box, setBox] = useState<{ w: number; h: number } | null>(null)\n  useLayoutEffect(() => {\n    const host = ref.current\n    if (!host || !w || !h) { setBox(null); return }\n    const measure = () => {\n      const cw = host.clientWidth, ch = host.clientHeight\n      if (!cw || !ch) return\n      const a = w / h\n      let dw = cw, dh = cw / a\n      if (dh > ch) { dh = ch; dw = ch * a }\n      setBox({ w: Math.round(dw), h: Math.round(dh) })\n    }\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(host)\n    return () => ro.disconnect()\n  }, [w, h])\n  return { ref, box }\n}\n\nfunction AssetShimmer({ w, h }: { w?: number; h?: number }) {\n  const { ref, box } = useContainRect(w, h)\n  return (\n    <div ref={ref} aria-hidden className=\"absolute inset-0 z-0 flex items-center justify-center\">\n      <div\n        className=\"dk-shimmer relative\"\n        style={box ? { width: box.w, height: box.h } : { position: 'absolute', inset: 0 }}\n      />\n    </div>\n  )\n}\n\n// Persistent neutral edge outline hugging the photo's contained rect — the same 1px treatment as\n// the grid (.dk-img-outline), so near-white / near-black photos don't merge with the scrim.\n// Sits above both image layers (z-30) so its inward outline paints over the photo's edge; photos\n// only, as videos keep their own progress-bar treatment.\nfunction AssetOutline({ w, h }: { w?: number; h?: number }) {\n  const { ref, box } = useContainRect(w, h)\n  return (\n    <div ref={ref} aria-hidden className=\"pointer-events-none absolute inset-0 z-30 flex items-center justify-center\">\n      {box && <div className=\"dk-img-outline\" style={{ width: box.w, height: box.h }} />}\n    </div>\n  )\n}\n\n/** A clip playing inside the lightbox: shimmer while it loads, then a 2px accent progress bar\n *  matched to the video's contained (letterboxed) rect. Only the current slide autoplays; on\n *  nav the slide remounts (keyed by src) so it reliably plays. */\nfunction LightboxVideo({ item, priority, onLoad, armed = true, getCloneTime }: {\n  item: DKLightboxItem\n  priority?: boolean\n  onLoad?: () => void\n  armed?: boolean\n  /** The playhead of the frame the flying clone is CURRENTLY showing — its live video's time\n   *  when that has painted, else the frozen poster's captured time. The reveal syncs to this,\n   *  because this clip plays behind the veil while it loads and drifts away from the clone. */\n  getCloneTime?: () => number | null\n}) {\n  const ref = useRef<HTMLVideoElement>(null)\n  // The REVEAL must not run until the fly-in has landed — otherwise the modal clip would appear\n  // over the top of the clone that is still flying. This clip is already playing by then (in sync\n  // with the clone); it is simply invisible, behind the transparent container. Deferred here and\n  // re-fired the moment `armed` flips.\n  const armedRef = useRef(armed)\n  const pendingRef = useRef(false)\n  const revealRef = useRef<(() => void) | null>(null)\n  useEffect(() => {\n    armedRef.current = armed\n    if (armed && pendingRef.current) { pendingRef.current = false; revealRef.current?.() }\n  }, [armed])\n  const onLoadRef = useRef(onLoad)\n  useEffect(() => { onLoadRef.current = onLoad })\n  const getCloneTimeRef = useRef(getCloneTime)\n  useEffect(() => { getCloneTimeRef.current = getCloneTime })\n  const [progress, setProgress] = useState(0)\n  const [ready, setReady] = useState(false)\n  const [bar, setBar] = useState<{ w: number; left: number; top: number } | null>(null)\n  // The clip's own dimensions size the loading shimmer to its contained rect. Seeded from the\n  // item's declared dimensions, then corrected from the modal video's own loadedmetadata — so a\n  // clip without dimensions still gets a clip-shaped shimmer once metadata lands.\n  const [dims, setDims] = useState<{ w: number; h: number } | null>(\n    item.width && item.height ? { w: item.width, h: item.height } : null,\n  )\n  const showBar = !!item.videoProgress\n\n  useEffect(() => {\n    const v = ref.current\n    if (!v) return\n    const onTime = () => { const d = v.duration; if (d && Number.isFinite(d)) setProgress(v.currentTime / d) }\n    // Match the bar to the video's contained rect. The clip is laid out in the top of the\n    // slide, leaving a 10px strip below (8px gap + 2px bar) so the bar never overlaps it.\n    const measure = () => {\n      const host = v.parentElement\n      if (!host) return\n      if (v.videoWidth && v.videoHeight) setDims({ w: v.videoWidth, h: v.videoHeight })\n      const cw = host.clientWidth, ch = host.clientHeight\n      const availH = ch - (showBar ? 10 : 0)\n      const a = (v.videoWidth || 16) / (v.videoHeight || 9)\n      let dw = cw, dh = cw / a\n      if (dh > availH) { dh = availH; dw = availH * a }\n      setBar({ w: dw, left: (cw - dw) / 2, top: (availH - dh) / 2 + dh + 8 })\n    }\n    // THIS CLIP RUNS IN SYNC WITH THE FLYING CLONE, from the moment it has metadata.\n    //\n    // It used to sit paused through the whole flight and only seek to the clone's live position at\n    // the handoff — and to keep the clone from drifting past the frame being handed over, the clone\n    // was PAUSED while that seek ran. Measured in Safari, that froze the picture for 175ms while\n    // the seek, the painted-frame wait, the React commit and the clone's two-frame overlap all\n    // stacked up: a visible split-second pause right after the animation completed.\n    //\n    // The stall existed only because the two clips were at different times and had to be\n    // reconciled. So don't let them diverge: both start from the same captured frame\n    // (item.videoTime) and both play at 1x from there, so they stay together on their own. The\n    // handoff is then a pure swap of two videos already showing the same thing — no seek, no pause,\n    // nothing to reconcile, and the clip never stops moving.\n    let started = false\n    let revealed = false\n    // COLD-CACHE GUARD: the reveal may not run until the seek to the clicked frame has LANDED.\n    //\n    // Setting currentTime updates the property immediately but seeks asynchronously — and on a\n    // cold cache a seek deep into the file has to fetch and decode a distant byte range, which\n    // takes long enough that requestVideoFrameCallback fires first with frame 0 still on screen.\n    // The reveal then dropped the clone (showing the correct clicked frame) over a modal video\n    // showing the START of the clip: the picture jumped backwards at the end of the fly-in, then\n    // jumped again when the seek landed. Warm caches seek instantly, which is why it only ever\n    // showed cold. FlightVideo has always gated on the landed seek; this is the same gate.\n    let seekTarget = priority && item.videoTime ? item.videoTime : 0\n    let seekLanded = seekTarget <= 0\n    let seekFailsafe: number | undefined\n    let resyncs = 0\n    const startPlayback = () => {\n      if (started) return\n      started = true\n      // Match the clone's starting frame. Only the clicked clip (priority) does this; neighbours\n      // rest at frame 0 until they are swiped to.\n      if (priority && item.videoTime && v.currentTime < 0.05) {\n        try {\n          v.currentTime = item.videoTime\n          // If `seeked` somehow never fires (a stream that can't land it), reveal anyway after\n          // 4s — the clone (the clip itself, playing) covers the whole wait, so the failsafe\n          // only trades a permanently-hidden modal video for a worst-case late swap.\n          seekFailsafe = window.setTimeout(() => { seekLanded = true; maybeReveal() }, 4000)\n        } catch {\n          seekLanded = true // not seekable yet — plays from 0; nothing better to wait for\n        }\n      } else {\n        seekLanded = true // no seek needed: the current frame is already the right frame\n      }\n      if (priority) v.play().catch(() => {})\n    }\n    let done = false\n    const reveal = () => {\n      if (done) return\n      done = true\n      // Seed the bar with the position the clip is ACTUALLY at, in the same commit it first mounts.\n      // It used to mount at 0 and only get a real value on the first `timeupdate`, and because the\n      // fill carries a width transition, that value then animated in from the left edge: the bar\n      // appeared empty and visibly chased the playhead. A freshly-inserted element does not\n      // transition its initial value, so setting it here means the bar's first paint is already in\n      // the right place; later timeupdates still glide.\n      const d = v.duration\n      if (d && Number.isFinite(d)) setProgress(v.currentTime / d)\n      setReady(true)\n      onLoadRef.current?.()\n    }\n    // Reveal on a PAINTED frame, never on an event.\n    //\n    // An event says the decoder is done, not that the frame is on screen — WebKit paints it on the\n    // next compositor tick. Measured in Safari: `seeked` at 592ms, the frame painted at 604ms.\n    // Handing the stage over inside that 12ms window (clone unmounted, modal video not yet showing\n    // anything) was a flash at the END of the open animation, exactly as painting frame 0 before\n    // the seek landed was the flash at the START (see FlightVideo). requestVideoFrameCallback fires\n    // only once a frame has actually been presented, so gating on it makes the swap frame-exact.\n    // The clip is playing by now, so frames keep coming and this always fires.\n    const revealOnPaintedFrame = () => {\n      if (revealed) return\n      revealed = true\n      const rvfc = (v as RVFCVideo).requestVideoFrameCallback\n      if (!rvfc) { reveal(); return } // no rVFC: this is the best signal available\n      const to = window.setTimeout(reveal, 120) // safety net if playback never starts\n      rvfc.call(v, () => { window.clearTimeout(to); reveal() })\n    }\n    // The single reveal funnel: seek landed AND flight landed (armed) AND in step with the\n    // clone, else park in pending. `revealRef` points HERE (not at revealOnPaintedFrame), so\n    // the armed-flip re-fire runs the same gates — it used to jump straight to the paint wait,\n    // which skipped the sync check below.\n    const maybeReveal = () => {\n      if (revealed || !seekLanded) return\n      // The flight has not landed yet: keep this clip playing BEHIND the clone (the container is\n      // still transparent) and reveal the moment `armed` flips.\n      if (!armedRef.current) { pendingRef.current = true; return }\n      // FINAL SYNC. \"Both start from the clicked frame and play at 1x, so they stay together\"\n      // is only true when both START at the same wall-clock moment. On a slow load this clip\n      // begins playing whenever its data arrives — behind the veil — while the clone has been\n      // showing the clicked frame (frozen poster) or its own live playback the whole time. By\n      // reveal time the two could be seconds apart, and the swap jumped: THE flash at the end\n      // of the open that survived the seek gate. So immediately before revealing, compare\n      // against the frame the clone is actually showing and land there first. Each pass loops\n      // back through `seeked` → here; three attempts is plenty (warm seeks converge in one).\n      const cloneT = priority ? getCloneTimeRef.current?.() : null\n      if (cloneT != null && Math.abs(v.currentTime - cloneT) > 0.15 && resyncs < 3) {\n        resyncs++\n        seekLanded = false\n        seekTarget = Math.max(0, Math.min(cloneT + 0.05, (Number.isFinite(v.duration) ? v.duration : Infinity) - 0.05))\n        try { v.currentTime = seekTarget } catch { seekLanded = true }\n        return\n      }\n      revealOnPaintedFrame()\n    }\n    revealRef.current = maybeReveal\n    const onSeeked = () => {\n      if (Math.abs(v.currentTime - seekTarget) > 0.25) return // an intermediate seek — keep waiting\n      window.clearTimeout(seekFailsafe)\n      seekLanded = true\n      maybeReveal()\n    }\n    const onReady = () => {\n      measure()\n      startPlayback() // in sync with the clone, from the first moment it can be\n      maybeReveal()\n    }\n    v.addEventListener('seeked', onSeeked)\n    v.addEventListener('timeupdate', onTime)\n    v.addEventListener('loadeddata', onReady)\n    v.addEventListener('canplay', onReady)\n    v.addEventListener('playing', onReady)\n    v.addEventListener('loadedmetadata', measure)\n    // Reveal precisely on the first painted frame where supported, so the clone→video swap is\n    // frame-exact (no flash).\n    type RVFCVideo = HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }\n    const rvfc = (v as RVFCVideo).requestVideoFrameCallback\n    if (rvfc) rvfc.call(v, () => onReady())\n    if (v.readyState >= 2) onReady() // frame 0 already decoded (e.g. bfcache)\n    if (v.videoWidth) measure()\n    const ro = new ResizeObserver(measure)\n    if (v.parentElement) ro.observe(v.parentElement)\n    return () => {\n      window.clearTimeout(seekFailsafe)\n      v.removeEventListener('seeked', onSeeked)\n      v.removeEventListener('timeupdate', onTime)\n      v.removeEventListener('loadeddata', onReady)\n      v.removeEventListener('canplay', onReady)\n      v.removeEventListener('playing', onReady)\n      v.removeEventListener('loadedmetadata', measure)\n      ro.disconnect()\n    }\n  }, [priority, item.videoSrc, item.videoTime, showBar])\n\n  return (\n    <>\n      {!ready && <AssetShimmer w={dims?.w} h={dims?.h} />}\n      <video\n        ref={ref}\n        src={item.videoSrc}\n        muted\n        loop\n        playsInline\n        preload={priority ? 'auto' : 'metadata'}\n        // No poster: the reveal waits for the clip to paint, so it shows frame 0 directly with\n        // no poster→video swap. A <video> is a replaced element, so left/right insets don't\n        // stretch it — size it explicitly, leaving a 10px strip for the bar when shown.\n        style={{ height: showBar ? 'calc(100% - 10px)' : '100%' }}\n        className=\"absolute inset-x-0 top-0 z-10 w-full object-contain\"\n      />\n      {/* Gated on `ready` — i.e. it mounts with the clip's real position already seeded (see\n          reveal()) — and eased in, so it resolves with the clip instead of snapping into existence\n          under it the instant the geometry is measured. */}\n      {showBar && bar && ready && (\n        <m.div\n          aria-hidden\n          className=\"absolute z-20 h-0.5 overflow-hidden\"\n          style={{ width: bar.w, left: bar.left, top: bar.top, background: 'color-mix(in srgb, currentColor 22%, transparent)' }}\n          // Half a second, accelerating (easeIn), and it only starts once the fly-in has landed —\n          // the bar does not mount until the clip reveals, which is gated on the open animation\n          // finishing. So it emerges quietly under the settled clip rather than competing with it.\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={{ duration: 0.5, ease: 'easeIn' }}\n        >\n          <div className=\"h-full w-full origin-left\" style={{ transform: `scaleX(${progress})`, background: 'var(--dk-accent)', transition: 'transform 0.25s linear' }} />\n        </m.div>\n      )}\n    </>\n  )\n}\n\n/** One image in the swipe track, offset by translateX so prev/current/next sit side by\n *  side. The base layer reuses the grid's already-loaded file (instant, from cache); a sharper\n *  variant (getHiResSrc) fades in on top once loaded — for every visible slide, so a swipe/nav\n *  always lands on a hi-res image. */\nfunction Slide({ item, offset, priority, hiRes = true, getHiResSrc, onLoad, showOutline = true, armed = true, getCloneTime }: {\n  item: DKLightboxItem\n  offset: string\n  priority?: boolean\n  /** Load the sharp layer. Off for neighbours during the open animation so five big images\n   *  don't decode at once and jitter the spring — they upgrade once the overlay has opened. */\n  hiRes?: boolean\n  getHiResSrc?: (item: DKMediaItem) => string\n  onLoad?: () => void\n  /** The 1px edge outline — see DKLightboxProps.showOutline. */\n  showOutline?: boolean\n  /** Has the fly-in landed? The clip plays behind the flying clone either way; this only gates\n   *  the REVEAL, so the modal clip cannot appear on top of a clone still in the air. */\n  armed?: boolean\n  /** See LightboxVideo — the clone's currently-visible playhead, for the reveal-time sync. */\n  getCloneTime?: () => number | null\n}) {\n  const [hiResLoaded, setHiResLoaded] = useState(false)\n  const [baseLoaded, setBaseLoaded] = useState(false)\n\n  // Video items play in place of the image layers (shimmer + progress bar live in\n  // LightboxVideo). Only the current slide (priority) autoplays; neighbours rest until\n  // swiped to, so opening the overlay never decodes three clips at once.\n  if (item.videoSrc) {\n    return (\n      <div className=\"absolute inset-0\" style={{ transform: `translateX(${offset})` }}>\n        {/* The slide spans the full track width so it can travel fully off-screen; the asset is\n            inset 16px so it never bleeds to the very edge at rest (the margins live here, not on\n            the clipped container, so they don't cut the slide short). */}\n        <div className=\"absolute inset-y-0 inset-x-4\">\n          <LightboxVideo item={item} priority={priority} onLoad={onLoad} armed={armed} getCloneTime={getCloneTime} />\n        </div>\n      </div>\n    )\n  }\n\n  // The base layer prefers flightSrc — the exact file the origin <img> had already decoded,\n  // served verbatim — so the track is painted before the fly-in lands even on a cold cache.\n  const baseSrc = item.flightSrc || item.src\n  // The sharper large variant, if the host supplies one (e.g. a CDN resize URL). Skipped when\n  // it resolves to the file already on screen — nothing to upgrade to.\n  const hiResUrl = getHiResSrc?.(item)\n  const showHiRes = !!hiResUrl && hiResUrl !== baseSrc\n  const loading = priority ? undefined : ('eager' as const)\n  return (\n    <div className=\"absolute inset-0\" style={{ transform: `translateX(${offset})` }}>\n      {/* Full-width slide (travels fully off-screen), asset inset 16px so it keeps its margins at\n          rest without the container's clip cutting the slide short mid-animation. */}\n      <div className=\"absolute inset-y-0 inset-x-4\">\n        {!baseLoaded && <AssetShimmer w={item.width} h={item.height} />}\n        <img\n          src={baseSrc}\n          alt={item.alt ?? item.caption ?? ''}\n          className=\"absolute inset-0 z-10 h-full w-full object-contain\"\n          loading={loading}\n          decoding={priority ? 'sync' : 'async'}\n          draggable={false}\n          onLoad={() => { setBaseLoaded(true); onLoad?.() }}\n        />\n        {hiRes && showHiRes && (\n          <img\n            src={hiResUrl}\n            alt=\"\"\n            aria-hidden\n            className=\"absolute inset-0 z-20 h-full w-full object-contain\"\n            loading={loading}\n            decoding=\"async\"\n            draggable={false}\n            onLoad={() => setHiResLoaded(true)}\n            style={{ opacity: hiResLoaded ? 1 : 0, transition: 'opacity 300ms ease-out' }}\n          />\n        )}\n        {showOutline && <AssetOutline w={item.width} h={item.height} />}\n      </div>\n    </div>\n  )\n}\n\n/** The still frame under a flying video clone. It is what guarantees the clone always has\n *  something painted: `flightPoster` (a canvas snapshot of the exact clicked frame) when the clip\n *  had decoded a frame, else the item's own poster image, which always exists. The live <video>\n *  layers on top and covers this as soon as it has a real frame. Without it, the clone is\n *  transparent while the video seeks — a hole showing the scrim through it. */\nfunction FlightStill({ item, onPainted, hidden = false, repaintRef }: {\n  item: DKLightboxItem\n  onPainted: () => void\n  hidden?: boolean\n  /** Parent-held hook: repaint this still from the live clone <video>, so it can come BACK at\n   *  the handoff showing the current frame instead of the stale click frame (see below). */\n  repaintRef?: { current: ((v: HTMLVideoElement) => void) | null }\n}) {\n  // A CANVAS rather than an <img>, so its pixels can be refreshed. The still starts as the\n  // click-frame snapshot (flightPoster) and its only job used to end the moment the live video\n  // painted — it went to opacity 0 and stayed there, because Safari transiently drops video\n  // layers while re-compositing and this STALE frame showing through read as the picture\n  // jumping backwards. But hiding it opened a worse hole: at the handoff re-composite (modal\n  // container promoted, clone eventually dropped) a transiently-dropped video layer now had\n  // NOTHING beneath it but the scrim — the intermittent WHITE flash at the end of the open.\n  // The parent now repaints this canvas from the clone's live frame right before the handoff\n  // and un-hides it, so every re-composite window has current pixels underneath.\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const src = item.flightPoster || item.src\n  const onPaintedRef = useRef(onPainted)\n  useEffect(() => { onPaintedRef.current = onPainted })\n  useEffect(() => {\n    if (!src) return\n    const img = new window.Image()\n    img.onload = () => {\n      const c = canvasRef.current\n      if (!c) return\n      c.width = img.naturalWidth\n      c.height = img.naturalHeight\n      c.getContext('2d')?.drawImage(img, 0, 0)\n      onPaintedRef.current()\n    }\n    img.src = src\n  }, [src])\n  useEffect(() => {\n    if (!repaintRef) return\n    repaintRef.current = (v: HTMLVideoElement) => {\n      const c = canvasRef.current\n      if (!c || !v.videoWidth) return\n      if (c.width !== v.videoWidth) { c.width = v.videoWidth; c.height = v.videoHeight }\n      try { c.getContext('2d')?.drawImage(v, 0, 0) } catch { /* cross-origin frame — keep poster */ }\n    }\n    return () => { repaintRef.current = null }\n  }, [repaintRef])\n  if (!src) return null\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden\n      className=\"absolute inset-0 h-full w-full object-contain\"\n      style={{ opacity: hidden ? 0 : 1 }}\n    />\n  )\n}\n\n/** The live <video> inside the fly-in clone — held INVISIBLE until it has painted the frame that\n *  was actually clicked.\n *\n *  A fresh <video> does not wait to be seeked before it starts painting. Measured: the clone\n *  paints frame 0 at ~45ms, and its seek to the clicked timestamp only lands at ~106ms. For those\n *  ~60ms the flying clone showed the START of the clip at full opacity, on top of the correct\n *  still, and then cut back — a flash of wrong CONTENT, not animation, which is why it survived\n *  every change to the spring and why slow-mo didn't slow it down. Short loops never show it\n *  (frame 0 is near enough the clicked frame); long clips always did.\n *\n *  FlightStill (the exact clicked frame) is already underneath, so hiding the video until it lands\n *  costs nothing: the swap is then pixel-identical and imperceptible. */\nfunction FlightVideo({ item, videoRef, onShown }: { item: DKLightboxItem; videoRef: { current: HTMLVideoElement | null }; onShown?: () => void }) {\n  const ref = useRef<HTMLVideoElement>(null)\n  const [shown, setShown] = useState(false)\n  const onShownRef = useRef(onShown)\n  useEffect(() => { onShownRef.current = onShown })\n  useEffect(() => { if (shown) onShownRef.current?.() }, [shown])\n\n  useEffect(() => {\n    const v = ref.current\n    if (!v) return\n    videoRef.current = v\n    type RVFCVideo = HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }\n    const rvfc = (v as RVFCVideo).requestVideoFrameCallback?.bind(v)\n    const target = item.videoTime ?? 0\n    // Clicked at the very start (or a clip with no captured time): frame 0 IS the right frame.\n    let landed = target <= 0\n    const onPaint = () => { if (landed) setShown(true); else rvfc?.(onPaint) }\n    const onSeeked = () => {\n      if (Math.abs(v.currentTime - target) > 0.25) return // an intermediate seek — keep waiting\n      landed = true\n      if (rvfc) rvfc(onPaint) // reveal on the first frame PAINTED after the seek, not on the event\n      else setShown(true) // no rVFC: `seeked` is the best signal available\n    }\n    v.addEventListener('seeked', onSeeked)\n    if (rvfc) rvfc(onPaint)\n    else if (landed) setShown(true)\n    return () => {\n      v.removeEventListener('seeked', onSeeked)\n      if (videoRef.current === v) videoRef.current = null\n    }\n  }, [item.videoTime, videoRef])\n\n  return (\n    <video\n      ref={ref}\n      src={item.videoSrc}\n      muted\n      playsInline\n      loop\n      preload=\"auto\"\n      onLoadedMetadata={(e) => {\n        const v = e.currentTarget\n        if (item.videoTime) v.currentTime = item.videoTime\n        v.play().catch(() => {})\n      }}\n      // No transition: this is a cut between identical pixels (the still and the clip's own frame),\n      // so a cross-fade would only make it visible.\n      style={{ opacity: shown ? 1 : 0 }}\n      className=\"absolute inset-0 z-10 h-full w-full object-contain\"\n    />\n  )\n}\n\nexport function DKLightbox({ item, index, total, onClose, onPrev, onNext, originRect, prevItem, nextItem, hasCaptions = true, showOutline = true, slowMo = false, getHiResSrc, onFlightPainted, onSettled }: DKLightboxProps) {\n  const caption = item.caption ?? item.alt ?? null\n\n  // Every color routes through the --dk-* tokens (the root below carries .dk-scope, so they\n  // resolve here). The two effects that paint OUTSIDE this subtree — the <html>/<body> pin and\n  // the theme-color meta — read the resolved literal via getComputedStyle instead.\n  const FG = 'var(--dk-fg)'\n  const BG = 'var(--dk-bg)'\n  const reduceMotion = useReducedMotion()\n  const openSpring = slowMo ? SLOW_OPEN_SPRING : OPEN_SPRING\n\n  const rootRef = useRef<HTMLDivElement>(null)\n  const scrimRef = useRef<HTMLDivElement>(null)\n  // The fly-in clone's <video> when the asset is a clip — the modal video reads its LIVE\n  // currentTime at handoff so playback continues from wherever the flight actually ended.\n  const cloneVideoRef = useRef<HTMLVideoElement>(null)\n  // Has the clone's live <video> actually painted? Once it has, the stale still beneath it is\n  // dropped (see FlightStill) — it can only do harm from that point on.\n  const [cloneVideoShown, setCloneVideoShown] = useState(false)\n  // Ref mirror for getCloneTime, which is read from inside LightboxVideo's long-lived effect.\n  const cloneVideoShownRef = useRef(false)\n  useEffect(() => { cloneVideoShownRef.current = cloneVideoShown }, [cloneVideoShown])\n  // The playhead of the frame the CLONE is currently showing: its live video's time once that\n  // has painted, else the frozen poster's captured time. The modal clip syncs to this right\n  // before it reveals (see the FINAL SYNC note in LightboxVideo).\n  const getCloneTime = useCallback(() => {\n    const cv = cloneVideoRef.current\n    if (cv && cloneVideoShownRef.current) return cv.currentTime\n    return item.videoTime ?? 0\n  }, [item.videoTime])\n  // First-real-frame signal for the clone — fired once; the opener hides the origin element\n  // on it, so the origin→clone handoff is paint-to-paint with no blink.\n  const flightPaintedRef = useRef(false)\n  // Also state, because the page-fade loop below must not START until this is true: the page may\n  // not begin disappearing before the thing replacing it is on screen.\n  const [flightPainted, setFlightPainted] = useState(false)\n  const fireFlightPainted = () => {\n    if (flightPaintedRef.current) return\n    flightPaintedRef.current = true\n    setFlightPainted(true)\n    onFlightPainted?.()\n  }\n  const containerRef = useRef<HTMLDivElement>(null)\n  const [targetRect, setTargetRect] = useState<DOMRect | null>(null)\n  const [animDone, setAnimDone] = useState(!originRect)\n  // Hold the spring for ONE painted frame before it starts.\n  //\n  // motion begins the open animation on the same frame React commits the whole modal — three\n  // slides, a <video preload=\"auto\">, and possibly a large data-URL poster to decode. That commit\n  // can take ~150ms, so the spring's FIRST rAF arrived with a ~150ms delta and integrated straight\n  // to 79% of the travel in a single frame: the clip appeared to snap to full size and then creep\n  // the last 20%, with a 4px overshoot correction at the end. Measured off a 60fps screen\n  // recording, that is exactly the \"jitter right after I click\" and the \"jitter at the end\".\n  //\n  // Gating on a double-rAF lets the expensive commit land, then starts the spring on a clean\n  // frame with a normal ~16ms delta, so it actually springs.\n  const [fly, setFly] = useState(false)\n  useEffect(() => {\n    if (!originRect) return\n    const id = requestAnimationFrame(() => requestAnimationFrame(() => setFly(true)))\n    return () => cancelAnimationFrame(id)\n  }, [originRect])\n  const [imageLoaded, setImageLoaded] = useState(false)\n  // Images: reveal as soon as the fly-in lands, so the shimmer covers a slow load. Videos:\n  // hold the clone — which is the clip itself, still PLAYING — until the modal video has\n  // sought to the clone's live position and is painting, so the swap lands on the same frame.\n  const containerVisible = !originRect || (animDone && (!item.videoSrc || imageLoaded))\n  // Hold the flying clone on screen for two painted frames AFTER the modal is revealed, then drop\n  // it. The clone is the same pixels, opaque, frozen, and pointer-events-none, so the overlap is\n  // invisible — but the gap it covers is not.\n  //\n  // Measured in Safari, 60fps: at the handoff there is exactly ONE frame where the whole clip is\n  // uniformly washed out (region mean 38.8 -> 67.6 -> 38.1, which solves to the clip at ~87% over\n  // the white scrim). Nothing in the tree has an 87% opacity, and it survived making the reveal\n  // wait for a painted frame — it is Safari re-compositing the video layer as the clone unmounts\n  // and the modal video takes over, during which the new layer is briefly not fully opaque and the\n  // scrim shows through it. Chrome never does this.\n  //\n  // There is no way to make Safari promote the layer faster, so instead nothing is ever uncovered:\n  // the clone stays on top across the swap.\n  const [cloneGone, setCloneGone] = useState(false)\n  const cloneGoneRef = useRef(false)\n  const onSettledRef = useRef(onSettled)\n  useEffect(() => { onSettledRef.current = onSettled })\n  // Hide the clone (it stays MOUNTED — see the render note) and declare the open settled.\n  // Shared by the overlap timer below and commit(), which must drop the clone instantly when a\n  // nav starts sliding the track underneath it.\n  const settleClone = useCallback(() => {\n    if (cloneGoneRef.current) return\n    cloneGoneRef.current = true\n    setCloneGone(true)\n    // The clone's job is over; its video needn't keep decoding in parallel with the modal's.\n    cloneVideoRef.current?.pause()\n    // The open has fully settled: the modal owns the screen. The opener restores the hidden\n    // origin element on this signal (see DKLightboxProps.onSettled).\n    onSettledRef.current?.()\n  }, [])\n  // The overlap grew from two painted frames to 300ms. Two frames covered Chrome, but Safari's\n  // transient layer washout at the handoff (see below) can outlive them — and since the clone\n  // and the modal are frame-locked by reveal time (see LightboxVideo's FINAL SYNC), a longer\n  // overlap is invisible. It ends EARLY the moment a nav needs the track (commit calls\n  // settleClone), so interaction never fights it.\n  useEffect(() => {\n    if (!containerVisible || cloneGone) return\n    // 300ms ONLY for clips (the Safari video-layer washout the overlap exists for; clone and\n    // modal are frame-locked so it's invisible). Images get the original two-frames-worth:\n    // an image clone fits by the THUMBNAIL's aspect, and when that differs a hair from the\n    // slide's intrinsic-aspect letterbox, a long overlap shows two sizes stacked.\n    const id = window.setTimeout(settleClone, item.videoSrc ? 300 : 35)\n    return () => window.clearTimeout(id)\n  }, [containerVisible, cloneGone, settleClone])\n  // At the moment of the handoff, refresh the still UNDER the clone's video with the clone's\n  // current frame and bring it back (FlightStill was hidden once the live video painted). Every\n  // layer Safari might transiently drop during the handoff re-composite now has current pixels\n  // beneath it instead of the white scrim.\n  const stillRepaintRef = useRef<((v: HTMLVideoElement) => void) | null>(null)\n  const [stillResurfaced, setStillResurfaced] = useState(false)\n  useEffect(() => {\n    if (!containerVisible || stillResurfaced) return\n    const cv = cloneVideoRef.current\n    if (cv) stillRepaintRef.current?.(cv)\n    setStillResurfaced(true)\n  }, [containerVisible, stillResurfaced])\n  // Where the flying-clone open lands: the photo's contain-fitted rect inside the container.\n  // Aspect comes from the thumbnail's rect (originRect) — always known, whereas declared\n  // width/height may be absent, which would make the fit NaN and the photo never reveal.\n  // Reserve the same 10px strip a progress-bar clip leaves at the bottom, so the clone lands\n  // exactly where the video ends up (no resize when the bar appears).\n  const barReserve = item.videoSrc && item.videoProgress ? 10 : 0\n  // Each slide insets its asset 16px per side (inset-x-4 in Slide), so the flight must land\n  // inside that same box. Fitting to the full container let a width-bound asset (a landscape\n  // photo or clip on a phone) fly to full-bleed and then snap to its real, inset width the\n  // moment the track revealed.\n  const SLIDE_INSET = 16\n  const cloneTarget = targetRect && originRect\n    ? containRect(\n        {\n          left: targetRect.left + SLIDE_INSET,\n          top: targetRect.top,\n          width: targetRect.width - SLIDE_INSET * 2,\n          height: targetRect.height - barReserve,\n        },\n        originRect.width,\n        originRect.height,\n      )\n    : null\n\n  // Caption/exif follow the committed item but update in step with the slide (not at\n  // its end), so they don't appear to lag the image. Re-synced to `item` on prop change.\n  const [captionItem, setCaptionItem] = useState(item)\n  useEffect(() => { setCaptionItem(item) }, [item])\n  const capCaption = captionItem.caption ?? null\n  const capExif = formatExif(captionItem.exif)\n\n  // Desktop off-photo detection: the glass material exists for text ON the photo. When\n  // the contain-fitted photo doesn't reach the card (short landscape photo, tall window),\n  // the card drops to theme ink — the same treatment mobile always uses. Geometry is\n  // computed (contain fit vs the card's text top) rather than measured off the img, so\n  // it works mid-flight, after nav, and independent of image load state.\n  const [capOnPhoto, setCapOnPhoto] = useState(false)\n  const capCardRef = useRef<HTMLDivElement | null>(null)\n  const measureCapRef = useRef<() => void>(() => {})\n  useEffect(() => {\n    const measure = () => {\n      // Contain-fit math from LIVE rects — never the img's own rect: the slide <img>\n      // fills the asset box (object-contain), so its element rect is the full-height\n      // area and its bottom sits at the viewport bottom no matter how short the photo\n      // renders. The container rect already carries every inset the layout owns; the\n      // only constant left is the slide's structural 16px side margins (inset-x-4).\n      const box = containerRef.current?.getBoundingClientRect()\n      const dock = dockRef.current\n      const root = rootRef.current\n      const wrap = capCardRef.current?.parentElement?.getBoundingClientRect()\n      if (!box || !wrap || !dock || !root || box.height === 0) return\n      // Declared dimensions may be absent on an item — without an aspect there is no\n      // verdict, and ties go to ink (glass is the exception that needs to earn itself).\n      if (!captionItem.width || !captionItem.height) { setCapOnPhoto(false); return }\n      const assetW = Math.max(0, box.width - SLIDE_INSET * 2)\n      // captionItem's aspect, NOT the item prop's: captionItem flips synchronously at a\n      // nav commit — it IS the photo the card is captioning, which is the one the\n      // material must match, even while the slide is still travelling.\n      const dispH = Math.min(box.height, assetW * (captionItem.height / captionItem.width))\n      const photoBottom = box.top + (box.height + dispH) / 2\n      // Compare against the card's RESTING position, not where it currently is: during\n      // the entrance the card is still below the fold, and measuring it there said\n      // \"off photo\" until the animation ended — the glass popped in at landing. The\n      // rest is knowable from frame one: the dock parks at the root's bottom edge\n      // (offsetHeight ignores the entrance transform), and the wrapper's offset inside\n      // the dock is transform-invariant.\n      const dockRect = dock.getBoundingClientRect()\n      const wrapRestTop = root.getBoundingClientRect().bottom - dock.offsetHeight + (wrap.top - dockRect.top)\n      setCapOnPhoto(photoBottom > wrapRestTop + 10)\n    }\n    measureCapRef.current = measure\n    // rAF: run after the layout from an item/caption swap has settled.\n    const raf = requestAnimationFrame(measure)\n    const late = window.setTimeout(measure, 450) // after the nav spring lands\n    window.addEventListener('resize', measure)\n    return () => { cancelAnimationFrame(raf); window.clearTimeout(late); window.removeEventListener('resize', measure) }\n  }, [item, captionItem])\n  // Publish the dock's live height as --dk-dock-h so the mobile media area can reserve\n  // space under the photo (see max-sm:pb below). Measured, not guessed: caption length\n  // and wrap count vary per item. offsetHeight ignores the entrance transform.\n  // LAYOUT effect with a synchronous first apply, and declared BEFORE the targetRect\n  // measurement: the fly-in target must be measured with the padding already in place,\n  // or the clone lands in the unpadded area and the photo jumps up at handoff.\n  const dockRef = useRef<HTMLDivElement | null>(null)\n  // Has the dock's entrance finished? While false, the dock's children carry the\n  // .dk-dock-enter rise animation; flipping true removes the class so the keyed\n  // caption card can remount on nav without replaying the entrance.\n  const [dockEntered, setDockEntered] = useState(false)\n  const dockRiseClass = dockEntered ? '' : 'dk-dock-enter '\n  useLayoutEffect(() => {\n    const dock = dockRef.current\n    const root = rootRef.current\n    if (!dock || !root) return\n    const apply = () => root.style.setProperty('--dk-dock-h', `${dock.offsetHeight}px`)\n    apply()\n    const ro = new ResizeObserver(apply)\n    ro.observe(dock)\n    return () => ro.disconnect()\n  }, [])\n\n  // Swipe track: `x` follows the finger horizontally (commit = navigate); `yDrag`\n  // follows a downward drag to dismiss. `axis` locks to whichever the finger leads with.\n  const x = useMotionValue(0)\n  const yDrag = useMotionValue(0)\n  // Fades to FULLY transparent by 280px: the release animation only needs to travel far\n  // enough for the content to dissolve, not escort it to the bottom of the screen.\n  const dragOpacity = useTransform(yDrag, [0, 280], [1, 0])\n\n  // GLASS FRESHNESS. WebKit takes a SAMPLE of the backdrop behind the card and can\n  // keep serving it when the only thing changing underneath is a composited transform\n  // — exactly what a slide is — so after a nav the glass kept the PREVIOUS photo's\n  // colors (Chromium re-samples fine). Every frame the track moves, nudge the card's\n  // backdrop-filter between two imperceptibly different blur radii: a filter change\n  // forces a fresh sample, so the glass is live during the slide and the settle frame\n  // (itself a change) is always fresh. Inline overrides only while the glass CSS is\n  // actually in effect (data-on-photo + the sm breakpoint) — an inline filter on the\n  // ink card would conjure phantom glass.\n  const nudgeGlassRef = useRef<() => void>(() => {})\n  useEffect(() => {\n    let flip = 0\n    const nudge = () => {\n      const el = capCardRef.current\n      if (!el) return\n      if (!el.hasAttribute('data-on-photo') || window.innerWidth < 640) {\n        el.style.removeProperty('-webkit-backdrop-filter')\n        el.style.removeProperty('backdrop-filter')\n        return\n      }\n      flip ^= 1\n      const v = flip ? 'blur(20.02px) saturate(1.8)' : 'blur(20px) saturate(1.8)'\n      el.style.setProperty('-webkit-backdrop-filter', v)\n      el.style.setProperty('backdrop-filter', v)\n    }\n    nudgeGlassRef.current = nudge\n    return x.on('change', nudge)\n  }, [x])\n  // Backstop for change paths the track never sees: the entrance (x is idle during the\n  // open), the hi-res layer's opacity fade after a nav, a slow-mo entrance's late end.\n  useEffect(() => {\n    const ts = [450, 1200, 4200].map((ms) => window.setTimeout(() => nudgeGlassRef.current(), ms))\n    return () => ts.forEach((t) => window.clearTimeout(t))\n  }, [captionItem])\n\n  // SWIPE DOWN UNDOES THE OPEN, rather than just sliding the photo away.\n  //\n  // Opening the overlay fades the page content OUT and the backdrop scrim IN (see the page-fade\n  // effect). Dismissing did neither in reverse: the photo slid down and faded, but the scrim stayed\n  // fully opaque the whole way, so the page never came back — it simply appeared, all at once, when\n  // the modal unmounted. Dragging down now runs the open transition BACKWARDS in step with the\n  // finger: the scrim dissolves and the page underneath fades up, so you are literally pulling the\n  // page back into view. Let go early and it springs back, taking the reveal with it.\n  // The in-document veil the iOS bottom bar actually blurs — created by the backdrop\n  // effect below; the swipe-down reveal drives its opacity back out.\n  const veilRef = useRef<HTMLDivElement | null>(null)\n  const REVEAL_DRAG = 260 // px of downward travel that fully restores the page\n  // The open page-fade's rAF id, so a downward drag can take the opacity channel over from it\n  // (see below). Written every frame by the page-fade loop.\n  const openFadeRafRef = useRef(0)\n  useEffect(() => {\n    // NOT gated on animDone. It used to be (\"the open is still running the same properties;\n    // don't fight it\") — but on a phone the common gesture is tap, then swipe down immediately,\n    // BEFORE the ~350ms fly-in lands. With the subscription not yet made, the drag moved the\n    // photo while the page stayed frozen at whatever opacity the open fade had reached, then\n    // snapped visible when the modal unmounted — \"it just instantly reappears\". The fight is\n    // resolved the other way now: at rest (v=0) this never writes, and the moment a real drag\n    // begins it CANCELS the open fade's rAF loop and owns the opacity channel from there.\n    const scrim = scrimRef.current\n    const apply = (v: number) => {\n      if (v <= 0) return // at rest: leave the open fade alone\n      cancelAnimationFrame(openFadeRafRef.current)\n      const t = Math.min(1, Math.max(0, v / REVEAL_DRAG))\n      if (scrim) scrim.style.opacity = String(1 - t)\n      // The veil — the in-document layer the iOS bottom bar blurs — tracks the scrim exactly,\n      // so the strip behind the bar fades with the finger like the rest of the modal.\n      if (veilRef.current) veilRef.current.style.opacity = String(Math.max(VEIL_MIN_OPACITY, 1 - t))\n    }\n    return yDrag.on('change', apply)\n  }, [yDrag])\n  const trackRef = useRef<HTMLDivElement>(null)\n  const startX = useRef(0)\n  const startY = useRef(0)\n  const axis = useRef<null | 'x' | 'y'>(null)\n  // Where the gesture crossed the 8px recognition threshold. Movement is measured FROM here, so the\n  // asset never jumps to catch up with the finger — it starts under it and stays under it.\n  const lock = useRef<{ x: number; y: number } | null>(null)\n  const busy = useRef(false)\n\n  useLayoutEffect(() => {\n    const body = document.body\n    const root = rootRef.current\n    const prevOverflow = body.style.overflow\n    // Lock scroll. If the host page sets `html { scrollbar-gutter: stable }`, that is deliberately\n    // left ALONE: it keeps the ~15px gutter reserved whether or not the scrollbar shows, so nothing\n    // shifts when overflow is hidden. The reserved gutter would leave a strip to the right of the\n    // modal, so size the modal to the PHYSICAL viewport width: window.innerWidth spans the gutter,\n    // whereas 100vw stops short of it when a gutter is reserved. Kept in sync on resize; cleared on\n    // close.\n    body.style.overflow = 'hidden'\n    // --dk-sb-gutter: how far innerWidth overhangs the visible layout viewport (the\n    // reserved scrollbar strip). The close button and the dock's right padding add it,\n    // so the chrome hugs the CONTENT edge, not the physical window edge under the\n    // scrollbar. Fine pointers only: touch scrollbars are overlays with no gutter.\n    const fit = () => {\n      if (!root) return\n      root.style.width = `${window.innerWidth}px`\n      const gutter = window.matchMedia('(pointer: fine)').matches\n        ? Math.max(0, window.innerWidth - document.documentElement.clientWidth)\n        : 0\n      root.style.setProperty('--dk-sb-gutter', `${gutter}px`)\n    }\n    fit()\n    window.addEventListener('resize', fit)\n    return () => {\n      body.style.overflow = prevOverflow\n      window.removeEventListener('resize', fit)\n      if (root) root.style.width = ''\n    }\n  }, [])\n\n  // A true modal for the keyboard, not just the eye. aria-modal announces the page as\n  // inaccessible but doesn't make it so: Tab still walked the faded-out page underneath (its\n  // controls sit at opacity 0 for the Safari toolbar fix — focusable yet fully invisible).\n  // `inert` on every other <body> child makes the browser enforce the boundary natively —\n  // unfocusable, unclickable, hidden from assistive tech — so Tab cycles the dialog's own\n  // controls. Focus moves onto the dialog on open; the opener hands it back to the asset's\n  // element on close.\n  useEffect(() => {\n    const root = rootRef.current\n    const touched: HTMLElement[] = []\n    for (const el of Array.from(document.body.children)) {\n      if (el === root || !(el instanceof HTMLElement) || el.inert) continue\n      el.inert = true\n      touched.push(el)\n    }\n    root?.focus({ preventScroll: true })\n    return () => touched.forEach((el) => { el.inert = false })\n  }, [])\n\n  // Chrome/Android and pre-26 Safari tint their toolbar from <meta name=\"theme-color\"> (Safari 26\n  // itself ignores it — that case is handled by the <html>/<body> background below). A host site's\n  // existing metas may be a prefers-color-scheme pair that (a) doesn't track class dark mode and\n  // (b) can out-rank a plain override. While open, remove those and install a single meta pinned to\n  // the modal's exact background; restore on close. The literal is read from the resolved --dk-bg\n  // (the meta lives outside the .dk-scope subtree, so the var itself can't be used).\n  useLayoutEffect(() => {\n    const root = rootRef.current\n    const bg = root ? getComputedStyle(root).getPropertyValue('--dk-bg').trim() : ''\n    if (!bg) return\n    const head = document.head\n    const saved = Array.from(head.querySelectorAll<HTMLMetaElement>('meta[name=\"theme-color\"]'))\n    saved.forEach((el) => el.remove())\n    const meta = document.createElement('meta')\n    meta.name = 'theme-color'\n    meta.content = bg\n    head.appendChild(meta)\n    return () => { meta.remove(); saved.forEach((el) => head.appendChild(el)) }\n  }, [])\n\n  // iOS 26 Safari dropped `theme-color` and derives its translucent toolbar tint from the\n  // <html>/<body> background-color, so pin BOTH to the modal's exact colour while open — that's\n  // what the bar samples. When the host page's background matches --dk-bg (the default pairing),\n  // the pin is visually a no-op; either way the toolbar samples the modal's colour. Restored on\n  // close. (Same subtlety as the meta: html/body sit outside .dk-scope, so the resolved literal\n  // is written, not the var.)\n  useLayoutEffect(() => {\n    const root = rootRef.current\n    const bg = root ? getComputedStyle(root).getPropertyValue('--dk-bg').trim() : ''\n    if (!bg) return\n    const html = document.documentElement\n    const prevBodyBg = document.body.style.backgroundColor\n    const prevHtmlBg = html.style.backgroundColor\n    document.body.style.backgroundColor = bg\n    html.style.backgroundColor = bg\n    return () => {\n      document.body.style.backgroundColor = prevBodyBg\n      html.style.backgroundColor = prevHtmlBg\n    }\n  }, [])\n\n  // Safari's translucent bottom bar blurs the scrolled DOCUMENT beneath it, and a fixed\n  // overlay does NOT occlude that view — so no matter how opaque the modal was, the page kept\n  // peering through the bar. The cure used to be fading every other <body> child to ~0 and\n  // letting the bar blur the bare modal-coloured body. That held until the swipe-down reveal\n  // exposed its weakness: the bar rebuilds its blur of REAPPEARING in-document content\n  // LAZILY (~1s), so the strip behind it sat as a flat modal-coloured box and popped late —\n  // while a tap-close (instant unmount) updated instantly. Warm-layer opacity floors and\n  // post-teardown repaint nudges did not move it; reappearing content is simply the slow path.\n  //\n  // So nothing reappears anymore. The page stays at FULL opacity the whole time, and a VEIL —\n  // an absolutely-positioned in-document element (NOT fixed, so the bar provably samples it)\n  // covering the viewport behind the modal — carries the modal colour instead. The open fades\n  // the veil IN over the unchanged page (perceived content visibility is (1-eased)² either\n  // way — scrim over veil now, scrim over fading page before — so the look is identical), and\n  // the swipe-down reveal fades it OUT: a DISAPPEARING in-document layer, the direction the\n  // bar's backdrop handles live. A tap-close removes it with the unmount, which was already\n  // instant. The veil floors at VEIL_MIN_OPACITY so its layer never tears down mid-session.\n  useLayoutEffect(() => {\n    const root = rootRef.current\n    if (!root || root.parentElement !== document.body) return\n    const scrim = scrimRef.current\n    const veil = document.createElement('div')\n    veil.setAttribute('aria-hidden', 'true')\n    // Viewport-covering with 200px of slack both ends (bar geometry, rubber-banding, rotation\n    // mid-gesture); re-fitted on resize. Scroll is locked while open, so top stays valid.\n    const fitVeil = () => {\n      veil.style.top = `${window.scrollY - 200}px`\n      veil.style.height = `${window.innerHeight + 400}px`\n    }\n    Object.assign(veil.style, {\n      position: 'absolute',\n      left: '0',\n      right: '0',\n      // Under the modal (z-50), above everything the page stacks (the header is 45).\n      zIndex: '49',\n      pointerEvents: 'none',\n      // The scrim's own resolved colour — the veil is the scrim's in-document twin.\n      backgroundColor: scrim ? getComputedStyle(scrim).backgroundColor : '#fff',\n      opacity: !originRect || reduceMotion ? '1' : String(VEIL_MIN_OPACITY),\n    })\n    fitVeil()\n    window.addEventListener('resize', fitVeil)\n    document.body.appendChild(veil)\n    veilRef.current = veil\n    return () => {\n      window.removeEventListener('resize', fitVeil)\n      veil.remove()\n      veilRef.current = null\n      // Belt and suspenders for any straggler blur the bar still holds after teardown: two\n      // frames later (after the opener's exact scroll restore), move the page 1px and put it\n      // back — the backdrop provably tracks scroll.\n      requestAnimationFrame(() => requestAnimationFrame(() => {\n        const sy = window.scrollY\n        window.scrollTo(window.scrollX, sy > 0 ? sy - 1 : sy + 1)\n        requestAnimationFrame(() => window.scrollTo(window.scrollX, sy))\n      }))\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  // The open transition: one main-thread rAF loop fades the scrim and the veil in together.\n  // Main-thread inline writes, not WAAPI/CSS: the bar samples the main-thread paint, so a\n  // composited fade would read there as a cut. Deep-link opens and reduced motion skip the\n  // loop entirely (the veil mounted at 1, the scrim keeps its default opaque state).\n  useLayoutEffect(() => {\n    if (!originRect || reduceMotion) return\n    const scrim = scrimRef.current\n    const veil = veilRef.current\n    // THE PAGE MAY NOT START DISAPPEARING BEFORE ITS REPLACEMENT IS ON SCREEN: the loop is\n    // gated on the clone's first painted frame (flightPainted). The scrim, whose default\n    // state is opaque, is made transparent HERE — before the gate — or the whole screen goes\n    // solid for the ~40ms until the clone paints. That placement is load-bearing.\n    if (scrim) scrim.style.opacity = '0' // start transparent; the loop eases it back to 1\n    if (!flightPainted) return\n    const DURATION = slowMo ? 3000 : 350 // shift-click slow-mo stretches the fade with the spring\n    // Integrate CLAMPED deltas rather than reading the wall clock: Safari drops frames through\n    // the modal's first commit, and a wall-clock fade teleports across the gap — a stall can\n    // never turn this dissolve into a cut.\n    let last = 0\n    let elapsed = 0\n    const frame = (now: number) => {\n      if (!last) { last = now; openFadeRafRef.current = requestAnimationFrame(frame); return }\n      elapsed += Math.min(now - last, 32)\n      last = now\n      const t = Math.max(0, Math.min(1, elapsed / DURATION))\n      const eased = 1 - (1 - t) ** 3 // ease-out cubic — fast, responsive\n      if (veil) veil.style.opacity = String(Math.max(VEIL_MIN_OPACITY, eased)) // the bar's white rises\n      if (scrim) scrim.style.opacity = String(eased)                          // the viewport's white, same curve\n      if (t < 1) openFadeRafRef.current = requestAnimationFrame(frame)\n    }\n    // The id lives in openFadeRafRef (not a local) so the swipe-down reveal can cancel this\n    // loop the moment a drag starts and own the opacity channel mid-open.\n    openFadeRafRef.current = requestAnimationFrame(frame)\n    return () => {\n      cancelAnimationFrame(openFadeRafRef.current)\n      if (scrim) scrim.style.opacity = ''\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [flightPainted])\n\n  useLayoutEffect(() => {\n    if (containerRef.current) setTargetRect(containerRef.current.getBoundingClientRect())\n  }, [])\n\n  const width = () => containerRef.current?.clientWidth ?? window.innerWidth\n\n  // Nav is ready as soon as the track has been measured (targetRect) — NOT once the whole open\n  // fly-in has finished. Gating on the open animation dropped every arrow pressed in its first\n  // few hundred ms (longer on a cold/slow load) — presses thrown away. If a press lands\n  // before the fly-in ends, commit() snaps it complete so the (now visible) track is what slides.\n  const navReadyRef = useRef(false)\n  useEffect(() => { navReadyRef.current = !!targetRect }, [targetRect])\n\n  // Index-first navigation. A press commits the new item IMMEDIATELY (so arrows always respond\n  // and naturally interrupt an in-flight slide — the press takes priority), then the incoming\n  // slide springs home from wherever the track is right now. `x.get() + dir*w` covers every\n  // start point in one expression: idle (0), a mid-slide interrupt, or a half-finished finger\n  // drag — so the motion is always continuous, never a snap. No queue, no end-of-slide rebase.\n  const commit = (dir: 1 | -1, slow = false) => {\n    if (!navReadyRef.current) return\n    // At a boundary there's no neighbour to reveal — settle any drag/interrupt back to centre.\n    if ((dir === 1 && index >= total - 1) || (dir === -1 && index <= 0)) {\n      animate(x, 0, SPRING)\n      return\n    }\n    // Pressed before the open fly-in finished: reveal the container now so the slide animates\n    // the real track rather than swiping behind the flying clone.\n    if (!animDone) flushSync(() => setAnimDone(true))\n    // The nav is about to slide the track: the (possibly still-overlapping) stationary clone\n    // must not sit on top of it. Ends the 300ms handoff overlap early.\n    settleClone()\n    const fromX = x.get() + dir * width()\n    const target = dir === 1 ? nextItem : prevItem\n    x.stop()\n    flushSync(() => { if (dir === 1) onNext(); else onPrev() })\n    // A nav DURING the dock's entrance ends the entrance: the incoming caption card is keyed\n    // per item, and mounting it with .dk-dock-enter still applied replayed the whole rise\n    // from below the fold — delay and backwards fill included — so the new item showed no\n    // caption for a beat, then it climbed in late. Batched with setCaptionItem, so the\n    // remounted card's first render is already class-free.\n    setDockEntered(true)\n    if (target) setCaptionItem(target)\n    // Position the just-committed track synchronously (jump + a direct write, same task as the\n    // index shift) so the swap lands in one paint with nothing displaced, then spring to centre.\n    x.jump(fromX)\n    if (trackRef.current) trackRef.current.style.transform = `translateX(${fromX}px)`\n    busy.current = true\n    animate(x, 0, { ...(slow ? SLOW_NAV_SPRING : reduceMotion ? NAV_SPRING_REDUCED : NAV_SPRING), onComplete: () => { busy.current = false } })\n  }\n\n  // Slide the image down and off, then unmount.\n  const dismiss = () => {\n    // A SHORT drop, not a ride to the bottom of the screen: the content is fully transparent\n    // by 280px of travel (dragOpacity), so animating to window.innerHeight just meant watching\n    // nothing move for most of the duration. Drop a further ~200px from wherever the finger\n    // let go — always past both the fade end and the page-restore distance (REVEAL_DRAG), so\n    // the dissolve completes and the page beneath is fully back before the unmount.\n    const target = Math.max(REVEAL_DRAG + 60, yDrag.get() + 200)\n    animate(yDrag, target, { duration: 0.16, ease: 'easeIn' })\n    window.setTimeout(onClose, 150)\n  }\n\n  // Arrow keys animate through the same track as swipe / on-screen arrows.\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'ArrowRight') { e.preventDefault(); commit(1, e.shiftKey) }\n      else if (e.key === 'ArrowLeft') { e.preventDefault(); commit(-1, e.shiftKey) }\n    }\n    window.addEventListener('keydown', onKey)\n    return () => window.removeEventListener('keydown', onKey)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [index, total])\n\n  const onTouchStart = (e: React.TouchEvent) => {\n    if (busy.current) return\n    x.stop(); yDrag.stop()\n    startX.current = e.touches[0].clientX\n    startY.current = e.touches[0].clientY\n    axis.current = null\n    lock.current = null\n  }\n  const onTouchMove = (e: React.TouchEvent) => {\n    if (busy.current) return\n    const dx = e.touches[0].clientX - startX.current\n    const dy = e.touches[0].clientY - startY.current\n    if (axis.current === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {\n      axis.current = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'\n      // A recognized gesture is about to move the media out from under the (possibly\n      // still-overlapping) stationary clone — end the handoff overlap now.\n      settleClone()\n      // Remember WHERE the axis locked. The 8px the finger travelled to trigger the lock is not\n      // drag — it is the gesture being recognised. Feeding the raw delta in meant the photo\n      // teleported those 8px the instant the lock fired, and then tracked the finger. That initial\n      // jump is what breaks the sense that you are holding the thing: the image should start moving\n      // from exactly where your finger is, and stay under it from then on.\n      lock.current = { x: dx, y: dy }\n    }\n    const lx = lock.current?.x ?? 0\n    const ly = lock.current?.y ?? 0\n    if (axis.current === 'x') {\n      const ox = dx - lx\n      // Rubber-band when there's no neighbour to reveal.\n      const resist = (index <= 0 && ox > 0) || (index >= total - 1 && ox < 0)\n      x.set(resist ? ox * 0.35 : ox)\n    } else if (axis.current === 'y') {\n      yDrag.set(Math.max(0, dy - ly)) // downward only, from the point of lock\n    }\n  }\n  const onTouchEnd = () => {\n    const a = axis.current\n    axis.current = null\n    if (busy.current) return\n    if (a === 'x') {\n      const dx = x.get()\n      const threshold = Math.min(width() * 0.25, 90)\n      if (dx <= -threshold) commit(1)\n      else if (dx >= threshold) commit(-1)\n      else animate(x, 0, SPRING)\n    } else if (a === 'y') {\n      if (yDrag.get() > CLOSE_DRAG) dismiss()\n      else animate(yDrag, 0, SPRING)\n    }\n    // A tap (no axis lock) falls through to the click handler → close.\n  }\n\n  return (\n    <m.div\n      ref={rootRef}\n      // Fixed, full-viewport via left:0 + width (not right:0). `w-screen` is only the pre-JS fallback;\n      // the scroll-lock effect sets width to window.innerWidth so the modal also covers a reserved\n      // scrollbar gutter (100vw stops short of it) — no bare strip on the right, and without touching\n      // the host's `scrollbar-gutter` (dropping that reflows the page under the modal).\n      className=\"dk-scope fixed inset-y-0 left-0 w-screen z-50 flex flex-col select-none overflow-hidden outline-none\"\n      // Programmatically focusable so focus enters the dialog on open (see the inert effect);\n      // -1 keeps it out of the Tab order itself.\n      tabIndex={-1}\n      // Transparent root; the backdrop is an absolute scrim child that FADES IN (below), so the\n      // background eases in instead of snapping opaque on open. (Safari's toolbar is kept solid\n      // separately by fading the page content out — see the page-fade effect — since the toolbar\n      // blurs the document behind this fixed overlay, not the overlay itself.)\n      style={{ color: FG }}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={caption ?? 'Photo'}\n      onClick={onClose}\n    >\n      {/* Backdrop scrim — its opacity is eased 0→1 by the page-fade rAF loop (see the effect above),\n          so the background fades in together with the page fading out rather than snapping opaque.\n          Kept `absolute`, never `fixed` + negative z — that composited ABOVE the photo in Safari and\n          hid it. */}\n      <div ref={scrimRef} className=\"absolute inset-0 -z-10\" style={{ backgroundColor: BG }} />\n      {/* Inner layer carries the swipe-down-to-dismiss transform. */}\n      {/* The swipe used to translate THIS whole layer — chrome included. The drag now lives\n          on the media area alone (and the rail fades in place): the close button and arrows\n          hold still while the photo is pulled away (Dave's call, 2026-07-24). */}\n      <div className=\"relative flex flex-1 flex-col min-h-0\">\n      {/* Close — sits above the image; the surrounding layer passes clicks through. */}\n      <m.div\n        className=\"absolute inset-0 pointer-events-none z-30\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        transition={{ delay: !animDone ? 0.25 : 0, duration: 0.2 }}\n      >\n        {/* Fades with the drag but does NOT move. It was the last opaque thing on the modal\n            at unmount, and iOS 26's bottom-bar backdrop cache held a snapshot of it (plus the\n            modal's white field) for ~a second after a swipe dismiss — a ghost close button.\n            Fully-faded chrome means any stale snapshot is of nothing. The outer overlay owns\n            the entrance opacity, so the drag binding needs its own layer. */}\n        <m.div className=\"absolute inset-0\" style={{ opacity: dragOpacity }}>\n        <button\n          onClick={(e) => { e.stopPropagation(); onClose() }}\n          aria-label=\"Close\"\n          className=\"pointer-events-auto absolute top-3 right-[calc(16px+var(--dk-sb-gutter,0px))] flex h-11 w-11 cursor-pointer items-center justify-center rounded-full text-[var(--dk-fg)] transition-colors hover:bg-[var(--dk-surface)] active:bg-[var(--dk-surface)]\"\n        >\n          <IconClose />\n        </button>\n        </m.div>\n      </m.div>\n\n      {/* Image area — full width so the slide track can carry an asset fully off-screen; the 16px\n          side margins live on each slide's inner box (see Slide), not here, so they never clip the\n          animation. No vertical padding: the photo owns the full lightbox height. */}\n      {/* max-sm:pb-[var(--dk-dock-h)]: on mobile the chrome never hides and a width-constrained\n          photo doesn't reach the viewport bottom, so without this the glass card straddles\n          the photo's bottom edge — half on photo, half on backdrop. Reserving the dock's\n          measured height (ResizeObserver above) centers the photo in the space above it.\n          Desktop keeps true full-bleed: the photo fills the height and the dock overlays. */}\n      <m.div className=\"flex-1 min-h-0 flex max-sm:pb-[var(--dk-dock-h,0px)]\" style={{ y: yDrag, opacity: dragOpacity }}>\n        <div\n          ref={containerRef}\n          className=\"flex-1 min-w-0 relative overflow-hidden\"\n          style={{ opacity: containerVisible ? 1 : 0, touchAction: 'none' }}\n          onTouchStart={onTouchStart}\n          onTouchMove={onTouchMove}\n          onTouchEnd={onTouchEnd}\n        >\n          <m.div ref={trackRef} className=\"absolute inset-0\" style={{ x }}>\n            {/* Neighbours do not MOUNT until the open has landed. They are off-screen the whole time\n                (translateX ±100%), but mounting them meant React committing two extra <img>/<video>\n                trees on the click frame — and that commit is what delayed the spring's first frame.\n                They were already deferring their hi-res layer for the same reason; this defers the\n                whole slide. Nav still works: commit() flushSync's animDone before it slides. */}\n            {prevItem && animDone && <Slide key={prevItem.src} item={prevItem} offset=\"-100%\" hiRes getHiResSrc={getHiResSrc} showOutline={showOutline} />}\n            <Slide\n              key={item.src}\n              item={item}\n              offset=\"0%\"\n              priority\n              // The hi-res layer waits for the landing, exactly as the neighbours' does.\n              //\n              // The clicked slide used to mount TWO images on the click frame: the base (the\n              // origin's already-decoded file — instant, from cache) and a FRESH large fetch on\n              // top of it. That second fetch and its decode landed right on the spring's opening\n              // frames. Measured in Safari at 60fps: the first two frames of the open took 32ms and\n              // 37ms — two missed vsyncs — and every frame after them was a clean 16-17ms. That is\n              // the \"jitter or two\" when opening an image.\n              //\n              // Nothing is lost by waiting: the base layer is the exact file already on screen, so\n              // the flight and the landing look identical either way; the sharp layer just fades in\n              // once the animation is out of the way, which is already how every other slide behaves.\n              hiRes={animDone}\n              getHiResSrc={getHiResSrc}\n              showOutline={showOutline}\n              armed={animDone}\n              getCloneTime={getCloneTime}\n              onLoad={() => setImageLoaded(true)}\n            />\n            {nextItem && animDone && <Slide key={nextItem.src} item={nextItem} offset=\"100%\" hiRes getHiResSrc={getHiResSrc} showOutline={showOutline} />}\n          </m.div>\n        </div>\n      </m.div>\n\n      {/* Corner dock — the photo fills the entire lightbox; caption + EXIF ride a\n          dark-material glass card centered at the bottom, flanked by the prev/next\n          buttons (theme ink, surface hover), all floating over the photo. Dark material\n          (black-tinted blur, boosted saturation) is the HIG direction for light text\n          over photos — readable on any image, no scrim. Off the photo (and always on\n          mobile, where the card sits on the backdrop below the photo) the material\n          comes off and the text speaks in theme ink — see .dk-caption-card.\n          The dock fades with the swipe-down drag via dragOpacity; while that ancestor\n          opacity dips below 1 the card's blur goes flat (backdrop root) — accepted,\n          it's a dismissal gesture over a moving photo.\n\n          Entrance DURING the fly-in: arrows and caption card rise in lockstep from\n          below the viewport edge alongside the photo's flight. It is a pure CSS\n          keyframes animation (.dk-dock-enter) applied to the dock's CHILDREN, never\n          this dock: motion re-applies its cached transform/will-change to this element\n          on every re-render, and any transform on an ANCESTOR of the glass card makes\n          it the backdrop root — the card's blur samples a transparent subtree instead\n          of the photo (flat tint). The card's OWN transform keeps its backdrop\n          sampling live, so the glass blurs for the whole ride up. dockRiseClass comes\n          off once the entrance ends — per-item remounts of the keyed card must not\n          replay it. motion owns ONLY the dragOpacity binding here. Runs once per open. */}\n      <m.div\n        ref={dockRef}\n        onClick={(e) => e.stopPropagation()}\n        // Slow-mo stretches the rise via the vars the children's animation reads;\n        // the first animationend flips dockEntered (class removal) and re-measures\n        // the caption geometry with everything at rest.\n        style={{ opacity: dragOpacity, ...(slowMo ? ({ '--dk-dock-rise-dur': '3s', '--dk-dock-rise-delay': '0.6s' } as React.CSSProperties) : null) }}\n        onAnimationEnd={(e) => {\n          if ((e as React.AnimationEvent).animationName !== 'dk-dock-rise') return\n          setDockEntered(true)\n          measureCapRef.current()\n        }}\n        className=\"dk-rail-foot absolute inset-x-0 bottom-0 z-[25]\"\n      >\n        {/* items-end: a long caption grows the card UPWARD while the arrows stay anchored\n            to the bottom edge instead of riding up with the row's centerline. On mobile the\n            caption takes the full dock width on its own line ABOVE the arrows (order-first +\n            basis-full wraps it); justify-between then spreads the arrows to the edges. */}\n        {/* 16px side padding at every size — the photo slides carry 16px inner margins, so\n            card edges and arrow buttons sit on the same vertical lines as the photo. */}\n        <div className=\"flex max-sm:flex-wrap items-end justify-between gap-3 pl-4 pr-[calc(16px+var(--dk-sb-gutter,0px))] pb-1\">\n          {index > 0 ? (\n            <button\n              onClick={(e) => { e.stopPropagation(); commit(-1, e.shiftKey) }}\n              aria-label=\"Previous photo\"\n              className={`${dockRiseClass}flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-[var(--dk-fg)] transition-colors hover:bg-[var(--dk-surface)] active:bg-[var(--dk-surface)]`}\n            >\n              <IconArrow dir=\"left\" />\n            </button>\n          ) : (\n            <span className={`${dockRiseClass}h-11 w-11 shrink-0`} />\n          )}\n          <div className=\"flex min-w-0 flex-1 justify-center max-sm:order-first max-sm:basis-full\">\n            {hasCaptions && (capCaption || capExif) && (\n              <div\n                key={captionItem.src}\n                ref={capCardRef}\n                data-on-photo={capOnPhoto ? '' : undefined}\n                className={`${dockRiseClass}dk-caption-card max-w-full rounded-xl px-3.5 py-2.5 text-center`}\n              >\n                {capCaption && (\n                  <p className=\"font-[family-name:var(--dk-font-sans)] text-[14px] leading-[1.35]\" style={{ color: 'var(--dk-cap-fg)' }}>{capCaption}</p>\n                )}\n                {capExif && (\n                  // Wraps rather than truncates (mobile EXIF must stay whole); the roomier\n                  // leading keeps the lines readable when they stack.\n                  <p className={`${capCaption ? 'mt-1 ' : ''}font-[family-name:var(--dk-font-mono)] text-[11px] leading-[1.5]`} style={{ color: 'var(--dk-cap-muted)' }}>\n                    {capExif}\n                  </p>\n                )}\n              </div>\n            )}\n          </div>\n          {index < total - 1 ? (\n            <button\n              onClick={(e) => { e.stopPropagation(); commit(1, e.shiftKey) }}\n              aria-label=\"Next photo\"\n              className={`${dockRiseClass}flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-[var(--dk-fg)] transition-colors hover:bg-[var(--dk-surface)] active:bg-[var(--dk-surface)]`}\n            >\n              <IconArrow dir=\"right\" />\n            </button>\n          ) : (\n            <span className={`${dockRiseClass}h-11 w-11 shrink-0`} />\n          )}\n        </div>\n      </m.div>\n\n      {cloneTarget && originRect && (\n        <m.div\n          className=\"absolute overflow-hidden pointer-events-none\"\n          // Sits at the photo's final rect; a transform (translate + uniform scale from the\n          // thumbnail) does the flight, so only the compositor works — no per-frame layout.\n          //\n          // The clone STAYS MOUNTED at opacity 0 after the handoff instead of unmounting.\n          // Unmounting it forced Safari to tear down and re-composite the layer tree at the\n          // exact moment the modal video's fresh layer was still being promoted — the transient\n          // \"not fully opaque\" washout that read as a white flash at the end of the open. An\n          // opacity flip destroys nothing; the whole subtree simply leaves with the modal.\n          style={{\n            zIndex: 20,\n            left: cloneTarget.left,\n            top: cloneTarget.top,\n            width: cloneTarget.width,\n            height: cloneTarget.height,\n            transformOrigin: '0 0',\n            opacity: cloneGone ? 0 : 1,\n          }}\n          initial={{\n            x: originRect.left - cloneTarget.left,\n            y: originRect.top - cloneTarget.top,\n            scaleX: originRect.width / cloneTarget.width,\n            scaleY: originRect.height / cloneTarget.height,\n          }}\n          animate={fly ? { x: 0, y: 0, scaleX: 1, scaleY: 1 } : {\n            x: originRect.left - cloneTarget.left,\n            y: originRect.top - cloneTarget.top,\n            scaleX: originRect.width / cloneTarget.width,\n            scaleY: originRect.height / cloneTarget.height,\n          }}\n          transition={openSpring}\n          // Guarded on `fly`: before the gate opens, animate === initial, and motion reports that\n          // no-op as \"complete\". Acting on it would flip animDone, hide the clone, and skip the\n          // flight entirely.\n          onAnimationComplete={() => { if (fly) flushSync(() => setAnimDone(true)) }}\n        >\n          {item.videoSrc ? (\n            FLY_LIVE_VIDEO ? (\n              // The clicked clip KEEPS PLAYING while it flies: a muted clone resumes from the\n              // origin's exact frame (videoTime). But a fresh <video> paints NOTHING until it has\n              // metadata AND has finished seeking to that frame — and an unpainted <video> is\n              // TRANSPARENT (measured in Safari), so the clone was a HOLE that showed the scrim\n              // straight through it. Safari also drops `poster` as soon as the video starts\n              // loading, so the poster did not cover the gap the way it does in Chrome.\n              //\n              // Hence the base layer below: the frozen frame ALWAYS paints, and the live <video>\n              // sits on top of it and covers it the moment it has a real frame. Playback is\n              // preserved; the hole is not possible.\n              //\n              // Long clips are the ones that show the hole: the clone may seek tens of seconds in,\n              // which Safari takes real time to do. Short loops seek almost instantly.\n              <>\n                <FlightStill\n                  item={item}\n                  onPainted={fireFlightPainted}\n                  // Hidden while the live video flies (its click frame goes stale within\n                  // frames), then resurfaced with FRESH pixels for the handoff window.\n                  hidden={cloneVideoShown && !stillResurfaced}\n                  repaintRef={stillRepaintRef}\n                />\n                <FlightVideo item={item} videoRef={cloneVideoRef} onShown={() => setCloneVideoShown(true)} />\n              </>\n            ) : item.flightPoster ? (\n              // FLY_LIVE_VIDEO off: fly the FROZEN frame instead of a second live <video>.\n              // Same pixels (flightPoster is a canvas snapshot of the exact clicked frame), but\n              // no video element is instantiated inside the animating layer. The modal player still\n              // lands on the right frame — it starts from item.videoTime regardless — so the clip\n              // simply does not advance during the flight.\n              <img\n                src={item.flightPoster}\n                className=\"absolute inset-0 h-full w-full object-contain\"\n                alt=\"\"\n                aria-hidden\n                onLoad={fireFlightPainted}\n              />\n            ) : null // clip had no decodable frame at click (never loaded) — fly nothing; the\n                     // origin simply stays put until the modal player takes over.\n          ) : (\n            <img\n              // flightSrc = the origin's already-decoded file, served verbatim — the clone\n              // paints on frame 1 of the flight even on a cold cache. (A fresh differently-sized\n              // fetch here often finished loading AFTER the spring, so no fly-in was ever seen.)\n              src={item.flightSrc || item.src}\n              className=\"absolute inset-0 h-full w-full object-contain\"\n              alt=\"\"\n              aria-hidden\n              onLoad={fireFlightPainted}\n            />\n          )}\n        </m.div>\n      )}\n      </div>\n    </m.div>\n  )\n}\n\n/* ------------------------------------------------------------------------------------------------\n * useDKLightbox — the controller both openers (grid, carousel) drive the lightbox through.\n * Owns: selection state, the click-time capture (origin rect, decoded src, video frame snapshot),\n * keyboard handling (Escape, and the modality tracking that decides whether close restores a\n * focus ring), origin hide/restore, neighbour prefetch, deep links, and the portal itself.\n * ---------------------------------------------------------------------------------------------- */\n\n// Returns focus to `el` after the modal closes. `withRing` should be true only when the session\n// showed real keyboard navigation (a Tab press, or a keyboard-activated open): those users need\n// the :focus-visible ring to see where they landed. A mouse user who pressed Escape just gets\n// their focus position back with no ring — the browser's own heuristic counts Escape as\n// \"keyboard\" and would flash the ring at them, which is exactly the over-trigger this avoids.\nfunction restoreFocus(el: HTMLElement, withRing: boolean) {\n  el.focus({ preventScroll: true })\n  el.scrollIntoView({ block: 'nearest' })\n  if (withRing) return\n  // Suppress the ring for this landing only — BOTH halves of any global :focus-visible recipe\n  // the host site may have: an outline AND a box-shadow halo. (An Escape press makes the browser\n  // treat the subsequent programmatic focus as :focus-visible, so such a rule fires even for a\n  // mouse session; suppressing only the outline leaves a halo showing.) Inline styles win over\n  // the rule; lifted the moment focus moves on or the keyboard comes into play.\n  const prevOutline = el.style.outline\n  const prevShadow = el.style.boxShadow\n  const lift = () => {\n    el.style.outline = prevOutline\n    el.style.boxShadow = prevShadow\n    el.removeEventListener('blur', lift)\n    window.removeEventListener('keydown', lift)\n  }\n  el.style.outline = 'none'\n  el.style.boxShadow = 'none'\n  el.addEventListener('blur', lift, { once: true })\n  window.addEventListener('keydown', lift, { once: true })\n}\n\nexport interface DKLightboxOptions {\n  /** A sharper large variant to fade in over the base image (and to prefetch around the current\n   *  one). Return item.src (or nothing) to opt an item out. */\n  getHiResSrc?: (item: DKMediaItem) => string\n  /** Reserve the caption/exif rail. Default: on when any item carries a caption or EXIF. */\n  hasCaptions?: boolean\n  /** The 1px photo edge outline in the modal. Default true (photographs). */\n  showOutline?: boolean\n  /** Open from `?photo=<index>` on mount (and strip the param), for shareable deep links. */\n  deepLink?: boolean\n  /** The on-page element for an item, used to hand focus back on close (and as the deep-link\n   *  zoom origin). Wire this to a ref registry of your thumbnails. */\n  getOriginEl?: (index: number) => HTMLElement | null\n  /** Fired as the modal closes, with the index the viewer was on — e.g. so a carousel can show\n   *  the slide they navigated to before focus lands on it. */\n  onClose?: (index: number) => void\n}\n\nexport function useDKLightbox(items: DKMediaItem[], options: DKLightboxOptions = {}) {\n  const [selectedIndex, setSelectedIndex] = useState<number | null>(null)\n  const [originRect, setOriginRect] = useState<DOMRect | null>(null)\n  const [slowMo, setSlowMo] = useState(false) // shift-click: ~3s slow-motion open for inspection\n  const itemsRef = useRef(items)\n  useEffect(() => { itemsRef.current = items }, [items])\n  const optionsRef = useRef(options)\n  useEffect(() => { optionsRef.current = options })\n  // The clicked item's runtime capture (decoded src / video frame), keyed to its index.\n  const captureRef = useRef<{ index: number; data: Partial<DKLightboxItem> } | null>(null)\n  const prefetchedHiRes = useRef<Set<string>>(new Set())\n  // Videos elsewhere in the opener's scope, paused while the overlay is open — otherwise they\n  // keep playing behind the fading-in backdrop and flash as \"remnants\". Resumed on close.\n  const pausedRef = useRef<HTMLVideoElement[]>([])\n  // The clicked element — hidden while open so it doesn't sit under the fly-in clone (the clone\n  // IS that asset moving into the modal). visibility keeps its layout slot, so nothing reflows.\n  const originElRef = useRef<HTMLElement | null>(null)\n  // The page's scroll position at open. Close restores it EXACTLY: traversing the modal must not\n  // move the reader — restoreFocus's scrollIntoView on the closed-on element was relocating the\n  // page after a few swipes.\n  const scrollPosRef = useRef<{ x: number; y: number } | null>(null)\n  // The last item shown, surviving close's setSelectedIndex(null) — close hands focus back to\n  // ITS element (not the entry one; the viewer may have arrowed far from where they came in).\n  const latestIndexRef = useRef<number | null>(null)\n  useEffect(() => {\n    if (selectedIndex !== null) latestIndexRef.current = selectedIndex\n  }, [selectedIndex])\n  // Did this session show REAL keyboard navigation (keyboard-activated open, or a Tab while\n  // open)? Only then does the restored thumbnail show its focus ring. Escape/arrows don't\n  // count — mouse users press those too, and the browser's own focus-visible heuristic\n  // flashing a ring after a mouse session + Escape is exactly the misfire this replaces.\n  const keyboardModeRef = useRef(false)\n  useEffect(() => {\n    if (selectedIndex === null) return\n    const onTab = (e: KeyboardEvent) => {\n      if (e.key === 'Tab') keyboardModeRef.current = true\n    }\n    window.addEventListener('keydown', onTab)\n    return () => window.removeEventListener('keydown', onTab)\n  }, [selectedIndex])\n\n  const close = useCallback(() => {\n    setSelectedIndex(null)\n    setOriginRect(null)\n    if (originElRef.current) { originElRef.current.style.visibility = ''; originElRef.current = null }\n    pausedRef.current.forEach((v) => v.play().catch(() => {}))\n    pausedRef.current = []\n    const idx = latestIndexRef.current\n    if (idx !== null) optionsRef.current.onClose?.(idx)\n    // Hand focus back to the element of the item the viewer closed ON (they may have navigated\n    // far from the one they entered through) — the APG dialog pattern, so a keyboard user\n    // resumes tabbing from where they left the page instead of from the top.\n    // Deferred a frame: at this point the modal is still mounted, so the page is still `inert` —\n    // and focusing an inert element is a silent no-op. One rAF later the dialog has unmounted and\n    // its cleanup has lifted inert, so the focus actually takes.\n    const withRing = keyboardModeRef.current\n    const pos = scrollPosRef.current\n    scrollPosRef.current = null\n    requestAnimationFrame(() => {\n      const el = idx !== null ? optionsRef.current.getOriginEl?.(idx) ?? null : null\n      if (el) {\n        restoreFocus(el, withRing)\n      } else if (document.activeElement instanceof HTMLElement) {\n        document.activeElement.blur()\n      }\n      // AFTER restoreFocus (whose scrollIntoView may have moved the page toward the closed-on\n      // element): put the reader back precisely where they were when they opened the modal.\n      // Same task, so only the final position ever paints.\n      if (pos) window.scrollTo(pos.x, pos.y)\n    })\n  }, [])\n  const prev = useCallback(() => {\n    setSelectedIndex((i) => (i !== null && i > 0 ? i - 1 : i))\n  }, [])\n  const next = useCallback(() => {\n    setSelectedIndex((i) => {\n      const len = itemsRef.current.length\n      return i !== null && i < len - 1 ? i + 1 : i\n    })\n  }, [])\n\n  useEffect(() => {\n    if (selectedIndex === null) return\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') close()\n    }\n    window.addEventListener('keydown', onKey)\n    return () => window.removeEventListener('keydown', onKey)\n  }, [selectedIndex, close])\n\n  /** Open the lightbox on `items[index]`, flying in from `el` (the clicked thumbnail/slide).\n   *  Pass the click event (or its shiftKey/detail) so slow-mo and keyboard modality register. */\n  const open = useCallback((index: number, el?: HTMLElement | null, e?: { shiftKey?: boolean; detail?: number }) => {\n    const item = itemsRef.current[index]\n    if (!item) return\n    setSlowMo(!!e?.shiftKey)\n    // A keyboard-activated button fires click with detail 0 — that's a keyboard open.\n    keyboardModeRef.current = (e?.detail ?? 1) === 0\n    scrollPosRef.current = { x: window.scrollX, y: window.scrollY }\n    const data: Partial<DKLightboxItem> = {}\n    if (el) {\n      setOriginRect(el.getBoundingClientRect())\n      // Hidden on the lightbox's onFlightPainted signal (below), not at click, so the origin\n      // never blinks out before its flying copy has painted.\n      originElRef.current = el\n      const video = el.querySelector('video') ?? (el instanceof HTMLVideoElement ? el : null)\n      if (item.videoSrc && video) {\n        // The fly-in clone resumes playing from this exact frame.\n        data.videoTime = video.currentTime || 0\n        // Freeze the exact on-screen frame as a data-URL poster for the fly-in clone. A brand-new\n        // <video> needs metadata + a seek before it paints ANYTHING, and during that ~100-500ms gap\n        // the page fade was already dimming the origin — the clip visibly blinked out, then\n        // reappeared. The canvas snapshot paints with the clone's first commit instead. Same-origin\n        // clips only; a tainted canvas just skips the poster and falls back to the paint signal.\n        // ONLY the clicked clip: this is a synchronous drawImage + toDataURL JPEG encode (~15ms)\n        // inside the click handler — doing it for every clip on a page blocked the main thread long\n        // enough to eat the open spring's first frames.\n        try {\n          if (video.readyState >= 2 && video.videoWidth) {\n            const c = document.createElement('canvas')\n            c.width = video.videoWidth\n            c.height = video.videoHeight\n            c.getContext('2d')?.drawImage(video, 0, 0)\n            data.flightPoster = c.toDataURL('image/jpeg', 0.85)\n          }\n        } catch { /* cross-origin frame — no poster */ }\n        // Freeze other playing clips in this opener's scope during the open.\n        const scope = el.closest('[data-dk-scope]') ?? document\n        const playing = Array.from(scope.querySelectorAll<HTMLVideoElement>('video')).filter((v) => !v.paused)\n        playing.forEach((v) => v.pause())\n        pausedRef.current = playing\n      } else {\n        // The exact file the origin <img> already decoded — the lightbox reuses it verbatim so\n        // the fly-in and the slide's base layer paint instantly from cache, instead of\n        // re-fetching at a fresh size (the cold-start \"modal first, image later\" gap).\n        const img = el.querySelector('img')\n        data.flightSrc = img?.currentSrc || undefined\n      }\n    } else {\n      setOriginRect(null)\n    }\n    captureRef.current = { index, data }\n    setSelectedIndex(index)\n  }, [])\n\n  // Deep-link: ?photo=<index> opens that item's lightbox on load. Uses the thumbnail's rect as\n  // the zoom origin, then strips the param so a later refresh/back doesn't reopen it.\n  useEffect(() => {\n    if (!optionsRef.current.deepLink) return\n    const raw = new URLSearchParams(window.location.search).get('photo')\n    if (raw === null) return\n    const idx = Number(raw)\n    if (!Number.isInteger(idx) || idx < 0 || idx >= itemsRef.current.length) return\n    const el = optionsRef.current.getOriginEl?.(idx) ?? null\n    el?.scrollIntoView({ block: 'center' })\n    open(idx, el)\n    const url = new URL(window.location.href)\n    url.searchParams.delete('photo')\n    window.history.replaceState(null, '', url.pathname + url.search + url.hash)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  // While an item is open, warm the hi-res variant of nearby items so navigation is instant and\n  // always sharp — next first, then +2, +3, then the previous one. Held off ~500ms so these\n  // background loads land AFTER the open/nav animation, not during it (five big images decoding\n  // at once jitters the spring). The timeout is cleared on fast re-navigation, so we only warm\n  // around where the viewer actually settles.\n  useEffect(() => {\n    if (selectedIndex === null) return\n    const getHiResSrc = optionsRef.current.getHiResSrc\n    if (!getHiResSrc) return\n    const t = window.setTimeout(() => {\n      const all = itemsRef.current\n      for (const i of [selectedIndex + 1, selectedIndex + 2, selectedIndex + 3, selectedIndex - 1]) {\n        const p = all[i]\n        if (!p || p.videoSrc) continue\n        const hi = getHiResSrc(p)\n        if (!hi || hi === p.src || prefetchedHiRes.current.has(hi)) continue\n        prefetchedHiRes.current.add(hi)\n        const img = new window.Image()\n        img.src = hi\n      }\n    }, 500)\n    return () => window.clearTimeout(t)\n  }, [selectedIndex])\n\n  const toLightboxItem = useCallback((i: number): DKLightboxItem => {\n    const base = itemsRef.current[i]\n    const cap = captureRef.current\n    return {\n      ...base,\n      // Package default: clips in the modal get the 2px accent progress bar.\n      videoProgress: !!base.videoSrc,\n      ...(cap && cap.index === i ? cap.data : null),\n    }\n  }, [])\n\n  const selected = selectedIndex !== null ? items[selectedIndex] : null\n  const hasCaptions =\n    options.hasCaptions ?? items.some((p) => p.caption || formatExif(p.exif))\n\n  const lightbox = selected && selectedIndex !== null\n    ? createPortal(\n        // Portaled to <body> (not rendered inline) so it's a sibling of the page content: the\n        // lightbox inerts + hides those siblings while open (Safari toolbar fix + real modality).\n        // The package ships its own LazyMotion so `m` components work without host setup.\n        <LazyMotion features={domAnimation}>\n          <DKLightbox\n            item={toLightboxItem(selectedIndex)}\n            index={selectedIndex}\n            total={items.length}\n            onClose={close}\n            onPrev={prev}\n            onNext={next}\n            originRect={originRect}\n            slowMo={slowMo}\n            hasCaptions={hasCaptions}\n            showOutline={options.showOutline ?? true}\n            getHiResSrc={options.getHiResSrc}\n            onFlightPainted={() => {\n              if (originElRef.current) originElRef.current.style.visibility = 'hidden'\n            }}\n            onSettled={() => {\n              // The modal fully covers the page now — put the tapped element back so a\n              // swipe-down dismiss reveals it instead of a blank hole. close() re-clearing\n              // visibility is a harmless no-op.\n              if (originElRef.current) originElRef.current.style.visibility = ''\n            }}\n            prevItem={selectedIndex > 0 ? toLightboxItem(selectedIndex - 1) : null}\n            nextItem={selectedIndex < items.length - 1 ? toLightboxItem(selectedIndex + 1) : null}\n          />\n        </LazyMotion>,\n        document.body,\n      )\n    : null\n\n  return { open, close, isOpen: selectedIndex !== null, activeIndex: selectedIndex, lightbox }\n}\n",
      "type": "registry:component",
      "target": "components/dk-media-viewer/dk-lightbox.tsx"
    },
    {
      "path": "registry/dk-media-viewer/dk-carousel.tsx",
      "content": "'use client'\n\n/* DKCarousel — a crossfading slideshow over the same items the grid takes.\n *\n *   <DKCarousel items={items} ratio=\"3 / 2\" />\n *\n * Images are stacked; the outgoing slide animates opacity + a small blur (a \"blur-dissolve\"),\n * both GPU-composited (safe for photos; the subpixel-AA caveat that bars opacity-animating TEXT\n * does not apply to images). The frame carries a fixed aspect-ratio so it reserves its space and\n * never shifts layout. Auto-advance stops for `prefers-reduced-motion` and while the tab is\n * hidden. Clicking a slide opens the shared lightbox over the whole set — navigable with the\n * same arrows, captions and EXIF included.\n *\n * The fade is one-directional to avoid the crossfade \"dip\": the incoming slide snaps fully\n * opaque UNDERNEATH, and only the OUTGOING slide fades out on top of it. If both faded at once,\n * the midpoint (both ~50% transparent) would let the page show through and read as a flash. So\n * the outgoing frame carries the transition and the higher z-index; the incoming one just sits\n * there solid, revealed as the old one dissolves away. */\n\nimport { useEffect, useRef, useState } from 'react'\nimport type { DKMediaItem } from './types'\nimport { useDKLightbox } from './dk-lightbox'\n\nexport interface DKCarouselProps {\n  items: DKMediaItem[]\n  /** The frame's CSS aspect-ratio, e.g. \"3 / 2\", \"16 / 9\". */\n  ratio?: string\n  /** Auto-advance dwell per slide, in ms. */\n  interval?: number\n  /** Extra classes on the outer wrapper. */\n  className?: string\n  /** A sharper large variant for the lightbox — see DKMediaViewer. */\n  getHiResSrc?: (item: DKMediaItem) => string\n  /** The 1px photo edge outline in the lightbox. Default off here — carousels often carry\n   *  screenshots and full-bleed art where it reads as a stray border. */\n  showOutline?: boolean\n}\n\nexport function DKCarousel({\n  items,\n  ratio = '3 / 2',\n  interval = 5000,\n  className,\n  getHiResSrc,\n  showOutline = false,\n}: DKCarouselProps) {\n  const [active, setActive] = useState(0)\n  // The slide that should fade OUT: whatever was active before this render's change. A ref\n  // updated in an effect holds the previous committed index — during the render where `active`\n  // just changed, it still points at the outgoing slide, which is exactly what we want to fade.\n  const prevRef = useRef(0)\n  useEffect(() => {\n    prevRef.current = active\n  }, [active])\n  const prev = prevRef.current\n\n  const slideEls = useRef<Map<number, HTMLButtonElement>>(new Map())\n  const { open, isOpen, lightbox } = useDKLightbox(items, {\n    getHiResSrc,\n    showOutline,\n    getOriginEl: (i) => slideEls.current.get(i) ?? null,\n    // Land the carousel on the slide the viewer navigated to in the lightbox, so the close\n    // fly-back and the focus restore both target the slide that is actually visible.\n    onClose: (i) => setActive(i),\n  })\n\n  // The effect depends on `active`, so it re-arms the timeout every advance — which also means\n  // a manual jump (a dot click that sets `active`) resets the dwell, keeping the progress pill\n  // in sync with the timer. No autoplay for reduced-motion users; the tab-hidden pause avoids\n  // a burst of queued advances landing at once on return. Paused while the lightbox is open —\n  // the viewer is looking at THIS set enlarged; the deck shouldn't shuffle under them.\n  useEffect(() => {\n    if (items.length < 2 || isOpen) return\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return\n    let timer: ReturnType<typeof setTimeout>\n    const schedule = () => {\n      timer = setTimeout(() => setActive((i) => (i + 1) % items.length), interval)\n    }\n    const onVisibility = () => {\n      clearTimeout(timer)\n      if (!document.hidden) schedule()\n    }\n    if (!document.hidden) schedule()\n    document.addEventListener('visibilitychange', onVisibility)\n    return () => {\n      clearTimeout(timer)\n      document.removeEventListener('visibilitychange', onVisibility)\n    }\n  }, [active, items.length, interval, isOpen])\n\n  if (items.length === 0) return null\n\n  return (\n    <div data-dk-scope className={`dk-scope w-full${className ? ` ${className}` : ''}`}>\n      <style>{`\n        /* Animate transform, not width: a width animation relayouts the pill every frame (main\n           thread) and reads as stepping; scaleX runs on the compositor and stays smooth. */\n        @keyframes dk-ss-progress { from { transform: scaleX(0) } to { transform: scaleX(1) } }\n        .dk-ss-fill { width: 100%; transform-origin: left; }\n        @media (prefers-reduced-motion: no-preference) {\n          .dk-ss-fill { transform: scaleX(0); animation: dk-ss-progress var(--dk-ss-dur, 5000ms) linear forwards; }\n        }\n      `}</style>\n      <div className=\"relative w-full overflow-hidden\" style={{ aspectRatio: ratio }}>\n        {items.map((item, i) => {\n          const isActive = i === active\n          // Only the outgoing slide animates (fades to 0) and sits on top; the incoming slide\n          // snaps to full opacity beneath it. `prev !== active` guards the first paint, where\n          // there is no previous slide.\n          const isPrev = i === prev && prev !== active\n          return (\n            // Each slide opens the shared lightbox — and because every slide is registered, the\n            // lightbox gallery is the whole carousel, navigable with the same arrows. Only the\n            // active slide is interactive: the stacked inactive slides sit at pointer-events:none\n            // (and out of the tab order) so a click can't land on the top-of-stack image instead\n            // of the visible one. The crossfade rides the button's opacity.\n            <button\n              key={item.src}\n              type=\"button\"\n              ref={(el) => {\n                if (el) slideEls.current.set(i, el)\n                else slideEls.current.delete(i)\n              }}\n              onClick={(e) => open(i, e.currentTarget, e)}\n              aria-label={item.alt ? `Enlarge: ${item.alt}` : 'View enlarged'}\n              aria-hidden={!isActive}\n              tabIndex={isActive ? 0 : -1}\n              className={`absolute inset-0 block cursor-zoom-in ease-out focus-visible:outline-none${\n                isPrev ? ' transition-[opacity,filter] duration-700 motion-reduce:transition-none' : ''\n              }`}\n              style={{\n                opacity: isActive ? 1 : 0,\n                // Blur-dissolve: the outgoing slide softens as it leaves, so the departure reads as\n                // a cinematic dissolve rather than a flat opacity ramp. The incoming slide stays\n                // sharp and solid beneath (see the one-way fade note above), so there's no dip.\n                filter: isPrev ? 'blur(6px)' : 'none',\n                // Outgoing on top (it does the fading); incoming just beneath, solid; rest behind.\n                zIndex: isPrev ? 20 : isActive ? 10 : 0,\n                pointerEvents: isActive ? 'auto' : 'none',\n              }}\n            >\n              <img\n                src={item.src}\n                alt={item.alt ?? ''}\n                className=\"absolute inset-0 h-full w-full object-cover\"\n                decoding=\"async\"\n                draggable={false}\n              />\n            </button>\n          )\n        })}\n\n        {items.length > 1 && (\n          // bottom-0: dots centred in a 24px hit area (8px padding) land the dot 8px above the base.\n          // z-30 keeps the dots above the slides, which carry z-index (10/20) for the one-way fade.\n          <div className=\"absolute inset-x-0 bottom-0 z-30 flex items-center justify-center gap-1\">\n            {items.map((item, i) => {\n              const isActive = i === active\n              return (\n                <button\n                  key={item.src}\n                  type=\"button\"\n                  aria-label={`Show ${item.alt ?? `slide ${i + 1}`}`}\n                  aria-current={isActive}\n                  onClick={() => setActive(i)}\n                  // The dot stays small, but the button is a taller/padded hit target so it is\n                  // easy to click, with a pointer cursor and a hover brighten so it reads as one.\n                  className=\"group/dot flex h-6 cursor-pointer items-center px-0.5\"\n                >\n                  <span\n                    className=\"relative block h-2 overflow-hidden rounded-full transition-[width,filter] duration-300 ease-out group-hover/dot:brightness-150\"\n                    style={{\n                      width: isActive ? 30 : 8,\n                      backgroundColor: isActive ? 'rgba(255,255,255,0.4)' : 'rgba(255,255,255,0.55)',\n                    }}\n                  >\n                    {/* The active pill's fill sweeps over the dwell time. Keyed by `active` so\n                        React remounts it each advance, restarting the CSS animation from 0. */}\n                    {isActive && (\n                      <span\n                        key={active}\n                        className=\"dk-ss-fill absolute inset-y-0 left-0 rounded-full bg-white\"\n                        style={{ ['--dk-ss-dur' as string]: `${interval}ms` }}\n                      />\n                    )}\n                  </span>\n                </button>\n              )\n            })}\n          </div>\n        )}\n      </div>\n      {items[active].caption && (\n        <p className=\"mt-3 text-center font-[family-name:var(--dk-font-sans)] text-[0.95rem] leading-snug text-[var(--dk-muted)]\">\n          {items[active].caption}\n        </p>\n      )}\n      {lightbox}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/dk-media-viewer/dk-carousel.tsx"
    },
    {
      "path": "registry/dk-media-viewer/types.ts",
      "content": "/* DKMediaViewer — the data contract.\n *\n * One item = one photo or one video. Hand-write the array, generate it with\n * `npx @diklein/dkmediaviewer scan ./public/photos`, or map it from your CMS — the viewer\n * only ever sees this shape. Everything beyond `src` is optional and degrades\n * cleanly: no exif → no spec line, no caption → no caption well, no dimensions\n * → the layout measures the rendered element instead. */\n\nexport interface DKExif {\n  make?: string | null\n  model?: string | null\n  /** Shutter as the photographer says it: \"1/80\", \"0.5\", \"30\". */\n  exposureTime?: string | null\n  /** Aperture without the ƒ: \"1.6\", \"8\". */\n  aperture?: string | null\n  /** Focal length in mm, without the unit: \"35\", \"23\". */\n  focalLength?: string | null\n  iso?: number | null\n}\n\nexport interface DKMediaItem {\n  /** The image URL — or, for a video, its poster frame. */\n  src: string\n  /** Marks the item as a video and gives the clip to play (mp4/webm URL). */\n  videoSrc?: string\n  alt?: string\n  /** Shown in the lightbox caption well (and read to assistive tech when alt is absent). */\n  caption?: string\n  /** Intrinsic pixels. Optional, but supplying them avoids layout shift in the grid. */\n  width?: number\n  height?: number\n  /** Average/dominant color as #rrggbb — used for the blur-up placeholder. */\n  color?: string\n  /** Camera settings for the mono spec line under the caption. */\n  exif?: DKExif\n}\n\nfunction toTitleCase(str: string): string {\n  return str.toLowerCase().replace(/\\b\\w/g, (c) => c.toUpperCase())\n}\n\n/** EXIF `Make` is often the full legal name (\"NIKON CORPORATION\", \"CASIO COMPUTER CO.,LTD.\").\n *  Strip the legal-entity tail(s) so the spec line reads like a photographer, not a filing:\n *  \"Nikon D610\", never \"Nikon Corporation D610\". */\nfunction cleanMake(raw: string): string {\n  let make = toTitleCase(raw)\n  const legal = /[\\s,]*(Corporation|Corp|Company|Co|Ltd|Inc|Gmbh|K\\.K)\\.?,?$/i\n  while (legal.test(make)) make = make.replace(legal, '')\n  return make\n}\n\n/** \"Ricoh GR IIIx · 26.1mm · ƒ/2.8 · 1/500s · ISO 200\" — omits whatever is missing.\n *  Drops a leading brand word from the model that just repeats the make (make \"Ricoh\",\n *  model \"RICOH GR IIIx\" → \"GR IIIx\"), so the camera never reads doubled. */\nexport function formatExif(exif?: DKExif | null): string {\n  if (!exif) return ''\n  const parts: string[] = []\n  const make = exif.make ? cleanMake(exif.make) : null\n  let model = exif.model ?? null\n  if (make && model) {\n    model = model.replace(new RegExp(`^${make.split(/[\\s,]/)[0]}\\\\s+`, 'i'), '') || null\n  }\n  if (make || model) parts.push([make, model].filter(Boolean).join(' '))\n  if (exif.focalLength) parts.push(`${exif.focalLength}mm`)\n  if (exif.aperture) parts.push(`ƒ/${exif.aperture}`)\n  if (exif.exposureTime) parts.push(`${exif.exposureTime}s`)\n  if (exif.iso) parts.push(`ISO ${exif.iso}`)\n  return parts.join(' · ')\n}\n",
      "type": "registry:lib",
      "target": "components/dk-media-viewer/types.ts"
    },
    {
      "path": "registry/dk-media-viewer/dk-media-viewer.css",
      "content": "/* DKMediaViewer — theme tokens + the handful of rules Tailwind utilities can't express.\n *\n * Every color routes through a --dk-* variable so theming is one override away:\n *\n *   .dk-scope { --dk-accent: rebeccapurple; }\n *\n * Dark mode follows the shadcn convention (a `.dark` class on <html>) and falls back to\n * prefers-color-scheme when no theme class is in play. Fonts default to the system stacks;\n * point --dk-font-sans / --dk-font-mono at your own faces to match your site. */\n\n.dk-scope {\n  --dk-bg: #ffffff;\n  --dk-fg: #1a1a1a;\n  --dk-muted: #6b6b6b;\n  --dk-surface: rgb(0 0 0 / 0.05);\n  --dk-accent: #e03131;\n  --dk-outline: rgb(0 0 0 / 0.1);\n  --dk-font-sans: ui-sans-serif, system-ui, sans-serif;\n  --dk-font-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace;\n}\n\n.dark .dk-scope {\n  --dk-bg: #101216;\n  --dk-fg: #ededed;\n  --dk-muted: #9a9a9a;\n  --dk-surface: rgb(255 255 255 / 0.08);\n  --dk-outline: rgb(255 255 255 / 0.1);\n}\n\n/* No theme class anywhere up the tree? Track the OS. (:where() keeps specificity at zero so\n   the .dark rule above always wins when present.) */\n@media (prefers-color-scheme: dark) {\n  :where(html:not(.light) .dk-scope) {\n    --dk-bg: #101216;\n    --dk-fg: #ededed;\n    --dk-muted: #9a9a9a;\n    --dk-surface: rgb(255 255 255 / 0.08);\n    --dk-outline: rgb(255 255 255 / 0.1);\n  }\n}\n\n/* A hairline edge INSIDE the photo (outline, not border, so it never adds layout size) —\n   keeps near-white photos from dissolving into a light page, and near-black ones into dark. */\n.dk-img-outline {\n  outline: 1px solid var(--dk-outline);\n  outline-offset: -1px;\n}\n\n/* Loading placeholder: a quiet tint with a translucent sweep. */\n.dk-shimmer {\n  position: relative;\n  overflow: hidden;\n  background: color-mix(in oklab, var(--dk-fg) 4%, transparent);\n}\n.dk-shimmer::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  transform: translateX(-100%);\n  background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--dk-fg) 6%, transparent), transparent);\n  animation: dk-shimmer-sweep 1.6s ease-in-out infinite;\n}\n@media (prefers-reduced-motion: reduce) {\n  .dk-shimmer::after {\n    animation: none;\n  }\n}\n@keyframes dk-shimmer-sweep {\n  100% {\n    transform: translateX(100%);\n  }\n}\n\n/* Grid perf: skip rendering off-screen rows until they approach the viewport. */\n.dk-cv-auto {\n  content-visibility: auto;\n  contain-intrinsic-size: auto 320px;\n}\n\n/* Lightbox bottom dock — 8px bottom padding on mobile (never less than the device's\n   safe-area inset), a touch more on desktop. */\n.dk-rail-foot {\n  padding-bottom: max(0.5rem, env(safe-area-inset-bottom));\n}\n@media (min-width: 56.25rem) {\n  .dk-rail-foot {\n    padding-bottom: 0.75rem;\n  }\n}\n\n/* Dock entrance. Pure CSS on purpose: a motion-managed transform gets re-applied on\n   every re-render, and any transform/will-change on an ANCESTOR of the glass card\n   makes that ancestor the backdrop root — the card's blur flat-tints (see the card\n   recipe below). The class therefore goes on the dock's CHILDREN (arrows + the card\n   itself), rising in lockstep: an element's OWN transform never breaks its backdrop\n   sampling, so the glass blurs LIVE for the whole ride. All children travel the same\n   fixed distance (the dock's published --dk-dock-h, plus some slack) so they move as\n   one unit; backwards fill holds them below the fold through the delay and evaporates\n   on the last frame. The lightbox removes the class once the entrance ends, so\n   per-item remounts of the caption card never replay it. Slow-mo stretches it via\n   --dk-dock-rise-dur/-delay on the dock.\n   The linear() easing is the house spring itself — x(t) for stiffness 620, damping 36,\n   mass 0.7 sampled over 500ms — so the rise shares the fly-in's physics, decel tail\n   and all. The cubic-bezier declaration is the fallback for browsers without linear(). */\n@keyframes dk-dock-rise {\n  from { transform: translateY(calc(var(--dk-dock-h, 96px) * 1.15)); }\n  to { transform: translateY(0); }\n}\n.dk-dock-enter {\n  animation: dk-dock-rise var(--dk-dock-rise-dur, 240ms) cubic-bezier(0.34, 1.3, 0.64, 1) var(--dk-dock-rise-delay, 80ms) backwards;\n  animation: dk-dock-rise var(--dk-dock-rise-dur, 500ms) linear(0, 0.1346, 0.3785, 0.6041, 0.7719, 0.8815, 0.9461, 0.9806, 0.9968, 1.0031, 1.0046, 1.004, 1.0029, 1.0019, 1.0011, 1.0006, 1.0003, 1.0001, 1, 1, 1, 1, 1, 1, 1) var(--dk-dock-rise-delay, 80ms) backwards;\n}\n\n/* Lightbox caption card. Desktop ON the photo: HIG thin-material-dark (black-tinted\n   blur, boosted saturation, hairline ring) because the photo fills the height and the\n   card sits over it. CONSTRAINT there: no ancestor of the card may carry opacity < 1\n   or a transform (it becomes the backdrop root and the blur flat-tints). Off the photo\n   — and always on mobile, where the photo rarely reaches the dock — the material comes\n   off and the text speaks in theme ink. --dk-cap-fg/--dk-cap-muted flip with the\n   material; the [data-on-photo] attribute is set by the lightbox's geometry check. */\n.dk-caption-card {\n  --dk-cap-fg: var(--dk-fg);\n  --dk-cap-muted: var(--dk-muted);\n}\n@media (min-width: 640px) {\n  .dk-caption-card[data-on-photo] {\n    --dk-cap-fg: rgba(245, 245, 245, 0.95);\n    --dk-cap-muted: rgba(245, 245, 245, 0.58);\n    background: rgba(0, 0, 0, 0.42);\n    backdrop-filter: blur(20px) saturate(1.8);\n    -webkit-backdrop-filter: blur(20px) saturate(1.8);\n    box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.15);\n  }\n}\n",
      "type": "registry:file",
      "target": "components/dk-media-viewer/dk-media-viewer.css"
    }
  ],
  "type": "registry:component"
}