{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "order-book",
  "title": "Order book",
  "description": "Order book 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/order-book.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  memo,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FocusEvent,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { LayoutGroup, motion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport type { BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { BJORK_SURFACE } from \"@/components/bjork-ui/_core/surface\";\nimport { mulberry32 } from \"@/components/bjork-ui/_core/random\";\nimport { useElementSize } from \"@/components/bjork-ui/_core/canvas\";\nimport { easeCss } from \"@/components/bjork-ui/_core/motion\";\nimport {\n  useChartTheme,\n  HoverTooltip,\n  ChartAnnouncer,\n  placeTooltip,\n  type TooltipHandle,\n  type AnnouncerHandle,\n  type TooltipContent,\n} from \"@/components/bjork-ui/charts/_kit/chrome\";\nimport { clamp, withAlpha, formatCompact, formatFixed, formatNumber, formatSigned, gaussian } from \"@/components/bjork-ui/charts/_kit/scale\";\n\n// --- Data model -----------------------------------------------------------------------------------\n// The same level shape the depth chart takes, so one feed can drive both side by side.\n\nexport interface BookLevel {\n  price: number;\n  size: number;\n}\n\nexport type BookSide = \"bid\" | \"ask\";\n\n/** A full book. Replaces every level. */\nexport interface OrderBookSnapshot {\n  bids: BookLevel[];\n  asks: BookLevel[];\n  /** Last traded price, if the feed carries trades. */\n  last?: number;\n}\n\n/** Changed levels only. A size of 0 removes the level. */\nexport interface OrderBookDelta {\n  bids?: BookLevel[];\n  asks?: BookLevel[];\n  last?: number;\n}\n\n/** What a feed sends: the shape most exchange WebSockets reduce to. */\nexport type OrderBookMessage = ({ type: \"snapshot\" } & OrderBookSnapshot) | ({ type: \"delta\" } & OrderBookDelta);\n\n/** One of the viewer's resting orders. Marked on the level that contains its price. */\nexport interface MyOrder {\n  id?: string;\n  side: BookSide;\n  price: number;\n  size: number;\n}\n\n/** A level as the book shows it, after grouping. Handed to `onPriceSelect`. */\nexport interface OrderBookLevelInfo {\n  side: BookSide;\n  /** Grouped price: bids round down to the tick, asks round up. */\n  price: number;\n  size: number;\n  /** Cumulative size from the touch through this level. */\n  total: number;\n  /** Cumulative notional (price × size) from the touch through this level. */\n  notional: number;\n  /** 0 is the best level on its side. */\n  depth: number;\n  /** Signed distance from mid, in price and in basis points. */\n  fromMid: number;\n  fromMidBp: number;\n  /** Size of the viewer's own orders on this level. */\n  mine: number;\n}\n\nexport interface OrderBookHandle {\n  applySnapshot: (snapshot: OrderBookSnapshot) => void;\n  applyDelta: (delta: OrderBookDelta) => void;\n  /** Routes a feed message to `applySnapshot` or `applyDelta`. */\n  apply: (message: OrderBookMessage) => void;\n  setLastPrice: (price: number) => void;\n  /** The raw (ungrouped) book, best first. Feed it to a DepthChart to show the same book. */\n  getSnapshot: () => OrderBookSnapshot;\n}\n\nexport type OrderBookLayout = \"stacked\" | \"side-by-side\" | \"auto\";\n\nexport interface OrderBookProps {\n  /** Snapshot as props. Each new pair of arrays is diffed into the book; use the ref's `applyDelta` for streams. */\n  bids?: BookLevel[];\n  asks?: BookLevel[];\n  lastPrice?: number;\n  /** Levels per side. Default 12. */\n  rows?: number;\n  /** Stacked puts asks above the spread and bids below. Auto goes side by side from 600px. Default \"auto\". */\n  layout?: OrderBookLayout;\n  /** Grouping choices. Shows the grouping control when there are two or more. */\n  tickSizes?: number[];\n  /** Controlled grouping. 0 or unset shows raw levels. */\n  tickSize?: number;\n  defaultTickSize?: number;\n  onTickSizeChange?: (tick: number) => void;\n  /** Price decimals. Defaults to the decimals of the grouping tick. */\n  precision?: number;\n  /** Size decimals. Default 0. */\n  sizePrecision?: number;\n  formatPrice?: (price: number) => string;\n  formatSize?: (size: number) => string;\n  formatNotional?: (notional: number) => string;\n  /** Column header for prices, e.g. \"Price (USD)\". */\n  priceLabel?: string;\n  /** Column header for sizes, e.g. \"Size (KSTL)\". */\n  sizeLabel?: string;\n  myOrders?: MyOrder[];\n  /** Bid against ask size across the visible levels. Default true. */\n  showImbalance?: boolean;\n  /** Brief highlight on levels whose size changed. Off under reduced motion. Default true. */\n  flash?: boolean;\n  /** Click, tap or Enter on a level. Use it to fill an order form. */\n  onPriceSelect?: (level: OrderBookLevelInfo) => void;\n  /** Posed active level, for previews and docs. */\n  activeLevel?: { side: BookSide; depth: number } | null;\n  /** Polite market summary: while focus is in the book (default), always, or never. */\n  announce?: \"focus\" | \"always\" | \"off\";\n  /** Minimum gap between spoken summaries, ms. Default 8000. */\n  announceInterval?: number;\n  /** Row height in px. Default 22. */\n  rowHeight?: number;\n  ariaLabel?: string;\n  tone?: BjorkTone;\n  className?: string;\n}\n\n// --- Internals ------------------------------------------------------------------------------------\n\ninterface Level {\n  price: number;\n  size: number;\n  cum: number;\n  notional: number;\n  mine: number;\n}\n\ninterface SlotEls {\n  row: HTMLDivElement | null;\n  bar: HTMLSpanElement | null;\n  flash: HTMLSpanElement | null;\n  price: HTMLSpanElement | null;\n  size: HTMLSpanElement | null;\n  total: HTMLSpanElement | null;\n  mine: HTMLSpanElement | null;\n  mineSr: HTMLSpanElement | null;\n}\n\ninterface SlotCache {\n  empty: boolean;\n  price: string;\n  size: string;\n  total: string;\n  frac: number;\n  mine: string;\n  flashAt: number;\n  flashTint: string;\n  anim: Animation | null;\n}\n\ntype Source = \"pointer\" | \"touch\" | \"focus\" | \"prop\";\n\ninterface Active {\n  side: BookSide;\n  depth: number;\n}\n\nconst SPLIT_MIN_W = 600;\nconst SPLIT_TOTAL_MIN_W = 520;\nconst FLASH_MS = 450;\nconst FLASH_GAP_MS = 90;\nconst BAR_MS = 160;\n// Layout changes glide on a soft spring: no bounce, about 0.45 s.\nconst GLIDE = { type: \"spring\", duration: 0.45, bounce: 0 } as const;\n\nconst SIDES: readonly BookSide[] = [\"ask\", \"bid\"];\nconst EMPTY_LEVELS: Level[] = [];\n\nfunction decimalsOf(n: number): number {\n  if (!Number.isFinite(n) || n <= 0) return 2;\n  for (let d = 0; d <= 8; d++) if (Math.abs(Math.round(n * 10 ** d) - n * 10 ** d) < 1e-6) return d;\n  return 8;\n}\n\n// Bids round down into their bucket and asks round up, so a group never crosses the spread.\nfunction bucketOf(price: number, tick: number, side: BookSide): number {\n  if (!(tick > 0)) return price;\n  const k = price / tick;\n  const i = side === \"bid\" ? Math.floor(k + 1e-7) : Math.ceil(k - 1e-7);\n  return +(i * tick).toFixed(10);\n}\n\n// Groups one side and keeps the best `n` buckets with running totals. O(levels log levels).\nfunction aggregate(map: Map<number, number>, side: BookSide, tick: number, n: number, mine: Map<number, number> | undefined): Level[] {\n  const prices: number[] = [];\n  for (const [p, s] of map) if (s > 0) prices.push(p);\n  prices.sort(side === \"bid\" ? (a, b) => b - a : (a, b) => a - b);\n  const out: Level[] = [];\n  let cum = 0;\n  let notional = 0;\n  let cur: Level | null = null;\n  for (const p of prices) {\n    const b = bucketOf(p, tick, side);\n    if (!cur || cur.price !== b) {\n      if (out.length === n) break;\n      cur = { price: b, size: 0, cum: 0, notional: 0, mine: 0 };\n      out.push(cur);\n    }\n    const s = map.get(p) ?? 0;\n    cur.size += s;\n    cum += s;\n    notional += s * p;\n    cur.cum = cum;\n    cur.notional = notional;\n  }\n  if (mine?.size) for (const lv of out) lv.mine = mine.get(lv.price) ?? 0;\n  return out;\n}\n\nfunction applyLevels(map: Map<number, number>, levels: BookLevel[] | undefined) {\n  if (!levels) return;\n  for (const l of levels) {\n    if (!Number.isFinite(l.price)) continue;\n    if (!(l.size > 0)) map.delete(l.price);\n    else map.set(l.price, l.size);\n  }\n}\n\nfunction emptySlot(): SlotEls {\n  return { row: null, bar: null, flash: null, price: null, size: null, total: null, mine: null, mineSr: null };\n}\n\nfunction emptyCache(): SlotCache {\n  return { empty: false, price: \"\\u0000\", size: \"\\u0000\", total: \"\\u0000\", frac: -1, mine: \"\\u0000\", flashAt: 0, flashTint: \"\", anim: null };\n}\n\n// Bar glide and flash state for the current motion preference.\nfunction applyMotion(slots: Record<BookSide, SlotEls[]>, cache: Record<BookSide, SlotCache[]>, meter: HTMLElement | null, reduce: boolean) {\n  const bar = reduce ? \"none\" : `transform ${BAR_MS}ms ${easeCss.out}`;\n  for (const side of SIDES) {\n    for (const el of slots[side]) if (el?.bar) el.bar.style.transition = bar;\n    if (reduce) for (const k of cache[side]) k?.anim?.cancel();\n  }\n  if (meter) meter.style.transition = reduce ? \"none\" : `transform 200ms ${easeCss.out}`;\n}\n\n// --- Row --------------------------------------------------------------------------------------------\n// Rows are fixed slots keyed by side and depth. React renders them once per layout; the flush writes\n// prices, sizes and bars straight into their cells, so a tick never reconciles the table.\n\ntype Register = (side: BookSide, depth: number, els: Partial<SlotEls>) => void;\n\nconst SlotRow = memo(function SlotRow({\n  side,\n  depth,\n  order,\n  anchor,\n  showTotal,\n  rowHeight,\n  columns,\n  barColor,\n  priceColor,\n  register,\n}: {\n  side: BookSide;\n  depth: number;\n  /** Cell order left to right. */\n  order: readonly (\"price\" | \"size\" | \"total\")[];\n  /** Edge the depth bar grows from. */\n  anchor: \"left\" | \"right\";\n  showTotal: boolean;\n  rowHeight: number;\n  columns: string;\n  barColor: string;\n  priceColor: string;\n  register: Register;\n}) {\n  const cell = (k: \"price\" | \"size\" | \"total\") => {\n    if (k === \"total\" && !showTotal) return null;\n    const align = k === \"price\" ? (anchor === \"right\" && order[0] !== \"price\" ? \"justify-end\" : \"justify-start\") : \"justify-end\";\n    if (k === \"price\") {\n      return (\n        <span key={k} role=\"gridcell\" className={cn(\"relative flex min-w-0 items-center gap-1.5\", align)}>\n          {anchor === \"right\" && order[0] !== \"price\" ? null : (\n            <span\n              ref={(el) => register(side, depth, { mine: el })}\n              aria-hidden=\"true\"\n              className=\"inline-block size-[5px] shrink-0 rotate-45 rounded-[1px] bg-[color:var(--bjork-accent)] opacity-0\"\n            />\n          )}\n          <span className=\"sr-only\">{side === \"ask\" ? \"Ask \" : \"Bid \"}</span>\n          <span ref={(el) => register(side, depth, { price: el })} style={{ color: priceColor }} />\n          <span ref={(el) => register(side, depth, { mineSr: el })} className=\"sr-only\" />\n          {anchor === \"right\" && order[0] !== \"price\" ? (\n            <span\n              ref={(el) => register(side, depth, { mine: el })}\n              aria-hidden=\"true\"\n              className=\"inline-block size-[5px] shrink-0 rotate-45 rounded-[1px] bg-[color:var(--bjork-accent)] opacity-0\"\n            />\n          ) : null}\n        </span>\n      );\n    }\n    return (\n      <span\n        key={k}\n        role=\"gridcell\"\n        ref={(el) => register(side, depth, k === \"size\" ? { size: el } : { total: el })}\n        className={cn(\n          \"relative flex items-center\",\n          align,\n          k === \"total\" ? \"text-[color:var(--bjork-text-muted)]\" : \"text-[color:var(--bjork-text)]\",\n        )}\n      />\n    );\n  };\n  return (\n    <div\n      ref={(el) => register(side, depth, { row: el })}\n      role=\"row\"\n      tabIndex={-1}\n      data-side={side}\n      data-depth={depth}\n      data-empty=\"true\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative grid cursor-pointer items-center gap-3 px-2 font-mono text-[12px] leading-none tabular-nums outline-none\",\n        \"data-[empty=true]:cursor-default\",\n        \"data-[range=true]:bg-[color:color-mix(in_srgb,var(--bjork-text)_4%,transparent)]\",\n        \"data-[active=true]:bg-[color:color-mix(in_srgb,var(--bjork-text)_8%,transparent)]\",\n        \"focus-visible:shadow-[inset_0_0_0_1.5px_var(--bjork-accent)]\",\n      )}\n      style={{ height: rowHeight, gridTemplateColumns: columns }}\n    >\n      <span\n        ref={(el) => register(side, depth, { bar: el })}\n        aria-hidden=\"true\"\n        className={cn(\"pointer-events-none absolute inset-y-px w-full\", anchor === \"right\" ? \"right-0 origin-right\" : \"left-0 origin-left\")}\n        style={{ background: barColor, transform: \"scaleX(0)\" }}\n      />\n      <span ref={(el) => register(side, depth, { flash: el })} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 opacity-0\" />\n      {order.map(cell)}\n    </div>\n  );\n});\n\n// --- Grouping control ---------------------------------------------------------------------------------\n\nfunction GroupingControl({ ticks, value, onChange }: { ticks: number[]; value: number; onChange: (t: number) => void }) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const i = ticks.indexOf(value);\n    let n = -1;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") n = Math.min(ticks.length - 1, i + 1);\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") n = Math.max(0, i - 1);\n    else if (e.key === \"Home\") n = 0;\n    else if (e.key === \"End\") n = ticks.length - 1;\n    if (n < 0) return;\n    e.preventDefault();\n    onChange(ticks[n]);\n    refs.current[n]?.focus();\n  };\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label=\"Price grouping\"\n      onKeyDown={onKeyDown}\n      className=\"inline-flex shrink-0 items-center gap-px rounded-[8px] border border-[color:var(--bjork-border)] p-[2px]\"\n    >\n      {ticks.map((t, i) => {\n        const on = t === value;\n        return (\n          <button\n            key={t}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={on}\n            tabIndex={on ? 0 : -1}\n            onClick={() => onChange(t)}\n            className={cn(\n              \"h-[22px] min-w-[34px] rounded-[6px] px-1.5 font-mono text-[10px] leading-none tabular-nums transition-colors duration-150 ease-out\",\n              \"outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)]\",\n              on\n                ? \"bg-[color:var(--bjork-surface-hover)] text-[color:var(--bjork-text)] shadow-[inset_0_0_0_1px_var(--bjork-border-strong)]\"\n                : \"text-[color:var(--bjork-text-soft)] hover:text-[color:var(--bjork-text-medium)]\",\n            )}\n          >\n            {formatFixed(t, decimalsOf(t))}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n// --- Component ----------------------------------------------------------------------------------------\n\nexport const OrderBook = forwardRef<OrderBookHandle, OrderBookProps>(function OrderBook(\n  {\n    bids,\n    asks,\n    lastPrice,\n    rows = 12,\n    layout = \"auto\",\n    tickSizes,\n    tickSize: tickProp,\n    defaultTickSize,\n    onTickSizeChange,\n    precision,\n    sizePrecision = 0,\n    formatPrice: formatPriceProp,\n    formatSize: formatSizeProp,\n    formatNotional = (v: number) => formatCompact(v, 2),\n    priceLabel = \"Price\",\n    sizeLabel = \"Size\",\n    myOrders,\n    showImbalance = true,\n    flash = true,\n    onPriceSelect,\n    activeLevel,\n    announce = \"focus\",\n    announceInterval = 8000,\n    rowHeight = 22,\n    ariaLabel = \"Order book\",\n    tone: toneProp,\n    className,\n  },\n  ref,\n) {\n  const { tone, pal, reduce, vars } = useChartTheme(toneProp);\n  const gid = useId();\n  const surface = BJORK_SURFACE[tone];\n  // Shared-id glide for the sides, headers and spread. Reduced motion drops the id, so the move snaps.\n  const glide = (id: string) => (reduce ? {} : { layoutId: id, layout: \"position\" as const, transition: GLIDE });\n  const rootRef = useRef<HTMLDivElement>(null);\n  const tipRef = useRef<TooltipHandle>(null);\n  const announcer = useRef<AnnouncerHandle>(null);\n  const { width } = useElementSize(rootRef);\n\n  const [tickState, setTickState] = useState(defaultTickSize ?? tickSizes?.[0] ?? 0);\n  const tick = tickProp ?? tickState;\n  const setTick = (t: number) => {\n    if (tickProp === undefined) setTickState(t);\n    onTickSizeChange?.(t);\n    announcer.current?.say(`Grouped by ${formatFixed(t, decimalsOf(t))}`);\n  };\n\n  const n = Math.max(1, Math.floor(rows));\n  const split = layout === \"side-by-side\" || (layout === \"auto\" && width >= SPLIT_MIN_W);\n  const showTotal = !split || width === 0 || width >= SPLIT_TOTAL_MIN_W;\n  const narrow = width > 0 && width < 440;\n\n  const pricePlaces = precision ?? decimalsOf(tick > 0 ? tick : 0.01);\n  const formatPrice = useMemo(() => formatPriceProp ?? ((p: number) => formatFixed(p, pricePlaces)), [formatPriceProp, pricePlaces]);\n  const formatSize = useMemo(() => formatSizeProp ?? ((s: number) => formatFixed(s, sizePrecision)), [formatSizeProp, sizePrecision]);\n\n  // The viewer's orders, bucketed with the book's grouping.\n  const mine = useMemo(() => {\n    const m: Record<BookSide, Map<number, number>> = { bid: new Map(), ask: new Map() };\n    for (const o of myOrders ?? []) {\n      if (!(o.size > 0)) continue;\n      const b = bucketOf(o.price, tick, o.side);\n      m[o.side].set(b, (m[o.side].get(b) ?? 0) + o.size);\n    }\n    return m;\n  }, [myOrders, tick]);\n\n  // Everything the flush reads, current without re-subscribing anything.\n  const cfg = useRef({ tick, n, mine, reduce, flash, pal, pricePlaces, formatPrice, formatSize, formatNotional, announce, announceInterval, onPriceSelect });\n  useLayoutEffect(() => {\n    cfg.current = { tick, n, mine, reduce, flash, pal, pricePlaces, formatPrice, formatSize, formatNotional, announce, announceInterval, onPriceSelect };\n  });\n\n  const store = useRef({\n    bids: new Map<number, number>(),\n    asks: new Map<number, number>(),\n    last: null as number | null,\n    lastDir: 0 as -1 | 0 | 1,\n    shown: null as number | null,\n    shownDir: 0 as -1 | 0 | 1,\n  });\n  const view = useRef<Record<BookSide, Level[]>>({ bid: EMPTY_LEVELS, ask: EMPTY_LEVELS });\n  const mid = useRef(0);\n  const prevSizes = useRef<Record<BookSide, Map<number, number>>>({ bid: new Map(), ask: new Map() });\n  const prevBest = useRef<Record<BookSide, number | null>>({ bid: null, ask: null });\n  const resetFlash = useRef(true);\n  const slots = useRef<Record<BookSide, SlotEls[]>>({ bid: [], ask: [] });\n  const cache = useRef<Record<BookSide, SlotCache[]>>({ bid: [], ask: [] });\n  const raf = useRef(0);\n  const st = useRef({\n    active: null as Active | null,\n    source: null as Source | null,\n    pointerX: 0,\n    tabStop: null as Active | null,\n    focusWithin: false,\n    lastSay: 0,\n  });\n\n  // Fixed elements the flush writes.\n  const lastRef = useRef<HTMLSpanElement>(null);\n  const arrowRef = useRef<HTMLSpanElement>(null);\n  const dirSrRef = useRef<HTMLSpanElement>(null);\n  const midRef = useRef<HTMLSpanElement>(null);\n  const spreadRef = useRef<HTMLSpanElement>(null);\n  const imbBarRef = useRef<HTMLSpanElement>(null);\n  const imbBidRef = useRef<HTMLSpanElement>(null);\n  const imbAskRef = useRef<HTMLSpanElement>(null);\n  const imbMeterRef = useRef<HTMLDivElement>(null);\n  const spreadCache = useRef({ last: \"\", arrow: \"\", color: \"\", mid: \"\", spread: \"\", imb: -1 });\n\n  const register = useCallback<Register>((side, depth, els) => {\n    const arr = slots.current[side];\n    const slot = arr[depth] ?? (arr[depth] = emptySlot());\n    Object.assign(slot, els);\n  }, []);\n\n  // --- Active level, tooltip and range highlight ---------------------------------------------------\n\n  const levelInfo = (side: BookSide, depth: number): OrderBookLevelInfo | null => {\n    const lv = view.current[side][depth];\n    if (!lv) return null;\n    const m = mid.current;\n    const d = lv.price - m;\n    return {\n      side,\n      price: lv.price,\n      size: lv.size,\n      total: lv.cum,\n      notional: lv.notional,\n      depth,\n      fromMid: d,\n      fromMidBp: m ? (d / m) * 1e4 : 0,\n      mine: lv.mine,\n    };\n  };\n\n  const tooltipFor = (a: Active): TooltipContent | null => {\n    const c = cfg.current;\n    const info = levelInfo(a.side, a.depth);\n    if (!info) return null;\n    const p = c.pal;\n    const rows: TooltipContent[\"rows\"] = [\n      { key: \"s\", label: \"Level size\", value: c.formatSize(info.size), color: a.side === \"ask\" ? p.accent : p.text },\n      { key: \"t\", label: `Total, ${a.depth + 1} level${a.depth ? \"s\" : \"\"}`, value: c.formatSize(info.total) },\n      { key: \"n\", label: \"Notional\", value: c.formatNotional(info.notional) },\n      {\n        key: \"d\",\n        label: \"From mid\",\n        value: `${formatSigned(info.fromMid, c.formatPrice)} · ${formatSigned(info.fromMidBp, (v) => formatNumber(v, 1))} bp`,\n        strong: false,\n      },\n    ];\n    if (info.mine > 0) rows.push({ key: \"m\", label: \"Your orders\", value: c.formatSize(info.mine), color: p.accent, dashed: true });\n    return {\n      key: `${a.side}${a.depth}|${info.price}|${info.size}|${info.total}|${info.mine}|${p.text}|${Math.round(info.fromMidBp * 10)}`,\n      title: `${a.side === \"ask\" ? \"Ask\" : \"Bid\"} · ${c.formatPrice(info.price)}`,\n      rows,\n    };\n  };\n\n  const placeTip = () => {\n    const tip = tipRef.current;\n    const a = st.current.active;\n    const root = rootRef.current;\n    if (!tip?.el || !a || !root) return;\n    const row = slots.current[a.side][a.depth]?.row;\n    if (!row) return;\n    const rr = root.getBoundingClientRect();\n    const r = row.getBoundingClientRect();\n    const x = st.current.source === \"pointer\" || st.current.source === \"touch\" ? st.current.pointerX : r.left - rr.left + r.width * 0.55;\n    const pos = placeTooltip(clamp(x, 0, rr.width), r.top - rr.top + 2, tip.size.w, tip.size.h, rr.width, rr.height, 10);\n    // Never over the column headers: drop below the level instead.\n    const grid = row.closest('[role=\"grid\"]');\n    const headerBottom = grid ? grid.getBoundingClientRect().top - rr.top + 24 : 0;\n    if (pos.y < headerBottom) pos.y = Math.round(Math.min(rr.height - tip.size.h, r.bottom - rr.top + 8));\n    tip.el.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;\n  };\n\n  const paintActive = () => {\n    const a = st.current.active;\n    for (const side of SIDES) {\n      const arr = slots.current[side];\n      for (let i = 0; i < arr.length; i++) {\n        const row = arr[i]?.row;\n        if (!row) continue;\n        const on = !!a && a.side === side && a.depth === i;\n        const inRange = !!a && a.side === side && i < a.depth;\n        if ((row.dataset.active === \"true\") !== on) row.setAttribute(\"data-active\", on ? \"true\" : \"false\");\n        if ((row.dataset.range === \"true\") !== inRange) row.setAttribute(\"data-range\", inRange ? \"true\" : \"false\");\n      }\n    }\n  };\n\n  const setActive = (a: Active | null, source: Source | null) => {\n    const s = st.current;\n    if (a && !view.current[a.side][a.depth]) a = null;\n    const same = (!a && !s.active) || (a && s.active && a.side === s.active.side && a.depth === s.active.depth);\n    s.active = a;\n    s.source = a ? source : null;\n    if (!same) paintActive();\n    tipRef.current?.set(a ? tooltipFor(a) : null);\n    placeTip();\n  };\n\n  const setTabStop = (a: Active | null) => {\n    const s = st.current;\n    const prev = s.tabStop;\n    if (prev && a && prev.side === a.side && prev.depth === a.depth) return;\n    if (prev) slots.current[prev.side][prev.depth]?.row?.setAttribute(\"tabindex\", \"-1\");\n    s.tabStop = a;\n    if (a) slots.current[a.side][a.depth]?.row?.setAttribute(\"tabindex\", \"0\");\n  };\n\n  // --- Flush: one DOM pass per frame, however many messages arrived ---------------------------------\n\n  const flush = () => {\n    raf.current = 0;\n    const c = cfg.current;\n    const S = store.current;\n    const p = c.pal;\n    const now = performance.now();\n    const levels: Record<BookSide, Level[]> = {\n      bid: aggregate(S.bids, \"bid\", c.tick, c.n, c.mine.bid),\n      ask: aggregate(S.asks, \"ask\", c.tick, c.n, c.mine.ask),\n    };\n    view.current = levels;\n    const bb = levels.bid[0]?.price;\n    const ba = levels.ask[0]?.price;\n    // Mid and spread from the raw touch, so grouping never widens the quoted spread.\n    let rawBid = -Infinity;\n    let rawAsk = Infinity;\n    for (const [px, sz] of S.bids) if (sz > 0 && px > rawBid) rawBid = px;\n    for (const [px, sz] of S.asks) if (sz > 0 && px < rawAsk) rawAsk = px;\n    const hasTouch = Number.isFinite(rawBid) && Number.isFinite(rawAsk);\n    const m = hasTouch ? (rawBid + rawAsk) / 2 : (bb ?? ba ?? 0);\n    mid.current = m;\n    const maxCum = Math.max(levels.bid[levels.bid.length - 1]?.cum ?? 0, levels.ask[levels.ask.length - 1]?.cum ?? 0, 1e-12);\n    const doFlash = c.flash && !c.reduce && !resetFlash.current;\n\n    for (const side of SIDES) {\n      const L = levels[side];\n      const els = slots.current[side];\n      const ch = cache.current[side];\n      const prev = prevSizes.current[side];\n      const best = prevBest.current[side];\n      const sideTint = side === \"ask\" ? withAlpha(p.accent, 0.24) : withAlpha(p.text, 0.11);\n      const dropTint = withAlpha(p.text, 0.05);\n      for (let i = 0; i < c.n; i++) {\n        const el = els[i];\n        if (!el?.row) continue;\n        const k = ch[i] ?? (ch[i] = emptyCache());\n        const lv = L[i];\n        if (!lv) {\n          if (!k.empty) {\n            k.empty = true;\n            if (el.price) el.price.textContent = \"\";\n            if (el.size) el.size.textContent = \"\";\n            if (el.total) el.total.textContent = \"\";\n            if (el.mineSr) el.mineSr.textContent = \"\";\n            if (el.mine) el.mine.style.opacity = \"0\";\n            if (el.bar) el.bar.style.transform = \"scaleX(0)\";\n            el.row.dataset.empty = \"true\";\n            el.row.setAttribute(\"aria-hidden\", \"true\");\n            k.price = k.size = k.total = k.mine = \"\\u0000\";\n            k.frac = 0;\n          }\n          continue;\n        }\n        if (k.empty || el.row.dataset.empty !== \"false\") {\n          k.empty = false;\n          el.row.dataset.empty = \"false\";\n          el.row.removeAttribute(\"aria-hidden\");\n        }\n        const pt = c.formatPrice(lv.price);\n        if (k.price !== pt && el.price) el.price.textContent = k.price = pt;\n        const sz = c.formatSize(lv.size);\n        if (k.size !== sz && el.size) el.size.textContent = k.size = sz;\n        const tt = c.formatSize(lv.cum);\n        if (k.total !== tt && el.total) el.total.textContent = k.total = tt;\n        const mn = lv.mine > 0 ? `, your orders ${c.formatSize(lv.mine)}` : \"\";\n        if (k.mine !== mn) {\n          k.mine = mn;\n          if (el.mineSr) el.mineSr.textContent = mn;\n          if (el.mine) el.mine.style.opacity = mn ? \"1\" : \"0\";\n        }\n        const frac = clamp(lv.cum / maxCum, 0, 1);\n        if (Math.abs(frac - k.frac) > 0.002 && el.bar) {\n          k.frac = frac;\n          el.bar.style.transform = `scaleX(${frac.toFixed(4)})`;\n        }\n        // Flash a level whose size changed, or a new level that improved the touch. A level that\n        // only moved slot as the book shifted keeps its size and stays quiet.\n        if (doFlash && el.flash && now - k.flashAt > FLASH_GAP_MS) {\n          const before = prev.get(lv.price);\n          const isNewTouch = before === undefined && best !== null && (side === \"bid\" ? lv.price > best : lv.price < best);\n          const changed = before !== undefined ? Math.abs(before - lv.size) > 1e-12 : isNewTouch;\n          if (changed) {\n            const tint = before === undefined || lv.size > before ? sideTint : dropTint;\n            if (k.flashTint !== tint) el.flash.style.background = k.flashTint = tint;\n            k.anim?.cancel();\n            k.anim = el.flash.animate([{ opacity: 1 }, { opacity: 0 }], { duration: FLASH_MS, easing: easeCss.out });\n            k.flashAt = now;\n          }\n        }\n      }\n      const next = new Map<number, number>();\n      for (const lv of L) next.set(lv.price, lv.size);\n      prevSizes.current[side] = next;\n      prevBest.current[side] = L[0]?.price ?? null;\n    }\n    resetFlash.current = false;\n\n    // Keep the tab stop on a real level.\n    const s = st.current;\n    if (!s.tabStop || !levels[s.tabStop.side][s.tabStop.depth]) {\n      const first: Active | null = levels.ask.length ? { side: \"ask\", depth: 0 } : levels.bid.length ? { side: \"bid\", depth: 0 } : null;\n      const keep = s.tabStop && levels[s.tabStop.side].length ? { side: s.tabStop.side, depth: levels[s.tabStop.side].length - 1 } : first;\n      setTabStop(keep);\n    }\n\n    // Centre row: last trade when there is one, else mid; arrows carry the direction as well as colour.\n    const shown = S.last ?? (hasTouch ? m : null);\n    if (shown !== null) {\n      if (S.last === null && S.shown !== null && shown !== S.shown) S.shownDir = shown > S.shown ? 1 : -1;\n      if (S.last !== null) S.shownDir = S.lastDir;\n      S.shown = shown;\n    }\n    const sc = spreadCache.current;\n    const lastText = shown === null ? \"–\" : c.formatPrice(shown);\n    if (sc.last !== lastText && lastRef.current) lastRef.current.textContent = sc.last = lastText;\n    const dir = S.shownDir;\n    const arrow = dir > 0 ? \"↑\" : dir < 0 ? \"↓\" : \"\";\n    const color = dir > 0 ? p.success : dir < 0 ? p.error : p.text;\n    if (sc.arrow !== arrow) {\n      sc.arrow = arrow;\n      if (arrowRef.current) arrowRef.current.textContent = arrow;\n      if (dirSrRef.current) dirSrRef.current.textContent = dir > 0 ? \", up\" : dir < 0 ? \", down\" : \"\";\n    }\n    if (sc.color !== color) {\n      sc.color = color;\n      if (lastRef.current) lastRef.current.style.color = color;\n      if (arrowRef.current) arrowRef.current.style.color = color;\n    }\n    const midText = hasTouch && S.last !== null ? `Mid ${c.formatPrice(m)}` : hasTouch ? \"Mid\" : \"\";\n    if (sc.mid !== midText && midRef.current) midRef.current.textContent = sc.mid = midText;\n    const spread = hasTouch ? rawAsk - rawBid : NaN;\n    const spreadText = hasTouch\n      ? `Spread ${formatFixed(spread, Math.max(c.pricePlaces, decimalsOf(spread)))} · ${formatNumber(m ? (spread / m) * 1e4 : 0, 1)} bp`\n      : \"No spread\";\n    if (sc.spread !== spreadText && spreadRef.current) spreadRef.current.textContent = sc.spread = spreadText;\n\n    // Imbalance across the visible levels.\n    const bidVol = levels.bid[levels.bid.length - 1]?.cum ?? 0;\n    const askVol = levels.ask[levels.ask.length - 1]?.cum ?? 0;\n    const f = bidVol + askVol > 0 ? bidVol / (bidVol + askVol) : 0.5;\n    if (Math.abs(f - sc.imb) > 0.001) {\n      sc.imb = f;\n      if (imbBarRef.current) imbBarRef.current.style.transform = `translateX(${(f * 100).toFixed(2)}%)`;\n      const pct = Math.round(f * 100);\n      if (imbBidRef.current) imbBidRef.current.textContent = `${pct}%`;\n      if (imbAskRef.current) imbAskRef.current.textContent = `${100 - pct}%`;\n      const meter = imbMeterRef.current;\n      if (meter && meter.getAttribute(\"aria-valuenow\") !== String(pct)) {\n        meter.setAttribute(\"aria-valuenow\", String(pct));\n        meter.setAttribute(\"aria-valuetext\", `Bids ${pct} percent, asks ${100 - pct} percent of visible size`);\n      }\n    }\n\n    // Live tooltip.\n    if (s.active) {\n      if (!levels[s.active.side][s.active.depth]) setActive(null, null);\n      else tipRef.current?.set(tooltipFor(s.active));\n    }\n\n    // Spoken summary, throttled; never every tick.\n    if (c.announce !== \"off\" && (c.announce === \"always\" || s.focusWithin) && shown !== null && now - s.lastSay >= c.announceInterval) {\n      s.lastSay = now;\n      const pct = Math.round(f * 100);\n      announcer.current?.say(\n        `${S.last !== null ? \"Last\" : \"Mid\"} ${c.formatPrice(shown)}${dir > 0 ? \", up\" : dir < 0 ? \", down\" : \"\"}. ${\n          hasTouch ? `Spread ${c.formatPrice(spread)}. ` : \"\"\n        }Bids ${pct} percent of visible size.`,\n      );\n    }\n  };\n  const flushRef = useRef(flush);\n  useLayoutEffect(() => {\n    flushRef.current = flush;\n  });\n\n  const schedule = useCallback(() => {\n    if (raf.current || typeof window === \"undefined\") return;\n    raf.current = requestAnimationFrame(() => flushRef.current());\n  }, []);\n  useEffect(\n    () => () => {\n      if (raf.current) cancelAnimationFrame(raf.current);\n      raf.current = 0;\n    },\n    [],\n  );\n\n  const setLast = useCallback((price: number | undefined) => {\n    if (price === undefined || !Number.isFinite(price)) return;\n    const S = store.current;\n    if (S.last !== null && price !== S.last) S.lastDir = price > S.last ? 1 : -1;\n    S.last = price;\n  }, []);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      applySnapshot: (snap) => {\n        const S = store.current;\n        S.bids.clear();\n        S.asks.clear();\n        applyLevels(S.bids, snap.bids);\n        applyLevels(S.asks, snap.asks);\n        setLast(snap.last);\n        schedule();\n      },\n      applyDelta: (d) => {\n        const S = store.current;\n        applyLevels(S.bids, d.bids);\n        applyLevels(S.asks, d.asks);\n        setLast(d.last);\n        schedule();\n      },\n      apply: (msg) => {\n        const S = store.current;\n        if (msg.type === \"snapshot\") {\n          S.bids.clear();\n          S.asks.clear();\n        }\n        applyLevels(S.bids, msg.bids);\n        applyLevels(S.asks, msg.asks);\n        setLast(msg.last);\n        schedule();\n      },\n      setLastPrice: (price) => {\n        setLast(price);\n        schedule();\n      },\n      getSnapshot: () => {\n        const S = store.current;\n        const toLevels = (map: Map<number, number>, desc: boolean) =>\n          [...map].filter(([, s]) => s > 0).sort((a, b) => (desc ? b[0] - a[0] : a[0] - b[0])).map(([price, size]) => ({ price, size }));\n        return { bids: toLevels(S.bids, true), asks: toLevels(S.asks, false), last: S.last ?? undefined };\n      },\n    }),\n    [schedule, setLast],\n  );\n\n  // Props as a snapshot.\n  useEffect(() => {\n    if (!bids && !asks) return;\n    const S = store.current;\n    S.bids.clear();\n    S.asks.clear();\n    applyLevels(S.bids, bids);\n    applyLevels(S.asks, asks);\n    schedule();\n  }, [bids, asks, schedule]);\n\n  useEffect(() => {\n    setLast(lastPrice);\n    schedule();\n  }, [lastPrice, setLast, schedule]);\n\n  // Grouping, row count or layout changed: the slots are new, so rewrite them all without flashing.\n  useLayoutEffect(() => {\n    cache.current = { bid: [], ask: [] };\n    spreadCache.current = { last: \"\", arrow: \"\", color: \"\", mid: \"\", spread: \"\", imb: -1 };\n    resetFlash.current = true;\n    const s = st.current;\n    const stop = s.tabStop;\n    s.tabStop = null;\n    if (stop) setTabStop(stop);\n    flushRef.current();\n    paintActive();\n    if (s.active) setActive(s.active, s.source);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [tick, n, split, showTotal, mine, pal, formatPrice, formatSize]);\n\n  // Motion is set here, after hydration, so server and client markup agree. Reduced motion\n  // drops the bar glide and stops any flash in flight.\n  useLayoutEffect(() => {\n    applyMotion(slots.current, cache.current, imbBarRef.current, reduce);\n  }, [reduce, n, split, showTotal, showImbalance]);\n\n  // Posed level.\n  useEffect(() => {\n    if (activeLevel === undefined) return;\n    const id = requestAnimationFrame(() => setActive(activeLevel, activeLevel ? \"prop\" : null));\n    return () => cancelAnimationFrame(id);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [activeLevel?.side, activeLevel?.depth]);\n\n  // A touch-pinned tooltip stays until the next tap outside the book.\n  useEffect(() => {\n    const onDown = (e: globalThis.PointerEvent) => {\n      if (st.current.source !== \"touch\") return;\n      if (rootRef.current?.contains(e.target as Node)) return;\n      setActive(null, null);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // --- Input -----------------------------------------------------------------------------------------\n\n  const rowOf = (target: EventTarget | null): Active | null => {\n    const row = (target as HTMLElement | null)?.closest?.(\"[data-depth]\") as HTMLElement | null;\n    if (!row || row.dataset.empty === \"true\" || !rootRef.current?.contains(row)) return null;\n    return { side: row.dataset.side as BookSide, depth: Number(row.dataset.depth) };\n  };\n\n  const localX = (clientX: number) => clientX - (rootRef.current?.getBoundingClientRect().left ?? 0);\n\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType === \"touch\") return;\n    const a = rowOf(e.target);\n    const s = st.current;\n    if (!a) {\n      if (s.source === \"pointer\") setActive(null, null);\n      return;\n    }\n    s.pointerX = localX(e.clientX);\n    setActive(a, \"pointer\");\n  };\n\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType !== \"touch\") return;\n    const a = rowOf(e.target);\n    if (!a) return;\n    st.current.pointerX = localX(e.clientX);\n    setActive(a, \"touch\");\n  };\n\n  const onPointerLeave = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType === \"touch\") return;\n    const s = st.current;\n    if (s.source !== \"pointer\") return;\n    const focused = rowOf(document.activeElement);\n    setActive(focused, focused ? \"focus\" : null);\n  };\n\n  const select = (a: Active) => {\n    const info = levelInfo(a.side, a.depth);\n    if (!info) return;\n    cfg.current.onPriceSelect?.(info);\n    announcer.current?.say(`Selected ${a.side} ${cfg.current.formatPrice(info.price)}`);\n  };\n\n  const onClick = (e: MouseEvent<HTMLDivElement>) => {\n    const a = rowOf(e.target);\n    if (a) select(a);\n  };\n\n  // Mouse clicks select without stealing focus, so a keyboard position survives a stray click.\n  const onMouseDown = (e: MouseEvent<HTMLDivElement>) => {\n    if (rowOf(e.target)) e.preventDefault();\n  };\n\n  const focusLevel = (a: Active) => {\n    const row = slots.current[a.side][a.depth]?.row;\n    if (!row) return;\n    setTabStop(a);\n    row.focus();\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const a = rowOf(e.target);\n    if (!a) return;\n    const L = view.current;\n    const k = e.key;\n    if (k === \"Enter\" || k === \" \") {\n      e.preventDefault();\n      select(a);\n      return;\n    }\n    if (k === \"Escape\") {\n      if (st.current.active) {\n        e.preventDefault();\n        setActive(null, null);\n      }\n      return;\n    }\n    const step = k === \"PageUp\" || k === \"PageDown\" ? 5 : 1;\n    let next: Active | null = null;\n    if (split) {\n      if (k === \"ArrowUp\" || k === \"PageUp\") next = { side: a.side, depth: Math.max(0, a.depth - step) };\n      else if (k === \"ArrowDown\" || k === \"PageDown\") next = { side: a.side, depth: Math.min(L[a.side].length - 1, a.depth + step) };\n      else if (k === \"ArrowLeft\" || k === \"ArrowRight\") {\n        const side: BookSide = k === \"ArrowLeft\" ? \"bid\" : \"ask\";\n        if (L[side].length) next = { side, depth: Math.min(a.depth, L[side].length - 1) };\n      } else if (k === \"Home\") next = { side: a.side, depth: 0 };\n      else if (k === \"End\") next = { side: a.side, depth: L[a.side].length - 1 };\n    } else {\n      // Visual order top to bottom: deepest ask … best ask, best bid … deepest bid.\n      const list: Active[] = [];\n      for (let i = L.ask.length - 1; i >= 0; i--) list.push({ side: \"ask\", depth: i });\n      for (let i = 0; i < L.bid.length; i++) list.push({ side: \"bid\", depth: i });\n      const at = list.findIndex((x) => x.side === a.side && x.depth === a.depth);\n      if (k === \"ArrowUp\" || k === \"PageUp\") next = list[Math.max(0, at - step)];\n      else if (k === \"ArrowDown\" || k === \"PageDown\") next = list[Math.min(list.length - 1, at + step)];\n      // Home and End jump to the touch and the deepest level on the current side.\n      else if (k === \"Home\") next = { side: a.side, depth: 0 };\n      else if (k === \"End\") next = { side: a.side, depth: L[a.side].length - 1 };\n    }\n    if (!next) return;\n    e.preventDefault();\n    if (next.depth >= 0) focusLevel(next);\n  };\n\n  const onFocus = (e: FocusEvent<HTMLDivElement>) => {\n    st.current.focusWithin = true;\n    const a = rowOf(e.target);\n    if (!a) return;\n    setTabStop(a);\n    setActive(a, \"focus\");\n  };\n\n  const onBlur = (e: FocusEvent<HTMLDivElement>) => {\n    if (rootRef.current?.contains(e.relatedTarget as Node)) return;\n    st.current.focusWithin = false;\n    if (st.current.source === \"focus\") setActive(null, null);\n  };\n\n  // --- Layout ------------------------------------------------------------------------------------------\n\n  // Price gets a little more room than size and total, wherever it sits in the row.\n  const colsFor = (order: readonly (\"price\" | \"size\" | \"total\")[]) =>\n    order\n      .filter((k) => k !== \"total\" || showTotal)\n      .map((k) => (k === \"price\" ? \"minmax(0,1.1fr)\" : \"minmax(0,1fr)\"))\n      .join(\" \");\n  const bidBar = withAlpha(pal.text, 0.075);\n  const askBar = withAlpha(pal.accent, 0.13);\n  const bidInk = pal.text;\n  const askInk = pal.accentInk;\n\n  const renderSide = (side: BookSide, order: readonly (\"price\" | \"size\" | \"total\")[], anchor: \"left\" | \"right\", reverse: boolean) => {\n    const out: ReactNode[] = [];\n    for (let j = 0; j < n; j++) {\n      const depth = reverse ? n - 1 - j : j;\n      out.push(\n        <SlotRow\n          key={`${side}${depth}`}\n          side={side}\n          depth={depth}\n          order={order}\n          anchor={anchor}\n          showTotal={showTotal}\n          rowHeight={rowHeight}\n          columns={colsFor(order)}\n          barColor={side === \"ask\" ? askBar : bidBar}\n          priceColor={side === \"ask\" ? askInk : bidInk}\n          register={register}\n        />,\n      );\n    }\n    return out;\n  };\n\n  const headerCell = \"font-mono text-[10px] uppercase leading-none tracking-[0.1em] text-[color:var(--bjork-text-soft)] [text-box:trim-both_cap_alphabetic]\";\n  const header = (order: readonly (\"price\" | \"size\" | \"total\")[], priceRight: boolean) => (\n    <div role=\"row\" className=\"grid h-6 items-center gap-3 px-2\" style={{ gridTemplateColumns: colsFor(order) }}>\n      {order.map((k) =>\n        k === \"total\" && !showTotal ? null : (\n          <span key={k} role=\"columnheader\" className={cn(headerCell, \"flex\", k === \"price\" && !priceRight ? \"justify-start\" : \"justify-end\")}>\n            {k === \"price\" ? priceLabel : k === \"size\" ? sizeLabel : \"Total\"}\n          </span>\n        ),\n      )}\n    </div>\n  );\n\n  const centre = (\n    <div className={cn(\"flex min-w-0 items-center gap-3 px-2\", split ? \"h-9\" : \"h-[38px]\")}>\n      <span className=\"flex min-w-0 items-center gap-1 font-mono text-[15px] leading-none tabular-nums\">\n        <span ref={lastRef} className=\"[text-box:trim-both_cap_alphabetic]\" />\n        <span ref={arrowRef} aria-hidden=\"true\" className=\"w-[1ch] text-[13px]\" />\n        <span ref={dirSrRef} className=\"sr-only\" />\n      </span>\n      <span ref={midRef} className={cn(\"font-mono text-[11px] leading-none tabular-nums text-[color:var(--bjork-text-soft)]\", narrow && \"hidden\")} />\n      <span ref={spreadRef} className=\"ml-auto whitespace-nowrap font-mono text-[11px] leading-none tabular-nums text-[color:var(--bjork-text-muted)]\" />\n    </div>\n  );\n\n  const PRICE_FIRST = [\"price\", \"size\", \"total\"] as const;\n  const PRICE_LAST = [\"total\", \"size\", \"price\"] as const;\n\n  const sideKeys = !narrow && (\n    <div aria-hidden=\"true\" className=\"flex items-center gap-4 font-bjork-alpha text-[11px] font-medium leading-3 text-[color:var(--bjork-text-medium)]\">\n      <span className=\"inline-flex items-center gap-1.5\">\n        <span className=\"inline-block h-[2px] w-2.5 rounded-[1px] bg-[color:var(--bjork-text-medium)]\" />\n        Bids\n      </span>\n      <span className=\"inline-flex items-center gap-1.5\">\n        <span className=\"inline-block h-[2px] w-2.5 rounded-[1px] bg-[color:var(--bjork-accent)]\" />\n        Asks\n      </span>\n    </div>\n  );\n\n  const keyHelp = split\n    ? \"Up and down move through a side, left and right switch between bids and asks, Enter selects a price.\"\n    : \"Up and down move through the levels, Home and End jump to the best and deepest level, Enter selects a price.\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative w-full select-none text-[color:var(--bjork-text)]\", className)}\n      style={vars as CSSProperties}\n      onPointerMove={onPointerMove}\n      onPointerDown={onPointerDown}\n      onPointerLeave={onPointerLeave}\n      onMouseDown={onMouseDown}\n      onClick={onClick}\n      onKeyDown={onKeyDown}\n      onFocus={onFocus}\n      onBlur={onBlur}\n    >\n      <div className=\"mb-1 flex min-h-7 items-center justify-between gap-3\">\n        {sideKeys || <span />}\n        {tickSizes && tickSizes.length > 1 && <GroupingControl ticks={tickSizes} value={tick} onChange={setTick} />}\n      </div>\n\n      <LayoutGroup id={gid}>\n        {split ? (\n          <>\n            <motion.div {...glide(\"spread\")} role=\"presentation\" className=\"my-1 overflow-hidden rounded-[12px] border\" style={{ borderColor: surface.border, boxShadow: surface.shadowSurface }}>\n              {centre}\n            </motion.div>\n            <div className=\"grid grid-cols-2 gap-2\">\n              <motion.div {...glide(\"bid-side\")} role=\"grid\" aria-label={`${ariaLabel}, bids. ${keyHelp}`}>\n                <motion.div {...glide(\"bid-head\")}>{header(PRICE_LAST, true)}</motion.div>\n                <div role=\"rowgroup\">{renderSide(\"bid\", PRICE_LAST, \"right\", false)}</div>\n              </motion.div>\n              <motion.div {...glide(\"ask-side\")} role=\"grid\" aria-label={`${ariaLabel}, asks. ${keyHelp}`}>\n                <motion.div {...glide(\"ask-head\")}>{header(PRICE_FIRST, false)}</motion.div>\n                <div role=\"rowgroup\">{renderSide(\"ask\", PRICE_FIRST, \"left\", false)}</div>\n              </motion.div>\n            </div>\n          </>\n        ) : (\n          <>\n            <motion.div {...glide(\"ask-side\")} role=\"grid\" aria-label={`${ariaLabel}, asks. ${keyHelp}`}>\n              <motion.div {...glide(\"ask-head\")}>{header(PRICE_FIRST, false)}</motion.div>\n              <div role=\"rowgroup\" aria-label=\"Asks\">\n                {renderSide(\"ask\", PRICE_FIRST, \"right\", true)}\n              </div>\n            </motion.div>\n            <motion.div {...glide(\"spread\")} role=\"presentation\" className=\"my-1 overflow-hidden rounded-[12px] border\" style={{ borderColor: surface.border, boxShadow: surface.shadowSurface }}>\n              {centre}\n            </motion.div>\n            <motion.div {...glide(\"bid-side\")} role=\"grid\" aria-label={`${ariaLabel}, bids. ${keyHelp}`}>\n              <div role=\"rowgroup\" aria-label=\"Bids\">\n                {renderSide(\"bid\", PRICE_FIRST, \"right\", false)}\n              </div>\n            </motion.div>\n          </>\n        )}\n      </LayoutGroup>\n\n      {showImbalance && (\n        <div className=\"mt-2 flex items-center gap-2 px-2 font-mono text-[10px] leading-none tabular-nums\">\n          <span className=\"text-[color:var(--bjork-text-medium)]\">\n            B <span ref={imbBidRef} />\n          </span>\n          <div\n            ref={imbMeterRef}\n            role=\"meter\"\n            aria-label=\"Book imbalance\"\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={50}\n            className=\"relative h-1 flex-1 overflow-hidden rounded-full\"\n            style={{ background: withAlpha(pal.text, 0.6) }}\n          >\n            {/* Ask share: an accent panel slides in from the right, a stage-coloured edge marks the split. */}\n            <span\n              ref={imbBarRef}\n              aria-hidden=\"true\"\n              className={cn(\"absolute inset-y-0 left-0 w-full border-l-2 border-[color:var(--bjork-chart-bg)]\")}\n              style={{ background: pal.accent, transform: \"translateX(50%)\" }}\n            />\n          </div>\n          <span className=\"text-[color:var(--bjork-text-medium)]\">\n            <span ref={imbAskRef} /> A\n          </span>\n        </div>\n      )}\n\n      <HoverTooltip ref={tipRef} onMeasure={placeTip} />\n      <ChartAnnouncer ref={announcer} />\n    </div>\n  );\n});\n\n// --- Simulated feed ---------------------------------------------------------------------------------------\n\nexport interface OrderBookFeedOptions {\n  seed?: number;\n  /** Starting mid. Default 101.2. */\n  mid?: number;\n  /** Exchange tick. Default 0.05. */\n  tick?: number;\n  /** Levels kept per side. Default 48. */\n  depth?: number;\n  /** Quiet-market message rate per second. Default 10. */\n  rate?: number;\n  /** Mean seconds between spontaneous bursts. 0 turns them off. Default 9. */\n  burstEvery?: number;\n}\n\nexport interface OrderBookFeed {\n  snapshot: () => OrderBookSnapshot;\n  /** Advances one message and returns it. Deterministic for a seed. */\n  step: (intensity?: number) => OrderBookDelta;\n  /** Streams a snapshot then deltas, like a WebSocket. Returns the unsubscribe. */\n  subscribe: (listener: (message: OrderBookMessage) => void) => () => void;\n  /** A volatile spell: several times the message rate and step size, for `ms`. */\n  burst: (ms?: number) => void;\n  setPaused: (paused: boolean) => void;\n}\n\n// A seeded random-walk book for demos and tests. Prices are kept as integer ticks internally so\n// levels never drift apart through float error. Swap it for your exchange's socket in production.\nexport function createOrderBookFeed(opts: OrderBookFeedOptions = {}): OrderBookFeed {\n  const rnd = mulberry32(opts.seed ?? 7);\n  const tick = opts.tick ?? 0.05;\n  const depth = opts.depth ?? 48;\n  const rate = opts.rate ?? 10;\n  const burstEvery = opts.burstEvery ?? 9;\n  const dec = decimalsOf(tick);\n  const px = (i: number) => +(i * tick).toFixed(dec);\n  let fair = (opts.mid ?? 101.2) / tick;\n  const bids = new Map<number, number>();\n  const asks = new Map<number, number>();\n  let last = Math.round(fair);\n  let burstUntil = 0;\n  let paused = false;\n  const sizeAt = (k: number) => Math.max(1, Math.round((18 + rnd() * 110) * (1 + k * 0.05) * (rnd() < 0.06 ? 4.5 : 1)));\n\n  let bestBid = Math.floor(fair - 0.5);\n  for (let k = 0; k < depth; k++) {\n    bids.set(bestBid - k, sizeAt(k));\n    asks.set(bestBid + 1 + k, sizeAt(k));\n  }\n\n  const toLevels = (m: Map<number, number>, desc: boolean) =>\n    [...m].sort((a, b) => (desc ? b[0] - a[0] : a[0] - b[0])).map(([i, size]) => ({ price: px(i), size }));\n\n  const snapshot = (): OrderBookSnapshot => ({ bids: toLevels(bids, true), asks: toLevels(asks, false), last: px(last) });\n\n  const step = (intensity = 1): OrderBookDelta => {\n    const dB = new Map<number, number>();\n    const dA = new Map<number, number>();\n    const set = (side: BookSide, i: number, size: number) => {\n      const m = side === \"bid\" ? bids : asks;\n      if (size > 0) m.set(i, size);\n      else m.delete(i);\n      (side === \"bid\" ? dB : dA).set(i, Math.max(0, size));\n    };\n    let traded = false;\n\n    // Fair value walks; bursts move it further and faster.\n    fair += gaussian(rnd) * 0.22 * intensity;\n\n    // Aggressive orders take liquidity at the touch and lean fair value their way.\n    if (rnd() < 0.35 * intensity) {\n      const buy = rnd() < 0.5 + clamp((fair - last) * 0.15, -0.3, 0.3);\n      const m = buy ? asks : bids;\n      let best = buy ? Infinity : -Infinity;\n      for (const i of m.keys()) best = buy ? Math.min(best, i) : Math.max(best, i);\n      if (Number.isFinite(best)) {\n        const have = m.get(best) ?? 0;\n        const take = Math.max(1, Math.round(have * (0.15 + rnd() * (intensity > 1 ? 1.1 : 0.7))));\n        set(buy ? \"ask\" : \"bid\", best, have - take);\n        last = best;\n        traded = true;\n        if (take >= have) fair += buy ? 0.45 : -0.45;\n      }\n    }\n\n    // Re-centre: quote around fair value with a mostly one-tick spread.\n    const spreadTicks = rnd() < 0.86 ? 1 : rnd() < 0.8 ? 2 : 3;\n    bestBid = Math.floor(fair - spreadTicks / 2);\n    const bestAsk = bestBid + spreadTicks;\n    for (const i of [...bids.keys()]) if (i > bestBid || i < bestBid - depth - 3) set(\"bid\", i, 0);\n    for (const i of [...asks.keys()]) if (i < bestAsk || i > bestAsk + depth + 3) set(\"ask\", i, 0);\n    for (let k = 0; k < depth; k++) {\n      if (!bids.has(bestBid - k) && (k > 0 || rnd() < 0.9)) set(\"bid\", bestBid - k, k === 0 ? Math.round(sizeAt(0) * 0.6) : sizeAt(k));\n      if (!asks.has(bestAsk + k) && (k > 0 || rnd() < 0.9)) set(\"ask\", bestAsk + k, k === 0 ? Math.round(sizeAt(0) * 0.6) : sizeAt(k));\n    }\n\n    // Makers add, trim and cancel, mostly near the touch.\n    const churn = 1 + Math.floor(rnd() * 3 * intensity);\n    for (let j = 0; j < churn; j++) {\n      const side: BookSide = rnd() < 0.5 ? \"bid\" : \"ask\";\n      const k = Math.floor(rnd() * rnd() * depth);\n      const i = side === \"bid\" ? bestBid - k : bestAsk + k;\n      const m = side === \"bid\" ? bids : asks;\n      const cur = m.get(i) ?? 0;\n      const r = rnd();\n      // Multipliers average just under 1, and walls are capped, so the book never inflates over a long session.\n      const size = r < 0.06 ? Math.min(cur + sizeAt(k) * 3, 2400) : r < 0.16 && k > 2 ? 0 : Math.max(1, Math.min(2400, Math.round(cur * (0.68 + rnd() * 0.62))));\n      set(side, i, size);\n    }\n\n    const out = (d: Map<number, number>) => [...d].map(([i, size]) => ({ price: px(i), size }));\n    return { bids: out(dB), asks: out(dA), last: traded ? px(last) : undefined };\n  };\n\n  let timer: ReturnType<typeof setTimeout> | null = null;\n  const listeners = new Set<(m: OrderBookMessage) => void>();\n  const loop = () => {\n    timer = null;\n    const now = performance.now();\n    if (burstEvery > 0 && now > burstUntil && rnd() < 1 / (burstEvery * rate)) burstUntil = now + 1200 + rnd() * 1600;\n    const hot = now < burstUntil;\n    if (!paused) {\n      const msg: OrderBookMessage = { type: \"delta\", ...step(hot ? 2.4 : 1) };\n      for (const l of listeners) l(msg);\n    }\n    const base = 1000 / (hot ? rate * 6 : rate);\n    timer = setTimeout(loop, base * (0.4 + rnd() * 1.2));\n  };\n\n  return {\n    snapshot,\n    step,\n    subscribe(listener) {\n      listeners.add(listener);\n      listener({ type: \"snapshot\", ...snapshot() });\n      if (!timer && typeof window !== \"undefined\") timer = setTimeout(loop, 1000 / rate);\n      return () => {\n        listeners.delete(listener);\n        if (!listeners.size && timer) {\n          clearTimeout(timer);\n          timer = null;\n        }\n      };\n    },\n    burst(ms = 2200) {\n      burstUntil = performance.now() + ms;\n    },\n    setPaused(p) {\n      paused = p;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/charts/order-book.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/_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/_core/random.ts",
      "content": "// Seeded randomness and noise. Deterministic for a given seed, so previews and tests repeat.\n\n// Small fast PRNG. Returns floats in [0, 1).\nexport function mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// FNV-1a 32-bit.\nexport function hashString(s: string): number {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  return h >>> 0;\n}\n\n// Integer lattice hash to [-1, 1].\nfunction latticeHash(seed: number, x: number, y = 0): number {\n  let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967295) * 2 - 1;\n}\n\nfunction smooth(t: number): number {\n  return t * t * (3 - 2 * t);\n}\n\nexport function valueNoise1D(seed: number): (x: number) => number {\n  return (x: number) => {\n    const i = Math.floor(x);\n    const f = x - i;\n    const a = latticeHash(seed, i);\n    const b = latticeHash(seed, i + 1);\n    return a + (b - a) * smooth(f);\n  };\n}\n\nexport function fbm1D(seed: number, octaves = 3): (x: number) => number {\n  const layers = Array.from({ length: octaves }, (_, o) => valueNoise1D(seed + o * 101));\n  let norm = 0;\n  for (let o = 0; o < octaves; o++) norm += 0.5 ** o;\n  return (x: number) => {\n    let sum = 0;\n    let amp = 1;\n    let freq = 1;\n    for (let o = 0; o < octaves; o++) {\n      sum += amp * layers[o](x * freq);\n      amp *= 0.5;\n      freq *= 2;\n    }\n    return sum / norm;\n  };\n}\n\nexport function valueNoise2D(seed: number): (x: number, y: number) => number {\n  return (x: number, y: number) => {\n    const xi = Math.floor(x);\n    const yi = Math.floor(y);\n    const fx = smooth(x - xi);\n    const fy = smooth(y - yi);\n    const a = latticeHash(seed, xi, yi);\n    const b = latticeHash(seed, xi + 1, yi);\n    const c = latticeHash(seed, xi, yi + 1);\n    const d = latticeHash(seed, xi + 1, yi + 1);\n    const top = a + (b - a) * fx;\n    const bottom = c + (d - c) * fx;\n    return top + (bottom - top) * fy;\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/random.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/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/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/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"
    }
  ],
  "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/order-book.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/order-book.json"
  },
  "type": "registry:component"
}