Commit Graph

13 Commits

Author SHA1 Message Date
Edward Langley 877d3e193b refactor(help): a chord is recognised by its modifiers, not its spelling (improvise-thpe)
`compact` tested whether a label it had just formatted began with "Ctrl-" or
"Alt-". No false positive today — the reviewer enumerated every
`format_key_label` output — so this is the shape, not the behaviour.

`Hint::keys` is now `(KeyCode, KeyModifiers)` and `compact` takes the pairs, so
the string is not in scope to parse; `Hint::labels` and the help pages format
last through the one spelling in `keymap::key_label`. The regression guard is
the signature, as it is for `Checked`.

`Keymap::labels_for` — the "throw the modifiers away" step the ticket names —
has no caller left and is deleted: a consolidation that keeps its old path keeps
the defect available.

The rule also got wider and total over the bitset: a chord is any modifier
beyond shift. Injected a temporary `KeyModifiers::SUPER` spelled `Super-x` and
ran it — the old filter keeps it, `compact` drops it. Binding dump byte-identical
across the change (4406 lines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:57:32 -07:00
Edward Langley bd3967cc7c fix: guard improvise-mai9 at the call site, and correct §3's group_of
Review of PR #77 found the mai9 fix unprotected and one doc paragraph
contradicting another. Both were right; both are addressed here.

The guard was aimed at the wrong thing
--------------------------------------
The mai9 defect was in the WIRING — `draw_bottom_bar` built
`format!("{}{}", mb.prompt, buf)` plus the cursor glyph and never consulted
`status_msg`. Both tests called the composing helper `minibuffer_line`
DIRECTLY, so nothing exercised the call site. Reverting that one line restored
the bug in full with the suite green at 1411, reproduced here before rewriting.
The helper did not even go dead: the test module's `use` kept it alive while it
asserted nothing about the product.

`a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open` now renders
`draw_bottom_bar` through a real `Terminal<TestBackend>` and reads the row the
terminal received. That is the only assertion that can fail for the real defect.
`the_minibuffer_prompt_is_not_displaced_by_the_message` is beside it because a
"fix" that dropped to `draw_status` would satisfy the first on the message
alone. Injected fault: the pre-mai9 `format!` — both fail, the first naming the
missing message and printing the row it did compose.

And the negative arm could not fail, twice over
-----------------------------------------------
`fill_line` computes `pad = width - left.len() - right.len()`, so with an empty
status an unconditional `format!("  {msg}  ")` produces a BYTE-IDENTICAL row at
any width where the line fits. The `match status_msg.is_empty()` is observable
only in the band where the prompt fits but `left.len() + 4` does not.

Rewriting it against that band was not enough: the first attempt derived its
lower bound by calling `minibuffer_line` itself, so the injected fault moved the
bound with it and the loop skipped the failing band entirely. The bound is now
spelled locally. A bound read off the function under test is not a bound.

Injected fault: `right` built unconditionally. Now fails at width 16 with
"an empty status still composed 20 columns".

context/code/protocol.md contradicted itself, 270 lines apart
-------------------------------------------------------------
§3 still said `group_of` answers "nearest preceding header", DELIBERATELY, and
that encoding an end boundary "is filed rather than smuggled into" the anr9
migration — while §3a, added by this branch, says `GroupEnd` is the boundary. A
reader arriving at §3 first would conclude this PR smuggled in exactly what that
paragraph forbids. It did not; improvise-gd83 IS the filed ticket. §3a was
written as an addition where it needed to be a CORRECTION, and gotcha 48 turned
on its author — worse than the `bindings.rs` case only because both copies sit
in one file.

§3 now records that the rule changed and which ticket changed it, and §3a points
at the correction rather than restating the history. The neighbouring paragraph
was stale too: `body_from`'s argument no longer exists "only because the two
front ends scroll differently".

Filed, not fixed
----------------
  * improvise-1wl0: `#[serde(default)]` on `AxisFrame::first` forges the
    permissive claim — `first: 0` asserts "this frame leads the axis", which is
    what `body_from` acts on, so a frame that loses the field deserializes as
    leading. Same argument that gave `Basis` no `Default`. No skew ships today.
  * improvise-h0iv: `group_runs` coalesces by name alone where `FrameGroup` has
    `cat` to fix it. Right call for a consolidation — both prior copies did the
    same, and changing the rule while moving it would have cost the agreement
    test its evidence — but a single home reads as decided, which is the new
    risk and the reason to file.

Both are cited in `context/code/protocol.md` §3a, so the single home does not
read as settled.

1412 tests pass (was 1411; +3 wiring tests, -2 helper-only tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:01:02 -07:00
Edward Langley 8f3a9768b7 fix: group membership, leading headers and one run rule on AxisFrame
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are
one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and
all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed
together or the test stops comparing anything.

improvise-gd83 — AxisEntry carries a group-end boundary
-------------------------------------------------------
`expand_category` emitted a header on ENTERING a group and no boundary on
leaving one, so `group_of` answered with the nearest preceding header rather
than membership, and the TUI painted that span. Copilot was right on PR #23;
this is the filing gotcha 44 said the fix needed.

`AxisEntry::GroupEnd` is the boundary, carried onto the wire as
`FrameEntry::GroupEnd`. Two arms, and a fix can close one and miss the other:

  * within one expansion — Jan[Q1], Feb[Q1], Sep — a `Some -> None` transition;
  * across expansions — `cross_product` expands the inner category once per
    prefix and each call starts with `last_group = None`, so Food/Bob[Family]
    is followed directly by Clothing/Carol with no transition to hook onto.
    That needs a `GroupEnd` at the END of an expansion whose last item was
    grouped.

`GroupEnd` is NOT emitted between two groups: the next header already ends the
previous run, and emitting one there moved `data_row_to_visual_skips_headers`
and its column twin for no gain. Those two are untouched, which is the evidence
the boundary is as narrow as it claims. A trailing marker is trimmed, since the
axis itself ends the last run. Nothing draws a `GroupEnd` and it consumes no
data index.

`THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are gone, and the
value they predicted is now asserted element for element — it was right to the
element, which is worth recording.

improvise-0o6x — a leading group header is drawn
------------------------------------------------
`body_from(idx)` started at the first ITEM, so at the top of an axis a leading
`FrameEntry::Group` was dropped on the way to the screen, in both front ends. A
collapsed group first on its axis therefore had its rows hidden and its header
nowhere: no affordance to get them back.

It is a boundary condition as the ticket suspected, but the boundary is not
`idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled
window for `group_of` and `body_from` discards it — correctly — and a frame
cannot tell that from the top of the axis using `idx` alone, because the
browser's frame is already windowed and always passes `0`. So `AxisFrame`
gained `first`, the absolute data index of its first item, and the rule is
`first + idx == 0`. Without it the browser would draw an extra header row at
every scrolled position that the TUI does not.

RE-PINNED DELIBERATELY, the improvise-q0vb acceptance shape: `GROUPED_FRAME` in
`src/ui/grid.rs` moves by exactly one line — the leading `▼ Co…` now draws
above `East` and everything below shifts down one. The group line, the column
headers, the separator, the data rows and the totals strip are byte-identical,
which is the evidence this is the boundary condition and not a reshuffle.

`harness/tests/grouped.spec.js` asserted TWO group rows against a fixture with
three, naming this ticket on the line and saying the count becoming 3 would be
the record of when it was fixed. It is 3, verified in Chromium. Three
assertions in that file took "the first tbody row" to be a data row; a
`firstDataRow` helper now skips the header, which is the same re-pin.

improvise-8qlr — the run rule lives once
-----------------------------------------
Column-group run coalescing was derived in `src/ui/grid.rs` and again in
`improvise-wasm-client/src/render.rs::col_groups`, held in step by a comment
saying so. `AxisFrame::group_runs`/`group_run_heads` is the one home and both
ends call it. Runs still coalesce by NAME, which is what both ends did before
and is preserved rather than quietly changed.

The TUI keeps one divergence, stated: it labels its first VISIBLE column even
mid-run, or a scrolled grid draws an unlabelled group line.

The agreement test's TUI arm was `group_line.contains("▼ Q1")` — the unpinned
arm the ticket names. It now compares the painted group line cell for cell
against spans derived from the browser's own runs. The test is fixed, not
deleted.

Injected faults, each failing by name
-------------------------------------
  * gd83: delete either `GroupEnd` emission — the two new layout tests fail
    with the leaked group, and the column-header agreement test fails with them.
  * 0o6x: force `body_from`'s match to `false` — `the_whole_frame_is_unchanged`
    and the new leading-header agreement test fail.
  * 8qlr: the ticket's own recipe, forcing the TUI to emit the group label on
    every column. `cargo test -p improvise` was GREEN before this commit and
    now fails with ["▼ Q1","▼ Q1","",...] against ["▼ Q1","","",...].

What the ticket did not predict
-------------------------------
A collapsed leading group has no item to carry its header into a window, so it
reaches the frame only through `axis_frame`'s `pending` queue. The first
version of the windowing change had `GroupEnd` CLEAR `pending` above the
window, which deleted exactly that header — the affordance 0o6x exists to
restore. `GroupEnd` is now queued like a header and clears nothing. Found by
driving the new agreement test in both collapse states; the uncollapsed arm
alone passes against the broken version.

1411 Rust tests pass (baseline 1405, +6). Playwright 28 passed + 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:57 -07:00
Edward Langley beef2587ad fix(command): a counter is bumped on an observed change, not a declared intent
`EffectKind` says what an effect *means* to do. `layout_version` and
`model_state.dirty` were believing it, and failed in both directions.

improvise-pdvm: AddItem/AddItemInGroup/RemoveItem declare CellData and
reshape the grid. Since improvise-9mo9 they call reset_cursor, so
cursor_epoch moved while layout_version did not -- one event moving one of
two counters that answer the same question, and a client told its cursor is
invalid while its layout basis tier still reads current.

improvise-75ld: a refused AddCategory (MAX_CATEGORIES) and a *duplicate* one
both bumped layout_version and raised dirty. The duplicate is the reachable
silent case -- Model::add_category returns Ok for a name it already has, so
it does not even abort -- and it refused every other client's next batch with
Rejection::LayoutMoved and made the worker rewrite the whole OPFS document.

Both are answered the way record_versions and cursor_epoch already are:
observe. Two fingerprints are taken around a batch that contains any
EffectKind::Model(_).

  view_shape   one view's DRAWN shape (axes, effective items minus this
               view's hidden ones, collapsed groups with something drawn
               under them, page selection, prune_empty). Per view name, so
               it answers both "did anything move" and "which views moved".
  document_config  everything a LayoutGeometry effect can write that the
               file would carry. Raises dirty. A different hash on purpose:
               a number format and a hidden ghost are bytes on disk and not
               shape.

No `Effect::kind()` declaration changed, so Dirty::of (gotcha 35) and
record_versions (gotcha 40) are untouched. What moved is which reader
believes the declaration: CellData and WholeWorkbook still raise dirty off
it, in an exhaustive match so a fourth ModelChange must answer for itself.

improvise-nynk: Sessions::adopt_cursor_epoch reset every mirror and returned
one Broadcast::ToAll, which improvise-jytm made wrong -- a reshape of view A
threw away the cursor of a client on view B. The trigger stays the epoch
moving (gotcha 24); only the audience narrows, to the observed reshaped-view
set, as one Broadcast::To per affected client. An empty set means "not
known", never "nobody", and cursor_invalidated is a named function with both
arms tested so that fail-safe cannot be tidied away.

improvise-56ek: SetPanelOpen and SetPanelCursor matched a Panel to a field
twice, in two crates. One home now -- ViewState::panel_open_mut /
panel_cursor_mut and their read halves -- and no compatibility test between
them, because there is no second copy to agree with.

1400 tests pass (baseline 1394: +7 guards, -1 replaced). Every guard proved
by injecting the fault it names; the injections and what each one broke are
in context/test-evidence/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:33:56 -07:00
Edward Langley 850ddcf0eb Merge gh/main (#48 active_view, #50 shared front end) into wave/b-projection-gaps
Eight conflicts, all resolved keeping both sides. The three in context/ were
append-vs-append. The five in code were the restructure #50 declared in advance:

- render.rs: render_model became a free fn over &ClientState and the mode label
  moved to improvise_protocol::mode_label. Took that shape and re-added the
  three doc_* fields.
- state.rs -> improvise-protocol/src/client.rs: only the import path conflicted.
  The file now lives IN improvise-protocol, so `crate::` is right, plus
  DocumentCache.
- keys.rs went 1387 -> 187 lines because resolve/step moved to
  improvise-protocol/src/resolve.rs. Took gh/main's slim file and ported every
  addition into resolve.rs: the Derived and Create arms of edit_or_drill, the
  or-patterns in record_at_cursor and record_cell_at_cursor, page_cycle with its
  Odometer, the two bindings, and `[`/`]` leaving the still-needs-the-host list.
- project.rs: kept the value-based numeric/error from improvise-tom1 with
  gh/main's cell_id(app, layout, ..) signature; kept BOTH document_update and
  view_in; kept our title-bar projection inside their per-view projection set.
- lockstep.rs: both sides appended tests and the hunks interleaved mid-function,
  so took gh/main's file and re-appended our page-turn block whole.

THE CLEAN MERGE WAS THE DANGEROUS ONE, exactly as warned. `cargo check
--workspace --all-targets` passed with three of our tests SILENTLY GONE — they
lived in keys.rs's test module, which moved wholesale. i_on_a_derived_cell_names_
the_formula, the_page_keys_turn_the_odometer_as_the_command_does and
a_filter_axis_is_projected_but_not_cycled are ported into resolve.rs, with
client_on now returning a ClientState rather than a Client.

One assertion changed meaning under us and is corrected: lockstep's
page_selections read workbook.active_view(), which since improvise-jytm is the
document's DEFAULT. SetPageSelection writes App::view(), the client's. They
coincide in a one-view fixture, so it was passing by fixture luck; it now reads
App::view() and still fails under the injection.

All four injections re-run on the merged code and all four still discriminate:
reversed odometer, dropped records formula_for lookup, title bar gated on Dirty,
and filtering_cats leaking into the protocol. 1348 passing, clippy clean, fmt
clean, all eight wasm crates clean, both comment-run checks clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:15:14 -07:00
Edward Langley 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>
2026-09-20 11:53:29 -07:00
Edward Langley f66c4aff04 fix(protocol): records_named has one home, and the lockstep sees every cursor
Findings 1 and 2 of the adversarial review of PR #50, both demonstrated by
injection before being fixed.

**F1.** `resolve.rs`'s `structural`/`record_write` were a second derivation of
`reduce.rs::records_named`, and nothing tested them: `lockstep.rs`'s
`apply_on` restates the basis against its twin with `caught_up`, which
re-derives it with the SERVER's answer and throws the client's claim away
before anything is compared. Emptying `record_write`'s claim left the
workspace green.

Fixed by collapsing rather than testing, which is what this unit argues for
everywhere else. `records_named` is pure and `RecordHash` is already
re-exported by the protocol, so it moved DOWN into `improvise-protocol`
beside the enum it reads; `improvise-command` re-exports it; `resolve.rs`
has one `model()` that claims `records_named(&cmd)`. The claim is now right
by construction and the guard is the exhaustive match that was always there.
Verified by emptying the shared function's `SetCell`/`ClearCell` arm: five
server-side tests fail, where the client's copy had none.

What the old shape cost, which was not a compile error: `structural` accepted
any variant and claimed `[]`, so a new record-writing command compiled clean
on the client with the wrong answer and the symptom was a dead browser key,
refused as `RecordUnstated`, with nothing failing.

**F2.** Three resolver divergences the suite could not see, all now failing:
`delete_view_at_cursor`'s cursor step-back (its only decision — the test
pressed `n` and `Enter` while its doc comment said "create, switch and
delete"), and both Formula arms of `CommitKind`, Formula being the only kind
whose `on_commit()` is `Some`. `both_ends` now compares every cursor, not
only the document, mode and status. A fourth injection — `cycle_panel_focus`'s
`start` index — still survived, because `PanelKeymap` binds Tab to
`enter-mode normal` inside a panel so no key reaches those arms; they are now
driven directly by `panel_focus_cycles_from_the_focused_panel_not_the_first`.

Widening the commit test found a real divergence, pinned rather than fixed as
improvise-cl7y: the client folds `CommitBuffer`'s optimistic "Formula added"
and cannot parse, so a browser reports a formula it did not add.
`a_formula_the_client_cannot_parse_diverges_on_the_status_line` asserts both
sides, so a fix has to come there and change it.

F3 filed as improvise-thpe and deliberately not fixed here.
`apply_on`'s doc comment pointed at an evidence entry that did not exist and
now names `context/code/clients.md`, which has the argument.

1324 tests pass, up from 1322.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:48:26 -07:00
Edward Langley 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>
2026-09-20 11:18:17 -07:00
Edward Langley 57a67e54cf refactor(protocol): delete GridUpdate::Invalidated; say why Items and Axes stay
improvise-n89z. Three Projection mutations had no producer. They get two
different answers, and the distinction is the finding.

GridUpdate::Invalidated is DELETED. It was not merely unused, it was
REDUNDANT: `grid_updates` diffs, so it always knows which cells moved and sends
Cells, and `GridCache::seed` compacts — there is no state in which a server
knows the contents are stale and not which ones, and if there were, the answer
is a fresh Snapshot through `rebase`. Meanwhile its stated meaning had migrated
into running code, because PR #23 gave `invalidate_relabelled` the job of
blanking exactly what a reframing invalidates, totals included. Three documents
still described it as load-bearing, which is worse than silence: a reader
reasoning about the fold's guarantees assumed something produced it.

CategoryTreeUpdate::Items and ::Axes STAY, with the argument written down.
They are derived rather than invented — the effects that move a category tree
come in three shapes, by the same recipe that built ModelCommand — and the
producer takes the coarse arm on purpose, because Dirty::of cannot say WHICH
category moved (an EffectKind never can), so emitting them needs the tree
observed and diffed for a widget that costs a few hundred bytes.

The two tests that reached the blanking through the retired arm now reach it
through a reframing, which is what really produces it; the row one is strictly
stronger, since blanking everything could not tell "blanks what it reframes"
from "blanks the lot". `the_retired_invalidated_spelling_does_not_parse` keeps
it from returning as a serde alias, with the injected-fault discipline
`the_retired_spellings_do_not_parse` earned: well-formed input first, error
KIND asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:16:29 -07:00
Edward Langley 69e04ff0b0 feat(protocol): name the records-mode creating columns instead of leaving them nameless
improvise-poia. `+` and `+cat` projected `None`, which the protocol defines as
"no projected cell", so a client could not tell a column that creates something
from a padded slot — gotcha 42's conflation, a third time.

The ticket offered two designs and both assumed a cell write. Neither happens:
add_measure emits exactly one effect, AddItem { _Measure, name }, and
add_category emits AddCategory(name) and deliberately writes no coordinate on
the current row (improvise-2zq). The record under the cursor is not part of the
operation and neither is any value — what the user types is a NAME. So there is
no distinct command to invent and no two-command flow to sequence.

What was missing was never an address but a recognition. CellId::Create carries
the kind and nothing else, because the kind plus the typed name is the whole
operand. It is not a SetCell target, so editable stays Record-only and
record_at_cursor still answers None — both by matching, which the compiler now
forces.

No client composes it yet: keys.rs::commit returns early for the whole of
records mode, and that guard is broader than its stated reason. Filed as
improvise-gcvb, with the two things that make it more than a one-liner — an
inserted +cat column means the TUI does not advance, and AddCategory can abort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:04 -07:00
Edward Langley 2e89de220a feat(protocol): project the title bar, as a widget rather than a ViewState field
improvise-z754. draw_title read workbook.model.name, model_state.dirty and
model_state.file_path; none was projected, so a browser could not draw a title
bar at all.

A projection, not a ViewState field: these are facts about the DOCUMENT, shared
by every client and writable only by the server. On ViewState they would be
document state wearing a session slice's clothes, and reduce_view would need
commands nothing composes. The per-widget cache is the shape for "a local copy
of what a widget draws, replaced by a Projection".

It is the one widget no EffectKind can gate, and that is the whole design.
`dirty` is raised by any EffectKind::Model(_) and cleared by effect::Save,
which is ViewOnly and correctly so; SaveAs moves file_path and is also
ViewOnly. So Dirty::of reports NOTHING for exactly the effects that change this
widget most visibly. ClientSession::document_update therefore OBSERVES — build
the desired cache, diff it against the mirror — the same answer record_versions
got for the same reason.

`stored_as` is a name, never a path. The TUI already drew only file_name(), and
a socket server projecting file_path would hand a remote client the host's
directory layout. `None` carries the fact ClientFrame::Save fails on.

draw_title now draws from project::document_of, so the two front ends share
one derivation. web/app.js deliberately does NOT paint it yet — every harness
fixture mocks a FAILING peer, so a title element added now would be unrun
JavaScript, which gotcha 48 forbids. Filed as improvise-5jna, fixture first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:11:11 -07:00
Edward Langley 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>
2026-09-20 10:59:59 -07:00
Edward Langley b394c9377f docs(protocol): land the crate's reasoning as context/ prose
The arguments in improvise-protocol's comments, relocated before the
comments are capped: the fold rules, the basis tiers, the one-envelope-pair
derivation, the TextEntry spine, and the four accessors that must not enter
a draw loop. Test narratives go to context/test-evidence/protocol.md,
keyed by test name per design-principles §7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:47:13 -07:00