Skip to content

bento/dash: the release blockers, two kinds of sheet, and an Excel bounce test - #323

Open
nyblnet wants to merge 117 commits into
mainfrom
worktree-bento-dash
Open

bento/dash: the release blockers, two kinds of sheet, and an Excel bounce test#323
nyblnet wants to merge 117 commits into
mainfrom
worktree-bento-dash

Conversation

@nyblnet

@nyblnet nyblnet commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Everything in bento/dash since dash-v0.2.0: the release blockers, the two sheet
kinds, and every finding from an Excel bounce test. 60 rigs, all green.

Why it is one PR

The changes reference each other. The type-conversion fix, the validator check
that catches the same state arriving from a file, and the import gate that
depends on defined names existing are three parts of one argument; split across
PRs each one half-explains a bug. The commit history is granular and each
message says what was measured.

The release blockers are done

§0 of docs/dash-release.md is clear, and ten further items closed here: file
write-back, print/PDF, grid accessibility, the 400k-row crash, per-cell
formatting on the dataset kind, pivot/canvas rename, reorder-undo cost, an
undoable version restore, the dropped-workbook read-only gap, and the grid that
ended at the data.

Three remain and none is code: no pack channel (deliberate), the update path
cannot be exercised until something is published, and releaseFileHandle()
belongs in the serialised kernel zone.

Two kinds of sheet

kind:'table' is a DATASET — typed by column, columnar, exactly the rows there
are. kind:'canvas' is a SPREADSHEET — typed by cell, sparse, unbounded. The
bridge between them (promote a range, open a copy as a spreadsheet) is the part
neither Excel nor a BI tool does well. Design in docs/dash-sheet-kinds.md.

On top: the step engine the format has described since commit one, a SQL surface
that compiles to Step[] rather than embedding an engine (docs/dash-sql.md
explains why DuckDB-WASM cannot load from file:// at all), named ranges,
array-formula spill, Data Validation, paste special, text-to-columns, and 12
more functions.

The bug worth reading about

Import lands a mixed column as text and advises "set the column type once you
have decided what it is". Doing that moved col.type and nothing else — so the
grid right-aligned and number-formatted the values while the footer totalled
them as SUM 0, against a true total of 10,308.85 that dash returns
correctly from =SUM() on a spreadsheet copy of the same rows.

A confident wrong number, at the end of a path the product recommends. Found by
doing a real job with a real .xlsx, not by inspection — a capability audit I
ran the same hour was wrong twice in twenty lines. Fixed in store.ts (convert
the storage, refuse what will not convert, and carry the pre-conversion bytes on
the inverse so undo restores them) and in validate.ts, because store.ts can
only stop the state being CREATED — it still arrives from files saved by the
build that had it.

Offline mode

dash's share of GHSA-5c3x-xqp6-g94r closes by merging #305 rather than by
fixing anything here: one chokepoint, kernel/src/net.ts, with a CI rig banning
raw fetch/WebSocket outside it. dash was converted in that PR rather than
exempted from the scan. The UI half was branch-local and is fixed here —
setOffline returns whether the preference persisted, and the dialog says so.

What the rigs are for

38 new ones. The failure they exist to catch is the one this branch hit four
times: a green rig over a feature that is invisible on screen, because the
check proved a function correct and nothing proved the caller called it. Each of
those now has an explicit assertion on the call site, and
scripts/test-ci-registered.ts fails if any rig is not actually run by CI —
which it found five of on its first run.

Known and open

  • A convergence divergence at 5 concurrent editors. Pre-existing — verified
    by running the new fuzzer against the pre-branch engine and getting the
    identical failure. ACTORS=4 is clean at 27,667 checks.
  • SUBTOTAL inside a cell formula does not yet see the viewer's filter. The
    engine half is done; wiring it makes the formula engine view-aware, which is a
    decision rather than a hook (docs/dash-excel-gap.md finding 18).
  • AVG over an empty view reports 0 (finding 17).

Full record, including what was declined and why:
docs/dash-excel-gap.md, docs/dash-release.md, docs/DECISIONS.md.

nyblnet added 30 commits August 4, 2026 01:25
Seven modules, each with its own rig (790 checks), plus the grid and
store changes that put them on screen.

  select.ts   ranges, the keyboard map, TSV clipboard, fill series
  a1.ts       A1 references and the cell-formula grammar
  rowcol.ts   insert/delete/move/resize/freeze/hide
  filter.ts   predicates, multi-sort, top-N
  condfmt.ts  colour scales, data bars, rules
  gl.ts       a WebGL2 renderer in 3.7 KB deflated
  viz3d.ts    surfaces, scatter and bars over a sheet

What changed in the feel, rather than the feature list:

TYPE TO EDIT. A printable key on a selected cell replaces its contents
and seeds the editor with the character typed, and Enter/Tab commit and
move (down/up/right/left). Before this every edit needed a double-click
first, which is the single gesture that made the grid read as a viewer
rather than a spreadsheet. It routes ahead of the key map, which returns
null for bare printable keys precisely so typing can reach it.

A SELECTION OUTLINE AND A FILL HANDLE. A per-cell tint alone does not
read as a selection; the eye wants the rectangle. The handle in its
corner is how a spreadsheet user expects to fill — not a menu item.

FROZEN COLUMNS. Columns only: rows are positioned by index so a subset
cannot simply stick, and the header is sticky already, so the gap a
reader actually hits is losing the labels when scrolling right.

setSheetProps grew a `drop` list and lost its duplicate implementation.
Deletes were spelled `props: {k: undefined}`, which `JSON.stringify`
erases — over collab the delete would land locally and reach no other
replica, and the same trap sat on the inverse, so undoing a newly-set
key was a no-op everywhere else. Deletes are now a listed `drop`,
undefined is refused rather than accepted as a second spelling, the
structural-key guard is enforced on the path that actually writes, and
the store delegates to rowcol.ts so the forward and the inverse cannot
drift apart.
=A1+B1 in a cell. Column formulas stay the better tool and the default —
they name columns by identity, so they survive every structural edit —
but a spreadsheet that cannot put a number in ONE cell is not a
spreadsheet, and nobody is going to be argued out of typing =B4*1.2.

cellformula.ts owns no engine. a1.ts already decides what a reference is
and where it points; formula.ts already parses, evaluates and owns the
error values. The bridge is that formula.ts resolves a `ref` node by NAME
against its context, so a reference needs no parser support: rewrite `B4`
to a generated name bound to a one-element vector. A range binds the same
way to a longer one, which is why SUM(A1:A5) works without `:` ever
becoming an operator.

Order is the correctness problem, not evaluation, so it is Kahn over
cells and anything left when the queue drains gets #CYCLE! — never a
plausible number. The rig's first draft passed on ordering by LUCK: every
fixture was written with dependencies to the left of their dependents, so
a plain document-order walk got the right answer. Sabotaging the sort
caught one check out of thirty-three. The fixtures now run backwards
against the document, and it catches them.

A1 counts CANONICAL positions, never the visible grid. Sorting and
filtering are view state, so a formula must not change meaning when a
reader sorts: verified in the browser — SUM(D1:D6) reads 72,350 before a
sort and 72,350 after one.

setOverrides had the same wire bug setSheetProps did: a delete was
`v: [undefined]`, and JSON.stringify turns that into `[null]`, so over
collab the delete arrived as a null override instead. null deletes too
now. runEdit takes several patches as ONE undo step (writing a value and
clearing the formula it replaced is two), with the inverses REVERSED —
which the obvious test cannot show, because two patches on different
structures commute and pass either way. The check that discriminates
writes the same key twice.

THE HEADER. Every column read "A R", "B O", "C :" — the letter, name,
type badge, filter caret and resize grip were sharing 130px and the name
lost. Two lines now: letters on their own strip where Excel puts them,
with the type badge riding along in the space going spare, and the name
below with room to be a name. Moving the letter cost two `parentElement`
reads that silently became NaN — column select and click-to-sort both
died, and both are `closest` now.

Type is a popover rather than window.prompt. Import refuses to guess a
type it cannot decide, and that refusal is only honest if fixing it is
one click — not a native dialog asking for the NUMBER of the type you
wanted.
Copy/paste and insert/delete now maintain A1 references. translateRef
and shiftRefsForInsert were both written and tested and called by
nothing; this wires them to the two events that need them.

They are DIFFERENT rules and conflating them is the classic spreadsheet
bug, so the rig proves they differ on the same input:

  COPY   the formula moved, the cells did not. References follow it,
         except the $-pinned ones. =$A$1+B2 copied two rows down is
         =$A$1+B4.
  INSERT the cells moved, the formula did not. EVERY reference moves,
         $ included, because the cell it names physically moved. The
         same input shifted by two rows is =$A$3+B4.

A reference into a deleted row becomes #REF!, never the row that slid up
into the gap. A range spanning the hole shrinks instead.

TWO BUGS FOUND WHILE WIRING THIS, both silent, neither mine:

⌘V NEVER REACHED THE GRID. The document paste listener routed every
pasted block into the CSV importer, so pasting cells created a whole new
sheet instead of filling the selection — and grid.pasteTsv, fully
written, had no callers at all. ⌘V pastes into the cells now; importing
a file is what the Import button is for.

DELETE ROW DELETED THE WRONG ROW. Every structural op in rowcol.ts takes
a CANONICAL row index — it must, since a document edit cannot be
expressed in one reader's view — but the context menu passed the VISIBLE
one. Identical until somebody sorts. Measured on a Value-sorted grid:
right-click the row showing £22,750, choose Delete row, and £12,400 is
destroyed. The re-sorted view then rescrambled over the evidence. Both
indices are converted through grid.canonicalRow now.

The order vector was stale after a structural edit too — it holds row
indices and insert/delete renumber the rows underneath them, so the grid
drew blanks and rows in an order matching nothing. It rebuilds on a
structural invalidation.

Copy keeps an internal clip so a copied formula stays a formula, while
the system clipboard still carries VALUES — paste into another app and
£37,200 is what is wanted there, not =D1*3. The clip is only used when
the pasted text still matches what we wrote, so copying something else
in between can never paste a stale clip in place of it.
Copying makes a SECOND formula that should mean the same thing in its
new place, so its references move. Cutting moves the ONE formula, and a
formula that travels with its cells still means exactly what it did —
so a cut clip pastes untranslated. Excel agrees, and reversing it
silently re-points a moved formula at the wrong data.

Cut also did not clear what it cut: clearSelection wrote null VALUES
through setCells and never dropped the `f` override, so the formula
stayed and simply recomputed. Cutting a formula cell appeared to do
nothing, and Delete on one was a no-op. It drops both now, in a single
commit so one ⌘Z puts back the values and the formulas together.

Known limitation, documented at the call site: formulas ELSEWHERE that
referenced the cut cells do not follow them to the new location — they
keep pointing at the old, now-empty positions.
About reaches the same places slides' does: this file (version, counts,
size against the budget, docId, write-in-place state), document
properties, updates, language, password, version history, and the
take-it-elsewhere set (copy JSON, replace from JSON, duplicate as a new
workbook, save a copy). Nothing was writing the version timeline before,
so the history it restores from would have been permanently empty —
rememberVersion now feeds it, and refuses an encrypted workbook for the
same reason putRecovery does.

