Commit Graph

1442 Commits

Author SHA1 Message Date
Edward Langley f52c2df0c7 fix(dist): the extra-artifacts build honours its script's shebang (improvise-f9qf)
`build = ["sh", "scripts/build-web-release.sh"]` overrides the shebang the
same way the old local gate did, so the script ran under dash on the release
runner while declaring bash. It is the seventh such site and the only one
outside a shell file; scripts/checkers.sh cannot see a TOML array, so this one
is fixed by reading rather than by a gate.

The script is mode 100755, so dropping the `sh` runs it through its own
`#!/usr/bin/env bash`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 16:08:33 -07:00
Edward Langley 2151fe0759 Merge gh/main into fix/f9qf-pin-the-shell 2026-09-22 15:50:32 -07:00
Ed Langley 42ec30df57 Merge pull request #135 from fiddlerwoaroof/fix/0ygd-tab-new-measure
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): naming a measure inserts a column, so Tab must not advance (improvise-0ygd)
2026-09-22 15:50:23 -07:00
Edward Langley cb6866b2c6 fix(scripts): one shell and one environment for every checker (improvise-f9qf)
`scripts/pr-reviews-test.sh` passed 19/19 locally and 2/19 in CI on the same
commit, because a multi-assignment `local` is not left-to-right on bash 5
while macOS's bash 3.2 tolerates it. The obvious half of the fix is pinning
the shell. The half that decides the design is not.

.envrc DOES already put a nix bash 5.3.15 on PATH, which is CI's semantics --
and it is not enough. `pkgs.mkShell` exports `name`, `out`, `outputs`,
`system`, `shell`, `builder`, `stdenv`, `phases` and about twenty more
generically-named variables that CI does not have, so a script reading one by
accident under `set -u` is SILENT in the dev shell and fatal in CI. That is
exactly what happened: the broken clause read `$name`, and in the dev shell
`$name` was `nix-shell-env`. Measured at 051f541^ on one machine:

  macOS /bin/bash 3.2                             19 passed, 0 failed
  direnv exec . ./scripts/pr-reviews-test.sh      19 passed, 0 failed
  the same, with `name` unset                      2 passed, 17 failed

Two reasons for green, one of them the correct interpreter. A dev shell is a
third environment, not a reproduction of CI.

A nix shebang was measured and declined, on availability rather than speed:
warm, `nix shell nixpkgs#bash --command true` is 61.8ms against an ambient
5.1ms, and `nix-shell -p bash --run true` -- the manpage form -- is 675.2ms,
132x, inside git's merge machinery for beads-merge-driver.sh. But only the
`kb` job installs nix; build, test, wasm, js, harness-lint and browser have
none, so a nix shebang needs an installer step on three more jobs to reach a
bash those runners already ship. zsh is not a shebang change and the runner
does not have it either. Both declined in context/code/tooling.md section 5.

What lands instead pins interpreter and environment together, in one place:
scripts/checkers.sh is the one entry point for every check_the_checker, used
verbatim by CI and by the local gate. It pins a bash >= 5 (ambient first, nix
only when the ambient one is older, exit 3 when neither), scrubs the
environment to an allowlist, and refuses any shell shebang in scripts/ that
is not `#!/usr/bin/env bash`. The pinned bash goes on the front of PATH so
inner invocations get it too -- which is where the fault actually lived.

The second undetected split is closed with it: the four `#!/bin/sh` and one
`#!/usr/bin/env sh` scripts are now bash, and the six places that ran a
script through an explicit `sh` -- overriding the callee's own shebang, which
is the same disease -- now invoke it directly.

Verification, injected against the real script: restoring pr-reviews'
multi-assignment `local` leaves `sh scripts/pr-reviews-test.sh` at 22/22 and
`direnv exec . ./scripts/pr-reviews-test.sh` at 22/22, while
`./scripts/checkers.sh pr-reviews` reports 2 passed, 20 failed, exit 1 --
matching the 2/19 CI actually reported. scripts/checkers-test.sh is twelve
cases, each injecting rather than asserting; the pair that carries it runs
one fixture both ways, so the `set -u` case is a test of the runner and not
of `set -u`.

The old local gate was `for t in scripts/*-test.sh; do sh "$t"; done`, which
overrides every shebang with /bin/sh -- so it ran every suite under bash 3.2
in sh-mode regardless of what the file said. It is replaced by
`./scripts/checkers.sh`.

