← Spreadsheet results

Spreadsheet

Claude haiku 5.5 · full

Initial view of Spreadsheet

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"} -->