Prufy notes
The interface · 12 min

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

RouteWhat it isMain file
/The landing page. The hero's input starts a claim, and your open domains are listed under itcomponents/landing/hero.tsx, domain-form.tsx
/claims/<id>The claim: your domain, add one record, we check it, verifiedcomponents/claim/claim-page.tsx
/setup/<token>What a helper opens from a setup link. Two steps, and no owner-only actionscomponents/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.

One response in, one layout outPick a situation

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.

The API says
fix_needed
The page shows
Add record
These differ. This is the one case where the page overrides the phase.
The steps down the line
  1. Your domainDone
  2. Add one recordThe step to act on
    The four ways to add it, and a quiet line: we've looked, and it isn't there yet.
  3. We check itStill ahead
  4. VerifiedStill ahead
The API's phase comes from the real toClaimView, and the phase shown from the real shownPhase in components/claim/phase.ts. The step states use ClaimPage's own six lines, copied here.
Predict it first
Pick 'Saved in the wrong place' above. It's a not_found too. Why doesn't the page treat it as 'still adding'?

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
Before this file
The first automatic check is scheduled 30 seconds after a claim is created. Someone still reading the steps watched the page turn red and say Fix needed, for a record they hadn't had a chance to add.
Now
The step stays on Add one record, the badge stays Add record, and one quiet line says: we've looked, and the record isn't there yet. That's expected until you add it.

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 appliedWhat it changes
claim-page.tsxThe tile, the badge and which step is active. It also adds the 'we've looked' line
check-panel.tsxThe three track nodes stay idle, and the card stays neutral, not red
setup-page.tsxThe same, for the helper
events-strip.tsxDoesn'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.

PhaseRefetch every
checking1.5 seconds
Any other proving phase5 seconds
verified, at_risk60 seconds
lapsed, expired, transferred, abandonedNever

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.

From the hero to step oneStep through it
DomainForm
Query cache
Router
ClaimPage
Step 1 of 7
You press Enter in the hero
The form runs normalizeDomain once more, sends one wave across the globe, and calls api.createClaim. The button reads 'Looking it up' while the server finds the DNS provider.
apps/web/components/landing/domain-form.tsx
Predict it first
Remove the setQueryData call and navigate anyway. What does the field do?

The motion rules

The real presets, playingRuns lib/motion.ts
A whole block arriving: a step's body, a result card.
We can see your record
Arrives in 0.42s. Leaves in 0.16s.
PresetInOutStarts from
enter0.3s0.12s6px below, blur(3px)
rise0.42s0.16s10px below, blur(6px)
reveal0.38s0.18sheight 0, blur(4px)
A state swapping in place uses a spring with no bounce
swap: 0.3s. settle, for height changes: 0.45s. Both have bounce 0, so a status never overshoots.
The numbers under each are read from the objects exported by apps/web/lib/motion.ts. With reduced motion on in your system settings, the movement goes and the fades stay.
RuleWhyWhere
Things that enter use a strong ease-outThey 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 entriesNobody waits for something to leave.Every preset's exit has its own, smaller duration
A state swapping in place is a spring with no bounceIt 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 blurIt reads as settling in, not popping.The initial of enter, rise and reveal
Height changes are animatedContent below slides instead of jumping.reveal in the timeline's steps, and AutoHeight around the method panes
Nothing animates on scrollThe 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 sessionComing 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 settlesA 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 itTwo 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

WhatHow it's switchedWhat's left
Motion componentsMotionConfig reducedMotion="user" in components/providers.tsxTransforms are dropped. Opacity changes stay
The page-to-page transitionThe view-transition rules sit inside prefers-reduced-motion: no-preferenceThe 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 fieldMotionConfig drops layout animationThe field swaps in place. The command and the field's contents still fade and blur
The rail filling, the breathing dot, the shimmerA reduce block in globals.cssOff
The globestart-globe.ts watches the media queryOne still frame, redrawn only on change
Scrolling back to the inputfocus-domain-button.tsx checks the media queryJumps, without smooth scrolling

Four ways to add the record

Connect with your provider
Only listed when the provider advertises Domain Connect. First and marked Easiest when it's switched on for Prufy. Last and disabled when it isn't, saying so.
Add it myself
The record, in the provider's own field names, with a copy button on each.
Ask someone to do it
Makes a setup link, optionally emailed. Progress comes from the event log.
Let an AI agent do it
Makes a setup link and wraps it in a message to paste.

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 normalizeDomain when 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. FocusDomainButton scrolls up and dispatches OPEN_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 verify isn'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:

TimeShell widthButton left, widthThe command
0ms184px (just the button)169px, 184pxShowing
160ms386px416px, 135pxAlmost gone, blurred 8px
320ms525px586px, 101pxRemoved
640ms544px (open)610px, 96pxRemoved
  • 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.