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:
Edward Langley
2026-09-21 22:56:32 -07:00
13 changed files with 1393 additions and 147 deletions
+4
View File
@@ -269,6 +269,10 @@ jobs:
# No trealla needed: pr-watch is driven entirely from fixtures. # No trealla needed: pr-watch is driven entirely from fixtures.
- name: The PR watcher refuses what it claims to - name: The PR watcher refuses what it claims to
run: ./scripts/pr-watch-test.sh run: ./scripts/pr-watch-test.sh
# The release preflight is the one guard whose absence is invisible until
# a tag is already public and immutable, so its refusals are checked here.
- name: The release preflight refuses what it claims to
run: ./scripts/release-preflight-test.sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lints the one crate the `test` job cannot reach. # Lints the one crate the `test` job cannot reach.
+71
View File
@@ -0,0 +1,71 @@
# Releasing
A checklist, not an explanation. The reasoning is `context/code/tooling.md` §4;
read it if a step surprises you, not before.
Its own file rather than a `README.md` section because `README.md` §Installation
addresses someone *installing* improvise and this addresses the one person
*publishing* it — folding them together lengthens the user-facing document for
no user benefit.
## The one rule
**Bump the version and commit it BEFORE you tag.** `dist` matches the tag
against the root package's version and refuses when they disagree — and by the
time CI tells you, the tag is already published and a published tag is not
moved. Every step below exists to put that check before the irreversible one.
## Cutting a release
1. **Bump the version.** `Cargo.toml`'s `[package]` version is the one `dist`
announces (`dist-workspace.toml` says `members = ["cargo:."]`). Keep the
crates under `crates/` in step with it; `gp-tree` sits at its own version
deliberately, and the preflight lists such crates as a note rather than an
error.
2. **Commit the bump**, and let `Cargo.lock` be part of that commit.
3. **Run the preflight**, with the tag you intend to push:
```sh
scripts/release-preflight.sh vX.Y.Z
```
It refuses a dirty tree, refuses a tag that does not match the version, runs
`dist plan` as a second opinion when `dist` is on `PATH`, and prints the tag
commands only once it has passed. It cuts nothing itself.
4. **Tag and push**, using the commands it printed:
```sh
git tag -a vX.Y.Z -m "vX.Y.Z"
git push gh vX.Y.Z
```
Push the tag to **`gh`** — `.github/workflows/release.yml` triggers on a tag
push there, matching `**[0-9]+.[0-9]+.[0-9]+*`. `origin` and `gitea` are
mirrors and build nothing.
5. **Watch the run.** A green release takes about three minutes and publishes
tarballs for all four targets with their `.sha256` files, `sha256.sum`,
`dist-manifest.json`, `source.tar.gz` and `improvise-installer.sh`.
The failure signature is unmistakable once you know it: **`plan` fails in
about twenty seconds and the four jobs behind it skip.** That is the tag not
matching the version — see below. A run that gets past `plan` is building.
## When a tag has already been pushed against the wrong version
**Leave it dead and cut the next one.** Do not move or re-push the tag: it is
already public, and a published tag that changes meaning is worse than one that
never built. Bump to the next version, commit, run the preflight, and tag that.
The worked example is `v0.1.0-rc3`: it was pushed while every crate still said
`0.1.0-rc2`, `dist` refused, and the four jobs behind `plan` skipped. No release
was ever built from it.
**Ask which tags exist; do not trust a document that tells you.** `git tag -l`,
and `git ls-remote --tags <remote>` for each remote you push to. Which tags are
where is a fact with a shelf life — it changed twice while this file was being
written — so this file deliberately records no inventory of it. Cleaning up a
dead tag is a separate decision and is not a step in cutting a release.
+103
View File
@@ -1653,6 +1653,109 @@ 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.
**One rule in it is load-bearing and is the stylesheet's own failure mode.**
`td.error` is the only place on this page where colour carries *meaning*
rather than emphasis — a circular reference or a `mixed` aggregate reading as
an ordinary number is worse than it reading as nothing. It used to coexist with
the dimming of unsettable cells because they set **different** properties
(`color` against `opacity`); once both are `color` at the same specificity,
only source order separates them, and nothing in any language fails when the
order is wrong. So the dimming sits **above** the semantic colours, and a
selection is a ring and a tint and never a colour. That is the class of defect
a stylesheet has and a program does not, and it is why the one assertion this
file makes about colour is about which rule wins rather than about a value.
**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
+169
View File
@@ -325,3 +325,172 @@ build already did some of that. Closing it properly means no two runs holding a
`github-pages` artifact across a deployment, which is a real change to the job `github-pages` artifact across a deployment, which is a real change to the job
graph with real costs and is not attempted here. A retry is **not** a fix: the graph with real costs and is not attempted here. A retry is **not** a fix: the
artifact is gone rather than transiently unlistable, so a retry cannot find it. artifact is gone rather than transiently unlistable, so a retry cannot find it.
### 4. The release preflight, and why a loud failure was not enough
`scripts/release-preflight.sh` answers, before a tag exists, the question the
Release workflow answers after. **The procedure is `RELEASING.md`**; this
section is the argument behind it, and the split is deliberate — a checklist
followed at 2am is not read for its reasoning, and one that carries it does not
get followed.
**The root cause was a missing document, not a missing check.** Nothing in this
repo said *bump the version before you tag* — `release.yml` is cargo-dist's
autogenerated mechanism, `README.md` §Installation describes the result, and
`context/plan.md` Phase 3 carries one-time *setup* instructions from the launch
plan. So the preflight is the second half of the fix; `RELEASING.md` is the
first.
**What happened.** `v0.1.0-rc3` was pushed while every
crate still said `0.1.0-rc2`. dist matches a tag against the root package's
version, refused with *"This workspace doesn't have anything for dist to
Release!"*, and the four jobs behind `plan` skipped. **Nothing was wrong with
the workflow**, which is worth stating plainly because the instinct on a failed
release run is to edit the YAML. The trigger is correct, the pattern matches,
the workflow is enabled; `dist plan --tag=v0.1.0-rc2` succeeds today and
`--tag=v0.1.0-rc3` reproduces the CI error exactly.
**The decisive evidence is a shipped release, not that local run**, and an
earlier draft of this passage rested on the weaker half. `v0.1.0-rc1` and
`v0.1.0-rc2` both carry **complete published GitHub Releases** from
2026-04-11 — tarballs for all four targets, their `.sha256` files,
`sha256.sum`, `dist-manifest.json`, `source.tar.gz` and
`improvise-installer.sh` (`gh release view v0.1.0-rc2 --json assets`). So the
workflow is not merely *expected* to work: it **demonstrably ran end to end**
the last time a tag and the version agreed. Note how nearly the opposite was
concluded — `gh run list` came back empty and was read as "only one tag push
ever ran this", when Actions logs simply age out at 90 days and those tags are
five months old. **An empty log is a statement about retention, not about
history**; the artefact a run *published* outlives the run, so ask the releases
before asking the runs. "The four jobs behind `plan` skipped" is true of the
rc3 run alone and must not be read as "no release has ever been built".
**A tag push has failed this way before — and how much that proves is worth
being exact about.** `gh run list --workflow=release.yml --event=push` keeps the
whole tag history, and it reads: rc1 **failed twice** (14s each) before
succeeding on the third push (3m25s), rc2 succeeded first time (3m9s), rc3
failed (21s). All three failures carry the **identical signature** — `plan:
failure` with `build-local-artifacts`, `build-global-artifacts`, `host` and
`announce` all `skipped` — checked per job, not inferred from the durations.
**A shared signature is not a shared cause, and rc1's is not knowable now.**
Only rc3's was reproduced (`dist plan --tag=v0.1.0-rc3`); rc1's logs aged out at
90 days, and its two failures sit on commits titled *"feat: cargo dist info"*
and *"chore: update version"*, i.e. during initial setup, where `plan` could as
easily have failed on an incomplete `dist` config. So the honest claim is: this
failure **shape** has now cost four pushes across two releases, and the one time
anyone has diagnosed it, it was the version/tag disagreement. That is ample
reason for `RELEASING.md` and a check before the irreversible step, and it is
**not** a claim that the same mistake was made three times — a first draft of
this paragraph said exactly that, from the durations alone, which is the same
asserted-not-asked error the two paragraphs around it correct.
The signature is worth memorising because it is so cheap to read: **`plan` fails
in ~20s with four jobs skipped**, where a real build takes about three minutes.
Note
the workflow also triggers on `pull_request`, where it plans without a tag and
passes in ~20s — so a *green* 20s Release run on a PR and a *failed* 20s one on
a tag look alike at a glance and mean opposite things.
**Why a loud, precise failure still cost something.** dist named the right
answer in its own error text — *"`--tag=v0.1.0-rc2` will Announce: improvise"*.
The problem is not the message, it is **when** it arrives: a tag is public and
immutable by the time CI reads it, so the repair is a new version rather than an
edit, and the tag that failed stays published forever. A check that runs
after the irreversible step is a check in the wrong place, however good its
message. So the preflight runs *before* tagging and prints the tag command only
once it has passed — the honest path made the easy one, the same shape as
`Basis` having no public constructor (gotcha 43).
**"All three remotes" was wrong when it was finally checked.** `aa4e901`'s
commit message said `v0.1.0-rc3` had been pushed to all three, and that was
copied from there into this section and into `RELEASING.md` before anyone ran
`git ls-remote`. Run, it showed the tag on `gh` alone. **That commit message is
superseded on this point and cannot be corrected**, which is why
`context/design-principles.md` §6 says an argument must not live only in one.
The irony was local: it sat two paragraphs from *an empty log is a statement
about retention, not about history* — the same failure, a remote-shaped fact
asserted rather than asked.
**Then the fact changed again, within hours, and that is the better lesson.**
Three measurements of the same thing in one session: present on all three
remotes and locally; then on `gh` alone; then **nowhere at all** — `rc3` was
deleted while this branch was in review. Only the first reading was careless.
The second and third were each correct when taken, by two different people, and
they disagree.
So the rule this section reaches for is **not** "ask rather than assert". It is
that **a remote-shaped fact has a shelf life even after you ask**: a verified
answer decays, and prose cannot distinguish a stale line from a live one, so a
reader at 2am has no way to tell which they are holding. A document that records
an inventory is therefore wrong on a schedule it does not control. This section
and `RELEASING.md` consequently record **how to ask** — `git tag -l`,
`git ls-remote --tags <remote>` — and deliberately record no inventory of which
tags exist where. What never decayed is the principle: a wrong tag is left dead
and the next version is cut. Note which half of the original correction survives
and which did not: *the commit message was wrong* is durable, because it is a
fact about a fixed text; *the tag is on `gh` alone* was true and is now false.
The keeper is the one whose subject cannot change.
**The primary check is textual and the `dist plan` run is a second opinion.**
An absent `dist` must not read as a pass, so the script says which of the two
ran. It also tolerates a locally-installed dist whose version differs from the
one pinned in `dist-workspace.toml`: that stops dist before it plans, and
reporting it as a refusal would train the reader to ignore refusals.
**Only the root package's version can disagree with a tag.**
`dist-workspace.toml` says `members = ["cargo:."]`, so `gp-tree` sitting at
`0.1.0` while everything else is on `0.1.0-rc2` is not a finding. The script
lists such crates and says so rather than failing, because a deliberate split
should stay deliberate and a silent one should be visible.
**The test's first version was vacuous, and the fault injection is what showed
it.** `scripts/release-preflight-test.sh` originally asserted only *did it
refuse*. Every negative case passed with the tag comparison replaced by
`if false;` — because the script refuses a **dirty working tree** first, and
this repo's tree is almost always dirty, bd auto-exporting `.beads/issues.jsonl`
on nearly every command. Four of five checks were answering *is the tree dirty*
while claiming to answer *does it catch a bad tag*. The test now runs the
negative cases against a clean throwaway checkout **and asserts the refusal
names the version mismatch**, so a refusal for the wrong reason is a failure
rather than a pass. Two rival faults were injected against the repaired
version — the comparison disabled, and `expected` derived from the tag itself —
and each produces four failures and exit 1.
That is `context/design-principles.md` §7's "a check that looks like the answer,
answering a neighbouring question", found in a checker written to prevent
exactly that class, within an hour of writing it.
**And the repair opened a second blind spot in the same place — which is the
more useful half.** Moving the negative cases to a clean checkout is what made
the tag comparison reachable at all, and it thereby removed the only
circumstance in which the test could observe the **dirty-tree guard**.
Disabling that guard outright left all six checks green and the script exiting
`0`: the guard whose *precedence* caused the original vacuity had become the one
thing the suite could not see, and the repair's own shape is what hid it. A
vacuity and its repair are not two events here; the repair relocated the blind
spot rather than closing it.
The check that closes it is therefore aimed the other way — a tag that
**matches** the version, run against a dirtied checkout, so the comparison
passes and a refusal can only be the tree. That is §7's *assert the direction
the default cannot supply* applied to a **fixture** rather than to a default:
the clean checkout is the fixture, and the discriminating input is the one that
makes it dirty. The two faults are independent in both directions — with the
comparison disabled the dirty-tree check still passes, and with the guard
disabled the three tag checks still pass — which is what makes the seventh
check worth having rather than a restatement of the other six.
**What is still unchecked, and why that is a decision.** The `dist plan` second
opinion can be disabled — its refusal arm rewritten never to match — with the
suite green, because every input the test feeds it is one the textual check has
already refused. That follows from the arm's own design rather than being an
oversight: it runs *after* the primary check, it is allowed to be absent
entirely, and an absent `dist` already prints that only the textual check ran.
Pinning it would mean installing a `dist` matching `cargo-dist-version`, which
is the very mismatch the script tolerates on purpose. Stated here rather than
guarded, so the next reader does not mistake the silence for coverage.
**Where it runs.** The `kb` job, beside `scripts/pr-watch-test.sh` — this
repo keeps its check_the_checkers there, not in `test`. The step needs no
trealla and no nix of its own; it is `sh`, `git archive` and `awk`.
+110
View File
@@ -3252,3 +3252,113 @@ that no keymap binds `view-forward` **and** that one binds `view-back`. The
control matters: without it the check passes against a misspelling, which is the control matters: without it the check passes against a misspelling, which is the
neighbouring-question defect §7 names. When a binding is added, this test fails neighbouring-question defect §7 names. When a binding is added, this test fails
and is where the gap stops being one. and is where the gap stops being one.
### "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.
### "an error cell stays an error cell when it is also dimmed or selected"
Found reviewing the styling change that introduced it, before it was pushed.
`td.error` and `td:not(.editable)` used to set **different** properties —
`color: var(--error)` and `opacity: 0.7` — so both applied and a dimmed error
cell was a dimmed red. The restyling made the dimming a `color` too, at the
same specificity, and put it **after** `td.error` in the file, so an error in
an aggregate cell rendered plain grey: measured `rgb(107, 103, 95)` where the
error colour is `rgb(168, 50, 45)`. `td.selected` and `td.typing` had the same
effect by setting `color: var(--fg)`, which is how a *selected* error lost its
colour as well.
That is the whole class of defect a stylesheet has and a program does not:
two rules that were independent become one property, and nothing in any
language fails. `#grid td.error` is the only place in this page where colour
carries meaning rather than emphasis, and it is the one a reader takes at face
value — a circular reference or a `mixed` aggregate reading as an ordinary
number is worse than it reading as nothing.
The fix is ordering plus a rule: the dimming goes **above** the semantic
colours, and a selection is a ring and a tint and never a colour. The test is
aimed at the stylesheet rather than at a document, deliberately — the question
is which rule wins, and a fixture per combination (a dimmed error, a selected
error, an error being typed over) would be three documents testing three
documents. It appends a `<td>` carrying each combination to the real page's
real table, reads `getComputedStyle`, and compares against two controls taken
the same way: a plain unsettable cell (what it must not be) and an error the
dimming cannot reach (what it must be). Reading absolute colours instead would
pin the palette, which is not the property.
Inject the fault by moving the `td:not(.editable)` block back below
`td.error`, or by restoring either `color: var(--fg)`; each makes exactly the
corresponding assertion fail.
+156
View File
@@ -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,154 @@ 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();
});
});
// The stylesheet's own cascade, which no other test here can reach: an
// error is a colour now, where it used to be a colour AND an opacity, so
// the order of two equally-specific rules decides it. See
// context/test-evidence/clients.md.
test.describe("the grid's semantic colours survive the cascade", () => {
test("an error cell stays an error cell when it is also dimmed or selected", async ({
page,
}) => {
await page.goto("/app/index.html?doc=error-colour.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
// The question is about the STYLESHEET, so the combinations are put to it
// directly: the document that produces each of them is a separate matter,
// and a fixture per combination would test the fixture.
const colours = await page.evaluate(() => {
const row = document.querySelector("#grid tbody tr");
const read = (classes) => {
const td = document.createElement("td");
td.className = classes;
td.textContent = "#err";
row.appendChild(td);
const colour = getComputedStyle(td).color;
td.remove();
return colour;
};
return {
// An aggregate error, which is the common case: `editable` is false
// for every cell that is not a stored record.
dimmed: read("error"),
selected: read("error selected"),
typing: read("error typing"),
// What "dimmed" must NOT be, read off a plain unsettable cell.
dim: read(""),
// …and what it must be, read off an error the dimming cannot reach.
error: read("error editable"),
};
});
expect(colours.error).not.toBe(colours.dim);
expect(colours.dimmed).toBe(colours.error);
expect(colours.selected).toBe(colours.error);
expect(colours.typing).toBe(colours.error);
});
});
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
# check_the_checker for scripts/release-preflight.sh.
#
# It exists for the reason scripts/assemble-stamp-test.sh does: the guard is the
# whole value, and a guard that silently stopped guarding is invisible from the
# outside -- a preflight that accepted everything would print the same reassuring
# lines it prints today.
#
# IT ASSERTS THE REASON, NOT THE EXIT CODE, and that is the point. A first version
# checked only "did it refuse", and every negative case passed while the tag
# comparison was disabled -- because the script refuses a DIRTY WORKING TREE first,
# and this repo's tree is usually dirty (bd auto-exports .beads/issues.jsonl). Four
# of five checks were answering "is the tree dirty" while claiming to answer "does
# it catch a bad tag". Injecting `if false;` into the comparison is what exposed it.
# See context/design-principles.md section 7 -- a check adjacent to the thing it names.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
script="$root/scripts/release-preflight.sh"
fails=0
# Refuses, AND the refusal names the tag/version mismatch rather than the tree.
refuses_for_the_tag() {
what=$1; tag=$2
out=$(sh "$script" "$tag" 2>&1) && { echo "FAIL: $what -- accepted" >&2; fails=$((fails+1)); return; }
case "$out" in
*"Cargo.toml has"*) echo " ok: $what (refused, naming the version)" ;;
*"uncommitted changes"*) echo "FAIL: $what -- refused for the DIRTY TREE, so this check proved nothing" >&2; fails=$((fails+1)) ;;
*) echo "FAIL: $what -- refused for an unrecognised reason:" >&2; echo "$out" | head -3 >&2; fails=$((fails+1)) ;;
esac
}
version=$(awk '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^version *=/{gsub(/[" ]/,"");sub(/^version=/,"");print;exit}' \
"$root/Cargo.toml")
test -n "$version" || { echo "FAIL: could not read the root version" >&2; exit 1; }
echo "release-preflight-test: root version is $version"
# The dirty-tree guard runs first, so the negative cases are exercised against a
# CLEAN checkout of this tree rather than the working copy. Without this the test
# cannot reach the comparison it exists to check.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
git -C "$root" archive HEAD | (cd "$tmp" && tar x)
cp -f "$script" "$tmp/scripts/release-preflight.sh"
cp -f "$root/Cargo.toml" "$tmp/Cargo.toml"
(cd "$tmp" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t commit -qm t)
script="$tmp/scripts/release-preflight.sh"
if sh "$script" --self-test >/dev/null 2>&1; then
echo " ok: --self-test (accepted)"
else
echo "FAIL: --self-test refused" >&2; fails=$((fails+1))
fi
if sh "$script" >/dev/null 2>&1; then
echo "FAIL: no argument should be a usage error" >&2; fails=$((fails+1))
else
echo " ok: no argument is a usage error (refused)"
fi
refuses_for_the_tag "a tag one character off" "v${version}x"
refuses_for_the_tag "the shape that actually broke CI" "v0.1.0-rc99"
refuses_for_the_tag "a bare version with no v" "$version"
# The positive direction, which only a clean tree can exercise.
if sh "$script" "v$version" >/dev/null 2>&1; then
echo " ok: the matching tag v$version (accepted)"
else
echo "FAIL: the matching tag v$version was refused" >&2; fails=$((fails+1))
fi
# The dirty-tree guard, which every check above is deliberately blind to: they
# run on a CLEAN checkout precisely so the tag comparison is reachable at all.
# The tag here MATCHES the version, so the comparison passes and a refusal can
# only be the tree -- which is what separates this check from the ones above
# instead of duplicating them. Disabling the guard leaves all six of those green.
echo "dirtied by release-preflight-test" >> "$tmp/README.md"
if out=$(sh "$script" "v$version" 2>&1); then
echo "FAIL: a dirty tree -- accepted" >&2; fails=$((fails+1))
else
case "$out" in
*"uncommitted changes"*) echo " ok: a dirty tree (refused, naming the tree)" ;;
*) echo "FAIL: a dirty tree -- refused, but not for the tree:" >&2; echo "$out" | head -3 >&2; fails=$((fails+1)) ;;
esac
fi
if [ "$fails" -ne 0 ]; then
echo "release-preflight-test: $fails failure(s)" >&2
exit 1
fi
echo "release-preflight-test: all checks passed"
+140
View File
@@ -0,0 +1,140 @@
#!/bin/sh
# Answer, before a tag exists, the question the Release workflow answers after.
#
# Usage: scripts/release-preflight.sh vX.Y.Z[-suffix]
# scripts/release-preflight.sh --self-test
#
# WHY THIS EXISTS. `v0.1.0-rc3` was pushed while every crate
# still said `0.1.0-rc2`. dist matches the tag against the root package version,
# refused with "This workspace doesn't have anything for dist to Release!", and
# the four jobs behind `plan` skipped. Nothing was wrong with the workflow.
#
# The cost is not the failure, it is WHEN the failure arrives: a tag is already
# public and immutable by the time CI reads it, so the repair is a new version
# rather than an edit. That is why this runs BEFORE tagging and prints the tag
# command only once it has passed -- the honest path made the easy one, the same
# shape as `Basis` having no public constructor (gotcha 43).
#
# The primary check is textual and always runs. `dist plan` is a SECOND opinion
# and is allowed to be unavailable; what it could not confirm is stated rather
# than assumed. Reasoning: context/code/tooling.md. scripts/release-preflight-test.sh
# is the check_the_checker.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
# The root package's version, read from the FIRST [package] block of the root
# Cargo.toml. dist-workspace.toml says `members = ["cargo:."]`, so the root
# package is the only one dist announces and the only one whose version can
# disagree with a tag. gp-tree sitting at 0.1.0 is therefore not a finding.
root_version() {
awk '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^version *=/{gsub(/[" ]/,"");sub(/^version=/,"");print;exit}' \
"$root/Cargo.toml"
}
fail() { echo "release-preflight: $*" >&2; exit 1; }
preflight() {
tag=$1
version=$(root_version)
test -n "$version" || fail "could not read a version from Cargo.toml's [package] block"
# A tag names a version; anything else is a spelling this repo does not use.
expected="v$version"
if [ "$tag" != "$expected" ]; then
echo "release-preflight: REFUSED" >&2
echo " tag asked for : $tag" >&2
echo " Cargo.toml has: $version (so the tag must be $expected)" >&2
echo "" >&2
echo " Bump every crate to the version you mean to release and commit that" >&2
echo " FIRST. A tag pushed ahead of its version fails in CI after the tag is" >&2
echo " already public, and a published tag should not be moved." >&2
return 1
fi
echo "release-preflight: tag $tag matches Cargo.toml version $version"
# Every crate, because a workspace that disagrees with itself is the other
# half of the same error and dist says so in the same breath.
mismatched=$(grep -l '^version = ' "$root"/crates/*/Cargo.toml 2>/dev/null | while read -r f; do
v=$(awk '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^version *=/{gsub(/[" ]/,"");sub(/^version=/,"");print;exit}' "$f")
case "$v" in "$version"|"") ;; *) echo " $f: $v" ;; esac
done)
if [ -n "$mismatched" ]; then
echo "release-preflight: note -- these crates are on a different version:"
echo "$mismatched"
echo " Not fatal: dist announces only the root package (dist-workspace.toml"
echo " says members = [\"cargo:.\"]). Listed so a deliberate split stays deliberate."
fi
return 0
}
self_test() {
# check_the_checker. Both directions, because a checker that only ever passes
# and a checker that only ever fails are indistinguishable from one that works.
version=$(root_version)
test -n "$version" || { echo "self-test: could not read the root version" >&2; exit 1; }
if ! preflight "v$version" >/dev/null 2>&1; then
echo "self-test FAILED: the matching tag v$version was refused" >&2
exit 1
fi
if preflight "v$version-nope" >/dev/null 2>&1; then
echo "self-test FAILED: a tag that does not match the version was accepted" >&2
exit 1
fi
# The exact shape that was actually pushed, spelled out, because this is the
# case the script was written for and a regression here is the whole failure.
if preflight "v0.1.0-rc3" >/dev/null 2>&1 && [ "$version" != "0.1.0-rc3" ]; then
echo "self-test FAILED: v0.1.0-rc3 accepted against version $version" >&2
exit 1
fi
echo "release-preflight: self-test passed (accepts v$version, refuses a mismatch)"
}
case "${1:-}" in
--self-test) self_test; exit 0 ;;
"" | -h | --help)
echo "usage: release-preflight.sh vX.Y.Z[-suffix] | --self-test" >&2
exit 2 ;;
esac
tag=$1
# A dirty tree means the tag would not name what was tested.
if [ -n "$(git -C "$root" status --porcelain --untracked-files=no)" ]; then
fail "the working tree has uncommitted changes; tag a commit, not a guess"
fi
preflight "$tag" || exit 1
# The second opinion. dist is the thing that actually refused in CI, so running
# it is worth more than any restatement of its rules -- but it is optional, and
# an absent dist must not read as a pass.
if command -v dist >/dev/null 2>&1; then
out=$(cd "$root" && dist plan "--tag=$tag" 2>&1) || true
case "$out" in
*"doesn't have anything for dist to Release"*)
echo "release-preflight: REFUSED -- dist rejected the tag:" >&2
echo "$out" | sed -n '/×/,$p' | head -8 >&2
exit 1 ;;
*"but 'cargo-dist-version"*)
echo "release-preflight: dist ran but is a different version from the one"
echo " pinned in dist-workspace.toml, so it stopped before planning. The"
echo " tag/version check above still passed; CI installs the pinned version."
;;
*) echo "release-preflight: dist plan accepted $tag" ;;
esac
else
echo "release-preflight: dist not on PATH, so only the textual check ran."
echo " That is the check that catches the failure this script exists for."
fi
cat <<EOF
Preflight passed. To cut the release:
git tag -a $tag -m "$tag"
git push gh $tag
Push the tag to gh; the Release workflow triggers on the tag push there.
EOF
+38
View File
@@ -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
View File
@@ -59,27 +59,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>
+24
View File
@@ -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;
+412 -127
View File
@@ -1,23 +1,44 @@
/* Legibility only. Styling the browser client properly is not improvise-cr3. */ /* Paint only. Every fact this file colours in — which cell is selected, which
axis a category is on, whether a cell may be typed into, what the prompt
says — is decided in Rust and arrives already decided. Nothing here derives
anything; see context/design-principles.md §5 and context/code/clients.md.
The palette is deliberately the landing page's, so docs/index.html and this
application read as one product rather than two. */
:root { :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,218 @@ 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 {
/* 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 { main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -72,7 +275,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 +285,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 +298,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;
} }
@@ -217,8 +395,17 @@ td.numeric {
text-align: right; 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 { 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 +413,33 @@ td.editable {
cursor: text; cursor: text;
} }
td:not(.editable) { td.editable:hover {
opacity: 0.7; 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 { td input {
@@ -239,28 +451,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 +557,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 +579,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 +604,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;
} }
+38
View File
@@ -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"]);
});