{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flap-ledger",
  "title": "Flap ledger",
  "description": "Flap ledger is a production-ready tables component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/tables/flap-ledger.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { BJORK_SURFACE } from \"@/components/bjork-ui/_core/surface\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\nimport { LiveRegion } from \"@/components/bjork-ui/_core/a11y\";\nimport { cubicBezier, ease } from \"@/components/bjork-ui/_core/motion\";\nimport { sizeCanvas } from \"@/components/bjork-ui/_core/canvas\";\n\nexport type FlapCharset = \"digits\" | \"alpha\" | \"alnum\" | string | string[];\n\nexport interface FlapColumn {\n  key: string;\n  header: string;\n  /** Width in character cells. An enum column is one word flap of this width. */\n  width: number;\n  charset: FlapCharset;\n  align?: \"left\" | \"right\";\n}\n\nexport type FlapRow = Record<string, string>;\nexport type FlapStatus = \"ok\" | \"warn\" | \"off\";\n\nexport interface FlapLedgerProps {\n  columns: FlapColumn[];\n  rows: FlapRow[];\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Duration of one flip (the top half plus the bottom half), in ms. */\n  flapMs?: number;\n  /** Delay per column away from the cascade origin, in ms. */\n  stagger?: number;\n  cascadeFrom?: \"changed\" | \"left\" | \"right\";\n  /** Optional LED left of each row. */\n  status?: (row: FlapRow) => FlapStatus;\n  /** Announces changed rows to screen readers. */\n  announceChanges?: boolean;\n  ariaLabel?: string;\n  tone?: BjorkTone;\n  /** Cycles the built-in departure sets every 5s. Pauses on pointer input and offscreen. */\n  attract?: boolean;\n  className?: string;\n}\n\nconst CHARSET_PRESETS: Record<\"digits\" | \"alpha\" | \"alnum\", string> = {\n  digits: \" 0123456789\",\n  alpha: \" ABCDEFGHIJKLMNOPQRSTUVWXYZ\",\n  alnum: \" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\",\n};\n\n// Timing is tuned for a Solari board you can follow: each flap is long enough\n// to see the top half fall, cells clatter through several characters, and the\n// start times ripple across each word and down the rows, so one update reads as\n// a wave of roughly 1.5 to 2 seconds rather than a blink.\nconst MAX_STEPS = 10;\nconst MAX_FLIPPING = 200;\n/** Extra delay per row, so the cascade also travels down the board. */\nconst ROW_DELAY_MS = 70;\n/** Extra delay per character inside a column, moving away from the cascade origin. */\nconst CHAR_DELAY_MS = 12;\nconst LAND_MS = 180;\nconst LAND_DEG = -10;\n/** Share of each flap spent on the top half falling. The rest is the bottom half settling. */\nconst FALL_SHARE = 0.58;\nconst EDGE_FADE_PX = 24;\nconst ATTRACT_MS = 5000;\nconst ATTRACT_RESUME_MS = 4000;\n\n// Geist Mono 500 glyphs are centred on the split line by cap height. Canvas\n// metrics: ascent 1.01em, descent 0.29em, cap height 0.71em. A line-height 1 box\n// puts the baseline at 0.5 + (1.01 - 0.29) / 2 = 0.86em, so the cap centre sits at\n// 0.86 - 0.355 = 0.505em, 0.005em (0.1px at 20px) below the box centre. A pixel\n// probe of a rendered H put the ink centre 0.13 scaled px above the cell centre.\n// Nudge up by 0.005em, which is within measurement noise.\nconst CAP_NUDGE_EM = -0.005;\n// Enum words carry letter-spacing 0.06em after the last letter. Shift right by\n// half of it so the visible word stays centred.\nconst WORD_TRAIL_EM = 0.03;\n\nconst SIZE_SCALE = { sm: 0.72, md: 1, lg: 1.36 } as const;\n\ninterface Material {\n  board: string;\n  topFace: readonly [string, string];\n  bottomFace: readonly [string, string];\n  split: string;\n  splitHighlight: string;\n  /** Lit free edge of a flap while it is moving, so it reads as a card. */\n  flapEdge: string;\n  /** Darkening of a flap as it turns away from flat, at edge-on. */\n  flapShade: number;\n}\n\nconst MATERIAL: Record<BjorkTone, Material> = {\n  dark: {\n    board: BJORK_SURFACE.dark.surface,\n    topFace: [\"#1e1e1e\", \"#191919\"],\n    bottomFace: [\"#171717\", \"#141414\"],\n    split: \"#050505\",\n    splitHighlight: \"rgba(255,255,255,0.04)\",\n    flapEdge: \"rgba(255,255,255,0.16)\",\n    flapShade: 0.6,\n  },\n  light: {\n    board: BJORK_SURFACE.light.surface,\n    topFace: [\"#f6f0e5\", \"#f1eadd\"],\n    bottomFace: [\"#eee7d9\", \"#e9e1d2\"],\n    split: \"#ddd2c1\",\n    splitHighlight: \"rgba(255,255,255,0.7)\",\n    flapEdge: \"rgba(0,0,0,0.14)\",\n    flapShade: 0.22,\n  },\n};\n\ninterface Metrics {\n  cellW: number;\n  cellH: number;\n  cellGap: number;\n  radius: number;\n  charFs: number;\n  wordFs: number;\n  headerFs: number;\n  headerGap: number;\n  led: number;\n  ledGap: number;\n  colGap: number;\n  rowGap: number;\n  pad: number;\n  boardRadius: number;\n  perspective: number;\n}\n\nfunction buildMetrics(size: \"sm\" | \"md\" | \"lg\"): Metrics {\n  const k = SIZE_SCALE[size];\n  return {\n    cellW: 22 * k,\n    cellH: 32 * k,\n    cellGap: 2 * k,\n    radius: 3 * k,\n    charFs: 20 * k,\n    wordFs: 15 * k,\n    headerFs: 10 * k,\n    headerGap: 8 * k,\n    led: 6 * k,\n    ledGap: 8 * k,\n    colGap: 8 * k,\n    rowGap: 6 * k,\n    pad: 16 * k,\n    boardRadius: 14 * k,\n    perspective: 220 * k,\n  };\n}\n\n/** Pixel width of a column of `width` character cells, including the gaps between them. */\nfunction columnPx(width: number, m: Metrics): number {\n  return width * (m.cellW + m.cellGap) - m.cellGap;\n}\n\n/** Cycle of states a cell flips through, in order. Blank comes first. */\nfunction buildCycle(col: FlapColumn): string[] {\n  if (Array.isArray(col.charset)) {\n    const words = col.charset.map((v) => v.toUpperCase().trim().slice(0, col.width));\n    return Array.from(new Set([\"\", ...words]));\n  }\n  const preset = col.charset in CHARSET_PRESETS\n    ? CHARSET_PRESETS[col.charset as keyof typeof CHARSET_PRESETS]\n    : col.charset;\n  const chars = Array.from(new Set(Array.from(preset)));\n  if (!chars.includes(\" \")) chars.unshift(\" \");\n  return chars;\n}\n\n/** Target cell values for one row, after normalising. Missing rows are blank. */\nfunction cellTargets(col: FlapColumn, cycle: string[], row: FlapRow | undefined): string[] {\n  const raw = row?.[col.key] ?? \"\";\n  if (Array.isArray(col.charset)) {\n    const word = raw.toUpperCase().trim().slice(0, col.width);\n    return [cycle.includes(word) ? word : \"\"];\n  }\n  let chars = Array.from(raw.toUpperCase()).map((ch) => (cycle.includes(ch) ? ch : \" \"));\n  chars = chars.slice(0, col.width);\n  const fill = Array.from({ length: col.width - chars.length }, () => \" \");\n  return col.align === \"right\" ? [...fill, ...chars] : [...chars, ...fill];\n}\n\n/** Forward distance through the cycle, wrapping. */\nfunction distance(cycle: string[], from: string, to: string): number {\n  const j = cycle.indexOf(to);\n  if (j < 0) return 0;\n  const i = Math.max(0, cycle.indexOf(from));\n  return (j - i + cycle.length) % cycle.length;\n}\n\ninterface FlipStep {\n  from: string;\n  to: string;\n  last: boolean;\n}\n\nfunction planFlips(cycle: string[], from: string, to: string): { jump: string | null; steps: FlipStep[] } {\n  const len = cycle.length;\n  let i = Math.max(0, cycle.indexOf(from));\n  const j = cycle.indexOf(to);\n  if (j < 0) return { jump: null, steps: [] };\n  let d = (j - i + len) % len;\n  if (d === 0) return { jump: null, steps: [] };\n  let jump: string | null = null;\n  if (d > MAX_STEPS) {\n    i = (j - MAX_STEPS + len) % len;\n    d = MAX_STEPS;\n    jump = cycle[i];\n  }\n  const steps: FlipStep[] = [];\n  for (let k = 0; k < d; k++) {\n    steps.push({ from: cycle[(i + k) % len], to: cycle[(i + k + 1) % len], last: k === d - 1 });\n  }\n  return { jump, steps };\n}\n\n// --- Canvas engine -----------------------------------------------------------\n// The whole board is painted into one <canvas>, driven by one board-level rAF.\n// This deliberately departs from the plan's \"WAAPI per flap layer\". A full\n// board is 135 cells with two 3D-rotated halves each; as DOM layers that meant\n// creating about 315 Animation objects per 70ms step and, even when the\n// transforms were set directly from one rAF, re-layerizing ~270 composited\n// layers every frame (under 15fps at a 4x CPU throttle). Here every active cell\n// derives its step and phase from wall-clock time, only dirty cells are\n// repainted, and the loop goes idle when nothing is moving. The DOM keeps the\n// layout boxes (so nothing shifts) and the hidden table for assistive tech.\n\ninterface CellState {\n  key: string;\n  c: number;\n  /** Device-pixel rect inside the canvas. */\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  value: string;\n  target: string | null;\n  // What is drawn right now.\n  top: string;\n  bottom: string;\n  fallText: string;\n  riseText: string;\n  fallDeg: number; // 0 (flat) to -90 (edge-on)\n  riseDeg: number; // 90 (edge-on) to 0 (flat), LAND_DEG while landing\n  shade: number;\n  alpha: number;\n  // Active motion, valid while `mode` is not \"idle\".\n  mode: \"idle\" | \"flip\" | \"fade\";\n  started: boolean;\n  startMs: number;\n  jump: string | null;\n  steps: FlipStep[];\n  k: number;\n  done: Promise<void>;\n  resolve: (() => void) | null;\n}\n\ntype CellMode = \"flip\" | \"crossfade\" | \"instant\";\n\ninterface Atlas {\n  canvas: HTMLCanvasElement;\n  /** Source x offset (device px) of each value. Width and height match the cell. */\n  index: Map<string, number>;\n}\n\ninterface Look {\n  dpr: number;\n  board: string;\n  split: string;\n  splitHighlight: string;\n  flapEdge: string;\n  flapShade: number;\n  perspective: number; // device px\n}\n\nconst fallEase = cubicBezier(0.55, 0, 1, 0.45); // gravity: the top half drops\nconst riseEase = cubicBezier(0, 0.55, 0.45, 1); // the bottom half lifts into place\nconst landEase = cubicBezier(ease.out[0], ease.out[1], ease.out[2], ease.out[3]);\nconst FADE_MS = 120;\nconst STRIPS = 6;\nconst DEG = Math.PI / 180;\n\nclass BoardEngine {\n  ctx: CanvasRenderingContext2D;\n  cells = new Map<string, CellState>();\n  active = new Set<CellState>();\n  dirty = new Set<CellState>();\n  atlases: Atlas[] = [];\n  shadeSprite: HTMLCanvasElement | null = null;\n  look: Look = { dpr: 1, board: \"#000\", split: \"#000\", splitHighlight: \"#000\", flapEdge: \"#000\", flapShade: 0, perspective: 220 };\n  flapMs = 130;\n\n  constructor(public canvas: HTMLCanvasElement) {\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) throw new Error(\"FlapLedger needs a 2D canvas context\");\n    this.ctx = ctx;\n  }\n\n  /** Repaints every cell, e.g. after a resize or a new atlas. */\n  invalidate() {\n    for (const cell of this.cells.values()) this.dirty.add(cell);\n  }\n\n  settle(cell: CellState) {\n    if (cell.mode === \"idle\") return;\n    cell.mode = \"idle\";\n    this.active.delete(cell);\n    const last = cell.steps[cell.steps.length - 1];\n    if (last) cell.value = last.to;\n    this.rest(cell, cell.value);\n    cell.steps = [];\n    cell.resolve?.();\n    cell.resolve = null;\n  }\n\n  rest(cell: CellState, value: string) {\n    cell.value = value;\n    cell.top = value;\n    cell.bottom = value;\n    cell.fallDeg = -90;\n    cell.riseDeg = 90;\n    cell.shade = 0;\n    cell.alpha = 1;\n    this.dirty.add(cell);\n  }\n\n  start(cell: CellState, cycle: string[], target: string, mode: CellMode, startMs: number): boolean {\n    this.settle(cell);\n    cell.done = Promise.resolve();\n    if (cell.value === target) return false;\n    if (mode === \"instant\" || this.active.size >= MAX_FLIPPING) {\n      this.rest(cell, target);\n      return false;\n    }\n    cell.done = new Promise<void>((resolve) => {\n      cell.resolve = resolve;\n    });\n    cell.started = false;\n    cell.startMs = startMs;\n    if (mode === \"crossfade\") {\n      cell.mode = \"fade\";\n      cell.steps = [{ from: cell.value, to: target, last: true }];\n      this.rest(cell, target);\n      cell.alpha = 0.35;\n      this.active.add(cell);\n      return true;\n    }\n    const plan = planFlips(cycle, cell.value, target);\n    if (plan.steps.length === 0) {\n      cell.resolve?.();\n      this.rest(cell, target);\n      return false;\n    }\n    cell.mode = \"flip\";\n    cell.jump = plan.jump;\n    cell.steps = plan.steps;\n    cell.k = -1;\n    this.active.add(cell);\n    return true;\n  }\n\n  /** Advances one cell to `now`. */\n  tick(cell: CellState, now: number) {\n    const e = now - cell.startMs;\n    if (e < 0) return;\n    if (cell.mode === \"fade\") {\n      const q = e / FADE_MS;\n      if (q >= 1) this.settle(cell);\n      else {\n        cell.alpha = 0.35 + 0.65 * landEase(q);\n        this.dirty.add(cell);\n      }\n      return;\n    }\n    if (!cell.started) {\n      cell.started = true;\n      if (cell.jump !== null) cell.value = cell.jump;\n    }\n    const ms = this.flapMs;\n    const total = cell.steps.length * ms;\n    this.dirty.add(cell);\n    if (e < total) {\n      const k = Math.floor(e / ms);\n      const p = (e - k * ms) / ms;\n      if (k !== cell.k) {\n        cell.k = k;\n        const step = cell.steps[k];\n        cell.value = step.from;\n        cell.top = step.to;\n        cell.bottom = step.from;\n        cell.fallText = step.from;\n        cell.riseText = step.to;\n      }\n      // Both halves share one phase: the top half falls under gravity (ease-in)\n      // for the first FALL_SHARE of the flap, then the bottom half, edge-on until\n      // then, slaps down into place with a quick ease-out.\n      const f = FALL_SHARE;\n      cell.fallDeg = p < f ? -90 * fallEase(p / f) : -90;\n      cell.riseDeg = p < f ? 90 : 90 * (1 - riseEase((p - f) / (1 - f)));\n      cell.shade = p < f ? p / f : 1 - (p - f) / (1 - f);\n      return;\n    }\n    const q = (e - total) / LAND_MS;\n    if (q < 1) {\n      const last = cell.steps[cell.steps.length - 1];\n      cell.value = last.to;\n      cell.top = last.to;\n      cell.bottom = last.to;\n      cell.riseText = last.to;\n      cell.fallDeg = -90;\n      cell.shade = 0;\n      // The card overshoots past flat and settles back: 0deg -> LAND_DEG at 30%\n      // -> 0deg, ease-out on both legs.\n      cell.riseDeg = q < 0.3 ? LAND_DEG * landEase(q / 0.3) : LAND_DEG * (1 - landEase((q - 0.3) / 0.7));\n      return;\n    }\n    this.settle(cell);\n  }\n\n  /** Advances every active cell and repaints what changed. Returns true while anything moves. */\n  frame(now: number): boolean {\n    for (const cell of this.active) this.tick(cell, now);\n    this.draw();\n    return this.active.size > 0;\n  }\n\n  draw() {\n    if (this.dirty.size === 0 || this.atlases.length === 0) return;\n    const { ctx, look } = this;\n    ctx.setTransform(1, 0, 0, 1, 0, 0);\n    for (const cell of this.dirty) this.drawCell(ctx, cell, look);\n    ctx.globalAlpha = 1;\n    this.dirty.clear();\n  }\n\n  drawCell(ctx: CanvasRenderingContext2D, cell: CellState, look: Look) {\n    const atlas = this.atlases[cell.c];\n    if (!atlas) return;\n    const { x, y, w, h } = cell;\n    const half = h / 2;\n    const mid = y + half;\n    ctx.globalAlpha = 1;\n    ctx.fillStyle = look.board;\n    ctx.fillRect(x, y, w, h);\n    ctx.globalAlpha = cell.alpha;\n    const src = (value: string) => atlas.index.get(value) ?? atlas.index.get(value === \"\" ? \" \" : \"\") ?? 0;\n    ctx.drawImage(atlas.canvas, src(cell.top), 0, w, half, x, y, w, half);\n    ctx.drawImage(atlas.canvas, src(cell.bottom), half, w, half, x, mid, w, half);\n    if (cell.shade > 0.001 && this.shadeSprite) {\n      ctx.globalAlpha = cell.alpha * cell.shade;\n      ctx.drawImage(this.shadeSprite, x, mid, w, half);\n      ctx.globalAlpha = cell.alpha;\n    }\n    const px = Math.max(1, Math.round(look.dpr));\n    ctx.fillStyle = look.split;\n    ctx.fillRect(x, mid - px, w, px);\n    ctx.fillStyle = look.splitHighlight;\n    ctx.fillRect(x, mid, w, px);\n\n    const fallOn = cell.fallDeg > -89.9;\n    const riseOn = cell.riseDeg < 89.9;\n    if (!fallOn && !riseOn) return;\n    // Flaps lean toward the viewer and grow slightly wider; the cell clips them,\n    // as `contain: paint` did on the DOM cell.\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(x, y, w, h);\n    ctx.clip();\n    if (fallOn) this.drawFlap(ctx, atlas.canvas, src(cell.fallText), cell, cell.fallDeg, true);\n    if (riseOn) this.drawFlap(ctx, atlas.canvas, src(cell.riseText), cell, cell.riseDeg, false);\n    ctx.restore();\n  }\n\n  /**\n   * Draws one half rotating about the split line, as CSS `perspective(P) rotateX(deg)`\n   * with the origin on the split. 2D canvas has no projective transform, so the\n   * half is drawn in horizontal strips, each scaled by its projected depth.\n   */\n  drawFlap(ctx: CanvasRenderingContext2D, img: HTMLCanvasElement, sx: number, cell: CellState, deg: number, upper: boolean) {\n    const { x, y, w, h } = cell;\n    const half = h / 2;\n    const mid = y + half;\n    const P = this.look.perspective;\n    const cos = Math.cos(deg * DEG);\n    // Depth toward the viewer per unit of distance from the axis.\n    const sin = upper ? Math.sin(-deg * DEG) : Math.sin(deg * DEG);\n    if (Math.abs(deg) < 0.05) {\n      ctx.drawImage(img, sx, upper ? 0 : half, w, half, x, upper ? y : mid, w, half);\n      return;\n    }\n    // A turning flap catches less light, so it darkens toward edge-on and its\n    // free edge picks up a thin highlight. Without these the moving half is the\n    // same flat grey as the faces behind it and the fall barely reads.\n    const shade = this.look.flapShade * (1 - cos);\n    const edge = Math.max(1, Math.round(this.look.dpr));\n    for (let i = 0; i < STRIPS; i++) {\n      const d0 = (half * i) / STRIPS;\n      const d1 = (half * (i + 1)) / STRIPS;\n      const y0 = (d0 * cos * P) / (P - d0 * sin);\n      const y1 = (d1 * cos * P) / (P - d1 * sin);\n      const dh = y1 - y0;\n      if (dh < 0.05) continue;\n      const s = P / (P - ((d0 + d1) / 2) * sin);\n      const dw = w * s;\n      const dx = x + (w - dw) / 2;\n      const pad = i < STRIPS - 1 ? 0.5 : 0; // overlap strips so no seam shows\n      const dy = upper ? mid - y1 : mid + y0 - pad;\n      if (upper) ctx.drawImage(img, sx, half - d1, w, d1 - d0, dx, dy, dw, dh + pad);\n      else ctx.drawImage(img, sx, half + d0, w, d1 - d0, dx, dy, dw, dh + pad);\n      if (shade > 0.01) {\n        ctx.fillStyle = `rgba(0,0,0,${shade.toFixed(3)})`;\n        ctx.fillRect(dx, dy, dw, dh + pad);\n      }\n      if (i === STRIPS - 1 && Math.abs(deg) > 2) {\n        ctx.fillStyle = this.look.flapEdge;\n        ctx.fillRect(dx, upper ? dy : dy + dh + pad - edge, dw, edge);\n      }\n    }\n  }\n}\n\ninterface AtlasSpec {\n  values: string[];\n  kind: \"char\" | \"word\";\n  w: number; // device px\n  h: number; // device px\n}\n\nfunction buildAtlas(spec: AtlasSpec, m: Metrics, dpr: number, family: string, ink: string, material: Material): Atlas {\n  const { w, h, kind } = spec;\n  const half = h / 2;\n  const gap = Math.ceil(2 * dpr);\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.max(1, spec.values.length * (w + gap));\n  canvas.height = Math.max(1, h);\n  const ctx = canvas.getContext(\"2d\");\n  const index = new Map<string, number>();\n  if (!ctx) return { canvas, index };\n  const fs = (kind === \"word\" ? m.wordFs : m.charFs) * dpr;\n  const r = m.radius * dpr;\n  ctx.font = `500 ${fs}px ${family}`;\n  ctx.textBaseline = \"alphabetic\";\n  ctx.textAlign = \"left\";\n  const metrics = ctx.measureText(\"H\");\n  const asc = metrics.fontBoundingBoxAscent ?? fs * 1.01;\n  const desc = metrics.fontBoundingBoxDescent ?? fs * 0.29;\n  // Same box as the DOM glyph: a line-height 1 line centred in the cell, plus the cap nudge.\n  const baseline = half + (asc - desc) / 2 + CAP_NUDGE_EM * fs;\n  const spacing = kind === \"word\" ? 0.06 * fs : 0;\n  const trail = kind === \"word\" ? WORD_TRAIL_EM * fs : 0;\n  const topGrad = ctx.createLinearGradient(0, 0, 0, half);\n  topGrad.addColorStop(0, material.topFace[0]);\n  topGrad.addColorStop(1, material.topFace[1]);\n  const bottomGrad = ctx.createLinearGradient(0, half, 0, h);\n  bottomGrad.addColorStop(0, material.bottomFace[0]);\n  bottomGrad.addColorStop(1, material.bottomFace[1]);\n\n  spec.values.forEach((value, n) => {\n    const ox = n * (w + gap);\n    index.set(value, ox);\n    ctx.save();\n    ctx.translate(ox, 0);\n    ctx.fillStyle = topGrad;\n    ctx.beginPath();\n    ctx.roundRect(0, 0, w, half, [r, r, 0, 0]);\n    ctx.fill();\n    ctx.fillStyle = bottomGrad;\n    ctx.beginPath();\n    ctx.roundRect(0, half, w, half, [0, 0, r, r]);\n    ctx.fill();\n    ctx.beginPath();\n    ctx.rect(0, 0, w, h);\n    ctx.clip();\n    ctx.fillStyle = ink;\n    const chars = Array.from(value);\n    const advances = chars.map((ch) => ctx.measureText(ch).width + spacing);\n    const total = advances.reduce((a, b) => a + b, 0);\n    let pen = (w - total) / 2 + trail;\n    chars.forEach((ch, j) => {\n      if (ch !== \" \") ctx.fillText(ch, pen, baseline);\n      pen += advances[j];\n    });\n    ctx.restore();\n  });\n  return { canvas, index };\n}\n\nfunction makeShadeSprite(h: number): HTMLCanvasElement {\n  const sprite = document.createElement(\"canvas\");\n  sprite.width = 1;\n  sprite.height = Math.max(1, Math.round(h));\n  const ctx = sprite.getContext(\"2d\");\n  if (ctx) {\n    const grad = ctx.createLinearGradient(0, 0, 0, sprite.height);\n    grad.addColorStop(0, \"rgba(0,0,0,0.35)\");\n    grad.addColorStop(1, \"rgba(0,0,0,0)\");\n    ctx.fillStyle = grad;\n    ctx.fillRect(0, 0, 1, sprite.height);\n  }\n  return sprite;\n}\n\n// --- Rendering ------------------------------------------------------------\n\nfunction Led({ state, m, colours }: { state: FlapStatus; m: Metrics; colours: Record<FlapStatus, string> }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative block shrink-0\"\n      style={{ width: m.led, height: m.led, marginRight: m.ledGap }}\n    >\n      {([\"ok\", \"warn\", \"off\"] as const).map((key) => (\n        <span\n          key={key}\n          className=\"absolute inset-0 rounded-full transition-opacity duration-200 ease-out\"\n          style={{ background: colours[key], opacity: state === key ? 1 : 0 }}\n        />\n      ))}\n    </span>\n  );\n}\n\n/**\n * Built-in idle sets for `attract`. Keys match the demo columns. Each set is the\n * next moment on the same board: the top flight departs and the rows scroll up,\n * a new flight arrives at the bottom, gates move and delayed times slip, so most\n * rows flip several columns at once.\n */\nexport const FLAP_LEDGER_SAMPLE_ROWS: FlapRow[][] = [\n  [\n    { time: \"08:15\", flight: \"BJ204\", destination: \"LONDON\", gate: \"A12\", status: \"GATE CLOSED\" },\n    { time: \"08:40\", flight: \"BJ317\", destination: \"TOKYO\", gate: \"B4\", status: \"BOARDING\" },\n    { time: \"09:05\", flight: \"BJ82\", destination: \"BERLIN\", gate: \"C7\", status: \"ON TIME\" },\n    { time: \"09:30\", flight: \"BJ551\", destination: \"OSLO\", gate: \"D2\", status: \"ON TIME\" },\n    { time: \"09:55\", flight: \"BJ19\", destination: \"NEW YORK\", gate: \"E9\", status: \"ON TIME\" },\n  ],\n  [\n    { time: \"08:40\", flight: \"BJ317\", destination: \"TOKYO\", gate: \"B4\", status: \"GATE CLOSED\" },\n    { time: \"09:05\", flight: \"BJ82\", destination: \"BERLIN\", gate: \"C7\", status: \"BOARDING\" },\n    { time: \"09:50\", flight: \"BJ551\", destination: \"OSLO\", gate: \"D2\", status: \"DELAYED\" },\n    { time: \"09:55\", flight: \"BJ19\", destination: \"NEW YORK\", gate: \"E12\", status: \"ON TIME\" },\n    { time: \"10:20\", flight: \"BJ446\", destination: \"REYKJAVIK\", gate: \"A3\", status: \"ON TIME\" },\n  ],\n  [\n    { time: \"09:05\", flight: \"BJ82\", destination: \"BERLIN\", gate: \"C7\", status: \"GATE CLOSED\" },\n    { time: \"09:50\", flight: \"BJ551\", destination: \"OSLO\", gate: \"D5\", status: \"BOARDING\" },\n    { time: \"10:15\", flight: \"BJ19\", destination: \"NEW YORK\", gate: \"E12\", status: \"DELAYED\" },\n    { time: \"10:20\", flight: \"BJ446\", destination: \"REYKJAVIK\", gate: \"A3\", status: \"ON TIME\" },\n    { time: \"10:45\", flight: \"BJ73\", destination: \"SINGAPORE\", gate: \"B11\", status: \"ON TIME\" },\n  ],\n  [\n    { time: \"09:50\", flight: \"BJ551\", destination: \"OSLO\", gate: \"D5\", status: \"GATE CLOSED\" },\n    { time: \"10:15\", flight: \"BJ19\", destination: \"NEW YORK\", gate: \"E12\", status: \"BOARDING\" },\n    { time: \"10:20\", flight: \"BJ446\", destination: \"REYKJAVIK\", gate: \"A7\", status: \"ON TIME\" },\n    { time: \"11:00\", flight: \"BJ73\", destination: \"SINGAPORE\", gate: \"B11\", status: \"DELAYED\" },\n    { time: \"11:10\", flight: \"BJ628\", destination: \"LISBON\", gate: \"C2\", status: \"ON TIME\" },\n  ],\n  [\n    { time: \"10:15\", flight: \"BJ19\", destination: \"NEW YORK\", gate: \"E12\", status: \"GATE CLOSED\" },\n    { time: \"10:20\", flight: \"BJ446\", destination: \"REYKJAVIK\", gate: \"A7\", status: \"BOARDING\" },\n    { time: \"11:00\", flight: \"BJ73\", destination: \"SINGAPORE\", gate: \"B11\", status: \"DELAYED\" },\n    { time: \"11:10\", flight: \"BJ628\", destination: \"LISBON\", gate: \"C4\", status: \"ON TIME\" },\n    { time: \"11:35\", flight: \"BJ905\", destination: \"MEXICO CITY\", gate: \"D8\", status: \"ON TIME\" },\n  ],\n];\n\nfunction diffMessage(prev: FlapRow[], next: FlapRow[], columns: FlapColumn[]): string {\n  const parts: string[] = [];\n  const count = Math.max(prev.length, next.length);\n  for (let r = 0; r < count; r++) {\n    const before = prev[r] ?? {};\n    const after = next[r] ?? {};\n    const changed = columns.filter((col) => (before[col.key] ?? \"\") !== (after[col.key] ?? \"\"));\n    if (changed.length === 0) continue;\n    const values = changed.map((col) => after[col.key] ?? \"\").filter(Boolean).join(\", \");\n    parts.push(`Row ${r + 1} updated${values ? `: ${values}` : \"\"}`);\n  }\n  return parts.join(\". \");\n}\n\nexport function FlapLedger({\n  columns,\n  rows,\n  size = \"md\",\n  flapMs = 130,\n  stagger = 50,\n  cascadeFrom = \"changed\",\n  status,\n  announceChanges = false,\n  ariaLabel = \"Board\",\n  tone,\n  attract = false,\n  className,\n}: FlapLedgerProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const palette = BJORK_PALETTE[resolvedTone];\n  const material = MATERIAL[resolvedTone];\n  const reduced = !!useReducedMotion();\n  const m = useMemo(() => buildMetrics(size), [size]);\n  const cycles = useMemo(() => columns.map(buildCycle), [columns]);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const boardRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const engineRef = useRef<BoardEngine | null>(null);\n  const genRef = useRef(0);\n  const firstRunRef = useRef(true);\n  const pauseUntilRef = useRef(0);\n  const attractAccRef = useRef(0);\n\n  const [attractStep, setAttractStep] = useState(0);\n  const attractActive = attract && !reduced;\n  const liveRows = attract ? FLAP_LEDGER_SAMPLE_ROWS[reduced ? 0 : attractStep % FLAP_LEDGER_SAMPLE_ROWS.length] : rows;\n\n  // Row slots stay mounted while removed rows flip to blank.\n  const [slots, setSlots] = useState(liveRows.length);\n  if (liveRows.length > slots) setSlots(liveRows.length);\n\n  // Announcements, computed while rendering from the previous rows snapshot.\n  const rowsKey = JSON.stringify(liveRows);\n  const [seenKey, setSeenKey] = useState(rowsKey);\n  const [prevRows, setPrevRows] = useState<FlapRow[]>(liveRows);\n  const [announcement, setAnnouncement] = useState(\"\");\n  if (rowsKey !== seenKey) {\n    setSeenKey(rowsKey);\n    if (announceChanges) setAnnouncement(diffMessage(prevRows, liveRows, columns));\n    setPrevRows(liveRows);\n  }\n\n  // One loop per board: it advances every flipping cell and the attract timer,\n  // and goes idle when neither has work.\n  const frame = useCallback(\n    (dt: number) => {\n      const now = performance.now();\n      const moving = engineRef.current ? engineRef.current.frame(now) : false;\n      if (attractActive && now >= pauseUntilRef.current) {\n        attractAccRef.current += dt;\n        if (attractAccRef.current >= ATTRACT_MS / 1000) {\n          attractAccRef.current = 0;\n          setAttractStep((s) => (s + 1) % FLAP_LEDGER_SAMPLE_ROWS.length);\n        }\n      }\n      return attractActive || moving;\n    },\n    [attractActive],\n  );\n  const { wake } = useVisibleLoop(rootRef, frame);\n\n  const pauseAttract = useCallback(() => {\n    pauseUntilRef.current = performance.now() + ATTRACT_RESUME_MS;\n  }, []);\n\n  const ink = palette.text;\n  const ledColours: Record<FlapStatus, string> = {\n    ok: palette.success,\n    warn: palette.warning,\n    off: palette.textFaint,\n  };\n  const ledSpace = status ? m.led + m.ledGap : 0;\n  const boardWidth = columns.reduce((sum, col) => sum + columnPx(col.width, m), 0) + Math.max(0, columns.length - 1) * m.colGap;\n  const canvasW = ledSpace + boardWidth;\n  const canvasH = slots * m.cellH + Math.max(0, slots - 1) * m.rowGap;\n\n  // Canvas size, cell rects and glyph atlases. Re-run when the layout or the look changes.\n  useLayoutEffect(() => {\n    const canvas = canvasRef.current;\n    const board = boardRef.current;\n    if (!canvas || !board) return;\n    const engine = (engineRef.current ??= new BoardEngine(canvas));\n    const dpr = sizeCanvas(canvas, canvasW, canvasH);\n    engine.look = {\n      dpr,\n      board: material.board,\n      split: material.split,\n      splitHighlight: material.splitHighlight,\n      flapEdge: material.flapEdge,\n      flapShade: material.flapShade,\n      perspective: m.perspective * dpr,\n    };\n    const h = Math.round(m.cellH * dpr);\n    const specs: AtlasSpec[] = columns.map((col, c) => {\n      const kind = Array.isArray(col.charset) ? \"word\" : \"char\";\n      const w = Math.round((kind === \"word\" ? columnPx(col.width, m) : m.cellW) * dpr);\n      return { values: cycles[c], kind, w, h };\n    });\n    const family = getComputedStyle(board).fontFamily || \"ui-monospace, monospace\";\n    const buildAll = () => {\n      engine.atlases = specs.map((spec) => buildAtlas(spec, m, dpr, family, ink, material));\n      engine.shadeSprite = makeShadeSprite(h / 2);\n      engine.invalidate();\n      engine.draw();\n    };\n\n    // Cell rects in device pixels. Existing cells keep their state.\n    const keep = new Set<string>();\n    let colX = ledSpace;\n    columns.forEach((col, c) => {\n      const isWord = Array.isArray(col.charset);\n      const count = isWord ? 1 : col.width;\n      for (let r = 0; r < slots; r++) {\n        for (let i = 0; i < count; i++) {\n          const key = `${r}:${c}:${i}`;\n          keep.add(key);\n          let cell = engine.cells.get(key);\n          if (!cell) {\n            cell = {\n              key,\n              c,\n              x: 0,\n              y: 0,\n              w: 0,\n              h: 0,\n              value: \"\",\n              target: null,\n              top: \"\",\n              bottom: \"\",\n              fallText: \"\",\n              riseText: \"\",\n              fallDeg: -90,\n              riseDeg: 90,\n              shade: 0,\n              alpha: 1,\n              mode: \"idle\",\n              started: false,\n              startMs: 0,\n              jump: null,\n              steps: [],\n              k: -1,\n              done: Promise.resolve(),\n              resolve: null,\n            };\n            engine.cells.set(key, cell);\n          }\n          cell.c = c;\n          cell.x = Math.round((colX + i * (m.cellW + m.cellGap)) * dpr);\n          cell.y = Math.round(r * (m.cellH + m.rowGap) * dpr);\n          cell.w = specs[c].w;\n          cell.h = h;\n        }\n      }\n      colX += columnPx(col.width, m) + m.colGap;\n    });\n    for (const [key, cell] of engine.cells) {\n      if (keep.has(key)) continue;\n      engine.settle(cell);\n      engine.dirty.delete(cell);\n      engine.cells.delete(key);\n    }\n    buildAll();\n\n    // Rebuild once the board font has loaded, so glyphs never stay in a fallback face.\n    let cancelled = false;\n    if (typeof document !== \"undefined\" && document.fonts) {\n      const fs = Math.max(m.charFs, m.wordFs) * dpr;\n      void document.fonts.load(`500 ${fs}px ${family}`).then(() => {\n        if (!cancelled) buildAll();\n      });\n    }\n    return () => {\n      cancelled = true;\n    };\n  }, [slots, columns, cycles, m, ink, material, ledSpace, canvasW, canvasH]);\n\n  // Flip the board toward the current rows. Only cells whose target changed move.\n  useLayoutEffect(() => {\n    const engine = engineRef.current;\n    if (!engine) return;\n    const gen = ++genRef.current;\n    const instant = firstRunRef.current;\n    firstRunRef.current = false;\n    const mode: CellMode = instant ? \"instant\" : reduced ? \"crossfade\" : \"flip\";\n    engine.flapMs = flapMs;\n\n    const targetCache = new Map<string, string[]>();\n    const jobs = Array.from(engine.cells.values()).map((cell) => {\n      const [r, c, i] = cell.key.split(\":\").map(Number);\n      const key = `${r}:${c}`;\n      let targets = targetCache.get(key);\n      if (!targets) {\n        targets = cellTargets(columns[c], cycles[c], liveRows[r]);\n        targetCache.set(key, targets);\n      }\n      return { cell, r, c, i, count: targets.length, cycle: cycles[c], target: targets[i] };\n    });\n\n    const colDistance = columns.map(() => 0);\n    if (mode === \"flip\") {\n      for (const job of jobs) {\n        if (job.target === undefined || job.cell.target === job.target) continue;\n        colDistance[job.c] += distance(job.cycle, job.cell.value, job.target);\n      }\n    }\n    let origin = 0;\n    if (cascadeFrom === \"right\") origin = columns.length - 1;\n    else if (cascadeFrom === \"changed\") {\n      let best = -1;\n      colDistance.forEach((d, c) => {\n        if (d > best) {\n          best = d;\n          origin = c;\n        }\n      });\n    }\n\n    const now = performance.now();\n    let moving = false;\n    for (const job of jobs) {\n      if (job.target === undefined || job.cell.target === job.target) continue;\n      job.cell.target = job.target;\n      // The ripple runs outward from the origin column, then along each word in\n      // the same direction, then down the rows.\n      const along = job.c < origin ? job.count - 1 - job.i : job.i;\n      const delay = mode === \"flip\" ? Math.abs(job.c - origin) * stagger + along * CHAR_DELAY_MS + job.r * ROW_DELAY_MS : 0;\n      if (engine.start(job.cell, job.cycle, job.target, mode, now + delay)) moving = true;\n    }\n    engine.draw();\n    if (moving) wake();\n\n    // Rows removed from the data flip to blank, then unmount.\n    if (slots > liveRows.length) {\n      const len = liveRows.length;\n      const removed = jobs.filter((job) => job.r >= len).map((job) => job.cell.done);\n      void Promise.all(removed).then(() => {\n        if (genRef.current === gen) setSlots(len);\n      });\n    }\n  }, [liveRows, slots, columns, cycles, cascadeFrom, stagger, flapMs, reduced, m, wake]);\n\n  // When the board is wider than its container, fade the right edge so the\n  // clipped columns read as scrollable. The fade drops once scrolled to the end.\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    let faded: boolean | null = null;\n    const update = () => {\n      const more = root.scrollWidth - root.clientWidth - root.scrollLeft > 1;\n      if (more === faded) return;\n      faded = more;\n      const mask = more ? `linear-gradient(to right, #000 calc(100% - ${EDGE_FADE_PX}px), transparent)` : \"\";\n      root.style.maskImage = mask;\n      root.style.setProperty(\"-webkit-mask-image\", mask);\n    };\n    update();\n    const observer = new ResizeObserver(update);\n    observer.observe(root);\n    if (boardRef.current) observer.observe(boardRef.current);\n    root.addEventListener(\"scroll\", update, { passive: true });\n    return () => {\n      observer.disconnect();\n      root.removeEventListener(\"scroll\", update);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative max-w-full overflow-x-auto overscroll-x-contain\", className)}\n      onPointerDown={pauseAttract}\n      onPointerMove={pauseAttract}\n    >\n      <div\n        ref={boardRef}\n        aria-hidden=\"true\"\n        className=\"relative block w-max font-mono tabular-nums select-none\"\n        style={{\n          padding: m.pad,\n          borderRadius: m.boardRadius,\n          // The library's card surface, pinned to the board's tone so the frame\n          // always matches the faces drawn on the canvas.\n          background: material.board,\n          border: `1px solid ${BJORK_SURFACE[resolvedTone].border}`,\n          boxShadow: BJORK_SURFACE[resolvedTone].shadowSurface,\n          color: ink,\n          width: boardWidth + ledSpace + m.pad * 2 + 2,\n        }}\n      >\n        <div className=\"flex\" style={{ paddingLeft: ledSpace, marginBottom: m.headerGap }}>\n          {columns.map((col, c) => {\n            const right = col.align === \"right\";\n            return (\n              <span\n                key={col.key}\n                className=\"block overflow-hidden whitespace-nowrap font-mono uppercase\"\n                style={{\n                  width: columnPx(col.width, m),\n                  marginLeft: c > 0 ? m.colGap : 0,\n                  fontSize: m.headerFs,\n                  lineHeight: 1,\n                  letterSpacing: \"0.12em\",\n                  textAlign: right ? \"right\" : \"left\",\n                  // Trailing letter-spacing pushes right-aligned text left. Pull it back by one tracking unit.\n                  marginRight: right ? \"-0.12em\" : undefined,\n                  color: palette.textFaint,\n                }}\n              >\n                {col.header}\n              </span>\n            );\n          })}\n        </div>\n\n        <div className=\"relative flex flex-col\" style={{ gap: m.rowGap }}>\n          {Array.from({ length: slots }, (_, r) => {\n            const state: FlapStatus = status && r < liveRows.length ? status(liveRows[r]) : \"off\";\n            return (\n              <div key={r} className=\"flex items-center\">\n                {status ? <Led state={state} m={m} colours={ledColours} /> : null}\n                <span className=\"block shrink-0\" style={{ width: boardWidth, height: m.cellH }} />\n              </div>\n            );\n          })}\n          <canvas\n            ref={canvasRef}\n            data-flap-canvas=\"\"\n            className=\"pointer-events-none absolute top-0 left-0 block\"\n            style={{ width: canvasW, height: canvasH }}\n          />\n        </div>\n      </div>\n\n      <table className=\"sr-only\">\n        <caption>{ariaLabel}</caption>\n        <thead>\n          <tr>\n            {columns.map((col) => (\n              <th key={col.key} scope=\"col\">\n                {col.header}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {liveRows.map((row, r) => (\n            <tr key={r}>\n              {columns.map((col) => (\n                <td key={col.key}>{row[col.key] ?? \"\"}</td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      {announceChanges ? <LiveRegion message={announcement} /> : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/flap-ledger.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/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/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/a11y.tsx",
      "content": "import type { ReactNode } from \"react\";\n\n// Text for assistive tech only.\nexport function VisuallyHidden({ children }: { children: ReactNode }) {\n  return <span className=\"sr-only\">{children}</span>;\n}\n\n// Announces `message` when it changes. Same text re-rendered does not re-announce.\nexport function LiveRegion({\n  message,\n  politeness = \"polite\",\n}: {\n  message: string;\n  politeness?: \"polite\" | \"assertive\";\n}) {\n  return (\n    <span role=\"status\" aria-live={politeness} aria-atomic=\"true\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/a11y.tsx"
    },
    {
      "path": "components/bjork-ui/_core/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/_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"
    }
  ],
  "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": "Tables",
    "registryUrl": "https://ui.isaiahbjork.com/flap-ledger.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/flap-ledger.json"
  },
  "type": "registry:component"
}