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
+25 -5
View File
@@ -10,6 +10,7 @@ import {
cellAttrs,
classifyWorkerMessage,
createShell,
createToasts,
documentTitle,
extentOf,
frameCostOf,
@@ -47,6 +48,7 @@ const el = {
pageMenu: document.getElementById("page-menu"),
mode: document.getElementById("mode"),
minibuffer: document.getElementById("minibuffer"),
toast: document.getElementById("toast"),
hint: document.getElementById("hint"),
help: document.getElementById("help"),
status: document.getElementById("status"),
@@ -87,12 +89,30 @@ el.notice.addEventListener("toggle", () =>
await init();
const client = new Client();
// A peer that never answers the handshake, and the two facts `#status` shows.
// Both are decided in shell.js; this is the wire to the element.
const shell = createShell({
onStatus: (text) => {
el.status.textContent = text;
// The document's own messages, out of flow and on a timer. `status_msg` is
// STATE, so a repaint re-offers it: the identity check is in `createToasts`.
const toasts = createToasts({
onShow: (text) => {
el.toast.textContent = text;
el.toast.hidden = false;
},
// The text is deliberately LEFT in place — see index.html.
onHide: () => {
el.toast.hidden = true;
},
});
// A peer that never answers the handshake, and the two facts the page shows.
// Two SINKS, never joined: the transport's is `#status` and lasts, the
// document's is a toast. Both are decided in shell.js; this is the wire.
const shell = createShell({
onShell: (text) => {
el.status.textContent = text;
// The line never wraps, so a long peer address ellipsises. The whole of
// it stays reachable rather than being lost to the cap.
el.status.title = text;
},
onModel: (text) => toasts.offer(text),
timeoutMs: HANDSHAKE_TIMEOUT_MS,
});
// The harness asks whether the watchdog is ARMED, because a negative assertion
+13 -4
View File
@@ -123,11 +123,20 @@
</main>
<!--
The bottom bar. The TUI draws the minibuffer INSTEAD of the status line;
here it is a line of its own, because #status also carries the transport's
own failures and a repaint must not erase one (gotcha 48).
The bottom dock (improvise-n6t8). It is `position: fixed`, so nothing
inside it takes part in the page's layout and a message cannot push the
header or the grid. It is anchored at its BOTTOM and stacks upward, so
the minibuffer sits where it always sat and a toast appears above it:
the prompt is never covered and never displaced. context/code/clients.md.
#toast keeps its text when it hides — the element is the record of the
last message shown, and an emptied one could not tell "nothing was said"
from "something else was said".
-->
<div id="minibuffer" role="status" hidden></div>
<div id="bottom-dock">
<div id="toast" role="status" aria-live="polite" hidden></div>
<div id="minibuffer" role="status" hidden></div>
</div>
<div id="help" role="dialog" aria-label="Help" hidden></div>
+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. */
+86 -15
View File
@@ -66,7 +66,14 @@ body {
/* ── The app bar ─────────────────────────────────────────────────────────
One line, and the eye's order across it is: what this is, which document,
which view, which mode, what keys, what the peer is doing. */
which view, which mode, what keys, what the peer is doing.
RESERVED, NOT FITTED (improvise-n6t8). Every child that changes with the
user's own keystrokes has an allocated width, so a mode change moves
nothing after it. #hint is the single elastic child and absorbs whatever is
left, which is why nothing is positioned by it. Why a floor rather than a
cap on the mode pill, and why the guard is a spec and not the number:
context/code/clients.md. */
header {
display: flex;
@@ -93,7 +100,7 @@ header h1 {
flex: none;
/* A tag fits whole; a 40-char commit SHA truncates to a prefix long enough
to name a build, and selecting the text still copies the whole thing. */
max-width: 14ch;
width: 14ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -101,11 +108,13 @@ header h1 {
font-size: 0.72rem;
}
/* Wide enough for the shipped document's whole title, `name (stored_as)`, so
the ellipsis is for an unusually long one rather than for the demo. A fixed
width and not a cap: the dirty marker `[+]` appears and disappears on an
edit, and a cap would let it shift the bar. */
#doc-name {
flex: none;
/* Wide enough for the shipped document's whole title, `name (stored_as)`,
so the ellipsis is for an unusually long one rather than for the demo. */
max-width: 36ch;
width: 34ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -116,20 +125,32 @@ header h1 {
chip shape the tile bar uses rather than the mode's filled one. */
#view-name {
flex: none;
width: 16ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.05rem 0.5rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
font-size: 0.8rem;
}
/* `visibility`, not `display`: the chip is blank before the first frame and
must still hold its place, or the bar jumps once on load. */
#view-name:empty {
display: none;
visibility: hidden;
}
/* The mode is the single most consequential thing on the page — every key
means something different in each — so it is the one filled badge. */
#mode {
flex: none;
/* A FLOOR and deliberately no cap, sized for sixteen characters where the
longest `mode_label` today is fourteen. A cap would absorb an over-long
label silently; a floor lets it grow, which is what the header-stability
spec measures. context/code/clients.md. */
min-width: 21ch;
text-align: center;
padding: 0.1rem 0.55rem;
border-radius: var(--radius);
background: var(--accent);
@@ -140,13 +161,18 @@ header h1 {
}
#mode:empty {
display: none;
visibility: hidden;
}
/* The hint is reference, not state: one line, ellipsised exactly as the TUI
truncates it to the terminal's width. It must never push the bar taller. */
#hint {
flex: 1 1 auto;
/* Basis ZERO, not `auto`: the hint text is the longest thing on the bar and
a content basis makes it compete with #status for the shrink, so a longer
hint narrowed #status until the transport's line WRAPPED and the bar grew
a whole row taller. From zero it absorbs the remainder and contributes
nothing to overflow. context/code/clients.md. */
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -155,11 +181,21 @@ header h1 {
font-size: 0.8rem;
}
/* Two facts composed in one string (`shell.js::composeStatus`), so the dot
marks the whole line rather than either half. */
/* The TRANSPORT's fact alone since improvise-n6t8 — what the peer is doing,
which is not transient and never auto-hides. The document's own messages
are the toast's. It is the last child and right-anchored, so it is the one
thing the reserve above does not cover: it changes only when the peer's
state changes, and #hint absorbs the difference. */
#status {
flex: 0 1 auto;
min-width: 0;
/* Never shrinks and never wraps: a second line here is a taller bar, which
is the one thing this whole section exists to prevent. The cap is the
backstop for an unusually long peer address — app.js puts the whole text
on `title`, so an ellipsis loses nothing. */
flex: 0 0 auto;
max-width: 52ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left: auto;
text-align: right;
color: var(--dim);
@@ -549,12 +585,49 @@ td.total {
`state.view.mode.minibuffer()`, the same value the TUI draws — so this is
position, weight and contrast and nothing else. */
#minibuffer {
/* ── The bottom dock ─────────────────────────────────────────────────────
Out of flow entirely, so nothing in it can push the header or the grid
(improvise-n6t8). Anchored at the BOTTOM and stacking upward, which is what
keeps the minibuffer where it was when a toast appears above it. */
#bottom-dock {
position: fixed;
left: 50%;
bottom: 1.75rem;
transform: translateX(-50%);
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
max-width: calc(100vw - 2rem);
/* It floats OVER the grid, so it must not eat a click meant for a cell. */
pointer-events: none;
}
/* The document's own messages, out of flow and on a timer. `hidden` and not
an empty node: the text stays as the record of what was last said. */
#toast {
max-width: min(46rem, calc(100vw - 2rem));
padding: 0.45rem 0.8rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: var(--raised);
box-shadow: var(--shadow);
color: var(--fg);
font-size: 0.85rem;
overflow-wrap: anywhere;
animation: toast-in 140ms ease-out;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(0.35rem);
}
}
#minibuffer {
max-width: min(46rem, calc(100vw - 2rem));
min-width: min(28rem, calc(100vw - 2rem));
padding: 0.6rem 0.9rem;
@@ -565,8 +638,6 @@ td.total {
font-family: inherit;
font-size: 1rem;
color: var(--fg);
/* It floats OVER the grid, so it must not eat a click meant for a cell. */
pointer-events: none;
overflow-wrap: anywhere;
}
+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,