feat(web): dismissable notice, a raised minibuffer, and a styled shell

Three things the user reported after looking at the deployed page, in the
order they cost a visitor something.

The deployment notice was ten dense lines of configuration reference —
?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the
category tree and the grid on every visit: about 230px of an 820px window.
It is now a <details>, so the collapsing is the BROWSER's (keyboard
operable, and correct with app.js absent or still loading) and the only
thing the page adds is remembering the answer. It starts COLLAPSED, and
collapsing withholds nothing: the one fact a first visitor needs — the
document lives in this tab, is saved to this browser's private storage and
never leaves the machine — is the <summary>, which never collapses.

What a stored value means is a decision and lives in shell.js; touching
localStorage is a browser API and stays in app.js, wrapped, with every
value that is not one of the two spellings read as "unanswered".

The minibuffer was a 14px line in the page's bottom-left corner, up to
800px from the cell being typed into. It is now a raised bar over the
grid. The CONTENT is untouched — RenderMinibuffer is built from
`state.view.mode.minibuffer()`, the same semantic value the TUI draws — so
this is position, weight and contrast, and there is no second derivation of
what a prompt says. MinibufColor still resolves here exactly as
draw.rs::minibuf_fg resolves it for the terminal.

style.css stops being "legibility only": layered surfaces, one accent, a
filled badge for the mode, chips for the two axis strips, one selection
treatment, tabular numerals in the grid, and a focus ring on the one
keyboard surface the page has. The palette is the landing page's, so the
two read as one product.

Asserted (harness/tests/contract.spec.js, Chromium): the notice starts
collapsed and remembers, in BOTH directions across a reload; it opens from
the keyboard, which the page's own keydown handler must not claim; the
minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static
bottom bar cannot satisfy — and is larger than body text. Each was proved
discriminating by injecting the fault and watching it fail.

NOT asserted, and named as such in context/code/clients.md: colour,
contrast, spacing, weight, the grouped and multi-level header DOM, either
theme, and the localStorage-throws path. Those are argued and read.

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-21 22:29:18 -07:00
parent f34334cfca
commit 8ad5c0806d
8 changed files with 822 additions and 146 deletions
+38
View File
@@ -13,6 +13,9 @@ import {
frameCostOf,
indicator,
isFatal,
NOTICE_OPEN_BY_DEFAULT,
noticeOpen,
noticeState,
noticeText,
socketUrl,
} from "../shell.js";
@@ -258,3 +261,38 @@ test("a notice DISARMS the watchdog rather than being replaced by it", () => {
`the watchdog spoke over a precise reason: ${JSON.stringify(seen)}`,
);
});
// The notice's remembered state. The value crosses `localStorage`, which is
// allowed to forget and allowed to throw, so the interesting inputs are the
// ones that are NOT either spelling.
test("a notice state this page did not write is unanswered, not a third state", () => {
assert.equal(noticeOpen("open"), true);
assert.equal(noticeOpen("closed"), false);
// Everything storage can hand back when it has forgotten, been cleared,
// been blocked, or been written by an older page.
for (const stored of [null, undefined, "", "true", "false", "1", "0", "OPEN", {}]) {
assert.equal(
noticeOpen(stored),
NOTICE_OPEN_BY_DEFAULT,
`${JSON.stringify(stored)} was read as an answer`,
);
}
});
// A first-time visitor lands on the application, not on its configuration
// reference. The argument is in context/code/clients.md; this pins the call so
// that changing it is a change to a stated decision rather than a stray edit.
test("a visitor who has never answered gets the notice collapsed", () => {
assert.equal(NOTICE_OPEN_BY_DEFAULT, false);
assert.equal(noticeOpen(null), false);
});
test("the two spellings round-trip, in both directions", () => {
for (const open of [true, false]) {
assert.equal(noticeOpen(noticeState(open)), open);
}
// And `noticeState` produces only spellings `noticeOpen` recognises — the
// half that would break silently, since an unrecognised value reads as the
// default and a visitor's answer would simply stop being kept.
assert.deepEqual([noticeState(true), noticeState(false)].sort(), ["closed", "open"]);
});