{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "indicator-rack",
  "title": "Indicator Rack",
  "description": "A plan selector built on the indicator-tablet rack of a pre-electronic cash register: four columns of ten printed tablets each, all ten always mounted, where changing the reading is a sort — the old tablet drops as the new one springs up, the two crossing mid-column 25ms apart — never a single glyph mutating in place.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/indicator-rack/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// IndicatorRack — a plan selector built on the indicator-tablet rack of a\n// pre-electronic cash register (NCR, c. 1900-1950). Every possible reading\n// is a separate printed tablet, permanently present, edge-on at the foot of\n// its column; pressing a key springs one tablet up into the window while the\n// tablet previously showing drops back onto the stack. Changing the reading\n// is a SORT — two plates trade places and visibly cross — never a mutation\n// of one glyph in place (that's split-flap-board's mechanism; see below).\n//\n// Four columns (tens / units / cents / term), ten tablets each (digits 0-9),\n// all ten always mounted — nothing is created or destroyed on a change, only\n// repositioned. Column geometry derives from the container's SMALLER\n// dimension: lift = clamp(0.19 * min(w,h), 44, 96)px (the spec's reference\n// figures assume a 62px lift). Selecting a plan or a billing term changes\n// the digit shown in one or more columns; each changed column runs two\n// independent WAAPI animations on its own tablet pair — a spring RAISE\n// (40ms latch hold, 210ms lift with a 4px overshoot, 90ms settle = 340ms)\n// starting immediately, and a gravity DROP (178ms fall with a 2px\n// overshoot-past-rest bounce, 70ms damping = 248ms) starting 25ms later, so\n// the latch's release of the old tablet visibly precedes the spring's\n// release of the new one — the offset that makes the two plates cross mid\n// column instead of trading places invisibly. A rapid re-selection cancels\n// whatever is in flight on that column (reading its LIVE computed\n// translateY first, so the interrupted plate keeps moving from wherever it\n// actually is, never snapping) and re-targets from there; the real DOM\n// price text and the aria-live announcement are never gated on the\n// animation, they update the instant a selection commits.\n//\n// Idle, unconditional, always running (this is what keeps the control alive\n// at rest with zero input): a 40px shop-light reflection band crosses the\n// window every 6.00s; a pivoted \"AMT\" flag above the window swings ±1.2° on\n// a 3.40s period and never damps; a drive-shaft pawl indexes the whole rack\n// 0.5px sideways every 1.60s. All three are pure CSS `animation: infinite`\n// loops, contrast-capped so none of them competes with the real price text\n// sitting outside the window.\n//\n// prefers-reduced-motion freezes the DECORATIVE rack (aria-hidden) on one\n// composed still frame — flag held at its +1.2° extreme, reflection held\n// mid-window, and every column's selected tablet held mid-crossing (41px up)\n// against a synthetic predecessor (the previous digit, cyclically) held\n// mid-fall (22px down) — a pure function of the selected plan and a frozen\n// clock, no PRNG, byte-stable over time. The real radios, the real price\n// text and the aria-live region are unaffected: the CONTROL stays fully\n// functional, only its illustration stops moving and stops literally\n// tracking the animation curve.\n//\n// Distinct from split-flap-board: a flap cell MUTATES in place — one hinged\n// leaf rotates through a glyph set, so values it isn't showing don't exist\n// on screen. A tablet rack is a SORT — every value is a separate plate\n// permanently in frame, and changing the reading costs the travel of two\n// plates moving in opposite directions past each other. Distinct from\n// counter-carry-ripple: that component is about a carry propagating between\n// columns; here the four columns are mechanically independent and read that\n// way, no carry ever crosses a column boundary.\n// ---------------------------------------------------------------------------\n\nexport interface IndicatorRackPlan {\n  /** stable identifier, used as the native radio's value */\n  value: string;\n  label: string;\n}\n\nexport type IndicatorRackTerm = \"monthly\" | \"annual\";\n\nexport interface IndicatorRackProps {\n  /** accessible name for the plan radiogroup. @default \"Plan\" */\n  label?: string;\n  /** the plan options. @default three placeholder plans, Plan A/B/C */\n  plans?: IndicatorRackPlan[];\n  /** controlled selected plan value */\n  value?: string;\n  /** initial plan when uncontrolled. @default plans[0]?.value */\n  defaultValue?: string;\n  /** called with the new plan value when a different plan is selected */\n  onValueChange?: (value: string) => void;\n  /** controlled billing term */\n  term?: IndicatorRackTerm;\n  /** initial billing term when uncontrolled. @default \"monthly\" */\n  defaultTerm?: IndicatorRackTerm;\n  /** called with the new term when it changes */\n  onTermChange?: (term: IndicatorRackTerm) => void;\n  /** shared `name` for the plan radios. @default a generated id */\n  name?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ntype Vars = React.CSSProperties & Record<`--${string}`, string | number>;\n\nconst DEFAULT_PLANS: IndicatorRackPlan[] = [\n  { value: \"a\", label: \"Plan A\" },\n  { value: \"b\", label: \"Plan B\" },\n  { value: \"c\", label: \"Plan C\" },\n];\n\n// The rack reads out the plan's figure the way a register does — leading\n// zeros included — so the three digit columns ARE the price text beside them.\n// The three placeholder figures are chosen so every column changes on at\n// least one transition: a column that never moves reads as decoration.\nconst PLAN_AMOUNTS: Record<string, number> = { a: 19, b: 42, c: 128 };\nconst PLAN_DIGITS: Record<string, [number, number, number]> = {\n  a: [0, 1, 9],\n  b: [0, 4, 2],\n  c: [1, 2, 8],\n};\nconst FALLBACK_DIGITS: [number, number, number] = [0, 0, 0];\nconst TERM_DIGIT: Record<IndicatorRackTerm, number> = { monthly: 0, annual: 1 };\n// The term column carries the same ten plates as every other column, but its\n// two reachable ones are printed M and A: a bare 0/1 in a price readout reads\n// as another digit, which is exactly the wrong thing for it to say.\nconst TERM_FACES = [\"M\", \"A\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"] as const;\n\nconst COLUMN_LABELS = [\"HUND\", \"TENS\", \"UNITS\", \"TERM\"] as const;\nconst TABLET_COUNT = 10;\nconst STACK_BAND_H = 30; // px — fixed, not derived (spec §3)\nconst PITCH = 3; // px — fixed pitch between down-stacked tablet edges\nconst LIFT_RATIO = 0.19;\nconst LIFT_MIN = 44;\nconst LIFT_MAX = 96;\nconst RAISED_Y = 0; // a raised tablet fills the window; rest is below it\n\nconst RAISE_LATCH_MS = 40;\nconst RAISE_SPRING_MS = 210;\nconst RAISE_SETTLE_MS = 90;\nconst RAISE_TOTAL_MS = RAISE_LATCH_MS + RAISE_SPRING_MS + RAISE_SETTLE_MS; // 340\nconst RAISE_OVERSHOOT_PX = 4;\nconst RAISE_DELAY_MS = 0;\n\nconst DROP_FALL_MS = 178;\nconst DROP_BOUNCE_MS = 70;\nconst DROP_TOTAL_MS = DROP_FALL_MS + DROP_BOUNCE_MS; // 248\nconst DROP_BOUNCE_PX = 2;\nconst DROP_DELAY_MS = 25; // the offset that makes the plates cross\n\nconst ANNOUNCE_DEBOUNCE_MS = 350;\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\n/** Reads the tablet's LIVE on-screen translateY (px) from its computed\n * transform matrix, so an interrupted animation can be re-targeted from\n * where the plate actually is rather than snapping to its rest style. */\nfunction readTranslateY(el: HTMLElement): number {\n  const t = getComputedStyle(el).transform;\n  if (!t || t === \"none\") return 0;\n  const m = t.match(/matrix\\(([^)]+)\\)/);\n  if (!m) return 0;\n  const parts = m[1]!.split(\",\").map((n) => parseFloat(n.trim()));\n  return parts[5] ?? 0;\n}\n\nfunction restY(digit: number, lift: number): number {\n  return lift + digit * PITCH;\n}\n\ninterface ColumnHandle {\n  tablets: (HTMLDivElement | null)[];\n  anims: (Animation | null)[];\n  digit: number;\n}\n\nfunction makeColumn(digit: number): ColumnHandle {\n  return {\n    tablets: new Array(TABLET_COUNT).fill(null),\n    anims: new Array(TABLET_COUNT).fill(null),\n    digit,\n  };\n}\n\nexport function IndicatorRack({\n  label = \"Plan\",\n  plans = DEFAULT_PLANS,\n  value,\n  defaultValue,\n  onValueChange,\n  term,\n  defaultTerm = \"monthly\",\n  onTermChange,\n  name,\n  className = \"\",\n}: IndicatorRackProps) {\n  const generatedName = useId();\n  const groupName = name ?? generatedName;\n\n  const isPlanControlled = value !== undefined;\n  const [internalPlan, setInternalPlan] = useState(\n    () => defaultValue ?? plans[0]?.value ?? \"\"\n  );\n  const committedPlan = isPlanControlled ? (value as string) : internalPlan;\n  const activePlan = plans.find((p) => p.value === committedPlan) ?? plans[0];\n\n  const isTermControlled = term !== undefined;\n  const [internalTerm, setInternalTerm] = useState<IndicatorRackTerm>(defaultTerm);\n  const committedTerm = isTermControlled ? (term as IndicatorRackTerm) : internalTerm;\n\n  const reducedMotion = useReducedMotion();\n\n  const commitPlan = useCallback(\n    (v: string) => {\n      if (!isPlanControlled) setInternalPlan(v);\n      if (v !== committedPlan) onValueChange?.(v);\n    },\n    [isPlanControlled, committedPlan, onValueChange]\n  );\n  const commitTerm = useCallback(\n    (t: IndicatorRackTerm) => {\n      if (!isTermControlled) setInternalTerm(t);\n      if (t !== committedTerm) onTermChange?.(t);\n    },\n    [isTermControlled, committedTerm, onTermChange]\n  );\n\n  // -- geometry: derived from the container's smaller dimension -----------\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const [lift, setLift] = useState(62);\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      const next = clamp(LIFT_RATIO * Math.min(box.width, box.height), LIFT_MIN, LIFT_MAX);\n      setLift(next);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const columnWidth = clamp(lift * 0.62, 28, 64);\n\n  // -- rack digits for the current plan + term -----------------------------\n  const [tens, units, cents] = PLAN_DIGITS[activePlan?.value ?? \"\"] ?? FALLBACK_DIGITS;\n  const amount = PLAN_AMOUNTS[activePlan?.value ?? \"\"] ?? 0;\n  const termDigit = TERM_DIGIT[committedTerm];\n  const digits = useMemo(() => [tens, units, cents, termDigit], [tens, units, cents, termDigit]);\n\n  const columnsRef = useRef<ColumnHandle[]>(digits.map((d) => makeColumn(d)));\n\n  // -- raise/drop: one column at a time, cancel-and-retarget on interrupt --\n  useLayoutEffect(() => {\n    if (reducedMotion) {\n      columnsRef.current.forEach((col, ci) => {\n        col.anims.forEach((a) => a?.cancel());\n        col.anims.fill(null);\n        col.digit = digits[ci]!;\n      });\n      return;\n    }\n\n    digits.forEach((targetDigit, ci) => {\n      const col = columnsRef.current[ci];\n      if (!col || col.digit === targetDigit) return;\n      const outgoing = col.digit;\n      const incoming = targetDigit;\n      const outEl = col.tablets[outgoing];\n      const inEl = col.tablets[incoming];\n      col.digit = targetDigit;\n\n      if (outEl) {\n        const fromY = col.anims[outgoing] ? readTranslateY(outEl) : RAISED_Y;\n        col.anims[outgoing]?.cancel();\n        const toRestY = restY(outgoing, lift);\n        const anim = outEl.animate(\n          [\n            { transform: `translateY(${fromY}px)`, offset: 0 },\n            {\n              transform: `translateY(${toRestY + DROP_BOUNCE_PX}px)`,\n              offset: DROP_FALL_MS / DROP_TOTAL_MS,\n              easing: \"cubic-bezier(0.55,0,1,0.45)\",\n            },\n            { transform: `translateY(${toRestY}px)`, offset: 1, easing: \"ease-out\" },\n          ],\n          { duration: DROP_TOTAL_MS, delay: DROP_DELAY_MS, fill: \"forwards\", easing: \"linear\" }\n        );\n        col.anims[outgoing] = anim;\n        anim.onfinish = () => {\n          if (col.anims[outgoing] === anim) col.anims[outgoing] = null;\n        };\n      }\n\n      if (inEl) {\n        const fromY = col.anims[incoming] ? readTranslateY(inEl) : restY(incoming, lift);\n        col.anims[incoming]?.cancel();\n        const anim = inEl.animate(\n          [\n            { transform: `translateY(${fromY}px)`, offset: 0 },\n            { transform: `translateY(${fromY}px)`, offset: RAISE_LATCH_MS / RAISE_TOTAL_MS },\n            {\n              transform: `translateY(${RAISED_Y - RAISE_OVERSHOOT_PX}px)`,\n              offset: (RAISE_LATCH_MS + RAISE_SPRING_MS) / RAISE_TOTAL_MS,\n              easing: \"cubic-bezier(0.34,1.56,0.64,1)\",\n            },\n            { transform: `translateY(${RAISED_Y}px)`, offset: 1, easing: \"ease-out\" },\n          ],\n          { duration: RAISE_TOTAL_MS, delay: RAISE_DELAY_MS, fill: \"forwards\", easing: \"linear\" }\n        );\n        col.anims[incoming] = anim;\n        anim.onfinish = () => {\n          if (col.anims[incoming] === anim) col.anims[incoming] = null;\n        };\n      }\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [digits, lift, reducedMotion]);\n\n  useEffect(() => {\n    return () => {\n      columnsRef.current.forEach((col) => col.anims.forEach((a) => a?.cancel()));\n    };\n  }, []);\n\n  // -- aria-live: announce on commit only, debounced -----------------------\n  const [announceText, setAnnounceText] = useState(\"\");\n  const announceTimerRef = useRef<number | undefined>(undefined);\n  useEffect(() => {\n    window.clearTimeout(announceTimerRef.current);\n    announceTimerRef.current = window.setTimeout(() => {\n      const billed = committedTerm === \"annual\" ? \"annually\" : \"monthly\";\n      setAnnounceText(\n        `${activePlan?.label}, ${amount} dollars per month, billed ${billed}.`\n      );\n    }, ANNOUNCE_DEBOUNCE_MS);\n    return () => window.clearTimeout(announceTimerRef.current);\n  }, [activePlan, committedTerm, amount]);\n\n  // -- Home/End roving to the rails (arrows + Space are native on radios) --\n  const planInputRefs = useRef<(HTMLInputElement | null)[]>([]);\n  const termInputRefs = useRef<(HTMLInputElement | null)[]>([]);\n  const railKeyDown = useCallback(\n    (\n      e: React.KeyboardEvent<HTMLInputElement>,\n      refs: React.MutableRefObject<(HTMLInputElement | null)[]>,\n      commit: (v: string) => void,\n      values: string[]\n    ) => {\n      if (e.key !== \"Home\" && e.key !== \"End\") return;\n      e.preventDefault();\n      const idx = e.key === \"Home\" ? 0 : refs.current.length - 1;\n      const el = refs.current[idx];\n      if (el) {\n        el.focus();\n        el.checked = true;\n        commit(values[idx]!);\n      }\n    },\n    []\n  );\n\n  const billedLabel = committedTerm === \"annual\" ? \"billed annually\" : \"billed monthly\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={`ns-ir-root ${className}`}\n      style={{ \"--fr-lift\": `${lift}px`, \"--fr-col-w\": `${columnWidth}px` } as Vars}\n    >\n      <style>{CSS}</style>\n\n      <div className=\"ns-ir-rack\" aria-hidden=\"true\" data-reduced={reducedMotion || undefined}>\n        <div className=\"ns-ir-window\">\n          <div className=\"ns-ir-cur\">$</div>\n          {digits.map((selectedDigit, ci) => {\n            const predecessor = (selectedDigit + TABLET_COUNT - 1) % TABLET_COUNT;\n            return (\n              <div className=\"ns-ir-column\" key={ci}>\n                <div className=\"ns-ir-stack\">\n                  {Array.from({ length: TABLET_COUNT }, (_, digit) => {\n                    let reducedTransform: string | undefined;\n                    if (reducedMotion) {\n                      if (digit === selectedDigit) {\n                        // still rising: short of seated by 21/62 of the lift\n                        reducedTransform = `translateY(${(21 / 62) * lift}px)`;\n                      } else if (digit === predecessor) {\n                        // still falling: short of its rest by 22/62 of the lift\n                        reducedTransform = `translateY(${restY(digit, lift) - (22 / 62) * lift}px)`;\n                      } else {\n                        reducedTransform = `translateY(${restY(digit, lift)}px)`;\n                      }\n                    }\n                    return (\n                      <div\n                        key={digit}\n                        ref={(el) => {\n                          columnsRef.current[ci].tablets[digit] = el;\n                        }}\n                        className=\"ns-ir-tablet\"\n                        style={{\n                          transform:\n                            reducedTransform ??\n                            `translateY(${digit === selectedDigit ? RAISED_Y : restY(digit, lift)}px)`,\n                        }}\n                      >\n                        <span>{ci === 3 ? TERM_FACES[digit] : digit}</span>\n                      </div>\n                    );\n                  })}\n                </div>\n              </div>\n            );\n          })}\n          <div className=\"ns-ir-reflection\" />\n        </div>\n        <div className=\"ns-ir-flag\" />\n        <div className=\"ns-ir-labels\">\n          <span className=\"ns-ir-labels-cur\" />\n          {COLUMN_LABELS.map((l) => (\n            <span key={l}>{l}</span>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"ns-ir-controls\">\n        <div role=\"radiogroup\" aria-label={label} className=\"ns-ir-radiogroup\">\n          {plans.map((p, i) => (\n            <label key={p.value} className=\"ns-ir-option\">\n              <input\n                ref={(el) => {\n                  planInputRefs.current[i] = el;\n                }}\n                type=\"radio\"\n                className=\"ns-ir-input\"\n                name={groupName}\n                value={p.value}\n                checked={p.value === committedPlan}\n                onChange={() => commitPlan(p.value)}\n                onKeyDown={(e) =>\n                  railKeyDown(e, planInputRefs, commitPlan, plans.map((o) => o.value))\n                }\n              />\n              <span className=\"ns-ir-option-face\">{p.label}</span>\n            </label>\n          ))}\n        </div>\n\n        <div role=\"radiogroup\" aria-label=\"Billing term\" className=\"ns-ir-radiogroup ns-ir-radiogroup-term\">\n          {([\"monthly\", \"annual\"] as IndicatorRackTerm[]).map((t, i) => (\n            <label key={t} className=\"ns-ir-option ns-ir-option-term\">\n              <input\n                ref={(el) => {\n                  termInputRefs.current[i] = el;\n                }}\n                type=\"radio\"\n                className=\"ns-ir-input\"\n                name={`${groupName}-term`}\n                value={t}\n                checked={t === committedTerm}\n                onChange={() => commitTerm(t)}\n                onKeyDown={(e) =>\n                  railKeyDown(e, termInputRefs, (v) => commitTerm(v as IndicatorRackTerm), [\"monthly\", \"annual\"])\n                }\n              />\n              <span className=\"ns-ir-option-face\">{t === \"monthly\" ? \"Monthly\" : \"Annual\"}</span>\n            </label>\n          ))}\n        </div>\n\n        <p className=\"ns-ir-price\">\n          <span className=\"ns-ir-price-plan\">{activePlan?.label}</span>\n          <span className=\"ns-ir-price-word\">\n            <span className=\"ns-ir-price-cur\">$</span>\n            {amount}\n          </span>\n          <span className=\"ns-ir-price-meta\">per month, {billedLabel}</span>\n        </p>\n\n        <div className=\"ns-ir-actions\">\n          <button type=\"button\" className=\"ns-ir-cta\">\n            Primary action\n          </button>\n          <button type=\"button\" className=\"ns-ir-link\">\n            Secondary link\n          </button>\n        </div>\n      </div>\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"ns-ir-sr-only\">\n        {announceText}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-ir-root{\n  display:flex;\n  flex-direction:column;\n  gap:16px;\n  container-type:inline-size;\n}\n.ns-ir-sr-only{\n  position:absolute;\n  width:1px;height:1px;\n  padding:0;margin:-1px;\n  overflow:hidden;\n  clip:rect(0,0,0,0);\n  white-space:nowrap;\n  border:0;\n}\n.ns-ir-rack{\n  position:relative;\n  align-self:flex-start;\n  padding:10px 10px 4px;\n  border:1px solid var(--border);\n  border-radius:8px;\n  background:var(--background);\n}\n.ns-ir-window{\n  position:relative;\n  display:flex;\n  gap:calc(var(--fr-col-w) * 0.14);\n  height:calc(var(--fr-lift) + ${STACK_BAND_H}px);\n  overflow:hidden;\n  border-radius:4px;\n}\n.ns-ir-column{\n  position:relative;\n  width:var(--fr-col-w);\n  height:100%;\n  overflow:hidden;\n  border-left:1px solid color-mix(in oklab, var(--ns-muted) 30%, transparent);\n}\n.ns-ir-column:first-child{ border-left:none; }\n.ns-ir-stack{\n  position:relative;\n  width:100%;\n  height:100%;\n}\n.ns-ir-tablet{\n  position:absolute;\n  top:0;\n  left:0;\n  width:100%;\n  height:var(--fr-lift);\n  display:flex;\n  align-items:center;\n  justify-content:center;\n  box-sizing:border-box;\n  /* a printed plate, not a hole in the rack: one step off the rack's own\n     background so the down-stacked edges read as a stack of plates rather\n     than as pinstriping, and the raised plate reads as a face. */\n  background:color-mix(in oklab, var(--background) 90%, var(--foreground) 10%);\n  color:var(--foreground);\n  border-top:1px solid color-mix(in oklab, var(--ns-muted) 75%, transparent);\n  border-bottom:1px solid color-mix(in oklab, var(--background) 55%, var(--foreground) 45%);\n  font-family:var(--font-geist-mono, ui-monospace, monospace);\n  font-size:calc(var(--fr-col-w) * 0.72);\n  font-weight:500;\n  line-height:1;\n  font-variant-numeric:tabular-nums;\n  will-change:transform;\n}\n.ns-ir-cur{\n  display:flex;\n  align-items:center;\n  justify-content:center;\n  width:calc(var(--fr-col-w) * 0.6);\n  height:var(--fr-lift);\n  flex:none;\n  font-family:var(--font-geist-mono, ui-monospace, monospace);\n  font-size:calc(var(--fr-col-w) * 0.5);\n  color:var(--ns-muted);\n}\n.ns-ir-labels-cur{\n  width:calc(var(--fr-col-w) * 0.6) !important;\n  flex:none;\n}\n.ns-ir-reflection{\n  position:absolute;\n  top:0;\n  left:-40px;\n  width:40px;\n  height:100%;\n  background:linear-gradient(\n    90deg,\n    transparent,\n    color-mix(in oklab, var(--background) 90%, var(--foreground) 10%),\n    transparent\n  );\n  mix-blend-mode:overlay;\n  opacity:0.14;\n  pointer-events:none;\n  animation:ns-ir-reflect 6s linear infinite;\n}\n.dark .ns-ir-reflection{\n  background:linear-gradient(\n    90deg,\n    transparent,\n    color-mix(in oklab, var(--foreground) 55%, var(--background) 45%),\n    transparent\n  );\n}\n@keyframes ns-ir-reflect{\n  from{ transform:translateX(0); }\n  to{ transform:translateX(196px); }\n}\n.ns-ir-flag{\n  position:absolute;\n  top:2px;\n  right:14px;\n  width:2px;\n  height:7px;\n  background:var(--ns-muted);\n  transform-origin:top center;\n  animation:ns-ir-flag 3.4s ease-in-out infinite;\n}\n@keyframes ns-ir-flag{\n  0%,100%{ transform:rotate(1.2deg); }\n  50%{ transform:rotate(-1.2deg); }\n}\n.ns-ir-rack:not([data-reduced]) .ns-ir-window{\n  animation:ns-ir-tick 3.2s ease-in-out infinite;\n}\n@keyframes ns-ir-tick{\n  0%,7%{ transform:translateX(0); }\n  50%{ transform:translateX(0.5px); }\n  57%,100%{ transform:translateX(0.5px); }\n}\n.ns-ir-rack[data-reduced] .ns-ir-reflection{\n  animation:none;\n  transform:translateX(9px);\n  opacity:0.14;\n}\n.ns-ir-rack[data-reduced] .ns-ir-flag{\n  animation:none;\n  transform:rotate(1.2deg);\n}\n.ns-ir-rack[data-reduced] .ns-ir-window{\n  animation:none;\n}\n.ns-ir-labels{\n  display:flex;\n  gap:calc(var(--fr-col-w) * 0.14);\n  margin-top:4px;\n}\n.ns-ir-labels span{\n  width:var(--fr-col-w);\n  font-family:var(--font-geist-mono, ui-monospace, monospace);\n  font-size:8px;\n  letter-spacing:0.1em;\n  text-align:center;\n  color:var(--ns-muted);\n}\n\n.ns-ir-controls{\n  display:flex;\n  flex-direction:column;\n  gap:12px;\n}\n.ns-ir-radiogroup{\n  display:flex;\n  gap:6px;\n  flex-wrap:wrap;\n}\n.ns-ir-option{\n  position:relative;\n  cursor:pointer;\n  -webkit-tap-highlight-color:transparent;\n}\n.ns-ir-input{\n  position:absolute;\n  width:1px;height:1px;\n  padding:0;margin:-1px;\n  overflow:hidden;\n  clip:rect(0,0,0,0);\n  white-space:nowrap;\n  border:0;\n}\n.ns-ir-option-face{\n  display:inline-flex;\n  align-items:center;\n  justify-content:center;\n  min-height:36px;\n  padding:6px 14px;\n  border:1px solid var(--border);\n  border-radius:6px;\n  font-size:0.8125rem;\n  color:var(--ns-muted);\n  transition:color 140ms ease, border-color 140ms ease, background-color 140ms ease;\n}\n.ns-ir-input:checked + .ns-ir-option-face{\n  color:var(--foreground);\n  border-color:var(--foreground);\n  background:color-mix(in oklab, var(--foreground) 5%, transparent);\n}\n.ns-ir-input:focus-visible + .ns-ir-option-face{\n  outline:2px solid var(--ns-accent);\n  outline-offset:2px;\n}\n.ns-ir-option-term .ns-ir-option-face{ min-height:32px; padding:5px 12px; font-size:0.75rem; }\n\n.ns-ir-price{\n  display:flex;\n  align-items:baseline;\n  gap:8px;\n  margin:0;\n}\n.ns-ir-price-plan{\n  font-size:0.75rem;\n  color:var(--ns-muted);\n}\n.ns-ir-price-word{\n  font-family:var(--font-geist-mono, ui-monospace, monospace);\n  font-size:1.05rem;\n  font-weight:500;\n  color:var(--foreground);\n  letter-spacing:0.04em;\n}\n.ns-ir-price-meta{\n  font-size:0.75rem;\n  color:var(--ns-muted);\n}\n\n.ns-ir-actions{\n  display:flex;\n  align-items:center;\n  gap:14px;\n}\n.ns-ir-cta{\n  min-height:40px;\n  padding:0 18px;\n  border:1px solid var(--ns-accent);\n  border-radius:6px;\n  background:var(--ns-accent);\n  color:var(--background);\n  font-size:0.8125rem;\n  font-weight:500;\n  cursor:pointer;\n}\n.ns-ir-cta:focus-visible{\n  outline:2px solid var(--ns-accent);\n  outline-offset:2px;\n}\n.ns-ir-link{\n  border:none;\n  background:none;\n  padding:0;\n  font-size:0.8125rem;\n  color:var(--ns-muted);\n  text-decoration:underline;\n  text-underline-offset:3px;\n  cursor:pointer;\n}\n.ns-ir-link:focus-visible{\n  outline:2px solid var(--ns-accent);\n  outline-offset:2px;\n}\n\n@media (prefers-reduced-motion: reduce){\n  .ns-ir-tablet{ transition:none !important; }\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/indicator-rack.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": "core",
    "tags": [
      "pricing",
      "plan-selector",
      "radiogroup",
      "aria-live",
      "mechanical",
      "cash-register",
      "flag-rack",
      "waapi"
    ],
    "instruction": "Build a plan-selector/price-reveal control (`IndicatorRack`, props: `label` default \"Plan\", `plans: {value,label}[]` default three placeholder plans Plan A/B/C, `value`/`defaultValue`/`onValueChange` for the controlled/uncontrolled plan, `term`/`defaultTerm`(\"monthly\"|\"annual\", default \"monthly\")/`onTermChange`, `name`, `className`) modeled on the indicator-tablet rack of a pre-electronic NCR cash register. Render an `aria-hidden` decorative rack of FOUR columns (hundreds/tens/units/term, labelled HUND/TENS/UNITS/TERM underneath in 8px mono uppercase, with a static `$` plate to their left so the rack reads as a price at a glance) each holding TEN absolutely-positioned tablet divs (digits 0-9), all ten always mounted for every column — nothing is created or destroyed on a change, only repositioned via `translateY`. Column geometry derives from the container's SMALLER dimension via ResizeObserver: `lift = clamp(0.19 * min(w,h), 44, 96)`px (reference figures assume a 62px lift), `columnWidth = clamp(lift * 0.62, 28, 64)`px. A tablet's rest (down-stacked, unselected) position is `translateY(lift + digit*3)`px — a fixed 3px pitch between the ten edges, producing a foreshortened 30px band of plate-edge slivers at the foot of the column when the window (`height: lift + 30px`, `overflow:hidden`) clips each tablet's own `lift`-tall body; the selected tablet's raised position is `translateY(0)` — the top of the window, where its face is visible; a raised position of `-lift` puts the whole plate above the window and the readout renders empty, filling the window. On a plan or term change, diff old vs new digit per column (plan digits ARE the plan's figure, read out the way a register reads one (leading zeros included): `{a:[0,1,9], b:[0,4,2], c:[1,2,8]}` for hundreds/tens/units, i.e. the placeholder figures 19 / 42 / 128, chosen so every column changes on at least one transition; term digit is monthly=0/annual=1, but the term column's plates are printed M and A rather than 0 and 1 (a bare digit there reads as another price digit)) and, for every changed column, run two independent Web Animations API animations on that column's outgoing and incoming tablet elements: RAISE (incoming tablet, `delay:0`, `duration:340`) keyframes translateY [restY -> restY (hold to 40ms, the latch-release wait) -> -4px at 250ms with a `cubic-bezier(0.34,1.56,0.64,1)` overshoot easing -> 0 at 340ms with `ease-out` settle]; DROP (outgoing tablet, `delay:25`, `duration:248`) keyframes translateY [0 -> restY+2px at 178ms with `cubic-bezier(0.55,0,1,0.45)` gravity easing -> restY at 248ms `ease-out` bounce-damp]. The 25ms delay on the drop relative to the raise's `delay:0` is what makes the two plates visibly cross mid-column — the whole point of the mechanism; do not tween a single value between old and new instead. A re-selection that lands before the previous change has settled must cancel the in-flight `Animation` on the affected tablet(s) AFTER reading its LIVE `translateY` via `getComputedStyle`'s transform matrix (so the interrupted plate keeps moving from wherever it visually is, never snapping), and re-target a fresh animation from that value — the rack must never gate interaction, and the real DOM price text updates immediately regardless of animation state. Idle, unconditional, always-running CSS `animation:infinite` loops (the 'alive at rest' requirement): a 40px shop-light reflection band sweeps the window left to right, 26px/s, 6.00s linear period (`mix-blend-mode:overlay`, opacity ~0.35, `+` in dark theme / `-` in light theme via a `.dark` selector swap of the gradient's mixed color, capped so it never competes with the real price text outside the window); a 2px-wide flag tick above the window rotates `rotate(1.2deg)` to `rotate(-1.2deg)` and back, `ease-in-out`, 3.4s, never damping; the whole rack window nudges `translateX(0.5px)` and back on a 3.2s two-tick cycle standing in for a 1.6s drive-shaft pawl index. `prefers-reduced-motion: reduce` (via `matchMedia` + change listener) cancels all in-flight tablet Animations and freezes the DECORATIVE rack only on one composed still frame, independent of real interaction state but a pure function of the currently selected plan/term (no PRNG, byte-stable over time): the reflection fixed at `translateX(9px)`, the flag fixed at `rotate(1.2deg)`, the rack-tick animation removed, and per column the CURRENTLY selected tablet held at `translateY((21/62)*lift)` (still rising, short of seated at 0) while its cyclic predecessor digit `(selected + 9) % 10` is held at `translateY(restY(predecessor) - (22/62)*lift)` (still falling, short of its rest) — both inside the window, so the freeze frame shows the crossing rather than an empty rack — a permanently mid-crossing pair that still legibly differs per plan, while every other tablet sits at its normal rest translateY. This freeze is decorative only: the real radios, the real DOM price paragraph, and the aria-live region remain fully live and functional under reduced motion, they simply don't animate. Accessibility: two real `role=\"radiogroup\"` sets of native `<input type=\"radio\">` (visually clipped to 1x1px, not display:none, kept in the tab order) — one for plan, one for billing term — get arrow-key roving and Space re-affirmation for free from the browser; add a manual `onKeyDown` handler per radio for Home/End (not native on radio groups) that focuses and checks the first/last option in that group. Focus order in DOM: plan radios, then term radios, then the primary CTA button (`--ns-accent` fill, the ONLY use of `--ns-accent` in the component — never in the reflection band), then a secondary text-button. The real, always-visible price line is plain DOM text (`{planLabel} ${amount} per month, billed monthly|annually`, where amount is the same placeholder figure the rack is reading out — the literal word PRICE is not a price and fails the two-second test) sitting beside the aria-hidden rack. A single `role=\"status\" aria-live=\"polite\" aria-atomic=\"true\"` region announces on commit only, restarted on every plan/term change and firing after a 350ms debounce so arrow-key traversal through several plans produces one announcement: `\"{Plan label}, {amount} dollars per month, billed monthly|annually.\"`. Tokens: tablets are `--background` plates with `--foreground` digits and `--ns-muted`-mixed hairline top/bottom borders; column separators and the rack's own border are `--border`/`--ns-muted` mixes, never a bare `--border` fill (it is a ~1.1:1 separator, invisible as a fill); `--ns-accent` appears only on the CTA fill and on `:focus-visible` outlines. Pure DOM/CSS + Web Animations API, no canvas, no SVG, zero color literals."
  },
  "type": "registry:ui"
}