← Back to library

Backgrounds

Rose Window

Gothic wheel rendered as an ordered dither field — thousands of cells, glass cooling to warm on drag.

2 tools★ 94
#dither#canvas#interactive
CursorClaude

The prompt

Prompt
# Rose Window — Dithered

## What you're building

A drop-in, full-viewport animated hero: a colossal gothic rose window rendered entirely as an ordered-dither field on a single 2D `<canvas>`, sitting behind an editorial serif headline stack. The one idea that makes this piece distinct from its siblings: **the artwork is radial and architectural — thousands of discrete dither cells arranged in polar coordinates, where the stone tracery (rings and mullions) stays absolutely locked while the glass between it wheels and shifts temperature under a drag.** Nothing scrolls. One screen, one canvas, one gesture.

Study No. 1 of a four-part dithered-background series (Rose Window / Falling Ash / Forge Sparks / Cartographic Field). Same paper, same type, same CTA — completely different artwork engine.

## Art direction

**Mood:** cathedral light on cold paper — a letterpress plate of a cathedral, not a photograph of one.

### Palette

| Token | Hex | Use |
|---|---|---|
| `--paper` | `#ECE3CF` | Page ground and canvas clear colour. Fills the whole viewport. |
| `--ink` | `#241A12` | Headline, body copy, CTA fill, base colour of every dither cell. |
| `--ink-cool` | `#22262F` | Cell colour at temperature 0 (glass at rest — blue-black). |
| `--ink-warm` | `#3C2412` | Cell colour at temperature 1 (glass under drag — red-brown). |
| `--ink-mute` | `rgba(36,26,18,0.62)` | Small uppercase label above the headline. |

There is no separate "paper on ink" token — the CTA label is `--paper` on `--ink`, the same hex as the page ground. One token, used twice.

The screen carries exactly two families: the paper, and the ink. `--ink`, `--ink-cool` and `--ink-warm` are one warm/cool axis of the same near-black — not three accents. Do not introduce a hue outside this table: no link blue, no border colour, no shadow, and no one-off hover value invented at the CTA.

### Type

- **Display / headline:** `"Times New Roman", Georgia, serif`, weight `400`, `font-size: clamp(2.75rem, 5vw, 4.5rem)` (72px at a 1440px viewport), `letter-spacing: -0.01em`, `line-height: 0.96`, colour `--ink`. The cap is 4.5rem, not larger: at 72px the longest line measures ≈402px, which fits the 432px copy column with room to spare. A 5.6rem cap overflows the column onto the canvas.
- **The italic word is mandatory.** The headline is three lines with three hard breaks — never rely on natural wrapping — and exactly one word is set in true italic (`font-style: italic`), on its own line, same size and weight, no colour change, no underline, no serif swap:

  ```html
  <h1>Light,<br /><em>wheeling</em><br />through stone.</h1>
  ```

- **Label above the headline:** `ui-monospace, "Courier New", monospace`, `font-size: 0.6875rem` (11px), `text-transform: uppercase`, `letter-spacing: 0.28em`, colour `--ink-mute`.
- **Body:** same serif, `400`, `20px / 30px` (`line-height: 1.5`), `letter-spacing: normal`, colour `--ink`, measure capped at `34ch`. In Times at 20px, `34ch` is **340px**, and the supplied body copy breaks to **four** lines — measured at 286 / 311.6 / 311.6 / 321.6px. If you get five, the measure is set wrong; check it before you touch the copy.
- **CTA label:** the mono face, 11px, uppercase, `letter-spacing: 0.22em`, colour `--paper`.

## Copy

Every string this hero renders, verbatim. There is nothing else on the page — no nav, no footer, no scroll cue, no counter.

