# Vellum

> **Category:** Landing Page
> **Effect:** Huge serif type bleeds into view through slowly expanding radial ink masks on warm cotton paper
> **Platform:** cmd-code (`cmdcode`)
> **Model:** `xiaomi/mimo-v2.6-pro` — Xiaomi MiMo v2.6 Pro (xiaomi/mimo-v2.6-pro)
> **License:** Free / MIT — copy, paste, ship.
> **Self-contained:** yes — one `index.html`, no build step, no external media.

---

## 🎬 Live demo

Open [`public/demos/vellum/index.html`](../public/demos/vellum/index.html) — generated by the model
above from the prompt below, with no human edits to the markup.

## ✅ Verification

<!-- VERIFICATION:START -->
_Pending — this block is written by `tools/verify.mjs` after the build run._
<!-- VERIFICATION:END -->

## 📋 Prompt

```text
Build one complete, self-contained `index.html` for **Vellum** — a luxury letterpress and print studio landing page. Document title: `Vellum — Print That Outlives Us`. `<html lang="en">`. ONE file only: plain HTML + CSS + JS in `<style>`/`<script>`. No frameworks, no bundlers, no external media (no images, no video, no audio, no base64 blobs, no CDN scripts, no fonts but Google Fonts). Every pixel comes from CSS gradients, inline SVG, text, or `<canvas>`. This is the library's light page: warm off-white cotton ground, near-black warm ink, one spot-colour oxide red as a second ink pass.

**Viewport rule:** at `>= 901px` the page is one frame: `html, body { height:100%; overflow:hidden }` and `.page { height:100dvh; overflow:hidden }`. NO SCROLL on desktop. At `<= 900px` the page may scroll vertically. No horizontal overflow at any width — checked at 390x844, 768x1024, 1440x900, 1920x1080.

**Do NOT build:** no video, no WebGL, no Three.js, no Lottie, no GSAP, no jQuery, no Tailwind, no React, no images (remote or base64), no audio, no forms, no pricing tables, no testimonials, no carousel, no cookie banner, no scroll indicator, no desktop hamburger (menu only at `<=900px`), no sections beyond header / hero / footer, no footer links beyond the three specified, no dark mode toggle, no backdrop blur stronger than 14px.

---

## A. WHAT IS BEING BUILT

A single-viewport landing page for a Lisbon letterpress studio called **Vellum**. Warm off-white cotton ground (`#f2ece2`), six soft ink blooms drifting beneath the surface on a low-resolution `<canvas>` upscaled and blurred by CSS, a hairline rule system (96px horizontal / 160px vertical grid at 4–5% ink, one major vertical rule, four corner registration marks), and a paper-grain overlay in multiply blend. The composition carries a mono eyebrow, a giant Fraunces serif H1 in two lines, a lead paragraph, two squared buttons, and a specimen panel with ink swatches. The signature effect is the **ink-bleed reveal**: each H1 glyph is an inline-block span whose radial-mask solid radius animates 0% → 124%, so every letter appears as a blot of ink spreading outward through paper fibre, feathered at the edge, staggered left-to-right.

---

## B. THE BACKDROP — paper, blooms, rules, grain

**B1. `.paper`** — `position:fixed; inset:0; z-index:0; pointer-events:none`, `background-color: #f2ece2` plus four stacked gradients (first listed = frontmost):
1. `radial-gradient(ellipse 70% 52% at 18% 8%, rgba(255,252,246,0.92) 0%, rgba(255,252,246,0.34) 38%, transparent 68%)` — light source, top left.
2. `radial-gradient(ellipse 90% 70% at 82% 96%, rgba(120,100,74,0.16) 0%, rgba(120,100,74,0.06) 42%, transparent 74%)` — warm shade, bottom right.
3. `linear-gradient(168deg, #f6f1e8 0%, #f2ece2 46%, #ece4d7 100%)` — stock tone.
4. `linear-gradient(180deg, rgba(23,19,15,0.05) 0%, transparent 14%, transparent 86%, rgba(23,19,15,0.07) 100%)` — edge burn.

**B2. `canvas#bloom`** — `position:fixed; inset:0; width:100%; height:100%; z-index:1; display:block; pointer-events:none; filter: blur(30px); opacity:0.86; mix-blend-mode: multiply`.

