Skip to content
Starter Kits— browse docs
On this page

Sheet (tool)

An Airtable-flavoured table — named typed columns, formulas, card-reference cells, and TSV paste.

Spreadsheets are the format people reach for when they don't yet know what shape their data is. Prices, stat blocks, timelines, a list of every ship in the fleet — it all starts as a grid, and it stays a grid for longer than anyone expects.

This kit is that grid, done properly for a world: addressed like Sheets, merged like a CRDT, and able to hold a reference to a real document in a cell instead of a copy of its name.

You will learn

  • How a grid stays mergeable without a row-identity list to keep in sync
  • How formulas, column types and column widths ride the same per-key merge
  • Where a cell that points at a world document differs from a cell that holds text
  • What to change first when your data isn't really a spreadsheet

Try it

Sheet, running
Starting the example…

Click a cell and type — the grid is fully live, formulas and all. One quirk worth knowing before it surprises you: the scaffold writes three sample rows into a fresh sheet on first open, and with no sync connection behind it the grid doesn't repaint until something else changes. So the sample arrives on your first keystroke rather than immediately. In a real world the connection keeps the view current and you never see it.

Click a cell and type. Tab across, Enter down. Drag a column edge to resize it, right-click a header to rename or retype the column, and paste a block of tab-separated text — it fills the grid the way you'd expect. =SUM(A1:A4) works.

Create it

vvd create armory --tool --template=sheet
Expected output:
→ Creating tool armory in /Users/you/dev/armory — from the Sheet template

✦  nice. armory the tool is born.

Next:
cd armory
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created armory (tool) → /Users/you/dev/armory

What you'd build with it

Anything where the answer to "what does one row mean?" is stable but the columns keep moving:

  • An item or equipment table — name, cost, weight, rarity, and a card-reference column pointing at the item's own page.
  • A stat block library — one row per creature, with the formula columns doing the arithmetic your system needs.
  • A production schedule — episodes, dates, who's on them, status. The column types keep dates as dates.
  • A pricing or economy sheet — where =SUM and =AVG over a range are the entire point.
  • An import landing zone — paste a CSV your collaborator sent from Excel, clean it up in place, then link the rows to real world documents one at a time.
  • A translation table — one row per string, one column per language, and everyone filling in their own column at once.

What's in it

src/codec.ts32 lines

The data model. One declaration of what the document holds and how two people's edits merge — and the file `vvd save` reads to derive what an agent can do with your creation.

src/codec.ts
import { defineStateCodec, field } from "@vvd/sdk"

// The MERGE story: the grid is ADDRESSED like Sheets (columns A,B,C…, rows
// 1..N) — no row/column identity lists to keep consistent. Every filled cell
// is one per-key map entry keyed "r:c" (0-based row:column), so two peers
// editing different cells always both land, and the same key resolves per-cell
// last-write-wins. Cells hold RAW text only: formulas start with "="
// ("=SUM(A1:B3)"); a world-document reference cell is "vvdref:<documentId>"
// (always the id, never a copy). The Airtable layer rides the SAME per-key
// merge, keyed by COLUMN INDEX as a string: colWidths (pixel numbers),
// colNames (display names — the letter stays the formula address), and
// colTypes ("text" | "number" | "card"). Two peers renaming/retyping different
// columns both land; the same column resolves last-write-wins per key.
// Structure ops (insert/delete columns) remap all three maps ALONGSIDE cells
// in one transact, so names and types never drift off their columns. The
// grid's extent is two last-write-wins counters — rows and columns are added
// deliberately (the New-row strip, the header plus, the context menus), never
// as a side effect of walking or pasting past the edge.
const shape = {
  cells: field.map<string>(),
  colWidths: field.map<number>(),
  colNames: field.map<string>(),
  colTypes: field.map<string>(),
  rowCount: field.value<number>(40),
  colCount: field.value<number>(12),
}

export type SheetShape = typeof shape

export const codec = defineStateCodec(shape)

export default codec
src/tool.tsx1234 lines

The tool itself: a `defineTool` with a `render` function. This is the file you edit first.

src/tool.tsx
import { type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useRef, useState } from "react"

import { DocumentGate, HostIcon, type StateActions, type StateData, defineTool, useContextMenu, useDocDropTarget, useDocument, useDocumentAwareness, useDocumentPresence, useHostCapability } from "@vvd/sdk"

import { type SheetShape, codec } from "@/codec"

const NAME = "armory"
const FILE = "src/tool.tsx"
void FILE

// armory — an Airtable-flavored collaborative table on a Sheets engine. The
// grid is ADDRESSED like Sheets (columns A,B,C…, rows 1..N; cells keyed "r:c",
// merged per-cell) and columns wear NAMES and TYPES on top: the sticky header
// edits each column's name in place (the letter stays the formula address, shown
// as a tiny tag beside a custom name), and the column right-click menu sets its
// type — text (left-aligned), number (right-aligned, summed in a footer row),
// or card (every cell is a live world-reference chip via the anchored picker or
// a sidebar drop). Formulas start with "=" and recompute live ("=SUM(A1:B3)*2")
// in text/number columns, and the clipboard speaks TSV both ways (pasting from
// Google Sheets just works). The extent is FIXED: paste, import, and arrow-walks
// CLAMP to it — rows and columns are added deliberately (the New-row strip, the
// header plus, the context menus). Column names/types/widths are shared state;
// your scroll position and selection stay yours alone — peers' selections glow
// in their color. Every mutation rides actions (multi-cell edits in one
// transact), so the platform undo timeline covers all of it. Every grid element
// sizes boxSizing "border-box" so padding never skews column alignment.

type Sel = { r: number; c: number }
type Rng = { r0: number; c0: number; r1: number; c1: number }
type PeerSel = { color: string; name: string; sel: Sel; range: Rng | null }
type Hit = { id: string; name: string; entityTypeName: string | null; entityTypeIcon: string | null }

const ROW_H = 26
const HEAD_H = 30
const NUM_W = 44
const DEF_W = 168
const MIN_W = 60
const ADD_W = 26
const REF_PREFIX = "vvdref:"
const LINE = "1px solid rgba(127,127,127,0.22)"
const HEAD_BG: CSSProperties = { background: "rgba(127,127,127,0.10)", backdropFilter: "blur(6px)" }

const normRange = (a: Sel, b: Sel): Rng => ({ r0: Math.min(a.r, b.r), c0: Math.min(a.c, b.c), r1: Math.max(a.r, b.r), c1: Math.max(a.c, b.c) })
const inRange = (rg: Rng, r: number, c: number) => r >= rg.r0 && r <= rg.r1 && c >= rg.c0 && c <= rg.c1

// [formula-engine:start] — a tiny spreadsheet evaluator. Pure and self-contained
// (no imports, no DOM): the CLI test suite extracts this block verbatim and runs
// it under node. Values are number | string | boolean; errors travel as the
// strings "#REF!" (cycles / broken references) and "#ERROR!" (everything else)
// and propagate through references, exactly like Sheets.

type Val = number | string | boolean

type Node =
  | { k: "num"; v: number }
  | { k: "str"; v: string }
  | { k: "bool"; v: boolean }
  | { k: "ref"; r: number; c: number }
  | { k: "rng"; r0: number; c0: number; r1: number; c1: number }
  | { k: "un"; op: string; a: Node }
  | { k: "pct"; a: Node }
  | { k: "bin"; op: string; a: Node; b: Node }
  | { k: "fn"; name: string; args: Node[] }

type Tok = { t: "num" | "str" | "id" | "op"; v: string }
type Getter = (r: number, c: number) => Val

/** 0-based column index to its Sheets letters — the A1 addressing helper (0 = A, 26 = AA). */
function colName(c: number): string {
  let n = c
  let out = ""
  for (;;) {
    out = String.fromCharCode(65 + (n % 26)) + out
    n = Math.floor(n / 26) - 1
    if (n < 0) return out
  }
}

/** "B3" to { r: 2, c: 1 } (0-based), or null when the text is not an A1 reference. */
function parseA1(ref: string): { r: number; c: number } | null {
  const m = /^([A-Za-z]+)([0-9]+)$/.exec(ref)
  if (!m) return null
  let c = 0
  for (let i = 0; i < m[1].length; i++) c = c * 26 + (m[1].toUpperCase().charCodeAt(i) - 64)
  const r = parseInt(m[2], 10) - 1
  return r < 0 ? null : { r, c: c - 1 }
}

