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
+91
View File
@@ -1644,6 +1644,97 @@ client resolves nothing under them. `BrowserKey` drops anything held with the
platform meta key precisely so `Cmd-R` does not arrive as a bare `r`; the page
does not claim it either.
### The deployment notice, and the one thing this page remembers
`#backend-notice` is a `<details>`. The collapsing is therefore the **browser's**
— keyboard operable, and correct with `app.js` absent, still loading, or thrown
— and the only thing the page adds is remembering the answer. That split is the
whole design: a static notice is what it was, and a notice whose open state is
decided in JavaScript would be a fourth thing that can be wrong on a page whose
job is not to be wrong about its own peer.
**It starts collapsed for a visitor who has never answered**, and the argument
is that collapsing withholds nothing. The one fact a first-time 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 is
behind the disclosure is `?doc=`, `?seed=`, `?ws=`, the `wss://` default and the
reverse proxy a plain-`ws://` server needs: *configuration reference*, looked up
rather than read, and irrelevant until a visitor has decided they want a second
document or a shared one. Expanded, it cost about 230px of an 820px window and
pushed the tile bar, the category tree and the grid below the fold on every
visit. The page is an application; it should open as one.
**The state is read through `shell.js` and stored in `app.js`**, on the usual
split: what a stored value *means* is a decision and is `noticeOpen`, while
touching `localStorage` is a browser API and stays in the page. `localStorage`
**throws** in a private window and in a context with site data blocked, and
returns `null` having simply forgotten, so every access is wrapped and there are
exactly two recognised spellings. Everything else — absent, empty, a value an
older page wrote — is *unanswered*, which is the default rather than a third
state: a page that treated forgetting as its own case would have to decide what
to do about it, and there is nothing to decide.
What is stored is per-viewer and per-origin by construction. It never reaches
Rust, never crosses to a peer, and is not state the application has any opinion
about — which is exactly the class browser storage is for, and exactly why it is
not a `ViewState` field (gotcha 30).
**The page's own keydown listener must not claim the summary's keys.** `Enter`
is claimed by the client (it is forwarded upstream), and the listener calls
`preventDefault()` on anything claimed — so without an explicit guard the
disclosure would open by pointer and be dead by keyboard, which is the whole
reason for using `<details>` rather than a `<button>` and a class. The guard is
the same shape as the inline editor's: the control that has focus owns its keys.
### Where the minibuffer is drawn, and why that is not a second derivation
The TUI draws the minibuffer on the bottom line, *instead of* the status line.
The browser has always drawn it in its own element, because `#status` carries
the transport's failures and a repaint must not erase one (gotcha 48) — but it
drew it as a 14px line in the page's bottom-left corner, which in a terminal is
a convention with decades behind it and in a browser window is where nothing is
read. With the grid scrolled, the cell being typed into can be 800px from the
text showing what is being typed.
So it is now a raised bar: fixed, centred over the grid, larger, bordered in the
mode's own colour and shadowed. **Every part of that is paint.** The content is
`RenderMinibuffer`, built in `render.rs` from `state.view.mode.minibuffer()` —
the same semantic value the TUI reads — and the colour is `MinibufColor`
resolved to a concrete colour exactly as `draw.rs::minibuf_fg` resolves it for
the terminal. Nothing here asks which mode is open, which buffer is being typed
into, or what the prompt says; a second answer to any of those is the disease
design-principles §5 is about. The caret is drawn as a CSS `::after` rather than
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.
### What the styling is, and what no test here can see
`web/style.css` stopped being "legibility only" and is now a small design
system: layered surfaces, one accent, a filled badge for the mode (the single
most consequential fact on the page, since every key means something different
in each), chips for the two axis strips, and one selection treatment used
everywhere a selection can occur. The palette is deliberately the landing page's
(`docs/index.html`), so the two read as one product.
Two things in it are worth naming because they look like derivations and are
not. The selected row's header lights up through `tbody tr:has(td.selected)`,
which is the *selection already decided in Rust* being read off the DOM by the
stylesheet rather than the page working out which row is selected. And
`.notice-toggle`'s glyph is chosen by `[open]`, which is the one piece of state
on this page that genuinely is the browser's and that nothing else knows.
**The harness cannot see any 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.
---
## `web/worker.js` — the worker half
+76
View File
@@ -3181,3 +3181,79 @@ The test asserts the status is `last`, not merely present, because the
or by computing the status from the category's *current* axis rather than
`Axis::next()` of it — the expected `Region → Col` reads `Region → Row` and
fails, so the test sees the rotation and not just the sentence.
### "the deployment notice starts collapsed and remembers being opened"
The page's `#backend-notice` was a static `<p>` carrying roughly ten dense
lines of configuration reference — `?doc=`, `?seed=`, `?ws=`, `wss://` versus
`ws://`, and the reverse proxy a plain-`ws://` server needs behind TLS — and it
was rendered above the tile bar, the category tree and the grid on **every**
visit. On the deployed page at 1280×820 it cost about 230px of the 820, which
is the difference between landing on an application and landing on its README.
Reported by the user against `fiddlerwoaroof.github.io/improvise/app/`.
The first-visit default is **collapsed**, and the argument is that nothing is
actually withheld by collapsing it: the one fact a first-time 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 is never collapsed. What
is behind the disclosure is *configuration*, which is looked up rather than
read, and which nobody needs before they have decided they want a second
document or a shared one.
The test asserts **both** directions across a reload, which is what separates
"remembers" from its two neighbours. A page that never reads storage passes the
open-then-reload half only if it also defaults open, and fails the
collapse-then-reload half; a page that always opens fails the first assertion
outright. Inject either fault — drop the `noticeOpen(remembered(...))`
assignment, or drop the `toggle` listener — and exactly one half fails.
What it does **not** establish is that storage is *optional*. A private window,
or one with site data blocked, throws from `localStorage` rather than returning
null, and Playwright's default context stores normally; that path is covered at
the unit level instead, by `noticeOpen` reading every non-spelling as the
default, with the `try`/`catch` in `app.js` argued and not run.
### "the notice opens from the keyboard, which the grid's own handler must not claim"
A `<details>` is a real control and its `<summary>` takes Enter and Space. The
page also installs a `window` keydown listener that hands the key to
`Client::on_key_event` and calls `preventDefault()` whenever the client claims
it — and `Enter` is claimed (it is forwarded upstream, which is why
`shell.spec.js` uses it to compose a frame on a client that has no snapshot).
So without the `el.notice.contains(e.target)` guard, focusing the summary and
pressing Enter would send a frame and suppress the browser's own toggle: the
disclosure would be operable by pointer and dead by keyboard, which is the
whole accessibility argument for using `<details>` in the first place.
Inject the fault by deleting that one guard line; this test fails and nothing
else in the suite moves, because every other keyboard assertion drives `#grid`.
### "the minibuffer is raised over the grid rather than filed under it"
`#minibuffer` was the last flex item in the body column, drawn as a 14px line
in the page's bottom-left corner with a one-pixel top border. In a terminal
that position is a convention with decades behind it; in a browser window it is
where nothing is read, and the user reported exactly that — a prompt reading
"Display a menu" in small text at the very bottom-left, missed. With the grid
scrolled the cell being typed into can be 800px away from the text showing what
is being typed.
The content is unchanged and deliberately so: `RenderMinibuffer` is built in
`improvise-wasm-client/src/render.rs` from `state.view.mode.minibuffer()`, the
same semantic value `src/draw.rs` draws, so there is no second derivation here
of what a prompt says (design-principles §5). What moved is position, size and
contrast, which is paint.
Three assertions, and the middle one is the one that earns the test. Being
inside the viewport is necessary but not sufficient — a short document satisfies
it with the old layout too. **Overlapping `#grid`'s own rectangle** is what
cannot happen for a statically-positioned element that follows `main` in the
flow, because a flex column gives its items disjoint bands: that is the
assertion that separates "raised over the content" from "moved within the
strip", and it fails the moment `position: fixed` is reverted. The font-size
comparison is the only prominence claim a browser can check; it was equal to the
body's before and is now strictly larger.
The precondition matters: `#mode` must read `INSERT` before anything is
measured, because `getBoundingClientRect` on a hidden element returns zeroes
and every comparison below would then be about the origin.
+111
View File
@@ -4,6 +4,11 @@
import { test, expect } from "@playwright/test";
// The page's own key, imported rather than spelled: a spec with its own
// copy would pass while reading a key nothing writes (shell.spec.js does
// the same with the handshake window).
import { NOTICE_KEY } from "../../web/shell.js";
async function openApp(page) {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
@@ -394,3 +399,109 @@ test.describe("the selection is on screen", () => {
expect(await page.locator("#grid").evaluate((g) => g.scrollTop)).toBeGreaterThan(0);
});
});
// The page's own chrome: what a visitor lands on, and where the prompt is.
// Only a browser can answer either — both are facts about layout and about
// storage that survives a reload. What these CANNOT see is the rest of the
// styling; that residue is named in context/code/clients.md.
test.describe("the shell puts the application first", () => {
const notice = (page) => page.locator("#backend-notice");
const stored = (page) => page.evaluate((k) => localStorage.getItem(k), NOTICE_KEY);
// The deployment notice is ten lines of configuration reference — ?doc=,
// ?seed=, ?ws=, wss:// and reverse proxies — and it was above the fold on
// every visit. See context/test-evidence/clients.md.
test("the deployment notice starts collapsed and remembers being opened", async ({
page,
}) => {
await page.goto("/app/index.html?doc=notice-memory.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
// The one sentence a visitor needs is never collapsed; the reference is.
await expect(page.locator("#backend-notice .notice-lede")).toBeVisible();
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
// BOTH directions, because a page that ignores storage passes the first
// half and a page that always opens passes the second (design-principles
// §7: assert the direction the default cannot supply).
await notice(page).locator("summary").click();
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
// `toggle` is dispatched as a task, not synchronously with the click, so
// the answer must be OBSERVED as stored before a reload can be said to
// have read it back. Without this the reload races the write.
await expect.poll(() => stored(page)).toBe("open");
await page.reload();
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
await notice(page).locator("summary").click();
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
await expect.poll(() => stored(page)).toBe("closed");
await page.reload();
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
});
// The `<details>` is the browser's control, and the page's own keydown
// handler claims Enter. Without the guard a keyboard visitor could focus
// the summary and not open it. See context/test-evidence/clients.md.
test("the notice opens from the keyboard, which the grid's own handler must not claim", async ({
page,
}) => {
await page.goto("/app/index.html?doc=notice-keyboard.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
await notice(page).locator("summary").focus();
await page.keyboard.press("Enter");
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
});
// The prompt was a small line in the page's bottom-left corner, 800px from
// the cell being typed into. See context/test-evidence/clients.md.
test("the minibuffer is raised over the grid rather than filed under it", async ({
page,
}) => {
await page.goto("/app/index.html?doc=minibuffer-place.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await page.locator("#grid").focus();
await expect(page.locator("#minibuffer")).toBeHidden();
// `a` is the one text-entry room this client can both enter and commit in
// (`keys.spec.js` drives the formula one). The precondition is that it
// really opened, or every measurement below is of a hidden element.
await page.keyboard.press("a");
await expect(page.locator("#mode")).toHaveText("INSERT");
await expect(page.locator("#minibuffer")).toBeVisible();
const boxes = await page.evaluate(() => {
const rect = (id) => {
const { top, bottom, left, right } = document
.getElementById(id)
.getBoundingClientRect();
return { top, bottom, left, right };
};
return {
mb: rect("minibuffer"),
grid: rect("grid"),
viewport: { height: window.innerHeight, width: window.innerWidth },
mbFont: parseFloat(getComputedStyle(document.getElementById("minibuffer")).fontSize),
bodyFont: parseFloat(getComputedStyle(document.body).fontSize),
};
});
// On screen without scrolling: the property a bottom-of-document line
// does not have once the page is taller than the window.
expect(boxes.mb.bottom).toBeLessThanOrEqual(boxes.viewport.height);
expect(boxes.mb.top).toBeGreaterThanOrEqual(0);
// And OVER the grid, not in a strip beneath it. This is what separates
// "raised" from "moved": a static bottom bar cannot overlap #grid at all.
expect(boxes.mb.top).toBeLessThan(boxes.grid.bottom);
expect(boxes.mb.bottom).toBeGreaterThan(boxes.grid.top);
// The one prominence claim a browser can check. It was body-sized before.
expect(boxes.mbFont).toBeGreaterThan(boxes.bodyFont);
await page.keyboard.press("Escape");
await expect(page.locator("#minibuffer")).toBeHidden();
});
});
+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"]);
});