The splash is now dismissed rather than removed on sight: held briefly,
faded, then taken out of the DOM because while it exists it is
position:fixed inset:0 and eats every click on the grid. The two gates
(password, unreadable file) dismiss it immediately — an error surface
must not wait behind branding. A failsafe timer covers a boot that
throws before it ever reaches the dismissal.

parseDoc NEVER MINTED A docId. The field is declared required and was
not enforced, so a document block without one parsed happily and booted
with `docId: undefined` — and everything keyed off it (autosave
recovery, the version timeline, every future merge) shared ONE slot
called "undefined" across every such workbook. Verified before and
after: minted when absent, preserved when present, different on two
parses. `newDocId` is promoted to model.ts, where two modules had
independently grown private copies of it.
The suite's shared chrome, matching slides to the pixel: 5px resizer
strips with chevron tabs that dock flush when a panel is shut, [ and ]
to toggle, widths and collapse state remembered, both panels shut on a
phone unless the reader has said otherwise. The right panel is the same
accordion, open state persisted per section title.

Left is the sheet list; right is Column / Sheet / Workbook. Two things
that had no route back before: hidden columns can be un-hidden (Hide
this column was one-way), and the freeze can be set from the panel.

Mounting threw and took the whole boot with it: `resizer()` writes into
a `chevrons` const declared further down the same closure, so it was in
its temporal dead zone when the strips were built — and because the
panels had already emptied `.dx-body` by then, the grid vanished too and
the page came up as bare chrome. Only reachable by actually mounting,
which is exactly what standalone markup rendering does not do.

setSheet gained a real onSheetChange rather than the instance being
monkey-patched from outside, and it now resets the selection, sorts and
filters — they belonged to the sheet being left, and carrying a
column-3 filter onto a two-column sheet would have hidden every row.
…ts CSS

The save button is a split menu now: Save · Save a copy… · Save as new
workbook… · Save as template… · Save read-only copy…. Templates and
read-only copies write WITHOUT retaining the file handle, so a later ⌘S
cannot overwrite an export with the full workbook — slides learned that
one the hard way. All exports go through serializeAuto rather than
serializeFile, so an encrypted workbook's template is not shipped in
cleartext.

registerPreview gives the file a real thumbnail: the first sheet drawn
as a table, so a workbook shows its data in Finder instead of the boot
splash. Four rules the kernel cannot make — formula columns past 5,000
rows draw EMPTY rather than a number nobody computed; totals past 100k
rows are omitted rather than summed over the drawn window only; theme
colours reaching a stylesheet are allowlisted as colour syntax, where
escaping `<` is not the relevant defence; and an encrypted workbook
never gets one. The provider NARROWS its argument rather than casting —
it runs inside serialization, so a throw there fails the save, not the
thumbnail.

AND THE BUILD LOST THE ENTIRE STYLESHEET. postbuild-compress.mjs found
the app CSS by taking the first big `<style>` in `<head>` — but vite
inlines the module script into the head too, so the moment an app's
SOURCE built a `<style>` string (the thumbnail does) that string was
matched first. The real stylesheet was left uncompressed and
unregistered and the app booted with no CSS whatsoever. It matches
vite's `<style rel="stylesheet">` now, with the old scan as a fallback,
verified against both shapes.

Not fixable in the app source, which was the first attempt: esbuild
constant-folds `` `<${'style'}>` `` straight back to a literal, so the
`</script>` trick from save.ts does not transfer. slides and spaces
never emitted a literal `<style>`, which is why this waited for dash.
A background or freshly-created tab reports window.innerWidth === 0
before its first layout, so `0 < 700` matched the phone rule and booted
a desktop with BOTH panels collapsed — with no stored preference to
explain it, which read as the panels not having shipped at all. Found
by smoke-testing the built artifact rather than the dev server.
Lookups (XLOOKUP, VLOOKUP, INDEX, MATCH, LOOKUP), multi-criteria
(SUMIFS, COUNTIFS, AVERAGEIFS, MINIFS, MAXIFS), finance (NPV, IRR, PMT,
FV, PV), statistics (VAR, VARP, STDEVP, PERCENTILE, QUARTILE, CORREL,
RANK, MODE, COUNTUNIQUE), logic (IFS, SWITCH, XOR, IFNA, TRUE, FALSE),
text (PROPER, REPT, TEXTJOIN) and dates (DATE, EOMONTH, EDATE, DAYS,
WEEKDAY).

Three deliberate departures, each because the Excel behaviour is a trap:

VLOOKUP DEFAULTS TO EXACT. Excel's 4th argument defaults to approximate,
so on unsorted data it returns whatever row the search lands on and
reports nothing wrong. That default has produced more quiet spreadsheet
errors than anything else in the product.

IRR IS BISECTION, NOT NEWTON-RAPHSON. Newton is faster and diverges on
the cash-flow shapes people actually have, returning a number rather
than failing. Cash flows with no sign change now give #NUM!.

PERCENTILE TAKES A FRACTION. Passing 90 is #NUM!, not a clamp to the
maximum — clamping answers a question nobody asked.

Two bugs found by the rig rather than by reading:

VECTOR FUNCTIONS WERE BROADCASTING. SCALAR dispatch calls a function
once per row, which is right for ROUND(value, 2) and wrong for
CORREL(a, b) — that ran per row and correlated two single numbers. They
now take their arguments unbroadcast.

CELL FORMULAS COULD NOT SEE THE COLUMNS. Only the COMPUTED columns were
passed to the cell evaluator, so `SUMIFS(Value, Region, "North")`
matched nothing and returned 0, XLOOKUP gave #N/A, TEXTJOIN gave #NAME?
— while PMT, which references no column, worked perfectly. One cause
wearing four faces. Verified in the browser against the starter data:
SUMIFS over North AND Won is 50,750, and PERCENTILE(Value, 0.5) is the
true median 10,750.

Ten negative controls on the rig, all caught — including ANDs flipped to
ORs, an approximate VLOOKUP default, IRR inventing a rate, and dates
computed in local time instead of UTC.
openFilterMenu built `{op:'greater', value: …}`; filter.ts spells the
payload `v`. The bound arrived undefined, so the predicate matched
nothing — measured before the fix: "Value greater than 10000" left 0 of
8 rows, and a text `contains` matched every row, which made the feature
look inert rather than wrong.

`as never` on the call is what let it compile. Removing the cast is the
actual fix; correcting the key is just what the compiler then demanded.
After: 4 of 8 rows, and they are the right four.
THE HOOK. bento/slides earns its keep through morph: both frames live in
the document, so a shape animates model-driven with no DOM measuring.
This is that idea applied to data. `doc.story` holds a sequence of
steps; each step is a saved VIEW — a filter, a sort, a chart binding, a
3D camera, a caption. Stepping between them morphs the chart. Tableau
needs a server for this; Excel has nothing like it.

A step stores a view and NEVER numbers, so the series are re-derived on
every render and a story cannot disagree with the table behind it. Edit
a cell and every step moves at once.

The interpolation refuses to invent data. A gap tweens as a gap:
morph(null, 100, 0.5) is null, never 50 and never 0 — one character
different from the obvious `a ?? b`, and the difference is whether an
absent quarter grows out of the floor as though it were a small one.
Series are matched by NAME, because matching by index tweens Revenue
into Cost while the legend still reads Revenue.

Where it cannot animate honestly, it CUTS and says so. charts-lite reads
every cartesian value through num(v, 0), so a null in a line series is a
dive to the axis and back — a lie about the data. Bars morph (a null bar
has no height, which is what absent looks like); lines cut. The 3D
camera cuts rather than flies, and highlight renders as chips rather
than painted bars, both because the honest version needs kernel changes
that are listed in the module's KNOWN GAPS block.

Supporting work: `setDocProps` is the document-level twin of
setSheetProps — same discipline, deletes are a listed `drop`, and it
refuses sheets/docId/format so an unbounded props write cannot rewrite
structure or identity. The story types moved into model.ts as an
additive field.

106 rig checks; 11 deliberate mutations, all 11 caught — including the
kernel's own leaf fallback, series matched by index, and a filtered line
diving through zero.
Tiles over one workbook — KPI, chart, table, text — and clicking a bar,
a slice or a row filters every other tile. Selections compose: values OR
within a column, columns AND across them. Verified in the browser
against the starter data: North gives £50,750 over 3 rows, then North
AND Priya gives £28,000 over 2, and the chips read back what is applied.

The LAYOUT is document data; the SELECTION is not. A cross-filter click
goes through store.view() — no checkpoint, no dirty flag, no op — so
exploring a dashboard never dirties the file. Measured: the dirty
indicator stays off across a two-column selection.

Series are derived through chart.ts's own optionFor over a PROJECTION of
the surviving rows, so there is one group-by in the app and a tile
cannot disagree with the table behind it. A side-effect worth having:
charts now honour hand-corrections in `sheet.cells`, which chart.ts's
own column reader does not.

NULL IS A CATEGORY, NOT AN EXEMPTION. Blank, empty string and whitespace
collapse to one "(blank)" category before the group-by, so selecting
North excludes blank-region rows and selecting "(blank)" selects exactly
them. The categories of a column therefore PARTITION the sheet — union
is every row, pairwise disjoint, asserted both ways. The tempting
alternative, letting a null match every selection so unknowns are never
hidden, makes the totals stop adding up.

`setView` writes one view by id and carries its POSITION, because views
is an array and the order is the tab order: without it, undoing the
deletion of the first dashboard silently reordered the tabs — a change
nobody asked for, arriving inside the operation meant to undo one. Found
by its own rig check, not by reading.

