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:
@@ -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
|
platform meta key precisely so `Cmd-R` does not arrive as a bare `r`; the page
|
||||||
does not claim it either.
|
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
|
## `web/worker.js` — the worker half
|
||||||
|
|||||||
@@ -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
|
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
|
`Axis::next()` of it — the expected `Region → Col` reads `Region → Row` and
|
||||||
fails, so the test sees the rotation and not just the sentence.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
import { test, expect } from "@playwright/test";
|
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) {
|
async function openApp(page) {
|
||||||
await page.goto("/app/index.html");
|
await page.goto("/app/index.html");
|
||||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
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);
|
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
@@ -17,6 +17,9 @@ import {
|
|||||||
indexAction,
|
indexAction,
|
||||||
indicator,
|
indicator,
|
||||||
isFatal,
|
isFatal,
|
||||||
|
NOTICE_KEY,
|
||||||
|
noticeOpen,
|
||||||
|
noticeState,
|
||||||
noticeText,
|
noticeText,
|
||||||
pageEntries,
|
pageEntries,
|
||||||
socketUrl,
|
socketUrl,
|
||||||
@@ -45,8 +48,38 @@ const el = {
|
|||||||
status: document.getElementById("status"),
|
status: document.getElementById("status"),
|
||||||
viewName: document.getElementById("view-name"),
|
viewName: document.getElementById("view-name"),
|
||||||
docName: document.getElementById("doc-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();
|
await init();
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
|
||||||
@@ -591,6 +624,11 @@ window.addEventListener("keydown", (e) => {
|
|||||||
// BrowserKey drops anything held with the platform meta key precisely so
|
// 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.
|
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
|
||||||
if (e.metaKey) return;
|
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);
|
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
|
||||||
if (frame) {
|
if (frame) {
|
||||||
|
|||||||
+37
-20
@@ -53,27 +53,44 @@
|
|||||||
and a page that cannot reach its peer cannot be trusted to explain why.
|
and a page that cannot reach its peer cannot be trusted to explain why.
|
||||||
What the peer is actually *doing* — starting, connected, unreachable,
|
What the peer is actually *doing* — starting, connected, unreachable,
|
||||||
gone — is live, and app.js writes it to #status.
|
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">
|
<details id="backend-notice">
|
||||||
Everything here runs in this tab: the document lives in a Web Worker
|
<summary>
|
||||||
and is saved to this browser's private storage, so edits survive a
|
<span class="notice-lede"
|
||||||
reload and never leave the machine. Load a different document with
|
>Everything here runs in this tab — the document lives in a Web
|
||||||
<code>?doc=name.improv</code>, seeded from
|
Worker and is saved to this browser's private storage, so edits
|
||||||
<code>?seed=url</code>. To open a document hosted by a separate
|
survive a reload and never leave the machine.</span
|
||||||
process instead, run
|
>
|
||||||
<code>improvise-ws-server model.improv --port 8787</code> and reload
|
<span class="notice-toggle" aria-hidden="true"></span>
|
||||||
with <code>?ws=host:port</code>. <strong>The scheme defaults to
|
</summary>
|
||||||
<code>wss://</code></strong>, because this page is served over https and
|
<div class="notice-body">
|
||||||
an https origin refuses a <code>ws://</code> address as mixed content.
|
<p>
|
||||||
Note that <code>improvise-ws-server</code> speaks plain
|
Load a different document with <code>?doc=name.improv</code>, seeded
|
||||||
<code>ws://</code> only, so reaching it from <em>this</em> page means
|
from <code>?seed=url</code>. To open a document hosted by a separate
|
||||||
terminating TLS in front of it with a reverse proxy or a tunnel — the
|
process instead, run
|
||||||
README has a worked Caddy example. Served over
|
<code>improvise-ws-server model.improv --port 8787</code> and reload
|
||||||
<code>http://localhost</code> the same page defaults to
|
with <code>?ws=host:port</code>.
|
||||||
<code>ws://</code> and pairs with it directly, with nothing in between;
|
</p>
|
||||||
that is the case the pairing is really for. Sharing a document is what
|
<p>
|
||||||
the server is for — not running one is what everything above is for.
|
<strong>The scheme defaults to <code>wss://</code></strong>, because
|
||||||
</p>
|
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>
|
<div id="tiles" aria-label="Tiles"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ export function indicator(collapsed) {
|
|||||||
return 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.
|
// 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.
|
// Being wrong here costs a row of over- or under-fetch, nothing more.
|
||||||
export const ROW_PX = 26;
|
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 {
|
:root {
|
||||||
--bg: #fbfaf7;
|
--bg: #fbfaf7;
|
||||||
|
--surface: #f4f1ea;
|
||||||
|
--raised: #ffffff;
|
||||||
--fg: #1c1b19;
|
--fg: #1c1b19;
|
||||||
--line: #d8d4cc;
|
--dim: #6b675f;
|
||||||
|
--line: #ddd8ce;
|
||||||
|
--line-strong: #bdb6a8;
|
||||||
--accent: #2f6f4f;
|
--accent: #2f6f4f;
|
||||||
|
--accent-soft: #e2efe6;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
--selected: #e8e2d4;
|
--selected: #e8e2d4;
|
||||||
--error: #a8322d;
|
--error: #a8322d;
|
||||||
|
--shadow: 0 10px 30px -12px rgb(28 27 25 / 35%);
|
||||||
|
--radius: 5px;
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #17181a;
|
--bg: #14161a;
|
||||||
--fg: #e7e4de;
|
--surface: #1b1e24;
|
||||||
--line: #35373b;
|
--raised: #22262e;
|
||||||
--accent: #7fc4a0;
|
--fg: #d7dae0;
|
||||||
--selected: #2c2f33;
|
--dim: #8b93a1;
|
||||||
|
--line: #2b2f37;
|
||||||
|
--line-strong: #414753;
|
||||||
|
--accent: #8ec07c;
|
||||||
|
--accent-soft: #1f2a22;
|
||||||
|
--accent-ink: #14161a;
|
||||||
|
--selected: #2c3340;
|
||||||
--error: #e2827c;
|
--error: #e2827c;
|
||||||
|
--shadow: 0 10px 30px -10px rgb(0 0 0 / 70%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,36 +53,214 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
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 {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
padding: 0.5rem 1rem;
|
flex: none;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#doc-name {
|
#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 {
|
#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 {
|
#mode:empty {
|
||||||
margin-left: auto;
|
display: none;
|
||||||
opacity: 0.75;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -72,7 +271,8 @@ main {
|
|||||||
width: 15rem;
|
width: 15rem;
|
||||||
flex: none;
|
flex: none;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem 0.25rem;
|
||||||
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +281,8 @@ main {
|
|||||||
width: 18rem;
|
width: 18rem;
|
||||||
flex: none;
|
flex: none;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem 0.25rem;
|
||||||
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,123 +294,96 @@ main {
|
|||||||
list-style: none;
|
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 {
|
#views .axes {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
opacity: 0.6;
|
color: var(--dim);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#views .active .name {
|
#views .active .name {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
font-weight: 600;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#categories .item {
|
#categories .item {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.75rem;
|
||||||
opacity: 0.85;
|
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,
|
#categories .selected,
|
||||||
#formulas .selected,
|
#formulas .selected,
|
||||||
#views .selected,
|
#views .selected {
|
||||||
#tiles .selected,
|
|
||||||
td.selected {
|
|
||||||
background: var(--selected);
|
background: var(--selected);
|
||||||
outline: 1px solid var(--accent);
|
border-left-color: var(--accent);
|
||||||
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#hint {
|
#tiles .selected {
|
||||||
opacity: 0.7;
|
border-color: var(--accent);
|
||||||
font-size: 0.85em;
|
background: var(--accent);
|
||||||
}
|
color: var(--accent-ink);
|
||||||
|
font-weight: 700;
|
||||||
/* 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#grid {
|
#grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.5rem;
|
padding: 0.75rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── The table ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
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,
|
th,
|
||||||
td {
|
td {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.28rem 0.6rem;
|
||||||
min-width: 6rem;
|
min-width: 6rem;
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
thead th {
|
||||||
background: color-mix(in srgb, var(--line) 35%, transparent);
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody th[scope="row"] {
|
||||||
|
background: var(--surface);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A group header, on either axis. The TUI draws these green and bold; the
|
/* 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 {
|
th.group {
|
||||||
color: var(--accent, inherit);
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +393,7 @@ td.numeric {
|
|||||||
|
|
||||||
td.error {
|
td.error {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The only affordance that matters: a cell the server said is settable. */
|
/* The only affordance that matters: a cell the server said is settable. */
|
||||||
@@ -226,8 +401,41 @@ td.editable {
|
|||||||
cursor: text;
|
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) {
|
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 {
|
td input {
|
||||||
@@ -239,28 +447,101 @@ td input {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The page cannot reach a server from a static host, and says so. */
|
/* The totals strip. Never summed here — it is projected, because the sum runs
|
||||||
#backend-notice {
|
over rows this viewport scrolled past. */
|
||||||
margin: 0;
|
tfoot th,
|
||||||
padding: 0.5rem 1rem;
|
td.total {
|
||||||
border-bottom: 1px solid var(--line);
|
font-weight: 700;
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
border-top: 2px solid var(--line-strong);
|
||||||
font-size: 0.85rem;
|
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 {
|
/* ── The minibuffer ──────────────────────────────────────────────────────
|
||||||
background: color-mix(in srgb, var(--line) 45%, transparent);
|
The TUI's bottom line is a terminal convention and a web page's bottom-left
|
||||||
padding: 0 0.25rem;
|
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 {
|
#help {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 2rem;
|
inset: 2rem;
|
||||||
|
z-index: 30;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 1rem;
|
padding: 1.25rem 1.5rem;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +553,13 @@ td input {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-tab {
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
.help-tab.active {
|
.help-tab.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-body {
|
.help-body {
|
||||||
@@ -290,9 +575,15 @@ td input {
|
|||||||
min-height: 1.2em;
|
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 {
|
.help-heading {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,23 +600,13 @@ td input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help-dim {
|
.help-dim {
|
||||||
opacity: 0.7;
|
color: var(--dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The bottom-bar prompt while a text-entry mode is open. The colour is the
|
@media (prefers-reduced-motion: reduce) {
|
||||||
mode's own answer (`MinibufColor`); this is the last translation, exactly
|
* {
|
||||||
as `draw.rs::minibuf_fg` is the TUI's. */
|
animation-duration: 0.01ms !important;
|
||||||
#minibuffer {
|
animation-iteration-count: 1 !important;
|
||||||
padding: 0.25rem 0.5rem;
|
transition-duration: 0.01ms !important;
|
||||||
font-family: inherit;
|
}
|
||||||
border-top: 1px solid var(--accent);
|
|
||||||
}
|
|
||||||
#minibuffer.green {
|
|
||||||
color: #7ec699;
|
|
||||||
}
|
|
||||||
#minibuffer.cyan {
|
|
||||||
color: #6fd0d8;
|
|
||||||
}
|
|
||||||
#minibuffer.yellow {
|
|
||||||
color: #d8c96f;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
frameCostOf,
|
frameCostOf,
|
||||||
indicator,
|
indicator,
|
||||||
isFatal,
|
isFatal,
|
||||||
|
NOTICE_OPEN_BY_DEFAULT,
|
||||||
|
noticeOpen,
|
||||||
|
noticeState,
|
||||||
noticeText,
|
noticeText,
|
||||||
socketUrl,
|
socketUrl,
|
||||||
} from "../shell.js";
|
} 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 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