{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart",
  "type": "registry:ui",
  "title": "Chart",
  "description": "Bar, line and stacked charts in plain SVG, and a meter. The marks are never the accent: the quiet foreground by default, one series at 55% with the current mark full or up to three stepping down, or up to five data hues when color tells the series apart. The stacked chart draws each category's parts as one thin rail, a hue per series, with the total in the tooltip. Hairline gridlines, mono labels, a tooltip on hover and focus, and one entrance on the responsive tier.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/chart.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/chart 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Chart: BarChart and LineChart in plain SVG, and Meter. No chart library. A chart is quiet on purpose (R6: the\n * accent belongs to the one primary action, and a chart is made of many marks).\n *\n * - Color (components.md, Chart): a mark is never the accent. By default (the quiet foreground) one series is fg\n *   at 55% for bars and fg for a line. The current mark (the last one by default) and the hovered or focused one\n *   are full fg, and the hovered or focused one replaces the current one, so only one is full. Up to three series\n *   step down the foreground, 100%, 55% and 30%, with a legend. At 30% a mark is under 3:1 against a card, so the\n *   third series' bars carry a 1px edge at 55% and its line is dashed. Colors come from CSS variables, so both\n *   themes work.\n * - Data hues (R7, v0.11): a series may carry one of the five data hues (`hue` on the series), and `hues` gives\n *   every series without one the next hue in the fixed order. Color follows the series, never its place, so a\n *   filter that drops a series never repaints the others. A hue series rests in the hue's mark tone (3:1 on every\n *   surface) and its emphasised mark takes the hue's text tone, a step stronger, so no mark falls under 3:1 and\n *   none needs an edge. Up to five hue series. Color alone must not tell five lines apart (WCAG 1.4.1), so the\n *   third, fourth and fifth hues' lines are dashed, dotted and dash-dotted, in the legend swatch too; blue and\n *   amber stay solid. The dash follows the hue, as the color does.\n * - Anatomy: horizontal gridlines only (line), the zero baseline in line-strong, three to five lines in all. Axis\n *   labels are caption-size in fg-subtle with tabular numbers. When categories are too close for their\n *   labels, every nth label shows, counted back from the last mark so the current one keeps its label. Bars are at\n *   most 32px thick and 60% of their slot, with square tops: 2px is off the radius scale and 4px would turn a\n *   phone-sized bar into a bullet. Lines are 2px with round joins and a 4px marker. Values are zero or more.\n * - Drawn at the container's width, measured with a ResizeObserver, so text and hairlines stay crisp (nothing is\n *   scaled). Before the first measure (and on the server) only the hidden table renders, in a box of the final height.\n * - Marks: the pointer is one layer over the plot, so the target is the whole plot and the mark under the pointer\n *   is the active one (on a phone a column is narrower than a finger, so a touch scrubs). For the keyboard and\n *   screen readers there is one visually hidden button per category: one tab stop (roving tabindex), and the\n *   arrow keys move along them. Hover is a color change on the instant tier. A keyboard-focused mark gets a 2px\n *   focus ring drawn around it in the SVG, only when :focus-visible matches.\n * - Tooltip: the Tooltip's look (solid surface-3, shadow-litt-tooltip) positioned above the mark, kept inside the\n *   chart, following the pointer from mark to mark with no re-entry. Esc hides it without moving focus. A touch\n *   pins it where the finger is, and it stays until the next touch elsewhere. It repeats the mark's label, so it is\n *   hidden from screen readers.\n * - Motion: one entrance on the responsive tier. Bars grow from the baseline by scaleY (transform-origin at the\n *   bottom) and a line fades in. Height, width and path data never animate: new data redraws at once. Reduced\n *   motion is the hooks' job. Hover and focus add no motion beyond a color change.\n * - Accessibility: the SVG is role=\"img\" with a summary label, and a visually hidden table lists every value.\n *   The category buttons are in a labelled group outside the image (children of an image are presentational).\n * - Stacked (StackedBarChart, growth step): the parts of each category as one stacked rail, a hue per series. A column\n *   is at most 8px wide, each segment has the control radius, and segments sit 2px apart, like the Breakdown's split\n *   rail: a hue is a thread, never a block. The tallest stack sets the scale. Series take the data hues (`hue`, else\n *   the next in the fixed order), so a Legend can drop one without repainting the others. The active column takes its\n *   series' text tones over a faint column band; the tooltip lists each part and the total; the hidden table adds a\n *   Total column. Up to five series. The entrance grows each column from the baseline, like the bars.\n * - Meter: a horizontal progress bar, a 4px rail at the control radius in fg 12% filled with fg, or a data hue. A target is\n *   a 2px tick in fg-subtle taller than the rail; steps split the rail into equal segments 2px apart. Static, unless fun\n *   mode (motion/fun.tsx, a trial) is on: then the fill grows in and springs to each new value, the reading counts with\n *   it, the target tick pops as the fill passes it and steps light in turn. At rest it is the static Meter exactly.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from \"react\";\nimport { animate, AnimatePresence, motion, useMotionValueEvent, useTransform } from \"motion/react\";\nimport { countText, useExit, useFun, useFunValue, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- types */\n\n/** The five data hues, in their fixed order (R7). */\nexport type ChartHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** One named run of values, one per category. Values are zero or more. */\nexport type ChartSeries = {\n  /** What the series is: \"Deploys\", \"Failed\". It names the legend, the tooltip and the table column. */\n  name: string;\n  /** One value per label, in the same order as `labels`. */\n  values: number[];\n  /** Draws this series in one of the five data hues instead of the foreground. Keep a series' hue the same wherever it appears. */\n  hue?: ChartHue;\n};\n\ntype ChartBaseProps = {\n  /** The chart's name and what it shows, in a few words: \"Deploys per day, last 14 days\". It labels the image, the value group and the table. */\n  label: string;\n  /** One label per category, oldest first, kept short: \"3 Oct\". On narrow screens only every nth shows. */\n  labels: string[];\n  /** One to three series. Up to three step down the foreground and get a legend; a single series needs none. */\n  series: ChartSeries[];\n  /** Height of the whole chart in px, axis labels included. The width is the container's. */\n  height?: number;\n  /** Turns a value into text for the axis, the tooltip and the labels: `(v) => \\`${v} s\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** What the values count, said after the number in the tooltip and the marks' names: \"deploys\". Pass [\"deploy\", \"deploys\"] to say the first for exactly 1. Leave it out when the label says it. */\n  unit?: string | [one: string, other: string];\n  /** The first column's name in the hidden table, such as \"Day\". */\n  xTitle?: string;\n  /** Replaces the generated summary that names the image, such as \"Deploys per day, last 14 days: from 5 to 1, peak 9\". */\n  summary?: string;\n  /** The category with the emphasis: an index, \"last\" (the default) or \"none\". Only a single series takes it; a hovered or focused mark replaces it. */\n  highlight?: number | \"last\" | \"none\";\n  /** Gives every series without its own hue the next data hue in the fixed order: blue, amber, teal, violet, rose. Leave it off for the quiet foreground. */\n  hues?: boolean;\n  /** Plays the entrance on the responsive tier when the chart mounts. Remount with a new key to replay. */\n  entrance?: boolean;\n  /** Draws the chart's own legend above the plot when there are several series. Turn it off when a Legend drives the chart, so there is only one. */\n  legend?: boolean;\n  /** Extra classes for the chart's box, such as a top margin. */\n  className?: string;\n};\n\nexport type BarChartProps = ChartBaseProps;\nexport type LineChartProps = ChartBaseProps;\nexport type StackedBarChartProps = Omit<ChartBaseProps, \"highlight\" | \"hues\">;\n\n/* ----------------------------------------------------------------- helpers */\n\nconst TOP = 8; // room above the plot\nconst AXIS = 28; // room under the plot for the category labels\nconst LABEL_GAP = 6; // between a value label and the plot\nconst FIGURE = 7.2; // advance of a 12px tabular figure in Geist (letters run narrower)\nconst MAX_BAR = 32;\nconst MAX_RAIL = 8; // a stacked column: a rail, not a block\nconst RAIL_GAP = 2; // air between the segments of a stacked column, the Breakdown's\n\nconst mix = (percent: number) => `color-mix(in oklab, var(--litt-fg) ${percent}%, transparent)`;\n/** Fill of each series, as a class: 100%, 55%, 30% of the foreground. */\nconst FILL = [\"fill-litt-fg\", \"fill-[color-mix(in_oklab,var(--litt-fg)_55%,transparent)]\", \"fill-[color-mix(in_oklab,var(--litt-fg)_30%,transparent)]\"];\nconst STROKE = [\"var(--litt-fg)\", mix(55), mix(30)];\nconst EDGE = \"stroke-[color-mix(in_oklab,var(--litt-fg)_55%,transparent)]\";\n/** A line's dash (round caps add 1px to each end of a dash, so 0.5 draws a dot), and the same at the legend swatch's 16px, whose caps are square. */\ntype Dash = { line: string; swatch: string };\nconst DASHED: Dash = { line: \"6 4\", swatch: \"4 3\" };\nconst HUE_DASH: Partial<Record<ChartHue, Dash>> = {\n  teal: DASHED,\n  violet: { line: \"0.5 5\", swatch: \"2 2\" },\n  rose: { line: \"8 4 0.5 4\", swatch: \"6 2 2 2\" },\n};\n\nconst HUE_ORDER: ChartHue[] = [\"blue\", \"amber\", \"teal\", \"violet\", \"rose\"];\n/* Literal names, so Tailwind sees the classes and the registry can rename the variables: the mark tone at rest, the\n   text tone for the emphasised mark, then the same two as colors for a line and its marker. */\nconst HUE_PAINT: Record<ChartHue, [rest: string, strong: string, stroke: string, marker: string]> = {\n  blue: [\"fill-litt-data-blue\", \"fill-litt-data-blue-text\", \"var(--litt-data-blue)\", \"var(--litt-data-blue-text)\"],\n  amber: [\"fill-litt-data-amber\", \"fill-litt-data-amber-text\", \"var(--litt-data-amber)\", \"var(--litt-data-amber-text)\"],\n  teal: [\"fill-litt-data-teal\", \"fill-litt-data-teal-text\", \"var(--litt-data-teal)\", \"var(--litt-data-teal-text)\"],\n  violet: [\"fill-litt-data-violet\", \"fill-litt-data-violet-text\", \"var(--litt-data-violet)\", \"var(--litt-data-violet-text)\"],\n  rose: [\"fill-litt-data-rose\", \"fill-litt-data-rose-text\", \"var(--litt-data-rose)\", \"var(--litt-data-rose-text)\"],\n};\n\n/** How one series is painted: its bar fills at rest and emphasised, its line and marker colors, and the cues past color. */\ntype Paint = { rest: string; strong: string; stroke: string; marker: string; edge: boolean; dash?: Dash };\n\nfunction paints(series: ChartSeries[], hues: boolean): Paint[] {\n  let next = 0;\n  return series.map((s, j) => {\n    const hue = s.hue ?? (hues ? HUE_ORDER[next++ % HUE_ORDER.length] : undefined);\n    if (hue) {\n      const [rest, strong, stroke, marker] = HUE_PAINT[hue];\n      return { rest, strong, stroke, marker, edge: false, dash: HUE_DASH[hue] };\n    }\n    /* The foreground: one series rests at 55% with the emphasised mark full; several step down by their place. */\n    const level = Math.min(j, 2);\n    return series.length === 1\n      ? { rest: FILL[1]!, strong: FILL[0]!, stroke: STROKE[0]!, marker: STROKE[0]!, edge: false }\n      : { rest: FILL[level]!, strong: FILL[level]!, stroke: STROKE[level]!, marker: STROKE[level]!, edge: level === 2, dash: level === 2 ? DASHED : undefined };\n  });\n}\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\n\n/** Zero, then up to four equal steps that reach the maximum: 3 to 5 gridlines in all. */\nfunction niceTicks(max: number): number[] {\n  if (!(max > 0)) return [0, 1];\n  const exp = Math.floor(Math.log10(max));\n  for (let pow = exp - 2; pow <= exp + 1; pow++) {\n    for (const m of [1, 2, 5]) {\n      const step = Number((m * 10 ** pow).toPrecision(12));\n      const n = Math.ceil(max / step - 1e-9);\n      if (n >= 2 && n <= 4) return Array.from({ length: n + 1 }, (_, i) => Number((i * step).toPrecision(12)));\n    }\n  }\n  return [0, max];\n}\n\ntype Geometry = {\n  width: number;\n  height: number;\n  left: number;\n  plotW: number;\n  plotH: number;\n  slotW: number;\n  ticks: number[];\n  top: number; // y of the top of the plot\n  base: number; // y of the zero baseline\n  y: (value: number) => number;\n  cx: (index: number) => number; // x of a slot's centre\n  step: number; // show every nth category label\n};\n\n/** A category's stacked total: the sum of its non-negative values. */\nconst stackTotal = (series: ChartSeries[], i: number) => series.reduce((sum, s) => sum + Math.max(0, s.values[i] ?? 0), 0);\n\nfunction layout(width: number, height: number, labels: string[], series: ChartSeries[], fmt: (v: number) => string, stacked = false): Geometry {\n  const n = labels.length;\n  const max = stacked\n    ? Math.max(0, ...labels.map((_, i) => stackTotal(series, i)))\n    : Math.max(0, ...series.flatMap((s) => s.values.slice(0, n)));\n  const ticks = niceTicks(max);\n  const top = ticks[ticks.length - 1]!;\n  const valueChars = Math.max(...ticks.map((t) => fmt(t).length));\n  const left = Math.ceil(valueChars * FIGURE) + LABEL_GAP;\n  const plotW = Math.max(0, width - left);\n  const plotH = Math.max(0, height - TOP - AXIS);\n  const slotW = n > 0 ? plotW / n : 0;\n  const labelChars = Math.max(1, ...labels.map((l) => l.length));\n  /* The last label may be pushed in from the right edge, which shortens its gap to the one before it. Allow for that. */\n  const hang = Math.max(0, (labelChars * FIGURE) / 2 - slotW / 2);\n  const step = slotW > 0 ? Math.max(1, Math.ceil((labelChars * FIGURE + 12 + hang) / slotW)) : 1;\n  const base = TOP + plotH;\n  return {\n    width,\n    height,\n    left,\n    plotW,\n    plotH,\n    slotW,\n    ticks,\n    top: TOP,\n    base,\n    y: (v) => base - (Math.max(0, v) / top) * plotH,\n    cx: (i) => left + (i + 0.5) * slotW,\n    step,\n  };\n}\n\n/** The container's width, measured before paint and kept current. 0 until the first measure. */\nfunction useWidth() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setWidth(Math.floor(el.getBoundingClientRect().width));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\n/* ----------------------------------------------------------------- the shared chart */\n\ntype Kind = \"bar\" | \"line\" | \"stack\";\n\nfunction Chart({\n  kind,\n  label,\n  labels,\n  series,\n  height,\n  formatValue,\n  unit,\n  xTitle,\n  summary,\n  highlight,\n  hues = false,\n  entrance,\n  legend = true,\n  className = \"\",\n}: Required<Pick<ChartBaseProps, \"height\" | \"xTitle\" | \"highlight\" | \"entrance\">> & ChartBaseProps & { kind: Kind }) {\n  const fmt = formatValue ?? groupDigits;\n  const n = labels.length;\n  const stacked = kind === \"stack\";\n  const many = series.length > 1;\n  const paint = useMemo(() => paints(series, hues || stacked), [series, hues, stacked]);\n  const [boxRef, width] = useWidth();\n  const g = useMemo(() => layout(width, height, labels, series, fmt, stacked), [width, height, labels, series, fmt, stacked]);\n  const value = (s: ChartSeries, i: number) => s.values[i] ?? 0;\n  const unitFor = (v: number) => (Array.isArray(unit) ? (v === 1 ? unit[0] : unit[1]) : unit);\n  const withUnit = (v: number) => fmt(v) + (unit ? ` ${unitFor(v)}` : \"\");\n\n  /* Which category is active: a hovered one, else a focused one, else a pinned one (a tap). */\n  const [hover, setHover] = useState<number | null>(null);\n  const [focus, setFocus] = useState<number | null>(null);\n  const [pinned, setPinned] = useState<number | null>(null);\n  const [ring, setRing] = useState<number | null>(null);\n  const [dismissed, setDismissed] = useState(false);\n  const active = dismissed ? null : (hover ?? focus ?? pinned);\n  useEffect(() => setDismissed(false), [hover, focus, pinned]);\n\n  const current = highlight === \"none\" || many ? null : highlight === \"last\" ? n - 1 : highlight;\n  const emphasis = active ?? current;\n\n  const [tabStop, setTabStop] = useState<number | null>(null);\n  const stop = Math.min(tabStop ?? (current ?? n - 1), n - 1);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const plotGroup = useRef<HTMLDivElement>(null);\n\n  /* A tap pins the tooltip; the next touch anywhere else clears it. */\n  useEffect(() => {\n    if (pinned === null) return;\n    const away = (e: PointerEvent) => {\n      if (!boxRef.current?.contains(e.target as Node)) setPinned(null);\n    };\n    document.addEventListener(\"pointerdown\", away, true);\n    return () => document.removeEventListener(\"pointerdown\", away, true);\n  }, [pinned]);\n\n  /* The category under a pointer, or null outside the plot. */\n  const indexAt = (clientX: number) => {\n    const box = boxRef.current?.getBoundingClientRect();\n    if (!box || g.slotW <= 0) return null;\n    const i = Math.floor((clientX - box.left - g.left) / g.slotW);\n    return i < 0 || i >= n ? null : i;\n  };\n\n  const move = (i: number) => {\n    const next = Math.max(0, Math.min(n - 1, i));\n    buttons.current[next]?.focus();\n  };\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const i = buttons.current.findIndex((b) => b === document.activeElement);\n    if (i < 0) return;\n    const to = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? i + 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? i - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? n - 1 : null;\n    if (to !== null) {\n      e.preventDefault();\n      move(to);\n    } else if (e.key === \"Escape\" && active !== null) {\n      setDismissed(true);\n    }\n  };\n\n  const itemLabel = (i: number) =>\n    `${labels[i]}: ` +\n    (many ? series.map((s) => `${s.name} ${withUnit(value(s, i))}`).join(\", \") : withUnit(value(series[0]!, i))) +\n    (stacked && many ? `, total ${withUnit(stackTotal(series, i))}` : \"\");\n\n  const text = useMemo(() => {\n    if (summary) return summary;\n    const part = (s: ChartSeries) => {\n      const v = s.values.slice(0, n);\n      return `from ${fmt(v[0] ?? 0)} to ${fmt(v[n - 1] ?? 0)}, peak ${fmt(Math.max(0, ...v))}`;\n    };\n    if (stacked) {\n      const totals = labels.map((_, i) => stackTotal(series, i));\n      return `${label}: total from ${fmt(totals[0] ?? 0)} to ${fmt(totals[n - 1] ?? 0)}, peak ${fmt(Math.max(0, ...totals))}; ${series.map((s) => s.name).join(\", \")}`;\n    }\n    return `${label}: ` + (many ? series.map((s) => `${s.name} ${part(s)}`).join(\"; \") : part(series[0]!));\n  }, [summary, label, labels, series, n, many, fmt, stacked]);\n\n  /* The tooltip sits above the tallest mark of the active column. */\n  const tipTop = active === null ? 0 : stacked ? g.y(stackTotal(series, active)) : Math.min(...series.map((s) => g.y(value(s, active))));\n  const tipCx = active === null ? 0 : g.cx(active);\n\n  const enter = useTransition(\"responsive\");\n  const empty = n === 0 || series.length === 0;\n\n  return (\n    <div className={\"w-full \" + className}>\n      {many && legend && <Legend series={series} kind={kind} paint={paint} />}\n      <div ref={boxRef} className=\"relative w-full\" style={{ height }}>\n        {width > 0 && !empty && (\n          <>\n            <svg width={width} height={height} role=\"img\" aria-label={text} className=\"block overflow-visible\">\n              <Grid g={g} labels={labels} fmt={fmt} />\n              {many && active !== null && (\n                <rect\n                  x={g.left + active * g.slotW}\n                  y={g.top}\n                  width={g.slotW}\n                  height={g.plotH}\n                  className=\"fill-[color-mix(in_oklab,var(--litt-fg)_4%,transparent)]\"\n                />\n              )}\n              {kind === \"stack\" ? (\n                <Rails g={g} series={series} paint={paint} n={n} emphasis={emphasis} ring={ring} entrance={entrance} enter={enter} />\n              ) : kind === \"bar\" ? (\n                <Bars g={g} series={series} paint={paint} n={n} emphasis={emphasis} ring={ring} entrance={entrance} enter={enter} />\n              ) : (\n                <Lines g={g} series={series} paint={paint} n={n} emphasis={emphasis} ring={ring} entrance={entrance} enter={enter} />\n              )}\n            </svg>\n\n            {/* One layer takes the pointer, so the target is the whole plot and not a 20px column. Columns on a phone\n                are narrower than a finger, so a touch scrubs: the mark under the finger is the pinned one. */}\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute inset-y-0 right-0 touch-pan-y select-none\"\n              style={{ left: g.left }}\n              onPointerMove={(e) => {\n                const i = indexAt(e.clientX);\n                if (e.pointerType === \"touch\") {\n                  if (e.buttons) setPinned(i);\n                } else setHover(i);\n              }}\n              onPointerLeave={(e) => {\n                if (e.pointerType !== \"touch\") setHover(null);\n              }}\n              onPointerDown={(e) => {\n                if (e.pointerType !== \"touch\") return;\n                const i = indexAt(e.clientX);\n                setPinned((p) => (p === i ? null : i));\n              }}\n            />\n\n            {/* Keyboard and screen readers get one hidden button per category: a stop for the arrow keys, named by its values. */}\n            <div ref={plotGroup} role=\"group\" aria-label={`${label}, values`} onKeyDown={onKeyDown}>\n              {labels.map((_, i) => (\n                <button\n                  key={i}\n                  ref={(el) => {\n                    buttons.current[i] = el;\n                  }}\n                  type=\"button\"\n                  tabIndex={i === stop ? 0 : -1}\n                  aria-label={itemLabel(i)}\n                  className=\"sr-only\"\n                  onFocus={(e) => {\n                    setFocus(i);\n                    setTabStop(i);\n                    setRing(e.currentTarget.matches(\":focus-visible\") ? i : null);\n                  }}\n                  onBlur={() => {\n                    setFocus((f) => (f === i ? null : f));\n                    setRing((r) => (r === i ? null : r));\n                  }}\n                />\n              ))}\n            </div>\n\n            <AnimatePresence>\n              {active !== null && (\n                <Tip key=\"tip\" cx={tipCx} markTop={tipTop} height={height} width={width}>\n                  <TipContent\n                    label={labels[active]!}\n                    series={series}\n                    many={many}\n                    value={(s) => withUnit(value(s, active))}\n                    total={stacked && many ? withUnit(stackTotal(series, active)) : undefined}\n                  />\n                </Tip>\n              )}\n            </AnimatePresence>\n          </>\n        )}\n      </div>\n\n      {/* A table will not shrink to sr-only's 1px, so a wrapper clips it; otherwise a long one widens a phone's page. */}\n      <div className=\"sr-only\">\n        <table className=\"[&_th]:font-normal\">\n          <caption>{label}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">{xTitle}</th>\n              {series.map((s) => (\n                <th key={s.name} scope=\"col\">\n                  {s.name}\n                  {unit ? ` (${unitFor(2)})` : \"\"}\n                </th>\n              ))}\n              {stacked && many && <th scope=\"col\">Total</th>}\n            </tr>\n          </thead>\n          <tbody>\n            {labels.map((l, i) => (\n              <tr key={i}>\n                <th scope=\"row\">{l}</th>\n                {series.map((s) => (\n                  <td key={s.name}>{fmt(value(s, i))}</td>\n                ))}\n                {stacked && many && <td>{fmt(stackTotal(series, i))}</td>}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- grid and axis labels */\n\nfunction Grid({ g, labels, fmt }: { g: Geometry; labels: string[]; fmt: (v: number) => string }) {\n  const n = labels.length;\n  const crisp = (y: number) => Math.round(y) + 0.5;\n  return (\n    <g aria-hidden=\"true\" shapeRendering=\"crispEdges\">\n      {g.ticks.map((t, i) => (\n        <line\n          key={t}\n          x1={g.left}\n          x2={g.width}\n          y1={crisp(g.y(t))}\n          y2={crisp(g.y(t))}\n          strokeWidth={1}\n          className={i === 0 ? \"stroke-litt-line-strong\" : \"stroke-litt-line\"}\n        />\n      ))}\n      <g shapeRendering=\"auto\" className=\"fill-litt-fg-subtle text-litt-caption font-normal tabular-nums\">\n        {g.ticks.map((t) => (\n          <text key={t} x={g.left - LABEL_GAP} y={g.y(t)} textAnchor=\"end\" dominantBaseline=\"central\">\n            {fmt(t)}\n          </text>\n        ))}\n        {labels.map((l, i) => {\n          if ((n - 1 - i) % g.step !== 0) return null;\n          /* The last label would hang past the right edge: right-align it to the edge instead. */\n          const half = (l.length * FIGURE) / 2;\n          const last = i === n - 1 && g.cx(i) + half > g.width;\n          return (\n            <text key={i} x={last ? g.width : g.cx(i)} y={g.base + 20} textAnchor={last ? \"end\" : \"middle\"}>\n              {l}\n            </text>\n          );\n        })}\n      </g>\n    </g>\n  );\n}\n\n/* ----------------------------------------------------------------- bars */\n\ntype MarksProps = {\n  g: Geometry;\n  series: ChartSeries[];\n  paint: Paint[];\n  n: number;\n  emphasis: number | null;\n  ring: number | null;\n  entrance: boolean;\n  enter: ReturnType<typeof useTransition>;\n};\n\nfunction Bars({ g, series, paint, n, emphasis, ring, entrance, enter }: MarksProps) {\n  const k = series.length;\n  const gap = 2;\n  const group = k === 1 ? Math.min(MAX_BAR, Math.floor(g.slotW * 0.6)) : Math.min(k * MAX_BAR + gap * (k - 1), Math.floor(g.slotW * 0.7));\n  const bw = Math.max(2, Math.floor((group - gap * (k - 1)) / k));\n  const span = bw * k + gap * (k - 1);\n  const baseline = g.y(0);\n  return (\n    <g aria-hidden=\"true\">\n      {Array.from({ length: n }, (_, i) => {\n        const x0 = g.left + i * g.slotW + (g.slotW - span) / 2;\n        return series.map((s, j) => {\n          const v = s.values[i] ?? 0;\n          const top = g.y(v);\n          const h = v > 0 ? Math.max(2, baseline - top) : 0;\n          if (h === 0) return null;\n          /* One series rests (fg 55% or the hue's mark tone) and the emphasised bar is strong. Several series keep their own paint. */\n          const p = paint[j]!;\n          const fill = k === 1 && emphasis === i ? p.strong : p.rest;\n          return (\n            <motion.rect\n              key={`${s.name}-${i}`}\n              x={x0 + j * (bw + gap)}\n              y={baseline - h}\n              width={bw}\n              height={h}\n              initial={entrance ? { scaleY: 0 } : false}\n              animate={{ scaleY: 1, transition: enter }}\n              style={{ originX: 0.5, originY: 1 }}\n              strokeWidth={1}\n              className={\n                \"transition-[fill] duration-(--litt-dur-instant) ease-litt \" +\n                fill +\n                (p.edge ? \" \" + EDGE : \" stroke-transparent\")\n              }\n            />\n          );\n        });\n      })}\n      {ring !== null && (\n        <rect\n          x={g.left + ring * g.slotW + (g.slotW - span) / 2 - 4}\n          y={Math.min(...series.map((s) => g.y(s.values[ring] ?? 0))) - 4}\n          width={span + 8}\n          height={baseline - Math.min(...series.map((s) => g.y(s.values[ring] ?? 0))) + 8}\n          fill=\"none\"\n          strokeWidth={2}\n          className=\"stroke-litt-focus\"\n        />\n      )}\n    </g>\n  );\n}\n\n/* ----------------------------------------------------------------- stacked rails */\n\n/**\n * One stacked rail per category, the first series at the bottom. Each segment keeps the control radius (set in CSS,\n * so the Customize radius and the registry's renamed token both reach it) and 2px of air from the next. A segment too\n * short to draw keeps 2px, taken from the air, so a tiny part still shows; a part of zero draws nothing.\n */\nfunction Rails({ g, series, paint, n, emphasis, ring, entrance, enter }: MarksProps) {\n  const bw = Math.max(2, Math.min(MAX_RAIL, Math.floor(g.slotW * 0.5)));\n  const baseline = g.y(0);\n  const scale = g.plotH / Math.max(1, g.ticks[g.ticks.length - 1]!);\n  return (\n    <g aria-hidden=\"true\">\n      {Array.from({ length: n }, (_, i) => {\n        const x = Math.round(g.left + i * g.slotW + (g.slotW - bw) / 2);\n        let y = baseline;\n        const parts = series\n          .map((s, j) => ({ j, v: Math.max(0, s.values[i] ?? 0) }))\n          .filter((p) => p.v > 0)\n          .map((p, k, all) => {\n            const h = Math.max(2, p.v * scale - (k < all.length - 1 ? RAIL_GAP : 0));\n            const top = y - h;\n            y = top - (k < all.length - 1 ? RAIL_GAP : 0);\n            return { ...p, top, h };\n          });\n        if (!parts.length) return null;\n        return (\n          <motion.g\n            key={i}\n            initial={entrance ? { scaleY: 0 } : false}\n            animate={{ scaleY: 1, transition: enter }}\n            style={{ originX: 0.5, originY: 1 }}\n          >\n            {parts.map((p) => {\n              const fill = emphasis === i ? paint[p.j]!.strong : paint[p.j]!.rest;\n              return (\n                <rect\n                  key={series[p.j]!.name}\n                  x={x}\n                  y={p.top}\n                  width={bw}\n                  height={p.h}\n                  style={{ rx: `min(var(--litt-radius-control), ${bw / 2}px)` } as CSSProperties}\n                  className={\"transition-[fill] duration-(--litt-dur-instant) ease-litt \" + fill}\n                />\n              );\n            })}\n          </motion.g>\n        );\n      })}\n      {ring !== null && (\n        <rect\n          x={Math.round(g.left + ring * g.slotW + (g.slotW - bw) / 2) - 4}\n          y={g.y(stackTotal(series, ring)) - 4}\n          width={bw + 8}\n          height={baseline - g.y(stackTotal(series, ring)) + 8}\n          fill=\"none\"\n          strokeWidth={2}\n          className=\"stroke-litt-focus\"\n        />\n      )}\n    </g>\n  );\n}\n\n/* ----------------------------------------------------------------- lines */\n\nfunction Lines({ g, series, paint, n, emphasis, ring, entrance, enter }: MarksProps) {\n  return (\n    <g aria-hidden=\"true\">\n      <motion.g initial={entrance ? { opacity: 0 } : false} animate={{ opacity: 1, transition: enter }}>\n        {series.map((s, j) => (\n          <path\n            key={s.name}\n            d={Array.from({ length: n }, (_, i) => `${i === 0 ? \"M\" : \"L\"}${g.cx(i)} ${g.y(s.values[i] ?? 0)}`).join(\" \")}\n            fill=\"none\"\n            stroke={paint[j]!.stroke}\n            strokeWidth={2}\n            strokeLinejoin=\"round\"\n            strokeLinecap=\"round\"\n            strokeDasharray={paint[j]!.dash?.line}\n          />\n        ))}\n      </motion.g>\n      {emphasis !== null &&\n        series.map((s, j) => (\n          <circle key={s.name} cx={g.cx(emphasis)} cy={g.y(s.values[emphasis] ?? 0)} r={4} fill={paint[j]!.marker} stroke=\"none\" />\n        ))}\n      {ring !== null &&\n        series.map((s) => (\n          <circle key={s.name} cx={g.cx(ring)} cy={g.y(s.values[ring] ?? 0)} r={8} fill=\"none\" strokeWidth={2} className=\"stroke-litt-focus\" />\n        ))}\n    </g>\n  );\n}\n\n/* ----------------------------------------------------------------- legend */\n\nfunction Swatch({ kind, paint }: { kind: Kind; paint: Paint }) {\n  return (\n    <svg width={16} height={8} aria-hidden=\"true\" className=\"shrink-0\" focusable=\"false\">\n      {kind === \"stack\" ? (\n        <rect x={2} y={2} width={12} height={4} style={{ rx: \"min(var(--litt-radius-control), 2px)\" } as CSSProperties} className={paint.rest} />\n      ) : kind === \"bar\" ? (\n        <rect x={4} y={0} width={8} height={8} strokeWidth={1} className={paint.rest + (paint.edge ? \" \" + EDGE : \" stroke-transparent\")} />\n      ) : (\n        <line x1={0} x2={16} y1={4} y2={4} stroke={paint.stroke} strokeWidth={2} strokeDasharray={paint.dash?.swatch} />\n      )}\n    </svg>\n  );\n}\n\nfunction Legend({ series, kind, paint }: { series: ChartSeries[]; kind: Kind; paint: Paint[] }) {\n  return (\n    <ul className=\"mb-4 flex flex-wrap items-center gap-x-6 gap-y-2 text-litt-small text-litt-fg-muted\">\n      {series.map((s, j) => (\n        <li key={s.name} className=\"flex items-center gap-2\">\n          <Swatch kind={kind} paint={paint[j]!} />\n          {s.name}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/* ----------------------------------------------------------------- tooltip */\n\nfunction Tip({ cx, markTop, height, width, children }: { cx: number; markTop: number; height: number; width: number; children: ReactNode }) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const ref = useRef<HTMLDivElement>(null);\n  const [w, setW] = useState(0);\n  const measure = useCallback(() => setW(ref.current?.offsetWidth ?? 0), []);\n  useLayoutEffect(measure);\n  const half = w / 2;\n  const left = Math.max(half, Math.min(width - half, cx));\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute z-10 -translate-x-1/2\"\n      style={{ left, bottom: height - markTop + 8 }}\n    >\n      <motion.div\n        ref={ref}\n        className=\"origin-bottom rounded-litt-control bg-litt-surface-3 px-2 py-1.5 text-litt-small whitespace-nowrap text-litt-fg shadow-litt-tooltip inset-shadow-litt-highlight\"\n        initial={{ opacity: 0, scale: 0.98, y: travel / 4 }}\n        animate={{ opacity: 1, scale: 1, y: 0, transition: enter }}\n        exit={{ opacity: 0, scale: 0.98, transition: exit }}\n      >\n        {children}\n      </motion.div>\n    </div>\n  );\n}\n\nfunction TipContent({\n  label,\n  series,\n  many,\n  value,\n  total,\n}: {\n  label: string;\n  series: ChartSeries[];\n  many: boolean;\n  value: (s: ChartSeries) => string;\n  /** A stacked column's total, under its parts. */\n  total?: string;\n}) {\n  if (!many) {\n    return (\n      <div className=\"flex items-center gap-3\">\n        <span className=\"text-litt-fg-muted\">{label}</span>\n        <span className=\"text-litt-caption font-normal tabular-nums\">{value(series[0]!)}</span>\n      </div>\n    );\n  }\n  return (\n    <div className=\"grid gap-1\">\n      <span className=\"text-litt-fg-muted\">{label}</span>\n      {series.map((s) => (\n        <div key={s.name} className=\"flex items-center justify-between gap-4\">\n          <span className=\"text-litt-fg-muted\">{s.name}</span>\n          <span className=\"text-litt-caption font-normal tabular-nums\">{value(s)}</span>\n        </div>\n      ))}\n      {total !== undefined && (\n        <div className=\"flex items-center justify-between gap-4 border-t border-litt-line in-[[data-material=flat]]:border-transparent pt-1\">\n          <span className=\"text-litt-fg-muted\">Total</span>\n          <span className=\"text-litt-caption font-normal tabular-nums\">{total}</span>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- public charts */\n\n/** Bars along a baseline, one column per category. One series is fg at 55% with the current bar full; up to three step down, or up to five in data hues. */\nexport function BarChart({ height = 240, xTitle = \"Label\", highlight = \"last\", entrance = true, ...rest }: BarChartProps) {\n  return <Chart kind=\"bar\" height={height} xTitle={xTitle} highlight={highlight} entrance={entrance} {...rest} />;\n}\n\n/** A 2px line over the same grid. One series is fg with a marker on the current point; up to three step down, the third dashed, or up to five in data hues, the third to fifth dashed, dotted and dash-dotted. */\nexport function LineChart({ height = 240, xTitle = \"Label\", highlight = \"last\", entrance = true, ...rest }: LineChartProps) {\n  return <Chart kind=\"line\" height={height} xTitle={xTitle} highlight={highlight} entrance={entrance} {...rest} />;\n}\n\n/** The parts of each category as one thin stacked rail, a data hue per series, with the total in the tooltip. Up to five series. */\nexport function StackedBarChart({ height = 240, xTitle = \"Label\", entrance = true, ...rest }: StackedBarChartProps) {\n  return <Chart kind=\"stack\" height={height} xTitle={xTitle} highlight=\"none\" hues entrance={entrance} {...rest} />;\n}\n\n/* ----------------------------------------------------------------- Meter */\n\nexport type MeterProps = {\n  /** What is measured, at the start of the row: \"Storage\". It names the meter for assistive tech. */\n  label: string;\n  /** The value, from 0 to max. */\n  value: number;\n  /** The value that fills the rail. */\n  max?: number;\n  /** The reading at the end of the row, in tabular figures: \"42 of 100 GB\". Defaults to the percentage. */\n  valueText?: string;\n  /** Fills the rail with one of the five data hues instead of the foreground, when the color says which thing this is (R7). */\n  hue?: ChartHue;\n  /** A value to aim for or stay under, such as a budget. A 2px tick crosses the rail there, in fg-subtle, and screen readers hear it after the reading. */\n  target?: number;\n  /** How the target is said: \"Budget 80 GB\". Defaults to \"target\" and the percentage. */\n  targetText?: string;\n  /** Splits the rail into this many equal steps with 2px of air between them, for a count of a few, such as 7 of 10 checks. Filled steps take the fill, the rest the track. */\n  steps?: number;\n  /** Extra classes for the row, such as a width. */\n  className?: string;\n};\n\nconst METER_FILL: Record<ChartHue, string> = {\n  blue: \"bg-litt-data-blue\",\n  amber: \"bg-litt-data-amber\",\n  teal: \"bg-litt-data-teal\",\n  violet: \"bg-litt-data-violet\",\n  rose: \"bg-litt-data-rose\",\n};\n\nconst TRACK = \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)] in-[[data-material=flat]]:bg-litt-surface-3\";\n\n/** A thin rail for one value against a limit, with an optional target tick or steps. Static, never the accent. */\nexport function Meter({ label, value, max = 100, valueText, hue, target, targetText, steps, className = \"\" }: MeterProps) {\n  const id = useId();\n  const share = (v: number) => (max > 0 ? Math.max(0, Math.min(100, (v / max) * 100)) : 0);\n  const percent = share(value);\n  const reading = valueText ?? `${Math.round(percent)}%`;\n  const fill = hue ? METER_FILL[hue] : \"bg-litt-fg\";\n  const count = steps && steps > 1 ? Math.round(steps) : 0;\n  const filled = Math.round((percent / 100) * count);\n  const aim = target === undefined ? null : share(target);\n  const spoken = aim === null ? reading : `${reading}, ${targetText ?? `target ${Math.round(aim)}%`}`;\n\n  /* Fun mode (motion/fun.tsx): the fill grows in from the start and springs to each new value, the reading counts with\n     it, and the target tick pops as the fill passes it. Moving, the fill slides (a transform, so its rounded end never\n     squashes); still, it is the plain fill below. Off, none of this runs. */\n  const fun = useFun();\n  const grow = useFunValue(percent, { count: valueText ? value : percent });\n  const slide = useTransform(grow.value, (r) => `${r - 100}%`);\n  const tick = useRef<HTMLSpanElement>(null);\n  const passed = useRef(percent);\n  useMotionValueEvent(grow.value, \"change\", (r) => {\n    const el = tick.current;\n    if (fun && el && aim !== null && passed.current < aim && r >= aim) {\n      animate(el, { scale: [1.8, 1] }, fun.pop).then(() => void (el.style.transform = \"\"));\n    }\n    passed.current = r;\n  });\n  const segments = useRef<(HTMLDivElement | null)[]>([]);\n  useLayoutEffect(() => {\n    if (!fun || !count) return;\n    /* Steps light in turn on arrival, each popping into place. */\n    const runs = segments.current.slice(0, filled).map((el, i) => {\n      if (!el) return null;\n      return animate(el, { opacity: [0, 1], scale: [0.3, 1] }, { ...fun.pop, delay: i * fun.wave * 6 });\n    });\n    Promise.all(runs).then(() => segments.current.forEach((el) => el && Object.assign(el.style, { opacity: \"\", transform: \"\" })));\n    return () => runs.forEach((r) => r?.stop());\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- the arrival plays on mount only\n  }, []);\n  const shownReading = fun && !count ? (valueText ? countText(valueText, value, grow.shown) : `${Math.round(grow.shown)}%`) : reading;\n\n  return (\n    <div className={\"grid gap-2 \" + className}>\n      <div className=\"flex items-baseline justify-between gap-4\">\n        <span id={id} className=\"text-litt-small text-litt-fg\">\n          {label}\n        </span>\n        <span className=\"text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">{shownReading}</span>\n      </div>\n      <div className=\"relative\">\n        <div\n          role=\"meter\"\n          aria-labelledby={id}\n          aria-valuemin={0}\n          aria-valuemax={max}\n          aria-valuenow={value}\n          aria-valuetext={spoken}\n          className={count ? \"flex h-1 gap-0.5\" : \"h-1 overflow-hidden rounded-litt-control \" + TRACK}\n        >\n          {count ? (\n            Array.from({ length: count }, (_, i) => (\n              <div\n                key={i}\n                ref={(el) => {\n                  segments.current[i] = el;\n                }}\n                className={\"h-full flex-1 rounded-litt-control \" + (i < filled ? fill : TRACK)}\n              />\n            ))\n          ) : grow.moving ? (\n            <motion.div className={\"h-full w-full rounded-litt-control \" + fill} style={{ x: slide }} />\n          ) : (\n            <div className={\"h-full rounded-litt-control \" + fill} style={{ width: `${percent}%` }} />\n          )}\n        </div>\n        {/* The target: a neutral tick taller than the rail, so it reads over the fill and the track alike. */}\n        {aim !== null && (\n          <span ref={tick} aria-hidden=\"true\" className=\"absolute top-1/2 h-3 w-0.5 -translate-x-1/2 -translate-y-1/2 bg-litt-fg-subtle\" style={{ left: `${aim}%` }} />\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/chart.tsx"
    }
  ],
  "docs": "Import BarChart, LineChart, StackedBarChart and Meter from components/litt/chart. Pass a label, the category labels and series of { name, values }, with an optional hue per series or hues on the chart. The chart measures its container, so give it a width. Every chart ships a hidden table and a summary for screen readers.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
