Prufy notes
The interface · 10 min

The globe

The object behind every page is a sphere of dots with a wave crossing it: a DNS record leaving one place and reaching resolvers around the world. It's one fragment shader, and this is how it works.

What it looks like

A sphere of white dots on black, with a bright ring of larger dots crossing it and green dots behind the ring
Landing
The hero, 0.9 seconds after a pulse. The bright ring is the wave's front. The green behind it is where it has already been.
Only the lower edge of a large sphere of dots, along the top of a black frame
Claim
A claim in progress: the same sphere, much larger and mostly off the top.
The same lower edge of the sphere, with every dot green
Verified
Verified: tint is 1, and every dot has settled to green.

These three are real frames from the shader, drawn without a browser by apps/web/scripts/render-globe.mjs while writing this chapter. The bright ring is the record arriving. The green it leaves behind is "seen here".

One shader, no geometry

A answers one question per pixel: what colour am I? This one is drawn over a single full-screen rectangle. There are no triangles making up a sphere and no textures. Every pixel works out for itself whether it's on the sphere, and how close it is to the nearest dot.

What every pixel asks, in orderStep through it
Step 1 of 7
1. Am I on the sphere?
var p = (uv - params.center) * size / short_side;
p.y = -p.y;
let q = p / params.radius;
let r2 = dot(q, q);
if (r2 < 1.0) { ... }

q is this pixel's position measured in sphere radii from the sphere's centre. If its length squared is under 1, the pixel is inside the circle the sphere makes on screen. There's no mesh. A sphere is just 'every pixel within one radius of a point'.

apps/web/components/stage/globe.wgsl. fs_main runs once per pixel, every frame.
The same formulas, with dots you can countDrag the sliders
The ring nearest a pole has 3 dots. The ring at the equator has 36. Same spacing, shorter ring.
The front is 1.35 radians from the origin (1.5 × seconds), at 72% strength. It reaches the far side at 3.14.
A sketch in SVG, not the shader. It uses the lattice, the tilt and the wave from globe.wgsl with far fewer rings (the real one has 40 to 128), so the pattern is visible. The shader works the other way round: it starts from a pixel and finds the nearest dot.
Predict it first
With 16 rings, about how many dots does the ring nearest a pole have, if the equator's has 32?

Two waves run at once. An ambient one loops every 9 seconds whatever the page is doing. The other is the one a page asks for with pulse(): it moves at 1.5 radians a second and fades out over 3.2 seconds, so it crosses the whole sphere in about 2.

What the page tells the shader

A shader can't see the page. Everything it knows arrives as , in one struct called Params.

UniformMeansSet from
texel1 ÷ the canvas size in pixelsThe canvas, on resize
center, radiusWhere the sphere sits and how bigposeFor(scene), eased, minus the scroll
timeSeconds since the globe startedThe frame loop
pulseSeconds since the last wave a page asked forstage.pulseAt
tint0 while proving, 1 once verifiedstage.tint, eased
energyOverall brightnessposeFor(scene), eased from 0 on load, plus 0.2 on hover
yaw, pitchExtra turn and lean, in radiansposeFor(scene), eased, plus what someone dragged it by

State in a module, not in React

Pages write to one object. The loop reads itPick what's written
Who writes
DomainForm, when you submit a domain
ClaimPage and SetupPage, when a check starts
ClaimPage and SetupPage, when the phase changes to a different one
The call
pulse()
What the loop does with it
It stores performance.now() in stage.pulseAt. The loop turns that into seconds since, and the shader draws a wave that far from the origin.
apps/web/components/stage/stage-store.ts. Every write also bumps stage.revision, so a paused loop knows to draw one more frame.
As React state
Every pulse would re-render whatever holds the state and everything under it. And the render loop would need the latest value on every frame, 60 times a second, from outside React's own timing.
As a plain object
A page writes a field. Nothing re-renders, because no component displays it. The loop reads the object each frame and eases toward what it finds.
// apps/web/components/stage/stage-store.ts
export const stage: StageState = { scene: "hero", tint: 0, pulseAt: null, revision: 0 }

pulse() is called in three places: when a domain is submitted, when a check starts, and when the phase changes. Typing doesn't send one.

One globe, moved between pages

Stage is rendered in the root layout, so the canvas is created once. Going from the landing page to a claim doesn't tear a globe down and start another. The scene changes, and the same sphere travels to its new pose.

Scene and screencenterradiusenergyyaw, pitchReads as
Landing, wide screen[0.8, 0.5]0.4710, 0The object on the right of the hero
Landing, phone[0.5, -0.27]0.950.60, 0Only its lower edge: a horizon above the headline
Claim, setup, email pages, wide[0.5, (300 − r·short) / h]1.120.71.6, −0.32Lower edge pinned 300px down, behind the title
Claim, setup, email pages, phone[0.5, (240 − r·short) / h]0.860.421.6, −0.32Lower edge pinned 240px down, dimmer under the work