**Resolution trick (critical):** the canvas never renders at device resolution. Backing store `iw = Math.max(160, Math.round(innerWidth * 0.28))`, `ih = Math.max(120, Math.round(innerHeight * 0.28))`; `canvas.width = iw; canvas.height = ih`. Dpr is deliberately NOT applied — the CSS stretch plus the single static `filter: blur(30px)` does all smoothing. At 1440x900 the store is 403x252.

**Offscreen blobs (drawn once at init, never per frame):** two 96x96 canvases, each a single radial gradient from centre (48,48) radius 48:
```
ink   : rgba(23,19,15,1) 0%, rgba(23,19,15,0.62) 34%, rgba(23,19,15,0.24) 62%, rgba(23,19,15,0) 100%
oxide : rgba(176,74,42,1) 0%, rgba(176,74,42,0.58) 34%, rgba(176,74,42,0.22) 62%, rgba(176,74,42,0) 100%
```

**The six blooms** — `bx, by` normalised, `r` a fraction of `min(iw,ih)`, `a` the globalAlpha, `P` the drift period in seconds:

```
#  blob   bx    by    r     a     P     phase   ax    ay
1  ink    0.18  0.26  0.52  0.16  23s   0.0     0.06  0.05
2  ink    0.74  0.18  0.46  0.13  31s   1.7     0.05  0.06
3  oxide  0.46  0.72  0.58  0.20  27s   3.1     0.07  0.04
4  ink    0.88  0.62  0.40  0.11  37s   4.4     0.04  0.05
5  oxide  0.12  0.78  0.44  0.15  41s   2.2     0.06  0.05
6  ink    0.60  0.40  0.62  0.09  47s   5.6     0.05  0.04
```

Per frame, `t` in seconds, with `w1 = 6.2832 / P`, `w2 = w1 / 1.37`, `w3 = w1 / 0.61` baked per bloom at init: `x = (bx + ax * Math.sin(t * w1 + phase)) * iw`, `y = (by + ay * Math.sin(t * w2 + phase * 0.6)) * ih`, `s = r * Math.min(iw, ih) * (1 + 0.12 * Math.sin(t * w3))`, then `ctx.globalAlpha = a; ctx.drawImage(blob, x - s/2, y - s/2, s, s)`. No division in the loop.

**Per-frame cost budget:** 1 `clearRect` + 6 `drawImage`. No `createRadialGradient`, no `ctx.filter`, no `ctx.shadowBlur`, no `getBoundingClientRect`, no path building inside the loop. Six blits of ~210x210 onto a 403x252 target, under 1.4 ms, never more than 8 canvas draw calls per frame.

**B3. `.rules`** — `position:fixed; inset:0; z-index:2; pointer-events:none`, background `repeating-linear-gradient(180deg, rgba(23,19,15,0.055) 0 1px, transparent 1px 96px), repeating-linear-gradient(90deg, rgba(23,19,15,0.040) 0 1px, transparent 1px 160px)`, masked so the grid dies at the frame edge with `radial-gradient(ellipse 78% 66% at 50% 44%, #000 0%, #000 40%, rgba(0,0,0,0.30) 72%, transparent 100%)` — set both `-webkit-mask-image` and `mask-image`.

**B4. `.rule-v`** — the one major rule: `position:fixed; left:8.6vw; top:0; bottom:0; width:1px; z-index:2; background: linear-gradient(180deg, transparent 0%, rgba(23,19,15,0.22) 11%, rgba(23,19,15,0.22) 89%, transparent 100%)`.

**B5. `.marks`** — four 18x18 registration crosshairs at `inset: 26px` (all corners), `z-index:2`, `opacity:0.5`, background an inline SVG data URI: a 6.5r circle plus a full cross, `stroke='%2317130F' stroke-opacity='.38' stroke-width='.8'`. Decorative: `aria-hidden="true"`.

**B6. `.sheen`** — `position:fixed; inset:-20% -60%; z-index:3; pointer-events:none; background: linear-gradient(104deg, transparent 0%, rgba(255,255,255,0.40) 46%, transparent 60%, transparent 100%); opacity:0.28; animation: sheen 34s cubic-bezier(0.45,0,0.55,1) infinite`; keyframes translate `-28% → 28%` on X only.

**B7. `.vign`** — `position:fixed; inset:0; z-index:4; pointer-events:none; background: radial-gradient(ellipse 92% 84% at 50% 46%, transparent 0%, transparent 52%, rgba(60,48,34,0.07) 78%, rgba(60,48,34,0.14) 100%)`.