Ten negative controls, all caught. One of the agent's own was initially
vacuous — it mutated the fixture rather than the parser, comparing a
document to itself — and was fixed by parameterising it.
Multi-level rows × columns, seven aggregations, subtotals, grand totals,
and drill-down from any cell to the source rows. Verified in the browser
against the starter data: North/Priya is 28,000, South/Sam 35,600, and
the cross-foot agrees both ways at 97,050.

It never materialises a row object. Each grouping field becomes a code
plane — for a dictionary column the codes ALREADY exist, so preparation
is one pass over the distinct values, not over the rows. 200k rows across
two row fields, one column field and two measures: 74 ms, 18 MB, 2 ms to
drill down.

SUBTOTALS ARE COMPUTED, NOT SUMMED. Every row accumulates into every
ancestor, so a subtotal average divides by its own values and a subtotal
distinct-count is a distinct count. The rig proves the average-of-averages
answer differs (16.31 against 22.5), and that per-region distincts sum to
8 where the truth is 5.

A pivot is a DOCUMENT, not a view. Sorting belongs to the reader; hiding
a column is editorial and travels in the file. "Revenue by region by
quarter" is the second kind — somebody built it, and "look at the pivot
on sheet 3" has to name something that exists. The sheet stores the SPEC
and never the numbers.

AND parseDoc DESTROYED EVERY UNRECOGNISED SHEET KIND. Anything not
'canvas' fell through and came back as `kind: 'table'` with empty
rids/columns/data/steps bolted on — the sheet rewritten into a different
thing, and what made it one dropped. That is not a pivot problem, it is
PLATFORM §3 failing for every future sheet kind: old builds are frozen
code, and a file from a newer dash has to survive a round trip through
them untouched. Now preserved verbatim, with a rig check that carries a
kind from a hypothetical future build through intact.

Twenty negative controls, all caught. Two of the agent's own checks were
vacuous and were fixed rather than kept: an unguarded min/max of an empty
group, and a header defence no test could distinguish from its absence —
that one was traced, shown unreachable, and deleted.
Import and export .xlsx on DecompressionStream/CompressionStream —
a minimal ZIP reader/writer and the SpreadsheetML actually needed. +12.2
KB deflated, about 10% of the shell. SheetJS alone is several times the
whole product; JSZip roughly doubles it.

PROVEN END TO END THROUGH THE SHIPPING PATH, not only in node. The
browser's CompressionStream produced a workbook, and LibreOffice opened
it and CALCULATED the SUM() we wrote — 97,050 is its arithmetic, not a
number we shipped. Percent renders 15%, not 1500%; dates land on the
right day. The archive passes `unzip -t`, `zipinfo` and python
zipfile.testzip, and our inflate of three real Excel files matches
`unzip -p` byte for byte.

Both date epochs, including the 1900 leap-year bug at serials 59/60/61 —
getting that wrong shifts every date by four years, silently.

Three refusals, each preserving the file rather than guessing:

A CROSS-SHEET FORMULA IS NEVER MADE LIVE. `Sheet2!A1` would silently
rebind to THIS sheet's column A, because a1.ts correctly declines to
rewrite a sheet-qualified name but does rewrite the A1 after the `!`.
The cached value is kept and the source preserved verbatim on the
override as `xlsxF` — now a named field in model.ts — which the exporter
writes back, so xlsx → dash → xlsx does not delete somebody's model.

COLUMN FORMULAS EXPORT AS VALUES. A dash column expression names columns
by identity and evaluates over vectors; Excel has no equivalent. A
per-row translation is right for simple arithmetic and stops being right
the moment the semantics diverge, and 95%-correct is exactly the silent
wrong answer this codebase is organised against. Reported in findings
either way.

MERGED CELLS KEEP THE VALUE WHERE THE FILE PUT IT. Filling the range
turns one spanning heading into N repeated values and one total into
several.

Fourteen negative controls, all caught. Two were initially MISSES
against LibreOffice, which happily opens a package Excel would refuse —
untyped worksheet parts, a styles part with no fills. With no Excel on
this machine, the requirements are restated as an explicit OPC
conformance block rather than trusting the one reader available.
Groundwork for collaboration, and two real bugs reachable without it.

INSERTING PAST THE END CORRUPTED THE SHEET. `splice` beyond the array
APPENDS, but the writeCell below it writes at the literal index — so the
row landed at position 3 and its value at position 10, leaving the column
longer than the sheet has rows with a hole of nulls between. Measured on
a 3-row sheet: rids said 4 rows, the column held 11 entries. rowcol.ts
clamps before building the patch so the UI never produced it, but
`window.bento.commit` is a public API and a remote op is another
producer. applyPatch is where the invariant has to hold.

RIDS WERE BEING REUSED. The floor derives from the current maximum, so
deleting the last row lowered it and the next insert minted that rid
again — measured: rid 3 deleted, then handed to a different row. The
model's own header says rids are never reused, and overrides, comments
and a peer's CRDT node all assume a rid names one row forever. Under
collaboration two replicas would mint the same rid for two different
rows and merge them into one. `nextRid` is now raised on insert AND on
delete — the delete half is the one that matters, since nothing else
remembers that a deleted rid ever existed — and it is never lowered, not
even by undo. That makes insert and delete the only ops whose apply→undo
is not byte-identical, and both rigs now say so explicitly rather than
comparing a field the format requires to survive.

AND A MONOTONIC COUNTER DOES NOT CONVERGE. Putting the watermark in the
document made two replicas disagree on it — 4002 against 4001, and on
nothing else. A monotonic counter's join is MAX, and the engine already
knows every rid that ever existed for a sheet, so the watermark settles
to one past the largest of them at every settle point. Both replicas
compute it identically once their states agree.

The collab engine and its 23,062-check convergence rig land here too,
with docs/dash-collab.md and its frank list of ten cases that lose data.
NOT WIRED INTO THE APP: one convergence bug is open (below), and the
session, transport and People panel are unmounted.
Both wore one symptom — identical sync state, workbooks differing by one
or two cells — which is why they looked like a single problem in the
dead-window value path. Neither was.

A DICTIONARY TRUNCATED BY SOMEBODY ELSE'S UNDO. `setCells`'s inverse
carries `dictLen`, and applyPatch honours it by cutting the dictionary
back. Exact for one writer. Under collaboration a peer's op landing
between a local commit and its undo interns ITS strings into the same
shared dictionary above that watermark, and the undo strands them: every
index pointing at one reads back null. Only the undoing replica
truncates, only values it did not author are lost, and NO register moves
— so the states stay identical while the documents drift. The sync layer
disarms `dictLen` on the patch the store is about to apply, because it
is the only layer that knows a session is live.

A PARKED VALUE THAT NEVER WEIGHED ITS COLUMN'S REBIRTH. A cell sits
under two whole-node assignments, but the stash replay compared the
parked authority only against the ROW's birth. Every replica that
reached the same rebirth by the other path applied the column rule and
blanked the value; the one that arrived through this path replayed it
unconditionally. The rule already existed — this path just never
consulted it.

Each fix closes exactly one seed, verified by reverting them
independently. Both are covered by hand-built deterministic checks with
the interleaving written out, rather than by a random seed nobody can
read, and each check was confirmed to fail when its fix is removed.

STILL OPEN, found at higher settings and reported rather than tuned
away, both failing identically on the unmodified engine:
  · row ORDER diverges at six actors (seed 116) — the fractional-key
    path, not the value path; values track their rows correctly
  · a row resurrected TWICE by two different actors drops a parked
    value on the receivers (seeds 374, 184)

Measured reach: clean across 300 seeds at four actors and 400 at five;
three failures across 200 seeds at six. Collab stays UNMOUNTED — these
lose a cell value at realistic editor counts, and a sync that silently
drops one number is worse than no sync.
SAVE WAS OFF THE SCREEN. Thirteen buttons in a flat row needed 1436px in
an 802px window, so Import CSV, Undo, Export CSV, both Excel buttons,
Save, its dropdown and the version chip all sat past the right edge. The
most important control in the application was unreachable, and About was
only openable by clicking the wordmark — which nobody guesses is a
button.

The bar is groups that degrade now, the way slides does it: every
control carries an icon AND a label, and the label is what shrinks.
Import/export fold into one Data menu at every width — four buttons for
something done twice a session is what pushed Save off the end. The
insert group becomes a + menu below 900px without any JS reparenting,
so no listener is ever rebound. Each breakpoint comes from a
measurement rather than a round number, and Save keeps its word longest:
it is the control people name when it goes missing.

Measured after, at 320/390/700/802/900/1100/1281/1440: the bar's
scrollWidth equals its clientWidth at every one, the body never scrolls
horizontally, and nothing is clipped — including at 320px with both
drawers open. An explicit ⓘ About button exists; the wordmark and
version chip stay as shortcuts.

THE GRID HAD NO HORIZONTAL RULES AT ALL — only verticals and zebra
striping, which is a web table, not a spreadsheet. Every cell has a
bottom rule now, the zebra is gone (Excel and Sheets have neither), the
header and gutter are a real tinted band with a seam, and the selection
tint is strong enough to find.

Seven bugs found by measuring rather than looking, including a boot
crash introduced and caught in the same pass — `esc` was a const while
the dispatcher calls boot() during module evaluation, so every load died
with the splash still up and NOTHING in the console. Also: `--surface`
and `--sel-bg` are declared nowhere in dash, so frozen cells resolved to
a transparent background — the exact bug the rule exists to prevent.

Row height stays 30px and is NOT Excel density: grid.ts positions every
virtualised row at `top: i * ROW_H` inline, so shrinking the cells
perforated the grid. Both have to change together; recorded at the site.
Excel's default row is 20px at 96dpi and Google Sheets' is 21px, both
against a ~13px face. dash sat at 30, which is a third fewer rows on
screen and part of why the grid read as a table on a web page rather
than a spreadsheet. 22px against dash's 13px text is a ratio of 1.69,
beside Sheets' 1.62.

THE REASON THIS FAILED THE FIRST TIME was that the number is declared
twice: grid.ts positions every virtualised row at `top: i * ROW_H`, and
the height comes from the stylesheet's `--row-h`. Changing one alone
perforates the grid — the cells shrink and the row boxes do not. So the
grid now WRITES the custom property from its own constant when it
mounts, and the stylesheet value is only the fallback for the moment
before the grid exists. They cannot drift in a running app at all.

A rig check guards the fallback anyway, and asserts the write is still
there; both halves were sabotaged and both were caught.

