{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "curtain-leader-countdown",
  "title": "Curtain Leader Countdown",
  "description": "A full-bleed route curtain modeled on the SMPTE Universal Leader film countdown, compressed to preview-card scale and stated as a departure, not a fidelity claim.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/curtain-leader-countdown/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CurtainLeaderCountdown — a full-bleed route curtain modeled on the SMPTE\n// Universal Leader film countdown (1966): a rotating clock-arm sweeping a\n// circular target once per digit, numerals ticking 8->2, C/C/F/F control-\n// frame glyphs flashing only on the \"4\" count, and a black \"PICTURE START\"\n// beat before the loop restarts.\n//\n// COMPRESSED, NOT LITERAL. The real leader runs one numeral per real second\n// (~9s total). Preview-card scale can't carry that, so this compresses to\n// 450ms/digit (7 steps, 8->2, ~3.15s) plus a 350ms black beat, ~3.5s/cycle.\n// The clock-arm still does one full revolution per digit, just tied to the\n// (much faster) digit interval rather than a real second — a stated\n// departure, not a fidelity claim.\n//\n// THE 2-POP HAS NO VISUAL EQUIVALENT IN THE SOURCE. The leader's \"2-pop\" is\n// an audio cue with nothing to look at. Here it is translated as a one-frame\n// flash of the target ring to full --foreground plus a brief scale-pulse,\n// landing right before the cut to black — an invented stand-in, not a\n// historical detail, and it goes to full VALUE only, never --ns-accent: that\n// is the standing pointer/beam-highlight rule generalized to a scripted\n// flash, and sampling its R/G/B in either theme must land on equal channels.\n//\n// Every phase of the loop is a presentable frame on purpose, because the\n// no-autoplay verifier can land on any of them: the ring outline is drawn\n// every frame regardless of phase, the outgoing digit ghosts into the\n// incoming one in --ns-muted instead of hard-cutting, and the black beat\n// still shows the ring plus a muted ghost of the \"2\" target rather than a\n// truly empty frame.\n// ---------------------------------------------------------------------------\n\nexport interface CurtainLeaderCountdownProps {\n  /** Called once per completed cycle, right as the black beat ends. */\n  onComplete?: () => void;\n  /** Restart automatically after each cycle. @default true */\n  loop?: boolean;\n  /** Freeze the loop on its current frame without unmounting. */\n  paused?: boolean;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst DIGIT_MS = 450; // compressed from the source's 1 numeral/real second\nconst STEPS = 7; // 8,7,6,5,4,3,2\nconst COUNTDOWN_MS = DIGIT_MS * STEPS; // 3150\nconst BEAT_MS = 350; // black \"PICTURE START\" beat before the loop restarts\nconst CYCLE_MS = COUNTDOWN_MS + BEAT_MS; // 3500\nconst FIRST_DIGIT = 8;\nconst LAST_DIGIT = 2;\nconst CONTROL_DIGIT = 4; // C/C/F/F only render on this step, as in the source\nconst GHOST_FRAC = 0.22; // fraction of a digit step the outgoing ghost fades across\nconst FLASH_FRAC = 0.82; // fraction into the \"2\" step the 2-pop stand-in begins\nconst ANNOUNCE_MIN_MS = 1400; // sr-only throttle, well below the 450ms tick\n\n// The frozen reduced-motion frame: a quarter-turn into the \"4\" step's sweep\n// — control frames visible, numeral centered, arm at a clean quadrant angle.\n// The single most structured frame in the loop, deliberately not t=0.\nconst STATIC_MS = (FIRST_DIGIT - CONTROL_DIGIT) * DIGIT_MS + DIGIT_MS * 0.25;\n\ninterface Frame {\n  phase: \"countdown\" | \"beat\";\n  digit: number;\n  ghostDigit: number | null;\n  ghostAlpha: number;\n  angle: number;\n  showControls: boolean;\n  flashAmt: number;\n}\n\nfunction computeFrame(msIntoCycle: number): Frame {\n  if (msIntoCycle < COUNTDOWN_MS) {\n    const idx = Math.min(STEPS - 1, Math.floor(msIntoCycle / DIGIT_MS));\n    const localFrac = (msIntoCycle - idx * DIGIT_MS) / DIGIT_MS;\n    const digit = FIRST_DIGIT - idx;\n    const ghostDigit = idx > 0 ? digit + 1 : null;\n    const ghostAlpha =\n      ghostDigit !== null ? Math.max(0, 1 - localFrac / GHOST_FRAC) : 0;\n    const flashAmt =\n      digit === LAST_DIGIT && localFrac >= FLASH_FRAC\n        ? (localFrac - FLASH_FRAC) / (1 - FLASH_FRAC)\n        : 0;\n    return {\n      phase: \"countdown\",\n      digit,\n      ghostDigit,\n      ghostAlpha,\n      angle: localFrac * Math.PI * 2,\n      showControls: digit === CONTROL_DIGIT,\n      flashAmt,\n    };\n  }\n  // the black beat: never a genuinely empty frame — the ring stays on\n  // screen and a muted ghost of the \"2\" target holds through the whole beat\n  const beatFrac = (msIntoCycle - COUNTDOWN_MS) / BEAT_MS;\n  return {\n    phase: \"beat\",\n    digit: LAST_DIGIT,\n    ghostDigit: LAST_DIGIT,\n    ghostAlpha: 0.18 + 0.22 * (1 - beatFrac),\n    angle: Math.PI * 2,\n    showControls: false,\n    flashAmt: 0,\n  };\n}\n\nexport function CurtainLeaderCountdown({\n  onComplete,\n  loop = true,\n  paused,\n  className = \"\",\n  style,\n}: CurtainLeaderCountdownProps) {\n  const uid = useId();\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const statusRef = useRef<HTMLDivElement>(null);\n  const liveRef = useRef<HTMLSpanElement>(null);\n  const pausedRef = useRef(paused);\n  pausedRef.current = paused;\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n  const loopRef = useRef(loop);\n  loopRef.current = loop;\n\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!wrap || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let disposed = false;\n    let running = false;\n    let raf = 0;\n    let lastMs = 0;\n    let simMs = 0;\n    let staticMode = false;\n    let stoppedAtEnd = false;\n\n    let cssW = 0;\n    let cssH = 0;\n    let dpr = 1;\n\n    // No hex fallback: a literal here would necessarily encode one theme's\n    // polarity, so a dark-theme user could get a wrong-polarity flash on\n    // first paint before the token read below lands. readTokens() runs\n    // synchronously, before resize()/applyMode() ever call draw(), so these\n    // are never read unassigned.\n    let bg = \"\";\n    let fg = \"\";\n    let muted = \"\";\n    let family = \"ui-monospace, monospace\";\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      bg = cs.getPropertyValue(\"--background\").trim();\n      fg = cs.getPropertyValue(\"--foreground\").trim();\n      muted = cs.getPropertyValue(\"--ns-muted\").trim();\n    };\n    readTokens();\n\n    const readFont = () => {\n      family = getComputedStyle(wrap).fontFamily || family;\n    };\n    readFont();\n\n    const resize = () => {\n      const { width, height } = wrap.getBoundingClientRect();\n      if (width < 2 || height < 2) return;\n      cssW = width;\n      cssH = height;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    let lastAnnounced = -1;\n    let lastAnnounceAt = -Infinity;\n\n    const draw = (msIntoCycle: number) => {\n      if (cssW <= 0 || cssH <= 0) return;\n      const frame = computeFrame(msIntoCycle);\n\n      ctx.clearRect(0, 0, cssW, cssH);\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, cssW, cssH);\n\n      const cx = cssW / 2;\n      const cy = cssH / 2;\n      // Outer ring diameter ~0.34 of the smaller dimension — the target is\n      // an object sitting inside a generous field, not the frame itself.\n      // Numeral, arm, guide ring and control-glyph placement all derive\n      // from radius below, so the whole assembly scales proportionally at\n      // any container size, full-bleed or a small registry preview card.\n      const radius = Math.min(cssW, cssH) * 0.17;\n\n      // inner guide ring — quiet structural echo of the outer target ring.\n      // --border is a near-zero-contrast separator token (~1.1:1 in light\n      // theme), so a hairline drawn IN it would be invisible; --ns-muted is\n      // the token whose luminance sits strictly between bg and fg in both\n      // themes, so the hairline weight comes from lineWidth, not from --border.\n      ctx.beginPath();\n      ctx.arc(cx, cy, radius * 0.62, 0, Math.PI * 2);\n      ctx.strokeStyle = muted;\n      ctx.lineWidth = Math.max(1, radius * 0.01);\n      ctx.globalAlpha = 1;\n      ctx.stroke();\n\n      // outer target ring — always on screen, every phase, so the black\n      // beat never reads as a dead render\n      const ringScale = 1 + 0.14 * frame.flashAmt;\n      ctx.beginPath();\n      ctx.arc(cx, cy, radius * ringScale, 0, Math.PI * 2);\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = Math.max(2, radius * 0.035);\n      ctx.globalAlpha = 1;\n      ctx.stroke();\n\n      // 2-pop stand-in: a one-frame flash of the target RING (not a filled\n      // disc — the numeral and arm must stay legible through it) to full\n      // --foreground value at full alpha, thickened, plus the scale-pulse\n      // above — value only, never accent, so both themes flash to equal R/G/B\n      if (frame.flashAmt > 0) {\n        ctx.beginPath();\n        ctx.arc(cx, cy, radius * ringScale, 0, Math.PI * 2);\n        ctx.strokeStyle = fg;\n        ctx.lineWidth = Math.max(2, radius * (0.035 + 0.05 * frame.flashAmt));\n        ctx.globalAlpha = 1;\n        ctx.stroke();\n      }\n\n      // clock arm — one full revolution per digit step, starting at 12\n      const armLen = radius * ringScale * 0.92;\n      const armAngle = frame.angle - Math.PI / 2;\n      ctx.beginPath();\n      ctx.moveTo(cx, cy);\n      ctx.lineTo(cx + Math.cos(armAngle) * armLen, cy + Math.sin(armAngle) * armLen);\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = Math.max(2, radius * 0.045);\n      ctx.lineCap = \"round\";\n      ctx.stroke();\n\n      // control-frame glyphs — only during the \"4\" step, hairline weight\n      // (weight, not a --border color — see the guide-ring note above),\n      // one in each corner of the ring's bounding box, as in the source\n      if (frame.showControls) {\n        const off = radius * 0.8;\n        const glyphSize = Math.max(10, radius * 0.16);\n        ctx.font = `${glyphSize}px ${family}`;\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"middle\";\n        ctx.lineWidth = Math.max(1, glyphSize * 0.06);\n        ctx.strokeStyle = muted;\n        ctx.globalAlpha = 1;\n        const corners: [string, number, number][] = [\n          [\"C\", cx - off, cy - off],\n          [\"C\", cx + off, cy - off],\n          [\"F\", cx - off, cy + off],\n          [\"F\", cx + off, cy + off],\n        ];\n        for (const [glyph, gx, gy] of corners) {\n          ctx.strokeText(glyph, gx, gy);\n        }\n      }\n\n      // numeral — ghost of the outgoing/target digit in --ns-muted, then\n      // (outside the black beat) the current digit solid in --foreground.\n      // \"middle\" textBaseline centers on the font's full ascent+descent\n      // box, which reserves room for descenders (g/j/p/y) that digits never\n      // use — left uncorrected, every numeral renders visibly above the\n      // ring's true center. optical Y per glyph pulls it back onto center:\n      // measureText's actualBoundingBox*/descent are reported relative to\n      // the \"middle\" anchor already in place, so (ascent - descent) / 2 is\n      // exactly the remaining offset between that anchor and the glyph's\n      // own visual midpoint.\n      const numSize = radius * 1.15;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.font = `700 ${numSize}px ${family}`;\n      const opticalY = (digit: number) => {\n        const m = ctx.measureText(String(digit));\n        return cy + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;\n      };\n\n      if (frame.ghostDigit !== null && frame.ghostAlpha > 0) {\n        ctx.fillStyle = muted;\n        ctx.globalAlpha = frame.ghostAlpha;\n        ctx.fillText(String(frame.ghostDigit), cx, opticalY(frame.ghostDigit));\n      }\n      if (frame.phase === \"countdown\") {\n        ctx.fillStyle = fg;\n        ctx.globalAlpha = 1;\n        ctx.fillText(String(frame.digit), cx, opticalY(frame.digit));\n      }\n      ctx.globalAlpha = 1;\n\n      // sr-only live region — throttled well below the 450ms tick so a\n      // screen reader gets one legible announcement per digit, not seven\n      if (\n        liveRef.current &&\n        frame.digit !== lastAnnounced &&\n        performance.now() - lastAnnounceAt >= ANNOUNCE_MIN_MS\n      ) {\n        lastAnnounced = frame.digit;\n        lastAnnounceAt = performance.now();\n        liveRef.current.textContent =\n          frame.phase === \"beat\" ? \"cue\" : String(frame.digit);\n      }\n    };\n\n    const loopFrame = (nowMs: number) => {\n      if (!running) return;\n      const dt = Math.min(50, lastMs ? nowMs - lastMs : 16.7);\n      lastMs = nowMs;\n      simMs += dt;\n      if (simMs >= CYCLE_MS) {\n        onCompleteRef.current?.();\n        if (loopRef.current) {\n          simMs %= CYCLE_MS;\n        } else {\n          simMs = CYCLE_MS - 1;\n          stoppedAtEnd = true;\n          draw(simMs);\n          sleep();\n          setStatus(false);\n          return;\n        }\n      }\n      draw(simMs);\n      raf = requestAnimationFrame(loopFrame);\n    };\n\n    const wake = () => {\n      if (running || disposed || staticMode || stoppedAtEnd) return;\n      running = true;\n      lastMs = 0;\n      raf = requestAnimationFrame(loopFrame);\n    };\n    const sleep = () => {\n      running = false;\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const setStatus = (busy: boolean) => {\n      statusRef.current?.setAttribute(\"aria-busy\", busy ? \"true\" : \"false\");\n    };\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const applyMode = () => {\n      if (reduced || pausedRef.current) {\n        staticMode = true;\n        sleep();\n        draw(reduced ? STATIC_MS : simMs);\n        setStatus(false);\n      } else {\n        staticMode = false;\n        setStatus(!stoppedAtEnd);\n        wake();\n      }\n    };\n    const onMq = () => {\n      reduced = mq.matches;\n      applyMode();\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    let onScreen = true;\n    const io = new IntersectionObserver(\n      (entries) => {\n        onScreen = entries.some((en) => en.isIntersecting);\n        if (!onScreen) sleep();\n        else if (!staticMode && !document.hidden) wake();\n      },\n      { threshold: 0 }\n    );\n    io.observe(wrap);\n\n    const onVis = () => {\n      if (document.hidden) sleep();\n      else if (!staticMode && onScreen) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      if (staticMode) draw(reduced ? STATIC_MS : simMs);\n    });\n    ro.observe(wrap);\n    resize();\n\n    const themeObserver = new MutationObserver(() => {\n      readTokens();\n      if (staticMode) draw(reduced ? STATIC_MS : simMs);\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    let lastPolledPaused = pausedRef.current;\n    const poll = window.setInterval(() => {\n      if (pausedRef.current !== lastPolledPaused) {\n        lastPolledPaused = pausedRef.current;\n        applyMode();\n      }\n    }, 140);\n\n    applyMode();\n\n    return () => {\n      disposed = true;\n      sleep();\n      ro.disconnect();\n      io.disconnect();\n      themeObserver.disconnect();\n      mq.removeEventListener(\"change\", onMq);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      window.clearInterval(poll);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={wrapRef}\n      data-curtain-leader-countdown={uid}\n      className={`relative h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"absolute inset-0 block h-full w-full\" />\n      {/* separate siblings, not nested — an aria-busy=true ancestor tells AT\n          to withhold updates from its whole subtree, which would silently\n          swallow the throttled live-region announcements below */}\n      <div ref={statusRef} role=\"status\" aria-busy=\"true\" className=\"sr-only\" />\n      <span ref={liveRef} aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n}\n\nCurtainLeaderCountdown.displayName = \"CurtainLeaderCountdown\";\n",
      "type": "registry:ui",
      "target": "components/ui/curtain-leader-countdown.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "preloader",
      "route-curtain",
      "countdown",
      "canvas",
      "transition"
    ],
    "instruction": "Build <CurtainLeaderCountdown onComplete? loop? paused? className?> as a full-bleed route curtain modeled on the SMPTE Universal Leader film countdown — the real broadcast/projection standard, not an invented loading screen. A rotating clock-arm sweeps a circular target once per digit step while numerals tick 8->2 on a canvas-drawn countdown field; C/C/F/F control-frame glyphs flash in the ring's corners only during the '4' step, exactly where the real leader places them. COMPRESSED, NOT LITERAL: the historical leader runs one numeral per real second (~9s total); this component compresses to ~450ms per digit (~3.5s/cycle) for preview-card scale, a deliberate departure from the standard, not a fidelity claim. THE 2-POP HAS NO VISUAL EQUIVALENT IN THE SOURCE: the leader's '2-pop' is an audio cue; here it's translated as a one-frame flash of the target ring to full --foreground plus a brief scale-pulse, an invented stand-in, stated as such. PALETTE: ring and numerals in --foreground, black field in --background, ghost/inactive digit in --ns-muted, control-frame hairlines in --border — no --ns-accent anywhere, including the flash, which goes to full luminance instead. The countdown-and-flash cycle is the unconditional idle loop from mount, no autoplay descriptor needed; any phase must read as complete (numeral + arm angle + ring together) since the no-autoplay verifier can land anywhere in the cycle. prefers-reduced-motion freezes on the '4' step, where control-frame glyphs are visible and the arm sits at a clean quadrant angle — the most structured single frame, not t=0."
  },
  "type": "registry:ui"
}