**B8. `.grain`** — `position:fixed; inset:-50%; width:200%; height:200%; z-index:40; pointer-events:none; opacity:0.11; mix-blend-mode: multiply`, background an inline SVG `feTurbulence` data URI (`type="fractalNoise" baseFrequency="0.82" numOctaves="3" stitchTiles="stitch"` + `feColorMatrix type="saturate" values="0"`), `background-size: 180px`, animated `grain 1.05s steps(4) infinite` jittering `transform: translate` through `(0,0) (-1.5%,1%) (1%,-1.5%) (-1%,1.5%)`.

---

## C. LAYER STACK (back → front)

1. `html, body` — `#f2ece2`, forced twice (see Failure Guards).
2. `.paper` — z-index 0, the four gradients.
3. `canvas#bloom` — z-index 1, blurred ink blooms, multiply.
4. `.rules` + `.rule-v` + `.marks` — z-index 2, hairline system, all `pointer-events:none`.
5. `.sheen` — z-index 3.
6. `.vign` — z-index 4.
7. `.page` — z-index 10, `position:relative; display:grid; grid-template-rows: auto minmax(0,1fr) auto; min-height:100dvh` (height:100dvh inside the desktop lock).
8. `header` — z-index 30; `main` — z-index 20; `footer` — z-index 30.
9. `.grain` — z-index 40, above everything, `pointer-events:none`.
10. `.menu` (mobile overlay, only `<=900px`) — z-index 80 when open.

---

## D. TOKEN TABLE (defaults at 1440x900)

```
--paper:        #f2ece2
--paper-2:      #e9e1d5
--paper-hi:     #fbf7f0
--ink:          #17130f
--ink-soft:     #3a322b
--muted:        #6e6357
--muted-2:      #9a8e80
--accent:       #b04a2a
--accent-2:     #c08a2e
--accent-wash:  rgba(176,74,42,0.10)
--line:         rgba(23,19,15,0.20)
--line-soft:    rgba(23,19,15,0.10)
--line-hair:    rgba(23,19,15,0.055)
--focus:        #b04a2a
--serif:        "Fraunces", "Times New Roman", Georgia, serif
--mono:         "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
--logo:         20px
--logo-sub:     9.5px
--nav:          13px
--eyebrow:      11.5px
--h1:           104px
--h1-lh:        0.94
--lead:         18px
--btn:          12.5px
--btn-h:        44px
--spec-lg:      34px
--spec-md:      18px
--spec-sm:      11.5px
--foot:         11.5px
--track-caps:   0.18em
--radius:       2px
--header-x:     48px
--header-y:     26px
--header-h:     46px
--page-x:       48px
--hero-y:       36px
--hero-gap:     22px
--hero-max:     960px
--aside-w:      300px
--gap-col:      56px
--footer-x:     48px
--footer-y:     24px
--grain-op:     0.11
--ease-out:     cubic-bezier(0.16,1,0.3,1)
--ease-ink:     cubic-bezier(0.33,0.02,0.28,1)
--enter-dur:    0.9s
--bleed-dur:    0.72s
```

Fonts (Google Fonts ONLY, with the fallback stacks above): one `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>` and one stylesheet `https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..700&family=IBM+Plex+Mono:wght@400;500&display=swap`. This is the document's only network request.

---

## E. THE COMPOSITION — exact build order

`.page` is the grid container with three rows: `<header>`, `<main>`, `<footer>`.

**HEADER** — `height: var(--header-h); padding: var(--header-y) var(--header-x) 0; display:flex; align-items:center; justify-content:space-between; gap:24px`.
- Left: `a.brand` (href `#`, accessible name "Vellum home"): `vellum` in `var(--serif)`, `font-size: var(--logo); font-weight:500; letter-spacing:-0.015em; color: var(--ink)`, then `<span class="brand-sub">EST. 1974</span>` in `var(--mono)`, `font-size: var(--logo-sub); letter-spacing: var(--track-caps); color: var(--muted-2); vertical-align: super; margin-left:8px`.
- Centre: `nav` with exactly 3 links — `Works`, `Process`, `Stock` — each `href="#"`, `position:relative`, `var(--serif)`, `font-size: var(--nav); letter-spacing:0.01em; color: var(--ink-soft); text-decoration:none`, `display:flex; gap:34px; align-items:center`. Hairline underline `::after { content:""; position:absolute; left:0; right:0; bottom:-6px; height:1px; background: var(--ink); transform: scaleX(0); transform-origin: 0 50%; transition: transform 0.28s var(--ease-out) }`; `:hover::after, :focus-visible::after { transform: scaleX(1) }`.
- Right: `a.cta` — see section F; text `Request a proof`.

