Lane 2: the stops, the waits, and the four written pages (#728) #758

Merged
claude-bot merged 6 commits from build/lane2-stops into main 2026-09-22 21:12:51 +00:00
Collaborator

Lane 2 of the v4.5.0 redesign (#728), the fifth PR of its series: PUB-7, PUB-6, PUB-10 and PUB-12 — the full-page stops and the four pages that are mostly words. Frontend only, plus one catalogue row.

PUB-7 — a real 404 at last

SH-009 (replaced), SH-010, SH-011, SH-012, SH-013, SH-030, IS-393.

The catch-all was <Navigate to="/dashboard">, so a mistyped address and being signed out were indistinguishable — both bounced through Home to sign-in. That is what SH-009 exists to end. An address that does not exist now says so, in the world's voice and then plainly (D65).

Its way out differs by reader, deliberately: signed in you are offered Home, signed out the front door, because sending a signed-out reader to /dashboard reproduces the exact bounce this row abolishes, one click later. The actions wait for the session probe rather than flickering.

The two crash screens keep their detail behind a fold — the first line matters and the stack does not — and the waits (SH-011, SH-030) stay plain, because D65 is about stops, not spinners.

PUB-6 — Licences and notices

IS-186, IS-391, API-016, IS-388. D64 is the shape of this page and it was a reversal: it lists nothing this server has loaded, because what a table plays with could give its campaign away. Three things only — Quest Board does not package rules, groups may add properly licensed content, and a rightsholder can ask for removal — at an address that falls back to whoever runs the server when none is published.

PUB-10 — Accessibility

IS-293, D4. The target, what is done, the known gaps — the board at the table, the map, long transcripts — and how to report a barrier. The gaps are the ones the prototype already knows are hard, which is the point: a statement claiming nothing is wrong is worth nothing.

PUB-12 — Privacy

IS-387, IS-388. The standard text, the administrator's own words above it, and the retention lines read from the server rather than typed — the whole reason this page exists is that it cannot drift from the settings it describes.

One departure, and it is about a sentence that is not true yet

The drawing says "A GM can keep less than this. Nobody can keep more." That is false today. get_effective_terms… more precisely get_effective_retention takes a campaign's override verbatim under max(1, …), and the schema only enforces ge=1, so a GM can set a longer window than the instance default.

This is already known and already decided — D24 (CATALOGUE.md:101): instance retention becomes two numbers, a default and an absolute cap, as IS-311 on ADMIN-5/ADMIN-6 (lane 9, #735) and IS-312 on CAMP-13 (lane 3, #729). So the drawn sentence is not wrong, it is ahead of the build.

The page therefore says what is true today, and the comment above it records D24 and says in as many words to put the drawing's sentence back when the cap lands. Printing a guarantee this server does not enforce, on the one page whose purpose is not to drift from the server, would have been the worst place in the app to do it. No clamp added here — that belongs to the lanes that own those surfaces, and a frontend enforcing a cap would be a second, invisible policy on top of the one the server applies.

publicLinks.js carried three with a note saying the rest arrived with their pages. They have. The same array feeds AppFrame, so the signed-in and signed-out footers cannot drift apart.

A new catalogue row: IS-438

The drawing's front door reads "Amberwake is run by Dana, for a few groups of friends", and nothing stored that clause — the build found it by hard-coding it. #752 made it a field; IS-389 names the four things About this server holds and this is a fifth, so without a row lane 9 builds that form and no real server ever gets a line on its front door.

Placed on ADMIN-15 and PUB-11, catalogue rebuilt (1,707 rows), every coverage script re-run — which is how the second surface turned up. lane1c.py counts it drawn; lane7.py counts it deferred, honestly, because lane 7's form has no field for it and I have not edited a drawing the owner already reviewed. That question is on #728.

Two things only a render caught

  • Preflight strips list-style from every ul. Privacy and Accessibility are the first pages of this frame to write a bulleted list in prose, so "What is kept" rendered as four indented, unmarked paragraphs. Fixed on the frame's own rule, not per page.
  • With /public/policies down, the privacy page asserted a fact it did not have: "Not said yet: this server's administrator has not set a contact", two lines under a banner saying the settings would not load. "Nobody set a contact" is a fact about a person; "we could not ask" is not. The two were identical in the source and no test would have caught it.

The crash screen also needed a router-free frame, which only became obvious rendering it: main.jsx's boundary sits above RouterProvider, so useNavigate inside it would throw in the screen that catches throws.

Checks

lint 0 errors (239 warnings, all in pages no lane has rebuilt), 1344 tests in 121 files, build clean. An axe pass over the built app at WCAG 2.2 AA: privacy 0/12, accessibility 0/13, licences 0/11, 404 0/13. All 16 coverage scripts pass.

🤖 Generated with Claude Code

Lane 2 of the v4.5.0 redesign (#728), the fifth PR of its series: **PUB-7, PUB-6, PUB-10 and PUB-12** — the full-page stops and the four pages that are mostly words. Frontend only, plus one catalogue row. ## PUB-7 — a real 404 at last `SH-009` (replaced), `SH-010`, `SH-011`, `SH-012`, `SH-013`, `SH-030`, `IS-393`. The catch-all was `<Navigate to="/dashboard">`, so **a mistyped address and being signed out were indistinguishable** — both bounced through Home to sign-in. That is what `SH-009` exists to end. An address that does not exist now says so, in the world's voice and then plainly (D65). Its way out differs by reader, deliberately: signed in you are offered **Home**, signed out **the front door**, because sending a signed-out reader to `/dashboard` reproduces the exact bounce this row abolishes, one click later. The actions wait for the session probe rather than flickering. The two crash screens keep their detail behind a fold — the first line matters and the stack does not — and the waits (`SH-011`, `SH-030`) stay plain, because D65 is about stops, not spinners. ## PUB-6 — Licences and notices `IS-186`, `IS-391`, `API-016`, `IS-388`. **D64 is the shape of this page and it was a reversal**: it lists *nothing* this server has loaded, because what a table plays with could give its campaign away. Three things only — Quest Board does not package rules, groups may add properly licensed content, and a rightsholder can ask for removal — at an address that falls back to whoever runs the server when none is published. ## PUB-10 — Accessibility `IS-293`, D4. The target, what is done, the **known gaps** — the board at the table, the map, long transcripts — and how to report a barrier. The gaps are the ones the prototype already knows are hard, which is the point: a statement claiming nothing is wrong is worth nothing. ## PUB-12 — Privacy `IS-387`, `IS-388`. The standard text, the administrator's own words above it, and **the retention lines read from the server** rather than typed — the whole reason this page exists is that it cannot drift from the settings it describes. ### One departure, and it is about a sentence that is not true yet The drawing says *"A GM can keep less than this. Nobody can keep more."* **That is false today.** `get_effective_terms`… more precisely `get_effective_retention` takes a campaign's override verbatim under `max(1, …)`, and the schema only enforces `ge=1`, so a GM can set a **longer** window than the instance default. This is already known and already decided — **D24** (`CATALOGUE.md:101`): instance retention becomes two numbers, a default and an absolute cap, as `IS-311` on ADMIN-5/ADMIN-6 (lane 9, #735) and `IS-312` on CAMP-13 (lane 3, #729). So the drawn sentence is not wrong, it is **ahead of the build**. The page therefore says what is true today, and the comment above it records D24 and says in as many words to put the drawing's sentence back when the cap lands. Printing a guarantee this server does not enforce, on the one page whose purpose is not to drift from the server, would have been the worst place in the app to do it. **No clamp added here** — that belongs to the lanes that own those surfaces, and a frontend enforcing a cap would be a second, invisible policy on top of the one the server applies. ## The footer has its six doors now `publicLinks.js` carried three with a note saying the rest arrived with their pages. They have. The same array feeds `AppFrame`, so the signed-in and signed-out footers cannot drift apart. ## A new catalogue row: `IS-438` The drawing's front door reads *"**Amberwake** is run by Dana, **for a few groups of friends**"*, and nothing stored that clause — the build found it by hard-coding it. #752 made it a field; `IS-389` names the four things About this server holds and this is a fifth, so without a row lane 9 builds that form and no real server ever gets a line on its front door. Placed on ADMIN-15 and PUB-11, catalogue rebuilt (1,707 rows), **every** coverage script re-run — which is how the second surface turned up. `lane1c.py` counts it **drawn**; `lane7.py` counts it **deferred**, honestly, because lane 7's form has no field for it and I have not edited a drawing the owner already reviewed. That question is on #728. ## Two things only a render caught - **Preflight strips `list-style` from every `ul`.** Privacy and Accessibility are the first pages of this frame to write a bulleted list in prose, so "What is kept" rendered as four indented, unmarked paragraphs. Fixed on the frame's own rule, not per page. - **With `/public/policies` down, the privacy page asserted a fact it did not have**: *"Not said yet: this server's administrator has not set a contact"*, two lines under a banner saying the settings would not load. "Nobody set a contact" is a fact about a person; "we could not ask" is not. The two were identical in the source and no test would have caught it. The crash screen also needed a router-free frame, which only became obvious rendering it: `main.jsx`'s boundary sits above `RouterProvider`, so `useNavigate` inside it would throw in the screen that catches throws. ## Checks lint **0 errors** (239 warnings, all in pages no lane has rebuilt), **1344 tests** in 121 files, build clean. An axe pass over the built app at WCAG 2.2 AA: privacy 0/12, accessibility 0/13, licences 0/11, 404 0/13. All 16 coverage scripts pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The last of lane 1c's twelve: the screens nobody goes looking for, and the three
doors that are nowhere but the footer.

**There is a 404 now** (`SH-009`). The catch-all was `<Navigate to="/dashboard">`,
which is the whole of the issue: a mistyped address and a session that had run
out were the same screen, because both bounced through Home to the sign-in page.
Whoever followed an old link was told, in effect, that they were not logged in.
The route is outside `AuthGuard`, so a signed-in person who mistypes reads the
page as well rather than being silently redirected — which was the other half of
it, and the half with no error to report.

The drawing's one still says *Go to Home*, and that is what a signed-in reader
gets. A signed-out one is offered the front door instead, because for them Home
is `/dashboard` and `/dashboard` is exactly the bounce this page exists to stop,
one click later. The words are the same either way and are on screen
immediately; only the door waits for the `/api/me` probe, since a page that
offers the wrong one and then changes its mind is worse than a page whose action
arrives a moment after its sentence.

**The two crash screens** are one screen and one file (`pages/Crashed.jsx`),
because which of the two boundaries caught the error is the app's business and
not the reader's. What differs is what is true: a page that could not be *shown*
(`SH-010`) against a Quest Board that could not *start* (`SH-012`), and therefore
whether "try again" reloads or drops the error and renders again. Both keep the
detail behind a fold, which matters more than it looks: the first line is
*nothing you were doing is lost; it is still on the server*, and a stack trace
above that sentence has told a great many people the opposite.

The drawing draws the boot crash with nothing behind its fold and this does not.
An error caught above the router still has a message, and it is the only
diagnostic a self-hoster gets for an app that will not start; the fold is drawn
when there is something in it, on both.

That screen is also why `PublicPage` now has a `PublicFrame` under it. The
boundary in `main.jsx` sits above `RouterProvider`, where `useNavigate` throws —
and throwing inside the screen that catches a throw is a loop. Nothing is lost
by it: every link in the frame is a real `<a href>` either way, and after a crash
a real navigation is the better of the two, since it starts the app again rather
than routing inside a broken one.

**The waits stay plain** (`SH-011`, `SH-030`). D65 gives the world's voice to the
stops and to nothing else: a stop is somewhere a reader has arrived and has to
decide what to do, and a joke on a spinner is a joke every time the app is
opened. What the wait screen gained is which wait it is — the session check says
so rather than showing the "Loading…" a page chunk shows.

**`SH-013`** already half existed. `main.jsx` has reloaded once on Vite's
`vite:preloadError` — a chunk whose hash is gone after a deploy — for a long
time, and the service worker is `autoUpdate`. What was missing is that none of it
said anything: the reload was silent, and a page that blanks and comes back by
itself reads as a fault. So the same reload now announces itself. This is not
D13, which is the quiet notice for a version that is *ready but not needed* and
belongs to lane 8; here the files the page is asking for are already gone, so
there is nothing to dismiss.

**Accessibility** (PUB-10, `IS-293`, D4) states the target, what is done, and the
known gaps — the board at the table, the map, long transcripts — because a
statement claiming nothing is wrong is worth nothing to the one reader it is
written for, who has already met the thing it does not mention. It says so of
itself, too: the list goes when a lane fixes something and grows when a lane
breaks something. It is static, and the way to report a barrier is the project or
whoever runs this server, whose address is on the privacy page rather than
copied onto a second one.

**Privacy** (PUB-12, `IS-387`, `IS-388`) is the standard text every server starts
from, with the administrator's own words above it and never instead of it —
nothing an operator writes changes what the software does. The retention lines
are read from `GET /public/policies` rather than typed, which is the entire
point: a policy typed by hand describes the settings on the day it was typed, and
the first time Recording & retention changes it becomes a statement about a
server that no longer exists — about audio of real people. Each mode has its own
sentence and each says what that mode does, so a server keeping recordings for
thirty days cannot read as one that bins them the same night. Recording is
standing consent by joining a campaign, as the repo's rules say; the page states
that and adds no gate.

**Licences and notices** (PUB-6, `IS-186`, `IS-391`, `API-016`, `IS-388`) lists
**nothing** this server has loaded. D64 reversed lane 1c's first drawing of it,
and that reversal is the page: what a table plays with is its own business and a
list of one publisher's bestiary tells a stranger what the group is running. The
attribution a licence actually requires is not skipped — it appears where the
content is read, to the members who read it (`IS-392`). The wording is the
drawing's, which the owner's second look made simpler rather than defensive, and
the address falls back to whoever runs the server when nobody has published one.

The footer finally has its six doors, in the order D15-as-amended-by-D63 gives,
and `AppFrame` draws the same array — the accessibility statement and the privacy
policy are one pair of pages, not two, and the footer is the only door either of
them has.

Two things in the drawing could not be built as drawn, and are the build's rather
than a redesign:

- "A GM can keep less than this. Nobody can keep more." The second half is not
  true of the code: a campaign's retention override is validated but not clamped
  against the instance default (`schemas/campaign.py`, `get_effective_retention`),
  so a GM can set a longer window as well as a shorter one. On the one page whose
  purpose is not to drift from what the server does, a reassuring sentence that is
  false is the worst thing it could carry, so it says a GM can set a different
  span and that this is what the server does otherwise.
- The accessibility statement's lead cites `IS-295` beside the colour rule. That
  is a redesign-ledger id and means nothing to a reader who cannot look it up;
  #367 stays, because it is a real issue in the project anyone can open.

Three things found by looking at the built pages rather than the source, none of
them caught by a test:

- Tailwind's preflight strips the marker from every `ul` in the document, and
  Privacy and Accessibility are the first pages of this frame to write a bulleted
  list in prose — four indented, unmarked paragraphs where the drawing has
  bullets. Restored on the frame's own list rule, which already owns the indent.
- With `/public/policies` down, the privacy page said under *Who runs this
  server* that nobody had set a contact. It did not know that: "nobody has said"
  is a fact about an administrator and a request that never answered is not one.
  It now says which of the two it is.
- The licences page is not `pub--wide` even though the drawing's `licPage` is.
  `wide` in a still means "fill the still"; in the product it means up to 1560px,
  and a prose page at that measure is unreadable. Help settled this one PR ago
  and these three follow it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page prints "A campaign's GM can set a different span for their own
campaign. This is what this server does otherwise." where the drawing prints "A
GM can keep less than this. Nobody can keep more." The last PR explained that
the drawing's sentence is false today — a campaign override is bounded below by
`max(1, …)` and not at all above — and stopped there, which leaves the next
reader of the file two ways to be wrong about it. They can "fix" the honest
sentence back to the drawing's, because the drawing is the specification and the
comment does not say the drawing is ahead of the build rather than mistaken. Or
they can leave it long after it stopped being true, because nothing tells them
what would make the drawing's sentence correct or who is building it.

So the comment now carries the decision. **D24** already settled this: instance
retention becomes two numbers, a default every campaign starts from and an
absolute cap no campaign may pass, with a campaign free anywhere between them —
`IS-311` on ADMIN-5/ADMIN-6 in build lane 9 (#735) and `IS-312` on CAMP-13 in
build lane 3 (#729). It cites the D24 row in `CATALOGUE.md` so the pointer
survives a rename of anything else, and it says in as many words that the
drawing's sentence goes back the day the cap exists.

No clamp here and no backend change: the cap belongs to the lanes that own those
two surfaces, and a frontend that enforced it would be a second, invisible
policy on top of the one the server actually applies. What this page owes is to
describe the server it is running on, which is the whole of `IS-387`.

Comment only — no rendered text changes. Lint 0 errors, 1344 tests, build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drawing's front door reads "**Amberwake** is run by Dana, *for a few
groups of friends*", and nothing stored that last clause. Lane 2's build found
it by hard-coding it, which would have had every self-hosted Quest Board
telling visitors its operator runs it for a few groups of friends; #752 made
it a field (`blurb` on `GET /public/site`).

`IS-389` names the four things About this server holds — the name, who runs it
and how to reach them, the privacy policy's own words, the numbers switch —
and this is a fifth. Without a row, lane 9 builds the form from `IS-389` and
no real server ever gets a line on its front door.

Placed on ADMIN-15 (primary) and PUB-11, the catalogue rebuilt, and every
coverage script re-run — which is how the second surface turned up:

- `lane1c.py` counts it **drawn** on PUB-11: #751 draws the sentence with the
  line and without it.
- `lane7.py` counts it **deferred** on ADMIN-15, honestly. That form draws Its
  name, Run by, How to reach them and the policy's words, and has no field for
  this one, because the row did not exist when lane 7 was drawn. Left undrawn
  rather than quietly added to a page the owner has already reviewed — lane 9
  (#735) needs one more input, and whether the drawing gains it first is the
  owner's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into build/lane2-stops
Some checks failed
CI / Backend lint (ruff) (pull_request) Successful in 32s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m20s
CI / Feedback site tests (pull_request) Successful in 40s
CI / Bot/backend version sync (pull_request) Successful in 37s
CI / Bot tests and audit (pull_request) Successful in 2m34s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m21s
CI / Docker image build (pull_request) Successful in 5m16s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Failing after 5m18s
CI / Backend migration, tests, and audit (pull_request) Successful in 13m49s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 18m15s
079db3250e
fix(frontend): the footer's links are a 24px target once there are six (#728)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m38s
CI / Backend lint (ruff) (pull_request) Successful in 27s
CI / Bot/backend version sync (pull_request) Successful in 1m1s
CI / Bot tests and audit (pull_request) Successful in 1m46s
CI / Feedback site tests (pull_request) Successful in 1m29s
CI / Docker image build (pull_request) Successful in 2m0s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m1s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 5m35s
CI / Backend migration, tests, and audit (pull_request) Successful in 15m55s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 19m51s
ba86d30cde
The journeys' axe gate failed on /dashboard in all three phone runs, on WCAG
2.2's 2.5.8 (target size):

    Target has insufficient size (62.8px by 20.1px, should be at least
    24px by 24px). Safe clickable space has a diameter of 23.4px instead
    of at least 24px.

`a:nth-child(3)` is the first footer link — `Footer` renders the version, then
a separator and a link per door — and it was 0.6px short.

Not a pre-existing fault: with two doors the footer never wrapped, so no link
had a vertical neighbour and the spacing exemption carried it. This PR takes
it to six (D15, amended by D63), they wrap on a phone, and vertically adjacent
links then sat 1.6px apart.

So the links carry a real target size, 24px, and the row gap goes to 4px. Both,
deliberately: either alone lands within a rounding error of the threshold, and
a gate that passes by 0.6px is one font-size change from failing again.

24px and not `--touch`'s 44px. That rule is for controls; a footer of six 44px
links is a third of a phone screen, and 2.5.8 AA asks for 24 — 44 is 2.5.5,
which is AAA.

The text does not move: the box grows around the same line.

Docker Desktop was down on this machine after a crash, so this is checked by
CI's axe gate rather than locally — which is the only thing that can really
check it in any case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.