Verified in the browser: row boxes butt exactly (no gaps), the selection
outline lands on the cell to the pixel, the sticky header sits flush
against row 0, and virtualisation is unaffected — 20,000 rows render 30
DOM nodes with a sizer of exactly 440,000px.
The 'dense pro' direction was chosen, and 20px at 96dpi is precisely
Excel's default row. 22 was the intermediate step taken while proving
the two declarations could be made to agree; this is the target.

The stylesheet fallback moves with it, and the guard added last round —
which asserts the two declarations match AND that grid.ts still writes
the property at mount — passes unchanged.
The chosen direction. Lattice kept on both axes but lightened to
#edf0f4, header band quieted to #fbfcfd with hierarchy carried by type
rather than fill, 12.5px grid type, and the selection moved off amber to
#eef4ff with a #2563eb ring. Selecting a column now lights its HEADER as
well as the row gutter — on a wide sheet the header is the only thing
still on screen once the cursor has scrolled away.

The amber selection was not just dated, it was ambiguous: conditional
formats are red/amber/green, so an amber tint sat on top of amber data
bars and you could not see where the bar ended.

A DARK THEME across every surface — grid, top bar and menus, formula and
status bars, both panels, pivot, dashboard, story editor, save menu,
About, popovers, splash. One palette declared once as `light-dark()`
pairs, so there is no second copy in a media query to drift out of step.

AND THE THEME NEVER REACHES THE FILE. The obvious `data-theme` on <html>
silently wrote itself into every save — `capturePristine()` clones the
LIVE document, so the serialized shell came back carrying the author's
preference, and everyone opening that workbook would have inherited it.
It is a `<style data-bento-transient>` now, which the kernel strips from
every serialized shell. Verified end to end: switch to dark, save, open
the bytes cold with no stored preference, and the workbook renders in
the READER's theme. Same rule as locale and reduced motion.

Data-driven colour does NOT move: scales and bars are computed from the
numbers and two readers must see the same encoding. What changes is the
ink on top of them, and the bar opacity drops on dark, where amber at
0.55 measured 3.9:1.

~40 element specs measured across both themes: zero contrast failures.
Every var() referenced now resolves — the class of bug that left frozen
cells transparent.

The KPI tile clipped its own figure: measured at 30px type, the value
needed 34px of height in a 20px box and 166px of width in 125, so
"£97,050.00" rendered as "£97,05" with the descenders sliced. A KPI that
truncates its number is worse than one with smaller type, because the
reader cannot tell it happened.
The sheet list labelled everything non-table "canvas sheet — not
editable in this build". True until pivots existed — and then a pivot,
generated by the app's own + Pivot button and now surviving a round trip
since parseDoc stopped coercing sheet kinds, sat in the list confidently
describing itself as something else. A label that states the wrong thing
is worse than a vague one.

The Bar/Line toggle also stayed beside the panel title while a pivot was
mounted there, where it controls nothing that is on screen.

Both were flagged by the agents that found them as needing files they
did not own.
The last two classes closed, and a third the sweep turned up.

ROW ORDER DIVERGED AT SIX ACTORS. A row resurrected by two actors
yields two inserts. A replica that received the LOSER first already had
the row in the document when the winner landed, took the already-here
branch, recorded the winner's key and left the row physically where the
loser put it. Same registers, same births, same tombs — two different
sequences. Worse, that replica's rids were then no longer sorted by
(key, rid), and the row lookup is a BINARY SEARCH that assumes they are,
so every later insert landed somewhere arbitrary. That amplification is
why it took six actors to see it. The branch relocates the row now.

A VALUE SURVIVED ONLY ON ITS SECOND RESURRECTOR. The first insert names
a column, the second — from an actor who has not yet heard of that
column — does not, so the row's named set is overwritten and the parked
value's authority becomes unrecognisable. The MINORITY replica was the
correct one: an insert makes no claim on a column it does not name, so
nothing superseded the value. Everyone else was dropping it.

AND A ROW INSERT CLOBBERED A NEWER PARKED VALUE (seed 163, found by the
sweep and confirmed against the unmodified engine, so not a regression).
Every replica but one recovers, because the write is still queued on the
buried column — but its AUTHOR applied it into a live document and never
queued it anywhere, so the parked copy was its only record. Five
replicas with a number and its author with a blank.

Each fix has a hand-built deterministic check with the interleaving
written out, not a random seed nobody can read, plus a TRACE_ORDER
invariant asserting the sort the binary search depends on. Verified
independently: with the relocation removed, seed 116 returns 10
failures and 7 of the new checks trip.

Sweeps, on this machine: 1000 seeds × 250 steps × 6 actors, 300 × 400 ×
8 with the order invariant on, 250 × 350 × 7 — all clean, plus the
agent's own thirteen configurations. No known failing seed remains.

Still NOT wired into the app: `nextRidFloor` derives from the current
maximum, so two replicas can still mint the same rid concurrently. The
watermark half landed; partitioning the rid space has not, and that is a
correctness precondition rather than a nicety.
THE LAST CORRECTNESS PRECONDITION. The watermark made minting monotonic,
which stops a rid being reused after a delete. It could never touch the
concurrent case: two people insert at the same moment, both compute "one
past the highest I know about", and both get the same number. rid is
IDENTITY — the CRDT keys a row node on it, overrides and comments attach
to it — so two different rows sharing one merge into a single row and
one is silently lost.

Each replica now mints from its own block: rid = base + counter, 30 bits
of block by 23 bits of counter. 1.07e9 blocks, 8.4M rows per replica —
past docBudget, which stops a document long before then — and the top
block ends exactly on MAX_SAFE_INTEGER.

BLOCK 0 IS RESERVED FOR SOLO DOCUMENTS, so an unshared workbook still
numbers 1, 2, 3 and run-length encodes to [[1, 4200]]. Nothing about the
common case changes; the cost is paid only once a second person is
editing.

The residual risk is stated rather than hidden: a block is derived from
the actor id, so two replicas collide only if their derived blocks do —
the same assumption every register comparison in the engine already
makes. About 5e-8 for ten concurrent editors.

Within a block the floor comes from an in-memory high-water mark, not
the durable watermark: that watermark is a maximum across every actor's
rids and would push a replica straight out of its own block into
someone else's.

PROVEN IN TWO REAL TABS, not only in the rig. Both open one document;
each inserts a row at the same moment; A mints 3000369571430400 and B
mints 7443666155077632, and both replicas converge on ten rows with
BOTH inserts alive. Unpartitioned, both mint rid 9 and one row vanishes.
The rig's own check is blunter: with partitioning off, 25 of 25 minted
rids collide.

The session is constructed for every workbook and connects to nothing
unless the file arrived with credentials or the reader opts in, so the
starter still never phones home. mountPeople takes over its host, and
handing it `app` erased the whole application on boot — grid, panels,
everything — leaving only the panel behind, with nothing in the console
because nothing threw. It has its own container now.
Mark the insertRows clamp done — it landed with the rid watermark work
and the doc still listed it as owed.
The sync session used to decorate `commit` and `runEdit` on the store
instance. That covers every edit the UI makes and CANNOT see undo or
redo, which apply their inverses through a private path — so undo fell
back to broadcasting a whole state snapshot. Correct, and enormously
heavier than the two ops it stood in for.

`store.beforePatch(fn)` is called from commit, runEdit AND invert with
the patches about to be applied, and may replace the list.
`store.afterPatch(fn)` is the other half: the CRDT mints ops from the
PRE-state, because a delete has to be read before it displaces anything,
and settles once the document has moved. Registering only the first half
would have left parked remote ops permanently unlanded.

`invert` passes substitute:false. A hook may REFUSE a patch it cannot
express, and dropping one there would apply half an inverse and leave
the undo stack describing a document that no longer exists. An undo
lands whole or not at all.

Measured in two tabs on one document: the edit broadcasts one `ops`
frame, the undo broadcasts one `ops` frame — not a snapshot — and the
peer follows both, back to "Priya".
Four things the store owed the sync engine.

A DELETED ROW NOW TAKES ITS OVERRIDES WITH IT, and the inverse carries
them back. `insertRows`' own inverse is a bare `deleteRows`, so undoing
an insert went through the raw patch and stranded any hand correction,
note or per-cell formula added to that row in the meantime — on the
undoing replica only, because the engine strips them everywhere else.
An invisible divergence. `insertRows` gained an `overrides` field, which
is the honest shape: it already carried the row's values.

`setOverrides`' INVERSE CARRIES `dropEmpty` BOTH WAYS. Undoing the
removal of the last override left `cells: {}` behind on the undoing
replica while every peer received it through a path that drops the
container. Twelve bytes, and a document that no longer matches its own
collaborators.

`store.changedRemotely()` is a public verb for "the document changed
underneath you" — stamp modified, invalidate, emit what an edit emits,
without touching the undo stack, because a collaborator's change is not
an entry in your history. The session reached through `unknown` for the
PRIVATE `emit` before, which works until the event names change and
nothing tells you.

AND SHEETS ARE A PATCH. Adding or deleting one went through
`replaceDoc`, which CLEARS THE UNDO STACK — so creating a pivot, or
adding a sheet, silently threw away every edit you could previously take
back, and deleting a sheet could not be undone at all. Verified in the
browser: edit a cell, create a pivot, undo twice, and the edit is still
there. The inverse carries the sheet's POSITION, because sheet order is
the tab order.

One rig check flipped rather than being deleted: it asserted that a bare
deleteRows stranded the override, which was true and was the bug.
A spreadsheet's grid does not stop where the data stops — Excel and
Sheets both rule the whole window, and that continuing lattice is a good
part of what makes a grid read as a sheet rather than as a table someone
put on a web page. An eight-row workbook ended in a large white
rectangle.

Painted as a BACKGROUND on the scrolling element, not as filler rows.
Empty rows would be real DOM, would have to be virtualised, and would be
selectable and editable — a grid you can type into a thousand rows below
your data is a different product decision and not one to make by
accident. The background costs nothing and cannot be clicked.

It is as WIDE as the sheet and no wider. Excel rules to the window edge
because its columns go on forever; dash's do not, and ruling past the
last one draws cells that cannot be typed into.

AND THE PEOPLE PANEL WAS DOCKED WRONG, twice. mountPeople renders a full
card — heading, toggle, member list, empty-state line — and dropped into
the topbar that was 103px of block content which pushed the bar from
56px to 118px. In the bar it is a chip now: the live dot, the state, the
toggle, a swatch per collaborator. "You" and "nobody else is here" are
not news to the person reading them.

