{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fiche-step-repeat",
  "title": "Fiche Step Repeat",
  "description": "A thumbnail grid that populates like a microfiche step-and-repeat camera: one cell exposed per raster step with a brief flash, then an index strip types in once the sheet is complete.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/fiche-step-repeat/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FicheStepRepeat — a thumbnail grid that populates the way a COM (computer\n// output microfilm) step-and-repeat camera exposed a microfiche sheet: one\n// frame at a time, strict raster order (left-to-right, then down a row), a\n// short exposure flash on arrival, and a typed index strip once the sheet is\n// complete. The mechanical step between frames is genuinely the slow part —\n// the flash itself is brief — so growth is paced at one cell per STEP_MS,\n// with an explicit FLASH_UP/FLASH_DOWN envelope on top of every arrival\n// rather than a plain fade-in (the real process is a discrete exposure, not\n// a dissolve).\n//\n// Grid geometry: cols/rows are derived from the container's own width and\n// height divided by a ~64px target cell, clamped to a 4x3 minimum and a\n// 7x5 maximum — the closest card-scale abstraction of a real fiche's\n// row/column raster (full sheets run 7x14 or denser; this keeps a card\n// legible). One rAF loop drives the whole cycle from a single elapsed-time\n// clock (grow -> type -> hold -> reset), touching the DOM directly per\n// cell rather than through React state, since redrawing 12-35 tiny cells\n// through render on every animation frame would be wasted work.\n// ---------------------------------------------------------------------------\n\nconst STEP_MS = 850; // one raster step: mechanical pitch between exposures\nconst FLASH_UP_MS = 120; // exposure flash ramp to peak\nconst FLASH_DOWN_MS = 280; // flash relaxes back to the cell's rest luminance\nconst FLASH_MS = FLASH_UP_MS + FLASH_DOWN_MS;\nconst TYPE_MS = 600; // index strip typing window\nconst TYPE_CHAR_MS = 17; // ~one character reveal every 17ms\nconst HOLD_MS = 1400; // full-sheet hold once the index strip finishes\nconst RESET_MS = 400; // raster-order step back to blank\nconst FLASH_AMP_DARK = 0.12; // +12% luminance over settled value (dark theme)\nconst FLASH_AMP_LIGHT = 0.08; // less headroom against near-white thumbnails\nconst TARGET_CELL_PX = 64;\nconst MIN_COLS = 4;\nconst MAX_COLS = 7;\nconst MIN_ROWS = 3;\nconst MAX_ROWS = 5;\n\nexport interface FicheFrame {\n  /** thumbnail image URL for this raster position */\n  src: string;\n  /** accessible description of the frame */\n  alt: string;\n}\n\nexport interface FicheStepRepeatProps {\n  /** real thumbnails placed in raster order; cells beyond the supplied count\n   * render the built-in placeholder silhouette */\n  images?: FicheFrame[];\n  /** reduction ratio printed on the index strip, e.g. 24 for \"24x\" */\n  reductionRatio?: number;\n  /** extra classes merged onto the root element */\n  className?: string;\n}\n\nfunction FrameSilhouette() {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      aria-hidden=\"true\"\n      className=\"h-2/5 w-2/5 text-ns-muted opacity-40\"\n    >\n      <rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" />\n      <circle cx=\"8\" cy=\"8.5\" r=\"2\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" />\n      <path\n        d=\"M3 17.5l5-5a1.4 1.4 0 0 1 2 0l4.5 4.5 1.5-1.5a1.4 1.4 0 0 1 2 0L21 18\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function FicheStepRepeat({ images = [], reductionRatio = 24, className = \"\" }: FicheStepRepeatProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const cellRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const headerRef = useRef<HTMLParagraphElement>(null);\n  const [grid, setGrid] = useState({ cols: MAX_COLS, rows: MAX_ROWS });\n  const headerId = useId();\n\n  const cellCount = grid.cols * grid.rows;\n  const label = `${reductionRatio}× · ${cellCount} FRAMES`;\n\n  // Derive the raster grid from the container's own dimensions — the\n  // smaller axis governs how coarse a \"still reads at card scale\" grid is,\n  // but each axis gets its own count so a wide card doesn't get squeezed\n  // into a square.\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const measure = () => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) return;\n      const cols = Math.min(MAX_COLS, Math.max(MIN_COLS, Math.round(rect.width / TARGET_CELL_PX)));\n      const rows = Math.min(MAX_ROWS, Math.max(MIN_ROWS, Math.round(rect.height / TARGET_CELL_PX)));\n      setGrid((prev) => (prev.cols === cols && prev.rows === rows ? prev : { cols, rows }));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const header = headerRef.current;\n    if (!header) return;\n\n    const cells = cellRefs.current.slice(0, cellCount);\n    const growEnd = cellCount * STEP_MS;\n    const typeEnd = growEnd + TYPE_MS;\n    const holdEnd = typeEnd + HOLD_MS;\n    const totalCycle = holdEnd + RESET_MS;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // Theme flip only ever swaps the `.dark` class — track it live so the\n    // flash amplitude reads correctly without a remount.\n    let flashAmp = document.documentElement.classList.contains(\"dark\") ? FLASH_AMP_DARK : FLASH_AMP_LIGHT;\n    const mo = new MutationObserver(() => {\n      flashAmp = document.documentElement.classList.contains(\"dark\") ? FLASH_AMP_DARK : FLASH_AMP_LIGHT;\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const populated = new Array(cellCount).fill(false);\n    let lastFlashCell = -1;\n    let lastTypedChars = -1;\n\n    const setFilled = (i: number, filled: boolean) => {\n      if (populated[i] === filled) return;\n      populated[i] = filled;\n      const el = cells[i];\n      if (!el) return;\n      if (filled) el.dataset.filled = \"true\";\n      else delete el.dataset.filled;\n    };\n\n    const clearFlash = (i: number) => {\n      const el = cells[i];\n      if (el) el.style.removeProperty(\"filter\");\n    };\n\n    const setHeaderText = (text: string) => {\n      if (lastTypedChars === text.length && header.textContent === text) return;\n      header.textContent = text;\n      lastTypedChars = text.length;\n    };\n\n    // Reduced motion: freeze on the full-sheet hold frame — every cell\n    // populated at rest, index strip fully typed, no flash, no loop.\n    if (reduced) {\n      for (let i = 0; i < cellCount; i++) setFilled(i, true);\n      setHeaderText(label);\n      return () => mo.disconnect();\n    }\n\n    let raf = 0;\n    let cycleStart = 0;\n    let visible = true;\n\n    const tick = (now: number) => {\n      if (!visible) return;\n      if (cycleStart === 0) cycleStart = now;\n      let elapsed = now - cycleStart;\n      if (elapsed >= totalCycle) {\n        cycleStart += totalCycle;\n        elapsed -= totalCycle;\n      }\n\n      if (elapsed < growEnd) {\n        const idx = Math.min(cellCount - 1, Math.floor(elapsed / STEP_MS));\n        for (let i = 0; i <= idx; i++) setFilled(i, true);\n        const localT = elapsed - idx * STEP_MS;\n        if (localT < FLASH_MS) {\n          const p = localT < FLASH_UP_MS ? localT / FLASH_UP_MS : 1 - (localT - FLASH_UP_MS) / FLASH_DOWN_MS;\n          const b = 1 + p * flashAmp;\n          const el = cells[idx];\n          if (el) el.style.filter = `brightness(${b.toFixed(3)})`;\n          lastFlashCell = idx;\n        } else if (lastFlashCell === idx) {\n          clearFlash(idx);\n          lastFlashCell = -1;\n        }\n        if (lastTypedChars !== 0) setHeaderText(\"\");\n      } else if (elapsed < typeEnd) {\n        if (lastFlashCell !== -1) {\n          clearFlash(lastFlashCell);\n          lastFlashCell = -1;\n        }\n        const typed = Math.min(label.length, Math.floor(((elapsed - growEnd) / TYPE_MS) * label.length));\n        setHeaderText(label.slice(0, typed));\n      } else if (elapsed < holdEnd) {\n        setHeaderText(label);\n      } else {\n        if (lastTypedChars !== 0) setHeaderText(\"\");\n        const t = elapsed - holdEnd;\n        const removed = Math.min(cellCount, Math.floor((t / RESET_MS) * cellCount));\n        for (let i = 0; i < removed; i++) setFilled(i, false);\n      }\n\n      raf = requestAnimationFrame(tick);\n    };\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible) {\n        // resume without a discontinuity: nudge cycleStart so elapsed picks\n        // up exactly where it left off, then re-arm the loop.\n        cycleStart = 0;\n        if (!raf) raf = requestAnimationFrame((now) => {\n          cycleStart = now;\n          raf = requestAnimationFrame(tick);\n        });\n      }\n    });\n    io.observe(rootRef.current ?? header);\n\n    raf = requestAnimationFrame(tick);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n      mo.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [cellCount, label]);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"list\"\n      aria-labelledby={headerId}\n      className={`relative flex h-full w-full flex-col gap-2 ${className}`}\n    >\n      <p id={headerId} className=\"sr-only\">\n        Thumbnail sheet, {label}\n      </p>\n      <p\n        ref={headerRef}\n        aria-hidden=\"true\"\n        className=\"h-3.5 shrink-0 whitespace-nowrap font-mono text-[11px] tracking-widest text-ns-muted\"\n      />\n      <div\n        className=\"grid min-h-0 flex-1 gap-1.5\"\n        style={{ gridTemplateColumns: `repeat(${grid.cols}, 1fr)`, gridTemplateRows: `repeat(${grid.rows}, 1fr)` }}\n      >\n        {Array.from({ length: cellCount }, (_, i) => {\n          const frame = images[i];\n          return (\n            <div\n              key={i}\n              role=\"listitem\"\n              className=\"relative overflow-hidden rounded-[1px] border border-border\"\n            >\n              <div\n                ref={(el) => {\n                  cellRefs.current[i] = el;\n                }}\n                className=\"absolute inset-0 flex items-center justify-center bg-surface opacity-0 outline-none transition-opacity duration-0 data-[filled=true]:opacity-100\"\n                style={{ willChange: \"filter\" }}\n              >\n                {frame ? (\n                  <img\n                    src={frame.src}\n                    alt={frame.alt}\n                    className=\"h-full w-full object-cover transition-[filter] duration-150 hover:brightness-105 focus-visible:brightness-105\"\n                    draggable={false}\n                  />\n                ) : (\n                  <FrameSilhouette />\n                )}\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/fiche-step-repeat.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "gallery",
      "grid",
      "thumbnail",
      "loading",
      "raster",
      "micro-interaction"
    ],
    "instruction": "Build <FicheStepRepeat images? reductionRatio? className?> as a card-scale DOM grid (CSS grid, no canvas) modeling a COM (computer output microfilm) step-and-repeat camera exposing a fiche sheet. Grid geometry: cols = clamp(round(containerWidth / 64px), 4, 7), rows = clamp(round(containerHeight / 64px), 3, 5), measured via a ResizeObserver on the root and re-derived on every resize, giving the closest card-scale abstraction of a real 7x14 fiche raster. A single requestAnimationFrame loop, driven off one elapsed-time clock from cycle start, walks four phases in order and writes DOM directly per cell (no React state per frame — 12 to 35 tiny cells re-rendering through React every frame is wasted work): (1) GROW — cells populate strictly in raster order (row-major, left to right then down), one cell every 850ms; the instant a cell's turn arrives it snaps from zero-opacity/no-geometry to full geometry in the same frame (a real exposure, never a fade-in), and only its luminance animates on top via an inline CSS filter:brightness() ramp — 120ms up to a peak of settled-value+12% (dark theme) or +8% (light theme, less headroom against near-white thumbnails; the amplitude is read live off a MutationObserver on documentElement's class so a theme flip mid-loop takes effect without a remount), then 280ms relaxing back to brightness(1); (2) TYPE — once every cell has populated, a header string like \"24× · 35 FRAMES\" (reductionRatio × cellCount) types in left to right over a fixed 600ms window, monospace, character-by-character; (3) HOLD — the complete sheet (all cells populated at rest, full header text) holds for 1.4s; (4) RESET — the header clears instantly and cells blank out in the SAME raster order they filled in (not reversed, not faded), spread evenly across a fixed 400ms regardless of cell count, each individual cell's removal an instant snap — this mirrors nothing in the real process (which never runs backward) and exists purely so the loop's restart reads as \"the same sequence, undone\" instead of a jump cut. The whole cycle then repeats unbounded. Each cell is two nested elements: an outer box with a `border border-border` outline and no fill, always visible even when empty (a bare separator-token box, never treated as a fill or stroke color), and an inner content layer holding either a caller-supplied `<img>` (from the `images` prop, an array of {src, alt} placed in raster order; cells beyond the supplied count get the placeholder) or a built-in placeholder silhouette (a faint outlined SVG frame/mountain glyph in text-ns-muted at low opacity) — that inner layer is what actually snaps from opacity 0 to opacity 100 the frame its cell is populated, and what the JS-driven brightness filter targets during its flash window. Hover/focus on a real image cell brightens it slightly via a plain CSS transition on the image itself (luminance only, never --ns-accent, never mixed into the highlight) purely to confirm it's a real target — it must never restart, pause, or otherwise perturb the raster cadence, which runs autonomously regardless of pointer input. Root carries role=\"list\" with a visually-hidden aria-labelledby summary naming the reduction ratio and frame count (read once, not re-announced per cell); each cell is role=\"listitem\"; supplied images keep their own alt text, the built-in placeholder is aria-hidden since it's decorative filler, not content. On mount, resume from an IntersectionObserver: when the root re-enters the viewport the rAF clock is re-armed cleanly (cycleStart reset to the resuming frame) rather than fast-forwarding through a stale elapsed offscreen time. prefers-reduced-motion skips the whole loop and renders once, directly, on the full-sheet HOLD frame: every cell populated at rest brightness, header fully typed — the most information-dense, most structured frame, deliberately not t0's near-empty sheet. Cleans up the rAF handle, the ResizeObserver and the IntersectionObserver, and the MutationObserver on unmount. Zero color literals — every color comes from Tailwind utility classes bound to --border, --surface, --ns-muted and --foreground tokens; the only literal numeric values are geometry, timing and the brightness filter multiplier. Zero dependencies."
  },
  "type": "registry:ui"
}