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
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
→ 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
=SUMand=AVGover 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.
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.
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 “{q.trim()}”</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.