{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shutter-telegraph-board",
  "title": "Shutter Telegraph Board",
  "description": "An ambient multi-stage status board of six louvred shutters, modelled on the Admiralty six-shutter telegraph: each shutter independently flips edge-on (open, near-invisible) or face-on (closed, a solid opaque square) in a staggered cascade that forms a new symbol every couple of seconds.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/shutter-telegraph-board/component.tsx",
      "content": "\"use client\";\n\n// ShutterTelegraphBoard — an ambient multi-stage status board modelled on the\n// Admiralty six-shutter telegraph (Murray's telegraph, England 1795-1816): a\n// 2x3 board of louvred shutters, each independently flipped edge-on (nearly\n// invisible, background shows through) or face-on (a solid opaque square),\n// read hilltop to hilltop through a telescope. Each of the 63 open/closed\n// combinations encoded a number or codebook phrase.\n//\n// Pure DOM/CSS, no canvas. Each shutter is a small stack of two divs: a\n// fixed 1px --foreground-at-25%-opacity frame (the shutter's edge, visible\n// once the panel in front of it rotates out of the way) and an opaque\n// --foreground panel driven purely by `transform: perspective(...)\n// rotateY(deg)` between 0deg (face-on/closed) and 90deg (edge-on/open) — the\n// perspective projection itself is what visually collapses the panel to a\n// near-invisible sliver, no opacity fade involved. Every 2s a new symbol\n// (one of 8 fixed 6-bit patterns) is applied; the six shutters that need to\n// flip each get a 60ms-staggered transitionDelay in reading order\n// (left-to-right, top-to-bottom) so a symbol change reads as a wave\n// crossing the board rather than a simultaneous snap, each individual flip\n// taking 320ms ease-in-out. Colours are plain `var(--foreground)` in inline\n// styles — pure DOM with no per-pixel colour math, so (as with the shipped\n// flag-hoist-run) there is nothing for getComputedStyle/MutationObserver to\n// do that the CSS cascade doesn't already do for free on a theme switch.\n\nimport { useEffect, useRef, useState } from \"react\";\n\nconst COLS = 2;\nconst ROWS = 3;\nconst CELL_COUNT = COLS * ROWS;\n\nconst FLIP_MS = 320;\nconst STAGGER_MS = 60;\nconst DWELL_MS = 1700;\nconst SYMBOL_INTERVAL_MS = DWELL_MS + (CELL_COUNT - 1) * STAGGER_MS; // ~2000ms\nconst PERSPECTIVE_PX = 600;\n\n// 8 fixed 6-bit symbols, reading order (row-major, top-to-bottom,\n// left-to-right within a row): true = open (edge-on), false = closed\n// (face-on). Index 4 is an exact checkerboard — 3 open / 3 closed, the\n// maximum structural contrast in the set — reserved as the reduced-motion\n// freeze frame. The set deliberately also contains the degenerate\n// all-closed (0) and all-open (5) symbols elsewhere in the loop, which is\n// exactly why those two are never chosen as the static frame.\nconst SYMBOLS: readonly boolean[][] = [\n  [false, false, false, false, false, false],\n  [true, true, false, false, true, false],\n  [false, true, true, false, true, true],\n  [true, false, true, true, false, false],\n  [true, false, false, true, true, false], // checkerboard — reduced-motion frame\n  [true, true, true, true, true, true],\n  [false, true, false, true, false, true],\n  [true, false, true, false, true, false],\n];\n\nconst STATIC_SYMBOL_INDEX = 4;\nconst START_SYMBOL_INDEX = 2; // a mixed, non-degenerate t0\n\nexport interface ShutterTelegraphBoardProps {\n  /** accessible label for the ambient board */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function ShutterTelegraphBoard({\n  label = \"Signal board\",\n  className = \"\",\n}: ShutterTelegraphBoardProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const gridRef = useRef<HTMLDivElement>(null);\n  const [cellSize, setCellSize] = useState(0);\n  const [reduced, setReduced] = useState(false);\n  // matches SSR's default (reduced motion is unknown server-side); flipped\n  // to STATIC_SYMBOL_INDEX synchronously in the reduced-motion effect below\n  // before any timer would otherwise fire, so no flash of the wrong symbol.\n  const [symbolIndex, setSymbolIndex] = useState(START_SYMBOL_INDEX);\n\n  // -- reduced motion: pin to the checkerboard symbol, never advance. ----\n  useEffect(() => {\n    const isReduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    setReduced(isReduced);\n    if (isReduced) setSymbolIndex(STATIC_SYMBOL_INDEX);\n  }, []);\n\n  // -- geometry: square shutters sized from the container's smaller\n  // dimension, so the board reads at card scale on any card. -------------\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver((entries) => {\n      const rect = entries[0]?.contentRect;\n      if (!rect) return;\n      const minDim = Math.min(rect.width, rect.height);\n      setCellSize(minDim / 4);\n    });\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, []);\n\n  // -- the loop: a new symbol every SYMBOL_INTERVAL_MS, unbounded, 8-symbol\n  // repeat (~16s per full cycle). Paused while off-screen, skipped\n  // entirely under reduced motion. ----------------------------------------\n  useEffect(() => {\n    if (reduced) return;\n    const root = rootRef.current;\n    if (!root) return;\n    let id = 0;\n    const start = () => {\n      if (id) return;\n      id = window.setInterval(() => {\n        setSymbolIndex((i) => (i + 1) % SYMBOLS.length);\n      }, SYMBOL_INTERVAL_MS);\n    };\n    const stop = () => {\n      if (!id) return;\n      window.clearInterval(id);\n      id = 0;\n    };\n    const io = new IntersectionObserver((entries) => {\n      if (entries[0]?.isIntersecting) start();\n      else stop();\n    });\n    io.observe(root);\n    return () => {\n      stop();\n      io.disconnect();\n    };\n  }, [reduced]);\n\n  const gap = cellSize * 0.14;\n  const pattern = SYMBOLS[symbolIndex] ?? SYMBOLS[0]!;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      aria-label={label}\n      className={`relative flex w-full items-center justify-center overflow-hidden ${className}`}\n    >\n      <div\n        ref={gridRef}\n        aria-hidden=\"true\"\n        style={{\n          display: \"grid\",\n          gridTemplateColumns: `repeat(${COLS}, ${cellSize}px)`,\n          gridTemplateRows: `repeat(${ROWS}, ${cellSize}px)`,\n          gap: `${gap}px`,\n          visibility: cellSize > 0 ? \"visible\" : \"hidden\",\n        }}\n      >\n        {pattern.map((open, i) => (\n          <div key={i} style={{ position: \"relative\", width: cellSize, height: cellSize }}>\n            <div\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                border: \"1px solid var(--foreground)\",\n                opacity: 0.25,\n              }}\n            />\n            <div\n              className=\"ns-shutter-panel\"\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                backgroundColor: \"var(--foreground)\",\n                transformOrigin: \"50% 50%\",\n                transform: `perspective(${PERSPECTIVE_PX}px) rotateY(${open ? 90 : 0}deg)`,\n                transitionProperty: reduced ? \"none\" : \"transform\",\n                transitionDuration: `${FLIP_MS}ms`,\n                transitionTimingFunction: \"ease-in-out\",\n                transitionDelay: `${i * STAGGER_MS}ms`,\n              }}\n            />\n          </div>\n        ))}\n      </div>\n      <style>{`\n@media (prefers-reduced-motion: reduce){\n  .ns-shutter-panel{transition:none!important;}\n}\n`}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/shutter-telegraph-board.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "status",
      "loader",
      "grid",
      "dom",
      "telegraph",
      "signal",
      "ambient",
      "generative"
    ],
    "instruction": "A 2-column x 3-row grid of shutters filling a card, sized entirely from the container's smaller dimension (cellSize = minDim/4, measured via ResizeObserver on the root) so it holds its identity at card scale. Each shutter is two stacked absolutely-positioned divs inside a relative cell: a fixed 1px `border: 1px solid var(--foreground)` frame at `opacity: 0.25` (the shutter's edge, deliberately NOT `--border`, since at ~1.1:1 contrast in light theme that token would be invisible and this line is load-bearing for reading which cells are open) sitting behind an opaque `backgroundColor: var(--foreground)` panel whose only animated property is `transform: perspective(600px) rotateY(0deg | 90deg)` — 0deg is closed/face-on (the panel fully covers the frame, reading as a solid black square), 90deg is open/edge-on (the perspective projection alone collapses the panel to a near-invisible sliver, revealing the low-opacity frame and the background behind it; no opacity animation is involved in the open/closed transition itself). One of 8 fixed 6-bit boolean patterns (SYMBOLS, row-major reading order) is applied at a time via React state; on every symbol change each of the 6 shutters that needs to flip gets a `transitionDelay` of `index * 60ms` in reading order (left-to-right, top-to-bottom) with a shared 320ms ease-in-out `transitionDuration`, so the change reads as a wave of flips crossing the board rather than a simultaneous snap — this is the one thing to visually follow, at a cadence of roughly one full symbol change every 2000ms (DWELL_MS 1700 + 5 x STAGGER_MS 60), giving an 8-symbol, ~16s unbounded loop that simply repeats. Colours are plain `var(--foreground)` references directly in inline styles, not JS-read RGB values — pure DOM/CSS with no per-pixel colour math, so (matching the shipped flag-hoist-run sibling) the CSS cascade already repaints on a theme class change for free and there is nothing for a getComputedStyle+MutationObserver dance to do. The interval driving symbol advancement is started/stopped by an IntersectionObserver on the root so nothing runs off-screen, and is never started at all under `prefers-reduced-motion: reduce` — instead the board is pinned, synchronously in that effect (before any timer would otherwise fire), to symbol index 4: an exact checkerboard of 3 open / 3 closed shutters, the maximum structural contrast of any symbol in the set, deliberately distinct from the degenerate all-closed (index 0) and all-open (index 5) symbols that also exist elsewhere in the sequence and which would read as a blank board if chosen as the freeze frame; the CSS transition itself is also stripped via a `prefers-reduced-motion` media query as a second guard. The root is `role=\"img\"` with an accessible label (default \"Signal board\"); the grid itself is `aria-hidden`. `autoplay: none` — the board advances on its own internal 2s timer with no pointer, press, or scroll input in the loop at all. Zero dependencies, DOM + CSS only, no canvas, every colour a token (--foreground only)."
  },
  "type": "registry:ui"
}