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
This commit is contained in:
Edward Langley
2026-09-22 00:17:22 -07:00
parent 7470b61476
commit d750b433b8
11 changed files with 698 additions and 70 deletions
+106 -17
View File
@@ -6,8 +6,8 @@ import assert from "node:assert/strict";
import {
classifyWorkerMessage,
composeStatus,
createShell,
createToasts,
documentTitle,
extentOf,
frameCostOf,
@@ -35,20 +35,109 @@ test("a peer whose host merely starts with ws is not a scheme", () => {
assert.equal(socketUrl("wsserver.test:9000", "http:"), "ws://wsserver.test:9000");
});
test("the two status facts compose, and neither can erase the other", () => {
assert.equal(composeStatus("connected", "Filter: Product = Latte"), "connected · Filter: Product = Latte");
// An empty fact contributes nothing rather than a stray separator.
assert.equal(composeStatus("connected", ""), "connected");
assert.equal(composeStatus("", "saved"), "saved");
assert.equal(composeStatus("", ""), "");
});
test("a repaint's model status does not overwrite a connection failure", () => {
test("a repaint's model status cannot reach the transport's sink at all", () => {
// improvise-n6t8. The old `·` join kept this by convention; two sinks make
// it structural. A single sink would show the last write, so the fault this
// separates from is `shell` LOSING its text, not merely being appended to.
const seen = [];
const shell = createShell({ onStatus: (t) => seen.push(t), timeoutMs: 10 });
const said = [];
const shell = createShell({
onShell: (t) => seen.push(t),
onModel: (t) => said.push(t),
timeoutMs: 10,
});
shell.fail("cannot reach 127.0.0.1:8787");
shell.setModel("Filter: Product = Latte");
assert.equal(seen.at(-1), "cannot reach 127.0.0.1:8787 · Filter: Product = Latte");
shell.setModel("");
assert.deepEqual(seen, ["cannot reach 127.0.0.1:8787"]);
assert.deepEqual(said, ["Filter: Product = Latte", ""]);
});
/// `status_msg` is STATE: `repaint()` re-offers it on every revision, so a
/// toast dismissed on a timer alone comes back on the next keypress.
test("the same message offered again is a repaint, not a second event", () => {
const clock = fakeClock();
const shown = [];
let visible = false;
const toasts = createToasts({
onShow: (t) => {
shown.push(t);
visible = true;
},
onHide: () => {
visible = false;
},
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("records mode is not available in the browser yet");
assert.equal(visible, true);
clock.fire();
assert.equal(visible, false);
// The keypress. Nothing cleared `status_msg` in Rust, so the page is
// offered the identical string — and must NOT resurrect the toast.
toasts.offer("records mode is not available in the browser yet");
assert.equal(visible, false);
assert.equal(clock.count(), 0, "a repaint must not arm a second timer");
assert.deepEqual(shown, ["records mode is not available in the browser yet"]);
});
test("a different message shows, and replaces rather than queues", () => {
const clock = fakeClock();
const shown = [];
const toasts = createToasts({
onShow: (t) => shown.push(t),
onHide: () => shown.push(null),
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("drill is not available in the browser yet");
toasts.offer("drilling into an aggregate needs the host");
// One timer, not two: the second message's window starts fresh.
assert.equal(clock.count(), 1);
assert.deepEqual(shown, [
"drill is not available in the browser yet",
"drilling into an aggregate needs the host",
]);
});
test("a host that clears the status hides the toast without waiting", () => {
const clock = fakeClock();
let visible = false;
const toasts = createToasts({
onShow: () => {
visible = true;
},
onHide: () => {
visible = false;
},
ttlMs: 6000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
toasts.offer("saved");
toasts.offer("");
assert.equal(visible, false);
assert.equal(clock.count(), 0, "a cleared message leaves no timer behind");
});
test("an empty status on the first frame shows nothing", () => {
// The common case: every repaint before any command offers "". A feed that
// treated the first offer as new would flash an empty toast on load.
const shown = [];
const toasts = createToasts({
onShow: (t) => shown.push(t),
onHide: () => shown.push(null),
setTimer: () => 1,
clearTimer: () => {},
});
toasts.offer("");
toasts.offer(undefined);
assert.deepEqual(shown, []);
});
/// A clock the test drives, so the watchdog's arming is asserted rather than
@@ -76,7 +165,7 @@ test("the watchdog speaks when the peer never answers the handshake", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
onShell: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
@@ -92,7 +181,7 @@ test("a loud failure DISARMS the watchdog rather than being replaced by it", ()
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
onShell: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
@@ -110,7 +199,7 @@ test("a frame that arrives is the answer the watchdog was waiting for", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
onShell: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
@@ -125,7 +214,7 @@ test("a frame that arrives is the answer the watchdog was waiting for", () => {
test("arming twice leaves one timer, not two", () => {
const clock = fakeClock();
const shell = createShell({
onStatus: () => {},
onShell: () => {},
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
@@ -244,7 +333,7 @@ test("a notice DISARMS the watchdog rather than being replaced by it", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
onShell: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,