# MASTER PROMPT — Wilder (WordPress / UiChemy edition)
## ▶ HOW TO USE THIS PROMPT
This is the **WordPress build** of Wilder, live at
`https://demo.uichemy.com/wilder-a-field-studio-for-living-systems/` (page id **351**, one
Composer widget **`qwxotrx`** labelled "Wilder Field Studio", published).
It is the companion to `WILDERMASTERPROMPT.md`, which stays the authority for the **standalone
three-file page**. This document carries every WordPress-specific block verbatim and the six
rules that the port actually broke on.
**INSTRUCTION TO CLAUDE:** build through the `uichemy-demo-wordpress-uichemy-mcp` server and put
each block in the location its heading names — **not** all into the section. The section's `js`
field stays EMPTY on purpose; read RULE 1 before doubting that.
---
## OVERVIEW
- **Stack:** vanilla HTML + CSS + JS. No framework, no bundler, no dependencies.
- **Engine:** seven `.stage` wrappers, each holding one sticky `.stage__pin` one viewport tall.
A single rAF loop writes `--p` (0→1) per stage; **every** animated property is a `calc()` that
reads it. `--p` is registered with `@property` as a `<number>`.
- **Fonts:** Bricolage Grotesque (display) + Inter (body).
- **Media:** five photographs, one poster and two VP9/WebM videos — all in the WP media library.
## ASSET MANIFEST (WordPress media library)
| Asset | URL |
|---|---|
| `7246228-hd_1920_1080_24fps.webm` | `https://demo.uichemy.com/wp-content/uploads/2026/09/7246228-hd_1920_1080_24fps.webm` |
| `9328701-uhd_3840_2160_30fps.webm` | `https://demo.uichemy.com/wp-content/uploads/2026/09/9328701-uhd_3840_2160_30fps.webm` |
| `heavy-rain-mangrove-rain-rain-in-forest-9328701.jpeg` | `https://demo.uichemy.com/wp-content/uploads/2026/09/heavy-rain-mangrove-rain-rain-in-forest-9328701.jpeg` |
| `pexels-photo-23709191.jpeg` | `https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg` |
| `pexels-photo-29275539.jpeg` | `https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-29275539.jpeg` |
| `pexels-photo-38418528.jpeg` | `https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-38418528.jpeg` |
| `pexels-photo-39040835.jpeg` | `https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg` |
| `pexels-photo-6186893.jpeg` | `https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-6186893.jpeg` |
All eight were sideloaded from their Pexels / jsDelivr originals by the page writer
(`upload_images: true`). Photograph licences (CC0 / Pexels) are listed in `WILDERMASTERPROMPT.md`.
## ⚠ RULE 1 — the UiChemy editor is lossy, so the engine lives outside the section
Saving the page from the UiChemy/Elementor editor re-serialises the section and **silently
deletes** what it does not model: `<script>`, `<canvas>`, `<svg>`, and `<link>` in page head code.
| Piece | Lives in | Survives an editor save |
|---|---|---|
| Seven stages of markup + the whole stylesheet | the Composer section (`html` + `css`) | ✅ |
| The rAF scroll engine | code file `wilder-app.js` | ✅ |
| Loader, page classes, motion hatch | code file `wilder.js`, from **site-wide** body code | ✅ |
| Fonts + the `html`/`body` rules | **site-wide** head code | ✅ |
| The logo `<svg>` in the header | the section | ❌ — an editor save loses it |
## ⚠ RULE 2 — `overflow-x: hidden` must NOT move onto the section wrapper
The source sets it on `body`. Moving it onto `.uichemy-wilder-1` computes `overflow-y` to `auto`,
which makes the wrapper **the scroll container** — and a `position: sticky` pin inside a box that
never scrolls simply stops pinning. Every stage dies.
The cruel part: `--p` still computes correctly from `getBoundingClientRect`, so the numbers all
look right and only the pixels are wrong. Keep the clip on `body` (block B1).
## ⚠ RULE 3 — UiChemy's CSS formatter corrupts comments INSIDE a rule block
A comment between rules survives. A comment **inside** a declaration block comes back with its
opening `/*` stripped, leaving prose and a stray `*/` among the declarations — and the rules
around it are discarded.
Here it ate the `.plate` sizing inside a mobile media query: desktop looked perfect and phones
rendered the plate at its intrinsic size. **Strip in-block comments before saving** (29 of this
file's 96). A local harness that strips comments before prefixing will NOT reproduce this — diff
the rendered widget CSS, not your input.
## ⚠ RULE 4 — mount runtime overlays INSIDE the wrapper, never on `document.body`
Section CSS is prefixed with `.elementor-element-<id>`, so anything the JS appends to
`document.body` lands outside that scope and gets **none** of the section's styling. The archive
"boom" overlay did exactly that: the photograph rendered at natural size (1800×1286) and burst out
of an unstyled panel. One-line fix in `wilder-app.js`:
```js
;(wrap || document.body).appendChild(node)
```
The wrapper creates no containing block, so the `position: fixed` overlay still covers the viewport.
## ⚠ RULE 5 — Cloudflare caches the code files for 30 days, imports included
`/wp-content/uichemy-composer/*.js` carries `cache-control: max-age=2592000`. Versioning the
`<script src>` is **not enough**: `wilder.js` `import()`s `wilder-app.js` at its own URL, which is
cached too. Bump the version in BOTH places (B2 and the `APP` constant in B3), then verify with
`curl 'https://…/wilder-app.js?x=1' | grep <your change>`.
## ⚠ RULE 6 — `upload_images` does not rewrite `data-*` attributes
`src`, `srcset`, `poster` and `url()` are re-homed automatically. The archive's
`data-src="…"` attributes — which the JS swaps in at runtime — are not: nine of them still pointed
at Pexels after the import and had to be repointed to the media library by hand.
## ⚠ RULE 7 — RULE 3 also eats the FIRST SELECTOR of a reduced-motion block
RULE 3 describes comments. The same formatter does something quieter and worse to selectors: it
inserts a space after a colon in the **first rule of a media block**, so
```
.uichemy-wilder-1:not([data-motion='on']) -> .uichemy-wilder-1: not([data-motion='on'])
```
`: not(...)` is not a valid selector, and an invalid selector invalidates the **entire rule** —
including every other selector in a comma-separated group. This build shipped with **four** of
them, one at the head of each `@media (prefers-reduced-motion: reduce)` block, which meant the
whole reduced-motion fallback was dead on the live page: the stages kept their 220-420vh heights
and the pins kept pinning for a visitor who had asked for no motion.
It is invisible in a text readback and silent in the console. Two checks catch it:
```js
// 1. no mangled selector survived into the parsed stylesheet
[...sheet.cssRules].filter(r => /: (not|hover|focus)\(/.test(r.selectorText || '')).length === 0
// 2. the fallback actually applies
matchMedia('(prefers-reduced-motion: reduce)') -> getComputedStyle(stage).height === '0px'
```
**The fix is the same guard Vela uses.** Open every media block with a no-op rule so the
mangling has something harmless to land on:
```css
@media (prefers-reduced-motion: reduce) {
.uichemy-wilder-1 { --wl-guard: 0; } /* absorbs the first-rule mangle */
.uichemy-wilder-1:not([data-motion='on']) { … }
}
```
Verified after the fix: 0 mangled selectors in the CSSOM, and under `reduce` the stage computes
`height: 0px` with its pin at `position: relative`, while the normal path is untouched
(3544.8px, `sticky`).
## WHAT DIFFERS FROM THE STANDALONE SOURCE
Verified live against the source at **1920 / 1440 / 1024 / 768 / 390**: document height, every
stage's `--p`, the sticky pin, plate, deck, rail, monitor page, cards and reveal counts are
**identical at six scroll positions**, and the opened archive panel matches for all three rows
(panel 1297×748, image 1295×746, `object-fit: cover`). 16/16 images and both videos load from
demo.uichemy.com. Zero console errors.
1. **One wrapper**: `<div class="uichemy-wilder-1" id="top">` (the source's `<body id="top">`
carried that id, and the logo links to `#top`).
2. **`:root` → the wrapper.** Prefixing would turn `:root` into `.elementor-element-x :root`,
which matches nothing and kills all five colour tokens, both font tokens and `--vh`.
The `@supports (height: 1dvh)` override moves with it — lose it and every pin hides its last
~100px behind a phone's URL bar.
3. **`html` / `body` split**: type, colour and smoothing onto the wrapper; scroll-behaviour,
`scrollbar-gutter`, the `is-locked` scroll lock, the page background and `overflow-x` into
site-wide CSS keyed off classes `wilder.js` adds.
4. **`html:not([data-motion='on'])` → `.uichemy-wilder-1:not([data-motion='on'])`** (27 rules) and
the `?motion=1` hatch now sets the attribute on the wrapper, not `<html>`.
5. **`body.has-cursor` → the wrapper**, and `wilder-app.js` toggles the class there.
6. **All ten `@keyframes` renamed** `wilder-*` — keyframe names are global no matter how the
selectors are scoped, and `caret` / `ripple` would collide with almost any theme.
7. **`--intro` → `--wilder-intro`** (published on `:root` by the JS every frame; too generic a name
to leave alone), in both CSS and JS.
8. **`wilder-app.js`** is app.js verbatim apart from: the `wrap` lookup, null guards on
`railbar` / `cursor` (so a partially rendered editor preview cannot throw), the has-cursor
target, the `--wilder-intro` rename and the RULE 4 `appendChild`.
9. **Section `js` empty** (RULE 1).
10. **In-block CSS comments removed** (RULE 3). The fully commented stylesheet stays in
`WILDERMASTERPROMPT.md`.
11. **The collision-prone class names carry a `wl-` prefix**: `stage`, `stage__pin`,
`stage--*`, `row`, `row__*` and `btn` became `wl-stage`, `wl-stage__pin`, `wl-stage--*`,
`wl-row`, `wl-row__*` and `wl-btn`. Everything else (`plate`, `records`, `set`, `grain`,
`cap`, `deck`) kept its own name, because nothing else on this site claims those. `.row`
and `.btn` in particular are names almost every theme defines, and `wilder-app.js` selects
`.wl-stage` accordingly.
12. **Every `prefers-reduced-motion` block opens with a `--wl-guard` no-op rule** (RULE 7).
13. **The mobile veil is capped at 0.3.** A phone gets the plate full bleed with the copy over
it, so the source veils it at `--veil-bleed: 0.5` — but the copy has lifted away by `--p`
0.5, and what is left for that stretch of scroll is a photograph behind half a screen of
cream. `min(var(--veil-bleed, 0.5), 0.3)` keeps the variable as the author's intent and
puts a ceiling under it. Desktop is untouched (0.05).
---
# APPENDIX B · THE WORDPRESS BUILD
## Build steps (uichemy-demo-wordpress-uichemy-mcp)
1. `uichemy-composer/read-skill` `{ "name": "code-to-wordpress" }`, then
`uichemy-composer/describe-site` — `platform.checks.elementor_active` must be true.
2. **B3 + B4 → code files.** `code-file` `request-upload`, then PUT both files from disk in one
curl loop. Never read them through the conversation.
3. **B1 + B2 → site-wide code.** `platform` `update-site-code` (append, deduped).
4. **B5 + B6 → the page.** `page` `create` with `title`, `status: "draft"`,
`label: "Wilder Field Studio"`, `upload_images: true`, `html` = B5, `css` = B6, `js` = `""`.
B5 already carries media-library URLs; if you build from the original source instead, expect
RULE 6 and repoint the nine `data-src` attributes afterwards.
5. **Read back**: `page` `grep` for `<svg` and `data-src`, diff the rendered widget CSS for RULE 3
damage, then load the page and check `document.querySelectorAll('.wl-stage').length` is 7, each
pin is `position: sticky`, and opening an archive row gives a fitted panel.
Payloads this size (≈80KB of html+css) go through the JSON-RPC endpoint from a script with basic
auth, so the bytes travel disk → server without passing through the conversation.
## B1 · site-wide `site_before_head`
```html
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,
[email protected],300;12..96,400;12..96,500;12..96,700&family=Inter:wght@300;400;500&display=swap" rel="stylesheet" />
<style>
/* WILDER: the html/body half of the source's page-level rules. The classes are
added by wilder.js only on a page that contains the section, so nothing else
on the site is touched. overflow-x MUST live here and not on the section
wrapper: on the wrapper it would make that box the scroll container and every
sticky stage pin would stop pinning. */
html.wilder-page{scrollbar-gutter:stable}
html.is-locked{overflow:hidden}
body.wilder-page{margin:0;overflow-x:hidden;background:rgb(244 241 234)}
</style>
```
## B2 · site-wide `site_before_body`
```html
<script src="/wp-content/uichemy-composer/wilder.js?v=2"></script>
```
## B3 · code file `wilder.js` → `/wp-content/uichemy-composer/wilder.js`
```js
/* WILDER — loader.
*
* Loaded site-wide (before </body>) but does nothing unless the page contains the
* section, so no other page pays for it. It carries the four things the section
* itself cannot: the <html>/<body> classes the page-level rules hang off, the
* ?motion=1 hatch, the document scroll-behavior, and a JS re-registration of
* --p in case any CSS pipeline eats the @property at-rule.
*/
(function () {
'use strict';
var ROOT = '.uichemy-wilder-1';
/* The ?v= is not decoration: Cloudflare caches /wp-content/uichemy-composer/*
for 30 days, so a changed app file only reaches visitors under a new URL.
Bump it here AND on this file's own <script src> whenever either changes. */
var APP = '/wp-content/uichemy-composer/wilder-app.js?v=2';
function start() {
var root = document.querySelector(ROOT);
if (!root || root.getAttribute('data-wilder-ready')) return;
root.setAttribute('data-wilder-ready', '1');
document.documentElement.classList.add('wilder-page');
document.body.classList.add('wilder-page');
/* The source sets this on <html> in a blocking head script; the CSS that
reads it is now scoped to the section, so the flag moves onto the wrapper. */
var motionOn = new URLSearchParams(location.search).get('motion') === '1';
if (motionOn) root.setAttribute('data-motion', 'on');
/* The source page scrolls smoothly, and instantly under reduced motion unless
the hatch is set. A section cannot style <html>, and Elementor forces
smooth on every page of the site, so decide it here. */
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.style.scrollBehavior = (reduce && !motionOn) ? 'auto' : 'smooth';
/* Belt and braces: --p is registered as a typed <number> in the section CSS.
If anything ever strips that at-rule, elements would paint their first
frame with --p unset instead of 0. Re-registering here is harmless when
the CSS registration survived (it throws InvalidModification, caught). */
try {
if (window.CSS && CSS.registerProperty) {
CSS.registerProperty({ name: '--p', syntax: '<number>', initialValue: '0', inherits: true });
}
} catch (e) {}
import(APP).catch(function (e) {
if (window.console) console.error('WILDER app failed to load:', e);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
})();
```
## B4 · code file `wilder-app.js` → `/wp-content/uichemy-composer/wilder-app.js`
```js
/* WILDER — the scroll engine, verbatim from the single-file source except for
the WordPress scoping noted in the master prompt: the has-cursor class and the
data-motion flag move onto the section wrapper, --intro is namespaced, and the
railbar/cursor lookups are null-guarded so a partially rendered editor preview
cannot throw. Loaded only by wilder.js, only where the section exists. */
/* ==================================================================
Wilder — every behaviour on the page, in one module.
Nothing here is a framework. The page is already in the HTML; this
file adds the six things markup cannot express on its own: the
scroll number, the boxes that have to be measured, the words that
have to be split, the cursor, the typing, and the blast.
Media that is not on the page but is kept as a documented swap:
jungleHd https://videos.pexels.com/video-files/9328701/9328701-hd_1920_1080_30fps.mp4
lavaVideo https://videos.pexels.com/video-files/39145389/16657776_1920_1080_30fps.mp4
heroFilm https://videos.pexels.com/video-files/39286980/16717041_540_960_30fps.mp4
source https://www.pexels.com/photo/<6186893|29275539|23709191|39040835|38418528>/
None of them is referenced. That is deliberate: it is unused code,
and the correct reproduction leaves it unused.
================================================================== */
const root = document.documentElement
const desktop = window.matchMedia('(min-width: 768px)')
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v)
/* ==================================================================
One scroll listener for the whole page.
Every stage is a tall wrapper holding one `position: sticky` child.
Each frame this writes a normalised `--p` (0 → 1) onto the wrapper
describing how far through its own travel you are; every animated
property downstream is a calc() reading that number. Nothing here
sets a transform, so there is one place that touches the DOM per
frame and one number to reason about.
================================================================== */
const stages = [...document.querySelectorAll('.wl-stage')].map((el) => ({ el, last: -1 }))
function flushStages() {
const vh = window.innerHeight
for (const s of stages) {
const rect = s.el.getBoundingClientRect()
// Off-screen stages cannot be visible, so skip the write entirely.
if (rect.bottom < -vh * 0.5 || rect.top > vh * 1.5) continue
const travel = s.el.offsetHeight - vh
const p = travel > 0 ? clamp01(-rect.top / travel) : rect.top <= 0 ? 1 : 0
// Sub-pixel churn would rewrite the variable every frame for no visible
// gain, and each write invalidates style for the whole subtree.
if (Math.abs(p - s.last) < 0.0004) continue
s.last = p
s.el.style.setProperty('--p', p.toFixed(4))
// The page opens inside a monitor, and the fixed chrome is not inside it.
// Republishing the intro stage's own --p on the root is what lets the
// header know to stay out of the way until the page is life-size — it is
// a sibling of the stages, so it can inherit nothing from them.
if (s.el.dataset.intro !== undefined) {
root.style.setProperty('--wilder-intro', p.toFixed(4))
}
}
}
/* The progress hairline. A spring is a lerp with a memory, and at this
scale the two are indistinguishable — so the easing is one line in
the frame loop rather than a physics library. */
const wrap = document.querySelector('.uichemy-wilder-1')
const railbar = document.querySelector('.railbar')
let railAt = 0
function flushRail() {
if (!railbar) return
const max = root.scrollHeight - window.innerHeight
const target = max > 0 ? clamp01(window.scrollY / max) : 0
railAt += (target - railAt) * 0.12
railbar.style.transform = `scaleX(${railAt.toFixed(4)})`
}
/** Holds the page still while something is open over it. `scrollbar-gutter`
is reserved in the stylesheet, so this cannot shift the layout sideways. */
function setScrollLocked(locked) {
root.classList.toggle('is-locked', locked)
}
function scrollToY(y) {
window.scrollTo({ top: y, behavior: 'smooth' })
}
/* ==================================================================
A plate's box, published as pixels.
Growing a frame is done on the box, not with a transform: scaling an
image 3.85x rasterises it at the small size and stretches that
bitmap, so it arrives soft. This hands CSS both ends of the move.
Measured from the viewport, never off the DOM — by the time you
could measure the plate it already carries a transform, and
getBoundingClientRect would return the transformed box.
================================================================== */
/** Room the fixed header needs above the grown frame, and air below it. */
const GROW_TOP = 96
const GROW_BOTTOM = 56
/** Side inset of the grown frame, as a fraction of the viewport. */
const GROW_SIDE = 0.045
const covers = [...document.querySelectorAll('[data-cover]')]
function applyCovers() {
const vw = window.innerWidth
const vh = window.innerHeight
for (const el of covers) {
const o = JSON.parse(el.dataset.cover)
const inset = o.inset ?? 0.06
const fullBleed = o.bleed === 'mobile' ? !desktop.matches : Boolean(o.bleed)
let w, h, left, top
if (fullBleed) {
w = vw
h = vh
left = 0
top = 0
} else {
w = vw * o.width
h = w * o.ratio
left =
o.align === 'right' ? vw - vw * inset - w : o.align === 'left' ? vw * inset : (vw - w) / 2
top = (vh - h) / 2
}
const set = (k, v) => el.style.setProperty(k, String(Math.round(v * 100) / 100))
set('--cw', w)
set('--ch', h)
set('--cl', left)
set('--ct', top)
if (!o.grow) continue
// A phone is already full bleed, so there is nothing to grow into and
// both ends of the move are the same box.
set('--gl', fullBleed ? left : vw * GROW_SIDE)
set('--gt', fullBleed ? top : GROW_TOP)
set('--gw', fullBleed ? w : vw - vw * GROW_SIDE * 2)
set('--gh', fullBleed ? h : Math.max(240, vh - GROW_TOP - GROW_BOTTOM))
}
}
/* ==================================================================
Headlines that come apart.
Each word is handed a vector, a rotation and its own slice of the
line's window as CSS variables. Nothing here animates — it publishes
constants and `.scatter__w` does the move.
Every vector points downward. Letting words fly in all directions
reads as noise once the line wraps: they cross into the line below
and you get two words legibly stacked on top of each other.
================================================================== */
for (const line of document.querySelectorAll('.scatter')) {
const words = line.textContent.trim().split(/\s+/)
const n = words.length
line.textContent = ''
words.forEach((word, i) => {
const angle = (i * 137.5 * Math.PI) / 180
const w0 = (i / n) * 0.55
const span = document.createElement('span')
span.className = 'scatter__w'
span.textContent = word
span.appendChild(document.createElement('span')).innerHTML = ' '
span.style.setProperty('--w0', w0.toFixed(3))
span.style.setProperty('--w1', (w0 + 0.45).toFixed(3))
span.style.setProperty('--wx', Math.round(Math.cos(angle) * 230))
span.style.setProperty('--wy', Math.round(70 + (i % 3) * 55 + Math.abs(Math.sin(angle)) * 90))
span.style.setProperty('--wr', (i % 2 ? 1 : -1) * 8)
line.appendChild(span)
})
}
/* ==================================================================
One-shot reveals, for small copy blocks only. Deliberately not
scroll-scrubbed: scrubbing a paragraph's opacity against the
scrollbar makes text flicker on the smallest wheel nudge.
================================================================== */
const reveals = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
entry.target.classList.add('is-in')
reveals.unobserve(entry.target)
}
},
{ threshold: 0.25 }
)
document.querySelectorAll('.rv').forEach((el) => reveals.observe(el))
/* ==================================================================
Types a line out, holds it, clears it, moves to the next.
Deliberately not scrubbed either: tying characters to the scrollbar
un-types the sentence whenever someone nudges the wheel upward. It
starts on intersection and then runs on its own clock — and stops
once off screen, so a page left open in a background tab is not
still ticking.
================================================================== */
function typewriter(host, lines, speed = 45, hold = 1600) {
const text = document.createTextNode('')
const caret = document.createElement('span')
caret.className = 'type__caret'
host.append(text, caret)
let line = 0
let char = 0
let erasing = false
let timer
const tick = () => {
const full = lines[line]
if (!erasing) {
char += 1
text.data = full.slice(0, char)
if (char >= full.length) {
erasing = true
timer = setTimeout(tick, hold)
return
}
timer = setTimeout(tick, speed)
} else {
// Erases two characters a step, and faster, so the clearing never
// reads as a second sentence being typed backwards.
char -= 2
if (char <= 0) {
char = 0
erasing = false
line = (line + 1) % lines.length
text.data = ''
timer = setTimeout(tick, 260)
return
}
text.data = full.slice(0, char)
timer = setTimeout(tick, speed / 2.5)
}
}
new IntersectionObserver(
([e]) => {
clearTimeout(timer)
if (e.isIntersecting) timer = setTimeout(tick, 300)
},
{ threshold: 0.4 }
).observe(host)
}
document
.querySelectorAll('[data-typewriter]')
.forEach((el) => typewriter(el, JSON.parse(el.dataset.typewriter)))
/* ==================================================================
A cursor that trails the pointer and swells into a labelled disc
over anything carrying data-cursor="…". Fine pointers only — it
never runs on touch, where there is no cursor to replace.
================================================================== */
const cursor = document.querySelector('.cursor')
const cursorLabel = cursor && cursor.querySelector('.cursor__label')
const pointer = { x: -200, y: -200, ax: -200, ay: -200 }
let cursorOn = false
if (cursor && window.matchMedia('(pointer: fine)').matches) {
cursorOn = true
cursor.hidden = false
/* the cursor-hiding rule is scoped to the section, not <body> */
wrap.classList.add('has-cursor')
addEventListener('pointermove', (e) => {
pointer.x = e.clientX
pointer.y = e.clientY
const hit = e.target.closest?.('[data-cursor]')
const label = hit ? hit.getAttribute('data-cursor') : null
if (label !== cursorLabel.textContent) cursorLabel.textContent = label ?? ''
cursor.classList.toggle('is-labelled', Boolean(label))
})
addEventListener('pointerdown', () => cursor.classList.add('is-down'))
addEventListener('pointerup', () => cursor.classList.remove('is-down'))
}
function flushCursor() {
if (!cursorOn || !cursor) return
pointer.ax += (pointer.x - pointer.ax) * 0.28
pointer.ay += (pointer.y - pointer.ay) * 0.28
cursor.style.transform = `translate3d(${pointer.ax.toFixed(1)}px, ${pointer.ay.toFixed(1)}px, 0)`
}
/* ==================================================================
Buttons that lean toward the pointer and bloom a ripple from
wherever they were actually clicked, so a press always has a
visible consequence. JS writes two numbers; the lean-back and the
ripple are CSS.
================================================================== */
const MAGNET = 0.32
for (const btn of document.querySelectorAll('.magnetic')) {
btn.addEventListener('pointermove', (e) => {
const r = btn.getBoundingClientRect()
btn.style.setProperty('--mx', `${(e.clientX - (r.left + r.width / 2)) * MAGNET}px`)
btn.style.setProperty('--my', `${(e.clientY - (r.top + r.height / 2)) * MAGNET}px`)
})
btn.addEventListener('pointerleave', () => {
btn.style.setProperty('--mx', '0px')
btn.style.setProperty('--my', '0px')
})
btn.addEventListener('pointerdown', (e) => {
const r = btn.getBoundingClientRect()
const ripple = document.createElement('span')
ripple.className = 'magnetic__ripple'
ripple.style.left = `${e.clientX - r.left}px`
ripple.style.top = `${e.clientY - r.top}px`
ripple.addEventListener('animationend', () => ripple.remove())
btn.appendChild(ripple)
})
}
document.querySelectorAll('[data-scroll-to]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = document.querySelector(btn.dataset.scrollTo)
if (target) scrollToY(target.getBoundingClientRect().top + window.scrollY)
})
})
/* ==================================================================
The boom — what opening something does.
Whatever was clicked is blown open out of the exact pixel that was
hit: a flash, two shockwaves and a scatter of debris off that point,
while the paper behind is clipped open from the same place. Closing
implodes back into it. The origin is published as --bx/--by, which
every layer in the CSS is anchored to.
================================================================== */
/** Debris, worked out once. Angles are spread evenly and then nudged off
the spokes, because a ring of shards on a perfect fan reads as a
diagram rather than as something that came apart. */
const SHARDS = Array.from({ length: 18 }, (_, i) => ({
a: Math.round(i * (360 / 18) + (i % 3) * 9),
d: 130 + (i % 5) * 52,
// Every fourth one is a streak rather than a speck, so the debris has
// two readings instead of a ring of identical dots.
w: i % 4 === 0 ? 40 : 12,
h: i % 4 === 0 ? 5 : 12,
spin: (i % 2 ? 1 : -1) * (120 + (i % 3) * 60),
tone: i % 5 === 0 ? 'amber' : i % 5 === 1 ? 'cyan' : 'ink',
delay: (i % 4) * 26,
}))
/** How long the pointer has to rest on something before it goes off.
Without a dwell, sweeping the pointer down a list of three rows fires
three detonations on the way past. */
const DWELL_MS = 360
/** How long after a close the pointer is ignored. The overlay unmounts out
from under the pointer, and the browser answers that with a fresh
mouseenter on whatever is now beneath it — which is the row that opened
it, which opens it again, for ever. This breaks that loop. */
const COOLDOWN_MS = 800
/** How long a hover-opened frame stays up before it shuts on its own. */
const HOLD_MS = 2000
const CLOSE_MS = 320
let live = false
let dwell
let cooling = false
/** Where a blast should come from: the pointer, or the middle of the thing
that was opened when no pointer was involved. Keyboard activation
reports a click at 0,0, and a burst out of the top-left corner of the
window is a bug you can see from across the room. */
function boomOrigin(el, e) {
const r = el.getBoundingClientRect()
return { x: e?.clientX || r.left + r.width / 2, y: e?.clientY || r.top + r.height / 2 }
}
const d = (el, k) => el.dataset[k] ?? ''
/** One picture filling the frame, labelled across the foot of it. */
function platePayload(el) {
return `
<figure class="boom__figure">
<img src="${d(el, 'src')}" alt="${d(el, 'title')}" style="object-position:${d(el, 'pos')}" />
<figcaption class="boom__cap boom__caption">
<div class="boom__caption-row">
<div>
<span class="mono eyebrow-amber">${d(el, 'eyebrow')}</span>
<h3 class="display boom__title">${d(el, 'title')}</h3>
<p class="boom__sub">${d(el, 'sub')}</p>
</div>
<span class="mono boom__where">${d(el, 'where')}</span>
</div>
</figcaption>
</figure>`
}
/** The plate on one side, everything the field card knows on the other. */
function recordPayload(el) {
const rows = [
['kind', d(el, 'kind')],
['plate', d(el, 'title')],
['frame', 'single, no stitch'],
['archive', 'open'],
]
return `
<div class="record">
<figure class="record__figure">
<img src="${d(el, 'src')}" alt="${d(el, 'title')}" style="object-position:${d(el, 'pos')}" />
</figure>
<div class="boom__cap record__body">
<span class="mono eyebrow-amber">${d(el, 'meta')}</span>
<h3 class="display record__title">${d(el, 'title')}</h3>
<p class="record__note">${d(el, 'note')}</p>
<dl class="record__dl">
${rows
.map(([k, v]) => `<div><dt class="mono">${k}</dt><dd>${v}</dd></div>`)
.join('')}
</dl>
</div>
</div>`
}
function openBoom(trigger, e, via) {
if (live) return
const origin = boomOrigin(trigger, e)
const payload = trigger.dataset.boom === 'record' ? recordPayload : platePayload
const node = document.createElement('div')
node.className = 'boom'
node.style.setProperty('--bx', `${origin.x}px`)
node.style.setProperty('--by', `${origin.y}px`)
node.innerHTML = `
<div class="boom__sheet">
<div class="grain grain--fill"></div>
<div class="boom__panel pane">
${payload(trigger)}
<button type="button" class="mono boom__close" data-cursor="close">close</button>
</div>
</div>
<div class="boom__debris">
<div class="boom__flash"></div>
<div class="boom__ring boom__ring--1"></div>
<div class="boom__ring boom__ring--2"></div>
${SHARDS.map(
(s) =>
`<span class="boom__shard boom__shard--${s.tone}" style="--a:${s.a}deg;--d:${s.d}px;--spin:${s.spin}deg;width:${s.w}px;height:${s.h}px;animation-delay:${s.delay}ms"></span>`
).join('')}
</div>`
/* Into the section, not <body>: the section's CSS is prefixed with the widget
id, so an overlay appended to <body> would land outside that scope and every
.boom__* rule — including the one that fits the photograph to the panel —
would stop matching. The wrapper creates no containing block, so the fixed
overlay still covers the viewport. */
;(wrap || document.body).appendChild(node)
setScrollLocked(true)
const panel = node.querySelector('.boom__panel')
const bound = []
const on = (target, type, fn) => {
target.addEventListener(type, fn)
bound.push(() => target.removeEventListener(type, fn))
}
let closing = false
const dismiss = () => {
if (closing) return
closing = true
node.dataset.closing = 'true'
bound.forEach((off) => off())
setTimeout(() => {
node.remove()
setScrollLocked(false)
live = false
// See COOLDOWN_MS.
cooling = true
setTimeout(() => (cooling = false), COOLDOWN_MS)
}, CLOSE_MS)
}
on(window, 'keydown', (ev) => ev.key === 'Escape' && dismiss())
node.querySelector('.boom__sheet').addEventListener('click', dismiss)
node.querySelector('.boom__close').addEventListener('click', dismiss)
panel.addEventListener('click', (ev) => ev.stopPropagation())
/* How it was opened decides how it goes away. One opened by pointing
shuts itself — after two seconds, or as soon as the pointer leaves the
frame. One opened by a deliberate click was asked for, so it waits to
be dismissed just as deliberately. */
if (via === 'hover') {
const held = setTimeout(dismiss, HOLD_MS)
bound.push(() => clearTimeout(held))
/* The leave test has to be geometric. `mouseleave` looks like the
obvious answer and does not work: the panel appears underneath a
pointer that has not moved, so the browser never records an enter for
it — and an element you never entered is one you can never leave. The
first move has to land inside before the test arms, so a frame opened
from something sitting outside its own edges cannot shut instantly. */
let entered = false
on(window, 'mousemove', (ev) => {
const r = panel.getBoundingClientRect()
const inside =
ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
if (inside) entered = true
else if (entered) dismiss()
})
}
}
const cancelDwell = () => clearTimeout(dwell)
for (const trigger of document.querySelectorAll('[data-boom]')) {
trigger.addEventListener('click', (e) => {
cancelDwell()
openBoom(trigger, e, 'click')
})
trigger.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
openBoom(trigger, null, 'click')
})
if (trigger.dataset.boomHover === undefined) continue
trigger.addEventListener('mouseenter', (e) => {
if (cooling) return
// Read the coordinates now: this fires a third of a second later.
const at = { clientX: e.clientX, clientY: e.clientY }
cancelDwell()
dwell = setTimeout(() => openBoom(trigger, at, 'hover'), DWELL_MS)
})
trigger.addEventListener('mouseleave', cancelDwell)
}
/* ==================================================================
Section wiring — the small pieces of state React used to hold.
================================================================== */
/* The index: pointing at a row brings its plate up beside it, so the
contents page and the pictures answer each other. */
const index = document.querySelector('[data-index]')
if (index) {
const rows = [...index.querySelectorAll('.row')]
const preview = document.querySelector('[data-preview]')
const shots = [...preview.querySelectorAll('img')]
const title = document.querySelector('[data-preview-title]')
const setActive = (i) => {
rows.forEach((r, j) => (r.dataset.live = String(i === j)))
shots.forEach((s, j) => s.classList.toggle('is-on', i === j))
title.textContent = rows[i].dataset.title
}
rows.forEach((row, i) => {
row.addEventListener('mouseenter', () => setActive(i))
// Not on hover-arm: tabbing through the list would otherwise set one
// off on every row on the way past.
row.addEventListener('focus', () => setActive(i))
row.addEventListener('click', () => setActive(i))
})
index.addEventListener('mouseleave', () => {
setActive(0)
cancelDwell()
})
}
/* The deck: take one and the others step back. */
const deck = document.querySelector('[data-deck]')
if (deck) {
const cards = [...deck.querySelectorAll('.deck__card')]
const hint = document.querySelector('[data-deck-hint]')
const focus = (card) => {
deck.classList.toggle('deck--dimmed', Boolean(card))
cards.forEach((c, i) => {
c.classList.toggle('is-focus', c === card)
c.style.zIndex = c === card ? 9 : i + 1
})
hint.textContent = card
? 'the others have stepped back — hold on it to open'
: 'take one — the others step back'
}
cards.forEach((card) => {
card.addEventListener('mouseenter', () => focus(card))
card.addEventListener('focus', () => focus(card))
})
deck.addEventListener('mouseleave', () => focus(null))
}
/* The filter pills. The arrival vectors are reassigned to whatever is
visible, so a filtered grid still fans in from three directions
rather than leaving a gap where a hidden card's vector was. */
const filters = document.querySelector('[data-filters]')
if (filters) {
const grid = document.querySelector('[data-records]')
const cards = [...grid.querySelectorAll('.card')]
filters.addEventListener('click', (e) => {
const btn = e.target.closest('[data-filter]')
if (!btn) return
const kind = btn.dataset.filter
filters.querySelectorAll('[data-filter]').forEach((b) => {
b.setAttribute('aria-pressed', String(b === btn))
})
let shown = 0
for (const card of cards) {
const visible = kind === 'all' || card.dataset.kind === kind
card.hidden = !visible
if (!visible) continue
const i = shown++
card.style.setProperty('--ax', (i - 1) * 26)
card.style.setProperty('--ay', 90 + i * 40)
card.style.setProperty('--ar', (i % 2 ? 1 : -1) * 6)
}
})
}
/* The dispatch form. It has nowhere to post; it acknowledges and stops. */
const signup = document.querySelector('[data-signup]')
if (signup) {
const button = signup.querySelector('button')
const note = signup.parentElement.querySelector('.dispatch__small')
signup.addEventListener('submit', (e) => {
e.preventDefault()
button.textContent = 'On the list'
button.dataset.cursor = 'done'
note.textContent = 'thank you — next one goes out after the northern season'
})
}
/* ==================================================================
Boot: measure, then start the one frame loop.
================================================================== */
function frame() {
flushStages()
flushRail()
flushCursor()
requestAnimationFrame(frame)
}
applyCovers()
flushStages()
requestAnimationFrame(frame)
addEventListener('resize', () => {
applyCovers()
for (const s of stages) s.last = -1
flushStages()
})
desktop.addEventListener('change', applyCovers)
```
## B5 · section `html`
```html
<div class="uichemy-wilder-1" id="top">
<!-- Filled in and moved by app.js on fine pointers only. -->
<div class="cursor" aria-hidden="true" hidden>
<div class="cursor__disc"><span class="mono cursor__label"></span></div>
</div>
<!-- Page-wide progress hairline. -->
<div class="railbar" aria-hidden="true"></div>
<header class="chrome">
<a href="#top" class="logo" data-cursor="top">
<!-- Two crossed arcs make a vesica — the shape a feather, a leaf and a
wing all reduce to — inside a ring for the horizon. Drawn on a 24
grid with a single stroke weight so it holds at 20px. -->
<svg class="logo__mark" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="10.4" stroke-opacity="0.35"></circle>
<path d="M3.6 16.2C8.4 16.2 12 12.6 12 3.4"></path>
<path d="M20.4 7.8C15.6 7.8 12 11.4 12 20.6"></path>
<circle cx="12" cy="12" r="1.35" fill="currentColor" stroke="none"></circle>
</svg>
<span class="logo__text">
<span class="display logo__name">Wilder</span>
<span class="mono logo__sub">field studio</span>
</span>
</a>
<a href="#archive" data-cursor="open" class="uline mono chrome__link">the archive</a>
</header>
<main>
<!-- ============================================================
STAGE 1 — the page arrives on a screen and then becomes the
screen.
0 → 0.26 a monitor stands in the jungle with the landing
page playing on it; the screen grows until it is
the window, and the room fades out
0.26 → 0.54 the headline comes apart and the copy lifts away
0.50 → 0.90 the frame opens into the room the copy just left
0.90 → 1 it holds at full size, the film still breathing
============================================================ -->
<section class="wl-stage wl-stage--hero" data-intro aria-label="Introduction">
<div class="wl-stage__pin" data-cover='{"width":0.42,"ratio":1.02,"align":"right","grow":true,"bleed":"mobile"}'>
<div class="set">
<video class="set__jungle" src="https://demo.uichemy.com/wp-content/uploads/2026/09/9328701-uhd_3840_2160_30fps.webm" poster="https://demo.uichemy.com/wp-content/uploads/2026/09/heavy-rain-mangrove-rain-rain-in-forest-9328701.jpeg" autoplay muted loop playsinline preload="auto" aria-hidden="true"></video>
<div class="set__dim"></div>
<div class="set__tv"></div>
<div class="set__neck"></div>
<div class="set__stand"></div>
<div class="set__screen">
<div class="set__page">
<div class="hero__ground"></div>
<div class="grain grain--fill"></div>
<!-- A later window than the default: this stage spends its
first quarter growing the page out of a screen, and the
plate cannot start opening into space the copy has not
vacated yet. -->
<figure class="plate plate--auto pane" style="--g0: 0.5; --g1: 0.9; --veil: 0.05; --veil-bleed: 0.5">
<video class="plate__img" src="https://demo.uichemy.com/wp-content/uploads/2026/09/7246228-hd_1920_1080_24fps.webm" poster="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-38418528.jpeg" autoplay muted loop playsinline preload="auto" aria-label="Tiger walking toward the lens"></video>
<div class="plate__veil"></div>
</figure>
<div class="band band--top"></div>
<div class="band band--bottom"></div>
<div class="copy">
<div class="lift" style="--e0: 0.26; --e1: 0.44">
<span class="mono eyebrow-cyan">01 — Field notes</span>
</div>
<h1 class="scatter display hero__title" style="--e0: 0.26; --e1: 0.54">
The world is still being described.
</h1>
<div class="lift" style="--e0: 0.26; --e1: 0.46">
<p class="prose hero__body">
Wilder is a field studio. We wade, wait and keep very still — then cut
what we find into records anyone can read. The waiting is most of it.
</p>
<div class="hero__actions">
<button type="button" class="magnetic wl-btn" data-cursor="go" data-scroll-to="#archive">
Begin the survey
</button>
<span class="mono hero__hint">scroll to open the plate →</span>
</div>
</div>
<span class="lift display hero__mark" aria-hidden="true" style="--e0: 0.26; --e1: 0.42">WILDER</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================
STAGE 2 — one movement, not two. A horizontal rail carries
you from the count to the plates inside a single pin.
0.00 → 0.42 the rail travels sideways, 02 → 03
0.46 → 0.94 the plates land, each over the last
============================================================ -->
<section class="wl-stage wl-stage--rail" aria-label="The count and the plates">
<div class="wl-stage__pin wl-stage__pin--paper">
<div class="grain grain--fill"></div>
<div class="rail" style="--span: 1; --r0: 0; --r1: 0.42">
<!-- 02 — the count -->
<div class="rail__panel">
<div class="panel-grid panel-grid--count">
<div>
<div class="rv"><span class="mono eyebrow-cyan">02 — The count</span></div>
<h2 class="scatter scatter--in display count__title" style="--e0: -0.08; --e1: 0.12">
Four hundred hours of standing still.
</h2>
<div class="figures">
<div class="win figure" style="--e0: -0.06; --e1: 0.1">
<div class="display figure__n">48</div>
<div class="figure__label">expeditions logged</div>
<div class="mono figure__note">longest: nine weeks</div>
</div>
<div class="win figure" style="--e0: -0.025; --e1: 0.135">
<div class="display figure__n">12</div>
<div class="figure__label">biomes surveyed</div>
<div class="mono figure__note">four of them twice</div>
</div>
<div class="win figure" style="--e0: 0.01; --e1: 0.17">
<div class="display figure__n">1,400</div>
<div class="figure__label">hours in the hide</div>
<div class="mono figure__note">most of it nothing</div>
</div>
</div>
</div>
<figure class="dropin pane count__aside" style="--e0: -0.1; --e1: 0.18">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-38418528.jpeg" alt="Giant panda resting against a felled log" style="object-position: 44% 50%">
<figcaption class="cap cap--bottom">
<span class="mono">giant panda · the long wait</span>
</figcaption>
</figure>
</div>
</div>
<!-- 03 — the plates, in the same pin -->
<div class="rail__panel">
<div class="panel-grid panel-grid--plates">
<div>
<div class="rv"><span class="mono eyebrow-cyan">03 — Three records</span></div>
<h2 class="scatter scatter--in display plates__title" style="--e0: 0.42; --e1: 0.68">
From the last descent.
</h2>
<div class="rv rv--1">
<p class="prose prose--dim prose--narrow" style="margin-top: 1.5rem">
The rail ran sideways to get here. From this point the page reads
downward — one plate at a time, laid over the last.
</p>
<p class="mono plates__hint" data-deck-hint>take one — the others step back</p>
</div>
</div>
<div class="deck" data-deck>
<figure class="deck__card pane" tabindex="0" data-cursor="open" data-boom="plate" data-boom-hover data-id="barbet" data-eyebrow="from the last descent" data-title="Red-headed barbet" data-sub="Cloud forest, mid-morning" data-where="04.71°N" data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-29275539.jpeg" data-pos="50% 45%" style="
--c0: 0.46;
--c1: 0.66;
--fx: 30;
--fy: 140;
--tx: -96;
--ty: -46;
--rot: -7;
z-index: 1;
">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-29275539.jpeg" alt="Red-headed barbet" style="object-position: 50% 45%">
<figcaption class="cap cap--top">
<div class="cap__row">
<span class="mono">Red-headed barbet</span>
<span class="mono cap__where">04.71°N</span>
</div>
<div class="cap__note">Cloud forest, mid-morning</div>
</figcaption>
</figure>
<figure class="deck__card pane" tabindex="0" data-cursor="open" data-boom="plate" data-boom-hover data-id="owl" data-eyebrow="from the last descent" data-title="Eagle owl" data-sub="In flight, under canopy" data-where="43.07°N" data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" data-pos="52% 50%" style="
--c0: 0.6;
--c1: 0.8;
--fx: 42;
--fy: 40;
--tx: 0;
--ty: 0;
--rot: 2;
z-index: 2;
">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" alt="Eagle owl" style="object-position: 52% 50%">
<figcaption class="cap cap--top">
<div class="cap__row">
<span class="mono">Eagle owl</span>
<span class="mono cap__where">43.07°N</span>
</div>
<div class="cap__note">In flight, under canopy</div>
</figcaption>
</figure>
<figure class="deck__card pane" tabindex="0" data-cursor="open" data-boom="plate" data-boom-hover data-id="bear" data-eyebrow="from the last descent" data-title="Brown bear" data-sub="The shallows, running" data-where="58.31°N" data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg" data-pos="38% 50%" style="
--c0: 0.74;
--c1: 0.94;
--fx: 34;
--fy: -120;
--tx: 96;
--ty: 46;
--rot: 9;
z-index: 3;
">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg" alt="Brown bear" style="object-position: 38% 50%">
<figcaption class="cap cap--top">
<div class="cap__row">
<span class="mono">Brown bear</span>
<span class="mono cap__where">58.31°N</span>
</div>
<div class="cap__note">The shallows, running</div>
</figcaption>
</figure>
</div>
<div class="deck-flow">
<figure class="pane">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-29275539.jpeg" alt="Red-headed barbet" style="object-position: 50% 45%">
</figure>
<figure class="pane">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" alt="Eagle owl" style="object-position: 52% 50%">
</figure>
<figure class="pane">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg" alt="Brown bear" style="object-position: 38% 50%">
</figure>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================
STAGE 3 — the axis has turned; the page introduces itself.
============================================================ -->
<section class="wl-stage wl-stage--archive" id="archive" aria-label="The archive">
<div class="wl-stage__pin wl-stage__pin--paper">
<div class="grain grain--fill"></div>
<div class="counter-strip">
<div class="counter">
<div class="counter__set">
<span class="display counter__word">display <span>·</span></span>
<span class="display counter__word">under canopy <span>·</span></span>
<span class="display counter__word">the shallows <span>·</span></span>
<span class="display counter__word">cloud forest <span>·</span></span>
<span class="display counter__word">still water <span>·</span></span>
</div>
<div class="counter__set" aria-hidden="true">
<span class="display counter__word">display <span>·</span></span>
<span class="display counter__word">under canopy <span>·</span></span>
<span class="display counter__word">the shallows <span>·</span></span>
<span class="display counter__word">cloud forest <span>·</span></span>
<span class="display counter__word">still water <span>·</span></span>
</div>
<div class="counter__set" aria-hidden="true">
<span class="display counter__word">display <span>·</span></span>
<span class="display counter__word">under canopy <span>·</span></span>
<span class="display counter__word">the shallows <span>·</span></span>
<span class="display counter__word">cloud forest <span>·</span></span>
<span class="display counter__word">still water <span>·</span></span>
</div>
</div>
</div>
<div class="archive-grid">
<div>
<div class="lift" style="--e1: 0.2">
<span class="mono eyebrow-amber">02 — The archive</span>
</div>
<h2 class="scatter scatter--in display archive__title" style="--e0: 0.12; --e1: 0.55">
Now it reads downward.
</h2>
<div class="rv rv--1">
<p class="prose prose--dim prose--narrow" style="margin-top: 1.75rem">
Three records, opened by hand. Rest on one and its plate blows open by
itself; move off the frame and it shuts again. The field notes further
down say what the day was actually like.
</p>
</div>
</div>
<div class="rv rv--2 archive__index">
<div class="mono list-head"><span>plate</span><span>located</span></div>
<ol data-index>
<li>
<button type="button" class="wl-row" data-cursor="open" data-live="true" data-boom="plate" data-boom-hover data-eyebrow="plate i" data-title="Mute swan" data-sub="Crossing the reflection of a whole hillside" data-where="51.75°N" data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-6186893.jpeg" data-pos="50% 52%">
<span class="mono wl-row__n">i</span>
<span class="wl-row__main">
<span class="display wl-row__title">Mute swan</span>
<span class="wl-row__sub">Crossing the reflection of a whole hillside</span>
</span>
<span class="boom-chip mono">open <span aria-hidden="true">→</span></span>
<span class="mono wl-row__where">51.75°N</span>
</button>
</li>
<li>
<button type="button" class="wl-row" data-cursor="open" data-boom="plate" data-boom-hover data-eyebrow="plate ii" data-title="Eagle owl" data-sub="In flight, under canopy, no sound at all" data-where="43.07°N" data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" data-pos="52% 46%">
<span class="mono wl-row__n">ii</span>
<span class="wl-row__main">
<span class="display wl-row__title">Eagle owl</span>
<span class="wl-row__sub">In flight, under canopy, no sound at all</span>
</span>
<span class="boom-chip mono">open <span aria-hidden="true">→</span></span>
<span class="mono wl-row__where">43.07°N</span>
</button>
</li>
<li>
<button type="button" class="wl-row" data-cursor="open" data-boom="plate" data-boom-hover data-eyebrow="plate iii" data-title="Brown bear" data-sub="Running the shallows on a falling tide" data-where="58.31°N" data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg" data-pos="38% 50%">
<span class="mono wl-row__n">iii</span>
<span class="wl-row__main">
<span class="display wl-row__title">Brown bear</span>
<span class="wl-row__sub">Running the shallows on a falling tide</span>
</span>
<span class="boom-chip mono">open <span aria-hidden="true">→</span></span>
<span class="mono wl-row__where">58.31°N</span>
</button>
</li>
</ol>
<p class="mono archive__foot">three plates · one season · no stitching</p>
</div>
<!-- All three stay mounted and cross-fade, so swapping never
flashes an empty frame while the next file decodes. -->
<div class="preview pane" data-preview>
<img class="is-on" src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-6186893.jpeg" alt="Mute swan" loading="lazy" style="object-position: 50% 52%">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" alt="Eagle owl" loading="lazy" style="object-position: 52% 46%">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg" alt="Brown bear" loading="lazy" style="object-position: 38% 50%">
<div class="preview__foot"><div class="mono" data-preview-title>Mute swan</div></div>
</div>
</div>
</div>
</section>
<!-- ============================================================
STAGE 4 — plate i. The signature move: a card beside the
copy, the line comes apart, the frame opens into the room
the copy leaves — still a frame, never full bleed.
============================================================ -->
<section class="wl-stage wl-stage--plate" aria-label="Everything is under the line.">
<div class="wl-stage__pin wl-stage__pin--paper">
<div class="grain grain--fill"></div>
<figure class="plate plate--auto pane" data-cover='{"width":0.44,"ratio":0.625,"align":"right","grow":true,"bleed":"mobile"}' style="--veil: 0.05; --veil-bleed: 0.48">
<img class="plate__img" src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-6186893.jpeg" alt="Mute swan on still water, an autumn hillside reflected around it" loading="lazy">
<div class="plate__veil"></div>
</figure>
<div class="band band--top"></div>
<div class="band band--bottom"></div>
<div class="copy">
<div class="lift" style="--e1: 0.18">
<span class="mono eyebrow-cyan">plate i</span>
</div>
<h2 class="scatter display plate-stage__title" style="--e0: 0; --e1: 0.3">
Everything is under the line.
</h2>
<div class="lift" style="--e1: 0.2">
<p class="prose plate-stage__body">
Above the waterline a swan appears to do nothing at all. The whole of the
effort happens below it, out of frame — which is why it can cross the
reflection of an entire hillside without ever seeming to try.
</p>
</div>
</div>
</div>
</section>
<!-- ============================================================
STAGE 5 — a filterable grid of three records. These cards
open only on click: they are big enough that opening on
hover went off every time the pointer crossed the grid.
============================================================ -->
<section class="wl-stage wl-stage--records" aria-label="Three records">
<div class="wl-stage__pin wl-stage__pin--paper">
<div class="grain grain--fill"></div>
<div class="records">
<div class="records__head">
<div class="rv"><h3 class="display records__title">Three records</h3></div>
<div class="pane filters" data-filters>
<button type="button" class="mono" data-filter="all" aria-pressed="true">
everything
</button>
<button type="button" class="mono" data-filter="motion" aria-pressed="false">
motion
</button>
<button type="button" class="mono" data-filter="still" aria-pressed="false">
stills
</button>
</div>
</div>
<div class="records__grid" data-records>
<button type="button" class="arrive pane card" data-cursor="open" data-kind="still" data-boom="record" data-title="Mute swan" data-meta="Still water · late afternoon" data-note="Everything that moves it is under the waterline, out of the frame entirely." data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-6186893.jpeg" data-pos="50% 52%" style="--ax: -26; --ay: 90; --ar: -6">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-6186893.jpeg" alt="Mute swan" loading="lazy" style="object-position: 50% 52%">
<div class="card__foot">
<div class="cap__row">
<span class="display card__title">Mute swan</span>
<span class="mono card__kind">still</span>
</div>
<div class="cap__note">Still water · late afternoon</div>
</div>
</button>
<button type="button" class="arrive pane card" data-cursor="open" data-kind="motion" data-boom="record" data-title="Eagle owl" data-meta="In flight · under canopy" data-note="It crossed the clearing without making a sound we could hear from thirty metres." data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" data-pos="52% 46%" style="--ax: 0; --ay: 130; --ar: 6">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-23709191.jpeg" alt="Eagle owl" loading="lazy" style="object-position: 52% 46%">
<div class="card__foot">
<div class="cap__row">
<span class="display card__title">Eagle owl</span>
<span class="mono card__kind">motion</span>
</div>
<div class="cap__note">In flight · under canopy</div>
</div>
</button>
<button type="button" class="arrive pane card" data-cursor="open" data-kind="still" data-boom="record" data-title="Red-headed barbet" data-meta="Cloud forest · mid-morning" data-note="Forty minutes on one branch, and it turned away from the lens for most of them." data-src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-29275539.jpeg" data-pos="50% 45%" style="--ax: 26; --ay: 170; --ar: -6">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-29275539.jpeg" alt="Red-headed barbet" loading="lazy" style="object-position: 50% 45%">
<div class="card__foot">
<div class="cap__row">
<span class="display card__title">Red-headed barbet</span>
<span class="mono card__kind">still</span>
</div>
<div class="cap__note">Cloud forest · mid-morning</div>
</div>
</button>
</div>
</div>
</div>
</section>
<!-- ============================================================
STAGE 6 — plate iii, aligned the other way.
============================================================ -->
<section class="wl-stage wl-stage--plate" aria-label="Nothing about this is quiet.">
<div class="wl-stage__pin wl-stage__pin--paper">
<div class="grain grain--fill"></div>
<figure class="plate plate--auto pane" data-cover='{"width":0.44,"ratio":0.625,"align":"left","grow":true,"bleed":"mobile"}' style="--veil: 0.05; --veil-bleed: 0.48">
<img class="plate__img" src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-39040835.jpeg" alt="Brown bear running through shallow water, a second bear behind" loading="lazy">
<div class="plate__veil"></div>
</figure>
<div class="band band--top"></div>
<div class="band band--bottom"></div>
<div class="copy copy--right">
<div class="lift" style="--e1: 0.18">
<span class="mono eyebrow-cyan">plate iii</span>
</div>
<h2 class="scatter display plate-stage__title" style="--e0: 0; --e1: 0.3">
Nothing about this is quiet.
</h2>
<div class="lift" style="--e1: 0.2">
<p class="prose plate-stage__body">
Shallow water is the one place a bear cannot move without announcing it.
Worked from the far bank on a long lens, with a second pair of eyes on the
animal behind.
</p>
</div>
</div>
</div>
</section>
<!-- ============================================================
STAGE 7 — the page finally speaks in the first person.
============================================================ -->
<section class="wl-stage wl-stage--dispatch" aria-label="The dispatch">
<div class="wl-stage__pin wl-stage__pin--paper">
<div class="grain grain--fill"></div>
<div class="dispatch">
<div>
<div class="lift" style="--e1: 0.22">
<span class="mono eyebrow-amber">04 — The dispatch</span>
</div>
<h2 class="scatter scatter--in display dispatch__title" style="--e0: 0.1; --e1: 0.5">
One letter a season.
</h2>
<div class="rv rv--1">
<p class="prose prose--dim dispatch__body">
No schedule, no digest. It goes out when we come back with something worth
writing down — usually
<span class="dispatch__type" data-typewriter='["a barbet that would not face the lens","three hours on a falling tide","what the hide sounds like at 04:00","why the blue is not a pigment"]'></span>
</p>
<form class="signup" data-signup>
<input class="pane" type="email" required placeholder="you@somewhere" aria-label="Email address">
<button type="submit" data-cursor="send">Send it</button>
</form>
<p class="mono dispatch__small">no more than four a year</p>
</div>
</div>
<!-- The back catalogue, so the block is not one input adrift. -->
<div class="rv rv--2">
<div class="mono list-head"><span>recent</span><span>read</span></div>
<ul>
<li>
<a href="#" class="post" data-cursor="read">
<span class="mono post__n">01</span>
<span class="post__main">
<span class="display post__title">Waiting is the technique</span>
<span class="mono post__kind">field note</span>
</span>
<span class="mono post__read">6 min</span>
</a>
</li>
<li>
<a href="#" class="post" data-cursor="read">
<span class="mono post__n">02</span>
<span class="post__main">
<span class="display post__title">What a long lens costs you</span>
<span class="mono post__kind">kit</span>
</span>
<span class="mono post__read">4 min</span>
</a>
</li>
<li>
<a href="#" class="post" data-cursor="read">
<span class="mono post__n">03</span>
<span class="post__main">
<span class="display post__title">Reading a tide table backwards</span>
<span class="mono post__kind">method</span>
</span>
<span class="mono post__read">9 min</span>
</a>
</li>
</ul>
<figure class="pane dispatch__aside">
<img src="https://demo.uichemy.com/wp-content/uploads/2026/09/pexels-photo-38418528.jpeg" alt="Giant panda resting against a felled log" loading="lazy" style="object-position: 44% 46%">
</figure>
</div>
</div>
</div>
</section>
</main>
</div>```
## B6 · section `css`
```css
/* ==================================================================
Wilder — one stylesheet, no build step.
Colour names are roles, not shades: `ink` is always the foreground
and `void` always the ground, so flipping the theme is flipping
those two. They are stored as bare channels so any rule can take a
slice of one — rgb(var(--ink) / 0.6) — which is the whole reason
the page never needs a second hex for "faded ink".
================================================================== */
.uichemy-wilder-1 {
color-scheme: light;
--ink: 18 20 15;
--void: 244 241 234;
--moss: 230 226 214;
--cyan: 14 116 144;
--amber: 138 90 16;
--font-display: 'Bricolage Grotesque', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
--vh: 1vh;
}
@supports (height: 1dvh) {
.uichemy-wilder-1 { --vh: 1dvh; }
}
.uichemy-wilder-1,
.uichemy-wilder-1 *,
.uichemy-wilder-1 *::before,
.uichemy-wilder-1 *::after { box-sizing: border-box; }
/* html/body-level rules (scroll-behavior, scrollbar-gutter, the is-locked
scroll lock, the page background and overflow-x) live in site-wide code,
because a section cannot style the document. What is left here is the
part that only has to inherit into the wrapper. */
.uichemy-wilder-1 {
background: rgb(var(--void));
color: rgb(var(--ink));
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
}
img,
video {
display: block;
max-width: 100%;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
}
a {
color: inherit;
text-decoration: none;
}
ol,
ul {
margin: 0;
padding: 0;
list-style: none;
}
figure,
p,
h1,
h2,
h3,
dl,
dd { margin: 0; }
/* The custom cursor replaces the native one, but only on real pointers. */
@media (pointer: fine) {
.uichemy-wilder-1.has-cursor,
.uichemy-wilder-1.has-cursor * { cursor: none; }
}
/* ---- Type ------------------------------------------------------- */
.display {
font-family: var(--font-display);
letter-spacing: -0.045em;
line-height: 0.92;
font-weight: 400;
}
/* The page's label voice: every eyebrow, caption, chip and meta string. */
.mono {
font-variant-numeric: tabular-nums;
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 11px;
}
.eyebrow-cyan { color: rgb(var(--cyan)); }
.eyebrow-amber { color: rgb(var(--amber)); }
/* ---- Surfaces --------------------------------------------------- */
/* Thin frosted panel used for chips, cards and frames. On paper the tint
has to go the other way — a white veil over cream is invisible. */
.pane {
background: rgb(var(--ink) / 0.025);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgb(var(--ink) / 0.13);
}
.grain::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.45;
background-image: radial-gradient(rgb(var(--ink) / 0.1) 1px, transparent 1px);
background-size: 3px 3px;
mix-blend-mode: multiply;
}
/* Every pin carries one, at z-index 1, above the ground and under the copy. */
.grain--fill {
position: absolute;
inset: 0;
z-index: 1;
}
/* Link underline that wipes in from the left. */
.uline { position: relative; }
.uline::after {
content: '';
position: absolute;
left: 0;
bottom: -2px;
height: 1px;
width: 100%;
background: currentColor;
transform: scaleX(0);
transform-origin: 0 50%;
transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.uline:hover::after { transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) {
.uichemy-wilder-1 { --wl-guard: 0; }
.uichemy-wilder-1:not([data-motion='on']) { scroll-behavior: auto; }
}
/* ==================================================================
Scroll stages
------------------------------------------------------------------
Every stage is a tall wrapper with one sticky child. A single scroll
listener writes --p (0 → 1) onto the wrapper; everything below is a
calc() reading it. No keyframes, no durations, no easing curves —
the scrollbar is the timeline.
--q is the house idiom for "this element's slice of --p", remapped
to 0 → 1 with clamp so each move can own its own window.
================================================================== */
@property --p {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
.wl-stage { position: relative; }
.wl-stage__pin {
position: sticky;
top: 0;
height: calc(100 * var(--vh));
overflow: hidden;
}
.wl-stage__pin--paper { background: rgb(var(--void)); }
/* Sub-range helper: set --e0/--e1 per element to carve a window. */
.win {
--e0: 0;
--e1: 1;
--q: clamp(0, calc((var(--p) - var(--e0)) / (var(--e1) - var(--e0))), 1);
}
/* ---- The plate ---------------------------------------------------
It used to grow until it covered the window. Scaling an image 3.85x
rasterises it at the small size and stretches that bitmap, so the
whole thing arrived soft and washed out. Now the box itself is
interpolated in pixels and only the image inside breathes.
------------------------------------------------------------------ */
.plate {
--g: 0;
position: absolute;
left: calc((var(--cl, 0) + (var(--gl, var(--cl, 0)) - var(--cl, 0)) * var(--g)) * 1px);
top: calc((var(--ct, 0) + (var(--gt, var(--ct, 0)) - var(--ct, 0)) * var(--g)) * 1px);
width: calc((var(--cw, 300) + (var(--gw, var(--cw, 300)) - var(--cw, 300)) * var(--g)) * 1px);
height: calc((var(--ch, 200) + (var(--gh, var(--ch, 200)) - var(--ch, 200)) * var(--g)) * 1px);
overflow: hidden;
margin: 0;
z-index: 2;
border-radius: calc(22px + var(--g) * 6px);
will-change: transform;
transform: translate3d(0, calc((var(--p) - 0.5) * -46px * (1 - var(--g))), 0);
}
/* The frame that fills the space the copy leaves. The headline is gone by
0.3 and the stage runs to 1, which would otherwise leave two thirds of
the window as blank paper next to a parked card. It opens to a cinema
box inset on every side, so it grows without becoming full bleed.
--gq is smoothstepped so it eases out of the move on its own.
In React this was two class names chosen in JS. Here it is one class
and a breakpoint, because nothing about the choice needs JS to know it. */
@media (min-width: 768px) {
.plate--auto {
--g0: 0.28;
--g1: 0.78;
--gq: clamp(0, calc((var(--p) - var(--g0)) / (var(--g1) - var(--g0))), 1);
--g: calc(var(--gq) * var(--gq) * (3 - 2 * var(--gq)));
}
}
/* A phone has no room for a plate beside copy: it is full bleed, with a
heavier veil because the words sit on top of the picture there. */
@media (max-width: 767px) {
.plate--auto {
border-radius: 0;
transform: none;
}
}
.plate__img {
--zoom: calc(min(var(--p), calc(1 - var(--p))) * 2);
display: block;
width: 100%;
height: 100%;
object-fit: cover;
will-change: transform;
transform: scale(calc(1 + var(--zoom) * 0.07));
}
.plate__veil {
position: absolute;
inset: 0;
background: rgb(var(--void));
pointer-events: none;
opacity: var(--veil, 0.06);
}
@media (max-width: 767px) {
.plate--auto .plate__veil { opacity: min(var(--veil-bleed, 0.5), 0.3); }
}
/* Paper bands so fixed chrome stays legible over a full-bleed plate.
They are empty divs, not media, so a breakpoint may hide them — the
rule about rendering rather than hiding is about what downloads. */
.band {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
z-index: 4;
opacity: clamp(0, calc((var(--p) - 0.06) / 0.2), 1);
}
.band--top {
top: 0;
height: 10rem;
background: linear-gradient(180deg, rgb(var(--void)), rgb(var(--void) / 0.8) 45%, transparent);
}
.band--bottom {
bottom: 0;
height: 12rem;
background: linear-gradient(0deg, rgb(var(--void)), rgb(var(--void) / 0.8) 45%, transparent);
}
@media (min-width: 768px) {
.band { display: none; }
}
/* ---- Headline that comes apart ---------------------------------- */
.scatter {
--e0: 0;
--e1: 0.3;
--q: clamp(0, calc((var(--p) - var(--e0)) / (var(--e1) - var(--e0))), 1);
}
.scatter__w {
display: inline-block;
--wq: clamp(0, calc((var(--q) - var(--w0)) / (var(--w1) - var(--w0))), 1);
will-change: transform;
transform: translate3d(calc(var(--wq) * var(--wx) * 1px), calc(var(--wq) * var(--wy) * 1px), 0)
rotate(calc(var(--wq) * var(--wr) * 1deg)) scale(calc(1 - var(--wq) * 0.12));
opacity: clamp(0, calc(1 - var(--wq) * 2.4), 1);
}
/* Assembling, for lines that should arrive rather than leave. Same maths,
read backwards — so the opacity rule above works unchanged. */
.scatter--in .scatter__w { --wq: calc(1 - clamp(0, calc((var(--q) - var(--w0)) / (var(--w1) - var(--w0))), 1)); }
/* ---- Copy that lifts away with the headline --------------------- */
.lift {
--e0: 0;
--e1: 0.2;
--q: clamp(0, calc((var(--p) - var(--e0)) / (var(--e1) - var(--e0))), 1);
transform: translate3d(0, calc(var(--q) * -40px), 0);
opacity: calc(1 - var(--q));
}
/* ---- Horizontal rail -------------------------------------------- */
.rail {
position: absolute;
inset: 0;
display: flex;
z-index: 10;
will-change: transform;
--r0: 0;
--r1: 1;
--rq: clamp(0, calc((var(--p) - var(--r0)) / (var(--r1) - var(--r0))), 1);
transform: translate3d(calc(var(--rq) * var(--span, 2) * -100vw), 0, 0);
}
.rail__panel {
width: 100vw;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2rem;
}
@media (min-width: 768px) {
.rail__panel { padding: 0 4rem; }
}
@media (min-width: 1024px) {
.rail__panel { padding: 0 6rem; }
}
/* ---- The deck: plates arrive one at a time, each in front -------
Each card owns a window (--c0 → --c1) that overlaps the next, so one
is still settling as the following one starts. It flies in from
(--fx, --fy) and lands on its place in the fan at (--tx, --ty).
Nothing shrinks: the deck ends larger than any single card starts,
which is what stops it reading as a thumbnail stranded mid-screen. */
.deck__card {
--c0: 0;
--c1: 1;
--q: clamp(0, calc((var(--p) - var(--c0)) / (var(--c1) - var(--c0))), 1);
position: absolute;
margin: 0;
height: clamp(200px, 52vh, 460px);
width: clamp(220px, 24vw, 350px);
overflow: hidden;
border-radius: 1.4rem;
box-shadow: 0 30px 70px -30px rgb(var(--ink) / 0.45);
will-change: transform;
transform: translate3d(
calc((1 - var(--q)) * var(--fx) * 1vw + var(--tx) * 1px),
calc((1 - var(--q)) * var(--fy) * 1px + var(--ty) * 1px),
0
)
rotate(calc(var(--q) * var(--rot) * 1deg)) scale(calc(0.82 + var(--q) * 0.18));
opacity: clamp(0, calc(var(--q) * 2.2), 1);
}
/* The dim rides on a veil of its own rather than on the card's opacity.
Transitioning the card meant the 0.4s duration also applied to the
scrubbed arrival — at 70% through the stage the plates read 0.5 instead
of 1, lagging the scrollbar by four hundred milliseconds. A scrubbed
property can never carry a duration. */
.deck__card::after {
content: '';
position: absolute;
inset: 0;
background: rgb(var(--void));
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease;
}
.deck--dimmed .deck__card:not(.is-focus)::after { opacity: 0.55; }
.deck__card img {
height: 100%;
width: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.deck__card.is-focus img { transform: scale(1.06); }
/* ---- Cards that arrive on their own vectors --------------------- */
.arrive {
--q: clamp(0, calc((var(--p) - 0.05) / 0.45), 1);
will-change: transform;
transform: translate3d(calc((1 - var(--q)) * var(--ax) * 1vw), calc((1 - var(--q)) * var(--ay) * 1px), 0)
rotate(calc((1 - var(--q)) * var(--ar) * 1deg));
opacity: var(--q);
}
/* ---- Strip that runs against the scroll ------------------------- */
.counter {
display: flex;
width: max-content;
gap: 2.5rem;
white-space: nowrap;
will-change: transform;
transform: translate3d(calc(-32% + var(--p) * 36%), 0, 0);
}
/* ---- Image that arrives from above ------------------------------
Comes down into place, then keeps drifting slowly, so the frame is
never simply parked once it has landed. */
.dropin {
--e0: 0;
--e1: 0.3;
--q: clamp(0, calc((var(--p) - var(--e0)) / (var(--e1) - var(--e0))), 1);
will-change: transform;
transform: translate3d(0, calc((1 - var(--q)) * -120px + var(--p) * 40px), 0);
opacity: var(--q);
}
.dropin img {
transform: scale(calc(1.04 + var(--p) * 0.05));
will-change: transform;
}
/* ---- One-shot reveals, for small copy blocks only ---------------
Deliberately not scroll-scrubbed: scrubbing a paragraph's opacity
against the scrollbar makes text flicker on the smallest nudge. */
.rv {
opacity: 0;
transform: translateY(18px);
transition:
opacity 0.7s ease,
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.rv.is-in {
opacity: 1;
transform: none;
}
.rv--1 { transition-delay: 0.08s; }
.rv--2 { transition-delay: 0.16s; }
.rv--3 { transition-delay: 0.24s; }
/* Accordion: grid-template-rows is the one cheap way to animate to
auto height without touching layout-heavy properties per frame. */
.reveal-rows {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal-rows[data-open='true'] { grid-template-rows: 1fr; }
.reveal-rows > * {
overflow: hidden;
min-height: 0;
}
/* ---- Typewriter -------------------------------------------------- */
.type__caret {
display: inline-block;
width: 1px;
height: 0.9em;
background: currentColor;
animation: wilder-caret 1s steps(1) infinite;
vertical-align: -0.12em;
}
@keyframes wilder-caret {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.uichemy-wilder-1 { --wl-guard: 0; }
.uichemy-wilder-1:not([data-motion='on']) .wl-stage { height: auto !important; }
.uichemy-wilder-1:not([data-motion='on']) .wl-stage__pin {
position: relative;
height: auto;
}
.uichemy-wilder-1:not([data-motion='on']) .rail {
position: relative;
inset: auto;
flex-direction: column;
transform: none;
}
.uichemy-wilder-1:not([data-motion='on']) .rail__panel {
width: 100%;
padding-block: 6rem;
}
.uichemy-wilder-1:not([data-motion='on']) .plate,
.uichemy-wilder-1:not([data-motion='on']) .scatter__w,
.uichemy-wilder-1:not([data-motion='on']) .lift,
.uichemy-wilder-1:not([data-motion='on']) .deck__card,
.uichemy-wilder-1:not([data-motion='on']) .arrive,
.uichemy-wilder-1:not([data-motion='on']) .counter {
transform: none !important;
opacity: 1 !important;
}
.uichemy-wilder-1:not([data-motion='on']) .deck__card { position: relative; }
.uichemy-wilder-1:not([data-motion='on']) .rv {
opacity: 1;
transform: none;
}
}
/* ==================================================================
The custom cursor and the progress hairline
------------------------------------------------------------------
Both were springs from an animation library. A spring is a lerp with
a memory, and for a cursor that trails and a bar that eases, an
exponential lerp in the frame loop is indistinguishable — so both
are three lines of JS writing one transform, and the swell of the
disc is a CSS transition with an overshoot curve.
================================================================== */
.cursor {
position: fixed;
left: 0;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
border-radius: 999px;
mix-blend-mode: difference;
transform: translate3d(-200px, -200px, 0);
}
.cursor__disc {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.8);
background: rgb(255 255 255 / 0.1);
transition:
width 0.4s cubic-bezier(0.2, 1.3, 0.4, 1),
height 0.4s cubic-bezier(0.2, 1.3, 0.4, 1),
margin 0.4s cubic-bezier(0.2, 1.3, 0.4, 1),
transform 0.2s ease;
}
.cursor.is-labelled .cursor__disc {
width: 72px;
height: 72px;
margin: -36px 0 0 -36px;
}
.cursor.is-down .cursor__disc { transform: scale(0.82); }
.cursor__label {
white-space: nowrap;
color: #fff;
opacity: 0;
transform: scale(0.7);
transition:
opacity 0.22s ease,
transform 0.22s ease;
}
.cursor.is-labelled .cursor__label {
opacity: 1;
transform: none;
}
.railbar {
position: fixed;
inset-inline: 0;
top: 0;
z-index: 60;
height: 1px;
transform-origin: 0 50%;
transform: scaleX(0);
background: linear-gradient(to right, rgb(var(--cyan)), rgb(var(--amber)), rgb(var(--cyan)));
}
/* ---- The magnetic button ---------------------------------------
Leans toward the pointer and blooms a wilder-ripple from wherever it was
actually clicked, so a press always has a visible consequence. JS
writes two numbers; the lean-back and the wilder-ripple are CSS. */
.magnetic {
position: relative;
overflow: hidden;
transform: translate3d(var(--mx, 0px), var(--my, 0px), 0);
transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.magnetic:active { transform: translate3d(var(--mx, 0px), var(--my, 0px), 0) scale(0.96); }
.magnetic__ripple {
position: absolute;
pointer-events: none;
border-radius: 999px;
background: rgb(var(--ink) / 0.35);
transform: translate(-50%, -50%);
animation: wilder-ripple 0.65s ease-out forwards;
}
@keyframes wilder-ripple {
from {
width: 0;
height: 0;
opacity: 0.6;
}
to {
width: 320px;
height: 320px;
opacity: 0;
}
}
/* ==================================================================
The boom — what opening something does
------------------------------------------------------------------
Whatever was clicked is blown open out of the exact pixel that was
hit: a flash, two shockwaves and a scatter of debris off that point,
while the paper behind is clipped open from the same place. Closing
implodes back into it. Every layer is anchored to --bx/--by, the
click point in viewport coordinates.
Event-driven, so these are keyframes rather than the scrubbed calc()
the stages use. Nothing here reads --p.
================================================================== */
.boom {
position: fixed;
inset: 0;
z-index: 70;
}
/* The paper that opens. A circle clipped from nothing up to the whole
window: the reveal *is* the blast. */
.boom__sheet {
position: absolute;
inset: 0;
background: rgb(var(--void) / 0.96);
animation: wilder-boom-open 0.72s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes wilder-boom-open {
from { clip-path: circle(0px at var(--bx) var(--by)); }
to { clip-path: circle(150vmax at var(--bx) var(--by)); }
}
/* Going back, it implodes into the same point it came out of. */
.boom[data-closing='true'] .boom__sheet { animation: wilder-boom-close 0.34s cubic-bezier(0.4, 0, 1, 1) forwards; }
@keyframes wilder-boom-close {
from { clip-path: circle(150vmax at var(--bx) var(--by)); }
to { clip-path: circle(0px at var(--bx) var(--by)); }
}
/* Same frame the stages grow into — inset on every side, corners kept,
header left clear — so an opened plate is recognisably the same object. */
.boom__panel {
position: absolute;
top: 96px;
right: 4.5vw;
bottom: 56px;
left: 4.5vw;
overflow: hidden;
border-radius: 28px;
animation: wilder-boom-punch 0.62s cubic-bezier(0.2, 1.42, 0.32, 1) both 0.18s;
}
@keyframes wilder-boom-punch {
from {
transform: scale(0.9) translateY(28px);
opacity: 0;
}
55% { opacity: 1; }
to {
transform: none;
opacity: 1;
}
}
.boom[data-closing='true'] .boom__panel { animation: wilder-boom-panel-out 0.3s ease-in forwards; }
@keyframes wilder-boom-panel-out {
to {
transform: scale(0.94);
opacity: 0;
}
}
/* One frame of over-exposure, straight after the hit. */
.boom__flash {
position: absolute;
inset: 0;
background: #fff;
pointer-events: none;
animation: wilder-boom-flash 0.32s ease-out forwards;
}
@keyframes wilder-boom-flash {
from { opacity: 0.55; }
to { opacity: 0; }
}
/* Shockwave. Width and height carry the growth rather than a scale: a
scaled ring scales its own border too, and 2px becomes a 100px slab. */
.boom__ring {
position: absolute;
left: var(--bx);
top: var(--by);
transform: translate(-50%, -50%);
border-radius: 999px;
border: 3px solid currentColor;
pointer-events: none;
animation: wilder-boom-ring 0.95s cubic-bezier(0.15, 0.72, 0.3, 1) forwards;
}
.boom__ring--1 { color: rgb(var(--ink)); }
.boom__ring--2 {
color: rgb(var(--amber));
animation-delay: 90ms;
}
@keyframes wilder-boom-ring {
from {
width: 0;
height: 0;
opacity: 0.95;
border-width: 3px;
}
55% { opacity: 0.5; }
to {
width: 1180px;
height: 1180px;
opacity: 0;
border-width: 1px;
}
}
/* Debris. Each one is handed an angle, a distance and a spin, and flies
its own line out of the click. */
.boom__shard {
position: absolute;
left: var(--bx);
top: var(--by);
border-radius: 999px;
pointer-events: none;
animation: wilder-boom-shard 0.95s cubic-bezier(0.15, 0.72, 0.3, 1) forwards;
}
.boom__shard--ink { background: rgb(var(--ink)); }
.boom__shard--cyan { background: rgb(var(--cyan)); }
.boom__shard--amber { background: rgb(var(--amber)); }
@keyframes wilder-boom-shard {
from {
transform: translate(-50%, -50%) rotate(var(--a)) translateX(0) scale(1);
opacity: 1;
}
55% { opacity: 0.85; }
to {
transform: translate(-50%, -50%) rotate(var(--a)) translateX(var(--d)) scale(0.4)
rotate(var(--spin));
opacity: 0;
}
}
.boom__debris {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
/* The caption arrives a beat after the plate, so the two reads are
separate: picture first, then what it is. */
.boom__cap { animation: wilder-boom-cap 0.5s cubic-bezier(0.16, 1, 0.3, 1) both 0.44s; }
@keyframes wilder-boom-cap {
from {
transform: translateY(22px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
/* Paper rather than the frosted `pane`: this chip sits over whatever the
plate happens to be, and ink on a dark owl at 2.5% tint is not a
readable button. */
.boom__close {
position: absolute;
right: 1.25rem;
top: 1.25rem;
z-index: 10;
padding: 0.5rem 1rem;
border-radius: 999px;
border: 1px solid rgb(var(--ink) / 0.15);
background: rgb(var(--void) / 0.85);
backdrop-filter: blur(8px);
color: rgb(var(--ink));
transition: background-color 0.3s ease, color 0.3s ease;
}
.boom__close:hover {
background: rgb(var(--ink));
color: rgb(var(--void));
}
.boom__figure {
margin: 0;
height: 100%;
width: 100%;
}
.boom__figure img {
height: 100%;
width: 100%;
object-fit: cover;
}
.boom__caption {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 1.75rem;
background: linear-gradient(to top, rgb(var(--void)), rgb(var(--void) / 0.75) 50%, transparent);
}
.boom__caption-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
}
.boom__title {
margin-top: 0.5rem;
font-size: clamp(1.8rem, 4vw, 3.25rem);
}
.boom__sub {
margin-top: 0.5rem;
max-width: 28rem;
font-size: 0.875rem;
font-weight: 300;
color: rgb(var(--ink) / 0.75);
}
.boom__where { color: rgb(var(--ink) / 0.6); }
@media (min-width: 768px) {
.boom__caption { padding: 2.5rem; }
}
/* An opened record: the plate on one side, everything the field card
knows about it on the other. The rows are declared rather than left
to stretch — an auto row takes half the panel and a 38%-tall picture
inside it opens a gap under the image. */
.record {
display: grid;
height: 100%;
grid-template-columns: 1fr;
grid-template-rows: 38% minmax(0, 1fr);
background: rgb(var(--void));
}
.record__figure {
position: relative;
margin: 0;
height: 100%;
overflow: hidden;
}
.record__figure img {
height: 100%;
width: 100%;
object-fit: cover;
}
/* Centred beside the picture, but stacked under it on a phone the copy
has to start where the image stops — centring it there opens a
screen's worth of blank paper in between. */
.record__body {
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-y: auto;
padding: 2rem;
}
.record__title {
margin-top: 1rem;
max-width: 12ch;
font-size: clamp(1.8rem, 3.6vw, 3rem);
}
.record__note {
margin-top: 1.25rem;
max-width: 28rem;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.625;
color: rgb(var(--ink) / 0.75);
}
.record__dl {
margin: 1.75rem 0 0;
display: grid;
max-width: 28rem;
grid-template-columns: 1fr 1fr;
column-gap: 2rem;
row-gap: 1rem;
}
.record__dl > div {
border-left: 1px solid rgb(var(--ink) / 0.15);
padding-left: 1rem;
}
.record__dl dt { color: rgb(var(--ink) / 0.5); }
.record__dl dd {
margin: 0.25rem 0 0;
font-size: 0.875rem;
font-weight: 300;
}
@media (min-width: 768px) {
.record {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: none;
}
.record__body {
justify-content: center;
padding: 2rem 3rem;
}
}
/* The affordance, before any of the above: the live row says it opens. */
.boom-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
max-width: 0;
opacity: 0;
flex-shrink: 0;
color: rgb(var(--amber));
transform: translateX(-6px);
transition:
max-width 0.4s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.3s ease,
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-live='true'] .boom-chip {
max-width: 7rem;
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.uichemy-wilder-1 { --wl-guard: 0; }
.uichemy-wilder-1:not([data-motion='on']) .boom__sheet,
.uichemy-wilder-1:not([data-motion='on']) .boom__panel,
.uichemy-wilder-1:not([data-motion='on']) .boom__cap {
animation: none !important;
clip-path: none !important;
opacity: 1 !important;
transform: none !important;
}
.uichemy-wilder-1:not([data-motion='on']) .boom__flash,
.uichemy-wilder-1:not([data-motion='on']) .boom__ring,
.uichemy-wilder-1:not([data-motion='on']) .boom__shard { display: none; }
}
/* ==================================================================
The set — the page starts inside a monitor
------------------------------------------------------------------
A screen standing in the jungle, with the landing page playing on
it. Scrolling grows the screen until it *is* the window, and what
was on it is the hero you were always going to land on.
No JavaScript measures anything here. The screen is a fraction of
the viewport (--fw), so it carries the viewport's own aspect ratio,
which means one scale takes the page from screen-size to life-size
and the two can never disagree about their shape.
================================================================== */
.set {
position: absolute;
inset: 0;
overflow: hidden;
--fw: 54;
--fl: 23; --ft: 16; --bez: 1.1vw;
--body: linear-gradient(157deg, #f4f5f2 0%, #dcdfd9 22%, #b2b6ae 58%, #cbcec7 82%, #9ea299 100%);
--e0: 0;
--e1: 0.26;
--q: clamp(0, calc((var(--p) - var(--e0)) / (var(--e1) - var(--e0))), 1);
--fnow: calc(var(--fw) + var(--q) * (100 - var(--fw)));
}
/* The room it is standing in. Gone by the time the page is life-size. */
.set__jungle,
.set__dim {
position: absolute;
inset: 0;
opacity: clamp(0, calc(1 - var(--q) * 1.35), 1);
}
.set__jungle {
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(1.06) brightness(1.04) blur(0.5px);
transform: scale(calc(1.05 + var(--q) * 0.06));
}
.set__dim {
background: radial-gradient(78% 68% at 50% 42%, rgb(var(--ink) / 0) 0%, rgb(var(--ink) / 0.3) 100%);
}
/* Bezel and stand. They fade faster than the room so the frame is gone
before the page reaches full size — a black edge easing out over the
last of the growth reads as a bug. */
.set__tv,
.set__stand,
.set__neck {
position: absolute;
background: var(--body);
opacity: clamp(0, calc(1 - var(--q) * 2.1), 1);
pointer-events: none;
}
.set__tv {
left: calc(var(--fl) * 1vw - var(--bez));
top: calc(var(--ft) * var(--vh) - var(--bez));
width: calc(var(--fw) * 1vw + var(--bez) * 2);
height: calc(var(--fw) * var(--vh) + var(--bez) * 2 + 1.6vw);
border-radius: 14px;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.85),
inset 0 -1px 0 rgb(var(--ink) / 0.28),
0 1px 0 rgb(var(--ink) / 0.35),
0 46px 70px -34px rgb(var(--ink) / 0.6);
}
/* The dark lip the screen is set into, so cream does not run straight
into silver with no edge between them. */
.set__tv::after {
content: '';
position: absolute;
inset: var(--bez);
height: calc(var(--fw) * var(--vh));
border-radius: 5px;
background: #1b1e18;
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.55);
}
.set__neck {
left: 50%;
top: calc(var(--ft) * var(--vh) + var(--fw) * var(--vh) + var(--bez) + 1.6vw);
width: 7vw;
height: calc(4 * var(--vh));
transform: translateX(-50%);
background: linear-gradient(90deg, #9da199 0%, #d7dad4 38%, #b4b8b0 72%, #8f938b 100%);
}
.set__stand {
left: 50%;
top: calc(var(--ft) * var(--vh) + var(--fw) * var(--vh) + var(--bez) + 1.6vw + 4 * var(--vh));
width: 22vw;
height: calc(1.15 * var(--vh));
border-radius: 999px;
transform: translateX(-50%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.8),
0 14px 22px -10px rgb(var(--ink) / 0.55);
}
/* The screen: the box the page is seen through, on its way to being
the window itself. */
.set__screen {
position: absolute;
left: calc((1 - var(--q)) * var(--fl) * 1vw);
top: calc((1 - var(--q)) * var(--ft) * var(--vh));
width: calc(var(--fnow) * 1vw);
height: calc(var(--fnow) * var(--vh));
overflow: hidden;
border-radius: calc((1 - var(--q)) * 6px);
}
/* The page itself, always laid out at full size and only ever scaled —
so no text reflows on the way out of the screen and the composition
that arrives is the one that was playing on it. */
.set__page {
width: 100vw;
height: calc(100 * var(--vh));
transform-origin: 0 0;
transform: scale(calc(var(--fnow) / 100));
}
/* A phone has no room for a monitor with margins around it. */
@media (max-width: 767px) {
.set {
--fw: 84;
--fl: 8;
--ft: 14;
--bez: 2.4vw;
}
.set__neck { width: 16vw; }
.set__stand { width: 40vw; }
}
@media (prefers-reduced-motion: reduce) {
.uichemy-wilder-1 { --wl-guard: 0; }
.uichemy-wilder-1:not([data-motion='on']) .set__jungle,
.uichemy-wilder-1:not([data-motion='on']) .set__dim,
.uichemy-wilder-1:not([data-motion='on']) .set__tv,
.uichemy-wilder-1:not([data-motion='on']) .set__neck,
.uichemy-wilder-1:not([data-motion='on']) .set__stand { display: none; }
.uichemy-wilder-1:not([data-motion='on']) .set__screen {
position: static;
width: auto;
height: auto;
border-radius: 0;
}
.uichemy-wilder-1:not([data-motion='on']) .set__page {
width: auto;
height: auto;
transform: none;
}
.uichemy-wilder-1:not([data-motion='on']) .chrome { opacity: 1; }
}
/* ==================================================================
Page chrome
================================================================== */
/* The fixed header lives outside the set and so can inherit nothing from
it. The scroll loop republishes the intro stage's own --p on the root
as --wilder-intro, and the header reads that: ink on a dark jungle is
unreadable, and chrome floating beside a monitor belongs to neither
the room nor the page — so it arrives with the page. */
.chrome {
position: fixed;
inset-inline: 0;
top: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
opacity: clamp(0, calc((var(--wilder-intro, 0) - 0.17) / 0.09), 1);
}
@media (min-width: 768px) {
.chrome { padding: 1.25rem 2.5rem; }
}
.chrome__link { color: rgb(var(--ink) / 0.8); }
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo__mark {
height: 1.75rem;
width: 1.75rem;
color: rgb(var(--ink));
transition: transform 0.5s ease;
}
.logo:hover .logo__mark { transform: rotate(45deg); }
.logo__text { line-height: 1; }
.logo__name {
display: block;
font-size: 1.35rem;
letter-spacing: -0.02em;
}
.logo__sub {
display: block;
margin-top: 0.25rem;
color: rgb(var(--ink) / 0.55);
}
/* ==================================================================
Stage layout
------------------------------------------------------------------
The copy column every stage shares: full height, centred, with the
page's one horizontal rhythm — 2rem / 4rem / 6rem.
================================================================== */
.copy {
position: relative;
z-index: 10;
display: flex;
height: 100%;
flex-direction: column;
justify-content: center;
padding: 0 2rem;
}
.copy--right {
align-items: flex-end;
text-align: right;
}
@media (min-width: 768px) {
.copy { padding: 0 4rem; }
}
@media (min-width: 1024px) {
.copy { padding: 0 6rem; }
}
.prose {
max-width: 28rem;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.625;
color: rgb(var(--ink) / 0.8);
}
.prose--dim { color: rgb(var(--ink) / 0.75); }
.prose--narrow { max-width: 24rem; }
/* ---- Stage 1: the hero ------------------------------------------ */
.wl-stage--hero { height: 420vh; }
.hero__ground {
position: absolute;
inset: 0;
z-index: 0;
background: radial-gradient(120% 90% at 70% 20%, #ffffff 0%, #ece8dd 65%);
}
.hero__title {
margin-top: 1.5rem;
max-width: 11ch;
font-size: clamp(2.5rem, 6.5vw, 6rem);
}
.hero__body { margin-top: 1.75rem; }
@media (min-width: 768px) {
.hero__body { font-size: 1rem; }
}
.hero__actions {
margin-top: 2.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
}
.hero__hint { color: rgb(var(--ink) / 0.55); }
.hero__mark {
position: absolute;
inset-inline: 0;
bottom: 1.5rem;
pointer-events: none;
text-align: center;
font-size: clamp(3.5rem, 13vw, 11rem);
line-height: 1;
color: rgb(var(--ink) / 0.09);
}
.wl-btn {
border-radius: 999px;
background: rgb(var(--ink));
color: rgb(var(--void));
padding: 1rem 1.75rem;
font-size: 0.875rem;
font-weight: 500;
}
/* ---- Stage 2: the rail ------------------------------------------ */
.wl-stage--rail { height: 320vh; }
.panel-grid {
display: grid;
height: 100%;
grid-template-columns: 1fr;
align-items: center;
gap: 2.5rem;
}
.count__title {
margin-top: 1.5rem;
max-width: 12ch;
font-size: clamp(2.25rem, 6vw, 5.5rem);
}
.figures {
margin-top: 3rem;
display: grid;
max-width: 48rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 2.5rem;
row-gap: 2rem;
}
.figure {
border-left: 1px solid rgb(var(--ink) / 0.15);
padding-left: 1.25rem;
transition: padding 0.3s ease;
opacity: var(--q);
transform: translate3d(0, calc((1 - var(--q)) * 26px), 0);
}
.figure:hover { padding-left: 1.75rem; }
.figure__n { font-size: clamp(2rem, 4vw, 3.5rem); }
.figure__label {
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 300;
color: rgb(var(--ink) / 0.7);
}
.figure__note {
margin-top: 0.75rem;
max-height: 0;
overflow: hidden;
opacity: 0;
color: rgb(var(--cyan));
transition: all 0.5s ease;
}
.figure:hover .figure__note {
max-height: 2.5rem;
opacity: 1;
}
/* A touch screen has no hover to give and the note is content, not
decoration — so on one it is simply there. Not on a phone lying down:
three of these open 40px the panel does not have at 375px tall, and a
note that pushes the figure it belongs to off the screen is worse
than a note nobody can open. */
@media (hover: none) and (min-height: 520px) {
.figure__note {
max-height: 2.5rem;
opacity: 1;
}
}
/* Without it this panel opened on an empty screen — everything else here
is scroll-revealed, so the first frame had nothing in it. */
.count__aside {
display: none;
position: relative;
height: clamp(180px, 54vh, 460px);
overflow: hidden;
border-radius: 1.4rem;
}
.count__aside img {
height: 100%;
width: 100%;
object-fit: cover;
}
.plates__title {
margin-top: 1.5rem;
max-width: 10ch;
font-size: clamp(2rem, 5vw, 4.25rem);
}
.plates__hint {
margin-top: 1.5rem;
color: rgb(var(--ink) / 0.45);
}
.deck {
display: none;
position: relative;
height: 100%;
align-items: center;
justify-content: center;
}
/* A phone gets them in flow — there is no room to overlap three plates
and still read any of them. */
.deck-flow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.deck-flow figure {
margin: 0;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 0.75rem;
}
.deck-flow img {
height: 100%;
width: 100%;
object-fit: cover;
}
.cap {
position: absolute;
inset-inline: 0;
padding: 1rem;
}
.cap--top {
top: 0;
padding-bottom: 2rem;
background: linear-gradient(to bottom, rgb(var(--void)), rgb(var(--void) / 0.75) 50%, transparent);
}
.cap--bottom {
bottom: 0;
background: linear-gradient(to top, rgb(var(--void)), rgb(var(--void) / 0.7) 50%, transparent);
}
.cap__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.cap__where { color: rgb(var(--ink) / 0.55); }
.cap__note {
margin-top: 0.25rem;
font-size: 11px;
font-weight: 300;
color: rgb(var(--ink) / 0.7);
}
/* A phone on its side is 375px tall. The copy and the three plates
stacked are half a screen longer than that, in a pin that clips — and
the layout that fits there is the wide one, which the page already
owns. So the two-column panel starts at the height where the stack
stops fitting as well as at the width where it always did. */
@media (max-height: 520px) and (min-width: 600px) {
.panel-grid--plates {
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
gap: 2rem;
}
}
@media (min-width: 768px) {
.panel-grid { gap: 4rem; }
.panel-grid--count { grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); }
.panel-grid--plates {
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
gap: 2rem;
}
.count__aside { display: block; }
.deck { display: flex; }
.deck-flow { display: none; }
.figures { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* ---- Stage 3: the archive --------------------------------------- */
.wl-stage--archive { height: 220vh; }
/* Runs the other way to the hero rail, so the axis change registers. */
.counter-strip {
position: absolute;
inset-inline: 0;
top: 14vh;
z-index: 10;
overflow: hidden;
border-block: 1px solid rgb(var(--ink) / 0.1);
padding: 1rem 0;
}
.counter__set {
display: flex;
gap: 2.5rem;
}
.counter__word {
font-size: clamp(1.4rem, 3vw, 2.6rem);
color: rgb(var(--ink) / 0.25);
}
.counter__word span { color: rgb(var(--cyan) / 0.6); }
.archive-grid {
position: relative;
z-index: 10;
display: grid;
height: 100%;
grid-template-columns: 1fr;
align-items: center;
gap: 2.5rem;
padding: 12vh 2rem 0;
}
.archive__title {
margin-top: 1.5rem;
max-width: 11ch;
font-size: clamp(2.25rem, 5.5vw, 4.5rem);
}
.archive__index { display: none; }
.list-head {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid rgb(var(--ink) / 0.15);
padding-bottom: 0.75rem;
color: rgb(var(--ink) / 0.5);
}
.wl-row {
display: flex;
width: 100%;
align-items: baseline;
gap: 1.25rem;
border-bottom: 1px solid rgb(var(--ink) / 0.12);
padding: 1.25rem 0;
text-align: left;
opacity: 0.6;
transition: padding 0.3s ease, opacity 0.3s ease;
}
.wl-row[data-live='true'] {
padding-left: 0.75rem;
opacity: 1;
}
.wl-row__n {
width: 1.5rem;
flex-shrink: 0;
color: rgb(var(--ink) / 0.4);
transition: color 0.3s ease;
}
.row[data-live='true'] .wl-row__n { color: rgb(var(--amber)); }
.wl-row__main {
min-width: 0;
flex: 1;
}
.wl-row__title {
display: block;
font-size: clamp(1.05rem, 1.8vw, 1.5rem);
}
.wl-row__sub {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
font-weight: 300;
color: rgb(var(--ink) / 0.6);
}
.wl-row__where {
flex-shrink: 0;
color: rgb(var(--ink) / 0.6);
}
.archive__foot {
margin-top: 1.25rem;
color: rgb(var(--ink) / 0.45);
}
/* All three stay mounted and cross-fade, so swapping never flashes an
empty frame while the next file decodes. */
.preview {
display: none;
position: relative;
aspect-ratio: 3 / 4;
width: 100%;
overflow: hidden;
border-radius: 1.25rem;
}
.preview img {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.5s ease;
}
.preview img.is-on { opacity: 1; }
.preview__foot {
pointer-events: none;
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 1rem;
background: linear-gradient(to top, rgb(var(--void)), rgb(var(--void) / 0.7) 50%, transparent);
}
@media (min-width: 768px) {
.archive-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
padding: 0 4rem;
}
.archive__index { display: block; }
}
@media (min-width: 1024px) {
.archive-grid {
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) minmax(0, 0.6fr);
gap: 3rem;
padding: 0 6rem;
}
.preview { display: block; }
}
/* ---- Stage 4: a plate ------------------------------------------- */
.wl-stage--plate { height: 230vh; }
.plate-stage__title {
margin-top: 1.25rem;
max-width: 11ch;
font-size: clamp(2rem, 5.5vw, 4.75rem);
}
.plate-stage__body { margin-top: 1.5rem; }
/* ---- Stage 5: the records grid ---------------------------------- */
.wl-stage--records { height: 240vh; }
.records {
position: relative;
z-index: 10;
display: flex;
height: 100%;
flex-direction: column;
justify-content: center;
padding: 0 1.5rem;
}
/* The head has to clear the fixed chrome. On a phone this stage now
fills its pin from the top down, and the title was arriving under the
logo — the archive grid pads itself off the header for the same
reason. The cards give the space back: they are capped to their row. */
@media (max-width: 767px) {
.records { padding-top: 5rem; }
}
@media (min-width: 768px) {
.records { padding: 0 2.5rem; }
}
.records__head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1.5rem;
}
.records__title { font-size: clamp(1.6rem, 3vw, 2.6rem); }
.filters {
display: flex;
gap: 0.25rem;
border-radius: 999px;
padding: 0.25rem;
}
.filters button {
border-radius: 999px;
padding: 0.5rem 1rem;
white-space: nowrap;
color: rgb(var(--ink) / 0.65);
transition: background-color 0.3s ease, color 0.3s ease;
}
.filters button[aria-pressed='true'] {
background: rgb(var(--ink));
color: rgb(var(--void));
}
/* Three chips at full padding are 275px wide. A 320px phone offers 257
of them — 320 less the two 1.5rem gutters and the 15px the stable
scrollbar gutter reserves — and the pin clips rather than scrolls, so
"stills" was simply gone off the edge. */
@media (max-width: 359px) {
.filters button { padding: 0.5rem; }
}
.records__grid {
margin-top: 2.5rem;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
min-height: 0;
grid-auto-rows: minmax(0, 1fr);
align-items: center;
--par: 70;
transform: translate3d(calc((var(--p) - 0.5) * var(--par) * 1px), 0, 0);
}
@media (max-width: 767px) {
.records__grid { --par: 24; }
}
@media (min-width: 640px) {
.records__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.records__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.card {
position: relative;
aspect-ratio: 4 / 5;
max-height: 100%;
width: 100%;
overflow: hidden;
border-radius: 1rem;
text-align: left;
}
.card[hidden] { display: none; }
.card img {
height: 100%;
width: 100%;
object-fit: cover;
transition: transform 0.7s ease;
}
.card:hover img { transform: scale(1.05); }
.card__foot {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 1.25rem;
background: linear-gradient(to top, rgb(var(--void)), rgb(var(--void) / 0.75) 50%, transparent);
}
.card__title { font-size: 1.25rem; }
.card__kind { color: rgb(var(--ink) / 0.6); }
/* ---- Stage 6: the dispatch -------------------------------------- */
.wl-stage--dispatch { height: 220vh; }
.dispatch {
position: relative;
z-index: 10;
display: grid;
height: 100%;
grid-template-columns: minmax(0, 1fr);
align-items: center;
gap: 2.5rem;
padding: 0 2rem;
}
@media (min-width: 768px) {
.dispatch {
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 4rem;
padding: 0 4rem;
}
}
@media (min-width: 1024px) {
.dispatch { padding: 0 6rem; }
}
.dispatch__title {
margin-top: 1.5rem;
max-width: 11ch;
font-size: clamp(2.25rem, 5.5vw, 4.5rem);
}
.dispatch__body { margin-top: 1.75rem; }
.dispatch__type { color: rgb(var(--ink)); }
.signup {
margin-top: 2.25rem;
display: flex;
max-width: 28rem;
align-items: center;
gap: 0.5rem;
}
.signup input {
min-width: 0;
flex: 1;
border-radius: 999px;
background: transparent;
padding: 0.875rem 1.25rem;
font: inherit;
font-size: 0.875rem;
color: rgb(var(--ink));
outline: none;
}
.signup input::placeholder { color: rgb(var(--ink) / 0.4); }
.signup input:focus { border-color: rgb(var(--ink) / 0.3); }
.signup button {
flex-shrink: 0;
border-radius: 999px;
background: rgb(var(--ink));
color: rgb(var(--void));
padding: 0.875rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
transition: transform 0.3s ease;
}
.signup button:hover { transform: translateY(-2px); }
.dispatch__small {
margin-top: 0.75rem;
color: rgb(var(--ink) / 0.45);
}
.post {
display: flex;
align-items: baseline;
gap: 1.25rem;
border-bottom: 1px solid rgb(var(--ink) / 0.12);
padding: 1.25rem 0;
transition: padding 0.3s ease;
}
.post:hover { padding-left: 0.75rem; }
.post__n {
width: 1.5rem;
flex-shrink: 0;
color: rgb(var(--ink) / 0.4);
}
.post__main {
min-width: 0;
flex: 1;
}
.post__title {
display: block;
font-size: clamp(1.05rem, 1.7vw, 1.4rem);
}
.post__kind {
display: block;
margin-top: 0.25rem;
color: rgb(var(--ink) / 0.5);
}
.post__read {
flex-shrink: 0;
color: rgb(var(--ink) / 0.6);
}
.dispatch__aside {
display: none;
margin-top: 2rem;
height: 10rem;
overflow: hidden;
border-radius: 1rem;
}
.dispatch__aside img {
height: 100%;
width: 100%;
object-fit: cover;
}
@media (min-width: 1024px) {
.dispatch__aside { display: block; }
}
/* Short viewports — a 568px phone, any phone on its side — keep every
element and give up the rhythm instead. The dispatch is the tallest
column on the page: on a 320x568 screen it ran 56px past a pin that
clips, which took the last post and the small print with it. Nothing
here hides anything; the gaps are what shrink. */
/* And on a phone lying down the column stack is 110px longer than the
pin however tight the rhythm gets, so it takes the wide layout for the
same reason the plates panel does. */
@media (max-height: 520px) and (min-width: 600px) {
.dispatch {
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 2rem;
}
}
@media (max-height: 620px) {
.dispatch { gap: 1.25rem; }
.dispatch__title { margin-top: 0.75rem; }
.dispatch__body { margin-top: 1rem; }
.signup { margin-top: 1rem; }
.post { padding: 0.75rem 0; }
}
```
Note: the server reformats CSS on save, so the stored copy is ~49KB against the ~48KB you send.
It renders identically — the byte difference is whitespace, not meaning.
## Integrity (sha256 of each block's exact text)
| Block | sha256 |
|---|---|
| B1 `site_before_head` | `30ea0847b3f7538b471bb821603fbd60ee6d14f5683fdc8b8d65381b54774c3b` |
| B2 `site_before_body` | `d848410de084a24dfd8f440797c83836d350af3ec0df482dad4e39df287f4f7f` |
| B3 `wilder.js` (matches the served file) | `20e41510f33a4752eb01b196c599497b250d1322961efde855f2f68ddd8859b6` |
| B4 `wilder-app.js` (matches the served file) | `9eb8567760dad70749f42d856792a57411799bc03301a62f6abf51a6d838f594` |
| B5 section `html` (renamed to `wl-*`, RULE 7 guards) | `b2daa9b96bd6a3a850bed24962262ec69dabfd1b98be303f78f49cbcfc671268` |
| B6 section `css` (input; stored copy is reformatted) | `0ad4c4f3fbac32daaa0e3a49a1173349a12d11668ab704c4e35626aff53f97b7` |
---
# APPENDIX C · THE PREVIEW VIDEO
The 1 MB WebM walkthrough, in case it needs redoing.
**Record** with Playwright at **deviceScaleFactor 1** — at 2 the recorder drops frames and the
result judders. This page animates *as you scroll*, so holding still freezes it mid-move: scrub
each stage through its own travel instead.
```js
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1, recordVideo: { dir: DIR, size: { width: 1280, height: 720 } } });
// per stage: scrollTo from s.offsetTop to s.offsetTop + s.offsetHeight - innerHeight
// linearly over ~2s (the stage IS the easing), then a ~0.4s beat before the next.
```
**Encode** (two-pass VP9; the grain overlay is the most expensive thing on screen at this bitrate,
so it is filtered out before scaling):
```
-ss <after the page settles> -to <end>
-vf hqdn3d=2:1.5:6:6,scale=960:540:flags=lanczos,unsharp=3:3:0.6:3:3:0.0
-c:v libvpx-vp9 -b:v 425k -minrate 212k -maxrate 850k -bufsize 2125k
-row-mt 1 -tile-columns 1 -auto-alt-ref 1 -lag-in-frames 25 -arnr-maxframes 15
-arnr-strength 5 -aq-mode 0 -deadline good -cpu-used 0 -g 300 -an -pass 1|2
```
Result: 960×540, 18.2s, **966,181 bytes**. Size scales linearly with `-b:v`, so pick the bitrate
from the clip length: `bytes ≈ b:v × seconds ÷ 8`.