bento/dash: the release blockers, two kinds of sheet, and an Excel bounce test - #323
Open
nyblnet wants to merge 117 commits into
Open
bento/dash: the release blockers, two kinds of sheet, and an Excel bounce test#323nyblnet wants to merge 117 commits into
nyblnet wants to merge 117 commits into
Conversation
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Everything in bento/dash since
dash-v0.2.0: the release blockers, the two sheetkinds, 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
§0ofdocs/dash-release.mdis clear, and ten further items closed here: filewrite-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 thereare.
kind:'canvas'is a SPREADSHEET — typed by cell, sparse, unbounded. Thebridge 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.mdexplains 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
textand advises "set the column type once youhave decided what it is". Doing that moved
col.typeand nothing else — so thegrid 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 returnscorrectly 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 Iran the same hour was wrong twice in twenty lines. Fixed in
store.ts(convertthe storage, refuse what will not convert, and carry the pre-conversion bytes on
the inverse so undo restores them) and in
validate.ts, becausestore.tscanonly 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 banningraw
fetch/WebSocketoutside it. dash was converted in that PR rather thanexempted from the scan. The UI half was branch-local and is fixed here —
setOfflinereturns 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.tsfails if any rig is not actually run by CI —which it found five of on its first run.
Known and open
by running the new fuzzer against the pre-branch engine and getting the
identical failure. ACTORS=4 is clean at 27,667 checks.
SUBTOTALinside a cell formula does not yet see the viewer's filter. Theengine half is done; wiring it makes the formula engine view-aware, which is a
decision rather than a hook (
docs/dash-excel-gap.mdfinding 18).AVGover 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.