| Slot | String |
|---|---|
| Label | `ROSE WINDOW · STUDY NO. 1` |
| H1 line 1 | `Light,` |
| H1 line 2 (`<em>`) | `wheeling` |
| H1 line 3 | `through stone.` |
| Body | `A colossal gothic wheel rendered in ordered dither — thousands of discrete cells, the tracery holding still while the glass turns cool to warm. Drag across it.` |
| CTA label | `VIEW THE STUDY` |
| CTA `href` | `{{study_url}}` — the only placeholder on the page |
| Section `aria-label` | `Rose Window — dithered hero` |

The em dash in the body is a real `—`, not a hyphen. "Drag across it." is load-bearing: it is the only instruction the visitor gets, so do not soften it to "hover" or drop it.

## The artwork (canvas spec)

One `<canvas>`, 2D context, no WebGL, no libraries.

### 1. The grid

- `CELL` starts at `4` CSS px. Field grid is `GW = ceil(w / CELL)`, `GH = ceil(h / CELL)` — about 360 × 270 = 97k cells at 1440×1080. That is too many for a 60fps `fillRect` loop, so **cap the cell count**: while `GW * GH > 26000`, increment `CELL` by 1 and recompute. At 1440×1080 the loop runs 4 → 5 → 6 → 7 (206 × 155 = 31,930, still over) → **8** (180 × 135 = 24,300, under the cap) and stops. `CELL = 8` at 1440×1080. Keep the cap; the cap is what makes it run on a laptop.
- Backing store: `canvas.width = w * DPR`, `canvas.height = h * DPR` with `DPR = Math.min(devicePixelRatio, 2)`, then `ctx.scale(DPR, DPR)` once per resize. Style size stays `100% / 100%`.

### 2. The ordered-dither matrix

Use the standard 8×8 Bayer matrix, normalised to `(v + 0.5) / 64`:

```
 0 32  8 40  2 34 10 42
48 16 56 24 50 18 58 26
12 44  4 36 14 46  6 38
60 28 52 20 62 30 54 22
 3 35 11 43  1 33  9 41
51 19 59 27 49 17 57 25
15 47  7 39 13 45  5 37
63 31 55 23 61 29 53 21
```

For grid cell `(gx, gy)` the threshold is `T = BAYER8[(gy & 7) * 8 + (gx & 7)]`. Given source luminance `L ∈ [0,1]` (1 = fully inked):

- If `L <= T` → draw nothing.
- If `L > T` → draw a square of side `s = CELL * (0.45 + 0.55 * clamp((L - T) / (1 - T), 0, 1))`, centred in the cell, `ctx.fillRect(gx*CELL + (CELL-s)/2, gy*CELL + (CELL-s)/2, s, s)`.

That second rule is what stops the field looking like a 1-bit screenshot: cells don't just switch on, they *grow*, so the gradient between glass and stone reads continuous at arm's length and discrete up close.

### 3. The source field — the rose

Compute `L` per grid cell in polar coordinates about the wheel centre.

This is the **landscape** composition. It applies only when `w >= 768 && w >= h`; portrait viewports use the stacked composition in **Layout** — see the guard below.

```
cx       = w * 0.72
cy       = h * 0.50
colRight = w * 0.061 + Math.min(w * 0.32, 432)      // right edge of the copy column
R        = Math.min(Math.min(w, h) * 0.44, cx - colRight - 40)
```

`cx` is 0.72, not 0.63, and the base `R` is 0.44, not 0.52, for one reason: the copy column occupies the left of the viewport and the wheel must not reach it. At 1440×1080 the wheel's left edge lands at `cx − R = 561.6px` and the copy column ends at 519.8px — a **41.8px** gutter.

**The `Math.min` is the guard, and it is not optional.** The base radius keys off `min(w, h)` while the copy column keys off `w` alone, so the gutter collapses as the viewport approaches square and goes *negative* in portrait. Measured `(cx − R) − colRight` with the unguarded formula: 1440×1080 → **+41.8px**; 1024×768 → **+9.2px**; 768×1024 → **−77.6px**; 1024×1366 → **−103.4px**. The desktop layout applies from 768px, so a portrait tablet dropped the wheel straight onto the headline with no scrim.

