{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spreadsheet-grid",
  "title": "Spreadsheet grid",
  "description": "Spreadsheet grid is a production-ready tables component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "lucide-react",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/tables/spreadsheet-grid.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n} from \"react\";\nimport { motion, useReducedMotion } from \"framer-motion\";\nimport { useTheme } from \"next-themes\";\nimport { SquareFunction } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  type BjorkTableThemeMode,\n  getBjorkSignalPalette,\n  getBjorkTablePalette,\n  useBjorkTableIsDark,\n} from \"./table-theme\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\n/** Raw cell contents keyed by A1 address (\"B4\"). Formulas start with \"=\". */\nexport type SpreadsheetCells = Record<string, string>;\n\nexport type SpreadsheetColumnType = \"text\" | \"number\" | \"currency\" | \"percent\";\n\nexport interface SpreadsheetColumn {\n  /** How numbers in this column are parsed, formatted and aligned. */\n  type?: SpreadsheetColumnType;\n  /** Fixed width in px. */\n  width?: number;\n  /** Fraction digits for number / currency / percent. */\n  decimals?: number;\n}\n\nexport interface SpreadsheetCellChange {\n  address: string;\n  /** Raw value before the change (\"\" when the cell was empty). */\n  previous: string;\n  /** Raw value after the change (\"\" clears the cell). */\n  value: string;\n}\n\nexport interface SpreadsheetGridProps {\n  /** Controlled cell contents. Pair with `onChange`. */\n  cells?: SpreadsheetCells;\n  /** Initial contents when uncontrolled. Defaults to the sample budget. */\n  defaultCells?: SpreadsheetCells;\n  onChange?: (cells: SpreadsheetCells, changes: SpreadsheetCellChange[]) => void;\n  /** Column formats, left to right (A, B, C…). */\n  columns?: SpreadsheetColumn[];\n  rowCount?: number;\n  /** Leading rows styled as labels. */\n  headerRows?: number;\n  /** 1-based row numbers styled as totals. */\n  totalRows?: number[];\n  /** Cell that starts active, e.g. \"B9\". */\n  defaultActiveCell?: string;\n  onActiveCellChange?: (address: string) => void;\n  title?: string;\n  readOnly?: boolean;\n  /** ISO 4217 code for currency columns. */\n  currency?: string;\n  locale?: string;\n  /** Max height of the scrolling grid in px. */\n  maxHeight?: number;\n  className?: string;\n  theme?: BjorkTableThemeMode;\n  enableAnimations?: boolean;\n}\n\n/* ------------------------------------------------------------------ */\n/* Sample: an invented quarterly operating budget                      */\n/* ------------------------------------------------------------------ */\n\nexport const SPREADSHEET_SAMPLE_COLUMNS: SpreadsheetColumn[] = [\n  { type: \"text\", width: 168 },\n  { type: \"currency\", width: 104 },\n  { type: \"currency\", width: 104 },\n  { type: \"currency\", width: 104 },\n  { type: \"currency\", width: 104 },\n  { type: \"currency\", width: 116 },\n  { type: \"percent\", width: 92 },\n  { type: \"text\", width: 202 },\n];\n\nconst sampleLines: [string, number, number, number, number, string][] = [\n  [\"Cloud infrastructure\", 42800, 44100, 46900, 51200, \"Reserved capacity renews Q3\"],\n  [\"Contractors\", 18500, 22000, 22000, 16000, \"Design system rebuild\"],\n  [\"Software licenses\", 9640, 9640, 12180, 12180, \"Seat count grows in Q3\"],\n  [\"Travel\", 6200, 3400, 8900, 4100, \"Offsite in Q3\"],\n  [\"Events\", 12000, 0, 24500, 7800, \"Fieldnote Summit booth\"],\n  [\"Hardware\", 15300, 2100, 2100, 19600, \"Laptop refresh, Q1 and Q4\"],\n  [\"Training\", 2400, 2400, 3600, 3600, \"Two cohorts a half\"],\n];\n\nfunction buildSample(): SpreadsheetCells {\n  const cells: SpreadsheetCells = {\n    A1: \"Line item\",\n    B1: \"Q1\",\n    C1: \"Q2\",\n    D1: \"Q3\",\n    E1: \"Q4\",\n    F1: \"FY27\",\n    G1: \"Share\",\n    H1: \"Notes\",\n  };\n  sampleLines.forEach(([label, q1, q2, q3, q4, note], i) => {\n    const r = i + 2;\n    cells[`A${r}`] = label;\n    cells[`B${r}`] = String(q1);\n    cells[`C${r}`] = String(q2);\n    cells[`D${r}`] = String(q3);\n    cells[`E${r}`] = String(q4);\n    cells[`F${r}`] = `=SUM(B${r}:E${r})`;\n    cells[`G${r}`] = `=F${r}/F9`;\n    cells[`H${r}`] = note;\n  });\n  cells.A9 = \"Total\";\n  cells.A10 = \"Average per line\";\n  cells.A11 = \"Budget cap\";\n  cells.A12 = \"Headroom\";\n  for (const col of [\"B\", \"C\", \"D\", \"E\", \"F\"]) {\n    cells[`${col}9`] = `=SUM(${col}2:${col}8)`;\n    cells[`${col}10`] = `=AVG(${col}2:${col}8)`;\n    cells[`${col}12`] = `=${col}11-${col}9`;\n  }\n  cells.B11 = \"112000\";\n  cells.C11 = \"84000\";\n  cells.D11 = \"116000\";\n  cells.E11 = \"118000\";\n  cells.F11 = \"=SUM(B11:E11)\";\n  cells.G9 = \"=SUM(G2:G8)\";\n  cells.G12 = \"=F12/F11\";\n  cells.H9 = \"Q3 runs over cap\";\n  cells.H12 = \"Share of cap left\";\n  return cells;\n}\n\nexport const SPREADSHEET_SAMPLE: SpreadsheetCells = buildSample();\nexport const SPREADSHEET_SAMPLE_ROWS = 14;\n\n/* ------------------------------------------------------------------ */\n/* Addresses                                                           */\n/* ------------------------------------------------------------------ */\n\nexport function columnLetter(index: number): string {\n  let n = index + 1;\n  let out = \"\";\n  while (n > 0) {\n    const rem = (n - 1) % 26;\n    out = String.fromCharCode(65 + rem) + out;\n    n = Math.floor((n - 1) / 26);\n  }\n  return out;\n}\n\nfunction columnIndex(letters: string): number {\n  let n = 0;\n  for (const ch of letters.toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);\n  return n - 1;\n}\n\nexport const toAddress = (r: number, c: number) => `${columnLetter(c)}${r + 1}`;\n\nfunction parseAddress(address: string): { r: number; c: number } | null {\n  const m = /^\\$?([A-Za-z]{1,3})\\$?(\\d+)$/.exec(address.trim());\n  if (!m) return null;\n  return { r: Number(m[2]) - 1, c: columnIndex(m[1]) };\n}\n\n/* ------------------------------------------------------------------ */\n/* Formula engine                                                      */\n/* ------------------------------------------------------------------ */\n\ntype ErrorCode = \"#REF!\" | \"#VALUE!\" | \"#DIV/0!\" | \"#NAME?\" | \"#ERROR!\";\n\ntype CellResult =\n  | { kind: \"empty\" }\n  | { kind: \"number\"; value: number }\n  | { kind: \"text\"; value: string }\n  | { kind: \"error\"; code: ErrorCode; message: string };\n\nclass FormulaError extends Error {\n  constructor(\n    public code: ErrorCode,\n    message: string,\n  ) {\n    super(message);\n  }\n}\n\ntype Token =\n  | { t: \"num\"; v: number }\n  | { t: \"ref\"; v: string }\n  | { t: \"name\"; v: string }\n  | { t: \"op\"; v: string };\n\nfunction tokenize(src: string): Token[] {\n  const out: Token[] = [];\n  const re = /\\s*(?:(\\d+(?:\\.\\d+)?|\\.\\d+)|(\\$?[A-Za-z]{1,3}\\$?\\d+)(?![A-Za-z(])|([A-Za-z_][A-Za-z_.]*)|([-+*/(),:]))/y;\n  let i = 0;\n  while (i < src.length) {\n    if (/^\\s*$/.test(src.slice(i))) break;\n    re.lastIndex = i;\n    const m = re.exec(src);\n    if (!m) throw new FormulaError(\"#ERROR!\", `Unexpected \"${src.slice(i).trim()[0]}\"`);\n    if (m[1] !== undefined) out.push({ t: \"num\", v: Number(m[1]) });\n    else if (m[2] !== undefined) out.push({ t: \"ref\", v: m[2].replace(/\\$/g, \"\").toUpperCase() });\n    else if (m[3] !== undefined) out.push({ t: \"name\", v: m[3].toUpperCase() });\n    else out.push({ t: \"op\", v: m[4] });\n    i = re.lastIndex;\n  }\n  return out;\n}\n\ntype Scalar = number;\ntype Arg = { range: { r0: number; c0: number; r1: number; c1: number } } | { value: Scalar };\n\nconst FUNCTIONS: Record<string, (values: number[], count: number) => number> = {\n  SUM: (v) => v.reduce((a, b) => a + b, 0),\n  AVG: (v) => {\n    if (!v.length) throw new FormulaError(\"#DIV/0!\", \"Average of no numbers\");\n    return v.reduce((a, b) => a + b, 0) / v.length;\n  },\n  MIN: (v) => (v.length ? Math.min(...v) : 0),\n  MAX: (v) => (v.length ? Math.max(...v) : 0),\n  COUNT: (v) => v.length,\n};\nFUNCTIONS.AVERAGE = FUNCTIONS.AVG;\n\nfunction createEvaluator(cells: SpreadsheetCells, rows: number, cols: number) {\n  const cache = new Map<string, CellResult>();\n  const visiting = new Set<string>();\n\n  const inBounds = (r: number, c: number) => r >= 0 && c >= 0 && r < rows && c < cols;\n\n  function evalCell(address: string): CellResult {\n    const hit = cache.get(address);\n    if (hit) return hit;\n    if (visiting.has(address)) {\n      return { kind: \"error\", code: \"#REF!\", message: `Circular reference through ${address}` };\n    }\n    const raw = (cells[address] ?? \"\").trim();\n    let result: CellResult;\n    if (raw === \"\") result = { kind: \"empty\" };\n    else if (raw.startsWith(\"=\")) {\n      visiting.add(address);\n      try {\n        result = { kind: \"number\", value: evalFormula(raw.slice(1)) };\n        if (!Number.isFinite(result.value)) result = { kind: \"error\", code: \"#DIV/0!\", message: \"Division by zero\" };\n      } catch (err) {\n        result =\n          err instanceof FormulaError\n            ? { kind: \"error\", code: err.code, message: err.message }\n            : { kind: \"error\", code: \"#ERROR!\", message: \"Could not evaluate\" };\n      }\n      visiting.delete(address);\n    } else {\n      const n = Number(raw);\n      result = Number.isFinite(n) ? { kind: \"number\", value: n } : { kind: \"text\", value: raw };\n    }\n    cache.set(address, result);\n    return result;\n  }\n\n  function scalarOf(address: string): number {\n    const pos = parseAddress(address);\n    if (!pos || !inBounds(pos.r, pos.c)) throw new FormulaError(\"#REF!\", `${address} is outside the sheet`);\n    const res = evalCell(address);\n    if (res.kind === \"error\") throw new FormulaError(res.code, res.message);\n    if (res.kind === \"empty\") return 0;\n    if (res.kind === \"text\") throw new FormulaError(\"#VALUE!\", `${address} holds text`);\n    return res.value;\n  }\n\n  function evalFormula(src: string): number {\n    const tokens = tokenize(src);\n    let p = 0;\n    const peek = () => tokens[p];\n    const isOp = (v: string) => peek()?.t === \"op\" && peek().v === v;\n    const expectOp = (v: string) => {\n      if (!isOp(v)) throw new FormulaError(\"#ERROR!\", `Expected \"${v}\"`);\n      p += 1;\n    };\n\n    const parseArg = (): Arg => {\n      const tok = peek();\n      const next = tokens[p + 1];\n      if (tok?.t === \"ref\" && next?.t === \"op\" && next.v === \":\") {\n        const end = tokens[p + 2];\n        if (end?.t !== \"ref\") throw new FormulaError(\"#ERROR!\", \"Incomplete range\");\n        p += 3;\n        const a = parseAddress(tok.v)!;\n        const b = parseAddress(end.v)!;\n        const range = {\n          r0: Math.min(a.r, b.r),\n          c0: Math.min(a.c, b.c),\n          r1: Math.max(a.r, b.r),\n          c1: Math.max(a.c, b.c),\n        };\n        if (!inBounds(range.r1, range.c1)) throw new FormulaError(\"#REF!\", \"Range runs off the sheet\");\n        return { range };\n      }\n      return { value: parseExpr() };\n    };\n\n    const parsePrimary = (): number => {\n      const tok = peek();\n      if (!tok) throw new FormulaError(\"#ERROR!\", \"Formula ends early\");\n      if (tok.t === \"num\") {\n        p += 1;\n        return tok.v;\n      }\n      if (tok.t === \"ref\") {\n        p += 1;\n        if (isOp(\":\")) throw new FormulaError(\"#VALUE!\", \"A range needs a function like SUM\");\n        return scalarOf(tok.v);\n      }\n      if (tok.t === \"name\") {\n        const fn = FUNCTIONS[tok.v];\n        if (!fn) throw new FormulaError(\"#NAME?\", `Unknown function ${tok.v}`);\n        p += 1;\n        expectOp(\"(\");\n        const args: Arg[] = [];\n        if (!isOp(\")\")) {\n          args.push(parseArg());\n          while (isOp(\",\")) {\n            p += 1;\n            args.push(parseArg());\n          }\n        }\n        expectOp(\")\");\n        const values: number[] = [];\n        for (const arg of args) {\n          if (\"value\" in arg) {\n            values.push(arg.value);\n            continue;\n          }\n          const { r0, c0, r1, c1 } = arg.range;\n          for (let r = r0; r <= r1; r += 1) {\n            for (let c = c0; c <= c1; c += 1) {\n              const res = evalCell(toAddress(r, c));\n              if (res.kind === \"error\") throw new FormulaError(res.code, res.message);\n              if (res.kind === \"number\") values.push(res.value);\n            }\n          }\n        }\n        return fn(values, values.length);\n      }\n      if (tok.v === \"(\") {\n        p += 1;\n        const v = parseExpr();\n        expectOp(\")\");\n        return v;\n      }\n      throw new FormulaError(\"#ERROR!\", `Unexpected \"${tok.v}\"`);\n    };\n\n    const parseUnary = (): number => {\n      if (isOp(\"-\")) {\n        p += 1;\n        return -parseUnary();\n      }\n      if (isOp(\"+\")) {\n        p += 1;\n        return parseUnary();\n      }\n      return parsePrimary();\n    };\n\n    const parseTerm = (): number => {\n      let v = parseUnary();\n      while (isOp(\"*\") || isOp(\"/\")) {\n        const op = peek().v;\n        p += 1;\n        const rhs = parseUnary();\n        if (op === \"/\" && rhs === 0) throw new FormulaError(\"#DIV/0!\", \"Division by zero\");\n        v = op === \"*\" ? v * rhs : v / rhs;\n      }\n      return v;\n    };\n\n    function parseExpr(): number {\n      let v = parseTerm();\n      while (isOp(\"+\") || isOp(\"-\")) {\n        const op = peek().v;\n        p += 1;\n        const rhs = parseTerm();\n        v = op === \"+\" ? v + rhs : v - rhs;\n      }\n      return v;\n    }\n\n    if (!tokens.length) throw new FormulaError(\"#ERROR!\", \"Empty formula\");\n    const value = parseExpr();\n    if (p < tokens.length) throw new FormulaError(\"#ERROR!\", `Unexpected \"${peek().v}\"`);\n    return value;\n  }\n\n  return evalCell;\n}\n\n/** Ranges referenced by a formula, for the dashed reference highlights. */\nfunction referencedRanges(raw: string) {\n  if (!raw.startsWith(\"=\")) return [];\n  const out: { r0: number; c0: number; r1: number; c1: number }[] = [];\n  const re = /(\\$?[A-Za-z]{1,3}\\$?\\d+)(?::(\\$?[A-Za-z]{1,3}\\$?\\d+))?(?![A-Za-z(])/g;\n  for (const m of raw.slice(1).matchAll(re)) {\n    const a = parseAddress(m[1]);\n    const b = m[2] ? parseAddress(m[2]) : a;\n    if (!a || !b) continue;\n    out.push({ r0: Math.min(a.r, b.r), c0: Math.min(a.c, b.c), r1: Math.max(a.r, b.r), c1: Math.max(a.c, b.c) });\n  }\n  return out.slice(0, 6);\n}\n\n/* ------------------------------------------------------------------ */\n/* Parsing & formatting                                                */\n/* ------------------------------------------------------------------ */\n\n/** Turns what was typed into the stored raw value for a column type. */\nfunction normalizeInput(text: string, type: SpreadsheetColumnType): string {\n  const trimmed = text.trim();\n  if (trimmed === \"\" || trimmed.startsWith(\"=\")) return trimmed;\n  const m = /^([-+])?\\$?\\s*([\\d,]*\\.?\\d+)\\s*(%)?$/.exec(trimmed);\n  if (!m || (type === \"text\" && !m[3])) return trimmed;\n  const digits = m[2].replace(/,/g, \"\");\n  let n = Number(digits) * (m[1] === \"-\" ? -1 : 1);\n  if (!Number.isFinite(n)) return trimmed;\n  if (m[3] || type === \"percent\") n /= 100;\n  return String(Number(n.toPrecision(15)));\n}\n\n/** What the editor shows for a stored raw value. */\nfunction toEditText(raw: string, type: SpreadsheetColumnType): string {\n  if (type === \"percent\" && raw !== \"\" && !raw.startsWith(\"=\")) {\n    const n = Number(raw);\n    if (Number.isFinite(n)) return `${Number((n * 100).toPrecision(12))}%`;\n  }\n  return raw;\n}\n\nfunction makeFormatter(type: SpreadsheetColumnType, decimals: number | undefined, locale: string, currency: string) {\n  if (type === \"currency\") {\n    const d = decimals ?? 0;\n    return new Intl.NumberFormat(locale, {\n      style: \"currency\",\n      currency,\n      minimumFractionDigits: d,\n      maximumFractionDigits: d,\n    });\n  }\n  if (type === \"percent\") {\n    const d = decimals ?? 1;\n    return new Intl.NumberFormat(locale, { style: \"percent\", minimumFractionDigits: d, maximumFractionDigits: d });\n  }\n  return new Intl.NumberFormat(locale, { maximumFractionDigits: decimals ?? 2 });\n}\n\nconst subscribeNoop = () => () => {};\nconst useMounted = () =>\n  useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  );\n\nconst ROW_H = 32;\nconst HEAD_H = 28;\nconst ROWHEAD_W = 44;\nconst DEFAULT_COL_W = 112;\n\ninterface Pos {\n  r: number;\n  c: number;\n}\n\ninterface EditState extends Pos {\n  value: string;\n  /** \"enter\": started by typing, arrows commit. \"edit\": F2 / Enter / double click, arrows move the caret. */\n  mode: \"enter\" | \"edit\";\n  source: \"cell\" | \"bar\";\n}\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport function SpreadsheetGrid({\n  cells: controlledCells,\n  defaultCells,\n  onChange,\n  columns: columnsProp,\n  rowCount: rowCountProp,\n  headerRows = 1,\n  totalRows,\n  defaultActiveCell = \"B2\",\n  onActiveCellChange,\n  title = \"FY27 operating budget\",\n  readOnly = false,\n  currency = \"USD\",\n  locale = \"en-US\",\n  maxHeight = 520,\n  className,\n  theme = \"auto\",\n  enableAnimations = true,\n}: SpreadsheetGridProps) {\n  const isSample = controlledCells === undefined && defaultCells === undefined;\n  const columns = useMemo<SpreadsheetColumn[]>(\n    () => columnsProp ?? (isSample ? SPREADSHEET_SAMPLE_COLUMNS : Array.from({ length: 6 }, () => ({}))),\n    [columnsProp, isSample],\n  );\n  const rowCount = rowCountProp ?? (isSample ? SPREADSHEET_SAMPLE_ROWS : 20);\n  const totals = useMemo(() => new Set(totalRows ?? (isSample ? [9] : [])), [totalRows, isSample]);\n  const colCount = columns.length;\n\n  const mounted = useMounted();\n  const { resolvedTheme } = useTheme();\n  const forcedTheme = theme === \"auto\" ? undefined : theme;\n  const detectedIsDark = useBjorkTableIsDark(resolvedTheme, forcedTheme);\n  const isDark = !mounted && theme === \"auto\" ? true : detectedIsDark;\n  const palette = getBjorkTablePalette(isDark);\n  const red = getBjorkSignalPalette(\"red\", isDark);\n  const reduceMotion = useReducedMotion();\n  const shouldAnimate = enableAnimations && !reduceMotion;\n  const accentHex = palette.checkboxAccent;\n\n  /* ---------- data ---------- */\n\n  const [internalCells, setInternalCells] = useState<SpreadsheetCells>(() => defaultCells ?? SPREADSHEET_SAMPLE);\n  const cells = controlledCells ?? internalCells;\n\n  const evaluate = useMemo(() => createEvaluator(cells, rowCount, colCount), [cells, rowCount, colCount]);\n\n  const formatters = useMemo(\n    () => columns.map((col) => makeFormatter(col.type ?? \"text\", col.decimals, locale, currency)),\n    [columns, locale, currency],\n  );\n\n  const applyChanges = useCallback(\n    (entries: { address: string; value: string }[]) => {\n      if (readOnly) return;\n      const next = { ...cells };\n      const changes: SpreadsheetCellChange[] = [];\n      for (const { address, value } of entries) {\n        const previous = cells[address] ?? \"\";\n        if (previous === value) continue;\n        if (value === \"\") delete next[address];\n        else next[address] = value;\n        changes.push({ address, previous, value });\n      }\n      if (!changes.length) return;\n      if (controlledCells === undefined) setInternalCells(next);\n      onChange?.(next, changes);\n    },\n    [cells, controlledCells, onChange, readOnly],\n  );\n\n  /* ---------- selection ---------- */\n\n  const clampPos = useCallback(\n    (p: Pos): Pos => ({\n      r: Math.max(0, Math.min(rowCount - 1, p.r)),\n      c: Math.max(0, Math.min(colCount - 1, p.c)),\n    }),\n    [rowCount, colCount],\n  );\n\n  const [active, setActive] = useState<Pos>(() => clampPos(parseAddress(defaultActiveCell) ?? { r: 0, c: 0 }));\n  const [anchor, setAnchor] = useState<Pos>(active);\n  const [editing, setEditingState] = useState<EditState | null>(null);\n  const editRef = useRef<EditState | null>(null);\n  const setEditing = (next: EditState | null) => {\n    editRef.current = next;\n    setEditingState(next);\n  };\n\n  const range = useMemo(\n    () => ({\n      r0: Math.min(active.r, anchor.r),\n      r1: Math.max(active.r, anchor.r),\n      c0: Math.min(active.c, anchor.c),\n      c1: Math.max(active.c, anchor.c),\n    }),\n    [active, anchor],\n  );\n  const isMulti = range.r0 !== range.r1 || range.c0 !== range.c1;\n\n  const gridRef = useRef<HTMLTableElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const barRef = useRef<HTMLInputElement>(null);\n  const pendingFocus = useRef(false);\n  const dragging = useRef(false);\n\n  const activeAddress = toAddress(active.r, active.c);\n\n  useEffect(() => {\n    onActiveCellChange?.(activeAddress);\n  }, [activeAddress, onActiveCellChange]);\n\n  useEffect(() => {\n    if (!pendingFocus.current) return;\n    pendingFocus.current = false;\n    if (editing?.source === \"cell\") return;\n    const el = gridRef.current?.querySelector<HTMLElement>(`[data-cell=\"${activeAddress}\"]`);\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n  }, [activeAddress, editing]);\n\n  useEffect(() => {\n    const stop = () => {\n      dragging.current = false;\n    };\n    window.addEventListener(\"pointerup\", stop);\n    return () => window.removeEventListener(\"pointerup\", stop);\n  }, []);\n\n  const select = useCallback(\n    (to: Pos, extend = false) => {\n      const next = clampPos(to);\n      pendingFocus.current = true;\n      setActive(next);\n      if (!extend) setAnchor(next);\n    },\n    [clampPos],\n  );\n\n  /* ---------- editing ---------- */\n\n  const typeAt = (c: number): SpreadsheetColumnType => columns[c]?.type ?? \"text\";\n\n  const beginEdit = (mode: EditState[\"mode\"], initial?: string, source: EditState[\"source\"] = \"cell\") => {\n    if (readOnly) return;\n    const raw = cells[activeAddress] ?? \"\";\n    setAnchor(active);\n    setEditing({ ...active, value: initial ?? toEditText(raw, typeAt(active.c)), mode, source });\n  };\n\n  /** Commits the open edit (if any) and optionally moves the active cell. */\n  const commit = (move?: Pos | \"stay\") => {\n    const e = editRef.current;\n    if (e) {\n      setEditing(null);\n      applyChanges([{ address: toAddress(e.r, e.c), value: normalizeInput(e.value, typeAt(e.c)) }]);\n    }\n    if (move && move !== \"stay\") select(move);\n    else {\n      pendingFocus.current = true;\n      setActive((a) => ({ ...a }));\n    }\n  };\n\n  const cancel = () => {\n    setEditing(null);\n    pendingFocus.current = true;\n    setActive((a) => ({ ...a }));\n  };\n\n  const clearRange = () => {\n    const entries: { address: string; value: string }[] = [];\n    for (let r = range.r0; r <= range.r1; r += 1)\n      for (let c = range.c0; c <= range.c1; c += 1) entries.push({ address: toAddress(r, c), value: \"\" });\n    applyChanges(entries);\n  };\n\n  /* ---------- keyboard ---------- */\n\n  const moveBy = (dr: number, dc: number, extend: boolean, jump: boolean) => {\n    const from = active;\n    const to = jump\n      ? { r: dr ? (dr > 0 ? rowCount - 1 : 0) : from.r, c: dc ? (dc > 0 ? colCount - 1 : 0) : from.c }\n      : { r: from.r + dr, c: from.c + dc };\n    select(to, extend);\n  };\n\n  const handleGridKeyDown = (e: ReactKeyboardEvent<HTMLTableElement>) => {\n    if (editRef.current) return;\n    if (!(e.target as HTMLElement).dataset.cell) return;\n    const mod = e.metaKey || e.ctrlKey;\n    let handled = true;\n    switch (e.key) {\n      case \"ArrowUp\":\n        moveBy(-1, 0, e.shiftKey, mod);\n        break;\n      case \"ArrowDown\":\n        moveBy(1, 0, e.shiftKey, mod);\n        break;\n      case \"ArrowLeft\":\n        moveBy(0, -1, e.shiftKey, mod);\n        break;\n      case \"ArrowRight\":\n        moveBy(0, 1, e.shiftKey, mod);\n        break;\n      case \"Home\":\n        select(mod ? { r: 0, c: 0 } : { r: active.r, c: 0 }, e.shiftKey);\n        break;\n      case \"End\":\n        select(mod ? { r: rowCount - 1, c: colCount - 1 } : { r: active.r, c: colCount - 1 }, e.shiftKey);\n        break;\n      case \"PageDown\":\n        moveBy(10, 0, e.shiftKey, false);\n        break;\n      case \"PageUp\":\n        moveBy(-10, 0, e.shiftKey, false);\n        break;\n      case \"Tab\": {\n        // Tab walks the row and wraps; at either end of the sheet it lets focus leave the grid.\n        const flat = active.r * colCount + active.c + (e.shiftKey ? -1 : 1);\n        if (flat < 0 || flat >= rowCount * colCount) handled = false;\n        else select({ r: Math.floor(flat / colCount), c: flat % colCount });\n        break;\n      }\n      case \"Enter\":\n        if (e.shiftKey) moveBy(-1, 0, false, false);\n        else if (readOnly) moveBy(1, 0, false, false);\n        else beginEdit(\"edit\");\n        break;\n      case \"F2\":\n        beginEdit(\"edit\");\n        break;\n      case \"Escape\":\n        if (isMulti) setAnchor(active);\n        else handled = false;\n        break;\n      case \"Delete\":\n      case \"Backspace\":\n        clearRange();\n        break;\n      case \"a\":\n      case \"A\":\n        if (mod) {\n          setAnchor({ r: 0, c: 0 });\n          setActive({ r: rowCount - 1, c: colCount - 1 });\n        } else handled = false;\n        break;\n      default:\n        handled = false;\n    }\n    if (!handled && !readOnly && e.key.length === 1 && !mod && !e.altKey) {\n      beginEdit(\"enter\", e.key);\n      handled = true;\n    }\n    if (handled) {\n      e.preventDefault();\n      e.stopPropagation();\n    }\n  };\n\n  const handleEditorKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>, source: EditState[\"source\"]) => {\n    const cur = editRef.current;\n    if (!cur) return;\n    const at = { r: cur.r, c: cur.c };\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      commit({ r: at.r + (e.shiftKey ? -1 : 1), c: at.c });\n    } else if (e.key === \"Tab\") {\n      e.preventDefault();\n      commit({ r: at.r, c: at.c + (e.shiftKey ? -1 : 1) });\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      cancel();\n    } else if (source === \"cell\" && cur.mode === \"enter\" && !cur.value.startsWith(\"=\") && e.key.startsWith(\"Arrow\")) {\n      // Typed-over cells behave like a sheet: arrows commit and move. Formulas keep the caret.\n      e.preventDefault();\n      const steps: Record<string, [number, number]> = {\n        ArrowUp: [-1, 0],\n        ArrowDown: [1, 0],\n        ArrowLeft: [0, -1],\n        ArrowRight: [0, 1],\n      };\n      const d = steps[e.key] ?? [0, 0];\n      commit({ r: at.r + d[0], c: at.c + d[1] });\n    }\n    e.stopPropagation();\n  };\n\n  /* ---------- clipboard ---------- */\n\n  const rawForCopy = useCallback(\n    (address: string) => {\n      const raw = cells[address] ?? \"\";\n      if (!raw.startsWith(\"=\")) return raw;\n      const res = evaluate(address);\n      if (res.kind === \"number\") return String(Number(res.value.toPrecision(15)));\n      if (res.kind === \"error\") return res.code;\n      return \"\";\n    },\n    [cells, evaluate],\n  );\n\n  const clipboardRef = useRef({ range, active, rawForCopy, applyChanges, select, rowCount, colCount, columns });\n  useEffect(() => {\n    clipboardRef.current = { range, active, rawForCopy, applyChanges, select, rowCount, colCount, columns };\n  });\n\n  useEffect(() => {\n    const owns = () => {\n      const el = document.activeElement;\n      return !!el && !!gridRef.current?.contains(el) && !editRef.current && (el as HTMLElement).dataset.cell !== undefined;\n    };\n    const tsv = () => {\n      const { range: rg, rawForCopy: raw } = clipboardRef.current;\n      const lines: string[] = [];\n      for (let r = rg.r0; r <= rg.r1; r += 1) {\n        const row: string[] = [];\n        for (let c = rg.c0; c <= rg.c1; c += 1) row.push(raw(toAddress(r, c)).replace(/[\\t\\n\\r]/g, \" \"));\n        lines.push(row.join(\"\\t\"));\n      }\n      return lines.join(\"\\n\");\n    };\n    const onCopy = (e: ClipboardEvent) => {\n      if (!owns() || !e.clipboardData) return;\n      e.preventDefault();\n      e.clipboardData.setData(\"text/plain\", tsv());\n    };\n    const onCut = (e: ClipboardEvent) => {\n      if (!owns() || !e.clipboardData) return;\n      e.preventDefault();\n      e.clipboardData.setData(\"text/plain\", tsv());\n      const { range: rg, applyChanges: apply } = clipboardRef.current;\n      const entries: { address: string; value: string }[] = [];\n      for (let r = rg.r0; r <= rg.r1; r += 1)\n        for (let c = rg.c0; c <= rg.c1; c += 1) entries.push({ address: toAddress(r, c), value: \"\" });\n      apply(entries);\n    };\n    const onPaste = (e: ClipboardEvent) => {\n      if (!owns() || !e.clipboardData) return;\n      const text = e.clipboardData.getData(\"text/plain\");\n      if (!text) return;\n      e.preventDefault();\n      const ctx = clipboardRef.current;\n      const rows = text.replace(/\\r\\n?/g, \"\\n\").replace(/\\n$/, \"\").split(\"\\n\").map((line) => line.split(\"\\t\"));\n      const single = rows.length === 1 && rows[0].length === 1;\n      const { r0, c0 } = ctx.range;\n      // One value pasted over a range fills the range; a block pastes from the top-left corner.\n      const r1 = single ? ctx.range.r1 : Math.min(ctx.rowCount - 1, r0 + rows.length - 1);\n      const c1 = single\n        ? ctx.range.c1\n        : Math.min(ctx.colCount - 1, c0 + Math.max(...rows.map((row) => row.length)) - 1);\n      const entries: { address: string; value: string }[] = [];\n      for (let r = r0; r <= r1; r += 1)\n        for (let c = c0; c <= c1; c += 1) {\n          const v = single ? rows[0][0] : rows[r - r0]?.[c - c0];\n          if (v === undefined) continue;\n          entries.push({ address: toAddress(r, c), value: normalizeInput(v, ctx.columns[c]?.type ?? \"text\") });\n        }\n      ctx.applyChanges(entries);\n      setAnchor({ r: r0, c: c0 });\n      ctx.select({ r: r1, c: c1 }, true);\n    };\n    document.addEventListener(\"copy\", onCopy);\n    document.addEventListener(\"cut\", onCut);\n    document.addEventListener(\"paste\", onPaste);\n    return () => {\n      document.removeEventListener(\"copy\", onCopy);\n      document.removeEventListener(\"cut\", onCut);\n      document.removeEventListener(\"paste\", onPaste);\n    };\n  }, []);\n\n  /* ---------- pointer ---------- */\n\n  const handleCellPointerDown = (e: ReactMouseEvent, pos: Pos) => {\n    if (e.button !== 0) return;\n    if (editRef.current && editRef.current.r === pos.r && editRef.current.c === pos.c) return;\n    if (editRef.current) commit(\"stay\");\n    dragging.current = true;\n    select(pos, e.shiftKey);\n  };\n\n  const handleCellPointerEnter = (pos: Pos) => {\n    if (!dragging.current) return;\n    setActive(pos);\n  };\n\n  const selectColumn = (c: number) => {\n    if (editRef.current) commit(\"stay\");\n    setAnchor({ r: 0, c });\n    pendingFocus.current = true;\n    setActive({ r: rowCount - 1, c });\n  };\n  const selectRow = (r: number) => {\n    if (editRef.current) commit(\"stay\");\n    setAnchor({ r, c: 0 });\n    pendingFocus.current = true;\n    setActive({ r, c: colCount - 1 });\n  };\n\n  /* ---------- geometry ---------- */\n\n  const widths = columns.map((col) => col.width ?? DEFAULT_COL_W);\n  const lefts: number[] = [];\n  let tableWidth = ROWHEAD_W;\n  for (const w of widths) {\n    lefts.push(tableWidth);\n    tableWidth += w;\n  }\n  const rectOf = (rg: { r0: number; c0: number; r1: number; c1: number }) => ({\n    left: lefts[rg.c0],\n    top: HEAD_H + rg.r0 * ROW_H,\n    width: lefts[rg.c1] + widths[rg.c1] - lefts[rg.c0],\n    height: (rg.r1 - rg.r0 + 1) * ROW_H,\n  });\n\n  const activeRaw = cells[activeAddress] ?? \"\";\n  const refHighlights = useMemo(\n    () =>\n      referencedRanges(editing ? (editing.value.startsWith(\"=\") ? editing.value : \"\") : activeRaw).filter(\n        (rg) => rg.r1 < rowCount && rg.c1 < colCount,\n      ),\n    [activeRaw, editing, rowCount, colCount],\n  );\n\n  /* ---------- status ---------- */\n\n  const stats = useMemo(() => {\n    let sum = 0;\n    let count = 0;\n    let filled = 0;\n    for (let r = range.r0; r <= range.r1; r += 1)\n      for (let c = range.c0; c <= range.c1; c += 1) {\n        const res = evaluate(toAddress(r, c));\n        if (res.kind !== \"empty\") filled += 1;\n        if (res.kind === \"number\") {\n          sum += res.value;\n          count += 1;\n        }\n      }\n    return { sum, count, filled };\n  }, [evaluate, range]);\n  const statFormat = formatters[range.c0] ?? formatters[0];\n  const statsType = columns[range.c0]?.type ?? \"number\";\n  const sameType = columns.slice(range.c0, range.c1 + 1).every((col) => (col.type ?? \"text\") === statsType);\n  const formatStat = (n: number) =>\n    sameType && statsType !== \"text\" ? statFormat.format(n) : new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(n);\n\n  const rangeLabel = isMulti\n    ? `${toAddress(range.r0, range.c0)}:${toAddress(range.r1, range.c1)}`\n    : activeAddress;\n\n  const barValue = editing ? editing.value : toEditText(activeRaw, typeAt(active.c));\n\n  /* ---------- render ---------- */\n\n  const gridLine = isDark ? \"border-[#232323]\" : \"border-[#efe6d8]\";\n  const headCell = isDark ? \"bg-[#161616] text-[#ededed]/40\" : \"bg-[#fbf7ef] text-[#171717]/50\";\n  const headActive = isDark ? \"bg-[#1f1a17] text-[#d86a2c]\" : \"bg-[#f6e8dc] text-[#bd4514]\";\n  const bodyBg = isDark ? \"bg-[#111]\" : \"bg-[#fffcf6]\";\n\n  return (\n    <div\n      className={cn(\n        \"mx-auto w-full max-w-[1040px] overflow-hidden rounded-[18px] border text-[13px]\",\n        palette.container,\n        className,\n      )}\n    >\n      {/* Title + formula bar */}\n      <div className={cn(\"flex items-center gap-2.5 border-b px-4 py-2.5\", palette.divider)}>\n        <div className={cn(\"min-w-0 truncate font-medium\", palette.primaryText)}>{title}</div>\n        <span className={cn(\"ml-auto hidden shrink-0 text-[12px] sm:inline\", palette.secondaryText)}>\n          {readOnly ? \"View only\" : \"Autosaved\"}\n        </span>\n      </div>\n      <div className={cn(\"flex h-10 items-stretch border-b\", palette.divider)}>\n        <div\n          className={cn(\n            \"flex w-[84px] shrink-0 items-center border-r px-3 font-medium tabular-nums\",\n            palette.divider,\n            palette.primaryText,\n          )}\n        >\n          <span className=\"sr-only\">Selection </span>\n          <span className=\"truncate\">{rangeLabel}</span>\n        </div>\n        <label className={cn(\"flex min-w-0 flex-1 items-center gap-2 px-3\", palette.secondaryText)}>\n          <SquareFunction aria-hidden size={15} strokeWidth={1.7} className={palette.accent} />\n          <span className=\"sr-only\">Contents of {activeAddress}</span>\n          <input\n            ref={barRef}\n            value={barValue}\n            readOnly={readOnly}\n            spellCheck={false}\n            autoComplete=\"off\"\n            onFocus={() => {\n              if (readOnly) return;\n              const cur = editRef.current;\n              if (cur) setEditing({ ...cur, source: \"bar\", mode: \"edit\" });\n              else beginEdit(\"edit\", undefined, \"bar\");\n            }}\n            onChange={(e) => {\n              const cur = editRef.current;\n              if (cur) setEditing({ ...cur, value: e.target.value });\n            }}\n            onKeyDown={(e) => handleEditorKeyDown(e, \"bar\")}\n            onBlur={() => {\n              if (editRef.current?.source === \"bar\") {\n                const e = editRef.current;\n                setEditing(null);\n                applyChanges([{ address: toAddress(e.r, e.c), value: normalizeInput(e.value, typeAt(e.c)) }]);\n              }\n            }}\n            className={cn(\n              \"h-full min-w-0 flex-1 bg-transparent font-mono text-[12.5px] outline-none\",\n              palette.primaryText,\n            )}\n          />\n        </label>\n      </div>\n\n      {/* Grid */}\n      <div\n        ref={scrollRef}\n        role=\"region\"\n        aria-label={`${title} sheet`}\n        className=\"relative overflow-auto overscroll-contain\"\n        style={{ maxHeight, scrollPaddingTop: HEAD_H, scrollPaddingLeft: ROWHEAD_W }}\n      >\n        <div className=\"relative\" style={{ width: tableWidth }}>\n          <table\n            ref={gridRef}\n            role=\"grid\"\n            aria-label={title}\n            aria-rowcount={rowCount + 1}\n            aria-colcount={colCount + 1}\n            aria-multiselectable\n            aria-readonly={readOnly || undefined}\n            onKeyDown={handleGridKeyDown}\n            className=\"table-fixed border-separate border-spacing-0\"\n            style={{ width: tableWidth }}\n          >\n            <caption className=\"sr-only\">\n              {title}. Arrow keys move between cells, Shift extends the selection, type or press Enter to edit, Escape\n              cancels.\n            </caption>\n            <colgroup>\n              <col style={{ width: ROWHEAD_W }} />\n              {widths.map((w, i) => (\n                <col key={i} style={{ width: w }} />\n              ))}\n            </colgroup>\n            <thead>\n              <tr role=\"row\" aria-rowindex={1}>\n                <th\n                  role=\"columnheader\"\n                  scope=\"col\"\n                  aria-colindex={1}\n                  className={cn(\"sticky left-0 top-0 z-30 border-b border-r p-0\", gridLine, headCell)}\n                  style={{ height: HEAD_H }}\n                >\n                  <span className=\"sr-only\">Row</span>\n                </th>\n                {columns.map((_, c) => {\n                  const on = c >= range.c0 && c <= range.c1;\n                  return (\n                    <th\n                      key={c}\n                      role=\"columnheader\"\n                      scope=\"col\"\n                      aria-colindex={c + 2}\n                      onMouseDown={(e) => {\n                        e.preventDefault();\n                        selectColumn(c);\n                      }}\n                      className={cn(\n                        \"sticky top-0 z-20 cursor-pointer select-none border-b border-r p-0 text-center text-[11px] font-medium tracking-[0.08em] tabular-nums\",\n                        gridLine,\n                        on ? headActive : headCell,\n                      )}\n                      style={{ height: HEAD_H }}\n                    >\n                      {columnLetter(c)}\n                    </th>\n                  );\n                })}\n              </tr>\n            </thead>\n            <tbody>\n              {Array.from({ length: rowCount }, (_, r) => {\n                const rowOn = r >= range.r0 && r <= range.r1;\n                const isHeader = r < headerRows;\n                const isTotal = totals.has(r + 1);\n                const enter = shouldAnimate\n                  ? {\n                      initial: { opacity: 0, y: 4 },\n                      animate: { opacity: 1, y: 0 },\n                      transition: { type: \"spring\" as const, stiffness: 520, damping: 40, delay: Math.min(r, 16) * 0.02 },\n                    }\n                  : { initial: false as const };\n                return (\n                  <motion.tr key={r} role=\"row\" aria-rowindex={r + 2} {...enter}>\n                    <th\n                      role=\"rowheader\"\n                      scope=\"row\"\n                      aria-colindex={1}\n                      onMouseDown={(e) => {\n                        e.preventDefault();\n                        selectRow(r);\n                      }}\n                      className={cn(\n                        \"sticky left-0 z-20 cursor-pointer select-none border-b border-r px-2 text-right text-[11px] font-medium tabular-nums\",\n                        gridLine,\n                        rowOn ? headActive : headCell,\n                      )}\n                      style={{ height: ROW_H }}\n                    >\n                      {r + 1}\n                    </th>\n                    {columns.map((col, c) => {\n                      const address = toAddress(r, c);\n                      const type = col.type ?? \"text\";\n                      const isActive = r === active.r && c === active.c;\n                      const inRange = rowOn && c >= range.c0 && c <= range.c1;\n                      const isEditingHere = editing && editing.r === r && editing.c === c;\n                      const res = evaluate(address);\n                      const numeric = type !== \"text\";\n                      let display = \"\";\n                      let tone: \"normal\" | \"muted\" | \"error\" | \"negative\" = \"normal\";\n                      let tip: string | undefined;\n                      if (isEditingHere && editing.source === \"bar\") display = editing.value;\n                      else if (res.kind === \"number\") {\n                        display = formatters[c].format(res.value);\n                        if (res.value < 0 && numeric) tone = \"negative\";\n                      } else if (res.kind === \"text\") {\n                        display = res.value;\n                        if (numeric && !isHeader) tone = \"muted\";\n                      } else if (res.kind === \"error\") {\n                        display = res.code;\n                        tone = \"error\";\n                        tip = res.message;\n                      }\n                      const alignRight = numeric && !(res.kind === \"text\" && !isHeader);\n                      return (\n                        <td\n                          key={c}\n                          role=\"gridcell\"\n                          data-cell={address}\n                          aria-colindex={c + 2}\n                          aria-selected={inRange}\n                          aria-label={tip ? `${display}, ${tip}` : undefined}\n                          tabIndex={isActive ? 0 : -1}\n                          title={tip}\n                          onMouseDown={(e) => handleCellPointerDown(e, { r, c })}\n                          onMouseEnter={() => handleCellPointerEnter({ r, c })}\n                          onDoubleClick={() => {\n                            if (!editRef.current) beginEdit(\"edit\");\n                          }}\n                          onFocus={() => {\n                            if (!isActive && !editRef.current) {\n                              setActive({ r, c });\n                              setAnchor({ r, c });\n                            }\n                          }}\n                          className={cn(\n                            \"relative overflow-hidden whitespace-nowrap border-b border-r px-2.5 outline-none\",\n                            gridLine,\n                            bodyBg,\n                            isHeader && cn(\"text-[12px] font-medium\", palette.secondaryText),\n                            !isHeader && palette.primaryText,\n                            isTotal && \"font-semibold\",\n                            isTotal && (isDark ? \"border-t-[#3a3a3a]\" : \"border-t-[#d9ccb8]\"),\n                            alignRight ? \"text-right tabular-nums\" : \"text-left\",\n                            tone === \"muted\" && palette.secondaryText,\n                            tone === \"negative\" && red.textColor,\n                            tone === \"error\" && cn(\"font-medium\", red.textColor),\n                          )}\n                          style={{ height: ROW_H, borderTopWidth: isTotal ? 1 : 0, borderTopStyle: \"solid\" }}\n                        >\n                          {isEditingHere && editing.source === \"cell\" ? (\n                            <input\n                              autoFocus\n                              aria-label={`Edit ${address}`}\n                              value={editing.value}\n                              spellCheck={false}\n                              autoComplete=\"off\"\n                              onChange={(e) => {\n                                const cur = editRef.current;\n                                if (cur) setEditing({ ...cur, value: e.target.value });\n                              }}\n                              onKeyDown={(e) => handleEditorKeyDown(e, \"cell\")}\n                              onBlur={(e) => {\n                                if (e.relatedTarget === barRef.current) return;\n                                if (editRef.current?.source === \"cell\") {\n                                  const cur = editRef.current;\n                                  setEditing(null);\n                                  applyChanges([\n                                    { address: toAddress(cur.r, cur.c), value: normalizeInput(cur.value, typeAt(cur.c)) },\n                                  ]);\n                                }\n                              }}\n                              onFocus={(e) => {\n                                const len = e.currentTarget.value.length;\n                                e.currentTarget.setSelectionRange(len, len);\n                              }}\n                              className={cn(\n                                \"absolute inset-0 z-10 w-full px-2.5 font-normal outline-none\",\n                                bodyBg,\n                                palette.primaryText,\n                                editing.value.startsWith(\"=\") ? \"text-left font-mono text-[12.5px]\" : alignRight ? \"text-right tabular-nums\" : \"text-left\",\n                              )}\n                            />\n                          ) : (\n                            <span className=\"block truncate\">{display}</span>\n                          )}\n                        </td>\n                      );\n                    })}\n                  </motion.tr>\n                );\n              })}\n            </tbody>\n          </table>\n\n          {/* Selection overlay: range tint, formula references, active cell. */}\n          <div aria-hidden className=\"pointer-events-none absolute inset-0 z-[15]\">\n            {refHighlights.map((rg, i) => (\n              <div\n                key={`ref-${i}`}\n                className=\"absolute rounded-[2px] border border-dashed\"\n                style={{ ...rectOf(rg), borderColor: `${accentHex}b3` }}\n              />\n            ))}\n            {isMulti ? (\n              <div\n                className={cn(\"absolute border\", palette.accentBg)}\n                style={{ ...rectOf(range), borderColor: accentHex }}\n              />\n            ) : null}\n            <div\n              className=\"absolute rounded-[2px]\"\n              style={{\n                ...rectOf({ r0: active.r, c0: active.c, r1: active.r, c1: active.c }),\n                boxShadow: `inset 0 0 0 2px ${accentHex}`,\n              }}\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* Status bar */}\n      <div\n        className={cn(\n          \"flex items-center justify-between gap-3 border-t px-4 py-2.5 text-[12px] tabular-nums\",\n          palette.divider,\n          palette.secondaryText,\n        )}\n      >\n        <span className=\"truncate\">\n          {editing ? (editing.value.startsWith(\"=\") ? \"Editing formula\" : \"Editing\") : `${rowCount} rows · ${colCount} columns`}\n        </span>\n        <span className={cn(\"flex shrink-0 items-center gap-3\", stats.count > 1 && palette.primaryText)}>\n          {stats.count > 1 ? (\n            <>\n              <span>Sum {formatStat(stats.sum)}</span>\n              <span className=\"hidden sm:inline\">Avg {formatStat(stats.sum / stats.count)}</span>\n              <span>Count {stats.filled}</span>\n            </>\n          ) : (\n            <span>{isMulti ? `Count ${stats.filled}` : activeAddress}</span>\n          )}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport default SpreadsheetGrid;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/spreadsheet-grid.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/tables/table-theme.ts",
      "content": "import { useEffect, useState } from \"react\";\n\nexport type BjorkTableThemeMode = \"light\" | \"dark\" | \"auto\";\n\nexport function useBjorkTableIsDark(\n  resolvedTheme?: string,\n  forcedTheme?: Exclude<BjorkTableThemeMode, \"auto\">\n) {\n  const [documentTheme, setDocumentTheme] = useState<\"light\" | \"dark\" | null>(null);\n  const [previewTheme, setPreviewTheme] = useState<\"light\" | \"dark\" | null>(null);\n  // Server and first client render must agree, otherwise React keeps the\n  // server-rendered (dark) classes when the real theme resolves to light.\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n    const syncDocumentTheme = () => {\n      const root = document.documentElement;\n      const searchParams = new URLSearchParams(window.location.search);\n      const theme = searchParams.get(\"theme\");\n\n      setPreviewTheme(theme === \"light\" || theme === \"dark\" ? theme : null);\n\n      setDocumentTheme(\n        root.classList.contains(\"light\")\n          ? \"light\"\n          : root.classList.contains(\"dark\")\n            ? \"dark\"\n            : null\n      );\n    };\n\n    syncDocumentTheme();\n\n    const observer = new MutationObserver(syncDocumentTheme);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const activeTheme =\n    forcedTheme ?? previewTheme ?? documentTheme ?? (mounted ? resolvedTheme : undefined);\n\n  return activeTheme !== \"light\";\n}\n\nexport function getBjorkTablePalette(isDark: boolean) {\n  return {\n    container: isDark\n      ? \"border-[#1c1c1c] bg-[#121212] shadow-[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      : \"border-[#f5ede2] bg-[#fffcf6] shadow-[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    header: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/42\"\n      : \"border-[#f1e8dc] bg-[#fbf7ef] text-[#171717]/60\",\n    row: isDark\n      ? \"border-[#232323] hover:bg-[#181818]/70\"\n      : \"border-[#f1e8dc] hover:bg-[#f8f2e7]/72\",\n    selectedRow: isDark\n      ? \"border-[#2a2a2a] bg-[#181818]\"\n      : \"border-[#eadfce] bg-[#f8f2e7]\",\n    control: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/70 hover:bg-[#202020] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)]\"\n      : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/68 shadow-[var(--bjork-shadow-soft)] hover:bg-[#f8f2e7]\",\n    menu: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/76 shadow-[inset_0_1px_0_rgba(255,255,255,0.055),inset_0_12px_24px_rgba(255,255,255,0.018),0_22px_42px_-22px_rgba(0,0,0,0.9)]\"\n      : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/76 shadow-[inset_0_1px_0_rgba(88,72,49,0.04),inset_0_12px_24px_rgba(88,72,49,0.022),0_22px_44px_-26px_rgba(66,52,33,0.22)]\",\n    menuItem: isDark ? \"hover:bg-[#232323]\" : \"hover:bg-[#f8f2e7]\",\n    menuActive: isDark ? \"bg-[#232323]\" : \"bg-[#f8f2e7]\",\n    divider: isDark ? \"border-[#232323]\" : \"border-[#f1e8dc]\",\n    primaryText: isDark ? \"text-[#ededed]/90\" : \"text-[#171717]/86\",\n    secondaryText: isDark ? \"text-[#ededed]/38\" : \"text-[#171717]/60\",\n    mutedSurface: isDark ? \"bg-[#181818]\" : \"bg-[#f8f2e7]\",\n    popover: isDark ? \"bg-[#111] text-[#ededed]\" : \"bg-[#fffcf6] text-[#171717]\",\n    accent: isDark ? \"text-[#d86a2c]\" : \"text-[#bd4514]\",\n    accentBg: isDark ? \"bg-[#ec5c13]/12\" : \"bg-[#ec5c13]/14\",\n    accentBorder: isDark ? \"border-[#ec5c13]/24\" : \"border-[#bd4514]/22\",\n    checkboxAccent: isDark ? \"#d86a2c\" : \"#bd4514\",\n  };\n}\n\nexport function getBjorkSignalPalette(\n  tone: \"green\" | \"red\" | \"orange\" | \"neutral\",\n  isDark: boolean\n) {\n  const palettes = {\n    green: {\n      bgColor: isDark ? \"bg-[#3e6f52]/18\" : \"bg-[#dce8dc]\",\n      borderColor: isDark ? \"border-[#5f9f78]/28\" : \"border-[#bad0bd]\",\n      textColor: isDark ? \"text-[#77b58b]\" : \"text-[#47785c]\",\n      dotColor: isDark ? \"bg-[#77b58b]\" : \"bg-[#47785c]\",\n      color: isDark ? \"#77b58b\" : \"#47785c\",\n    },\n    red: {\n      bgColor: isDark ? \"bg-[#87463f]/18\" : \"bg-[#eadad5]\",\n      borderColor: isDark ? \"border-[#c46a61]/28\" : \"border-[#d7bcb4]\",\n      textColor: isDark ? \"text-[#cf7c72]\" : \"text-[#9d5149]\",\n      dotColor: isDark ? \"bg-[#cf7c72]\" : \"bg-[#9d5149]\",\n      color: isDark ? \"#cf7c72\" : \"#9d5149\",\n    },\n    orange: {\n      bgColor: isDark ? \"bg-[#ec5c13]/14\" : \"bg-[#ead9cb]\",\n      borderColor: isDark ? \"border-[#ec5c13]/28\" : \"border-[#d5b59f]\",\n      textColor: isDark ? \"text-[#d86a2c]\" : \"text-[#9f4317]\",\n      dotColor: isDark ? \"bg-[#d86a2c]\" : \"bg-[#9f4317]\",\n      color: isDark ? \"#d86a2c\" : \"#9f4317\",\n    },\n    neutral: {\n      bgColor: isDark ? \"bg-[#ededed]/8\" : \"bg-[#ded7ca]\",\n      borderColor: isDark ? \"border-[#ededed]/14\" : \"border-[#cfc6b7]\",\n      textColor: isDark ? \"text-[#ededed]/58\" : \"text-[#171717]/60\",\n      dotColor: isDark ? \"bg-[#ededed]/45\" : \"bg-[#171717]/42\",\n      color: isDark ? \"#a3a3a3\" : \"#706b62\",\n    },\n  } as const;\n\n  return palettes[tone];\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/table-theme.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/spreadsheet-grid.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/spreadsheet-grid.json"
  },
  "type": "registry:component"
}