Commit Graph

1465 Commits

Author SHA1 Message Date
Edward Langley aa86a48af1 review: the "grid, not mode" clause was argued and unchecked
Adversarial review of d124f18 found that the clause the fix was argued on had
nothing behind it. Both mode-shaped guards were injected and the WHOLE WORKSPACE
stayed green at 1659:

    if state.view.mode.is_records()   1659 passed, 0 failed
    if mode.is_records()              1659 passed, 0 failed

`the_tile_bar_route_into_records_mode_does_not_strand_the_user` cannot see it,
and not by accident: it enters records mode the way a user does, where the grid
and the mode AGREE. It is standing next to the question rather than on it —
a_careful_comment_is_weak_evidence_that_anything_checks_it, with the comment
being the one that argued hardest.

The separating input is a client whose mode has DRIFTED off its own records
grid, which is the fixture `a_records_commit_is_declined_off_the_grid_and_not_off
_the_mode` already built for `commit`. This adds its twin for the door, with the
same regular-grid control so the decline is not merely "this client never edits".
Both injections were re-run against it and both now fail at its first assertion.

Two prose corrections from the same review, each an overclaim:

  - "the toggle-records-mode decline is now TRUE" — it is not. The wording is
    "records mode is not available", and the tile bar still puts a browser in
    RecordsNormal with a records grid drawn. The fix closes the SILENCE, not the
    route; what is now true is that EDITING there is unavailable. So the
    stranding rule is still violated by the tile bar, and this guards the exit
    rather than the door — deliberately, since the room is worth being in and
    only the writes are missing. Saying otherwise was the same defect one layer
    up.
  - The CellId::Create arm's justification named a state that cannot exist:
    project.rs::cell_id emits Create only inside `if layout.is_records_mode()`,
    so the entrance guard takes every such cell and the arm change is a
    functional no-op with no test behind it. Kept, but no longer read as covered.

And the evidence file's column table stopped at 2-3 for a five-column fixture,
so a reader reconstructing it got four columns. The `+` row is restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 01:04:48 -07:00
Edward Langley c80da1be36 fix: seat a category by its kind, not by its name
improvise-cj4u (P2). View::on_category_added took a name and asked
CategoryKind::of_name, which reads Label as Regular by design.
Workbook::add_label_category papered over that with a set_axis(None)
straight afterwards, so views existing at that moment were right;
Workbook::create_view had only names to hand and had no such correction,
so every view created after a label pivoted it onto Row — and on an
already-pivoted document onto Page, filtering the whole document down to
one description.

on_category_added now takes the CategoryKind, and the two set_axis
corrections are deleted: one answer to "where does this category go"
rather than two, of which only one caller knew to write the second. The
signature change is deliberate — it is what made the compiler name every
call site. Every site outside Workbook is test code and passes
of_name(name), which preserves what those fixtures meant.

Shares a theme with improvise-dy3j — a Label kind that cannot cross a
boundary carrying only a name — but not a mechanism: dy3j is the file
format, this is a function signature. Fixed separately for that reason.

The control tests carry as much as the fix: a blanket `unseated = true`
satisfies every label assertion in the repository, so the direction the
bug cannot supply is asserted too, and a label is shown not to consume
the Row slot the next regular category wants. Reasoning in
context/code/core.md §9; evidence in context/test-evidence/core.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:57:48 -07:00
Edward Langley d9f732c529 fix: persist a category's kind, so a label no longer unloads a document
improvise-dy3j (P1). CategoryKind::Label had no spelling in `.improv` at
all: format_md wrote every category as a bare `## Category: Name` and
build_category read every section back through add_category. The kind was
dropped on the way out AND on the way in, so a round-trip equality test
could not see it.

A label is outside MAX_CATEGORIES = 12 by design and the import wizard
scores it as zero, so twelve categories plus a label is legally
constructible. On load every label came back Regular, the thirteenth was
refused, and parse_md failed: any such document was permanently
unloadable. Below the limit the loss was quieter — a label silently
became a pivot category.

Grammar first. The ticket proposed `## Category: Note [label]`, and
improvise-pczv forbids it: a section header takes its name as the WHOLE
line, so the suffix is indistinguishable from a category named
`Note [label]`. The kind is therefore an entry inside the section,
`kind: label`, spelled with a real token rather than a lookahead — the
grammar-walking generator skips predicates. Absence means `regular`, so
every existing file parses unchanged and a pivot category's section is
byte-identical to before.

Tests assert the parsed Category::kind, never the emitted line. Both
rival faults were injected against the fixed tree and both fail two
tests: a writer that omits the line, and a reader that discards the kind
it parsed (whose file text is correct, so any text-comparing test passes
it). Reasoning in context/code/formula-and-io.md; evidence in
context/test-evidence/formula-and-io.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:57:43 -07:00
Edward Langley d124f18c2e fix(browser): the tile bar reaches records mode, where every key was silent
`declined()` answers `toggle-records-mode` out loud and the code read as though
that closed the route. It did not. The tile bar carries the virtual `_Index` and
`_Dim` (`View::tileable_cats`), `set-axis-for-tile` composes locally, and records
mode auto-detects from `_Index` on Row plus `_Dim` on Column — so a handful of
undeclined keys land a browser in `RecordsNormal`, where `i` did nothing and said
nothing.

The silence had TWO sources, and the ticket predicted one. On a measure column
the cell is a `CellId::Record`, so the editor opened and `Enter` died in
`commit`'s `grid.records` early return. On a COORDINATE column the projected cell
carries no `CellId` at all, so `edit_or_drill` short-circuited before reaching any
arm — improvise-fdk1 seen from the key path, since a coordinate rename names a
record the `RecordHash` surrogate cannot address.

So the guard goes at the entrance of `edit_or_drill`, before the cell is looked
at, rather than on the `Record` arm: a guard on the arm answers "is this cell one
we cannot commit" where the question is "is this grid one we cannot commit in".
It reads `grid.records` and not `mode.is_records()` for the reason `commit`
already did — the grid is the observed fact and the mode settles from it.

`CellId::Create` now declines instead of returning `None`, so a creating column
reaching this function is a door rather than a hole. `create_column`
(improvise-gcvb) was already unreachable and is now explicitly so.

The regression guard walks every column of the records row and prints each
cell's `CellId`, because a fixture on the measure column — the one the ticket's
transcript stands on — cannot see the other two. Two rivals were injected and
each fails on a different assertion; both are recorded in the evidence file.

Closes improvise-bd9o.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:54:22 -07:00
Ed Langley d46ec59984 Merge pull request #127 from fiddlerwoaroof/ui/header-stable
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix: the app bar is reserved, not fitted, and a message is a toast (improvise-n6t8)
2026-09-22 00:45:00 -07:00
Ed Langley 4625593dbb Merge pull request #126 from fiddlerwoaroof/fix/command-layer
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix(command): two P1s in the key path, and both were about silence
2026-09-22 00:40:02 -07:00
Edward Langley 1f222e6215 style: centre the view chip in its reserve, as the mode pill already is
Found by looking at the screenshots rather than by a test, which is the
only instrument that reaches this: `#view-name` is now a fixed 16ch, and
"Plan" left-aligned in it read as a pill that had failed to shrink rather
than as a deliberately reserved slot. Centring is what makes the reserve
legible as a reserve.

No test moves -- the header spec measures boxes, and text alignment inside
a fixed box changes none of them. That is the honest scope of the guard and
not a gap in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:38:19 -07:00
Edward Langley 87a9377de7 Merge gh/main (#125 decimal default) into fix/command-layer
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH

# Conflicts:
#	context/test-evidence/command.md
2026-09-22 00:24:39 -07:00
Edward Langley 66693602c3 review: the two-facts claim was stated in four places, and I changed one
Self-review against the code-review skill, and it is gotcha 48's own third
lesson turning up in the change that cites it: "when a fact is stated in
more than one place, changing it is a grep, not an edit."

`context/code/clients.md` said `#status` composes two facts in three further
places -- the shell.js inventory, the transports section and the two-numbers
section -- all still describing the `.` join this branch deletes. A reader
arriving at any of them would have been told the opposite of what the code
does. The minibuffer section also predated `#bottom-dock` and said nothing
about what happens when a toast and the prompt coincide.

Also drops `createToasts().showing()`, which nothing calls. It was
speculative API on a single-use object (CLAUDE.md section 2), and an
unexercised accessor on a timer is one more thing that can be wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:22:12 -07:00
Edward Langley 6de56e6ba1 Merge branch 'main' into ui/header-stable 2026-09-22 00:17:27 -07:00
Edward Langley d750b433b8 fix: the app bar is reserved, not fitted, and a message is a toast (improvise-n6t8)
The rule is the deliverable: nothing a user expects to be fixed may move.

Four children of <header> were sized by their own content and all four
changed on the SAME event, so the bar reflowed on a mode change with no
message at all: #mode (mode_label, 4-14 chars), #hint (hint_line, one
line per mode), #doc-name (gains and loses " [+]") and #view-name, which
had :empty { display: none } and so appeared out of nothing.

Every child that changes with a keystroke now has an allocated width, and
#hint is the single elastic child, so nothing is positioned by it. The
mode pill's reserve is a FLOOR and deliberately not a cap: a cap absorbs
an over-long label inside a box that never grows, which is a reserve that
hides its own failure. AppMode's exhaustive-match methods make the
compiler demand mode_label for a new variant; nothing checked the CSS
still fit it. harness/tests/header.spec.js is that check.

The spec found what the ticket did not predict: the compounding one was
not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so
a longer hint took the shrink out of #status, which WRAPPED to two and
three lines and grew the header by a row -- 52.5px to 71.0px. Reserving
the pill alone would have left that untouched.

ViewState::status_msg is now a toast in a position: fixed dock anchored at
its bottom, so it takes part in no layout and stacks ABOVE the minibuffer:
the prompt is neither covered nor displaced. composeStatus is gone -- the
two facts go to two elements, so a repaint reaches onModel and cannot
address onShell at all. That is gotcha 46's rule made structural rather
than kept by a join's convention; its unit test was replaced, not deleted.

Dismissal keys off message IDENTITY, not a timer. status_msg is STATE and
repaint() re-offers it on every revision, so a setTimeout alone resurrects
the toast on the next keypress. Cost stated rather than hidden: two
identical messages in a row show once, because nothing carries a sequence
and the alternative reading of an ambiguous offer is exactly the bug.

Nine of seventeen modes are reachable in a browser and the spec names them
individually; three injected faults are recorded in the evidence file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:17:22 -07:00
Ed Langley 5fdce21005 Merge pull request #125 from fiddlerwoaroof/fix/e0ex-decimal-default
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix: show decimals by default, and let a formula name its own format (improvise-e0ex)
2026-09-22 00:15:55 -07:00
Edward Langley 907a3aa4c7 fix(command): two P1s in the key path, and both were about silence
improvise-55l5 (P1): `KeymapSet::dispatch` spelled command construction
`registry.interactive(name, args, ctx).ok()?`, so `None` carried two facts —
*no binding matched* and *a binding matched but would not build* — and
`App::dispatch_key` answers `None` with `Dirty::NOTHING` and no status. The
class that reaches a user is `no cell at cursor`, returned by six registrations
that read `ctx.cell_key()`: `clear-cell`, `yank-cell`, `paste-cell`,
`drill-into-cell`, `commit-cell-edit` and `commit-and-advance-right`. It is
state-dependent, so nothing checkable at registration time can see it. Every
other constructor error is a keymap-authoring mistake that
`every_bound_command_name_is_registered` already refuses at test time
(gotcha 15), so surfacing them is free rather than the point. `None` now means
no binding matched and nothing else; a construction failure becomes a status
naming the binding. `an_unbound_key_stays_silent` is the control, and the
rival that reports a failed *lookup* fails it.

improvise-o9uq (P1, silent data corruption): `o` in records mode is
`[add-record-row, enter-edit-at-cursor]`, and `AddRecordRow::execute` never
placed the cursor. Appending a row changes `view_frame`'s hash, so the frame
observation inside `apply_effects` invalidated the cursor to the origin
mid-batch (gotcha 24) and the editor opened pre-filled with row 0's category
coordinate; Enter then went through `rename_record_coordinate`, which by
gotcha 1 moves every measure cell of that row. Demonstrated through
`App::handle_key`: `East`'s Revenue cell became part of a record named
`EastNorth`. The command now names the row it lands on, and the ORDER is the
repair — the injected rival is the swap, not the omission, because
`viewport_effects` before the `SetCell` is the bug verbatim. The arm where the
record already exists stops claiming it added one; it is not made a refusal,
because `CommitAndAdvance`'s Tab path seeds the same empty-coordinate row.