Two rules close it, and you need both:

1. **Portrait goes stacked.** Any viewport where `h > w` uses the mobile/stacked composition and its scrim, regardless of width. That is exactly the case where `min(w, h) = w` and the arithmetic above stops meaning anything.
2. **Landscape clamps `R`.** The `cx − colRight − 40` term holds a 40px gutter open at every landscape viewport, including near-square ones. Re-verified: 1440×1080 → clamp inactive (476.96 vs 475.2), `R = 475.2`, gutter **41.8px**, the reference composition unchanged; 1920×1080 → clamp inactive, gutter 358.1px; 1024×768 → clamped, `R` 337.9 → **307.1**, gutter exactly **40px**; 900×880 → clamped, `R` 387.2 → **265.1**, gutter exactly **40px**. The clamp only ever shrinks the wheel, never moves it, so the composition degrades by getting smaller rather than by colliding.

For each cell centre `(px, py)`: `dx = px - cx`, `dy = py - cy`, `r = hypot(dx, dy)`, `u = r / R`, `θ = atan2(dy, dx)`.

**Paper grain, everywhere.** Every cell carries a fixed low-amplitude grain so the empty field is not surgically blank: `grain = (hash(gx, gy) - 0.5) * 0.10`, where `hash` is a cheap deterministic integer hash — **not** `Math.random()`. Recomputing random noise per frame is what makes a dither field shimmer, and shimmer is the failure mode of this whole series.

This is the only algorithm the prompt asks you to invent, so it is supplied. Use it as written; `Math.imul` is required, because plain `*` on these constants overflows the 53-bit mantissa and the result stops being uniform:

```js
function hash(x, y) {
  let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;   // uniform in [0, 1)
}
```

**The grain is added AFTER the gamma, and the amplitude is 0.10.** Both halves matter, and both are easy to get wrong:

- Peak grain is `+0.05`. The three lowest Bayer thresholds are `0.0078`, `0.0234` and `0.0391`, so averaged over all 64 matrix values a `0.05` peak lights **≈1.2%** of cells — about 300 across the 180 × 135 grid at 1440×1080, roughly 165 of them outside the wheel, and about **8** in any 200 × 200px corner. Each is a minimum-size square (`s = 0.45 * CELL`), which is what makes it read as paper tooth rather than as noise.
- Fold the grain in *before* `pow(L, 1.15)` and the gamma eats it: a `0.0175` peak becomes `0.0095`, which clears only the single lowest threshold (`0.0078`) and only in the 1-in-64 cells that carry it. Measured on the same grid: **40** lit cells on the whole field instead of 124, and **zero** in the top-left 200 × 200 corner. The scatter *is* the paper — do not lose it to the gamma.

Then discard early: if `u > 1.06`, set `L = grain` and **skip the three rose layers**. The early-out is most of your frame budget, and it must not skip the grain — the region outside the wheel is precisely where the grain has a job to do.

Three additive layers, inside `u <= 1.06`:

1. **Tracery (static, never rotates).** Concentric stone rings at `RINGS = [0.16, 0.31, 0.49, 0.68, 0.86, 0.94]`. For each ring `k`: `ring += exp(-((u - k)^2) / 0.00042)`. Radial mullions: `mull = pow(abs(cos(4 * θ)), 26)`, gated to `u > 0.16`. `abs(cos(4θ))` peaks at both `+1` and `−1`, i.e. eight times per revolution — that is the eight mullions. `cos(8θ)` would give sixteen. `tracery = clamp(ring + mull * 0.9, 0, 1)`.
2. **Glass (rotates and warms).** `petal = 0.5 + 0.5 * cos(16 * θ + spin * (0.35 + 1.6 * u))` — the `u` term makes the rotation shear, so the outer glass lags the inner rosette. `lobe = 0.5 + 0.5 * cos(8 * θ - spin * 0.8)`. `glass = (0.55 * petal + 0.45 * lobe) * smoothstep(0.14, 0.22, u)`.
3. **Vignette.** `fade = 1 - smoothstep(0.98, 1.06, u)`.

