The page and its motion
The landing page is the app: its input starts a real claim. The claim page is four steps down a line. Neither decides what state a claim is in, with one deliberate exception.
Three pages, one layout around them
| Route | What it is | Main file |
|---|---|---|
/ | The landing page. The hero's input starts a claim, and your open domains are listed under it | components/landing/hero.tsx, domain-form.tsx |
/claims/<id> | The claim: your domain, add one record, we check it, verified | components/claim/claim-page.tsx |
/setup/<token> | What a helper opens from a setup link. Two steps, and no owner-only actions | components/setup/setup-page.tsx |
app/layout.tsx wraps all three in the same things: the query client and MotionConfig from components/providers.tsx, the globe (Stage), the header, and the service worker's registration. The globe is in the layout so it's created once and survives a route change.
The page reads the phase and never decides it
Everything on the claim page comes from one response, ClaimDetailResponse. The server derives view.phase with toClaimView, and the page lays the steps out to match. The headline, the reason and the fix are core's words. So the UI can't disagree with the engine about what's happening.
About 30 seconds in, the worker ran its first check by itself. The record hasn't been added, so it found nothing at the name.
- Your domainDone
- Add one recordThe step to act onThe four ways to add it, and a quiet line: we've looked, and it isn't there yet.
- We check itStill ahead
- VerifiedStill ahead
The one exception, and why
// apps/web/components/claim/phase.ts
export function isWaitingForRecord(view: ClaimView): boolean {
return (
view.phase === "fix_needed" &&
view.outcome.kind === "not_found" &&
view.outcome.reason === "nothing_at_name"
)
}
export const shownPhase = (view: ClaimView): Phase =>
isWaitingForRecord(view) ? "add_record" : view.phase
To the engine, "nothing at the name" and "added wrong" are the same kind of answer: a check ran and didn't pass. To a person they're opposite experiences. The engine's answer stays as it is, because it's true. The page chooses how to say it.
| Where the rule is applied | What it changes |
|---|---|
claim-page.tsx | The tile, the badge and which step is active. It also adds the 'we've looked' line |
check-panel.tsx | The three track nodes stay idle, and the card stays neutral, not red |
setup-page.tsx | The same, for the helper |
events-strip.tsx | Doesn't use the function, and reaches the same place: this check is listed as 'Not there yet', in a neutral tone |
How the page stays current
The page polls. While proving, something can change without this browser doing anything: the worker re-checks, or a helper adds the record.
| Phase | Refetch every |
|---|---|
| checking | 1.5 seconds |
| Any other proving phase | 5 seconds |
| verified, at_risk | 60 seconds |
| lapsed, expired, transferred, abandoned | Never |
Pressing a button doesn't wait for the next poll. Check, new code and stop are mutations whose response is the whole claim again, and apply writes it straight into the cache.
The field that travels
The domain field is a . It's named on both pages, so the browser's View Transitions API moves it from the hero to step one. That only happens if the destination renders it in the same commit as the navigation, which is why the claim is put in the query cache first.
The motion rules
| Preset | In | Out | Starts from |
|---|---|---|---|
enter | 0.3s | 0.12s | 6px below, blur(3px) |
rise | 0.42s | 0.16s | 10px below, blur(6px) |
reveal | 0.38s | 0.18s | height 0, blur(4px) |
| Rule | Why | Where |
|---|---|---|
| Things that enter use a strong ease-out | They start fast, so the page feels like it answered at once, and settle gently. | EASE_OUT_QUART [0.165, 0.84, 0.44, 1] and EASE_OUT_EXPO [0.19, 1, 0.22, 1] |
| Exits are shorter than entries | Nobody waits for something to leave. | Every preset's exit has its own, smaller duration |
| A state swapping in place is a spring with no bounce | It can be interrupted mid-way without jumping, and it never overshoots a status. | swap and settle |
| New content arrives from a few pixels below, out of a soft blur | It reads as settling in, not popping. | The initial of enter, rise and reveal |
| Height changes are animated | Content below slides instead of jumping. | reveal in the timeline's steps, and AutoHeight around the method panes |
| Nothing animates on scroll | The page is short, and content that waits to fade in makes a reader wait too. | components/landing/sections.tsx has no motion at all |
| The hero's entrance plays once per session | Coming back to the page shouldn't replay it. It's CSS, so it doesn't wait for JavaScript. | .intro-item in globals.css, and an inline script in app/layout.tsx |
| The headline shines once, after it settles | A band of light that sweeps across and is gone. It says 'this is the thing' without looping for attention. | .headline-shine in globals.css, the aria-hidden copy in hero.tsx |
| Verify is a button until you choose it | Two clear choices first: the web or the command line. The field opens out of the button you pressed, so it's one object changing shape. | components/landing/domain-form.tsx |
Reduced motion is a first-class path
| What | How it's switched | What's left |
|---|---|---|
| Motion components | MotionConfig reducedMotion="user" in components/providers.tsx | Transforms are dropped. Opacity changes stay |
| The page-to-page transition | The view-transition rules sit inside prefers-reduced-motion: no-preference | The browser's plain crossfade. The field doesn't travel |
| The hero's entrance | .intro-item { animation: none } | The text is simply there |
| The headline's shine | .headline-shine { display: none } | Off. It's decoration |
| Verify opening into the field | MotionConfig drops layout animation | The field swaps in place. The command and the field's contents still fade and blur |
| The rail filling, the breathing dot, the shimmer | A reduce block in globals.css | Off |
| The globe | start-globe.ts watches the media query | One still frame, redrawn only on change |
| Scrolling back to the input | focus-domain-button.tsx checks the media query | Jumps, without smooth scrolling |
Four ways to add the record
MethodPicker in components/claim/methods.tsx is a real radio group, so arrow keys move between the options and a screen reader announces the position. The pane underneath swaps with enter inside an AutoHeight.
Small choices in the landing form
- It accepts what people paste. A URL, an email address or a www name is tidied by
normalizeDomainwhen you leave the field, and a line under it says what changed. - The field starts as a button. The hero offers two choices, Verify a domain and the CLI command. Pressing Verify opens the field and moves the cursor into it, on any device, because now someone has asked for it. Escape in an empty field closes it and puts focus back on the button.
- The closing button opens it from far away.
FocusDomainButtonscrolls up and dispatchesOPEN_DOMAIN_FIELD, a window event the form listens for, since the field may not exist yet. - The input's text is 16px. Anything smaller makes iOS zoom the page on focus.
- The message line is always there. It has a minimum height, so a message appearing never pushes the page down.
- A problem shakes the field, next to the message that says why. It's a transform, so nothing reflows.
- The CLI command is marked Soon.
npx prufy verifyisn't published, and copying it says so.
From button to field
The shell and the Verify button are the same elements before and after. Motion's animates the change with one no-bounce spring (MORPH, 0.55s), so the button slides to the end of a field that opens out of it. These are the numbers measured in the browser at 1440px wide:
| Time | Shell width | Button left, width | The command |
|---|---|---|---|
| 0ms | 184px (just the button) | 169px, 184px | Showing |
| 160ms | 386px | 416px, 135px | Almost gone, blurred 8px |
| 320ms | 525px | 586px, 101px | Removed |
| 640ms | 544px (open) | 610px, 96px | Removed |
- The command leaves at once. It exits in 220ms under
AnimatePresence mode="popLayout", which takes it out of the layout immediately, so the field can grow into its space while it blurs away. - The field's contents wait. The globe icon, the input and the tick fade in from a 4px blur after 140ms, so they don't ride along while the shell is still growing.
- Corners stay round. The radius is inline in pixels (16, then 12 for the button). Motion can only correct a pixel radius while it scales a box. A class radius would stretch.
- Closed, the row is 38rem; open, the field is 34rem. The two buttons need 583px side by side.
The headline's shine
Taken from react.email by reading its CSS in the browser. A copy of the headline sits exactly over it, aria-hidden and unselectable, with transparent text. Its background is a 45° gradient that's clear except for a thin white band, sized 225% and clipped to the letters with . Moving background-position from 110% to 0% carries the band from just off the left edge to just off the right: 1.5s, ease-in-out, starting 0.8s in, once the headline's rise has settled. It plays on every load. On a repeat visit in the same session the entrance is skipped, so the shine starts at 0.3s instead. Only reduced motion turns it off.
Status is never colour alone
PhaseBadge always renders a text label, and the colour only reinforces it. The three track nodes in the check panel each carry a word under them: Not yet, Looking, Found, Waiting, Needs a fix. The active step has aria-current="step", and a result changing is announced with role="status", or role="alert" when there's something to fix.
The decision is in docs/decisions/0010-the-landing-page-is-the-app.md.