Moatbrain web Visual implementation contract T3 · review only main @ 25f21f6

Eliminating the light surfaces, and the one defect underneath all of them

Read against origin/main 25f21f6 (verified as the current head of origin/main; note origin/develop is behind it at 525194a). Covers the Risk and Memo stages, Screener, the auth shell and the token layer. No code, no merge, no deploy — every item below is a change for Charlene to make.

The headline. Risk and Memo are not the problem. They use the Desk vocabulary cleanly — .p-body, .btn, .chip, .eyebrow — with zero inline styles and only two undeclared classes in the whole set. The light surfaces come from two places: a token collision that silently replaces the approved palette, and two stylesheet regions written in literal light hexes (the progress page and the public/auth shell). Fix C1 first; several other items shrink once it lands.

C1Two token blocks are fighting, and the approved one loses

Root cause · fix before anything else

Both of these declare tokens on the same selector, at the same specificity (0,1,0), and both are loaded by apps/web-start/src/routes/app.tsx:

Declared inLoad order in app.tsxgroundsurfacecreamaccent
sana-source.css:12
the warm Desk
3rd of 7 #141210 #1B1815 #F2EBDD #7FBFA8
sana-accessibility.css:7
cold steel + a light theme
5th — wins #08090b #111318 #f7f8fa #72cdb0

So all 231 Desk component rules render, painted in a palette nobody picked. The Desk block in sana-source.css is dead as tokens while its own header comment still describes the room it no longer produces. tokens.css is a one-line stub claiming "Token values are owned by the immutable Sana source stylesheet" — which is now false.

Contract. Exactly one file declares the palette. Put it in tokens.css, which is what its name and its own comment already promise, and delete the block from both other files. sana-accessibility.css keeps focus rings, reduced-motion and contrast helpers — it should not own a colour system.

Which palette is canonical is a call I should not make silently, so I am making it explicitly and you can overrule it: the warm Desk. It is the direction Khoa picked, it is what every mock in this program has been drawn in, and it is what the progress round was signed off against two days ago. The steel palette has one thing the Desk block does not — a working [data-theme="light"] variant that milestone.spec.ts:1177 asserts. That light theme is a tested feature and must survive; it needs re-deriving from the Desk hues rather than being deleted.

Do not read "eliminate light cards" as "delete the light theme." They are different things, and conflating them breaks a passing test. The rule that resolves it: a light surface is legal only under [data-theme="light"], and only ever from a token. A literal light hex in a rule that is not theme-scoped is the defect. That single sentence is the whole of C2 and C3.

C2The progress page is built in literal light hexes

apps/web-start/src/styles/app.css lines 24–77 · inside a dark DeskRoot

The build itself is faithful — app.progress.tsx honours the null confidence bucket, the hollow low-n point, insufficient-data held outside the grid, the never-summed note and the paused-memory state. The defect is confined to the stylesheet. Two failure classes, both from light-theme values rendered on the dark ground:

Light cards punched into the dark ground 5 rules · 17–19:1 luminance step
Light-theme text greys on the dark ground 8 rules · all fail WCAG AA

Measured against the ground actually in effect, #08090b — the value .sana-source resolves to today, not the Desk's #141210. Both are dark, so the failures hold either way; the exact ratios move slightly once C1 lands.

Contract. Every value in both tables becomes a token. No literal hex survives in the .progress-*, .calibration-*, .confidence-* or .base-rate rules. Borders #c9ccc3 #d7dad2 #bdc3b9 #9ba39a #adb3aa #cbd0c6 #989e95 #a9aea5 all collapse to var(--line) or var(--line-2).

C3The public and auth shell is a second, separate light theme

The first thing a user sees, and it is not the product