center is in uv, where (0, 0) is the top-left of the canvas and (1, 1) the bottom-right, so a negative y puts the centre above the screen. radius is a share of the canvas's shorter side. Wide means at least 900 pixels across and not much taller than it is wide.

// apps/web/components/stage/start-globe.ts
const k = snap ? 1 : 1 - Math.exp(-dt * FOLLOW)   // FOLLOW = 3.2
current.radius += (target.radius - current.radius) * k

Each frame closes a fraction of the remaining gap. The fraction depends on how long the frame took, so the motion is the same at any frame rate: at 60 frames a second each step closes about 5% of the gap, at 30 about 10%, and two of the small steps equal one of the big ones. After one second about 4% of the gap is left. It can also be redirected mid-way without a jump, which matters when someone goes back quickly.

Softening the edges

Every page adds two layers, after react.email's hero. Both live in EdgeFade in components/stage/edge-fade.tsx. The landing page, the claim page, the setup page and the two email pages each render it.

LayerWhere it sitsWhat it does
Headerz-10, scrolls with the pageStays sharp, because it's above the blur
fixed, z-[5], over the contentContent leaving at the top or bottom goes soft and dark
Contentnormal flowThe hero and sections
fixed, -z-10, after Stage in the DOMThe globe's light fades to black at the corners instead of being cut by the window
Stagefixed, -z-10, in the root layoutThe globe canvas

The vignette and Stage share the same z-index. The vignette wins because it comes later in the DOM, which only holds while nothing between them makes a new .

Softening the edgesSwitch the layers, then scroll inside the box

Prove a domain

is really yours

One record

Checked on its own

Told plainly

No account

No passwords

How it works

BandBlurCovers (from the inner edge)
10.5px0% to 33%
21px0% to 50%
32px17% to 67%
44px33% to 83%
58px50% to 100%
616px67% to 100%
The strips use BLUR_STEPS and bandMask imported from components/stage/edge-fade.tsx. On the page they're fixed to the window, 5rem and 6rem tall on a phone, 7rem and 8rem from sm up.

A surface under the title

On every page that uses the claim pose, the globe's lower edge is pinned at a fixed height, CLAIM_HORIZON_PX in stage-store.ts: 300px down on a wide screen, 240px on a phone. poseFor works the center backwards from it:

const centerY = (horizon - radius * Math.min(width, height)) / height

A fraction of the window's height would move the edge as the window got taller, and it would drift away from the title, which sits at a fixed distance from the top. In pixels the two line up at any size. The pages start their content at pt-32 (sm:pt-40), so the title lands right on the edge.

ClaimSurface then darkens the bottom of the sphere behind the title: an oval centered on the edge plus a vertical ramp that goes from clear to 75% black at the edge, then fades out. The top of the globe stays bright and the title gets a dark surface to sit on. Its CSS is .claim-surface in app/globals.css, and its media query matches the globe's own test for wide: at least 900px and not portrait.

It turns as it moves

Each scene has a yaw and pitch too, and they ease with the same FOLLOW rate as center. Going to a claim page, the globe turns 1.6 radians and leans back 0.32 (SCENE_TURN in start-globe.ts) while it travels. Before, only its position and size changed, so it read as a flat picture sliding up.

Dragging it

Pointer
start-globe.ts
Shader
Step 1 of 5
Hover
onGlobe asks two things: is the target outside anything of the page's (NOT_THE_GLOBE: links, buttons, inputs, text), and is the point inside the sphere as it's drawn now? If both, <html> gets data-globe="hover", which shows a grab cursor.
apps/web/components/stage/start-globe.ts

Reduced motion

With reduced motion on, there's no frame loop at all. startStill draws one frame with the time fixed at 4.2 seconds, so the ambient wave is frozen mid-way, and with no pulse. It draws again only when stage.revision or the scroll position changes: a 250ms timer and a scroll listener both check. Following the scroll isn't an animation, so that stays on. If the setting changes while the page is open, it switches modes straight away.

When there's no WebGPU

SituationWhat happens
The browser has no navigator.gpuStage sets its status to unavailable and shows a still radial gradient where the globe would be. The shader code is never downloaded, because start-globe is a dynamic import behind that check
WebGPU exists and init() failsA blocked or missing GPU. onUnavailable fires, and the same gradient shows
It worksThe canvas starts transparent and fades in over 1.4 seconds after the first frame, so it never flashes

Either way the page is complete without it. The canvas is hidden from assistive tech and ignores the pointer. On a dense screen it renders at no more than 2× the CSS size, because a 3× phone would shade more than twice the pixels for a difference nobody can see in soft dots.

How a .wgsl file gets into the app

start-globe.ts imports ./globe.wgsl like any module. A Turbopack rule in next.config.ts, wgslTurbopackRule from @vgpu/wgsl/next, compiles and minifies it at build time, and wgsl-env.d.ts gives the import a type. The rendering itself uses vgpu: init, surface, effect and frameLoop.

The decision is in docs/decisions/0010-the-landing-page-is-the-app.md, under The globe.