Spreadsheet
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 59066511d7
# Task: Build a Spreadsheet Application Build a functional spreadsheet (vanilla JS, no external assets, no CDN, no libraries) with a real formula engine. The top priority is **correct computation** — formulas must parse correctly and recalculate in the right order, every time. ## Core Specification **Grid & Editing** - 26 columns (A–Z) × 100 rows, rendered as an HTML table or canvas with column/row headers - Click a cell to select it; type to edit; Enter/Tab commits and moves down/right - Formula bar above the grid showing the raw content of the selected cell; editing there and in-cell stay in sync - Cell display shows the *computed value*; the formula bar shows the *raw input* - Arrow keys move the selection; double-click enters edit mode on a cell **Formula Engine** - Formulas begin with `=`. Support: - Arithmetic: `+`, `-`, `*`, `/`, parentheses, unary minus (e.g. `=A1*2+B1/4`) - Cell references: `A1`, `B7` (and in formulas from other cells) - Ranges: `A1:A10` - Functions: `SUM`, `AVG`/`AVERAGE`, `MIN`, `MAX`, `COUNT` (each accepting any mix of refs, ranges, and literals: `=SUM(A1:A5, 10, B2)`) - Numbers, plain text, and formulas coexist; non-formula input displays as-is - Errors display inline: `#REF!` for deleted/invalid references, `#DIV/0!` for division by zero, `#ERR!` for parse failures, `#CYCLE!` for circular references — errors must never crash the app **Dependency Graph & Recalculation** - Build a dependency graph from cell references; when a cell changes, recompute **only its dependents**, in topological order - Changing an upstream cell must cascade through arbitrarily deep chains (`A1=5`, `B1=A1*2`, `C1=B1+1`, …) - Circular references must be detected and reported as `#CYCLE!` on every cell in the cycle — without hanging or stack overflow **Operations** - Insert/delete row and column (via header right-click menu or toolbar buttons), with **all formulas automatically rewritten** to keep pointing at the same logical cells - Clear cell (Delete key) - Full-sheet serialization to `localStorage` (raw formulas, not values) — reload restores the sheet exactly ## Visual Style - Clean, modern, light UI in the spirit of Google Sheets: pinned header row/column, subtle gridlines, bold blue selection border with a visible active cell - Formula bar styled distinctly from the grid; selected cell reference shown to its left (e.g. `B4`) - Numeric values right-aligned, text left-aligned, errors shown in red - No decorative flourishes at the expense of legibility — this is a tool ## Technical Requirements - Vanilla JS only; organize the code with clear section comments - The formula parser must be a real tokenizer + recursive-descent (or shunting-yard) parser — **no `eval()`** - Tunable constants block at the top (grid size, colors, cell dimensions) - Target: typing and recalculation feel instant on a full 26×100 sheet ## Quality Bar (must hit all) - `=SUM(A1:A5)` and deep dependency chains compute correctly and update live - A cycle (`A1=B1`, `B1=A1`) shows `#CYCLE!` on both cells and recovers when one is cleared - Deleting a row referenced by a formula rewrites it correctly (or yields `#REF!` if the target is gone) - Editing in the cell and in the formula bar never disagree - State survives a page reload ## Self-Check Before Finishing Verify with the browser tools: - Enter `5` in A1, `=A1*2` in B1, `=B1+1` in C1 → C1 shows `11`; change A1 to `10` → C1 shows `21` - `=SUM(A1:A3)`, `=AVG(A1:A3)`, `=1/0` → `#DIV/0!`, `=SUM(` → `#ERR!` - Create the A1↔B1 cycle above and confirm `#CYCLE!` appears and clears - Reload the page and confirm all formulas and values persist
Inspect original source 34,531 bytes · SHA-256 460cb09f4ee6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sheet</title>
<style>
[hidden]{display:none!important}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#fff;color:#202124;font:13px/1.4 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
#app{display:flex;flex-direction:column;height:100%}
/* toolbar */
#toolbar{display:flex;align-items:center;gap:6px;height:40px;padding:0 10px;border-bottom:1px solid var(--grid);flex:none;background:#fff}
#toolbar .title{font-weight:600;font-size:15px;color:var(--sel);margin-right:10px}
#toolbar button,.ctx button{font:inherit;color:#202124;background:#fff;border:1px solid #dadce0;border-radius:4px;padding:4px 9px;cursor:pointer}
#toolbar button:hover{background:#f1f3f4}
#toolbar .sep{width:1px;height:20px;background:#dadce0;margin:0 4px}
#toolbar .hint{margin-left:auto;color:#5f6368;font-size:12px}
/* formula bar */
#fbar{display:flex;align-items:stretch;height:34px;border-bottom:1px solid var(--grid);background:#f8f9fa;flex:none}
#namebox{width:84px;display:flex;align-items:center;justify-content:center;border-right:1px solid var(--grid);background:#fff;font-weight:600;color:#202124}
#fx{width:44px;display:flex;align-items:center;justify-content:center;color:#5f6368;font:italic 600 15px Georgia,serif}
#fbar-input{flex:1;min-width:0;border:0;outline:none;padding:0 10px;font:13px/1 Consolas,"Courier New",monospace;background:#fff;color:#202124;margin:3px 6px 3px 0;border-radius:3px}
#fbar-input:focus{box-shadow:inset 0 0 0 2px var(--sel)}
/* grid */
#wrap{flex:1;min-height:0;overflow:auto;position:relative;background:#fff}
#grid{border-collapse:separate;border-spacing:0;table-layout:fixed;user-select:none;-webkit-user-select:none}
#grid th,#grid td{height:var(--cell-h);border-right:1px solid var(--grid);border-bottom:1px solid var(--grid);padding:0 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#grid td{background:var(--bg);color:var(--text);text-align:left;vertical-align:middle;cursor:cell}
#grid td.num{text-align:right}
#grid td.err{color:var(--err)}
#grid td.sel{box-shadow:inset 0 0 0 2px var(--sel);position:relative}
#grid th{background:var(--hdr-bg);color:var(--hdr-text);font-weight:500;text-align:center;padding:0;cursor:pointer;position:sticky;z-index:2}
#grid thead th{top:0}
#grid th.rowh{left:0;z-index:2}
#grid th.corner{left:0;top:0;z-index:3;cursor:default}
#grid th.hl{background:var(--hdr-hl);color:#174ea6;font-weight:600}
#editor{position:absolute;display:none;z-index:10;box-sizing:border-box;border:2px solid var(--sel);outline:none;padding:0 5px;margin:0;font:inherit;background:#fff;color:var(--text);min-width:0}
/* context menu */
.ctx{position:fixed;z-index:50;background:#fff;border:1px solid #dadce0;border-radius:6px;box-shadow:0 4px 14px rgba(0,0,0,.16);padding:4px 0;min-width:190px;display:flex;flex-direction:column}
.ctx button{border:0;border-radius:0;text-align:left;padding:7px 14px}
.ctx button:hover{background:#f1f3f4}
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<span class="title">Sheet</span>
<button data-op="row-above">+ Row above</button>
<button data-op="row-below">+ Row below</button>
<button data-op="row-del">− Row</button>
<span class="sep"></span>
<button data-op="col-left">+ Column left</button>
<button data-op="col-right">+ Column right</button>
<button data-op="col-del">− Column</button>
<span class="hint">Right-click a header for insert/delete · Enter/Tab commit · F2 or double-click to edit · Autosaves</span>
</div>
<div id="fbar">
<div id="namebox">A1</div>
<div id="fx">fx</div>
<input id="fbar-input" type="text" spellcheck="false" autocomplete="off">
</div>
<div id="wrap">
<table id="grid"></table>
<input id="editor" type="text" spellcheck="false" autocomplete="off">
</div>
</div>
<div id="ctx" class="ctx" hidden></div>
<script>
/* =====================================================================
1. TUNABLE CONSTANTS
===================================================================== */
const ROWS = 100;
const COLS = 26;
const CELL_W = 100; // px
const CELL_H = 24; // px
const HDR_W = 46; // row-header width
const HDR_H = 24; // column-header height
const COLORS = {
grid: '#e2e3e6',
sel: '#1a73e8',
hdrBg: '#f8f9fa',
hdrHl: '#d3e3fd',
hdrText: '#5f6368',
err: '#d93025',
text: '#202124',
bg: '#ffffff'
};
const STORAGE_KEY = 'mini-sheet-v1';
// Apply constants to CSS
(function applyTheme() {
const s = document.documentElement.style;
s.setProperty('--cell-w', CELL_W + 'px');
s.setProperty('--cell-h', CELL_H + 'px');
s.setProperty('--grid', COLORS.grid);
s.setProperty('--sel', COLORS.sel);
s.setProperty('--hdr-bg', COLORS.hdrBg);
s.setProperty('--hdr-hl', COLORS.hdrHl);
s.setProperty('--hdr-text', COLORS.hdrText);
s.setProperty('--err', COLORS.err);
s.setProperty('--text', COLORS.text);
s.setProperty('--bg', COLORS.bg);
})();
/* =====================================================================
2. ERRORS & ADDRESS HELPERS
===================================================================== */
class SheetErr {
constructor(code) { this.code = code; }
}
const E = {
REF: '#REF!', DIV0: '#DIV/0!', ERR: '#ERR!', CYCLE: '#CYCLE!',
VALUE: '#VALUE!', NAME: '#NAME?', NUM: '#NUM!'
};
function colToLetters(c) {
let s = '';
let n = c + 1;
while (n > 0) {
const m = (n - 1) % 26;
s = String.fromCharCode(65 + m) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
function lettersToCol(s) {
let n = 0;
for (const ch of s.toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
return n - 1;
}
const key = (c, r) => c + ',' + r;
const addr = (c, r) => colToLetters(c) + (r + 1);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/* =====================================================================
3. TOKENIZER
===================================================================== */
function tokenize(src) {
const toks = [];
let i = 0;
const N = src.length;
while (i < N) {
const ch = src[i];
if (/\s/.test(ch)) { i++; continue; }
const rest = src.slice(i);
let m;
// number
if ((m = /^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?/.exec(rest))) {
toks.push({ t: 'num', v: parseFloat(m[0]), s: i, e: i + m[0].length });
i += m[0].length; continue;
}
// string literal
if (ch === '"') {
const end = src.indexOf('"', i + 1);
if (end < 0) throw new Error('unterminated string');
toks.push({ t: 'str', v: src.slice(i + 1, end), s: i, e: end + 1 });
i = end + 1; continue;
}
// error literal
if (ch === '#') {
const m2 = /^#(REF!|DIV\/0!|VALUE!|NAME\?|NUM!|N\/A|CYCLE!|ERR!)/i.exec(rest);
if (!m2) throw new Error('bad #');
toks.push({ t: 'err', v: m2[0].toUpperCase(), s: i, e: i + m2[0].length });
i += m2[0].length; continue;
}
// cell reference (checked before function names; lookahead rejects "LOG10(")
if ((m = /^\$?[A-Za-z]{1,3}\$?\d+(?![A-Za-z0-9_(.])/.exec(rest))) {
const mt = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)$/.exec(m[0]);
const col = lettersToCol(mt[2]);
const row = parseInt(mt[4], 10) - 1;
toks.push({
t: 'ref', col, row, dc: mt[1] === '$', dr: mt[3] === '$',
bad: col >= COLS || row < 0 || row >= ROWS,
s: i, e: i + m[0].length
});
i += m[0].length; continue;
}
// function name (must be followed directly by '(')
if ((m = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(rest))) {
if (src[i + m[0].length] !== '(') throw new Error('unknown identifier ' + m[0]);
toks.push({ t: 'fn', v: m[0].toUpperCase(), s: i, e: i + m[0].length });
i += m[0].length; continue;
}
// operators & punctuation
const single = {
'+': 'op', '-': 'op', '*': 'op', '/': 'op', '^': 'op',
'(': 'lp', ')': 'rp', ',': 'comma', ':': 'colon'
};
if (single[ch]) {
toks.push({ t: single[ch], v: ch, s: i, e: i + 1 });
i++; continue;
}
throw new Error('unexpected character ' + ch);
}
toks.push({ t: 'eof', s: N, e: N });
return toks;
}
/* =====================================================================
4. PARSER (recursive descent) -> AST
Grammar:
expr := term (('+'|'-') term)*
term := unary (('*'|'/') unary)*
unary := ('-'|'+') unary | power
power := primary ('^' unary)?
primary := NUMBER | STRING | ERRLIT | REF [':' REF] | FUNC '(' [expr (',' expr)*] ')' | '(' expr ')'
===================================================================== */
function makeRef(t) {
if (t.bad) return { t: 'err', e: E.REF };
return { t: 'ref', c: t.col, r: t.row };
}
function makeRange(a, b) {
if (a.bad || b.bad) return { t: 'err', e: E.REF };
return {
t: 'range',
c1: Math.min(a.col, b.col), c2: Math.max(a.col, b.col),
r1: Math.min(a.row, b.row), r2: Math.max(a.row, b.row)
};
}
function parseFormula(src) {
const toks = tokenize(src);
let p = 0;
const expect = (t) => {
if (toks[p].t !== t) throw new Error('expected ' + t + ' got ' + toks[p].t);
return toks[p++];
};
const isOp = (set) => toks[p].t === 'op' && set.includes(toks[p].v);
function parseExpr() {
let left = parseTerm();
while (isOp(['+', '-'])) {
const op = toks[p++].v;
const right = parseTerm();
left = { t: 'bin', op, a: left, b: right };
}
return left;
}
function parseTerm() {
let left = parseUnary();
while (isOp(['*', '/'])) {
const op = toks[p++].v;
const right = parseUnary();
left = { t: 'bin', op, a: left, b: right };
}
return left;
}
function parseUnary() {
if (isOp(['-', '+'])) {
const op = toks[p++].v;
const a = parseUnary();
return op === '-' ? { t: 'neg', a } : a;
}
return parsePower();
}
function parsePower() {
const base = parsePrimary();
if (isOp(['^'])) {
p++;
const exp = parseUnary();
return { t: 'bin', op: '^', a: base, b: exp };
}
return base;
}
function parsePrimary() {
const t = toks[p];
switch (t.t) {
case 'num': p++; return { t: 'num', v: t.v };
case 'str': p++; return { t: 'str', v: t.v };
case 'err': p++; return { t: 'err', e: t.v };
case 'ref': {
p++;
if (toks[p].t === 'colon') {
p++;
const t2 = expect('ref');
return makeRange(t, t2);
}
return makeRef(t);
}
case 'fn': {
p++;
expect('lp');
const args = [];
if (toks[p].t !== 'rp') {
args.push(parseExpr());
while (toks[p].t === 'comma') { p++; args.push(parseExpr()); }
}
expect('rp');
return { t: 'call', name: t.v, args };
}
case 'lp': {
p++;
const e = parseExpr();
expect('rp');
return e;
}
default:
throw new Error('unexpected token ' + t.t);
}
}
const ast = parseExpr();
if (toks[p].t !== 'eof') throw new Error('trailing input');
return ast;
}
// Collect every cell key the AST reads (ranges expanded).
function collectPrecs(ast) {
const set = new Set();
(function walk(n) {
switch (n.t) {
case 'ref': set.add(key(n.c, n.r)); break;
case 'range':
for (let r = n.r1; r <= n.r2; r++)
for (let c = n.c1; c <= n.c2; c++) set.add(key(c, r));
break;
case 'neg': walk(n.a); break;
case 'bin': walk(n.a); walk(n.b); break;
case 'call': n.args.forEach(walk); break;
}
})(ast);
return [...set];
}
/* =====================================================================
5. EVALUATOR
===================================================================== */
const cells = new Map(); // key -> cell {c,r,raw,ast,precs,val,isF}
const dependents = new Map(); // key -> Set(key of formulas that read it)
const NUM_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
function parseLiteral(raw) {
const t = raw.trim();
return NUM_RE.test(t) ? Number(t) : raw;
}
function toNum(v) {
if (v instanceof SheetErr) return v;
if (v === null || v === undefined) return 0;
if (typeof v === 'number') return v;
if (typeof v === 'string') {
const t = v.trim();
if (t !== '' && Number.isFinite(Number(t))) return Number(t);
}
return new SheetErr(E.VALUE);
}
function evalNode(n) {
switch (n.t) {
case 'num': case 'str': return n.v;
case 'err': return new SheetErr(n.e);
case 'ref': {
const cell = cells.get(key(n.c, n.r));
return cell ? cell.val : null;
}
case 'range': return new SheetErr(E.VALUE);
case 'neg': {
const a = toNum(evalNode(n.a));
return a instanceof SheetErr ? a : -a;
}
case 'bin': {
const a = toNum(evalNode(n.a));
if (a instanceof SheetErr) return a;
const b = toNum(evalNode(n.b));
if (b instanceof SheetErr) return b;
let r;
switch (n.op) {
case '+': r = a + b; break;
case '-': r = a - b; break;
case '*': r = a * b; break;
case '/': if (b === 0) return new SheetErr(E.DIV0); r = a / b; break;
case '^': r = Math.pow(a, b); break;
default: return new SheetErr(E.ERR);
}
return Number.isFinite(r) ? r : new SheetErr(E.NUM);
}
case 'call': return callFn(n);
}
return new SheetErr(E.ERR);
}
// Gather numeric values from function args. Ranges skip non-numbers;
// direct args coerce numeric text. Errors propagate.
function gatherNums(args, countMode) {
const nums = [];
for (const a of args) {
if (a.t === 'range') {
for (let r = a.r1; r <= a.r2; r++) {
for (let c = a.c1; c <= a.c2; c++) {
const cell = cells.get(key(c, r));
const v = cell ? cell.val : null;
if (v instanceof SheetErr) return { err: v };
if (typeof v === 'number') nums.push(v);
}
}
} else {
const v = evalNode(a);
if (v instanceof SheetErr) return { err: v };
if (v === null) continue;
if (typeof v === 'number') nums.push(v);
else if (typeof v === 'string') {
const t = v.trim();
if (t !== '' && Number.isFinite(Number(t))) nums.push(Number(t));
else if (!countMode) return { err: new SheetErr(E.VALUE) };
}
}
}
return { nums };
}
const KNOWN_FN = ['SUM', 'AVG', 'AVERAGE', 'MIN', 'MAX', 'COUNT'];
function callFn(n) {
if (!KNOWN_FN.includes(n.name)) return new SheetErr(E.NAME);
const { nums, err } = gatherNums(n.args, n.name === 'COUNT');
if (err) return err;
switch (n.name) {
case 'SUM': return nums.reduce((a, b) => a + b, 0);
case 'AVG':
case 'AVERAGE':
if (!nums.length) return new SheetErr(E.DIV0);
return nums.reduce((a, b) => a + b, 0) / nums.length;
case 'MIN': return nums.length ? nums.reduce((a, b) => Math.min(a, b)) : 0;
case 'MAX': return nums.length ? nums.reduce((a, b) => Math.max(a, b)) : 0;
case 'COUNT': return nums.length;
}
return new SheetErr(E.NAME);
}
/* =====================================================================
6. DEPENDENCY GRAPH & RECALCULATION
===================================================================== */
function makeCell(c, r, raw) {
return { c, r, raw, ast: null, precs: [], val: null, isF: false };
}
// Parse a cell's raw input into its AST / literal value and precedents.
function compile(cell) {
cell.ast = null;
cell.precs = [];
cell.isF = cell.raw.startsWith('=');
if (!cell.isF) { cell.val = parseLiteral(cell.raw); return; }
try {
cell.ast = parseFormula(cell.raw.slice(1));
cell.precs = collectPrecs(cell.ast);
cell.val = null;
} catch (e) {
cell.val = new SheetErr(E.ERR);
}
}
function link(k, cell) {
for (const p of cell.precs) {
let s = dependents.get(p);
if (!s) { s = new Set(); dependents.set(p, s); }
s.add(k);
}
}
function unlink(k, cell) {
for (const p of cell.precs) {
const s = dependents.get(p);
if (s) { s.delete(k); if (!s.size) dependents.delete(p); }
}
}
// Iterative Tarjan SCC. Returns components in evaluation order
// (every precedent component is emitted before its dependents).
function sccOrder(nodes, edgesOf) {
const index = new Map(), low = new Map(), onStack = new Set();
const stack = [], out = [];
let counter = 0;
for (const root of nodes) {
if (index.has(root)) continue;
const work = [{ v: root, ei: 0, es: null }];
index.set(root, counter); low.set(root, counter); counter++;
stack.push(root); onStack.add(root);
while (work.length) {
const fr = work[work.length - 1];
if (fr.es === null) fr.es = edgesOf(fr.v);
if (fr.ei < fr.es.length) {
const w = fr.es[fr.ei++];
if (!index.has(w)) {
index.set(w, counter); low.set(w, counter); counter++;
stack.push(w); onStack.add(w);
work.push({ v: w, ei: 0, es: null });
} else if (onStack.has(w)) {
low.set(fr.v, Math.min(low.get(fr.v), index.get(w)));
}
} else {
work.pop();
if (work.length) {
const parent = work[work.length - 1].v;
low.set(parent, Math.min(low.get(parent), low.get(fr.v)));
}
if (low.get(fr.v) === index.get(fr.v)) {
const comp = [];
let w;
do { w = stack.pop(); onStack.delete(w); comp.push(w); } while (w !== fr.v);
out.push(comp);
}
}
}
}
return out;
}
function finalize(v) { return v === null || v === undefined ? 0 : v; }
// Evaluate a set of formula keys (must be closed under dependents) in topological order.
function evaluateKeys(keys) {
const list = keys.filter(k => { const c = cells.get(k); return c && c.isF; });
const F = new Set(list);
const comps = sccOrder(list, k => cells.get(k).precs.filter(p => F.has(p)));
for (const comp of comps) {
const first = cells.get(comp[0]);
const cyclic = comp.length > 1 || first.precs.includes(comp[0]);
if (cyclic) {
for (const k of comp) cells.get(k).val = new SheetErr(E.CYCLE);
continue;
}
if (!first.ast) continue; // parse error already stored
first.val = finalize(evalNode(first.ast));
}
}
// Recompute only the transitive dependents of `keys`. Returns the affected key set.
function propagate(keys) {
const D = new Set();
const stack = [...keys];
while (stack.length) {
const k = stack.pop();
if (D.has(k)) continue;
D.add(k);
const ds = dependents.get(k);
if (ds) for (const d of ds) if (!D.has(d)) stack.push(d);
}
const F = [];
for (const k of D) { const c = cells.get(k); if (c && c.isF) F.push(k); }
evaluateKeys(F);
return D;
}
// Set a cell's raw input ('' clears it) and recalc dependents.
function setRaw(c, r, raw) {
const k = key(c, r);
const old = cells.get(k);
if (old) unlink(k, old);
if (raw === '' || raw === null || raw === undefined) {
cells.delete(k);
} else {
const cell = makeCell(c, r, raw);
compile(cell);
cells.set(k, cell);
link(k, cell);
}
return propagate([k]);
}
function rawOf(c, r) {
const cell = cells.get(key(c, r));
return cell ? cell.raw : '';
}
// Full rebuild of graph + values (used on load and after structural edits).
function rebuildGraph() {
dependents.clear();
const formulaKeys = [];
for (const [k, cell] of cells) {
compile(cell);
link(k, cell);
if (cell.isF) formulaKeys.push(k);
}
evaluateKeys(formulaKeys);
}
/* =====================================================================
7. STRUCTURAL OPS (insert/delete rows & columns, formula rewriting)
===================================================================== */
// op: {axis:'row'|'col', ins:boolean, idx}
function shiftIdx(v, op, lim) {
if (op.ins) {
const nv = v >= op.idx ? v + 1 : v;
return nv >= lim ? -1 : nv;
}
if (v === op.idx) return -1;
return v > op.idx ? v - 1 : v;
}
function fmtRef(col, row, dc, dr) {
return (dc ? '$' : '') + colToLetters(col) + (dr ? '$' : '') + (row + 1);
}
function rewriteRef(t, op) {
const lim = op.axis === 'row' ? ROWS : COLS;
const nv = shiftIdx(op.axis === 'row' ? t.row : t.col, op, lim);
if (nv < 0) return E.REF;
const col = op.axis === 'col' ? nv : t.col;
const row = op.axis === 'row' ? nv : t.row;
return fmtRef(col, row, t.dc, t.dr);
}
function rewriteRange(a, b, op) {
const lim = op.axis === 'row' ? ROWS : COLS;
const av = op.axis === 'row' ? a.row : a.col;
const bv = op.axis === 'row' ? b.row : b.col;
const sA = Math.min(av, bv), eA = Math.max(av, bv);
let ns, ne;
if (op.ins) {
ns = sA >= op.idx ? sA + 1 : sA;
ne = eA >= op.idx ? eA + 1 : eA;
if (ns >= lim) return E.REF;
ne = Math.min(ne, lim - 1);
} else {
if (sA === op.idx && eA === op.idx) return E.REF;
ns = sA > op.idx ? sA - 1 : sA;
ne = eA >= op.idx ? eA - 1 : eA;
}
const forward = av <= bv;
const x1 = forward ? ns : ne, x2 = forward ? ne : ns;
if (op.axis === 'row') {
return fmtRef(a.col, x1, a.dc, a.dr) + ':' + fmtRef(b.col, x2, b.dc, b.dr);
}
return fmtRef(x1, a.row, a.dc, a.dr) + ':' + fmtRef(x2, b.row, b.dc, b.dr);
}
// Rewrite every reference in a formula so it still points at the same logical cells.
function rewriteFormula(raw, op) {
const body = raw.slice(1);
let toks;
try { toks = tokenize(body); } catch (e) { return raw; }
const reps = [];
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.t !== 'ref' || t.bad) continue;
const t2 = toks[i + 1], t3 = toks[i + 2];
if (t2 && t2.t === 'colon' && t3 && t3.t === 'ref' && !t3.bad) {
reps.push({ s: t.s, e: t3.e, txt: rewriteRange(t, t3, op) });
i += 2;
continue;
}
reps.push({ s: t.s, e: t.e, txt: rewriteRef(t, op) });
}
let out = body;
for (let i = reps.length - 1; i >= 0; i--) {
const r = reps[i];
out = out.slice(0, r.s) + r.txt + out.slice(r.e);
}
return '=' + out;
}
function structural(axis, ins, idx) {
const lim = axis === 'row' ? ROWS : COLS;
const op = { axis, ins, idx };
const moved = [];
for (const cell of cells.values()) {
const p = axis === 'row' ? cell.r : cell.c;
let np;
if (ins) {
np = p >= idx ? p + 1 : p;
if (np >= lim) continue; // pushed off the grid
} else {
if (p === idx) continue; // deleted
np = p > idx ? p - 1 : p;
}
moved.push([cell, np]);
}
cells.clear();
for (const [cell, np] of moved) {
if (axis === 'row') cell.r = np; else cell.c = np;
if (cell.isF) cell.raw = rewriteFormula(cell.raw, op);
cells.set(key(cell.c, cell.r), cell);
}
rebuildGraph();
paintAll();
save();
}
/* =====================================================================
8. PERSISTENCE
===================================================================== */
function save() {
const arr = [];
for (const cell of cells.values()) arr.push([cell.c, cell.r, cell.raw]);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, cells: arr })); } catch (e) { /* quota / disabled */ }
}
function load() {
try {
const d = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (!d || !Array.isArray(d.cells)) return;
for (const entry of d.cells) {
if (!Array.isArray(entry)) continue;
const [c, r, raw] = entry;
if (Number.isInteger(c) && Number.isInteger(r) && c >= 0 && c < COLS &&
r >= 0 && r < ROWS && typeof raw === 'string' && raw !== '') {
cells.set(key(c, r), makeCell(c, r, raw));
}
}
} catch (e) { /* corrupt storage: start empty */ }
}
/* =====================================================================
9. DISPLAY / RENDERING
===================================================================== */
function fmtNum(n) {
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
return String(parseFloat(n.toPrecision(12)));
}
function display(v) {
if (v === null || v === undefined) return '';
if (v instanceof SheetErr) return v.code;
if (typeof v === 'number') return fmtNum(v);
return String(v);
}
const table = document.getElementById('grid');
const wrap = document.getElementById('wrap');
const editor = document.getElementById('editor');
const bar = document.getElementById('fbar-input');
const namebox = document.getElementById('namebox');
const ctx = document.getElementById('ctx');
let tdGrid = []; // [r][c] -> td
let colHeads = []; // [c] -> th
let rowHeads = []; // [r] -> th
function buildGrid() {
let h = '<colgroup><col style="width:' + HDR_W + 'px">';
for (let c = 0; c < COLS; c++) h += '<col style="width:' + CELL_W + 'px">';
h += '</colgroup><thead><tr><th class="corner" style="width:' + HDR_W + 'px"></th>';
for (let c = 0; c < COLS; c++) h += '<th class="colh" data-c="' + c + '">' + colToLetters(c) + '</th>';
h += '</tr></thead><tbody>';
for (let r = 0; r < ROWS; r++) {
h += '<tr><th class="rowh" data-r="' + r + '">' + (r + 1) + '</th>';
for (let c = 0; c < COLS; c++) h += '<td data-c="' + c + '" data-r="' + r + '"></td>';
h += '</tr>';
}
h += '</tbody>';
table.innerHTML = h;
const body = table.tBodies[0];
for (let r = 0; r < ROWS; r++) {
const tr = body.rows[r];
tdGrid[r] = [];
for (let c = 0; c < COLS; c++) tdGrid[r][c] = tr.cells[c + 1];
rowHeads[r] = tr.cells[0];
}
colHeads = [...table.tHead.rows[0].cells].slice(1);
}
function paint(c, r) {
const td = tdGrid[r][c];
const cell = cells.get(key(c, r));
const v = cell ? cell.val : null;
const txt = display(v);
if (td.textContent !== txt) td.textContent = txt;
td.classList.toggle('num', typeof v === 'number');
td.classList.toggle('err', v instanceof SheetErr);
}
function paintKeys(set) {
for (const k of set) {
const i = k.indexOf(',');
paint(+k.slice(0, i), +k.slice(i + 1));
}
}
function paintAll() {
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) paint(c, r);
}
/* =====================================================================
10. SELECTION, SCROLLING & EDITING
===================================================================== */
const state = { c: 0, r: 0, editing: null };
function ensureVisible(td) {
const w = wrap.getBoundingClientRect();
const t = td.getBoundingClientRect();
if (t.top < w.top + HDR_H) wrap.scrollTop -= (w.top + HDR_H - t.top);
else if (t.bottom > w.bottom) wrap.scrollTop += (t.bottom - w.bottom);
if (t.left < w.left + HDR_W) wrap.scrollLeft -= (w.left + HDR_W - t.left);
else if (t.right > w.right) wrap.scrollLeft += (t.right - w.right);
}
function select(c, r) {
c = clamp(c, 0, COLS - 1);
r = clamp(r, 0, ROWS - 1);
tdGrid[state.r][state.c].classList.remove('sel');
colHeads[state.c].classList.remove('hl');
rowHeads[state.r].classList.remove('hl');
state.c = c; state.r = r;
const td = tdGrid[r][c];
td.classList.add('sel');
colHeads[c].classList.add('hl');
rowHeads[r].classList.add('hl');
namebox.textContent = addr(c, r);
if (!state.editing) bar.value = rawOf(c, r);
ensureVisible(td);
}
function moveSel(dc, dr) { select(state.c + dc, state.r + dr); }
function placeEditor(td) {
const w = wrap.getBoundingClientRect();
const t = td.getBoundingClientRect();
editor.style.left = (t.left - w.left + wrap.scrollLeft) + 'px';
editor.style.top = (t.top - w.top + wrap.scrollTop) + 'px';
editor.style.width = Math.max(t.width, CELL_W) + 'px';
editor.style.height = t.height + 'px';
}
// Begin editing the selected cell. `initial` replaces content (typed char).
function startEdit(initial, focusEditor = true) {
if (state.editing) return;
const { c, r } = state;
const td = tdGrid[r][c];
ensureVisible(td);
state.editing = { c, r };
const val = initial !== undefined ? initial : rawOf(c, r);
placeEditor(td);
editor.value = val;
bar.value = val;
editor.style.display = 'block';
if (focusEditor) {
editor.focus();
editor.setSelectionRange(val.length, val.length);
}
}
function blurEditors() {
const a = document.activeElement;
if (a === editor || a === bar) a.blur();
}
function commitEdit() {
if (!state.editing) return;
const { c, r } = state.editing;
const raw = editor.value;
state.editing = null;
editor.style.display = 'none';
blurEditors();
const D = setRaw(c, r, raw);
paintKeys(D);
bar.value = rawOf(state.c, state.r);
save();
}
function cancelEdit() {
if (!state.editing) return;
state.editing = null;
editor.style.display = 'none';
blurEditors();
bar.value = rawOf(state.c, state.r);
}
function clearSelected() {
const { c, r } = state;
const D = setRaw(c, r, '');
paintKeys(D);
bar.value = '';
save();
}
// Keep formula bar and in-cell editor in sync
editor.addEventListener('input', () => { bar.value = editor.value; });
editor.addEventListener('blur', () => { commitEdit(); });
editor.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commitEdit(); moveSel(0, e.shiftKey ? -1 : 1); }
else if (e.key === 'Tab') { e.preventDefault(); commitEdit(); moveSel(e.shiftKey ? -1 : 1, 0); }
else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
commitEdit();
moveSel(0, e.key === 'ArrowUp' ? -1 : 1);
}
});
bar.addEventListener('focus', () => { if (!state.editing) startEdit(undefined, false); });
bar.addEventListener('input', () => { if (state.editing) editor.value = bar.value; });
bar.addEventListener('blur', () => { commitEdit(); });
bar.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commitEdit(); moveSel(0, e.shiftKey ? -1 : 1); }
else if (e.key === 'Tab') { e.preventDefault(); commitEdit(); moveSel(e.shiftKey ? -1 : 1, 0); }
else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
});
/* =====================================================================
11. CONTEXT MENU & TOOLBAR
===================================================================== */
function hideCtx() { ctx.hidden = true; }
function doOp(axis, ins, idx) {
commitEdit();
hideCtx();
structural(axis, ins, idx);
}
function showCtx(x, y, axis, idx) {
const items = axis === 'row'
? [['Insert 1 row above', true, idx], ['Insert 1 row below', true, idx + 1], ['Delete row', false, idx]]
: [['Insert 1 column left', true, idx], ['Insert 1 column right', true, idx + 1], ['Delete column', false, idx]];
ctx.innerHTML = '';
for (const [label, ins, at] of items) {
const b = document.createElement('button');
b.textContent = label;
b.onclick = () => doOp(axis, ins, at);
ctx.appendChild(b);
}
ctx.hidden = false;
ctx.style.left = Math.min(x, window.innerWidth - 200) + 'px';
ctx.style.top = Math.min(y, window.innerHeight - 130) + 'px';
}
document.addEventListener('mousedown', e => { if (!ctx.contains(e.target)) hideCtx(); });
document.querySelectorAll('#toolbar button[data-op]').forEach(b => {
b.addEventListener('mousedown', e => e.preventDefault()); // keep focus/editing intact until click
b.addEventListener('click', () => {
const op = b.dataset.op;
const { c, r } = state;
if (op === 'row-above') doOp('row', true, r);
else if (op === 'row-below') doOp('row', true, r + 1);
else if (op === 'row-del') doOp('row', false, r);
else if (op === 'col-left') doOp('col', true, c);
else if (op === 'col-right') doOp('col', true, c + 1);
else if (op === 'col-del') doOp('col', false, c);
});
});
/* =====================================================================
12. EVENT WIRING (grid + global keyboard)
===================================================================== */
table.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const td = e.target.closest('td');
const th = e.target.closest('th');
if (td) {
commitEdit();
select(+td.dataset.c, +td.dataset.r);
} else if (th && th.classList.contains('colh')) {
commitEdit();
select(+th.dataset.c, state.r);
} else if (th && th.classList.contains('rowh')) {
commitEdit();
select(state.c, +th.dataset.r);
}
});
table.addEventListener('dblclick', e => {
const td = e.target.closest('td');
if (!td) return;
select(+td.dataset.c, +td.dataset.r);
startEdit();
});
table.addEventListener('contextmenu', e => {
const th = e.target.closest('th');
if (!th || th.classList.contains('corner')) return;
e.preventDefault();
commitEdit();
if (th.classList.contains('colh')) {
const c = +th.dataset.c;
select(c, state.r);
showCtx(e.clientX, e.clientY, 'col', c);
} else {
const r = +th.dataset.r;
select(state.c, r);
showCtx(e.clientX, e.clientY, 'row', r);
}
});
document.addEventListener('keydown', e => {
if (state.editing) return; // editor / formula bar handle their own keys
if (e.key === 'Escape') { hideCtx(); return; }
if (e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key) {
case 'ArrowUp': e.preventDefault(); moveSel(0, -1); return;
case 'ArrowDown': e.preventDefault(); moveSel(0, 1); return;
case 'ArrowLeft': e.preventDefault(); moveSel(-1, 0); return;
case 'ArrowRight': e.preventDefault(); moveSel(1, 0); return;
case 'Enter': e.preventDefault(); moveSel(0, e.shiftKey ? -1 : 1); return;
case 'Tab': e.preventDefault(); moveSel(e.shiftKey ? -1 : 1, 0); return;
case 'F2': e.preventDefault(); startEdit(); return;
case 'Delete': case 'Backspace': e.preventDefault(); clearSelected(); return;
}
if (e.key.length === 1) { // printable -> start typing
e.preventDefault();
startEdit(e.key);
}
});
/* =====================================================================
13. INIT
===================================================================== */
buildGrid();
load();
rebuildGraph();
paintAll();
select(0, 0);
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"4cf34349-4d69-4bc6-b678-49055a30ee31","tokensIn":86855,"tokensOut":68341,"tokensTotal":155196,"cost":0.03830544,"turns":4,"toolCalls":4,"failedToolCalls":0,"timestamp":"2026-10-08T01:58:06.770Z"} -->