Reading prefers-reduced-motion in the state initialiser made the first
client render disagree with the server for anyone who has it on: the server
always serialises the searching state, the client painted the resting one,
and React bailed out of hydration with #418 on every load. The preference
now applies a tick later, from the opening-run effect, so the first render
matches the HTML and the resting state arrives immediately after.
The header's home link was 19px tall — the most reached-for control in the
nav, and the only one under the 24px target minimum, while every nav link
beside it was already 44. It gets a 44px box.
The demo's search field also carries an id, name and type, which clears the
"form field should have an id or name" issue and stops browsers offering
autofill on a fake terminal input.
Audited against the AI-design catalog. The world stays — the terminal
aesthetic, the oxide accent, IBM Plex Mono are all chosen, and the audit
cleared them. What went is the handful of defaults that had crept in
underneath it.
Eight feature cards at identical weight said every feature mattered equally.
Search is what the client is for, so it takes a double-width cell and the
grid drops to three up; eight cards plus that one double still comes out
square.
Each glyph sat in a 34px bordered square — the stock icon-in-a-chip
treatment that ships with every component library. The glyphs are the real
TUI's, so they now sit inline like the prompt markers they are.
Callouts lose the coloured left rule. The catalog puts it plainly: a
coloured left border is about as reliable a sign of generated design as an
em-dash is in prose. The tone already lives in the label, which was saying
the same thing twice.
Thirteen arrow glyphs were typed into labels and link text. The
leaving-the-site mark belongs to the control, not the copy, so Button draws
it once, aria-hidden, and only when the link actually leaves. The "read
more" arrows on internal links were pure flourish and are gone.
The hero also drops "blazingly fast Rust", which TONE-OF-VOICE.md lists
under banned, no exceptions.
Contrast was measured, not eyeballed: 41 foreground/background pairs across
both themes and the terminal surface, each against the darkest (light) or
lightest (dark) surface it actually sits on. Five failed. Links on manila
read 3.64:1 and the footer's "No telemetry. Ever." — a claim the site goes
out of its way to make — sat at 2.86:1.
The accent needed splitting rather than moving. #c1502e clears 4.5:1 as a
button fill behind white but only 3.99:1 as an 11px eyebrow on vinyl, so
--accent keeps the brand hue for fills and a new --accent-text carries the
readable cut. The terminal gets the same treatment dark emulators give their
palettes: alarm and oxide brighten inside .tui-content so "Failed" is
legible next to its glyph.
Reduced motion no longer nukes everything. A blanket 0.01ms also kills the
transitions that answer a click, which is feedback rather than movement;
looping and travelling animation stops, the rest caps at 80ms. The TUI
already skipped its opening run and still does.
The hero reserves its box with aspect-ratio from the first paint. It was
prerendered at full unscaled height and collapsed once JS measured, moving
everything below it by ~145px. Measured shift is now 0.
Also: the webfont loads non-blocking (display=swap meant the first frame
never waited for it anyway), footer link groups nest under a heading instead
of sitting level with page sections, and eight tokens nothing referenced
stop shipping.
A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.
The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.
Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.
Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.
CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.