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:
@@ -269,6 +269,10 @@ jobs:
|
||||
# No trealla needed: pr-watch is driven entirely from fixtures.
|
||||
- name: The PR watcher refuses what it claims to
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
### 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`.
|
||||
|
||||
@@ -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
|
||||
neighbouring-question defect §7 names. When a binding is added, this test fails
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// The page's own key, imported rather than spelled: a spec with its own
|
||||
// copy would pass while reading a key nothing writes (shell.spec.js does
|
||||
// the same with the handshake window).
|
||||
import { NOTICE_KEY } from "../../web/shell.js";
|
||||
|
||||
async function openApp(page) {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -394,3 +399,154 @@ test.describe("the selection is on screen", () => {
|
||||
expect(await page.locator("#grid").evaluate((g) => g.scrollTop)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The page's own chrome: what a visitor lands on, and where the prompt is.
|
||||
// Only a browser can answer either — both are facts about layout and about
|
||||
// storage that survives a reload. What these CANNOT see is the rest of the
|
||||
// styling; that residue is named in context/code/clients.md.
|
||||
test.describe("the shell puts the application first", () => {
|
||||
const notice = (page) => page.locator("#backend-notice");
|
||||
const stored = (page) => page.evaluate((k) => localStorage.getItem(k), NOTICE_KEY);
|
||||
|
||||
// The deployment notice is ten lines of configuration reference — ?doc=,
|
||||
// ?seed=, ?ws=, wss:// and reverse proxies — and it was above the fold on
|
||||
// every visit. See context/test-evidence/clients.md.
|
||||
test("the deployment notice starts collapsed and remembers being opened", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/app/index.html?doc=notice-memory.improv");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// The one sentence a visitor needs is never collapsed; the reference is.
|
||||
await expect(page.locator("#backend-notice .notice-lede")).toBeVisible();
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
||||
|
||||
// BOTH directions, because a page that ignores storage passes the first
|
||||
// half and a page that always opens passes the second (design-principles
|
||||
// §7: assert the direction the default cannot supply).
|
||||
await notice(page).locator("summary").click();
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
|
||||
// `toggle` is dispatched as a task, not synchronously with the click, so
|
||||
// the answer must be OBSERVED as stored before a reload can be said to
|
||||
// have read it back. Without this the reload races the write.
|
||||
await expect.poll(() => stored(page)).toBe("open");
|
||||
await page.reload();
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
|
||||
|
||||
await notice(page).locator("summary").click();
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
||||
await expect.poll(() => stored(page)).toBe("closed");
|
||||
await page.reload();
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
||||
});
|
||||
|
||||
// The `<details>` is the browser's control, and the page's own keydown
|
||||
// handler claims Enter. Without the guard a keyboard visitor could focus
|
||||
// the summary and not open it. See context/test-evidence/clients.md.
|
||||
test("the notice opens from the keyboard, which the grid's own handler must not claim", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/app/index.html?doc=notice-keyboard.improv");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
||||
|
||||
await notice(page).locator("summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
|
||||
});
|
||||
|
||||
// The prompt was a small line in the page's bottom-left corner, 800px from
|
||||
// the cell being typed into. See context/test-evidence/clients.md.
|
||||
test("the minibuffer is raised over the grid rather than filed under it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/app/index.html?doc=minibuffer-place.improv");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator("#grid").focus();
|
||||
await expect(page.locator("#minibuffer")).toBeHidden();
|
||||
|
||||
// `a` is the one text-entry room this client can both enter and commit in
|
||||
// (`keys.spec.js` drives the formula one). The precondition is that it
|
||||
// really opened, or every measurement below is of a hidden element.
|
||||
await page.keyboard.press("a");
|
||||
await expect(page.locator("#mode")).toHaveText("INSERT");
|
||||
await expect(page.locator("#minibuffer")).toBeVisible();
|
||||
|
||||
const boxes = await page.evaluate(() => {
|
||||
const rect = (id) => {
|
||||
const { top, bottom, left, right } = document
|
||||
.getElementById(id)
|
||||
.getBoundingClientRect();
|
||||
return { top, bottom, left, right };
|
||||
};
|
||||
return {
|
||||
mb: rect("minibuffer"),
|
||||
grid: rect("grid"),
|
||||
viewport: { height: window.innerHeight, width: window.innerWidth },
|
||||
mbFont: parseFloat(getComputedStyle(document.getElementById("minibuffer")).fontSize),
|
||||
bodyFont: parseFloat(getComputedStyle(document.body).fontSize),
|
||||
};
|
||||
});
|
||||
|
||||
// On screen without scrolling: the property a bottom-of-document line
|
||||
// does not have once the page is taller than the window.
|
||||
expect(boxes.mb.bottom).toBeLessThanOrEqual(boxes.viewport.height);
|
||||
expect(boxes.mb.top).toBeGreaterThanOrEqual(0);
|
||||
// And OVER the grid, not in a strip beneath it. This is what separates
|
||||
// "raised" from "moved": a static bottom bar cannot overlap #grid at all.
|
||||
expect(boxes.mb.top).toBeLessThan(boxes.grid.bottom);
|
||||
expect(boxes.mb.bottom).toBeGreaterThan(boxes.grid.top);
|
||||
// The one prominence claim a browser can check. It was body-sized before.
|
||||
expect(boxes.mbFont).toBeGreaterThan(boxes.bodyFont);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#minibuffer")).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
Executable
+91
@@ -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"
|
||||
Executable
+140
@@ -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
@@ -17,6 +17,9 @@ import {
|
||||
indexAction,
|
||||
indicator,
|
||||
isFatal,
|
||||
NOTICE_KEY,
|
||||
noticeOpen,
|
||||
noticeState,
|
||||
noticeText,
|
||||
pageEntries,
|
||||
socketUrl,
|
||||
@@ -45,8 +48,38 @@ const el = {
|
||||
status: document.getElementById("status"),
|
||||
viewName: document.getElementById("view-name"),
|
||||
docName: document.getElementById("doc-name"),
|
||||
notice: document.getElementById("backend-notice"),
|
||||
};
|
||||
|
||||
// The deployment notice, and the one thing this page remembers about its
|
||||
// visitor. `localStorage` THROWS in a private window and in a context with
|
||||
// site data blocked, and returns null having forgotten — so every access is
|
||||
// guarded and the absent answer is the default, not a third state. What is
|
||||
// stored is per-viewer and per-origin: it never reaches Rust, never travels
|
||||
// to a peer and never reaches another tab's document.
|
||||
const remembered = (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const remember = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// A viewer whose browser will not remember still gets a working page: the
|
||||
// disclosure stays operable, it just re-opens at the default next time.
|
||||
}
|
||||
};
|
||||
|
||||
el.notice.open = noticeOpen(remembered(NOTICE_KEY));
|
||||
// `toggle` fires for the keyboard and the pointer alike, because the opening
|
||||
// is the BROWSER's: this listener records an answer rather than making one.
|
||||
el.notice.addEventListener("toggle", () =>
|
||||
remember(NOTICE_KEY, noticeState(el.notice.open)),
|
||||
);
|
||||
|
||||
await init();
|
||||
const client = new Client();
|
||||
|
||||
@@ -591,6 +624,11 @@ window.addEventListener("keydown", (e) => {
|
||||
// BrowserKey drops anything held with the platform meta key precisely so
|
||||
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
|
||||
if (e.metaKey) return;
|
||||
// The notice's <summary> is a real control and owns Enter and Space while
|
||||
// it has focus. Claiming them here would call `preventDefault` on the
|
||||
// browser's own disclosure toggle, so a keyboard visitor could focus it and
|
||||
// not open it — which is the whole affordance.
|
||||
if (el.notice.contains(e.target)) return;
|
||||
|
||||
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
|
||||
if (frame) {
|
||||
|
||||
+37
-20
@@ -59,27 +59,44 @@
|
||||
and a page that cannot reach its peer cannot be trusted to explain why.
|
||||
What the peer is actually *doing* — starting, connected, unreachable,
|
||||
gone — is live, and app.js writes it to #status.
|
||||
|
||||
It is a <details>, so the collapsing is the BROWSER's — keyboard
|
||||
operable, and correct with app.js absent or still loading. The only
|
||||
thing app.js adds is remembering the answer; the one line every visitor
|
||||
needs is the <summary>, which never collapses.
|
||||
-->
|
||||
<p id="backend-notice">
|
||||
Everything here runs in this tab: the document lives in a Web Worker
|
||||
and is saved to this browser's private storage, so edits survive a
|
||||
reload and never leave the machine. Load a different document with
|
||||
<code>?doc=name.improv</code>, seeded from
|
||||
<code>?seed=url</code>. To open a document hosted by a separate
|
||||
process instead, run
|
||||
<code>improvise-ws-server model.improv --port 8787</code> and reload
|
||||
with <code>?ws=host:port</code>. <strong>The scheme defaults to
|
||||
<code>wss://</code></strong>, because this page is served over https and
|
||||
an https origin refuses a <code>ws://</code> address as mixed content.
|
||||
Note that <code>improvise-ws-server</code> speaks plain
|
||||
<code>ws://</code> only, so reaching it from <em>this</em> page means
|
||||
terminating TLS in front of it with a reverse proxy or a tunnel — the
|
||||
README has a worked Caddy example. Served over
|
||||
<code>http://localhost</code> the same page defaults to
|
||||
<code>ws://</code> and pairs with it directly, with nothing in between;
|
||||
that is the case the pairing is really for. Sharing a document is what
|
||||
the server is for — not running one is what everything above is for.
|
||||
</p>
|
||||
<details id="backend-notice">
|
||||
<summary>
|
||||
<span class="notice-lede"
|
||||
>Everything here runs in this tab — the document lives in a Web
|
||||
Worker and is saved to this browser's private storage, so edits
|
||||
survive a reload and never leave the machine.</span
|
||||
>
|
||||
<span class="notice-toggle" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="notice-body">
|
||||
<p>
|
||||
Load a different document with <code>?doc=name.improv</code>, seeded
|
||||
from <code>?seed=url</code>. To open a document hosted by a separate
|
||||
process instead, run
|
||||
<code>improvise-ws-server model.improv --port 8787</code> and reload
|
||||
with <code>?ws=host:port</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The scheme defaults to <code>wss://</code></strong>, because
|
||||
this page is served over https and an https origin refuses a
|
||||
<code>ws://</code> address as mixed content. Note that
|
||||
<code>improvise-ws-server</code> speaks plain <code>ws://</code>
|
||||
only, so reaching it from <em>this</em> page means terminating TLS in
|
||||
front of it with a reverse proxy or a tunnel — the README has a
|
||||
worked Caddy example. Served over <code>http://localhost</code> the
|
||||
same page defaults to <code>ws://</code> and pairs with it directly,
|
||||
with nothing in between; that is the case the pairing is really for.
|
||||
Sharing a document is what the server is for — not running one is
|
||||
what everything above is for.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="tiles" aria-label="Tiles"></div>
|
||||
|
||||
|
||||
@@ -28,6 +28,30 @@ export function indicator(collapsed) {
|
||||
return collapsed ? "▶" : "▼";
|
||||
}
|
||||
|
||||
/** Where the deployment notice's open/closed state is remembered. Per-viewer
|
||||
* and per-origin by construction: it never reaches Rust and never travels. */
|
||||
export const NOTICE_KEY = "improvise.notice.open";
|
||||
|
||||
/** Whether a visitor who has never answered sees the notice OPEN. It is a
|
||||
* product call and the argument for `false` is in context/code/clients.md. */
|
||||
export const NOTICE_OPEN_BY_DEFAULT = false;
|
||||
|
||||
/** What a stored notice state MEANS. Everything that is not one of the two
|
||||
* spellings — absent, empty, a value from an older page, a string a private
|
||||
* window handed back — is "unanswered", which is the default and not an
|
||||
* error: storage is allowed to forget, and a page that treated forgetting as
|
||||
* a third state would flicker between them. */
|
||||
export function noticeOpen(stored) {
|
||||
if (stored === "open") return true;
|
||||
if (stored === "closed") return false;
|
||||
return NOTICE_OPEN_BY_DEFAULT;
|
||||
}
|
||||
|
||||
/** …and the inverse, so the two spellings have exactly one home. */
|
||||
export function noticeState(open) {
|
||||
return open ? "open" : "closed";
|
||||
}
|
||||
|
||||
// Rough character metrics, used only to turn the window size into an extent.
|
||||
// Being wrong here costs a row of over- or under-fetch, nothing more.
|
||||
export const ROW_PX = 26;
|
||||
|
||||
+412
-127
@@ -1,23 +1,44 @@
|
||||
/* Legibility only. Styling the browser client properly is not improvise-cr3. */
|
||||
/* Paint only. Every fact this file colours in — which cell is selected, which
|
||||
axis a category is on, whether a cell may be typed into, what the prompt
|
||||
says — is decided in Rust and arrives already decided. Nothing here derives
|
||||
anything; see context/design-principles.md §5 and context/code/clients.md.
|
||||
|
||||
The palette is deliberately the landing page's, so docs/index.html and this
|
||||
application read as one product rather than two. */
|
||||
|
||||
:root {
|
||||
--bg: #fbfaf7;
|
||||
--surface: #f4f1ea;
|
||||
--raised: #ffffff;
|
||||
--fg: #1c1b19;
|
||||
--line: #d8d4cc;
|
||||
--dim: #6b675f;
|
||||
--line: #ddd8ce;
|
||||
--line-strong: #bdb6a8;
|
||||
--accent: #2f6f4f;
|
||||
--accent-soft: #e2efe6;
|
||||
--accent-ink: #ffffff;
|
||||
--selected: #e8e2d4;
|
||||
--error: #a8322d;
|
||||
--shadow: 0 10px 30px -12px rgb(28 27 25 / 35%);
|
||||
--radius: 5px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #17181a;
|
||||
--fg: #e7e4de;
|
||||
--line: #35373b;
|
||||
--accent: #7fc4a0;
|
||||
--selected: #2c2f33;
|
||||
--bg: #14161a;
|
||||
--surface: #1b1e24;
|
||||
--raised: #22262e;
|
||||
--fg: #d7dae0;
|
||||
--dim: #8b93a1;
|
||||
--line: #2b2f37;
|
||||
--line-strong: #414753;
|
||||
--accent: #8ec07c;
|
||||
--accent-soft: #1f2a22;
|
||||
--accent-ink: #14161a;
|
||||
--selected: #2c3340;
|
||||
--error: #e2827c;
|
||||
--shadow: 0 10px 30px -10px rgb(0 0 0 / 70%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,36 +53,218 @@ body {
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* The one keyboard surface the page has is `#grid`, and until it is focused
|
||||
nothing responds to a key. A visible ring is the affordance that says so. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── The app bar ─────────────────────────────────────────────────────────
|
||||
One line, and the eye's order across it is: what this is, which document,
|
||||
which view, which mode, what keys, what the peer is doing. */
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
#doc-name {
|
||||
opacity: 0.85;
|
||||
flex: none;
|
||||
/* Wide enough for the shipped document's whole title, `name (stored_as)`,
|
||||
so the ellipsis is for an unusually long one rather than for the demo. */
|
||||
max-width: 36ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* The view's name reads as a label on the grid below it, so it takes the
|
||||
chip shape the tile bar uses rather than the mode's filled one. */
|
||||
#view-name {
|
||||
flex: none;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#view-name:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The mode is the single most consequential thing on the page — every key
|
||||
means something different in each — so it is the one filled badge. */
|
||||
#mode {
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-left: auto;
|
||||
opacity: 0.75;
|
||||
#mode:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The hint is reference, not state: one line, ellipsised exactly as the TUI
|
||||
truncates it to the terminal's width. It must never push the bar taller. */
|
||||
#hint {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Two facts composed in one string (`shell.js::composeStatus`), so the dot
|
||||
marks the whole line rather than either half. */
|
||||
#status {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
color: var(--dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#status:not(:empty)::before {
|
||||
content: "●";
|
||||
margin-right: 0.35rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.7em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── The deployment notice ───────────────────────────────────────────────
|
||||
Collapsed by default: the one sentence a first visitor needs is the
|
||||
summary, and the rest is a configuration reference. clients.md. */
|
||||
|
||||
#backend-notice {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--accent-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
#backend-notice > summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#backend-notice > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notice-lede {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The disclosure's own affordance. It is the ONE place on this page where a
|
||||
glyph is chosen by CSS state rather than by a fact from Rust — the open
|
||||
state is the browser's, and nothing else knows it. */
|
||||
.notice-toggle::after {
|
||||
content: "▸ options";
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#backend-notice[open] .notice-toggle::after {
|
||||
content: "▾ hide";
|
||||
}
|
||||
|
||||
.notice-body {
|
||||
/* Expanded, this must not take the whole window: `main` is free to shrink
|
||||
to nothing, so a short viewport would lose the grid entirely. */
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
padding: 0 0.9rem 0.6rem;
|
||||
max-width: 100ch;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.notice-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
#backend-notice code {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--line-strong) 40%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ── The tile bar and the page bar ───────────────────────────────────────
|
||||
Two strips of chips. Which category is on which axis, and whether an axis
|
||||
is navigable, are both decided in Rust; only the weight is here. */
|
||||
|
||||
#tiles,
|
||||
#pages {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#tiles .tile,
|
||||
#pages .page-axis {
|
||||
padding: 0.1rem 0.55rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--raised);
|
||||
font-size: 0.78rem;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
#pages {
|
||||
background: var(--surface);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* A Page axis can be moved with `[`/`]`; a Filter one constrains without
|
||||
being navigable (gotcha 27), which is why only one of them lights up. */
|
||||
#pages .navigable {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── The frame: panels and grid ──────────────────────────────────────── */
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -72,7 +275,8 @@ main {
|
||||
width: 15rem;
|
||||
flex: none;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -81,7 +285,8 @@ main {
|
||||
width: 18rem;
|
||||
flex: none;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -93,123 +298,96 @@ main {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#categories li,
|
||||
#formulas li,
|
||||
#views li {
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
#views .axes {
|
||||
margin-left: 0.5rem;
|
||||
opacity: 0.6;
|
||||
color: var(--dim);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#views .active .name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The tile bar. One line above the grid, as the TUI draws it. */
|
||||
#tiles {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#tiles .tile {
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* The page bar. Magenta in the TUI; the FACT that an axis is navigable is
|
||||
`cycle`, decided in Rust, and only this weight is the page's. */
|
||||
#pages {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#pages .page-axis {
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#pages .navigable {
|
||||
color: var(--accent, inherit);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The totals strip. Never summed here — it is projected, because the sum runs
|
||||
over rows this viewport scrolled past. */
|
||||
tfoot th,
|
||||
td.total {
|
||||
font-weight: 700;
|
||||
border-top: 2px solid var(--line);
|
||||
/* `td:not(.editable)` dims every unsettable cell; a total is unsettable and
|
||||
is not incidental, so it opts back out. */
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#categories .item {
|
||||
padding-left: 1.5rem;
|
||||
opacity: 0.85;
|
||||
padding-left: 1.75rem;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
#categories .category {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* One selection treatment everywhere it can occur: a filled row with an
|
||||
accent edge. The tile bar takes the ring instead, being a chip. */
|
||||
#categories .selected,
|
||||
#formulas .selected,
|
||||
#views .selected,
|
||||
#tiles .selected,
|
||||
td.selected {
|
||||
#views .selected {
|
||||
background: var(--selected);
|
||||
outline: 1px solid var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#hint {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* A keyboard edit in progress. Left-aligned even in a numeric column,
|
||||
because what is drawn is the text being typed rather than a value. */
|
||||
td.typing {
|
||||
text-align: left;
|
||||
outline: 2px solid var(--accent);
|
||||
#tiles .selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#grid {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ── The table ───────────────────────────────────────────────────────── */
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
background: var(--raised);
|
||||
box-shadow: 0 1px 0 var(--line);
|
||||
/* Every number in this grid is a measure, and a pivot table is read down
|
||||
its columns: proportional digits make that harder for no gain. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
min-width: 6rem;
|
||||
height: 1.75rem;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
th {
|
||||
background: color-mix(in srgb, var(--line) 35%, transparent);
|
||||
thead th {
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
tbody th[scope="row"] {
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* A group header, on either axis. The TUI draws these green and bold; the
|
||||
glyph is already in the text (`indicator` in app.js). */
|
||||
glyph is already in the text (`indicator` in shell.js). */
|
||||
th.group {
|
||||
color: var(--accent, inherit);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -217,8 +395,17 @@ td.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Everything else on the grid is an aggregate, and dimming it is what makes
|
||||
the settable cells read as the surface you can act on. */
|
||||
td:not(.editable) {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* …and the semantic colours come AFTER it, because they are now the same
|
||||
property at the same specificity and only the order separates them. */
|
||||
td.error {
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The only affordance that matters: a cell the server said is settable. */
|
||||
@@ -226,8 +413,33 @@ td.editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
td:not(.editable) {
|
||||
opacity: 0.7;
|
||||
td.editable:hover {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
/* The selected cell, and the row header that names it. `:has()` is what lets
|
||||
the header light up without the page deriving which row is selected — the
|
||||
selection is already on the cell, decided in Rust. */
|
||||
td.selected {
|
||||
background: var(--accent-soft);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:has(td.selected) th[scope="row"] {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
}
|
||||
|
||||
/* A keyboard edit in progress. Left-aligned even in a numeric column,
|
||||
because what is drawn is the text being typed rather than a value. */
|
||||
td.typing {
|
||||
text-align: left;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
background: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
td input {
|
||||
@@ -239,28 +451,101 @@ td input {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The page cannot reach a server from a static host, and says so. */
|
||||
#backend-notice {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
font-size: 0.85rem;
|
||||
/* The totals strip. Never summed here — it is projected, because the sum runs
|
||||
over rows this viewport scrolled past. */
|
||||
tfoot th,
|
||||
td.total {
|
||||
font-weight: 700;
|
||||
border-top: 2px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
/* `td:not(.editable)` dims every unsettable cell; a total is unsettable and
|
||||
is not incidental, so it opts back out. */
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#backend-notice code {
|
||||
background: color-mix(in srgb, var(--line) 45%, transparent);
|
||||
padding: 0 0.25rem;
|
||||
/* ── The minibuffer ──────────────────────────────────────────────────────
|
||||
The TUI's bottom line is a terminal convention and a web page's bottom-left
|
||||
corner is where nothing is read. The CONTENT is unchanged — it is
|
||||
`state.view.mode.minibuffer()`, the same value the TUI draws — so this is
|
||||
position, weight and contrast and nothing else. */
|
||||
|
||||
#minibuffer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.75rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
max-width: min(46rem, calc(100vw - 2rem));
|
||||
min-width: min(28rem, calc(100vw - 2rem));
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--raised);
|
||||
box-shadow: var(--shadow);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--fg);
|
||||
/* It floats OVER the grid, so it must not eat a click meant for a cell. */
|
||||
pointer-events: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The help overlay. Content is improvise_protocol::help's; this is paint. */
|
||||
/* The caret. Purely an affordance — it says "this is where typing lands" —
|
||||
and it is drawn by CSS rather than written into the text, because the text
|
||||
is the host's and the page adds nothing to it. */
|
||||
#minibuffer::after {
|
||||
content: "▏";
|
||||
color: var(--accent);
|
||||
animation: minibuffer-caret 1.1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes minibuffer-caret {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* `MinibufColor`, resolved to a terminal-descended colour exactly as
|
||||
`draw.rs::minibuf_fg` resolves it for the TUI. This is the last
|
||||
translation and the only thing about the prompt that is the page's. */
|
||||
#minibuffer.green {
|
||||
border-color: #6aa84f;
|
||||
color: #4f7f3c;
|
||||
}
|
||||
#minibuffer.cyan {
|
||||
border-color: #3f9ba3;
|
||||
color: #2f7980;
|
||||
}
|
||||
#minibuffer.yellow {
|
||||
border-color: #b8952f;
|
||||
color: #8a6f21;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#minibuffer.green {
|
||||
color: #9edb8c;
|
||||
}
|
||||
#minibuffer.cyan {
|
||||
color: #7fd8e0;
|
||||
}
|
||||
#minibuffer.yellow {
|
||||
color: #e0cf7f;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The help overlay ────────────────────────────────────────────────────
|
||||
Content is improvise_protocol::help's; this is paint. */
|
||||
|
||||
#help {
|
||||
position: fixed;
|
||||
inset: 2rem;
|
||||
z-index: 30;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -272,9 +557,13 @@ td input {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.help-tab {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.help-tab.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help-body {
|
||||
@@ -290,9 +579,15 @@ td input {
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
/* Only the key column is accented: the other kinds are prose rows that
|
||||
happen to be `dt`s because they span both columns. */
|
||||
.help-keys {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.help-heading {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -309,23 +604,13 @@ td input {
|
||||
}
|
||||
|
||||
.help-dim {
|
||||
opacity: 0.7;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* The bottom-bar prompt while a text-entry mode is open. The colour is the
|
||||
mode's own answer (`MinibufColor`); this is the last translation, exactly
|
||||
as `draw.rs::minibuf_fg` is the TUI's. */
|
||||
#minibuffer {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: inherit;
|
||||
border-top: 1px solid var(--accent);
|
||||
}
|
||||
#minibuffer.green {
|
||||
color: #7ec699;
|
||||
}
|
||||
#minibuffer.cyan {
|
||||
color: #6fd0d8;
|
||||
}
|
||||
#minibuffer.yellow {
|
||||
color: #d8c96f;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
frameCostOf,
|
||||
indicator,
|
||||
isFatal,
|
||||
NOTICE_OPEN_BY_DEFAULT,
|
||||
noticeOpen,
|
||||
noticeState,
|
||||
noticeText,
|
||||
socketUrl,
|
||||
} from "../shell.js";
|
||||
@@ -258,3 +261,38 @@ test("a notice DISARMS the watchdog rather than being replaced by it", () => {
|
||||
`the watchdog spoke over a precise reason: ${JSON.stringify(seen)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// The notice's remembered state. The value crosses `localStorage`, which is
|
||||
// allowed to forget and allowed to throw, so the interesting inputs are the
|
||||
// ones that are NOT either spelling.
|
||||
test("a notice state this page did not write is unanswered, not a third state", () => {
|
||||
assert.equal(noticeOpen("open"), true);
|
||||
assert.equal(noticeOpen("closed"), false);
|
||||
// Everything storage can hand back when it has forgotten, been cleared,
|
||||
// been blocked, or been written by an older page.
|
||||
for (const stored of [null, undefined, "", "true", "false", "1", "0", "OPEN", {}]) {
|
||||
assert.equal(
|
||||
noticeOpen(stored),
|
||||
NOTICE_OPEN_BY_DEFAULT,
|
||||
`${JSON.stringify(stored)} was read as an answer`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// A first-time visitor lands on the application, not on its configuration
|
||||
// reference. The argument is in context/code/clients.md; this pins the call so
|
||||
// that changing it is a change to a stated decision rather than a stray edit.
|
||||
test("a visitor who has never answered gets the notice collapsed", () => {
|
||||
assert.equal(NOTICE_OPEN_BY_DEFAULT, false);
|
||||
assert.equal(noticeOpen(null), false);
|
||||
});
|
||||
|
||||
test("the two spellings round-trip, in both directions", () => {
|
||||
for (const open of [true, false]) {
|
||||
assert.equal(noticeOpen(noticeState(open)), open);
|
||||
}
|
||||
// And `noticeState` produces only spellings `noticeOpen` recognises — the
|
||||
// half that would break silently, since an unrecognised value reads as the
|
||||
// default and a visitor's answer would simply stop being kept.
|
||||
assert.deepEqual([noticeState(true), noticeState(false)].sort(), ["closed", "open"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user