**MAIN (hero)** — `display:grid; grid-template-columns: minmax(0,1fr) var(--aside-w); gap: var(--gap-col); align-items:center; padding: var(--hero-y) var(--page-x); max-width: var(--hero-max); margin: 0 auto; width:100%`. Left column `.hero-copy { display:flex; flex-direction:column; gap: var(--hero-gap); min-width:0 }`:

1. `.eyebrow` — `var(--mono)`, `font-size: var(--eyebrow); letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--muted)`. Copy: `Letterpress · Hot foil · Hand-set type`, preceded by a 34px `<span class="tick">` hairline (1px tall, `background: var(--line)`, `display:inline-block; vertical-align:middle; margin-right:12px`).
2. `h1` — EXACTLY ONE `<h1>`, `var(--serif)`, `font-weight:400; font-size: var(--h1); line-height: var(--h1-lh); letter-spacing:-0.035em; color: var(--ink); margin:0`, `aria-label="Print that outlives us."`. Two `.line` blocks (`display:block`) separated by one whitespace text node, so `textContent` reads `Print that outlives us.` while each line renders on its own row: Line 1 `Print that`, Line 2 `outlives us.`. Inside each line every glyph is `<span class="ltr">X</span>`; words are wrapped in `<span class="word">` (`display:inline-block; white-space:nowrap`) with a space text node between them. Letters of `outlives` take `ltr--spot` (`color: var(--accent)`) — the second ink pass. Spaces are NOT spans. See section G for the bleed.
3. `.lead` — `var(--serif)`, `font-size: var(--lead); line-height:1.58; letter-spacing:-0.012em; color: var(--ink-soft); max-width: 52ch; margin:0`. Copy: `Vellum sets, presses and binds limited editions by hand — one sheet at a time, on presses that have not stopped running since 1974.`
4. `.actions { display:flex; gap:12px; flex-wrap:wrap }` — `a.btn.btn--ink` text `Request a proof`, then `a.btn.btn--ghost` text `See the stock`.

Right column `aside.specimen` — `border:1px solid var(--line-soft); background: linear-gradient(180deg, rgba(255,255,255,0.52) 0%, rgba(233,225,213,0.30) 100%); border-radius: var(--radius); padding: 22px 22px 18px; display:flex; flex-direction:column; gap:14px`, labelled by `<h2 class="spec-title">Specimen 04</h2>` (`var(--mono); font-size:11.5px; letter-spacing:var(--track-caps); text-transform:uppercase; color:var(--muted); margin:0; font-weight:400`) — an `h2`, never an `h1`, the only other heading. Each `.spec-row` is `display:flex; align-items:baseline; justify-content:space-between; gap:12px`; each `.rule` is `height:1px; background: var(--line-soft); transform-origin:0 50%`:
- Row 1: `<span class="spec-lg">Aa</span>` (`var(--serif); font-size: var(--spec-lg); font-weight:300; letter-spacing:-0.02em; color:var(--ink)`) + right-aligned `<span class="spec-tag">96 pt</span>` (`var(--mono); font-size: var(--spec-sm); color: var(--muted-2); letter-spacing:0.06em`).
- `.rule`.
- Row 2: `<span class="spec-md">Hand-set</span>` at `var(--spec-md)` + tag `24 pt`.
- `.rule`.
- Row 3: `<span class="spec-sm">Cotton · 600 gsm</span>` (`var(--mono); font-size: var(--spec-sm); letter-spacing:0.08em; color: var(--ink-soft)`) + tag `Ed. 90`.
- `.swatches { display:flex; gap:8px }` — 4 chips, each `width:26px; height:26px; border:1px solid rgba(23,19,15,0.18)`, backgrounds `#17130f`, `#b04a2a`, `#c08a2e`, `#e9e1d5`; the row is `aria-hidden="true"` (decorative, not a control).

