{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bombe-drum-halt",
  "title": "Bombe Drum Halt",
  "description": "An ambient search/lookup loading indicator built as the electromechanical Bombe's drum-and-stop search cycle: four drum columns scan continuously until, every 4-6s, all of them lock still at once while a sensing bar brightens for a check, then release and resume.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/bombe-drum-halt/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BombeDrumHalt — a search/lookup loading indicator built as the Bombe's\n// drum-and-stop search cycle: banks of rotating drums spin continuously\n// through a hypothesis space; a sensing relay bank tests each position as\n// the drums pass it, and when a candidate briefly satisfies the test the\n// whole bank locks dead for a beat (a diagonal-board-style check running\n// against it) before releasing and resuming — almost every stop a false\n// positive. Sourced from the drum-and-stop search cycle itself, not the\n// electrical/plugboard logic the real machine tested.\n//\n// Four vertical drum columns, each an overflow:hidden window over a\n// repeating tick-mark strip. The strip is NOT a uniform 1-tick gradient —\n// a uniform pattern repeats every single tick, which makes a drum's visible\n// state alias back to itself dozens of times a second and leaves t0/2.5s/5s\n// screenshots indistinguishable whenever no halt happens to be mid-flight.\n// Instead each drum's strip is one 26-position revolution (one per rotor\n// letter, historically the right count for a Bombe drum): every 5th\n// position is a heavier tick and position 0 is a distinct brighter datum\n// mark, so a screenshot of a drum's resting phase is legible and never\n// repeats for ~2s of continuous scan.\n//\n// All four columns share ONE driving clock (a single rAF loop tracking one\n// `phase` state machine: scan -> decel -> hold -> reaccel -> scan) rather\n// than four independent CSS animation timelines, specifically so a halt can\n// pause that clock precisely instead of fighting four separately-running\n// loops back into sync. Each drum's on-screen offset is (accumulated\n// scroll px) mod (one full 26-tick revolution); the per-drum phase stagger\n// (drum N reads N*0.6s \"ahead\") is nothing more than a different starting\n// offset on that same shared clock, so the drums never need independent\n// timers to stay out of lock-step.\n//\n// A halt is scheduled 4-6s out (re-rolled after every resume, never a fixed\n// metronome). Decel eases speed 1 -> 0 over 180ms while the sensing bar\n// brightens from its idle 20% opacity toward peak; hold hangs at zero\n// velocity for 900ms at 90% bar opacity (the \"check running\" beat); reaccel\n// eases 0 -> 1 back over 220ms as the bar dims back to idle. On 15% of\n// resumes a single randomly-chosen drum gets a 40%-opacity outline ring at\n// the sensing line (where its current tick mark sits under test) for one\n// extra 600ms after motion resumes — a rarer \"this one got a second look\"\n// variant that still resolves and never latches.\n//\n// prefers-reduced-motion renders the single most-structured frame directly\n// (no rAF loop at all): drums stopped at their phase-staggered rest\n// offsets, sensing bar at peak 90% opacity — the mid-hold instant, not a\n// mid-scroll blur.\n// ---------------------------------------------------------------------------\n\n// Rendered scan speed is a decoupled rate, not the historical one: the real\n// Bombe tested on the order of hundreds of rotor-hypotheses/s, far above\n// anything that reads as motion (rather than noise/strobe) on a 60Hz\n// screen. 180px/s here is a visually smooth continuous scroll — roughly 13\n// tick-marks/s at the default tick spacing — chosen the same way round-9's\n// rate-decoupling rule chose a slow sweep over a literal 180marks/s\n// (2520px/s), which would alias against 60Hz paint exactly like the strobe\n// bug that rule was written for.\nconst SCROLL_SPEED_PX_S = 180;\nconst STRIP_TICKS = 26; // one revolution = 26 rotor-letter positions\nconst DRUM_COUNT = 4;\nconst PHASE_STEP_S = 0.6; // drum N starts N*0.6s \"ahead\" of drum 0\nconst HALT_MIN_S = 4;\nconst HALT_MAX_S = 6;\nconst DECEL_MS = 180;\nconst HOLD_MS = 900;\nconst REACCEL_MS = 220;\nconst RING_CHANCE = 0.15;\nconst RING_MS = 600;\nconst SENSING_BAR_Y_RATIO = 0.42; // fixed height across drums, fraction of drum height\nconst BAR_IDLE_OPACITY = 0.2;\nconst BAR_PEAK_OPACITY = 0.9;\n\ntype Phase = \"scan\" | \"decel\" | \"hold\" | \"reaccel\";\n\nfunction easeInOutCubic(t: number): number {\n  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;\n}\n\ninterface Tick {\n  top: number;\n  height: number;\n  opacity: number;\n}\n\n/** Builds `repeats` back-to-back 26-position revolutions of tick marks —\n * a datum mark every 26th position, a heavier mark every 5th, a plain tick\n * otherwise — so the drum window always has enough rendered strip above\n * and below the visible offset range to cover a full modulo sweep. */\nfunction buildTicks(tickPeriod: number, repeats: number): Tick[] {\n  const ticks: Tick[] = [];\n  const total = STRIP_TICKS * repeats;\n  for (let i = 0; i < total; i++) {\n    const pos = i % STRIP_TICKS;\n    const isDatum = pos === 0;\n    const isHeavy = pos % 5 === 0;\n    ticks.push({\n      top: i * tickPeriod,\n      height: isDatum ? tickPeriod * 0.32 : isHeavy ? tickPeriod * 0.22 : tickPeriod * 0.14,\n      opacity: isDatum ? 0.9 : isHeavy ? 0.68 : 0.45,\n    });\n  }\n  return ticks;\n}\n\nexport interface BombeDrumHaltProps {\n  /** accessible label for the ambient search/loading status */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function BombeDrumHalt({ label = \"Searching\", className = \"\" }: BombeDrumHaltProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const drumRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const ringRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const barRef = useRef<HTMLDivElement>(null);\n  const [smaller, setSmaller] = useState(0);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver((entries) => {\n      const entry = entries[0];\n      if (!entry) return;\n      const { width, height } = entry.contentRect;\n      setSmaller(Math.min(width, height));\n    });\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, []);\n\n  // Geometry derives from the container's smaller dimension so the\n  // component reads at card scale regardless of aspect ratio.\n  const drumWidth = smaller > 0 ? smaller / 9 : 0;\n  const drumHeight = smaller > 0 ? smaller * 0.85 : 0;\n  const tickPeriod = drumHeight > 0 ? Math.max(4, drumHeight / 45) : 4;\n  const stripLength = STRIP_TICKS * tickPeriod;\n  const repeats = drumHeight > 0 ? Math.max(3, Math.ceil((drumHeight + stripLength) / stripLength) + 1) : 3;\n  const ticks = useMemo(\n    () => (drumHeight > 0 ? buildTicks(tickPeriod, repeats) : []),\n    [tickPeriod, repeats, drumHeight]\n  );\n\n  useEffect(() => {\n    if (drumWidth <= 0 || drumHeight <= 0) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // accumulated scroll offset per drum, px, seeded by the phase stagger\n    const positions = Array.from(\n      { length: DRUM_COUNT },\n      (_, i) => i * PHASE_STEP_S * SCROLL_SPEED_PX_S\n    );\n\n    const applyDrumTransform = (i: number) => {\n      const el = drumRefs.current[i];\n      if (!el) return;\n      const offset = ((positions[i] ?? 0) % stripLength + stripLength) % stripLength;\n      el.style.transform = `translateY(${-offset}px)`;\n    };\n\n    const setBarOpacity = (v: number) => {\n      const el = barRef.current;\n      if (el) el.style.opacity = String(v);\n    };\n\n    const setRing = (drum: number, opacity: number) => {\n      ringRefs.current.forEach((el, i) => {\n        if (el) el.style.opacity = i === drum ? String(opacity) : \"0\";\n      });\n    };\n\n    if (reduced) {\n      // freeze on the mid-hold instant: drums stopped, bar at peak brightness\n      positions.forEach((_, i) => applyDrumTransform(i));\n      setBarOpacity(BAR_PEAK_OPACITY);\n      setRing(-1, 0);\n      return;\n    }\n\n    let raf = 0;\n    let visible = true;\n    let last = 0;\n    let phase: Phase = \"scan\";\n    let phaseStart = 0;\n    let nextHaltAt = 0;\n    let ringDrum = -1;\n    let ringUntil = 0;\n\n    const scheduleNextHalt = (now: number) => {\n      nextHaltAt = now + (HALT_MIN_S + Math.random() * (HALT_MAX_S - HALT_MIN_S)) * 1000;\n    };\n\n    const advance = (dt: number, speedFactor: number) => {\n      const delta = (SCROLL_SPEED_PX_S * speedFactor * dt) / 1000;\n      for (let i = 0; i < positions.length; i++) {\n        positions[i] = (positions[i] ?? 0) + delta;\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = requestAnimationFrame(tick);\n      if (!visible) return;\n      if (last === 0) last = now;\n      const dt = Math.min(100, now - last);\n      last = now;\n\n      if (phase === \"scan\") {\n        if (nextHaltAt === 0) scheduleNextHalt(now);\n        advance(dt, 1);\n        if (ringDrum >= 0 && now >= ringUntil) {\n          setRing(-1, 0);\n          ringDrum = -1;\n        }\n        if (now >= nextHaltAt) {\n          phase = \"decel\";\n          phaseStart = now;\n        }\n      } else if (phase === \"decel\") {\n        const t = Math.min(1, (now - phaseStart) / DECEL_MS);\n        advance(dt, 1 - easeInOutCubic(t));\n        setBarOpacity(BAR_IDLE_OPACITY + (BAR_PEAK_OPACITY - BAR_IDLE_OPACITY) * t);\n        if (t >= 1) {\n          phase = \"hold\";\n          phaseStart = now;\n        }\n      } else if (phase === \"hold\") {\n        const t = Math.min(1, (now - phaseStart) / HOLD_MS);\n        setBarOpacity(BAR_PEAK_OPACITY);\n        if (t >= 1) {\n          phase = \"reaccel\";\n          phaseStart = now;\n        }\n      } else if (phase === \"reaccel\") {\n        const t = Math.min(1, (now - phaseStart) / REACCEL_MS);\n        advance(dt, easeInOutCubic(t));\n        setBarOpacity(BAR_PEAK_OPACITY - (BAR_PEAK_OPACITY - BAR_IDLE_OPACITY) * t);\n        if (t >= 1) {\n          phase = \"scan\";\n          scheduleNextHalt(now);\n          // false-positive resolve: 15% of halts leave a \"second look\" ring\n          if (Math.random() < RING_CHANCE) {\n            ringDrum = Math.floor(Math.random() * DRUM_COUNT);\n            ringUntil = now + RING_MS;\n            setRing(ringDrum, 0.4);\n          }\n        }\n      }\n\n      for (let i = 0; i < DRUM_COUNT; i++) applyDrumTransform(i);\n    };\n\n    raf = requestAnimationFrame(tick);\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible) {\n        last = 0; // resync elapsed-time base on resume\n        // A halt frozen mid-flight while offscreen has a stale phaseStart;\n        // rather than resolve decel/hold/reaccel in one jumped frame,\n        // treat the away period as invalidating it and resume scanning.\n        if (phase !== \"scan\") {\n          phase = \"scan\";\n          setBarOpacity(BAR_IDLE_OPACITY);\n          setRing(-1, 0);\n          ringDrum = -1;\n        }\n        scheduleNextHalt(performance.now());\n      }\n    });\n    if (rootRef.current) io.observe(rootRef.current);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n    };\n  }, [drumWidth, drumHeight, stripLength]);\n\n  const drums = useMemo(() => Array.from({ length: DRUM_COUNT }, (_, i) => i), []);\n  const ringInset = Math.max(1, drumWidth * 0.1);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"status\"\n      aria-label={label}\n      className={`relative flex h-full w-full min-h-[120px] items-center justify-center ${className}`}\n    >\n      <div className=\"relative flex items-stretch\" style={{ gap: drumWidth * 0.35 }}>\n        {drums.map((i) => (\n          <div\n            key={i}\n            className=\"relative overflow-hidden\"\n            style={{\n              width: drumWidth,\n              height: drumHeight,\n              borderLeft: i === 0 ? \"none\" : \"1px solid var(--border)\",\n            }}\n          >\n            <div\n              ref={(el) => {\n                drumRefs.current[i] = el;\n              }}\n              className=\"absolute inset-x-0 top-0 will-change-transform\"\n              style={{ height: repeats * stripLength }}\n            >\n              {ticks.map((t, idx) => (\n                <div\n                  key={idx}\n                  className=\"absolute inset-x-0\"\n                  style={{\n                    top: t.top,\n                    height: t.height,\n                    background: \"var(--foreground)\",\n                    opacity: t.opacity,\n                  }}\n                />\n              ))}\n            </div>\n            <div\n              ref={(el) => {\n                ringRefs.current[i] = el;\n              }}\n              className=\"pointer-events-none absolute\"\n              style={{\n                left: ringInset,\n                right: ringInset,\n                top: drumHeight * SENSING_BAR_Y_RATIO - tickPeriod / 2,\n                height: tickPeriod,\n                border: \"1px solid var(--foreground)\",\n                borderRadius: 1,\n                opacity: 0,\n                transition: \"opacity 200ms ease\",\n              }}\n            />\n          </div>\n        ))}\n        <div\n          ref={barRef}\n          className=\"pointer-events-none absolute left-0 right-0\"\n          style={{\n            top: drumHeight * SENSING_BAR_Y_RATIO,\n            height: 1,\n            background: \"var(--foreground)\",\n            opacity: BAR_IDLE_OPACITY,\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/bombe-drum-halt.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "search",
      "lookup",
      "ambient",
      "drum",
      "cryptography",
      "dom",
      "raf",
      "indeterminate"
    ],
    "instruction": "An ambient search/lookup loading indicator sourced from the electromechanical Bombe's drum-scanning search cycle: banks of rotating drums (each a candidate rotor position) spin continuously while a sensing relay bank tests each position as it passes, and when a candidate briefly satisfies the test the whole bank locks dead for a beat while a diagonal-board-style check runs, then releases and resumes — almost every stop a false positive. Renders four vertical drum columns side by side (DRUM_COUNT = 4), each an overflow:hidden window over an absolutely-positioned strip of explicit tick-mark `<div>`s (not a CSS repeating-linear-gradient) built as a 26-position revolution — one per rotor letter, historically the right count for a Bombe drum — with position 0 a distinct brighter/taller 'datum' mark, every 5th position a heavier mark, and the rest plain, repeated back-to-back enough times to cover the drum window across a full modulo sweep. This non-uniform 26-tick revolution is deliberate: a uniform 1-tick-period gradient (the first implementation) aliases back to a visually identical state every single tick — about every 78ms at the default spacing — so a t0/2.5s/5s screenshot triple is indistinguishable whenever no halt happens to be mid-flight; the 26-tick revolution (~stripLength/180px/s, on the order of 1-2s depending on card size) makes a drum's resting phase legible and non-repeating across the whole 2.5s/5s sampling window even with zero halts landing in it. Column separators are 1px var(--border) dividers between drums (never a fill). Geometry is derived from the container's smaller dimension via a ResizeObserver on the root: drum width = smaller/9, drum height = smaller*0.85, and tick spacing itself scales with drum height (tickPeriod = max(4px, drumHeight/45)) so the strip's density — not just the drum's outer box — reads correctly at card scale. All four drums are driven by ONE shared requestAnimationFrame clock rather than four independent CSS animation timelines or a CSS `animation: infinite`, specifically so a halt can pause that one clock precisely instead of fighting four separately-running loops back into alignment: a single `phase` state machine cycles scan -> decel -> hold -> reaccel -> scan, and every drum's per-frame position delta is `SCROLL_SPEED_PX_S (180) * speedFactor(phase) * dt / 1000`, applied identically to all four so they lock and release in unison. 180px/s is a rendered rate, not the historical one — the real Bombe tested on the order of hundreds of rotor-hypotheses/s, far above what could read as anything but noise on a 60Hz screen, so the visible scroll speed is decoupled from that real rate the same way round-9's rate-decoupling rule decoupled a strobing 240Hz row-scan into a slow legible sweep; at the default tick spacing 180px/s reads as roughly 13 tick-marks/s of smooth, blur-free motion. The 3-4 drum phase stagger (drum N reads N*0.6s 'ahead' of drum 0) is implemented as nothing more than a different seed offset added to each drum's accumulated position at mount — not an independent timer — so the columns never lock-step visually despite sharing one clock. Each drum's on-screen transform is `translateY(-(position mod stripLength))`, applied via a ref-held style mutation every frame (not React state) to stay off the render path. A halt is scheduled 4-6s out, re-rolled fresh after every resume (`HALT_MIN_S=4, HALT_MAX_S=6`, uniform random — never a fixed metronome). On entering decel, speed eases 1 -> 0 over 180ms via a cubic ease-in-out while a 1px-tall sensing bar spanning all four drums (positioned at 42% of drum height, the fixed 'sensing bar' height) brightens linearly from its idle var(--foreground) 20% opacity toward 90%; hold then freezes speed at exactly 0 for 900ms at the full 90% bar opacity (the 'check running' beat, the single longest and most legible sub-phase); reaccel eases speed 0 -> 1 back over 220ms as the bar dims back to 20%. The full halt event totals roughly 1.3s (180+900+220ms), comfortably long enough to register as three distinct beats — stop, hold, release — rather than a blink. On exactly 15% of resumes (rolled once, at the moment reaccel completes and a fresh halt is scheduled) a single randomly-chosen drum column is given a 40%-opacity outline ring at the same 42%-height sensing line — read as the mark currently under test, since that is where the check runs — that fades back to 0 opacity 600ms later via a plain CSS opacity transition — a rarer 'this one got a second look' variant that still resolves and never latches into a permanent state. Nothing here is a real search result or a readout; there is no click-to-force-a-halt and no percentage, by design, since either would imply an actual result being represented. Under prefers-reduced-motion the rAF loop never starts at all: the component renders directly into the single most-structured static frame — every drum's transform set once to its phase-staggered rest offset, and the sensing bar's opacity set once to 90% (the peak-brightness mid-hold instant, not a mid-scroll blur) — via the exact same style-mutation helpers the animated path uses, just called once instead of every frame. An IntersectionObserver pauses the driving clock's elapsed-time accounting (not the rAF calls themselves) while the root scrolls offscreen; on return it resyncs the time base and, if a halt was mid-flight when visibility was lost, treats that stale halt as invalidated (rather than resolving three sub-phases in one jumped frame) — it snaps back to scan, dims the sensing bar back to idle, clears any ring, and reschedules the next halt fresh, so scrolling away and back never produces a burst of catch-up motion or a glitchy multi-phase jump. Cleanup on unmount cancels the rAF handle and disconnects the ResizeObserver and IntersectionObserver. Every colour is a CSS custom property referenced directly (var(--foreground), var(--border)) — no colour literals, no accent (this is an ambient loader, not interaction chrome, so var(--ns-accent) never appears). Props: label (accessible status text, default 'Searching', applied as aria-label on the root's role=status wrapper — the drum grid itself carries no other ARIA since it is purely decorative motion), className. Pure DOM + CSS + requestAnimationFrame, zero dependencies."
  },
  "type": "registry:ui"
}