Then it went in AFTER the right-hand group rather than inside it — and
the responsive ladder measures that group, so anything appended past it
sits outside the arithmetic and pushed Save off the screen again, the
third time this session. Inside the group now, and on a phone the chip
stands down entirely unless somebody else is actually present: measured
at 390px it cost 20px more than the bar had and clipped About.
#236 added the formula and chart rigs. There are twenty-three now, and
until this the other twenty-one only ran when somebody ran them by hand
— roughly 24,600 checks, including the 23,100-check convergence suite
that has caught every ordering bug in the collab engine.

They land HERE rather than on main because eighteen of the rigs do not
exist on main: they arrived with this branch. Adding the CI steps first
would have failed every one of them on a missing file. A step that runs
a rig belongs in the same commit as the rig.

Each line says why it gates a merge rather than what it runs — the rigs
argue their own cases in their headers.
THE ROW NUMBERS SCROLLED AWAY. `.dg-gutter` declares `position: sticky`,
and `.dg-cell { position: relative }` thirty lines later has the same
specificity and silently won. Measured: scroll 600px right and the
gutter sits at left −495, entirely off screen. A spreadsheet whose row
numbers leave when you scroll sideways has lost the thing they are for.

The header's corner had never been sticky at all, so a scrolled column
header slid underneath and showed through where the row numbers'
heading belongs — the corner read "age", the tail of "Stage". It is the
one cell that must outrank both axes.

And the gutter now wins its overlap with a frozen column: equal
z-index left it to DOM order, and the gutter comes first.

CHART TEXT IS THEMED BY VALUE, NOT BLANKET. charts-lite writes `fill` as
a presentation ATTRIBUTE, which loses to a stylesheet — that is what
lets a theme reach chart text, and CSS is the right layer, because the
theme is a VIEWER preference while the chart option is derived from the
DOCUMENT. Baking a colour into the option would put one reader's
preference into everyone's chart.

But the rule matched every `text`, so it would also have flattened a
colour somebody CHOSE — legend.textStyle.color, an axis label, a future
per-series colour — and done it silently. It now matches only the
kernel's own fallback (#6B7280). Verified both ways in the browser: a
default-coloured label themes to 8.78:1 on dark, and an explicitly
coloured one stays exactly the colour it was given.
nyblnet added 30 commits August 18, 2026 11:44
…nothing

The heaviest finding from the Excel bounce test, and the worst shape a defect
can have: not a crash, not an error cell, a confident wrong total, reached by
following dash's own advice.

Import lands a mixed column as `text` and says "set the column type once you
have decided what it is". Doing that moved `col.type` and nothing else. The grid
then right-aligned the values and formatted them as numbers — so the column
LOOKED converted — while `aggregate` (grid.ts:318) skips anything that is not
already `typeof v === 'number'` and totalled the lot as ZERO. Measured on the
bounce test's file: footer `SUM 0` against a true total of 10,308.85, a number
dash returns correctly from `=SUM(D2:D10)` on a spreadsheet copy of the same
rows. Two halves of the app disagreeing, and only one of them saying so.

Reproduced through the real Store before changing anything, and again after.

THE CONVERSION reuses import's own `inferColumn`/`coerce`/`encodeColumn`, so
"what counts as a number" has one answer on import, on paste, and here.

WHAT WILL NOT CONVERT IS REFUSED. `coerce` answers null for a value it cannot
read; committing those nulls would delete data on a dropdown change. The patch
throws with a count and an example, and the column keeps both its old type and
its old bytes — the rule import already follows.

THE INVERSE CARRIES THE BYTES. Undo has to restore the storage, not just the
declaration, or it recreates the same disagreement pointing the other way.

Second hole, found while fixing the first: `validateDoc` — which exists to
answer "does this workbook agree with itself", and whose header says the
columnar failure is the one that matters because it has no symptom — reported
ZERO issues on a number column full of strings. It does now. store.ts can only
stop the state being CREATED; it still ARRIVES from a file saved by the build
that had the bug, and from hand-edited or model-generated JSON, which
PLATFORM §7 makes a first-class way in.

Four negative controls. One of them caught MY OWN rig: with the inverse's bytes
ignored, undo re-runs the conversion in reverse, and text→number→text is lossy
— `"1200.50"` comes back as `"1200.5"`. Every value is still a string, so the
`typeof x === 'string'` assertion I had written stayed green while a trailing
zero was silently deleted. It compares byte for byte now, and that control is
red.
Found by looking at my own fix in a browser rather than trusting its rig.

The refusal worked: a column holding "n/a" kept its type and every byte. But a
throw is only half an answer, and the missing half was plainly visible — the
reader picked "Money", the error went to the CONSOLE, and the dropdown went on
displaying `money` while the column header chip beside it displayed `Text`. Two
controls disagreeing, no message, nothing to act on.

That is the same shape as the bug the rest of this change exists to fix: the app
knowing something and not saying it. It is also the second time this exact
pattern has appeared here — the Offline switch stayed ticked over a preference
that had not persisted, for the same reason, that a control kept showing what
was asked for rather than what happened.

`setColumnType(store, sheet, col, next, report)` is now the one way in: it
commits, and on refusal reports the reason and returns false. Both call sites
use it — the panel's dropdown, which also rebuilds so the control snaps back to
the type the column actually has, and the header menu, which closes itself
before the commit and would otherwise vanish leaving nothing behind.

Guarded on the SOURCE as well as the behaviour: the rig fails if either file
regrows a bare `setColumn` type commit. A third call site that forgets is the
failure this helper exists to remove, and a rig that only tested the helper
would not have noticed one.

Re-measured in the browser on the built shell: toast reads "1 value in “Amount”
cannot be read as money (for example “n/a”). The column was left as it was.",
dropdown reads `text`, column type is `text`, and the data is untouched.
…d paints #NAME? over Excel's number

The gate screened a formula for a sheet qualifier, an external or structured
reference, and function names dash does not implement. A bare identifier is
none of the three, so =SUM(RentCells)/B5 — where RentCells is a definedName —
was imported LIVE, rendered #NAME? in red, and the 0.61 Excel had cached was
the number nobody could see any more. The message printed elsewhere on the
same screen promises exactly the opposite.

Fourth check: every word a formula mentions must be a function dash has, TRUE
or FALSE, or a name the workbook defines AND this import carried. a1.ts's
mapNames is the lexer, so a word inside a string, in front of a '(', after a
'!' or spelled like a cell address is not offered as a name.

Names are carried now rather than merely refused: workbook <definedNames> come
back as doc.names when the caller opts in (XlsxImportOpts.names) — a number, a
piece of text or one sheet-qualified range, with the range row-shifted by the
header rows the dataset does not have. A formula, a multi-area or unqualified
ref, or a cell-shaped spelling is dropped and named. The opt exists because
the gate TRUSTS the table: were a caller to ignore result.names, a live
=B4*TaxRate would paint #NAME? over a real number all over again, so the
default is off and the default is safe.

Off the default path, the names are reported rather than silently dropped —
finding 7 counted them as one of the silent losses.
…keeps its numbers

budget.xlsx put 'Jan 2026 budget' across A1:C1 and its real header in row 2.
Row 1 was taken as the header, so column A was called after the title, B and C
became 'Column 2' and 'Column 3', the real header became data row 1, and
because every numeric column then held one text value EVERY COLUMN TYPED AS
TEXT — a three-column budget with no numbers in it. dash emitted merged-cells,
empty-header and mixed-types and never joined them into the one sentence that
would have helped: the header looks like it is in row 2.

Detect, do, and say. The evidence is a proof rather than a hunch — a row
holding ONE value merged across the columns cannot be the header of those
columns, and the row under it names every column that has data — so the header
moves down one and a header-row finding names the merge that caused it, the
row it used instead, the title it did not import, and the row number to
re-import against. Silence is the one answer that is wrong. The two-tier
'Region | Q1 Q2' idiom is unaffected: that merge sits over a row with three
values, not one.

XlsxImportOpts gains headerRow — WHICH row, the answer a boolean cannot give,
and the hook for a 'use this row as the header' command. It and header both
beat the inference.
`=SUM(Jan!B1:B6)` answered #REF! on a dataset sheet and the right number
on a spreadsheet sheet in the SAME workbook. That reads as a missing
feature and was not one: recalcWorkbook has crossed sheets since the
workbook graph landed, one grid called it and the other called the
one-sheet entry point beside it. A kind is not a place to keep an
accident.

THE DECISION, recorded in docs/dash-sheet-kinds.md. A per-CELL formula on
a dataset DOES reach another sheet: a cell override is the cellular
escape hatch inside the columnar kind, addressed by position and bound by
the module that is already workbook-wide, and a spreadsheet has read
Pipeline!A1 all along — refusing only the outbound direction made the
dataset the lesser kind. A COLUMN formula does NOT, and that refusal is
the feature: reaching another sheet by position puts back the address
that moves when somebody edits a tab you are not looking at, and reaching
its COLUMN is a join with no key, no cardinality answer and no opinion
about two sheets of different lengths — dash has `join`, which asks all
three. This keeps one rule rather than two: defined names do not reach a
column formula either, so the column language is closed over its own
sheet without exception.

WHAT THE READER SEES INSTEAD OF #REF!. To an Excel user #REF! means you
deleted the thing this pointed at — their file, their mistake — and it
was being spent on two things that are not that. `Jan!A1` in a column
formula said `#NAME? unknown name "Jan"`, false twice over: the name is
not unknown, it is a sheet in the tab strip, and the reason it cannot be
used is a boundary rather than a typo, so the wording sent the reader to
fix a spelling that was already right. A formula evaluated outside its
workbook claimed "there is no sheet called Jan in this workbook" about a
sheet that is in it. Both now name the boundary; the codes stay Excel's,
because the grid has one cell of space to say it in, and the whole
difference lives in FormulaError.why, which is what the panel shows.
`Jan!Amount` in a CELL formula gets its own third sentence — a cell CAN
reach another sheet, it just cannot address a column there.

WHAT ROW 1 MEANS, which had no answer written down. An A1 row number is
the row number the addressed sheet paints in its own gutter: a dataset's
counts DATA rows (its header is chrome), a spreadsheet's counts every row
(it has no headers, only a cell holding a word). Making a dataset count
the header would agree with Excel and put a formula's rows out of step
with the numbers printed down the side of the same sheet — a worse
mismatch, needing no second sheet to be seen, and it would silently
change what every saved Pipeline!A2 already means. So the one-row offset
is owned rather than removed: flattenToSpreadsheet already shifts a
copy's LOCAL references by the header and leaves QUALIFIED ones exactly
as written, which is what makes the two bases safe to coexist, and
rowMeaning() is the sentence for the reader with both sheets on screen.