**FOOTER** — `padding: var(--footer-y) var(--footer-x); display:flex; align-items:center; justify-content:space-between; gap:20px; border-top:1px solid var(--line-soft)`. Left: `var(--mono)`, `font-size: var(--foot); color: var(--muted)`: `© 2026 Vellum Press · Lisbon`.
- Centre: `ul.meta` with exactly 3 `li` — `600 gsm cotton`, `Editions of 90`, `Foil &amp; letterpress` — `display:flex; gap:0; list-style:none; margin:0; padding:0`; each `li` is `var(--mono); font-size: var(--foot); color: var(--muted); padding: 0 14px`, `border-left:1px solid var(--line-soft)` on all but the first and `border-right` on all but the last.
- Right: exactly 3 links — `instagram`, `contact`, `studio` — `href="#"`, `var(--mono); font-size: var(--foot); color: var(--ink-soft); text-decoration:none`, `display:flex; gap:22px`, hover `color: var(--accent)`. Each link's text is its accessible name.

---

## F. THE UI LANGUAGE — squared, letterpress, hairline

Two button treatments, both `border-radius: var(--radius)` (2px — print is square), `height: var(--btn-h); padding: 0 20px; display:inline-flex; align-items:center; justify-content:center; font-family: var(--mono); font-size: var(--btn); letter-spacing:0.06em; text-transform: uppercase; text-decoration:none`, and `transition: background 0.22s var(--ease-out), color 0.22s var(--ease-out), border-color 0.22s var(--ease-out), transform 0.22s var(--ease-out)`.

- `.btn--ink`: `color: var(--paper-hi); border:1px solid var(--ink); background: linear-gradient(180deg, #241d17 0%, #17130f 58%, #0e0b09 100%); box-shadow: 0 14px 34px rgba(23,19,15,0.24), inset 0 1px 0 rgba(255,255,255,0.10)`. Hover `background: linear-gradient(180deg, #302720 0%, #1e1813 58%, #12100d 100%); transform: translateY(-1px)`. Active `transform: translateY(0)`.
- `.btn--ghost`: `color: var(--ink); border:1px solid var(--line); background: linear-gradient(180deg, rgba(255,255,255,0.46) 0%, rgba(233,225,213,0.16) 100%)`. Hover `border-color: var(--ink); background: linear-gradient(180deg, rgba(255,255,255,0.70) 0%, rgba(233,225,213,0.30) 100%); transform: translateY(-1px)`.
- `.cta` in the header: `height:34px; padding:0 14px; font-size:11.5px`, otherwise `.btn--ghost` but `color: var(--accent); border-color: rgba(176,74,42,0.42); background: var(--accent-wash)`; hover `border-color: var(--accent); background: rgba(176,74,42,0.16)`.

Every `a` and `button`: `:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--radius) }`. No default blue ring; no `text-decoration` but the nav hover hairline. `::selection { background: rgba(176,74,42,0.22); color: var(--ink) }`.

---

## G. MOTION — entrance stagger + named keyframes

Resting state first: `.appear { opacity: 1 }`. Entrance: `animation-duration: var(--enter-dur); animation-fill-mode: both; animation-timing-function: var(--ease-out); animation-delay: var(--d, 0s)`. Each animated element carries `.appear`, a modifier class, and an inline `--d`.

The ink-bleed animates a registered custom property driving a radial mask:
```
@property --bleed { syntax: "<percentage>"; inherits: false; initial-value: 124%; }
.ltr { --bleed: 124%; --ox: 50%; --oy: 58%; display:inline-block;
  -webkit-mask-image: radial-gradient(circle at var(--ox) var(--oy), #000 0%, #000 var(--bleed),
    rgba(0,0,0,0.62) calc(var(--bleed) * 1.14), rgba(0,0,0,0.20) calc(var(--bleed) * 1.30), transparent calc(var(--bleed) * 1.46));
  mask-image: identical to -webkit-mask-image }
@keyframes in-bleed { from { --bleed: 0%; opacity: 0.55 } to { --bleed: 124%; opacity: 1 } }
```
`.ltr` also sets `--ox`/`--oy` per glyph from this six-origin cycle, in document order, restarting each line: `(34%,62%) (58%,44%) (46%,70%) (66%,52%) (38%,50%) (54%,66%)`. `.ltr--spot` letters add `+0.22s` to their delay — the oxide pass lands after the black pass.

Stagger table (element → modifier → `--d`):
```
brand                appear--fade   0.06s
nav Works            appear--fade   0.12s
nav Process          appear--fade   0.18s
nav Stock            appear--fade   0.24s
header cta           appear--rise   0.30s
eyebrow              appear--fade   0.34s
h1 line 1 letters    appear--bleed  0.46s + i*0.034s  (i = 0..8 — 9 glyphs)
h1 line 2 letters    appear--bleed  0.62s + i*0.030s  (i = 0..10 — 11 glyphs; +0.22s if .ltr--spot)
lead                 appear--soft   1.18s
btn--ink             appear--rise   1.30s
btn--ghost           appear--rise   1.38s
specimen panel       appear--soft   1.02s
spec row 1/2/3       appear--soft   1.10s / 1.18s / 1.26s
footer left          appear--fade   1.46s
footer centre        appear--fade   1.52s
footer right         appear--fade   1.58s
```
`.appear--bleed` uses `animation-duration: var(--bleed-dur); animation-timing-function: var(--ease-ink)`.

