955c30a8641a8f7a726d130880a20cdf29f3ff81
22 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
955c30a864 |
fix(web): land the review findings main is missing
Repair-forward for the accidental push at
|
||
|
|
959f249c4b |
feat(web): a click is resolved in Rust, and the sidebar obeys its flag
The user's rulings on the two beads PR #55 filed. improvise-7ybz: the browser was keyboard-only by omission. Nothing outside an editable grid cell had a click listener, so clicking the collapse triangle on a category did nothing — which is what "I can't expand categories" meant, since the keyboard path worked all along. `improvise_protocol::resolve::click` now answers all three widgets under one rule: **go there, then do what Enter does there.** A category row focuses the panel, moves its cursor and activates (expand on a header, filter on an item); a tile selects and cycles its axis; a cell moves the selection, with the page's inline editor as the pointer's `i`. One rule rather than three inventions, and it is the keyboard's own definition rather than a second one — `cat_tree_entry`, `toggle_cat_expand`, `filter_to_item` and `tile_axis` each gained an `_at` twin and the cursor-reading form is now a one-line call of it. It lives in the protocol crate, not the wasm client, for the `flatten_cat_tree` reason: a third front end must call this, not derive it. `Client::click_category` / `click_tile` / `click_cell` are thin wasm-bindgen wrappers that fold and serialize exactly as `key` does, so `app.js` still composes no `Command`. The subtlety worth knowing: a click's three intents are resolved in one pure call, so the activation must be asked about the row *clicked*, never about `cat_panel_cursor` — the `SetPanelCursor` ahead of it has not been folded yet. Injecting that exact fault turns `ToggleCatExpand("Product")` into `ToggleCatExpand("Region")`, which is the silent half and is now pinned. improvise-wsaw: `RenderModel::category_panel_open` existed from the start and `app.js` never read it, so the sidebar was drawn unconditionally and `C` moved only the mode label. It now hides on load and toggles with `C`, matching `src/draw.rs:322` and the two panels PR #55 added. A first-time visitor sees one fewer thing; the user chose front-end consistency, and the grid is still populated. Tests: harness/tests/pointer.spec.js, six assertions, **all six proved by running them against the bundle built immediately before this commit** — where the sidebar is wrongly visible and every click lands on an element with no listener. Plus four Rust tests on the resolver, one of them proved by the injection above. Note this branch is based on gh/main but MERGES fix/web-ui-correctness (PR #55): click_tile needs a tile bar to click, and that is #55's. Rebase or re-merge once #55 lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6f9cd6cad5 |
fix(web): the tile bar, the view panel and the formula panel are drawn
A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.
Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.
The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.
So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.
The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).
Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.
Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
414d02c4a6 |
feat(protocol): one home for UI strings (improvise-x7sw, ktv1, k099)
Three tickets asked one question — where do UI strings live relative to a
serde wire enum — and answering it three times is the disease this unit exists
to cure. One answer, in `improvise-protocol/src/help.rs`:
**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label`/`hint_text` are gone;
`help::mode_label`/`help::hint_line` replace them, so the enum a client
deserializes carries no English at all (improvise-k099). **And wherever a
string names a key, the key is looked up rather than spelled**:
`Hint { keys, command, caption }` takes `keys` off `Keymaps::default_keymaps()`,
`command` is the semantic value a localized front end keys off, and `caption`
is the default English — the `AxisColor`/`axis_fg` shape.
- **improvise-x7sw**: `help::which_key` is the which-key popup's rows, keys and
captions together. The client already holds the keymaps, which is the answer
the ticket preferred, so `ViewState::transient_prefix` stays an address.
- **improvise-ktv1**: the help pages are data — `help::pages()` — with every
key row derived. `src/ui/help.rs` keeps its ratatui styles and loses ~310
lines of content; `web/app.js` paints rows it does not author, and
`harness/tests/shell.spec.js` asserts that `?` really draws them.
**What this deletes is the measure of it.**
`every_hint_key_resolves_in_its_mode` and its 60-line hint tokenizer are gone:
they existed to hold a hand-written string honest against the keymap, and the
string is derived now, so the property is true by construction
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
`Keymap::binding_hints` goes with them — `which_key` is the one walker, and the
binding dump reads it. What replaced the test checks the *other* direction, a
mode advertising a command nothing binds, which was never checkable before.
Two defects the derivation exposed, both fixed here: `key_label` dropped
modifiers, so Ctrl-arrows read as bare arrows, and `Char(' ')` printed a literal
space instead of `Space`.
**What is deliberately not derived**, and says so: the import wizard's keys
(its whole keymap is one `Any` binding into `HandleWizardKey`, which decides
per step inside the command — a real hole, left visible), `gg`/`yy`/`ZZ` and
the ex-commands (`HelpLine::Literal`), and the captions, which no test can
judge. `context/code/protocol.md` §8 has the argument.
Cost, stated: the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists on the Panels page lost their visual indent.
That is the price of the keys being true.
Evidence: 1321 tests pass, and the 7 Chromium shell tests pass against the
assembled site. Two faults were injected into the help overlay — dropping the
`paintHelp` call, and removing the `mode != Help` guard so it is never hidden —
and each fails the new spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3d3bec43e0 |
docs(web,harness): cap the JavaScript and the browser harness
web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two ordering rules — including the one nothing here can test, that an `async` message listener does not serialize — are now prose in context/code/clients.md. The harness keeps its own section: why a worker, why a report, the engine coverage and what `wipe` deliberately cannot clear. harness/tests/*.spec.js's comments were the record of what each assertion proves; they are in context/test-evidence/clients.md under the exact test titles, including the `test.fail` measurement that could NOT become a CI guard because its trigger is the host's network stack, and the closed-invalid improvise-xps8 report that a simulated fix appeared to confirm. ws-server/src/main.rs's ```text fence was prose claiming the doctest exemption, not a runnable doctest; the synopsis is now two lines and the full form is in the prose file. No fences remain in this unit. Verified: node --check on every JS file touched; the non-comment diff for both '*.rs' and '*.js' is empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
22360d46e5 |
Merge main into wave/a-grid-frame
Two conflicts, both expected.
**`invalidate_relabelled` (improvise-hkt6, WU-K).** Both sides kept: the
signature and the item-tuple comparison are this branch's — `old` is an
`AxisFrame` now, and a group header appearing or collapsing must not blank
cells it did not rename — and the totals blanking and its whole argument are
main's. `grid_updates` still diffs the totals against the *framed* mirror,
which is what makes a renamed column whose total did not change send one;
`a_relabelled_column_drops_its_total`,
`a_relabelled_row_leaves_the_totals_alone` and
`a_renamed_column_whose_total_did_not_change_still_gets_its_total_back` all
pass against the new frame.
**`lib.rs`'s composition test.** Main's deletion taken:
|
||
|
|
d9a4790d4d |
fix: an empty group name is a name; and stop reserving the group rows
Copilot's seventh pass. One applied, one applied by *withdrawing* the previous fix rather than patching it again. **`RenderColGroup::label` is an `Option<String>`.** `""` was the ungrouped-run sentinel, but an empty string is a legal group *name* — `Category::add_item_in_group` accepts one and the TUI draws its indicator like any other — so a real group serialized identically to no group at all and the page dropped its indicator on a truthiness test. `None` is now the run in no group, and `app.js` checks for null. **Inline group rows are no longer reserved in the viewport, and the reason is written down.** Copilot was right twice in a row here, about two opposite mistakes. Counting the current group rows is a feedback loop (a smaller window crosses fewer groups and asks for a larger one). Keeping the maximum is a ratchet: one group header per data row stores an oversized cost on the first oversized projection and clamps the grid to a single row *permanently*. Both were on this branch. The third option — invent a density heuristic — is the one this repo warns against, in a file no test here can execute. So the reservation keeps only what depends on the axes and not on the window: the header lines, the group line, the row-header width. The residue is an over-request bounded by the groups in view, and the off-screen-selection gap it leaves is neither new nor specific to groups — nothing in this page calls `scrollIntoView` at all. Filed rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fb2122f23d |
fix: commit the high-water mark unconditionally, coalesce groups by name
Copilot's sixth pass; both correct, and the second is a front-end divergence of exactly the kind this PR exists to remove. **The high-water mark was committed only when the extent changed.** A group count that rose while a header line fell by the same amount left `next.rows` equal, so the larger count was discarded and a later repaint under-reserved — the DOM-scroll bug again. `frameCost` is now assigned unconditionally and only `sendViewport()` is gated on the extent moving. **`col_groups` started a new run when `collapsed` differed; the TUI does not.** `src/ui/grid.rs` breaks a run on `group_name != prev_group` alone. Collapse is category-scoped, so two adjacent groups from different categories may legally share a name and differ in it, and the browser would then have drawn a second indicator the TUI suppresses. Coalesced by name, with the run taking its first group's indicator, as the TUI paints it. `column_groups_coalesce_by_name_as_the_tui_does` pins it; restoring the `(name, collapsed)` key splits the span and fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ff351ee014 |
fix: reserve the group rows monotonically, and bulk the ancestry compare
Copilot's fifth pass; both correct. **Inline group rows are back in `frameCost`.** Omitting them was the same failure I had just fixed for the header block: they take real table height, so any viewport containing one asked for more data rows than `#grid` could show, and the DOM takes its own scroll position while the client's cursor offsets come from the larger window. Copilot's own suggestion answers the oscillation objection — the reservation is a **high-water mark**, which cannot oscillate because it only rises, and it rises in the safe direction: over-reserving leaves blank space, under-reserving is the bug. It resets on a view change so one dense document does not make every later view pessimistic. **`same_prefix` is replaced by `AxisFrame::shares_ancestry`**, the bulk form, for the same reason `header_labels` and `groups` are bulk: called per (column, level) it scanned the frame twice apiece, quadratic in the columns drawn and growing with the horizontal offset, where the pre-anr9 widget's materialized lookup was O(1). `same_prefix` is **deleted** rather than kept beside it — two answers to one question is the thing this PR exists to remove, and keeping a per-position form that nothing calls would be an invitation. `shared_ancestry_widens_as_ the_level_rises` checks both arms, because a version comparing whole tuples passes the leaf arm and a version comparing only the outermost label passes the outer one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9aef855d5f |
fix: the viewport reserves the real frame, and a fallback width counts cells
Copilot's two "previously missed" findings, both correct. **`extent()` reserved one header row and one header column.** That was right until this PR: the column header is now one line per level plus an optional group line, and the row header is `row_levels` columns wide. Announcing a viewport the page cannot show matters because `#grid` has `overflow: auto` — the DOM would take a scroll position of its own while the client's cursor offsets were computed from the larger window, putting the selected cell off screen. The cost is measured off the painted model and re-announced when it changes. Inline group *rows* are deliberately **not** counted, although they occupy space: they depend on how many rows the window asked for, so counting them would be a feedback loop — a smaller window crosses fewer groups and asks for a larger one. The three facts that are counted depend only on the axes. The residue is a little over-fetch, the direction `ROW_PX` already errs in. **`fit_to_labels` measured its fallback width in Unicode scalars.** It is documented as the width a column is drawn at while `ColWidths` catches up, and the authoritative widths are in display cells, so `日本` came out two cells wide where it needs four. Now measured with `UnicodeWidthStr`, like `header_widths` and like the TUI. `a_column_without_its_width_yet_is_measured_in_display_cells` pins it, and restoring `chars().count()` fails it with `[2]` against `[4]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7c4b90c537 |
fix: four Copilot findings on the grid frame
All four were right; two more were right and are declined on the threads. **A frame's pre-window headers were O(offset).** `axis_frame` accumulated every group header above the window and flushed the run at the first in-window item, so a deep scroll past many groups serialized headers that `body_from` discards on arrival. Only the *nearest* one can ever be answered with, so only that one is kept. **`web/app.js` captured the row counter, not its value.** `r` is one binding shared by every click-handler closure, so after the loop every editable cell would have called `edit` with the last data row. The group-aware counter was right; capturing it was not. It is a bug this PR introduced — the old code took `r` as a `forEach` parameter, which is per-call. **The agreement suite drove one arm.** It only read `rows`, and its fixture had no grouped column axis, so `col_levels` and `col_groups` could regress behind a green suite (`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`). The fixture now groups two of three months under `Q1` and leaves `Sep` outside, with `_Measure` giving a second column level, and `the_two_front_ends_draw_the_same_column_header` checks the levels against the painted header lines and the spans against the column count. One span per column, and a dropped level suppression, were each injected and watched to fail. **The group branch did not compare the label.** It asserted only that the painted text began with an indicator, so any group name passed. It now compares the whole painted header against the browser's glyph-plus-label. The column test also *demonstrated* the finding this PR declines: `Q1` spans all six columns including the two under the ungrouped `Sep`, because `expand_category` emits a header on entering a group and no boundary on leaving one. That is `GridLayout`'s rule and the TUI paints the same span. It is asserted as it is, with the defect and the corrected expectation written next to it, and stated on `AxisFrame::group_of`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
16471d7def |
docs: the proxy is the open door, not the closed one (Copilot review)
Both Copilot findings on PR #21 applied. The first is the serious one and it is a defect in documentation this PR itself introduced. `improvise-ws-server` has NO authentication -- a deliberate MVP limit its own module doc states -- so anyone who can open a socket to it can rewrite every document it serves. Loopback binding is the only thing standing between that and the network, and terminating TLS in front of it REMOVES EXACTLY THAT PROTECTION. The previous text not only omitted the warning, it actively reassured the reader: the `--bind` note explained that keeping loopback means the port "is not reachable from anywhere else", which is true of the port and false of the deployment, because the proxy is public. TLS buys confidentiality, not access control, and the two read alike in a recipe. So the warning leads the section, the Caddyfile carries `basic_auth` with a comment saying it is not optional, the `--bind` note now says what it does and does not buy, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing by default -- an unguessable URL is not a control. Second finding: the watchdog comment was ambiguous in a file whose only verification is reading, which makes an ambiguous comment a real defect. It said a drop before the first frame "leaves the watchdog armed", meaning the state on ARRIVAL; it reads as a claim about the state after the call, which is the opposite of what `setShellFailure` does. Reworded to say the call disarms, why the opened path needs that (the watchdog is armed only after `transport.ready` resolves, i.e. on `open`), and that the never-opened path makes it a no-op. No open ticket covers server auth -- improvise-j31x is the multi-document registry and is closed -- so nothing is cited here and one is proposed in the hand-back rather than an ID invented. 1184 passing, unchanged. `node --check` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d3fe4aa041 |
fix(web): a socket that never opened did not disconnect
The `close` listener wrote "disconnected" unconditionally, so a connection that failed loudly -- the `error` listener having already written "cannot reach <url> -- is improvise-ws-server running?" -- was immediately followed by a close event replacing it with the vaguer of the two facts. The user saw the least informative thing that happened. This is the watchdog bug's family and deliberately NOT its fix. The watchdog is for silence only, so it is disarmed; suppressing `close` outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for. So the two cases are split rather than one being guarded. A CORRECTION TO THE TICKET'S SUGGESTED MECHANISM: it proposed reading `readyState` at close time, and that cannot work. `readyState` is already CLOSED by the time a close listener runs, whichever happened, and the CloseEvent cannot separate them either -- a refused connection and a mid-session network drop are both code 1006 with `wasClean: false`. The only discriminator is whether `open` ever fired, which the page must remember for itself. Hence the `opened` flag. Both arms go through `setShellFailure`, because a drop before the first frame leaves the watchdog armed and "disconnected" is more precise than the guess it would fire 8s later. The header comment stating the `#status` vocabulary is updated in the same commit: that fact is stated twice, and gotcha 48 says changing it is a grep. VERIFICATION IS READING, and that is the whole budget. There is no browser and no headless DOM here, so this is `node --check` (proved discriminating by appending an unbalanced paren and watching it fail) plus confirmation that every call app.js makes is in the generated .d.ts at the right arity: on_frame/1, on_key_event/5, render_model/0, set_viewport/2, typed_cell/3. Both wasm bundles still build. The listener itself has never been executed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cf086f8c04 |
Merge: one keymap table, shared by the TUI and the browser (improvise-bpq2 successor)
The browser's hand-written key classifier was a second spelling of default_keymaps(), held honest by a lockstep test over a hand-written list -- doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)'s exact shape. So the TABLE moved to improvise-protocol and improvise-command keeps only what needs a CmdRegistry. Evidence is binding_dump_diff, not the suite: 4021 resolved (mode, key, modifier) lookups over sixteen modes, BYTE-IDENTICAL, with the dump kept as an opt-in test and proved discriminating by renaming one binding. The boundary that did NOT move is the better one: key -> intent is shared, intent -> Command stays client-side, and the named list of what still needs the host is now a list of COMMAND NAMES rather than keys -- so a rebinding no longer invalidates it. AND A LOCKSTEP TEST FOUND A LIVE BUG, BY A COMMA. CellDisplay::text has been through format_f64; App::display_value_at_cursor has not. Seeding an editor from the drawn text and committing turns a stored 1200 into the string '1,200' -- display rounding fed back into the model, which design-principles 4 forbids outright. CellDisplay::edit_text puts the unformatted value on the wire, and it repairs web/app.js's click-to-edit, which had been seeding its input from td.textContent since it was written. That bug is on the live page today for any value with a thousands separator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
59cc6720b5 |
feat(browser): the page draws the mode's hint instead of spelling keys
`?` now enters `Help` — the keymap says so and this client resolves `enter-mode` — but the page has no help overlay: `src/ui/help.rs` is 600 lines of ratatui and nothing shared carries its content. Landing the key without this would have swapped the mode label to HELP over an unchanged grid, which looks broken rather than unfinished. `AppMode::hint_text()` already exists in the protocol crate and is what the TUI's footer draws, so `RenderModel::hint` relays it. Every mode now states its own keys in the browser, the page spells none of them, and the modes this client can enter but not yet draw say what they are. A real help overlay needs the content moved into the shared layer first, which is the same move design-principles §5 asks for and is filed rather than faked. `web/*` is unrun; `node --check` only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
e13635638c |
feat(browser): the browser reads the TUI's keymap, and i opens an editor
Follows the keymap move. `keys.rs`'s hand-written per-mode classifier is gone; `resolve` now looks the key up in `Keymaps::default_keymaps()` — the same table the terminal dispatches against — and what is left here is the narrow residue: turning a bound command NAME into a `Command`, for the subset a client holding no `CmdContext` can compose. That is a better seam than the one it replaces. The drift risk was "which key does what": 170 bindings, no direct test, invisible when wrong. It is now "which command names can I execute", which is a list — and `the_normal_mode_keys_that_still_need_the_host_are_these` becomes `the_command_names_that_still_need_the_host_are_these` to match, so a rebinding no longer invalidates it and a new binding for an already-executable command reaches the browser with no change here. Several gaps close by sharing rather than by code. Search-mode typing resolves locally for the first time (the table binds `append-char ["search"]`, and "search" is `ViewState::search_query` — gotcha 11's pair is mirrored and nothing else is). `Esc` resolves as the `Binding::Sequence` it is, all-or-nothing. A modified character in a text-entry mode now types, because the `AnyChar` fallback being reached for is the host's own — the old "unmodified only" rule was a hedge against a table this client no longer guesses at. And a key nothing binds is `Ignored` rather than forwarded into `Host::handle`'s empty vec. What is newly executable, and so what a user gets back: `edit-or-drill` (`i`/`a`), `commit-cell-edit`/`commit-and-advance-right` (Enter/Tab in `Editing`), `enter-mode` (`?`/F1, Esc), `enter-tile-select` (`T`), `toggle-panel-visibility` (Ctrl-F/C/V), `toggle-panel-and-focus` (F/C/V) and `transpose` (`t`) — four of which an independent browser smoke test had just found inert. `i` on an **aggregate** does not go upstream. Every upstream key is a black hole, and a keypress that visibly does nothing is the worst of the three answers, so it answers with a `SetStatus` naming drill. A view-half command is folded into the asker's own mirror and broadcast to nobody, so that message reaches this user and no one else. The write itself is not composed twice: the keyboard commit calls `socket::typed_cell_command`, the function click-to-edit already used, so Enter and a click cannot disagree about what `""` or `"42"` means. ## `CellDisplay::edit_text`, found by lockstep `tests/lockstep.rs` drove `i` through the host and the client over one grid and they disagreed by a comma: the host seeds its edit buffer from `App::display_value_at_cursor` (the stored `1200`) while the client had only `CellDisplay::text`, which has been through `format_f64` (`1,200`). Committing that stores the *string* `"1,200"` and the number is gone — display rounding fed back into the model, which design-principles §4 forbids outright. So the projection is enriched rather than the client taught to un-format: `CellDisplay::edit_text` is `display_value_of` on the wire, and it repairs **click**-to-edit too, which had been seeding its `<input>` from the drawn text all along. ## Making the edit visible `RenderModel::editing` carries what the user has typed so far, or `None` when no edit is open. The decision — is this an editing mode, and which buffer does it type into — is made in Rust; the page draws the string in the selected cell. Without it the mode indicator would read INSERT while every keystroke vanished into a buffer nobody painted. `web/app.js` and `web/style.css` change accordingly, and are **unrun**: there is no browser here (gotcha 48). `node --check` only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
94d26d8705 |
fix(web): default the socket to TLS, and cache-bust the deployed bundles
?ws= now takes a host:port and the page supplies the scheme: wss:// when it
is itself served over https, ws:// when over http. An explicit ws:// from an
https page is UPGRADED rather than passed through, because an https origin
refuses it as mixed content -- so passing it through can only produce a
failure that reads like 'the server is not running'. The scheme is not the
caller's to get wrong.
And the deploy now stamps ?v=<sha> onto both bundles. Pages serves everything
max-age=600 and the wasm-pack filenames carry no content hash, so for ten
minutes after a deploy a returning visitor runs the PREVIOUS build -- which
made a shipped fix look like it had not shipped, twice, during the first real
browser debugging session.
BOTH halves are stamped, because a fresh glue file does not pull a fresh
.wasm: the glue locates it with new URL('..._bg.wasm', import.meta.url), and
a URL built against a base does not inherit the base's query. Stamping only
the import specifier would refresh the wrapper and keep the stale binary --
exactly the failure this is here to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
bf02472d89 |
fix(web): an async message listener does not serialize, and a watchdog must not overwrite a real error
Three findings from the review-only PR #13, which put 28 already-merged
commits in front of a second reader, plus two comments that had gone false.
worker.js — `self.onmessage` was `async` and awaited `handle()`, which awaits
`server.flush()`. An async listener does NOT serialize: the event loop may
start the next `message` while an earlier one is suspended at an await, so two
`on_message`/`flush` pairs interleave and a later frame's replies can be posted
before an earlier frame's. That breaks the one rule the file names first — a
`GridUpdate::Cells` that overtakes its labels is DROPPED by the client's fold,
not replayed (gotcha 33) — and the pre-start `pending` drain had the same race
twice over, since `server` was assigned before the loop that drained it, so a
message arriving mid-drain overtook the frames it was queued behind. Every
frame is now chained onto one tail promise, which also makes "arrived before
start() finished" an ordinary case of the same rule instead of a second path.
app.js — the handshake watchdog fires 8s after the handshake and writes "the
peer did not answer". A peer that failed LOUDLY has already said something more
precise (worker `error`, `messageerror`, socket `error`), and the watchdog then
overwrote it with the vaguer guess. Those three paths now disarm it:
`setShellFailure` clears the timer rather than merely pre-empting it. The
watchdog is for silence only, which is what it was always for.
README — the "In the browser" section still said the deployed page needs a
server and that the in-page worker "is not built yet". Both landed in the same
unreviewed batch (improvise-9x6, improvise-djm), so the README described the
published site as a client with nowhere to connect while index.html described
it correctly. Rewritten, and it now states the caveat the commits state: no
line of that JavaScript has ever run.
Two comments that had gone false with it, and for the same reason — a follow-up
commit updated one copy and not the others. `web/app.js`'s keyboard note said
key resolution was "text entry, and nothing else" and that upstream keys would
work "until improvise-bpq2 lands"; bpq2 landed, moved the grid, panel and tile
cursors to the client, and settled that the host must not know the keymap.
`Host::handle`'s `ClientFrame::Key` arm said the same thing;
|
||
|
|
145d76e7da |
fix(web): the page records its window, or every cursor key vanishes
sendViewport called the associated Client.viewport, which builds the handshake frame without RECORDING the window. A client learns its window only from what it announced -- row_labels.len() is the window clipped to the grid, so the two differ exactly at the bottom edge, which is where the scroll arithmetic matters. Without the record the client cannot clamp, so every cursor key goes upstream into Host::handle's empty vec and the arrow keys silently do nothing. Flagged by the improvise-bpq2 agent as the one line its work depended on and which it could not make, web/ being another agent's ground. 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
|
||
|
|
c72832f542 |
feat(wasm-client,web): the DOM renderer for the browser grid (improvise-cr3)
The last blocker of improvise-1ey. Mostly assembly: `Client` already had the fold, the key classifier and `set_cell`, `CellDisplay::id` already said what a cell is (ADR 002), and `flatten_cat_tree` already owned the category panel's flattening. What was missing was the three joints between them. `render.rs` is the one that matters. The DOM half is JavaScript and JavaScript here is untested, so every question with a *silent* wrong answer is answered in Rust under test and crosses as decided data: `editable` is a fact about the `CellId` variant (Record only — an Aggregate is a drill target and `Drill` is not on the wire yet, and `None` is "no projected cell", not "aggregate"), the selection is placed by subtracting the scroll offset because `GridCache` is viewport-indexed while the cursor is not, and the category panel is `flatten_cat_tree` rather than a second walk of the cache. `socket.rs` frames what `improvise-ws-server` actually speaks, which is not what `wire.rs` speaks: it has `Viewport` (the handshake — a snapshot built at extent zero is an empty grid), it batches `Commands`, and it has no `Key` variant. The two enums are duplicated because a native crate cannot be a wasm dependency; `improvise-ws-server` is a dev-dependency so the compatibility is checked through its own types, and a companion test proves the check is discriminating on the variant names that would drift. `web/` is the shell, and it is thin on purpose: four string calls in and out, no protocol knowledge, no framework. Gates: 1047 passing (was 1031), clippy and fmt clean, and `cargo check -p improvise-wasm-client --target wasm32-unknown-unknown` clean. Verified end to end headlessly — the wasm-pack artifact driven from node over a real websocket to a real `improvise-ws-server`: 1,200 read, "98765" typed, "98,765" projected back. The DOM painting itself is unverified; there is no headless browser here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |