Appearance
The front end
One design system, one app bar, one footer, and an audit that measures the rendered page instead of asking whether the parts exist.
Why there was a problem
The app had three competing style systems at once:
globals.css, used by/and/op- a
COLORSmap and anSobject of inline React styles insrc/app/ui.ts, used by/shop,/buy,/selland/order - a third private copy of
Sinsidereveal/[token]/reveal-client.tsx
Inline styles cannot express :hover, :focus-visible, :disabled or a media query, so every interactive element styled that way was inert to a keyboard and dead on hover. That is the root of most of what was wrong. It is why the fix was a rewrite rather than a pass of tweaks.
The rules the system holds itself to
Pages compose classes. They do not invent values. Every colour, size, space and radius is a token in :root. There is one accent, one focus ring, one prefers-reduced-motion block, and tabular numerals on every money figure.
Three CSS lessons are written into globals.css next to the rules that learned them, because each was a rule that looked correct and never ran:
input[type="text"]does not match an input written without atypeattribute. Seven inputs in this app omit it, so they rendered as raw browser controls. The unstyled 40px amount field on/shop, sized by itssizeattribute rather than by its container, pushed the whole document into a horizontal scroll on every phone. The base selector now names what is not a text field, wrapped in:where().:not()carries the specificity of its argument. The nine-:not()version of that selector scored (0,9,1), higher than any class, and silently beat.affix input. That rendered a currency sign on top of the placeholder.:where()contributes zero, so the base layer can always be overridden.- A media query adds no specificity.
.cluster--scroll { flex-wrap: nowrap }was declared before.cluster { flex-wrap: wrap }and lost on source order.
The common thread: in CSS, "I wrote the rule" and "the rule runs" are different claims, and only a rendered measurement tells them apart.
The audit
bash
pnpm ui:auditRuns every state in scripts/ui-states.ts at 390×844 and 1440×900 against the dev server. It fails on measurable defects: horizontal scroll, tap targets under 44px, WCAG AA contrast, unlabelled fields, controls the design system failed to match, collapsed spacing between stacked controls, gutter misalignment between the chrome and the content, a wrapped app bar, duplicate landmarks, skipped heading levels, missing page titles, console errors, and per-state behavioural invariants.
Three properties of it matter more than the rule list:
It walks interactive states, not just routes. The first version measured six URLs in their resting state. It passed clean while the evidence form had a submit button sharing an edge with the input above it, because reaching that form takes three clicks and the audit never clicked anything.
It shares its state list with the screenshots. scripts/ui-shots.ts and scripts/ui-audit.ts both import STATES, so a state worth photographing is measured and a state worth measuring is photographed. They used to be two lists and the audit's was the shorter one.
Rules are verified by breaking the thing they protect. Every rule added here was confirmed to fail when the defect was deliberately reintroduced, not merely observed to pass. That caught a rule that was itself broken. appbar-wrap compared the bar's height against a threshold, and min-height: 44px swallowed two wrapped lines. So it returned green on a bar visibly reading "Pay less at the / till". It measures the text with a Range now.
The barcode must not move
{ barcodeStable: true } watches the barcode's DOM for 2.5s while a code is live and fails on any mutation.
encode() and barPatternToSvg() were called inline in the render, and the countdown called setRemainingMs every 250ms. So the <svg> a scanner is pointed at was destroyed and rebuilt four times a second for ninety seconds. Measured at sixteen childList mutations in four seconds, with the element coming back as a different DOM node. None of that is visible and no unit test can see it. It only exists as a count of mutations over time.
Two changes. The barcodes are useMemo'd on the payload. The countdown's state updater returns the previous value unless the displayed second has changed, so React bails out of the render entirely. The interval stays at 250ms so expiry is still detected promptly. After: zero mutations in six seconds, same node, countdown ticking at exactly 1Hz.
Behavioural invariants
UiState.expect asserts things that are not appearance. The one that matters:
ts
{ name: 'order-reveal-live', expect: { navReachable: false } }
{ name: 'reveal-settled', expect: { navReachable: true } }While a code is on screen, nothing in the chrome navigates away from it. At a register with a countdown running, one fat-fingered tap on "Sell" destroys a live code with a cashier waiting. RevealClient publishes data-live, and globals.css collapses the app bar nav, the footer nav and the brand link with :has(). There is no client JavaScript or lifted state, and no prop for a route to get wrong. An earlier version decided it from the route, and so hid navigation on the settled and evidence screens too. Nothing is at risk there, and a buyer with a held payment is precisely the person who needs a way out. Both directions are asserted.
The screenshots
bash
pnpm ui:shotsWrites every state at both viewports into .ui-shots/ at 2×. The directory is cleared first. A renamed state leaves its old PNG behind, and a reviewer then reads a stale screenshot as current and reports a defect that was fixed hours earlier. That has already happened once here.
Fixtures are Playwright route interceptions, never seeded rows. The real components render the real layout against a plausible response, and nothing is written to a database to take a picture. Two fixture bugs are worth knowing about, because both are the same mistake:
- the order-summary interception originally matched every id including the one used to reach the not-found screen, so that screenshot was byte-identical to the ready one and the failure screen had never been looked at;
- the settle override for the released outcome was registered without
{ times: 1 }, and the page is reused across states. So the very next state (which exists to show the held treatment) would have been captured as a release.
A fixture that silently covers the state it was meant to contrast is worse than no fixture.
Efficiency, measured
The third review agent stalled on its first step and was stopped after an hour, so its brief was answered by direct measurement instead. All numbers below are from a real browser at 390×844 against the dev server.
| Question | Measured |
|---|---|
| Does the 250ms countdown rebuild the barcode? | Yes: 16 mutations in 4s, different DOM node. Now 0 in 6s, same node. See above. |
| Do the debounced quotes discard out-of-order responses? | Yes on both /shop and /sell, verified by making the first request slow and the second fast and asserting which one renders. /sell had no sequence guard until this pass; /shop always did. |
| Anything that leaks or never cleans up? | Every timer has a cleanup path. The one exception was the 650ms navigation timer added for the confirming screen; it now lives in an effect keyed on the order id, because it is the only timer in the app that does something irreversible. |
| Any client component that did not need to be? | No. All six carry state, effects and handlers. The app bar, the footer and every layout are server components. |
| Payload and paint | 8–11 requests per route, zero offsite, FCP 48–56ms warm, CLS 0 (0.004 on /). A first measurement of 1900ms on /sell was dev-mode on-demand compilation, not the page. |
| Dead CSS | Five rules nothing could reach, removed. |
That last one is a caution rather than a win. Deleting .barcode__caption by matching its selector text took the BODY of the rule it shared a list with. That left a dangling .barcode figcaption, that silently merged into .masked below it. So every barcode caption rendered in the dark-theme muted colour on a white card at 2.25:1. The contrast rule caught it on the next run. Removing dead CSS is a code change like any other.
The till and the account
Phases 1a and 1b of the unified account put the card rail behind one keypad and one account, and phase 3b put the dollars rail behind the same keypad. Built and tested offline. Not deployed, and not yet tried on a phone. The dollars rail is testnet only, and not reviewed.
| Address | What it is |
|---|---|
/pay/[store] | The till for one store, on both rails. The keypad is the first thing on screen. By card, Pay holds every card and the deposit at once and shows card 1. In dollars, Pay is one batch on the escrow, with no prompt, and Lit releases every card to the phone. Card 2 is drawn only after Next |
/pay | The till's front door: the store this phone used last, else the four stores, one tap each. Never a search |
/account | Face ID sign-in, what pays at the till (dollars and the allowance, the saved card, which one is the default), how the till picks cards, balances and deposits held (the signed-in account's only), one store credit list, which the till pays from first when it pays in dollars (the card rail does not use it yet, and the page says so), with the codes of cards bought on the first escrow, shown signed out too, each store's Home Screen icon, and a calendar reminder |
/sell | The usual way, and on the testnet Salvo Pay, which lists on the new escrow (V2); a seller's listings on the first escrow stay below it until they settle |
Redirects, temporary, in next.config.ts: /shop?merchant=target goes to /pay/target, any other /shop to /pay, /buy to /pay, and /cards to /account#credit. There is no page of its own for either rail. The nav is Pay, Account and Sell. Movie tickets moved to the footer.
One Home Screen icon per store. Each till page links its own manifest: "Salvo · Target", opening /pay/target. It opens as a Safari tab (display: "browser"), so it shares Safari's storage and a phone signs in once. Whether iOS honours that for an added icon is a device check. If it does not, the answer is standalone, one constant in src/app/pay/stores.ts, and each icon then costs one Face ID the first time. The icon is the Salvo mark, drawn as a PNG in code (src/lib/brand/mark-png.ts) from the same two bars as the app bar. Never a store's logo.
Remind me at… makes a calendar file on the phone, one event at the shopper's own clock, weekly or once, linking the store's till. Nothing about it is sent to us. Whether Safari on iOS opens the file straight into Calendar is a device check.
The scan screens keep the reveal's rules. While a code is on screen the nav collapses (data-live), the barcode is not rebuilt, and the screen stays on. The audit has till-* and account-* states for the new screens and their sheets, answered from scripts/till-fixtures.ts, which a test holds to what the real card backend accepts. They have not been run against a dev server yet.
How fast. pnpm till:timing times the price, the tap to the first barcode step by step (ticket wait, reads, sign, send, included, second host, release, render), each Next and Done. It is offline unless given --live, a server and a cookie file. Offline, --tap-after-ms taps before the tickets are back, and --behind keeps the second chain host a block behind, as it was measured. A live run is real purchases, so only on a test card key. In a browser, salvo:till-timing set to 1 in local storage prints the same steps to the console, names and milliseconds only. A dollars tap measured live on 2026-10-14 took 4.98 s; the target is about 2 s from the tap to the first barcode.
What the audit deliberately does not check
Whether it looks good. That is a judgement, and it was made by review rather than by rule: four rounds against two independent reviewers, on professionalism and visual craft, and on UX intuitiveness. Scores moved 6 → 7.5 → 9 and 5 → 7 → 8 → 8.5 across those rounds. Everything they raised that was a measurable property became a rule above. Everything that was taste was argued on the merits, and the reasoning is in the comment next to the code.
Two things they raised are still open and are not front-end problems: there is no receipt, order lookup or route to a human, and there is no real payment step. Those are logged as backend work rather than papered over with copy.