- `Category`, `Item` and `View` each gain a private `display` that starts equal
to the key and changes only through `set_display` / `set_item_display`.
- `CategoryRow`, `TileEntry` and `ViewSummary` carry `display` beside their key
field. The key stays what commands are composed from.
- The category-panel headers, the tile bar and the view list paint the display
in the TUI and the browser. The active-view marker still compares keys.
- No production code sets a display yet, so behaviour is unchanged.
- This is the first slice of ADR 007 step 2. The grid, page bar, records labels,
item rows and CSV export are improvise-cq1q.12; messages and the view title
are improvise-cq1q.13.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
When source records collide on every coordinate, the import wizard now asks:
sum them, keep them separate with a generated occurrence category, or go
back. Collisions are found before the build by the same placement pass the
build uses, and are carried to the TUI as structured data (WizardCache) and
listed in full, scrollable. At the 12-category cap, "keep separate" is shown
disabled with its reason and remedy.
Every wizard warning and message now wraps to the panel instead of being
truncated, and long lists scroll.
Headless paths no longer sum silently: `import --no-wizard` takes
`--on-duplicate refuse|sum|separate` (default refuse), and `:import-json`
refuses and points at `:import`.
Reasoning: context/code/formula-and-io.md, "Colliding records are a
question, not a default"; context/code/tui.md; context/code/cli.md §11.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Resolutions:
- CellId: #221 gives Coordinate fields { record, category }; main (#292)
adds NonItem { category, item }. Every exhaustive match now names both
(render.rs, core.rs x2, project.rs, resolve.rs x3, wasm-client lib.rs and
render.rs); project.rs's basis arm keeps #221's Coordinate-carries-a-record.
- resolve.rs edit_or_drill: #221 drops the "renaming a coordinate" decline
(Coordinate opens an editor); main's NonItem decline arm is kept.
coordinate_at_cursor's None arm gains NonItem. reopened_at keeps #295's
declined_at shape; declined_at's editable arm names Coordinate { .. }.
- client.rs: kept main's status_seen/status_owner and #221's opened +
OpenedCoordinate; every settle site calls both settle_opened and
settle_status. Main's new rebase() also gets settle_opened, since it
rebuilds the view exactly as apply() does.
- view/mod.rs: re-export both GridEval (main) and record_row (#221).
- context/code/command.md and context/test-evidence/clients.md: both sides
appended independent sections; kept both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Conflicts with #294 in render.rs and project.rs resolved as tested:
#250's side of every hunk (levelled GroupEnd { level }, groups()/group_of,
axis_frame's carried headers, the try_from level check), then #294's shape
(Item { tuple, .. }, FrameEntry::item(..), the DataItem { tuple, collapsed }
arm, and GroupEnd { .. } in folds). Evidence EOF appends keep both sides.
Two ChatGPT Codex findings on the #250 stack.
- #250, P2 (improvise-e265): a FrameGroup.level from the wire sized the
open-run stack in AxisFrame::groups, so a huge level drove a huge
allocation. AxisFrame now deserializes through UncheckedAxisFrame and
refuses any group or GroupEnd level not below cats.len(), naming it.
context/code/protocol.md §3b.
- #273, P3 (improvise-0r01): the lazy open_runs still cloned a stack per
skipped row under .nth(idx). walk_open_runs lends one stack to a visitor
that can stop the walk, so a lookup copies nothing for earlier rows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-4k1j, from PR #275's review. hidden_row_item refused on a fold
only when the NEAREST group header was collapsed. Under two row
categories that header can be the inner one's: with Region's Coast
collapsed and Product's Kit expanded, the client composed
HideItem{Region, "Coast"} and a twin host wrote a group name into the
document, where the host refuses.
The frame cannot answer this on gh/main: FrameGroup names no level and
an inner GroupEnd reads as an outer one. So FrameEntry::Item now carries
the layout's own DataItem::collapsed flag (a fold, or under one, at any
level) and AxisFrame::folds reads it, the same bit
GridLayout::row_folds_a_group reads. FrameEntry::item builds an
unfolded one, as AxisEntry::item does.
hiding_agrees_with_the_host_on_every_row_of_nested_groups presses H on
all 25 rows of four nested grouped grids, in two windows each. It was
red on the nearest-header check at rows 4 and 5, as the review found.
Also fixes the fmt gate the review found red: fmt-workspaces.sh is a
check, not a formatter, and its diff output was misread as success.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
On an axis with two grouped categories, runs nest: North in Upper holds
Jan in Q1, and after Q1 ends North/Mar is still in Upper. The markers
carried no level, and every reader was flat, so the nearest marker
decided. Pruning an inner header dropped its GroupEnd too, and a
surviving row changed group. The unpruned answer was lossy as well:
North/Mar was in no group while it was drawn under Upper.
The user ruled per-level membership, with z toggling the innermost group
(the one nearest the cursor). So:
- AxisEntry::GroupHeader and GroupEnd carry a level. expand_category
reads it off its prefix, which holds one item per outer category.
Membership is one open run per level, and a row's group is the
innermost one (row_group_for/col_group_for, hence z).
- prune_axis_items re-marks the survivors instead of filtering the
markers. Before each kept item it emits the markers that make the
output's open runs equal the input's, closing deeper runs innermost-out
first. So each survivor keeps exactly its runs, no header is kept over
nothing, no trailing GroupEnd is left, and when nothing is pruned the
output equals the input. This replaces the pending/close_runs_of/
run_open machinery.
- The wire: FrameGroup gains level (an index into AxisFrame::cats), and
FrameEntry::GroupEnd becomes GroupEnd { level }. groups()/group_of
read the same per-level rule, and group_runs is unchanged (h0iv).
- axis_frame carries the open run at each level above a scrolled
window, not the single nearest header, so a window opening inside
North/Q1 still knows Upper.
Tests:
- an enumeration over every funded mask x collapse subset of a two- and
a three-level fixture (about 4,000 cases), asserting that pruning
moves no row's group, that no header is drawn over nothing and no axis
ends on a marker, and that nothing-pruned is marker-for-marker equal;
- the ruling at the layout and through z;
- frame/layout agreement at every window offset;
- the nested window carry.
Rivals, each injected and restored, are in the evidence files: flat
membership, a pruner that owes no GroupEnd, ends passed through eagerly,
headers kept eagerly, nearest-only window carry, and a flat frame.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-bqm0: opening_tile fell back to tile 0, _Index, when a view had
no Regular category, so T Enter still emptied that grid. TileEntry now
carries the host's CategoryKind in place of `regular: bool`, and
opening_tile ranks Regular, then _Measure, then Label, then _Index/_Dim,
leftmost on a tie, through an exhaustive match. A bool could not express
the fallback without a client sniffing names. CategoryKind is re-exported
from the protocol so the wasm client names it through its one dependency.
improvise-52od: in every fixture the first Regular tile was also the first
tile on Row, so a rival `position(axis == Row)` passed the workspace. A
protocol unit test on a records-shaped bar and a lockstep case in records
mode now fail that rival; a no-regular unit test and lockstep case fail
#231's own `first regular, else 0`.
improvise-q69m: context/code/command.md said tile 0 is _Index and that
the fix would be the tile order, and clients.md's Rule 3 counterexample
named the leftmost tile. Both now describe opening_tile, and command.md
says why placing the cursor beat reordering: click targets are indices,
and the offered order is #192's pinned characterization.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-oplp: EnterTileSelect and the browser's resolver both opened the
tile cursor at 0, which is _Index, so `T Enter` or `T r` put _Index alone
on Row and emptied a pivot grid; the browser worker then persisted it.
The rule is improvise_protocol::opening_tile, over &[TileEntry], called by
both ends. TileEntry gains `regular`, filled host-side from CategoryKind in
project::tiles_of, because a thin client cannot tell _Index from a user
category without sniffing names. _Index/_Dim stay tileable (#192).
A lockstep case presses `T Enter` on both ends and pins which category
moved; it was red at base (_Index on Row), and fails for a TUI-only revert
and for a `regular` filled true everywhere. contract.spec.js counted three
ArrowRights from tile 0 and now asserts `T` alone selects Product.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Enter or e in the formula panel opens FormulaEdit on the formula under the
cursor, seeded with its text. The editor names the formula by a FormulaId
captured when it opens, and commits through a new ReplaceFormula effect and
ModelCommand. The edit keeps the formula's position, renames it in place when
the target changes, and refuses out loud if the formula was removed or
rewritten meanwhile, if the new target belongs to another formula, or if the
text does not parse. A refusal leaves the editor open over the user's text.
FormulaCache now carries each formula's id, so the browser composes the same
edit, and a formula-row click opens the editor on the clicked row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The field's doc comment said a client echoes it back in ToggleGroup, but
resolve.rs has no toggle-group arm and neither renderer read it. Deleting
it is the minimal honest answer: composing `z` in a browser needs its own
design (the TUI places the cursor on the layout the toggle will produce),
and the operand should come back with that consumer. The h0iv test now
asserts whole-header equality, which is stronger than the (cat, name)
pair it compared.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
A drawn coordinate cell projected id: None, which ADR 002 and gotcha 42
define as "no projected cell". Absence was encoding a permission (the
host's). It is now its own kind, carrying no operand because no client
composes a coordinate rename. render.rs::editable is an exhaustive match
so the next variant must be classified. Paste, clear and edit are
unchanged: every write path still matches Record alone. The test that
pinned the bug (a_coordinate_column_is_projected_with_no_identity) is
replaced by one that enumerates the whole records grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
CellValue's Display writes a non-whole number {:.4}, and every editor seed
was that string, so i then Enter on an untouched 1.23456789 stored 1.2346,
in the TUI (pivot and records) and in the browser, which seeds from the
projected edit_text. CellValue::edit_text is now the one home of the seed:
the shortest decimal that reads back as the same f64 (non-finite values
take the core.md §16 token). display_value_in's two arms and
cell_display's records arm all go through it; the now-orphaned
GridLayout::resolve_display is removed.
Text that reads as a number (007) is exact in the seed but still
reclassified by typed(); filed as improvise-ixxx.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Integration review of #106. Three findings; two applied, one does not reproduce.
1. AxisFrame::group_of had NO coverage of its GroupEnd arm. Replacing it with
`FrameEntry::GroupEnd => None` left all 133 improvise-protocol lib tests
passing — only tests/grid_frame_agreement.rs in the root crate caught it,
and that file does no pruning. So improvise-gd83's property reached the
protocol by construction through project.rs and never by assertion: a
derivation living in two crates with one of them unchecked, which is what
gotcha 44 and design-principles §5 exist to prevent.
Two tests in render.rs over the exact frames prune_axis_items now produces
— the re-opened run closed by a GroupEnd, and the collapsed header with no
item under it. The second also asserts collapsed: true travels, because a
header arriving without the flag is there and inert, which is lhi4 again
one layer out. The injection now fails both where it failed nothing.
This is the landed half of u6gc's AC3. The other half — that
prune_axis_items -> axis_frame -> group_of really composes to these frames
— still needs project.rs and stays filed rather than landed.
2. a_collapsed_groups_header_survives_pruning passed with prune_empty disabled
entirely: its fixture funded every item, so nothing was ever pruned and the
name claimed more than the test could see. It now carries an unfunded Mar,
so turning pruning off leaves DataItem(["Mar"]) in the asserted sequence and
the test fails. Same vacuity class as the any() -> whole-sequence fix in
4075d66.
3. NOT APPLIED — "no fault separates the collapsed test from the GroupEnd
test" does not reproduce. Re-measured all three faults over all four tests:
inverting close_pending_run's condition to `had_item` fails the collapsed
test while the GroupEnd test PASSES, because a re-opened run is closed by
the GroupEnd arm whatever the header condition says. The evidence now
carries the full 3x4 matrix rather than prose, so the claim is checkable
instead of assertable, and every column is distinct.
Also moves wide_cell_clamps_without_overflow's evidence from core.md to
protocol.md. improvise-yae4 touches viewport.rs and render.rs and does not
touch improvise-core at all, so core.md was the wrong home; it was put there
by hand and the checker cannot see a misfiled entry — it asks whether a
heading names a real test, not whether it sits in the right file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-yae4. grid_body narrowed every measured width to u16 and only then
added the one-character gap and clamped to MAX_COL_WIDTH, so the arithmetic ran
at full width before the cap that makes it safe. A stored 65535-column text
value panicked at `w + 1` in debug; in release the sum wrapped to 0 and the
column was drawn at MIN_COL_WIDTH — the narrowest column, for the widest value.
A 65536-column value never reached the addition: `as u16` truncated it to 0
first, so it wrapped in debug builds too, silently.
Widths are now capped while still usize, in the one place every measurement
enters (`widen`), which is why header_level_seed folds through it rather than
keeping a second `as u16`. The answer is unchanged for every input that did not
overflow: (min(w,32) + 1).clamp(5,32) and (w + 1).clamp(5,32) agree everywhere,
both saturating at 32 for any w >= 31.
improvise_protocol::header_level_widths carried the identical defect on the
row-header axis — a 65535-column item label — and is fixed the same way. It is
the one home for that rule (gotcha 44), so the row header and the column widths
cannot end up with two answers.
wide_cell_clamps_without_overflow is un-ignored and extended to the 65536 arm
and to an ordinary 20-column value. It asserts `widths == vec![expect]` rather
than `all(<= 32)`: the length pins that the fixture really produces ONE column,
and the ordinary arm pins that the single column is the one carrying the value —
a fixture in which no column was wide would read MIN_COL_WIDTH there and pass a
`<= 32` check. Both injected faults are recorded in
context/test-evidence/core.md.
Not fixed, and out of this ticket's scope: compute_visible_cols accumulates
`acc + w` in u16, reachable only from a terminal ~65535 columns wide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
changed_mode settled only Normal -> RecordsNormal, but the host's
settle_normal_mode settles BOTH members of the pair, so a RecordsNormal
over an ordinary grid stayed put on the client and became Normal on the
host. Esc out of RecordsEditing resolves through entered() to
`enter-mode records-normal`, so the mirror-image drift was reachable by
the same route the fixed one was. Now total, with the pass-through arm
and both negative rows asserted.
a_compacted_seed_folds_to_the_same_cache_as_the_whole_history left
GridCache::records at its default, so a seed() that OMITTED the arm
compacted to an equal cache and the check could not see the omission.
The history now sets it; deleting the arm from seed() fails the test.
grid_updates' records arm had no test at all: grid_of setting the field
is covered by lockstep, but a client that connects before records mode is
entered learns only through the diff.
Evidence for all three, with the injected faults:
context/test-evidence/{clients,command}.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
One substitution at three decision points: wherever the host asks
layout.is_records_mode(), resolve.rs asked state.view.mode.is_records().
The two agree only while the mode and the layout agree, and
Command::ChangeMode(AppMode::Normal) is the message that separates them --
the host settles Normal against the layout the batch ends on, reduce_view
folds the variant it was handed.
GridCache::records carries the fact, projected by grid_of off
layout.is_records_mode() and diffed like the extent. Deriving it on the
client from row_frame.cats would have been a second implementation of
is_records_mode (improvise-edp's disease); deriving it from the mode is
the bug. Like Extent it frames nothing, so apply does not re-fit and its
batch position is not load-bearing.
The client's settling has one home, resolve::changed_mode, reached by
every site that can emit AppMode::Normal -- including entered(), which is
how a bound `enter-mode normal` gets there. The three decision points ask
state.grid.records, so a commit at a records cell declines rather than
composing typed_cell_command against a grid whose columns are
[coords.., +cat, measures.., +].
lockstep now DRIVES the keys: in_lockstep presses on both ends and
refreshes the client from a real projection rather than from the host's
slice, which is what let the old records coverage start from agreement.
The reachable path is Tab (category panel) then Esc; c/f/v are Ignored in
RecordsNormal and R is a stranding decline, which a first draft of the
test found the hard way.
Two resolve.rs fixtures put a records mode over a non-records grid -- a
state no host can produce -- and had to be corrected with the fix.
Evidence and both injected faults: context/test-evidence/clients.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
grid_of evaluated the whole grid roughly three times per frame:
compute_col_widths walked every cell for its text, then every cell again for the
column totals it must size, and grid_of then evaluated the window a third time.
Every result was discarded at the end of the frame.
ui/viewport.rs::grid_body is now the one walk. It evaluates each cell once and
derives all three answers from that value -- widen the column, add to the
column's sum, and if the position is inside the requested window keep the
CellValue for cell_display to dress. compute_col_widths survives as grid_body
with an empty window, because the keypress path wants widths and no cells, and
two width rules would be the disease improvise-edp names.
MEASURED, orchestrator's machine, cargo bench --bench grid_projection, before
and after:
typical (400 cells) 1.840ms -> 0.687ms
large (9,600) 172.6ms -> 50.0ms
18k (gotcha 41) 294.4ms -> 95.2ms
pathological (90,000) 4.481s -> 1.320s
About 3x, which is the number of passes removed -- the shape the acceptance
criterion asked for rather than a speedup argued from the structure.
Three things the ticket did not predict.
The benchmark's col_widths column now measures something ELSE. It read 45-47%
and now reads 64-82%, which looks like a regression and is the opposite:
compute_col_widths IS the fused walk, so its share rose while the frame fell by
two thirds. A ratio whose denominator moved is not comparable across the change,
and the benchmark now says so.
The windowing ratio did not improve, and that is correct: the pass that remains
is exactly the one a viewport cannot narrow. improvise-i5tk's decline stands and
the two findings are now cleanly separated.
It is not solved, only three times cheaper -- 18k cells is still 95ms, 5.7
frames. The next lever is caching ACROSS frames.
Guard: a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths. Fusing
three consumers into one walk creates a hazard the separate passes did not have,
and the natural "optimisation" (iterate rows rather than 0..row_count) makes a
column total the sum of the visible rows, silently. Watched fail with
left: [1.0] against right: [300021.0]. All 1469 pre-existing tests unchanged,
which is also the evidence the outputs did not move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The move -- diff col_widths against the label-folded cache, as hkt6 already
obliged for totals -- is DECLINED, and the two things learned are worth more
than the change would have been.
THE TICKET'S STATED COST DOES NOT EXIST. It says the move breaks
a_client_on_a_socket_is_seeded_and_gets_its_edit_back, and that that assertion
is gotcha 33(c)'s standing evidence, so rewriting it inside a change 33(c)
guards is the improvise-q0vb / PR #12 shape. Measured by deleting the re-fit
from fit_to_labels: three existing unit tests fail and the socket test is not
one of them. What it actually asserts is that a reframe CARRIED widths -- a
statement about the producer's diff, not about the consumer's re-fit. 33(c) is
triply guarded elsewhere.
THE REAL REASON IS COUPLING. The widths a label-fold invents are each column's
own label width: an approximation the consumer applies until the authority
catches up. Diffing the producer's output against it makes what the server sends
depend on a fallback rule defined in the client -- invisible today, silent the
day a second client implements the fold. The redundant ColWidths per reframe
buys out of that, and over-sending is the direction Dirty::of is deliberately
sloppy in.
Landed anyway: the half the ticket calls "the better test either way".
a_client_never_holds_widths_of_a_different_length_from_its_labels asserts the
invariant 33(c) is really about, at EVERY step of a fold that widens before its
widths arrive and narrows before they shrink -- not merely at the end, which is
where a message-count assertion looks. Watched fail at step 0 with the re-fit
deleted.
The totals/col_widths asymmetry is now stated rather than discovered: totals is
fold-relative because hkt6 found a real hole; col_widths has none, the re-fit
being a safe fallback rather than a stale value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
group_runs coalesced adjacent positions by group NAME, so two runs under two
different headers sharing a name read as one and both front ends drew a single
span across the boundary.
THE TICKET'S PROPOSED FIX DOES NOT WORK, and that is worth more than the fix.
h0iv suggested comparing the (cat, name) pair, FrameGroup carrying cat for
exactly that reason. But its own reachability case is ONE category -- Month
grouped into Q1 nested under Region, giving North/Q1 and South/Q1 adjacent --
so both headers are (Month, Q1), equal as values, and the pair separates
nothing. A test pins that so the next reader does not re-propose it. What does
separate them is that they are two distinct FrameEntry::Group entries, which
groups() already borrows from self.entries: std::ptr::eq on the header is the
literal statement of "the same header opened both".
THE PRODUCT QUESTION, decided here and reversible in one comparison. Whether
two adjacent same-named runs are one span a user wants spanned is a matter of
taste, which is why #77 filed rather than fixed. What settled it is not the
span but the indicator: the existing wasm-client test merged Month/Q1
(expanded) with Fiscal/Q1 (COLLAPSED) and its own message said the merged span
is drawn with the first one's indicator -- a collapsed group rendered as
expanded, with no affordance saying otherwise. A span boundary is taste; a
wrong collapse glyph is a wrong fact.
That test's justification was also spent: it said the rule must be name-only
"because that is what src/ui/grid.rs uses", and since improvise-8qlr grid.rs
calls group_run_heads. There is no second copy left to agree with, which is the
general point -- a consolidation's frozen behaviour reads as decided afterwards,
and nothing says it was frozen only for the duration of the move.
The agreement fixture gains the case it could not see: two_level_rows minus the
ungrouped Sep, so the two Q1 runs abut, end to end through a real GridLayout and
both renderers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
AxisFrame::first carried #[serde(default)], so a frame whose first was dropped
in transit deserialized as 0 -- not an absence but the claim "this frame leads
the axis", which is precisely what body_from acts on. A scrolled browser would
draw the carried group header above its first row, silently.
Dropping the attribute rather than reaching for Option<usize> or a two-variant
enum, and the reason is about the wire rather than the type: there is exactly
one production producer, reading window.start, so a frame is never partially
constructed and "unknown" is not a state an honest peer can be in. Making it
representable would only oblige body_from to answer the same question one level
down. A missing field is now a loud parse failure at the transport.
Same argument that gave Basis no Default (gotcha 43). The Default derive still
yields first: 0 and that is not the same hole -- a default AxisFrame has no
entries, so the claim is vacuous either way.
Test written first and watched fail, printing the forged first: 0. It asserts
the key was present before removal, that the mutated value is still a
well-formed AxisFrame minus one field, and that the error kind is "missing
field" -- the three ways this check could otherwise pass for the wrong reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
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
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>
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>
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>
improvise-p1ym. GridCache::page_coords carried (category, selected item), so
PageNext/PagePrev resolved to a command name the browser held but could not
compose an argument for. They reached Host::handle, which answers an empty vec,
so a smoke test found them inert on the live page.
The item list is not a second field beside the selection: page_coords becomes
Vec<PageAxis>, and `items` is Some exactly when the axis is Axis::Page. That
puts gotcha 27's split in the type — a Filter axis constrains without being
navigable, so it carries no list and no caller can compose a cycle for one.
GridLayout::page_coords stays filtering_cats(), which is right for the bar,
and navigation.rs::page_cat_data stops spelling the Page-vs-Filter rule a
second time: it reads the layout's list.
The browser's page_cycle mirrors PageNext/PagePrev line for line, odometer
carry included, and `[`/`]` leave the still-needs-the-host list.
Two decisions stated in context/code/core.md. The list is
effective_item_names — the same call that already chose the default selection,
so selection and list come from one place; page_cat_data used
ordered_item_names before, which for a _Measure page axis let the default
selection be a formula target `[`/`]` could not reach. And the list is NOT
filtered to visible items: the TUI cycles hidden ones, and making the browser
differ would be the divergence rather than the fix.
The lockstep test's first version was vacuous — it rebuilt the client's cache
from the host and compared it against that same host. It now drives two
independent documents and compares View::page_selection; the injection that
reverses the odometer's significance now fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
"Don't overdo this" is part of the instruction. A pointer earns its place
only where a reader would otherwise re-derive the rule wrongly; where the
two lines already state it completely, the pointer is noise. Seven go, and
leaf_labels now names header_labels rather than a section number, which is
the more useful thing to say there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>