{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sheet",
  "title": "Sheet",
  "description": "A spreadsheet in suggesting mode: people edit cells in place, an agent's edits arrive as proposals drawn on the cells they would change, accepted or skipped per proposal. Backed by a Cube store.",
  "dependencies": [
    "usecube",
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/sheet/Sheet.tsx",
      "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { ArrowDown, ArrowUp, BetweenVerticalStart, Check, Copy, Eraser, PenLine, Plus, Search, Settings2, Trash2, X, type LucideIcon } from 'lucide-react'\r\nimport type { Operation, Proposal } from 'usecube'\r\nimport { useCube } from 'usecube/react'\r\nimport type { Cube } from 'usecube'\r\nimport { PageBones } from './Bones'\r\nimport ColumnSettings from './ColumnSettings'\r\nimport { ConfirmDialog } from './dialog'\r\nimport { ProposalCard, ShadowRows, ShadowValue, colShade, livePlaces, rowShades, shadeOf, sheetAt, text, useFocus, type RowShade } from './Shadow'\r\nimport { mintId, orderAfter, sorted, type Column, type SheetStore, type Value } from './store'\r\nimport { MENU_BOX, MENU_DANGER, MENU_ROW, useScrollFade } from './ui'\r\n\r\n// THE SHEET (Cube's Doc/ws/SheetView.tsx, moved onto the Cube core) — the same table, menus, editor, badges and shadows; the state is a\r\n// Cube store laid out as /columns/{colId} and /rows/{rowId} (store.ts). Everything a person does here is one store.edit() with\r\n// JSON Patch, applied at once; everything an agent does arrives as a proposal, drawn in place (Shadow.tsx) until someone says Do or\r\n// Skip, for one cell or the whole proposal, or Accept all / Reject all (Cmd/Ctrl+Enter, Cmd/Ctrl+Backspace) for everything at once.\r\n// One click selects a cell, a double-click / Enter / F2 / typing edits it; right-click for the row and column menus.\r\n// READ-ONLY (`readOnly`) is a table of what others sent, a form's answers (Tally, Typeform, Google Forms): select, copy, sort,\r\n// filter, resize, and Do / Skip what an agent proposes; nothing is authored there.\r\n//\r\n//   import { Sheet } from '@/components/sheet/Sheet'\r\n//   import { sheetStore } from '@/components/sheet/store'\r\n//   const store = sheetStore({ columns: {...}, rows: {...} }, { name: 'pipeline' })\r\n//   <div className=\"h-[480px]\"><Sheet store={store} /></div>          // it fills its box and scrolls inside\r\n//   const tools = store.tools({ by: 'Claude' })                         // pipeline_read, pipeline_propose\r\n//\r\n// ONE STORE, SEVERAL COMPONENTS — `at` is where the sheet lives in the store (a JSON Pointer, \"\" for the whole store): every pointer\r\n// the sheet writes or reads is under it, so a sheet and a doc can share one store and one agent's proposal can change both.\r\n//   <Sheet store={workspace} at=\"/sheets/pipeline\" />\r\n\r\nconst th =\r\n  'relative sticky top-0 z-20 border-b border-t border-r border-gray-200 bg-white px-2 py-1 text-left text-sheet-ui font-medium text-gray-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400'\r\n// THE HEADER BLEEDS UPWARD — two pixels of the header's own colour painted above it cover a hairline of scrolled rows a sub-pixel\r\n// scroll offset can leave showing over the stuck header\r\nconst thBleed = 'shadow-[0_-2px_0_0_#fff] dark:shadow-[0_-2px_0_0_#18181b]'\r\nconst td =\r\n  'overflow-hidden text-ellipsis border-b border-r border-gray-200 bg-white px-2 py-1 whitespace-nowrap dark:border-zinc-800 dark:bg-zinc-900'\r\n\r\ntype Focus = { row: string; col: string }\r\n\r\n// the keys as the platform writes them\r\nconst MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)\r\nconst MOD = MAC ? '⌘' : 'Ctrl+'\r\nconst ENTER = MAC ? '⏎' : 'Enter'\r\nconst BACKSPACE = MAC ? '⌫' : 'Backspace'\r\ntype Col = Column & { id: string; /** the first column: the row's name, never removed */ key: boolean; /** only a proposal has it so far */ proposed: boolean }\r\n\r\nexport function Sheet({\r\n  store,\r\n  at: base = '',\r\n  loading = false,\r\n  compact = false,\r\n  bulk = true,\r\n  inlineNewRows = false,\r\n  readOnly = false,\r\n}: {\r\n  /** a sheet store, or any store holding a sheet at `at` */\r\n  store: SheetStore | Cube<any>\r\n  /** where the sheet lives in the store, as a JSON Pointer (\"/sheets/pipeline\"); \"\" when the store is the sheet */\r\n  at?: string\r\n  /** the sheet is still on its way: bones, never an empty sheet */\r\n  loading?: boolean\r\n  /** the small, embedded form (a sheet inside a doc, a chat reply, a post): no Filter button, and the bar only while it has something to say */\r\n  compact?: boolean\r\n  /** show Accept all / Reject all (and their keys); false inside a Doc, whose own bar already takes this sheet's changes */\r\n  bulk?: boolean\r\n  /** a row a proposal would add is drawn in the table, in its place among the others (green values, a press opens its Do / Skip),\r\n      not listed under it */\r\n  inlineNewRows?: boolean\r\n  /** nothing is authored here (a form's answers): no New column / New row, no rename, no cell edits, no column or row menus;\r\n      a cell's menu is Copy */\r\n  readOnly?: boolean\r\n}) {\r\n  const snap = useCube(store as Cube<unknown>)\r\n  const state = useMemo(() => sheetAt(snap.state, base), [snap.state, base])\r\n  const preview = useMemo(() => sheetAt(snap.preview, base), [snap.preview, base])\r\n  // the proposals this sheet draws: those touching it (a proposal may change other things in the store too)\r\n  const proposals = useMemo(() => snap.proposals.filter((p) => touches(p, base)), [snap.proposals, base])\r\n  // COLUMN RENAME, IN THE HEADER — double-click the name, type, Enter / blur commit, Esc cancels\r\n  const [renamingCol, setRenamingCol] = useState<{ id: string; value: string } | null>(null)\r\n  // the column whose settings are open (right-click a header -> Column settings)\r\n  const [settingsCol, setSettingsCol] = useState<string | null>(null)\r\n  // THE FILTER IS A BUTTON UNTIL IT IS USED — the sort capsule's size in black; pressing it puts the box in its place, focused\r\n  const [filtering, setFiltering] = useState(false)\r\n  const [q, setQ] = useState('')\r\n  const [sort, setSort] = useState<{ col: string; dir: 'asc' | 'desc' } | null>(null)\r\n  const [focus, setFocus] = useState<Focus | null>(null)\r\n  // the selected row (its cells tint) and the selected column (its header rings, its cells tint)\r\n  const [selRow, setSelRow] = useState<string | null>(null)\r\n  const [selCol, setSelCol] = useState<string | null>(null)\r\n  const [edit, setEdit] = useState<(Focus & { value: string }) | null>(null)\r\n  const [menu, setMenu] = useState<{ x: number; y: number; row?: string; col: string } | null>(null)\r\n  // the ENUM PICKER — a box over the cell, same grammar as the text editor\r\n  const [pick, setPick] = useState<{ row: string; col: string } | null>(null)\r\n  // A PROPOSAL'S CARD, and the proposal under the pointer: both deepen that proposal's changes wherever they show\r\n  const [card, setCard] = useState<{ pid: string; anchor: DOMRect; /** the cell or row it was opened from */ place: string } | null>(null)\r\n  // the proposal under the pointer, shared with every component on this store\r\n  const [hot, setHot] = useFocus(store)\r\n  const deep = hot ?? card?.pid ?? null\r\n  const scRef = useRef<HTMLDivElement>(null)\r\n  // MORE TO THE SIDE — a fade at an edge the table runs past, stopping short of the scrollbars' own footprint\r\n  // the fade is as tall as the table still showing: the proposed rows listed under it never scroll sideways, so it stays off them\r\n  const [edges, setEdges] = useState({ left: false, right: false, sbV: 0, h: 0 })\r\n  const placeAdd = useCallback(() => {\r\n    const sc = scRef.current\r\n    if (!sc) return\r\n    const tb = sc.querySelector('table')\r\n    const more = {\r\n      left: sc.scrollLeft > 1,\r\n      right: sc.scrollLeft + sc.clientWidth < sc.scrollWidth - 1,\r\n      sbV: sc.offsetWidth - sc.clientWidth,\r\n      h: Math.max(0, Math.min(sc.clientHeight, (tb?.offsetHeight ?? 0) - sc.scrollTop)),\r\n    }\r\n    setEdges((e) => (e.left === more.left && e.right === more.right && e.sbV === more.sbV && e.h === more.h ? e : more))\r\n  }, [])\r\n  const rootRef = useRef<HTMLDivElement>(null)\r\n  const menuRef = useRef<HTMLDivElement>(null)\r\n  // COLUMN WIDTHS — drag the seam at a header's right edge; remembered in this browser (a way of looking, never the data)\r\n  const widthKey = useMemo(() => `cube-sheet-colw-${store.tools()[0]?.name ?? 'sheet'}${base}`, [store, base])\r\n  const [widths, setWidths] = useState<Record<string, number>>(() => {\r\n    try {\r\n      return JSON.parse(localStorage.getItem(widthKey) || '{}')\r\n    } catch {\r\n      return {}\r\n    }\r\n  })\r\n  const widthPx = (c: Col) => widths[c.id] ?? (c.key ? 176 : c.type === 'number' || c.type === 'enum' ? 112 : 160)\r\n  const startResize = (col: Col) => (e: React.PointerEvent) => {\r\n    e.preventDefault()\r\n    e.stopPropagation() // the header's own press selects the column, not this one\r\n    const sx = e.clientX\r\n    const from = widthPx(col)\r\n    document.body.classList.add('sheet-col-resizing')\r\n    const move = (ev: PointerEvent) => setWidths((cur) => ({ ...cur, [col.id]: Math.max(40, Math.round(from + (ev.clientX - sx))) }))\r\n    const up = () => {\r\n      document.body.classList.remove('sheet-col-resizing')\r\n      window.removeEventListener('pointermove', move)\r\n      window.removeEventListener('pointerup', up)\r\n      setWidths((cur) => {\r\n        try {\r\n          localStorage.setItem(widthKey, JSON.stringify(cur))\r\n        } catch {}\r\n        return cur\r\n      })\r\n    }\r\n    window.addEventListener('pointermove', move)\r\n    window.addEventListener('pointerup', up)\r\n  }\r\n\r\n  // menus close on any press ELSEWHERE / a wheel / Esc\r\n  useEffect(() => {\r\n    if (!menu) return undefined\r\n    const close = (e?: Event) => {\r\n      if (e && menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) return\r\n      setMenu(null)\r\n    }\r\n    const onKey = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape') close()\r\n    }\r\n    window.addEventListener('pointerdown', close, true)\r\n    window.addEventListener('wheel', close, { passive: true })\r\n    window.addEventListener('keydown', onKey)\r\n    return () => {\r\n      window.removeEventListener('pointerdown', close, true)\r\n      window.removeEventListener('wheel', close)\r\n      window.removeEventListener('keydown', onKey)\r\n    }\r\n  }, [menu])\r\n\r\n  // the columns: the sheet's own, and any a proposal would add (drawn with their proposed name) — in their order\r\n  const cols = useMemo<Col[]>(() => {\r\n    const ids = new Set([...Object.keys(state.columns ?? {}), ...Object.keys(preview.columns ?? {})])\r\n    const all: Record<string, Column> = {}\r\n    for (const id of ids) all[id] = state.columns[id] ?? preview.columns[id]\r\n    const keyId = sorted(state.columns)[0]?.[0]\r\n    return sorted(all).map(([id, c]) => ({ ...c, id, key: id === keyId, proposed: !state.columns[id] }))\r\n  }, [state.columns, preview.columns])\r\n  const live = (id: string) => cols.find((c) => c.id === id && !c.proposed)\r\n  const valueOf = (row: string, col: string): Value | undefined => state.rows[row]?.cells?.[col]\r\n\r\n  // ---- writes: every one is a person's edit, applied at once ----\r\n  const write = (ops: Operation[]) => {\r\n    // a read-only sheet writes nothing, whatever asks\r\n    if (!ops.length || readOnly) return\r\n    try {\r\n      store.edit(ops)\r\n    } catch (e) {\r\n      // a refused write leaves the sheet as it was\r\n      console.warn(e)\r\n    }\r\n  }\r\n  const at = (...keys: string[]) => base + '/' + keys.map((k) => k.replace(/~/g, '~0').replace(/\\//g, '~1')).join('/')\r\n  // + NEW COLUMN ADDS ONE, NO DIALOG (Excel, Google Sheets) — a text column named \"Column N\", right of `after` or at the end\r\n  const addColumn = (after?: string): Col | undefined => {\r\n    const own = cols.filter((c) => !c.proposed)\r\n    const names = new Set(own.map((c) => c.name))\r\n    let n = own.length + 1\r\n    while (names.has(`Column ${n}`)) n++\r\n    const i = after ? own.findIndex((c) => c.id === after) : -1\r\n    const id = mintId('c')\r\n    const col: Column = { name: `Column ${n}`, type: 'text', order: orderAfter(own, i < 0 ? undefined : i) }\r\n    write([{ op: 'add', path: at('columns', id), value: col }])\r\n    return { ...col, id, key: own.length === 0, proposed: false }\r\n  }\r\n  const keyCol = () => cols.find((c) => c.key)\r\n  // A PERSON ADDS A ROW — it exists the moment it appears, empty, under the row you right-clicked or at the end; the New row button\r\n  // selects its key cell so typing fills it, the menu's Add row below leaves the selection where it was\r\n  const addRow = (opts: { after?: string; select?: boolean } = {}) => {\r\n    const { after, select: take = true } = opts\r\n    // a sheet with no columns still takes a row: its first column is made here, and is the key\r\n    const key = keyCol() ?? addColumn()\r\n    if (!key) return\r\n    const list = sorted(sheetAt(store.snapshot().state, base).rows)\r\n    const i = after ? list.findIndex(([id]) => id === after) : -1\r\n    const id = mintId('r')\r\n    setQ('')\r\n    write([{ op: 'add', path: at('rows', id), value: { order: orderAfter(list.map(([, r]) => r), i < 0 ? undefined : i), cells: {} } }])\r\n    if (!take) return\r\n    requestAnimationFrame(() => {\r\n      setFocus({ row: id, col: key.id })\r\n      setSelRow(id)\r\n      rootRef.current?.querySelector<HTMLElement>(`td[data-cell=\"${id}:${key.id}\"]`)?.focus()\r\n    })\r\n  }\r\n  const renameCol = (col: Col, next: string) => {\r\n    setRenamingCol(null)\r\n    const name = next.trim()\r\n    if (!name || name === col.name) return\r\n    write([{ op: 'replace', path: at('columns', col.id, 'name'), value: name }])\r\n  }\r\n  const removeRows = (ids: string[]) => write(ids.map((id) => ({ op: 'remove', path: at('rows', id) }) as Operation))\r\n  const removeCols = (ids: string[]) =>\r\n    write([\r\n      ...Object.entries(state.rows).flatMap(([r, row]) =>\r\n        ids.filter((c) => row.cells && c in row.cells).map((c) => ({ op: 'remove', path: at('rows', r, 'cells', c) }) as Operation),\r\n      ),\r\n      ...ids.filter((c) => state.columns[c]).map((c) => ({ op: 'remove', path: at('columns', c) }) as Operation),\r\n    ])\r\n  const setCell = (row: string, col: string, v: Value | undefined): Operation[] => {\r\n    const before = valueOf(row, col)\r\n    if (v === undefined || v === '') return before === undefined ? [] : [{ op: 'remove', path: at('rows', row, 'cells', col) }]\r\n    return before === v ? [] : [{ op: 'add', path: at('rows', row, 'cells', col), value: v }]\r\n  }\r\n\r\n  const activeSort = sort\r\n  const needle = q.trim().toLowerCase()\r\n  const matches = useCallback((cells?: Record<string, unknown>) => !needle || cols.some((c) => text(cells?.[c.id]).toLowerCase().includes(needle)), [needle, cols])\r\n  const bySort = useCallback(\r\n    (a: { cells?: Record<string, unknown> }, b: { cells?: Record<string, unknown> }) => {\r\n      if (!activeSort) return 0\r\n      const ka = text(a.cells?.[activeSort.col])\r\n      const kb = text(b.cells?.[activeSort.col])\r\n      const na = Number(ka)\r\n      const nb = Number(kb)\r\n      const cmp = !Number.isNaN(na) && !Number.isNaN(nb) && ka !== '' && kb !== '' ? na - nb : ka.localeCompare(kb)\r\n      return activeSort.dir === 'asc' ? cmp : -cmp\r\n    },\r\n    [activeSort],\r\n  )\r\n  const rows = useMemo(() => {\r\n    let out = sorted(state.rows).map(([id, r]) => ({ id, ...r }))\r\n    if (needle) out = out.filter((r) => matches(r.cells))\r\n    if (activeSort) out = [...out].sort(bySort)\r\n    return out\r\n  }, [state.rows, needle, matches, activeSort, bySort])\r\n  const shadedRows = useMemo(() => rowShades(snap, base), [snap, base])\r\n  // INLINE NEW ROWS — a proposed row takes its place among the rows (by its order, or by the sort), drawn in the suggestion's ink;\r\n  // only the rows a proposal would remove stay listed under the table\r\n  const ghosts = useMemo(() => (inlineNewRows ? shadedRows.filter((s) => s.kind === 'add' && matches(s.cells)) : []), [inlineNewRows, shadedRows, matches])\r\n  const listed = useMemo(() => (inlineNewRows ? shadedRows.filter((s) => s.kind !== 'add') : shadedRows), [inlineNewRows, shadedRows])\r\n  const lines = useMemo(() => {\r\n    const real = rows.map((r, i) => ({ order: r.order, cells: r.cells, real: r, i, ghost: undefined as RowShade | undefined }))\r\n    if (!ghosts.length) return real\r\n    const more = ghosts.map((g) => ({ order: (g.v as { order?: number } | undefined)?.order ?? Infinity, cells: g.cells, real: undefined, i: -1, ghost: g }))\r\n    // a stable sort: the real rows keep the order they already have among themselves\r\n    return [...real, ...more].sort((a, b) => (activeSort ? bySort(a, b) : a.order - b.order))\r\n  }, [rows, ghosts, activeSort, bySort])\r\n\r\n  // A RANGE, THE SPREADSHEET WAY — press on a cell and drag: every cell in the rectangle is selected; shift-click stretches it from the\r\n  // cell already selected. `focus` stays the active cell; Ctrl/Cmd+C copies the range as tab-separated text, Backspace clears it.\r\n  const [range, setRange] = useState<{ a: Focus; b: Focus } | null>(null)\r\n  const dragSel = useRef(false)\r\n  // MORE THAN ONE ROW OR COLUMN ASKS FIRST — one row or one column goes at once\r\n  const [removeAsk, setRemoveAsk] = useState<{ kind: 'rows' | 'cols'; ids: string[] } | null>(null)\r\n  const box = useMemo(() => {\r\n    if (!range) return null\r\n    const ri = new Map(rows.map((r, i) => [r.id, i]))\r\n    const ci = new Map(cols.map((c, i) => [c.id, i]))\r\n    const [ra, rb, ca, cb] = [ri.get(range.a.row), ri.get(range.b.row), ci.get(range.a.col), ci.get(range.b.col)]\r\n    if (ra === undefined || rb === undefined || ca === undefined || cb === undefined) return null\r\n    return { r0: Math.min(ra, rb), r1: Math.max(ra, rb), c0: Math.min(ca, cb), c1: Math.max(ca, cb) }\r\n  }, [range, rows, cols])\r\n  const multi = !!box && (box.r0 !== box.r1 || box.c0 !== box.c1)\r\n  useEffect(() => {\r\n    if (!focus) setRange(null)\r\n  }, [focus])\r\n  useEffect(() => {\r\n    const cellAt = (x: number, y: number) => {\r\n      const el = document.elementFromPoint(x, y)?.closest<HTMLElement>('td[data-r]')\r\n      if (!el || !rootRef.current?.contains(el)) return null\r\n      return { row: el.dataset.r ?? '', col: el.dataset.c ?? '' }\r\n    }\r\n    const grow = (x: number, y: number) => {\r\n      const here = cellAt(x, y)\r\n      if (!here) return\r\n      setRange((prev) => {\r\n        if (!prev || (prev.b.row === here.row && prev.b.col === here.col)) return prev\r\n        window.getSelection()?.removeAllRanges()\r\n        return { a: prev.a, b: here }\r\n      })\r\n    }\r\n    // THE SHEET SCROLLS UNDER A DRAG THAT REACHES ITS EDGE (Excel does) — within 32px of an edge the scroller moves a frame at a time\r\n    const EDGE = 32\r\n    const HEAD = 32\r\n    const NUMS = 64\r\n    let last: { x: number; y: number } | null = null\r\n    let raf = 0\r\n    const tick = () => {\r\n      raf = 0\r\n      const sc = scRef.current\r\n      if (!dragSel.current || !last || !sc) return\r\n      const b = sc.getBoundingClientRect()\r\n      const over = (v: number, lo: number, hi: number) => (v < lo ? v - lo : v > hi ? v - hi : 0)\r\n      const dx = over(last.x, b.left + NUMS + EDGE, b.right - EDGE)\r\n      const dy = over(last.y, b.top + HEAD + EDGE, b.bottom - EDGE)\r\n      if (!dx && !dy) return\r\n      const step = (d: number) => Math.sign(d) * Math.min(24, Math.ceil(Math.abs(d) / 3))\r\n      sc.scrollLeft += step(dx)\r\n      sc.scrollTop += step(dy)\r\n      grow(Math.min(Math.max(last.x, b.left + NUMS + 2), b.right - 12), Math.min(Math.max(last.y, b.top + HEAD + 2), b.bottom - 12))\r\n      raf = requestAnimationFrame(tick)\r\n    }\r\n    const move = (ev: PointerEvent) => {\r\n      if (!dragSel.current) return\r\n      last = { x: ev.clientX, y: ev.clientY }\r\n      grow(ev.clientX, ev.clientY)\r\n      if (!raf) raf = requestAnimationFrame(tick)\r\n    }\r\n    const up = () => {\r\n      dragSel.current = false\r\n      last = null\r\n      if (raf) cancelAnimationFrame(raf)\r\n      raf = 0\r\n      if (rootRef.current) rootRef.current.style.userSelect = ''\r\n    }\r\n    window.addEventListener('pointermove', move)\r\n    window.addEventListener('pointerup', up)\r\n    window.addEventListener('pointercancel', up)\r\n    return () => {\r\n      if (raf) cancelAnimationFrame(raf)\r\n      window.removeEventListener('pointermove', move)\r\n      window.removeEventListener('pointerup', up)\r\n      window.removeEventListener('pointercancel', up)\r\n    }\r\n  }, [])\r\n  const copyRange = useCallback(() => {\r\n    if (!box) return false\r\n    const lines: string[] = []\r\n    for (let r = box.r0; r <= box.r1; r++) {\r\n      const cells: string[] = []\r\n      for (let c = box.c0; c <= box.c1; c++) cells.push(text(rows[r].cells?.[cols[c].id]).replace(/[\\t\\r\\n]+/g, ' '))\r\n      lines.push(cells.join('\\t'))\r\n    }\r\n    void navigator.clipboard?.writeText(lines.join('\\n'))\r\n    return true\r\n  }, [box, rows, cols])\r\n  // the overlay scrollbars show while the sheet is being scrolled, then fade\r\n  const onScrollFade = useScrollFade()\r\n  const onScroll = useCallback(\r\n    (e: React.UIEvent<HTMLDivElement>) => {\r\n      placeAdd()\r\n      onScrollFade(e)\r\n    },\r\n    // eslint-disable-next-line react-hooks/exhaustive-deps\r\n    [placeAdd],\r\n  )\r\n  useEffect(() => {\r\n    placeAdd()\r\n    const sc = scRef.current\r\n    if (!sc) return undefined\r\n    const ro = new ResizeObserver(placeAdd)\r\n    ro.observe(sc)\r\n    const tb = sc.querySelector('table')\r\n    if (tb) ro.observe(tb)\r\n    return () => ro.disconnect()\r\n  }, [placeAdd, rows.length, cols.length])\r\n\r\n  // A SELECTED COLUMN is dismissed by a press anywhere but a cell or header, or by Esc\r\n  useEffect(() => {\r\n    if (!selCol) return undefined\r\n    const down = (ev: PointerEvent) => {\r\n      if (!(ev.target instanceof Element)) return\r\n      if (rootRef.current?.contains(ev.target) && ev.target.closest('td[data-cell], th[data-col]')) return\r\n      setSelCol(null)\r\n      setFocus(null)\r\n    }\r\n    const key = (ev: KeyboardEvent) => {\r\n      if (ev.key !== 'Escape') return\r\n      setSelCol(null)\r\n      setFocus(null)\r\n    }\r\n    window.addEventListener('pointerdown', down, true)\r\n    window.addEventListener('keydown', key)\r\n    return () => {\r\n      window.removeEventListener('pointerdown', down, true)\r\n      window.removeEventListener('keydown', key)\r\n    }\r\n  }, [selCol])\r\n\r\n  // a card whose proposal was decided (here or anywhere else) goes with it\r\n  const cardP = card ? proposals.find((p) => p.id === card.pid) : undefined\r\n  useEffect(() => {\r\n    if (card && !cardP) setCard(null)\r\n  }, [card, cardP])\r\n  // a press on a shadow opens its card there; a second press on the same place closes it\r\n  const openCard = (pid: string, anchor: HTMLElement, place: string) =>\r\n    setCard((c) => (c?.pid === pid && c.place === place ? null : { pid, place, anchor: anchor.getBoundingClientRect() }))\r\n  // A PROPOSAL'S LIVE PLACES: all of it, or (a person changed some of it since) the rest, through `only` — the card's Do all takes\r\n  // all of a proposal, also what it changes outside this sheet\r\n  const acceptLive = (id: string, within?: string) => {\r\n    const p = store.snapshot().proposals.find((x) => x.id === id)\r\n    if (!p) return\r\n    const live = livePlaces(p).filter((path) => within === undefined || inside(path, within))\r\n    if (!live.length) return\r\n    store.accept(id, p.stale || live.length < p.paths.length ? { only: live } : {})\r\n  }\r\n  // ACCEPT ALL / REJECT ALL (Cursor's, and its keys: Cmd/Ctrl+Enter, Cmd/Ctrl+Backspace) — every change drawn on this sheet; an\r\n  // outdated place can only go, and never blocks the rest of its proposal. A proposal that also changes something outside the\r\n  // sheet keeps that part pending, for where it shows.\r\n  const pending = proposals.filter((p) => livePlaces(p).some((path) => inside(path, base))).length\r\n  const acceptAll = () => {\r\n    setCard(null)\r\n    for (const p of store.snapshot().proposals) {\r\n      if (!touches(p, base)) continue\r\n      try {\r\n        acceptLive(p.id, base)\r\n      } catch (e) {\r\n        // it no longer fits (a schema refusal): it stays for its own card to say why\r\n        console.warn(e)\r\n      }\r\n    }\r\n  }\r\n  const rejectAll = () => {\r\n    setCard(null)\r\n    for (const p of store.snapshot().proposals) {\r\n      if (!touches(p, base)) continue\r\n      const mine = p.paths.filter((path) => inside(path, base))\r\n      store.reject(p.id, mine.length < p.paths.length ? { only: mine } : {})\r\n    }\r\n  }\r\n  const keysRef = useRef({ acceptAll, rejectAll, any: proposals.length > 0 })\r\n  keysRef.current = { acceptAll, rejectAll, any: proposals.length > 0 }\r\n  useEffect(() => {\r\n    const key = (e: KeyboardEvent) => {\r\n      if (!(e.metaKey || e.ctrlKey) || e.altKey || (e.key !== 'Enter' && e.key !== 'Backspace')) return\r\n      // words being typed keep their keys (an input, a textarea, an editor anywhere on the page)\r\n      const t = e.target\r\n      if (t instanceof HTMLElement && (t.isContentEditable || t.closest('input, textarea, select, [contenteditable=\"true\"]'))) return\r\n      if (!bulk || !keysRef.current.any) return\r\n      e.preventDefault()\r\n      if (e.key === 'Enter') keysRef.current.acceptAll()\r\n      else keysRef.current.rejectAll()\r\n    }\r\n    window.addEventListener('keydown', key)\r\n    return () => window.removeEventListener('keydown', key)\r\n  }, [bulk])\r\n  const hover = (pid: string | null) => setHot(pid)\r\n\r\n  if (loading && cols.length === 0) return <PageBones />\r\n  const bones = loading && rows.length === 0\r\n  // the row-number column grows with its widest number; while the rows are still coming it reserves two digits\r\n  const rowNumW = 20 + 8 * Math.max(bones ? 2 : 1, String(rows.length).length)\r\n  // while the rows are still coming the rules go a step stronger\r\n  const boneEdge = bones ? ' !border-gray-300 dark:!border-zinc-700' : ''\r\n\r\n  const commitEdit = () => {\r\n    if (!edit) return\r\n    const { row, col, value } = edit\r\n    setEdit(null)\r\n    const column = live(col)\r\n    if (!column) return\r\n    let v: Value | undefined = value\r\n    if (column.type === 'number') {\r\n      const t = value.trim().replace(/,/g, '')\r\n      if (t === '') v = undefined\r\n      else {\r\n        const n = Number(t)\r\n        // not a number: the cell keeps what it had\r\n        if (!Number.isFinite(n)) return\r\n        v = n\r\n      }\r\n    }\r\n    write(setCell(row, col, v))\r\n  }\r\n  // CLEARING THE RANGE — every cell in it that holds something is written empty, as one edit\r\n  const clearRange = () => {\r\n    if (!box) return\r\n    const ops: Operation[] = []\r\n    for (let r = box.r0; r <= box.r1; r++)\r\n      for (let k = box.c0; k <= box.c1; k++) {\r\n        const row = rows[r]\r\n        const col = cols[k]\r\n        if (!row || !col || col.proposed) continue\r\n        ops.push(...setCell(row.id, col.id, undefined))\r\n      }\r\n    write(ops)\r\n  }\r\n  const rangeRows = box ? rows.slice(box.r0, box.r1 + 1).map((r) => r.id) : []\r\n  const rangeCols = box ? cols.slice(box.c0, box.c1 + 1) : []\r\n  const pickEnum = (row: string, col: string, v: string) => {\r\n    setPick(null)\r\n    write(setCell(row, col, v))\r\n  }\r\n  // one click SELECTS; double-click / Enter / F2 / typing EDITS (Excel, Sheets, Airtable); typing starts the edit with that key\r\n  const startEdit = (row: string, col: Col, initial?: string) => {\r\n    if (readOnly || col.proposed) return\r\n    setSelCol(null)\r\n    if (col.type === 'enum' && col.enum) {\r\n      setPick({ row, col: col.id })\r\n      return\r\n    }\r\n    setEdit({ row, col: col.id, value: initial ?? text(valueOf(row, col.id)) })\r\n  }\r\n  const anchorOf = (sel: string) => rootRef.current?.querySelector(sel)?.getBoundingClientRect() ?? null\r\n  const colName = (id: string) => cols.find((c) => c.id === id)?.name ?? id\r\n  const keyId = keyCol()?.id\r\n\r\n  return (\r\n    <div ref={rootRef} className=\"flex h-full min-h-0 flex-col\">\r\n      {/* A BAR OF ONE HEIGHT — 26px whatever stands in it, so nothing under it steps */}\r\n      {(!compact || (bulk && proposals.length > 0) || activeSort || q) && (\r\n      <div className=\"mb-2 flex h-[26px] shrink-0 items-center gap-2 px-3\">\r\n        {compact && !q ? null : !filtering && !q ? (\r\n          <button\r\n            type=\"button\"\r\n            onClick={() => setFiltering(true)}\r\n            className=\"box-border flex h-[26px] shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-black bg-black pl-[7px] pr-2.5 text-sheet-ui leading-5 text-white transition-opacity hover:opacity-85 dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900\"\r\n          >\r\n            <Search className=\"h-3.5 w-3.5 shrink-0\" strokeWidth={2} />\r\n            Filter\r\n          </button>\r\n        ) : (\r\n          <span className=\"relative flex min-w-0 shrink\">\r\n            <Search className=\"pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400 dark:text-zinc-500\" strokeWidth={2} />\r\n            <input\r\n              autoFocus\r\n              value={q}\r\n              onChange={(ev) => setQ(ev.target.value)}\r\n              onBlur={(ev) => {\r\n                if (!ev.currentTarget.value) setFiltering(false)\r\n              }}\r\n              onKeyDown={(ev) => {\r\n                if (ev.key !== 'Escape') return\r\n                ev.preventDefault()\r\n                ev.stopPropagation()\r\n                setQ('')\r\n                setFiltering(false)\r\n              }}\r\n              placeholder=\"Filter rows…\"\r\n              className=\"box-border field-sizing-content h-[26px] w-auto min-w-72 max-w-full rounded-md border border-gray-200 bg-white py-0 pl-[27px] pr-2.5 text-sheet-ui leading-5 text-gray-800 outline-none transition-colors placeholder:text-gray-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-600\"\r\n            />\r\n          </span>\r\n        )}\r\n        <span className=\"min-w-0 flex-1\" />\r\n        {bulk && proposals.length > 0 && (\r\n          <>\r\n            <button\r\n              type=\"button\"\r\n              onClick={rejectAll}\r\n              className=\"flex h-[26px] shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-sheet-ui font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\r\n            >\r\n              Reject all\r\n              <span className=\"hidden font-normal opacity-60 sm:inline\">{MOD}{BACKSPACE}</span>\r\n            </button>\r\n            {pending > 0 && (\r\n              <button\r\n                type=\"button\"\r\n                onClick={acceptAll}\r\n                className=\"flex h-[26px] shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-black px-2.5 text-sheet-ui font-medium text-white transition-opacity hover:opacity-85 dark:bg-zinc-100 dark:text-zinc-900\"\r\n              >\r\n                Accept all\r\n                <span className=\"hidden font-normal opacity-60 sm:inline\">{MOD}{ENTER}</span>\r\n              </button>\r\n            )}\r\n          </>\r\n        )}\r\n        {activeSort && (\r\n          // A TAG, NOT A BUTTON — the capsule says what the rows are sorted by; only its x clears it\r\n          <span className=\"flex h-[26px] shrink-0 items-center gap-1 rounded-full bg-gray-100 pl-2.5 pr-1 text-sheet-ui text-gray-600 dark:bg-zinc-800 dark:text-zinc-300\">\r\n            Sort by: {colName(activeSort.col)}\r\n            {activeSort.dir === 'asc' ? <ArrowUp className=\"h-3 w-3\" strokeWidth={2} /> : <ArrowDown className=\"h-3 w-3\" strokeWidth={2} />}\r\n            <button\r\n              type=\"button\"\r\n              onClick={() => setSort(null)}\r\n              aria-label=\"Clear sort\"\r\n              className=\"flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-gray-400 transition-colors hover:text-gray-800 dark:text-zinc-500 dark:hover:text-zinc-100\"\r\n            >\r\n              <X className=\"h-3 w-3\" strokeWidth={2} />\r\n            </button>\r\n          </span>\r\n        )}\r\n      </div>\r\n      )}\r\n      {/* min-w-0: a wide table must not push this wrapper past its box; isolate: the edge fade only has to beat the sheet's own\r\n          sticky cells. Everything that must rise above the page (editor, pickers, menus, the card) is portaled. */}\r\n      <div className=\"relative isolate min-h-0 min-w-0 flex-1\">\r\n        <div\r\n          ref={scRef}\r\n          data-sheet-scroller\r\n          onScroll={onScroll}\r\n          className={`sheet-scroller sheet-scroll relative h-full w-full ${bones ? 'overflow-hidden' : 'overflow-auto'}`}\r\n        >\r\n          {/* FIXED layout — the header widths rule the columns, a narrowed column clips its cells with an ellipsis; the trailing\r\n              add-column cell soaks up any slack */}\r\n          <table\r\n            style={{ tableLayout: 'fixed', width: rowNumW + cols.reduce((a, c) => a + widthPx(c), 0) + (readOnly ? 0 : 128) }}\r\n            className=\"min-w-full border-separate border-spacing-0 text-sheet-body leading-5 text-gray-800 dark:text-zinc-200\"\r\n          >\r\n            <thead>\r\n              <tr>\r\n                <th\r\n                  style={{ width: rowNumW }}\r\n                  className={`${th}${boneEdge} sticky left-0 z-30 border-l text-center shadow-[-2px_0_0_0_#fff,0_-2px_0_0_#fff] dark:shadow-[-2px_0_0_0_#18181b,0_-2px_0_0_#18181b]`}\r\n                />\r\n                {cols.map((c) => {\r\n                  const shade = colShade(snap, c.id, base)\r\n                  return (\r\n                    <th\r\n                      key={c.id}\r\n                      data-col={c.id}\r\n                      onClick={() => {\r\n                        if (c.proposed) return\r\n                        setFocus(null)\r\n                        setSelCol(c.id)\r\n                      }}\r\n                      onContextMenu={\r\n                        readOnly\r\n                          ? undefined\r\n                          : (ev) => {\r\n                              ev.preventDefault()\r\n                              if (c.proposed) return\r\n                              // A RIGHT-CLICK SELECTS FIRST, AS A CELL'S DOES — then the menu\r\n                              setFocus(null)\r\n                              setSelCol(c.id)\r\n                              setMenu({ x: ev.clientX, y: ev.clientY, col: c.id })\r\n                            }\r\n                      }\r\n                      onPointerEnter={shade ? () => hover(shade.pid) : undefined}\r\n                      onPointerLeave={shade ? () => hover(null) : undefined}\r\n                      style={{ width: widthPx(c), minWidth: widthPx(c), maxWidth: widthPx(c) }}\r\n                      className={`${th}${boneEdge} ${thBleed} group/th relative cursor-pointer select-none ${\r\n                        renamingCol?.id !== c.id && selCol === c.id ? '!ring-1 !ring-inset !ring-blue-500 !bg-blue-50 dark:!ring-blue-400 dark:!bg-[#151b27]' : ''\r\n                      }`}\r\n                    >\r\n                      {shade ? (\r\n                        <ShadowValue now={c.proposed ? null : c.name} shade={shade} deep={deep === shade.pid} onOpen={(pid, el) => openCard(pid, el, at('columns', c.id))} />\r\n                      ) : (\r\n                        // THE NAME GETS THE WHOLE CELL — the sort mark is an overlay at the right, faint on hover, solid when sorted\r\n                        <span\r\n                          onDoubleClick={\r\n                            readOnly\r\n                              ? undefined\r\n                              : (ev) => {\r\n                                  ev.stopPropagation()\r\n                                  setRenamingCol({ id: c.id, value: c.name })\r\n                                }\r\n                          }\r\n                          className={`block min-w-0 truncate ${activeSort?.col === c.id ? 'pr-5' : ''}`}\r\n                        >\r\n                          {c.name}\r\n                        </span>\r\n                      )}\r\n                      {!c.proposed && (\r\n                        <button\r\n                          type=\"button\"\r\n                          onClick={(ev) => {\r\n                            ev.stopPropagation()\r\n                            setSort((s) => (s?.col === c.id ? (s.dir === 'asc' ? { col: c.id, dir: 'desc' } : null) : { col: c.id, dir: 'asc' }))\r\n                          }}\r\n                          aria-label={`Sort by ${c.name}`}\r\n                          title={`Sort by ${c.name}`}\r\n                          className={`absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded bg-white transition-colors hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-zinc-800 ${\r\n                            activeSort?.col === c.id ? 'opacity-100' : 'opacity-0 group-hover/th:opacity-40 focus:opacity-100'\r\n                          }`}\r\n                        >\r\n                          {activeSort?.col === c.id && activeSort.dir === 'desc' ? <ArrowDown className=\"h-3 w-3\" strokeWidth={2} /> : <ArrowUp className=\"h-3 w-3\" strokeWidth={2} />}\r\n                        </button>\r\n                      )}\r\n                      {/* the seam: grab it to widen / narrow this column */}\r\n                      <span\r\n                        onPointerDown={startResize(c)}\r\n                        onClick={(ev) => ev.stopPropagation()}\r\n                        className=\"absolute right-0 top-0 z-30 h-full w-[6px] cursor-col-resize hover:bg-blue-500/30\"\r\n                      />\r\n                    </th>\r\n                  )\r\n                })}\r\n                {/* + NEW COLUMN IS A HEADER CELL (Airtable) — right after the last column, sticky with the header; read-only, the\r\n                    cell stays, empty, to take up the slack */}\r\n                <th className={`${th}${boneEdge} ${thBleed}`}>{!readOnly && <NewColumnButton onClick={() => addColumn()} />}</th>\r\n              </tr>\r\n            </thead>\r\n            <tbody>\r\n              {lines.map(({ real: r, i, ghost }) => {\r\n                if (ghost)\r\n                  return (\r\n                    <tr\r\n                      key={`ghost:${ghost.pid}:${ghost.row}`}\r\n                      data-row-shadow={ghost.pid}\r\n                      data-shadow-open={ghost.pid}\r\n                      onPointerEnter={() => hover(ghost.pid)}\r\n                      onPointerLeave={() => hover(null)}\r\n                      className=\"cursor-pointer\"\r\n                    >\r\n                      <td style={{ width: rowNumW }} className={`${td} sticky left-0 z-10 border-l shadow-[-2px_0_0_0_#fff] dark:shadow-[-2px_0_0_0_#18181b]`} />\r\n                      {cols.map((c) => {\r\n                        const v = ghost.cells[c.id]\r\n                        return (\r\n                          <td\r\n                            key={c.id}\r\n                            data-ghost-cell={`${ghost.row}:${c.id}`}\r\n                            onClick={(ev) => openCard(ghost.pid, ev.currentTarget, ghost.place)}\r\n                            className={`${td} ${c.type === 'number' ? 'text-right tabular-nums' : ''}`}\r\n                          >\r\n                            {text(v) === '' ? (\r\n                              <span>&nbsp;</span>\r\n                            ) : (\r\n                              <ShadowValue\r\n                                now={null}\r\n                                shade={{ pid: ghost.pid, v, stale: ghost.stale }}\r\n                                deep={deep === ghost.pid}\r\n                                numeric={c.type === 'number'}\r\n                                onOpen={(pid, el) => openCard(pid, el, ghost.place)}\r\n                              />\r\n                            )}\r\n                          </td>\r\n                        )\r\n                      })}\r\n                      <td className={td} />\r\n                    </tr>\r\n                  )\r\n                if (!r) return null\r\n                // the whole-row tint follows a single active cell; with a range selected the range is the selection\r\n                const isSel = selRow === r.id && !multi\r\n                return (\r\n                  <tr key={r.id}>\r\n                    <td\r\n                      onClick={() => setSelRow(r.id)}\r\n                      onContextMenu={\r\n                        readOnly\r\n                          ? undefined\r\n                          : (ev) => {\r\n                              ev.preventDefault()\r\n                              setMenu({ x: ev.clientX, y: ev.clientY, row: r.id, col: '' })\r\n                            }\r\n                      }\r\n                      style={{ width: rowNumW }}\r\n                      className={`${td} sticky left-0 z-10 cursor-pointer border-l !px-1 text-center text-sheet-ui tabular-nums text-gray-400 shadow-[-2px_0_0_0_#fff] dark:text-zinc-500 dark:shadow-[-2px_0_0_0_#18181b] ${\r\n                        isSel ? '!bg-blue-50 dark:!bg-[#151b27]' : ''\r\n                      }`}\r\n                    >\r\n                      {i + 1}\r\n                    </td>\r\n                    {cols.map((c, ci) => {\r\n                      const inBox = multi && !!box && i >= box.r0 && i <= box.r1 && ci >= box.c0 && ci <= box.c1\r\n                      // ONE OUTLINE AROUND THE WHOLE RANGE — each cell on the rim draws its outer edges as inset lines\r\n                      const rim = inBox\r\n                        ? [\r\n                            i === box!.r0 && 'inset 0 1px 0 0 #3b82f6',\r\n                            i === box!.r1 && 'inset 0 -1px 0 0 #3b82f6',\r\n                            ci === box!.c0 && 'inset 1px 0 0 0 #3b82f6',\r\n                            ci === box!.c1 && 'inset -1px 0 0 0 #3b82f6',\r\n                          ]\r\n                            .filter(Boolean)\r\n                            .join(', ')\r\n                        : ''\r\n                      const anchorCell = inBox && focus?.row === r.id && focus?.col === c.id\r\n                      const editing = (edit && edit.row === r.id && edit.col === c.id) || (pick && pick.row === r.id && pick.col === c.id)\r\n                      const focused = focus && focus.row === r.id && focus.col === c.id\r\n                      const shade = shadeOf(snap, r.id, c.id, base)\r\n                      return (\r\n                        <td\r\n                          key={c.id}\r\n                          data-cell={`${r.id}:${c.id}`}\r\n                          data-r={r.id}\r\n                          data-c={c.id}\r\n                          onPointerDown={(ev) => {\r\n                            if (editing || ev.button !== 0) return\r\n                            const here = { row: r.id, col: c.id }\r\n                            if (ev.shiftKey && focus) {\r\n                              ev.preventDefault()\r\n                              setRange({ a: focus, b: here })\r\n                              return\r\n                            }\r\n                            setFocus(here)\r\n                            setSelRow(r.id)\r\n                            setRange({ a: here, b: here })\r\n                            dragSel.current = true\r\n                            if (rootRef.current) rootRef.current.style.userSelect = 'none'\r\n                          }}\r\n                          onClick={(ev) => {\r\n                            if (ev.shiftKey && focus) return\r\n                            setFocus({ row: r.id, col: c.id })\r\n                            setSelRow(r.id)\r\n                            setSelCol(null)\r\n                          }}\r\n                          onDoubleClick={() => !editing && startEdit(r.id, c)}\r\n                          onKeyDown={(ev) => {\r\n                            if (editing) return\r\n                            if ((ev.metaKey || ev.ctrlKey) && (ev.key === 'Enter' || ev.key === 'Backspace')) return\r\n                            if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'c' && box) {\r\n                              if (copyRange()) ev.preventDefault()\r\n                              return\r\n                            }\r\n                            // read-only: no key clears or edits a cell\r\n                            if (readOnly) return\r\n                            if ((ev.key === 'Backspace' || ev.key === 'Delete') && box) {\r\n                              ev.preventDefault()\r\n                              clearRange()\r\n                              return\r\n                            }\r\n                            if (ev.key === 'Enter' || ev.key === 'F2') {\r\n                              ev.preventDefault()\r\n                              startEdit(r.id, c)\r\n                            } else if (ev.key.length === 1 && !ev.metaKey && !ev.ctrlKey && !ev.altKey) {\r\n                              // typing into a selected cell replaces its value (Excel / Sheets)\r\n                              ev.preventDefault()\r\n                              startEdit(r.id, c, ev.key)\r\n                            }\r\n                          }}\r\n                          onContextMenu={(ev) => {\r\n                            ev.preventDefault()\r\n                            // inside a range the menu speaks for the range; outside it, the cell becomes the selection\r\n                            if (!inBox) {\r\n                              setFocus({ row: r.id, col: c.id })\r\n                              setRange({ a: { row: r.id, col: c.id }, b: { row: r.id, col: c.id } })\r\n                            }\r\n                            setMenu({ x: ev.clientX, y: ev.clientY, row: r.id, col: c.id })\r\n                          }}\r\n                          onPointerEnter={shade ? () => hover(shade.pid) : undefined}\r\n                          onPointerLeave={shade ? () => hover(null) : undefined}\r\n                          tabIndex={0}\r\n                          className={`${td} ${c.type === 'number' ? 'text-right tabular-nums' : ''} ${\r\n                            isSel || (inBox && !anchorCell) || (renamingCol?.id !== c.id && selCol === c.id) ? '!bg-blue-50 dark:!bg-[#151b27]' : ''\r\n                          } ${focused && !editing && !multi ? '!ring-1 !ring-inset !ring-blue-500' : ''} outline-none`}\r\n                          style={rim ? { boxShadow: rim } : undefined}\r\n                        >\r\n                          {shade ? (\r\n                            <ShadowValue\r\n                              now={(() => {\r\n                                const v = valueOf(r.id, c.id)\r\n                                if (v === undefined || v === '') return null\r\n                                // live: what goes, struck through, as text; stale: the newer value, drawn as the cell draws it\r\n                                return shade.stale && c.type === 'enum' ? <EnumBadge col={c} value={String(v)} /> : String(v)\r\n                              })()}\r\n                              shade={shade}\r\n                              deep={deep === shade.pid}\r\n                              numeric={c.type === 'number'}\r\n                              onOpen={(pid, el) => openCard(pid, el, at('rows', r.id, 'cells', c.id))}\r\n                            />\r\n                          ) : (\r\n                            <Cell col={c} v={valueOf(r.id, c.id)} />\r\n                          )}\r\n                        </td>\r\n                      )\r\n                    })}\r\n                    <td className={td} />\r\n                  </tr>\r\n                )\r\n              })}\r\n              {/* THE ROWS ARE STILL COMING — the columns are real, so the bones are real <tr>s under the real header */}\r\n              {bones &&\r\n                Array.from({ length: 44 }).map((_, i) => (\r\n                  <tr key={`bone-${i}`} aria-hidden>\r\n                    <td className={`${td} sheet-bone${boneEdge}`} style={{ width: rowNumW }}>\r\n                      &nbsp;\r\n                    </td>\r\n                    {cols.map((c) => (\r\n                      <td key={c.id} className={`${td} sheet-bone${boneEdge}`}>\r\n                        &nbsp;\r\n                      </td>\r\n                    ))}\r\n                    <td className={`${td} sheet-bone${boneEdge}`}>&nbsp;</td>\r\n                  </tr>\r\n                ))}\r\n            </tbody>\r\n          </table>\r\n          {listed.length > 0 && (\r\n            <div className=\"sticky left-0\">\r\n              <ShadowRows\r\n                shades={listed}\r\n                colName={colName}\r\n                colOrder={(id) => cols.findIndex((c) => c.id === id)}\r\n                rowLabel={(id) => (keyId && text(valueOf(id, keyId))) || id}\r\n                deep={deep}\r\n                onOpen={openCard}\r\n                onHover={hover}\r\n              />\r\n            </div>\r\n          )}\r\n          {/* THE WAY TO ADD A ROW IS ONE BUTTON — only while the sheet is empty, centred in the empty body and pinned to the view */}\r\n          {!readOnly && !bones && rows.length === 0 && ghosts.length === 0 && Object.keys(state.rows).length === 0 && (\r\n            <div className=\"sticky left-0 flex items-center justify-center py-2\" style={{ height: 'calc(100% - 2rem)' }}>\r\n              <button\r\n                type=\"button\"\r\n                onClick={() => addRow({ select: !focus })}\r\n                className=\"pointer-events-auto flex h-[26px] cursor-pointer items-center gap-1 rounded-full bg-black px-2.5 text-sheet-ui font-medium text-white transition-opacity hover:opacity-85 dark:bg-zinc-100 dark:text-zinc-900\"\r\n              >\r\n                <Plus className=\"h-3.5 w-3.5 shrink-0\" strokeWidth={2.2} /> New row\r\n              </button>\r\n            </div>\r\n          )}\r\n          {Object.keys(state.rows).length > 0 && rows.length === 0 && (\r\n            <div className=\"pointer-events-none absolute inset-x-0 bottom-0 top-8 z-20 flex items-center justify-center text-sheet-body text-gray-400 dark:text-zinc-500\">\r\n              <span className=\"max-w-[80%] truncate\">No results for “{q.trim()}”</span>\r\n            </div>\r\n          )}\r\n        </div>\r\n        {/* MORE TO THE SIDE — not a band: a soft inset shadow on the edge the table runs past, starting past the row numbers */}\r\n        {(edges.left || edges.right) && (\r\n          <div\r\n            style={{ left: rowNumW, right: edges.sbV, height: edges.h }}\r\n            className={`pointer-events-none absolute top-0 z-40 ${edges.left && edges.right ? 'sheet-edge-lr' : edges.left ? 'sheet-edge-l' : 'sheet-edge-r'}`}\r\n          />\r\n        )}\r\n      </div>\r\n      {/* THE ENUM PICKER — the same box as the editor, over the cell, listing the badges */}\r\n      {pick &&\r\n        typeof document !== 'undefined' &&\r\n        (() => {\r\n          const col = live(pick.col)\r\n          const anchor = anchorOf(`td[data-cell=\"${pick.row}:${pick.col}\"]`)\r\n          if (!col?.enum || !anchor) return null\r\n          return createPortal(\r\n            <EnumPicker\r\n              key={`${pick.row}:${pick.col}`}\r\n              anchor={anchor}\r\n              col={col}\r\n              current={text(valueOf(pick.row, pick.col))}\r\n              onPick={(v) => pickEnum(pick.row, pick.col, v)}\r\n              onClose={() => setPick(null)}\r\n            />,\r\n            document.body,\r\n          )\r\n        })()}\r\n      {/* THE IN-PLACE EDITOR (Notion's) — a box laid exactly over the cell that grows with the text; Enter commits, Shift+Enter\r\n          breaks a line, Esc cancels, a press elsewhere commits */}\r\n      {edit &&\r\n        typeof document !== 'undefined' &&\r\n        createPortal(\r\n          <CellEditor\r\n            key={`${edit.row}:${edit.col}`}\r\n            anchor={anchorOf(`td[data-cell=\"${edit.row}:${edit.col}\"]`)}\r\n            value={edit.value}\r\n            numeric={live(edit.col)?.type === 'number'}\r\n            onChange={(v) => setEdit((e) => (e ? { ...e, value: v } : e))}\r\n            onCommit={commitEdit}\r\n            onCancel={() => setEdit(null)}\r\n          />,\r\n          document.body,\r\n        )}\r\n      {/* THE HEADER RENAMES IN THE CELL EDITOR — the same box, over the header cell */}\r\n      {renamingCol &&\r\n        typeof document !== 'undefined' &&\r\n        (() => {\r\n          const col = live(renamingCol.id)\r\n          const anchor = anchorOf(`th[data-col=\"${renamingCol.id}\"]`)\r\n          if (!col || !anchor) return null\r\n          return createPortal(\r\n            <CellEditor\r\n              key={`head:${col.id}`}\r\n              anchor={anchor}\r\n              value={renamingCol.value}\r\n              numeric={false}\r\n              onChange={(v) => setRenamingCol((x) => (x ? { ...x, value: v } : x))}\r\n              onCommit={() => renameCol(col, renamingCol.value)}\r\n              onCancel={() => setRenamingCol(null)}\r\n            />,\r\n            document.body,\r\n          )\r\n        })()}\r\n      {/* the context menu — a body portal */}\r\n      {menu &&\r\n        typeof document !== 'undefined' &&\r\n        createPortal(\r\n          <div ref={menuRef} style={{ left: menu.x, top: menu.y }} className={`fixed z-[90] w-56 ${MENU_BOX}`}>\r\n            <CellMenu\r\n              column={live(menu.col)}\r\n              row={menu.row}\r\n              readOnly={readOnly}\r\n              onCopy={copyRange}\r\n              onEdit={(r, c) => startEdit(r, c)}\r\n              onAddRight={() => addColumn(menu.col)}\r\n              onAddRow={() => addRow({ after: multi && rangeRows.length ? rangeRows[rangeRows.length - 1] : menu.row, select: false })}\r\n              onRemoveRow={(r) => removeRows([r])}\r\n              onRemoveColumn={(c) => removeCols([c])}\r\n              multi={\r\n                multi && menu.row\r\n                  ? {\r\n                      cells: rangeRows.length * rangeCols.length,\r\n                      rows: rangeRows,\r\n                      cols: rangeCols.filter((c) => !c.key && !c.proposed).map((c) => c.id),\r\n                      onClear: clearRange,\r\n                      onRemove: (kind, ids) => {\r\n                        if (ids.length > 1) {\r\n                          setRemoveAsk({ kind, ids })\r\n                          return\r\n                        }\r\n                        setRange(null)\r\n                        if (kind === 'rows') removeRows(ids)\r\n                        else removeCols(ids)\r\n                      },\r\n                    }\r\n                  : undefined\r\n              }\r\n              onSettings={() => setSettingsCol(menu.col)}\r\n              onClose={() => setMenu(null)}\r\n            />\r\n          </div>,\r\n          document.body,\r\n        )}\r\n      <ConfirmDialog\r\n        open={!!removeAsk}\r\n        onOpenChange={(open) => {\r\n          if (!open) setRemoveAsk(null)\r\n        }}\r\n        title={removeAsk ? (removeAsk.kind === 'rows' ? `Remove ${removeAsk.ids.length} rows?` : `Remove ${removeAsk.ids.length} columns?`) : ''}\r\n        description={removeAsk?.kind === 'rows' ? 'Every cell in them goes with them.' : 'Every row loses its value in them.'}\r\n        action={removeAsk ? (removeAsk.kind === 'rows' ? `Remove ${removeAsk.ids.length} rows` : `Remove ${removeAsk.ids.length} columns`) : ''}\r\n        onConfirm={() => {\r\n          if (!removeAsk) return\r\n          const { kind, ids } = removeAsk\r\n          setRemoveAsk(null)\r\n          setRange(null)\r\n          if (kind === 'rows') removeRows(ids)\r\n          else removeCols(ids)\r\n        }}\r\n      />\r\n      {(() => {\r\n        const c = settingsCol ? live(settingsCol) : undefined\r\n        if (!c) return null\r\n        return (\r\n          <ColumnSettings\r\n            key={c.id}\r\n            column={c}\r\n            option={(v) => <EnumBadge col={c} value={v} size=\"label\" />}\r\n            onRename={(name) => write([{ op: 'replace', path: at('columns', c.id, 'name'), value: name }])}\r\n            onClose={() => setSettingsCol(null)}\r\n          />\r\n        )\r\n      })()}\r\n      {card && cardP && (\r\n        <ProposalCard\r\n          key={card.pid}\r\n          p={cardP}\r\n          place={card.place}\r\n          anchor={card.anchor}\r\n          onAccept={(all) => (all ? acceptLive(card.pid) : store.accept(card.pid, { only: [card.place] }))}\r\n          onReject={(all) => store.reject(card.pid, all ? {} : { only: [card.place] })}\r\n          onClose={() => setCard(null)}\r\n          onHover={setHot}\r\n        />\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n\r\n/** the pointer is `base` or lies inside it */\r\nconst inside = (path: string, base: string) => base === '' || path === base || path.startsWith(base + '/')\r\n/** the proposal changes something at or inside `base` */\r\nconst touches = (p: Proposal, base: string) => p.paths.some((path) => inside(path, base))\r\n\r\nconst EDITOR_MAX_H = 240 // ~11 lines; past this the box scrolls instead of growing\r\n\r\n// the editor box over a cell — same place, same size to start with, then it grows downward as the text wraps; the caret lands at the\r\n// end (Notion), not on a selected-all value\r\nfunction CellEditor({\r\n  anchor,\r\n  value,\r\n  numeric,\r\n  onChange,\r\n  onCommit,\r\n  onCancel,\r\n}: {\r\n  anchor: DOMRect | null\r\n  value: string\r\n  numeric: boolean\r\n  onChange: (v: string) => void\r\n  onCommit: () => void\r\n  onCancel: () => void\r\n}) {\r\n  const ref = useRef<HTMLTextAreaElement>(null)\r\n  const gauge = useRef<HTMLSpanElement>(null)\r\n  const [width, setWidth] = useState(anchor?.width ?? 160)\r\n  const padTopRef = useRef(3)\r\n  const padBottomRef = useRef(4)\r\n  const onScrollFade = useScrollFade()\r\n  useEffect(() => {\r\n    const el = ref.current\r\n    if (!el) return\r\n    el.focus()\r\n    el.setSelectionRange(el.value.length, el.value.length)\r\n  }, [])\r\n  // WIDTH — the cell's width while the text fits; wider as the longest line needs, up to a cap (and the screen's edge); past that it\r\n  // wraps and grows down. Measured on a hidden span in the same font.\r\n  useEffect(() => {\r\n    const g = gauge.current\r\n    if (!g || !anchor) return\r\n    const longest = value.split('\\n').reduce((a, l) => (l.length > a.length ? l : a), '')\r\n    g.textContent = longest || ' '\r\n    const need = Math.ceil(g.getBoundingClientRect().width) + 18\r\n    const cap = Math.min(520, Math.max(anchor.width, window.innerWidth - anchor.left - 16))\r\n    setWidth(Math.max(anchor.width, Math.min(cap, need)))\r\n  }, [value, anchor])\r\n  // grow with the text (after the width has settled) — up to a ceiling, then it scrolls inside\r\n  useEffect(() => {\r\n    const el = ref.current\r\n    if (!el) return\r\n    el.style.height = '0px'\r\n    const H = Math.round(anchor?.height ?? 29)\r\n    const one = el.scrollHeight <= 21\r\n    const want = one ? H - 1 : el.scrollHeight + padTopRef.current + padBottomRef.current\r\n    const cap = EDITOR_MAX_H - 4\r\n    el.style.height = `${Math.min(want, cap)}px`\r\n    el.style.overflowY = want > cap ? 'auto' : 'hidden'\r\n  }, [value, width, anchor?.height])\r\n  // a scroll ELSEWHERE ends the edit (the box would drift off its cell); a scroll inside the box is the box's own\r\n  useEffect(() => {\r\n    const end = (e?: Event) => {\r\n      if (e && ref.current && e.target instanceof Node && ref.current.contains(e.target)) return\r\n      onCommit()\r\n    }\r\n    const resize = () => end()\r\n    window.addEventListener('wheel', end, { passive: true })\r\n    window.addEventListener('resize', resize)\r\n    return () => {\r\n      window.removeEventListener('wheel', end)\r\n      window.removeEventListener('resize', resize)\r\n    }\r\n  }, [onCommit])\r\n  if (!anchor) return null\r\n  // CENTRED IN THE FRAME, AND ON THE CELL'S GLYPHS — the frame starts 2px above the cell and ends 1px below its bottom border, so\r\n  // with a 2px border and padding t on both sides the line sits exactly where the cell drew it\r\n  const H = Math.round(anchor.height)\r\n  const t = Math.max(1, Math.round((H - 21) / 2))\r\n  const padTop = t\r\n  const padBottom = Math.max(1, H - 21 - t)\r\n  padTopRef.current = padTop\r\n  padBottomRef.current = padBottom\r\n  return (\r\n    <>\r\n      <span ref={gauge} aria-hidden className=\"pointer-events-none fixed left-0 top-0 -z-10 whitespace-pre text-sheet-body leading-5 opacity-0\" />\r\n      <div\r\n        style={{ left: Math.round(anchor.left) - 1, top: Math.round(anchor.top) - 2, width: width + 2 }}\r\n        className=\"fixed z-[90] overflow-hidden rounded-md border-2 border-blue-500 bg-white shadow-lg dark:border-blue-400 dark:bg-zinc-900\"\r\n      >\r\n        <textarea\r\n          ref={ref}\r\n          data-cell-editor\r\n          value={value}\r\n          inputMode={numeric ? 'decimal' : 'text'}\r\n          onChange={(e) => onChange(e.target.value)}\r\n          onScroll={onScrollFade}\r\n          onBlur={onCommit}\r\n          onKeyDown={(e) => {\r\n            e.stopPropagation()\r\n            if (e.key === 'Enter' && !e.shiftKey) {\r\n              e.preventDefault()\r\n              onCommit()\r\n            } else if (e.key === 'Escape') {\r\n              e.preventDefault()\r\n              onCancel()\r\n            }\r\n          }}\r\n          rows={1}\r\n          style={{ minHeight: H - 1, borderTopWidth: padTop, borderBottomWidth: padBottom }}\r\n          className={`sheet-scroll block w-full resize-none whitespace-pre-wrap break-words border-transparent bg-transparent px-[7px] text-sheet-body leading-5 text-gray-800 outline-none dark:text-zinc-200 ${numeric ? 'text-right tabular-nums' : ''}`}\r\n        />\r\n      </div>\r\n    </>\r\n  )\r\n}\r\n\r\n// one cell's face; an enum column's values are badges. NOTHING COMES OUT ON HOVER.\r\nfunction Cell({ col, v }: { col: Col; v: Value | undefined }) {\r\n  if (v === undefined || v === '') return <span>&nbsp;</span>\r\n  return (\r\n    <span className={`flex items-center gap-1.5 ${col.type === 'number' ? 'justify-end' : ''}`}>\r\n      <span className=\"flex min-w-0 items-center gap-1 truncate\">\r\n        {col.type === 'enum' ? <EnumBadge col={col} value={String(v)} /> : <span className=\"min-w-0 truncate\">{String(v)}</span>}\r\n      </span>\r\n    </span>\r\n  )\r\n}\r\n\r\n// ENUM VALUES ARE TAGS — one muted tone per value, by its place in the column's enum list, the same in the cell and in the picker\r\nconst TONES = [\r\n  'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300',\r\n  'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300',\r\n  'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',\r\n  'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',\r\n  'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300',\r\n  'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300',\r\n]\r\n/** `size=\"label\"` — the badge at a row label's size, for Column settings; a cell and the picker keep the cell's size */\r\nexport function EnumBadge({ col, value, size = 'cell' }: { col: Column; value: string; size?: 'cell' | 'label' }) {\r\n  const i = Math.max(0, col.enum?.indexOf(value) ?? 0)\r\n  const box = size === 'label' ? 'px-2.5 py-0.5 text-sheet-body leading-5' : 'px-2 py-[1px] text-sheet-meta leading-[16px]'\r\n  return <span className={`inline-flex max-w-40 items-center truncate rounded-full font-medium ${box} ${TONES[i % TONES.length]}`}>{value}</span>\r\n}\r\n\r\nfunction MenuRow({ label, icon: Icon, onPick, danger = false, disabled = false }: { label: React.ReactNode; icon?: LucideIcon; onPick: () => void; danger?: boolean; disabled?: boolean }) {\r\n  return (\r\n    <button disabled={disabled} onClick={onPick} className={`${MENU_ROW} ${danger ? MENU_DANGER : ''}`}>\r\n      {Icon && <Icon className=\"h-4 w-4 shrink-0\" strokeWidth={2} />}\r\n      <span className=\"min-w-0 flex-1 truncate\">{label}</span>\r\n    </button>\r\n  )\r\n}\r\n\r\n// \"+ New column\" — quiet until hovered, at the header's end\r\nfunction NewColumnButton({ onClick }: { onClick: () => void }) {\r\n  return (\r\n    <button\r\n      onClick={onClick}\r\n      aria-label=\"New column\"\r\n      className=\"flex cursor-pointer items-center gap-1 whitespace-nowrap rounded-md px-1 py-0.5 text-sheet-ui font-medium text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\r\n    >\r\n      <Plus className=\"h-3.5 w-3.5 shrink-0\" strokeWidth={2.2} /> New column\r\n    </button>\r\n  )\r\n}\r\n\r\n// THE ENUM PICKER — the editor's box, over the cell: the column's values as badges, a tick on the current one; Esc / a press elsewhere\r\n// closes it\r\nfunction EnumPicker({ anchor, col, current, onPick, onClose }: { anchor: DOMRect; col: Column; current: string; onPick: (v: string) => void; onClose: () => void }) {\r\n  const ref = useRef<HTMLDivElement>(null)\r\n  useEffect(() => {\r\n    const down = (e: PointerEvent) => {\r\n      if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) return\r\n      onClose()\r\n    }\r\n    const key = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape') onClose()\r\n    }\r\n    const wheel = (e: Event) => {\r\n      if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) return\r\n      onClose()\r\n    }\r\n    window.addEventListener('pointerdown', down, true)\r\n    window.addEventListener('wheel', wheel, { passive: true })\r\n    window.addEventListener('keydown', key)\r\n    return () => {\r\n      window.removeEventListener('pointerdown', down, true)\r\n      window.removeEventListener('wheel', wheel)\r\n      window.removeEventListener('keydown', key)\r\n    }\r\n  }, [onClose])\r\n  return (\r\n    <div\r\n      ref={ref}\r\n      style={{ left: anchor.left, top: anchor.top, minWidth: anchor.width, maxHeight: EDITOR_MAX_H }}\r\n      className=\"fixed z-[90] overflow-y-auto rounded-md border-2 border-blue-500 bg-white p-1 shadow-lg dark:border-blue-400 dark:bg-zinc-900\"\r\n    >\r\n      {[...(col.enum ?? [])]\r\n        .sort((a, b) => (a === current ? -1 : b === current ? 1 : 0))\r\n        .map((v) => (\r\n          <button\r\n            key={v}\r\n            onClick={() => onPick(v)}\r\n            className=\"flex w-full cursor-pointer items-center gap-3 rounded px-2 py-1 text-left transition-colors hover:bg-gray-100 dark:hover:bg-zinc-800\"\r\n          >\r\n            <EnumBadge col={col} value={v} />\r\n            <span className=\"min-w-0 flex-1\" />\r\n            {current === v && <Check className=\"h-3.5 w-3.5 shrink-0 text-gray-500 dark:text-zinc-400\" strokeWidth={2.5} />}\r\n          </button>\r\n        ))}\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction CellMenu({\r\n  column,\r\n  row,\r\n  readOnly,\r\n  onCopy,\r\n  onEdit,\r\n  onAddRight,\r\n  onAddRow,\r\n  onRemoveRow,\r\n  onRemoveColumn,\r\n  multi,\r\n  onSettings,\r\n  onClose,\r\n}: {\r\n  column?: Col\r\n  row?: string\r\n  /** the sheet authors nothing: the menu is Copy alone */\r\n  readOnly: boolean\r\n  /** copies the selected cells (the one right-clicked, or the range it lies in) */\r\n  onCopy: () => void\r\n  onEdit: (row: string, column: Col) => void\r\n  /** a new column right of this one */\r\n  onAddRight: () => void\r\n  /** a new row, below this one */\r\n  onAddRow: () => void\r\n  onRemoveRow: (row: string) => void\r\n  onRemoveColumn: (col: string) => void\r\n  /** a RANGE is selected and the menu was opened inside it: the menu speaks for all of it (never the key column) */\r\n  multi?: { cells: number; rows: string[]; cols: string[]; onClear: () => void; onRemove: (kind: 'rows' | 'cols', ids: string[]) => void }\r\n  onSettings: () => void\r\n  onClose: () => void\r\n}) {\r\n  const then = (fn: () => void) => () => {\r\n    onClose()\r\n    fn()\r\n  }\r\n  // READ-ONLY, COPY IS ALL THAT MAKES SENSE — of the cell, or of every cell in the range\r\n  if (readOnly)\r\n    return row && column ? <MenuRow label={multi && multi.cells > 1 ? `Copy ${multi.cells} cells` : 'Copy'} icon={Copy} onPick={then(onCopy)} /> : null\r\n  if (multi)\r\n    return (\r\n      <>\r\n        <MenuRow label={multi.cells === 1 ? 'Clear 1 cell' : `Clear ${multi.cells} cells`} icon={Eraser} onPick={then(multi.onClear)} />\r\n        <MenuRow label=\"Add row below\" icon={Plus} onPick={then(onAddRow)} />\r\n        <MenuRow\r\n          label={multi.rows.length === 1 ? 'Remove 1 row' : `Remove ${multi.rows.length} rows`}\r\n          icon={Trash2}\r\n          danger\r\n          onPick={then(() => multi.onRemove('rows', multi.rows))}\r\n        />\r\n        {multi.cols.length > 0 && (\r\n          <MenuRow\r\n            label={multi.cols.length === 1 ? 'Remove 1 column' : `Remove ${multi.cols.length} columns`}\r\n            icon={Trash2}\r\n            danger\r\n            onPick={then(() => multi.onRemove('cols', multi.cols))}\r\n          />\r\n        )}\r\n      </>\r\n    )\r\n  return (\r\n    <>\r\n      {row && column && <MenuRow label=\"Edit content\" icon={PenLine} onPick={then(() => onEdit(row, column))} />}\r\n      {row && <MenuRow label=\"Add row below\" icon={Plus} onPick={then(onAddRow)} />}\r\n      {row && <MenuRow label=\"Remove row\" icon={Trash2} danger onPick={then(() => onRemoveRow(row))} />}\r\n      {/* THE HEADER'S OWN ROWS: add a column to the right, its settings */}\r\n      {!row && column && (\r\n        <>\r\n          <MenuRow label=\"Add column to right\" icon={BetweenVerticalStart} onPick={then(onAddRight)} />\r\n          <MenuRow label=\"Column settings\" icon={Settings2} onPick={then(onSettings)} />\r\n        </>\r\n      )}\r\n      {/* WHAT CANNOT BE DONE IS NOT OFFERED — the key column carries the row's name and cannot go */}\r\n      {!row && column && !column.key && <MenuRow label=\"Remove column\" icon={Trash2} danger onPick={then(() => onRemoveColumn(column.id))} />}\r\n    </>\r\n  )\r\n}\r\n\r\n",
      "type": "registry:component",
      "target": "components/sheet/Sheet.tsx"
    },
    {
      "path": "registry/sheet/Shadow.tsx",
      "content": "import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { applyPatch, equal, get, type Operation, type Proposal, type Snapshot } from 'usecube'\r\nimport type { SheetState } from './store'\r\nimport { MENU_CARD, MENU_LABEL } from './ui'\r\n\r\n// AN AGENT'S PROPOSALS, DRAWN WHERE THEY ACT (Cube's Folder/CellShadow.tsx and Doc/ws/ActionShadow.tsx) — Google Docs' suggesting\r\n// mode on a sheet: a cell a proposal would set shows what it says now, struck through, and the value it would take in green; rows it\r\n// would add or remove are listed under the table. A press opens one small card: Do / Skip for that cell or row alone (the core's\r\n// accept(id, { only })), and Do all / Skip all for the rest of the proposal. Nothing here keeps state; it is all read off the store's\r\n// snapshot.\r\n//\r\n// AN OUTDATED PLACE (a person edited it after the agent proposed) can no longer be accepted: the cell keeps the newer value and the\r\n// agent's one sits beside it in grey, struck through; its card says \"changed since\" and offers only Skip. The proposal's other places\r\n// stay live (the core's `outdated`), so one edited cell never blocks the rest.\r\n//\r\n// ONE STORE, SEVERAL COMPONENTS — the sheet can live anywhere in a store (`base`, a JSON Pointer such as \"/sheets/pipeline\"): every\r\n// place below is a full pointer into the store, so a proposal that also changes something else (a doc's paragraph) is still one\r\n// proposal, and its card's Do all takes all of it.\r\n\r\nexport interface Shade {\r\n  /** the proposal drawn here */\r\n  pid: string\r\n  /** the value it would set (undefined: it clears the cell) */\r\n  v: unknown\r\n  stale: boolean\r\n}\r\n\r\nexport interface RowShade extends Shade {\r\n  kind: 'add' | 'remove'\r\n  row: string\r\n  cells: Record<string, unknown>\r\n  /** the row's place in the store (a full pointer) */\r\n  place: string\r\n}\r\n\r\nexport const text = (v: unknown): string => (v == null ? '' : typeof v === 'string' ? v : Array.isArray(v) ? v.join(', ') : String(v))\r\n\r\nconst esc = (key: string) => key.replace(/~/g, '~0').replace(/\\//g, '~1')\r\nconst keys = (pointer: string) => pointer.slice(1).split('/').map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~'))\r\n\r\nconst overlap = (a: string, b: string) => a === b || a.startsWith(b + '/') || b.startsWith(a + '/')\r\nconst writes = (op: Operation) => (op.op === 'test' ? [] : op.op === 'move' ? [op.from, op.path] : [op.path])\r\n\r\n/** this place of the proposal can no longer be accepted: a person changed it since (or the whole proposal no longer applies) */\r\nexport const isOut = (p: Proposal, place: string) => p.stale && (!p.outdated.length || p.outdated.some((o) => overlap(o, place)))\r\n\r\n/** the places of the proposal that can still be accepted */\r\nexport const livePlaces = (p: Proposal): string[] =>\r\n  !p.stale ? [...p.paths] : p.outdated.length ? p.paths.filter((path) => !isOut(p, path)) : []\r\n\r\n// ONE HOVER PER STORE — the proposal under the pointer, shared by every component on the same store (a sheet, a doc, a card), so\r\n// hovering a proposal's change anywhere deepens all of its changes everywhere: Maya's stage cell and the paragraph about her together.\r\ntype Focus = { pid: string | null; listeners: Set<() => void> }\r\nconst focuses = new WeakMap<object, Focus>()\r\nconst focusOf = (store: object): Focus => {\r\n  let f = focuses.get(store)\r\n  if (!f) focuses.set(store, (f = { pid: null, listeners: new Set() }))\r\n  return f\r\n}\r\n\r\n/** the proposal hovered anywhere on this store, and a way to say which one is (null: none) */\r\nexport function useFocus(store: object): [string | null, (pid: string | null) => void] {\r\n  const f = focusOf(store)\r\n  const subscribe = useCallback(\r\n    (fn: () => void) => {\r\n      f.listeners.add(fn)\r\n      return () => {\r\n        f.listeners.delete(fn)\r\n      }\r\n    },\r\n    [f],\r\n  )\r\n  const pid = useSyncExternalStore(subscribe, () => f.pid, () => f.pid)\r\n  const set = useCallback(\r\n    (next: string | null) => {\r\n      if (f.pid === next) return\r\n      f.pid = next\r\n      for (const fn of f.listeners) fn()\r\n    },\r\n    [f],\r\n  )\r\n  return [pid, set]\r\n}\r\n\r\n/** the sheet at `base` in a store's state (an empty sheet while nothing is there) */\r\nexport const sheetAt = (state: unknown, base: string): SheetState =>\r\n  (get(state, base) as SheetState | undefined) ?? { columns: {}, rows: {} }\r\n\r\n/** what the proposal meant to do at `place`, applied to the state as it is now (undefined when it no longer applies at all) */\r\nexport function intended<S>(state: S, p: Proposal, place: string): S | undefined {\r\n  try {\r\n    return applyPatch(state, (p.ops as Operation[]).filter((op) => writes(op).some((w) => overlap(w, place))))\r\n  } catch {\r\n    return undefined\r\n  }\r\n}\r\n\r\n/** the pointer's keys below `base`, or null when it lies outside it */\r\nconst below = (path: string, base: string) => (base === '' ? keys(path) : path.startsWith(base + '/') ? keys(path.slice(base.length)) : null)\r\n\r\n/** does the proposal add or remove whole rows (those are listed under the sheet, not drawn in cells) */\r\nconst rowLevel = (p: Proposal, base: string) => p.paths.some((path) => {\r\n  const k = below(path, base)\r\n  return !!k && k[0] === 'rows' && k.length === 2\r\n})\r\n\r\n/** the proposal that would change this cell, and to what */\r\nexport function shadeOf(snap: Snapshot<unknown>, row: string, col: string, base = ''): Shade | null {\r\n  if (sheetAt(snap.preview, base).rows?.[row] === undefined) return null\r\n  const pointer = `${base}/rows/${esc(row)}/cells/${esc(col)}`\r\n  const spot = snap.at(pointer)\r\n  if (!spot.proposals.length) return null\r\n  const live = spot.proposals.find((p) => !isOut(p, pointer))\r\n  if (live) return equal(spot.value, spot.next) ? null : { pid: live.id, v: spot.next, stale: false }\r\n  for (const p of spot.proposals) {\r\n    if (rowLevel(p, base)) continue\r\n    const then = intended(snap.state, p, pointer)\r\n    if (!then) continue\r\n    const v = get(then, pointer)\r\n    if (!equal(v, spot.value)) return { pid: p.id, v, stale: true }\r\n  }\r\n  return null\r\n}\r\n\r\n/** a column a proposal would add, rename or remove: its header name, as it would read */\r\nexport function colShade(snap: Snapshot<unknown>, col: string, base = ''): Shade | null {\r\n  const spot = snap.at(`${base}/columns/${esc(col)}/name`)\r\n  const live = snap.at(`${base}/columns/${esc(col)}`).proposals.find((p) => !isOut(p, `${base}/columns/${esc(col)}`))\r\n  if (!live || equal(spot.value, spot.next)) return null\r\n  return { pid: live.id, v: spot.next, stale: false }\r\n}\r\n\r\n/** the rows proposals would add or remove, in the order the proposals came */\r\nexport function rowShades(snap: Snapshot<unknown>, base = ''): RowShade[] {\r\n  const out: RowShade[] = []\r\n  for (const p of snap.proposals) {\r\n    const rows = new Set(\r\n      p.paths\r\n        .map((path) => below(path, base))\r\n        .filter((k): k is string[] => !!k && k[0] === 'rows' && k.length >= 2)\r\n        .map((k) => k[1]),\r\n    )\r\n    for (const row of rows) {\r\n      const place = `${base}/rows/${esc(row)}`\r\n      const stale = isOut(p, place)\r\n      const then = stale ? intended(snap.state, p, place) : snap.preview\r\n      if (then === undefined) continue\r\n      const was = sheetAt(snap.state, base).rows?.[row]\r\n      const next = sheetAt(then, base).rows?.[row]\r\n      if (!was && next) out.push({ pid: p.id, v: next, stale, kind: 'add', row, cells: next.cells ?? {}, place })\r\n      else if (was && !next) out.push({ pid: p.id, v: undefined, stale, kind: 'remove', row, cells: was.cells, place })\r\n    }\r\n  }\r\n  return out\r\n}\r\n\r\n/** the cell's face while a proposal would change it: now (struck through) -> proposed; a press opens the card */\r\nexport function ShadowValue({\r\n  now,\r\n  shade,\r\n  deep,\r\n  numeric = false,\r\n  onOpen,\r\n}: {\r\n  now: ReactNode\r\n  shade: Shade\r\n  /** this proposal is hovered somewhere, or its card is open: its changes deepen everywhere they show */\r\n  deep: boolean\r\n  numeric?: boolean\r\n  onOpen: (pid: string, anchor: HTMLElement) => void\r\n}) {\r\n  const said = text(shade.v)\r\n  const open = (e: React.MouseEvent<HTMLElement>) => {\r\n    e.stopPropagation()\r\n    onOpen(shade.pid, e.currentTarget)\r\n  }\r\n  if (shade.stale)\r\n    return (\r\n      <span className={`flex min-w-0 items-center gap-1.5 ${numeric ? 'justify-end' : ''}`} data-cell-shadow={shade.pid} data-stale>\r\n        {now && <span className=\"flex max-w-full shrink-0 items-center truncate\">{now}</span>}\r\n        <button\r\n          type=\"button\"\r\n          data-shadow-open={shade.pid}\r\n          onClick={open}\r\n          className={`min-w-0 cursor-pointer truncate rounded-sm outline-none text-gray-400 line-through decoration-gray-400 dark:text-zinc-500 ${deep ? 'bg-gray-200 dark:bg-zinc-700' : ''}`}\r\n        >\r\n          {said || '—'}\r\n        </button>\r\n      </span>\r\n    )\r\n  return (\r\n    <span className={`flex min-w-0 items-center gap-1.5 ${numeric ? 'justify-end' : ''}`} data-cell-shadow={shade.pid}>\r\n      {now && <span className={`min-w-0 truncate rounded-sm text-gray-400 line-through decoration-gray-400 dark:text-zinc-500 ${deep ? 'bg-[var(--diff-del)]' : ''}`}>{now}</span>}\r\n      <button\r\n        type=\"button\"\r\n        data-shadow-open={shade.pid}\r\n        onClick={open}\r\n        className={`min-w-0 max-w-full shrink-0 cursor-pointer truncate rounded-sm outline-none text-emerald-700 underline decoration-emerald-600/60 decoration-dotted underline-offset-2 dark:text-emerald-400 ${deep ? 'bg-[var(--diff-add)]' : ''}`}\r\n      >\r\n        {said || '—'}\r\n      </button>\r\n    </span>\r\n  )\r\n}\r\n\r\n/** the rows proposals would add or remove, listed under the sheet, each a press away from its card */\r\nexport function ShadowRows({\r\n  shades,\r\n  colName,\r\n  colOrder,\r\n  rowLabel,\r\n  deep,\r\n  onOpen,\r\n  onHover,\r\n}: {\r\n  shades: RowShade[]\r\n  colName: (id: string) => string\r\n  /** a column's place, so a proposed row reads in the sheet's own order */\r\n  colOrder: (id: string) => number\r\n  rowLabel: (id: string) => string\r\n  deep: string | null\r\n  onOpen: (pid: string, anchor: HTMLElement, place: string) => void\r\n  onHover: (pid: string | null) => void\r\n}) {\r\n  if (!shades.length) return null\r\n  const hover = (pid: string) => ({ onPointerEnter: () => onHover(pid), onPointerLeave: () => onHover(null) })\r\n  return (\r\n    <div className=\"flex flex-col gap-0.5 border-t border-gray-200 px-2 py-1.5 text-sheet-ui dark:border-zinc-700\">\r\n      {shades.map((s) => {\r\n        const press = (e: React.MouseEvent<HTMLElement>) => {\r\n          e.stopPropagation()\r\n          onOpen(s.pid, e.currentTarget, s.place)\r\n        }\r\n        const on = deep === s.pid\r\n        if (s.kind === 'add') {\r\n          const words = Object.entries(s.cells)\r\n            .filter(([, v]) => text(v) !== '')\r\n            .sort(([a], [b]) => colOrder(a) - colOrder(b))\r\n            .map(([k, v]) => `${colName(k)}: ${text(v)}`)\r\n            .join(' · ')\r\n          const ink = s.stale ? 'text-gray-400 line-through dark:text-zinc-500' : 'text-emerald-700 dark:text-emerald-400'\r\n          return (\r\n            <button\r\n              key={`${s.pid}:${s.row}`}\r\n              type=\"button\"\r\n              data-row-shadow={s.pid}\r\n              data-shadow-open={s.pid}\r\n              onClick={press}\r\n              {...hover(s.pid)}\r\n              className={`flex min-w-0 cursor-pointer items-baseline gap-2 rounded-sm px-1 py-0.5 text-left outline-none ${on ? 'bg-[var(--diff-add)]' : ''}`}\r\n            >\r\n              <span className={`shrink-0 text-sheet-meta ${s.stale ? 'text-gray-500 dark:text-zinc-400' : 'text-emerald-700 dark:text-emerald-400'}`}>Proposed row</span>\r\n              <span className={`min-w-0 [overflow-wrap:anywhere] ${ink}`}>{words}</span>\r\n            </button>\r\n          )\r\n        }\r\n        return (\r\n          <button\r\n            key={`${s.pid}:${s.row}`}\r\n            type=\"button\"\r\n            data-row-shadow={s.pid}\r\n            data-shadow-open={s.pid}\r\n            onClick={press}\r\n            {...hover(s.pid)}\r\n            className={`flex min-w-0 cursor-pointer items-baseline gap-2 rounded-sm px-1 py-0.5 text-left outline-none ${on ? 'bg-[var(--diff-del)]' : ''}`}\r\n          >\r\n            <span className=\"shrink-0 text-sheet-meta text-gray-500 dark:text-zinc-400\">Proposed: remove</span>\r\n            <span className=\"min-w-0 [overflow-wrap:anywhere] text-gray-400 line-through dark:text-zinc-500\">{rowLabel(s.row)}</span>\r\n          </button>\r\n        )\r\n      })}\r\n    </div>\r\n  )\r\n}\r\n\r\nconst BTN =\r\n  'flex h-7 shrink-0 cursor-pointer items-center rounded-full px-3 text-sheet-ui font-medium transition-colors disabled:cursor-default disabled:opacity-40'\r\nconst DO_BTN = `${BTN} bg-gray-900 text-white hover:bg-gray-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300`\r\nconst OUTLINE_BTN = `${BTN} border border-gray-200 text-gray-700 hover:bg-gray-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800`\r\nconst QUIET_BTN = `${BTN} text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100`\r\n\r\n/** THE CARD A SHADOW OPENS (Cube's ShadowCard + ActionPiece \"pop\") — a small label, the proposal's line and who made it, then Do / Skip\r\n    for the place it was opened from (a cell, a row: Cursor's per-hunk accept). A proposal that changes more than this place adds\r\n    Do all / Skip all for the rest of it. There at once, no motion; a press elsewhere or Esc closes it. */\r\nexport function ProposalCard({\r\n  p,\r\n  place,\r\n  anchor,\r\n  onAccept,\r\n  onReject,\r\n  onClose,\r\n  onHover,\r\n}: {\r\n  p: Proposal\r\n  /** the cell or row it was opened from */\r\n  place: string\r\n  anchor: DOMRect\r\n  /** `all`: every place of the proposal still live; otherwise just the place the card was opened from */\r\n  onAccept: (all: boolean) => void\r\n  onReject: (all: boolean) => void\r\n  onClose: () => void\r\n  /** the pointer is over the card (its proposal), or left it (null) */\r\n  onHover?: (pid: string | null) => void\r\n}) {\r\n  const ref = useRef<HTMLDivElement>(null)\r\n  const [pos, setPos] = useState({ left: anchor.left, top: anchor.bottom + 4 })\r\n  const [error, setError] = useState<string | null>(null)\r\n  const out = isOut(p, place)\r\n  const live = livePlaces(p).length\r\n  useLayoutEffect(() => {\r\n    const el = ref.current\r\n    if (!el) return\r\n    const w = el.offsetWidth\r\n    const h = el.offsetHeight\r\n    const left = Math.max(16, Math.min(anchor.left, window.innerWidth - w - 16))\r\n    const top = anchor.bottom + 4 + h > window.innerHeight - 8 ? Math.max(8, anchor.top - h - 4) : anchor.bottom + 4\r\n    setPos((q) => (q.left === left && q.top === top ? q : { left, top }))\r\n  }, [anchor])\r\n  useEffect(() => {\r\n    const away = (e: PointerEvent) => {\r\n      const t = e.target as Element | null\r\n      if (ref.current?.contains(t as Node)) return\r\n      // a press on one of this proposal's own shadows toggles the card there, not here\r\n      if (t instanceof Element && t.closest(`[data-shadow-open=\"${p.id}\"]`)) return\r\n      onClose()\r\n    }\r\n    const key = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape') onClose()\r\n    }\r\n    document.addEventListener('pointerdown', away, true)\r\n    document.addEventListener('keydown', key)\r\n    return () => {\r\n      document.removeEventListener('pointerdown', away, true)\r\n      document.removeEventListener('keydown', key)\r\n    }\r\n  }, [onClose, p.id])\r\n  const run = (fn: () => void) => {\r\n    try {\r\n      setError(null)\r\n      fn()\r\n      onClose()\r\n    } catch (e) {\r\n      setError(e instanceof Error ? e.message : String(e))\r\n    }\r\n  }\r\n  return createPortal(\r\n    <div\r\n      ref={ref}\r\n      role=\"dialog\"\r\n      data-proposal-card={p.id}\r\n      style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 95 }}\r\n      className={`${MENU_CARD} w-[360px] max-w-[calc(100vw-32px)]`}\r\n      onPointerDown={(e) => e.stopPropagation()}\r\n      onClick={(e) => e.stopPropagation()}\r\n      onDoubleClick={(e) => e.stopPropagation()}\r\n      onKeyDown={(e) => e.stopPropagation()}\r\n      onContextMenu={(e) => e.stopPropagation()}\r\n      onPointerEnter={() => onHover?.(p.id)}\r\n      onPointerLeave={() => onHover?.(null)}\r\n    >\r\n      <div className={MENU_LABEL}>Proposed action</div>\r\n      <div className=\"px-3.5 pb-3 pt-1\" data-piece={p.id} data-state={out ? 'stale' : 'pending'}>\r\n        <div className=\"flex w-full min-w-0 items-baseline gap-2\">\r\n          <span className=\"min-w-0 flex-1 text-sheet-ui font-medium text-gray-900 [overflow-wrap:anywhere] dark:text-zinc-100\">{p.why}</span>\r\n          {out && <span className=\"shrink-0 text-sheet-meta text-amber-700 dark:text-amber-400\">changed since</span>}\r\n          <span className=\"shrink-0 text-sheet-meta text-gray-400 dark:text-zinc-500\">{p.by}</span>\r\n        </div>\r\n        <div className=\"mt-2 space-y-2.5\">\r\n          {error && <div className=\"text-sheet-ui text-red-600 dark:text-red-400\">Could not do it — {error}</div>}\r\n          <div className=\"flex flex-wrap items-center gap-2\">\r\n            {!out && (\r\n              <button type=\"button\" className={DO_BTN} data-press=\"do\" onClick={() => run(() => onAccept(false))}>\r\n                Do\r\n              </button>\r\n            )}\r\n            <button type=\"button\" className={`${QUIET_BTN} ${out ? '-ml-3' : ''}`} data-press=\"skip\" onClick={() => run(() => onReject(false))}>\r\n              Skip\r\n            </button>\r\n            {/* an outdated place offers only Skip; a live one with other live places beside it offers them too */}\r\n            {!out && live > 1 && (\r\n              <>\r\n                <span className=\"min-w-0 flex-1\" />\r\n                <button type=\"button\" className={OUTLINE_BTN} data-press=\"do-all\" onClick={() => run(() => onAccept(true))}>\r\n                  Do all {live}\r\n                </button>\r\n                <button type=\"button\" className={QUIET_BTN} data-press=\"skip-all\" onClick={() => run(() => onReject(true))}>\r\n                  Skip all\r\n                </button>\r\n              </>\r\n            )}\r\n          </div>\r\n        </div>\r\n      </div>\r\n    </div>,\r\n    document.body,\r\n  )\r\n}\r\n",
      "type": "registry:component",
      "target": "components/sheet/Shadow.tsx"
    },
    {
      "path": "registry/sheet/ColumnSettings.tsx",
      "content": "import { useRef, useState, type ReactNode } from 'react'\r\nimport { Dialog } from './dialog'\r\nimport type { Column } from './store'\r\n\r\n// COLUMN SETTINGS (Cube's ColumnSettings.tsx) — rows, a label on the left: the name is the one thing you change here (written when you\r\n// leave the field or close the window); the type and an enum's values are read off the column and shown as capsules.\r\n\r\nconst TYPE_NAMES = { text: 'String', number: 'Number', enum: 'Enum' } as const\r\n\r\nconst ROW = 'flex items-center gap-4'\r\nconst LABEL = 'w-24 shrink-0 text-sheet-body text-gray-500 dark:text-zinc-400'\r\nconst CAPSULE = 'inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-sheet-body text-gray-700 dark:bg-zinc-800 dark:text-zinc-200'\r\n\r\nexport default function ColumnSettings({\r\n  column,\r\n  option,\r\n  onRename,\r\n  onClose,\r\n}: {\r\n  column: Column\r\n  /** one enum value as the sheet draws it */\r\n  option: (value: string) => ReactNode\r\n  onRename: (name: string) => void\r\n  onClose: () => void\r\n}) {\r\n  const [name, setName] = useState(column.name)\r\n  // what was last sent, so a blur and the close that follows it do not write the same name twice\r\n  const sent = useRef(column.name)\r\n  const commitName = () => {\r\n    const next = name.trim()\r\n    if (!next) {\r\n      setName(sent.current)\r\n      return\r\n    }\r\n    if (next === sent.current) return\r\n    sent.current = next\r\n    onRename(next)\r\n  }\r\n  const options = column.enum ?? []\r\n\r\n  return (\r\n    <Dialog\r\n      open\r\n      label=\"Column settings\"\r\n      onClose={() => {\r\n        commitName()\r\n        onClose()\r\n      }}\r\n      className=\"bg-white sm:max-w-[480px] dark:bg-zinc-950\"\r\n    >\r\n      <div className=\"flex flex-col gap-4\">\r\n        <div className={ROW}>\r\n          <span className={LABEL}>Name</span>\r\n          <input\r\n            autoFocus\r\n            value={name}\r\n            onChange={(e) => setName(e.target.value)}\r\n            onBlur={commitName}\r\n            onKeyDown={(e) => {\r\n              if (e.key === 'Enter') {\r\n                e.preventDefault()\r\n                ;(e.target as HTMLInputElement).blur()\r\n              }\r\n            }}\r\n            className=\"min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sheet-body text-gray-900 outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100\"\r\n          />\r\n        </div>\r\n        <div className={ROW}>\r\n          <span className={LABEL}>Type</span>\r\n          <span className={CAPSULE}>{TYPE_NAMES[column.type]}</span>\r\n        </div>\r\n        {column.type === 'enum' && options.length > 0 && (\r\n          <div className=\"flex items-baseline gap-4\">\r\n            <span className={LABEL}>Enum list</span>\r\n            <div className=\"flex min-w-0 flex-1 flex-wrap gap-1.5\">\r\n              {options.map((v) => (\r\n                <span key={v} className=\"inline-flex\">\r\n                  {option(v)}\r\n                </span>\r\n              ))}\r\n            </div>\r\n          </div>\r\n        )}\r\n      </div>\r\n    </Dialog>\r\n  )\r\n}\r\n",
      "type": "registry:component",
      "target": "components/sheet/ColumnSettings.tsx"
    },
    {
      "path": "registry/sheet/dialog.tsx",
      "content": "import { useEffect, useRef, type ReactNode } from 'react'\r\nimport { createPortal } from 'react-dom'\r\n\r\n// Cube's two windows (components/ui/dialog + alert-dialog) without Radix: there at once and gone at once (no fade, no scale), a 1px\r\n// edge and no shadow, 12px corners; Esc and a press outside close them.\r\n\r\nfunction useEsc(open: boolean, close: () => void) {\r\n  const latest = useRef(close)\r\n  latest.current = close\r\n  useEffect(() => {\r\n    if (!open) return undefined\r\n    const key = (e: KeyboardEvent) => {\r\n      if (e.key !== 'Escape') return\r\n      e.stopPropagation()\r\n      latest.current()\r\n    }\r\n    window.addEventListener('keydown', key, true)\r\n    return () => window.removeEventListener('keydown', key, true)\r\n  }, [open])\r\n}\r\n\r\nconst OVERLAY = 'fixed inset-0 z-50000 bg-black/40 dark:bg-black/60'\r\n\r\n/** a window that holds content (Column settings): 24px in, a 1px edge, no title row, no × */\r\nexport function Dialog({ open, onClose, label, className = '', children }: { open: boolean; onClose: () => void; label: string; className?: string; children: ReactNode }) {\r\n  useEsc(open, onClose)\r\n  if (!open || typeof document === 'undefined') return null\r\n  return createPortal(\r\n    <>\r\n      <div className={OVERLAY} onPointerDown={onClose} />\r\n      <div\r\n        role=\"dialog\"\r\n        aria-modal\r\n        aria-label={label}\r\n        className={`fixed left-1/2 top-1/2 z-[10000000] grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 grid-cols-[minmax(0,1fr)] gap-4 rounded-xl border border-gray-200 p-6 dark:border-zinc-800 ${className || 'sm:max-w-lg'}`}\r\n      >\r\n        {children}\r\n      </div>\r\n    </>,\r\n    document.body,\r\n  )\r\n}\r\n\r\nconst BTN =\r\n  'inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-3 text-sheet-ui font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 disabled:cursor-default disabled:opacity-60'\r\nconst CANCEL =\r\n  'border border-gray-200 bg-white text-gray-800 hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800'\r\nconst ACTION = {\r\n  danger: 'bg-red-600 text-white hover:bg-red-700',\r\n  default: 'bg-gray-900 text-white hover:bg-gray-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300',\r\n}\r\n\r\n/** THE CONFIRM WINDOW — 400px, a 16px title and one line under it, Cancel (focused on open, so Enter never removes) and the action */\r\nexport function ConfirmDialog({\r\n  open,\r\n  onOpenChange,\r\n  title,\r\n  description,\r\n  action,\r\n  tone = 'danger',\r\n  onConfirm,\r\n}: {\r\n  open: boolean\r\n  onOpenChange: (open: boolean) => void\r\n  title: ReactNode\r\n  description?: ReactNode\r\n  action: ReactNode\r\n  tone?: keyof typeof ACTION\r\n  onConfirm: () => void\r\n}) {\r\n  useEsc(open, () => onOpenChange(false))\r\n  if (!open || typeof document === 'undefined') return null\r\n  return createPortal(\r\n    <>\r\n      <div className={OVERLAY} onPointerDown={() => onOpenChange(false)} />\r\n      <div\r\n        role=\"alertdialog\"\r\n        aria-modal\r\n        className=\"fixed left-1/2 top-1/2 z-[10000000] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-gray-200 bg-white p-5 outline-none sm:max-w-[400px] dark:border-zinc-800 dark:bg-zinc-900\"\r\n      >\r\n        <div className=\"text-sheet-head font-semibold leading-snug text-gray-900 dark:text-zinc-100\">{title}</div>\r\n        {description && <div className=\"mt-1.5 text-sheet-body leading-relaxed text-gray-500 dark:text-zinc-400\">{description}</div>}\r\n        <div className=\"mt-5 flex items-center justify-end gap-2\">\r\n          <button type=\"button\" autoFocus onClick={() => onOpenChange(false)} className={`${BTN} ${CANCEL}`}>\r\n            Cancel\r\n          </button>\r\n          <button type=\"button\" onClick={onConfirm} className={`${BTN} ${ACTION[tone]}`}>\r\n            {action}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    </>,\r\n    document.body,\r\n  )\r\n}\r\n",
      "type": "registry:component",
      "target": "components/sheet/dialog.tsx"
    },
    {
      "path": "registry/sheet/Bones.tsx",
      "content": "import { Plus, Search } from 'lucide-react'\r\n\r\n// BONES, NOT AN EMPTY STATE (Cube's Bones.tsx, the sheet kind) — while a sheet is still on its way it has no rows, and saying \"no rows\r\n// yet\" would be a lie about the data. A fake table of its own: it copies the real table's box model (text-sheet-body leading-5 with\r\n// px-2 py-1 is the row height to the pixel, every cell carries a real space), runs past any screen and lets the frame clip it, and the\r\n// whole thing breathes together with one animate-pulse.\r\n\r\nconst COLS = 8\r\nconst ROWS = 44\r\nconst GUTTER = 36 // 20 + 8 x two digits, the row-number column at its usual width\r\nconst TAIL = 128 // the add-column column, as the real table reserves it\r\n\r\nexport function PageBones() {\r\n  const edge = 'border-b border-r border-gray-300 dark:border-zinc-700'\r\n  const pad = 'px-2 py-1'\r\n  return (\r\n    <div className=\"flex h-full min-h-0 animate-pulse flex-col\" aria-busy>\r\n      <div className=\"mb-2 flex shrink-0 items-center gap-2 px-3 pt-0.5\">\r\n        <span className=\"relative flex min-w-0 shrink\">\r\n          <Search className=\"pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-300 dark:text-zinc-700\" strokeWidth={2} />\r\n          <span className=\"block h-[30px] w-44 rounded-md border border-gray-100 dark:border-zinc-800/60\" />\r\n        </span>\r\n      </div>\r\n      <div className=\"min-h-0 flex-1 overflow-hidden\">\r\n        <table className=\"w-full border-separate border-spacing-0 text-sheet-body leading-5\" style={{ tableLayout: 'fixed' }}>\r\n          <thead>\r\n            <tr>\r\n              <th className={`${edge} ${pad} sheet-bone-firm border-l border-t`} style={{ width: GUTTER }}>\r\n                &nbsp;\r\n              </th>\r\n              {Array.from({ length: COLS }).map((_, i) => (\r\n                <th key={i} className={`${edge} ${pad} sheet-bone-firm border-t`}>\r\n                  &nbsp;\r\n                </th>\r\n              ))}\r\n              <th className={`${edge} ${pad} sheet-bone-firm border-t`} style={{ width: TAIL }}>\r\n                <span className=\"flex items-center gap-1 px-1 py-0.5 text-sheet-ui text-gray-300 dark:text-zinc-700\">\r\n                  <Plus className=\"h-3.5 w-3.5 shrink-0\" strokeWidth={2.2} />\r\n                  &nbsp;\r\n                </span>\r\n              </th>\r\n            </tr>\r\n          </thead>\r\n          <tbody>\r\n            {Array.from({ length: ROWS }).map((_, row) => (\r\n              <tr key={row}>\r\n                <td className={`${edge} ${pad} sheet-bone border-l`} style={{ width: GUTTER }}>\r\n                  &nbsp;\r\n                </td>\r\n                {Array.from({ length: COLS }).map((_, i) => (\r\n                  <td key={i} className={`${edge} ${pad} sheet-bone`}>\r\n                    &nbsp;\r\n                  </td>\r\n                ))}\r\n                <td className={`${edge} ${pad} sheet-bone`} style={{ width: TAIL }}>\r\n                  &nbsp;\r\n                </td>\r\n              </tr>\r\n            ))}\r\n          </tbody>\r\n        </table>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n",
      "type": "registry:component",
      "target": "components/sheet/Bones.tsx"
    },
    {
      "path": "registry/sheet/ui.ts",
      "content": "import { useEffect, useRef } from 'react'\r\n\r\n// ONE MENU GRAMMAR (Cube's lib/cube/menu.ts) — the card has no padding and clips its rows to its corners; rows run edge to edge with a\r\n// hairline between each, and hover fills the whole row at once (no colour transition).\r\n\r\n/** the card: rounded edge, no padding, rows clipped to the corners, a hairline between every child */\r\nexport const MENU_BOX =\r\n  'overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-100 dark:border-zinc-700 dark:bg-zinc-900 dark:divide-zinc-800'\r\n\r\n/** a row: edge to edge, the whole row's ground answers hover */\r\nexport const MENU_ROW =\r\n  'flex w-full cursor-pointer items-center gap-2.5 px-3.5 py-1.5 text-left text-sheet-body font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent dark:text-zinc-300 dark:hover:bg-white/[0.04]'\r\n\r\n/** a destructive row's ink (Remove) */\r\nexport const MENU_DANGER = '!text-red-600 dark:!text-red-400'\r\n\r\n/** a section's small label inside a menu (no hover, not a row) */\r\nexport const MENU_LABEL = 'px-3.5 pb-1 pt-2 text-sheet-meta font-medium text-gray-400 dark:text-zinc-500'\r\n\r\n/** the card alone, for a menu whose inside is not one list of rows */\r\nexport const MENU_CARD = 'overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-zinc-700 dark:bg-zinc-900'\r\n\r\n// THE ONE SCROLLBAR (Cube's lib/cube/scrollFade.ts) — `.sheet-scroll` paints an overlay bar that is invisible at rest and shows only\r\n// while its own box is being scrolled, which it learns from an `.is-scrolling` class.\r\nexport function useScrollFade(quietMs = 700) {\r\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)\r\n  useEffect(\r\n    () => () => {\r\n      if (timer.current) clearTimeout(timer.current)\r\n    },\r\n    [],\r\n  )\r\n  return (e: React.UIEvent<HTMLElement>) => {\r\n    const el = e.currentTarget\r\n    el.classList.add('is-scrolling')\r\n    if (timer.current) clearTimeout(timer.current)\r\n    timer.current = setTimeout(() => el.classList.remove('is-scrolling'), quietMs)\r\n  }\r\n}\r\n",
      "type": "registry:lib",
      "target": "components/sheet/ui.ts"
    },
    {
      "path": "registry/sheet/store.ts",
      "content": "import { cube, type Cube, type StandardSchemaV1 } from 'usecube'\r\n\r\n// THE SHEET'S STATE, ADDRESSED FOR AGENTS — every column and row has a key that never moves, so a model writes\r\n// /rows/r3/cells/stage, never /rows/2/cells/4. Order is a field, sorted on render.\r\n\r\nexport type Value = string | number\r\nexport type ColumnType = 'text' | 'number' | 'enum'\r\n\r\nexport interface Column {\r\n  name: string\r\n  type: ColumnType\r\n  /** columns show sorted by order */\r\n  order: number\r\n  /** an enum column's values, in the order their badges are toned */\r\n  enum?: string[]\r\n}\r\n\r\nexport interface Row {\r\n  /** rows show sorted by order */\r\n  order: number\r\n  /** column id -> value; an empty cell is a missing key */\r\n  cells: Record<string, Value>\r\n}\r\n\r\nexport interface SheetState {\r\n  columns: Record<string, Column>\r\n  rows: Record<string, Row>\r\n}\r\n\r\nexport type SheetStore = Cube<SheetState>\r\n\r\n/** the sheet's pointer layout, in words for an agent */\r\nexport const SHEET_LAYOUT =\r\n  'A spreadsheet. Columns live at /columns/{colId} as {name, type: \"text\" | \"number\" | \"enum\", order, enum?: [allowed values]}; ' +\r\n  'rows live at /rows/{rowId} as {order, cells: {[colId]: value}}. Both show sorted by order. Read it first to learn the ids. ' +\r\n  'Set a cell with add /rows/{rowId}/cells/{colId}; clear one with remove. ' +\r\n  'Add a row with add /rows/{newRowId} and {order, cells}, using a new id and an order after the last row\\'s. ' +\r\n  'Remove a row with remove /rows/{rowId}. A number cell holds a number; an enum cell holds one of its column\\'s values. ' +\r\n  'An empty cell is a missing key.'\r\n\r\n/** A Cube store for <Sheet>: the pointer layout explained to agents, and edits that would break the sheet refused with the reason. */\r\nexport function sheetStore(initial: SheetState, { name = 'sheet', about }: { name?: string; about?: string } = {}): SheetStore {\r\n  return cube(initial, { name, about: [about, SHEET_LAYOUT].filter(Boolean).join(' '), schema: sheetSchema })\r\n}\r\n\r\nconst isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)\r\nconst isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)\r\nconst TYPES: ColumnType[] = ['text', 'number', 'enum']\r\n\r\n/** A Standard Schema (standardschema.dev): its messages go back to the model as they are, so they say what to send instead. */\r\nexport const sheetSchema: StandardSchemaV1<SheetState> = {\r\n  '~standard': {\r\n    version: 1,\r\n    vendor: 'usecube-sheet',\r\n    validate(value) {\r\n      const issues: StandardSchemaV1.Issue[] = []\r\n      const say = (path: string[], message: string) => issues.push({ path, message })\r\n      if (!isObj(value) || !isObj(value.columns) || !isObj(value.rows)) {\r\n        return { issues: [{ message: 'the sheet is {columns: {[colId]: column}, rows: {[rowId]: row}}' }] }\r\n      }\r\n      const columns = value.columns as Record<string, unknown>\r\n      for (const [id, c] of Object.entries(columns)) {\r\n        if (!isObj(c)) {\r\n          say(['columns', id], 'a column is {name, type, order}')\r\n          continue\r\n        }\r\n        if (typeof c.name !== 'string' || !c.name.trim()) say(['columns', id, 'name'], 'a column needs a name')\r\n        if (!TYPES.includes(c.type as ColumnType)) say(['columns', id, 'type'], 'type is \"text\", \"number\" or \"enum\"')\r\n        if (!isNum(c.order)) say(['columns', id, 'order'], 'order is a number')\r\n        if (c.type === 'enum' && (!Array.isArray(c.enum) || c.enum.some((x) => typeof x !== 'string')))\r\n          say(['columns', id, 'enum'], 'an enum column lists its values as enum: [\"...\", \"...\"]')\r\n      }\r\n      const ids = Object.keys(columns).join(', ')\r\n      for (const [id, r] of Object.entries(value.rows as Record<string, unknown>)) {\r\n        if (!isObj(r)) {\r\n          say(['rows', id], 'a row is {order, cells: {[colId]: value}}')\r\n          continue\r\n        }\r\n        if (!isNum(r.order)) say(['rows', id, 'order'], 'order is a number')\r\n        if (!isObj(r.cells)) {\r\n          say(['rows', id, 'cells'], 'cells is an object: {[colId]: value}')\r\n          continue\r\n        }\r\n        for (const [colId, v] of Object.entries(r.cells)) {\r\n          const col = columns[colId] as Column | undefined\r\n          const at = ['rows', id, 'cells', colId]\r\n          if (!isObj(col)) say(at, `there is no column \"${colId}\"; the columns are ${ids}`)\r\n          else if (col.type === 'number' && !isNum(v)) say(at, `${col.name} is a number column: send a number, not ${JSON.stringify(v)}`)\r\n          else if (col.type === 'enum' && !(col.enum ?? []).includes(v as string))\r\n            say(at, `${JSON.stringify(v)} is not one of ${col.name}'s values: ${(col.enum ?? []).join(', ')}`)\r\n          else if (col.type === 'text' && typeof v !== 'string' && !isNum(v)) say(at, 'a text cell holds a string')\r\n        }\r\n      }\r\n      return issues.length ? { issues } : { value: value as unknown as SheetState }\r\n    },\r\n  },\r\n}\r\n\r\n/** columns or rows as [id, item] pairs in their order (ties by id, so the order never flickers) */\r\nexport function sorted<T extends { order: number }>(items: Record<string, T> | undefined): [string, T][] {\r\n  return Object.entries(items ?? {}).sort(([ia, a], [ib, b]) => a.order - b.order || (ia < ib ? -1 : ia > ib ? 1 : 0))\r\n}\r\n\r\n/** an order that lands right after `after` (and before whatever follows it), or at the end */\r\nexport function orderAfter(list: { order: number }[], after?: number): number {\r\n  if (!list.length) return 1\r\n  if (after === undefined || after >= list.length - 1) return list[list.length - 1].order + 1\r\n  return (list[after].order + list[after + 1].order) / 2\r\n}\r\n\r\n/** a fresh id: time plus a little noise, like Cube mints them */\r\nexport const mintId = (prefix: string) =>\r\n  `${prefix}${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36).padStart(2, '0')}`\r\n",
      "type": "registry:lib",
      "target": "components/sheet/store.ts"
    },
    {
      "path": "registry/sheet/sheet.css",
      "content": "/* The sheet's tokens and the few classes Tailwind does not make (Cube's globals.css, the parts the sheet uses).\r\n   Import it from your Tailwind v4 entry, after Tailwind:\r\n\r\n     @import \"tailwindcss\";\r\n     @import \"./components/sheet/sheet.css\";\r\n\r\n   Dark mode follows a `.dark` class on an ancestor (as shadcn does). If your app does that too, declare the variant there:\r\n     @custom-variant dark (&:where(.dark, .dark *));                                                                        */\r\n\r\n@theme {\r\n  /* the four type sizes the sheet uses: text-sheet-meta, -ui, -body, -head */\r\n  --text-sheet-meta: 12px; /* labels, hints */\r\n  --text-sheet-ui: 13px; /* headers, row numbers, dense controls */\r\n  --text-sheet-body: 14px; /* cells, menus, dialogs */\r\n  --text-sheet-head: 16px; /* dialog titles */\r\n}\r\n\r\n/* A DEEPENED PROPOSAL — GitHub's word-level diff tints, laid behind new values (add) and struck old ones (del) while a proposal is hovered */\r\n:root { --diff-add: #abf2bc; --diff-del: rgb(255 129 130 / 0.4); }\r\n.dark { --diff-add: rgb(46 160 67 / 0.4); --diff-del: rgb(248 81 73 / 0.4); }\r\n\r\n/* the sheet's scroller is its own compositor layer, so its sticky header never trails the scroll by a frame */\r\n.sheet-scroller {\r\n  transform: translateZ(0);\r\n  will-change: scroll-position;\r\n  contain: paint;\r\n}\r\n\r\n/* ONE SCROLLBAR — an overlay bar, invisible at rest; it shows while its box is being scrolled (.is-scrolling, toggled by\r\n   useScrollFade) or under the pointer. No scrollbar-width here: with it Chrome draws the classic bar with arrow buttons. */\r\n.sheet-scroll::-webkit-scrollbar { width: 10px; height: 10px; }\r\n.sheet-scroll::-webkit-scrollbar-button { display: none !important; height: 0 !important; width: 0 !important; }\r\n.sheet-scroll::-webkit-scrollbar-track { background: transparent; }\r\n.sheet-scroll::-webkit-scrollbar-corner { background: transparent; }\r\n.sheet-scroll::-webkit-scrollbar-thumb {\r\n  background-color: transparent;\r\n  border-radius: 999px;\r\n  border: 2px solid transparent;\r\n  background-clip: padding-box;\r\n  transition: background-color 0.7s ease;\r\n}\r\n.sheet-scroll.is-scrolling::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.32); transition: background-color 0.12s ease; }\r\n.sheet-scroll::-webkit-scrollbar-thumb:hover { background-color: rgba(0, 0, 0, 0.5); }\r\n.dark .sheet-scroll.is-scrolling::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.3); }\r\n.dark .sheet-scroll::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, 0.48); }\r\n\r\n/* MORE TO THE SIDE — a soft inset shadow on the edge the table runs past, in the sheet's own ground (white / zinc-900) */\r\n.sheet-edge-l { box-shadow: inset 18px 0 14px -12px rgb(255 255 255 / 0.95); }\r\n.sheet-edge-r { box-shadow: inset -18px 0 14px -12px rgb(255 255 255 / 0.95); }\r\n.sheet-edge-lr { box-shadow: inset 18px 0 14px -12px rgb(255 255 255 / 0.95), inset -18px 0 14px -12px rgb(255 255 255 / 0.95); }\r\n.dark .sheet-edge-l { box-shadow: inset 18px 0 14px -12px rgb(24 24 27 / 0.95); }\r\n.dark .sheet-edge-r { box-shadow: inset -18px 0 14px -12px rgb(24 24 27 / 0.95); }\r\n.dark .sheet-edge-lr { box-shadow: inset 18px 0 14px -12px rgb(24 24 27 / 0.95), inset -18px 0 14px -12px rgb(24 24 27 / 0.95); }\r\n\r\n/* BONES — two rest tones; the breathing is animate-pulse on the container */\r\n.sheet-bone { background-color: rgba(229, 231, 235, 0.55); }\r\n.sheet-bone-firm { background-color: rgba(209, 213, 219, 0.6); }\r\n.dark .sheet-bone { background-color: rgba(39, 39, 42, 0.85); }\r\n.dark .sheet-bone-firm { background-color: rgba(63, 63, 70, 0.7); }\r\n\r\n/* while a column's seam is dragged */\r\n.sheet-col-resizing,\r\n.sheet-col-resizing * { cursor: col-resize !important; user-select: none !important; }\r\n",
      "type": "registry:file",
      "target": "components/sheet/sheet.css"
    }
  ],
  "type": "registry:block"
}