function tokenize(src: string): Tok[] {
  const out: Tok[] = []
  const isDigit = (ch: string) => ch >= "0" && ch <= "9"
  const isAlpha = (ch: string) => (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_"
  let i = 0
  while (i < src.length) {
    const ch = src.charAt(i)
    if (ch === " ") { i++; continue }
    if (isDigit(ch) || (ch === "." && isDigit(src.charAt(i + 1)))) {
      let j = i + 1
      while (j < src.length && (isDigit(src.charAt(j)) || src.charAt(j) === ".")) j++
      out.push({ t: "num", v: src.slice(i, j) })
      i = j
      continue
    }
    if (ch === '"') {
      let j = i + 1
      let s = ""
      for (;;) {
        if (j >= src.length) throw new Error("#ERROR!")
        if (src.charAt(j) === '"') {
          if (src.charAt(j + 1) === '"') { s += '"'; j += 2; continue }
          break
        }
        s += src.charAt(j)
        j++
      }
      out.push({ t: "str", v: s })
      i = j + 1
      continue
    }
    if (isAlpha(ch)) {
      let j = i + 1
      while (j < src.length && (isAlpha(src.charAt(j)) || isDigit(src.charAt(j)))) j++
      out.push({ t: "id", v: src.slice(i, j) })
      i = j
      continue
    }
    const two = src.slice(i, i + 2)
    if (two === "<=" || two === ">=" || two === "<>") { out.push({ t: "op", v: two }); i += 2; continue }
    if ("+-*/^%(),:=<>&".indexOf(ch) >= 0) { out.push({ t: "op", v: ch }); i++; continue }
    throw new Error("#ERROR!")
  }
  return out
}

/** Recursive-descent parse of the text after "=". Throws "#ERROR!" on bad syntax. */
function parseFormula(src: string): Node {
  const toks = tokenize(src)
  let pos = 0
  const isOp = (v: string) => { const t = toks[pos] as Tok | undefined; return !!t && t.t === "op" && t.v === v }
  const take = (): Tok => {
    const t = toks[pos] as Tok | undefined
    if (!t) throw new Error("#ERROR!")
    pos++
    return t
  }
  const expectOp = (v: string) => { if (!isOp(v)) throw new Error("#ERROR!"); pos++ }
  const primary = (): Node => {
    const t = take()
    if (t.t === "num") return { k: "num", v: parseFloat(t.v) }
    if (t.t === "str") return { k: "str", v: t.v }
    if (t.t === "op" && t.v === "(") {
      const inner = comparison()
      expectOp(")")
      return inner
    }
    if (t.t === "id") {
      if (isOp("(")) {
        pos++
        const args: Node[] = []
        if (!isOp(")")) {
          for (;;) {
            args.push(comparison())
            if (isOp(",")) { pos++; continue }
            break
          }
        }
        expectOp(")")
        return { k: "fn", name: t.v.toUpperCase(), args }
      }
      const up = t.v.toUpperCase()
      if (up === "TRUE") return { k: "bool", v: true }
      if (up === "FALSE") return { k: "bool", v: false }
      const a = parseA1(t.v)
      if (!a) throw new Error("#ERROR!")
      if (isOp(":")) {
        pos++
        const t2 = take()
        const b = t2.t === "id" ? parseA1(t2.v) : null
        if (!b) throw new Error("#ERROR!")
        return { k: "rng", r0: Math.min(a.r, b.r), c0: Math.min(a.c, b.c), r1: Math.max(a.r, b.r), c1: Math.max(a.c, b.c) }
      }
      return { k: "ref", r: a.r, c: a.c }
    }
    throw new Error("#ERROR!")
  }
  const postfix = (): Node => {
    let n = primary()
    while (isOp("%")) { pos++; n = { k: "pct", a: n } }
    return n
  }
  const unary = (): Node => {
    if (isOp("-") || isOp("+")) { const t = take(); return { k: "un", op: t.v, a: unary() } }
    return postfix()
  }
  const power = (): Node => {
    const a = unary()
    if (isOp("^")) { pos++; return { k: "bin", op: "^", a, b: power() } }
    return a
  }
  const mul = (): Node => {
    let a = power()
    while (isOp("*") || isOp("/")) { const t = take(); a = { k: "bin", op: t.v, a, b: power() } }
    return a
  }
  const add = (): Node => {
    let a = mul()
    while (isOp("+") || isOp("-")) { const t = take(); a = { k: "bin", op: t.v, a, b: mul() } }
    return a
  }
  const concat = (): Node => {
    let a = add()
    while (isOp("&")) { pos++; a = { k: "bin", op: "&", a, b: add() } }
    return a
  }
  const comparison = (): Node => {
    let a = concat()
    while (isOp("=") || isOp("<>") || isOp("<") || isOp(">") || isOp("<=") || isOp(">=")) {
      const t = take()
      a = { k: "bin", op: t.v, a, b: concat() }
    }
    return a
  }
  const node = comparison()
  if (pos < toks.length) throw new Error("#ERROR!")
  return node
}

const lift = (v: Val): Val => {
  if (v === "#REF!" || v === "#ERROR!") throw new Error(typeof v === "string" ? v : "#ERROR!")
  return v
}

function toNum(v: Val): number {
  if (typeof v === "number") return v
  if (typeof v === "boolean") return v ? 1 : 0
  if (v === "") return 0
  const n = Number(v)
  if (Number.isNaN(n)) throw new Error("#ERROR!")
  return n
}

function toStr(v: Val): string {
  if (typeof v === "boolean") return v ? "TRUE" : "FALSE"
  return String(v)
}

function cmpVals(a: Val, b: Val): number {
  if (typeof a === "number" && typeof b === "number") return a - b
  const as = toStr(a).toLowerCase()
  const bs = toStr(b).toLowerCase()
  return as < bs ? -1 : as > bs ? 1 : 0
}

/** Flatten function args — ranges expand to every cell in them, in row order. */
function flatten(args: Node[], get: Getter): Val[] {
  const out: Val[] = []
  for (const n of args) {
    if (n.k === "rng") {
      // Cap range expansion — a runaway A1:ZZ999999 must error, never freeze the tab.
      if ((n.r1 - n.r0 + 1) * (n.c1 - n.c0 + 1) > 100000) throw new Error("#ERROR!")
      for (let r = n.r0; r <= n.r1; r++) for (let c = n.c0; c <= n.c1; c++) out.push(lift(get(r, c)))
    } else out.push(evalNode(n, get))
  }
  return out
}

function numbersIn(vals: Val[]): number[] {
  const out: number[] = []
  for (const v of vals) {
    if (typeof v === "number") out.push(v)
    else if (typeof v === "string" && v !== "" && !Number.isNaN(Number(v))) out.push(Number(v))
  }
  return out
}

function callFn(name: string, args: Node[], get: Getter): Val {
  if (name === "IF") {
    if (args.length < 2) throw new Error("#ERROR!")
    const cond = toNum(evalNode(args[0], get)) !== 0
    if (cond) return evalNode(args[1], get)
    return args.length > 2 ? evalNode(args[2], get) : ""
  }
  const vals = flatten(args, get)
  if (name === "SUM") { let s = 0; for (const v of numbersIn(vals)) s += v; return s }
  if (name === "AVERAGE") {
    const ns = numbersIn(vals)
    if (ns.length === 0) throw new Error("#ERROR!")
    let s = 0
    for (const v of ns) s += v
    return s / ns.length
  }
  if (name === "MIN") { const ns = numbersIn(vals); return ns.length === 0 ? 0 : Math.min.apply(null, ns) }
  if (name === "MAX") { const ns = numbersIn(vals); return ns.length === 0 ? 0 : Math.max.apply(null, ns) }
  if (name === "COUNT") return numbersIn(vals).length
  if (name === "COUNTA") { let s = 0; for (const v of vals) if (v !== "") s++; return s }
  if (name === "ROUND") {
    if (vals.length === 0) throw new Error("#ERROR!")
    const f = Math.pow(10, vals.length > 1 ? toNum(vals[1]) : 0)
    return Math.round(toNum(vals[0]) * f) / f
  }
  if (name === "ABS") {
    if (vals.length === 0) throw new Error("#ERROR!")
    return Math.abs(toNum(vals[0]))
  }
  if (name === "CONCATENATE") { let s = ""; for (const v of vals) s += toStr(v); return s }
  throw new Error("#ERROR!")
}

function evalNode(n: Node, get: Getter): Val {
  if (n.k === "num") return n.v
  if (n.k === "str") return n.v
  if (n.k === "bool") return n.v
  if (n.k === "ref") return lift(get(n.r, n.c))
  if (n.k === "rng") throw new Error("#ERROR!")
  if (n.k === "un") { const v = toNum(evalNode(n.a, get)); return n.op === "-" ? -v : v }
  if (n.k === "pct") return toNum(evalNode(n.a, get)) / 100
  if (n.k === "bin") {
    if (n.op === "&") return toStr(evalNode(n.a, get)) + toStr(evalNode(n.b, get))
    const a = evalNode(n.a, get)
    const b = evalNode(n.b, get)
    if (n.op === "+") return toNum(a) + toNum(b)
    if (n.op === "-") return toNum(a) - toNum(b)
    if (n.op === "*") return toNum(a) * toNum(b)
    if (n.op === "/") {
      const d = toNum(b)
      if (d === 0) throw new Error("#ERROR!")
      return toNum(a) / d
    }
    if (n.op === "^") return Math.pow(toNum(a), toNum(b))
    if (n.op === "=") return cmpVals(a, b) === 0
    if (n.op === "<>") return cmpVals(a, b) !== 0
    if (n.op === "<") return cmpVals(a, b) < 0
    if (n.op === ">") return cmpVals(a, b) > 0
    if (n.op === "<=") return cmpVals(a, b) <= 0
    return cmpVals(a, b) >= 0
  }
  return callFn(n.name, n.args, get)
}

/** Recompute every derived value from the raw cells — memoized per pass, cycle-safe. */
function computeCells(cells: Readonly<Record<string, string>>): Record<string, Val> {
  const memo: Record<string, Val> = {}
  const busy: Record<string, boolean> = {}
  const get: Getter = (r, c) => {
    const key = r + ":" + c
    if (key in memo) return memo[key]
    if (busy[key]) throw new Error("#REF!")
    const raw: string | undefined = cells[key]
    if (raw === undefined || raw === "") {
      memo[key] = ""
      return ""
    }
    let v: Val
    if (raw.charAt(0) === "=") {
      busy[key] = true
      try {
        v = evalNode(parseFormula(raw.slice(1)), get)
        if (typeof v === "number" && !Number.isFinite(v)) v = "#ERROR!"
      } catch (err) {
        const msg = err instanceof Error ? err.message : ""
        v = msg === "#REF!" || msg === "#ERROR!" ? msg : "#ERROR!"
      }
      busy[key] = false
    } else {
      // A card-reference cell has no formula value — referencing it yields an error,
      // never the internal encoding (the cell itself still renders its chip).
      if (raw.startsWith("vvdref:")) {
        v = "#ERROR!"
      } else {
        const n = raw.trim() === "" ? NaN : Number(raw)
        v = Number.isNaN(n) ? raw : n
      }
    }
    memo[key] = v
    return v
  }
  const out: Record<string, Val> = {}
  for (const key of Object.keys(cells)) {
    const p = key.split(":")
    out[key] = get(parseInt(p[0], 10), parseInt(p[1], 10))
  }
  return out
}

/** Display formatting: trims float noise, TRUE/FALSE for booleans. */
function fmtVal(v: Val): string {
  if (typeof v === "number") return String(Math.round(v * 1e10) / 1e10)
  if (typeof v === "boolean") return v ? "TRUE" : "FALSE"
  return v
}

// [formula-engine:end]

/** RFC-ish delimited text: quoted fields, doubled-quote escapes, CRLF line ends. */
function parseDelimited(text: string, delim: string): string[][] {
  const rows: string[][] = []
  let row: string[] = []
  let cur = ""
  let inQ = false
  let i = 0
  const pushCell = () => { row.push(cur); cur = "" }
  const pushRow = () => { pushCell(); rows.push(row); row = [] }
  while (i < text.length) {
    const ch = text.charAt(i)
    if (inQ) {
      if (ch === '"') {
        if (text.charAt(i + 1) === '"') { cur += '"'; i += 2; continue }
        inQ = false
        i++
        continue
      }
      cur += ch
      i++
      continue
    }
    if (ch === '"' && cur === "") { inQ = true; i++; continue }
    if (ch === delim) { pushCell(); i++; continue }
    if (ch === "\r") { if (text.charAt(i + 1) === "\n") i++; pushRow(); i++; continue }
    if (ch === "\n") { pushRow(); i++; continue }
    cur += ch
    i++
  }
  if (cur !== "" || row.length > 0) pushRow()
  return rows
}

/** Clipboard text to a 2D grid: tabs mean TSV (what Sheets copies), multi-line means CSV. */
function parseClipboard(text: string): string[][] {
  let t = text
  while (t.length > 0 && (t.charAt(t.length - 1) === "\n" || t.charAt(t.length - 1) === "\r")) t = t.slice(0, -1)
  if (t.indexOf("\t") >= 0) return t.split(/\r\n|\r|\n/).map((line) => line.split("\t"))
  if (t.indexOf("\n") >= 0 || t.indexOf("\r") >= 0) return parseDelimited(t, ",")
  return [[t]]
}

/** Shift an index-keyed column map for an insert (delta +1) or delete (delta -1) at "at" —
 *  the same remap the cells get, so names/types/widths travel with their column. */
function shiftIndexMap<T>(cur: Readonly<Record<string, T>>, at: number, delta: number): Record<string, T> {
  const next: Record<string, T> = {}
  for (const [key, v] of Object.entries(cur)) {
    const c = parseInt(key, 10)
    if (delta > 0) next[String(c >= at ? c + 1 : c)] = v
    else if (c !== at) next[String(c > at ? c - 1 : c)] = v
  }
  return next
}

type XlsxMod = {
  read(data: ArrayBuffer, opts: { type: "array" }): { SheetNames: string[]; Sheets: Record<string, unknown> }
  utils: { sheet_to_json(ws: unknown, opts: { header: 1; raw: boolean; defval: string }): unknown[][] }
}

// Fetched lazily, only on demand — the Function indirection keeps bundlers from following it.
const loadXlsx = (): Promise<XlsxMod> =>
  (new Function("u", "return import(u)") as unknown as (u: string) => Promise<XlsxMod>)("https://cdn.sheetjs.com/xlsx-latest/package/xlsx.mjs")

function PresenceStrip({ handle }: { handle: Parameters<typeof useDocumentPresence>[0] }) {
  const others = useDocumentPresence(handle).filter((p) => !p.isSelf)
  if (others.length === 0) return null
  return (
    <span style={{ display: "flex", alignItems: "center" }} title={others.map((p) => p.name).join(", ")}>
      {others.slice(0, 5).map((p) => (
        <span key={p.userId} style={{ width: 22, height: 22, borderRadius: "50%", marginLeft: -7, display: "inline-flex", alignItems: "center", justifyContent: "center", background: p.color, color: "#fff", fontSize: 10.5, fontWeight: 700, border: "2px solid rgba(255,255,255,0.35)" }}>
          {p.name ? p.name.slice(0, 1).toUpperCase() : ""}
        </span>
      ))}
      {others.length > 5 && <span style={{ marginLeft: 5, fontSize: 11, opacity: 0.7 }}>+{others.length - 5}</span>}
    </span>
  )
}

/** Which cell (and range) each peer has selected — their own awareness field, drawn as a halo. */
function usePeerSelections(awareness: ReturnType<typeof useDocumentAwareness>): PeerSel[] {
  const [list, setList] = useState<PeerSel[]>([])
  useEffect(() => {
    const a = awareness
    if (!a) { setList([]); return }
    const read = () => {
      const out: PeerSel[] = []
      a.getStates().forEach((raw, id) => {
        const s = raw as { user?: { color?: string; name?: string }; armorySel?: { r: number; c: number; range?: Rng | null } | null }
        if (id === a.clientID || !s.armorySel) return
        out.push({ color: s.user?.color || "#7c8cff", name: s.user?.name || "", sel: { r: s.armorySel.r, c: s.armorySel.c }, range: s.armorySel.range || null })
      })
      setList(out)
    }
    read()
    a.on("change", read)
    return () => a.off("change", read)
  }, [awareness])
  return list
}

/** The native reference-picker pattern (the Board/Canvas/relation-tree pickers):
 *  search the world IMMEDIATELY (an empty query shows suggestions), arrow keys +
 *  Enter to pick, a create-as-card fallback — and always hand back the document
 *  ID, never the text. Entity icons render through the platform icon engine. */
function ReferencePicker({ onPick, onClose }: { onPick: (hit: Hit) => void; onClose: () => void }) {
  const search = useHostCapability("search")
  const [q, setQ] = useState("")
  const [hits, setHits] = useState<Hit[]>([])
  const [loaded, setLoaded] = useState(false)
  const [active, setActive] = useState(0)
  useEffect(() => {
    let live = true
    const t = setTimeout(() => {
      search.query(q, { types: ["card"], limit: 12 }).then((r) => {
        if (!live) return
        setHits(r)
        setLoaded(true)
        setActive(0)
      })
    }, q ? 150 : 0)
    return () => { live = false; clearTimeout(t) }
  }, [q, search])
  const canCreate = !!search.create && q.trim() !== "" && !hits.some((h) => h.name.toLowerCase() === q.trim().toLowerCase())
  const total = hits.length + (canCreate ? 1 : 0)
  const choose = (i: number) => {
    if (i < hits.length) onPick(hits[i])
    else if (canCreate) void search.create?.(q.trim(), null).then((made) => { if (made) onPick(made) })
  }
  return (
    <span style={{ display: "grid", gap: 2, minWidth: 220, padding: 4, borderRadius: 10, background: "rgba(20,22,29,0.97)", color: "#fff", border: "1px solid rgba(255,255,255,0.14)", boxShadow: "0 14px 32px rgba(0,0,0,0.45)" }}>
      <input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search your world…"
        onKeyDown={(e) => {
          if (e.key === "Escape") { e.preventDefault(); onClose() }
          else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, Math.max(0, total - 1))) }
          else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)) }
          else if (e.key === "Enter") { e.preventDefault(); if (total > 0) choose(active) }
        }}
        onBlur={() => setTimeout(onClose, 120)}
        style={{ padding: "7px 10px", borderRadius: 7, border: "none", outline: "none", background: "rgba(255,255,255,0.08)", color: "#fff", font: "inherit", fontSize: 13 }} />
      <span style={{ display: "grid", maxHeight: 240, overflowY: "auto" }}>
        {hits.map((h, i) => (
          <button key={h.id} type="button" onMouseDown={(e) => { e.preventDefault(); choose(i) }} onMouseEnter={() => setActive(i)}
            style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: i === active ? "rgba(255,255,255,0.12)" : "transparent" }}>
            <HostIcon icon={h.entityTypeIcon || "file-text"} size={13} />
            <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{h.name || "Untitled"}</span>
            {h.entityTypeName ? <span style={{ fontSize: 10.5, opacity: 0.5 }}>{h.entityTypeName}</span> : null}
          </button>
        ))}
        {canCreate && (
          <button type="button" onMouseDown={(e) => { e.preventDefault(); choose(hits.length) }} onMouseEnter={() => setActive(hits.length)}
            style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: active === hits.length ? "rgba(255,255,255,0.12)" : "transparent" }}>
            <HostIcon icon="plus" size={13} />
            <span style={{ flex: 1 }}>Create &ldquo;{q.trim()}&rdquo;</span>
          </button>
        )}
        {loaded && hits.length === 0 && !canCreate && <span style={{ padding: "10px 12px", fontSize: 12, opacity: 0.5 }}>Nothing found</span>}
      </span>
    </span>
  )
}

