fix(website): stop the hero mismatching its own prerendered markup

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.
This commit is contained in:
Michel de Graaf
2026-07-25 01:21:53 +02:00
parent 1eef464677
commit ba92e75fd3
7 changed files with 70 additions and 21 deletions
+7 -1
View File
@@ -20,7 +20,13 @@ const ITEMS = [
export const Nav = () => ( export const Nav = () => (
<header className="sticky top-0 z-50 border-b border-hairline bg-[color-mix(in_srgb,var(--bg-base)_88%,transparent)] backdrop-blur-[8px]"> <header className="sticky top-0 z-50 border-b border-hairline bg-[color-mix(in_srgb,var(--bg-base)_88%,transparent)] backdrop-blur-[8px]">
<div className="mx-auto flex max-w-[1140px] flex-wrap items-center gap-x-4 gap-y-3 px-[18px] pt-2.5 sm:h-[60px] sm:flex-nowrap sm:gap-6 sm:px-7 sm:pt-0"> <div className="mx-auto flex max-w-[1140px] flex-wrap items-center gap-x-4 gap-y-3 px-[18px] pt-2.5 sm:h-[60px] sm:flex-nowrap sm:gap-6 sm:px-7 sm:pt-0">
<NavLink to="/" aria-label="soulseek-rs home" className="mr-auto flex items-center"> {/* min-h-11: the wordmark's own box is 19px tall, which left the most
reached-for control in the header under the 24px target minimum. */}
<NavLink
to="/"
aria-label="soulseek-rs home"
className="mr-auto flex min-h-11 items-center"
>
<Wordmark size={19} blink /> <Wordmark size={19} blink />
</NavLink> </NavLink>
+27 -13
View File
@@ -79,14 +79,17 @@ export const HeroTerminal = () => {
const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS) const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS)
const [focus, setFocus] = useState(2) const [focus, setFocus] = useState(2)
// Reduced motion skips the performance, so the resting state is the initial /*
// state. Resolved once, in the initialiser, to keep it out of an effect. * Both start where the prerendered HTML starts, whatever the OS says.
const [phase, setPhase] = useState<Phase>(() => *
prefersReducedMotion() ? 'interactive' : 'searching', * Reading prefers-reduced-motion in the initialiser made the first client
) * render disagree with the server for anyone who has it on: the server
const [selected, setSelected] = useState(() => * always serialises the searching state, the client painted the resting
prefersReducedMotion() ? RESTING_ROW : 0, * one, and React bailed out with a hydration mismatch (#418). The
) * preference is applied a tick later instead, by the opening-run effect.
*/
const [phase, setPhase] = useState<Phase>('searching')
const [selected, setSelected] = useState(0)
const shellRef = useRef<HTMLDivElement>(null) const shellRef = useRef<HTMLDivElement>(null)
@@ -300,10 +303,23 @@ export const HeroTerminal = () => {
* script only ever describes the opening state. * script only ever describes the opening state.
*/ */
useEffect(() => { useEffect(() => {
if (prefersReducedMotion()) return
const timers: ReturnType<typeof setTimeout>[] = [] const timers: ReturnType<typeof setTimeout>[] = []
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms)) const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
const cancel = () => {
for (const timer of timers) clearTimeout(timer)
}
// Asked for no motion: cut to where the run would have ended. Deferred by
// a tick rather than decided in the state initialiser, so the first render
// still matches the prerendered HTML.
if (prefersReducedMotion()) {
at(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, 0)
return cancel
}
const mark = (i: number) => { const mark = (i: number) => {
setResults((prev) => ({ setResults((prev) => ({
...prev, ...prev,
@@ -343,9 +359,7 @@ export const HeroTerminal = () => {
setPhase('interactive') setPhase('interactive')
}, 5050) }, 5050)
return () => { return cancel
for (const timer of timers) clearTimeout(timer)
}
}, [queueFrom]) }, [queueFrom])
const submitSearch = (query: string) => { const submitSearch = (query: string) => {
+4
View File
@@ -47,6 +47,10 @@ export const SearchesPane = ({
> >
<span className="text-[13px] text-[var(--color-oxide)]">/</span> <span className="text-[13px] text-[var(--color-oxide)]">/</span>
<input <input
id="tui-search"
name="tui-search"
type="search"
autoComplete="off"
value={query} value={query}
onChange={(event) => { onChange={(event) => {
setQuery(event.target.value) setQuery(event.target.value)
+6
View File
@@ -42,6 +42,11 @@ interface CalloutProps {
* *
* The tone lives in the label, not in a coloured left rule — one ribbon per * The tone lives in the label, not in a coloured left rule — one ribbon per
* card is decoration, and the label was already saying the same thing twice. * card is decoration, and the label was already saying the same thing twice.
*
* But hue alone cannot carry it: in the light theme --status-warning and
* --accent-text are exactly isoluminant (5.14:1 each on --bg-panel, 1.00:1
* against each other), so a red-green deficient reader sees one colour. The
* "!" is the second channel the left rule used to provide by sheer area.
*/ */
export const Callout = ({ title, tone = 'accent', children, className }: CalloutProps) => ( export const Callout = ({ title, tone = 'accent', children, className }: CalloutProps) => (
<div <div
@@ -58,6 +63,7 @@ export const Callout = ({ title, tone = 'accent', children, className }: Callout
tone === 'warn' ? 'text-warning' : 'text-accent-text', tone === 'warn' ? 'text-warning' : 'text-accent-text',
)} )}
> >
{tone === 'warn' && <span aria-hidden="true">! </span>}
{title} {title}
</div> </div>
)} )}
+6 -3
View File
@@ -112,7 +112,7 @@ export const Community = () => {
<> <>
<PageHead <PageHead
eyebrow="community" eyebrow="community"
title="Help out, or go where you're better served." title="Help out, or go where you’re better served."
> >
It&rsquo;s mostly one person who&rsquo;s been on Soulseek since the early 2000s. It&rsquo;s mostly one person who&rsquo;s been on Soulseek since the early 2000s.
Issues and pull requests are welcome, and if another client fits you better, use it. Issues and pull requests are welcome, and if another client fits you better, use it.
@@ -207,7 +207,7 @@ export const Community = () => {
title="Three good clients to point you elsewhere." title="Three good clients to point you elsewhere."
> >
soulseek-rs has no GUI and no daemon mode yet. If one of these fits you better, soulseek-rs has no GUI and no daemon mode yet. If one of these fits you better,
use it. Recommending them is the strongest trust signal there is. use it.
</SectionHead> </SectionHead>
<div className="grid grid-cols-1 gap-3.5"> <div className="grid grid-cols-1 gap-3.5">
{ALTERNATIVES.map((alt) => ( {ALTERNATIVES.map((alt) => (
@@ -251,7 +251,10 @@ export const Community = () => {
</Button> </Button>
</div> </div>
</div> </div>
<Callout title="two things that stay true"> {/* Raised, not panel: this section is a band, which is itself
bg-panel — a panel-on-panel callout has only a 1.36:1 hairline
holding it together. */}
<Callout className="bg-raised" title="two things that stay true">
<p className="mb-1.5 !text-success">No telemetry. Ever.</p> <p className="mb-1.5 !text-success">No telemetry. Ever.</p>
<p className="font-forum text-xs !text-muted"> <p className="font-forum text-xs !text-muted">
Not affiliated with or endorsed by the Soulseek project. Not affiliated with or endorsed by the Soulseek project.
+8 -1
View File
@@ -204,7 +204,14 @@ export const Docs = () => {
</Section> </Section>
<Section band> <Section band>
<Panel title="the rest"> {/*
The only Panel on a band. Two consequences, both handled here:
its default bg-panel fill would match the band exactly, so it takes
bg-raised like the band callouts do; and the legend paints over the
top border to punch the gap, so it has to match the surface *behind*
the panel — the band — not the panel's own fill. See panel.tsx.
*/}
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
<Cols center className="gap-6"> <Cols center className="gap-6">
<Prose> <Prose>
<p> <p>
+12 -3
View File
@@ -255,7 +255,13 @@ export const Install = () => {
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium"> <h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
{platform.name} {platform.name}
</h3> </h3>
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2"> {/*
Label beside value while the card is full width, label above it
once the cards go three up: at a third of the row the label
column ate enough of the line that every path broke mid-token
("config.to / ml"). Stacking gives the path the whole card.
*/}
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2 lg:grid-cols-1 lg:gap-y-0.5">
{( {(
[ [
['Password', platform.credentialStore, false], ['Password', platform.credentialStore, false],
@@ -265,7 +271,7 @@ export const Install = () => {
] as const ] as const
).map(([label, value, mono]) => ( ).map(([label, value, mono]) => (
<div key={label} className="contents"> <div key={label} className="contents">
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5"> <dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5 lg:pt-2">
{label} {label}
</dt> </dt>
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary"> <dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
@@ -333,7 +339,10 @@ export const Install = () => {
/> />
</Cols> </Cols>
<div className="mt-5"> <div className="mt-5">
<Callout title="two things it will not touch"> {/* Raised, not panel: this section is a band, which is itself
bg-panel — a panel-on-panel callout has only a 1.36:1 hairline
holding it together. */}
<Callout className="bg-raised" title="two things it will not touch">
<p> <p>
Your downloads and your shared directories are yours: they stay exactly where Your downloads and your shared directories are yours: they stay exactly where
they are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in they are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in