{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ledger",
  "type": "registry:ui",
  "title": "Ledger",
  "description": "The atomic KPI stat card, animated in three beats: the value rolls up on odometer digit drums, the sparkline scores itself in, and the delta badge stamps down last with one crisp X-flip. Live updates re-roll only the digits that changed — a meter, not a poster.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/tally.json",
    "https://crucibleui.com/r/scriber.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/data/ledger/ledger.tsx",
      "type": "registry:ui",
      "target": "components/crucible/ledger.tsx",
      "content": "\"use client\";\n\nimport { motion, useInView } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Scriber, type ScriberProps } from \"@/components/crucible/scriber\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { Tally } from \"@/components/crucible/tally\";\n\nexport type LedgerFormat = \"number\" | \"currency\" | \"percent\";\nexport type LedgerDirection = \"up\" | \"down\" | \"flat\";\nexport type LedgerTrigger = \"in-view\" | \"mount\" | \"prop-change\";\nexport type LedgerSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface LedgerColors {\n  /** Delta badge hue when the direction is up. @default \"#39c5b1\" (restrained teal) */\n  up?: string;\n  /** Delta badge hue when the direction is down. @default \"#e08563\" (restrained rust) */\n  down?: string;\n  /** Delta badge hue when the direction is flat. @default \"#a1a1aa\" */\n  flat?: string;\n}\n\nexport interface LedgerProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** KPI label rendered above the value, e.g. \"Monthly revenue\". */\n  label: string;\n  /**\n   * The metric. Changing it after the initial choreography is a live update:\n   * only the digits that changed re-roll (unchanged drums hold still), the\n   * badge re-stamps with a short tick, and no full re-choreography runs.\n   */\n  value: number;\n  /** How the value is formatted. `percent` follows Intl: pass `0.246` for \"24.6%\". @default \"number\" */\n  format?: LedgerFormat;\n  /** ISO 4217 currency code used when `format` is `currency`. @default \"USD\" */\n  currency?: string;\n  /** BCP 47 locale for all number formatting. @default \"en-US\" */\n  locale?: string;\n  /** Decimal places on the value. @default 0 (`number`), 2 (`currency`), 1 (`percent`) */\n  decimals?: number;\n  /** Change figure shown in the badge (percent points, e.g. `12.4` → \"▴ 12.4%\"). Omit for no badge. */\n  delta?: number;\n  /** Badge direction override; inferred from the sign of `delta` when omitted. */\n  deltaDirection?: LedgerDirection;\n  /** Decimal places on the delta figure. @default 1 */\n  deltaDecimals?: number;\n  /** Trend points for the sparkline, any numeric range. Omit for no sparkline. New arrays swap in without redrawing. */\n  sparkline?: number[];\n  /** Advanced pass-through to the underlying Scriber (variant, colors, strokeWidth…). */\n  sparklineProps?: Omit<ScriberProps, \"data\">;\n  /**\n   * What starts the three-beat choreography: entering the viewport, mounting,\n   * or the first `value` change. The digit roll itself always waits until the\n   * value is actually on screen (so it is never spent offscreen). @default \"in-view\"\n   */\n  trigger?: LedgerTrigger;\n  /**\n   * Seconds the whole choreography is shifted by — set `delay={i * 0.15}`\n   * across a row of ledgers to sequence a metrics strip. @default 0\n   */\n  delay?: number;\n  /** Seconds between beat starts (value → sparkline → badge). @default 0.75 */\n  stagger?: number;\n  /** Seconds the value takes to roll up (beat one). @default 1.4 */\n  valueDuration?: number;\n  /** Seconds the sparkline takes to draw (beat two). @default 1.2 */\n  sparklineDuration?: number;\n  /** Seconds the badge flip takes to land (beat three). @default 0.5 */\n  badgeDuration?: number;\n  /** Overall scale of the card. @default \"md\" */\n  size?: LedgerSize;\n  /** Delta badge hues — the only color on the card. */\n  colors?: LedgerColors;\n  /** Render the finished card with no animation at all. @default false */\n  paused?: boolean;\n  /** Forwarded to the value's Tally: hue of the entry cast, bloom and baseline glint. @default Tally's cool white-blue */\n  tint?: string;\n  /** Forwarded to the value's Tally: drum bloom + backlight. @default true */\n  glow?: boolean;\n  /** Forwarded to the value's Tally: the baseline glint as the value settles. @default true */\n  glint?: boolean;\n  /** Forwarded to the value's Tally: cylinder shading over each drum face. @default true */\n  shade?: boolean;\n}\n\nconst SIZES = {\n  sm: {\n    card: \"p-4\",\n    label: \"text-[10px]\",\n    value: \"text-2xl\",\n    valueMt: \"mt-2\",\n    badge: \"text-[10px]\",\n    spark: \"h-8\",\n    sparkMt: \"mt-3 pt-3\",\n  },\n  md: {\n    card: \"p-5\",\n    label: \"text-[11px]\",\n    value: \"text-4xl\",\n    valueMt: \"mt-3\",\n    badge: \"text-[11px]\",\n    spark: \"h-10\",\n    sparkMt: \"mt-4 pt-4\",\n  },\n  lg: {\n    card: \"p-6\",\n    label: \"text-xs\",\n    value: \"text-5xl\",\n    valueMt: \"mt-4\",\n    badge: \"text-xs\",\n    spark: \"h-12\",\n    sparkMt: \"mt-5 pt-5\",\n  },\n} as const;\n\nconst DEFAULT_COLORS: Required<LedgerColors> = {\n  up: \"#39c5b1\",\n  down: \"#e08563\",\n  flat: \"#a1a1aa\",\n};\n\nconst ARROWS: Record<LedgerDirection, string> = { up: \"▴\", down: \"▾\", flat: \"–\" };\n\n/**\n * Ledger — the atomic KPI stat card, animated in three beats instead of pasted\n * static: the value rolls up on odometer digit drums (Tally), the trend\n * sparkline scores itself in (Scriber), and the delta badge flips in last —\n * one crisp X-rotation with a one-frame settle, like a stamp landing. Neutral\n * typographic monochrome with a hairline rule; the badge is the only color on\n * the card. Live `value` changes re-roll only the digits that moved and\n * re-stamp the badge with a short tick — never a full re-choreography — and\n * the new value is announced politely. A `delay` shifts the whole timeline so\n * a row of ledgers sequences into a metrics strip. Reduced motion renders the\n * finished card: final value, drawn sparkline, static badge.\n */\nexport function Ledger({\n  label,\n  value,\n  format = \"number\",\n  currency = \"USD\",\n  locale = \"en-US\",\n  decimals,\n  delta,\n  deltaDirection,\n  deltaDecimals = 1,\n  sparkline,\n  sparklineProps,\n  trigger = \"in-view\",\n  delay = 0,\n  stagger = 0.75,\n  valueDuration = 1.4,\n  sparklineDuration = 1.2,\n  badgeDuration = 0.5,\n  size = \"md\",\n  colors,\n  paused = false,\n  tint,\n  glow,\n  glint,\n  shade,\n  className,\n  ...props\n}: LedgerProps) {\n  const reducedMotion = useReducedMotion();\n  const staticMode = reducedMotion || paused;\n  const S = SIZES[size];\n\n  // ---- formatting -----------------------------------------------------------\n  const dec = decimals ?? (format === \"currency\" ? 2 : format === \"percent\" ? 1 : 0);\n  const formatOptions = React.useMemo<Intl.NumberFormatOptions | undefined>(() => {\n    if (format === \"currency\") return { style: \"currency\", currency };\n    if (format === \"percent\") return { style: \"percent\" };\n    return undefined;\n  }, [format, currency]);\n  const formatted = React.useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        minimumFractionDigits: dec,\n        maximumFractionDigits: dec,\n        ...formatOptions,\n      }).format(value),\n    [value, dec, locale, formatOptions]\n  );\n\n  const direction: LedgerDirection =\n    deltaDirection ?? (delta == null || delta === 0 ? \"flat\" : delta > 0 ? \"up\" : \"down\");\n  const deltaText = React.useMemo(() => {\n    if (delta == null) return \"\";\n    const n = new Intl.NumberFormat(locale, {\n      minimumFractionDigits: 0,\n      maximumFractionDigits: deltaDecimals,\n    }).format(Math.abs(delta));\n    return `${n}%`;\n  }, [delta, locale, deltaDecimals]);\n  const dirColor = colors?.[direction] ?? DEFAULT_COLORS[direction];\n\n  // ---- choreography ----------------------------------------------------------\n  // Beat starts: value at `delay`, sparkline at `delay + stagger`, badge one\n  // stagger after the last beat before it — the judgment always arrives last.\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const inView = useInView(rootRef, { once: true, amount: 0.35 });\n  const [started, setStarted] = React.useState(false);\n  const [sparkGo, setSparkGo] = React.useState(false);\n  const initialValueRef = React.useRef(value);\n  const stampedRef = React.useRef(false);\n\n  React.useEffect(() => {\n    if (started) return;\n    if (trigger === \"prop-change\") {\n      if (value !== initialValueRef.current) setStarted(true);\n    } else if (trigger === \"mount\" || inView) {\n      setStarted(true);\n    }\n  }, [started, trigger, inView, value]);\n\n  React.useEffect(() => {\n    if (!started || sparkGo) return;\n    if (staticMode) {\n      setSparkGo(true);\n      return;\n    }\n    const t = window.setTimeout(() => setSparkGo(true), Math.max(0, (delay + stagger) * 1000));\n    return () => window.clearTimeout(t);\n  }, [started, sparkGo, staticMode, delay, stagger]);\n\n  const hasSparkline = !!sparkline && sparkline.length >= 2;\n  const badgeDelay = delay + stagger * (hasSparkline ? 2 : 1);\n\n  // ---- value (beat one) -------------------------------------------------------\n  // `prop-change` holds a static figure until the first update, then Tally\n  // rolls from that figure to the new one. `paused` renders the final figure.\n  const holdStatic = paused || (trigger === \"prop-change\" && !started);\n  const valueNode = holdStatic ? (\n    <span aria-hidden className=\"tabular-nums\">\n      {formatted}\n    </span>\n  ) : (\n    <Tally\n      aria-hidden\n      value={value}\n      start={trigger === \"prop-change\" ? initialValueRef.current : 0}\n      decimals={dec}\n      locale={locale}\n      formatOptions={formatOptions}\n      duration={valueDuration}\n      delay={delay}\n      tint={tint}\n      glow={glow}\n      glint={glint}\n      shade={shade}\n    />\n  );\n\n  // ---- badge (beat three) -------------------------------------------------------\n  // First arrival is the stamp: a single crisp X-rotation past level (-7°) with\n  // a one-frame settle back onto 0°. Later delta changes re-key the badge and\n  // land a shorter, immediate tick — no replay of the full choreography.\n  const isFirstStamp = !stampedRef.current;\n  const badgeCls = cn(\n    \"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-[3px] font-medium leading-none tabular-nums\",\n    S.badge\n  );\n  const badgeStyle: React.CSSProperties = {\n    color: dirColor,\n    borderColor: `color-mix(in srgb, ${dirColor} 30%, transparent)`,\n    backgroundColor: `color-mix(in srgb, ${dirColor} 9%, transparent)`,\n  };\n  const badgeInner = (\n    <>\n      <span className=\"text-[0.85em]\">{ARROWS[direction]}</span>\n      <span>{deltaText}</span>\n    </>\n  );\n  const badge =\n    delta == null ? null : staticMode ? (\n      <span aria-hidden className={badgeCls} style={badgeStyle}>\n        {badgeInner}\n      </span>\n    ) : (\n      <motion.span\n        aria-hidden\n        key={`${direction}:${deltaText}`}\n        className={cn(badgeCls, \"will-change-[transform,opacity]\")}\n        style={{ ...badgeStyle, transformPerspective: 420 }}\n        initial={{ opacity: 0, rotateX: isFirstStamp ? 92 : 58 }}\n        animate={\n          started\n            ? { opacity: [0, 1, 1], rotateX: isFirstStamp ? [92, -7, 0] : [58, -5, 0] }\n            : undefined\n        }\n        transition={{\n          delay: isFirstStamp ? badgeDelay : 0,\n          duration: isFirstStamp ? badgeDuration : Math.min(badgeDuration, 0.32),\n          times: [0, 0.8, 1],\n          ease: \"easeOut\",\n        }}\n        onAnimationComplete={() => {\n          stampedRef.current = true;\n        }}\n      >\n        {badgeInner}\n      </motion.span>\n    );\n\n  // ---- sparkline (beat two) -----------------------------------------------------\n  const { className: sparkClassName, ...sparkRest } = sparklineProps ?? {};\n\n  // ---- a11y announcement -----------------------------------------------------------\n  const dirWord = direction === \"up\" ? \"up\" : direction === \"down\" ? \"down\" : \"flat\";\n  const announce = delta == null ? formatted : `${formatted}, ${dirWord} ${deltaText}`;\n\n  return (\n    <div\n      ref={rootRef}\n      data-crucible=\"ledger\"\n      className={cn(\n        \"relative flex flex-col rounded-lg border border-white/8 bg-white/1.5 text-left\",\n        S.card,\n        className\n      )}\n      {...props}\n    >\n      <div className=\"flex items-start justify-between gap-3\">\n        <span className={cn(\"font-medium uppercase tracking-[0.18em] text-neutral-500\", S.label)}>\n          {label}\n        </span>\n        {badge}\n      </div>\n\n      <div className={cn(\"font-semibold leading-none tracking-tight text-white\", S.valueMt, S.value)}>\n        {valueNode}\n      </div>\n\n      {hasSparkline ? (\n        <span aria-hidden className={cn(\"block border-t border-white/6\", S.sparkMt)}>\n          <Scriber\n            variant=\"area\"\n            data={sparkline}\n            {...sparkRest}\n            drawDuration={paused ? 0 : sparklineDuration}\n            paused={paused || !sparkGo}\n            className={cn(\"block w-full\", S.spark, sparkClassName)}\n          />\n        </span>\n      ) : null}\n\n      {/* Polite live region: the value (and judgment) read as real text and\n          re-announce on every live update. */}\n      <span role=\"status\" className=\"sr-only\">\n        {label}: {announce}\n      </span>\n    </div>\n  );\n}\n"
    }
  ]
}