Named keyframes (all end at opacity 1 / identity transform):
```
in-fade   from { opacity: 0 }                                  to { opacity: 1 }
in-soft   from { opacity: 0; transform: translateY(12px) }      to { opacity: 1; transform: none }
in-rise   from { opacity: 0; transform: translateY(9px) scale(0.985) } to { opacity: 1; transform: none }
in-bleed  from { --bleed: 0%; opacity: 0.55 }                   to { --bleed: 124%; opacity: 1 }
in-rule   from { transform: scaleX(0) }                         to { transform: scaleX(1) }
sheen     0% { transform: translate3d(-28%,0,0) } 50% { transform: translate3d(28%,0,0) } 100% { transform: translate3d(-28%,0,0) }
grain     0% { transform: translate(0,0) } 25% { transform: translate(-1.5%,1%) } 50% { transform: translate(1%,-1.5%) } 75% { transform: translate(-1%,1.5%) } 100% { transform: translate(0,0) }
```
The `.tick` hairline and every specimen `.rule` use `in-rule` (`transform-origin: 0 50%`), delays matching their parent row. Canvas blooms, `sheen` and `grain` run continuously, independent of the entrance stagger.

---

## H. RESPONSIVE TABLE — exact token overrides

```
>= 1600px:
  --h1: 120px  --logo: 21px  --nav: 13.5px  --lead: 19px  --btn: 13px  --btn-h: 46px
  --header-x: 56px  --page-x: 56px  --footer-x: 56px  --hero-max: 1060px
  --aside-w: 320px  --gap-col: 64px  --spec-lg: 38px

>= 1920px:
  --h1: 136px  --logo: 22px  --nav: 14px  --lead: 20px  --btn-h: 48px
  --header-x: 72px  --page-x: 72px  --footer-x: 72px  --hero-max: 1180px
  --aside-w: 340px  --gap-col: 76px  --spec-lg: 42px  --header-y: 30px

1280px – 1599px:
  --h1: 94px  --header-x: 44px  --page-x: 44px  --footer-x: 44px
  --hero-max: 900px  --aside-w: 280px  --gap-col: 48px

901px – 1279px:
  --h1: 72px  --lead: 16.5px  --header-x: 32px  --page-x: 32px  --footer-x: 32px
  --header-y: 22px  --hero-max: 760px  --aside-w: 244px  --gap-col: 36px  --spec-lg: 28px

>= 901px AND max-height 850px:
  --h1: 74px  --hero-y: 24px  --header-y: 18px  --hero-gap: 16px
  --lead: 16px  --spec-lg: 28px  --footer-y: 18px

>= 901px AND max-height 720px:
  --h1: 58px  --hero-y: 14px  --header-y: 12px  --hero-gap: 12px
  --lead: 14.5px  --eyebrow: 11px  --btn-h: 38px  --spec-lg: 24px  --spec-md: 16px
  --footer-y: 12px  --aside-w: 224px

>= 901px (desktop lock, all heights):
  html, body { height:100%; overflow:hidden }   .page { height:100dvh; overflow:hidden }

<= 900px:
  no vh lock; page may scroll; nav becomes a full-viewport overlay menu:
  - header keeps brand + burger: <button class="burger" aria-label="Menu" aria-expanded="false">
    with three 18x1.5px bars in var(--ink), gap 5px
  - .menu { position:fixed; inset:0; z-index:80; background: rgba(242,236,226,0.94);
    backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); display:flex;
    flex-direction:column; align-items:flex-start; justify-content:center;
    padding: 0 32px; gap: 26px; transform: translateY(-100%);
    transition: transform 0.45s var(--ease-out) }   .menu.open { transform: none }
  - menu holds the 3 nav links stacked at 22px serif plus the header cta; Escape,
    link click, and resize to >900px all close it
  - main becomes grid-template-columns: minmax(0,1fr); the specimen aside drops below the
    copy with margin-top: 28px; .rule-v is display:none
  --h1: 46px  --header-x: 24px  --page-x: 24px  --footer-x: 24px  --hero-max: 100%
  .grain opacity stays --grain-op: 0.11

<= 560px:
  --h1: 34px  --lead: 15px  --header-x: 20px  --page-x: 20px  --footer-x: 20px
  --btn-h: 42px  --spec-lg: 26px  --hero-gap: 18px
  footer becomes flex-direction:column; align-items:flex-start; gap:10px
  .meta li:first-child { padding-left: 0 }; .meta wraps with row-gap 6px
  header cta is display:none — it lives inside the menu on mobile
  .swatches chips shrink to 22x22; --grain-op: 0.09; .marks is hidden
```