Workspace suite unchanged at 1689 passing over 38 binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:14 -07:00
Edward Langley d5201d984b fix(command): naming a measure inserts a column, so Tab must not advance (improvise-0ygd)
Typing a name into the trailing `+` column of a records grid and pressing Tab
created the measure and then threw the user across the grid: it added a record
row nobody asked for and parked the cursor at (row + 1, 0), leaving the column
the commit had just made unreachable. Enter on the same cell was correct.

The cause is a stale index, not an abort and not an ordering. `AdvanceDir::Right`
reads `at_bottom_right` off `CursorState::col_count`, the PRE-commit column count
— `apply_effects` rebuilds the layout only after the whole batch (gotcha 23). A
`+measure` commit inserts its column at the cursor's own index and pushes the `+`
one to the right, exactly as `+cat` does, so after the commit the cursor is no
longer at the right edge.

`context/code/command.md` argued the opposite in prose: that the measure `+` has
no column to its right for an index to shift into, and that both advances are
already special-cased at the bottom-right edge. The first is false — the column
that shifts is `+measure` itself — and the second names the bug rather than a
safeguard. That paragraph is corrected here.

`inserts_a_column` therefore covers both creating columns, and takes the value,
because an empty name creates nothing: `created_name` is now the one home of that
rule, read by `add_measure`, `add_category` and the predicate. Splitting on the
buffer rather than the column is what keeps bulk entry, whose Tab-at-the-bottom-
right path is reachable only from `+measure` — it is always the rightmost column
— by tabbing onto the `+` with nothing typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:43:09 -07:00
Ed Langley 22ab58f652 Merge pull request #133 from fiddlerwoaroof/fix/pr-reviews-inline
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(scripts): pr-reviews reads all three review surfaces
2026-09-22 14:04:09 -07:00
Ed Langley a799e244af Merge pull request #134 from fiddlerwoaroof/fix/jz6w-keyoutcome-frame
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(protocol): a key can ask its host to save (improvise-jz6w)
2026-09-22 13:59:10 -07:00
Edward Langley 051f54111b fix(scripts): a local is not left-to-right on bash 5, and --paginate emits one array per page
Both found by ChatGPT Codex on PR #133, both invisible on macOS, both
arriving as exit 2 with no output -- jq's answer to an invalid --argjson,
so a shell fault at the top of the script wears jq's exit code.

`local n="$1" name="$2" f="$DIR/$n.$name.json"` creates every name before
running the assignments on bash 5, so $n is unbound in the third clause
and set -u kills the shell. The dead command substitution then hands
--argjson an empty string. Works on macOS bash 3.2: the suite passed
19/19 locally and 2/19 in CI on the same commit. Each local now gets its
own statement.

`gh api --paginate` emits one JSON array PER PAGE, so a comment thread
past the page size is several concatenated documents that --argjson
rejects the same way. Both fetch paths now flatten through one
pages_to_array (`jq -s 'add // []'`), which is a no-op on a single page.

The fixture path shares that helper, which is the only reason a fixture
can test it: a fixture holding a single array would exercise a shape the
live fetch never produces. Three cases added, and removing the helper
from the fixture path fails exactly those three, the wait case with the
live exit-2 signature. Suite is 22 cases.

No PR in this repo has yet had enough inline comments to reach a second
page, so nothing but the test will see that one before a user does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:57:34 -07:00
Edward Langley 7e6cac0885 fix(protocol): a key can ask its host to save (improvise-jz6w)
`ClientFrame::Save` landed with improvise-368h and `Host::handle` has answered
it ever since, but `KeyOutcome` was `Local | Upstream | Ignored`, so no
resolver could say "send this frame". `save` therefore sat on
`the_command_names_that_still_need_the_host_are_these`, resolved to `Upstream`,
and `Host::handle` answers `Upstream` with an empty vec — gotcha 47's black
hole. An implemented server feature that no key could reach.