Also closes the gap grid.ts and main.ts both documented: every dataset
but the one on screen handed over no computed columns, so a reference
into a CALCULATED column read blank and `=Sales!C2*1.2` reported a
confident 0. workbookSources now falls back to a real recalculation,
affordable because tableCellSource resolves it lazily and memoises — a
sheet nothing references is never asked, measured in the rig.

scripts/test-dash-xsheet.ts, 31 checks, registered in CI. Every guard
negative-controlled, and the row-meaning offset is MEASURED against both
sheets rather than asserted, because that is the part that would rot in
silence.

NOT WIRED YET: dash/src/grid.ts:1108 still calls the one-sheet path for a
dataset, so the feature is invisible until that one line changes. It is
owned by another agent; recalcSheetCells() is exported for it.
…ad of dropping all three without a word

Measured in the bounce test: every fixture froze its header and bolded it, and
every imported sheet reported frozenRows 0 with not one cell override carrying
bold — for two things dash has had all along (the sheet panel's frozen rows,
cellfmt.ts's per-cell appearance). There was no import code for a dropped
freeze, format or totals row either, so none of it was even reported.

FROZEN PANES. A <pane state="frozen"/> becomes sheet.frozen, with the one
translation that matters: Excel's ySplit counts SHEET rows and its first is the
header, while dash's frozen.rows counts DATA rows and a dataset pins its header
itself. So ySplit=1 is correctly zero and loses nothing, and ySplit=2 is the
one data row the author actually pinned. A SPLIT pane is a scroll position and
is not a freeze; it is not read.

PER-CELL FORMAT. bold, italic, underline, font colour, a solid fill and the
border edges arrive as appearance-only overrides — no 'v', so a bolded cell
cannot move a total, a chart, a pivot or an export by one digit. Theme and
indexed colours, patterns, font family and per-edge weights are dropped rather
than approximated. Past CELL_FORMAT_BUDGET the values still all arrive and the
finding says the paint did not. The exporter learned the same vocabulary, or
the import would have made a fresh silent loss on the way back out.

THE TOTALS ROW, which is the consequential one. An xlsx ListObject is dash's
dataset kind, docs/dash-sheet-kinds.md worked the mapping out, and without it
Excel's totals row imports as an ordinary data row: sorting pipeline.xlsx by
Value descending put the row labelled Total, holding 869,050, at the top of the
deals, where filters catch it and aggregates count it. It now leaves the data
and becomes totals: {value:'sum'}; a function dash cannot express (custom,
stdDev, var) is dropped and named rather than approximated. The export writes a
real ListObject so the property survives the round trip — LibreOffice still
opens the workbook and still calculates the totals row.

The table also SAYS where its header is, which beats inferring it.
…ref resolves

The hook the cross-sheet work reported, applied — and it was the whole defect
in one line. `recalcWorkbook` has crossed sheets since the workbook graph
landed, and `cvRefresh` already called it for the canvas kind. This call site
handed the DATASET kind the one-sheet `recalcCells`, so `=SUM(Jan!B1:B6)`
answered `#REF!` on a dataset and resolved on a spreadsheet in the same
workbook. Two call sites, never two kinds.

Applying it made `cellSource`, `columnVectors` and the `recalcCells` import
DEAD — which is the evidence that the one-sheet path had no other caller and
was not a considered narrowing. All three removed.

THE CALLER CHECK IS THE POINT. The agent said plainly it could not write this
one from inside its own lane, because it lands on a file it did not own: all 31
of its checks passed while the feature was still `#REF!` on screen. That exact
failure — a rig proving an engine correct over a feature nothing invokes — has
now happened three times in this codebase in one week (per-cell appearance,
array spill, this). So it gets a check rather than a comment, and the check is
on grid.ts's source: it must call `recalcSheetCells`, and it must no longer
contain `recalcCells(` at all, because leaving the one-sheet path available is
how the two kinds drift apart again.

Negative-controlled by putting the old call back: two failures, exit 1.

(My first attempt at that control reported zero failures and a crash — I had
used `readFileSync` without importing it in that rig. Instrument, not finding.)
…line

importXlsx returns doc.names when asked; a caller has to put them somewhere,
and the collision rule is the part every caller would get wrong differently.
A name the document already has WINS: importing a second workbook must not
silently repoint TaxRate at the new file's rate and change every formula that
used it at once. The skipped spellings come back so the caller can say so.
`main.ts` created every computed column with a hardcoded `type: 'number'`.
Measured in the app: splitting "Lastname, Firstname" with
`TRIM(LEFT(Name, FIND(",", Name) - 1))` produced a column of surnames badged
NUMBER, right-aligned, offering numeric filter operators, sorted as numbers,
exported as numbers and totalling to nothing. A type is a claim about data,
and this one was made by a literal.

`computedtype.ts` runs the expression against the sheet and judges what it
produced. THE JS TYPE OF THE VALUE DECIDES THE CLASS, NOT ITS APPEARANCE:
numbers make a number column, booleans a bool column, and strings never
become numbers here however numeric they look — `aggregate` skips anything
that is not `typeof v === 'number'`, so typing a column of "1,240.00" as
number is the SUM 0 bug of finding 1 arriving through a new door.

Within the string class the question is import.ts's, so `inferColumn` is
reused rather than re-answered — including its documented `ambiguous`
refusal, which lands here as text exactly as it lands on an import. Its
answer is then clamped to the types a string can be stored as: `date`, and
only when the strings are already ISO.

Errors and blanks are not evidence and are skipped; when they are all there
is, or when the values are mixed, the answer is TEXT — the one type that
makes no claim, right-aligns nothing, promises no total and loses nothing.

Editing an existing expression re-infers, but never overrules a person: the
type moves only while it still matches what the OLD expression produced, so
a type chosen by hand in the panel survives an unrelated formula edit. The
panel's own Formula field follows the same rule.

A computed column has no stored bytes, so validate.ts exempts it from
`type-storage-mismatch` whatever this returns — asserted in both directions
rather than assumed.

scripts/test-dash-computedtype.ts, registered in CI: 18 checks, including the
caller check that makes the rest mean anything (a rig can prove a function
correct while the feature is invisible because nothing asked whether the call
site calls it). Negative-controlled: restoring the hardcoded `type: 'number'`
reddens 2, and unclamping inferColumn's verdict over strings reddens 2 more.
condfmt.ts implements six rule kinds. validate.ts accepts six, the grid and
the printer paint six, a saved workbook round-trips six. The application
constructed TWO: a right-click gave "Colour scale" and "Data bars" with
hardcoded colours, and nothing anywhere in dash/src built a `cellValue`,
`topN`, `duplicates` or `formula` rule. So "highlight cells greater than 40",
the most-used conditional format there is — and the literal job a bounce test
sat down to do — was missing from a product that had already built it.

The engine is untouched. This is the dialog.

condfmtui.ts is a PANEL SECTION, not a modal: formatting is judged by looking
at the grid, and the panel is already where a column's type, pattern, total
and validation live. It is built from panels.ts's own `PanelKit`, so its rows
cannot drift from the sections above it. Rules COMPOSE in this engine, so a
column holds an array and the section edits one rule at a time with the
others listed above it; which one is open is view state, in memory, never in
the file. A rule kind this build cannot read keeps its slot and its dropdown
entry rather than being deleted by a control that could not describe it, the
way datavalid.ts already treats an imported formula validation.

The cell menu keeps its two one-click presets and gains the two that were
asked for by name — "Highlight cells greater than…" and "Highlight duplicate
values" — plus a way through to the rest. All four now go through one
`blankCondFmtRule`, so there is no second set of colour literals to drift.
Clearing the last rule DROPS `condfmt` rather than storing an empty object:
an additive field meaning "none" has to be absent, or a rule added and
removed leaves a changed workbook.

`Panels.reveal(title)` opens the panel, opens the accordion section and
scrolls to it — the menu item needs it because the phone layout boots with
the panel shut.

53 new strings in all seven catalogs, packed regenerated. Two fragment keys
were designed out rather than translated (a row labelled "and", a dropdown
reading "the top"): a bare conjunction is a piece of an English sentence no
other language builds the same way.

scripts/test-dash-condfmtui.ts, registered in CI: 36 checks that drive the
real controls over scripts/lib/dash-dom.ts and assert the rule OBJECT that
reaches the document, then hand that object to the real `evaluateRules` and
assert the CELLS it paints. The over-40 check is paired with the vacuous pass
it would otherwise hide (every row in the sample is also over the rule's
default operand). Negative-controlled five ways: shrinking the kind list,
never coercing the operand, storing an empty object instead of dropping,
unmounting the panel section, and deleting the menu item each redden the
check that names them. dash-dom gains `append` and `remove`, which the panel
builders use and the grid did not.
The hook the xlsx work reported. `names: true` is opt-in because the live-
formula gate TRUSTS the table: a formula goes live when every bare word it
mentions is a name the import carried. So asking for names and not installing
them re-creates finding 4 exactly — `=B4*TaxRate` painting #NAME? over a real
number — while looking more capable rather than less. The two lines are one
change and are committed as one.

BOTH DOORS TOGETHER, and that is the point of doing it in a single commit
rather than as each file came free. dash has two import paths, and the bounce
test already caught them out of step once: fifteen findings render as bullets
through the menu and as a single unbroken paragraph through the drop door —
same feature, two doors, and the door people use is the broken one. Landing
names on one door only would have been that bug again, freshly made.

Guarded on the source for both files, and both halves for each: asks, and
installs.

MY OWN CHECK WAS DEFEATED BY MY OWN COMMENT. The first version tested the raw
source for `names: true`, and dropopen.ts carries a comment EXPLAINING the
contract — so deleting the actual option left the string sitting in the prose
above it and the control stayed green. The check strips comments now. A guard a
comment can satisfy is a guard that certifies documentation; test-ci-registered
already says exactly this about itself, and I wrote this one anyway.

A second control reported zero failures and was also worthless: the sabotage
never applied (two-space indent, not four). The assert-it-applied line caught
that, which is the only reason it is written down here as an instrument failure
rather than filed as a finding.
…r is not

A backlog whose entries describe intentions rather than observations rots, so
each finding now carries what happened and where. Findings 8-16 are untouched
and stay open; #12 is the one to take next, because it is a defect in a DOOR
rather than in a feature — fifteen import findings render as bullets through the
menu and as a single unbroken paragraph through the drop door, and the drop door
is the one people use.

Also recorded because it is the lesson of the week rather than of one fix: three
of the seven needed a caller change in a file the agent could not own, and in
all three the rig was green while the feature was invisible on screen.
Finding 12, and it is a defect in a DOOR rather than in a feature.
`showFindings` renders one bullet per finding and the MENU import path passes
it the array, so it does. `DropHost.notice` could only take a single string, so
the drop path had to `.join(' ')` fifteen findings first — and budget.xlsx
arrived as a wall of amber text 224px tall, 31% of a 720px window, with no
bullets, no grouping by sheet and no link to the column concerned.

The string-only signature was the cause, so widening it is the fix rather than
reformatting at the call site: `notice` now takes an array too, and both doors
hand over the same shape.

Two doors into one feature and the one people actually use — dragging a file
onto the window — was the one that destroyed the structure. That is the same
shape as the names contract landed an hour ago, which is why both doors went in
one commit there too.

Negative-controlled by restoring the join: one failure.
…als row that was dead

`docs/dash-excel-gap.md` finding 13 counted 91 functions in formula.ts and
named fourteen that were not among them. Twelve are here. Two of them are the
reason the list mattered.

SUBTOTAL, because Excel writes `SUBTOTAL(109, …)` into EVERY table's totals
row, so every imported Excel table arrived carrying a dead total (measured on
pipeline.xlsx). Its defining behaviour is that it ignores rows a filter has
hidden — which is exactly what dash's footer already does, and
docs/dash-sheet-kinds.md works out why that agreement is dash matching Excel's
TABLE semantics rather than deviating from Excel. So the check that matters is
that the two agree: `scripts/test-dash-functions.ts` computes the footer with
grid.ts's own `aggregate` over an order vector filter.ts's own `buildOrder`
produced, and the formula with `SUBTOTAL(109, …)` over the same rows, and
asserts they are the same number. Neither half is worked out by hand, because a
hand-worked total is the thing that agrees with a wrong implementation.

The view mask reaches the evaluator on the bound vector (`Shaped.__hidden`,
stamped by the exported `markHidden`). cellformula.ts is another agent's file,
so the one-line hook that stamps it is REPORTED rather than made; until it
lands, an unmarked range means nothing is hidden, which is the truth on an
unfiltered sheet and the whole of what a column expression can ever say.

TEXT / VALUE / DATEVALUE, because they are the standard Excel repair for
numbers-stored-as-text — the whole of the bounce test's Job 4 — and the person
who reached for the formula answer found nothing. All three REUSE dash's
existing judgement rather than restating it:

  TEXT       format.ts's own `readPattern` + a new `formatNumber`, so a column
             formatted "#,##0.00" and TEXT(x, "#,##0.00") cannot drift apart.
             A DATE pattern is refused: dash's patterns describe numbers, and
             handing the value back unchanged would look like it worked.
  VALUE      import.ts's `inferColumn` + `coerce`. Grouping, currency,
             accounting brackets and a trailing % all read; a date is refused
             and points at DATEVALUE, because dash keeps a date as a date.
  DATEVALUE  the same reading, so `03/04/2026` is refused HERE for the same
             reason import refuses it, in import's own words. A looser rule
             would make six characters mean one day in a column and another in
             a formula, in one file, with nothing on screen to say which.

Also: SUMPRODUCT (ranges of different lengths refused, never zero-padded),
LARGE/SMALL (k past the end is #NUM!, never a clamp to the last value), SEARCH
(case-insensitive, Excel's ?/*/~ wildcards, every other character escaped so a
pattern cannot leak into the regular expression), REPLACE, CHOOSE, HLOOKUP,
and TRANSPOSE — which returns a real ARRAY with its shape, so it SPILLS on the
spreadsheet kind through cellformula.ts's existing geometry (measured: a 2x3 at
A1 lands as 3x2 at E1:F3, six claims). ROW and COLUMN are deliberately NOT
here; the reasons are in the report.

format.ts is refactored, not changed: `readPattern` and the Intl assembly are
now shared with `formatNumber`, and `formatValue` was checked identical across
1,344 type x pattern x value combinations before and after.

Two rigs asserted that dash LACKED SUBTOTAL and SUMPRODUCT — the liveness gate
in xlsx.ts reads `FUNCTIONS`, so both now go live on import, which for SUBTOTAL
is the dead-total fix itself. Both checks are re-pointed at OFFSET, a function
dash still does not have, so each rig goes on testing the gate rather than the
example.

Every new guard is negative-controlled with the outcome asserted, and each
control was applied and confirmed to fail the rig before being written down:
mask ignored (6 checks), wildcard unescaped (2), LARGE clamped (1), SUMPRODUCT
padded (1), TEXT pattern dropped (2), DATEVALUE guessing (1), TRANSPOSE shape
dropped (2), VALUE via parseFloat (1).

175 checks in the function rig, every dash rig green, tsc clean.
Surfaced by the SUBTOTAL work, and only because its rig compared SUBTOTAL
against the SHIPPING footer rather than against a hand-worked number: the two
disagreed and the formula was the one that was right.

grid.ts aggregate() returns 0 for an average with nothing in it. SUBTOTAL(101)
returns #DIV/0!. Same class as finding 1 — a confident number where there is
none — and smaller, since the view is visibly empty above it. sum of nothing is
legitimately 0; avg, min and max are not.

Recorded rather than fixed: grid.ts belongs to a running agent. The rig asserts
it as a DIFFERENCE, which is the right call — matching the formula to the grid
would have buried a real defect inside a green check.
…he rest

Finding 10 of the bounce test: Excel's autofilter is a checklist of every
distinct value; dash offered one free-text "Contains" box per text column and
exactly "Greater than" per number column. To filter Stage to "Open" you had to
already know the word and spell it, and on a column of forty values you could
not see what was in it.

The engine was never the problem. filter.ts has carried sixteen predicates and
a deduped distinct-value list from the start, and the composition across
columns the report praised ("4 of 11 rows", correct) is unchanged. What was
missing was any way to SAY the other fifteen things. So this is a surface over
an engine that was already there, and it writes through the same single door:
setFilter -> applyView -> store.view -> store.order. No second way to hide a
row, no patch, no checkpoint, no dirty flag.

filterui.ts (new) - the column menu:
  - a checklist of the column's distinct values, built from the rows THE OTHER
    columns' filters leave (Excel's rule) and never narrowed by the column's
    own filter, which would delete the box you just unticked and make the
    filter a one-way door;
  - fifteen operators, spelled in condfmt.ts's CompareOp vocabulary rather than
    a second differently-spelled set: the twelve a CellValueRule already has,
    plus does-not-contain, top-N and bottom-N. Every column offers all fifteen
    (a text column full of digits is the commonest imported shape there is);
    the column type reorders them, and a date leads with "between";
  - a search that RE-SCANS the column, so a value past the 1,000 cap is
    reachable by typing three letters of it;
  - blanks as one selectable box, offered last;
  - a banner naming the filter live on this column, and re-opening the menu
    onto it so nudging a threshold is an edit rather than a re-authoring.

