{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "breakdown",
  "type": "registry:ui",
  "title": "Breakdown",
  "description": "One whole split into its parts: a thin split rail with a segment per part in the data hues, then a row per part with its own 4px rail, a label and a mono reading, or a small legend for a tile. Storage by file type, credits by feature.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/breakdown.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/breakdown 2026.10.26. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Breakdown: one whole split into its parts. Storage by file type, credits by feature, time by project. The\n * Meter's thin rail (chart.tsx) grown into a component: a hue is a thread through neutral space, never an area.\n *\n * - Header: the Meter's row. The label at the start (small, `--litt-fg`), the reading at the end (tabular, caption,\n *   `--litt-fg-subtle`, tabular). A long label truncates; the reading never does.\n * - Split rail: a 4px rail under the header, one segment per part, each as wide as its share of the whole, in its\n *   hue's mark tone, at the control radius (so each segment has the Meter fill's rounded ends), with 2px of air\n *   between segments. When `total` is more than the sum, the rest is one last segment in fg at 12%, the Meter's\n *   track. A part too small to draw keeps 4px, taken from the widest segment (the free space or the largest part),\n *   so the rail never overflows. A part of zero draws nothing.\n * - Rows (the default): one row per part, `--litt-space-6` apart, in exactly the Meter's anatomy: the label, the reading\n *   (the value, then its share of the whole), and a 4px rail on fg at 12% filled to the share in the part's hue. The\n *   rows line up with a plain Meter beside them. No dividers and no boxes.\n * - Legend: for a tile or a narrow panel. Under the split rail, a wrapping row of entries: a 12px by 4px swatch rail\n *   in the hue at the control radius, the label (small, fg) and the value (tabular, caption, fg-subtle).\n * - Hues (R7): a part without a hue takes the next free one in the fixed order (blue, amber, teal, violet, rose).\n *   Parts past the fifth fold into one \"Other\" part in fg at 55% (their values summed), so the hues are never cycled\n *   and a sixth never gets one. A hue the caller sets is never reused for another part in the same Breakdown: if it\n *   would be, development logs a console error and the part takes the next free hue.\n * - Semantics: the split rail is `role=\"img\"` named by a summary (\"Storage, 91 of 100 GB: Video 58 GB, ..., 9 GB\n *   free\"). In rows, each row is a list item whose rail is `role=\"meter\"` named by the row's label, with the value,\n *   the whole, and the reading as its text; the visible header of the Breakdown and of each row is hidden from\n *   assistive tech so nothing is read twice. In the legend the entries are a list, read as \"Video, 58 GB\".\n * - Motion: none. Static, like the Meter: no entrance, no hover, no focus stop. A value that changes just changes.\n */\nimport { useEffect, useId } from \"react\";\n\n/** The five data hues, in their fixed order (R7). */\nexport type BreakdownHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** The hues in the order a part without one takes them. */\nconst ORDER: BreakdownHue[] = [\"blue\", \"amber\", \"teal\", \"violet\", \"rose\"];\n\n/** A hue's mark tone fills a segment, a swatch and a row's rail. */\nconst MARK: Record<BreakdownHue, 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/** The folded part is the quiet foreground, the way a sixth series is in chart.tsx. */\nconst OTHER = \"bg-[color-mix(in_oklab,var(--litt-fg)_55%,transparent)]\";\n/** The Meter's track, and the free space in the split rail. */\nconst FREE = \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\";\n\n/** How many parts get a hue before the rest fold into Other. */\nconst HUED = ORDER.length;\n/** The least a drawn segment is wide, in px: the rail's own height, so a tiny part is still a dot. */\nconst SEGMENT_MIN = 4;\n/** The air between segments, in px. */\nconst SEGMENT_GAP = 2;\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\nconst clean = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0);\nconst tidy = (n: number) => Math.round(n * 1e6) / 1e6;\n\nexport type BreakdownItem = {\n  /** What the part is, in a few words: \"Video\". It names the row and the legend entry, and truncates when narrow. */\n  label: string;\n  /** The part's quantity, zero or more. It sets the segment's width and the row's fill. */\n  value: number;\n  /** Pins this part to one of the five data hues, so a kind keeps its color wherever it appears. Never reuse one hue for two parts in the same Breakdown. */\n  hue?: BreakdownHue;\n};\n\nexport type BreakdownProps = {\n  /** The whole that is split, at the start of the header: \"Storage\". It names the list and the split rail for assistive tech. */\n  label: string;\n  /** The parts, in the order to show them. Sort them yourself, largest first: the Breakdown does not. Past the fifth they fold into Other. */\n  items: BreakdownItem[];\n  /** The whole, such as the plan's 100 GB. When it is more than the sum of the parts, the rest shows as free space. Defaults to the sum, and is never less than it. */\n  total?: number;\n  /** The reading at the end of the header, in tabular figures: \"91 of 100 GB\". Defaults to the sum, or \"91 of 100\" when there is a total, written by `formatValue`. */\n  totalText?: string;\n  /** Turns a value into the text shown and read: `(v) => \\`${v} GB\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** What the values count, said after each number to screen readers: \"credits\". Pass [\"credit\", \"credits\"] to say the first for exactly 1. Leave it out when `formatValue` already says it. */\n  unit?: string | [one: string, other: string];\n  /** Rows draw one thin rail per part with its label and reading. The legend is a wrapping row of small swatches for a tile or a narrow panel. */\n  layout?: \"rows\" | \"legend\";\n  /** Extra classes for the Breakdown, such as a width. */\n  className?: string;\n};\n\ntype Part = { label: string; value: number; fill: string };\n\n/**\n * Cuts the items into drawn parts: the first five keep their own hue, the rest fold into Other. Hues the caller set\n * are reserved first, then every part without one takes the next free hue in the fixed order, so pinning one part\n * never repaints the others by position.\n */\nfunction plan(items: BreakdownItem[], name: string) {\n  const shown = items.slice(0, HUED);\n  const folded = items.slice(HUED);\n  const taken = new Set<BreakdownHue>();\n  const problems: string[] = [];\n  const pinned = shown.map((item) => {\n    if (!item.hue || !ORDER.includes(item.hue)) return null;\n    if (taken.has(item.hue)) {\n      problems.push(\n        `Breakdown \"${name}\": the hue \"${item.hue}\" is set on more than one part (\"${item.label}\" is the second). A hue means one part in one Breakdown, so \"${item.label}\" takes the next free hue.`,\n      );\n      return null;\n    }\n    taken.add(item.hue);\n    return item.hue;\n  });\n  const parts: Part[] = shown.map((item, i) => {\n    let hue = pinned[i];\n    if (!hue) {\n      hue = ORDER.find((h) => !taken.has(h)) ?? ORDER[0];\n      taken.add(hue);\n    }\n    return { label: item.label, value: clean(item.value), fill: MARK[hue] };\n  });\n  if (folded.length) parts.push({ label: \"Other\", value: folded.reduce((sum, item) => sum + clean(item.value), 0), fill: OTHER });\n  return { parts, problems };\n}\n\n/** Whether this is a development build. The bundler replaces `process.env.NODE_ENV`; where it does not, stay quiet. */\ndeclare const process: { env: { NODE_ENV?: string } };\nconst inDevelopment = () => {\n  try {\n    return process.env.NODE_ENV !== \"production\";\n  } catch {\n    return false;\n  }\n};\n\n/** A share as the reading shows it: \"24%\", and \"<1%\" for a part that is there but under one percent. */\nconst percent = (share: number) => (share <= 0 ? \"0%\" : share < 0.01 ? \"<1%\" : `${Math.round(share * 100)}%`);\n\n/** One whole split into its parts: a split rail, then a thin rail per part or a legend. Static: it never animates. */\nexport function Breakdown({ label, items, total, totalText, formatValue, unit, layout = \"rows\", className = \"\" }: BreakdownProps) {\n  const id = useId();\n  const fmt = formatValue ?? groupDigits;\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  const { parts, problems } = plan(items, label);\n  useEffect(() => {\n    if (problems.length && inDevelopment()) for (const problem of problems) console.error(problem);\n  }, [problems.join(\"\\n\")]);\n\n  const sum = tidy(parts.reduce((s, p) => s + p.value, 0));\n  const whole = tidy(Math.max(total != null && Number.isFinite(total) ? total : sum, sum));\n  const free = tidy(whole - sum);\n  const reading = totalText ?? (total != null ? `${fmt(sum)} of ${fmt(whole)}` : fmt(sum));\n  const spoken = totalText ?? (total != null ? `${fmt(sum)} of ${withUnit(whole)}` : withUnit(sum));\n  const summary =\n    `${label}, ${spoken}` +\n    (parts.length || free > 0\n      ? \": \" + [...parts.map((p) => `${p.label} ${withUnit(p.value)}`), ...(free > 0 ? [`${withUnit(free)} free`] : [])].join(\", \")\n      : \"\");\n\n  /* The split rail: parts of zero draw nothing, and an empty Breakdown is one quiet track. */\n  const segments = [\n    ...parts.filter((p) => p.value > 0).map((p, i) => ({ key: `${i}-${p.label}`, value: p.value, fill: p.fill })),\n    ...(free > 0 ? [{ key: \"free\", value: free, fill: FREE }] : []),\n  ];\n  if (!segments.length) segments.push({ key: \"empty\", value: 1, fill: FREE });\n  /* The widest segment (the free space or the largest part) takes what is left, so a part held to 4px costs it, never the rail. */\n  const widest = segments.reduce((best, s, i) => (s.value > segments[best].value ? i : best), 0);\n  const gaps = (segments.length - 1) * SEGMENT_GAP;\n\n  return (\n    <div className={\"grid grid-cols-1 content-start \" + (layout === \"legend\" ? \"gap-4 \" : \"gap-6 \") + className}>\n      <div className=\"grid grid-cols-1 gap-2\">\n        <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-4\">\n          <span className=\"min-w-0 truncate text-litt-small text-litt-fg\">{label}</span>\n          <span className=\"shrink-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">{reading}</span>\n        </div>\n        <div role=\"img\" aria-label={summary} className=\"flex h-1 gap-0.5 overflow-hidden\">\n          {segments.map((s, i) =>\n            i === widest ? (\n              <span key={s.key} className={\"min-w-1 flex-1 rounded-litt-control \" + s.fill} />\n            ) : (\n              <span\n                key={s.key}\n                className={\"shrink-0 rounded-litt-control \" + s.fill}\n                style={{ width: `max(${SEGMENT_MIN}px, calc((100% - ${gaps}px) * ${Math.round((s.value / whole) * 1e5) / 1e5}))` }}\n              />\n            ),\n          )}\n        </div>\n      </div>\n\n      {layout === \"legend\" ? (\n        <ul role=\"list\" aria-label={label} className=\"m-0 flex list-none flex-wrap gap-x-4 gap-y-2 p-0\">\n          {parts.map((p, i) => (\n            <li key={`${i}-${p.label}`} className=\"flex max-w-full min-w-0 items-baseline gap-2\">\n              <span aria-hidden=\"true\" className={\"h-1 w-3 shrink-0 self-center rounded-litt-control \" + p.fill} />\n              <span className=\"min-w-0 truncate text-litt-small text-litt-fg\">\n                {p.label}\n                <span className=\"sr-only\">, </span>\n              </span>\n              <span className=\"shrink-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n                {fmt(p.value)}\n                {unit && <span className=\"sr-only\"> {unitFor(p.value)}</span>}\n              </span>\n            </li>\n          ))}\n        </ul>\n      ) : (\n        <ul role=\"list\" aria-label={label} className=\"m-0 grid grid-cols-1 list-none gap-6 p-0\">\n          {parts.map((p, i) => {\n            const share = whole > 0 ? Math.min(1, p.value / whole) : 0;\n            const nameId = `${id}-${i}`;\n            return (\n              <li key={`${i}-${p.label}`} className=\"grid grid-cols-1 gap-2\">\n                <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-4\">\n                  <span id={nameId} className=\"min-w-0 truncate text-litt-small text-litt-fg\">\n                    {p.label}\n                  </span>\n                  <span className=\"shrink-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n                    {fmt(p.value)}\n                    <span className=\"ml-2 inline-block min-w-[4ch] text-right\">{percent(share)}</span>\n                  </span>\n                </div>\n                <div\n                  role=\"meter\"\n                  aria-labelledby={nameId}\n                  aria-valuemin={0}\n                  aria-valuemax={whole}\n                  aria-valuenow={p.value}\n                  aria-valuetext={`${withUnit(p.value)}, ${percent(share)}`}\n                  className={\"h-1 overflow-hidden rounded-litt-control \" + FREE}\n                >\n                  <div className={\"h-full rounded-litt-control \" + p.fill} style={{ width: `${Math.round(share * 1e5) / 1e3}%` }} />\n                </div>\n              </li>\n            );\n          })}\n        </ul>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/breakdown.tsx"
    }
  ],
  "docs": "Import Breakdown from components/litt/breakdown. Pass a label, the parts as { label, value } largest first, and the total when there is free space. Past five parts the rest fold into Other. Pin a hue on a part to keep a kind's color across a page.",
  "meta": {
    "version": "2026.10.26",
    "released": "2026-10-26",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