The outer ring sits at `0.94` and the vignette does not begin until `0.98`, so the last ring gets `fade = 1.0` — full ink, identical to every other ring. Get this wrong (ring at `1.00` against a `smoothstep(0.88, 1.06)` vignette) and the outermost ring renders at roughly a third the ink of its neighbours: the wheel frays into the paper instead of ending on a hard edge, which is the single most visible failure of the whole piece.

Composite, then gamma, **then** grain:

```
rose = clamp((tracery * 0.92 + glass * 0.58) * fade, 0, 1)
L    = clamp(pow(rose, 1.15) + grain, 0, 1)
```

The `pow` is a gentle gamma that keeps the paper clean inside the wheel. It is applied to the rose only. Outside the wheel `rose = 0`, so `L = grain` and the two paths agree.

### 4. Animation and cursor

Two state values, both damped, both driven from pointer events on the hero:

| State | Target | Damping | Effect |
|---|---|---|---|
| `spin` | `spinTarget += dragDX * 0.0026` while pointer is down | `spin += (spinTarget - spin) * 0.055` per frame | Rotates layer 2 only. Tracery is untouched, so the stone reads as fixed and the glass turns inside it. |
| `warm` | `1` while the pointer is down **and** a `pointermove` fired within the last **120ms**, else `0` | `warm += (target - warm) * 0.045` | Lerps cell colour `--ink-cool → --ink-warm`. |

"Moving" is a time window, not a per-frame delta. Store `lastMoveAt = performance.now()` in the `pointermove` handler and evaluate `warmTarget = (isDown && now - lastMoveAt < 120) ? 1 : 0` once per frame. A per-frame delta test stutters, because pointer events do not arrive on every frame; 120ms is long enough to bridge the gaps and short enough that a held-still finger cools within a quarter second.

`spinTarget` creeps by `+0.00055` per frame — one revolution takes roughly three minutes. **The creep runs on every frame, drag or no drag.** Add it unconditionally at the top of the frame, then add `dragDX * 0.0026` on top of it if the pointer is down; do not gate the creep on "at rest". A drag is therefore creep plus drag, and the creep is four orders of magnitude below a real drag delta, so it is invisible during one and there is no discontinuity on release. It should be almost impossible to catch it moving on its own; you only notice the window has turned if you look away and back.

Cell colour is one `ctx.fillStyle` per frame, not per cell: lerp the two hex values in RGB, `ctx.fillStyle = rgb(...)`, then run the whole cell loop.

### 5. Performance — the precompute

**This is the most important implementation decision in the piece.** Read it before you write the loop, not after you profile it.

The naive reading — recompute the whole source field from scratch every frame — costs about **6ms** for the field plus ~7,300 `fillRect` calls, roughly **9ms** a frame. That misses the 8ms budget, on a desktop, with nothing else on the page. There is no caching trick that saves you, because `spinTarget` creeps every frame even at rest: the field genuinely changes on every single frame and always will. Do not add a "rebuild only when `spin` moved more than `1e-4`" gate — the idle creep is `0.00055`, five and a half times that threshold, so the gate never fires and it is dead code that reads like an optimisation. There is no cached-blit path and no double buffer; an `OffscreenCanvas` saves nothing here because there are no unchanged frames to blit.

What actually works is splitting the field into what depends on `spin` and what does not. **Almost nothing does.** Spin-independent, and therefore precomputed once per resize into packed `Float32Array`s of length `GW * GH` (plus `Uint8Array`s where a byte will do):

