Lane 2: the public frame, the front door and sign in (#728) #751

Merged
claude-bot merged 5 commits from build/lane2-public-frame into main 2026-09-22 17:30:41 +00:00
Collaborator

Lane 2 of the v4.5.0 redesign (#728), the third PR of its series and the first UI one. The drawing is /feedback/prototype/lane1c, sections pub11, pub1, pub2.

This description has been corrected. An earlier version of it claimed this PR fixed a production bug on main — a primary link-button drawing its label in the link colour. That bug never existed on main. It was this branch's own, it was the first symptom of the cascade problem described below, and the one-line :where() "fix" I first reported made it worse. The section below is what actually happened; the original claim is left described rather than deleted so the record is legible.

The cascade, which is the real story here

The journeys went red on this branch — 18 failed, 82 axe colour-contrast violations across the app bar, the Contents rail and the account links — and it turned out to be one bug with three faces.

The tie. base.css scopes the type to .qb, and AppFrame puts .qb on each chrome piece rather than on the frame's root (the src/ui/README.md rule, so pages not yet rebuilt keep their own look). So .qb { background: var(--paper) } and .appbar { background: var(--chrome-bg) } are equally specific and land on the same element, as are .rail's, and .qb a against each of a.btn--primary, .foot a, .appbar a, .toc--sub a. Those ties are meant to go to the component, and they did — on source order alone. main.jsx arranges that order deliberately and says so in a comment, because lane 1 hit it once already.

Chunking defeats source order. Measured, by building main's frontend beside this branch's and reading byte offsets in the emitted CSS:

stylesheets linked order
main one (index-*.css) .qb{ 88317 → a.btn--primary{ 94880 → .rail{ 110463 → .appbar{ 110483 → .foot a{ 115582
this branch two (ui-*.css, then index-*.css) base.css last

Adding a second eager consumer of the library — the front door, through PublicPage — tipped the bundler into emitting the kit's CSS as a shared chunk, and a shared chunk's stylesheet is linked before the entry's. So .qb started winning: the bar and the rail lost --chrome-bg, while everything inside them kept the near-white --chrome-ink that is correct on navy and unreadable on paper.

The fix is one line, in src/ui/index.css:

@import "./base.css" layer(qb-base);

An un-layered rule beats a layered one whatever order the files load in, so "a component wins over the base" becomes a fact about the stylesheet instead of a fact about the bundler's output. Nothing else is layered, deliberately — the only rule being written down is that the base sits underneath.

Two alternatives were tried and rejected on measurement, not on taste. build.cssCodeSplit: false gives one stylesheet but concatenates with the entry last (.qb{ at 117364 against .appbar{ at 17741), making the inversion permanent. Hand-weakening every .qb-level rule with :where() is the same idea with a far wider blast radius, and unnecessary once the layer covers the class.

The surfaces

PUB-11, the front door, at / — IS-384, IS-385, IS-386, IS-388. Until now / was not a route at all: the catch-all sent whoever typed the bare address to /dashboard, which bounced them to a sign-in page for an account they have not got. D62 makes it a page about Quest Board — the hero, how a night runs, what you get, this server's numbers, and who runs it.

The numbers are the server's, never a campaign's (D64). Turned off means the block is not drawn, not zeroes; a server too new says so in a line, because three noughts read as a broken page rather than a new one. /public/site failing is not an error to show a stranger — the page is complete without it.

PUB-1, sign in — SH-074, SH-075, SH-076, SH-077, SH-087, SH-152, IS-386, SH-032. The tagline moves to the front door. Two states that were silent now say which they are: a session that ran out, and the goodbye after deleting an account.

PUB-2, sign-in failed — SH-078, SH-079, SH-080, IS-393. On Stop: The gate stayed shut, the provider's words, one way back (D65).

Behaviour rows, tested rather than drawn: SH-001, SH-002, SH-014, SH-031, SH-073.

The journeys, and the baseline

  • J0's signed-out test asserts the page as rebuilt, keeping every guarantee it was making: the sign-in control is still an anchor to the backend's own /auth/login (its comment explains that this is the tripwire for a test-only endpoint), and signed out is still outside the frame. The keyboard test now asserts its locator resolves before asking anything of it — the pointer guard refuses before a locator is resolved, so one matching nothing would pass every toThrow and certify nothing.
  • J1 starts at the front door and scans it. It was the first page a GM meets and the one page in the app with no axe scan on it.
  • A11Y_BASELINE is now {}. /login was its last entry, named to lane 2 by owner; this lane rebuilt the page, so the entry goes. Verified rather than assumed — --grep J0 green with nothing left to tolerate.

A product bug found by writing J1

The front door's Sign in went straight to /auth/login. D62 is that sign in is a page behind the front door, reached from it — jumping to the provider took away the two states PUB-1 exists to carry. Both Sign in controls go to /login now.

Departures, and what the drawing did not settle

  • The footer carries three of its six doors. Accessibility, Privacy and Licences & notices are drawn, but their pages come in the fifth PR of this lane; shipping the links now would ship three dead ends, and main stays deployable after every PR.
  • "for a few groups of friends" is a field, not copy. It was Dana's line about Amberwake, baked in — every self-hosted Quest Board would have told visitors its operator runs it for a few groups of friends. It is blurb on GET /public/site (#752), and the paragraph is composed from whatever the server has actually said, each clause drawn only when there is something true to put in it.
  • The section eyebrows are <h2>s. The drawing writes them as <span class="label">; a named region says where you are once inside it, but a heading is how a screen-reader user gets between sections at all. They keep the label's look, and display: inline keeps the span's box — the built page is a byte-identical screenshot before and after.
  • .stop__voice goes back to var(--serif), as lane1c.html:181 has it: the only heading in the app that opts out of the display face, so the sentence spoken in the world's voice is softer than the furniture. Lane 0's port flattened it.
  • .pub--wide got a measure (the drawing's own 1560px page width; max-width: none was the still supplying the bound).
  • Underlines restored on public-page prose links — preflight strips the default the drawing assumes, leaving links distinguished by colour alone (1.4.1).
  • SH-152 uses sessionStorage, because /auth/logout is a server redirect to a bare /login with nothing to put a parameter on. One tab, one use, cleared on read.
  • SH-032's promise is made true by #752, which widens _safe_next.

Also found by looking at the built output

With a short ?message=, the sign-in-failed stop drew narrow, centred and indented inside its card. A grid item with an auto margin does not stretch — the margins eat the free space and the item shrinks to its content — so Stop's own margin: 0 auto, right when it is the whole window, made the box as wide as its longest sentence. A long message clamped to the track and looked fine, which is how it survived the first look.

Checks

--grep J0 12 passed and --grep J1 6 passed (3 skipped, lane 3's placeholders) on desktop, phone and keyboard, locally. Lint 0 errors (239 warnings, the unchanged baseline), 1269 unit tests, build clean. An independent axe run of the three public pages against the built bundle, two viewport shapes × both colour schemes: 12/12 clean.

🤖 Generated with Claude Code

Lane 2 of the v4.5.0 redesign (#728), the third PR of its series and the first UI one. The drawing is `/feedback/prototype/lane1c`, sections `pub11`, `pub1`, `pub2`. > **This description has been corrected.** An earlier version of it claimed this PR fixed a production bug on `main` — a primary link-button drawing its label in the link colour. **That bug never existed on `main`.** It was this branch's own, it was the first symptom of the cascade problem described below, and the one-line `:where()` "fix" I first reported made it worse. The section below is what actually happened; the original claim is left described rather than deleted so the record is legible. ## The cascade, which is the real story here The journeys went red on this branch — **18 failed**, 82 axe colour-contrast violations across the app bar, the Contents rail and the account links — and it turned out to be one bug with three faces. **The tie.** `base.css` scopes the type to `.qb`, and `AppFrame` puts `.qb` on *each chrome piece* rather than on the frame's root (the `src/ui/README.md` rule, so pages not yet rebuilt keep their own look). So `.qb { background: var(--paper) }` and `.appbar { background: var(--chrome-bg) }` are equally specific **and land on the same element**, as are `.rail`'s, and `.qb a` against each of `a.btn--primary`, `.foot a`, `.appbar a`, `.toc--sub a`. Those ties are meant to go to the component, and they did — on source order alone. `main.jsx` arranges that order deliberately and says so in a comment, because lane 1 hit it once already. **Chunking defeats source order.** Measured, by building `main`'s frontend beside this branch's and reading byte offsets in the emitted CSS: | | stylesheets linked | order | |---|---|---| | `main` | **one** (`index-*.css`) | `.qb{` 88317 → `a.btn--primary{` 94880 → `.rail{` 110463 → `.appbar{` 110483 → `.foot a{` 115582 | | this branch | **two** (`ui-*.css`, then `index-*.css`) | base.css **last** | Adding a second eager consumer of the library — the front door, through `PublicPage` — tipped the bundler into emitting the kit's CSS as a shared chunk, and a shared chunk's stylesheet is linked *before* the entry's. So `.qb` started winning: the bar and the rail lost `--chrome-bg`, while everything inside them kept the near-white `--chrome-ink` that is correct on navy and unreadable on paper. **The fix is one line**, in `src/ui/index.css`: ```css @import "./base.css" layer(qb-base); ``` An un-layered rule beats a layered one whatever order the files load in, so "a component wins over the base" becomes a fact about the stylesheet instead of a fact about the bundler's output. Nothing else is layered, deliberately — the only rule being written down is that the base sits underneath. Two alternatives were tried and rejected on measurement, not on taste. `build.cssCodeSplit: false` gives one stylesheet but concatenates with the **entry last** (`.qb{` at 117364 against `.appbar{` at 17741), making the inversion permanent. Hand-weakening every `.qb`-level rule with `:where()` is the same idea with a far wider blast radius, and unnecessary once the layer covers the class. ## The surfaces **PUB-11, the front door, at `/`** — `IS-384`, `IS-385`, `IS-386`, `IS-388`. Until now `/` was not a route at all: the catch-all sent whoever typed the bare address to `/dashboard`, which bounced them to a sign-in page for an account they have not got. D62 makes it a page about Quest Board — the hero, how a night runs, what you get, this server's numbers, and who runs it. The numbers are the server's, never a campaign's (D64). Turned off means the block is not drawn, not zeroes; a server too new says so in a line, because three noughts read as a broken page rather than a new one. `/public/site` failing is not an error to show a stranger — the page is complete without it. **PUB-1, sign in** — `SH-074`, `SH-075`, `SH-076`, `SH-077`, `SH-087`, `SH-152`, `IS-386`, `SH-032`. The tagline moves to the front door. Two states that were silent now say which they are: a session that ran out, and the goodbye after deleting an account. **PUB-2, sign-in failed** — `SH-078`, `SH-079`, `SH-080`, `IS-393`. On `Stop`: *The gate stayed shut*, the provider's words, one way back (D65). Behaviour rows, tested rather than drawn: `SH-001`, `SH-002`, `SH-014`, `SH-031`, `SH-073`. ## The journeys, and the baseline - **J0's signed-out test asserts the page as rebuilt**, keeping every guarantee it was making: the sign-in control is still an anchor to the backend's own `/auth/login` (its comment explains that this is the tripwire for a test-only endpoint), and signed out is still outside the frame. The keyboard test now asserts its locator *resolves* before asking anything of it — the pointer guard refuses before a locator is resolved, so one matching nothing would pass every `toThrow` and certify nothing. - **J1 starts at the front door** and scans it. It was the first page a GM meets and the one page in the app with no axe scan on it. - **`A11Y_BASELINE` is now `{}`.** `/login` was its last entry, named to lane 2 by owner; this lane rebuilt the page, so the entry goes. Verified rather than assumed — `--grep J0` green with nothing left to tolerate. ## A product bug found by writing J1 The front door's Sign in went straight to `/auth/login`. D62 is that sign in is a page *behind* the front door, reached from it — jumping to the provider took away the two states PUB-1 exists to carry. Both Sign in controls go to `/login` now. ## Departures, and what the drawing did not settle - **The footer carries three of its six doors.** Accessibility, Privacy and Licences & notices are drawn, but their pages come in the fifth PR of this lane; shipping the links now would ship three dead ends, and main stays deployable after every PR. - **"for a few groups of friends" is a field, not copy.** It was Dana's line about Amberwake, baked in — every self-hosted Quest Board would have told visitors its operator runs it for a few groups of friends. It is `blurb` on `GET /public/site` (#752), and the paragraph is composed from whatever the server has actually said, each clause drawn only when there is something true to put in it. - **The section eyebrows are `<h2>`s.** The drawing writes them as `<span class="label">`; a named region says where you are once inside it, but a heading is how a screen-reader user gets between sections at all. They keep the label's look, and `display: inline` keeps the span's box — the built page is a byte-identical screenshot before and after. - **`.stop__voice` goes back to `var(--serif)`**, as `lane1c.html:181` has it: the only heading in the app that opts out of the display face, so the sentence spoken in the world's voice is softer than the furniture. Lane 0's port flattened it. - **`.pub--wide` got a measure** (the drawing's own 1560px page width; `max-width: none` was the still supplying the bound). - **Underlines restored** on public-page prose links — preflight strips the default the drawing assumes, leaving links distinguished by colour alone (1.4.1). - **`SH-152` uses `sessionStorage`**, because `/auth/logout` is a server redirect to a bare `/login` with nothing to put a parameter on. One tab, one use, cleared on read. - **`SH-032`'s promise is made true by #752**, which widens `_safe_next`. ## Also found by looking at the built output With a short `?message=`, the sign-in-failed stop drew narrow, centred and indented inside its card. A grid item with an `auto` margin does not stretch — the margins eat the free space and the item shrinks to its content — so `Stop`'s own `margin: 0 auto`, right when it is the whole window, made the box as wide as its longest sentence. A long message clamped to the track and looked fine, which is how it survived the first look. ## Checks `--grep J0` 12 passed and `--grep J1` 6 passed (3 skipped, lane 3's placeholders) on desktop, phone and keyboard, locally. Lint **0 errors** (239 warnings, the unchanged baseline), **1269 unit tests**, build clean. An independent axe run of the three public pages against the built bundle, two viewport shapes × both colour schemes: 12/12 clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
`/` was not a route. The catch-all sent whoever typed the bare address to
`/dashboard`, which bounced them to a sign-in button for an account they have
not got — so the first thing a stranger saw of Quest Board was a door with no
handle. D62 makes `/` a page about the product, and sign in a page behind it.

Three of lane 1c's twelve public pages, and the frame all twelve share:

- **PublicPage** (`src/shell/`) is the head, the page and the footer, and
  nothing else — a signed-out visitor has no Contents, no bell, no search and
  no live bar, so this is not `AppFrame`. The head is the wordmark or a crumb
  back to the front door; `footer={false}` is for the screens that are only a
  wait. Its links are in one exported constant so a later PR of this lane can
  extend it, and today it carries only the doors that exist: Accessibility,
  Privacy and Licences & notices are in the drawing but their pages are not
  built yet, and three dead links is worse than three absent ones.
- **The front door** (PUB-11, IS-384/385/388) says what Quest Board is, how a
  night runs, what you get, and — from `GET /public/site` — a few numbers and
  who runs this server. The numbers are the server's, never a campaign's or a
  person's; turned off means no block at all, and a server too new says so in
  a line rather than showing three noughts. A server that has not said how to
  reach whoever runs it says that. The endpoint failing is not an error to
  show: the page is complete without those two sections, which is also how it
  behaves on an instance whose backend has not caught up.
- **Sign in** (PUB-1) loses the tagline to the front door and gains the reason
  you are here: *Your session ran out* (SH-032) and *Your account is gone*
  (SH-152) both used to land on the page a first visit lands on, with nothing
  said. The 401 handler says why in the query; deleting an account cannot,
  because the server's own sign-out redirect is not ours to write, so it
  leaves the reason where the page will find it.
- **Sign-in failed** (PUB-2) is the library's `Stop`: the world's voice, then
  the provider's own words or the plain line, and one way back (D65).

The catch-all is untouched — an address that does not exist saying so is
SH-009, later in this lane — and no existing path moves.

One thing found by looking at the built page rather than the source: a primary
link-button's label was invisible in production. `base.css`'s `.qb a` and a
component's own link colour are equally specific and land in different chunks,
with the entry chunk's linked last, so every rule a component wrote to claim a
link's colour won in `vite dev` and lost in the build. The base rule is
`:where()`-wrapped now and carries no weight of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(frontend): the blurb is the server's, the eyebrows are headings, and a stop speaks softly (#728, lane 2)
Some checks failed
CI / Backend lint (ruff) (pull_request) Successful in 1m2s
CI / Feedback site tests (pull_request) Successful in 1m25s
CI / Bot/backend version sync (pull_request) Successful in 29s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m40s
CI / Docker image build (pull_request) Successful in 29s
CI / Bot tests and audit (pull_request) Successful in 2m53s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m14s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Failing after 7m46s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m40s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m47s
b5ddf530c5
Three corrections to the public pages, from the review.

**"for a few groups of friends" was Dana's line about Amberwake**, and it was
baked into the page — so every self-hosted Quest Board would have told its
visitors that its operator runs it for a few groups of friends. It is a field
now (`blurb` on `GET /public/site`, written in About this server), and the
paragraph is composed from whatever the server has actually said: who runs it,
their own line, a way to write to them, each clause drawn only when there is
something true to put in it. The drawing shows the two ends of that range
because a still shows ends; the states between are the build's. No invented
words: every clause is the drawing's copy or a field. The one bridge is the
subject of the sentence when a server has a person but no name, which borrows
this section's own heading.

**The section eyebrows are `<h2>`s.** A named region says where you are once
you are inside it; a heading is how somebody reading with a screen reader gets
between sections in the first place, and the front door is the first page
anybody meets. They keep the label's look, and `display: inline` keeps the
span's box exactly — an inline eyebrow sits on a line struck by the section's
own type, and a block one would pull everything under it up by the difference.
The built front door is byte-for-byte the same screenshot as before the
change. The regions stay; they cost nothing and the two do different jobs.

**`.stop__voice` is `var(--serif)` again**, as `lane1c.html:181` has it. It is
the only heading in the app that opts out of the display face — `looks.css`
puts every h1-h4 in it, and the stop's voice line steps out on purpose, so
that the sentence the app speaks in the world's voice is softer than the
furniture around it. Lane 0's port flattened it, and lane 2 is the first lane
to put a `Stop` on a page anybody reaches.

Looking at that stop with a short message found one more thing: inside the
card it was indented and centred. A grid item with an auto margin does not
stretch — the margins eat the free space and the item shrinks to its content —
so `Stop`'s own `margin: 0 auto`, which is right when it is the whole window,
made the box as narrow as its longest sentence. A long message clamped to the
track and looked fine, which is how it got past the first look. The card's
override resets the margin as well as the measure and the padding now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 18 red journeys were one bug, and it is not the one I reported yesterday.

**The mechanism, measured.** Half of what a component's stylesheet claims, it
claims at the same specificity as `src/ui/base.css` and *on the same element*:
the base scopes the type to `.qb`, `AppFrame` puts `.qb` on each chrome piece,
so `.qb { background: var(--paper) }` ties with `.appbar { background:
var(--chrome-bg) }` and `.rail`'s, and `.qb a` ties with `a.btn--primary`,
`.foot a`, `.appbar a`, `.toc--sub a`. Those ties are meant to go to the
component and do, while base.css is emitted first — which `main.jsx` arranges
deliberately and explains in a comment.

Chunking defeats that. Building `main`'s frontend beside this branch's and
reading the byte offsets:

  main       one stylesheet: .qb{ 88317 → a.btn--primary{ 94880 →
             .rail{ 110463 → .appbar{ 110483 → .foot a{ 115582
  this PR    two: ui-*.css, then index-*.css — base.css last

Adding a second eager consumer of the library (`Front`, through `PublicPage`)
tipped the bundler into emitting the kit's CSS as a shared chunk, and a shared
chunk's stylesheet is linked before the entry's. So `.qb` began winning: the
app bar and the Contents rail lost `--chrome-bg`, while everything inside them
kept the near-white `--chrome-ink` that is correct on navy and unreadable on
paper. 82 contrast violations across the frame.

**So yesterday's "find" was this bug's first symptom, not a second bug.** The
unlabelled primary button was `a.btn--primary` losing the same tie; there was
never an invisible button on `main`. Worse, the `:where()` I put on `.qb a`
handed `.appbar a` and `.rail .toc a` their win too, which is why the crumb
and the Contents links went from legible accent to white-on-white. That change
is reverted.

**The fix** is one line: `@import "./base.css" layer(qb-base)`. An un-layered
rule beats a layered one whatever order the files load in, so "a component
wins over the base" stops being a property of the bundler's output. Nothing
else is layered — the rule being written down is only that the base sits
underneath. I stopped short of weakening every `.qb`-level rule by hand, which
would have been the sweeping version of the same idea with a much wider blast
radius.

Also here, from the review:

- **The journeys walk the pages this lane rebuilt.** J0's signed-out test
  asserts the page as it is now — heading *Sign in*, the wordmark a crumb back
  to `/` — and keeps every guarantee it was making: the sign-in control is
  still an anchor to the backend's own `/auth/login`, and signed out is still
  outside the frame. The keyboard test now asserts its locator resolves before
  it asks anything of it, because the pointer guard refuses *before* a locator
  is resolved and a locator matching nothing would pass every `toThrow`.
  J1 starts at the front door, which is the first page a GM meets, and scans
  it — it was the one page in the app with no axe scan on it.
- **The a11y baseline is empty.** `/login` was its last entry and this lane
  rebuilt the page, so the entry goes and the header prose goes with it.
  Verified rather than assumed: `--grep J0` green with nothing left to
  tolerate.
- **The front door's Sign in goes to `/login`, not to the provider.** D62 is
  that sign in is a page behind the front door, "reached from it"; jumping
  straight to the provider took away the two states that page exists to carry.

Locally: `--grep J0` 12 passed, `--grep J1` 6 passed (3 skipped, lane 3's),
on desktop, phone and keyboard; lint 0 errors; 1269 unit tests; build green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(frontend): base.css points at the layer, not at a setting that is not there
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 52s
CI / Docker image build (pull_request) Successful in 46s
CI / Bot/backend version sync (pull_request) Successful in 49s
CI / Backend lint (ruff) (pull_request) Successful in 54s
CI / Feedback site tests (pull_request) Successful in 1m5s
CI / Bot tests and audit (pull_request) Failing after 2m12s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m38s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 2m58s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m34s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 18m34s
fc6dfee0e2
The comment above `.qb a` still said source order was what made a component
win, and pointed at `build.cssCodeSplit` in vite.config.js — which is not set
there, and which was measured and rejected as a fix anyway because it
concatenates with the entry last and would have made the inversion permanent.
The layer is the guarantee now; point at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into build/lane2-public-frame
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 57s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m31s
CI / Bot/backend version sync (pull_request) Successful in 31s
CI / Feedback site tests (pull_request) Successful in 47s
CI / Bot tests and audit (pull_request) Successful in 2m41s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m8s
CI / Docker image build (pull_request) Successful in 4m32s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 3m59s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 12m51s
CI / Backend migration, tests, and audit (pull_request) Successful in 14m23s
ba39845386
# Conflicts:
#	CHANGELOG.md
Sign in to join this conversation.
No description provided.