Operand parsing is fixed while it is here: "50%" against a percent column is
now the fraction 0.5. The old menu stripped the sign and kept the digits, so
every comparison in a percent column was out by a factor of a hundred and
looked entirely reasonable.

filter.ts - additive. matchKey and BLANK_KEY exported (the menu has to be able
to name the blank box); distinctValues takes a row subset and a search string;
and a bounded raw-value memo in front of the canonical key, which is what makes
the list affordable at dash's advertised scale: 10M rows, 40 distinct values,
622ms -> 90ms to open a menu. The blanks sentinel is now written as an escape
rather than as a raw NUL byte - same string, and git and grep stop calling the
file binary.

A spreadsheet (kind: 'canvas') REFUSES, with a reason: it has no columns to
filter by, and A4 in a formula means the fourth row by position, so hiding rows
underneath the addresses would make one reader's =SUM(B2:B9) cover different
cells than another's. No ACTIONS id - it is a column's own menu, not a toolbar
action, and an entry there would be a disabled button for a control that is not
on screen.

scripts/test-dash-filterui.ts (new, registered in CI) - 62 checks. The
load-bearing ones mount a REAL grid, dispatch a REAL click on a REAL checkbox
and assert the row indices store.order ends up holding, because a menu can be
perfectly correct and never be called. Eight sabotages were confirmed applied
and each turned it red.

Not wired yet: main.ts is another agent's file. One import, one changed line at
main.ts:662, and the 78-line openFilterMenu it replaces comes out.
Findings 8 and 11, and two from 13. They arrive together because all four
live in grid.ts and are guarded by one rig; splitting them would have meant
a commit whose own test fails.

8 — RIGHT-CLICK ON A GUTTER DID NOTHING, on both, measured twice. The row
number and the column header are the first place an Excel hand reaches, and
dash selected the row and showed no menu, so the reader concluded there was
no insert. There are three menus now and they are deliberately not one menu
with three headings:

  cell    — the clipboard, both axes, fill, clear, paste special, split, and
            the conditional formats. A cell is where every gesture converges.
  row     — the clipboard, insert above/below, delete, clear. NOT fill down
            (a fill runs down a COLUMN, so a fill from a row acts on an axis
            nobody clicked) and NOT the conditional formats (dash stores a
            rule per column; one added from a row would colour something
            else).
  column  — the clipboard, insert left/right, delete, clear, split, the
            conditional formats (which ARE column-scoped, and this is their
            honest home), and a way into the caret's sort/filter/hide — the
            SAME function the caret calls, not a second copy.

Cut, Copy and Paste are on all three and are FIRST, which is finding 8's
other half. Copy and Cut go through one new `Grid.copyToClipboard`, so the
menu cannot forget what ⌘X does that ⌘C does not. Paste has two answers
because a menu has no paste EVENT: the system clipboard when the browser
hands it over, dash's own last clip when it refuses, and a sentence when
there is neither.

Insert and Delete count the SELECTION and say so — "Insert 3 rows above" —
and under a sort, where the selected view rows need not be canonically
contiguous, the span falls back to one row rather than deleting a range the
reader never made. A cell menu that read `sheet.columns[ci]` with a hidden
column present named the wrong column; it reads the visible list now.