| Precomputed on resize | Why it is static |
|---|---|
| `theta16[i] = 16 * θ`, `theta8[i] = 8 * θ` | θ depends only on the cell's position and the wheel centre |
| `shear[i] = 0.35 + 1.6 * u` | function of `u` only |
| `tracery[i]` (rings + mullions, composited and clamped) | explicitly "static, never rotates" |
| `fade[i]` | function of `u` only |
| `gate[i] = smoothstep(0.14, 0.22, u)` | function of `u` only |
| `grain[i]` | deterministic hash of `(gx, gy)` |
| `bayer[i]` (the `(gy & 7) * 8 + (gx & 7)` lookup, resolved) | function of grid indices |
| `ox[i]`, `oy[i]` — cell origins in CSS px | function of grid indices and `CELL` |
| `inside[i]` — the `u <= 1.06` mask | function of `u` only |

That leaves, per cell per frame, **two `cos` calls and one `pow`** — and only for cells inside the wheel. **Branch on `inside[i]`.** It is in the precompute table above and it earns nothing unless the per-frame loop actually skips on it; at 1440×1080 roughly 45% of the grid is inside the wheel, so the branch is most of the saving:

```js
let L;
if (inside[i]) {
  const petal = 0.5 + 0.5 * Math.cos(theta16[i] + spin * shear[i]);
  const lobe  = 0.5 + 0.5 * Math.cos(theta8[i]  - spin * 0.8);
  const rose  = (tracery[i] * 0.92 + (0.55*petal + 0.45*lobe) * gate[i] * 0.58) * fade[i];
  L = Math.pow(rose < 0 ? 0 : rose > 1 ? 1 : rose, 1.15) + grain[i];  // grain AFTER the gamma
  if (L > 1) L = 1; else if (L < 0) L = 0;
} else {
  L = grain[i];                                                       // outside the wheel: paper only
}
```

Measured, that rebuild is **3.03ms** — inside budget with the `fillRect` pass. Skip the precompute and you will not hit 8ms, no matter how you arrange the drawing.

- Single rAF loop. At the top of each frame, repaint the ground with **`ctx.fillStyle = --paper; ctx.fillRect(0, 0, w, h)`** — one call, and specifically not `clearRect`, which would leave the canvas transparent and let the page behind it show through instead of the paper. Then the cell pass. `cancelAnimationFrame` on unmount.
- `IntersectionObserver` at `threshold: 0` — stop the loop when the hero leaves the viewport. `document.visibilitychange` — stop when the tab is hidden. Resume both on re-entry, and reset the frame clock so nothing jumps.

## Layout

Single `100vh` section — `100dvh` wherever the stacked composition applies, so a portrait tablet gets the dynamic unit too — `position: relative`, `overflow: hidden`, background `--paper`, `touch-action: pan-y`.

- **Canvas:** `position: absolute; inset: 0; width: 100%; height: 100%;` `z-index: 0`, `pointer-events: none` (listen on the section, not the canvas), `aria-hidden="true"`.
Three stacking levels, and only three: canvas `z-index: 0`, the stacked-composition scrim `z-index: 1`, copy column `z-index: 2`. Nothing else is positioned.

- **Copy column:** `z-index: 2`, absolutely positioned, `left: 6.1vw` (88px at 1440), vertically centred (`top: 50%; transform: translateY(-50%)`), `width: min(32vw, 27rem)` — 432px at 1440, so the column's right edge is at 519.8px and the wheel's left edge at 561.6px. **Nothing in the column may cross that line.** The landscape composition has no scrim; the separation is the whole reason the wheel is off-centre. If you change `width`, the headline clamp, or `cx`/`R`, recheck the arithmetic at 1024, 1440 and 1920 before shipping — the `R` clamp in §3 holds a 40px gutter open, but it holds it by shrinking the wheel, so a widened column is paid for in artwork.
- **Vertical rhythm inside the column:** label → 18px → h1 → 30px → body → 34px → CTA.
- **CTA:** hard-edged rectangle, `border-radius: 0`, background `--ink`, `padding: 14px 26px`; the label sets the width — `VIEW THE STUDY` at 11px / `0.22em` lands it at roughly 180 × 44px. No shadow, no border, no pill. Hover: background swaps to `--ink-warm` (`#3C2412`) over `140ms ease` — a token already in the palette, not a new value invented at the button. Focus: `outline: 2px solid var(--ink); outline-offset: 3px`.
- **Stacked composition — `@media (max-width: 767px), (orientation: portrait)`.** It is not a phone breakpoint: it is every viewport where the landscape gutter arithmetic in §3 stops holding, which is anything narrow *or* anything taller than it is wide. Column goes `left: 24px; right: 24px; width: auto`, and the block sits at `bottom: 8vh` instead of centred. Move the wheel centre to `cx = w * 0.5, cy = h * 0.34` and `R = min(w, h) * 0.62` (no gutter clamp here — there is no gutter). Here the wheel and the copy *do* overlap, so lay a scrim behind the text — a `position: absolute; inset: 0; z-index: 1; pointer-events: none` layer filled with `linear-gradient(to top, var(--paper) 34%, transparent 78%)` — and the serif never fights a dense cell field. Headline bottoms out at `2.75rem`.