Reasoning in `context/code/command.md`; evidence, injections and the two
things this deliberately does not close in `context/test-evidence/command.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:13:45 -07:00
Edward Langley 260f6e0b48 test: the page-menu spec reads the demo under the decimal default
`harness/tests/pages.spec.js` arrived on `main` with #123 while this branch was
open, and asserts `toHaveText("20,300")` against `web/demo.improv`. The merge
was clean and the combination was wrong — design-principles §7's third case
exactly: `git merge-tree` answers "can git combine these texts", not "does the
combination do what both sides meant". CI caught it on the merge ref, which is
the only place the two sides had ever been run together.

The assertion is unchanged in what it discriminates: it exists so that a menu
moving only the label fails, and `"20,300.00"` separates Q4 from Q1's
`"18,400.00"` exactly as the old literal did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:59:35 -07:00
Edward Langley faa2f28f29 Merge remote-tracking branch 'gh/main' into fix/e0ex-decimal-default 2026-09-21 23:50:06 -07:00
Edward Langley 2329dc6204 fix: resolve a cell's format through the formula that owns it
Both findings from ChatGPT Codex's review of PR #125. Both are real, and both
are the same mistake in two places: answering "whose format is this" from
something other than the rule that answers "whose value is this".

**1. `FormatPlan` indexed formats in a `HashMap` keyed by target.** A target
category need not be `_Measure` — `[CustomCat]` is in the format's own
documentation — so a key can carry coordinates two formulas claim, and
iterating the map answered from whichever category hashed first: the displayed
format was nondeterministic per process while the value was not. That is
gotcha 18's shape. Worse, only *formatted* formulas were indexed, so a later
formula's `FORMAT` reached a cell whose value an earlier unformatted formula
produces.

`FormatPlan` now holds every formula's `(target_category, target,
Option<format>)` in the model's own order and takes the first coordinate match
— `Model::formula_for`'s rule and order exactly. A match naming no format falls
through to the view; it is a match, not a miss. The list is dropped entirely
when no formula names a format, so the common document still pays one
`is_empty` per cell.

**2. A column total took its format from row 0.** `context/code/core.md` argued
that was safe because "where `_Measure` is on the row axis a column is not a
measure at all". Wrong: a formula's target category is any category, so a
formula can own a row-axis item — the total then inherited a row-local override
for a row it had summed away, and re-ordering the rows changed its spelling.
New `GridLayout::column_key(col)` gives the coordinates that survive the row
aggregation (page/filter constraints plus the column's tuple), and `col_formats`
is resolved from that, once per column, outside the row loop.

Three regression tests, each with the rejected version injected as the fault:

- `the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none` puts
  the same two formulas in both orders, so preferring the formatted one fails
  one half and ignoring formats fails the other.
- `the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does` rebuilds
  the plan five times, since each `HashMap` gets its own `RandomState`.
- `a_column_total_does_not_inherit_a_row_local_format` builds one grid with the
  owned row first and one with it last and requires the strips to agree. Every
  raw cell holds the same value per measure, so the totals are numerically
  identical and only the format can differ.

`context/code/core.md`'s wrong paragraph is replaced rather than amended, and
says what it got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:45:07 -07:00
Ed Langley 7470b61476 Merge pull request #123 from fiddlerwoaroof/ui/page-menu
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Page bar: a menu with a down-arrow, 'Page' not 'Pag', and `[`/`]` on a document layer
2026-09-21 23:41:15 -07:00
Ed Langley e8f66e6665 Merge pull request #124 from fiddlerwoaroof/fix/panel-bugs
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix: the formula panel's byte truncation panics on a Unicode formula (improvise-8l0c)
2026-09-21 23:38:55 -07:00
Edward Langley 9156b48f12 fix: the page menu must not claim a modified key (ChatGPT Codex, PR #123)
The menu's early return sat ABOVE `app.js`'s `metaKey` guard, so with the menu
open `Cmd-R` reached `menuKey`, was answered `ignore`, and was then defaulted
away by `if (!e.ctrlKey && !e.altKey) e.preventDefault()` — true for Meta.
Reload was blocked. `Ctrl-j`/`Alt-Home` had the mirror fault and MOVED the menu,
two lines below a comment saying Ctrl and Alt stay the browser's.

Ordering plus one bail: the `metaKey` guard goes back above the menu branch,
`pageMenuKeydown` returns on `ctrlKey || altKey`, and what remains claims
unconditionally — every unmodified key, `ignore` included, which is the rule the
menu exists to keep.

The harness assertion installs its `defaultPrevented` probe AFTER load so it
runs second and sees what the page decided, and ends on an unmodified `j`
asserting `["j", true]` — without which "nothing was prevented" is satisfied by
a page that claims nothing. Proved by restoring the reviewed code: it fails on
`Control+j` moving the active row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:36:43 -07:00
Edward Langley d00cea2477 fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.

Two halves, per the user's ruling:

* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
  noise on whole numbers is the cheaper error — a currency column with two
  redundant zeroes is legible, a ratio with none is wrong — and a format cannot
  be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
  keeps the view's. `formula.pest` gains
  `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
  reason (a predicate is invisible to the grammar-walking generator) and
  `FORMAT` added to `quote::KEYWORDS`.

