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
@@ -17,6 +17,9 @@ import {
indexAction,
indicator,
isFatal,
NOTICE_KEY,
noticeOpen,
noticeState,
noticeText,
pageEntries,
socketUrl,
@@ -45,8 +48,38 @@ const el = {
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
docName: document.getElementById("doc-name"),
notice: document.getElementById("backend-notice"),
};
// The deployment notice, and the one thing this page remembers about its
// visitor. `localStorage` THROWS in a private window and in a context with
// site data blocked, and returns null having forgotten — so every access is
// guarded and the absent answer is the default, not a third state. What is
// stored is per-viewer and per-origin: it never reaches Rust, never travels
// to a peer and never reaches another tab's document.
const remembered = (key) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const remember = (key, value) => {
try {
localStorage.setItem(key, value);
} catch {
// A viewer whose browser will not remember still gets a working page: the
// disclosure stays operable, it just re-opens at the default next time.
}
};
el.notice.open = noticeOpen(remembered(NOTICE_KEY));
// `toggle` fires for the keyboard and the pointer alike, because the opening
// is the BROWSER's: this listener records an answer rather than making one.
el.notice.addEventListener("toggle", () =>
remember(NOTICE_KEY, noticeState(el.notice.open)),
);
await init();
const client = new Client();
@@ -591,6 +624,11 @@ window.addEventListener("keydown", (e) => {
// BrowserKey drops anything held with the platform meta key precisely so
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
if (e.metaKey) return;
// The notice's <summary> is a real control and owns Enter and Space while
// it has focus. Claiming them here would call `preventDefault` on the
// browser's own disclosure toggle, so a keyboard visitor could focus it and
// not open it — which is the whole affordance.
if (el.notice.contains(e.target)) return;
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
if (frame) {
+37 -20
View File
@@ -53,27 +53,44 @@
and a page that cannot reach its peer cannot be trusted to explain why.
What the peer is actually *doing* — starting, connected, unreachable,
gone — is live, and app.js writes it to #status.
It is a <details>, so the collapsing is the BROWSER's — keyboard
operable, and correct with app.js absent or still loading. The only
thing app.js adds is remembering the answer; the one line every visitor
needs is the <summary>, which never collapses.
-->
<p id="backend-notice">
Everything here runs in this tab: the document lives in a Web Worker
and is saved to this browser's private storage, so edits survive a
reload and never leave the machine. Load a different document with
<code>?doc=name.improv</code>, seeded from
<code>?seed=url</code>. To open a document hosted by a separate
process instead, run
<code>improvise-ws-server model.improv --port 8787</code> and reload
with <code>?ws=host:port</code>. <strong>The scheme defaults to
<code>wss://</code></strong>, because this page is served over https and
an https origin refuses a <code>ws://</code> address as mixed content.
Note that <code>improvise-ws-server</code> speaks plain
<code>ws://</code> only, so reaching it from <em>this</em> page means
terminating TLS in front of it with a reverse proxy or a tunnel — the
README has a worked Caddy example. Served over
<code>http://localhost</code> the same page defaults to
<code>ws://</code> and pairs with it directly, with nothing in between;
that is the case the pairing is really for. Sharing a document is what
the server is for — not running one is what everything above is for.
</p>
<details id="backend-notice">
<summary>
<span class="notice-lede"
>Everything here runs in this tab — the document lives in a Web
Worker and is saved to this browser's private storage, so edits
survive a reload and never leave the machine.</span
>
<span class="notice-toggle" aria-hidden="true"></span>
</summary>
<div class="notice-body">
<p>
Load a different document with <code>?doc=name.improv</code>, seeded
from <code>?seed=url</code>. To open a document hosted by a separate
process instead, run
<code>improvise-ws-server model.improv --port 8787</code> and reload
with <code>?ws=host:port</code>.
</p>
<p>
<strong>The scheme defaults to <code>wss://</code></strong>, because
this page is served over https and an https origin refuses a
<code>ws://</code> address as mixed content. Note that
<code>improvise-ws-server</code> speaks plain <code>ws://</code>
only, so reaching it from <em>this</em> page means terminating TLS in
front of it with a reverse proxy or a tunnel — the README has a
worked Caddy example. Served over <code>http://localhost</code> the
same page defaults to <code>ws://</code> and pairs with it directly,
with nothing in between; that is the case the pairing is really for.
Sharing a document is what the server is for — not running one is
what everything above is for.
</p>
</div>
</details>
<div id="tiles" aria-label="Tiles"></div>
+24
View File
@@ -28,6 +28,30 @@ export function indicator(collapsed) {
return collapsed ? "▶" : "▼";
}
/** Where the deployment notice's open/closed state is remembered. Per-viewer
* and per-origin by construction: it never reaches Rust and never travels. */
export const NOTICE_KEY = "improvise.notice.open";
/** Whether a visitor who has never answered sees the notice OPEN. It is a
* product call and the argument for `false` is in context/code/clients.md. */
export const NOTICE_OPEN_BY_DEFAULT = false;
/** What a stored notice state MEANS. Everything that is not one of the two
* spellings — absent, empty, a value from an older page, a string a private
* window handed back — is "unanswered", which is the default and not an
* error: storage is allowed to forget, and a page that treated forgetting as
* a third state would flicker between them. */
export function noticeOpen(stored) {
if (stored === "open") return true;
if (stored === "closed") return false;
return NOTICE_OPEN_BY_DEFAULT;
}
/** …and the inverse, so the two spellings have exactly one home. */
export function noticeState(open) {
return open ? "open" : "closed";
}
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
export const ROW_PX = 26;
+407 -126
View File
@@ -1,23 +1,44 @@
/* Legibility only. Styling the browser client properly is not improvise-cr3. */
/* Paint only. Every fact this file colours in — which cell is selected, which
axis a category is on, whether a cell may be typed into, what the prompt
says — is decided in Rust and arrives already decided. Nothing here derives
anything; see context/design-principles.md §5 and context/code/clients.md.
The palette is deliberately the landing page's, so docs/index.html and this
application read as one product rather than two. */
:root {
--bg: #fbfaf7;
--surface: #f4f1ea;
--raised: #ffffff;
--fg: #1c1b19;
--line: #d8d4cc;
--dim: #6b675f;
--line: #ddd8ce;
--line-strong: #bdb6a8;
--accent: #2f6f4f;
--accent-soft: #e2efe6;
--accent-ink: #ffffff;
--selected: #e8e2d4;
--error: #a8322d;
--shadow: 0 10px 30px -12px rgb(28 27 25 / 35%);
--radius: 5px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #17181a;
--fg: #e7e4de;
--line: #35373b;
--accent: #7fc4a0;
--selected: #2c2f33;
--bg: #14161a;
--surface: #1b1e24;
--raised: #22262e;
--fg: #d7dae0;
--dim: #8b93a1;
--line: #2b2f37;
--line-strong: #414753;
--accent: #8ec07c;
--accent-soft: #1f2a22;
--accent-ink: #14161a;
--selected: #2c3340;
--error: #e2827c;
--shadow: 0 10px 30px -10px rgb(0 0 0 / 70%);
}
}
@@ -32,36 +53,214 @@ body {
flex-direction: column;
background: var(--bg);
color: var(--fg);
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
-webkit-font-smoothing: antialiased;
}
/* The one keyboard surface the page has is `#grid`, and until it is focused
nothing responds to a key. A visible ring is the affordance that says so. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ── 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. */
header {
display: flex;
gap: 1rem;
align-items: baseline;
padding: 0.5rem 1rem;
gap: 0.75rem;
align-items: center;
flex: none;
padding: 0.45rem 0.9rem;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
header h1 {
margin: 0;
font-size: 1rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--accent);
flex: none;
}
#doc-name {
opacity: 0.85;
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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dim);
}
/* The view's name reads as a label on the grid below it, so it takes the
chip shape the tile bar uses rather than the mode's filled one. */
#view-name {
flex: none;
padding: 0.05rem 0.5rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
font-size: 0.8rem;
}
#view-name:empty {
display: none;
}
/* 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 {
color: var(--accent);
flex: none;
padding: 0.1rem 0.55rem;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-ink);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
}
#status {
margin-left: auto;
opacity: 0.75;
#mode:empty {
display: none;
}
/* 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;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dim);
font-size: 0.8rem;
}
/* Two facts composed in one string (`shell.js::composeStatus`), so the dot
marks the whole line rather than either half. */
#status {
flex: 0 1 auto;
min-width: 0;
margin-left: auto;
text-align: right;
color: var(--dim);
font-size: 0.8rem;
}
#status:not(:empty)::before {
content: "●";
margin-right: 0.35rem;
color: var(--accent);
font-size: 0.7em;
vertical-align: middle;
}
/* ── The deployment notice ───────────────────────────────────────────────
Collapsed by default: the one sentence a first visitor needs is the
summary, and the rest is a configuration reference. clients.md. */
#backend-notice {
flex: none;
border-bottom: 1px solid var(--line);
background: var(--accent-soft);
font-size: 0.82rem;
}
#backend-notice > summary {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.4rem 0.9rem;
cursor: pointer;
list-style: none;
}
#backend-notice > summary::-webkit-details-marker {
display: none;
}
.notice-lede {
flex: 1 1 auto;
min-width: 0;
}
/* The disclosure's own affordance. It is the ONE place on this page where a
glyph is chosen by CSS state rather than by a fact from Rust — the open
state is the browser's, and nothing else knows it. */
.notice-toggle::after {
content: "▸ options";
color: var(--accent);
white-space: nowrap;
}
#backend-notice[open] .notice-toggle::after {
content: "▾ hide";
}
.notice-body {
padding: 0 0.9rem 0.6rem;
max-width: 100ch;
color: var(--dim);
}
.notice-body p {
margin: 0 0 0.5rem;
}
#backend-notice code {
padding: 0 0.25rem;
border-radius: 3px;
background: color-mix(in srgb, var(--line-strong) 40%, transparent);
color: var(--fg);
}
/* ── The tile bar and the page bar ───────────────────────────────────────
Two strips of chips. Which category is on which axis, and whether an axis
is navigable, are both decided in Rust; only the weight is here. */
#tiles,
#pages {
display: flex;
flex: none;
gap: 0.35rem;
align-items: center;
padding: 0.3rem 0.9rem;
border-bottom: 1px solid var(--line);
overflow-x: auto;
white-space: nowrap;
scrollbar-width: thin;
}
#tiles .tile,
#pages .page-axis {
padding: 0.1rem 0.55rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--raised);
font-size: 0.78rem;
color: var(--dim);
}
#pages {
background: var(--surface);
font-size: 0.85em;
}
/* A Page axis can be moved with `[`/`]`; a Filter one constrains without
being navigable (gotcha 27), which is why only one of them lights up. */
#pages .navigable {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
/* ── The frame: panels and grid ──────────────────────────────────────── */
main {
flex: 1;
display: flex;
@@ -72,7 +271,8 @@ main {
width: 15rem;
flex: none;
overflow: auto;
padding: 0.5rem;
padding: 0.5rem 0.25rem;
background: var(--surface);
border-right: 1px solid var(--line);
}
@@ -81,7 +281,8 @@ main {
width: 18rem;
flex: none;
overflow: auto;
padding: 0.5rem;
padding: 0.5rem 0.25rem;
background: var(--surface);
border-right: 1px solid var(--line);
}
@@ -93,123 +294,96 @@ main {
list-style: none;
}
#categories li,
#formulas li,
#views li {
padding: 0.12rem 0.5rem;
border-left: 2px solid transparent;
border-radius: 0 var(--radius) var(--radius) 0;
}
#views .axes {
margin-left: 0.5rem;
opacity: 0.6;
color: var(--dim);
font-size: 0.85em;
}
#views .active .name {
color: var(--accent);
}
/* The tile bar. One line above the grid, as the TUI draws it. */
#tiles {
display: flex;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--line);
overflow-x: auto;
white-space: nowrap;
}
#tiles .tile {
padding: 0 0.4rem;
border: 1px solid var(--line);
border-radius: 3px;
font-size: 0.85em;
}
/* The page bar. Magenta in the TUI; the FACT that an axis is navigable is
`cycle`, decided in Rust, and only this weight is the page's. */
#pages {
display: flex;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--line);
overflow-x: auto;
white-space: nowrap;
font-size: 0.85em;
}
#pages .page-axis {
padding: 0 0.4rem;
border: 1px solid var(--line);
border-radius: 3px;
opacity: 0.7;
}
#pages .navigable {
color: var(--accent, inherit);
opacity: 1;
}
/* The totals strip. Never summed here — it is projected, because the sum runs
over rows this viewport scrolled past. */
tfoot th,
td.total {
font-weight: 700;
border-top: 2px solid var(--line);
/* `td:not(.editable)` dims every unsettable cell; a total is unsettable and
is not incidental, so it opts back out. */
opacity: 1;
font-weight: 600;
}
#categories .item {
padding-left: 1.5rem;
opacity: 0.85;
padding-left: 1.75rem;
color: var(--dim);
}
#categories .category {
font-weight: 600;
}
/* One selection treatment everywhere it can occur: a filled row with an
accent edge. The tile bar takes the ring instead, being a chip. */
#categories .selected,
#formulas .selected,
#views .selected,
#tiles .selected,
td.selected {
#views .selected {
background: var(--selected);
outline: 1px solid var(--accent);
border-left-color: var(--accent);
color: var(--fg);
}
#hint {
opacity: 0.7;
font-size: 0.85em;
}
/* A keyboard edit in progress. Left-aligned even in a numeric column,
because what is drawn is the text being typed rather than a value. */
td.typing {
text-align: left;
outline: 2px solid var(--accent);
#tiles .selected {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-ink);
font-weight: 700;
}
#grid {
flex: 1;
overflow: auto;
padding: 0.5rem;
padding: 0.75rem;
scrollbar-width: thin;
}
/* ── The table ───────────────────────────────────────────────────────── */
table {
border-collapse: collapse;
background: var(--raised);
box-shadow: 0 1px 0 var(--line);
/* Every number in this grid is a measure, and a pivot table is read down
its columns: proportional digits make that harder for no gain. */
font-variant-numeric: tabular-nums;
}
th,
td {
border: 1px solid var(--line);
padding: 0.25rem 0.5rem;
padding: 0.28rem 0.6rem;
min-width: 6rem;
height: 1.75rem;
text-align: left;
font-weight: normal;
}
th {
background: color-mix(in srgb, var(--line) 35%, transparent);
thead th {
background: var(--surface);
color: var(--fg);
font-weight: 700;
letter-spacing: 0.02em;
}
tbody th[scope="row"] {
background: var(--surface);
font-weight: 600;
}
/* A group header, on either axis. The TUI draws these green and bold; the
glyph is already in the text (`indicator` in app.js). */
glyph is already in the text (`indicator` in shell.js). */
th.group {
color: var(--accent, inherit);
color: var(--accent);
background: var(--accent-soft);
font-weight: 700;
}
@@ -219,6 +393,7 @@ td.numeric {
td.error {
color: var(--error);
font-weight: 600;
}
/* The only affordance that matters: a cell the server said is settable. */
@@ -226,8 +401,41 @@ td.editable {
cursor: text;
}
td.editable:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
/* Everything else on the grid is an aggregate, and dimming it is what makes
the settable cells read as the surface you can act on. */
td:not(.editable) {
opacity: 0.7;
color: var(--dim);
}
/* The selected cell, and the row header that names it. `:has()` is what lets
the header light up without the page deriving which row is selected — the
selection is already on the cell, decided in Rust. */
td.selected {
background: var(--accent-soft);
outline: 2px solid var(--accent);
outline-offset: -2px;
color: var(--fg);
font-weight: 700;
}
tbody tr:has(td.selected) th[scope="row"] {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
/* A keyboard edit in progress. Left-aligned even in a numeric column,
because what is drawn is the text being typed rather than a value. */
td.typing {
text-align: left;
outline: 2px solid var(--accent);
outline-offset: -2px;
background: var(--accent-soft);
color: var(--fg);
font-weight: 700;
}
td input {
@@ -239,28 +447,101 @@ td input {
background: transparent;
}
/* The page cannot reach a server from a static host, and says so. */
#backend-notice {
margin: 0;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--accent) 10%, transparent);
font-size: 0.85rem;
/* The totals strip. Never summed here — it is projected, because the sum runs
over rows this viewport scrolled past. */
tfoot th,
td.total {
font-weight: 700;
border-top: 2px solid var(--line-strong);
background: var(--surface);
/* `td:not(.editable)` dims every unsettable cell; a total is unsettable and
is not incidental, so it opts back out. */
color: var(--fg);
}
#backend-notice code {
background: color-mix(in srgb, var(--line) 45%, transparent);
padding: 0 0.25rem;
/* ── The minibuffer ──────────────────────────────────────────────────────
The TUI's bottom line is a terminal convention and a web page's bottom-left
corner is where nothing is read. The CONTENT is unchanged — it is
`state.view.mode.minibuffer()`, the same value the TUI draws — so this is
position, weight and contrast and nothing else. */
#minibuffer {
position: fixed;
left: 50%;
bottom: 1.75rem;
transform: translateX(-50%);
z-index: 20;
max-width: min(46rem, calc(100vw - 2rem));
min-width: min(28rem, calc(100vw - 2rem));
padding: 0.6rem 0.9rem;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--raised);
box-shadow: var(--shadow);
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;
}
/* The help overlay. Content is improvise_protocol::help's; this is paint. */
/* The caret. Purely an affordance — it says "this is where typing lands" —
and it is drawn by CSS rather than written into the text, because the text
is the host's and the page adds nothing to it. */
#minibuffer::after {
content: "▏";
color: var(--accent);
animation: minibuffer-caret 1.1s steps(1) infinite;
}
@keyframes minibuffer-caret {
50% {
opacity: 0;
}
}
/* `MinibufColor`, resolved to a terminal-descended colour exactly as
`draw.rs::minibuf_fg` resolves it for the TUI. This is the last
translation and the only thing about the prompt that is the page's. */
#minibuffer.green {
border-color: #6aa84f;
color: #4f7f3c;
}
#minibuffer.cyan {
border-color: #3f9ba3;
color: #2f7980;
}
#minibuffer.yellow {
border-color: #b8952f;
color: #8a6f21;
}
@media (prefers-color-scheme: dark) {
#minibuffer.green {
color: #9edb8c;
}
#minibuffer.cyan {
color: #7fd8e0;
}
#minibuffer.yellow {
color: #e0cf7f;
}
}
/* ── The help overlay ────────────────────────────────────────────────────
Content is improvise_protocol::help's; this is paint. */
#help {
position: fixed;
inset: 2rem;
z-index: 30;
overflow: auto;
padding: 1rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--bg);
box-shadow: var(--shadow);
font-size: 0.9rem;
}
@@ -272,9 +553,13 @@ td input {
margin-bottom: 0.75rem;
}
.help-tab {
color: var(--dim);
}
.help-tab.active {
color: var(--accent);
font-weight: 600;
font-weight: 700;
}
.help-body {
@@ -290,9 +575,15 @@ td input {
min-height: 1.2em;
}
/* Only the key column is accented: the other kinds are prose rows that
happen to be `dt`s because they span both columns. */
.help-keys {
color: var(--accent);
}
.help-heading {
grid-column: 1 / -1;
font-weight: 600;
font-weight: 700;
color: var(--accent);
}
@@ -309,23 +600,13 @@ td input {
}
.help-dim {
opacity: 0.7;
color: var(--dim);
}
/* The bottom-bar prompt while a text-entry mode is open. The colour is the
mode's own answer (`MinibufColor`); this is the last translation, exactly
as `draw.rs::minibuf_fg` is the TUI's. */
#minibuffer {
padding: 0.25rem 0.5rem;
font-family: inherit;
border-top: 1px solid var(--accent);
}
#minibuffer.green {
color: #7ec699;
}
#minibuffer.cyan {
color: #6fd0d8;
}
#minibuffer.yellow {
color: #d8c96f;
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+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"]);
});