## Motion & accessibility

- Pointer: `pointerdown` / `pointermove` / `pointerup` / `pointercancel` on the section. Track `lastX` to derive `dragDX`. Touch works unchanged — do not add a separate touch path.
- **Vertical scrolling on mobile is preserved by `touch-action: pan-y` on the section, not by `preventDefault`.** Touch `pointermove` listeners are passive by default in Chrome, so `preventDefault()` inside one is ignored and logs a console warning; it does nothing for you either way. `touch-action: pan-y` is the declaration that actually delivers the behaviour: the browser keeps vertical panning and hands you horizontal movement.
- On `pointerdown` call `el.setPointerCapture(e.pointerId)`; on `pointerup` / `pointercancel` call `releasePointerCapture` and clear the drag flag. Without capture, a drag that ends outside the hero never delivers `pointerup`, the flag stays set, and the wheel keeps spinning and staying warm under a released finger.
- The gesture is a **drag**, not a hover: moving the cursor across the hero with no button down changes nothing. That is deliberate and it is what the body copy promises ("Drag across it.").
- **`prefers-reduced-motion: reduce` is mandatory.** Read it with `matchMedia`. When it matches: set `spin = 0.42` and `warm = 0`, build the field exactly once, draw it, and **never call `requestAnimationFrame` at all**. Do not start a loop and skip work inside it — the loop must not exist. Attach no pointer listeners in this mode: pointer listeners are attached by mode init (see the `ready` flag in Build notes), and mode init skips them under `reduce`. The `ResizeObserver` and the `matchMedia` subscription are attached unconditionally in both modes. Subscribe to the media query's `change` event so a user flipping the setting mid-session gets the right build — on change, tear down the current mode (cancel the rAF, remove pointer listeners) and re-run mode init.
- The canvas is decorative: `aria-hidden="true"`, no `role`, no alt text. The headline is real text in an `<h1>` with the emphasis word in `<em>` — it must be selectable, searchable, and translatable.
- CTA is a real `<a>` or `<button>` with a visible focus ring. Colour contrast: `--ink` on `--paper` is ~13:1; keep it.

## Build notes

- React function component + Tailwind for layout; one `useRef<HTMLCanvasElement>` and one `useEffect` that owns the entire canvas lifecycle. All engine state lives in refs or in closure variables inside the effect — never in `useState`, which would re-render on every frame.
- Effect cleanup must: `cancelAnimationFrame(rafId)`, disconnect the `IntersectionObserver`, remove all pointer listeners, remove the `visibilitychange` listener, and remove the `matchMedia` change listener.
- Resize: `ResizeObserver` on the section, debounced 120ms with `setTimeout`. On fire, recompute `DPR`, canvas backing size, `CELL` cap, grid dimensions, `cx/cy/R`, **rebuild every precomputed array from §5**, then draw one frame. Clear the pending timeout in cleanup.
- Zero external libraries. No `three`, no `gsap`, no noise package. It is all arithmetic — but budget for the precompute in §5, which is most of the code.
- **Zero-size guard, and how to recover from it.** A zero-size container on first paint happens inside flex parents. Do *not* early-return out of setup: if setup returns, no listeners and no `ResizeObserver` are ever attached and the canvas stays blank forever. Instead, attach the `ResizeObserver` and the `matchMedia` subscription unconditionally, and keep a `ready` boolean that starts `false`. A `layout()` function does `if (w === 0 || h === 0) { ready = false; return; }`, otherwise sizes the canvas, rebuilds the precomputed arrays, sets `ready = true`, and — on the first transition to `true` — runs mode init: under reduced motion, draw the single frame; otherwise attach the pointer listeners and start the rAF loop. The frame callback returns immediately while `ready` is `false`. Call `layout()` once at setup and from every `ResizeObserver` fire.

