Merge main (#120 ui/shell-polish) into feat/oujb-web-in-release
#120 restyled the page and rewrote #backend-notice as a <details>; <header>'s markup is untouched, so #build-version lands where it was written. What the merge actually has to answer is the CSS, and the stamp gate after it. 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
@@ -59,27 +59,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;
|
||||
|
||||
+412
-127
@@ -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,218 @@ 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 {
|
||||
/* Expanded, this must not take the whole window: `main` is free to shrink
|
||||
to nothing, so a short viewport would lose the grid entirely. */
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
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 +275,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 +285,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 +298,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;
|
||||
}
|
||||
|
||||
@@ -217,8 +395,17 @@ td.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* …and the semantic colours come AFTER it, because they are now the same
|
||||
property at the same specificity and only the order separates them. */
|
||||
td.error {
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The only affordance that matters: a cell the server said is settable. */
|
||||
@@ -226,8 +413,33 @@ td.editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
td:not(.editable) {
|
||||
opacity: 0.7;
|
||||
td.editable:hover {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
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);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
td input {
|
||||
@@ -239,28 +451,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 +557,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 +579,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 +604,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