`KeyOutcome::Request(Request)` closes it, with `Request::Save` the one member
and `Request::frame` the one mapping to an envelope. The variant carries a
narrow enum rather than a whole `ClientFrame` on purpose: a resolver handing
out arbitrary frames could compose a `Viewport` (the page's fact, not the
keymap's) or a `Commands` batch, which would be indistinguishable on the wire
from a `Local` while skipping the local fold `Local`'s contract requires.
`requested` is checked first in `step`, so no name is in two lists, and a
request cannot travel inside a `Binding::Sequence` — a sequence is one frame.

`save` leaves the host-needed list in the same commit, as gotcha 47 requires.

The guard is end to end, in `browser_round_trip.rs`: Ctrl-S on a real `Client`
yields `ClientFrame::Save`, and a real `Host` over a real `App` writes the
document to storage. Two half-tests that never meet is exactly what left this
gap open. Reasoning in `context/code/clients.md`; evidence and the injected
rivals in `context/test-evidence/clients.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:52:36 -07:00
Edward Langley 0e10af005a docs(pr-review): a commit_id is per-comment, not per-review
Found by dogfooding the new script on PR #122, the PR that prompted it.
One review submitted between 19:48 and 19:53 carries five inline
comments: three name 246e8f4a, two name 6a4c7dcd, and the body names
246e8f4a. GitHub pins each comment to the file version it was written
against, so a comment on an outdated diff position keeps the older
commit while its siblings move on.

Two consequences worth writing down. The per-entry "EARLIER head" flag
is honest but can make a current review look partly stale. And a head
comparison against the review BODY is not a proxy for its own comments:
on that PR the body read as an earlier head while two of its rulings sat
on the current one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:52:11 -07:00
Edward Langley a47310d50f fix(scripts): pr-reviews reads all three review surfaces (improvise-xgra)
It printed review BODIES only and was blind to inline review comments --
the surface a human reviewer is most likely to use. On PR #122 the user
reviewed with the body "Generally good, added comments inline." and five
substantive rulings underneath it; pr-reviews printed the one-liner, and
an agent reported the PR as awaiting review while the answers sat on it.

pulls/{n}/comments and issues/{n}/comments join pulls/{n}/reviews, all
three interleaved in time order, each inline comment carrying its path
and line (falling back to original_line, which is what an outdated
comment has left).

The exit contract is unchanged: 0 means something exists against the
current head, 1 means the wait elapsed, 4 is usage. An inline comment at
the head now satisfies --wait -- it carries a commit_id and is exactly
the case that failed. An issue-level comment does not: it names no
commit and so makes no claim about which tree was read.

The usage's exit-code warning is widened from "do not pipe into
head/tail" to the real rule: nothing may follow the script in the same
`;` list either, because a trailing `echo "exit=$?"` makes the status
the echo's.

scripts/pr-reviews-test.sh is the check_the_checker, fixture-driven and
needing no network, wired into CI's kb job beside pr-watch-test.sh and
release-preflight-test.sh. Five faults were injected and watched to
fail; context/test-evidence/tooling.md records which case caught each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:49:36 -07:00
Ed Langley d672985b07 Merge pull request #132 from fiddlerwoaroof/docs/adr-004-rgb-ruling
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-22 13:19:13 -07:00
Edward Langley 85c273952f docs(adr): keep the Rgb arm — the user ruled on the one choice left open
"Keep full color support in themes" (review on 6a4c7dc). The draft left
dropping the Rgb arm as a live choice, because clause 4's protection against
losing a user's terminal theme is a property of the SHIPPED THEME rather than
of the TYPE — weaker than the unconstructible-bad-state shape this repo
usually reaches for. The answer settles it: the arm stays.

Recorded as a REJECTED ALTERNATIVE with its reasoning rather than deleted,
and as the sixth entry in "What the user decided". The substance of the
change is small; what it fixes is that the weakness is now a CHOSEN cost
rather than an unexamined one, which is the difference between a trade-off
and an oversight.

One distinction stated explicitly because it is the easy misreading: "full
colour support in themes" is about what the FORMAT MAY EXPRESS; the
shipped-theme rule is about what IMPROVISE'S OWN DEFAULT SAYS. They are
independent and clause 4 keeps both. If the intent was also that the shipped
default use absolute colour, that is a different change and would reopen the
regression clause 4 exists to prevent — so it is flagged rather than
inferred, cheap to correct either way.

Sibling sweep, since this touched a clause, an alternative and a deferral:
the status line, the intro and the section intro all claimed five answers
and now say five plus the one they left standing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:13:03 -07:00
Ed Langley 56632430db Merge pull request #122 from fiddlerwoaroof/docs/adr-004-faces 2026-09-22 13:11:32 -07:00
Edward Langley 6a4c7dcd8e docs(adr): ADR 004 accepted — the user answered all five open questions
Status proposed -> accepted. Each answer is folded into the clauses and
recorded verbatim in a new "What the user decided" section, with what it
settled and where it now lives. The questions are kept: a decision whose
alternatives have been deleted reads as though it was never in doubt.

Two answers changed the decision rather than confirming it.

REVERSE IS IN, AND MY OBJECTION TO IT WAS WRONG (Q3). The draft excluded it
because "CSS, Swift and Qt would have to compute it, which makes resolution
stateful". The user asked whether a class overriding custom properties could
do it. It can, and working it through shows the objection was mistaken: the
swap happens in the RENDERER, after resolution, reading the face's own two
custom properties crossed — a re-reference, not a computation — so `resolve`
stays context-free and clause 3 is untouched. The `Default` case, which is
the one that would have broken it, is covered by the var() fallback. No
reversed palette is needed, and neither is `--accent-ink`: a hardcoded ink
token makes the same assertion about the surface that `fg(Color::Black)`
does, one layer up. Swift and Qt swap two values they already hold. The
portable set is six, and the twelve hand-rolled `fg(Black).bg(accent)` sites
stop asserting the ink is black — a latent light-terminal bug.

THE FORMAT IS JSON, AND THE DEFERRAL DISSOLVED RATHER THAN BEING MET (Q5).
It was deferred on the condition that improvise grows a configuration story.
Download-edit-upload is not a config file: no search path, no precedence, no
absent-file policy — it is an export/import pair, the shape export_csv
already has. So the larger question stays unmade. JSON costs no new
dependency (serde_json is already in every crate but improvise-protocol,
where it is optional); the types derive serde in the protocol and the
serde_json call happens at the front ends, keeping the bundle footprint
unchanged. "Editable by hand" constrains the shape: face names as keys,
stable ordering, no surrogate keys. And a published face name is a
compatibility surface — stated as a cost, not only a benefit.

The other three. Q1 (the item-add/category-add split is an accident) is the
one flagged as expensive and went the cheap way: no face name encodes it,
and the default theme makes them agree — which is clause 8's ONE DECLARED
EXCEPTION to the reproduces-today-exactly property, written down because the
migration's whole safety argument is that it changes nothing. Q2 decomposes
the thirteen DarkGray modes into five faces derived from distinctions
AppMode already draws (minibuffer/panel/is_editing), with the three splits
that could not be justified named rather than guessed; the user's "probably"
is respected, since thirteen faces would have been a transcription.
Q4 makes the second slot table optional, and a terminal mostly ignores a
two-table theme because it HAS the user's palette — the tables are the
browser's substitute for one. Detection declined: verified that crossterm
0.29 has OSC 52 but no OSC 11 background query.

Kept deliberately: every rejected alternative; the palette-relative-vs-
absolute clause Codex improved; and the honest statement that the protection
is a property of the shipped theme rather than of the type. The user has not
ruled on dropping `Rgb`, so it is now listed as a live open choice.

Gotcha 48 applied to myself, since this touched five sections: grepped every
claim before changing it, which caught "Seven clauses", the stale
`--accent-ink` alternative, and two headings I had wrapped across lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:03:25 -07:00
Ed Langley 9d2bdd39e6 Merge pull request #131 from fiddlerwoaroof/fix/cli-honesty
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 headless CLI was silent whether it worked or not, and the refusal named the wrong command
2026-09-22 08:38:15 -07:00
Edward Langley ffa1a87cd9 Merge remote-tracking branch 'gh/main' into fix/cli-honesty
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 08:31:19 -07:00
Ed Langley 69c873d3c7 Merge pull request #130 from fiddlerwoaroof/fix/vw47-view-name
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: a new view takes an unused name, derived once for both front ends
2026-09-22 08:30:23 -07:00
Edward Langley 6034a216e1 docs(site): publish the two new context docs, and de-number two stale counts
The `build` job failed, correctly: `scripts/build-doc-site.mjs` requires every
`context/**/*.md` to be either in the SECTIONS manifest or a named omission
(improvise-res9's cure), and the two files this branch added were neither.
Both are now published, beside the TUI in each section.

While there: `context/code/doc-site.md` asserted 'the seven
`context/code/*.md` documents' when there were already twelve, and '501
entries' when the checker reports 801. Both were stale before this branch and
this branch makes them staler, so they are de-numbered rather than re-asserted
— a count in prose that nothing checks is a fact with no owner, and the shape
that stops drifting is not having one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 08:26:12 -07:00
Edward Langley 42cbf0932b docs: name the follow-up, and stop the scratch dir looking like a ticket id
improvise-udt2 is the ticket context/code/cli.md section 3 points at: a status
message carries no severity, so a refusal that does not abort cannot set a
non-zero exit. It names both disproved classifiers, so the next attempt does
not rediscover them.

And the per-test scratch directory was named `improvise-cli-<tag>`, which
matches the ticket-id shape the AGENTS.md audit greps for. An ID-shaped
non-ID is worse than a dead ID, because the diagnosis is identical and the
repair is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 08:20:48 -07:00
Edward Langley b8e1c138eb fix(command): a refusal named a command the user did not type
improvise-z05h, three bugs stacked on one surface.

`CmdRegistry::resolve` rewrites `formula` to `add-formula` *before* the arity
check runs, so `formula Product Total = Widget + Gadget` was refused with
"add-formula requires 1-2 argument(s), got 6" — a command nobody typed, and
an arity where the user needed to be told to quote.

The name now lives in the one place that knows which of the two was written:
`require_args` no longer takes a `word` and `CmdRegistry::parse` prefixes
`"{typed}: "` onto any `Err`. That removes 41 name literals from `cmd/`,
which is the argument gotcha 15 already makes about `registry.rs`. The
`AddFormulaCmd` validator states the shape rather than the arity.

`interactive` deliberately does not prefix — `dispatch` in
`command/keymap.rs` already formats `"{name}: {e}"` with the bound name
(improvise-55l5, PR #126), and doing both would double it.

All three documented spellings of `:formula` taught the unquoted form and so
were ones the parser refuses. help.rs carried the quick-start line **twice**,
on two different pages, which is gotcha 48's "changing a fact stated twice is
a grep, not an edit".

`every_ex_command_the_help_pages_show_is_one_the_parser_accepts` closes the
surface rather than the instance: `HelpLine::Keys` rows are derived from the
keymap and cannot go stale, but `Literal` and `Accent` rows are prose with
nothing checking them, and that is the whole unguarded surface. Run against
bf83294 it fails naming the Welcome page and the exact line.

What it still cannot see, stated in `context/code/cli.md` §6:
`draw.rs::draw_welcome` builds its lines inside a ratatui render function, so
no walk reaches them; its line was corrected by hand.

Checked and not needed: none of the four asciinema casts in `docs/` shows the
`:formula` ex-command — `formulas.cast` uses the formula panel — so none
inherits this and none needs re-recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 08:18:34 -07:00
Edward Langley c13466f1d9 fix(cli): the headless commands were silent whether they worked or not
improvise-q914 and improvise-j6x8 are one bug seen twice, and the second
measured it: `run_headless_commands` applied effects and never read
`app.view_state.status_msg`, setting `exit_code` only for parse errors. A
successful command and a refused one therefore produced byte-identical
output — nothing — and exit 0 for both. `improvise script` is the same
function, so it shared the hole exactly.

Three decisions, argued in `context/code/cli.md`:

**Everything to stderr.** The tempting split — stdout for a report, stderr
for a refusal — cannot be built honestly, because `status_msg` is a bare
`String` with no severity and both candidate classifiers are disproved by
code already in the tree. "A refusal aborted" is false: `AddItem` writes its
`Err` to the status and does not abort, and that is q914's own example. "A
refusal is anything that spoke" is false too: `cmd/fit.rs` reports the seed
that reproduces a fit on **success**. Guessing between them is the adjacent
check design-principles §7 names.

**Exit 1 on a parse error or an aborted batch, and no further.** Both are
real flags. A refusal that only writes a status is now printed but still
exits 0, because nothing can classify it; the README says so plainly rather
than over-promising, and closing it needs a severity on `ViewState`, which
is a wire change reaching the browser.

**No quiet flag.** Silence is never the default and `2>/dev/null` already
exists.

Also: a run that changed nothing no longer rewrites the document. Saving
unconditionally normalised it — `1e20` to its twenty digits — which turns a
hand-edited fixture into a silent no-op. The gate accumulates
`model_state.dirty` per command rather than reading it once at the end,
because `effect::Save` clears the flag and `[add-item, w elsewhere]` would
otherwise drop the edit.

And the three clap doc strings advertised JSON, which the parser refuses.

`tests/headless_cli.rs` pins all of it at the process boundary, where an
exit code and a stream exist. Six of its twelve tests fail at bf83294. The
rival worth injecting — print on the happy path, stay silent on the
refusal — fails five of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 08:18:17 -07:00
Edward Langley d0ec6a44a1 fix: a new view takes an unused name, derived once for both front ends
improvise-vw47. Create View 2 and View 3, configure View 3, delete View 2,
then ask for a new view: both resolvers spelled `format!("View {}", len + 1)`,
which is the count and not a free name. `Workbook::create_view` replaces
whatever holds the name, so View 3's format reset to defaults and no view was
added -- a symptom that looks like nothing happened.

The name is derived at two sites, `cmd/panel.rs::CreateAndSwitchView` and
`resolve.rs`'s "create-and-switch-view" arm, so fixing one would have left the
TUI and the browser naming different views. `view::unused_view_name` in
improvise-core is the one derivation both now call. It takes an iterator of
names because the browser holds no `Workbook`, only the `ViewListCache` that
`view_summaries` maps one-for-one off `workbook.views`.

`Workbook::create_view` is deliberately untouched. Its replacement semantics is
what the `_Records`/`_Drill` rebuilders need -- both reach it through
`effect::CreateView` on every entry -- so refusing or renaming there would turn
"a view was reset" into "the key does nothing". The argument is in
`context/code/core.md` section 11.

Three tests, written failing first. The helper's own cases pin that a run of
taken names is searched rather than bumped once. The `Cmd` test uses the
ticket's fixture and asserts the count is below the highest name before asking
anything, because the pre-existing sibling test's fixtures all leave the
derived name free -- which is how the bug lived under it. `lockstep.rs`
witnesses the agreement: note that `both_ends` PASSES against the unfixed
source, both ends being wrong identically, so that test carries the count and
format assertions too.

Rivals injected: fixing panel.rs alone fails lockstep on the document text;
starting the search at 1 fails the pre-existing incremental-naming test.

Baseline 1667 passed / 0 failed / 7 ignored over 37 result lines; now 1670.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 08:13:12 -07:00
Ed Langley bf83294e15 Merge pull request #129 from fiddlerwoaroof/fix/label-and-views
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: a Label category survives a save, and a new view does not pivot it
2026-09-22 01:31:52 -07:00
Ed Langley 76d909ec6f Merge pull request #128 from fiddlerwoaroof/fix/bd9o-records-browser
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
The tile bar reaches records mode, where every key was silent (improvise-bd9o)
2026-09-22 01:16:07 -07:00
Edward Langley afbe7e7d1f docs: record the measured generator coverage of the new kind_line rule
context/code/formula-and-io.md already says a new grammar production needs
generator coverage or the proptests quietly stop describing the format.
`kind_line` is an alternative of `category_entry`, so the grammar walk
reaches it with no generator change — but that is precisely the claim the
section exists to say must be checked rather than assumed.

Probed over 500 files from generator::improv_file(): 14 carried a
`kind: label` line and all 14 parsed to a CategoryKind::Label, so each
256-case proptest run exercises the production about seven times.

The probe is not kept. Its rate depends on proptest's internal RNG, so as
a standing assertion it would fail on a dependency bump for a reason that
has nothing to do with the format — the measurement belongs in the
argument, where it can be re-run and corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 01:15:47 -07:00
Edward Langley b0739326f8 review: two sibling passages went stale when the new section landed
ChatGPT Codex on PR #128, correct on both, against head d124f18 and still live
at aa86a48.

The bd9o section says create_column is now explicitly dead. Two older passages
describing the same fact were not updated and still read as though a user key
reached it:

  - clients.md's "classes that remain" bullet: "the records-mode commit — EXCEPT
    the two creating columns". True of `commit`; false as a statement about what
    a browser can do, since the decline moved to the entrance of edit_or_drill.
  - the evidence entry for a_locally_committed_creating_column_agrees_with_the
    _host: its injection is described as failing at "the client must answer this
    key locally", which reads as a user key. The fixture sets RecordsEditing
    itself, so what the test still pins is commit's OWN narrowing and its advance
    behaviour — kept, so that reviving the door is a change to edit_or_drill
    alone and not a rediscovery of gotcha 23.

Neither is reverted; both are scoped, and both point at improvise-x0xd.

The lesson is the one already in gotcha 48 and it bit in its sharpest form: a
fact stated in three places is changed by a grep, not an edit — and the edit that
created the drift was the one that ADDED the third place. Writing a new section
is exactly when the other two stop being searched for.

Out of scope and reported rather than fixed: context/code/protocol.md:470 says
"No client composes it yet" about CellId::Create and cites improvise-gcvb as an
open gap. That predates gcvb landing and is stale independently of this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 01:10:59 -07:00
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