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
+52 -13
View File
@@ -10,10 +10,44 @@ export function socketUrl(peer, pageProtocol) {
return `${secure ? "wss" : "ws"}://${peer}`;
}
/** `#status` carries two independent facts, composed rather than overwritten:
* a repaint must not quietly erase a connection error. */
export function composeStatus(shell, model) {
return [shell, model].filter(Boolean).join(" · ");
/** How long a transient message stays on screen. Long enough to read a
* sentence, short enough that it is gone before it is furniture. */
export const TOAST_MS = 6000;
/** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the
* string that is already on screen, so dismissal keys off message IDENTITY
* and never off a timer alone. What that costs: context/code/clients.md. */
export function createToasts({
onShow,
onHide,
ttlMs = TOAST_MS,
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
// What was last ACCEPTED, kept after the toast hides. That is the whole
// mechanism: a repaint offering it again is recognised and dropped.
let offered = "";
let timer = null;
return {
offer(text) {
const next = text ?? "";
if (next === offered) return;
offered = next;
clearTimer(timer);
timer = null;
if (next === "") {
onHide();
return;
}
onShow(next);
timer = setTimer(() => {
timer = null;
onHide();
}, ttlMs);
},
showing: () => timer !== null,
};
}
/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title`
@@ -293,22 +327,27 @@ export function isFatal(notice) {
}
/** The page's two status facts and the handshake watchdog, which share a home
* because the one rule between them is that a *failure disarms the timer*. */
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
let shell = "";
let model = "";
* because the one rule between them is that a *failure disarms the timer*.
*
* The two facts go to two SINKS and are never joined: a repaint reaches
* `onModel` and cannot address `onShell` at all. That is the structural form
* of the rule the `·` join used to keep by convention — context/code/clients.md. */
export function createShell({
onShell,
onModel,
timeoutMs,
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
let watchdog = null;
let arms = 0;
const paint = () => onStatus(composeStatus(shell, model));
return {
setShell(text) {
shell = text;
paint();
onShell(text);
},
setModel(text) {
model = text;
paint();
onModel(text);
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */