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
+113 -9
View File
@@ -1786,8 +1786,104 @@ design-principles §5 is about. The caret is drawn as a CSS `::after` rather tha
appended to the text for the same reason: the text is the host's, and the page
adds nothing to it.
`pointer-events: none` is load-bearing — the bar floats over `#grid`, and a
prompt that ate a click meant for a cell would be a new bug introduced by paint.
`pointer-events: none` is load-bearing — the prompt floats over `#grid`, and one
that ate a click meant for a cell would be a new bug introduced by paint. Since
improvise-n6t8 it sits on `#bottom-dock` rather than on `#minibuffer` itself,
because the dock is now what covers the grid; the property is unchanged and the
declaration moved up one level rather than being duplicated.
### The bar is reserved, not fitted, and the toast is out of flow (improvise-n6t8)
The user's report was two symptoms and one rule. The symptoms: *"the top bar
jumps around a lot"* when a message arrives, and *"the normal/categories/etc.
pill changes sizes constantly"*. The rule, which is the deliverable: **"there
shouldn't be motion of layout elements I expect to be fixed."**
Four things in `<header>` were sized by their own content, and they compounded,
because they all change on the *same* event. `#mode` is `help::mode_label`, and
`NORMAL`, `CATEGORIES` and `NEW CATEGORY` are 6, 10 and 12 characters. `#hint`
is `help::hint_line`, one line per mode. `#doc-name` gains and loses ` [+]` on
every edit. `#view-name` had `:empty { display: none }`, so it appeared out of
nothing on the first frame. So the bar reflowed **on a mode change with no
message at all**, which is why a toast alone would not have fixed the complaint.
The answer is a **reserve**: every child that changes with the user's own
keystrokes 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**, and that is
the part worth carrying. `min-width: 21ch` holds sixteen characters where the
longest label today is fourteen (`RECORDS INSERT`) — but a `width` or a
`max-width` would *absorb* a seventeenth character silently, by overflowing
inside a box that never grows. A floor lets an over-long label push the bar,
which is the only thing a bounding-box measurement can see. **A reserve that
caps is a reserve that hides its own failure.**
That matters because of where the seam is. `AppMode`'s per-variant facts are
exhaustive-match methods, so the *compiler* makes you write `mode_label` for a
new variant — and nothing anywhere checks that the CSS still fits what you
wrote. `harness/tests/header.spec.js` is that check, and it is the deliverable
as much as the stylesheet is.
**The status line was the compounding one, and it 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 a second and then a third line** and
grew the whole header by a row. This was not predicted; the spec found it on its
first run. The fix is `flex: 1 1 0` on the hint — from a zero basis it absorbs
the remainder and contributes nothing to the overflow — and `white-space: nowrap`
plus `flex: 0 0 auto` on `#status`, because a second line there is a taller bar,
which is the one thing this whole section exists to prevent. `app.js` puts the
whole text on `title`, so the `max-width` backstop loses nothing.
**The two status facts now go to two elements, and `composeStatus` is gone.**
`#status` carries the *transport's* fact alone — "in-page worker", "connected",
"disconnected", "cannot reach …" — which is not transient and never auto-hides.
`ViewState::status_msg` is the *toast's*. That is a stronger form of the rule
gotcha 46 records than the `·` join was: a repaint reaches `createShell`'s
`onModel` and **cannot address `onShell` at all**, so a connection error cannot
be erased by construction rather than by a convention the join kept. The join's
own unit test was replaced rather than deleted — a separation with no guard is
how one silently regresses into a single sink.
**The toast is out of flow entirely.** `#bottom-dock` is `position: fixed`, so
nothing inside it can push the header or the grid, and it is anchored at its
**bottom** and stacks upward. That is what answers "what happens when the
minibuffer and a toast coincide": the minibuffer sits exactly where it sat, the
toast appears *above* it, and the prompt is neither covered nor displaced. A
top-anchored stack, or a toast positioned by an offset from the minibuffer's
height, would both have moved the prompt.
#### Dismissal keys off message identity, and two identical messages show once
**This is the trap, and a timer alone walks straight into it.**
`ViewState::status_msg` is **state, not an event**: `repaint()` calls
`shell.setModel(model.status)` unconditionally, and it runs on every revision
bump — which is every keypress that moves anything. Nothing in Rust clears the
string; it is replaced only when a later command sets one. So a toast hidden by
`setTimeout` **resurrects the moment the user touches the keyboard**, which is
worse than a message that simply stays, because it reads as a new event.
`createToasts` therefore keeps what it last **accepted**, and keeps it *after*
the toast hides. An offer equal to it is recognised as a repaint and dropped.
The cost is stated rather than hidden: **two identical messages in a row show
once.** Pressing `:` twice re-shows nothing. That is a real miss, and it is
forced, because the browser cannot distinguish the two cases — nothing in the
projection stream carries a sequence or a timestamp beside `status_msg`, and
`model.revision` moves for a cursor key as readily as for a re-issued command.
Given an ambiguous offer there are two choices, and **only one of them does not
reintroduce the reported bug**: showing it again is exactly the resurrection.
Making both show is a protocol change — a sequence number beside the string —
and that is the thing improvise-n6t8's scope deliberately forbids, because the
TUI draws `status_msg` in a bottom bar where it costs no layout at all and
nothing here forecloses the split later.
Note what the toast does *not* do when it hides: **it does not empty itself.**
The element is the record of what was last said, and `drill.spec.js`'s
discrimination between two refusals — `>` must not talk about aggregates —
reads that text. An emptied node cannot tell "nothing was said" from "something
else was said", and the assertion would pass vacuously the moment the timer
fired.
### What the styling is, and what no test here can see
@@ -1817,14 +1913,22 @@ selection is a ring and a tint and never a colour. That is the class of defect
a stylesheet has and a program does not, and it is why the one assertion this
file makes about colour is about which rule wins rather than about a value.
**The harness cannot see any of this**, and that is the honest scope. Playwright
**The harness cannot see most of this**, and that is the honest scope. Playwright
asserts what a browser can answer: that the notice starts collapsed and
remembers, that it opens from the keyboard, and that the minibuffer is inside
the viewport, overlaps `#grid` and is larger than body text. It cannot see
colour, contrast, spacing, weight, the grouped or multi-level header DOM, or
either theme — so the rest of the styling is **argued and not tested**, on the
same terms as the rest of this section, and a change to it is reviewed by
reading and by looking at the page.
remembers, that it opens from the keyboard, that the minibuffer is inside the
viewport, overlaps `#grid` and is larger than body text, that an error cell wins
the cascade — and, since improvise-n6t8, **that the app bar holds still across
every mode the browser can reach**. That last one is the first assertion here
about *layout* rather than about one element's placement, and it is the shape to
imitate: it measures bounding boxes, which a browser can answer exactly, rather
than asking whether the page "looks stable", which it cannot.
What it still cannot see: colour, contrast, spacing, weight, the grouped or
multi-level header DOM, and **either theme** — `prefers-color-scheme` is not
exercised anywhere, so the dark palette is read and looked at and never run. So
the rest of the styling is **argued and not tested**, on the same terms as the
rest of this section, and a change to it is reviewed by reading and by looking
at the page.
---
+111
View File
@@ -3487,3 +3487,114 @@ test is about.
Fault injected: the reviewed code restored exactly — menu branch above the
`metaKey` guard, no `ctrlKey || altKey` bail, conditional `preventDefault`. The
test fails on `Control+j` moving the active row from `✓ Q1` to ` Q2`.
### "no element of the bar moves as the mode changes"
improvise-n6t8. The user's report was that *"the top bar jumps around a lot"*
and that *"the normal/categories/etc. pill changes sizes constantly"*. Four
children of `<header>` were sized by their own content and all four changed on
the **same** event, so a mode change reflowed the bar even with no message on
screen: `#mode` (`help::mode_label`, 4 to 14 characters), `#hint`
(`help::hint_line`, one line per mode), `#doc-name` (gains and loses ` [+]`) and
`#view-name` (`:empty { display: none }`, so it appeared out of nothing).
The walk is **nine** of the seventeen `mode_label` variants, named individually
in the spec rather than described as "every mode": `SavePrompt`, `ExportPrompt`
and `CommandMode` are refused by `resolve::stranding`, `RecordsNormal` and
`RecordsEditing` decline out loud, `ImportWizard` has no browser entry and
`Quit` is not a screen. Each iteration asserts `#mode` **first**, because a key
that failed to reach its mode would leave NORMAL's boxes in place and every
comparison after it would hold vacuously.
Fault injected: `min-width: 21ch` removed from `#mode` in the served stylesheet.
Fails at the first mode change with *"the bar moved in CATEGORIES"*.
**What this found that the ticket did not predict.** On its first run it failed
on the header's *height*, not on any 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 then three lines** — 52.5px of header becoming 71.0px.
Reserving the pill alone would have left that untouched and the bar still moving.
### "the measurement sees a label longer than the reserve, and not one that fits"
The check-the-checker for the walk above. A reserve sized by eye regresses
silently the day an `AppMode` with a longer label is added — the compiler makes
you write `mode_label` for a new variant, and nothing checks that the CSS still
fits what you wrote — so the measurement has to be able to *see* one.
Both directions are asserted, and both are needed. Sixteen characters (what the
reserve claims to hold, against a longest-today of fourteen) must **not** move
the boxes, or the reserve does not hold what it says. Forty characters **must**
move them, or the walk is measuring something that cannot fail.
This is also why `#mode` carries a `min-width` and not a `width` or a
`max-width`: a cap absorbs the overflow inside a box that never grows, so the
bounding boxes would be identical and the fault invisible. **A reserve that caps
is a reserve that hides its own failure.**
### "a status message changes nothing in the bar and overlaps the grid"
`ViewState::status_msg` reached `#status`, which is inside `<header>`, so every
refusal this client makes — three of them are advertised in Normal mode's own
hint line — reflowed the bar. It is a toast now, inside a `position: fixed`
dock, so it takes part in no layout at all.
The overlap assertion is what separates "out of flow" from "in a strip of its
own": a toast given its own row would have taken its height from `#grid`, and
both the header box and the grid box would have moved.
Fault injected: `onModel` restored to writing `el.status.textContent` beside the
toast, in the served `app.js`. The header box changes and the test fails.
### "a dismissed message does not come back on the next keypress"
**The trap, and the reason a timer alone is not the fix.**
`ViewState::status_msg` is *state*, not an event: `repaint()` calls
`shell.setModel(model.status)` unconditionally and runs on every revision bump,
and nothing in Rust clears the string. So a toast hidden by `setTimeout` is
re-offered — and re-shown — the moment the user presses anything, which reads as
a new event and is worse than a message that simply stays.
`createToasts` keeps what it last **accepted**, and keeps it after the toast
hides, so an equal offer is recognised as a repaint and dropped. The spec drives
the real trap: `:` for the message, `page.clock.runFor(10s)` past the window,
then `ArrowDown`, which moves the selection and so forces a full repaint.
The last assertion — that the element still *contains* the text — is what makes
the two before it about **visibility** rather than about an emptied node. It is
also what `drill.spec.js` needs: its discrimination between two refusals (`>`
must not talk about aggregates) reads `#toast`'s text, and an emptied node would
satisfy `not.toContainText` vacuously the moment the timer fired.
Fault injected: the `if (next === offered) return;` identity check removed from
`createToasts` in the served `shell.js`, leaving dismissal to the timer alone.
The toast is visible again after `ArrowDown` and the test fails.
### "a toast does not cover or displace an open minibuffer"
What happens when both are present, which the ticket asked to be decided rather
than discovered. `#bottom-dock` is anchored at its **bottom** and stacks upward,
so the minibuffer's box is identical with and without a toast above it, and the
toast's bottom edge is at or above the prompt's top edge. A top-anchored stack,
or a toast positioned by an offset from the minibuffer's own height, would both
have moved the prompt.
The toast is **forced** here rather than provoked by a key, and the reason is
stated in the spec: a host message arriving *during* text entry is reachable but
has no keyboard route in this client — in `INSERT`, `:` is bound to `AnyChar`
and types a colon. The rule under test is the stylesheet's, not the route's, so
setting the two DOM states directly is the honest instrument rather than a
contrivance that would test the contrivance.
### "a repaint's model status cannot reach the transport's sink at all"
The unit half of the same rule, and a replacement rather than a deletion.
`composeStatus` joined the transport's fact and the document's with `·` because
a repaint had once silently erased a connection error (gotcha 46); the two facts
now go to two *elements*, so the join is gone. A separation with no guard is how
one regresses into a single sink, so the test moved rather than went.
It asserts the whole sequence each sink saw, not the last value: the fault it
separates from is `onShell` **losing** its text, and a test reading only the
final `#status` would pass against a page that wrote the model's status there
and then happened to be asked again.
+1 -1
View File
@@ -228,7 +228,7 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
// which arm ran, so a click wired to the category arm passes everything
// above and fails here.
await page.locator("#categories li.item").filter({ hasText: "Latte" }).click();
await expect(page.locator("#status")).toContainText("Filter: Product = Latte");
await expect(page.locator("#toast")).toContainText("Filter: Product = Latte");
await expect(page.locator("#categories li.selected")).toHaveText("Latte");
await page.keyboard.press("Escape");
+4 -4
View File
@@ -40,7 +40,7 @@ test.describe("drill is refused in the browser, not broken", () => {
await anAggregateGrid(page);
await page.keyboard.press("i");
await expect(page.locator("#status")).toContainText(
await expect(page.locator("#toast")).toContainText(
"drilling into an aggregate needs the host",
);
// And it really is a refusal: no editor opened, no mode change.
@@ -58,7 +58,7 @@ test.describe("drill is refused in the browser, not broken", () => {
await anAggregateGrid(page);
await page.keyboard.press(">");
await expect(page.locator("#status")).toContainText(
await expect(page.locator("#toast")).toContainText(
"drill is not available in the browser yet",
);
await expect(page.locator("#mode")).toHaveText("NORMAL");
@@ -66,12 +66,12 @@ test.describe("drill is refused in the browser, not broken", () => {
// The two refusals have two SUBJECTS, and conflating them would be a
// true-sounding sentence about the wrong thing — worse than silence,
// because it stops the reader looking. `>` must not talk about aggregates.
await expect(page.locator("#status")).not.toContainText("aggregate");
await expect(page.locator("#toast")).not.toContainText("aggregate");
// And `i` still gives the OTHER refusal, which is what proves the two are
// distinct rather than one message reached by two keys.
await page.keyboard.press("i");
await expect(page.locator("#status")).toContainText(
await expect(page.locator("#toast")).toContainText(
"drilling into an aggregate needs the host",
);
});
+185
View File
@@ -0,0 +1,185 @@
// The app bar holds still (improvise-n6t8). The rule is that nothing a user
// expects to be fixed may move, and the CSS reserve is only half of it: the
// other half is this file, because nothing else checks that the reserve still
// fits. See context/code/clients.md.
import { test, expect } from "@playwright/test";
/// The shipped demo, focused, with a grid on screen. `#grid` must have focus
/// or the page's keydown listener never sees a key and every mode below stays
/// NORMAL — which would make every assertion here pass for the wrong reason.
async function theDemo(page) {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await page.locator("#grid").focus();
await expect(page.locator("#grid td.editable").first()).toBeVisible();
}
/// Every child of the bar, by id, with the bar itself. Measured rather than
/// predicted: a reserve that has stopped fitting shows up here as a box that
/// moved, whatever the stylesheet says it should be.
async function headerBoxes(page) {
return page.evaluate(() => {
const box = (node) => {
const { x, y, width, height } = node.getBoundingClientRect();
return { x, y, width, height };
};
const header = document.querySelector("header");
const children = {};
for (const child of header.children) children[child.id || child.tagName] = box(child);
return { header: box(header), children };
});
}
/// The keys that reach each mode from NORMAL, and the label each must show.
/// NINE of the seventeen `mode_label` variants: `SavePrompt`, `ExportPrompt`
/// and `CommandMode` are refused by `resolve::stranding`, `RecordsNormal` and
/// `RecordsEditing` decline out loud, `ImportWizard` has no browser entry and
/// `Quit` is not a screen. Named individually because "every mode" over a
/// four-mode walk is the overstatement this ticket asked not to make.
/// `exit` is spelled out because the two families leave differently: a panel
/// is closed by its own toggle key (Escape drops focus but leaves it open, so
/// the mode does not change), and everything else answers Escape.
const MODES = [
{ keys: [], label: "NORMAL", exit: [] },
{ keys: ["C"], label: "CATEGORIES", exit: ["C"] },
{ keys: ["F"], label: "FORMULAS", exit: ["F"] },
{ keys: ["V"], label: "VIEWS", exit: ["V"] },
{ keys: ["T"], label: "TILES", exit: ["Escape"] },
{ keys: ["?"], label: "HELP", exit: ["Escape"] },
{ keys: ["a"], label: "INSERT", exit: ["Escape"] },
// Escape leaves a text-entry mode for its EXIT mode, not for Normal
// (gotcha 11), and both of these exit into the panel that owns them.
{ keys: ["N"], label: "NEW CATEGORY", exit: ["Escape", "C"] },
{ keys: ["F", "n"], label: "FORMULA", exit: ["Escape", "F"] },
];
test.describe("the app bar is reserved, not fitted", () => {
// The pill is sized by `help::mode_label`, and the labels differ in length.
// Before the reserve, every mode change resized it and shifted #hint,
// #status and the whole right-hand end of the bar with it.
test("no element of the bar moves as the mode changes", async ({ page }) => {
await theDemo(page);
const first = await headerBoxes(page);
const visited = [];
for (const { keys, label, exit } of MODES) {
for (const key of keys) await page.keyboard.press(key);
// The precondition, and the thing that makes the walk nine rather than
// one: a key that did not reach its mode would leave NORMAL's boxes and
// every comparison below would hold vacuously.
await expect(page.locator("#mode")).toHaveText(label);
visited.push(label);
expect(await headerBoxes(page), `the bar moved in ${label}`).toEqual(first);
for (const key of exit) await page.keyboard.press(key);
await expect(page.locator("#mode")).toHaveText("NORMAL");
}
expect(visited).toEqual(MODES.map((m) => m.label));
});
// The check-the-checker. A reserve sized by eye regresses silently the day
// a longer `AppMode` label is added, so the measurement above has to be
// able to SEE one — which a `max-width` or a fixed `width` on the pill
// would prevent, by absorbing the overflow instead of growing.
test("the measurement sees a label longer than the reserve, and not one that fits", async ({
page,
}) => {
await theDemo(page);
const first = await headerBoxes(page);
// Sixteen characters: what the reserve claims to hold. The longest label
// the browser can reach is NEW CATEGORY at twelve, and the longest in the
// enum is RECORDS INSERT at fourteen, so this is the claim under test.
await page.evaluate(() => {
document.getElementById("mode").textContent = "ABCDEFGHIJKLMNOP";
});
expect(await headerBoxes(page), "the reserve does not hold sixteen characters").toEqual(
first,
);
// And the fault: a label the reserve cannot hold. If this passed, the
// walk above would be measuring something that cannot fail.
await page.evaluate(() => {
document.getElementById("mode").textContent = "A".repeat(40);
});
expect(await headerBoxes(page)).not.toEqual(first);
});
// `status_msg` reached #status, which is in the bar, so every refusal the
// client makes reflowed the header. It is a toast now, out of flow.
test("a status message changes nothing in the bar and overlaps the grid", async ({
page,
}) => {
await theDemo(page);
const before = await headerBoxes(page);
const grid = await page.locator("#grid").boundingBox();
// A key this client declines, which is the message the user reported.
await page.keyboard.press(":");
await expect(page.locator("#toast")).toContainText(
"the command line is not available in the browser yet",
);
await expect(page.locator("#toast")).toBeVisible();
expect(await headerBoxes(page)).toEqual(before);
expect(await page.locator("#grid").boundingBox()).toEqual(grid);
// Out of flow means OVER, not beside: a toast in a strip of its own would
// have taken its height from the grid and the two boxes above would move.
const toast = await page.locator("#toast").boundingBox();
expect(toast.y).toBeLessThan(grid.y + grid.height);
expect(toast.y + toast.height).toBeGreaterThan(grid.y);
});
// The trap the ticket names: `status_msg` is STATE, so `repaint()` offers
// the same string on every keydown. A toast on a timer alone comes back the
// moment the user touches the keyboard, which is worse than one that stays.
test("a dismissed message does not come back on the next keypress", async ({ page }) => {
await page.clock.install();
await theDemo(page);
await page.keyboard.press(":");
await expect(page.locator("#toast")).toBeVisible();
await page.clock.runFor(10_000);
await expect(page.locator("#toast")).toBeHidden();
// A cursor key: it changes the revision, so `repaint()` runs in full and
// re-offers the identical `status_msg` — nothing in Rust cleared it.
await page.keyboard.press("ArrowDown");
await expect(page.locator("#grid td.selected")).toHaveCount(1);
await expect(page.locator("#toast")).toBeHidden();
// And the element still holds what was last said, so the assertion above
// is about visibility rather than about an emptied node.
await expect(page.locator("#toast")).toContainText("command line");
});
// What happens when both are present. The dock is anchored at its bottom
// and stacks upward, so the prompt is neither covered nor displaced. The
// toast is forced rather than provoked: a host message arriving DURING text
// entry is reachable but has no keyboard route, and the rule under test is
// the stylesheet's, not the route's.
test("a toast does not cover or displace an open minibuffer", async ({ page }) => {
await theDemo(page);
await page.keyboard.press("a");
await expect(page.locator("#mode")).toHaveText("INSERT");
await expect(page.locator("#minibuffer")).toBeVisible();
const alone = await page.locator("#minibuffer").boundingBox();
await page.evaluate(() => {
const toast = document.getElementById("toast");
toast.textContent = "a message that arrived while you were typing";
toast.hidden = false;
});
await expect(page.locator("#toast")).toBeVisible();
expect(await page.locator("#minibuffer").boundingBox()).toEqual(alone);
const toast = await page.locator("#toast").boundingBox();
expect(toast.y + toast.height).toBeLessThanOrEqual(alone.y);
});
});
+2 -2
View File
@@ -30,9 +30,9 @@ test.describe("an advertised key is never silent", () => {
]) {
// A stale status would let the NEXT key pass on the previous key's
// message, so each assertion is preceded by a state it is not.
await expect(page.locator("#status")).not.toContainText(said);
await expect(page.locator("#toast")).not.toContainText(said);
await page.keyboard.press(key);
await expect(page.locator("#status")).toContainText(said);
await expect(page.locator("#toast")).toContainText(said);
// A refusal that changed the mode would be a door opened anyway.
await expect(page.locator("#mode")).toHaveText("NORMAL");
}
+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,