function Sheet({ data, actions, canEdit, handle, worldId, embed, docTitle }: {
  data: StateData<SheetShape>
  actions: StateActions<SheetShape>
  canEdit: boolean
  handle: Parameters<typeof useDocumentAwareness>[0]
  worldId: string
  embed: boolean
  docTitle: string
}) {
  const awareness = useDocumentAwareness(handle)
  const peerSels = usePeerSelections(awareness)
  const refs = useHostCapability("refs")
  const nav = useHostCapability("nav")
  const { openContextMenu } = useContextMenu()
  const wrapRef = useRef<HTMLDivElement | null>(null)
  const gridRef = useRef<HTMLDivElement | null>(null)
  const selectingRef = useRef(false)

  const [sel, setSel] = useState<Sel | null>({ r: 0, c: 0 })
  const [ext, setExt] = useState<Sel | null>(null)
  const [editing, setEditing] = useState<Sel | null>(null)
  const [editValue, setEditValue] = useState("")
  const [resize, setResize] = useState<{ c: number; w: number } | null>(null)
  const [linkAt, setLinkAt] = useState<Sel | null>(null)

  // Derived values — recomputed from the raw cells, memoized per snapshot.
  const computed = useMemo(() => computeCells(data.cells), [data.cells])

  const curRange: Rng | null = sel ? (ext ? normRange(sel, ext) : { r0: sel.r, c0: sel.c, r1: sel.r, c1: sel.c }) : null

  const colW = (c: number): number => {
    if (resize && resize.c === c) return resize.w
    const w: number | undefined = data.colWidths[String(c)]
    return typeof w === "number" && w > 0 ? Math.max(MIN_W, w) : DEF_W
  }

  // The Airtable layer: per-column display names + types, keyed by column index.
  // The letter (A, B, C…) stays the formula ADDRESS; the name is presentation.
  const colNameOf = (c: number): string => data.colNames[String(c)] || ""
  const colTypeOf = (c: number): string => data.colTypes[String(c)] || "text"
  const setColType = (c: number, t: string) => actions.map("colTypes").set(String(c), t)
  const renameCol = (c: number, name: string) => {
    if (name === "") actions.map("colNames").delete(String(c))
    else actions.map("colNames").set(String(c), name)
  }

  // [sample-seed] A fresh doc (no cells AND no column names) seeds itself on the
  // first canEdit mount — no button, no prompt. The write is DETERMINISTIC
  // (identical keys + values on every client) and lands in ONE transact, so two
  // peers racing the first open write the same entries and last-write-wins
  // CONVERGES on them. The Amount column is index 2 — letter C — which the
  // sample =SUM formula addresses.
  const seededRef = useRef(false)
  useEffect(() => {
    if (!canEdit || seededRef.current) return
    if (Object.keys(data.cells).length > 0 || Object.keys(data.colNames).length > 0 || Object.keys(data.colTypes).length > 0) return
    seededRef.current = true
    actions.transact(() => {
      actions.set("rowCount", 8)
      actions.set("colCount", 4)
      const names = ["Name", "Notes", "Amount", "Linked"]
      const types = ["text", "text", "number", "card"]
      names.forEach((n, i) => actions.map("colNames").set(String(i), n))
      types.forEach((t, i) => actions.map("colTypes").set(String(i), t))
      const sample: string[][] = [
        ["Aldermoor Keep", "Seat of the northern watch", "12"],
        ["The Gilded Fen", "Marsh markets and smuggler roads", "7"],
        ["Skyharbor", "Cliffside port of the sky guilds", "30"],
      ]
      sample.forEach((vals, r) => vals.forEach((v, c) => actions.map("cells").set(r + ":" + c, v)))
      actions.map("cells").set("3:2", "=SUM(C1:C3)")
    })
  }, [canEdit, data.cells, data.colNames, actions])

  // Broadcast the local selection (anchor + range) on this tool's awareness field.
  useEffect(() => {
    if (!awareness) return
    if (!sel) { awareness.setLocalStateField("armorySel", null); return }
    awareness.setLocalStateField("armorySel", { r: sel.r, c: sel.c, range: ext ? normRange(sel, ext) : null })
  }, [awareness, sel, ext])
  useEffect(() => () => { awareness?.setLocalStateField("armorySel", null) }, [awareness])

  // Keep the anchor visible as the keyboard walks the grid.
  useEffect(() => {
    if (!sel) return
    const el = gridRef.current?.querySelector('[data-rc="' + sel.r + ":" + sel.c + '"]')
    if (el) (el as HTMLElement).scrollIntoView({ block: "nearest", inline: "nearest" })
  }, [sel])

  useEffect(() => {
    const up = () => { selectingRef.current = false }
    window.addEventListener("pointerup", up)
    return () => window.removeEventListener("pointerup", up)
  }, [])

  const setCell = (r: number, c: number, v: string) => {
    if (v === "") actions.map("cells").delete(r + ":" + c)
    else actions.map("cells").set(r + ":" + c, v)
  }
  const clearRange = (rg: Rng) => actions.transact(() => {
    for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) actions.map("cells").delete(r + ":" + c)
  })

  const navFrom = (from: Sel, dr: number, dc: number) => {
    // The extent is FIXED: walking (or Enter on the last row) just stays put.
    // Rows and columns are added deliberately — the New-row strip, the header
    // plus, the context menus — never as a side effect of movement.
    const to = {
      r: Math.min(Math.max(0, from.r + dr), data.rowCount - 1),
      c: Math.min(Math.max(0, from.c + dc), data.colCount - 1),
    }
    setSel(to)
    setExt(null)
  }
  const extend = (dr: number, dc: number) => {
    if (!sel) return
    const from = ext || sel
    setExt({
      r: Math.min(Math.max(0, from.r + dr), data.rowCount - 1),
      c: Math.min(Math.max(0, from.c + dc), data.colCount - 1),
    })
  }

  const startEdit = (r: number, c: number, init: string | null) => {
    if (!canEdit) return
    setSel({ r, c })
    setExt(null)
    if (colTypeOf(c) === "card") { setLinkAt({ r, c }); return } // card cells pick, never type
    const raw: string | undefined = data.cells[r + ":" + c]
    setEditing({ r, c })
    setEditValue(init === null ? raw || "" : init)
  }
  const commitEdit = () => {
    if (!editing) return
    setCell(editing.r, editing.c, editValue)
    setEditing(null)
    wrapRef.current?.focus()
  }
  const cancelEdit = () => {
    setEditing(null)
    wrapRef.current?.focus()
  }

  const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if ((e.target as HTMLElement).tagName === "INPUT") return
    if (e.metaKey || e.ctrlKey) return // copy/paste/undo ride the clipboard events + the platform
    if (!sel) return
    const k = e.key
    if (k === "ArrowUp") { e.preventDefault(); if (e.shiftKey) extend(-1, 0); else navFrom(sel, -1, 0); return }
    if (k === "ArrowDown") { e.preventDefault(); if (e.shiftKey) extend(1, 0); else navFrom(sel, 1, 0); return }
    if (k === "ArrowLeft") { e.preventDefault(); if (e.shiftKey) extend(0, -1); else navFrom(sel, 0, -1); return }
    if (k === "ArrowRight") { e.preventDefault(); if (e.shiftKey) extend(0, 1); else navFrom(sel, 0, 1); return }
    if (k === "Tab") { e.preventDefault(); navFrom(sel, 0, e.shiftKey ? -1 : 1); return }
    if (k === "Enter" || k === "F2") { e.preventDefault(); startEdit(sel.r, sel.c, null); return }
    if (k === "Delete" || k === "Backspace") { e.preventDefault(); if (canEdit && curRange) clearRange(curRange); return }
    if (k === "Escape") { setExt(null); return }
    if (k.length === 1 && !e.altKey) { e.preventDefault(); startEdit(sel.r, sel.c, k) } // type-to-replace
  }
  const onCellInputKey = (e: ReactKeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") { e.preventDefault(); const at = editing; commitEdit(); if (at) navFrom(at, 1, 0) }
    else if (e.key === "Tab") { e.preventDefault(); const at = editing; commitEdit(); if (at) navFrom(at, 0, e.shiftKey ? -1 : 1) }
    else if (e.key === "Escape") { e.preventDefault(); cancelEdit() }
  }

  const onCellPointerDown = (r: number, c: number, e: ReactPointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return
    if (editing && editing.r === r && editing.c === c) return // caret placement inside the editor
    if (editing) commitEdit()
    e.preventDefault()
    wrapRef.current?.focus()
    setLinkAt(null)
    if (e.shiftKey && sel) { setExt({ r, c }); return }
    setSel({ r, c })
    setExt(null)
    selectingRef.current = true
  }
  const onCellPointerEnter = (r: number, c: number) => {
    if (selectingRef.current) setExt({ r, c })
  }

  // Clipboard: copy serializes the selected range as TSV (raw values — formulas
  // as text); paste parses TSV/CSV starting at the anchor in ONE transact, so a
  // block copied out of Google Sheets lands as the same block here. Paste and
  // file import CLAMP to the current extent — writing never grows the table.
  const writeGrid = (r0: number, c0: number, rows: string[][]) => {
    if (rows.length === 0) return
    actions.transact(() => {
      rows.forEach((rowVals, dr) => {
        if (r0 + dr >= data.rowCount) return
        rowVals.forEach((v, dc) => {
          if (c0 + dc >= data.colCount) return
          const key = (r0 + dr) + ":" + (c0 + dc)
          if (v === "") actions.map("cells").delete(key)
          else actions.map("cells").set(key, v)
        })
      })
    })
  }
  const onCopy = (e: ReactClipboardEvent<HTMLDivElement>) => {
    if ((e.target as HTMLElement).tagName === "INPUT") return
    if (editing || !curRange) return
    e.preventDefault()
    const lines: string[] = []
    for (let r = curRange.r0; r <= curRange.r1; r++) {
      const rowOut: string[] = []
      for (let c = curRange.c0; c <= curRange.c1; c++) rowOut.push(data.cells[r + ":" + c] || "")
      lines.push(rowOut.join("\t"))
    }
    e.clipboardData.setData("text/plain", lines.join("\n"))
  }
  const onCut = (e: ReactClipboardEvent<HTMLDivElement>) => {
    if ((e.target as HTMLElement).tagName === "INPUT") return
    if (editing || !curRange || !canEdit) return
    onCopy(e)
    clearRange(curRange)
  }
  const onPaste = (e: ReactClipboardEvent<HTMLDivElement>) => {
    if ((e.target as HTMLElement).tagName === "INPUT") return
    if (editing || !sel || !canEdit) return
    const text = e.clipboardData.getData("text/plain")
    if (!text) return
    e.preventDefault()
    writeGrid(sel.r, sel.c, parseClipboard(text))
  }

  // Structural edits SHIFT cell content by remapping keys in one transact.
  // Like Sheets' own insert/delete, formulas are not rewritten.
  const remapCells = (fn: (r: number, c: number) => Sel | null, also?: () => void) => {
    actions.transact(() => {
      const entries = Object.entries(data.cells)
      const next: Record<string, string> = {}
      for (const [key, v] of entries) {
        const p = key.split(":")
        const to = fn(parseInt(p[0], 10), parseInt(p[1], 10))
        if (to) next[to.r + ":" + to.c] = v
      }
      for (const [key] of entries) if (next[key] === undefined) actions.map("cells").delete(key)
      for (const key of Object.keys(next)) if (data.cells[key] !== next[key]) actions.map("cells").set(key, next[key])
      if (also) also()
    })
  }
  // Column structure ops remap colNames + colTypes + colWidths ALONGSIDE the
  // cells — all inside remapCells' single transact — so a column's name, type,
  // and width travel with it and a concurrent editor never sees them drift.
  const shiftColMeta = (at: number, delta: number) => {
    const nextW = shiftIndexMap(data.colWidths, at, delta)
    const nextN = shiftIndexMap(data.colNames, at, delta)
    const nextT = shiftIndexMap(data.colTypes, at, delta)
    for (const key of Object.keys(data.colWidths)) if (nextW[key] === undefined) actions.map("colWidths").delete(key)
    for (const key of Object.keys(nextW)) if (data.colWidths[key] !== nextW[key]) actions.map("colWidths").set(key, nextW[key])
    for (const key of Object.keys(data.colNames)) if (nextN[key] === undefined) actions.map("colNames").delete(key)
    for (const key of Object.keys(nextN)) if (data.colNames[key] !== nextN[key]) actions.map("colNames").set(key, nextN[key])
    for (const key of Object.keys(data.colTypes)) if (nextT[key] === undefined) actions.map("colTypes").delete(key)
    for (const key of Object.keys(nextT)) if (data.colTypes[key] !== nextT[key]) actions.map("colTypes").set(key, nextT[key])
  }
  const insertRow = (at: number) => remapCells((r, c) => (r >= at ? { r: r + 1, c } : { r, c }), () => actions.update("rowCount", (n) => n + 1))
  const deleteRow = (at: number) => remapCells((r, c) => (r === at ? null : r > at ? { r: r - 1, c } : { r, c }), () => actions.update("rowCount", (n) => Math.max(1, n - 1)))
  const insertCol = (at: number) => remapCells((r, c) => (c >= at ? { r, c: c + 1 } : { r, c }), () => {
    actions.update("colCount", (n) => n + 1)
    shiftColMeta(at, 1)
  })
  const deleteCol = (at: number) => remapCells((r, c) => (c === at ? null : c > at ? { r, c: c - 1 } : { r, c }), () => {
    actions.update("colCount", (n) => Math.max(1, n - 1))
    shiftColMeta(at, -1)
  })
  const clearRow = (at: number) => clearRange({ r0: at, c0: 0, r1: at, c1: data.colCount - 1 })
  const clearCol = (at: number) => clearRange({ r0: 0, c0: at, r1: data.rowCount - 1, c1: at })
  const sortByCol = (c: number, asc: boolean) => {
    const rows: number[] = []
    for (let r = 0; r < data.rowCount; r++) rows.push(r)
    const keyFor = (r: number): { n: number | null; s: string; empty: boolean } => {
      const raw: string | undefined = data.cells[r + ":" + c]
      if (raw === undefined || raw === "") return { n: null, s: "", empty: true }
      const num = Number(raw)
      return Number.isNaN(num) ? { n: null, s: raw.toLowerCase(), empty: false } : { n: num, s: "", empty: false }
    }
    rows.sort((a, b) => {
      const ka = keyFor(a)
      const kb = keyFor(b)
      if (ka.empty !== kb.empty) return ka.empty ? 1 : -1 // empty rows sink either way
      if (ka.empty) return a - b
      let d = 0
      if (ka.n !== null && kb.n !== null) d = ka.n - kb.n // numbers compare numerically
      else if (ka.n !== null) d = -1
      else if (kb.n !== null) d = 1
      else d = ka.s < kb.s ? -1 : ka.s > kb.s ? 1 : 0
      if (d === 0) return a - b
      return asc ? d : -d
    })
    const newRowOf: Record<number, number> = {}
    rows.forEach((oldR, newR) => { newRowOf[oldR] = newR })
    remapCells((r, cc) => ({ r: r < data.rowCount ? newRowOf[r] : r, c: cc }))
  }

  // The Sheets-complete right-click menus — structure edits as platform menu data.
  const rangeAt = (r: number, c: number): Rng => (curRange && inRange(curRange, r, c) ? curRange : { r0: r, c0: c, r1: r, c1: c })
  const cellItems = (r: number, c: number) => [
    { kind: "item" as const, id: "clear", label: "Clear", icon: "tabler:circle-x", onSelect: () => clearRange(rangeAt(r, c)) },
    { kind: "item" as const, id: "link-doc", label: "Link a document", icon: "link", onSelect: () => setLinkAt({ r, c }) },
    { kind: "separator" as const },
    { kind: "item" as const, id: "row-above", label: "Insert row above", icon: "arrow-up", onSelect: () => insertRow(r) },
    { kind: "item" as const, id: "row-below", label: "Insert row below", icon: "arrow-down", onSelect: () => insertRow(r + 1) },
    { kind: "item" as const, id: "col-left", label: "Insert column left", icon: "arrow-left", onSelect: () => insertCol(c) },
    { kind: "item" as const, id: "col-right", label: "Insert column right", icon: "arrow-right", onSelect: () => insertCol(c + 1) },
    { kind: "separator" as const },
    { kind: "item" as const, id: "row-delete", label: "Delete row", icon: "tabler:trash", variant: "destructive" as const, onSelect: () => deleteRow(r) },
    { kind: "item" as const, id: "col-delete", label: "Delete column", icon: "tabler:trash", variant: "destructive" as const, onSelect: () => deleteCol(c) },
  ]
  const colHeadItems = (c: number) => [
    // Airtable-style column typing — a submenu on the platform menu contract
    // (ContextMenuSubmenu), so the host renders + keyboard-navigates it.
    {
      kind: "submenu" as const,
      id: "col-type",
      label: "Column type",
      items: [
        { kind: "item" as const, id: "type-text", label: "Text", icon: "pencil", onSelect: () => setColType(c, "text") },
        { kind: "item" as const, id: "type-number", label: "Number", icon: "hash", onSelect: () => setColType(c, "number") },
        { kind: "item" as const, id: "type-card", label: "Card", icon: "link", onSelect: () => setColType(c, "card") },
      ],
    },
    { kind: "separator" as const },
    { kind: "item" as const, id: "col-left", label: "Insert column left", icon: "arrow-left", onSelect: () => insertCol(c) },
    { kind: "item" as const, id: "col-right", label: "Insert column right", icon: "arrow-right", onSelect: () => insertCol(c + 1) },
    { kind: "separator" as const },
    { kind: "item" as const, id: "sort-az", label: "Sort sheet A to Z", icon: "arrow-down", onSelect: () => sortByCol(c, true) },
    { kind: "item" as const, id: "sort-za", label: "Sort sheet Z to A", icon: "arrow-up", onSelect: () => sortByCol(c, false) },
    { kind: "separator" as const },
    { kind: "item" as const, id: "col-clear", label: "Clear column", icon: "tabler:circle-x", onSelect: () => clearCol(c) },
    { kind: "item" as const, id: "col-delete", label: "Delete column", icon: "tabler:trash", variant: "destructive" as const, onSelect: () => deleteCol(c) },
  ]
  const rowHeadItems = (r: number) => [
    { kind: "item" as const, id: "row-above", label: "Insert row above", icon: "arrow-up", onSelect: () => insertRow(r) },
    { kind: "item" as const, id: "row-below", label: "Insert row below", icon: "arrow-down", onSelect: () => insertRow(r + 1) },
    { kind: "separator" as const },
    { kind: "item" as const, id: "row-clear", label: "Clear row", icon: "tabler:circle-x", onSelect: () => clearRow(r) },
    { kind: "item" as const, id: "row-delete", label: "Delete row", icon: "tabler:trash", variant: "destructive" as const, onSelect: () => deleteRow(r) },
  ]

  // Which cell sits under a client point — header offsets + scroll + shared widths.
  const cellAtPoint = (x: number, y: number): Sel | null => {
    const grid = gridRef.current
    if (!grid) return null
    const rect = grid.getBoundingClientRect()
    const gx = x - rect.left + grid.scrollLeft - NUM_W
    const gy = y - rect.top + grid.scrollTop - HEAD_H
    if (gx < 0 || gy < 0) return null
    const r = Math.floor(gy / ROW_H)
    let c = 0
    let acc = 0
    while (c < data.colCount) {
      acc += colW(c)
      if (gx < acc) break
      c++
    }
    if (r >= data.rowCount || c >= data.colCount) return null
    return { r, c }
  }

  // The platform dnd contract: drop a sidebar document on a cell and the cell
  // becomes a reference to it — always the id, never a copy.
  const drop = useDocDropTarget({
    zone: "armory-grid",
    accepts: (p) => canEdit && (!p.worldId || p.worldId === worldId),
    onDrop: (p, point) => {
      const at = cellAtPoint(point.x, point.y) // already clamped to the extent
      if (!at) return
      actions.map("cells").set(at.r + ":" + at.c, REF_PREFIX + p.documentId)
      setSel(at)
      setExt(null)
    },
  })

  // Native FILE drops (.csv/.tsv/.txt/.xlsx/.xls) import at A1, silently.
  const importRows = (rows: unknown[][]) => {
    const grid = rows.map((rowVals) => rowVals.map((v) => (v === null || v === undefined ? "" : String(v))))
    if (grid.length > 0) writeGrid(0, 0, grid)
  }
  const onFileDrop = (file: File) => {
    const lower = file.name.toLowerCase()
    if (/\.(xlsx|xls)$/.test(lower)) {
      file.arrayBuffer()
        .then((buf) => loadXlsx().then((xlsx) => {
          const wb = xlsx.read(buf, { type: "array" })
          const ws = wb.Sheets[wb.SheetNames[0]]
          importRows(xlsx.utils.sheet_to_json(ws, { header: 1, raw: false, defval: "" }))
        }))
        .catch(() => undefined)
      return
    }
    if (!/\.(csv|tsv|txt)$/.test(lower)) return
    file.text()
      .then((text) => {
        const delim = lower.slice(-4) === ".tsv" || text.indexOf("\t") >= 0 ? "\t" : ","
        importRows(parseDelimited(text, delim))
      })
      .catch(() => undefined)
  }
  const dragHasFiles = (e: ReactDragEvent<HTMLDivElement>) => {
    const types = e.dataTransfer ? e.dataTransfer.types : null
    return !!types && types.indexOf("Files") >= 0
  }
  const onGridDragOver = (e: ReactDragEvent<HTMLDivElement>) => {
    if (canEdit && dragHasFiles(e)) { e.preventDefault(); return }
    drop.props.onDragOver(e)
  }
  const onGridDrop = (e: ReactDragEvent<HTMLDivElement>) => {
    if (canEdit && dragHasFiles(e) && e.dataTransfer.files.length > 0) {
      e.preventDefault()
      try { onFileDrop(e.dataTransfer.files[0]) } catch { /* silently ignore */ }
      return
    }
    drop.props.onDrop(e)
  }

  // Column-border resize — a pointer drag on the header's right edge; the width
  // is SHARED state (data.colWidths), committed once on release.
  const beginResize = (c: number, e: ReactPointerEvent<HTMLSpanElement>) => {
    if (!canEdit) return
    e.preventDefault()
    e.stopPropagation()
    const startX = e.clientX
    const startW = colW(c)
    const onMove = (ev: PointerEvent) => setResize({ c, w: Math.max(MIN_W, startW + ev.clientX - startX) })
    const onUp = (ev: PointerEvent) => {
      window.removeEventListener("pointermove", onMove)
      window.removeEventListener("pointerup", onUp)
      setResize(null)
      actions.map("colWidths").set(String(c), Math.round(Math.max(MIN_W, startW + ev.clientX - startX)))
    }
    window.addEventListener("pointermove", onMove)
    window.addEventListener("pointerup", onUp)
    setResize({ c, w: startW })
  }

  // Resolve reference-cell ids to names through the host (batched; never stored).
  const refIdsKey = useMemo(() => {
    const out = new Set<string>()
    for (const v of Object.values(data.cells)) if (v.indexOf(REF_PREFIX) === 0) out.add(v.slice(REF_PREFIX.length))
    return Array.from(out).join(",")
  }, [data.cells])
  const [names, setNames] = useState<Record<string, string>>({})
  useEffect(() => {
    const ids = refIdsKey ? refIdsKey.split(",") : []
    if (ids.length === 0) return
    let live = true
    refs.meta(ids).then((m) => {
      if (!live) return
      const out: Record<string, string> = {}
      m.forEach((row, docId) => { out[docId] = row.name })
      setNames(out)
    })
    return () => { live = false }
  }, [refIdsKey, refs])
  const openDoc = (docId: string) => nav.openDocument(refs.coordsFor(docId) ?? { worldId, documentId: docId })

  // The Sheets quick-stats chip: Sum / Avg / Count of the numbers in a multi-cell range.
  const stats = useMemo(() => {
    if (!sel || !ext) return null
    const rg = normRange(sel, ext)
    if (rg.r0 === rg.r1 && rg.c0 === rg.c1) return null
    let sum = 0
    let count = 0
    for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) {
      const v: Val | undefined = computed[r + ":" + c]
      if (typeof v === "number") { sum += v; count++ }
    }
    if (count === 0) return null
    return { sum: fmtVal(sum), avg: fmtVal(sum / count), count }
  }, [sel, ext, computed])

  // The per-column footer sums — present as soon as ANY column is typed number.
  const colSums = useMemo(() => {
    const out: Record<string, number> = {}
    let any = false
    for (let c = 0; c < data.colCount; c++) {
      if ((data.colTypes[String(c)] || "text") !== "number") continue
      any = true
      let s = 0
      for (let r = 0; r < data.rowCount; r++) {
        const v: Val | undefined = computed[r + ":" + c]
        if (typeof v === "number") s += v
      }
      out[String(c)] = s
    }
    return any ? out : null
  }, [data.colTypes, data.colCount, data.rowCount, computed])

  let bodyW = NUM_W
  for (let c = 0; c < data.colCount; c++) bodyW += colW(c)

  const rowIdx: number[] = []
  for (let r = 0; r < data.rowCount; r++) rowIdx.push(r)
  const colIdx: number[] = []
  for (let c = 0; c < data.colCount; c++) colIdx.push(c)

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
      {/* the roomy document header — title large, presence right-aligned; the
          grid runs edge-to-edge below it. An EMBED (a card block) skips ALL of
          this and keeps the compact grid only. */}
      {!embed && (
        <div style={{ display: "flex", alignItems: "center", gap: 14, padding: "20px 24px 12px" }}>
          <span style={{ flex: 1, minWidth: 0, fontSize: 21, fontWeight: 700, letterSpacing: -0.2, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{docTitle}</span>
          <PresenceStrip handle={handle} />
        </div>
      )}
      <div ref={wrapRef} tabIndex={0} onKeyDown={onGridKeyDown} onCopy={onCopy} onCut={onCut} onPaste={onPaste}
        style={{ position: "relative", flex: 1, minHeight: 0, overflow: "hidden", outline: "none" }}>
      <div ref={(el) => { gridRef.current = el; drop.ref(el) }} {...drop.props} onDragOver={onGridDragOver} onDrop={onGridDrop}
        style={{ boxSizing: "border-box", position: "absolute", inset: 0, overflow: "auto", outline: drop.canDrop ? "2px dashed rgba(127,127,127,0.55)" : "none", outlineOffset: -4 }}>
        {/* header row — sticky through vertical scroll; the corner stays put on both axes */}
        <div style={{ display: "flex", width: "max-content", position: "sticky", top: 0, zIndex: 6 }}>
          <span style={{ ...HEAD_BG, boxSizing: "border-box", flex: "0 0 auto", width: NUM_W, height: HEAD_H, position: "sticky", left: 0, zIndex: 7, borderRight: LINE, borderBottom: LINE }} />
          {colIdx.map((c) => (
            <span key={c} data-colhead
              onContextMenu={(e) => canEdit && openContextMenu(e, colHeadItems(c))}
              style={{ ...HEAD_BG, boxSizing: "border-box", flex: "0 0 auto", width: colW(c), height: HEAD_H, position: "relative", display: "flex", alignItems: "center", gap: 5, padding: "0 8px", fontSize: 11.5, fontWeight: 700, opacity: sel && sel.c === c ? 0.95 : 0.6, borderRight: LINE, borderBottom: LINE, userSelect: "none" }}>
              {/* the NAMED column header: an in-place name input whose placeholder
                  is the letter; a custom name keeps a tiny dim letter tag beside
                  it so formula addressing (=SUM(C1:C3)) stays legible. */}
              {canEdit ? (
                <input value={colNameOf(c)} placeholder={colName(c)} title={"Column " + colName(c)}
                  onChange={(ev) => renameCol(c, ev.target.value)}
                  onKeyDown={(ev) => { if (ev.key === "Enter" || ev.key === "Escape") ev.currentTarget.blur() }}
                  style={{ boxSizing: "border-box", flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent", color: "inherit", font: "inherit", fontSize: 11.5, fontWeight: 700, padding: 0 }} />
              ) : (
                <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{colNameOf(c) || colName(c)}</span>
              )}
              {colNameOf(c) !== "" && (
                <span style={{ flex: "0 0 auto", padding: "0 4px", borderRadius: 4, background: "rgba(127,127,127,0.16)", fontSize: 8.5, fontWeight: 700, letterSpacing: 0.5, opacity: 0.55 }}>{colName(c)}</span>
              )}
              {canEdit && (
                <span onPointerDown={(e) => beginResize(c, e)} title="Drag to resize"
                  style={{ position: "absolute", top: 0, right: -4, width: 8, height: "100%", cursor: "col-resize", zIndex: 5, touchAction: "none" }} />
              )}
            </span>
          ))}
          {canEdit && (
            <button type="button" title="Add a column" onClick={() => actions.update("colCount", (n) => n + 1)}
              style={{ boxSizing: "border-box", flex: "0 0 auto", width: ADD_W, height: HEAD_H, display: "inline-flex", alignItems: "center", justifyContent: "center", border: "none", borderBottom: LINE, borderRight: LINE, background: "rgba(127,127,127,0.08)", color: "inherit", cursor: "pointer", opacity: 0.6 }}>
              <HostIcon icon="plus" size={11} />
            </button>
          )}
        </div>

        {/* body */}
        {rowIdx.map((r) => (
          <div key={r} style={{ display: "flex", width: "max-content" }}>
            <span onContextMenu={(e) => canEdit && openContextMenu(e, rowHeadItems(r))}
              style={{ ...HEAD_BG, boxSizing: "border-box", flex: "0 0 auto", width: NUM_W, height: ROW_H, position: "sticky", left: 0, zIndex: 3, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10.5, opacity: sel && sel.r === r ? 0.95 : 0.55, borderRight: LINE, borderBottom: LINE, userSelect: "none" }}>
              {r + 1}
            </span>
            {colIdx.map((c) => {
              const key = r + ":" + c
              const raw: string | undefined = data.cells[key]
              const refId = raw && raw.indexOf(REF_PREFIX) === 0 ? raw.slice(REF_PREFIX.length) : null
              const isAnchor = !!sel && sel.r === r && sel.c === c
              const inSel = !!curRange && inRange(curRange, r, c)
              const isEditing = !!editing && editing.r === r && editing.c === c
              const v: Val | undefined = raw === undefined ? "" : computed[key]
              const cardCol = colTypeOf(c) === "card" // every cell in a card column is reference UX
              const peerHere = peerSels.find((p) => p.sel.r === r && p.sel.c === c)
              const peerRange = peerHere ? null : peerSels.find((p) => p.range && inRange(p.range, r, c))
              const shadows: string[] = []
              if (isAnchor) shadows.push("inset 0 0 0 2px currentColor")
              if (peerHere) shadows.push("inset 0 0 0 2px " + peerHere.color)
              return (
                <div key={c} data-rc={key}
                  onPointerDown={(e) => onCellPointerDown(r, c, e)}
                  onPointerEnter={() => onCellPointerEnter(r, c)}
                  onClick={() => { if (canEdit && cardCol && !refId) setLinkAt({ r, c }) }} // an empty card cell opens the picker on click
                  onDoubleClick={() => startEdit(r, c, null)}
                  onContextMenu={(e) => {
                    if (!canEdit) return
                    if (!inSel) { setSel({ r, c }); setExt(null) }
                    openContextMenu(e, cellItems(r, c))
                  }}
                  style={{ boxSizing: "border-box", flex: "0 0 auto", width: colW(c), height: ROW_H, position: "relative", display: "flex", alignItems: "center", padding: isEditing ? 0 : "0 8px", borderRight: LINE, borderBottom: LINE, background: inSel && !isAnchor ? "rgba(127,127,127,0.14)" : undefined, boxShadow: shadows.length > 0 ? shadows.join(", ") : undefined, zIndex: isAnchor ? 2 : undefined, cursor: "default" }}>
                  {peerRange && <span style={{ position: "absolute", inset: 0, background: peerRange.color, opacity: 0.12, pointerEvents: "none" }} />}
                  {peerHere && peerHere.name !== "" && (
                    <span style={{ position: "absolute", zIndex: 5, top: -7, right: 2, padding: "0 4px", borderRadius: 4, background: peerHere.color, color: "#fff", fontSize: 8.5, fontWeight: 700, pointerEvents: "none" }}>{peerHere.name}</span>
                  )}
                  {isEditing ? (
                    <input autoFocus value={editValue}
                      onChange={(ev) => setEditValue(ev.target.value)}
                      onKeyDown={onCellInputKey}
                      onBlur={commitEdit}
                      onFocus={(ev) => { const n = ev.target.value.length; ev.target.setSelectionRange(n, n) }}
                      style={{ boxSizing: "border-box", width: "100%", height: "100%", border: "none", outline: "none", background: "transparent", color: "inherit", font: "inherit", fontSize: 13, padding: "0 8px" }} />
                  ) : refId ? (
                    <button type="button" title="Open in your world" onClick={() => openDoc(refId)}
                      style={{ boxSizing: "border-box", display: "inline-flex", alignItems: "center", gap: 5, maxWidth: "100%", padding: "1px 7px", borderRadius: 999, border: "1px solid rgba(127,127,127,0.45)", background: "transparent", color: "inherit", font: "inherit", fontSize: 12, cursor: "pointer", overflow: "hidden", whiteSpace: "nowrap" }}>
                      <HostIcon icon="link" size={11} />
                      <span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{names[refId] || "linked"}</span>
                    </button>
                  ) : cardCol ? (
                    <span style={{ display: "inline-flex", alignItems: "center", opacity: canEdit ? 0.3 : 0.15 }}>
                      <HostIcon icon="plus" size={10} />
                    </span>
                  ) : (
                    <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", fontSize: 13, textAlign: colTypeOf(c) === "number" ? "right" : "left" }}>
                      {raw === undefined || v === undefined ? "" : fmtVal(v)}
                    </span>
                  )}
                  {linkAt && linkAt.r === r && linkAt.c === c && (
                    <span onPointerDown={(e) => e.stopPropagation()} style={{ position: "absolute", zIndex: 40, top: "100%", left: 0, marginTop: 2, width: 250 }}>
                      <ReferencePicker onClose={() => setLinkAt(null)} onPick={(h) => { setCell(r, c, REF_PREFIX + h.id); setLinkAt(null) }} />
                    </span>
                  )}
                </div>
              )
            })}
          </div>
        ))}

        {/* the footer sum row — appears as soon as any column is typed number */}
        {colSums && (
          <div style={{ display: "flex", width: "max-content", position: "sticky", bottom: 0, zIndex: 4 }}>
            <span style={{ ...HEAD_BG, boxSizing: "border-box", flex: "0 0 auto", width: NUM_W, height: ROW_H, position: "sticky", left: 0, zIndex: 5, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, letterSpacing: 0.5, opacity: 0.5, borderRight: LINE, borderBottom: LINE, userSelect: "none" }}>SUM</span>
            {colIdx.map((c) => (
              <span key={c} style={{ ...HEAD_BG, boxSizing: "border-box", flex: "0 0 auto", width: colW(c), height: ROW_H, display: "flex", alignItems: "center", justifyContent: "flex-end", padding: "0 8px", fontSize: 11.5, fontWeight: 600, opacity: 0.75, borderRight: LINE, borderBottom: LINE, userSelect: "none" }}>
                {colSums[String(c)] === undefined ? "" : fmtVal(colSums[String(c)])}
              </span>
            ))}
          </div>
        )}

        {/* the New-row strip — the ONE way rows are added (plus the row context
            menu inserts); each click adds exactly one row */}
        {canEdit && (
          <div style={{ display: "flex", width: "max-content" }}>
            <button type="button" title="Add one row" onClick={() => actions.update("rowCount", (n) => n + 1)}
              style={{ boxSizing: "border-box", flex: "0 0 auto", width: bodyW, height: 24, display: "inline-flex", alignItems: "center", justifyContent: "flex-start", gap: 6, padding: "0 14px", border: "none", borderBottom: LINE, background: "transparent", color: "inherit", cursor: "pointer", opacity: 0.55, font: "inherit", fontSize: 11.5 }}>
              <HostIcon icon="plus" size={11} />
              New row
            </button>
          </div>
        )}
      </div>

      {/* the quick-stats chip floats only in the full views — an embed stays bare */}
      {!embed && stats && (
        <div style={{ position: "absolute", right: 14, bottom: 12, zIndex: 20, display: "flex", gap: 12, padding: "6px 12px", borderRadius: 999, background: "rgba(20,22,29,0.95)", color: "#fff", fontSize: 11.5, border: "1px solid rgba(255,255,255,0.14)", boxShadow: "0 10px 26px rgba(0,0,0,0.4)", pointerEvents: "none", whiteSpace: "nowrap" }}>
          <span>Sum {stats.sum}</span>
          <span>Avg {stats.avg}</span>
          <span>Count {stats.count}</span>
        </div>
      )}
      </div>
    </div>
  )
}

export default defineTool({
  id: "armory",
  name: "armory",
  documentTypes: ["armory"],
  needs: [],
  render: function ArmoryView({ document, context }) {
    const coords = useMemo(
      () => ({ worldId: document.worldId, documentId: document.id }),
      [document.worldId, document.id],
    )
    const { data, status, actions, retry, handle } = useDocument(coords, codec)
    // context.view is "embed" | "editor" | "fullscreen" (undefined = non-embed).
    // Embedded as a block the table drops its title header + floating chrome and
    // sits compact inside the card; every other view gets the roomy document
    // header — the document's own title, falling back to the tool name.
    const embed = context.view === "embed"
    return (
      <DocumentGate status={status} onRetry={retry}>
        {data && actions && (
          <Sheet data={data} actions={actions} canEdit={context.canEdit} handle={handle} worldId={document.worldId}
            embed={embed} docTitle={document.title || NAME} />
        )}
      </DocumentGate>
    )
  },
})

It's the longest kit on the list by a distance, and almost all of that is grid behaviour — selection, keyboard movement, resize handles, paste parsing. The part that matters is the first thirty lines of src/codec.ts, and it's short.

Why is every cell one map entry instead of a list of rows?Deep dive

The instinct is rows: field.list<Row>(). It falls over the first time two people work in the same table.

A list of rows means the document has to agree on row order and row identity. Insert a row at index 2 while someone deletes index 0 and every subsequent index means something different to each of you — so either you carry a separate id per row and reconcile it, or edits land on the wrong row.

The Sheet kit sidesteps it by addressing cells the way a spreadsheet already does: column letter plus row number. Every filled cell is one entry in cells: field.map<string>(), keyed "r:c". There is no ordering to converge on, because position is the key. Two people editing different cells always both land; the same cell resolves last-write-wins.

The Airtable layer rides the same trick — colNames, colTypes and colWidths are maps keyed by column index, so renaming column C and retyping column F at the same moment both land. Insert and delete remap all four maps inside one transact, which is why names never drift off their columns.

How it works

One declaration, four maps. The codec is where every merge guarantee in this kit comes from — see Storing data for the field types, and Define a tool for how the codec reaches render.

A cell can hold a reference. A cell whose text starts with vvdref: is a pointer at a world document, resolved at render time. Rename the card and the cell follows. That's the same rule everywhere in vvd — see Linking documents.

Structure edits are one transaction. Inserting a column touches four maps; doing it in one transact is what stops a peer ever seeing half of it. Retrieving data covers transact.

What to change first

Start in src/codec.ts and ask whether your data is really addressed by cell. If it is — rows and columns genuinely mean something — keep the four maps and change what a column means: the column types, the formula set, the reference-cell rule. If a row is really an object with named fields, this kit is the wrong starting shape: begin from Hello World's field.map of typed values instead, and lift out the parts of this one that still apply — the TSV paste parser and the vvdref: reference-cell pattern both travel well.

Next steps

  • Canvas — the same "content is shared, view is yours" split, in two dimensions.
  • Agentable tools — a codec this explicit gets a REST and MCP surface almost for free.
  • Linking documents — how the reference cells work.