{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "frank-register",
  "title": "Frank Register",
  "description": "Paying from a prepaid wallet rendered as a postal franking meter: committing stamps an indicium onto the invoice while a descending BALANCE odometer rolls down and an ascending SPENT odometer rolls up by the same amount, the two always summing to credits purchased.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/frank-register/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FrankRegister — a postal franking meter for paying out of a prepaid wallet.\n// Committing stamps an indicium (amount / date / serial) onto an invoice\n// while two odometer stacks roll: the descending BALANCE register rolls\n// down by exactly the paid amount, the ascending SPENT register rolls up by\n// the same amount. The two are never tracked as independent numbers — spent\n// is *derived* as (creditsPurchased - balance) every render, so the\n// reconciliation invariant (balance + spent === creditsPurchased) holds by\n// construction, not by hoping two separately-animated counters stay in\n// sync. One `commit(amountCents)` call is the single governing scalar that\n// feeds the balance delta, the frank's printed amount, and the die-drop\n// trigger — one gear train, not three independent tweens.\n//\n// ODOMETER MECHANISM: each digit column is a vertically clipped cell over a\n// 30-row strip of the sequence 0..9 repeated three times (indices 0-29).\n// The \"home\" band is indices 10-19, where index (10+d) shows digit d. A\n// commit computes, per column, a `rest` row (home row for the old digit),\n// a `play` row (home row for the new digit, OVERSHOT by +10 if the digit\n// wrapped forward, or UNDERSHOT by -10 if it wrapped backward), and a\n// `final` row (home row for the new digit, always back in 10-19). The cell\n// renders at `rest` while unarmed and `play` once armed, transitioning\n// over 400ms; once the transition settles, state snaps instantly (no\n// transition) from `play` to `final` — a jump of exactly one strip period,\n// so the same digit is on screen before and after and the cut is\n// invisible. Because the ascending register's row only ever climbs and the\n// descending register's row only ever falls, the two wheel families spin\n// in opposite senses from the same 400ms drive — mechanically mirrored,\n// not two unrelated tweens. Carries stagger 40ms per column, right-to-left\n// (rightmost changed place first), computed the same way as this\n// registry's counter-carry-ripple digit diffing, but the visual here is a\n// continuous odometer roll, never a single flipped glyph.\n//\n// Pure DOM + SVG + CSS, no canvas. All ink is token-relative (--background,\n// --foreground, --ns-muted, --border; --ns-accent only for the keyboard\n// focus ring). Reduced motion snaps every wheel straight to its final\n// digit and the die never travels.\n// ---------------------------------------------------------------------------\n\nconst ROW_EM = 1.15; // one digit row's clipped height\nconst STRIP_LEN = 30; // three full 0-9 cycles; home band is 10-19\nconst HOME = 10;\nconst STRIP = Array.from({ length: STRIP_LEN }, (_, i) => String(i % 10));\n\nconst ROLL_MS = 400; // wheel roll duration (one gear train, both registers)\nconst STAGGER_MS = 40; // per-column carry stagger, right-to-left\nconst ROLL_EASE = \"cubic-bezier(0.65, 0, 0.35, 1)\"; // mechanical, not springy\n\nconst DIE_DROP_MS = 140;\nconst DIE_DWELL_MS = 120;\nconst DIE_RETURN_MS = 180;\nconst DIE_DROP_EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\"; // ease-out-expo\nconst PRESS_MS = 90; // frank's one-frame press-darken\n\nconst MONTHS = [\n  \"JAN\", \"FEB\", \"MAR\", \"APR\", \"MAY\", \"JUN\",\n  \"JUL\", \"AUG\", \"SEP\", \"OCT\", \"NOV\", \"DEC\",\n];\n\n// ---------------------------------------------------------------------------\n// money helpers — everything animatable is tracked in integer cents so\n// repeated commits can never drift from float rounding.\n// ---------------------------------------------------------------------------\n\nfunction toCents(dollars: number): number {\n  return Math.round(Math.max(0, dollars) * 100);\n}\n\nfunction fmt(cents: number): string {\n  return `$${(cents / 100).toLocaleString(\"en-US\", {\n    minimumFractionDigits: 2,\n    maximumFractionDigits: 2,\n  })}`;\n}\n\nfunction digitAt(cents: number, place: number): number {\n  if (place >= 0) {\n    const dollars = Math.floor(cents / 100);\n    return Math.floor(dollars / 10 ** place) % 10;\n  }\n  const frac = cents % 100;\n  return place === -1 ? Math.floor(frac / 10) % 10 : frac % 10;\n}\n\nfunction formatFrankDate(d: Date): string {\n  const day = String(d.getDate()).padStart(2, \"0\");\n  return `${day} ${MONTHS[d.getMonth()]} ${String(d.getFullYear()).slice(-2)}`;\n}\n\n// ---------------------------------------------------------------------------\n// wheel column model\n// ---------------------------------------------------------------------------\n\ninterface Col {\n  place: number;\n  rest: number;\n  play: number;\n  final: number;\n  delay: number;\n}\n\nfunction idleCols(cents: number, places: number[]): Col[] {\n  return places.map((place) => {\n    const row = HOME + digitAt(cents, place);\n    return { place, rest: row, play: row, final: row, delay: 0 };\n  });\n}\n\n// `dir` fixes which way THIS register is allowed to wrap: \"asc\" only ever\n// carries forward (9 -> 0), \"desc\" only ever borrows backward (0 -> 9) —\n// true for any single addition/subtraction of one amount, which is all a\n// single commit ever does.\nfunction commitCols(prevCents: number, nextCents: number, places: number[], dir: \"asc\" | \"desc\"): Col[] {\n  const changed = places.filter((p) => digitAt(prevCents, p) !== digitAt(nextCents, p));\n  const minChanged = changed.length ? Math.min(...changed) : 0;\n\n  return places.map((place) => {\n    const d0 = digitAt(prevCents, place);\n    const d1 = digitAt(nextCents, place);\n    const rest = HOME + d0;\n    const final = HOME + d1;\n    let play = final;\n    if (dir === \"asc\" && d1 < d0) play = final + STRIP_LEN / 3; // overshoot: carried through 9->0\n    if (dir === \"desc\" && d1 > d0) play = final - STRIP_LEN / 3; // undershoot: borrowed through 0->9\n    const delay = changed.includes(place) ? STAGGER_MS * (place - minChanged) : 0;\n    return { place, rest, play, final, delay };\n  });\n}\n\nfunction useReducedMotion(): boolean {\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\nfunction WheelRow({ cols, armed }: { cols: Col[]; armed: boolean }) {\n  return (\n    <div aria-hidden=\"true\" className=\"ns-fr-wheel\">\n      {cols.map((col) => {\n        const row = armed ? col.play : col.rest;\n        return (\n          <span key={col.place} className=\"ns-fr-wcell\">\n            <span\n              className=\"ns-fr-wtrack\"\n              style={{\n                transform: `translateY(-${(row * ROW_EM).toFixed(3)}em)`,\n                transition: armed\n                  ? `transform ${ROLL_MS}ms ${ROLL_EASE} ${col.delay}ms`\n                  : \"none\",\n              }}\n            >\n              {STRIP.map((d, i) => (\n                <span key={i} className=\"ns-fr-wdigit\">\n                  {d}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n\nexport interface FrankRegisterProps {\n  /** total credits ever purchased for this wallet — the reconciliation invariant: balance + spent always equals this. */\n  creditsPurchased: number;\n  /** current balance; supplying this makes the component controlled (pair with onCommit). */\n  balance?: number;\n  /** starting balance when uncontrolled. @default creditsPurchased */\n  defaultBalance?: number;\n  /** the amount debited on each commit. */\n  amount: number;\n  /** what the charge is for, printed above the amount. */\n  description?: string;\n  /** verb on the commit button, e.g. \"Charge\" or \"Pay\". @default \"Charge\" */\n  actionLabel?: string;\n  /** fires once a commit lands, with the amount paid and the resulting balance (both in dollars). */\n  onCommit?: (amount: number, nextBalance: number) => void;\n  /** extra classes merged onto the root element. */\n  className?: string;\n}\n\ninterface Frank {\n  amountCents: number;\n  date: string;\n  serial: string;\n}\n\nexport function FrankRegister({\n  creditsPurchased,\n  balance,\n  defaultBalance,\n  amount,\n  description,\n  actionLabel = \"Charge\",\n  onCommit,\n  className = \"\",\n}: FrankRegisterProps) {\n  const creditsCents = toCents(creditsPurchased);\n  const isControlled = balance !== undefined;\n  const [internalCents, setInternalCents] = useState(() =>\n    Math.min(creditsCents, toCents(defaultBalance ?? creditsPurchased))\n  );\n  const balanceCents = Math.min(\n    creditsCents,\n    isControlled ? toCents(balance as number) : internalCents\n  );\n  const spentCents = creditsCents - balanceCents; // derived, not tracked — the invariant can't drift\n\n  if (process.env.NODE_ENV !== \"production\") {\n    console.assert(\n      balanceCents + spentCents === creditsCents,\n      \"frank-register: descending + ascending registers do not reconcile with creditsPurchased\"\n    );\n  }\n\n  const amountCents = toCents(amount);\n  const canCommit = amountCents > 0 && amountCents <= balanceCents;\n\n  const intDigits = Math.max(4, String(Math.floor(creditsCents / 100)).length);\n  const places = [...Array.from({ length: intDigits }, (_, i) => intDigits - 1 - i), -1, -2];\n\n  const reducedMotion = useReducedMotion();\n\n  const [descCols, setDescCols] = useState<Col[]>(() => idleCols(balanceCents, places));\n  const [ascCols, setAscCols] = useState<Col[]>(() => idleCols(spentCents, places));\n  const [armed, setArmed] = useState(false);\n  const prevBalanceRef = useRef(balanceCents);\n  const settleTimer = useRef<number | undefined>(undefined);\n  const armRaf = useRef<number | undefined>(undefined);\n  const mountedRef = useRef(false);\n\n  useLayoutEffect(() => {\n    const prevBalance = prevBalanceRef.current;\n    prevBalanceRef.current = balanceCents;\n\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      return;\n    }\n    if (prevBalance === balanceCents) return;\n\n    window.clearTimeout(settleTimer.current);\n    if (armRaf.current !== undefined) cancelAnimationFrame(armRaf.current);\n\n    const prevSpent = creditsCents - prevBalance;\n\n    if (reducedMotion) {\n      setDescCols(idleCols(balanceCents, places));\n      setAscCols(idleCols(spentCents, places));\n      setArmed(false);\n      return;\n    }\n\n    const nextDesc = commitCols(prevBalance, balanceCents, places, \"desc\");\n    const nextAsc = commitCols(prevSpent, spentCents, places, \"asc\");\n    setDescCols(nextDesc);\n    setAscCols(nextAsc);\n    setArmed(false);\n\n    // two rAFs: the reset above hasn't painted yet this frame, so arming on\n    // the very next rAF can collapse the \"rest\" and \"play\" frames into one\n    // and skip the transition. waiting an extra frame guarantees the rest\n    // pose actually painted before the transition is armed.\n    armRaf.current = requestAnimationFrame(() => {\n      armRaf.current = requestAnimationFrame(() => setArmed(true));\n    });\n\n    const maxDelay = Math.max(\n      0,\n      ...nextDesc.map((c) => c.delay),\n      ...nextAsc.map((c) => c.delay)\n    );\n    settleTimer.current = window.setTimeout(() => {\n      setDescCols((cols) => cols.map((c) => ({ ...c, rest: c.final, play: c.final, delay: 0 })));\n      setAscCols((cols) => cols.map((c) => ({ ...c, rest: c.final, play: c.final, delay: 0 })));\n      setArmed(false);\n    }, maxDelay + ROLL_MS + 30);\n\n    return () => {\n      if (armRaf.current !== undefined) cancelAnimationFrame(armRaf.current);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [balanceCents]);\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(settleTimer.current);\n      if (armRaf.current !== undefined) cancelAnimationFrame(armRaf.current);\n    },\n    []\n  );\n\n  const [frank, setFrank] = useState<Frank | null>(null);\n  const [batch, setBatch] = useState(0);\n  const [diePhase, setDiePhase] = useState<\"rest\" | \"down\" | \"up\">(\"rest\");\n  const [announced, setAnnounced] = useState(\"\");\n  const serialRef = useRef(0);\n  const dieTimers = useRef<number[]>([]);\n\n  const commit = () => {\n    if (!canCommit) return;\n    const nextBalanceCents = balanceCents - amountCents;\n    serialRef.current += 1;\n    const now = new Date();\n    const nextFrank: Frank = {\n      amountCents,\n      date: formatFrankDate(now),\n      serial: String(serialRef.current).padStart(6, \"0\"),\n    };\n\n    // one commit, one governing scalar (amountCents) feeding the balance\n    // update, the frank, and the die trigger together — never three\n    // independently-timed writes.\n    setFrank(nextFrank);\n    setBatch((b) => b + 1);\n    if (!isControlled) setInternalCents(nextBalanceCents);\n    onCommit?.(amountCents / 100, nextBalanceCents / 100);\n    setAnnounced(\n      `Stamped — ${fmt(amountCents)}. Balance ${fmt(nextBalanceCents)} remaining, ` +\n        `${fmt(creditsCents - nextBalanceCents)} spent to date, registers reconcile.`\n    );\n\n    dieTimers.current.forEach((t) => window.clearTimeout(t));\n    dieTimers.current = [];\n    if (!reducedMotion) {\n      setDiePhase(\"down\");\n      dieTimers.current.push(\n        window.setTimeout(() => setDiePhase(\"up\"), DIE_DROP_MS + DIE_DWELL_MS)\n      );\n      dieTimers.current.push(\n        window.setTimeout(() => setDiePhase(\"rest\"), DIE_DROP_MS + DIE_DWELL_MS + DIE_RETURN_MS)\n      );\n    }\n  };\n\n  useEffect(() => () => dieTimers.current.forEach((t) => window.clearTimeout(t)), []);\n\n  const dieTransform = diePhase === \"down\" ? \"translateY(8px)\" : \"translateY(0px)\";\n  const dieTransition =\n    reducedMotion || diePhase === \"rest\"\n      ? \"none\"\n      : diePhase === \"down\"\n        ? `transform ${DIE_DROP_MS}ms ${DIE_DROP_EASE}`\n        : `transform ${DIE_RETURN_MS}ms ease-in`;\n\n  const frankAlt = frank\n    ? `Franking indicium: ${fmt(frank.amountCents)}, stamped ${frank.date}, serial ${frank.serial}`\n    : \"\";\n\n  return (\n    <div className={`ns-fr-root ${className}`} data-reduced={reducedMotion || undefined}>\n      <div className=\"ns-fr-card\">\n        <div className=\"ns-fr-head\">\n          <span className=\"ns-fr-kicker\">Invoice</span>\n          {description && <span className=\"ns-fr-desc\">{description}</span>}\n        </div>\n\n        <div className=\"ns-fr-amount-row\">\n          <span className=\"ns-fr-amount\">{fmt(amountCents)}</span>\n          <span className=\"ns-fr-amount-label\">due</span>\n        </div>\n\n        <div className=\"ns-fr-stampzone\">\n          {frank && (\n            <svg\n              key={batch}\n              role=\"img\"\n              aria-label={frankAlt}\n              viewBox=\"0 0 168 76\"\n              className=\"ns-fr-frank\"\n              style={{ animationDelay: reducedMotion ? \"0ms\" : `${DIE_DROP_MS}ms` }}\n            >\n              <rect x=\"1\" y=\"1\" width=\"166\" height=\"74\" rx=\"6\" className=\"ns-fr-frank-border\" />\n              <g transform=\"translate(38, 38)\" className=\"ns-fr-frank-rosette\">\n                {Array.from({ length: 12 }, (_, i) => {\n                  const a = (i * 30 * Math.PI) / 180;\n                  const x1 = Math.cos(a) * 9;\n                  const y1 = Math.sin(a) * 9;\n                  const x2 = Math.cos(a) * 15;\n                  const y2 = Math.sin(a) * 15;\n                  return (\n                    <line\n                      key={i}\n                      x1={x1.toFixed(2)}\n                      y1={y1.toFixed(2)}\n                      x2={x2.toFixed(2)}\n                      y2={y2.toFixed(2)}\n                      className=\"ns-fr-frank-ray\"\n                    />\n                  );\n                })}\n                <circle r=\"16\" className=\"ns-fr-frank-ring\" />\n                <circle r=\"2.5\" className=\"ns-fr-frank-hub\" />\n              </g>\n              <line x1=\"70\" y1=\"8\" x2=\"70\" y2=\"68\" className=\"ns-fr-frank-div\" />\n              <text x=\"80\" y=\"34\" className=\"ns-fr-frank-amt\">\n                {fmt(frank.amountCents)}\n              </text>\n              <text x=\"80\" y=\"49\" className=\"ns-fr-frank-date\">\n                {frank.date}\n              </text>\n              <text x=\"80\" y=\"63\" className=\"ns-fr-frank-serial\">\n                SER {frank.serial}\n              </text>\n            </svg>\n          )}\n        </div>\n\n        <button\n          type=\"button\"\n          className=\"ns-fr-die\"\n          aria-label={`${actionLabel} ${fmt(amountCents)}`}\n          disabled={!canCommit}\n          onClick={commit}\n          style={{ transform: reducedMotion ? undefined : dieTransform, transition: dieTransition }}\n        >\n          <span aria-hidden=\"true\" className=\"ns-fr-die-label\">\n            {actionLabel}\n          </span>\n          <span aria-hidden=\"true\" className=\"ns-fr-die-amt\">\n            {fmt(amountCents)}\n          </span>\n        </button>\n      </div>\n\n      <div className=\"ns-fr-registers\">\n        <div className=\"ns-fr-register\">\n          <span className=\"ns-fr-reg-caption\">Balance</span>\n          <WheelRow cols={descCols} armed={armed} />\n          <span className=\"ns-fr-reg-mirror\">{fmt(balanceCents)}</span>\n        </div>\n        <div className=\"ns-fr-register\">\n          <span className=\"ns-fr-reg-caption\">Spent to date</span>\n          <WheelRow cols={ascCols} armed={armed} />\n          <span className=\"ns-fr-reg-mirror\">{fmt(spentCents)}</span>\n        </div>\n      </div>\n\n      <p className=\"ns-fr-reconcile\">registers reconcile: {fmt(creditsCents)}</p>\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announced}\n      </div>\n\n      <style>{`\n        .ns-fr-root {\n          display: flex;\n          flex-direction: column;\n          gap: 14px;\n          width: 100%;\n          max-width: 22rem;\n          font-family: var(--font-sans);\n          color: var(--foreground);\n        }\n        .ns-fr-card {\n          position: relative;\n          border: 1px solid var(--border);\n          border-radius: 12px;\n          background: var(--background);\n          padding: 16px 16px 20px;\n          display: flex;\n          flex-direction: column;\n          gap: 10px;\n        }\n        .ns-fr-head {\n          display: flex;\n          flex-direction: column;\n          gap: 2px;\n        }\n        .ns-fr-kicker {\n          font-family: var(--font-mono);\n          font-size: 10px;\n          letter-spacing: 0.14em;\n          text-transform: uppercase;\n          color: var(--ns-muted);\n        }\n        .ns-fr-desc {\n          font-size: 13px;\n          color: var(--foreground);\n        }\n        .ns-fr-amount-row {\n          display: flex;\n          align-items: baseline;\n          gap: 6px;\n        }\n        .ns-fr-amount {\n          font-family: var(--font-mono);\n          font-size: 26px;\n          font-weight: 500;\n          font-variant-numeric: tabular-nums;\n        }\n        .ns-fr-amount-label {\n          font-size: 12px;\n          color: var(--ns-muted);\n        }\n        .ns-fr-stampzone {\n          min-height: 78px;\n          display: flex;\n          align-items: center;\n        }\n        .ns-fr-frank {\n          width: 100%;\n          max-width: 220px;\n          height: auto;\n          overflow: visible;\n          animation: ns-fr-press ${PRESS_MS}ms ease-out both;\n        }\n        .ns-fr-frank-border {\n          fill: none;\n          stroke: var(--border);\n          stroke-width: 1.5px;\n          stroke-dasharray: 3 2.5;\n        }\n        .ns-fr-frank-ray {\n          stroke: var(--ns-muted);\n          stroke-width: 1.2px;\n        }\n        .ns-fr-frank-ring {\n          fill: none;\n          stroke: var(--foreground);\n          stroke-width: 1.4px;\n        }\n        .ns-fr-frank-hub {\n          fill: var(--foreground);\n        }\n        .ns-fr-frank-div {\n          stroke: var(--border);\n          stroke-width: 1px;\n        }\n        .ns-fr-frank-amt {\n          font-family: var(--font-mono);\n          font-size: 15px;\n          font-weight: 600;\n          fill: var(--foreground);\n        }\n        .ns-fr-frank-date {\n          font-family: var(--font-mono);\n          font-size: 10px;\n          letter-spacing: 0.06em;\n          fill: var(--foreground);\n        }\n        .ns-fr-frank-serial {\n          font-family: var(--font-mono);\n          font-size: 9px;\n          letter-spacing: 0.05em;\n          fill: var(--ns-muted);\n        }\n        @keyframes ns-fr-press {\n          0% { opacity: 0.35; }\n          55% { opacity: 1; filter: brightness(1.25); }\n          100% { opacity: 1; filter: brightness(1); }\n        }\n\n        .ns-fr-die {\n          align-self: flex-start;\n          display: flex;\n          flex-direction: column;\n          align-items: flex-start;\n          gap: 2px;\n          padding: 8px 14px;\n          border: 1px solid var(--foreground);\n          border-radius: 6px;\n          background: var(--background);\n          color: var(--foreground);\n          cursor: pointer;\n          outline: none;\n          -webkit-tap-highlight-color: transparent;\n        }\n        .ns-fr-die:not(:disabled):hover {\n          background: color-mix(in oklab, var(--foreground) 6%, transparent);\n        }\n        .ns-fr-die:focus-visible {\n          outline: 2px solid var(--ns-accent);\n          outline-offset: 2px;\n        }\n        .ns-fr-die:disabled {\n          opacity: 0.45;\n          cursor: not-allowed;\n        }\n        .ns-fr-die-label {\n          font-family: var(--font-mono);\n          font-size: 10px;\n          letter-spacing: 0.1em;\n          text-transform: uppercase;\n          color: var(--ns-muted);\n        }\n        .ns-fr-die-amt {\n          font-family: var(--font-mono);\n          font-size: 14px;\n          font-weight: 600;\n        }\n\n        .ns-fr-registers {\n          display: grid;\n          grid-template-columns: 1fr 1fr;\n          gap: 12px;\n        }\n        .ns-fr-register {\n          display: flex;\n          flex-direction: column;\n          gap: 4px;\n          border: 1px solid var(--border);\n          border-radius: 6px;\n          padding: 8px 10px;\n        }\n        .ns-fr-reg-caption {\n          font-family: var(--font-mono);\n          font-size: 9px;\n          letter-spacing: 0.1em;\n          text-transform: uppercase;\n          color: var(--ns-muted);\n        }\n        .ns-fr-wheel {\n          display: flex;\n          font-family: var(--font-mono);\n          font-size: 15px;\n          font-weight: 600;\n          line-height: ${ROW_EM};\n        }\n        .ns-fr-wcell {\n          display: inline-block;\n          width: 1ch;\n          height: ${ROW_EM}em;\n          overflow: hidden;\n        }\n        .ns-fr-wtrack {\n          display: flex;\n          flex-direction: column;\n          will-change: transform;\n        }\n        .ns-fr-wdigit {\n          height: ${ROW_EM}em;\n          line-height: ${ROW_EM}em;\n          text-align: center;\n        }\n        .ns-fr-reg-mirror {\n          font-family: var(--font-mono);\n          font-size: 11px;\n          color: var(--ns-muted);\n        }\n\n        .ns-fr-reconcile {\n          margin: 0;\n          font-family: var(--font-mono);\n          font-size: 10px;\n          letter-spacing: 0.04em;\n          color: var(--ns-muted);\n          text-align: center;\n        }\n\n        .ns-fr-root[data-reduced] .ns-fr-frank {\n          animation: none;\n          opacity: 1;\n        }\n        @media (prefers-reduced-motion: reduce) {\n          .ns-fr-frank {\n            animation: none;\n            opacity: 1;\n          }\n        }\n      `}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/frank-register.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": [
      "wallet",
      "payment",
      "odometer",
      "invoice",
      "svg",
      "aria-live",
      "accessibility",
      "credits"
    ],
    "instruction": "A prepaid-wallet payment control rendered as a postal franking meter, for API-credit products, ad platforms and expense wallets where the user has to trust that a charge really decremented the balance by exactly what it claims. `creditsPurchased` fixes the invariant denominator; `balance` (controlled) or `defaultBalance` (uncontrolled) tracks the wallet, and the ASCENDING spent register is never stored as its own state — it is derived every render as `creditsPurchased - balance`, so the reconciliation invariant (balance + spent === creditsPurchased) holds by construction rather than by hoping two independently-animated counters stay in agreement; a dev-mode console.assert double-checks it and a visible caption prints it plainly ('registers reconcile: $2,000.00'). Money is tracked in integer cents throughout so repeated commits never drift from float rounding. STRUCTURE: an invoice card (amount due, description) with a die-shaped commit button below it and a stamp zone above the button where the frank prints; below the card, two bordered odometer panels (Balance, Spent to date), each pairing a decorative digit-wheel readout with a plain-text mirror of the same number for screen readers. MECHANISM: one `commit()` call is the single governing scalar (the payment amount, in cents) that feeds three things at once — the balance delta, the frank's printed amount, and the die-drop trigger — never three independently-timed writes that could disagree. ODOMETER: each digit is a vertically clipped column over a 30-row strip (0-9 repeated three times); a commit computes a rest row (old digit's home position), a play row (new digit's home position, overshot by one strip-third if the digit carried forward past 9, undershot by one strip-third if it borrowed backward past 0), and a final row (new digit's home position, always back in the middle band) — the cell renders at rest while unarmed and at play once armed via a double-rAF-gated transition, then snaps instantly from play to final once the 400ms roll settles, a jump of exactly one strip period so the same digit is on screen before and after and the cut is invisible. Because the ascending register's row only ever climbs and the descending register's only ever falls, the two wheel families spin in opposite senses off the same 400ms drive — mechanically mirrored, not independent tweens. Digit columns diff by place value (never string index) against the previous rendered value; changed columns stagger 40ms each, right-to-left (rightmost changed place first, matching how a carry actually propagates in addition), unchanged columns never move. COMMIT SEQUENCE: the button IS the die — clicking drops it 8px over 140ms ease-out-expo, dwells there 120ms (so the stamp reads as pressure landing, not a blink), then returns over 180ms; the frank SVG (a rosette of 12 rays around a ring/hub, a divider, and amount/date/serial text in Geist Mono) is remounted per commit (keyed on a batch counter) so its one-frame press-darken animation (opacity 0.35 -> a 1.25x brightness flash -> settled) always replays, delayed to land at the same 140ms mark the die makes contact. Serial numbers increment per commit (zero-padded to 6 digits) and the date is read fresh from the clock at commit time, never during render, so there's no hydration mismatch. A commit is refused (button disabled, accessible name still present) if the amount exceeds the remaining balance — the registers can never go negative. ACCESSIBILITY: the commit button's accessible name always names the amount ('Charge $142.50'); after a commit an aria-live=polite, aria-atomic region announces 'Stamped — $142.50. Balance $857.50 remaining, $1,142.50 spent to date, registers reconcile.'; the frank SVG is role=img with an alt carrying its serial, amount and date; both odometers render aria-hidden decoratively alongside a plain visible text mirror ('$857.50') that is the actual accessible content, so a screen reader gets the number twice over (testifies, doesn't just assert) rather than depending on the wheel glyphs. REDUCED MOTION: wheels snap straight to their final digits with no roll, the die never travels (no drop, no dwell, no return), and the frank appears at full opacity with no press-darken — fully legible, all four state changes (balance, spent, frank, and the disabled/enabled button) still land correctly, only the choreography is skipped. Distinct from counter-carry-ripple (a single live numeric readout with no payment action, no conserved pair, no stamped artifact) and from the otp-reel digit-wheel family (keyboard entry into an OTP box) by animating a CONSERVED PAIR plus a stamped indicium, never a single number. Zero dependencies, pure DOM + SVG + CSS, no canvas; all ink is token-relative (--background, --foreground, --ns-muted, --border; --ns-accent only for the keyboard focus ring)."
  },
  "type": "registry:ui"
}