Canvas constants are responsive too: the 0.28 scale stays; at `<=900px` bloom `r` multiplies by 1.15 (fewer pixels, so blooms must read larger) and `a` by 0.85. The lead's `max-width` is `100%` below 901px. Each block above is a width `@media`; the max-height blocks add `and (min-width: 901px)`, desktop only.

---

## I. FAILURE GUARDS — the page is never blank, never broken

1. **Paper-first:** the FIRST CSS rule is `html, body { background:#f2ece2 !important; color:#17130f }`; the body tag carries `style="background:#f2ece2;color:#17130f"`; a later rule restates `html, body { background: var(--paper) }`.
2. **Resting opacity is 1:** every `.appear` element's un-animated computed style is its final state (opacity 1, no transform). `.ltr` spans default to `--bleed: 124%` — a fully open mask — so a failed animation leaves legible type; if `@property` is unsupported, `mask-image` falls back to `none` and the letters show anyway. `fill-mode: both` only holds the FROM state during the delay.
3. **Two-rAF entrance fallback:** after two nested `requestAnimationFrame` ticks, for each `.appear`, if `el.getAnimations` is missing or reports no animation with `playState` `"running"` or `"finished"`, add `.is-in` to every `.appear`. `.is-in { animation:none !important; opacity:1 !important; transform:none !important; -webkit-mask-image:none !important; mask-image:none !important }`.
4. **Canvas fallback:** if `canvas.getContext('2d')` returns null, add `.no-canvas` to `<html>`; CSS hides `canvas#bloom` and paints static blooms on `.paper::after`: `radial-gradient(circle at 18% 26%, rgba(23,19,15,0.07), transparent 34%), radial-gradient(circle at 46% 72%, rgba(176,74,42,0.09), transparent 36%), radial-gradient(circle at 74% 18%, rgba(23,19,15,0.05), transparent 30%)`. The page reads correctly regardless.
5. **Reduced motion:** inside `@media (prefers-reduced-motion: reduce)`: `*, *::before, *::after { animation:none !important; transition:none !important; scroll-behavior:auto !important }`; force `.appear, .ltr, .tick, .rule` to `opacity:1 !important; transform:none !important; -webkit-mask-image:none !important; mask-image:none !important`; freeze `.sheen` at `opacity:0.14` and stop the grain animation; the loop counts frames but repaints nothing.
6. **Contrast:** body copy `#3a322b` on `#f2ece2` (`> 9:1`), muted mono `#6e6357` (`> 5:1`), never lower. No text opacity below 0.86.
7. **No console output** in the happy path: no `console.*`, no thrown exceptions; every canvas and media-query call guarded.

---

## J. THE EXACT JS (one IIFE at the end of body, nothing else)