sign-in.tsx, sign-up.tsx, demo-access.tsx and the landing route all render <main className="tracer-main"> outside DeskRoot, so no .sana-source applies and they fall to app.css:1:root{background:#f4f5f0;color:#171915}.

SelectorLocationNowContract
.tracer-headerapp.css:919 background:#fff var(--sunk) + var(--line) border
.wordmarkapp.css:934 color:#171915var(--cream)
.tracer-main h1app.css:954 Georgia serif, clamp(38–68px) var(--sans), one display size — see C7
.tracer-main > .eyebrowapp.css:947 color:#566258var(--cream-3)
.tracer-main > .ledeapp.css:962 color:#454a43var(--cream-2)
a (bare element selector)app.css:916 color:#245342var(--accent); scope it, do not leave it global
.primary-linkapp.css:977 #1f493a bg / #fff textreuse .btn.solid
.account-password buttonapp.css:1029 background:#f4f2eb var(--raised)

Contract. Wrap the public and auth routes in the same scope the app uses, so they inherit tokens instead of re-declaring colours — either DeskRoot directly or a thin PublicRoot that applies .sana-source without the app3 grid. Then delete every literal above.

While you are in there: tracer-* is leftover naming from the pipeline-tracer lab UI. I flagged the visible "pipeline tracer" pill in the July audit and it went; the class names stayed. Rename to auth-* / public-* in the same pass — it is free while you are already touching every one of these rules, and expensive later.

C4Remove the unauthenticated Home / Build provenance nav

apps/web-start/src/routes/__root.tsx lines 73–86

The block is gated on !isAuthenticatedRoute, where isAuthenticatedRoute is pathname.startsWith("/app"). Three links: Home → /, Build provenance → /health-ui, Sign in → /sign-in.

Contract. Remove Home and Build provenance. Keep the wordmark (it is the home affordance) and keep Sign in. If the <nav> is then a single link, drop the element and its aria-label rather than shipping a one-item navigation.

C5Screener Review Lane — two defects at two breakpoints

These need separate fixes; doing one leaves the other broken

BreakpointVisible by default?Collapsible?Why
≥ 1200px yes no app.screener.tsx:177 passes only pageClass. DeskRoot accepts drawerClosed (index.tsx:13) and the collapse is fully built — sana-source.css:252 margin, :333 grid, :264 reveals the .dopen button — but the Screener never sets the state, so the control never appears.
< 1200px no — display:none unreachable sana-source.css:225 sets .sana-source .drawer{display:none}. The assessment shell escapes it via review-mobile-open (sana-extensions.css:64) plus .review-mobile-trigger / .review-mobile-close (app.assessments.$assessmentId.tsx:255,264). The Screener has neither.

Contract. Hold lane state in app.screener.tsx, default open; pass drawerClosed={!laneOpen} to DeskRoot and render the .dopen toggle. Separately, adopt the assessment shell's mobile pattern by reusing its exact class names and buttons rather than writing a Screener-specific variant — a second mechanism for the same behaviour is how these two got out of step in the first place.

Responsive caveat, and it is the one most likely to be missed. Desktop collapse and mobile open are two different mechanisms. drawerClosed does nothing below 1200px because the drawer is display:none there; review-mobile-open does nothing above 1200px because the media query does not apply. Wire one and the review will look correct at whatever width you happened to test. Check 1400 / 1199 / 900 / 390.

Also on the Screener: .check and .screener-protection are rendered in app.screener.tsx and declared in no stylesheet — unstyled elements falling back to browser defaults. Either style them or drop the class.

C6Spacing — the scale exists, it just did not survive the rebuild

33 distinct pixel values across padding, margin and gap in app.css, and 29 in sana-pages.css — including every integer from 1 to 20. That is not a system, and it is worse than what I audited in July, which found 13 and called it two interleaved scales.

The canonical scale from that audit — --space-1..9 = 4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 56 — was implemented and verified green at 1e5b4ea on 19 July. It is gone: there is no --space-* token anywhere on main.

Contract. Re-adopt it rather than re-derive it, with the tier rule that came with it: a gap is one tier larger than the gaps inside what it separates. Snap every value to the nearest step, and treat any new literal in a padding, margin or gap as a review failure. This is mechanical and it is the single cheapest way to make the whole app read as one product.

C7Hierarchy — three display sizes and a serif that belongs elsewhere

RuleNowContract
.tracer-main h1 app.css:954 Georgia, clamp(38–68px)One display size per page. Page titles 27, section headings 20 — the hierarchy verified at 1e5b4ea. No serif: Georgia is the Ledger direction, not the Desk.
.progress-intro h1 app.css:26 Georgia, 42px
.progress-section h2 app.css:30 Georgia, 27px
.calibration-cell strong app.css:38 Georgia, 42px var(--num), 27px — see the note below

The 42px quadrant count is a compliance point, not a taste point. It is the largest element on a page whose entire design brief is that it must never rate the reader. A number set larger than the page title is the closest thing to a score that surface can produce, and it undoes the restraint the rest of the build got right. It is 27px in the approved design and in monospace, deliberately, so that four counts read as four counts rather than as one headline figure.

C8Two fidelity gaps, adjacent to what you asked

Outside the visual scope — raising, not expanding

Both were named as acceptance criteria in the signed-off round, and neither is in the current build. Flagging them here only because I was already in the file:

Neither blocks the visual work. Both belong to Scott's build spec rather than to this contract.

C9What I did not check

Read against ownermind-web origin/main 25f21f6 · Merge PR #53 charlene/moatbrain-residual-casing · 2 Aug 2026 17:22 +0700.
Contrast computed to WCAG 2.1 relative luminance against #08090b, the ground .sana-source resolves to on this ref. Design review only — no code, no merge, no deploy.