## Acceptance checks

- [ ] At rest the field is completely stable — no cell flickers, no grain crawl. Screenshot two frames 5s apart with the pointer untouched and diff them: only the slow `spin` creep should differ. Note that the field *is* rebuilt every frame; stability comes from the grain being a deterministic hash, not from skipping work.
- [ ] Counting the radial mullions in a screenshot gives exactly **8**, matching `pow(abs(cos(4θ)), 26)`.
- [ ] The concentric rings and the mullions do **not** move during a drag; only the glass between them turns.
- [ ] The outermost ring is as dark as the inner rings — the wheel ends on a hard circular edge, not a fray.
- [ ] Outside the wheel the paper is not blank. Count the lit cells in the top-left 200 × 200px of a 1440×1080 render: at `CELL = 8` that region holds 625 cells and **roughly 8** should be inked — anything from about 4 to 14 is the expected spread, and **zero is the failure**. Zero means the grain went in before `pow(L, 1.15)` and the gamma crushed it; the whole-field count in that case is around 40 lit cells instead of ~300, all of them clustered on the 1-in-64 cells carrying the lowest Bayer threshold.
- [ ] Dragging left-to-right across the hero spins the glass and shifts cell colour from `#22262F` toward `#3C2412` within ~1 second, and it eases back to cool within ~2 seconds of release.
- [ ] Hovering without pressing changes nothing at all.
- [ ] With `prefers-reduced-motion: reduce`, the page renders one composed frame with the wheel clearly legible, and `requestAnimationFrame` is never called (verify by breakpointing or stubbing it).
- [ ] The grid never exceeds 26,000 cells at any viewport from 320px to 2560px wide; frame time stays under 8ms on a mid laptop while dragging, with the field rebuild itself around 3ms. If the rebuild is nearer 6ms, the §5 precompute is missing.
- [ ] In landscape the wheel's left edge and the copy column's right edge never touch — measure `(cx − R) − colRight` at 1024×768, 1440×1080 and 1920×1080. Expect **≥ 40px** at every one: 40.0 (clamped), 41.8 (clamp inactive) and 358.1. The headline sits entirely on clean paper with no scrim.
- [ ] Portrait viewports render the stacked composition, not the landscape one. Check 768×1024 and 1024×1366 specifically — both were collisions before the guard existed (−77.6px and −103.4px of overlap). The wheel should be centred above the copy with the scrim under the text, exactly as on a phone.
- [ ] The body copy sets to **four** lines at the `34ch` measure, not three and not five.
- [ ] A vertical swipe starting on the hero scrolls the page on a touch device; a horizontal drag spins the wheel and does not scroll. No passive-listener warning appears in the console.
- [ ] Starting a drag on the hero and releasing outside the window leaves the wheel cooling and settling, not stuck spinning.
- [ ] Resizing from 1440×1080 to 375×812 re-centres the wheel to the stacked composition with no stretched or stale pixels, and the scrim keeps the serif legible over the field. Rotating a tablet from 1024×768 to 768×1024 does the same thing at the orientation flip.
- [ ] Mounting the hero inside a zero-height flex parent and then giving it height produces a correct render — the canvas recovers rather than staying blank.

More like this