1. `window.__DEMO__ = { name: 'vellum', ready: false, frames: 0, effects: [], errors: [] };` then `window.addEventListener('error', e => window.__DEMO__.errors.push(String((e && e.message) || e)))`. Push effect names once, in this order: `'ink-bleed-mask'`, `'ink-bloom-canvas'`, `'hairline-rules'`, `'paper-grain'`.
2. **Cache at startup:** `document.documentElement`, `canvas#bloom`, its 2d context, `.burger`, `.menu`, all `.menu a`, `matchMedia('(prefers-reduced-motion: reduce)')`, `t0 = performance.now()`. No `getBoundingClientRect` anywhere in the file; all sizing reads `window.innerWidth` / `window.innerHeight`.
3. **Blob builder:** `makeBlob(r,g,b)` builds a 96x96 canvas with one `createRadialGradient(48,48,0,48,48,48)` (stops `0 → rgba(r,g,b,1)`, `0.34 → 0.62 alpha`, `0.62 → 0.24 alpha`, `1 → 0 alpha`), fills a 96x96 rect, returns it. Called exactly twice (ink `23,19,15`; oxide `176,74,42`), never in the loop.
4. **resize():** `iw = Math.max(160, Math.round(innerWidth * 0.28))`, `ih = Math.max(120, Math.round(innerHeight * 0.28))`, `canvas.width = iw; canvas.height = ih`. Scheduled only by a boolean `resizeQueued` consumed by one rAF; never a timer, never twice per frame.
5. **draw(t):** `ctx.clearRect(0,0,iw,ih)`; for each of the 6 blooms compute `x, y, s` from the section B table with `Math.sin`, set `ctx.globalAlpha = a`, `ctx.drawImage(blob, x - s/2, y - s/2, s, s)`; reset `globalAlpha = 1`. No gradients, filters, shadows, paths.
6. **Loop:** `function step(now){ window.__DEMO__.frames += 1; var paint = !document.hidden && (!reduced || !painted); if (paint) { draw(reduced ? 6.0 : (now - t0) / 1000); painted = true; window.__DEMO__.ready = true; } requestAnimationFrame(step) }`, kicked off once. `ready` becomes true after the first paint; `frames` increments once per frame; a hidden document counts the tick but skips painting.
7. **Entrance fallback:** two nested `requestAnimationFrame` callbacks, then per `.appear` run the `getAnimations` check from guard 3 and add `.is-in` to all if any check fails.
8. **Menu (mobile only):** burger click toggles `.menu.open` and `aria-expanded`; Escape closes; any menu link click closes; the `resize` handler closes the menu above 900px and sets `resizeQueued`.
9. **Reduced motion:** if the media query matches, `reduced = true`, so `draw(6.0)` runs once, `ready` becomes true, and no further pixels change.

No other JS. No libraries, no timers, no `console` calls, no `fetch`. Final file must be under 90 KB.

---

TECHNICAL CONTRACT (non-negotiable):

1. Deliver ONE single file: a complete, valid `index.html`. No build step, no bundler,
   no framework, no package install. It must open correctly by double-clicking the file
   and also over http://.
2. Vanilla only. No React, no Tailwind CDN, no GSAP, no Three.js, no jQuery, no Lottie.
   Plain HTML + CSS + modern JavaScript in <style> and <script> tags.
3. NO external media. No .mp4, no .webm, no .gif, no remote images, no base64 blobs of
   photos. Every visual must be generated procedurally with CSS gradients, SVG, or
   <canvas>. This keeps the page free to host and impossible to break with a dead link.
4. Google Fonts is the ONLY permitted network request, and it must be declared with a
   <link rel="preconnect"> plus a system-font fallback stack so the page still looks
   intentional offline. If you prefer, skip it and use a pure system stack.
5. Responsive and correct at 390x844 (phone), 768x1024 (tablet) and 1440x900 (desktop).
   No horizontal scrollbar at any of those widths. Nothing may overflow its container.
6. Accessibility: exactly one <h1>; real <header>/<main>/<footer>/<nav> landmarks; every
   interactive element is a <button> or <a> with an accessible name; visible :focus-visible
   ring; body text contrast ratio >= 4.5:1 against its background.
7. Motion safety: wrap all continuous motion in
   `@media (prefers-reduced-motion: reduce)` rules that reduce it to a static or
   near-static state. The page must remain fully readable and usable with motion off.
8. Performance: no layout thrash. Cache DOM reads, batch writes inside a single
   requestAnimationFrame loop, use transform/opacity for animation, and never allocate
   inside the hot loop. Target 60fps on a 2019 laptop. Total file under 90 KB.
9. Runtime telemetry hook (used by the automated verifier — implement it exactly):
   at the top of your script define
     window.__DEMO__ = { name: '<slug>', ready: false, frames: 0, effects: [], errors: [] };
   then set `ready = true` after the first paint of your main effect, increment
   `frames` once per animation frame inside your rAF loop, and push the name of each
   implemented effect into `effects`. Add a `window.addEventListener('error', ...)`
   handler that pushes messages into `errors`. Do not rename these fields.
10. Zero console output in the happy path. No console.log left behind. No uncaught
    exceptions, no failed network requests.

RESPONSE FORMAT:
Return ONLY the raw HTML inside a single ```html fenced code block. No explanation, no
preamble, no commentary before or after the fence. The file must be complete and
immediately runnable — never truncate, never write "// rest of code here".
```

---

*LandingPagePrompts Prompt Library — free, verified, model-attributed.*
