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:
+38
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user