{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"usage-meter","type":"registry:component","title":"Usage meter","description":"An allowance and what fills it: one bar split by source, the room left, a warning near the limit and the overflow in its own red well past a notch at the limit.","categories":["feedback"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","lucide-react@^1.48.0","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/_shared/delta-ghost.module.css","type":"registry:file","target":"src/ui/beamline/components/_shared/delta-ghost.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* The ghost spans the two real values. A loss sits under the fill in a pale wash of its colour, holds for a flash and\n     drains toward the new end; a gain sits over the fill, lit, and cools. Reduced motion: shown still, then gone. */\n  .ghost {\n    --g-color: var(--pui-ink-3);\n    position: absolute;\n    inset-block: 0;\n    inset-inline-start: calc(var(--g-lo) * 100%);\n    width: calc((var(--g-hi) - var(--g-lo)) * 100%);\n    pointer-events: none;\n  }\n  .ghost[data-kind=\"loss\"] {\n    z-index: 0;\n    background: color-mix(in oklab, var(--g-color) 42%, transparent);\n    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--g-color) 55%, transparent);\n    animation: drain calc(var(--pui-duration-flash) * 2.4) var(--pui-ease-standard) both;\n  }\n  .ghost[data-kind=\"gain\"] {\n    z-index: 2;\n    /* a lift of the fill's own light, not a white flash */\n    /* organism-allow-start: literal-colour -- a lift of the fill's own light (white at low alpha over any tone), not a theme colour */\n    background: light-dark(rgb(255 255 255 / 0.4), rgb(255 255 255 / 0.26));\n    box-shadow: inset 0 0 0 1px light-dark(rgb(255 255 255 / 0.55), rgb(255 255 255 / 0.3));\n    /* organism-allow-end */\n    animation: cool calc(var(--pui-duration-flash) * 2.2) var(--pui-ease-exit) both;\n  }\n  /* reduced motion: the same moment, without movement */\n  .ghost[data-reduced] {\n    /* organism-allow: literal-motion -- reduced motion collapses duration tokens to 1ms; this still-frame must stay readable for its moment */\n    animation: hold 1.1s steps(1, end) both;\n  }\n  /* the loss drains toward where the bar now ends (the start side of the ghost) */\n  @keyframes drain {\n    0%,\n    46% {\n      clip-path: inset(0 0 0 0);\n    }\n    100% {\n      clip-path: inset(0 100% 0 0);\n    }\n  }\n  .ghost[data-kind=\"loss\"]:dir(rtl) {\n    animation-name: drain-rtl;\n  }\n  @keyframes drain-rtl {\n    0%,\n    46% {\n      clip-path: inset(0 0 0 0);\n    }\n    100% {\n      clip-path: inset(0 0 0 100%);\n    }\n  }\n  @keyframes cool {\n    0%,\n    25% {\n      opacity: 1;\n    }\n    to {\n      opacity: 0;\n    }\n  }\n  @keyframes hold {\n    from {\n      opacity: 1;\n    }\n    to {\n      opacity: 0;\n    }\n  }\n}\n"},{"path":"beamline/components/_shared/delta-ghost.tsx","type":"registry:file","target":"src/ui/beamline/components/_shared/delta-ghost.tsx","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./delta-ghost.module.css\";\n\n/**\n * Magnitude memory for a bar (research/morphing-interfaces-2026-10-04, M4). When a value drops, the part it lost stays\n * as a pale ghost for a moment and then drains toward the new end; when it rises, the part it gained arrives lit and\n * cools. You see how much changed, not only the new value. The ghost always spans two real values (before and after),\n * never an invented in-between. A run of changes the same way extends one ghost; a reversal starts a new one. With\n * reduced motion the ghost is shown still for the same moment and then removed, so the information stays.\n */\nexport interface Ghost {\n  /** Fractions of the track (0–1): where the bar was and where it is now. */\n  from: number;\n  to: number;\n  /** Increments per change, to restart the CSS animation. */\n  key: number;\n}\n\nexport function useDeltaGhost(value: number | null | undefined) {\n  const before = useRef(value);\n  const [ghost, setGhost] = useState<Ghost | null>(null);\n  useLayoutEffect(() => {\n    const was = before.current;\n    before.current = value;\n    if (was == null || value == null || !Number.isFinite(was) || !Number.isFinite(value) || was === value) return;\n    setGhost((g) => {\n      const sameWay = g && Math.sign(g.to - g.from) === Math.sign(value - was) && g.to === was;\n      return { from: sameWay ? g.from : was, to: value, key: (g?.key ?? 0) + 1 };\n    });\n  }, [value]);\n  const clear = useCallback(() => setGhost(null), []);\n  return [ghost, clear] as const;\n}\n\n/** The ghost itself, absolutely placed in a relative, clipped track. `color` is the fill's colour. */\nexport function DeltaGhost({\n  ghost,\n  onDone,\n  color,\n  className,\n}: {\n  ghost: Ghost | null;\n  onDone: () => void;\n  color?: string;\n  className?: string;\n}) {\n  const reduced = useReducedMotionSafe();\n  if (!ghost) return null;\n  const lo = Math.max(0, Math.min(1, Math.min(ghost.from, ghost.to)));\n  const hi = Math.max(0, Math.min(1, Math.max(ghost.from, ghost.to)));\n  if (hi - lo < 0.002) return null;\n  return (\n    <span\n      key={ghost.key}\n      aria-hidden\n      data-slot=\"delta-ghost\"\n      data-kind={ghost.to < ghost.from ? \"loss\" : \"gain\"}\n      data-reduced={reduced ? \"\" : undefined}\n      className={cn(styles.ghost, className)}\n      style={{ \"--g-lo\": lo, \"--g-hi\": hi, ...(color ? { \"--g-color\": color } : {}) } as CSSProperties}\n      onAnimationEnd={(e) => {\n        if (e.target === e.currentTarget) onDone();\n      }}\n    />\n  );\n}\n"},{"path":"beamline/components/slot-text/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/slot-text/defaults.ts","content":"/** Pure allowed presentation defaults for SlotText; no component or runtime imports. */\nexport interface SlotTextDefaults {\n  /** Shared align setting; omit to inherit the surrounding scope or the component default. */\n  align?: \"start\" | \"end\";\n}\n"},{"path":"beamline/components/slot-text/slot-text.module.css","type":"registry:file","target":"src/ui/beamline/components/slot-text/slot-text.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A reel stands on the run of digits it is rolling to; once it lands it is quietly reset to the same digit on its\n     middle run (no motion), so the next roll can turn either way without running out of reel. A roll is an animation\n     from the numeral it showed to the one it lands on, in whole numerals (the staircase timing in slot-text.tsx), so it\n     always starts from a whole digit, even when a new value arrives mid-roll; the two names take turns so each change\n     starts afresh. */\n  .reel {\n    position: absolute;\n    inset-inline: 0;\n    top: 0;\n    display: flex;\n    flex-direction: column;\n    animation-fill-mode: both;\n  }\n  .rollA {\n    animation-name: slot-text-roll-a;\n  }\n  .rollB {\n    animation-name: slot-text-roll-b;\n  }\n  /* the inline transform names the target; a landed reel is reset over it */\n  .reel[data-settled] {\n    transform: translateY(calc(var(--_rest) * -1lh)) !important;\n    animation: none !important;\n  }\n\n  /* a new place or a changed sign waits, unseen, for its turn in the ripple (its delay is the animation's length), then\n     is there whole: never half faded over its neighbour */\n  .arrive {\n    animation-name: slot-text-wait;\n    animation-timing-function: step-end;\n  }\n  @keyframes slot-text-roll-a {\n    from {\n      transform: translateY(calc(var(--_from) * -1lh));\n    }\n    to {\n      transform: translateY(calc(var(--_to) * -1lh));\n    }\n  }\n  @keyframes slot-text-roll-b {\n    from {\n      transform: translateY(calc(var(--_from) * -1lh));\n    }\n    to {\n      transform: translateY(calc(var(--_to) * -1lh));\n    }\n  }\n  @keyframes slot-text-wait {\n    from,\n    to {\n      visibility: hidden;\n    }\n  }\n}\n"},{"path":"beamline/components/slot-text/slot-text.tsx","type":"registry:file","target":"src/ui/beamline/components/slot-text/slot-text.tsx","content":"\"use client\";\n\nimport { type AnimationEvent, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\n\nimport { useLocale, numberFormatter } from \"../../runtime/localization\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { SlotTextDefaults } from \"./defaults\";\nexport type { SlotTextDefaults } from \"./defaults\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./slot-text.module.css\";\n\nexport interface SlotTextClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the spoken part; no override by default. */\n  spoken?: string;\n  /** Classes for the visual part; no override by default. */\n  visual?: string;\n  /** Classes for the digit part; no override by default. */\n  digit?: string;\n  /** Classes for the measure part; no override by default. */\n  measure?: string;\n  /** Classes for the reel part; no override by default. */\n  reel?: string;\n  /** Classes for the numeral part; no override by default. */\n  numeral?: string;\n  /** Classes for the character part; no override by default. */\n  character?: string;\n}\n\nexport interface SlotTextProps {\n  /** Text or number shown on the animated reels. Required. */\n  value: string | number;\n  /** Turns a number into its text: currency, thousands. */\n  format?: (value: number) => string;\n  /** ms per reel. Default: twice the considered duration (about 0.7 s), following the motion scale. */\n  duration?: number;\n  /** ms between reels, right to left. Default: the word stagger (about 30 ms), following the motion scale. */\n  stagger?: number;\n  /** Extra full turns a changing digit makes. Default 0. */\n  spins?: number;\n  /** Which side stays put when the length changes. Default end (numbers). */\n  align?: \"start\" | \"end\";\n  /** Read the new value aloud when it changes. Default false (most counters are glanceable). */\n  announce?: boolean;\n  /** Content direction. Numbers stay LTR; other text resolves its own direction instead of inheriting page layout. */\n  dir?: \"ltr\" | \"rtl\" | \"auto\";\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: SlotTextClassNames;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLSpanElement>;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n}\n\nconst DIGITS = \"0123456789\";\n\n/** A roll of `n` numerals as a staircase on the system's ease-out: each numeral is shown whole and held until the eased\n *  travel reaches the next, so the reel slows into place like an odometer and no frame ever shows two digits split by\n *  the window (the owner rejected digits drawn over each other mid-roll, 27 Oct). */\nconst stairs = new Map<number, string>();\nfunction staircase(n: number) {\n  let s = stairs.get(n);\n  if (!s) {\n    // ease-out cubic: travel p at time t = 1 - (1 - p)^(1/3); numeral k shows once the travel passes the middle of k\n    const at = (k: number) => `${((1 - Math.cbrt(1 - (k - 0.5) / n)) * 100).toFixed(2)}%`;\n    const points = [\"0 0%\"];\n    for (let k = 1; k <= n; k++) points.push(`${(k - 1) / n} ${at(k)}`, `${k / n} ${at(k)}`);\n    points.push(\"1 100%\");\n    s = `linear(${points.join(\", \")})`;\n    stairs.set(n, s);\n  }\n  return s;\n}\n\n/** A caller's ms, or the token default, on the system's clock. */\nconst clock = (ms: number | undefined, token: string) =>\n  ms === undefined ? token : `calc(${ms}ms * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1))`;\n\n/**\n * Text and numbers that roll into their new value on reels, digit by digit from the right. A number that goes up rolls\n * every reel up and one that goes down rolls them down (9 → 0 on the way up passes through the wrap, like an odometer),\n * so the direction of the change reads before the digits do. A reel ticks through whole numerals, slowing into place, so\n * any frame taken mid-roll reads clean digits; a place the new value no longer has goes at once, and a new place or a\n * changed sign appears whole when its turn in the ripple comes. Reduced motion swaps the value at once. The reels are\n * hidden from assistive tech, which reads the value as text.\n */\nexport function SlotText({\n  value,\n  format,\n  duration,\n  stagger,\n  spins = 0,\n  align: alignProp,\n  announce = false,\n  dir,\n  className,\n  classNames,\n  ref,\n  style,\n}: SlotTextProps) {\n  const locale = useLocale();\n  const scopeDefaults = useComponentDefaults<SlotTextDefaults>(\"slot-text\", [\"align\"]);\n  const align = alignProp ?? scopeDefaults.align ?? \"end\";\n\n  const text =\n    typeof value === \"number\"\n      ? format\n        ? format(value)\n        : numberFormatter(locale, { maximumSignificantDigits: 21 }).format(value)\n      : value;\n  const direction = dir ?? (typeof value === \"number\" || !/\\p{L}/u.test(text) ? \"ltr\" : \"auto\");\n  const host = useRef<HTMLSpanElement>(null);\n  const attach = useComposedRef(host, ref);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n  // the change is known in the same render it arrives in (no effect-delayed second pass): what it was, which way a\n  // number went, and a run counter that restarts the character fades\n  const [seen, setSeen] = useState({ text, value });\n  const [from, setFrom] = useState(text);\n  const [trend, setTrend] = useState(0);\n  const [run, setRun] = useState(0);\n  if (text !== seen.text) {\n    setSeen({ text, value });\n    setFrom(seen.text);\n    setTrend(typeof value === \"number\" && typeof seen.value === \"number\" ? Math.sign(value - seen.value) : 0);\n    setRun((r) => r + 1);\n  }\n  // when the roll has landed, the columns the old value needed and the new one does not are let go\n  const settle = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => {\n    if (from === text) return;\n    const s = tokens.duration.considered * 2 + tokens.stagger.word * Math.max(text.length, from.length);\n    const ms = Math.max((duration ?? s * 1000) + (stagger ?? 0) * Math.max(text.length, from.length), 0) + 50;\n    settle.current = setTimeout(() => setFrom(text), reduced ? 0 : ms);\n    return () => clearTimeout(settle.current);\n  }, [from, text, run, duration, stagger, reduced, tokens.duration.considered, tokens.stagger.word]);\n  // a new roll starts from where each reel rests: the resting mark from the last roll is lifted before paint\n  useLayoutEffect(() => {\n    host.current?.querySelectorAll(\"[data-settled]\").forEach((r) => r.removeAttribute(\"data-settled\"));\n  }, [run]);\n  const len = Math.max(text.length, from.length);\n  const pad = (s: string) => (align === \"end\" ? s.padStart(len, \" \") : s.padEnd(len, \" \"));\n  const now = pad(text),\n    was = pad(from);\n  // a reel holds the digits 2·spins + 3 times; it rests on the middle run, and a roll that passes the wrap lands on\n  // the run beside it, from which the reel is quietly reset to the middle once it stops\n  const base = 10 * (spins + 1);\n  const reel = Array.from({ length: 10 * (2 * spins + 3) }, (_, k) => DIGITS[k % 10]);\n  const durationCss = clock(duration, \"calc(var(--pui-duration-considered) * 2)\");\n  const staggerCss = clock(stagger, \"var(--pui-stagger-word)\");\n  const landed = (e: AnimationEvent<HTMLSpanElement>) => {\n    const target = e.target as HTMLElement;\n    if (target.dataset.slot === \"slot-text-reel\") target.setAttribute(\"data-settled\", \"\");\n  };\n  return (\n    <span\n      ref={attach}\n      data-slot=\"slot-text\"\n      data-trend={trend > 0 ? \"up\" : trend < 0 ? \"down\" : undefined}\n      dir={direction}\n      className={cn(\"relative inline-flex tabular-nums [unicode-bidi:isolate]\", classNames?.root, className)}\n      style={style}\n    >\n      <span\n        data-slot=\"slot-text-spoken\"\n        className={cn(\"sr-only\", classNames?.spoken)}\n        aria-live={announce ? \"polite\" : undefined}\n      >\n        {text}\n      </span>\n      <span\n        aria-hidden\n        data-slot=\"slot-text-visual\"\n        className={cn(\"inline-flex overflow-hidden\", classNames?.visual)}\n        onAnimationEnd={landed}\n      >\n        {[...now].map((ch, i) => {\n          const order = align === \"end\" ? len - 1 - i : i;\n          // a column keeps its element while it stays the same distance from the aligned side, so it can roll\n          const key = align === \"end\" ? `e${order}` : `s${order}`;\n          const delay = `calc(${order} * ${staggerCss})`;\n          const old = was[i];\n          // a place the new value no longer has (1,284 → 488: the \"1\" and the \",\"): gone at once, so the number takes\n          // its new width with the change itself and no half-faded digit sits over its neighbour while the reels turn\n          const padding = align === \"end\" ? i < len - text.length : i >= text.length;\n          if (padding) return null;\n          if (DIGITS.includes(ch)) {\n            const d = Number(ch);\n            const before = Number(old);\n            const changed = old !== ch && DIGITS.includes(old) && run > 0;\n            const turns = changed ? spins * 10 : 0;\n            // the way the number went decides the way every reel turns; a text value turns each the short way\n            const to =\n              !changed || reduced\n                ? base + d\n                : trend > 0\n                  ? base + d + (d > before ? 0 : 10) + turns\n                  : trend < 0\n                    ? base + d - (d < before ? 0 : 10) - turns\n                    : base + d + turns;\n            const fresh = !DIGITS.includes(old) && run > 0 && !reduced;\n            const start = changed && !reduced ? base + before : to;\n            const steps = Math.abs(to - start);\n            return (\n              <span\n                key={key}\n                data-slot=\"slot-text-digit\"\n                className={cn(\n                  \"relative inline-block h-[1lh] overflow-hidden\",\n                  fresh && styles.arrive,\n                  classNames?.digit,\n                )}\n                style={fresh ? { animationDuration: delay } : undefined}\n              >\n                <span data-slot=\"slot-text-measure\" className={cn(\"invisible\", classNames?.measure)}>\n                  0\n                </span>\n                <span\n                  data-slot=\"slot-text-reel\"\n                  className={cn(styles.reel, steps > 0 && (run % 2 ? styles.rollA : styles.rollB), classNames?.reel)}\n                  style={\n                    {\n                      \"--_from\": start,\n                      \"--_to\": to,\n                      \"--_rest\": base + d,\n                      transform: \"translateY(calc(var(--_to) * -1lh))\",\n                      ...(run > 0 &&\n                        !reduced && {\n                          animationDuration: durationCss,\n                          animationDelay: delay,\n                          animationTimingFunction: staircase(Math.max(steps, 1)),\n                        }),\n                    } as CSSProperties\n                  }\n                >\n                  {reel.map((n, k) => (\n                    <span key={k} data-slot=\"slot-text-numeral\" className={cn(\"h-[1lh]\", classNames?.numeral)}>\n                      {n}\n                    </span>\n                  ))}\n                </span>\n              </span>\n            );\n          }\n          return (\n            <span\n              key={`${key}-${ch}-${run}`}\n              data-slot=\"slot-text-character\"\n              className={cn(\n                \"inline-block whitespace-pre\",\n                !reduced && old !== ch && run > 0 && styles.arrive,\n                classNames?.character,\n              )}\n              style={{ animationDuration: delay }}\n            >\n              {ch}\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n\nexport default SlotText;\n"},{"path":"beamline/components/usage-meter/messages.ts","type":"registry:file","target":"src/ui/beamline/components/usage-meter/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  freeLabel: \"left\",\n  overLabel: \"over\",\n  of: \"of {value}\",\n  valueText: \"{used} of {limit}, {remaining} {label}\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/usage-meter/usage-meter.module.css","type":"registry:file","target":"src/ui/beamline/components/usage-meter/usage-meter.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* A size container (never collapsing when left content-sized): from 28rem the header adds the share of the\n     allowance used, a narrower meter keeps the amounts. */\n  .root {\n    container-type: inline-size;\n    min-width: min(16rem, 100%);\n  }\n  .share {\n    display: none;\n  }\n  @container (width >= 28rem) {\n    .share {\n      display: inline;\n    }\n  }\n  /* The bar is the allowance: a well (inset role, like Progress) that the sources fill in order. Past the limit a\n     second, shorter well holds the overflow in solid red, separated by a notch: the limit is a break in the bar,\n     never a hatch (hatching means \"not measured\"). */\n  .bar {\n    display: flex;\n    gap: var(--pui-space-1);\n    height: 8px;\n  }\n  .track {\n    --m-inset-fill: var(--pui-control-track);\n    position: relative;\n    display: flex;\n    gap: 1px;\n    min-width: 0;\n    overflow: hidden;\n    border-radius: var(--pui-radius-pill);\n  }\n  /* A source's part of the bar, in its series colour. Pointing at it or at its legend entry keeps it and fades the others\n     toward the card (never through transparency); the change arrives at once and eases back over --pui-motion-out. */\n  .segment {\n    position: relative;\n    height: 100%;\n    background: var(--_c);\n    transition:\n      width var(--pui-duration-standard) var(--pui-ease-default),\n      background-color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .segment[data-dim] {\n    background: color-mix(in oklab, var(--_c) 30%, var(--pui-surface));\n    transition-duration: var(--pui-duration-standard), 0ms;\n  }\n  .item {\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .item[data-focus] {\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  .item[data-dim] {\n    color: var(--pui-ink-3);\n    transition-duration: 0ms;\n  }\n  .over {\n    width: 100%;\n    background: var(--pui-red);\n  }\n  .swatch {\n    flex: none;\n    width: var(--pui-space-2);\n    height: var(--pui-space-2);\n    border-radius: calc(var(--pui-radius-chip) / 3);\n    background: var(--_c);\n  }\n  .swatchFree {\n    background: var(--pui-control-track);\n    box-shadow: inset 0 0 0 1px var(--pui-line);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.track {\n  composes: m-inset meter from global;\n}\n.swatchFree {\n  composes: swatch;\n}\n"},{"path":"beamline/components/usage-meter/usage-meter.tsx","type":"registry:file","target":"src/ui/beamline/components/usage-meter/usage-meter.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useState, type CSSProperties, type Ref } from \"react\";\nimport { AlertCircle, AlertTriangle } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { formatNumber } from \"../../lib/number\";\nimport { SlotText } from \"../slot-text/slot-text\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { DeltaGhost, useDeltaGhost } from \"../_shared/delta-ghost\";\nimport styles from \"./usage-meter.module.css\";\n\nexport interface UsageMeterSegment {\n  id: string;\n  label: string;\n  value: number;\n}\n\nexport interface UsageMeterClassNames {\n  root?: string;\n  header?: string;\n  label?: string;\n  value?: string;\n  bar?: string;\n  track?: string;\n  segment?: string;\n  overflow?: string;\n  over?: string;\n  legend?: string;\n  legendItem?: string;\n  swatch?: string;\n  amount?: string;\n  remainder?: string;\n}\n\nexport interface UsageMeterProps {\n  /** What is metered: \"Storage\", \"Seats\". */\n  label: string;\n  /** What fills the allowance, in order. */\n  segments: UsageMeterSegment[];\n  limit: number;\n  /** After each number: \"GB\", \"k\". */\n  unit?: string;\n  decimals?: number;\n  /** Write the numbers with Intl options instead (`{ style: \"currency\", currency: \"USD\" }` gives \"$7.50\"); `unit`, if\n   *  given, still follows. Serializable, so a server component can pass it. */\n  formatOptions?: Intl.NumberFormatOptions;\n  /** The locale the numbers are written in. Defaults to the enclosing UIRoot locale. */\n  locale?: string;\n  /** Words for what is left. Default \"left\". */\n  freeLabel?: string;\n  /** Words for going past the limit. Default \"over\". */\n  overLabel?: string;\n  /** How the sources are told apart: series colours (default), or quiet ink steps, darkest first, for money and other\n   *  amounts where a series colour (red in Pop) would read as an alarm; the overflow keeps its tone either way. */\n  palette?: \"series\" | \"quiet\";\n  /** Share of the limit (0–1) where it turns to a warning. Default 0.9. */\n  warnAt?: number;\n  className?: string;\n  style?: CSSProperties;\n  classNames?: UsageMeterClassNames;\n  ref?: Ref<HTMLDivElement>;\n}\n\n/** An allowance and what fills it: one bar split by source, the remaining room, a warning near the limit and the\n *  overflow in its own red well past a notch at the limit; a change to the total leaves its ghost for a moment. Colour is never the only signal: every number is written in the legend. */\n/** The quiet palette: one ink, darkest first, each step still told apart from the next and from the empty well. */\nconst QUIET = [78, 52, 32, 20];\n\nexport function UsageMeter({\n  label,\n  segments,\n  limit,\n  unit = \"\",\n  decimals = 0,\n  formatOptions,\n  locale,\n  freeLabel: freeLabelProp,\n  overLabel: overLabelProp,\n  warnAt = 0.9,\n  palette = \"series\",\n  className,\n  style,\n  classNames,\n  ref,\n}: UsageMeterProps) {\n  const i18n = useMessages(\"usage-meter\", englishMessages);\n  const freeLabel = freeLabelProp === undefined ? i18n(\"freeLabel\") : freeLabelProp;\n  const overLabel = overLabelProp === undefined ? i18n(\"overLabel\") : overLabelProp;\n\n  const total = segments.reduce((n, s) => n + s.value, 0);\n  const over = total > limit;\n  const fmt = (n: number) =>\n    `${formatNumber(n, { locale: locale ?? i18n.locale, formatOptions: formatOptions ?? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } })}${unit ? (unit.length > 2 ? ` ${unit}` : unit) : \"\"}`;\n  const state = over ? \"over\" : total >= limit * warnAt ? \"warn\" : \"ok\";\n  // the allowance well shows each source up to the limit; whatever lies past it goes to the overflow well\n  let room = limit;\n  const shown = segments.map((s) => {\n    const v = Math.max(0, Math.min(s.value, room));\n    room -= v;\n    return v;\n  });\n  // the total's last change stays a moment as a ghost (_shared/delta-ghost): freed room drains, new use arrives lit\n  const [ghost, clearGhost] = useDeltaGhost(Math.min(total, limit) / limit);\n  const swatch = (i: number) =>\n    ({\n      \"--_c\":\n        palette === \"quiet\"\n          ? `color-mix(in oklab, var(--pui-ink) ${QUIET[i % QUIET.length]}%, transparent)`\n          : `var(--pui-series-${(i % 8) + 1})`,\n    }) as CSSProperties;\n  // one source in focus (its legend entry or its part of the bar pointed at): the others fade toward the card\n  const [focus, setFocus] = useState<string | null>(null);\n  const tokens = useMotionTokens();\n  const lit = (id: string) => ({\n    \"data-focus\": focus === id ? \"\" : undefined,\n    \"data-dim\": focus !== null && focus !== id ? \"\" : undefined,\n    onPointerEnter: () => setFocus(id),\n    onPointerLeave: () => setFocus((f) => (f === id ? null : f)),\n  });\n  return (\n    <div\n      ref={ref}\n      style={style}\n      data-slot=\"usage-meter\"\n      data-state={state}\n      className={cn(styles.root, \"grid gap-2\", classNames?.root, className)}\n    >\n      <div\n        data-slot=\"usage-meter-header\"\n        className={cn(\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 text-sm\", classNames?.header)}\n      >\n        <span data-slot=\"usage-meter-label\" className={cn(\"font-medium text-ink\", classNames?.label)}>\n          {label}\n        </span>\n        <span\n          data-slot=\"usage-meter-value\"\n          className={cn(\n            \"inline-flex items-center gap-1 font-numeric whitespace-nowrap tabular-nums\",\n            classNames?.value,\n            state === \"over\" ? \"text-red\" : state === \"warn\" ? \"text-orange\" : \"text-ink-2\",\n          )}\n        >\n          {state === \"over\" && <AlertCircle aria-hidden className=\"size-3.5 self-center\" />}\n          {state === \"warn\" && <AlertTriangle aria-hidden className=\"size-3.5 self-center\" />}\n          <span className=\"[unicode-bidi:isolate]\">\n            <SlotText value={fmt(total)} stagger={0} duration={tokens.duration.standard * 1000} />{\" \"}\n            <span className=\"text-ink-3\">{i18n(\"of\", { value: fmt(limit) })}</span>\n            <span data-slot=\"usage-meter-share\" className={cn(styles.share, \"text-ink-3\")}>\n              {\" · \"}\n              {i18n.number(total / limit, { style: \"percent\", maximumFractionDigits: 0 })}\n            </span>\n          </span>\n        </span>\n      </div>\n      <div\n        role=\"meter\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={limit}\n        aria-valuenow={total}\n        aria-valuetext={i18n(\"valueText\", {\n          used: fmt(total),\n          limit: fmt(limit),\n          remaining: fmt(Math.abs(total - limit)),\n          label: over ? overLabel : freeLabel,\n          count: Math.abs(total - limit),\n        })}\n        data-slot=\"usage-meter-bar\"\n        className={cn(styles.bar, classNames?.bar)}\n      >\n        <span\n          className={cn(styles.track, classNames?.track)}\n          data-slot=\"usage-meter-track\"\n          style={{ flex: `${limit} 1 0px` }}\n        >\n          <DeltaGhost ghost={ghost} onDone={clearGhost} />\n          {segments.map((s, i) => (\n            <span\n              key={s.id}\n              data-slot=\"usage-meter-segment\"\n              className={cn(styles.segment, classNames?.segment)}\n              style={{ ...swatch(i), width: `${(shown[i] / limit) * 100}%` }}\n              {...lit(s.id)}\n            />\n          ))}\n        </span>\n        {over && (\n          <span\n            data-slot=\"usage-meter-overflow\"\n            className={cn(styles.track, classNames?.overflow)}\n            style={{ flex: `${total - limit} 1 0px` }}\n          >\n            <span data-slot=\"usage-meter-over\" className={cn(styles.over, classNames?.over)} />\n          </span>\n        )}\n      </div>\n      <ul\n        data-slot=\"usage-meter-legend\"\n        className={cn(\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2\", classNames?.legend)}\n      >\n        {segments.map((s, i) => (\n          <li\n            key={s.id}\n            data-slot=\"usage-meter-legend-item\"\n            className={cn(styles.item, \"flex items-center gap-1.5\", classNames?.legendItem)}\n            {...lit(s.id)}\n          >\n            <span\n              aria-hidden\n              className={cn(styles.swatch, classNames?.swatch)}\n              data-slot=\"usage-meter-swatch\"\n              style={swatch(i)}\n            />\n            {s.label}\n            <span\n              data-slot=\"usage-meter-amount\"\n              className={cn(\"font-numeric text-ink-3 tabular-nums\", classNames?.amount)}\n            >\n              {fmt(s.value)}\n            </span>\n          </li>\n        ))}\n        <li\n          data-slot=\"usage-meter-remainder\"\n          className={cn(\n            \"flex items-center gap-1.5\",\n            over ? \"font-medium text-red\" : \"text-ink-3\",\n            classNames?.remainder,\n          )}\n        >\n          {over ? (\n            <>\n              <AlertCircle aria-hidden className=\"size-3\" />\n              {fmt(total - limit)} {overLabel}\n            </>\n          ) : (\n            <>\n              <span aria-hidden className={styles.swatchFree} />\n              {fmt(limit - total)} {freeLabel}\n            </>\n          )}\n        </li>\n      </ul>\n    </div>\n  );\n}\n\nexport default UsageMeter;\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/lib/number.ts","type":"registry:file","target":"src/ui/beamline/lib/number.ts","content":"import { createFormatters, FALLBACK_LOCALE } from \"../runtime/messages\";\n/** Reads a number typed in `locale` (\"1.234,5\" in de, \"1,234.5\" in en): group separators dropped, the locale's decimal\n *  mark read as the point, currency signs and units ignored. Empty or unreadable text is null; nothing is clamped. */\nexport function parseLocaleNumber(text: string, locale?: string): number | null {\n  if (!text.trim()) return null;\n  // exponent form: \"1e3\" is a thousand and \"2.5e-3\" a fraction (the digits are never run together into 13)\n  const exp = text.trim().match(/^(.*\\d)\\s*e\\s*([+\\u2212-]?\\d+)$/i);\n  if (exp) {\n    const base = parseLocaleNumber(exp[1], locale);\n    return base === null ? null : Number((base * 10 ** Number(exp[2].replace(\"\\u2212\", \"-\"))).toPrecision(15));\n  }\n  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);\n  const group = parts.find((p) => p.type === \"group\")?.value ?? \",\";\n  const decimal = parts.find((p) => p.type === \"decimal\")?.value ?? \".\";\n  let ascii = text;\n  // one grouping mark that cannot group (not three digits after it) and no decimal mark: the decimal was written the\n  // other locale's way (\"1,5\" in English is one and a half, never fifteen)\n  const marks = text.split(group).length - 1;\n  if (marks === 1 && !text.includes(decimal) && !new RegExp(`\\\\${group}\\\\d{3}(?!\\\\d)`).test(text))\n    ascii = text.replace(group, decimal);\n  if (/[^\\p{ASCII}]/u.test(text)) {\n    const digits = new Intl.NumberFormat(locale, { useGrouping: false });\n    for (let value = 0; value < 10; value++) ascii = ascii.replaceAll(digits.format(value), String(value));\n  }\n  const normalized = ascii\n    .replaceAll(group, \"\")\n    .replaceAll(\"\\u202f\", \"\")\n    .replace(decimal, \".\")\n    .replace(/[\\u2212\\u2013]/g, \"-\")\n    .replace(/[^\\d.-]/g, \"\");\n  if (!/\\d/.test(normalized)) return null;\n  const n = Number(normalized);\n  return Number.isFinite(n) ? n : null;\n}\n\nconst formatters = new Map<string, Intl.NumberFormat>();\n/** A number written from serializable settings, so a server component can pass them where a `format` function cannot\n *  cross: Intl options in a locale, with words before and after (\"$\" … \" / mo\"). One Intl.NumberFormat per setting. */\nexport function formatNumber(\n  value: number,\n  { locale, formatOptions, prefix = \"\", suffix = \"\" }: NumberFormatSettings = {},\n): string {\n  const key = `${locale ?? \"\"}|${JSON.stringify(formatOptions ?? {})}`;\n  let f = formatters.get(key);\n  if (!f) formatters.set(key, (f = new Intl.NumberFormat(locale, formatOptions)));\n  return `${prefix}${f.format(value)}${suffix}`;\n}\n\nexport interface NumberFormatSettings {\n  locale?: string;\n  formatOptions?: Intl.NumberFormatOptions;\n  /** Written before the number: \"$\". */\n  prefix?: string;\n  /** Written after the number: \" ms\", \" / mo\". */\n  suffix?: string;\n}\n\n/** Binary-scaled file sizes; the digits and unit names follow the supplied locale. */\nexport function formatByteSize(bytes: number, locale = FALLBACK_LOCALE, maximumUnit = 3, fractionDigits?: number) {\n  const units = [\"byte\", \"kilobyte\", \"megabyte\", \"gigabyte\"];\n  let value = bytes,\n    unit = 0;\n  while (value >= 1024 && unit < maximumUnit) {\n    value /= 1024;\n    unit++;\n  }\n  const digits = fractionDigits ?? (value < 10 && unit > 0 ? 1 : 0);\n  return createFormatters(locale).number(value, {\n    style: \"unit\",\n    unit: units[unit],\n    unitDisplay: \"short\",\n    minimumFractionDigits: digits,\n    maximumFractionDigits: digits,\n  });\n}\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Usage meter is in src/ui/beamline/components/usage-meter/usage-meter.tsx. The source is in src/ui/beamline (ui/beamline in a project without src/). After Tailwind, import its styles/beamline.css once, then styles/look-prism.css in your app stylesheet (use relative paths). Keep the shared base before all chosen looks. Wrap your app in <UIRoot preset={prism}> with UIRoot from its runtime/appearance and prism from its looks/prism.  shadcn replaces files when asked; use the Beamline managed-source CLI from the start for three-way upgrades that preserve edits.","meta":{"free":true,"version":"0.2.0-rc.42","library":"https://beamline.io/components/usage-meter"}}