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:
+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