1ee1f26c0d3f6f12969d3b4edf985152271b4154
15 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d750b433b8 |
fix: the app bar is reserved, not fitted, and a message is a toast (improvise-n6t8)
The rule is the deliverable: nothing a user expects to be fixed may move.
Four children of <header> were sized by their own content and all four
changed on the SAME event, so the bar reflowed on a mode change with no
message at all: #mode (mode_label, 4-14 chars), #hint (hint_line, one
line per mode), #doc-name (gains and loses " [+]") and #view-name, which
had :empty { display: none } and so appeared out of nothing.
Every child that changes with a keystroke now has an allocated width, and
#hint is the single elastic child, so nothing is positioned by it. The
mode pill's reserve is a FLOOR and deliberately not a cap: a cap absorbs
an over-long label inside a box that never grows, which is a reserve that
hides its own failure. AppMode's exhaustive-match methods make the
compiler demand mode_label for a new variant; nothing checked the CSS
still fit it. harness/tests/header.spec.js is that check.
The spec found what the ticket did not predict: the compounding one was
not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so
a longer hint took the shrink out of #status, which WRAPPED to two and
three lines and grew the header by a row -- 52.5px to 71.0px. Reserving
the pill alone would have left that untouched.
ViewState::status_msg is now a toast in a position: fixed dock anchored at
its bottom, so it takes part in no layout and stacks ABOVE the minibuffer:
the prompt is neither covered nor displaced. composeStatus is gone -- the
two facts go to two elements, so a repaint reaches onModel and cannot
address onShell at all. That is gotcha 46's rule made structural rather
than kept by a join's convention; its unit test was replaced, not deleted.
Dismissal keys off message IDENTITY, not a timer. status_msg is STATE and
repaint() re-offers it on every revision, so a setTimeout alone resurrects
the toast on the next keypress. Cost stated rather than hidden: two
identical messages in a row show once, because nothing carries a sequence
and the alternative reading of an ambiguous offer is exactly the bug.
Nine of seventeen modes are reachable in a browser and the spec names them
individually; three injected faults are recorded in the evidence file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
0a920f1d54 |
fix: declare click_page_item on the wasm-surface ratchet
`web/tests/bundle/wasm_surface.test.js` pins the "handful of calls" the page reaches the client through, and its own comment says a new one has to come and say so. This is that. The reason it belongs on the list rather than above the seam: the page holds no `Basis`, so `SetPageSelection` cannot be composed there. It needs a built `web/pkg`, so it lives in `web/tests/bundle/` and is outside the `web/tests/*.test.js` glob the brief's gate names — it only ran in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
a913390322 |
feat: the page bar becomes a control, and [/] reach the modes about the document
Three tickets on one subject: paging.
improvise-rw6l — clicking a page chip opens a menu of that axis's items, with
a down-arrow saying so. Almost nothing had to be derived: `PageAxis::items` was
already projected and `ModelCommand::SetPageSelection` was already on the wire.
The one gap was that `RenderPageAxis` dropped the names, keeping the position
and count improvise-p1ym needed for `[`/`]`. `items` is relayed INSIDE `cycle`
rather than beside it, so there is one predicate and not two: a Filter axis has
no cycle, therefore no arrow, no menu, no items, and composes nothing if a click
reaches it — all four off `PageAxis::cycle`, the one home of gotcha 27's split.
improvise-771e — the browser tile bar said "Quarter Pag". `short_label` is three
characters so the TUI aligns in a fixed-width terminal (gotcha 26); a browser has
no column to fit. `render.rs` asks `plain_label` instead; `short_label` is
untouched. Note what pinned the truncation was `harness/tests/drill.spec.js`,
not `tests/grid_frame_agreement.rs`, which does not mention the tile bar.
improvise-wiew — `[`/`]` move from Normal onto a new `document_keymap` between
the global and the modes whose subject IS the document (Normal, the three
panels, TileSelect; RecordsNormal through Normal). Help and the import wizard
take the global directly. Per the user's ruling: an overlay has a subject of its
own, and these are the first shared keys that MUTATE the document — under the
help overlay that is an invisible change to the grid it is covering.
Boundary crossings to flag, both outside the brief's stated write scope and
neither a wire change:
- `resolve.rs` gains `Click::PageItem` and `lib.rs` a `click_page_item`
binding. Composing the command anywhere else would put a derivation in the
page, which design-principles §5 forbids; `Command`/`ModelCommand` are
untouched.
- `improvise-command/src/command/keymap.rs` gains three tests. The two
existing guards were the mechanism that forced the wiew argument, so they
had to be answered where they live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
8ad5c0806d |
feat(web): dismissable notice, a raised minibuffer, and a styled shell
Three things the user reported after looking at the deployed page, in the order they cost a visitor something. The deployment notice was ten dense lines of configuration reference — ?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the category tree and the grid on every visit: about 230px of an 820px window. It is now a <details>, so the collapsing is the BROWSER's (keyboard operable, and correct with app.js absent or still loading) and the only thing the page adds is remembering the answer. It starts COLLAPSED, and collapsing withholds nothing: the one fact a first visitor needs — the document lives in this tab, is saved to this browser's private storage and never leaves the machine — is the <summary>, which never collapses. What a stored value means is a decision and lives in shell.js; touching localStorage is a browser API and stays in app.js, wrapped, with every value that is not one of the two spellings read as "unanswered". The minibuffer was a 14px line in the page's bottom-left corner, up to 800px from the cell being typed into. It is now a raised bar over the grid. The CONTENT is untouched — RenderMinibuffer is built from `state.view.mode.minibuffer()`, the same semantic value the TUI draws — so this is position, weight and contrast, and there is no second derivation of what a prompt says. MinibufColor still resolves here exactly as draw.rs::minibuf_fg resolves it for the terminal. style.css stops being "legibility only": layered surfaces, one accent, a filled badge for the mode, chips for the two axis strips, one selection treatment, tabular numerals in the grid, and a focus ring on the one keyboard surface the page has. The palette is the landing page's, so the two read as one product. Asserted (harness/tests/contract.spec.js, Chromium): the notice starts collapsed and remembers, in BOTH directions across a reload; it opens from the keyboard, which the page's own keydown handler must not claim; the minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static bottom bar cannot satisfy — and is larger than body text. Each was proved discriminating by injecting the fault and watching it fail. NOT asserted, and named as such in context/code/clients.md: colour, contrast, spacing, weight, the grouped and multi-level header DOM, either theme, and the localStorage-throws path. Those are argued and read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
a93d668e5e |
feat(browser): paint the page bar and the totals strip, and delegate the pointer
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected to every session and folded correctly by every client, and NO front end read either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` — drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally off that same `page_coords`, so a user could page through quarters watching every number change with no way to tell which one they were looking at. `RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`. The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from `items.is_some()`, which disagrees with it on an empty item list (gotcha 27). The totals are RELAYED, never summed in the client: the sum runs over every row including ones a viewport scrolled past (gotcha 44). `web/tests/render_surface.test.js` gains the direction that would have caught this when improvise-n10 added the fields. The existing guard is closed under what `RenderModel` chose to expose, so a cache field that never reaches it is invisible to it; the new one asks that every `pub` field of every widget cache is read somewhere the browser runs, or is named as debt with a reason. `grid.col_widths` is the one entry, pointing at improvise-pcje. improvise-b8ot: the five clickable surfaces moved from one listener per node, reallocated inside the render loops on every repaint, to one handler per stable delegation root — the user's ruling. The operands are carried on the node as data attributes and read back by a pure `cellAction`/`indexAction` in `web/shell.js`, so the handler is a LOOKUP rather than a second derivation of which data row was clicked: `gridPlan` counts data rows because a group header is not one, and walking the table back up would disagree the moment one appears. The editor's own keydown/blur stay direct — one transient node, one at a time, and `blur` does not bubble. All four documented behaviours a naive delegation loses are preserved and each is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click (asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell, the conditionally skipped repaint (asserted by the editor's survival), and closing a live editor before composing the click. Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595); node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed / 4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
12714c7527 |
feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.
A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.
- improvise-1wft the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9 read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw start() rejecting for any reason now posts BootFailed
- improvise-9cma boot::document_name -- and a/b/c.improv was ACCEPTED as
c.improv, not rejected, which is worse than the report
- improvise-iobc vfs::Origin: a seed error is a notice only when the
document really had to be created without it
- improvise-vep8 the watchdog arms BEFORE transport.ready; its message
stopped claiming the connection was accepted
- improvise-7gip DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1 a persist that fails raises a notice; the trigger is the
dirty flag surviving, observed rather than predicted
- improvise-7gf8 the render breaker trips once instead of retrying forever
- improvise-ociy non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k the three ws-server drops are logged
- improvise-r4nf serde failures throw instead of reading as "nothing to send"
Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
3542d6f717 |
fix(tooling): a leaf is not an identity, and a convenience flag was a bypass
Both blockers from the adversarial review of #83, both in the mode this PR rewrote, both producing a pass while the gate did not do what its prose said. 1. cargo_test_names reduced every listed path to its last segment. Six leaf names collide in this workspace and two are cited in evidence, so a heading was satisfiable by a test in another module answering another question -- renaming ui::project::tests::a_load_that_fails_is_not_a_rebase, the one its entry describes, left the checker at exit 0 with the ui::host namesake answering for it. Paths are kept whole; a bare leaf resolves only when unique, and an ambiguous one is REFUSED with both candidates named. _RUST_NAME accepts `::`, so a qualified heading is a claim rather than prose, and a citation is matched on the leaf so qualifying a heading cannot orphan its own test. The two colliding entries are qualified. Neither half suffices alone: rename one of a colliding pair and the leaf becomes unique again, which is why qualification is the fix and the refusal is what forces it. 2. --rust-tests called cargo_test_names with one argument, so the --ignored subtraction never ran, and it printed a byte-identical success line. It now requires --rust-tests-ignored (one alone exits 2) and the line names its source. Also, from the same review: a missing context/test-evidence/ exited 0 while 170 citations pointed at nothing (early return, now gone); the doc-site "runs last" check accepted a trailing `echo` that merely NAMED the generator, so it asks for an invocation; bundle_freedom gains an arm for a transitive `import "../app.js"`, which reaches the bundle while naming nothing forbidden; and two prose overclaims are corrected -- the prose-heading count is a convenience rather than a guard (git diff is the mechanism), and the ratio rule says "above the run MEDIAN", which is defensible where "above the others" is false against second place. scripts/check-ci-graph.py makes claim 6's rule a gate: pages-deploy must `needs` every other job in ci.yml. The rule was stated in the last commit and a zz-new-check: job added during review was still ungated with nothing noticing. README now states the rule and lists no jobs, the second copy being what went stale twice. Every fault re-injected and observed; figures in context/test-evidence/tooling.md rather than only here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
29cdab1a4a | Merge remote-tracking branch 'gh/main' into worktree-agent-a97453f7fa65d51b9 | ||
|
|
a69b0bca86 |
test: the third edit of improvise-0x9u's repair, and two evidence corrections
`close` restores the DRAWN cell text rather than the seed, and reverting that one line left the whole suite green while `18400` sat where `18,400` had been on the shipped demo. Asserted after the second click, proved by that revert. improvise-o31u's desktop arm cannot fail for the reason its evidence gave: both named mechanisms were measured and neither moves it (`#cast-pivot` is 966px at 1280, so an 880px floor is inert wherever it is placed). The entry now says it is forward-looking, and credits the phone arm with the fault it actually catches. Plus three one-liners: UNCALLED's source-scan blindness, the gzip -9 proxy in the improvise-cl7y table, and why `view-back` is not one of its five siblings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
8af37d44a1 |
feat(ci): two conventions become gates (improvise-w8ih, improvise-4ktt)
The stamp gate is scripts/assemble-stamp.sh, taking FILE PATTERN pairs, and scripts/assemble-stamp-test.sh is its check_the_checker. Deleting the final `test -z "$missing"` makes four of its eight cases fail; before the extraction that same deletion was invisible, both misses printing while assemble.sh exited 0. It is a separate script because exercising it inside assemble.sh would need a fabricated web/pkg, which is a second answer to "what does the site assemble from" (improvise-vvl3). Wired into the `build` job, first, because it needs nothing and guards a step in that job. web/tests/bundle_freedom.test.js refuses a flat web/tests/ file that names the bundle directory -- the constraint keeping the `js` job at seconds rather than ~4m30s, stated in prose and checked by nothing. It scans its own filename too, so it cannot become the place a violation hides, and its companion asserts web/tests/bundle/ still holds a test that DOES use the bundle: a scoping rule is uninteresting if nothing falls outside it. Both faults were injected and watched to fail; evidence in context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
628f43121d |
feat(web): the title bar is painted, a click survives an editor, and the twin door is gone
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with one shape: a thing that looks done from every check except running it. improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING SEARCH over the page's source, so a field named by a function nothing calls reads as painted, which is also how `model.minibuffer` shipped dead. So the guard is a harness assertion that loads the page and reads `#doc-name`, proved by writing it first (`element(s) not found`). The joining is `shell.js::documentTitle`, the TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a harness one, because this deployment persists on every accepted batch and clears the flag in the same round trip, so the marker is transient. UNPAINTED is now empty. improvise-0x9u. From an EDITING state a cell click did nothing at all: the mousedown moved focus, the input's blur ran `close`, `close` repainted, `replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click` was ever dispatched. The old spec pressed Escape between the two clicks, so it only ever clicked from a non-editing page — this repo's characteristic defect. Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the first attempt failed the same assertion with the same symptom. Removing a focused `<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside itself and detached the `<td>` again. `close` therefore no longer repaints, the `blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus change happens at all — otherwise the default action focuses `#grid` after `input.focus()` and blurs the new editor. `close` restores the DRAWN text, since nothing repaints behind it any more. improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept for a reason `web/app.js` falsified in the same wave. It is one letter from `set_viewport` and fails SILENTLY — identical frame, forgotten window, every cursor key an upstream black hole. Deleted, along with the exported `init()` free function that duplicated `new Client()`. The guard is not the deletion: `wasm_surface.test.js` now reads the generated `.d.ts` and requires every declared method to be one `app.js` calls or to be named in `UNCALLED` with its reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts` and watching it fail by name. Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium harness (was 23). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
ec03f950b3 | Merge remote-tracking branch 'gh/main' into fix/web-panel-clicks | ||
|
|
57a25807af |
Merge gh/main (e8562f0) into fix/web-panel-clicks
PR #61 landed while this branch was open. It deleted `harness/tests/pointer.spec.js` and `harness/tests/widgets.spec.js`, split `web/app.js` into app/shell/serial, and added a Node suite under `web/tests/`. Two conflicts, both resolved deliberately rather than by reflex. **`harness/tests/pointer.spec.js` — modify/delete, resolved as DELETE.** The habitual "keep both sides" would resurrect a file #61 removed on purpose. `contract.spec.js` states its own scope as the RenderModel→DOM boundary and the pointer's half of the JS→wasm one, so that is where these assertions belong. Merged in, rather than restored: - the category sidebar is open at launch and `C` still hides it; - a first tile click selects and does NOT touch the document, a second cycles; - the view row and formula row clicks improvise-iioi added, which `contract.spec.js` had no entry for at all. **`context/test-evidence/clients.md` — content, resolved as BOTH SIDES.** The two `pointer.spec.js` subsections are retargeted at the file that exists, with a note saying why restoring the deleted one would have been wrong. The two older `#### … the file's own scope` sections keep their history and gain a forward pointer, because a reader following them would otherwise hunt for a file that is gone. **`web/tests/bundle/wasm_surface.test.js` now names `click_formula` and `click_view`.** That test is an exact `deepEqual` over the page's `client.*` calls and its comment says a new call "has to come and say so". It did: it failed on the merge, which is the mechanism working. It could not have been satisfied earlier — it did not exist when this branch was cut. Two review findings fixed on top, neither a live bug: - **The tile guarantee was overclaimed.** `T` leaves `tile_cat_idx` at 0, so a click on the LEFTMOST tile cycles on the first gesture. "A click never mutates on its own" and "a second click cycles" are both false against that probe. The behaviour is right — a click does what `Enter` does there — so the prose now states the implemented rule, and `t_then_a_click_on_the_leftmost_tile_cycles_it_immediately` names the case and asserts the neighbouring tile answers the same gesture differently. - **`switch_view_at`'s `Option` had no guard on the keyboard side.** Removing the `unwrap_or_default()` makes `Enter` in an empty view panel `Upstream` — gotcha 47's black hole — and passed the whole workspace. That would make membership of the host-needed list state-dependent, which is the one property that list denies. One test closes it. Gates on the merged tree: 1391 Rust tests, 26 + 3 Node tests, 16 Playwright (3 WebKit OPFS skips), clippy -D warnings, fmt, harness fmt + wasm32 clippy, eight wasm crates, comment runs and 513 evidence entries clean. `.beads/` matches gh/main exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9863d96eef |
fix(harness): restore the close-routing guard PR #61 dropped, on a faked clock
Adversarial review of PR #61 found one real coverage loss, and it is the defect class that PR's own body names: an assertion that survives the deletion of the thing it was about. `shell.spec.js` lost "a socket that opened and then dropped reads as disconnected, and stays that way" and both MutationObserver negative assertions. Reverting PR #21's routing half in `web/app.js` — the socket `error` and `close` arms calling `setShell` instead of `fail` — left the node suite 26/26 and Playwright 13/13 green. `shell.test.js` cannot cover it, and why is the whole boundary argument: "a loud failure DISARMS the watchdog" tests `createShell` in isolation and cannot see whether `app.js` ROUTES the close event through `fail()`. That routing is the half gotcha 48 says keeps getting lost; this was the third time, after two earlier mis-confirmations that simulated the `opened` flag and not the routing. Both tests are back under their original names, so the two `####` headings in context/test-evidence/clients.md name live tests again. They cost ~75ms each rather than ~9s, because `page.clock` fakes the PAGE's timers: the page, the socket, the listeners and the `setTimeout` call are all real and only the passage of time is simulated. That is not a weakening, and the injections are the proof in both directions — if `runFor` did not advance the page's timers, a broken routing could not surface the watchdog's message and they would pass: - both arms reverted to `setShell` -> "…and stays that way" fails - close arm only -> "…and stays that way" fails - close arm drops the `opened` flag -> "…the watchdog stays out of it" fails Three injections, three outcomes, none of them the unit test. The helper guards its own premise, because it turned out not to: `runFor` without `clock.install()` does nothing AND throws nothing, so a test that forgot the install asserted "the watchdog never spoke" against a watchdog that never ran — 6/6 green. `outlastTheWatchdog` now requires the page's own `Date.now()` to have moved, and that omission fails by name. Also: `render_surface.test.js` matched `model.<field>` over source INCLUDING comments, so un-painting a field and mentioning it in a comment passed — demonstrated against the pre-fix version, then fixed and re-demonstrated. And the PR's speed claim is corrected where it is documented. The eleven deleted per-widget tests cost 3.3s between them against 3.4s for the three contract tests replacing them: they were free. Essentially all of 36.9s -> 15.5s was two real-timer waits, one of them this guard. The wait was the cost, not the test. Filed, not fixed: improvise-igxp (the help overlay's rows are asserted as present, never as content — inherited verbatim from the test contract.spec.js replaced) and improvise-4ktt (the js job's bundle-freedom is a shell-glob convention, not a gate). The `####`-vs-`###` blind spot in --check-evidence, which is why the dangling heading survived the gate, is a comment on improvise-r0qa. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
36ade35240 |
test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them (user, 2026-09-20). The browser half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS — and everything between them needed no browser at all. `web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every question `gridTable` used to answer while creating elements — the data-row index across group headers, what a group header spans, a null group label against an empty one, an empty edit buffer against no editor — and `app.js` is now a loop that builds elements from it. Painting stays untested by Node; what to paint stopped being untestable. 26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle, no npm install), plus three in `web/tests/bundle/` that read the generated .d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to 16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone, replaced by `contract.spec.js`, which asserts that EVERY field the render model projects reaches the screen. The four widgets those specs were written for were one failure at one boundary, and one assertion catches the fifth. It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element (improvise-5jna, already open). They are named in `UNPAINTED` with the ticket, so the list is the debt written down rather than an exemption. Thirteen faults injected into the Node suite and eight into the served page, each watched to fail the test it names; the last of those was strengthened because the first version passed. `web/assemble.sh` grew an import-completeness guard whose own first version was blind to the multi-line import it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |