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:
+113
-9
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user