`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.

Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.

Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.

The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:52 -07:00
Edward Langley 364052b7cc docs: mode_style spells thirteen variants, not fourteen
`context/code/tui.md` claimed `mode_style`'s default-chrome arm spells out
fourteen `AppMode` variants. It spells thirteen — Normal, RecordsNormal,
FormulaEdit, FormulaPanel, CategoryPanel, CategoryAdd, ItemAdd, ViewPanel,
ImportWizard, ExportPrompt, SavePrompt, Help, Quit — counted off `src/draw.rs`
rather than taken on report.

The match itself is sound and exhaustive; only the sentence about it was wrong.
The arithmetic that makes the corrected count checkable now sits beside it:
13 + Editing/RecordsEditing + CommandMode + TileSelect = 17, which is
`AppMode`'s variant count.

Nothing in this repository checks a count written in prose, which is exactly why
it survived — the same failure mode gotcha 38's derivation-count correction and
gotcha 44's stale gap list record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:30 -07:00
Edward Langley 0a920f1d54 fix: declare click_page_item on the wasm-surface ratchet
`web/tests/bundle/wasm_surface.test.js` pins the "handful of calls" the page
reaches the client through, and its own comment says a new one has to come and
say so. This is that. The reason it belongs on the list rather than above the
seam: the page holds no `Basis`, so `SetPageSelection` cannot be composed there.

It needs a built `web/pkg`, so it lives in `web/tests/bundle/` and is outside
the `web/tests/*.test.js` glob the brief's gate names — it only ran in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:29:49 -07:00
Edward Langley dd1194ef45 fix: one display-width-safe truncator, and the two panels that measured in bytes (improvise-8l0c)
`src/ui/formula_panel.rs` cut a formula by bytes and subtracted 1 from an
unchecked width, so `|é| = 1` in a panel of outer width 7 panicked on a char
boundary and an inner width of 0 panicked on the subtraction. Formula
identifiers are pipe-quoted and may carry any character, so an ordinary resize
crashed the TUI.

The grid's existing width-measured `truncate` moves to `src/ui/text.rs` and the
formula panel calls it, rather than becoming a third copy. The wizard's
byte-measured one stays as `context/code/tui.md` argues. `prefix_by_width`
replaces the old `chars().take` tail, which over-ran by a cell for a wide
character at width 1.

Searching for the class rather than the line found two siblings: the category
and view panels placed a second string at `inner.x + name.len()`, a byte count
used as a column. Neither panics, so the symptom is a gap that widens with every
non-ASCII character in the name.

Two rival fixes were injected and watched fail: a char-count truncator (passes
both panic tests, over-runs the width on `日本語`), and repairing only the
subtraction (still panics on the char boundary, and at width 1).

Evidence: context/test-evidence/tui.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:28:51 -07:00
Edward Langley a913390322 feat: the page bar becomes a control, and [/] reach the modes about the document
Three tickets on one subject: paging.

improvise-rw6l — clicking a page chip opens a menu of that axis's items, with
a down-arrow saying so. Almost nothing had to be derived: `PageAxis::items` was
already projected and `ModelCommand::SetPageSelection` was already on the wire.
The one gap was that `RenderPageAxis` dropped the names, keeping the position
and count improvise-p1ym needed for `[`/`]`. `items` is relayed INSIDE `cycle`
rather than beside it, so there is one predicate and not two: a Filter axis has
no cycle, therefore no arrow, no menu, no items, and composes nothing if a click
reaches it — all four off `PageAxis::cycle`, the one home of gotcha 27's split.

improvise-771e — the browser tile bar said "Quarter Pag". `short_label` is three
characters so the TUI aligns in a fixed-width terminal (gotcha 26); a browser has
no column to fit. `render.rs` asks `plain_label` instead; `short_label` is
untouched. Note what pinned the truncation was `harness/tests/drill.spec.js`,
not `tests/grid_frame_agreement.rs`, which does not mention the tile bar.

improvise-wiew — `[`/`]` move from Normal onto a new `document_keymap` between
the global and the modes whose subject IS the document (Normal, the three
panels, TileSelect; RecordsNormal through Normal). Help and the import wizard
take the global directly. Per the user's ruling: an overlay has a subject of its
own, and these are the first shared keys that MUTATE the document — under the
help overlay that is an invisible change to the grid it is covering.

Boundary crossings to flag, both outside the brief's stated write scope and
neither a wire change:
  - `resolve.rs` gains `Click::PageItem` and `lib.rs` a `click_page_item`
    binding. Composing the command anywhere else would put a derivation in the
    page, which design-principles §5 forbids; `Command`/`ModelCommand` are
    untouched.
  - `improvise-command/src/command/keymap.rs` gains three tests. The two
    existing guards were the mechanism that forced the wiew argument, so they
    had to be answered where they live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:24:38 -07:00
Edward Langley 246e8f4a63 docs(adr): nine, in all three places — finish the hex-count correction
The previous commit corrected clause 4's count from six to nine and left the
migration step and the Consequences section saying six, so the ADR contradicted
itself twice. Found by the third Codex review, and it is the same class as the
stale count that review round caught in context/code/tui.md: when a number is
stated in more than one place, changing it is a grep, not an edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:10:52 -07:00
Edward Langley 9191718612 docs(adr): answer the Codex review — Indexed is a fourth arm, selection is per-surface
Three findings, all correct, all applied.

1. A contextual selection face cannot be resolved by `resolve(Face)`. Clause 3
   fixed that signature while the vocabulary described one `selection` face
   "parameterised by the surface's accent" — the same Face cannot yield the
   grid's Cyan and the formula panel's Yellow. Resolved the way clause 2
   already works: `selection` is a BASE face and each surface has a derived
   one. A context argument was rejected explicitly, because it is a second
   input three front ends would each have to supply correctly, which is the
   three-derivations hazard one level down.

2. `Indexed` could not be "a detail". The ADR defined Colour as exactly
   Default | Named | Rgb, promised the two existing Indexed greys would still
   render exactly, and said the shipped theme used only the first two. Folding
   an index into Rgb changes its meaning AND breaks that rule. `Indexed(u8)` is
   now a fourth arm, the browser resolves 16-255 through the xterm cube and
   ramp, and the shipped-theme rule is restated over the line that actually
   matters: palette-relative versus absolute. The shipped theme contains no
   `Rgb`. The rejected-alternatives entry now also says that dropping `Rgb`
   entirely is a coherent version of this ADR, since that would make the
   regression unrepresentable rather than defaulted-against.

3. "Build all bundles" was failing: the ADR was neither published nor a named
   omission. Added to SECTIONS in scripts/build-doc-site.mjs. The checked-in
   site/docs output is deliberately NOT regenerated here — assemble.sh builds
   into its own output directory, and site/ is a periodic snapshot rather than
   a per-commit artifact.

Also corrects the hex count: nine stray values in style.css, six light and
three dark, not six.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:06:21 -07:00
Ed Langley ee0f57d227 Merge pull request #121 from fiddlerwoaroof/feat/oujb-web-in-release
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
feat: attach the assembled site to every release, and let the page name its build
2026-09-21 23:06:05 -07:00
Edward Langley 926396352f fix: seat the build stamp in #120's redesigned bar, and stop it faking a ticket id
Three things the merge with #120 (ui/shell-polish) actually required, none of
which a clean auto-merge could have told us.

MOVED, not preserved. #120 left <header>'s markup alone but gave every child
explicit styling and a stated reading order -- "what this is, which document,
which view, which mode, what keys, what the peer is doing". A build identifier
is "what this is", so the span moves from after #status to beside <h1>. Keeping
it at the tail was loyalty to my own first draft: it read as metadata trailing
#status, and with #hint's `flex: 1 1 auto` fighting for the slack a 40-char
commit SHA there is the thing that crowds the bar.

STYLED, because unstyled is illegible-by-default in a design system. It takes
`var(--dim)` -- the same token #hint and #status use, so it tracks both themes
by construction rather than by a hand-picked colour -- at 0.72rem, and clamps
to 14ch. Measured under Chromium on the merged tree: a tag renders whole
(scrollWidth == clientWidth, 69px), a 40-char SHA truncates to a 13-character
prefix with an ellipsis, and `header` does not overflow in either case
(scrollWidth == clientWidth == 1280). Contrast of rgb(107,103,95) on
rgb(244,241,234) is ~5.0:1, above AA for small text. Selecting the text still
copies the whole SHA -- text-overflow is a rendering clamp, not a truncation.

NOT AN ID. `improvise-web.tar.gz` puts the token `improvise-web` in the tree,
and AGENTS.md's audit matches `improvise-[a-z0-9]{3,8}` -- so the artifact name
resolved to no ticket and read exactly like a dead id, which AGENTS.md calls
worse than one. `improvise-installer.sh` escapes only by being ten characters,
which is luck rather than a rule, so the exclusion list gains `web` and the
paragraph gains the general case.

RELEASING.md (which arrived in this same merge, from improvise-gx5d) listed
what a tag publishes and was made wrong by omission; it now names the web
capture and says plainly that it is not what Pages is serving.

Verified on the merged tree: 1631 cargo tests over 37 binaries, node --test
47 -> 50 (so #120's three new shell tests survived the merge rather than being
silently dropped), 42 Playwright tests green under Chromium, and the stamp
gate re-proved by injecting the missed substitution again -- a header rewrite
does not carry the old fault injection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:01:08 -07:00
Edward Langley 718f1ab2a9 docs(adr): thirteen, not fourteen — mode_style's default-chrome arm, counted
context/code/tui.md says fourteen variants take the default chrome. The arm
lists thirteen, and 13 + Editing + RecordsEditing + CommandMode + TileSelect
is the 17 AppMode variants. The ADR now says thirteen and names the stale
claim, rather than inheriting it — a count in context/ is read as code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:01:05 -07:00
Edward Langley c7f7ea49b4 docs(adr): ADR 004 — faces, so appearance is UI-agnostic data
Fixes a vocabulary and four constraints so incremental theming work cannot
paint the repo into a corner. Proposed, not accepted: the user decides.

The hard clause is the terminal palette. 123 of the app's 125 ratatui Color
literals are NAMED colours, so the user's terminal theme applies to improvise
for free today; a format carrying only RGB would end that silently. A colour
attribute is therefore Default | Named(slot) | Rgb, the shipped theme states
only the first two, and a browser resolves a slot through a 16-entry table
that is where PR #120's palette meets the face system.

The role vocabulary is derived from the call sites rather than designed, and
two plausible readings of MinibufColor turn out to be wrong: cyan is the
formula prompt and not search, and the green/yellow split does not fall on
"editing vs prompting" — item-add is green while category-add is yellow.
That contradiction is handed to the user as an open question rather than
settled quietly, because the face names outlive the colours.

Also records why the ticket's own cheapest first step does not survive the
call sites: there is no three-way role split that fits the eight MinibufColor
construction sites, so the honest move is a face per text-entry mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:59:52 -07:00
Edward Langley 1ac2a13b6c Merge main (#120 ui/shell-polish) into feat/oujb-web-in-release
#120 restyled the page and rewrote #backend-notice as a <details>; <header>'s
markup is untouched, so #build-version lands where it was written. What the
merge actually has to answer is the CSS, and the stamp gate after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:56:32 -07:00
Ed Langley f0687b1774 Merge pull request #120 from fiddlerwoaroof/ui/shell-polish
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Browser shell polish: a dismissable notice, a raised minibuffer, and a styled page
2026-09-21 22:55:28 -07:00
Ed Langley 17a8ed66c6 Merge pull request #119 from fiddlerwoaroof/fix/release-preflight
ci: a release procedure, the preflight that enforces it, and the blind spot its own repair opened
2026-09-21 22:55:22 -07:00
Edward Langley de24c65119 fix: the build stamp's check must not depend on a title attribute
Self-review of the previous commit. The assemble-stamp pattern spelled the
whole opening tag, including the title text, so a purely cosmetic edit on
ui/shell-polish -- which is rewriting this header right now -- would have
failed the assembly with a message about a missing cache-bust stamp.

`id` moves last in the tag and the pattern is `id="build-version">TOKEN<`,
which still verifies the token is inside that element and nothing else.
Re-proved discriminating by injecting the missed substitution: exit 1 with
`assemble: refusing to ship; unstamped: .../app/index.html`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:49:13 -07:00
Edward Langley 7d12ada45d docs: strip the tag inventory; a verified fact still has a shelf life
v0.1.0-rc3 no longer exists anywhere -- not local, not gh, not origin, not
gitea. Verified here independently before changing anything; the releases are
untouched (rc1, rc2, and there never was an rc3 release).

THREE MEASUREMENTS OF ONE FACT IN ONE SESSION, and only the first was careless:

  1. present on all three remotes and locally  (from aa4e901's commit message)
  2. on gh alone                               (git ls-remote, this branch)
  3. nowhere at all                            (git ls-remote, now)

Reading 1 was asserted rather than asked, and correcting it was right. Readings
2 and 3 were each taken correctly, by two different people, and they disagree --
because the tag was deleted in between. So the lesson this section reached for
was too weak. It is not "ask rather than assert"; it is that A REMOTE-SHAPED
FACT HAS A SHELF LIFE EVEN AFTER YOU ASK. A verified answer decays, and prose
cannot distinguish a stale line from a live one, so a reader following
RELEASING.md at 2am has no way to tell which they are holding.

WHAT CHANGES. The present-tense inventory goes, everywhere I put it:
RELEASING.md's "It is on gh only; origin and gitea carry rc1 and rc2", the same
clause's "the mirrors did not agree", tooling.md's "rc3 is on gh alone", and
"pushed to gh" in both tooling.md and the script header -- that last one named a
specific remote too, and we do not actually know which remotes the push reached.
rc3 survives as a plainly historical example: it was pushed while every crate
still said 0.1.0-rc2, dist refused, no release was built. Nothing about where it
is now.

WHAT STAYS. The instruction to ask -- `git tag -l`, `git ls-remote --tags
<remote>` -- because that is the part that ages well: it tells the reader to
look rather than to believe. And the record that aa4e901's claim was wrong when
checked, which is durable precisely because its subject is a fixed text. Note
which half of the earlier correction survived and which did not: "the commit
message was wrong" is permanent; "the tag is on gh alone" was true and is now
false. The keeper is the one whose subject cannot change.

Both files now say explicitly that they record no inventory, so the next editor
knows the absence is deliberate rather than an omission to helpfully fill in.

The principle never decayed and is untouched: a wrong tag is left dead and the
next version cut; a published tag is not moved.

No version bumped, no tag cut, no tag deleted by me.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:42:00 -07:00
Edward Langley 90ccfb0154 feat: attach the assembled site to every release, and let the page name its build
improvise-oujb. A tagged release carried four native tarballs and nothing for
the web; the site shipped only by ci.yml's pages-deploy, on a push to main.

Pages keeps tracking main (user, 2026-09-22: "I like Pages showing the latest
main for now"), so this is a CAPTURE, not a second deployment: the tarball is a
self-hostable copy of the site as of the tag and a rollback target, not what a
visitor to the published site is running. pages-deploy is untouched.

Mechanism is dist's own [[dist.extra-artifacts]], so it survives `dist init`:
`dist plan` under the pinned 0.30.4 lists improvise-web.tar.gz beside the four
platform tarballs and `dist generate` leaves release.yml byte-identical. The
build command is scripts/build-web-release.sh, which calls web/build.sh and
web/assemble.sh -- the same two scripts ci.yml and a developer run, so there is
no second way to build the bundle. Its toolchain pins come from
scripts/web-toolchain-versions.env, which ci.yml's build job now reads too.

`github-build-setup` was measured and does NOT help: it injects into
build-local-artifacts only, which is the wrong job for a global artifact.

The page now names the build it was assembled from in #build-version, stamped
by web/assemble.sh with the same token as the bundles and verified by the same
scripts/assemble-stamp.sh gate. An unassembled tree honestly reads "unstamped".

Verified by running `dist build --artifacts=global` locally: the script ran,
the site assembled, and dist placed improvise-web.tar.gz in target/distrib.
Not verified: a real tag push, which would require cutting a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:37:05 -07:00
Edward Langley 0930e9e360 Merge remote-tracking branch 'gh/main' into ui/shell-polish
# Conflicts:
#	context/test-evidence/clients.md
2026-09-21 22:34:08 -07:00
Edward Langley 52f615d14d fix(web): an error cell must not lose its colour to the dimming or the selection
Found by reviewing the restyling before pushing it, and demonstrated before
it was fixed.

`td.error` and `td:not(.editable)` used to set DIFFERENT properties — colour
and opacity — so both applied and a dimmed error cell was a dimmed red. The
restyling made the dimming a colour too, at the same specificity and below
`td.error` in the file, so an error in an aggregate cell rendered plain grey:
rgb(107, 103, 95) where the error colour is rgb(168, 50, 45). `td.selected`
and `td.typing` did the same by setting `color: var(--fg)`.

`#grid td.error` is the only place on this page where colour carries MEANING
rather than emphasis, and it is the one a reader takes at face value: a
circular reference or a `mixed` aggregate reading as an ordinary number is
worse than it reading as nothing.

So the dimming moves ABOVE the semantic colours, and a selection is a ring
and a tint and never a colour. That is the class of defect a stylesheet has
and a program does not — two rules that were independent become one property,
and nothing in any language fails.

Also: an expanded notice now caps at 40vh and scrolls. `main` is free to
shrink to nothing, so on a short viewport opening it could have pushed the
grid out entirely.

The test is aimed at the STYLESHEET rather than at a document, because the
question is which rule wins and a fixture per combination would be three
documents testing three documents. It appends a <td> carrying each
combination to the real page's real table and compares two controls taken the
same way, rather than pinning absolute colours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:33:02 -07:00
Edward Langley d8eca0f0af docs: a shared failure signature is not a shared cause (my own overreach)
The paragraph added two commits ago said this version/tag disagreement "has been
hit three times across two releases". I did not check that. What the tag history
actually supports is narrower, and the gap is the same asserted-not-asked error
the paragraphs on either side of it exist to correct -- which is why this is a
commit rather than a quiet edit.

WHAT IS VERIFIED. All three failed tag pushes carry the identical signature:
plan failure with build-local-artifacts, build-global-artifacts, host and
announce all skipped. Checked per job through `gh run view --json jobs` on each
of the three runs, not inferred from the 14s/21s durations, which was how the
claim got made.

WHAT IS NOT. Only rc3's CAUSE was reproduced, by running dist plan against the
tag. rc1's logs aged out at 90 days and cannot be read now, and its two failures
sit on commits titled "feat: cargo dist info" and "chore: update version" --
initial setup, where plan could as easily have failed on an incomplete dist
config as on a version/tag mismatch. A shared signature is evidence of a shared
shape, never of a shared cause.

So the honest claim is: this failure SHAPE has cost four pushes across two
releases, and the one time anyone diagnosed it, it was the version/tag
disagreement. That is ample reason for RELEASING.md and for a check before the
irreversible step. It is not a claim that the same mistake was made three times,
and the paragraph now says which half is which and names its own first draft as
the error.

Also dropped: an attribution of the earlier failures to a particular person,
which the run list does not establish and which the argument does not need.

No version bumped, no tag cut, no tag deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:31:34 -07:00
Edward Langley b5a4d227fe docs: rc3 is on gh alone, not "all three remotes"
FOUND BY RUNNING THE COMMAND THE CLAIM IMPLIED. `git ls-remote --tags` against
each of gh, origin and gitea: v0.1.0-rc3 exists on gh ONLY. origin and gitea
carry v0.1.0-rc1 and v0.1.0-rc2 and nothing else.

The claim originates in aa4e901's commit message and was repeated from there
into context/code/tooling.md and, an hour ago, into RELEASING.md by me -- three
copies of a fact nobody had asked a remote about. That commit message is
superseded on this point and cannot be corrected, which is the case
context/design-principles.md section 6 makes for not letting an argument live
only in one; the correction is therefore recorded in tooling.md, naming the
commit.

The irony is local. The false claim sat two paragraphs from the sentence "an
empty log is a statement about RETENTION, not about history", which is the same
failure in the same section: a fact about a remote asserted instead of asked.
Writing that sentence did not stop me repeating the neighbouring instance of it,
which is the more useful half of this commit.

What it changes and what it does not. The rule is untouched -- a published tag
is not moved, and gh is the remote carrying the release machinery, so the
preflight's reason for existing is unaffected. What moves is the SCOPE of any
cleanup, from three remotes to one, and that is precisely the sort of detail a
reader following RELEASING.md at 2am would act on. RELEASING.md now says which
remote and tells the reader to run ls-remote rather than assume the mirrors
agree -- they did not.

No version bumped, no tag cut, no tag deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:29:57 -07:00
Edward Langley 8ad5c0806d feat(web): dismissable notice, a raised minibuffer, and a styled shell
Three things the user reported after looking at the deployed page, in the
order they cost a visitor something.

The deployment notice was ten dense lines of configuration reference —
?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the
category tree and the grid on every visit: about 230px of an 820px window.
It is now a <details>, so the collapsing is the BROWSER's (keyboard
operable, and correct with app.js absent or still loading) and the only
thing the page adds is remembering the answer. It starts COLLAPSED, and
collapsing withholds nothing: the one fact a first visitor needs — the
document lives in this tab, is saved to this browser's private storage and
never leaves the machine — is the <summary>, which never collapses.

What a stored value means is a decision and lives in shell.js; touching
localStorage is a browser API and stays in app.js, wrapped, with every
value that is not one of the two spellings read as "unanswered".

The minibuffer was a 14px line in the page's bottom-left corner, up to
800px from the cell being typed into. It is now a raised bar over the
grid. The CONTENT is untouched — RenderMinibuffer is built from
`state.view.mode.minibuffer()`, the same semantic value the TUI draws — so
this is position, weight and contrast, and there is no second derivation of
what a prompt says. MinibufColor still resolves here exactly as
draw.rs::minibuf_fg resolves it for the terminal.

style.css stops being "legibility only": layered surfaces, one accent, a
filled badge for the mode, chips for the two axis strips, one selection
treatment, tabular numerals in the grid, and a focus ring on the one
keyboard surface the page has. The palette is the landing page's, so the
two read as one product.

Asserted (harness/tests/contract.spec.js, Chromium): the notice starts
collapsed and remembers, in BOTH directions across a reload; it opens from
the keyboard, which the page's own keydown handler must not claim; the
minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static
bottom bar cannot satisfy — and is larger than body text. Each was proved
discriminating by injecting the fault and watching it fail.

NOT asserted, and named as such in context/code/clients.md: colour,
contrast, spacing, weight, the grouped and multi-level header DOM, either
theme, and the localStorage-throws path. Those are argued and read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:29:18 -07:00
Edward Langley 50d93eb299 docs: a release procedure, and the overreach the tag history corrects
THE ROOT CAUSE WAS A MISSING DOCUMENT, NOT A MISSING CHECK. Nothing in this repo
said "bump the version before you tag": release.yml is cargo-dist's autogenerated
mechanism, README's Installation section describes the result, and plan.md
Phase 3 carries one-time SETUP instructions from the launch plan. So the
preflight added in the previous commits is the second half of the fix. RELEASING.md
is the first, and it is a checklist rather than an explanation -- the reasoning
stays in context/code/tooling.md section 4, because a checklist followed at 2am
is not read for its argument and one carrying it does not get followed. Its own
file rather than a README section because README Installation addresses someone
INSTALLING and this addresses the one person PUBLISHING.

THE DIAGNOSIS WAS UNDERSTATED AND IS NOW CORRECTED AT THE SOURCE. The earlier
passage rested on a local `dist plan` run, which is the weaker half. v0.1.0-rc1
and v0.1.0-rc2 both carry COMPLETE published GitHub Releases from 2026-04-11 --
all four targets, .sha256 files, sha256.sum, dist-manifest.json, source.tar.gz
and improvise-installer.sh. The workflow is not merely expected to work; it
demonstrably ran end to end the last time a tag and the version agreed.

Nearly the opposite was concluded from an empty `gh run list`: Actions logs age
out at 90 days and those tags are five months old. An empty log is a statement
about RETENTION, not about history -- the artefact a run published outlives the
run, so ask the releases before asking the runs.

AND THE MISTAKE IS RECURRENT, which is the real argument for writing a procedure
down. `gh run list --workflow=release.yml --event=push` keeps the whole tag
history: rc1 FAILED TWICE (14s each) before succeeding on the third push
(3m25s), rc2 succeeded first time (3m9s), rc3 failed (21s). Three occurrences
across two releases, all the same version/tag disagreement. A one-off justifies
a note; a third justifies a document and a check before the irreversible step.

The failure signature is recorded because it is cheap to read and easy to
misread: `plan` fails in ~20s with the four jobs behind it skipped -- verified
on the rc3 run, where plan is `failure` and build-local-artifacts,
build-global-artifacts, host and announce are all `skipped` -- against ~3
minutes for a real build. The workflow ALSO triggers on pull_request, where it
plans without a tag and passes in ~20s, so a green 20s run on a PR and a failed
20s run on a tag look alike at a glance and mean opposite things.

Every claim above was read off `gh`, not inferred. Step 5 of RELEASING.md said
"if plan succeeds and the four jobs skip" in a first draft; the run says plan
FAILS, and it was corrected rather than kept as a plausible-sounding shape.

No version bumped, no tag cut, no tag deleted, the rc1/rc2 releases untouched.
Whether the stale rc tags are ever removed is left as the user's call and
deliberately not made a step: it is not part of cutting a release, and the
principle that covers it -- a published tag is not moved -- is stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:28:15 -07:00
Edward Langley 708d335fa7 ci: the preflight repair relocated its blind spot; close it
REVIEW OF THE PREVIOUS COMMIT, NOT NEW MACHINERY. The diagnosis it states was
re-verified in both directions before anything here was written: `dist plan
--tag=v0.1.0-rc2` gets past the tag/version check (stopping at the pinned
cargo-dist-version, as expected) and `--tag=v0.1.0-rc3` reproduces the CI error
verbatim, naming `--tag=v0.1.0-rc2` as the fix in its own help text. The
workflow was never broken. Both claimed fault injections also reproduce exactly:
the comparison replaced by `if false;`, and `expected` derived from the tag, each
produce four failures and exit 1 -- exit code read unpiped, since a pager's
status would mask it.

WHAT THE TEST COULD NOT SEE. Disabling the dirty-tree guard outright left all
six checks green and the script exiting 0. That guard is not decoration: it is
what stops a tag naming a commit that was never tested, and its failure is
irreversible in the same way the version mismatch is.

The cause is the previous repair. Moving the negative cases to a clean throwaway
checkout is what made the tag comparison reachable at all -- and it thereby
removed the only circumstance in which the test could observe the tree guard.
The guard whose PRECEDENCE caused the original vacuity became the one thing the
suite could not see. The vacuity and its repair are not two events; the repair
relocated the blind spot rather than closing it.

So the new check is aimed the other way: a tag that MATCHES the version, run
against a dirtied checkout, so the comparison passes and a refusal can only be
the tree. Verified discriminating -- with the guard disabled it fails with exit
1, and the two faults are independent in both directions (comparison disabled
leaves the tree check passing, guard disabled leaves the three tag checks
passing), so the seventh check is not a restatement of the other six.

Also recorded, rather than guarded: the `dist plan` second opinion can be
disabled with the suite green, because every input the test feeds it has already
been refused by the textual check. That follows from the arm being optional by
design -- pinning it would mean installing a dist matching cargo-dist-version,
the very mismatch the script tolerates on purpose. Stated so the silence is not
mistaken for coverage.

Two corrections of fact. The CI step lives in the `kb` job beside
pr-watch-test.sh, where this repo keeps its check_the_checkers -- not in `test`;
tooling.md now says so. And the awk version parser was probed rather than
hardened: `version.workspace = true` yields empty, which the root path refuses
loudly and the per-crate loop skips; a non-first [package] block and build
metadata both parse correctly; a trailing comment on the version line mis-parses
but fails CLOSED and prints the mis-parsed value in the refusal. No manifest in
this workspace has one, so nothing was changed for it.

No version bumped, no tag cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:25:37 -07:00
Ed Langley d17ad0feb1 Merge pull request #117 from fiddlerwoaroof/fix/cfly-browser-view-history-key
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Give the browser a key that reaches the view history (improvise-cfly)
2026-09-21 22:22:04 -07:00
Ed Langley 94a06efc94 Merge pull request #118 from fiddlerwoaroof/bd-updates
chore: update issues.jsonl
2026-09-21 22:21:47 -07:00
Edward Langley c720565372 chore: update issues.jsonl
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
2026-09-21 22:21:03 -07:00
Edward Langley aa4e901bd2 ci: a release preflight, because the tag is already public when CI refuses
THE WORKFLOW WAS NEVER BROKEN. v0.1.0-rc3 was pushed to all three remotes
while every crate still said 0.1.0-rc2; dist matches a tag against the root
package version, refused with "This workspace doesn't have anything for dist
to Release!", and the four jobs behind `plan` skipped. Proved in both
directions locally: `dist plan --tag=v0.1.0-rc2` gets past the check and
`--tag=v0.1.0-rc3` reproduces the CI error exactly.

So this changes no trigger and no job graph. What it adds is a check that runs
BEFORE the irreversible step. dist named the right answer in its own error
text; the problem is when that answer arrives -- a tag is public and immutable
by the time CI reads it, so the repair is a new version rather than an edit.

scripts/release-preflight.sh refuses a dirty tree, compares the intended tag
against the root package version, runs `dist plan` as a second opinion when
dist is on PATH (and says so when it is not, since an absent dist must not
read as a pass), and prints the tag-and-push commands only once it passes.

THE TEST'S FIRST VERSION WAS VACUOUS AND THE INJECTION IS WHAT SHOWED IT.
release-preflight-test.sh originally asserted only "did it refuse". Every
negative case still passed with the tag comparison replaced by `if false;`,
because the script refuses a DIRTY WORKING TREE first and this repo's tree is
almost always dirty -- bd auto-exports .beads/issues.jsonl on nearly every
command. Four of five checks were answering "is the tree dirty" while claiming
to answer "does it catch a bad tag".

It now runs the negative cases against a clean throwaway checkout AND asserts
the refusal names the version mismatch, so a refusal for the wrong reason
fails. Two rival faults were injected against the repaired version -- the
comparison disabled, and `expected` derived from the tag itself -- and each
produces four failures and exit 1.

That is design-principles section 7's "a check that looks like the answer,
answering a neighbouring question", found inside a checker written to prevent
that class, within an hour of writing it. Recorded in context/code/tooling.md.

No version is bumped and no tag is cut here, per the user's instruction to
make the workflow work without releasing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:20:34 -07:00
Ed Langley f34334cfca Merge pull request #116 from fiddlerwoaroof/docs/wave-corrections
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
docs: gotcha 38's stated reason was false, and four waves' worth of kb facts
2026-09-21 22:12:10 -07:00
Edward Langley 06f847bb66 docs: four more stale facts in repo-map, found by the h3y7 audit
An audit that had to read the code to answer a completeness question kept
tripping over the map. Each of these is a fact the map states that the tree
does not support, verified before applying:

- gotcha 3 said model_effects sends 28 ModelCommand variants. A brace-matched
  count of the enum's top-level variants returns 24. Same staleness class as
  the gotcha 38 arithmetic corrected in the previous commit.
- gotcha 43 named `reduce_full` twice in the present tense. It has been DELETED;
  `reduce_key` is the survivor. Note the paragraph's own argument PREDICTED
  this ("no production caller anywhere in the workspace, so pub(crate) would
  have made it dead code") -- so the deletion vindicates the reasoning and
  falsifies only the tense. Recorded that way rather than quietly renamed.
- the File Inventory's counts for ui/reduce.rs (1411 -> 1080), ui/effect.rs
  (2731 -> 3034) and resolve.rs (2477 -> 2765) had drifted, two of them by
  more than the 10% the inventory's own preamble warns about.
- "50+ effect types" is now 58, with the number that matters recorded beside
  it: a brace-match over `impl Effect for` in effect.rs finds only 49, missing
  8 simple_effect! expansions and SetTransientPrefix in command/keymap.rs. All
  9 it misses are ViewOnly -- the half the ViewBack bug lived in. gotcha 38
  warns that the naive awk OVER-reports; the brace-matcher it recommends
  instead UNDER-reports, and nothing said so until now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:48:33 -07:00
Edward Langley 5a3d1d1109 docs: gotcha 38's own arithmetic was stale in the same way (Codex P2)
The paragraph's head says 26 model effects and 24 ModelCommand variants; its
tail still said "29 - SwitchView - these two = 26", which subtracts SwitchView
and forgets ViewBack/ViewForward. So the corrected gotcha contradicted itself
and the enum's own variant count.

Verified independently before applying: a brace-matched count of ModelCommand's
top-level variants in improvise-protocol/src/reduce.rs returns 24.

Worth noting what this is: the correction landed in the same commit range as the
sentence it contradicted, which is exactly the "one copy of an argument updated
and not its siblings" failure gotcha 48 names. A paragraph about a derivation
going unchecked for completeness had its own sum go unchecked.

Found by ChatGPT Codex reviewing PR #116.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:47:32 -07:00