{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grouped-bar-chart",
  "title": "Grouped bar chart",
  "description": "Grouped bar chart is a production-ready charts component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/charts/grouped-bar-chart.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useChartCanvas, writeLabels } from \"@/components/bjork-ui/charts/_kit/canvas\";\nimport {\n  useChartTheme,\n  chartFocusRing,\n  ChartTable,\n  LabelPool,\n  placeTooltip,\n  HoverTooltip,\n  ChartAnnouncer,\n  type TooltipHandle,\n  type AnnouncerHandle,\n  type TooltipContent,\n  type TooltipRow,\n} from \"@/components/bjork-ui/charts/_kit/chrome\";\nimport { clamp, crisp, damp, niceDomain, niceTicks, withAlpha, formatCompact, formatPercent } from \"@/components/bjork-ui/charts/_kit/scale\";\nimport { seriesColor, foldSeries, inkOn, CHART_OTHER, MAX_SERIES } from \"@/components/bjork-ui/charts/_kit/series\";\n\nexport interface BarCategory {\n  id: string;\n  label: string;\n}\n\nexport interface BarSeries {\n  id: string;\n  label: string;\n  /** One value per category, in `categories` order. Missing or non-finite values count as zero. */\n  values: number[];\n}\n\nexport type BarMode = \"grouped\" | \"stacked\" | \"percent\";\nexport type BarOrientation = \"vertical\" | \"horizontal\" | \"auto\";\n\nexport interface GroupedBarChartProps {\n  categories: BarCategory[];\n  /**\n   * Up to six series; colour follows each series' index in this array. Past six, the tail folds\n   * into one neutral \"Other\" series.\n   */\n  series: BarSeries[];\n  /**\n   * \"grouped\" sets bars side by side, \"stacked\" piles them (positives up, negatives down from zero),\n   * \"percent\" shows each series' share of its category. Percent is for non-negative data: negative\n   * values count as zero there (the tooltip and table still show the real value).\n   */\n  mode?: BarMode;\n  /** \"auto\" (default) turns horizontal when vertical bands get too narrow for the bars or labels. */\n  orientation?: BarOrientation;\n  /** Hidden series ids (controlled). The last visible series can never be hidden. */\n  hidden?: string[];\n  defaultHidden?: string[];\n  onHiddenChange?: (hidden: string[]) => void;\n  /** Net total at the end of each stack (stacked mode only), when it fits. Default true. */\n  showTotals?: boolean;\n  /** A value on every bar or segment, when it fits. Default false. */\n  showValues?: boolean;\n  /** Series legend with toggles above the plot (shown for two or more series). Default true. */\n  legend?: boolean;\n  formatValue?: (v: number) => string;\n  /** Posed hover by category and series id. */\n  active?: { category: string; series: string } | null;\n  height?: number;\n  ariaLabel?: string;\n  tone?: BjorkTone;\n  className?: string;\n}\n\nconst GAP = 2;\nconst RADIUS = 4;\nconst TAU = 0.13;\nconst PHASE_MS = 180;\nconst STAGGER_MS = 45;\nconst STAGGER_MAX_MS = 360;\nconst TICK_POOL = 10;\nconst MAX_GROUP_FRAC = 0.74;\nconst BAR_MAX = 56;\nconst BAR_STEP = 30;\nconst CAT_FALLBACK = \"500 11px system-ui, sans-serif\";\nconst MONO_FALLBACK = \"400 10px ui-monospace, monospace\";\nconst INK_DARK = BJORK_PALETTE.light.text;\nconst INK_LIGHT = BJORK_PALETTE.dark.text;\n\nconst defaultFormatValue = (v: number) => formatCompact(v, 1);\n\n// --- Text measurement (layout is budgeted, never assumed) ----------------------------------------\n\nlet measureCtx: CanvasRenderingContext2D | null = null;\nconst widthCache = new Map<string, number>();\nfunction textWidth(font: string, text: string): number {\n  const key = `${font}|${text}`;\n  const hit = widthCache.get(key);\n  if (hit !== undefined) return hit;\n  if (!measureCtx) {\n    if (typeof document === \"undefined\") return text.length * 6.5;\n    measureCtx = document.createElement(\"canvas\").getContext(\"2d\");\n    if (!measureCtx) return text.length * 6.5;\n  }\n  measureCtx.font = font;\n  const w = measureCtx.measureText(text).width;\n  if (widthCache.size > 4000) widthCache.clear();\n  widthCache.set(key, w);\n  return w;\n}\n\nfunction fontOf(el: HTMLElement | null, fallback: string): string {\n  if (!el || typeof getComputedStyle === \"undefined\") return fallback;\n  const cs = getComputedStyle(el);\n  if (!cs.fontFamily) return fallback;\n  return `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;\n}\n\n// Rect with a rounded data end and square baseline, in either orientation.\nfunction barPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number, end: \"top\" | \"bottom\" | \"left\" | \"right\") {\n  if (w <= 0 || h <= 0) return;\n  const rr = Math.max(0, Math.min(r, w / 2, h / 2));\n  const tl = end === \"top\" || end === \"left\" ? rr : 0;\n  const tr = end === \"top\" || end === \"right\" ? rr : 0;\n  const br = end === \"bottom\" || end === \"right\" ? rr : 0;\n  const bl = end === \"bottom\" || end === \"left\" ? rr : 0;\n  ctx.moveTo(x + tl, y);\n  ctx.lineTo(x + w - tr, y);\n  if (tr) ctx.arcTo(x + w, y, x + w, y + tr, tr);\n  ctx.lineTo(x + w, y + h - br);\n  if (br) ctx.arcTo(x + w, y + h, x + w - br, y + h, br);\n  ctx.lineTo(x + bl, y + h);\n  if (bl) ctx.arcTo(x, y + h, x, y + h - bl, bl);\n  ctx.lineTo(x, y + tl);\n  if (tl) ctx.arcTo(x, y, x + tl, y, tl);\n  ctx.closePath();\n}\n\n// Writes one overlay label without allocating: text only when it changes, position by transform.\n// `rot` turns the label to read bottom-to-top (its origin is the top-left corner).\nfunction putLabel(el: HTMLElement | null | undefined, cache: string[], k: number, text: string | null, x = 0, y = 0, ax = -50, ay = -50, color?: string, rot = false) {\n  if (!el) return;\n  if (text === null) {\n    if (el.style.opacity !== \"0\") el.style.opacity = \"0\";\n    return;\n  }\n  if (cache[k] !== text) {\n    cache[k] = text;\n    el.textContent = text;\n  }\n  if (color !== undefined && el.style.color !== color) el.style.color = color;\n  el.style.opacity = \"1\";\n  el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)${rot ? \" rotate(-90deg)\" : \"\"} translate(${ax}%, ${ay}%)`;\n}\n\n// --- Model ---------------------------------------------------------------------------------------\n\ninterface Ser {\n  id: string;\n  label: string;\n  color: string;\n  /** Ink for labels drawn on the bar itself. */\n  ink: string;\n  values: number[];\n}\n\ninterface Geo {\n  n: number;\n  m: number;\n  mode: BarMode;\n  /** Per bar (index c * m + i), in value space: `a` is the base side, `b` the data end. */\n  a: Float64Array;\n  b: Float64Array;\n  /** Slot position within the group (grouped), 0 when stacked. */\n  pos: Float64Array;\n  /** 1 visible, 0 hidden. */\n  wt: Float64Array;\n  /** 1 when this bar owns the rounded outer end. */\n  round: Float64Array;\n  /** Share of the category's visible positive total. */\n  share: Float64Array;\n  net: Float64Array;\n  posEnd: Float64Array;\n  negEnd: Float64Array;\n  k: number;\n  lo: number;\n  hi: number;\n  visList: number[];\n}\n\nfunction buildGeo(ser: Ser[], n: number, mode: BarMode, vis: boolean[]): Geo {\n  const m = ser.length;\n  const N = n * m;\n  const a = new Float64Array(N);\n  const b = new Float64Array(N);\n  const pos = new Float64Array(N);\n  const wt = new Float64Array(N);\n  const round = new Float64Array(N);\n  const share = new Float64Array(N);\n  const net = new Float64Array(n);\n  const posEnd = new Float64Array(n);\n  const negEnd = new Float64Array(n);\n  const visList: number[] = [];\n  for (let i = 0; i < m; i++) if (vis[i]) visList.push(i);\n  let lo = 0;\n  let hi = 0;\n  for (let c = 0; c < n; c++) {\n    let tot = 0;\n    for (let i = 0; i < m; i++) if (vis[i]) tot += Math.max(0, ser[i].values[c]);\n    let pb = 0;\n    let nb = 0;\n    let lastP = -1;\n    let lastN = -1;\n    let rank = 0;\n    let sum = 0;\n    for (let i = 0; i < m; i++) {\n      const j = c * m + i;\n      const on = vis[i];\n      const v = ser[i].values[c];\n      wt[j] = on ? 1 : 0;\n      pos[j] = mode === \"grouped\" ? rank : 0;\n      if (on) rank++;\n      share[j] = on && tot > 0 ? Math.max(0, v) / tot : 0;\n      if (on) sum += v;\n      if (mode === \"grouped\") {\n        a[j] = 0;\n        b[j] = on ? v : 0;\n        round[j] = on ? 1 : 0;\n        if (on) {\n          lo = Math.min(lo, v);\n          hi = Math.max(hi, v);\n        }\n      } else if (mode === \"percent\") {\n        const s = share[j];\n        a[j] = pb;\n        b[j] = pb + s;\n        pb += s;\n        if (on && s > 0) lastP = i;\n      } else if (v >= 0) {\n        a[j] = pb;\n        b[j] = pb + (on ? v : 0);\n        if (on) {\n          pb += v;\n          if (v > 0) lastP = i;\n        }\n      } else {\n        a[j] = nb;\n        b[j] = nb + (on ? v : 0);\n        if (on) {\n          nb += v;\n          lastN = i;\n        }\n      }\n    }\n    if (mode !== \"grouped\") for (let i = 0; i < m; i++) round[c * m + i] = i === lastP || i === lastN ? 1 : 0;\n    net[c] = sum;\n    posEnd[c] = pb;\n    negEnd[c] = nb;\n    if (mode === \"stacked\") {\n      lo = Math.min(lo, nb);\n      hi = Math.max(hi, pb);\n    }\n  }\n  if (mode === \"percent\") {\n    lo = 0;\n    hi = 1;\n  }\n  return { n, m, mode, a, b, pos, wt, round, share, net, posEnd, negEnd, k: Math.max(1, visList.length), lo, hi, visList };\n}\n\ninterface Layout {\n  horiz: boolean;\n  l: number;\n  r: number;\n  t: number;\n  b: number;\n  band: number;\n  G: number;\n  slot: number;\n  gap: number;\n}\n\ninterface Run {\n  N: number;\n  a: Float64Array;\n  b: Float64Array;\n  pos: Float64Array;\n  wt: Float64Array;\n  round: Float64Array;\n  kk: number;\n  lo: number;\n  hi: number;\n  ready: boolean;\n  clock: number;\n  enterAt: number;\n  holdX: number;\n  holdY: number;\n  mode: BarMode | null;\n  hover: { c: number; i: number } | null;\n  source: \"pointer\" | \"keyboard\" | \"prop\" | null;\n  legend: number | null;\n  layout: Layout;\n  fonts: { cat: string; mono: string } | null;\n  tickCache: string[];\n  valCache: string[];\n  totCache: string[];\n  horizShown: boolean | null;\n}\n\nexport function GroupedBarChart({\n  categories,\n  series,\n  mode = \"grouped\",\n  orientation = \"auto\",\n  hidden,\n  defaultHidden,\n  onHiddenChange,\n  showTotals = true,\n  showValues = false,\n  legend = true,\n  formatValue = defaultFormatValue,\n  active,\n  height = 360,\n  ariaLabel = \"Bar chart\",\n  tone: toneProp,\n  className,\n}: GroupedBarChartProps) {\n  const { tone, pal, reduce, vars } = useChartTheme(toneProp);\n  const announcer = useRef<AnnouncerHandle>(null);\n  const tipRef = useRef<TooltipHandle>(null);\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const catRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const tickPool = useRef<(HTMLSpanElement | null)[]>([]);\n  const valPool = useRef<(HTMLSpanElement | null)[]>([]);\n  const totPool = useRef<(HTMLSpanElement | null)[]>([]);\n  const rectRef = useRef<DOMRect | null>(null);\n  const [horizUI, setHorizUI] = useState(false);\n\n  const n = categories.length;\n  const ser = useMemo<Ser[]>(() => {\n    const clean = series.map((s, idx) => ({\n      id: s.id,\n      label: s.label,\n      idx,\n      values: Array.from({ length: n }, (_, c) => (Number.isFinite(s.values[c]) ? s.values[c] : 0)),\n    }));\n    const folded = foldSeries(clean, MAX_SERIES, (rest) => ({\n      id: \"__other\",\n      label: \"Other\",\n      idx: -1,\n      values: Array.from({ length: n }, (_, c) => rest.reduce((acc, r) => acc + r.values[c], 0)),\n    }));\n    return folded.map((s, i) => {\n      const color = s.idx < 0 ? CHART_OTHER[tone] : seriesColor(tone, i);\n      return { id: s.id, label: s.label, color, ink: inkOn(color) === \"dark\" ? INK_DARK : INK_LIGHT, values: s.values };\n    });\n  }, [series, n, tone]);\n  const m = ser.length;\n\n  const [hiddenState, setHiddenState] = useState<string[]>(defaultHidden ?? []);\n  const hiddenIds = hidden ?? hiddenState;\n  const vis = useMemo(() => {\n    const set = new Set(hiddenIds);\n    const v = ser.map((s) => !set.has(s.id));\n    return v.some(Boolean) ? v : v.map(() => true);\n  }, [ser, hiddenIds]);\n  const visSig = vis.map((v) => (v ? 1 : 0)).join(\"\");\n\n  const geo = useMemo(() => buildGeo(ser, n, mode, vis), [ser, n, mode, vis]);\n\n  const cfg = useRef({ geo, ser, categories, reduce, pal, formatValue, orientation, showTotals, showValues });\n  useEffect(() => {\n    cfg.current = { geo, ser, categories, reduce, pal, formatValue, orientation, showTotals, showValues };\n  });\n\n  const st = useRef<Run>({\n    N: -1,\n    a: new Float64Array(0),\n    b: new Float64Array(0),\n    pos: new Float64Array(0),\n    wt: new Float64Array(0),\n    round: new Float64Array(0),\n    kk: 1,\n    lo: 0,\n    hi: 1,\n    ready: false,\n    clock: 0,\n    enterAt: 0,\n    holdX: 0,\n    holdY: 0,\n    mode: null,\n    hover: null,\n    source: null,\n    legend: null,\n    layout: { horiz: false, l: 0, r: 0, t: 0, b: 0, band: 1, G: 1, slot: 1, gap: GAP },\n    fonts: null,\n    tickCache: [],\n    valCache: [],\n    totCache: [],\n    horizShown: null,\n  });\n  // setState is stable, so the loop can hold it directly.\n  const reportHoriz = useRef(setHorizUI);\n\n  const { rootRef, hostRef, canvasRef, wake } = useChartCanvas(({ ctx, w, h, dt }) => {\n    const s = st.current;\n    const c = cfg.current;\n    const g = c.geo;\n    const p = c.pal;\n    const { n: nc, m: ms, mode: md } = g;\n    if (!nc || !ms) {\n      for (const el of catRefs.current) putLabel(el, [], 0, null);\n      writeLabels(tickPool.current, s.tickCache, []);\n      return false;\n    }\n    s.clock += dt * 1000;\n    const now = s.clock;\n    if (!s.fonts) s.fonts = { cat: fontOf(catRefs.current[0], CAT_FALLBACK), mono: fontOf(tickPool.current[0], MONO_FALLBACK) };\n    const { cat: catFont, mono: monoFont } = s.fonts;\n\n    // --- Animation state -------------------------------------------------------------------------\n    const N = nc * ms;\n    const jump = !s.ready || c.reduce || s.N !== N;\n    if (s.N !== N) {\n      s.a = new Float64Array(N);\n      s.b = new Float64Array(N);\n      s.pos = new Float64Array(N);\n      s.wt = new Float64Array(N);\n      s.round = new Float64Array(N);\n    }\n    if (s.mode !== null && s.mode !== md && !jump) {\n      // Grouped to stacked: rise first, then close ranks. Stacked to grouped: spread first, then drop.\n      if (s.mode === \"grouped\") s.holdX = now + PHASE_MS;\n      else if (md === \"grouped\") s.holdY = now + PHASE_MS;\n    }\n    s.mode = md;\n    let moving = false;\n    const entering = !s.ready && !c.reduce;\n    if (entering) s.enterAt = now;\n    const span0 = Math.max(1e-9, s.hi - s.lo);\n    const tolV = 1e-4 * span0;\n    for (let j = 0; j < N; j++) {\n      if (jump) {\n        s.a[j] = entering ? 0 : g.a[j];\n        s.b[j] = entering ? 0 : g.b[j];\n        s.pos[j] = g.pos[j];\n        s.wt[j] = g.wt[j];\n        s.round[j] = g.round[j];\n        if (entering) moving = true;\n        continue;\n      }\n      const cat = Math.floor(j / ms);\n      const yOk = now >= s.holdY && now - s.enterAt >= Math.min(STAGGER_MAX_MS, cat * STAGGER_MS);\n      if (yOk) {\n        s.a[j] = damp(s.a[j], g.a[j], TAU, dt);\n        s.b[j] = damp(s.b[j], g.b[j], TAU, dt);\n        s.round[j] = damp(s.round[j], g.round[j], TAU, dt);\n      }\n      if (now >= s.holdX) {\n        s.pos[j] = damp(s.pos[j], g.pos[j], TAU, dt);\n        s.wt[j] = damp(s.wt[j], g.wt[j], TAU, dt);\n      }\n      if (Math.abs(s.a[j] - g.a[j]) + Math.abs(s.b[j] - g.b[j]) > tolV || Math.abs(s.pos[j] - g.pos[j]) + Math.abs(s.wt[j] - g.wt[j]) > 1e-3) moving = true;\n      else {\n        s.a[j] = g.a[j];\n        s.b[j] = g.b[j];\n        s.pos[j] = g.pos[j];\n        s.wt[j] = g.wt[j];\n        s.round[j] = g.round[j];\n      }\n    }\n    if (jump || c.reduce) s.kk = g.k;\n    else if (now >= s.holdX) {\n      s.kk = damp(s.kk, md === \"grouped\" ? g.k : 1, TAU, dt);\n      if (Math.abs(s.kk - (md === \"grouped\" ? g.k : 1)) > 1e-3) moving = true;\n    } else moving = true;\n    s.N = N;\n\n    // --- Layout ----------------------------------------------------------------------------------\n    const fmtTick = md === \"percent\" ? (v: number) => formatPercent(v, 0) : c.formatValue;\n    const labelsOn = c.showValues || (c.showTotals && md === \"stacked\");\n    let longest = 0;\n    for (let i = 0; i < nc; i++) longest = Math.max(longest, textWidth(catFont, c.categories[i].label));\n    let maxValW = 0;\n    if (labelsOn) {\n      for (let i = 0; i < nc; i++) {\n        maxValW = Math.max(maxValW, textWidth(monoFont, c.formatValue(g.net[i])), textWidth(monoFont, c.formatValue(g.posEnd[i])));\n      }\n      if (md === \"grouped\") maxValW = Math.max(maxValW, textWidth(monoFont, c.formatValue(g.hi)), textWidth(monoFont, c.formatValue(g.lo)));\n    }\n\n    // Vertical candidate first: its left gutter is the widest tick label.\n    const vT = labelsOn ? 20 : 12;\n    const vB = 26;\n    const vPlotH = Math.max(1, h - vT - vB);\n    const domainFor = (loPad: number, hiPad = 0.04): [number, number] => {\n      if (md === \"percent\") return [0, 1];\n      let lo = g.lo;\n      let hi = g.hi;\n      if (!(hi > lo)) return [0, 1];\n      const sp = hi - lo;\n      if (hi > 0) hi += sp * hiPad;\n      if (lo < 0) lo -= sp * loPad;\n      return niceDomain(lo, hi, 5);\n    };\n    // Grouped value labels may stand upright past the bar end, so budget their length as headroom.\n    const uprightPad = clamp((maxValW + 10) / vPlotH, 0.04, 0.4);\n    const vDom =\n      c.showValues && md === \"grouped\" ? domainFor(uprightPad, uprightPad) : domainFor(labelsOn ? clamp(18 / vPlotH, 0.04, 0.3) : 0.04);\n    const vCount = clamp(Math.floor(vPlotH / 44), 2, 8);\n    let tickW = 0;\n    for (const v of niceTicks(vDom[0], vDom[1], vCount)) tickW = Math.max(tickW, textWidth(monoFont, fmtTick(v)));\n    const vL = Math.max(28, Math.ceil(tickW) + 12);\n    const vR = 8;\n    const bandV = (w - vL - vR) / nc;\n    const kNow = md === \"grouped\" ? g.k : 1;\n    const needBar = (kNow * 9 + (kNow - 1) * GAP) / MAX_GROUP_FRAC;\n    const needLabel = Math.min(longest + 8, 64);\n    const horiz = c.orientation === \"horizontal\" || (c.orientation === \"auto\" && bandV < Math.max(needBar, needLabel, 18));\n    if (s.horizShown !== horiz) {\n      s.horizShown = horiz;\n      reportHoriz.current(horiz);\n    }\n\n    let L: number, R: number, T: number, Bm: number, dom: [number, number], count: number;\n    if (!horiz) {\n      L = vL;\n      R = vR;\n      T = vT;\n      Bm = vB;\n      dom = vDom;\n      count = vCount;\n    } else {\n      const catW = clamp(Math.ceil(longest) + 2, 36, Math.min(150, w * 0.3));\n      L = catW + 12;\n      T = 6;\n      Bm = 22;\n      const approxW = Math.max(1, w - L - 12);\n      dom = domainFor(labelsOn ? clamp((maxValW + 10) / approxW, 0.04, 0.4) : 0.04);\n      let tw = 0;\n      for (const v of niceTicks(dom[0], dom[1], 5)) tw = Math.max(tw, textWidth(monoFont, fmtTick(v)));\n      // Room for the last tick label, centred on the right edge, and for labels past the bar ends.\n      R = Math.max(12, tw / 2 + 4, labelsOn ? Math.min(maxValW + 10, w * 0.2) : 0);\n      count = clamp(Math.floor(Math.max(1, w - L - R) / (tw + 28)), 2, 8);\n    }\n    const plot = { l: L, r: Math.max(L + 1, w - R), t: T, b: Math.max(T + 1, h - Bm) };\n    const catLen = horiz ? plot.b - plot.t : plot.r - plot.l;\n    const valLen = horiz ? plot.r - plot.l : plot.b - plot.t;\n    const band = catLen / nc;\n    const kk = Math.max(1, s.kk);\n    const G = Math.max(2, Math.min(band * MAX_GROUP_FRAC, BAR_MAX + (kk - 1) * BAR_STEP));\n    const gap = Math.min(GAP, (G / kk) * 0.25);\n    const slot = Math.max(0.5, (G - gap * (kk - 1)) / kk);\n    s.layout = { horiz, l: plot.l, r: plot.r, t: plot.t, b: plot.b, band, G, slot, gap };\n\n    if (jump) {\n      s.lo = dom[0];\n      s.hi = dom[1];\n    } else {\n      s.lo = damp(s.lo, dom[0], TAU, dt);\n      s.hi = damp(s.hi, dom[1], TAU, dt);\n      if (Math.abs(s.lo - dom[0]) + Math.abs(s.hi - dom[1]) > 1e-4 * (dom[1] - dom[0])) moving = true;\n      else {\n        s.lo = dom[0];\n        s.hi = dom[1];\n      }\n    }\n    s.ready = true;\n    const dspan = Math.max(1e-12, s.hi - s.lo);\n    // Value to pixel along the value axis.\n    const vp = horiz ? (v: number) => plot.l + ((v - s.lo) / dspan) * valLen : (v: number) => plot.b - ((v - s.lo) / dspan) * valLen;\n    const catStart = (ci: number) => (horiz ? plot.t : plot.l) + band * ci;\n\n    // --- Grid and ticks --------------------------------------------------------------------------\n    const ticks = niceTicks(s.lo, s.hi, count);\n    ctx.lineWidth = 1;\n    ctx.strokeStyle = p.hair;\n    ctx.beginPath();\n    for (const v of ticks) {\n      if (Math.abs(v) < 1e-9 * dspan) continue;\n      const q = crisp(vp(v));\n      if (horiz) {\n        ctx.moveTo(q, plot.t);\n        ctx.lineTo(q, plot.b);\n      } else {\n        ctx.moveTo(plot.l, q);\n        ctx.lineTo(plot.r, q);\n      }\n    }\n    ctx.stroke();\n    const tp = tickPool.current;\n    for (let k = 0; k < tp.length; k++) {\n      const v = ticks[k];\n      if (v === undefined) putLabel(tp[k], s.tickCache, k, null);\n      else if (horiz) putLabel(tp[k], s.tickCache, k, fmtTick(v), vp(v), plot.b + 8, -50, 0);\n      else putLabel(tp[k], s.tickCache, k, fmtTick(v), plot.l - 8, vp(v), -100, -50);\n    }\n\n    // --- Bars ------------------------------------------------------------------------------------\n    const hv = s.hover;\n    if (hv) {\n      ctx.fillStyle = withAlpha(p.text, 0.035);\n      const cs = catStart(hv.c);\n      if (horiz) ctx.fillRect(plot.l, cs, valLen, band);\n      else ctx.fillRect(cs, plot.t, band, valLen);\n    }\n    const p0 = vp(0);\n    const vPool = valPool.current;\n    const tPool = totPool.current;\n    let tipX = 0;\n    let tipY = 0;\n    for (let ci = 0; ci < nc; ci++) {\n      const g0 = catStart(ci) + (band - G) / 2;\n      let endPos = p0;\n      let endNeg = p0;\n      for (let i = 0; i < ms; i++) {\n        const j = ci * ms + i;\n        const wt = s.wt[j];\n        const pa0 = vp(s.a[j]);\n        const pb = vp(s.b[j]);\n        // The 2px surface gap opens on the base side of every segment that doesn't sit on zero.\n        const d = pb >= pa0 ? 1 : -1;\n        const pa = pa0 + d * Math.min(GAP, Math.abs(pa0 - p0));\n        const len = (pb - pa) * d;\n        const up = s.b[j] >= s.a[j];\n        const thick = slot * wt;\n        const cs = g0 + s.pos[j] * (slot + gap) + (slot - thick) / 2;\n        const drawn = wt > 0.01 && len >= 0.5 && thick >= 0.3;\n        const isHover = !!hv && hv.c === ci && hv.i === i;\n        if (drawn) {\n          let alpha = 1;\n          if (hv) alpha = isHover ? 1 : hv.c === ci ? 0.5 : 0.32;\n          else if (s.legend !== null) alpha = s.legend === i ? 1 : 0.28;\n          ctx.globalAlpha = alpha * Math.min(1, wt * 1.5);\n          ctx.fillStyle = c.ser[i].color;\n          ctx.beginPath();\n          const r = RADIUS * s.round[j];\n          if (horiz) barPath(ctx, Math.min(pa, pb), cs, len, thick, r, d > 0 ? \"right\" : \"left\");\n          else barPath(ctx, cs, Math.min(pa, pb), thick, len, r, d < 0 ? \"top\" : \"bottom\");\n          ctx.fill();\n          ctx.globalAlpha = 1;\n          if (up) endPos = horiz ? Math.max(endPos, pb) : Math.min(endPos, pb);\n          else endNeg = horiz ? Math.min(endNeg, pb) : Math.max(endNeg, pb);\n        }\n        if (isHover) {\n          tipX = horiz ? Math.max(pa, pb) : cs + thick;\n          tipY = horiz ? cs : Math.min(pa, pb);\n        }\n\n        // Value labels: outside the end when grouped, inside the segment when stacked.\n        const el = vPool[j];\n        if (!el) continue;\n        if (!c.showValues || !drawn || g.wt[j] < 1) {\n          putLabel(el, s.valCache, j, null);\n          continue;\n        }\n        const v = c.ser[i].values[ci];\n        const text = md === \"percent\" ? formatPercent(g.share[j], 0) : c.formatValue(v);\n        const tw = textWidth(monoFont, text);\n        const dim = hv ? (isHover ? 1 : 0.4) : s.legend !== null && s.legend !== i ? 0.3 : 1;\n        if (md === \"grouped\") {\n          const mid = cs + thick / 2;\n          if (horiz) {\n            const fits = thick >= 9 && (up ? pb + 5 + tw <= w : pb - 5 - tw >= plot.l);\n            if (fits) putLabel(el, s.valCache, j, text, up ? pb + 5 : pb - 5, mid, up ? 0 : -100, -50, p.textMedium);\n            else putLabel(el, s.valCache, j, null);\n          } else {\n            // Level when it fits the slot, turned upright along the bar when only the slot's depth does.\n            if (tw <= slot + gap - 1 && (up ? pb - 15 >= 0 : pb + 15 <= plot.b)) putLabel(el, s.valCache, j, text, mid, up ? pb - 5 : pb + 5, -50, up ? -100 : 0, p.textMedium);\n            else if (thick >= 10 && (up ? pb - 7 - tw >= 0 : pb + 7 + tw <= plot.b)) putLabel(el, s.valCache, j, text, mid, up ? pb - 5 : pb + 5, up ? 0 : -100, -50, p.textMedium, true);\n            else putLabel(el, s.valCache, j, null);\n          }\n        } else {\n          const fits = horiz ? len >= tw + 10 && thick >= 15 : thick >= tw + 8 && len >= 15;\n          const mid = (pa + pb) / 2;\n          if (fits) putLabel(el, s.valCache, j, text, horiz ? mid : cs + thick / 2, horiz ? cs + thick / 2 : mid, -50, -50, c.ser[i].ink);\n          else putLabel(el, s.valCache, j, null);\n        }\n        if (el.style.opacity !== \"0\") el.style.opacity = String(dim);\n      }\n\n      // Net total at the outer end of the stack.\n      const tel = tPool[ci];\n      if (tel) {\n        if (md !== \"stacked\" || !c.showTotals) putLabel(tel, s.totCache, ci, null);\n        else {\n          const text = c.formatValue(g.net[ci]);\n          const tw = textWidth(monoFont, text) * 1.1;\n          const usePos = g.posEnd[ci] > 0 || g.negEnd[ci] === 0;\n          const e = usePos ? endPos : endNeg;\n          const mid = g0 + G / 2;\n          let fits: boolean;\n          if (horiz) fits = band >= 12 && (usePos ? e + 6 + tw <= w : e - 6 - tw >= plot.l);\n          else fits = tw <= band - 4 && (usePos ? e - 16 >= 0 : e + 16 <= plot.b);\n          if (!fits) putLabel(tel, s.totCache, ci, null);\n          else if (horiz) putLabel(tel, s.totCache, ci, text, usePos ? e + 6 : e - 6, mid, usePos ? 0 : -100, -50);\n          else putLabel(tel, s.totCache, ci, text, mid, usePos ? e - 6 : e + 6, -50, usePos ? -100 : 0);\n          if (tel.style.opacity !== \"0\" && (hv ? hv.c !== ci : s.legend !== null)) tel.style.opacity = \"0.45\";\n        }\n      }\n    }\n\n    // Zero line, emphasised, over the bars' square baselines.\n    ctx.strokeStyle = p.textSoft;\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    const z = crisp(p0);\n    if (horiz) {\n      ctx.moveTo(z, plot.t - 2);\n      ctx.lineTo(z, plot.b + 2);\n    } else {\n      ctx.moveTo(plot.l, z);\n      ctx.lineTo(plot.r, z);\n    }\n    ctx.stroke();\n\n    // --- Category labels ------------------------------------------------------------------------\n    const minBand = horiz ? 12 : 26;\n    const every = Math.max(1, Math.ceil(minBand / Math.max(1e-6, band)));\n    for (let ci = 0; ci < catRefs.current.length; ci++) {\n      const el = catRefs.current[ci];\n      if (!el) continue;\n      if (ci >= nc || ci % every !== 0) {\n        if (el.style.opacity !== \"0\") el.style.opacity = \"0\";\n        continue;\n      }\n      const mid = catStart(ci) + band / 2;\n      const on = hv?.c === ci;\n      el.style.opacity = \"1\";\n      el.style.color = on ? \"var(--bjork-text)\" : \"\";\n      if (horiz) {\n        el.style.width = `${Math.max(0, plot.l - 12).toFixed(0)}px`;\n        el.style.textAlign = \"right\";\n        el.style.transform = `translate3d(0px, ${mid.toFixed(1)}px, 0) translateY(-50%)`;\n      } else {\n        el.style.width = `${Math.max(0, band * every - 4).toFixed(0)}px`;\n        el.style.textAlign = \"center\";\n        el.style.transform = `translate3d(${mid.toFixed(1)}px, ${(plot.b + 10).toFixed(1)}px, 0) translateX(-50%)`;\n      }\n    }\n\n    const tip = tipRef.current;\n    if (tip?.el && hv) {\n      const pos = placeTooltip(tipX, tipY, tip.size.w, tip.size.h, w, h, 10);\n      tip.el.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;\n    }\n    return moving;\n  });\n\n  const modeWord = mode === \"percent\" ? \"share\" : \"value\";\n  const tooltipFor = (ci: number, i: number): TooltipContent | null => {\n    const cat = categories[ci];\n    if (!cat || !ser[i]) return null;\n    const order = mode !== \"grouped\" && !st.current.layout.horiz ? [...geo.visList].reverse() : geo.visList;\n    const rows: TooltipRow[] = order.map((k) => {\n      const v = ser[k].values[ci];\n      return {\n        key: ser[k].id,\n        label: ser[k].label,\n        value: mode === \"percent\" ? `${formatPercent(geo.share[ci * m + k], 1)} · ${formatValue(v)}` : formatValue(v),\n        color: ser[k].color,\n        strong: k === i,\n      };\n    });\n    if (geo.visList.length > 1) rows.push({ key: \"__total\", label: \"Total\", value: formatValue(geo.net[ci]) });\n    return { key: `${ci}|${i}|${mode}|${tone}|${visSig}|${st.current.layout.horiz ? 1 : 0}`, title: cat.label, rows };\n  };\n\n  const setHover = (hv: { c: number; i: number } | null, source: Run[\"source\"]) => {\n    const s = st.current;\n    if (s.hover?.c === hv?.c && s.hover?.i === hv?.i && s.source === source) return;\n    s.hover = hv;\n    s.source = hv ? source : null;\n    tipRef.current?.set(hv ? tooltipFor(hv.c, hv.i) : null);\n    if (hv && source === \"keyboard\") {\n      const sr = ser[hv.i];\n      const v = sr.values[hv.c];\n      const share = mode === \"percent\" ? `, ${formatPercent(geo.share[hv.c * m + hv.i], 1)} of ${categories[hv.c].label}` : \"\";\n      announcer.current?.say(`${categories[hv.c].label}, ${sr.label}: ${formatValue(v)}${share}. Total ${formatValue(geo.net[hv.c])}.`);\n    }\n    wake();\n  };\n\n  // Keep an open tooltip truthful when data, mode or visibility change underneath it.\n  useEffect(() => {\n    const s = st.current;\n    if (!s.hover) return;\n    if (s.hover.c >= n || s.hover.i >= m || !vis[s.hover.i]) {\n      s.hover = null;\n      tipRef.current?.set(null);\n    } else tipRef.current?.set(tooltipFor(s.hover.c, s.hover.i));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [geo, tone, horizUI]);\n\n  useEffect(() => {\n    wake();\n  }, [geo, pal, reduce, formatValue, orientation, showTotals, showValues, wake]);\n\n  // Web fonts change label widths: measure again once they land.\n  useEffect(() => {\n    if (typeof document === \"undefined\" || !document.fonts) return;\n    let live = true;\n    document.fonts.ready.then(() => {\n      if (!live) return;\n      widthCache.clear();\n      st.current.fonts = null;\n      wake();\n    });\n    return () => {\n      live = false;\n    };\n  }, [wake]);\n\n  useEffect(() => {\n    if (active === undefined) return;\n    const ci = active ? categories.findIndex((c) => c.id === active.category) : -1;\n    const i = active ? ser.findIndex((s) => s.id === active.series) : -1;\n    setHover(ci >= 0 && i >= 0 && vis[i] ? { c: ci, i } : null, \"prop\");\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [active?.category, active?.series, categories, ser, visSig]);\n\n  const pick = (px: number, py: number): { c: number; i: number } | null => {\n    const L = st.current.layout;\n    if (!n || !geo.visList.length) return null;\n    const along = L.horiz ? py : px;\n    const start = L.horiz ? L.t : L.l;\n    const end = L.horiz ? L.b : L.r;\n    if (along < start || along > end) return null;\n    const ci = clamp(Math.floor((along - start) / L.band), 0, n - 1);\n    if (mode === \"grouped\") {\n      const off = along - (start + ci * L.band + (L.band - L.G) / 2);\n      const r = clamp(Math.floor(off / (L.slot + L.gap)), 0, geo.visList.length - 1);\n      return { c: ci, i: geo.visList[r] };\n    }\n    const s = st.current;\n    const v = L.horiz ? s.lo + ((px - L.l) / Math.max(1, L.r - L.l)) * (s.hi - s.lo) : s.lo + ((L.b - py) / Math.max(1, L.b - L.t)) * (s.hi - s.lo);\n    let best = geo.visList[0];\n    let bestD = Infinity;\n    for (const i of geo.visList) {\n      const j = ci * m + i;\n      const a = Math.min(geo.a[j], geo.b[j]);\n      const b = Math.max(geo.a[j], geo.b[j]);\n      if (b - a < 1e-12) continue;\n      const d = v >= a && v <= b ? 0 : Math.min(Math.abs(v - a), Math.abs(v - b));\n      if (d < bestD) {\n        bestD = d;\n        best = i;\n      }\n    }\n    return { c: ci, i: best };\n  };\n\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    const rect = rectRef.current ?? wrapperRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    setHover(pick(e.clientX - rect.left, e.clientY - rect.top), \"pointer\");\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const s = st.current;\n    const vl = geo.visList;\n    if (!n || !vl.length) return;\n    const horiz = s.layout.horiz;\n    const cur = s.hover ?? null;\n    const catNext = horiz ? \"ArrowDown\" : \"ArrowRight\";\n    const catPrev = horiz ? \"ArrowUp\" : \"ArrowLeft\";\n    const serNext = horiz ? \"ArrowRight\" : \"ArrowUp\";\n    const serPrev = horiz ? \"ArrowLeft\" : \"ArrowDown\";\n    const serAt = (i: number | undefined) => (i !== undefined && vl.includes(i) ? i : vl[0]);\n    let next: { c: number; i: number } | null | undefined;\n    if (e.key === catNext || e.key === catPrev) {\n      const d = e.key === catNext ? 1 : -1;\n      next = cur ? { c: clamp(cur.c + d, 0, n - 1), i: serAt(cur.i) } : { c: d > 0 ? 0 : n - 1, i: vl[0] };\n    } else if (e.key === serNext || e.key === serPrev) {\n      const d = e.key === serNext ? 1 : -1;\n      if (!cur) next = { c: 0, i: d > 0 ? vl[0] : vl[vl.length - 1] };\n      else {\n        const r = vl.indexOf(serAt(cur.i));\n        next = { c: cur.c, i: vl[clamp(r + d, 0, vl.length - 1)] };\n      }\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      next = { c: e.key === \"Home\" ? 0 : n - 1, i: serAt(cur?.i) };\n    } else if (e.key === \"Escape\") {\n      if (cur) {\n        e.preventDefault();\n        setHover(null, null);\n      }\n      return;\n    }\n    if (next === undefined) return;\n    e.preventDefault();\n    setHover(next, \"keyboard\");\n  };\n\n  const toggle = (id: string) => {\n    const set = new Set(hiddenIds);\n    if (set.has(id)) set.delete(id);\n    else {\n      if (vis.filter(Boolean).length <= 1) return;\n      set.add(id);\n    }\n    const next = ser.filter((s) => set.has(s.id)).map((s) => s.id);\n    if (hidden === undefined) setHiddenState(next);\n    onHiddenChange?.(next);\n  };\n  const emphasise = (i: number | null) => {\n    st.current.legend = i;\n    wake();\n  };\n\n  // The table twin carries every original series, folded or not.\n  const tableCols = useMemo(() => [\"Category\", ...series.map((s) => s.label), \"Total\"], [series]);\n  const tableRows = useMemo(\n    () =>\n      categories.map((cat, ci) => {\n        let tot = 0;\n        const cells = series.map((s) => {\n          const v = s.values[ci];\n          if (!Number.isFinite(v)) return \"–\";\n          tot += v;\n          return formatValue(v);\n        });\n        return [cat.label, ...cells, formatValue(tot)];\n      }),\n    [categories, series, formatValue],\n  );\n\n  const summary = useMemo(() => {\n    if (!n || !m) return `${ariaLabel}: no data`;\n    let top = 0;\n    for (let c = 1; c < n; c++) if (geo.net[c] > geo.net[top]) top = c;\n    const modeText = mode === \"grouped\" ? \"grouped\" : mode === \"stacked\" ? \"stacked\" : \"stacked to 100 percent\";\n    return `${ariaLabel}: ${n} categories by ${geo.visList.length} series, ${modeText}. Highest total ${categories[top].label} at ${formatValue(geo.net[top])}.`;\n  }, [ariaLabel, n, m, geo, mode, categories, formatValue]);\n\n  const keysHint = horizUI ? \"Up and down step through categories, left and right through series.\" : \"Left and right step through categories, up and down through series.\";\n\n  return (\n    <div ref={rootRef} data-loop=\"idle\" className={cn(\"relative flex w-full select-none flex-col gap-3 text-[color:var(--bjork-text)]\", className)} style={{ ...vars, height }}>\n      {legend && m >= 2 && (\n        <div role=\"group\" aria-label=\"Series\" className=\"-ml-2 flex flex-wrap items-center gap-x-0.5 gap-y-1\">\n          {ser.map((sr, i) => {\n            const on = vis[i];\n            return (\n              <button\n                key={sr.id}\n                type=\"button\"\n                aria-pressed={on}\n                onClick={() => toggle(sr.id)}\n                onPointerEnter={() => on && emphasise(i)}\n                onPointerLeave={() => emphasise(null)}\n                onFocus={() => on && emphasise(i)}\n                onBlur={() => emphasise(null)}\n                className={cn(\n                  \"inline-flex h-6 items-center gap-1.5 rounded-[7px] px-2 font-bjork-alpha text-[11px] font-medium leading-none transition-[background-color,color] duration-150 ease-out hover:bg-[color:var(--bjork-surface-hover)] active:scale-[0.97]\",\n                  chartFocusRing,\n                  on ? \"text-[color:var(--bjork-text-medium)]\" : \"text-[color:var(--bjork-text-soft)]\",\n                )}\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"inline-block size-2 shrink-0 rounded-[2px]\"\n                  style={on ? { background: sr.color } : { boxShadow: `inset 0 0 0 1.5px ${sr.color}`, opacity: 0.7 }}\n                />\n                <span className=\"[text-box:trim-both_cap_alphabetic]\">{sr.label}</span>\n              </button>\n            );\n          })}\n        </div>\n      )}\n      <div\n        ref={wrapperRef}\n        role=\"group\"\n        aria-roledescription=\"chart\"\n        aria-label={`${ariaLabel}. ${keysHint}`}\n        tabIndex={0}\n        onPointerEnter={() => {\n          if (wrapperRef.current) rectRef.current = wrapperRef.current.getBoundingClientRect();\n        }}\n        onPointerMove={onPointerMove}\n        onPointerDown={(e) => {\n          if (wrapperRef.current) rectRef.current = wrapperRef.current.getBoundingClientRect();\n          if (e.pointerType === \"touch\") onPointerMove(e);\n        }}\n        onPointerLeave={(e) => {\n          rectRef.current = null;\n          // A touch tap fires leave right after up; keep the tapped bar until the next tap.\n          if (e.pointerType !== \"touch\") setHover(null, null);\n        }}\n        onBlur={() => setHover(null, null)}\n        onKeyDown={onKeyDown}\n        className={cn(\"relative min-h-0 flex-1 touch-pan-y rounded-[10px]\", chartFocusRing)}\n      >\n        <div ref={hostRef} className=\"absolute inset-0\">\n          <canvas ref={canvasRef} role=\"img\" aria-label={summary} className=\"pointer-events-none absolute left-0 top-0\" />\n        </div>\n        <LabelPool count={TICK_POOL} pool={tickPool} />\n        <LabelPool\n          count={n * m}\n          pool={valPool}\n          className=\"pointer-events-none absolute left-0 top-0 origin-top-left whitespace-nowrap font-mono text-[10px] leading-none tabular-nums opacity-0 [text-box:trim-both_cap_alphabetic]\"\n        />\n        <LabelPool\n          count={n}\n          pool={totPool}\n          className=\"pointer-events-none absolute left-0 top-0 whitespace-nowrap font-mono text-[11px] leading-none tabular-nums text-[color:var(--bjork-text)] opacity-0 [text-box:trim-both_cap_alphabetic]\"\n        />\n        {categories.map((cat, ci) => (\n          <span\n            key={cat.id}\n            ref={(el) => {\n              catRefs.current[ci] = el;\n            }}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-0 top-0 truncate font-bjork-alpha text-[11px] font-medium leading-[13px] text-[color:var(--bjork-text-muted)] opacity-0\"\n          >\n            {cat.label}\n          </span>\n        ))}\n        {(!n || !m) && (\n          <span className=\"absolute inset-0 grid place-items-center font-bjork-alpha text-[12px] text-[color:var(--bjork-text-soft)]\">No data</span>\n        )}\n        <HoverTooltip ref={tipRef} onMeasure={wake} />\n      </div>\n      <ChartTable caption={`${ariaLabel}, ${modeWord} by category`} columns={tableCols} rows={tableRows} />\n      <ChartAnnouncer ref={announcer} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/charts/grouped-bar-chart.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/charts/_kit/canvas.ts",
      "content": "\"use client\";\n\n// Canvas plumbing shared by the Charts collection. One hook gives a chart a measured host, a\n// DPR-capped canvas that follows it, and a draw loop that pauses offscreen and idles when settled.\n\nimport { useCallback, useEffect, useRef, type RefObject } from \"react\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\nimport { sizeCanvas, useElementSize } from \"@/components/bjork-ui/_core/canvas\";\nimport { cubicBezier, ease } from \"@/components/bjork-ui/_core/motion\";\n\nexport interface ChartFrame {\n  ctx: CanvasRenderingContext2D;\n  w: number;\n  h: number;\n  dt: number;\n  t: number;\n}\n\n/**\n * `draw` runs inside `useVisibleLoop`. Return true to keep animating, false to go idle until `wake()`.\n * The loop root is `rootRef` (gets `data-loop`). The canvas is sized to `hostRef`.\n */\nexport function useChartCanvas(draw: (f: ChartFrame) => boolean) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const hostRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const size = useElementSize(hostRef);\n  const sizeRef = useRef({ w: 0, h: 0, dpr: 1 });\n  const drawRef = useRef(draw);\n  useEffect(() => {\n    drawRef.current = draw;\n  });\n\n  const frame = useCallback((dt: number, t: number) => {\n    const canvas = canvasRef.current;\n    const { w, h, dpr } = sizeRef.current;\n    if (!canvas || !w || !h) return true;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return false;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    ctx.clearRect(0, 0, w, h);\n    return drawRef.current({ ctx, w, h, dt, t });\n  }, []);\n\n  const { wake } = useVisibleLoop(rootRef as RefObject<HTMLElement | null>, frame);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || !size.width || !size.height) return;\n    sizeRef.current = { w: size.width, h: size.height, dpr: sizeCanvas(canvas, size.width, size.height) };\n    wake();\n  }, [size.width, size.height, wake]);\n\n  return { rootRef, hostRef, canvasRef, size, wake };\n}\n\n// The house ease-out for entrances, usable in rAF code.\nexport const easeOut = cubicBezier(...(ease.out as unknown as [number, number, number, number]));\nexport const easeInOut = cubicBezier(...(ease.inOut as unknown as [number, number, number, number]));\n\n// A 45 degree hatch pattern in `color`, for counterfactual and loss regions.\nconst hatchCache = new Map<string, CanvasPattern | null>();\nexport function hatchPattern(ctx: CanvasRenderingContext2D, color: string, gap = 5, width = 1): CanvasPattern | null {\n  const key = `${color}|${gap}|${width}`;\n  if (hatchCache.has(key)) return hatchCache.get(key)!;\n  if (typeof document === \"undefined\") return null;\n  const tile = document.createElement(\"canvas\");\n  const s = gap * 2;\n  tile.width = s * 2;\n  tile.height = s * 2;\n  const c = tile.getContext(\"2d\");\n  if (!c) return null;\n  c.scale(2, 2);\n  c.strokeStyle = color;\n  c.lineWidth = width;\n  c.beginPath();\n  for (let k = -s; k <= s * 2; k += gap) {\n    c.moveTo(k, s);\n    c.lineTo(k + s, 0);\n  }\n  c.stroke();\n  const pattern = ctx.createPattern(tile, \"repeat\");\n  pattern?.setTransform(new DOMMatrix().scale(0.5, 0.5));\n  hatchCache.set(key, pattern);\n  return pattern;\n}\n\n// Rounded rect path with independent top and bottom radii (bars: round data end, square baseline).\nexport function roundRectPath(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  w: number,\n  h: number,\n  rTop: number,\n  rBottom = 0,\n) {\n  if (w <= 0 || h <= 0) return;\n  const rt = Math.min(rTop, w / 2, h / 2);\n  const rb = Math.min(rBottom, w / 2, h / 2);\n  ctx.moveTo(x + rt, y);\n  ctx.lineTo(x + w - rt, y);\n  if (rt) ctx.arcTo(x + w, y, x + w, y + rt, rt);\n  ctx.lineTo(x + w, y + h - rb);\n  if (rb) ctx.arcTo(x + w, y + h, x + w - rb, y + h, rb);\n  ctx.lineTo(x + rb, y + h);\n  if (rb) ctx.arcTo(x, y + h, x, y + h - rb, rb);\n  ctx.lineTo(x, y + rt);\n  if (rt) ctx.arcTo(x, y, x + rt, y, rt);\n  ctx.closePath();\n}\n\nexport interface PlacedLabel {\n  text: string;\n  x: number;\n  y: number;\n  /** translate percentages applied after the position, e.g. -50 to centre. */\n  ax?: number;\n  ay?: number;\n  opacity?: number;\n}\n\n// Writes a pool of absolutely positioned label spans from the loop: text only when it changes,\n// position by transform only. Unused spans hide.\nexport function writeLabels(els: (HTMLElement | null)[], cache: string[], items: PlacedLabel[]) {\n  for (let i = 0; i < els.length; i++) {\n    const el = els[i];\n    if (!el) continue;\n    const it = items[i];\n    if (!it) {\n      if (el.style.opacity !== \"0\") el.style.opacity = \"0\";\n      continue;\n    }\n    if (cache[i] !== it.text) {\n      cache[i] = it.text;\n      el.textContent = it.text;\n    }\n    el.style.opacity = String(it.opacity ?? 1);\n    el.style.transform = `translate3d(${it.x.toFixed(2)}px, ${it.y.toFixed(2)}px, 0) translate(${it.ax ?? 0}%, ${it.ay ?? -50}%)`;\n  }\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/charts/_kit/canvas.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    },
    {
      "path": "components/bjork-ui/_core/canvas.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState, type RefObject } from \"react\";\nimport { isCoarsePointer } from \"./loop\";\n\n// Caps DPR at 1.5 on coarse pointers and 2 otherwise.\nexport function defaultMaxDpr(): number {\n  return isCoarsePointer() ? 1.5 : 2;\n}\n\n// Sets the backing size for a CSS box and returns the DPR used.\nexport function sizeCanvas(\n  canvas: HTMLCanvasElement,\n  cssW: number,\n  cssH: number,\n  maxDpr: number = defaultMaxDpr(),\n): number {\n  const dpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, maxDpr);\n  canvas.width = Math.max(1, Math.round(cssW * dpr));\n  canvas.height = Math.max(1, Math.round(cssH * dpr));\n  canvas.style.width = `${cssW}px`;\n  canvas.style.height = `${cssH}px`;\n  return dpr;\n}\n\n// Rounded size of an element, kept current with a ResizeObserver.\nexport function useElementSize(ref: RefObject<HTMLElement | null>): { width: number; height: number } {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      const width = Math.round(box.width);\n      const height = Math.round(box.height);\n      setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/canvas.ts"
    },
    {
      "path": "components/bjork-ui/_core/motion.ts",
      "content": "// Shared springs, easings and durations. Components import these instead of inventing values.\n\nexport const springs = {\n  press: { type: \"spring\", stiffness: 500, damping: 30, mass: 0.6 },\n  snappy: { type: \"spring\", stiffness: 400, damping: 25, mass: 1 },\n  standard: { type: \"spring\", stiffness: 300, damping: 30, mass: 0.8 },\n  soft: { type: \"spring\", stiffness: 200, damping: 25, mass: 1.2 },\n  settle: { type: \"spring\", stiffness: 380, damping: 30, mass: 0.8 },\n  blurIn: { type: \"spring\", stiffness: 400, damping: 28, mass: 0.6 },\n} as const;\n\nexport const ease = {\n  out: [0.23, 1, 0.32, 1],\n  inOut: [0.77, 0, 0.175, 1],\n  drawer: [0.32, 0.72, 0, 1],\n} as const;\n\nexport const easeCss = {\n  out: \"cubic-bezier(0.23,1,0.32,1)\",\n  inOut: \"cubic-bezier(0.77,0,0.175,1)\",\n  drawer: \"cubic-bezier(0.32,0.72,0,1)\",\n} as const;\n\nexport const durations = { press: 140, tooltip: 160, menu: 200, panel: 280 } as const; // ms\n\n// Cubic bezier easing for canvas and rAF code. Returns progress y for input x in [0, 1].\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {\n  const cx = 3 * x1;\n  const bx = 3 * (x2 - x1) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * y1;\n  const by = 3 * (y2 - y1) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (s: number) => ((ax * s + bx) * s + cx) * s;\n  const sampleY = (s: number) => ((ay * s + by) * s + cy) * s;\n  const sampleDX = (s: number) => (3 * ax * s + 2 * bx) * s + cx;\n\n  const solveX = (x: number) => {\n    let s = x;\n    for (let i = 0; i < 8; i++) {\n      const err = sampleX(s) - x;\n      if (Math.abs(err) < 1e-6) return s;\n      const d = sampleDX(s);\n      if (Math.abs(d) < 1e-6) break;\n      s -= err / d;\n    }\n    let lo = 0;\n    let hi = 1;\n    s = x;\n    while (lo < hi) {\n      const v = sampleX(s);\n      if (Math.abs(v - x) < 1e-6) return s;\n      if (x > v) lo = s;\n      else hi = s;\n      s = (lo + hi) / 2;\n      if (hi - lo < 1e-7) break;\n    }\n    return s;\n  };\n\n  return (t: number) => {\n    if (t <= 0) return 0;\n    if (t >= 1) return 1;\n    return sampleY(solveX(t));\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/motion.ts"
    },
    {
      "path": "components/bjork-ui/charts/_kit/chrome.tsx",
      "content": "\"use client\";\n\n// Shared chart chrome for the Charts collection: theme vars, the hover tooltip, legend keys and the\n// screen-reader table twin every chart ships with.\n\nimport { forwardRef, memo, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { LiveRegion } from \"@/components/bjork-ui/_core/a11y\";\nimport { surfaceVars } from \"@/components/bjork-ui/_core/surface\";\n\nexport type ChartPalette = (typeof BJORK_PALETTE)[BjorkTone];\n\n// Tokens a chart root writes so DOM overlays follow `tone` even outside a themed page.\nexport function chartVars(pal: ChartPalette): CSSProperties {\n  return {\n    ...surfaceVars(pal === BJORK_PALETTE.light ? \"light\" : \"dark\"),\n    \"--bjork-accent\": pal.accent,\n    \"--bjork-accent-ink\": pal.accentInk,\n    \"--bjork-accent-fill\": pal.accentFill,\n    \"--bjork-accent-soft\": pal.accentSoft,\n    \"--bjork-accent-foreground\": pal.accentFg,\n    \"--bjork-text\": pal.text,\n    \"--bjork-text-medium\": pal.textMedium,\n    \"--bjork-text-muted\": pal.textMuted,\n    \"--bjork-text-soft\": pal.textSoft,\n    \"--bjork-text-faint\": pal.textFaint,\n    \"--bjork-hair\": pal.hair,\n    \"--bjork-border\": pal.border,\n    \"--bjork-border-strong\": pal.borderStrong,\n    \"--bjork-surface\": pal.surface,\n    \"--bjork-surface-hover\": pal.raised,\n    \"--bjork-ring-offset\": pal.bg,\n    // The surface a chart sits on, for label halos and cut-outs. Override it on the root if needed.\n    \"--bjork-chart-bg\": pal.stage,\n    \"--bjork-success\": pal.success,\n    \"--bjork-warning\": pal.warning,\n    \"--bjork-error\": pal.error,\n  } as CSSProperties;\n}\n\n// Tone, palette, reduced motion and root vars in one call.\nexport function useChartTheme(toneProp?: BjorkTone) {\n  const tone = useBjorkTone(toneProp);\n  const pal = BJORK_PALETTE[tone];\n  const reduce = !!useReducedMotion();\n  return { tone, pal, reduce, vars: chartVars(pal) };\n}\n\n// Focus ring shared by every focusable plot surface.\nexport const chartFocusRing =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bjork-ring-offset)]\";\n\n// Mono label style: 10px Geist Mono, tabular, cap-trimmed.\nexport const chartMono = \"font-mono text-[10px] leading-none tabular-nums [text-box:trim-both_cap_alphabetic]\";\n\n// Floating chart surfaces follow the library canon (_core/surface.ts): a 1px border plus the inset light\n// shadow. Hover cards take the full surface shadow; small readout tags take the soft one. Colours come\n// from chartVars, so light and dark both follow the tone.\nexport const chartCardEdge = \"border border-[color:var(--bjork-border)] shadow-[var(--bjork-shadow-surface)]\";\nexport const chartTagEdge = \"border border-[color:var(--bjork-border)] shadow-[var(--bjork-shadow-soft)]\";\n\nexport interface TooltipRow {\n  key?: string;\n  label: ReactNode;\n  value: ReactNode;\n  color?: string;\n  dashed?: boolean;\n  strong?: boolean;\n}\n\n// The hover card. Values lead, labels follow, series keyed with a short line in the series colour.\n// Position it with `style.transform` from the caller; it never takes pointer events.\nexport const ChartTooltip = forwardRef<\n  HTMLDivElement,\n  { title?: ReactNode; rows: TooltipRow[]; visible: boolean; className?: string; style?: CSSProperties; tipKey?: string }\n>(function ChartTooltip({ title, rows, visible, className, style, tipKey }, ref) {\n  return (\n    <div\n      ref={ref}\n      data-key={tipKey}\n      role=\"presentation\"\n      aria-hidden=\"true\"\n      className={cn(\n        `pointer-events-none absolute left-0 top-0 z-10 min-w-[120px] rounded-[10px] ${chartCardEdge} bg-[color:var(--bjork-surface)] px-2.5 py-2 transition-opacity duration-150 ease-out`,\n        visible ? \"opacity-100\" : \"opacity-0\",\n        className,\n      )}\n      style={style}\n    >\n      {title !== undefined && (\n        <div className=\"mb-1.5 whitespace-nowrap font-mono text-[10px] uppercase leading-none tracking-[0.08em] text-[color:var(--bjork-text-soft)] [text-box:trim-both_cap_alphabetic]\">\n          {title}\n        </div>\n      )}\n      <div className=\"flex flex-col gap-[7px]\">\n        {rows.map((r, i) => (\n          <div key={r.key ?? i} className=\"flex items-center gap-2 whitespace-nowrap leading-none\">\n            {r.color && (\n              <span\n                aria-hidden=\"true\"\n                className=\"inline-block w-2.5 shrink-0\"\n                style={\n                  r.dashed\n                    ? { borderTop: `1.5px dashed ${r.color}`, height: 0 }\n                    : { background: r.color, height: 2, borderRadius: 1 }\n                }\n              />\n            )}\n            <span\n              className={cn(\n                \"font-mono text-[12px] tabular-nums [text-box:trim-both_cap_alphabetic]\",\n                r.strong === false ? \"text-[color:var(--bjork-text-medium)]\" : \"text-[color:var(--bjork-text)]\",\n              )}\n            >\n              {r.value}\n            </span>\n            <span className=\"ml-auto pl-3 font-bjork-alpha text-[11px] text-[color:var(--bjork-text-muted)] [text-box:trim-both_cap_alphabetic]\">\n              {r.label}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n});\n\n// Places a tooltip of size (tw, th) next to (x, y) inside a (w, h) box, flipping away from edges.\nexport function placeTooltip(\n  x: number,\n  y: number,\n  tw: number,\n  th: number,\n  w: number,\n  h: number,\n  gap = 12,\n): { x: number; y: number } {\n  let tx = x + gap;\n  if (tx + tw > w) tx = x - gap - tw;\n  if (tx < 0) tx = Math.max(0, Math.min(w - tw, x - tw / 2));\n  let ty = y - th - gap;\n  if (ty < 0) ty = Math.min(h - th, y + gap);\n  return { x: Math.round(tx), y: Math.round(Math.max(0, ty)) };\n}\n\nexport interface LegendItem {\n  id: string;\n  label: string;\n  color: string;\n  shape?: \"line\" | \"dash\" | \"rect\" | \"dot\";\n  muted?: boolean;\n}\n\n// A legend row. Keys mirror the mark: line for lines, rect for areas and bars.\nexport function ChartLegend({\n  items,\n  className,\n  onHover,\n  activeId,\n}: {\n  items: LegendItem[];\n  className?: string;\n  onHover?: (id: string | null) => void;\n  activeId?: string | null;\n}) {\n  return (\n    <div className={cn(\"flex flex-wrap items-center gap-x-4 gap-y-2\", className)}>\n      {items.map((it) => {\n        const dim = (activeId && activeId !== it.id) || it.muted;\n        return (\n          <span\n            key={it.id}\n            onPointerEnter={onHover ? () => onHover(it.id) : undefined}\n            onPointerLeave={onHover ? () => onHover(null) : undefined}\n            className={cn(\n              \"inline-flex items-center gap-1.5 font-bjork-alpha text-[11px] font-medium leading-3 transition-opacity duration-150 ease-out\",\n              dim ? \"opacity-45\" : \"opacity-100\",\n              \"text-[color:var(--bjork-text-medium)]\",\n            )}\n          >\n            <LegendKey color={it.color} shape={it.shape} />\n            {it.label}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\nexport function LegendKey({ color, shape = \"rect\" }: { color: string; shape?: LegendItem[\"shape\"] }) {\n  if (shape === \"line\") return <span aria-hidden=\"true\" className=\"inline-block h-[2px] w-2.5 rounded-[1px]\" style={{ background: color }} />;\n  if (shape === \"dash\") return <span aria-hidden=\"true\" className=\"inline-block h-0 w-2.5\" style={{ borderTop: `1.5px dashed ${color}` }} />;\n  if (shape === \"dot\") return <span aria-hidden=\"true\" className=\"inline-block size-2 rounded-full\" style={{ background: color }} />;\n  return <span aria-hidden=\"true\" className=\"inline-block size-2 rounded-[2px]\" style={{ background: color }} />;\n}\n\n// The table twin. Every value the chart shows is reachable here without hovering.\n// Memoised: hover state re-renders the chart, never the table. Pass memoised rows.\nexport const ChartTable = memo(function ChartTable({\n  caption,\n  columns,\n  rows,\n}: {\n  caption: string;\n  columns: string[];\n  rows: (string | number)[][];\n}) {\n  return (\n    <table className=\"sr-only\">\n      <caption>{caption}</caption>\n      <thead>\n        <tr>\n          {columns.map((c) => (\n            <th key={c} scope=\"col\">\n              {c}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((r, i) => (\n          <tr key={i}>\n            {r.map((cell, j) =>\n              j === 0 ? (\n                <th key={j} scope=\"row\">\n                  {cell}\n                </th>\n              ) : (\n                <td key={j}>{cell}</td>\n              ),\n            )}\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n});\n\n// A pool of absolutely positioned spans the draw loop fills through `writeLabels`.\nexport function LabelPool({\n  count,\n  pool,\n  className,\n}: {\n  count: number;\n  pool: { current: (HTMLSpanElement | null)[] };\n  className?: string;\n}) {\n  return (\n    <>\n      {Array.from({ length: count }).map((_, i) => (\n        <span\n          key={i}\n          ref={(el) => {\n            pool.current[i] = el;\n          }}\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute left-0 top-0 whitespace-nowrap opacity-0\",\n            className ?? \"font-mono text-[10px] leading-none tabular-nums text-[color:var(--bjork-text-faint)] [text-box:trim-both_cap_alphabetic]\",\n          )}\n        />\n      ))}\n    </>\n  );\n}\n\nexport interface TooltipContent {\n  key: string;\n  title?: ReactNode;\n  rows: TooltipRow[];\n}\n\nexport interface TooltipHandle {\n  /** Shows `content`, or hides with `null`. Same key is a no-op. */\n  set: (content: TooltipContent | null) => void;\n  readonly el: HTMLDivElement | null;\n  /** Measured size of the current content. */\n  readonly size: { w: number; h: number };\n}\n\n// A tooltip that owns its state, so hovering re-renders only the tooltip, never the chart.\n// The chart positions it from its draw loop through `handle.el.style.transform`.\nexport const HoverTooltip = forwardRef<TooltipHandle, { onMeasure?: () => void; className?: string }>(function HoverTooltip(\n  { onMeasure, className },\n  ref,\n) {\n  const [state, setState] = useState<{ content: TooltipContent | null; last: TooltipContent | null }>({ content: null, last: null });\n  const elRef = useRef<HTMLDivElement>(null);\n  const size = useRef({ w: 0, h: 0 });\n  const onMeasureRef = useRef(onMeasure);\n  useEffect(() => {\n    onMeasureRef.current = onMeasure;\n  });\n  useImperativeHandle(\n    ref,\n    () => ({\n      set: (content) =>\n        setState((prev) => {\n          if ((prev.content?.key ?? null) === (content?.key ?? null)) return prev;\n          return { content, last: content ?? prev.last };\n        }),\n      get el() {\n        return elRef.current;\n      },\n      get size() {\n        return size.current;\n      },\n    }),\n    [],\n  );\n  useLayoutEffect(() => {\n    const el = elRef.current;\n    if (!el) return;\n    size.current = { w: el.offsetWidth, h: el.offsetHeight };\n    onMeasureRef.current?.();\n  }, [state.last]);\n  const shown = state.content ?? state.last;\n  return <ChartTooltip ref={elRef} title={shown?.title} rows={shown?.rows ?? []} visible={!!state.content} className={className} />;\n});\n\nexport interface AnnouncerHandle {\n  say: (message: string) => void;\n}\n\n// Polite live region with a 250ms trailing throttle, isolated from the chart's renders.\nexport const ChartAnnouncer = forwardRef<AnnouncerHandle, { ms?: number }>(function ChartAnnouncer({ ms = 250 }, ref) {\n  const [message, setMessage] = useState(\"\");\n  const at = useRef(0);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n  useImperativeHandle(\n    ref,\n    () => ({\n      say: (text: string) => {\n        const now = performance.now();\n        if (timer.current) clearTimeout(timer.current);\n        if (now - at.current >= ms) {\n          at.current = now;\n          setMessage(text);\n        } else {\n          timer.current = setTimeout(() => {\n            at.current = performance.now();\n            setMessage(text);\n          }, ms);\n        }\n      },\n    }),\n    [ms],\n  );\n  return <LiveRegion message={message} />;\n});\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/charts/_kit/chrome.tsx"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/a11y.tsx",
      "content": "import type { ReactNode } from \"react\";\n\n// Text for assistive tech only.\nexport function VisuallyHidden({ children }: { children: ReactNode }) {\n  return <span className=\"sr-only\">{children}</span>;\n}\n\n// Announces `message` when it changes. Same text re-rendered does not re-announce.\nexport function LiveRegion({\n  message,\n  politeness = \"polite\",\n}: {\n  message: string;\n  politeness?: \"polite\" | \"assertive\";\n}) {\n  return (\n    <span role=\"status\" aria-live={politeness} aria-atomic=\"true\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/a11y.tsx"
    },
    {
      "path": "components/bjork-ui/_core/surface.ts",
      "content": "import type { CSSProperties } from \"react\";\nimport type { BjorkTone } from \"./palette\";\n\n// The library surface (CANON.md §1). Values mirror app/globals.css :root and .dark. Change both together.\nexport const BJORK_SURFACE = {\n  light: {\n    surface: \"#fffcf6\", surfaceMuted: \"rgba(250, 246, 237, 0.94)\", surfaceHover: \"#f5efe3\", surfaceActive: \"#efe7d8\",\n    panel: \"#f8f2e7\", menu: \"rgba(255, 252, 246, 0.98)\", field: \"rgba(255, 252, 246, 0.96)\",\n    fieldMuted: \"rgba(248, 242, 231, 0.82)\", fieldInset: \"rgba(239, 231, 216, 0.72)\",\n    border: \"#eee6db\", borderMuted: \"#f5ede2\", borderStrong: \"#e1d7c8\",\n    shadowSurface: \"inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)\",\n    shadowSoft: \"inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)\",\n    shadowPanel: \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)\",\n    shadowMenu: \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)\",\n    shadowInset: \"inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)\",\n  },\n  dark: {\n    surface: \"#121212\", surfaceMuted: \"rgba(22, 22, 22, 0.92)\", surfaceHover: \"#161616\", surfaceActive: \"#202020\",\n    panel: \"#0d0d0d\", menu: \"rgba(18, 18, 18, 0.98)\", field: \"rgba(18, 18, 18, 0.95)\",\n    fieldMuted: \"rgba(18, 18, 18, 0.55)\", fieldInset: \"rgba(9, 9, 9, 0.75)\",\n    border: \"#232323\", borderMuted: \"#1c1c1c\", borderStrong: \"#343434\",\n    shadowSurface: \"inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)\",\n    shadowSoft: \"inset 0 1px 0 rgba(255, 255, 255, 0.045)\",\n    shadowPanel: \"inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)\",\n    shadowMenu: \"inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)\",\n    shadowInset: \"inset 0 1px 10px rgba(0, 0, 0, 0.45)\",\n  },\n} as const;\n\n/** Writes the surface tokens for a pinned tone as --bjork-* custom properties. Spread first; kit-specific keys may override. */\nexport function surfaceVars(tone: BjorkTone): CSSProperties {\n  const s = BJORK_SURFACE[tone];\n  return {\n    \"--bjork-surface\": s.surface, \"--bjork-surface-muted\": s.surfaceMuted, \"--bjork-surface-hover\": s.surfaceHover,\n    \"--bjork-surface-active\": s.surfaceActive, \"--bjork-panel\": s.panel, \"--bjork-menu\": s.menu, \"--bjork-field\": s.field,\n    \"--bjork-field-muted\": s.fieldMuted, \"--bjork-field-inset\": s.fieldInset, \"--bjork-border\": s.border,\n    \"--bjork-border-muted\": s.borderMuted, \"--bjork-border-strong\": s.borderStrong,\n    \"--bjork-shadow-surface\": s.shadowSurface, \"--bjork-shadow-soft\": s.shadowSoft, \"--bjork-shadow-panel\": s.shadowPanel,\n    \"--bjork-shadow-menu\": s.shadowMenu, \"--bjork-shadow-inset\": s.shadowInset,\n  } as CSSProperties;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/surface.ts"
    },
    {
      "path": "components/bjork-ui/charts/_kit/scale.ts",
      "content": "// Shared chart math for the Charts collection: nice ticks, scales, damping, colour and number formatting.\n// Pure functions only. Every chart imports what it needs by path.\n\nexport function clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\nexport function lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\n// Maps v from [d0, d1] to [r0, r1]. Degenerate domains map to the range midpoint.\nexport function scaleLinear(d0: number, d1: number, r0: number, r1: number): (v: number) => number {\n  const span = d1 - d0;\n  if (!(Math.abs(span) > 1e-12)) return () => (r0 + r1) / 2;\n  const k = (r1 - r0) / span;\n  return (v: number) => r0 + (v - d0) * k;\n}\n\n// Frame-rate independent approach: moves `cur` toward `target` with time constant `tau` seconds.\nexport function damp(cur: number, target: number, tau: number, dt: number): number {\n  if (tau <= 0) return target;\n  return target + (cur - target) * Math.exp(-dt / tau);\n}\n\n// A \"nice\" step (1, 2, 2.5 or 5 x 10^k) that splits `span` into roughly `count` intervals.\nexport function niceStep(span: number, count = 5): number {\n  if (!(span > 0) || !Number.isFinite(span)) return 1;\n  const raw = span / Math.max(1, count);\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const r = raw / mag;\n  const m = r <= 1 ? 1 : r <= 2 ? 2 : r <= 2.5 ? 2.5 : r <= 5 ? 5 : 10;\n  return m * mag;\n}\n\n// Tick values at a nice step, inclusive of the domain ends when they land on the step.\nexport function niceTicks(lo: number, hi: number, count = 5): number[] {\n  if (!(hi > lo)) return [lo];\n  const step = niceStep(hi - lo, count);\n  const first = Math.ceil(lo / step - 1e-9);\n  const last = Math.floor(hi / step + 1e-9);\n  const out: number[] = [];\n  for (let n = first; n <= last; n++) out.push(Number((n * step).toFixed(12)));\n  return out;\n}\n\n// Expands [lo, hi] outward to the nearest nice step, so the outer gridlines sit on round values.\nexport function niceDomain(lo: number, hi: number, count = 5): [number, number] {\n  if (!(hi > lo)) {\n    const pad = Math.max(Math.abs(hi) * 0.1, 1);\n    return [lo - pad, hi + pad];\n  }\n  const step = niceStep(hi - lo, count);\n  return [Math.floor(lo / step + 1e-9) * step, Math.ceil(hi / step - 1e-9) * step];\n}\n\n// Half-pixel snap for crisp 1px hairlines.\nexport function crisp(v: number): number {\n  return Math.round(v) + 0.5;\n}\n\n// --- Colour -------------------------------------------------------------------------------------\n\nconst rgbCache = new Map<string, [number, number, number, number]>();\n\n// Parses #rgb, #rrggbb and rgba()/rgb() strings. Returns [r, g, b, a].\nexport function parseColor(c: string): [number, number, number, number] {\n  const hit = rgbCache.get(c);\n  if (hit) return hit;\n  let out: [number, number, number, number] = [0, 0, 0, 1];\n  const s = c.trim();\n  if (s.startsWith(\"#\")) {\n    let h = s.slice(1);\n    if (h.length === 3) h = h.split(\"\").map((ch) => ch + ch).join(\"\");\n    const n = parseInt(h.slice(0, 6), 16);\n    out = [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1];\n  } else {\n    const m = s.match(/rgba?\\(([^)]+)\\)/);\n    if (m) {\n      const p = m[1].split(\",\").map((x) => parseFloat(x));\n      out = [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0, p[3] ?? 1];\n    }\n  }\n  rgbCache.set(c, out);\n  return out;\n}\n\n// The colour at alpha `a`, multiplied with any alpha the colour already has.\nexport function withAlpha(c: string, a: number): string {\n  const [r, g, b, a0] = parseColor(c);\n  return `rgba(${r},${g},${b},${+(a0 * a).toFixed(4)})`;\n}\n\n// Mixes two opaque colours in sRGB. t = 0 gives `a`.\nexport function mixColor(a: string, b: string, t: number): string {\n  const ca = parseColor(a);\n  const cb = parseColor(b);\n  const r = Math.round(lerp(ca[0], cb[0], t));\n  const g = Math.round(lerp(ca[1], cb[1], t));\n  const bl = Math.round(lerp(ca[2], cb[2], t));\n  return `rgb(${r},${g},${bl})`;\n}\n\n// --- Numbers ------------------------------------------------------------------------------------\n\nconst fmtCache = new Map<string, Intl.NumberFormat>();\nfunction nf(opts: Intl.NumberFormatOptions): Intl.NumberFormat {\n  const key = JSON.stringify(opts);\n  let f = fmtCache.get(key);\n  if (!f) {\n    f = new Intl.NumberFormat(\"en-US\", opts);\n    fmtCache.set(key, f);\n  }\n  return f;\n}\n\nexport function formatNumber(v: number, digits = 1): string {\n  if (!Number.isFinite(v)) return \"–\";\n  return nf({ maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(v);\n}\n\nexport function formatFixed(v: number, digits = 2): string {\n  if (!Number.isFinite(v)) return \"–\";\n  return nf({ maximumFractionDigits: digits, minimumFractionDigits: digits }).format(v);\n}\n\n// 1,284 / 12.9K / 4.2M. Uses a true minus sign.\nexport function formatCompact(v: number, digits = 1): string {\n  if (!Number.isFinite(v)) return \"–\";\n  const s = nf({ notation: \"compact\", maximumFractionDigits: digits }).format(Math.abs(v));\n  return v < 0 ? `−${s}` : s;\n}\n\nexport function formatPercent(v: number, digits = 0): string {\n  if (!Number.isFinite(v)) return \"–\";\n  const s = nf({ maximumFractionDigits: digits, minimumFractionDigits: digits }).format(Math.abs(v * 100));\n  return `${v < 0 ? \"−\" : \"\"}${s}%`;\n}\n\n// +1.2 / −0.8 / 0. Signed numbers use a true minus and an explicit plus.\nexport function formatSigned(v: number, format: (n: number) => string = (n) => formatNumber(n)): string {\n  if (!Number.isFinite(v)) return \"–\";\n  const body = format(Math.abs(v));\n  // A value that rounds to zero carries no sign.\n  if (Math.abs(v) < 1e-12 || !/[1-9]/.test(body)) return body;\n  return `${v > 0 ? \"+\" : \"−\"}${body}`;\n}\n\n// Gaussian kernel density at x for a sorted sample, bandwidth h.\nexport function kde(sample: number[], x: number, h: number): number {\n  const n = sample.length;\n  if (!n || !(h > 0)) return 0;\n  let s = 0;\n  const inv = 1 / h;\n  for (let i = 0; i < n; i++) {\n    const u = (x - sample[i]) * inv;\n    if (u > 4 || u < -4) continue;\n    s += Math.exp(-0.5 * u * u);\n  }\n  return s / (n * h * Math.sqrt(2 * Math.PI));\n}\n\n// Silverman's rule of thumb bandwidth.\nexport function silverman(sample: number[]): number {\n  const n = sample.length;\n  if (n < 2) return 1;\n  const mean = sample.reduce((a, b) => a + b, 0) / n;\n  const sd = Math.sqrt(sample.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1));\n  const sorted = [...sample].sort((a, b) => a - b);\n  const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);\n  const spread = Math.min(sd, iqr / 1.34) || sd || 1;\n  return 0.9 * spread * n ** -0.2;\n}\n\n// Linear-interpolated quantile of a sorted array.\nexport function quantile(sorted: number[], q: number): number {\n  const n = sorted.length;\n  if (!n) return NaN;\n  if (n === 1) return sorted[0];\n  const pos = clamp(q, 0, 1) * (n - 1);\n  const lo = Math.floor(pos);\n  const hi = Math.min(n - 1, lo + 1);\n  return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);\n}\n\n// Standard normal sample from a uniform source (Box-Muller).\nexport function gaussian(rnd: () => number): number {\n  const u = Math.max(rnd(), 1e-9);\n  const v = rnd();\n  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n}\n\n// --- Time ---------------------------------------------------------------------------------------\n\nconst HOUR = 3600000;\nconst DAY = 86400000;\nconst timeFmt = {\n  hour: new Intl.DateTimeFormat(\"en-US\", { hour: \"numeric\", timeZone: \"UTC\" }),\n  day: new Intl.DateTimeFormat(\"en-US\", { month: \"short\", day: \"numeric\", timeZone: \"UTC\" }),\n  month: new Intl.DateTimeFormat(\"en-US\", { month: \"short\", timeZone: \"UTC\" }),\n  monthYear: new Intl.DateTimeFormat(\"en-US\", { month: \"short\", year: \"2-digit\", timeZone: \"UTC\" }),\n  year: new Intl.DateTimeFormat(\"en-US\", { year: \"numeric\", timeZone: \"UTC\" }),\n};\n\nexport interface TimeTick {\n  t: number;\n  label: string;\n}\n\n// Calendar-aligned ticks (UTC) for [t0, t1] drawn across `px` pixels, at least `minPx` apart.\nexport function timeTicks(t0: number, t1: number, px: number, minPx = 64): TimeTick[] {\n  const span = t1 - t0;\n  if (!(span > 0) || !(px > 0)) return [];\n  const maxTicks = Math.max(1, Math.floor(px / minPx));\n  const perTick = span / maxTicks;\n  const out: TimeTick[] = [];\n  const hourSteps = [1, 2, 3, 6, 12];\n  for (const k of hourSteps) {\n    if (k * HOUR >= perTick) {\n      const step = k * HOUR;\n      for (let t = Math.ceil(t0 / step) * step; t <= t1; t += step) {\n        const d = new Date(t);\n        out.push({ t, label: d.getUTCHours() === 0 ? timeFmt.day.format(t) : timeFmt.hour.format(t) });\n      }\n      return out;\n    }\n  }\n  const daySteps = [1, 2, 3, 7, 14];\n  for (const k of daySteps) {\n    if (k * DAY >= perTick) {\n      const step = k * DAY;\n      // Weekly steps land on Mondays.\n      const offset = k === 7 || k === 14 ? 4 * DAY : 0;\n      for (let t = Math.ceil((t0 - offset) / step) * step + offset; t <= t1; t += step) out.push({ t, label: timeFmt.day.format(t) });\n      return out;\n    }\n  }\n  const monthSteps = [1, 2, 3, 6, 12, 24, 60];\n  for (const k of monthSteps) {\n    if (k * 30.44 * DAY >= perTick) {\n      const d0 = new Date(t0);\n      let y = d0.getUTCFullYear();\n      let m = d0.getUTCMonth();\n      // First boundary at or after t0 that is a multiple of k months.\n      m = Math.ceil(m / k) * k;\n      for (;;) {\n        y += Math.floor(m / 12);\n        m %= 12;\n        const t = Date.UTC(y, m, 1);\n        if (t > t1) break;\n        if (t >= t0) {\n          const label = k >= 12 ? timeFmt.year.format(t) : m === 0 ? timeFmt.monthYear.format(t) : timeFmt.month.format(t);\n          out.push({ t, label });\n        }\n        m += k;\n      }\n      return out;\n    }\n  }\n  return out;\n}\n\nexport function formatDateUTC(t: number, style: \"day\" | \"full\" = \"day\"): string {\n  if (style === \"full\") return new Date(t).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" });\n  return timeFmt.day.format(t);\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/charts/_kit/scale.ts"
    },
    {
      "path": "components/bjork-ui/charts/_kit/series.ts",
      "content": "// Series colour for the Charts collection: a fixed categorical order for identity and a one-hue\n// ramp for magnitude. Pure data and functions; every chart imports what it needs by path.\n//\n// The categorical order is warm-anchored: the ember accent first, then warm ink-neutral steps,\n// then one cool counterpoint (steel), then deeper or lighter steps of the accent family. Every\n// light slot clears 3:1 on the paper stage. Hues are assigned in this order and never cycled:\n// past six series, fold the rest into \"Other\" (see `foldSeries`).\n\nimport type { BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { clamp, lerp, parseColor } from \"@/components/bjork-ui/charts/_kit/scale\";\n\nexport const CHART_SERIES: Record<BjorkTone, readonly string[]> = {\n  //       ember      warm ink   warm grey  steel      deep rust  light ember\n  dark: [\"#ec5c13\", \"#d9d3c7\", \"#8f877a\", \"#5b8fb9\", \"#b84a12\", \"#f0a272\"],\n  light: [\"#c94f10\", \"#3a352d\", \"#8a8174\", \"#3f6f96\", \"#8f3a0e\", \"#d67a45\"],\n};\n\n// The neutral an \"Other\" bucket takes, so it never impersonates a series.\nexport const CHART_OTHER: Record<BjorkTone, string> = { dark: \"#5c5c5c\", light: \"#a8a39a\" };\n\nexport const MAX_SERIES = 6;\n\n// Slot colour by the series' stable index (its position in the caller's data, never its rank).\nexport function seriesColor(tone: BjorkTone, index: number): string {\n  const set = CHART_SERIES[tone];\n  return index >= 0 && index < set.length ? set[index] : CHART_OTHER[tone];\n}\n\n// Keeps the first `max - 1` items and sums the rest into one \"Other\" item when there are more\n// than `max`. `merge` builds the Other item from the folded ones.\nexport function foldSeries<T>(items: T[], max: number, merge: (rest: T[]) => T): T[] {\n  if (items.length <= max) return items;\n  return [...items.slice(0, max - 1), merge(items.slice(max - 1))];\n}\n\n// One-hue magnitude ramp in the accent family. t = 0 recedes toward the stage, t = 1 is the\n// strongest step. Dark mode brightens with magnitude, light mode darkens, so \"more\" always reads\n// as more contrast against the surface.\nconst RAMP: Record<BjorkTone, readonly string[]> = {\n  dark: [\"#1d1611\", \"#3a2112\", \"#6a3013\", \"#a84514\", \"#ec5c13\", \"#ff9a5c\"],\n  light: [\"#f1e8dc\", \"#f3cfb2\", \"#eda57a\", \"#e07a43\", \"#b4531f\", \"#7a3412\"],\n};\n\nexport function rampColor(tone: BjorkTone, t: number): string {\n  const stops = RAMP[tone];\n  const x = clamp(Number.isFinite(t) ? t : 0, 0, 1) * (stops.length - 1);\n  const i = Math.min(stops.length - 2, Math.floor(x));\n  const a = parseColor(stops[i]);\n  const b = parseColor(stops[i + 1]);\n  const f = x - i;\n  return `rgb(${Math.round(lerp(a[0], b[0], f))},${Math.round(lerp(a[1], b[1], f))},${Math.round(lerp(a[2], b[2], f))})`;\n}\n\n// WCAG relative luminance of an sRGB colour.\nfunction relLum(color: string): number {\n  const [r, g, b] = parseColor(color).map((c) => {\n    const v = c / 255;\n    return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;\n  });\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n// Light or dark ink for text on a filled mark, whichever has more WCAG contrast. The inks are\n// near-white (L ≈ 0.94) and near-black (L ≈ 0.009). Saturated orange gets dark ink; white text\n// only lands on the deeper rust steps, the same rule as --bjork-accent-fill.\nexport function inkOn(color: string): \"light\" | \"dark\" {\n  const L = relLum(color);\n  return (0.94 + 0.05) / (L + 0.05) >= (L + 0.05) / (0.009 + 0.05) ? \"light\" : \"dark\";\n}\n\n// Whether text on a ramp cell at `t` should use the light or dark ink.\nexport function rampInk(tone: BjorkTone, t: number): \"light\" | \"dark\" {\n  return inkOn(rampColor(tone, t));\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/charts/_kit/series.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Charts",
    "registryUrl": "https://ui.isaiahbjork.com/grouped-bar-chart.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/grouped-bar-chart.json"
  },
  "type": "registry:component"
}