The menus moved out of main.ts into gridmenu.ts, and that is the load-bearing
part. main.ts boots on evaluation and can never be imported, so nothing could
ever assert what a right-click produces — which is how this feature and the
conditional formats (finding 5) both shipped complete and unreachable.
`installGridMenus` is the app's wiring AND the rig's, and the rig's negative
control is a grid where it was not called: the same right-clicks must produce
nothing. Verified by removing the wiring, the DOM handler, the Escape
listener, the appender and the formula carry in turn, and confirming each
sabotage applied before believing the red.

11 — AN IMPORTED PER-CELL FORMULA AND A NEW ROW. Of fill / offer to convert /
say plainly, this fills AND says. But not by copying the last row's formula:
an imported sheet very often ends in a total the import could not know about
(finding 7), and =SUM(D1:D3) translated down a row is a wrong number that
looks like a right one. So a column must PROVE it repeats — the last two rows
must hold the same formula one row apart — and only then is it carried, using
the same `translateCellFormula` a fill uses, in the same commit as the insert
so one ⌘Z takes back both. When it refuses it names the column, says the cell
is empty, and points at the column formula that would have filled every
future row. Both doors to a new row (typing on the appender, Insert row below)
give the same answer. A sheet with no cell formulas says nothing at all.

13 — ESCAPE now closes any popover, and its listener stands down the moment
the node is detached, so a stale menu cannot swallow an Escape meant for a
cell editor.

13 — THE COLUMN APPENDER. Rows grew a `+` frontier and columns had nothing,
which teaches a convention and then breaks it. It follows the frontier rules
— absent read-only, absent on a spreadsheet, and clicking costs nothing —
but the gesture differs because the thing differs: a row is brought into
being by typing in it, and a column needs a name first, so the click opens
the dialog and the file grows only when it is answered.

scripts/test-dash-menu.ts, 71 checks, registered in CI. scripts/lib/dash-dom.ts
gained document-level listeners (previously dropped, which no existing rig
can notice) and a `contextmenu` helper, because a menu that closes on a
document keydown cannot be tested any other way.
Measured: apply a number format, sort a column, press ⌘Z. The sort stays
and the NUMBER FORMAT comes off. The reader keeps the thing they asked to
reverse and loses a thing they were not thinking about, and nothing on
screen says either happened.

Sorting and filtering are view state and stay that way — they write
store.order and nothing else, take no checkpoint, mint no op, never dirty
the file. That was never the bug. A real view history belongs to the grid
(docs/DECISIONS.md, "one view vector": filters and sorts live in grid.ts
and the store owns only the vector they produce), so a stack kept here
would restore the row order while the header arrow, the status line and
the filter menu went on describing the sort that had just been undone —
and the next applyView() would put it back. Half an undo that lies is
worse than none.

So the store does the one thing it can see and can promise: it notices
that the reader's last action changed the rows on screen without changing
the document, and makes the next undo refuse and say why. One press to be
told, a second press to undo the edit anyway.

The test for "the reader did this" is that the order map changed with no
document change in flight — grid.applyView() re-derives the vector from
inside the doc listener, and that re-derivation must not cost anyone their
undo. It is deliberately about the VECTOR rather than about sorts and
filters by name: the store cannot see grid.ts's filters/sorts, and
filter.ts can widen what a filter is without this hearing about it.

Redo is guarded on the same footing. With an empty stack the barrier says
nothing, because "press undo again" would be a lie there.

The store refuses through an injected reporter (store.say) rather than
importing the DOM — the shape setColumnType already takes its `report` in.
panels.ts lends it toast. Unlent it swallows: a silent refusal is still
honest, it is just half the sentence.

Rig: the measured sequence is pinned — edit, view change, undo — asserting
what the document holds afterwards, since "undo did nothing" and "undo
undid the wrong thing" look identical anywhere except the document.
Negative controls run and confirmed red: barrier never arms (13 checks
fail), re-entrancy guard removed so derived view changes arm too (3 fail),
barrier computed but not obeyed (7 fail).
Measured: "#,##0.00" typed into the Format field of a Text column is
accepted, shown back as set, saved into the file — and changes nothing, on
screen or on paper. formatValue answers String(v) for text and never reads
the pattern at all.

The panel's own sentence next door is exactly right — "The column decides
what these cells ARE. A pattern here only changes how they print." This is
its sibling, for the case where "how they print" is nothing at all, and it
appears in both places a pattern can be typed: the Column section and the
dataset Cell section.

Deliberately NOT a refusal, and not setColumnType's mechanism.
setColumnType refuses and names the offending value because converting a
column would DESTROY values it cannot read; a display pattern destroys
nothing, and a Text column on its way to becoming a Number column is a
reasonable place to be standing with a pattern already typed. So the
pattern is kept and the panel stops pretending it is doing something.

patternIsInert is exported so the DECISION can be asserted: a note nobody
can check is how a sentence ends up shown in the wrong case, which is the
same defect pointing the other way. It covers every type formatValue
prints as-stored (Text, Category, Date, Yes/No) and is silent on a date
pattern like yyyy-mm-dd — that does nothing either, but it is not the
number mistake this sentence names, and the wrong explanation is worse
than none.

Negative controls run and confirmed red: the note firing on every pattern
(2 checks fail), and never firing (3 fail).
The filter work's hook, applied. It was NOT one line, as reported: routing the
caret orphans `openFilterMenu`, which orphans `frozenTo`, `readCellOf` and five
rowcol imports, and leaves `Greater than` and `Contains` as dead catalog keys in
seven languages. tsc found the first four, the i18n rig found the last two — I
did not have to remember either, which is the point of both.

BOTH CALL SITES IN ONE CHANGE. The caret in the column header and the column
context menu's "Sort and filter…" — added hours ago by the gutter work, and
routed through the same old function — are two ways to one thing. This codebase
has now been bitten three times by giving one door a capability the other lacks:
import findings rendered as bullets through the menu and as one paragraph
through the drop door, defined names carried by one importer and not the other,
and this menu, which only ever existed on the caret at all.

So the check is on the count: at least two call sites, and no `openFilterMenu`
left anywhere. Negative-controlled by stubbing the second door — "both doors
call openColumnMenu (found 1)". Comments are stripped before matching, because
one of my own checks this week was satisfied by a comment I had written
explaining the very thing it was meant to guard.
Two of the three things the gap report says print lacks were already
there, and this fixes the third by moving what existed rather than adding
a mechanism.

REPEAT HEADER ROWS were half-built: `thead { display: table-header-group }`
already repeats the COLUMN names on every page, but the sheet caption was
a <div> above the table, so it printed on page one and was never seen
again. The caption is now the first row of that same thead — one <th>
spanning the table, so a long workbook title is not hyphenated into the
row-number gutter — which is the whole of Excel's "rows to repeat at top",
built out of the one mechanism that behaves across engines. Page 27 of a
budget now names the sheet, the workbook, the view it is a view OF, and
the DATE it was printed.

The date is an argument to buildPrintable, not a `new Date()` inside it: a
rig that cannot pin the date cannot assert on the markup at all.

MARGINS existed but were fixed at 12mm; they are now Normal 12 / Narrow 10
/ Wide 20. 10mm is the floor because consumer printers clip under it. This
is not decoration — pageBox shrinks with it, so the choice feeds the
column planner, changes the shrink factor and moves the page estimate. It
is the one knob a reader has when a sheet is one column wider than the
paper.

PAGE NUMBERS: the existing reasoning is re-argued and stands. CSS Paged
Media margin boxes are unimplemented in every browser, and a second set of
numbers disagreeing with the ones the system dialog already prints is
worse than none. What was wrong was not the decision but the silence
around it — a reader who wants "Page 3 of 40" was left to conclude the
feature is missing rather than that it lives one dialog along. The Print
dialog now says where they come from.

Page header is an option (default on, since the caption it replaces was
unconditional and a printout that lost its sheet name in an upgrade would
be a regression). A preference file written before this option existed
carries no key, so only an explicit false turns it off.

Negative controls run and confirmed red: caption back to a <div> (2 checks
fail), date never reaching the markup (2 fail), margin choice ignored for
a constant (3 fail).
…n purpose

The formula work reported this as a mechanical hook — four lines plus a
CellSource field. It is not. cellformula.ts takes a DOCUMENT, and store.order is
view state that store.ts:952 declares 'never in the document, never synced,
never undoable'. Wiring it makes the formula engine view-aware and two
collaborators with different filters compute different numbers for one cell.

Checked rather than assumed before writing that down: computed values are never
persisted, so nothing diverges in the file, and Excel does the same thing — its
filters are just shared state where dash's are not. So the answer is probably
yes. It is still a decision, and taking it silently while wiring a hook is how a
boundary gets crossed without anybody choosing to cross it.

The case that mattered — every imported Excel table arriving with a dead total —
needs none of it and works now.
All of 8-13 are done or deliberately declined, each with where and why. Two
notes worth keeping rather than deleting: two of the three print complaints were
already fixed when the report was written, and ROW/COLUMN are declined for a
reason that has nothing to do with cost — registering a function is what admits
it through the xlsx liveness gate, so a ROW() that cannot answer would import
live and paint #VALUE! over a number Excel had cached.
The list had gone stale in the direction that matters — it described intentions
where there were now observations, which is the rot its own header warns about.
Ten of thirteen open items are done; each now says where and what was measured.
Three remain, and none of them is code: the pack channel (deferred on purpose),
the update path that cannot be exercised until something is published, and
releaseFileHandle, which belongs in the serialised kernel zone.
…t notice

main gained #313 — the transport and session layers lifted from slides into
`kernel/src/sync/` — plus four spaces PRs. Two conflicts, both appends:

- `.gitignore` — BOTH rules kept. main ignores `.worktrees/` (per-session
  worktrees, kept inside the repo on purpose so the zsh hook picks the right gh
  account and /tmp cannot reap them); this branch ignores `.claude/worktrees/`.
  Different directories, both real.
- `docs/DECISIONS.md` — append-only by design, so a union.

THE INTERESTING PART IS WHAT DID NOT BREAK. `scripts/test-relay-protocol.ts`
guards that dash's transport and its twin agree on everything that goes on the
wire — signature texts, curve, hash, ?tok=, keepalive, room template — because
one deployed relay verifies both and a drift locks one app's users out of the
other's rooms.

It was written to FOLLOW the twin rather than pin a path, with the kernel lift
named in its header as the move it expected. That paid today with no
intervention: it now reports "comparing dash against kernel/src/sync/online.ts"
and passes 15/15. A guard that pinned `slides/src/sync/online.ts` would have
gone quiet on the exact day the code was most likely to drift.

60 rigs green, tsc clean, shell builds.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant