Spreadsheet
Qwen 3.7 Flash · typescript

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 60,097 bytes · SHA-256 e1c13930ae5c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spreadsheet</title>
<style>
/* ── css: layout ── */
:root {
--cell-width: 100px;
--row-height: 24px;
--header-bg: #f8f9fa;
--header-border: #dee2e6;
--grid-border: #dee2e6;
--selection-color: #1a73e8;
--bg: #ffffff;
--text: #202124;
--error-color: #d93025;
--formula-bar-bg: #f8f9fa;
--context-menu-bg: #ffffff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: var(--text);
background: var(--bg);
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
#formula-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: var(--formula-bar-bg);
border-bottom: 1px solid var(--grid-border);
min-height: 32px;
flex-shrink: 0;
}
#address-label {
font-weight: 600;
min-width: 40px;
text-align: center;
color: #5f6368;
font-size: 12px;
user-select: none;
}
#formula-input {
flex: 1;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 8px;
font-family: inherit;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
#formula-input:focus {
border-color: var(--selection-color);
}
#grid-container {
flex: 1;
overflow: auto;
position: relative;
}
#grid-table {
border-collapse: collapse;
table-layout: fixed;
}
#grid-table th {
background: var(--header-bg);
border: 1px solid var(--grid-border);
font-weight: 500;
text-align: center;
user-select: none;
position: sticky;
z-index: 2;
}
#grid-table th.col-header {
top: 0;
height: var(--row-height);
min-width: var(--cell-width);
width: var(--cell-width);
font-size: 12px;
color: #5f6368;
}
#grid-table th.row-header {
left: 0;
width: 48px;
min-width: 48px;
font-size: 12px;
color: #5f6368;
z-index: 3;
}
#grid-table th.corner {
top: 0;
left: 0;
z-index: 4;
width: 48px;
min-width: 48px;
}
#grid-table td {
border: 1px solid var(--grid-border);
height: var(--row-height);
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: cell;
vertical-align: middle;
min-width: var(--cell-width);
max-width: var(--cell-width);
font-size: 13px;
}
td.selected {
outline: 2px solid var(--selection-color);
outline-offset: -1px;
z-index: 1;
position: relative;
background: rgba(26, 115, 232, 0.04);
}
td.editing {
padding: 0;
}
td.editing input {
width: 100%;
height: 100%;
border: none;
outline: 2px solid var(--selection-color);
outline-offset: -1px;
padding: 0 4px;
font-family: inherit;
font-size: 13px;
background: white;
}
td.text-cell { text-align: left; }
td.number-cell { text-align: right; }
td.error-cell { text-align: right; color: var(--error-color); font-weight: 500; }
#context-menu {
position: absolute;
background: var(--context-menu-bg);
border: 1px solid var(--grid-border);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 100;
min-width: 160px;
padding: 4px 0;
}
#context-menu .menu-item {
padding: 6px 16px;
cursor: pointer;
font-size: 13px;
}
#context-menu .menu-item:hover {
background: #e8eaed;
}
#context-menu .divider {
height: 1px;
background: var(--grid-border);
margin: 4px 0;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="formula-bar">
<span id="address-label"></span>
<input type="text" id="formula-input">
</div>
<div id="grid-container">
<table id="grid-table">
<thead id="grid-head"></thead>
<tbody id="grid-body"></tbody>
</table>
</div>
<div id="context-menu" class="hidden"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./store.js":"src/store.ts","./ui.js":"src/ui.ts"},"src/formula-parser.ts":{"./types.js":"src/types.ts"},"src/engine.ts":{"./types.js":"src/types.ts","./formula-parser.js":"src/formula-parser.ts"},"src/store.ts":{"./types.js":"src/types.ts","./engine.js":"src/engine.ts","./formula-parser.js":"src/formula-parser.ts"},"src/ui.ts":{"./types.js":"src/types.ts","./store.js":"src/store.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const store_js_1 = require("./store.js");
const ui_js_1 = require("./ui.js");
// ============================================================
// Entry point - wire up Store and UI
// DOMContentLoaded has already fired per bootstrap template
// ============================================================
const container = document.getElementById('grid-container');
const formulaBar = document.getElementById('formula-bar');
if (container && formulaBar) {
const store = new store_js_1.CellStore();
const ui = new ui_js_1.SpreadsheetUI(container, formulaBar, store);
// Expose for testing via probe
window.__spreadsheetStore = store;
window.__spreadsheetUI = ui;
// Load saved state
ui.loadState();
}
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ROW_HEADER_WIDTH = exports.HEADER_HEIGHT = exports.CELL_WIDTH = exports.ROWS = exports.COLS = void 0;
exports.colIndexToLetter = colIndexToLetter;
exports.letterToColIndex = letterToColIndex;
exports.cellAddress = cellAddress;
exports.parseCellRef = parseCellRef;
exports.parseRange = parseRange;
// ============================================================
// Tunable constants
// ============================================================
exports.COLS = 26; // A-Z
exports.ROWS = 100;
exports.CELL_WIDTH = 100; // px
exports.HEADER_HEIGHT = 24; // px
exports.ROW_HEADER_WIDTH = 48; // px
// ============================================================
// Address helpers
// ============================================================
function colIndexToLetter(c) {
let s = '';
c++;
while (c > 0) {
c--;
s = String.fromCharCode(65 + (c % 26)) + s;
c = Math.floor(c / 26);
}
return s;
}
function letterToColIndex(letter) {
let idx = 0;
for (const ch of letter.toUpperCase()) {
idx = idx * 26 + (ch.charCodeAt(0) - 64);
}
return idx - 1;
}
function cellAddress(col, row) {
return colIndexToLetter(col) + (row + 1);
}
function parseCellRef(addr) {
const m = addr.match(/^([A-Z]+)(\d+)$/);
if (!m)
return null;
const col = letterToColIndex(m[1]);
const row = parseInt(m[2]) - 1;
if (col < 0 || col >= exports.COLS || row < 0 || row >= exports.ROWS)
return null;
return [col, row];
}
function parseRange(rangeStr) {
// expects "ADDR:ADDR"
const m = rangeStr.match(/^([A-Z]+\d+):([A-Z]+\d+)$/);
if (!m)
return null;
const start = parseCellRef(m[1]);
const end = parseCellRef(m[2]);
if (!start || !end)
return null;
const top = Math.min(start[1], end[1]);
const bot = Math.max(start[1], end[1]);
const left = Math.min(start[0], end[0]);
const right = Math.max(start[0], end[0]);
return [left, top, right, bot];
}
};
// ── module: src/formula-parser.ts ──
__mods["src/formula-parser.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.tokenize = tokenize;
exports.parseFormula = parseFormula;
function tokenize(source) {
const tokens = [];
let pos = 0;
while (pos < source.length) {
if (/\s/.test(source[pos])) {
pos++;
continue;
}
if (/[0-9]/.test(source[pos]) || (source[pos] === '.' && pos + 1 < source.length && /[0-9]/.test(source[pos + 1]))) {
let start = pos;
while (pos < source.length && (/[0-9]/.test(source[pos]) || source[pos] === '.'))
pos++;
tokens.push({ kind: 'NUMBER', value: source.slice(start, pos) });
continue;
}
if (source[pos] === '"') {
let start = pos;
pos++;
while (pos < source.length && source[pos] !== '"') {
if (source[pos] === '\\')
pos++;
pos++;
}
if (pos < source.length)
pos++;
tokens.push({ kind: 'STRING', value: source.slice(start + 1, pos - 1).replace(/\\"/g, '"') });
continue;
}
if (source[pos] === '(') {
tokens.push({ kind: 'LPAREN', value: '(' });
pos++;
continue;
}
if (source[pos] === ')') {
tokens.push({ kind: 'RPAREN', value: ')' });
pos++;
continue;
}
if (source[pos] === ',') {
tokens.push({ kind: 'COMMA', value: ',' });
pos++;
continue;
}
if (source[pos] === ':') {
tokens.push({ kind: 'COLON', value: ':' });
pos++;
continue;
}
if ('+-*/'.includes(source[pos])) {
tokens.push({ kind: 'OP', value: source[pos] });
pos++;
continue;
}
if (/[A-Za-z]/.test(source[pos])) {
let start = pos;
while (pos < source.length && /[A-Za-z]/.test(source[pos]))
pos++;
if (pos < source.length && /[0-9]/.test(source[pos])) {
while (pos < source.length && /[0-9]/.test(source[pos]))
pos++;
tokens.push({ kind: 'COLREF', value: source.slice(start, pos).toUpperCase() });
}
else {
tokens.push({ kind: 'IDENT', value: source.slice(start, pos).toUpperCase() });
}
continue;
}
pos++;
}
tokens.push({ kind: 'EOF', value: '' });
return tokens;
}
// ============================================================
// Recursive Descent Parser
// Grammar:
// expr -> term (('+' | '-') term)*
// term -> factor (('*' | '/') factor)*
// factor -> ['+'|'-'] primary
// primary -> NUMBER | STRING | IDENT(args) | COLREF | RANGE(COLREF:COLREF) | '(' expr ')'
// ============================================================
function makeNumber(v) { return { kind: 'number', value: v }; }
function makeString(v) { return { kind: 'string', value: v }; }
function makeCellref(addr) { return { kind: 'cellref', address: addr }; }
function makeRange(addr) { return { kind: 'range', address: addr }; }
function makeBinary(op, l, r) { return { kind: 'binary', op, left: l, right: r }; }
function makeUnary(op, o) { return { kind: 'unary', op, operand: o }; }
function makeFunc(name, args) { return { kind: 'func', name, args }; }
function parseFormula(source) {
const tokens = tokenize(source);
const lexer = { pos: 0, source };
function peek() { return tokens[lexer.pos]; }
function advance() { return tokens[lexer.pos++]; }
function expect(kind, value) {
const tok = peek();
if (tok.kind !== kind || (value !== undefined && tok.value !== value)) {
throw new Error(`Expected ${kind}${value ? "='" + value + "'" : ''}, got '${tok.kind}'='${tok.value}'`);
}
advance();
}
function parseExpr() {
let left = parseTerm();
while (peek().kind === 'OP' && (peek().value === '+' || peek().value === '-')) {
const op = advance().value;
const right = parseTerm();
left = makeBinary(op, left, right);
}
return left;
}
function parseTerm() {
let left = parseFactor();
while (peek().kind === 'OP' && (peek().value === '*' || peek().value === '/')) {
const op = advance().value;
const right = parseFactor();
left = makeBinary(op, left, right);
}
return left;
}
function parseFactor() {
if (peek().kind === 'OP' && (peek().value === '+' || peek().value === '-')) {
const op = advance().value;
const operand = parseFactor();
return makeUnary(op, operand);
}
return parsePrimary();
}
function parsePrimary() {
const tok = peek();
if (tok.kind === 'NUMBER') {
advance();
return makeNumber(parseFloat(tok.value));
}
if (tok.kind === 'STRING') {
advance();
return makeString(tok.value);
}
if (tok.kind === 'LPAREN') {
advance();
const expr = parseExpr();
expect('RPAREN');
return expr;
}
// Function call: IDENT LPAREN ...
if (tok.kind === 'IDENT' && tokens[lexer.pos + 1]?.kind === 'LPAREN') {
advance();
advance(); // consume (
const args = [];
if (peek().kind !== 'RPAREN') {
args.push(parseExpr());
while (peek().kind === 'COMMA') {
advance();
args.push(parseExpr());
}
}
expect('RPAREN');
return makeFunc(tok.value, args);
}
// Cell reference or range
if (tok.kind === 'COLREF') {
advance();
const addr = tok.value;
if (peek().kind === 'COLON') {
advance();
const rightTok = peek();
if (rightTok.kind === 'COLREF') {
advance();
return makeRange(addr + ':' + rightTok.value);
}
throw new Error(`Invalid range syntax near ${addr}:`);
}
return makeCellref(addr);
}
throw new Error(`Unexpected token '${tok.kind}'='${tok.value}' at position ${lexer.pos}`);
}
const ast = parseExpr();
if (peek().kind !== 'EOF') {
throw new Error(`Unexpected token after expression: '${peek().kind}'`);
}
return ast;
}
};
// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CalculationEngine = void 0;
const formula_parser_js_1 = require("./formula-parser.js");
function isError(v) {
return typeof v === 'object' && v !== null && 'kind' in v && v.kind === 'error';
}
// ============================================================
// Calculation engine with dependency tracking
// ============================================================
class CalculationEngine {
constructor() {
// Store raw cell data
this.store = new Map();
// dependencyMap[cellId] = Set of cellIds this cell depends on
this.depMap = new Map();
// reverseDepMap[depId] = Set of cellIds that depend on it
this.revDepMap = new Map();
}
evaluate(raw, resolveFn) {
if (raw === null || raw.trim() === '')
return '';
if (!raw.startsWith('=')) {
const trimmed = raw.trim();
const num = Number(trimmed);
return isNaN(num) ? trimmed : num;
}
try {
const ast = (0, formula_parser_js_1.parseFormula)(raw.slice(1));
const result = this.evalAst(ast, resolveFn);
if (isError(result))
return '';
return result;
}
catch {
return '';
}
}
extractDeps(ast) {
const deps = new Set();
this.collectDeps(ast, deps);
return deps;
}
setDependency(cellId, deps) {
this.depMap.set(cellId, deps);
for (const d of deps) {
if (!this.revDepMap.has(d))
this.revDepMap.set(d, new Set());
this.revDepMap.get(d).add(cellId);
}
}
getDependents(cellId) {
return this.revDepMap.get(cellId) ?? new Set();
}
/** Get all cells (for iteration during full rebuild) */
getAllCellIds() {
const ids = new Set();
for (const [k] of this.depMap)
ids.add(k);
for (const [k] of this.revDepMap)
ids.add(k);
// Only return cells that actually have data stored
return Array.from(ids);
}
removeCellDeps(cellId) {
const deps = this.depMap.get(cellId);
if (deps) {
for (const d of deps) {
const r = this.revDepMap.get(d);
if (r)
r.delete(cellId);
}
}
this.depMap.delete(cellId);
this.revDepMap.delete(cellId);
}
/** Evaluate the entire sheet given a row of updates, topologically sorted. */
evalAst(node, resolveFn) {
switch (node.kind) {
case 'number': return node.value;
case 'string': return node.value;
case 'cellref': {
const addr = node.address;
const val = resolveFn(addr);
if (val === null)
return { kind: 'error', message: '#REF!' };
return val;
}
case 'range':
// Only used inside functions; standalone is an error
return { kind: 'error', message: '#ERR!' };
case 'binary': {
const left = this.evalAst(node.left, resolveFn);
if (isError(left))
return left;
const right = this.evalAst(node.right, resolveFn);
if (isError(right))
return right;
const l = this.asNumber(left), r = this.asNumber(right);
switch (node.op) {
case '+': return l + r;
case '-': return l - r;
case '*': return l * r;
case '/': return r === 0 ? { kind: 'error', message: '#DIV/0!' } : l / r;
default: throw new Error('Unknown op');
}
}
case 'unary': {
const operand = this.evalAst(node.operand, resolveFn);
if (isError(operand))
return operand;
return (node.op === '-') ? -this.asNumber(operand) : this.asNumber(operand);
}
case 'func': {
const name = node.name.toUpperCase();
const args = node.args;
const valsOrErr = this.flattenArgs(args, resolveFn);
if ('kind' in valsOrErr && valsOrErr.kind === 'error')
return valsOrErr;
const values = valsOrErr;
switch (name) {
case 'SUM': return values.reduce((a, b) => a + b, 0);
case 'AVERAGE':
case 'AVG': return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
case 'MIN': return values.length > 0 ? Math.min(...values) : 0;
case 'MAX': return values.length > 0 ? Math.max(...values) : 0;
case 'COUNT': return values.length;
default: return 0;
}
}
default: return '';
}
}
collectDeps(node, deps) {
switch (node.kind) {
case 'cellref':
deps.add(node.address);
break;
case 'range': {
// Collect all cells in the range
const addr = node.address;
const parts = addr.split(':');
if (parts.length === 2) {
deps.add(parts[0]);
deps.add(parts[1]);
}
break;
}
case 'binary':
this.collectDeps(node.left, deps);
this.collectDeps(node.right, deps);
break;
case 'unary':
this.collectDeps(node.operand, deps);
break;
case 'func':
for (const arg of node.args)
this.collectDeps(arg, deps);
break;
}
}
flattenArgs(args, resolveFn) {
const values = [];
for (const arg of args) {
switch (arg.kind) {
case 'number':
values.push(arg.value);
break;
case 'string': break;
case 'cellref': {
const addr = arg.address;
const val = resolveFn(addr);
if (val === null)
return { kind: 'error', message: '#REF!' };
values.push(this.asNumber(val));
break;
}
case 'range': {
const valsOrErr = this.rangeValues(arg.address, resolveFn);
if ('kind' in valsOrErr && valsOrErr.kind === 'error')
return valsOrErr;
values.push(...valsOrErr);
break;
}
case 'binary': {
const res = this.evalAst(arg, resolveFn);
if (isError(res))
return res;
values.push(this.asNumber(res));
break;
}
case 'unary': {
const res = this.evalAst(arg, resolveFn);
if (isError(res))
return res;
values.push(this.asNumber(res));
break;
}
case 'func': {
const res = this.evalAst(arg, resolveFn);
if (isError(res))
return res;
values.push(this.asNumber(res));
break;
}
}
}
return values;
}
rangeValues(rangeStr, resolveFn) {
const parts = rangeStr.split(':');
if (parts.length !== 2)
return [];
const startM = parts[0].match(/^([A-Z]+)(\d+)$/);
const endM = parts[1].match(/^([A-Z]+)(\d+)$/);
if (!startM || !endM)
return [];
const colStart = letterToCol(startM[1]), rowStart = parseInt(startM[2]) - 1;
const colEnd = letterToCol(endM[1]), rowEnd = parseInt(endM[2]) - 1;
const values = [];
for (let c = Math.min(colStart, colEnd); c <= Math.max(colStart, colEnd); c++) {
for (let r = Math.min(rowStart, rowEnd); r <= Math.max(rowStart, rowEnd); r++) {
const addr = colToLetter(c) + (r + 1);
const val = resolveFn(addr);
if (val === null)
return { kind: 'error', message: '#REF!' };
if (typeof val === 'number')
values.push(val);
else if (typeof val === 'boolean')
values.push(val ? 1 : 0);
// strings count as 0 for SUM etc.
}
}
return values;
}
asNumber(v) {
if (typeof v === 'number')
return v;
if (typeof v === 'boolean')
return v ? 1 : 0;
const n = Number(v);
return isNaN(n) ? 0 : n;
}
}
exports.CalculationEngine = CalculationEngine;
function letterToCol(name) {
let idx = 0;
for (const ch of name)
idx = idx * 26 + (ch.charCodeAt(0) - 64);
return idx - 1;
}
function colToLetter(c) {
let s = '';
c++;
while (c > 0) {
c--;
s = String.fromCharCode(65 + (c % 26)) + s;
c = Math.floor(c / 26);
}
return s;
}
};
// ── module: src/store.ts ──
__mods["src/store.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CellStore = void 0;
const types_js_1 = require("./types.js");
const engine_js_1 = require("./engine.js");
const formula_parser_js_1 = require("./formula-parser.js");
// ============================================================
// CellStore: manages all cells, dependencies, and recalculation
// ============================================================
class CellStore {
constructor() {
this.data = new Map();
this.engine = new engine_js_1.CalculationEngine();
this.dirtyCells = new Set();
this.pendingUpdates = new Set();
}
// Public accessor
getRaw(addr) { return this.data.get(addr)?.raw ?? null; }
setRaw(addr, raw) {
const oldData = this.data.get(addr);
this.data.set(addr, { raw, computed: oldData?.computed ?? '', error: null });
this.markDirty(addr);
}
getValue(addr) {
return this.data.get(addr)?.computed ?? '';
}
hasError(addr) {
return !!this.data.get(addr)?.error;
}
/** Get all cell addresses */
getAllKeys() {
return Array.from(this.data.keys());
}
/** Recalculate a cell and its dependents in topological order */
recalc(addr) {
// Collect ALL affected cells including upstream dependencies
const affected = new Set();
this.collectUpstreamAndDownstream(addr, affected);
const topo = this.topologicalSortFromStore(affected);
for (const cellAddr of topo) {
this.recalcOne(cellAddr);
}
}
/** Recursively collect both upstream dependencies and downstream dependents */
collectUpstreamAndDownstream(addr, affected) {
if (affected.has(addr))
return;
affected.add(addr);
// Follow upstream: expand full deps from AST (including range expansion)
const cellData = this.data.get(addr);
if (cellData && cellData.raw && cellData.raw.startsWith('=')) {
try {
const ast = (0, formula_parser_js_1.parseFormula)(cellData.raw.slice(1));
const fwdDeps = this.expandFullRangeDeps(ast);
for (const dep of fwdDeps) {
if (!affected.has(dep)) {
this.collectUpstreamAndDownstream(dep, affected);
}
}
}
catch { /* skip parse errors */ }
}
// Follow downstream: use engine's public method
const revDeps = this.engine.getDependents(addr);
for (const rdep of revDeps) {
if (!affected.has(rdep)) {
this.collectUpstreamAndDownstream(rdep, affected);
}
}
}
/** Expand all AST references into individual cell addresses (expanding ranges) */
expandFullRangeDeps(node) {
const deps = [];
this._expandNode(node, deps);
return new Set(deps);
}
_expandNode(node, out) {
switch (node.kind) {
case 'cellref':
out.push(node.address);
break;
case 'range':
this._expandRangeAddr(node.address, out);
break;
case 'binary':
this._expandNode(node.left, out);
this._expandNode(node.right, out);
break;
case 'unary':
this._expandNode(node.operand, out);
break;
case 'func':
for (const arg of node.args)
this._expandNode(arg, out);
break;
}
}
_expandRangeAddr(rangeStr, out) {
const parts = rangeStr.split(':');
if (parts.length !== 2)
return;
const sm = parts[0].match(/^([A-Z]+)(\d+)$/);
const em = parts[1].match(/^([A-Z]+)(\d+)$/);
if (!sm || !em)
return;
const cs = letterToCol(sm[1]), rs = parseInt(sm[2]) - 1;
const ce = letterToCol(em[1]), re = parseInt(em[2]) - 1;
for (let c = Math.min(cs, ce); c <= Math.max(cs, ce); c++) {
for (let r = Math.min(rs, re); r <= Math.max(rs, re); r++) {
out.push(colToLetter(c) + (r + 1));
}
}
}
/** Recalculate just one cell (given it was marked dirty) */
recalcOne(addr) {
const cellData = this.data.get(addr);
if (!cellData || cellData.raw === null || cellData.raw.trim() === '') {
if (cellData) {
cellData.computed = '';
cellData.error = null;
}
return;
}
// Check if this is a formula
const isFormula = cellData.raw.startsWith('=');
// Build resolver that reads current values from store
const resolveFn = (a) => {
const d = this.data.get(a);
return d ? d.computed : null;
};
if (isFormula) {
try {
const ast = (0, formula_parser_js_1.parseFormula)(cellData.raw.slice(1));
// Check for circular dependency
if (this.containsCycle(cellData.raw, addr)) {
cellData.computed = '';
cellData.error = '#CYCLE!';
return;
}
// Evaluate with resolution through live values
const result = this.engine.evalAst(ast, resolveFn);
if (typeof result === 'object' && result !== null && 'kind' in result && result.kind === 'error') {
cellData.computed = '';
cellData.error = result.message;
}
else {
cellData.computed = result;
cellData.error = null;
}
// Update dependency graph
const deps = this.engine.extractDeps(ast);
this.engine.setDependency(addr, deps);
}
catch {
cellData.computed = '';
cellData.error = '#ERR!';
}
}
else {
// Plain value
const trimmed = cellData.raw.trim();
const num = Number(trimmed);
cellData.computed = isNaN(num) ? trimmed : num;
cellData.error = null;
this.engine.setDependency(addr, new Set());
}
}
containsCycle(rawFormula, cellId) {
// Simple cycle detection: check if traversing dependencies from this cell
// eventually leads back to it
try {
const ast = (0, formula_parser_js_1.parseFormula)(rawFormula.slice(1));
const deps = this.engine.extractDeps(ast);
// BFS/DFS from deps to see if cellId is reachable
const visited = new Set();
const stack = Array.from(deps);
while (stack.length > 0) {
const current = stack.pop();
if (current === cellId)
return true;
if (visited.has(current))
continue;
visited.add(current);
const d = this.data.get(current);
if (d && d.raw && d.raw.startsWith('=')) {
const a = (0, formula_parser_js_1.parseFormula)(d.raw.slice(1));
const subDeps = this.engine.extractDeps(a);
for (const dep of subDeps) {
if (!visited.has(dep))
stack.push(dep);
}
}
}
}
catch {
// If parsing fails, assume no cycle
}
return false;
}
collectDependents(startAddr) {
const result = new Set();
const stack = [startAddr];
while (stack.length > 0) {
const addr = stack.pop();
const deps = this.engine.getDependents(addr);
for (const d of deps) {
if (!result.has(d)) {
result.add(d);
stack.push(d);
}
}
}
return result;
}
/** Build topo order using AST-based deps instead of stored deps */
topologicalSortFromStore(cells) {
if (cells.size === 0)
return [];
const adj = new Map();
const inDeg = new Map();
for (const cell of cells) {
adj.set(cell, new Set());
inDeg.set(cell, 0);
}
// Build edges from parsed AST dependencies
for (const cell of cells) {
const cellData = this.data.get(cell);
if (!cellData || !cellData.raw || !cellData.raw.startsWith('='))
continue;
try {
const ast = (0, formula_parser_js_1.parseFormula)(cellData.raw.slice(1));
const deps = this.expandFullRangeDeps(ast);
for (const dep of deps) {
if (cells.has(dep)) {
adj.get(dep).add(cell); // dep → cell (cell depends on dep)
inDeg.set(cell, (inDeg.get(cell) || 0) + 1);
}
}
}
catch { /* skip errors */ }
}
// Kahn's algorithm
const queue = [];
for (const [cell, deg] of inDeg) {
if (deg === 0)
queue.push(cell);
}
const result = [];
while (queue.length > 0) {
const cell = queue.shift();
result.push(cell);
for (const dep of adj.get(cell)) {
inDeg.set(dep, inDeg.get(dep) - 1);
if (inDeg.get(dep) === 0)
queue.push(dep);
}
}
// If not all cells were processed, they form cycles
// Add remaining unprocessed cells to result (they'll get #CYCLE! during recalcOne)
if (result.length < cells.size) {
for (const cell of cells) {
if (!result.includes(cell)) {
result.push(cell);
}
}
}
return result;
}
topologicalSort(cells) {
if (cells.size === 0)
return [];
// Build adjacency within the subset
const adj = new Map();
const inDeg = new Map();
for (const cell of cells) {
adj.set(cell, new Set());
inDeg.set(cell, 0);
}
for (const cell of cells) {
const deps = this.engine.getDependents(cell); // Actually these are reverse deps
for (const dep of deps) {
if (cells.has(dep)) {
// dep depends on cell, so edge from cell -> dep
// But we want processing order: cells with no unprocessed deps first
// So edge direction: cell must be processed before dep
adj.get(cell).add(dep);
inDeg.set(dep, (inDeg.get(dep) || 0) + 1);
}
}
}
// Kahn's algorithm
const queue = [];
for (const [cell, deg] of inDeg) {
if (deg === 0)
queue.push(cell);
}
const result = [];
while (queue.length > 0) {
const cell = queue.shift();
result.push(cell);
for (const dep of adj.get(cell)) {
inDeg.set(dep, inDeg.get(dep) - 1);
if (inDeg.get(dep) === 0)
queue.push(dep);
}
}
return result;
}
// ============================================================
// Row/Column Operations with Formula Rewriting
// ============================================================
/** Insert a column at index `col`. All formulas referencing columns >= col shift right. */
insertColumn(col) {
// Shift all existing cells: A->B, B->C, etc. for columns >= col
const keys = Array.from(this.data.keys()).sort().reverse();
const newData = new Map();
for (const key of keys) {
const parsed = (0, types_js_1.parseCellRef)(key);
if (!parsed) {
newData.set(key, this.data.get(key));
continue;
}
if (parsed[0] >= col) {
// Shift right
const shiftedKey = (0, types_js_1.cellAddress)(parsed[0] + 1, parsed[1]);
newData.set(shiftedKey, { ...this.data.get(key) });
}
else {
newData.set(key, this.data.get(key));
}
}
this.data = newData;
this.rebuildDependencies();
}
/** Delete column at index `col`. All formulas lose references to deleted column. */
deleteColumn(col) {
const keys = Array.from(this.data.keys());
const newData = new Map();
for (const key of keys) {
const parsed = (0, types_js_1.parseCellRef)(key);
if (!parsed) {
newData.set(key, this.data.get(key));
continue;
}
if (parsed[0] === col) {
// Remove this cell entirely
continue;
}
else if (parsed[0] > col) {
// Shift left
const shiftedKey = (0, types_js_1.cellAddress)(parsed[0] - 1, parsed[1]);
newData.set(shiftedKey, { ...this.data.get(key) });
}
else {
newData.set(key, this.data.get(key));
}
}
this.data = newData;
this.rebuildDependencies();
}
/** Insert a row at index `row`. */
insertRow(row) {
const keys = Array.from(this.data.keys()).sort().reverse();
const newData = new Map();
for (const key of keys) {
const parsed = (0, types_js_1.parseCellRef)(key);
if (!parsed) {
newData.set(key, this.data.get(key));
continue;
}
if (parsed[1] >= row) {
const shiftedKey = (0, types_js_1.cellAddress)(parsed[0], parsed[1] + 1);
newData.set(shiftedKey, { ...this.data.get(key) });
}
else {
newData.set(key, this.data.get(key));
}
}
this.data = newData;
this.rebuildDependencies();
}
/** Delete row at index `row`. */
deleteRow(row) {
const keys = Array.from(this.data.keys());
const newData = new Map();
for (const key of keys) {
const parsed = (0, types_js_1.parseCellRef)(key);
if (!parsed) {
newData.set(key, this.data.get(key));
continue;
}
if (parsed[1] === row)
continue; // skip deleted row
else if (parsed[1] > row) {
const shiftedKey = (0, types_js_1.cellAddress)(parsed[0], parsed[1] - 1);
newData.set(shiftedKey, { ...this.data.get(key) });
}
else {
newData.set(key, this.data.get(key));
}
}
this.data = newData;
this.rebuildDependencies();
}
rebuildDependencies() {
// Re-clear all deps and rebuild from scratch
// This is safe because recalcOne also updates deps
this.engine = new engine_js_1.CalculationEngine();
// Recompute all cells from their stored raw values
for (const addr of Array.from(this.data.keys())) {
this.recalcOne(addr);
}
}
clear(addr) {
const old = this.data.get(addr);
this.data.delete(addr);
if (old)
this.recalc(addr); // notify dependents
}
markDirty(addr) {
this.pendingUpdates.add(addr);
}
flush() {
for (const addr of this.pendingUpdates) {
this.recalcOne(addr);
this.recalc(addr);
}
this.pendingUpdates.clear();
}
/** Serialize entire state */
serialize() {
const obj = {};
for (const [key, cell] of this.data) {
obj[key] = cell.raw;
}
return obj;
}
/** Restore from serialized state */
deserialize(data) {
this.data.clear();
for (const [key, raw] of Object.entries(data)) {
this.data.set(key, { raw, computed: '', error: null });
}
// Full re-eval
for (const addr of Array.from(this.data.keys())) {
this.recalcOne(addr);
}
}
/** Clear all data and dependency graph (for testing) */
/** Clear all data and dependency graph (for testing) */
clearAll() {
this.data.clear();
this.engine = new engine_js_1.CalculationEngine();
this.pendingUpdates.clear();
}
}
exports.CellStore = CellStore;
// ============================================================
// Range helper functions
// ============================================================
function letterToCol(name) {
let idx = 0;
for (const ch of name)
idx = idx * 26 + (ch.charCodeAt(0) - 64);
return idx - 1;
}
function colToLetter(c) {
let s = '';
c++;
while (c > 0) {
c--;
s = String.fromCharCode(65 + (c % 26)) + s;
c = Math.floor(c / 26);
}
return s;
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpreadsheetUI = void 0;
const types_js_1 = require("./types.js");
// ============================================================
// Grid UI: table creation, selection, editing, events
// ============================================================
class SpreadsheetUI {
constructor(container, formulaBar, store) {
this.selectedCol = 0;
this.selectedRow = 0;
this.editingCell = null;
this.editingInput = null;
this._fiHandler = null;
this.store = store;
this.container = container;
this.formulaInput = formulaBar.querySelector('#formula-input');
this.addressLabel = formulaBar.querySelector('#address-label');
this.contextMenu = document.getElementById('context-menu');
this.table = document.getElementById('grid-table');
this.head = document.getElementById('grid-head');
this.body = document.getElementById('grid-body');
this.buildGrid();
this.bindEvents();
this.selectCell(0, 0);
}
setOnCellValueChange(fn) {
this.onCellValueChange = fn;
}
buildGrid() {
// Column headers row
const headerRow = document.createElement('tr');
const corner = document.createElement('th');
corner.className = 'corner';
headerRow.appendChild(corner);
for (let c = 0; c < types_js_1.COLS; c++) {
const th = document.createElement('th');
th.className = 'col-header';
th.style.width = types_js_1.CELL_WIDTH + 'px';
th.textContent = (0, types_js_1.colIndexToLetter)(c);
th.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showColumnMenu(e, c); });
headerRow.appendChild(th);
}
this.head.appendChild(headerRow);
// Body rows
for (let r = 0; r < types_js_1.ROWS; r++) {
const tr = document.createElement('tr');
const rowHeader = document.createElement('td');
rowHeader.className = 'row-header';
rowHeader.style.width = types_js_1.ROW_HEADER_WIDTH + 'px';
rowHeader.style.height = types_js_1.HEADER_HEIGHT + 'px';
rowHeader.textContent = String(r + 1);
rowHeader.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showRowMenu(e, r); });
tr.appendChild(rowHeader);
for (let c = 0; c < types_js_1.COLS; c++) {
const td = document.createElement('td');
td.id = 'cell-' + (0, types_js_1.cellAddress)(c, r);
td.style.height = types_js_1.HEADER_HEIGHT + 'px';
td.addEventListener('click', () => {
if (this.editingInput)
this.commitEdit();
this.selectCell(c, r);
});
td.addEventListener('dblclick', () => {
this.startEditCell(td, c, r);
});
td.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showCellContextMenu(e, c, r); });
tr.appendChild(td);
}
this.body.appendChild(tr);
}
}
selectCell(col, row) {
this.selectedCol = col;
this.selectedRow = row;
const addr = (0, types_js_1.cellAddress)(col, row);
const prevSelected = this.table.querySelector('td.selected');
if (prevSelected)
prevSelected.classList.remove('selected');
const cell = this.table.querySelector('#cell-' + addr);
if (cell)
cell.classList.add('selected');
this.addressLabel.textContent = addr;
this.formulaInput.value = this.store.getRaw(addr) ?? '';
cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
this.refreshCellDisplay(cell);
}
refreshCellDisplay(cell) {
if (!cell)
return;
const id = cell.id.replace('cell-', '');
const addrMatch = id.match(/^([A-Z]+)(\d+)$/);
if (!addrMatch)
return;
const colName = addrMatch[1];
const rowNum = parseInt(addrMatch[2]);
const colIdx = (0, types_js_1.letterToColIndex)(colName);
const rowIdx = rowNum - 1;
const addr = (0, types_js_1.cellAddress)(colIdx, rowIdx);
const cellData = this.store['data'].get(addr);
const hasError = !!cellData?.error;
const val = cellData?.computed ?? '';
const isSelected = cell.classList.contains('selected');
// Show error message in cell if present, otherwise show computed value
const displayText = hasError && cellData.error ? cellData.error : String(val);
cell.textContent = displayText;
var classes = [];
if (isSelected)
classes.push('selected');
if (hasError)
classes.push('error-cell');
else if (typeof val === 'number')
classes.push('number-cell');
else
classes.push('text-cell');
cell.className = classes.join(' ');
}
startEditCell(cell, col, row) {
this.stopEditing();
this.editingCell = cell;
cell.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
cell.appendChild(input);
this.editingInput = input;
const addr = (0, types_js_1.cellAddress)(col, row);
input.value = this.store.getRaw(addr) ?? '';
input.focus();
input.select();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.commitMoveDown(col, row);
}
else if (e.key === 'Tab') {
e.preventDefault();
this.commitMoveRight(col, row);
}
else if (e.key === 'Escape') {
this.cancelEdit();
}
});
input.addEventListener('input', () => {
this.formulaInput.value = input.value;
});
// Sync from formula bar back to input
this._fiHandler = () => { input.value = this.formulaInput.value; };
this.formulaInput.addEventListener('input', this._fiHandler);
}
commitEdit() {
if (!this.editingInput || !this.editingCell)
return;
const addr = this.editingCell.id.replace('cell-', '');
const newRaw = this.editingInput.value.trim() !== '' ? this.editingInput.value : null;
this.store.setRaw(addr, newRaw);
this.recalcAndRender(addr);
}
cancelEdit() {
if (this.editingInput && this.editingCell) {
if (this.editingCell.contains(this.editingInput)) {
this.editingCell.removeChild(this.editingInput);
}
this.editingCell.classList.remove('editing');
}
this.editingInput = null;
this.editingCell = null;
if (this._fiHandler) {
this.formulaInput.removeEventListener('input', this._fiHandler);
this._fiHandler = null;
}
const addr = (0, types_js_1.cellAddress)(this.selectedCol, this.selectedRow);
this.formulaInput.value = this.store.getRaw(addr) ?? '';
}
commitMoveDown(col, row) {
this.commitEdit();
this._fiHandler = null;
this.selectCell(col, Math.min(row + 1, types_js_1.ROWS - 1));
}
commitMoveRight(col, row) {
this.commitEdit();
this._fiHandler = null;
this.selectCell(Math.min(col + 1, types_js_1.COLS - 1), row);
}
recalcAndRender(startAddr) {
this.store.recalc(startAddr);
// Refresh display of changed cells
for (const key of Object.keys(this.store.serialize())) {
const cell = this.table.querySelector('#cell-' + key);
if (cell)
this.refreshCellDisplay(cell);
}
}
stopEditing() {
if (this.editingInput && this.editingCell) {
if (this.editingCell.contains(this.editingInput)) {
this.editingCell.removeChild(this.editingInput);
}
this.editingCell.classList.remove('editing');
}
this.editingInput = null;
this.editingCell = null;
this._fiHandler = null;
}
handleKeyDown(e) {
if (this.editingInput)
return false;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
this.selectCell(this.selectedCol, Math.max(0, this.selectedRow - 1));
return true;
case 'ArrowDown':
e.preventDefault();
this.selectCell(this.selectedCol, Math.min(types_js_1.ROWS - 1, this.selectedRow + 1));
return true;
case 'ArrowLeft':
e.preventDefault();
this.selectCell(Math.max(0, this.selectedCol - 1), this.selectedRow);
return true;
case 'ArrowRight':
e.preventDefault();
this.selectCell(Math.min(types_js_1.COLS - 1, this.selectedCol + 1), this.selectedRow);
return true;
case 'Enter':
e.preventDefault();
this.startEditInCurrentCell(false);
return true;
case 'Tab':
e.preventDefault();
if (e.shiftKey) {
this.selectCell(Math.max(0, this.selectedCol - 1), this.selectedRow);
}
else {
this.selectCell(Math.min(types_js_1.COLS - 1, this.selectedCol + 1), this.selectedRow);
}
return true;
case 'Delete':
if (!e.ctrlKey && !e.metaKey) {
this.clearCurrentCell();
e.preventDefault();
return true;
}
break;
case 'F2':
e.preventDefault();
this.startEditInCurrentCell(false);
return true;
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
this.saveState();
}
return false;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
this.startEditInCurrentCell(true, e.key);
return true;
}
return false;
}
startEditInCurrentCell(_isEnter, initialChar) {
const cell = this.table.querySelector('#cell-' + (0, types_js_1.cellAddress)(this.selectedCol, this.selectedRow));
if (!cell)
return;
this.stopEditing();
this.startEditCell(cell, this.selectedCol, this.selectedRow);
if (initialChar) {
this.editingInput.value = initialChar;
this.formulaInput.value = initialChar;
}
if (this.editingInput) {
this.editingInput.focus();
this.editingInput.select();
}
}
clearCurrentCell() {
const addr = (0, types_js_1.cellAddress)(this.selectedCol, this.selectedRow);
const oldRaw = this.store.getRaw(addr);
this.store.setRaw(addr, null);
this.store.recalc(addr);
const cell = this.table.querySelector('#cell-' + addr);
if (cell) {
cell.textContent = '';
cell.className = 'selected text-cell';
}
this.formulaInput.value = '';
}
saveState() {
localStorage.setItem('spreadsheet-data', JSON.stringify(this.store.serialize()));
}
loadState() {
const saved = localStorage.getItem('spreadsheet-data');
if (saved) {
try {
this.store.deserialize(JSON.parse(saved));
// Full grid refresh after deserialization
for (const addr of this.store.getAllKeys()) {
const cell = this.table.querySelector(`#cell-${addr}`);
if (cell)
this.refreshCellDisplay(cell);
}
}
catch { /* ignore corrupt data */ }
}
}
showCellContextMenu(e, col, row) {
this.showMenu(e.clientX, e.clientY, [
{ label: 'Insert Row Above', action: () => this.insertRowAbove(row) },
{ label: 'Insert Row Below', action: () => this.insertRowBelow(row) },
{ label: 'Delete Row', action: () => this.deleteRowOp(row) },
{ divider: true },
{ label: 'Clear Cell', action: () => { this.clearCurrentCell(); } },
]);
}
showColumnMenu(e, col) {
this.showMenu(e.clientX, e.clientY, [
{ label: 'Insert Column Left', action: () => this.insertColLeft(col) },
{ label: 'Insert Column Right', action: () => this.insertColRight(col) },
{ label: 'Delete Column', action: () => this.deleteColOp(col) },
]);
}
showRowMenu(e, row) {
this.showMenu(e.clientX, e.clientY, [
{ label: 'Insert Row Above', action: () => this.insertRowAbove(row) },
{ label: 'Insert Row Below', action: () => this.insertRowBelow(row) },
{ label: 'Delete Row', action: () => this.deleteRowOp(row) },
]);
}
showMenu(x, y, items) {
this.hideContextMenu();
for (const item of items) {
if (item.divider) {
const div = document.createElement('div');
div.className = 'divider';
this.contextMenu.appendChild(div);
}
else if (item.label && item.action) {
const el = document.createElement('div');
el.className = 'menu-item';
el.textContent = item.label;
el.addEventListener('click', () => {
this.hideContextMenu();
item.action();
});
this.contextMenu.appendChild(el);
}
}
// Ensure menu stays within viewport
const rect = this.contextMenu.getBoundingClientRect();
if (rect.width > 0 && x + rect.width > window.innerWidth)
x -= rect.width;
if (y + 200 > window.innerHeight)
y = window.innerHeight - 200;
this.contextMenu.style.left = x + 'px';
this.contextMenu.style.top = y + 'px';
this.contextMenu.classList.remove('hidden');
}
hideContextMenu() {
this.contextMenu.innerHTML = '';
this.contextMenu.classList.add('hidden');
}
insertRowAbove(row) { this._insertRow(row); }
insertRowBelow(row) { this._insertRow(row + 1); }
deleteRowOp(row) { this.store.deleteRow(row); this.rebuildAll(); this.saveState(); }
insertColLeft(col) { this._insertCol(col); }
insertColRight(col) { this._insertCol(col + 1); }
deleteColOp(col) { this.store.deleteColumn(col); this.rebuildAll(); this.saveState(); }
_insertRow(row) {
this.stopEditing();
this.store.insertRow(row);
this.rebuildAll();
this.saveState();
}
_insertCol(col) {
this.stopEditing();
this.store.insertColumn(col);
this.rebuildAll();
this.saveState();
}
rebuildAll() {
// Refresh all cells that have stored data
for (const addr of this.store.getAllKeys()) {
const cell = this.table.querySelector(`#cell-${addr}`);
if (cell)
this.refreshCellDisplay(cell);
}
}
/** Refresh display for every single cell in the grid */
refreshAllCells() {
for (let r = 0; r < types_js_1.ROWS; r++) {
for (let c = 0; c < types_js_1.COLS; c++) {
const addr = (0, types_js_1.cellAddress)(c, r);
const cell = this.table.querySelector(`#cell-${addr}`);
if (cell)
this.refreshCellDisplay(cell);
}
}
}
bindEvents() {
window.addEventListener('keydown', (e) => {
if (this.handleKeyDown(e))
return;
if (e.key === 'Escape') {
this.stopEditing();
this.hideContextMenu();
}
});
document.addEventListener('click', (e) => {
if (!this.contextMenu.contains(e.target)) {
this.hideContextMenu();
}
});
// Formula bar sync
let fiTimer = null;
this.formulaInput.addEventListener('input', () => {
if (this.editingInput) {
this.editingInput.value = this.formulaInput.value;
}
});
this.formulaInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const addr = (0, types_js_1.cellAddress)(this.selectedCol, this.selectedRow);
const newVal = this.formulaInput.value.trim() !== '' ? this.formulaInput.value : null;
this.store.setRaw(addr, newVal);
this.recalcAndRender(addr);
if (this.editingInput)
this.cancelEdit();
}
else if (e.key === 'Escape') {
const addr = (0, types_js_1.cellAddress)(this.selectedCol, this.selectedRow);
this.formulaInput.value = this.store.getRaw(addr) ?? '';
if (this.editingInput)
this.cancelEdit();
}
});
// Save state on any change
const origSetRaw = this.store.setRaw.bind(this.store);
this.store.setRaw = (addr, raw) => {
origSetRaw(addr, raw);
if (fiTimer)
clearTimeout(fiTimer);
fiTimer = setTimeout(() => this.saveState(), 500);
};
}
}
exports.SpreadsheetUI = SpreadsheetUI;
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.7-flash","provider":"openrouter","persona":"typescript","sessionId":"e8feda4f-fc46-4cd0-b36b-31becf973955","tokensIn":12139620,"tokensOut":80869,"tokensTotal":12220489,"cost":0.4369330939999999,"turns":167,"toolCalls":166,"failedToolCalls":8,"timestamp":"2026-08-02T03:26:26.747Z"} -->