29cdab1a4a049d260dc966bca132a08f180a50e1
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3f8f7cb765 |
refactor(core): evaluate_aggregated takes Aggregation, not a list it cannot read
The parameter was none_cats: &[String] and the body consulted it exactly once,
for emptiness -- a boolean wearing a list's type, since the aggregation runs
over key.0 through matching_values. git log -S back to the commit that
introduced Axis::None shows it never examined an element, so the name promised a
filter the function has never performed.
Aggregation::{Exact, OverHidden} admits only the two states the body
distinguishes, and Aggregation::of reduces the list once at the boundary that
holds it. The regression guard is the signature: two lists with the same
emptiness and different contents is no longer a constructible call.
recompute_formulas and aggregate_raw keep &[String] -- they walk the elements.
improvise-5jm5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
7d25762725 |
test: the worker's dirty-marker test could not see the widget vanishing
Re-review finding, low severity and correct. The test asserted only `!document.dirty`, and `DocumentCache::default()` is already clean — so it could not tell "told it cleared" from "never told anything", and passed under an injection that made `document_update` always return `None`. It is the test that names the browser deployment, which is the one place nothing ever sends ClientFrame::Save, so it is the only guard on that path. It now records every marker the client is told and asserts `[true, false]` for the edit. The bootstrap seeds its own clean marker first, so the sequence is cleared after the handshake and the assertion is about the edit rather than about the connect. Verified failing under both injections: dropping the worker's re-projection gives `[true]`, and stopping the widget being projected at all gives `[]`. Declined, with the reason: a test for `GridLayout::record_cell_key` returning a key for a `RecordCol::Category` column. The injection the reviewer describes would make a coordinate column project `Derived` where a formula owns that coordinate — and the reviewer's own harder case established that a formula targeting a regular category SHOULD project `Derived` there, in both modes. So the injected behaviour is not clearly a fault, the exhaustive match already forces a decision on any new `RecordCol` variant, and a test would be pinning an answer nobody has argued for. Recorded as a known gap rather than guarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24d9f61e35 |
fix: improvise-ir7l was not closed in records mode, and a save told no client
Two findings from the adversarial review of #51, both demonstrated before being fixed and both re-proved by injection afterwards. FINDING 1 — a formula-owned cell was still a writable Record in records mode, and my own guard test's written premise was false. "A formula target lives only in measure_item_names" does not hold: an item can be BOTH an explicit _Measure item and a formula target, and improvise-poia in this same PR made the `+` column that builds one a single keystroke away. Type `Profit` into `+` on a document that already has `Profit = Revenue - Cost`, and the column projects CellId::Record; `999` commits, is stored, and is then shadowed forever by formula_cache — the exact defect ir7l exists to close, in both front ends. The question is now asked of the record's REAL coordinate. GridLayout::record_cell_key is the one mapping from a records position to a model cell; cell_id and the new CmdContext::owning_formula both go through it. The synthetic (_Index, _Dim) key names no measure, which is why EditOrDrill's guard could not fire there. typing_into_a_records_formula_column_is_refused_on_the_key_path drives the reviewer's repro through App::handle_key with no test API. It exists because the projection half and the EditOrDrill half are separately breakable — injecting each alone fails a different assertion. FINDING 2 — DocumentCache::dirty never cleared on a client. Both save paths cleared model_state.dirty OUTSIDE a projection: Host::save returned only a possible Rejected, and Worker::on_frame built its frames and persisted after. So after the user's last keystroke a browser showed [+] on a saved document, indefinitely — which is the failure mode protocol.md's own z754 section names as the thing the design avoids. The z754 test that should have caught it called sessions.project_all by hand, which is exactly the call both production paths omit. Not a test comparing a thing with itself; a test exercising a call the deployment never makes. The new ones go through Host::handle and through the worker's real message loop. Also filed improvise-8ec3: the reviewer measured improvise-io's test binary at 3.7-5.2s where this machine reads 1.4-1.7s, so the 2s per-binary rule is breached on some machines — on main too — and the figure design-principles §7 pins is not reproducible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f14b2a39e |
fix(protocol): a formula-owned cell projects CellId::Derived, not Record
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether a regular category sat on Axis::None; it never asked whether the coordinate named a formula target. So a formula cell was a Record, render.rs::editable said true, and a write to it landed, was stored, and was then shadowed forever by formula_cache — not a revert, a permanent silent shadow, on the demo's front page. Rather than a second "but not really" flag beside CellId (the two-answers shape ADR 002 exists to forbid), a formula-owned cell now projects a different kind of identity. Derived carries the formula's target and no surrogate, so the write is unconstructible rather than refused. The predicate is Model::formula_for(&CellKey), one home, which already existed three times open-coded inside model/types.rs. The TUI lands in the same change: EditOrDrill calls the same predicate and refuses, or the two front ends diverge. Two orderings are load-bearing and tested: Derived is asked before the record branch (the coordinate is stored), and after the aggregate branch (gotcha 10 — an aggregating formula cell is still drillable). Two tests were passing while their names were false for the Profit column and are renamed and two-sided now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
625f4acb80 |
docs(worker-server): move the browser-host argument to context/code/clients.md
The 121-line module doc on worker.rs, vfs.rs's seeding and flat-root rules, bindings.rs's async-seam argument and the crate lib.rs coverage statement now live in context/code/clients.md; the four bug narratives in the tests move to context/test-evidence/clients.md, keyed by test name. No behaviour change: comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f12a2d3517 |
docs: reconcile the docs with the code after wave 1
An independent review checked every factual claim in the docs against the code at |
||
|
|
301a74821a |
feat(protocol): the grid's frame on the wire, as AxisFrame
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by `GridLayout::row_label`, which slash-joins a multi-level tuple — the exact rendering `two_row_categories_produce_cross_product_labels` asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely that form: the browser showed `Food/Alice` where the TUI showed two header columns, with no groups and no collapse indicators (gotcha 44). Both fields become one `AxisFrame` per axis, carrying the five facts gotcha 44 enumerates: the categories (hence the header levels), the label *tuples* and the inline group headers, the group a position sits in, and each group's collapse state. `record_col` stays off — session-local, keys pending drill edits, correct to keep off. It is one type and not a `row_levels` beside `row_labels`, because two answers to "what are the labels" is the disease (improvise-edp). The rules that used to be written twice are methods on it and are now called from both front ends: `shows_level` (repeat suppression, improvise-jjx's fix), `group_of`, `body_from`, and `header_widths` — the row-header width rule, which existed twice already, inline in `render_grid` and again in `viewport::compute_row_header_width` (improvise-1125). `GridWidget` loses its `&View`: nothing in it reads the view any more. Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization over a grid with row groups, column groups, a page axis, multi-level headers and a total row — passes byte for byte, so the TUI is unchanged. The new `tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and the browser client through a `Snapshot` of the *same* cache, and compares what each draws; re-introducing the slash-join on either side was injected and watched to fail before the test was kept. `tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is deleted: it guarded the gap this closes, and a consolidation that keeps its compatibility test is unfinished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
12febcf73b |
Merge: the client resolves its own cursors (improvise-bpq2)
Keys become commands in the browser; the server must not know the keymap. What made it urgent is not latency. Host::handle answers ClientFrame::Key with an explicit empty vec, so an upstream key is a BLACK HOLE, not a slow path -- every KeyOutcome::Upstream names a binding that does nothing at all in a browser. And a_key_resolved_on_the_host_moves_state_no_client_can_hear still holds: bpq2 closes the divergence by AVOIDING host-side resolution, not by contradicting the property. Three costs. GridExtent on GridCache -- the whole grid, of which the labels are one window -- because a viewport-indexed cache cannot tell 'the last visible row' from 'the last row'. The window is the client's OWN fact, recorded from what it announced, because row_labels.len() is the window CLIPPED and so differs exactly at the bottom edge where scroll arithmetic matters. And ClientFrame::Commands replaces the singleton, because one cursor key is SetSelected plus the offsets viewport_effects returns. Two corrections to the ticket's own gap list: panel cursor movement was never a gap (improvise-edp already made flatten_cat_tree the one home both ends call), and the unmodified-characters rule was MIS-SCOPED rather than wrong. Each resolver mirrors its Cmd line for line, including where siblings disagree about a no-op move. Tidying that into one rule would make the client a second derivation, which is what lockstep testing exists to rule out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
c848d93255 |
refactor(protocol): ClientFrame batches, because one keypress is several commands
A cursor key resolved on a client is `SetSelected` plus the scroll
offsets that follow it (`viewport_effects`), and a host that folded the
selection but not the offset projects that client the wrong window. They
must arrive whole, so `ClientFrame::Command(Command)` becomes
`Commands(Vec<Command>)` — the same argument `ServerFrame::Commands`
already carries, and the same conclusion: `Commands(vec![c])` expresses
everything a singleton variant could, so there is one variant, not two.
`ClientFrame::command(c)` is the constructor every existing call site now
spells.
`Host::commands` applies a batch in order and abandons it at the first
rejection: the commands after a refused one were composed against a
picture the document has just contradicted, so applying them invents a
basis nobody claimed. That does leave a hole ADR 001's "never applied,
not even partially" does not have — a refused batch keeps its earlier
commands — which no client in this workspace can reach and which is
stated on the variant rather than hidden.
`the_retired_spellings_do_not_parse` grows the client half. It is the
sharper case of the three: the singleton was this crate's own spelling
until this commit, and `{"Command":{...}}` differs from
`{"Commands":[{...}]}` by a pair of brackets — exactly what a permissive
alias would paper over. Both new host tests had their fault injected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
a550201fae |
feat(web): the page spawns the worker, opens a demo, and reads a key
Three pieces of the standalone deployment, all in web/.
improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.
improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).
One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.
improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.
Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
bdce1636bb |
feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS
improvise-9x6: the server half of the standalone static-web deployment (epic improvise-tm6). `improvise-worker-server` hosts the authoritative `App`, the command pipeline, the projection layer and VFS persistence behind `self.onmessage`/`self.postMessage`. It is small because everything it needs already existed: the frames are `improvise_protocol::frame`'s (improvise-xks6), the session loop is `Host` (previous commit), the basis check is `Sessions::apply`/`Checked` (improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq, i34). What is genuinely new is three things. `vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is unchanged: it is `persistence::load_from` on a `Storage`, the same call the native binary makes. Seeding is the new problem, and it is a `Storage::write`, so a fetched demo, an upload and a saved document take the same parse path and a seed never clobbers an edit. `list` is the document picker a browser has no OS file manager for. `worker.rs` — the loop: one port, one client, the `Viewport` handshake (dropped rather than closed, since there is no connection to close), and the filter from `Addressed`. Every decision is here and every test points at it. `bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and forwards strings. Two gaps found and reported rather than papered over. No client on any transport can ask its host to save — `effect::Save` is `ViewOnly`, so the wire vocabulary derived from the model effects cannot name it — and a wire `ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is raised by a separate `MarkDirty` effect the key path emits. So the worker persists on its own behalf, keyed off the command variant rather than off a flag that is never set; `touches_document` carries the argument. `ClientFrame::Key` is still answered with nothing. A worker CAN call `reduce_key` — it holds the `App` — but nothing in the projection stream carries the host's own `ViewState`, so a half-wired arm diverges rather than under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear` demonstrates it against a real `App` and `Sessions` (improvise-bpq2). 22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`, which drives the worker with nothing but the JSON a `MessagePort` carries. Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |