{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"context-ring","type":"registry:ui","title":"ContextRing","description":"How much of the context window is gone, in the space of a favicon: a donut that fills as the conversation grows, the compact count beside it, and an optional popover breaking the total down by what is holding it","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/context-ring/ContextRing.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useFloat } from \"../../internals/use-float\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport type { FloatRect } from \"../../internals/float\";\nimport type { TokenUsageData } from \"../../internals/ai-types\";\nimport \"./context-ring.css\";\n\n/**\n * Props for ContextRing\n */\nexport interface ContextRingProps {\n\t/** Context-window consumption: how many tokens are used, out of how many, and of what. */\n\tusage: TokenUsageData;\n\t/** Outer diameter of the ring, in pixels. */\n\tsize?: number;\n\t/** Thickness of both the track and the arc, in pixels. */\n\tstrokeWidth?: number;\n\t/** Whether the compact \"12.4k / 200k\" figure is shown beside the ring. */\n\tshowLabel?: boolean;\n\t/** Fraction at which the ring leaves the quiet band, 0–1. */\n\twarnAt?: number;\n\t/** Fraction at which the ring turns to the error colour, 0–1. */\n\tcriticalAt?: number;\n\t/** Accessible name for the meter — what this ring is measuring. */\n\tlabel?: string;\n\t/** Whether clicking the ring opens a popover listing `usage.breakdown`. */\n\texpandable?: boolean;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/** Shown in the popover when a caller asked for one but sent no rows. */\nconst NO_BREAKDOWN = \"No breakdown reported.\";\n\n// -----------------------------------------------------------------------------\n// Numbers\n// -----------------------------------------------------------------------------\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(value, max));\n}\n\n/** A fraction prop that arrived as nonsense falls back rather than poisoning the bands. */\nfunction fractionProp(value: number, fallback: number): number {\n\treturn Number.isFinite(value) ? clamp(value, 0, 1) : fallback;\n}\n\n/**\n * The ring's own tiny formatter, deliberately not `Intl.NumberFormat`'s compact\n * notation: that rounds 12,400 to \"12K\" and drops the digit the reader is\n * actually watching. Below a thousand the count is exact, below a hundred\n * thousand it keeps one decimal, and above that the decimal is noise on a\n * figure nobody reads that precisely. A whole number never carries a hanging\n * \".0\" — \"1k\", not \"1.0k\".\n */\nfunction compact(value: number): string {\n\tif (!Number.isFinite(value)) return \"0\";\n\tconst n = Math.max(0, Math.round(value));\n\tif (n < 1000) return String(n);\n\tif (n < 100_000) {\n\t\tconst fixed = (n / 1000).toFixed(1);\n\t\treturn `${fixed.endsWith(\".0\") ? fixed.slice(0, -2) : fixed}k`;\n\t}\n\treturn `${Math.round(n / 1000)}k`;\n}\n\n/** The spoken figure is grouped in full, because \"12.4k\" is a glance, not a reading. */\nfunction grouped(value: number): string {\n\treturn Math.max(0, Math.round(Number.isFinite(value) ? value : 0)).toLocaleString(\"en-US\");\n}\n\n/**\n * ContextRing\n */\nexport const ContextRing = forwardRef<HTMLDivElement, ContextRingProps>(function ContextRing(\n\t{\n\t\tusage,\n\t\tsize = 28,\n\t\tstrokeWidth = 3,\n\t\tshowLabel = true,\n\t\twarnAt = 0.75,\n\t\tcriticalAt = 0.9,\n\t\tlabel = \"Context usage\",\n\t\texpandable = false,\n\t\tclassName,\n\t\tsound = false,\n\t},\n\tforwardedRef\n) {\n\tconst uid = useFancyId();\n\tconst panelId = `${uid}-breakdown`;\n\n\t// Plain refs, not `useElementRef` (the exception convention C-1 carves out):\n\t// both elements are rendered unconditionally for the configuration that uses\n\t// them and the nodes are only ever read inside an event handler or the float\n\t// anchor getter, never by a hook keyed on their arrival.\n\tconst rootRef = useRef<HTMLDivElement | null>(null);\n\tconst composedRootRef = useComposedRefs(forwardedRef, rootRef);\n\n\tconst playCue = useSoundCue(sound);\n\n\t// -------------------------------------------------------------------------\n\t// Numbers\n\t// -------------------------------------------------------------------------\n\n\tconst used = Number.isFinite(usage.used) ? Math.max(0, usage.used) : 0;\n\t// A budget of zero — or of nothing at all — is an empty ring, not a division.\n\tconst max = Number.isFinite(usage.max) && usage.max > 0 ? usage.max : 0;\n\tconst fraction = max > 0 ? Math.min(1, used / max) : 0;\n\n\t// `criticalAt` is floored at `warnAt` so a caller who swaps the two gets a ring\n\t// that still escalates in one direction instead of one band that can never win.\n\tconst warn = fractionProp(warnAt, 0.75);\n\tconst critical = Math.max(warn, fractionProp(criticalAt, 0.9));\n\tconst band = fraction >= critical ? \"critical\" : fraction >= warn ? \"warn\" : \"ok\";\n\n\tconst labelText = `${compact(used)} / ${compact(max)}`;\n\t// Over budget pins the meter at its maximum, which is what `aria-valuenow` is\n\t// allowed to say; the text beside it still reports the real figure.\n\tconst valueNow = Math.round(Math.min(used, max));\n\tconst valueMax = Math.round(max);\n\tconst valueText = `${grouped(used)} of ${grouped(max)} tokens`;\n\n\tconst rows = usage.breakdown ?? [];\n\n\t// -------------------------------------------------------------------------\n\t// Geometry\n\t// -------------------------------------------------------------------------\n\n\tconst px = Number.isFinite(size) ? Math.max(8, size) : 28;\n\t// The stroke is centred on the radius, so it cannot be wider than the ring has\n\t// room for — past that the arc eats its own centre and the shape stops reading.\n\tconst stroke = clamp(\n\t\tNumber.isFinite(strokeWidth) ? strokeWidth : 3,\n\t\t0.5,\n\t\tMath.max(0.5, px / 2 - 0.5)\n\t);\n\tconst centre = px / 2;\n\tconst radius = Math.max(0.5, (px - stroke) / 2);\n\tconst circumference = 2 * Math.PI * radius;\n\n\t// The ring is painted empty and its target written a frame later, so the sweep\n\t// runs from a value the browser has already put on screen. Everything the meter\n\t// *means* — its value, its text, its band — is derived above and needs no mount,\n\t// so a server render is exact and only the entrance waits for the client.\n\tconst [filled, setFilled] = useState(false);\n\tconst sweep = filled ? fraction : 0;\n\tconst dashOffset = circumference * (1 - sweep);\n\n\tuseIsomorphicLayoutEffect(() => {\n\t\t// Reduced motion wants the settled arc, not a fast one: write it in the same\n\t\t// breath so there is never a frame of empty ring to notice.\n\t\tif (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n\t\t\tsetFilled(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// The mount effect still runs inside the frame that mounted the ring, so\n\t\t// setting the target here would land in the same paint as the empty arc and\n\t\t// the browser would have nothing to transition between. Two frames out is the\n\t\t// guarantee: the first callback can still belong to the mounting frame, the\n\t\t// second cannot, so the empty ring is on screen before the target is written.\n\t\tlet second = 0;\n\t\tconst first = requestAnimationFrame(() => {\n\t\t\tsecond = requestAnimationFrame(() => {\n\t\t\t\tsetFilled(true);\n\t\t\t});\n\t\t});\n\n\t\treturn () => {\n\t\t\tcancelAnimationFrame(first);\n\t\t\tcancelAnimationFrame(second);\n\t\t};\n\t}, []);\n\n\t// -------------------------------------------------------------------------\n\t// Breakdown popover\n\t// -------------------------------------------------------------------------\n\n\tconst [open, setOpen] = useState(false);\n\tconst triggerRef = useRef<HTMLButtonElement | null>(null);\n\t// The open flag as the handlers read it, written in the same breath as the\n\t// state so a second dismissal in the same tick is already guarded — the\n\t// state itself is a render behind until React flushes.\n\tconst openRef = useRef(false);\n\n\t// One place every dismissal funnels through, so the toggle, Escape and an\n\t// outside pointerdown all give exactly one `close` cue rather than each\n\t// wiring its own.\n\tconst close = useCallback(() => {\n\t\tif (!openRef.current) return;\n\t\topenRef.current = false;\n\t\tsetOpen(false);\n\t\tplayCue(\"close\");\n\t}, [playCue]);\n\n\tconst toggle = useCallback(() => {\n\t\tif (!openRef.current) {\n\t\t\topenRef.current = true;\n\t\t\tsetOpen(true);\n\t\t\tplayCue(\"open\");\n\t\t\treturn;\n\t\t}\n\t\tclose();\n\t}, [close, playCue]);\n\n\t// Taking expandability away takes the panel with it. Left open, it would keep\n\t// its window listeners with no trigger on screen, and turning expandability\n\t// back on would reopen a panel nobody asked to see again. Bookkeeping rather\n\t// than a dismissal, so it writes the flag directly and stays silent.\n\tuseEffect(() => {\n\t\tif (!expandable) {\n\t\t\topenRef.current = false;\n\t\t\tsetOpen(false);\n\t\t}\n\t}, [expandable]);\n\n\t// Escape and a click elsewhere both dismiss. Bound to the window rather than to\n\t// the panel so they work whether or not anything inside it holds focus; the\n\t// listeners exist only while the panel does.\n\tuseEffect(() => {\n\t\tif (!open) return;\n\n\t\tconst onKeydown = (event: KeyboardEvent) => {\n\t\t\tif (event.key !== \"Escape\") return;\n\t\t\tclose();\n\t\t\t// Focus goes back to what opened the panel, not to the top of the page.\n\t\t\ttriggerRef.current?.focus();\n\t\t};\n\t\tconst onPointerDown = (event: Event) => {\n\t\t\tconst target = event.target as Node | null;\n\t\t\tif (target && rootRef.current?.contains(target)) return;\n\t\t\tclose();\n\t\t};\n\n\t\twindow.addEventListener(\"keydown\", onKeydown);\n\t\twindow.addEventListener(\"pointerdown\", onPointerDown, true);\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"keydown\", onKeydown);\n\t\t\twindow.removeEventListener(\"pointerdown\", onPointerDown, true);\n\t\t};\n\t}, [open, close]);\n\n\t// The anchor is read through a getter so the float re-measures the trigger on\n\t// every scroll and resize tick rather than holding a stale rect.\n\t// Identity-stable, so the float is never re-synced for the getter alone.\n\tconst anchor = useCallback(\n\t\t(): FloatRect | null => triggerRef.current?.getBoundingClientRect() ?? null,\n\t\t[]\n\t);\n\t// In the DOM only while it is on screen, so the hook is handed `null` — and\n\t// does nothing at all — for as long as the panel is closed.\n\tconst [panelEl, panelRef] = useElementRef<HTMLDivElement>();\n\tuseFloat(panelEl, { anchor, placement: \"bottom-end\", offset: 8 });\n\n\t/*\n\t * One `meter` for the pair: the arc says nothing out loud and the compact figure\n\t * is a glance rather than a reading, so assistive tech is given the full count\n\t * once through `aria-valuetext` and both halves are hidden behind it.\n\t */\n\tconst face = (\n\t\t<>\n\t\t\t<span\n\t\t\t\tclassName=\"ft-ctxring-meter inline-flex flex-none\"\n\t\t\t\trole=\"meter\"\n\t\t\t\taria-label={label}\n\t\t\t\taria-valuenow={valueNow}\n\t\t\t\taria-valuemin={0}\n\t\t\t\taria-valuemax={valueMax}\n\t\t\t\taria-valuetext={valueText}\n\t\t\t>\n\t\t\t\t<svg\n\t\t\t\t\tclassName=\"ft-ctxring-ring\"\n\t\t\t\t\tviewBox={`0 0 ${px} ${px}`}\n\t\t\t\t\tstyle={{ width: `${px}px`, height: `${px}px` }}\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t>\n\t\t\t\t\t<circle\n\t\t\t\t\t\tclassName=\"ft-ctxring-track\"\n\t\t\t\t\t\tcx={centre}\n\t\t\t\t\t\tcy={centre}\n\t\t\t\t\t\tr={radius}\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstrokeWidth={stroke}\n\t\t\t\t\t/>\n\t\t\t\t\t<circle\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"ft-ctxring-value\",\n\t\t\t\t\t\t\tband === \"ok\" && \"ft-status-pending\",\n\t\t\t\t\t\t\tband === \"warn\" && \"ft-status-running\",\n\t\t\t\t\t\t\tband === \"critical\" && \"ft-status-error\"\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tcx={centre}\n\t\t\t\t\t\tcy={centre}\n\t\t\t\t\t\tr={radius}\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstrokeWidth={stroke}\n\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\tstrokeDasharray={circumference}\n\t\t\t\t\t\tstrokeDashoffset={dashOffset}\n\t\t\t\t\t\ttransform={`rotate(-90 ${centre} ${centre})`}\n\t\t\t\t\t/>\n\t\t\t\t</svg>\n\t\t\t</span>\n\n\t\t\t{showLabel && (\n\t\t\t\t<span className=\"ft-ctxring-label text-foreground/70 text-xs tabular-nums\" aria-hidden=\"true\">\n\t\t\t\t\t{labelText}\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</>\n\t);\n\n\treturn (\n\t\t<div\n\t\t\tref={composedRootRef}\n\t\t\tclassName={cn(\"ft-ctxring inline-flex items-center gap-2\", className)}\n\t\t\tdata-band={band}\n\t\t>\n\t\t\t{expandable ? (\n\t\t\t\t/*\n\t\t\t\t * An explicit `aria-label`, because a button flattens everything inside it\n\t\t\t\t * to presentational: the `role=\"meter\"` and its value below never reach\n\t\t\t\t * assistive tech once they are nested here. The button's own name carries\n\t\t\t\t * both the meter's name and its current reading instead.\n\t\t\t\t */\n\t\t\t\t<button\n\t\t\t\t\tref={triggerRef}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"ft-ctxring-trigger hover:bg-foreground/5 focus-visible:ring-ring -mx-1 -my-0.5 inline-flex items-center gap-2 rounded-md px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n\t\t\t\t\taria-expanded={open}\n\t\t\t\t\taria-controls={panelId}\n\t\t\t\t\taria-label={`${label}, ${valueText}`}\n\t\t\t\t\tonClick={toggle}\n\t\t\t\t>\n\t\t\t\t\t{face}\n\t\t\t\t</button>\n\t\t\t) : (\n\t\t\t\tface\n\t\t\t)}\n\n\t\t\t{expandable && open && (\n\t\t\t\t/*\n\t\t\t\t * Rendered only while it is on screen: a breakdown that lives in the DOM\n\t\t\t\t * permanently is a hidden list every screen-reader element list has to\n\t\t\t\t * step over. The anchor is read through a getter so the float re-measures\n\t\t\t\t * the trigger on every scroll and resize tick rather than holding a stale\n\t\t\t\t * rect.\n\t\t\t\t */\n\t\t\t\t<div\n\t\t\t\t\tref={panelRef}\n\t\t\t\t\tid={panelId}\n\t\t\t\t\trole=\"group\"\n\t\t\t\t\taria-label={`${label} breakdown`}\n\t\t\t\t\tclassName=\"ft-ctxring-panel z-50 rounded-lg border p-2 text-left text-xs shadow-lg\"\n\t\t\t\t>\n\t\t\t\t\t{rows.length > 0 ? (\n\t\t\t\t\t\t<ul className=\"flex flex-col gap-1\">\n\t\t\t\t\t\t\t{/*\n\t\t\t\t\t\t\t\tKeyed on the label *and* the index: a model happily reports two\n\t\t\t\t\t\t\t\trows called \"System prompt\", and a key that is only the label\n\t\t\t\t\t\t\t\twould make the second one replace the first.\n\t\t\t\t\t\t\t*/}\n\t\t\t\t\t\t\t{rows.map((row, i) => (\n\t\t\t\t\t\t\t\t<li\n\t\t\t\t\t\t\t\t\tkey={`${row.label}#${i}`}\n\t\t\t\t\t\t\t\t\tclassName=\"ft-ctxring-row flex items-baseline justify-between gap-4\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span className=\"min-w-0 truncate\">{row.label}</span>\n\t\t\t\t\t\t\t\t\t{/* Kept explicitly: the source markup leaves whitespace between\n\t\t\t\t\t\t\t\t\t    the two spans, and the row's spoken/copied text depends on it. */}{\" \"}\n\t\t\t\t\t\t\t\t\t<span className=\"ft-ctxring-row-value flex-none tabular-nums\">\n\t\t\t\t\t\t\t\t\t\t{compact(row.tokens)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<p className=\"ft-ctxring-empty italic\">{NO_BREAKDOWN}</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/context-ring/ContextRing.tsx"},{"path":"react/src/components/context-ring/context-ring.css","content":"/*\n * Every colour is read at the point of use through two hooks: this ring's own\n * `--ft-ctxring-*`, then the `--ft-status-*` vocabulary the whole AI family\n * shares, then the literal. Setting either anywhere up the tree retints the\n * ring without having to win a specificity fight against these scoped rules,\n * and the shared one recolours every sibling component at once.\n */\n.ft-ctxring-track {\n\tstroke: var(--ft-ctxring-track, color-mix(in oklab, currentColor 15%, transparent));\n}\n\n/*\n * The band is carried by the arc's length as much as by its hue — a quarter\n * ring and a nearly closed one are told apart without seeing colour at all,\n * and the figure beside it says the count in words either way.\n */\n.ft-ctxring-value.ft-status-pending {\n\tstroke: var(\n\t\t--ft-ctxring-ok,\n\t\tvar(--ft-status-pending, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260)))\n\t);\n}\n\n.ft-ctxring-value.ft-status-running {\n\tstroke: var(\n\t\t--ft-ctxring-warn,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n.ft-ctxring-value.ft-status-error {\n\tstroke: var(\n\t\t--ft-ctxring-critical,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n}\n\n.ft-ctxring-panel {\n\twidth: var(--ft-ctxring-panel-width, 14rem);\n\tmax-width: calc(100vw - 1rem);\n\tbackground: var(--ft-ctxring-panel-bg, var(--color-popover, canvas));\n\tborder-color: var(--ft-ctxring-panel-border, var(--color-border, currentColor));\n\tcolor: var(--ft-ctxring-panel-fg, var(--color-popover-foreground, canvastext));\n}\n\n.ft-ctxring-row-value {\n\tcolor: var(--ft-ctxring-row-value, color-mix(in oklab, currentColor 70%, transparent));\n}\n\n.ft-ctxring-empty {\n\tcolor: var(--ft-ctxring-row-value, color-mix(in oklab, currentColor 70%, transparent));\n}\n\n/*\n * Everything that moves lives behind the query, so reduced motion is not a\n * degraded variant to keep in sync: the arc is drawn at its final length, the\n * band change is instant, and the panel simply appears, already placed.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-ctxring-value {\n\t\ttransition:\n\t\t\tstroke-dashoffset 600ms cubic-bezier(0.4, 0, 0.2, 1),\n\t\t\tstroke 300ms linear;\n\t}\n\n\t.ft-ctxring-panel {\n\t\tanimation: ft-ctxring-in 140ms cubic-bezier(0.4, 0, 0.2, 1);\n\t}\n}\n\n@keyframes ft-ctxring-in {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(-3px);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: none;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/context-ring/context-ring.css"},{"path":"react/src/components/context-ring/index.ts","content":"\"use client\";\n\nexport { ContextRing } from \"./ContextRing\";\nexport type { ContextRingProps } from \"./ContextRing\";\n","type":"registry:ui","target":"@ui/fancy/components/context-ring/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/context-ring?framework=react"}}