Halden
aadmin2026-11-05 发布Site模板4 阅读

Halden

类型:Site模板 · 收录:2026-11-05 01:12:51 · 源码:会员可见

[hidecontent type="vip1"]
# Recreate this site as a single HTML file: HALDEN

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline in a
`<script type="module">`. **Lenis is the only external code**, pulled through an import map; there
is no three.js and no canvas on this page. The spring solver, the shared ticker, the reveal
observer, the letter/line engine, the viewfinder preloader, the framing cursor, the search panel,
the catalogue band, the room spotlight and every hover are all written by hand. Hardcode every value
given here as a fixed constant.

Where this document quotes source verbatim it is the project's own TypeScript — **strip the type
annotations and use it as it stands**. Every number in it is the shipped default. Nothing in it is
a suggestion.

## What it is

A one-page site for **Halden**, a Danish furniture maker ("timeless furniture /", "Halden Møbler
ApS · Refshalevej 88, 1432 København K"). Six blocks on **warm paper** (`#edeae2`) and **brand red**
(`#de0419`), each a 1440×800 art-directed board transcribed one-to-one in `rem`:

1. **Hero** — "timeless" and "furniture /" in a 240px condensed display face, arriving from opposite
   edges and meeting; five photographic plates uncovering bottom-up, each turning **red under the
   cursor** through a `color-burn` tint that rides up from the bottom edge; captions, prices, a
   bracketed "[ explore collection ]" whose brackets push apart on hover, and a welcome line.
2. **Manifesto** — a red screen with four centred serif lines that **write themselves** letter by
   letter at a steady 9 ms per character, an underline that draws under "considered living," the
   moment the phrase finishes, and two cut-out chairs dropping into gaps the sentence leaves for
   them, each rocking once on its legs when pointed at.
3. **Catalogue** — nine category rows down the centre with a **full-bleed red band** that slides
   onto whichever row is hovered (resting on "Chairs"), the row's type flipping to paper white,
   and two tilted preview plates travelling with the band and cross-fading to that category's
   photographs. Below 1024 the band steps through the rows on a 2.4 s beat while the block is on
   screen.
4. **Products** — a heading sliding in from the left, filter pills that fill with sand from the
   left edge, and four 326×438 cards uncovering top-down, each with a "+" chip that floods red and
   turns a quarter when the card is pointed at.
5. **Room** — one 1376×603 apartment photograph with five shoppable tags; pointing at a tag
   **spotlights** the piece (a radial scrim dims everything but a circle around it) and the other
   tags step out of the way.
6. **Footer** — red again: a newsletter row that lifts "EMAIL" out as a label when it wakes, three
   link columns whose items wipe a white bar behind themselves on hover, legal lines over a
   hairline, and a 192px letter-spaced **HALDEN** wordmark whose letters rise one after another from
   behind a clip.

Over everything: a **fixed header** that recolours itself to whichever block is under it (red type
on paper, white type on red), a **full-screen red search panel** that drops from the top and shows
the collection as product plates, a **viewfinder cursor** — four corner marks that gather around the
pointer and spread open over every photograph — and a **preloader** whose frame IS the progress bar:
four corner ticks grow along the edges as the hero's five photographs decode, close the rectangle
at 100%, then the whole thing opens like a shutter onto the page.

The page is written to a **1440-wide board**: at 1440 the root is 16px and every rem is one board
pixel; four comps (1440, 1024, 768, 390) each scale from their own width, and the differences
between them are re-compositions, not one layout scaled. Above 1440 the root grows 1:1 with the
viewport.

## Page shell & libraries

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Halden</title>
<meta name="description" content="Halden makes furniture for considered living — oak, ash and walnut pieces built to belong in your everyday and stay with you for years. See the 2026 collection.">
<meta name="theme-color" content="#de0419">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap" rel="stylesheet">
<script type="importmap">
{ "imports": { "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs" } }
</script>
<noscript><style>#preloader{display:none}</style></noscript>
```

### Fonts

Three faces, two of them self-hosted and served from the asset bucket (see **Assets**), the third
from Google Fonts:

```css
@font-face { font-family: "Instrument Serif"; font-weight: 400; font-style: normal; font-display: swap;
  src: url("https://storage.getlayers.ai/assets/halden-5875ef9537/fonts/InstrumentSerif-Regular.ttf") format("truetype"); }
@font-face { font-family: "Instrument Serif"; font-weight: 400; font-style: italic; font-display: swap;
  src: url("https://storage.getlayers.ai/assets/halden-5875ef9537/fonts/InstrumentSerif-Italic.ttf") format("truetype"); }
@font-face { font-family: "Bebas Neue"; font-weight: 400; font-style: normal; font-display: swap;
  src: url("https://storage.getlayers.ai/assets/halden-5875ef9537/fonts/BebasNeue-Regular.otf") format("opentype"); }
@font-face { font-family: "Bebas Neue"; font-weight: 700; font-style: normal; font-display: swap;
  src: url("https://storage.getlayers.ai/assets/halden-5875ef9537/fonts/BebasNeue-Bold.otf") format("opentype"); }
```

- **`--font-serif` = "Instrument Serif"** — the workhorse: body copy, captions, product meta,
  catalogue rows, the manifesto, the footer wordmark. Its own word space is **0.17em** (the engine
  below needs that number).
- **`--font-display` = "Bebas Neue"** — hero headline, nav, catalogue counts, the welcome line, the
  preloader label. Its own word space is **0.155em**; the comp's gap around the hero slash is
  0.0333em at 240px. Always used at weight 700 except the search input's placeholder face is the
  same 700.
- **`--font-sans` = "Inter"**, weight 500 — used **only** by the HALDEN® wordmark lockup in the
  header.

`-webkit-font-smoothing: antialiased` on `body`; `body { background: var(--background); color:
var(--foreground); min-height: 100vh; }`.

### Smooth scroll

Lenis at the project's own settings, driven from your one `requestAnimationFrame` loop — **first
thing every frame**, before any scroll reader:

```js
import Lenis from "lenis";
window.scrollTo(0, 0);
const lenis = new Lenis({ smoothWheel: true });   // syncTouch is OFF on this page
window.lenis = lenis;
// inside the shared ticker, before every other subscriber:
lenis.raf(time);
```

Include Lenis's required rules (`html.lenis, html.lenis body { height: auto }`,
`.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain }`,
`.lenis.lenis-stopped { overflow: hidden }`). Whenever the page must not scroll (the preloader is
up, the search panel is open) set `document.body.style.overflow = "hidden"` and clear it after —
that is exactly what the project does; it does not stop Lenis.

### The root font-size — four comps, four bands

```css
html { font-size: 16px; }
@media (max-width: 1440px) { html { font-size: 1.111111vw; } } /* 16 * 100 / 1440 */
@media (max-width: 1024px) { html { font-size: 1.5625vw; } }   /* 16 * 100 / 1024 */
@media (max-width: 768px)  { html { font-size: 2.083333vw; } } /* 16 * 100 / 768  */
@media (max-width: 548px)  { html { font-size: 4.102564vw; } } /* 16 * 100 / 390  */
```

The 548 split is the geometric mean of 390 and 768 (√(390×768) ≈ 547): the 768 layout never
renders below 71%, the 390 layout never above 141%.

Above 1440 a script sets an inline `font-size` on `<html>` and removes it at or below 1440,
re-run through the ticker whenever `window.innerWidth` changes. **This page passes `coef = 1`**
(the starter's default is 0.6666), so the board scales 1:1 with the viewport:

```js
const FONT_BASE = 16, BASE_WIDTH = 1440, COEF = 1;
const interpolateFontSize = (w) => {
  const widthReduction = ((BASE_WIDTH - w) / BASE_WIDTH) * 100;   // negative above 1440
  const fontReduction = widthReduction * COEF;
  return FONT_BASE - (FONT_BASE * fontReduction) / 100;
};
// size > 16 → root.style.fontSize = size + "px"; else root.style.removeProperty("font-size")
```

So 1920 wide gives a 21.33px root.

### Breakpoints and the class vocabulary used below

Three custom variants, all **max-width** queries in px:

| variant | query | which comp |
|---|---|---|
| `at1024:` | `(max-width: 1024px)` | the 1024 comp |
| `at768:` | `(max-width: 768px)` | the 768 comp |
| `at390:` | `(max-width: 548px)` | the 390 comp |

They stack: a value written `at768:` also applies at 390 unless `at390:` overrides it. The layout
below is given in the project's Tailwind utilities with the numbers spelled out where they matter.
Read them as: **1 spacing unit = 0.25rem**; `left-[2rem]` is literal; `-translate-x-full` =
`translate: -100% 0`; `-translate-x-1/2 -translate-y-1/2` = `translate: -50% -50%`;
`-scale-x-100` = `scale: -1 1`; `rounded-image` = `border-radius: 0.125rem` (every photograph);
`rounded-pill` = `6.25rem`; `bg-foreground/35` = the colour at 35% alpha (`color-mix(in oklab,
var(--foreground) 35%, transparent)`); `bg-surface-overlay/92` likewise at 92%; `whitespace-pre`
keeps the double spaces in the legal lines; `[text-box-edge:cap_alphabetic] [text-box-trim:trim-both]`
= `text-box: trim-both cap alphabetic` — **load-bearing on every caption and label**: it trims the
line box to the cap height and baseline so the comp's coordinates land, and it only works on a block
container. Tailwind v4 writes `translate`, `rotate` and `scale` as the separate CSS properties, not
inside `transform` — the springs below write `transform`, so the two never fight.

## Fixed parameters — the whole token block, verbatim

```css
:root {
  --raw-color-white: #ffffff;
  --raw-color-black: #000000;

  /* Halden brand red — nav, hero type, blocks 2 & 6, catalogue band */
  --raw-color-red-600: #de0419;

  /* Warm paper neutrals */
  --raw-color-cream-50: #f4f2ed;
  --raw-color-cream-100: #edeae2;
  --raw-color-sand-200: #d8d3c0;
  --raw-color-stone-500: #6e6a60;
  --raw-color-ink-950: #14130f;

  --raw-duration-fast: 150ms;
  --raw-duration-normal: 250ms;

  --background: var(--raw-color-cream-100);
  --foreground: var(--raw-color-black);

  /* Red surfaces (blocks 2 & 6, catalogue active band) and type on them */
  --surface-accent: var(--raw-color-red-600);
  --foreground-accent: var(--raw-color-red-600);
  --foreground-on-accent: var(--raw-color-white);

  /* Paper-light type used over red or over photography */
  --foreground-on-band: var(--raw-color-cream-50);

  /* Neutral type ramp */
  --foreground-strong: var(--raw-color-ink-950);
  --foreground-muted: var(--raw-color-stone-500);

  /* Scrim laid over the room photograph while one piece is spotlit */
  --scrim: var(--raw-color-ink-950);

  /* Hairlines, chips, pills */
  --rule: var(--raw-color-sand-200);
  --surface-subtle: var(--raw-color-sand-200);
  --surface-overlay: var(--raw-color-cream-50);

  --duration-fast: var(--raw-duration-fast);
  --duration-normal: var(--raw-duration-normal);

  /* Type scale — every size in the Figma comps, at the 1440 design base. */
  --text-display: 15rem;        /* 240px — hero headline */
  --text-wordmark: 12.01425rem; /* 192.228px — footer HALDEN */
  --text-manifesto: 4.875rem;   /* 78px — block 2 statement, the search input */
  --text-section: 3.875rem;     /* 62px — block 4 & 5 headings */
  --text-category: 3.375rem;    /* 54px — catalogue list */
  --text-headline: 2.875rem;    /* 46px — footer heading */
  --text-title: 1.625rem;       /* 26px — product titles, hero welcome & CTA */
  --text-plus: 1.125rem;        /* 18px — hotspot "+" glyph */
  --text-body: 1rem;            /* 16px */
  --text-label: 0.875rem;       /* 14px */
  --text-caption: 0.75rem;      /* 12px */
  --text-micro: 0.625rem;       /* 10px */
  --text-nano: 0.5rem;          /* 8px — registered mark */

  --radius-image: 0.125rem;     /* 2px — every image plate */
  --radius-pill: 6.25rem;       /* 100px — pills, chips, save buttons */

  --leading-display: 1.1;       /* the clip floor for clipped text reveals */
  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
}
```

There is **no dark mode** — the starter's `prefers-color-scheme: dark` override was removed on
purpose; the design is one fixed theme.

## The motion engine you write

Every motion on the page is a spring. The only CSS transitions are the discrete two-state colour
and opacity swaps named as such (`duration-[var(--duration-fast)]` = 150 ms,
`duration-[var(--duration-normal)]` = 250 ms, always `ease-entrance`).

### The spring solver

`@react-spring/web`'s integrator, per animated value, with **mass** — this page's springs carry it:

```js
// per 1 ms step, frame delta capped at 64 ms
const springForce  = -tension * 0.000001 * (position - to);
const dampingForce = -friction * 0.001 * velocity;
velocity += (springForce + dampingForce) / mass;   // dt = 1
position += velocity;
// at rest when |velocity| < precision / 10 and |position - to| <= precision  (precision 0.01)
```

A spring value can be a number or a unit string (`"0%"`, `"inset(100% 0 0 0)"`,
`"translateX(-170px)"`): lerp the numbers inside the string and keep its shape. A spring with
`delay` waits that many ms before starting toward its new target. `immediate` jumps with no motion.
Springs are **declarative**: an element has a `from` and a `to`, a target set twice is a no-op, and
a re-render never restarts a spring. The three presets (`lib/springs/config.ts`, verbatim):

```js
/** The page's motion law: heavy, high-friction — type and plates settle like solid oak. */
const springWeight     = { mass: 2.4, tension: 120, friction: 34 };
/** The same character with less mass, for small type and rules. */
const springWeightSoft = { mass: 1.4, tension: 170, friction: 30 };
/** Per-letter spring for the handwritten statement — snappy, no overshoot. */
const springInk        = { mass: 1,   tension: 420, friction: 34 };
```

Value keys map to style as: `opacity` → `opacity`; `x` / `y` (numbers) → `translate3d(xpx, ypx, 0)`;
`y: "110%"` → `translateY(110%)`; `scaleX`, `scale`, `rotate` (deg) → their transform functions;
`clipPath` → `clip-path`. Compose transforms in the order `translate, scale, rotate`.

### The four element behaviours

- **`Spring`** — animates from `from` to `to` on mount, after `delayIn`. `mode: "always"` on this
  page (the hero headline), which means it plays every time it mounts — and the hero **is**
  remounted by the preloader (below), so it plays once the shutter opens, not behind it.
- **`Inview`** — an `IntersectionObserver` on the element itself (threshold 0, no margin): while
  intersecting it animates to `to` after `delayIn`; `mode: "once"` never goes back; before it has
  ever intersected it sits at `from` **immediately** (no motion). Every entrance on the page below
  the hero is one of these.
- **`Hover`** — a `mouseenter` / `mouseleave` pair on a **trigger** element (often a parent, so the
  whole plate or card or row is the hot zone) animates to `to` while hovered, back to `from` when
  not. **Disabled entirely at or below 768px** (`springsConfig.mobileWidth = 768`): on those widths
  every `Hover` spring sits at `from` for good — the tints, the brackets, the nav wipes, the chair
  rock, the chip turns. The CSS `group-hover` colour swaps beside them still fire on a hover-capable
  device; on a phone neither does.
- **`Handle`** — a cross-fade for content that changes: when its child changes it springs to
  `from` (`opacity 0`), swaps the child at rest, then springs to `to`. If a change arrives while it
  is hidden it swaps immediately and fades in. The catalogue plates use it.

### The shared ticker

One `requestAnimationFrame` loop for the whole page, reference-counted (starts on the first
subscriber, stops when the last leaves). Each subscriber has its own minimum gap in ms and is skipped
while `time - last <= gap`. A subscriber that throws is caught and logged, never allowed to kill the
loop. Lenis first, then the resize watcher (the root font-size above), then everything else.

### The letter / line engine (spring-text-engine's contract, hand-written)

A text block is split into **lines** and **letters**; each animated unit is an `inline-block` span
with its own spring. The container is a **flex row that wraps** (`display: flex; flex-wrap: wrap`),
words as flex items with `column-gap` in `em` between them — so alignment is `justify-content`,
never `text-align` (the project pairs `justify-end text-right` and `justify-center text-center`).
**`columnGap` replaces the real spaces**, and the engine's default of 0.3em is wider than either
face, so every call passes the face's own space: **0.17em for Instrument Serif, 0.155em / 0.24em
for Bebas Neue** (0.24em where the comp measures the CTA's gap). With `overflow` on, each line sits
in a wrapper with `overflow: hidden` whose height comes from `line-height` — **never below 1.1**
there, or descenders are shaved. Each unit has an **In** and an **Out** target; unit *i* is delayed
`delayIn + i × stagger` ms on the way in. `mode: "once"` — plays when the block first intersects the
viewport, never replays. The real sentence is duplicated once in an `sr-only` span; the split units
are `aria-hidden`.

**One caveat, verbatim from the project**: `y` accepts percentages (that is the engine's
clipped-line idiom), but `x` does **not** interpolate them — a percentage there leaves the spring
dead and the line stuck at `opacity: 0`. Horizontal reveals therefore travel in pixels.

The three recipes on the page (`views/home/motion/`, verbatim):

```js
/** LineReveal — the page's default entrance. `up` clips; `left`/`right` meet in the hero. */
const OUT = {
  up:    { y: "110%", opacity: 1 },
  left:  { x: -160, opacity: 0 },
  right: { x: 160, opacity: 0 },
};
const IN = {
  up:    { y: "0%", opacity: 1 },
  left:  { x: 0, opacity: 1 },
  right: { x: 0, opacity: 1 },
};
/** Unclipped fallback (`clip: false`) — pixel offsets, so no line-height floor applies. */
const OUT_SOFT = { up: { y: 18, opacity: 0 }, left: { x: -48, opacity: 0 }, right: { x: 48, opacity: 0 } };
const IN_SOFT  = { up: { y: 0,  opacity: 1 }, left: { x: 0,   opacity: 1 }, right: { x: 0,  opacity: 1 } };
// LineReveal defaults: from "up", stagger 90 (between lines), delay 0, weight "full" (springWeight;
// "soft" = springWeightSoft), columnGap 0.17, clip true (overflow on, the wrappers clipped).

/** LetterReveal — the footer wordmark only: letters rise from behind a clip, one after the next. */
// letterIn { y: "0%", opacity: 1 }, letterOut { y: "110%", opacity: 1 }, letterStagger 90,
// letterConfig springWeight, columnGap 0.17, overflow on.

/** Written — the manifesto's hand: letters land in reading order, snappy, no overshoot. */
// letterIn { opacity: 1, y: 0 }, letterOut { opacity: 0, y: 6 }, letterStagger PACE (9 ms),
// letterConfig springInk, delayIn = start × 9, columnGap 0.17, no clip.
```

### Reduced motion

`prefers-reduced-motion: reduce` sets a global `skipAnimation`: every spring jumps straight to its
end state (react-spring's `useReducedMotion`). The preloader releases the page at once and never
shows; the catalogue's tablet beat does not run; the frame cursor springs to place with no travel.

### The preloader's two signals

Two window events, in this order, and two flags mirroring them for anything that mounts late:

- **`halden:opening`** (`window.__haldenOpening = true`) — the shutter has started to lift. The
  hero's whole content is held `invisible` until this fires and is then **remounted** (every spring
  in it sent back to its start), so the first screen arrives in front of the visitor rather than
  playing out behind the veil. The `halden:ready` event counts as opening too.
- **`halden:ready`** (`window.__haldenReady = true`) — the preloader has unmounted. Nothing else on
  this page waits on it (the project holds only its cookie banner on it).

## The preloader — the viewfinder, variant P4

The frame is the progress bar. Its four corner ticks extend along the edges as the hero's five
photographs decode, and at 100% they meet and close the rectangle. Under it the plain percentage.
What it frames is the site's own content: each photograph drops in as it actually finishes decoding,
one at a time, so the wait is spent looking at furniture. Then it opens: the rails retract back into
corner ticks, the ticks travel outward past the edges and the veil goes with them.

Constants, verbatim:

```js
const FRAME_W = 460;                 // the frame at the 1440 base, px
const FRAME_RATIO = 288 / 460;
const FRAME_MIN = 240;               // never so narrow the photograph stops reading
const FRAME_SHARE = 0.32;            // of the viewport width
const TICK = 15;                     // the resting corner tick, px
const frameFor = (width) => {
  const w = Math.round(Math.max(FRAME_MIN, Math.min(width * FRAME_SHARE, FRAME_W)));
  return { w, h: Math.round(w * FRAME_RATIO) };
};
const STEP_MS = 150;                 // one arrival at a time reads as loading
const MIN_MS = 900;
const MAX_MS = 7000;
const SHOTS = [
  { src: ASSET("hero/modular-sofa.png"),     alt: "Modular sofa" },
  { src: ASSET("hero/collection-chair.png"), alt: "Lounge chair" },
  { src: ASSET("hero/new-arrival-1.png"),    alt: "Sideboard" },
  { src: ASSET("hero/dining-chair.png"),     alt: "Dining chair" },
  { src: ASSET("hero/new-arrival-2.png"),    alt: "Dining table" },
];
```

**Markup**: `#preloader` — `position: fixed; inset: 0; z-index: 100; pointer-events: none`,
`role="status" aria-live="polite" aria-label="Loading Halden"`. Inside: a **veil** (`absolute
inset-0 bg-background`, opacity = `veil`); a **frame** at `left: 50%; top: 50%; transform:
translate(-50%, -50%)` (centred by CSS, nudged by the springs — so the server-rendered frame is
already in the middle) with `width = w`, `height = h`, opacity = `frame`; inside it a content box
`absolute inset-[0.3125rem] overflow-hidden` (opacity = `content`) holding the five shots as
`object-cover` fills, the one at index `shown − 1` at opacity 1 and the rest at 0 (a 250 ms
transition); four corner spans — `tl` `border-l border-t left-0 top-0`, `tr` `border-r border-t
right-0 top-0`, `bl` `border-l border-b bottom-0 left-0`, `br` `border-r border-b bottom-0 right-0`
— each `width = armX`, `height = armY`, its two borders **1.4px** brand red, the other two 0; and a
caption row `absolute -bottom-[2.125rem] left-0 w-full flex items-baseline justify-between
text-foreground-accent` (opacity = `content`): "Halden" in `font-display text-[1.125rem] font-bold
uppercase leading-none tracking-[0.16em]` (cap-trimmed) and the percentage in `font-serif text-body
uppercase leading-none tracking-[0.08em] tabular-nums` (cap-trimmed), rendered as
`${Math.min(100, Math.max(0, Math.round(pct)))}%`.

**One spring set**, all values on `{ mass: 1.6, tension: 150, friction: 26 }` unless a step says
otherwise: `pct 0, w 460, h 288, armX 15, armY 15, content 0, veil 1, frame 1`.

**The run**, verbatim in intent:

```js
if (reducedMotion) { release(); unmount(); return; }
document.body.style.overflow = "hidden";
spring.start({ content: 1 });
let frame = frameFor(innerWidth);  spring.set({ w: frame.w, h: frame.h });
onResize: frame = frameFor(innerWidth); if (!finished) spring.start({ w: frame.w, h: frame.h });

const UNITS = SHOTS.length + 1;            // five photographs + the font faces, one unit each
let fontsReady = false;  document.fonts.ready.then(() => { fontsReady = true; });
let arrived = 0, released = 0, last = 0;
SHOTS.forEach((shot) => { const img = new Image(); img.src = shot.src;
  const mark = () => { arrived += 1; }; img.decode().then(mark, mark); });

const started = performance.now(); let finished = false;
const tick = setInterval(() => {
  const now = performance.now();
  if (!finished && released < arrived && now - last >= STEP_MS) { released += 1; last = now; shown = released; }
  const p = (released + (fontsReady ? 1 : 0)) / UNITS;
  // the number rides a tighter spring than the rails
  spring.start({ pct: p * 100, config: { tension: 260, friction: 34 } });
  // ticks grow along the edges until the four corners meet
  spring.start({ armX: TICK + (frame.w / 2 - TICK) * p, armY: TICK + (frame.h / 2 - TICK) * p });
  const elapsed = now - started;
  const ready = released === SHOTS.length && fontsReady && elapsed >= MIN_MS;
  if (!finished && (ready || elapsed >= MAX_MS)) { clearInterval(tick); finish(); }
}, 40);

function finish() {
  finished = true;
  spring.start({ pct: 100, config: { tension: 320, friction: 34 } });
  // the closed frame snaps once — a tighter target on a loose spring
  const snapW = frame.w - Math.round(frame.w * 0.048);
  const snapH = frame.h - Math.round(frame.h * 0.049);
  spring.start({ w: snapW, h: snapH, armX: snapW / 2, armY: snapH / 2, config: { mass: 1, tension: 320, friction: 15 } });
  setTimeout(() => {   // 300ms — unlock: the rails run back into ticks and the picture lets go
    spring.start({ content: 0, config: { tension: 200, friction: 30 } });
    spring.start({ armX: TICK, armY: TICK, config: { mass: 1, tension: 220, friction: 30 } });
  }, 300);
  setTimeout(() => {   // 520ms — open: the ticks carry past the edges and take the veil with them
    spring.start({ w: innerWidth * 1.6, h: innerHeight * 1.6, config: { mass: 1.4, tension: 130, friction: 30 } });
    spring.start({ veil: 0, config: { tension: 120, friction: 28 } });
    spring.start({ frame: 0, delay: 420, config: { tension: 170, friction: 30 } });
  }, 520);
  setTimeout(open, 620);       // halden:opening — the veil is half gone by ~730ms and clear by ~1080ms
  setTimeout(() => { release(); unmount(); document.body.style.overflow = ""; }, 1420);  // halden:ready
}
```

## The site header — fixed, and it recolours itself

`<header>` `fixed left-0 top-0 z-50 w-full`, height **4.5rem** (`at768:` 4rem, `at390:`
3.1875rem), `border-b-[0.08125rem] border-solid`, `transition-colors 250ms ease-entrance`. Two
tones, decided by **sampling the bar's own midline** on every scroll and resize: if any
`[data-header-accent]` panel (the manifesto and the footer) has `rect.top <= line && rect.bottom >
line`, where `line = header.height / 2`, the bar is **on accent**: `border-foreground-on-accent
bg-surface-accent` with white type; otherwise `border-foreground-accent bg-background` with red
type. It carries an opaque background in the same tone as the block beneath so content passing
under it is covered rather than tangled.

- **Wordmark lockup** — an `<a href="#" aria-label="Halden — home">` at `left-[2rem] top-[1.5rem]`
  (`at1024:` left 1.5rem top 1.6875rem; `at768:` top 1.375rem; `at390:` left 1rem top 0.9375rem),
  `font-sans font-medium` (Inter 500): "HALDEN" `absolute left-0 top-[0.125rem] text-label
  tracking-[0.14em]`, and "®" `absolute left-[4.4375rem] top-0 text-nano`. Both take the bar's tone.
- **Primary nav** (`aria-label="Primary"`) — `absolute left-1/2 top-[calc(50%+0.071875rem)]
  -translate-x-1/2 -translate-y-1/2` (`at768:` `left-[calc(50%-2.8125rem)]`; `at390:` hidden). Its
  `<ul>` is `flex items-center gap-[2.5rem] font-display text-body font-bold tracking-[0.08em]`
  (`at1024:` gap 1.875rem; `at768:` gap 1.25rem; `at390:` gap 1rem `text-label`). Items: Catalogue,
  Rooms, ABOUT, MATERIALS (all `href="#"`), each a **NavItem** with `delay = 120 + index × 70`.
- **Utility nav** (`aria-label="Account and basket"`) — same ul styling, at
  `left-[calc(50%+35.875rem)]` (`at1024:` +24rem; `at768:` +16.625rem; `at390:` +6.171875rem), same
  vertical centring. Items: Search, Saved (3), Bag (0), `delay = 400 + index × 70`. **Search is a
  `<button>`** that opens the search panel; the other two are links.

**NavItem — variant N5.** An `<li>` `group/nav relative isolate shrink-0`, arriving as an `Inview`
(`mode once`, `from { opacity: 0, y: 12 }` → `{ opacity: 1, y: 0 }`, `springWeightSoft`, its
`delayIn`). Inside: a **bar** span `absolute -inset-x-[0.42em] -inset-y-[0.2em] -z-10 block
origin-left` — brand red over paper, **white over red** — driven by a `Hover` on the `<li>` from
`{ scaleX: 0 }` to `{ scaleX: 1 }` on `{ tension: 280, friction: 28 }`; and the label
(`block whitespace-nowrap`, cap-trimmed) whose colour flips on the group hover to the opposite tone
(paper white over the red bar; red over the white bar) with a 150 ms transition. The wipe is the
same gesture the catalogue band makes across a row.

## The search panel — variant S2

A brand-red panel over the whole page: one oversized line to type on and the collection underneath
as full product plates. Before anything is typed it already shows the collection, so it never opens
as an empty red wall.

`<div role="dialog" aria-modal="true" aria-label="Search the collection">` — `fixed inset-0 z-[90]
flex flex-col bg-surface-accent text-foreground-on-accent px-[2rem] pb-[2rem] pt-[1.5rem]`
(`at768:` px/pb 1.5rem; `at390:` px/pb 1rem). It moves on one spring, `y` from **−100** (closed)
to **0** (open) as `translateY(${y}%)`, `{ mass: 1.2, tension: 190, friction: 30 }`. While
**parked** (closed for 700 ms) it is `visibility: hidden` — no clicks, no tab stops; it unparks the
moment it is asked to open, and the input receives focus on the first frame it is visible.
`aria-hidden` mirrors `!open`.

- Top row: `flex shrink-0 items-baseline justify-between font-display text-body font-bold uppercase
  tracking-[0.14em]` — "Halden" and a **Close** button (`hover:opacity-70`, 150 ms).
- Form (`role="search"`, submit prevented): `mt-[2rem] border-b-[0.09375rem] border-solid
  border-foreground-on-accent pb-[0.875rem]`; the input `w-full bg-transparent font-display
  text-manifesto font-bold uppercase leading-none tracking-[0.02em]` (`at1024:` 3.75rem; `at768:`
  3rem; `at390:` 2rem), `placeholder="Search"` at 40% opacity, caret in the current colour, no
  outline, the native search-cancel button hidden, `autocomplete="off"`, with an `sr-only` label.
- Status line, `aria-live="polite"`: `mt-[1rem] font-serif text-body uppercase leading-normal
  tracking-[0.08em] opacity-70` (cap-trimmed): "The collection" while the query is blank, else
  `${n} piece` / `${n} pieces`.
- Results: `mt-[1.25rem] min-h-0 flex-1 overflow-y-auto`, a `<ul>` `grid grid-cols-4 gap-x-[1.5rem]
  gap-y-[2.5rem]` (`at768:` 2 columns, gaps 1rem / 2rem; `at390:` 2 columns, 0.75rem / 1.5rem).
  The filter: `` `${name} ${spec}`.toLowerCase().includes(query.trim().toLowerCase()) `` over the
  four products. Empty: `font-serif text-title leading-normal opacity-70` — "Nothing under that
  name. Try a material — oak, ash, walnut."
- **A result plate** is the product card's plate exactly: an `<li>` `flex flex-col gap-[1.25rem]`
  (`at768:` 0.875rem; `at390:` 0.625rem); the plate `relative isolate aspect-[326/438] w-full
  cursor-none overflow-hidden rounded-image` marked `data-frame-cursor="light"`, the photograph
  `absolute max-w-none` at the product's own **crop** (width/height/left/top from the data below),
  and a `Hover` span over it from `clipPath: inset(100% 0 0 0)` to `inset(0% 0 0 0)` on
  `{ tension: 240, friction: 30 }`, `absolute inset-0 bg-surface-accent mix-blend-color-burn`,
  triggered by the plate. Under it: name `text-title font-normal leading-[1.1538]` (`at768:`
  1.125rem; `at390:` 0.9375rem) and spec `text-label opacity-60` (`at390:` caption), then the price
  `font-serif text-body tracking-[0.02em] tabular-nums`.

Escape closes; `body.overflow = hidden` while open; the query is reset on close; focus returns to
the SEARCH button.

## The framing cursor — variant CC4

Mounted once. **Mouse only**: it exists only where `(pointer: fine)` matches, and only
`pointerType === "mouse"` events move it. A `fixed inset-0 z-[60] pointer-events-none` layer holding
four corner marks, each `absolute block h-[0.6875rem] w-[0.6875rem]` with 1.4px borders on two
edges (`tl: l+t`, `tr: r+t`, `bl: l+b`, `br: r+b`), coloured **brand red** by default and **paper
white** (`--foreground-on-band`) over elements marked `data-frame-cursor="light"` (150 ms
`transition-colors`).

One spring `{ x, y, spread }` on `{ mass: 0.8, tension: 380, friction: 30 }`. On every mouse move:
`x = clientX, y = clientY, spread = 1` if `event.target.closest("[data-frame-cursor]")` else `0`;
on `pointerleave` (the window) `spread = 0`. Each mark: `left = x; top = y; opacity = spread;
transform = translate3d(dx, dy, 0)` where

```js
const gap = 5 + spread * 17;
const dx = corner.x < 0 ? -gap : gap - 11;
const dy = corner.y < 0 ? -gap : gap - 11;
```

The marked elements — every photograph: the five hero plates (`light`), the two catalogue preview
plates (default red), the four product plates (default), the room photograph (`light`), the search
result plates (`light`) — all carry `cursor: none` so the marks replace the arrow over them.

## Block 1 — hero (Figma 2412:210, 1440×800)

`<section aria-label="Halden — timeless furniture">` `h-lvh w-full overflow-hidden bg-background
text-foreground-accent`. Inside, a **stage** `relative w-full` of height **50rem** (`at1024:`
48rem; `at768:` 64rem; `at390:` 52.75rem) pinned to the top; at the design ratio (1.8) it fills the
viewport almost exactly and anything left over falls below the last element. The whole stage's
content is wrapped in the **PreloadReveal** gate (invisible until `halden:opening`, then remounted).
Every coordinate below is absolute inside the stage; four columns = 1440 / 1024 / 768 / 390.

**The `<h1>`** — `pointer-events-none absolute inset-0 font-display text-display font-bold
leading-normal text-foreground-accent` (`at1024:` 12.75rem; `at768:` 9.4375rem; `at390:`
4.875rem). It needs a real box (never `display: contents`) and passes pointer events through to the
plates. Two `Spring` spans, `mode always`, `springWeight`, each `pointer-events-auto absolute
whitespace-nowrap` and cap-trimmed:

| word | from | left / top (1440) | 1024 | 768 | 390 |
|---|---|---|---|---|---|
| **timeless** | `{ opacity: 0, x: -170 }` → `{ opacity: 1, x: 0 }` | 1.5625rem / 7.5rem | 1.5 / 6.125 | (1.5) / 5.5625 | 1 / 4.1875 |
| **furniture /** | `{ opacity: 0, x: 170 }` → `{ opacity: 1, x: 0 }`, `delayIn 140` | 16rem / 19.625rem | 15.125 / 15.8125 | 11.3125 / 13.1875 | 5.3125 / 8.3125 |

"furniture /" is `flex items-center gap-[0.5rem]` (`at390:` gap 0.1875rem) with the slash in its
own `aria-hidden` span. **At 390 only**, a rule under the headline: `left-[1rem] top-[18.375rem]
h-px w-[22.375rem] bg-rule`.

**Captions** are all `LineReveal` blocks (`weight "soft"`, `columnGap 0.17`) in `font-serif
text-body uppercase leading-[1.1]` (`at1024:` `text-label`; `at390:` `text-caption`), cap-trimmed,
placed on a plain wrapper span — the engine writes its own inline `position`, so placement never
goes on the engine's tag:

| text | delay | 1440 left / top / width | 1024 | 768 | 390 |
|---|---|---|---|---|---|
| collection 2026 | 320 | 74.3125 / 7.375 / w 4.5625 | 45.625 / 5.8125 / w 4 | 32.5625 / 5.375 | hidden |
| 01-11 | 380 | 74.3125 / 13.125 | 45.625 / 14.375 | 32.5625 / 11.5625 | hidden |
| dining chair 32"W x 38"H | 420 | 2 / 23.875 / w 5 | 1.5 / 23.125 / w 5.375 | (1.5) / 18.1875 / w 6 | hidden |
| $499.99 (`leading-normal`) | 480 | 2 / 29.25 / w 5 | 11.3125 / 23.9375, w auto, `-translate-x-full` | 9.0625 / 18.9375 | hidden |
| modular sofa 98"W x 38"H (`justify-end text-right`) | 500 | 46.0625 / 34.6875 / w 5.8125, `-translate-x-full` | 16.875 / 29.6875 / w 5.125, translate 0, left-aligned | 1.5 / 24.8125 / w 10.5 | 1 / 32.75 / w 4.5 |
| $2999.99 (`justify-end text-right leading-normal`) | 560 | 46.0625 / 46.9375, `-translate-x-full` | 39.4375 / 30.5, translate 0 | 13.5625 / 24.6875, translate 0 | 23.375 / 33.25, `-translate-x-full` |
| new arrivals | 600 | 69.875 / 37.5625 | 42.5625 / 35.1875 | 1.5 / 48.75 | 1 / 51.1875 |

Plus a static `aria-hidden` "//" (`font-serif text-body uppercase leading-normal text-right
-translate-x-full`) at 88 / 37.6875 (`at1024:` 62.5 / 35.25; `at768:` 46.5 / 48.875; `at390:`
23.375 / 51.1875).

**"[ explore collection ]"** — a span at 73.5625 / 28.625 (`at1024:` 51.3125 / 30.125, 1.25rem;
`at768:` 36.4375 / 24.5, 1.125rem; `at390:` left 23.45 top 16.125 `-translate-x-full`, 1rem),
`flex w-max items-center whitespace-nowrap font-display text-title font-bold leading-normal
tracking-[0.08em]`. The two brackets are `Hover` spans triggered by the whole span: `[` from
`{ x: 0 }` to `{ x: -7 }`, `]` to `{ x: 7 }`, both `{ tension: 240, friction: 24 }`. The words are
a `LineReveal` (`delay 560`, `columnGap 0.24`, `px-[0.22em]`, full weight).

**The welcome line** — a wrapper at 2 / 43.8125 with `-mt-[0.24375rem]`, width 10.6875rem
(`at1024:` 1.5 / 42.6875, w 9.75; `at768:` 17.6875 / 55.5625, w 12.6875, `justify-center
text-center`; `at390:` 1 / 15, w 5.9375, left-aligned) — a `LineReveal` with `delay 700`,
`columnGap 0.155`, **`clip false`** (the leading is 1, below the clip floor), in `font-display
text-title font-bold uppercase leading-[1]` (`at1024:` 1.5rem; `at768:` 1.25rem; `at390:`
0.875rem): **"WELCOME TO HALDEN – tIMELESS DESIGN, MADE TO LAST."** — the lowercase t is the
comp's own.

**The five plates** — each a **HeroImage**: a wrapper `isolate cursor-none overflow-hidden` marked
`data-frame-cursor="light"`, holding an `Inview` (`mode once`, its `revealDelay`, from `clipPath:
inset(100% 0 0 0)` to `inset(0% 0 0 0)`, `springWeight` — the photograph **uncovers bottom-up**),
which holds the `<img>` and, **inside the same wrapper** (the reveal span is its own stacking
context; an overlay outside it would blend against the page and flood the plate flat red), a
`Hover` span `pointer-events-none absolute inset-0 bg-surface-accent mix-blend-color-burn` from
`clipPath: inset(100% 0 0 0)` to `inset(0% 0 0 0)` on `{ tension: 240, friction: 30 }`, triggered by
the plate: **the red tint rides up from the bottom edge like a swatch held against the piece.**

| plate | file | delay | 1440 left / top / w × h | 1024 | 768 | 390 | image |
|---|---|---|---|---|---|---|---|
| collection chair | hero/collection-chair.png | 340 | 79.3125 / 7.5 / 8.6875 × 6.5 | 50.125 / 6 / 12.375 × 9.1875 | 37.3125 / 5.5 / 9.1875 × 6.8125 | hidden | cover |
| dining chair | hero/dining-chair.png | 440 | 9.25 / 24 / 6.4375 × 6.25 | 1.5 / 15.6875 / 9.8125 × 7.125 | (1.5) / 13.125 / 7.5625 × 4.6875 | hidden | cover |
| modular sofa | hero/modular-sofa.png | 520 | 46.5625 / 34.8125 / 22.8125 × 13.1875, **`-scale-x-100`** | 16.875 / 31.9375 / 25.1875 × 14.5625 | 1.5 / 26.3125 / 45 × 21 | 1 / 19.6875 / 22.375 × 12.5625 | cover; `at768:` `absolute left-[-5.2%] top-[-25.65%] h-[136.36%] w-[106.47%] max-w-none`; `at390:` `left-0 top-[-7.1%] h-[107.34%] w-[100.84%]` |
| new arrival 1 | hero/new-arrival-1.png | 620 | 69.875 / 38.9375 / 8.8125 × 9.0625, `-scale-x-100` (`at390:` `scale-x-100`) | 42.5625 / 36.5 / 9.75 × 10 | 1.5 / 50.25 / 10.875 × 12.25 | 1 / 35.125 / 11.1875 × 15.5625 | cover |
| new arrival 2 | hero/new-arrival-2.png | 660 | 79.1875 / 38.9375 / 8.8125 × 9.0625, `-scale-x-100` (`at390:` `scale-x-100`) | 52.8125 / 36.5 / 9.6875 × 10 | 35.625 / 50.25 / 10.875 × 12.25 | 12.625 / 35.125 / 10.75 × 15.5625 | `absolute left-[-23.81%] top-[-63.24%] h-[170.17%] w-[123.81%] max-w-none`; `at768:` full `object-cover` |

Alt text: "Red sculptural lounge chair from the 2026 collection", "Black wooden dining chair",
"Modular sofa with a low coffee table in a bright living room", "Sideboard with a table lamp and
framed print", "Round dining table with four chairs".

## Block 2 — manifesto (Figma 2428:599, 1440×800)

`<section data-header-accent>` `relative w-full overflow-hidden bg-surface-accent`, height
**50rem** (`at1024:` 34.5rem; `at768:` 25rem; `at390:` 13.5rem).

The statement is an `<h2>` `absolute left-[calc(50%+0.03125rem)] top-1/2 flex w-max -translate-x-1/2
-translate-y-1/2 flex-col items-center gap-[0.25rem] font-serif text-manifesto font-normal
leading-[1.0769] text-foreground-on-accent` (`at1024:` 3.5rem; `at768:` 2.5rem; `at390:` `left-
[1.6875rem] top-[3.125rem]`, no translate, 1.375rem). Four rows:

1. `flex gap-[0.17em] whitespace-nowrap`: **"(Halden) is made for"** then an `<em>` (`relative
   inline-block italic`) holding **"considered living,"** and, under it, the **underline** — an
   `Inview` span `absolute bottom-[0.02em] left-0 right-0 h-[0.045em] origin-left bg-current`, from
   `{ scaleX: 0 }` to `{ scaleX: 1 }` on `springWeight`, `delayIn = at(39)` = 351 ms — drawn left to
   right the moment the phrase finishes.
2. `relative flex items-center gap-[1.125rem]` (`at1024:` 0.875rem; `at768:` 0.625rem; `at390:`
   0.375rem): **"for beautiful"**, the **blue chair's gap**, **"spaces, made to last —"**.
3. Same: **"furniture that belongs"**, the **orange chair's gap**, **"in your everyday,"**.
4. `whitespace-nowrap`: **"and stays with you for years to come."**

**Written**, the hand. Every fragment is the `Written` recipe above with its own start:

```js
const PACE = 9;   // ms per character — the pace of a hand moving across the line
const F = {
  open:      { text: "(Halden) is made for",                 at: 0 },
  italic:    { text: "considered living,",                    at: 21 },
  beautiful: { text: "for beautiful",                         at: 39 },
  spaces:    { text: "spaces, made to last —",                at: 52 },
  belongs:   { text: "furniture that belongs",                at: 74 },
  everyday:  { text: "in your everyday,",                     at: 96 },
  close:     { text: "and stays with you for years to come.", at: 113 },
};
const at = (n) => n * PACE;   // delayIn for a fragment, ms
```

Letters land strictly in reading order at a steady 9 ms, so the eye follows a moving point across
the line as it would follow a nib; the underline is the one genuine stroke.

**The chair gaps** — an `aria-hidden` span `relative shrink-0 rounded-image` of **6.5 × 3.75rem**
(`at1024:` 4.75 × 2.75; `at768:` 3.625 × 2.125; `at390:` 2 × 1.1875), holding a centring span
`absolute left-1/2 top-1/2 block size-[8.125rem] -translate-x-1/2 -translate-y-1/2` (`at1024:`
6rem; `at768:` 4.5rem; `at390:` 2.625rem) — centred on the gap rather than at a fixed offset, because
the springs inside write their own transform. Inside, the **ManifestoChair** (variant CH4): an
`Inview` (`mode once`, from `{ opacity: 0, y: -18 }` to `{ opacity: 1, y: 0 }`, `springWeight`,
`delayIn` = the moment the fragment before it finishes: **`at(74)` = 666 ms** for the blue chair
after "spaces, made to last —", **`at(113)` = 1017 ms** for the orange after "in your everyday,"),
wrapping a `Hover` span `block size-full origin-[50%_92%]` from `{ rotate: 0 }` to `{ rotate: -5 }`
on `{ tension: 260, friction: 11 }` — a firm tension against low friction overshoots past five
degrees and swings back: **the chair rocks once on its legs** when pointed at, pivoting down by the
feet. Each chair listens only on itself. The images: `manifesto/armchair-blue.png` ("Tufted blue
armchair", `size-full max-w-none`) and `manifesto/armchair-orange.png` ("Orange swivel lounge
chair", `size-full max-w-none object-cover`).

## Block 3 — catalogue (Figma 2384:156, 1440×800)

`<section>` `relative w-full overflow-hidden bg-background`, height **50rem** (`at1024:`
43.9375rem; `at768:` 36.875rem; `at390:` 24.1875rem).

- **Eyebrow `<h2>`** "(02) — Catalogue" — `absolute left-[2rem] top-[2rem] whitespace-nowrap
  font-serif text-body font-normal uppercase leading-[1.1] text-foreground` (`at768:` 1.5 / 1.5,
  `text-label`; `at390:` 1 / 1.5, w 22.375rem, wrapping, centred, `text-caption`), cap-trimmed; an
  `Inview` (`once`, `{ opacity: 0, y: 16 }` → `{ opacity: 1, y: 0 }`, `springWeightSoft`).
- **Support copy `<p>`** — `left-[59.8125rem] top-[2rem] w-[27.1875rem] font-serif text-body
  leading-[1.3] text-foreground-strong` (`at1024:` left 35.75, w 26.25; `at768:` 28.125 / 1.5, w
  17.875, 0.8125rem; `at390:` 1.9375 / 3.0625, w 20.5, centred, `text-caption`); `Inview` `delayIn
  160`, `{ opacity: 0, y: 22 }` → `{ opacity: 1, y: 0 }`, soft: **"Nine categories, and that's the
  whole company. No outlet line, no seasonal drop, no second brand for the cheap version. If it
  isn't on this list, we don't make it."**
- **Hairline** — `left-[2rem] top-[6.625rem] h-px w-[86rem] origin-left bg-rule` (`at1024:` w
  60rem; `at768:` 1.5 / 6.1875, w 44; `at390:` 1 / 6.0625, w 22.375); `Inview` `{ scaleX: 0 }` →
  `{ scaleX: 1 }`, `springWeight`.

**The browser.** The data:

```js
const CATALOGUE_CATEGORIES = [
  { label: "All products", count: "152",
    previewLeft:  { src: "catalogue/beds-1.png",    alt: "Beds — catalogue preview",    width: 1134, height: 876 },
    previewRight: { src: "catalogue/mirrors-2.png", alt: "Mirrors — catalogue preview", width: 652,  height: 876 } },
  { label: "Sofas", count: "12",
    previewLeft:  { src: "catalogue/sofas-1.png", alt: "Sofas — catalogue preview", width: 634, height: 478 },
    previewRight: { src: "catalogue/sofas-2.png", alt: "Sofas — catalogue preview", width: 652, height: 876 } },
  { label: "Chairs", count: "34", active: true,
    previewLeft:  { src: "catalogue/preview-left.png",  alt: "Chairs — cream bouclé swivel armchair", width: 923, height: 698 },
    previewRight: { src: "catalogue/preview-right.png", alt: "Chairs — red lacquered dining chair",   width: 609, height: 744 } },
  { label: "Tables", count: "21",
    previewLeft:  { src: "catalogue/tables-1.png", alt: "Tables — catalogue preview", width: 652, height: 558 },
    previewRight: { src: "catalogue/tables-2.png", alt: "Tables — catalogue preview", width: 634, height: 478 } },
  { label: "Storage", count: "18",
    previewLeft:  { src: "catalogue/storage-1.png", alt: "Storage — catalogue preview", width: 652, height: 582 },
    previewRight: { src: "catalogue/storage-2.png", alt: "Storage — catalogue preview", width: 634, height: 592 } },
  { label: "Beds", count: "9",
    previewLeft:  { src: "catalogue/beds-1.png", alt: "Beds — catalogue preview", width: 1134, height: 876 },
    previewRight: { src: "catalogue/beds-2.png", alt: "Beds — catalogue preview", width: 1244, height: 876 } },
  { label: "Bedsides", count: "7",
    previewLeft:  { src: "catalogue/bedsides-1.png", alt: "Bedsides — catalogue preview", width: 652, height: 530 },
    previewRight: { src: "catalogue/bedsides-2.png", alt: "Bedsides — catalogue preview", width: 634, height: 478 } },
  { label: "Lighting", count: "14",
    previewLeft:  { src: "catalogue/lighting-1.png", alt: "Lighting — catalogue preview", width: 652, height: 658 },
    previewRight: { src: "catalogue/lighting-2.png", alt: "Lighting — catalogue preview", width: 652, height: 876 } },
  { label: "Mirrors", count: "11",
    previewLeft:  { src: "catalogue/mirrors-1.png", alt: "Mirrors — catalogue preview", width: 652, height: 876 },
    previewRight: { src: "catalogue/mirrors-2.png", alt: "Mirrors — catalogue preview", width: 652, height: 876 } },
];
const RESTING_ROW = 2;                              // "Chairs" — the comp's drawn state
const BAND_SPRING = { tension: 210, friction: 30 };
const STEP_MS = 2400;                               // the tablet beat
const ROW_LEAD = 260, ROW_STEP = 70;                // rows peel away from the band, nearest first
const FADE_SPRING = { tension: 200, friction: 26 }; // the plates' cross-fade
/** Plate area in rem², per comp — each plate keeps the AREA of its Figma counterpart and takes
 *  its photograph's own aspect ratio, so object-cover never cuts and a wide shot never balloons. */
const PLATE_AREA_REM = {
  wide:  { left: 12.25 * 9.25, right: 10.75 * 13.125 },
  at768: { left: 7.75 * 5.875, right: 6.5 * 8 },
  at390: { left: 6 * 4.625,    right: 5 * 6 },
};
// a plate's box: height = sqrt(area / aspect) rem, width = height * aspect rem  (aspect = width / height)
```

Three things travel together on **one spring** `{ y: offset }` on `BAND_SPRING`, where `offset =
activeRow.offsetTop − restingRow.offsetTop` **measured** (the root re-bases per band, so row height
in px changes with width) and re-measured on every active-row change and resize:

- **The band** — `absolute left-0 top-[17.4375rem] w-[90rem]` (`at1024:` top 16.3125, w 64rem;
  `at768:` 13.875, w 48; `at390:` 10.75, w 24.375), holding an `Inview` span `block h-[4.125rem]
  w-full origin-center bg-surface-accent` (`at1024:` 3.4375rem; `at768:` 3.0625; `at390:` 1.6875)
  from `{ scaleX: 0 }` to `{ scaleX: 1 }` on `springWeight` — it draws out from the centre on
  arrival.
- **The left plate** — a wrapper at `left-[9.375rem] top-[18.96875rem]` (`at1024:` 6.875 /
  18.84375; `at768:` 1.5 / 15.0417; `at390:` 1 / 11.375) — the comp's plate centre — holding a
  `-translate-y-1/2 -rotate-2` box with a `Handle` (`{ opacity: 0 }` ↔ `{ opacity: 1 }`,
  `FADE_SPRING`) around the plate.
- **The right plate** — `right-[9.375rem] top-[19.30875rem]` (`at1024:` right 2.25, top 19.3125;
  `at768:` right 1.6875, top 14.84375; `at390:` right 1, top 11.25), `-translate-y-1/2 rotate-2`,
  same `Handle`.

A plate is a span `block cursor-none overflow-hidden rounded-image` marked `data-frame-cursor`,
sized by the area formula (`wide` above 768, `at768` for 549–768, `at390` at or below 548), with the
photograph `h-full w-full object-cover`. When the active category changes, the `Handle` fades the
old plate out, swaps, and fades the new one in.

**The list** — a `<ul>` `absolute left-[calc(50%+0.03125rem)] top-[9.1875rem] flex -translate-x-1/2
flex-col items-center overflow-hidden whitespace-nowrap` (`at1024:` left 25.8125, top 9.5, no
translate; `at768:` left 50%, top 8, translate again; `at390:` top 7.375). Each row `<li>` is `flex
shrink-0 items-start gap-[0.5rem] py-[0.25rem]` (`at1024:` gap 0.40625 py 0.203125; `at768:` gap
0.3125 py 0.390625; `at390:` gap 0.203125 py 0.101563) — **it must not clip its own contents** (the
list holds the outer mask; at 390 a row is shorter than the 26px the label travels). Inside:

- the **label** — an `Inview` span `block font-serif text-category leading-[1.0741]` (`at1024:`
  2.75rem; `at768:` 2.125rem; `at390:` 1.375rem), `delayIn = ROW_LEAD + |index − 2| × ROW_STEP`,
  from `{ opacity: 0, y: index === 2 ? 0 : index < 2 ? 26 : -26 }` to `{ opacity: 1, y: 0 }` on
  `springWeight` — rows above the band arrive from below it, rows below from above it, nearest
  first; colour `--foreground-on-band` (paper) while active, `--foreground-strong` otherwise, 150 ms;
- the **count** — an `Inview` span `block font-display text-caption font-bold leading-normal
  tracking-[0.04em]` (`at1024:` 0.611rem; `at768:` 0.4722rem; `at390:` caption), `delayIn` 60 ms
  after its label, opacity only; paper while active, `--foreground-muted` otherwise.

**Behaviour.** `mouseenter` on a row makes it active; `pointerdown` too. On a mouse, leaving the
list returns the band to the resting row. **At or below 1024** (`byTimer`): while the section is at
least 40% in view (an `IntersectionObserver`, threshold 0.4) the active row advances by one every
`STEP_MS`, wrapping — the same gesture the cursor makes, kept by the page; a mouse over the list
**holds** the beat (and leaving no longer resets to Chairs); a tap selects a row and the beat
carries on from there; `prefers-reduced-motion` gets the resting state and nothing else.

## Block 4 — products (Figma 2431:940, 1440×800)

`<section>` `relative w-full overflow-hidden bg-background`, height **50rem** (`at1024:`
43.125rem; `at768:` 87.6875rem; `at390:` 147.875rem).

- **Heading `<h2>`** — a wrapper at 2 / 2 (`at768:` 1.5 / 1.5, w 19.0625rem; `at390:` 1, w
  22.375rem); a `LineReveal` **`from "left"`, `clip false`** (a clipped line that travels further
  than its own box never intersects the observer, and at 390 the whole heading sat at opacity 0 for
  good), `whitespace-nowrap font-serif text-section font-normal leading-[1.0968] text-foreground`
  (`at1024:` 3.25rem; `at768:` wrapping, 2.25rem, leading 1; `at390:` 1.75rem, leading 1.0968),
  cap-trimmed: **"Finished, boxed, waiting by the door"**.
- **Support `<p>`** — `left-[59.8125rem] top-[3.125rem] w-[12.8125rem] font-serif text-body
  leading-[1.3]`, cap-trimmed (`at1024:` left 47, **bottom `calc(100% − 4.9rem)`**, top auto, w 13;
  `at768:` left 32, top 3.8125, w 13, 0.875rem; `at390:` 1 / 4.5, w 22.375, caption); `Inview`
  `delayIn 160`, `{ opacity: 0, y: 22 }` → `{ opacity: 1, y: 0 }`, soft: **"Our current collection -
  ready to ship. Pieces in stock ship within days."**
- **Hairline** — 2 / 7.75, w 86rem (`at1024:` w 60; `at768:` 1.5 / 7.5, w 45; `at390:` 1 / 6.75, w
  22.375), `scaleX 0 → 1`, `springWeight`.
- **Filters** — a `<ul>` `absolute left-[2rem] top-[10.3125rem] flex items-start gap-[0.5rem]
  overflow-hidden` (`at768:` 1.5 / 9.0625; `at390:` 1 / 8.0625, w 22.375rem, `flex-wrap`,
  `overflow-visible`): `All (14)` (active), `Chairs (5)`, `Tables (3)`, `Seating (4)`, `Storage
  (2)`. **FilterPill — variant B1**: an `Inview` `<li>` (`delayIn = 260 + index × 70`, `{ opacity: 0,
  y: 14 }` → `{ opacity: 1, y: 0 }`, soft), `group/pill relative isolate flex shrink-0 items-center
  overflow-hidden rounded-pill px-[1rem] py-[0.6875rem]`; the active one `bg-foreground` with
  paper-white type; the others `border-[0.0375rem] border-solid border-foreground` with a `Hover`
  fill span `absolute inset-0 -z-10 block origin-left bg-surface-subtle` from `{ scaleX: 0 }` to
  `{ scaleX: 1 }` on `{ tension: 260, friction: 28 }` — sand wipes in from the left edge under the
  cursor; weight climbs with commitment: outline available, sand previewed, ink chosen. Label
  `font-serif text-label leading-normal tracking-[0.04em]`, cap-trimmed.
- **"Sorted by lead time  ↓"** — `absolute left-[80.8125rem] top-[11.6875rem] whitespace-pre
  font-serif text-label leading-normal tracking-[0.04em]` (`at1024:` 54.8125 / 11; `at768:`
  39.8125 / 9.8125, 0.8125rem; `at390:` 9.125 / 14.0625, caption); `Inview` `delayIn 420`,
  `{ opacity: 0, y: 14 }` → `{ opacity: 1, y: 0 }`, soft. The arrow is **drawn, not set** (the
  serif's ↓ hangs below the baseline): an inline SVG `viewBox="0 0 8 10"`, `h-[0.62em] w-[0.5em]
  align-baseline`, `fill="none" stroke="currentColor" stroke-width="1"`, path
  `M4 0v9M0.6 5.6L4 9.2 7.4 5.6`.
- **The cards** — a `<ul>` `absolute left-[2rem] top-[13.5625rem] flex w-[86rem] items-start
  gap-[1.5rem] overflow-hidden` (`at1024:` w 60rem; `at768:` left 1.5, top 12.5625, `grid
  grid-cols-2 gap-[1rem]`, w 45rem; `at390:` `grid-cols-1 gap-[2.5rem]`, left 1, top 15.625, w
  22.375rem).

```js
const PRODUCTS = [
  { index: "(01)", name: "Elara Lounge Chair", spec: "Oiled oak, bouclé 12", badge: "Ships in 3 days", price: "€1,840",
    image: "products/elara-lounge-chair.png", alt: "Elara lounge chair in oiled oak with a bouclé seat",
    crop: { width: "118.31%", height: "100%",    left: "-18.36%", top: "0%" } },
  { index: "(02)", name: "Ansel Dining Table",  spec: "Solid ash, 220 cm",    badge: "Ships in 3 days", price: "€3,120",
    image: "products/ansel-dining-table.png", alt: "Ansel oval dining table in solid ash",
    crop: { width: "107.19%", height: "106.21%", left: "-7.16%",  top: "-3.1%" } },
  { index: "(03)", name: "Marlow Two-Seater",   spec: "Walnut, wool 04",      badge: "Ships in 5 days", price: "€4,460",
    image: "products/marlow-two-seater.png", alt: "Marlow two-seater sofa upholstered in red wool",
    crop: { width: "167.95%", height: "144.47%", left: "-30.48%", top: "-44.47%" } },
  { index: "(04)", name: "Vega Sideboard",      spec: "Smoked oak, brass",    badge: "Last one",        price: "€2,290",
    image: "products/vega-sideboard.png", alt: "Vega sideboard in smoked oak with brass detailing",
    crop: { width: "108.3%",  height: "107.31%", left: "-5.24%",  top: "-3.2%" } },
];
```

**A card** — an `<li>` `group/card flex w-[20.375rem] shrink-0 flex-col gap-[1.25rem]
overflow-hidden` (`at1024:` w 13.875rem; `at768:` w full; `at390:` w 22.375rem), its `<article>`
`display: contents`:

- the **plate** — an `Inview` (`mode once`, `delayIn = index × 110`, from `clipPath: inset(0 0 100%
  0)` to `inset(0 0 0% 0)`, `springWeight` — **uncovers top-down**), `relative h-[27.375rem] w-full
  shrink-0 cursor-none overflow-hidden rounded-image` (`at1024:` 18.75rem; `at768:` 29.25rem;
  `at390:` 23.75rem), marked `data-frame-cursor`; the photograph `absolute max-w-none` at the
  product's crop; the index `absolute left-[1rem] top-[1rem] font-serif text-body leading-normal
  tracking-[0.06em]`; and the **SaveChip — variant K3**: a `<button aria-pressed aria-label="Save
  {name}">` `absolute right-[1rem] top-[1rem] size-[1.75rem] cursor-none overflow-hidden
  rounded-pill bg-surface-subtle`, `transition-[background-color,rotate] 150ms`, **floods brand red
  on the card's hover** (`group-hover/card:bg-surface-accent`) and when pressed (`saved`: also
  `rotate-90`, toggled by click). Inside, a `Hover` span `absolute inset-0 block text-body` from
  `{ rotate: 0 }` to `{ rotate: 90 }` on `{ tension: 200, friction: 14 }`, triggered by the **card**,
  its colour flipping to white on the card hover / when saved; the "+" is **two bars**, not a glyph
  (the serif places + off-centre by 1.2px and the offset changes shape as it turns): each `absolute
  left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-[0.5px] bg-current`, one `h-[0.045em]
  w-[0.43em]`, one `h-[0.43em] w-[0.045em]`.
- the **name and spec** — `flex flex-col gap-[0.5rem] whitespace-nowrap font-serif`: `<h3>`
  `text-title font-normal leading-[1.1538]` (`at390:` 1.375rem); spec `text-label leading-normal
  text-foreground/35`, cap-trimmed.
- the **badge and price** — `flex items-center justify-between`: the badge `rounded-pill
  border-[0.0375rem] border-solid border-foreground px-[0.75rem] py-[0.5rem] font-serif text-caption
  tracking-[0.04em]`; the price `font-serif text-body tracking-[0.02em]`; both cap-trimmed.

## Block 5 — room (Figma 2384:190, 1440×800)

`<section>` `relative w-full overflow-hidden bg-background`, height **50rem** (`at1024:`
44.0625rem; `at768:` 31.0625rem; `at390:` 25.3125rem).

- **Heading `<h2>`** at 2 / 2 (`at768:` 1.5 / 1.5; `at390:` 1, w 22.375rem): `LineReveal` `from
  "left"` (clipped, full weight), `whitespace-nowrap font-serif text-section font-normal
  leading-[1.0968]` (`at1024:` 3.25rem; `at768:` 2.25rem; `at390:` wrapping, 1.75rem), cap-trimmed:
  **"See it where it actually lives"**.
- **Support `<p>`** — `left-[59.8125rem] top-[3.125rem] w-[18.6875rem] font-serif text-body
  leading-[1.3]`, cap-trimmed (`at1024:` left 43.25, bottom `calc(100% − 4.9rem)`, w 18.75; `at768:`
  30.875 / 1.75, w 15.625, `text-label`; `at390:` 1 / 4.5, w 19.5625, caption); `Inview` `delayIn
  160`, `{ opacity: 0, y: 22 }` → `{ opacity: 1, y: 0 }`, soft: **"Our furniture in real homes.
  Explore the pieces in their natural surroundings and get inspired."**
- **Hairline** — 2 / 7.75, w 86rem (`at1024:` w 60; `at768:` 1.5 / 5.5, w 45; `at390:` 1 / 7.75, w
  22.375), `scaleX 0 → 1`, `springWeight`.

**The plate** — an `Inview` `<div>` (`mode once`, `delayIn 220`, `clipPath: inset(0 0 100% 0)` →
`inset(0 0 0% 0)`, `springWeight` — top-down) `absolute left-[2rem] top-[10.3125rem] h-[37.6875rem]
w-[86rem] cursor-none overflow-hidden rounded-image` (`at1024:` top 10.3125, 60 × 30rem; `at768:`
1.5 / 7.0625, 45 × 22.5; `at390:` 1 / 9.0625, 22.375 × 12.5), marked `data-frame-cursor="light"`.
Only the photograph and the tags live inside it — a clip-path bites every descendant whatever
`overflow` says — so the caption and counter sit outside. Inside:

- the photograph `room/christianshavn.png` (1376×603, "Apartment interior in Christianshavn with a
  dining table, sideboard and red sofa"), `absolute inset-0 size-full object-cover` (`at390:`
  `left-[-13.13%] top-[-22.75%] h-[123.48%] w-[122.7%] max-w-none`);
- the **scrim** — `pointer-events-none absolute inset-0 z-[1]`, opacity 0 → 1 over 250 ms while a
  piece is picked, `background: radial-gradient(circle at ${spot.x}px ${spot.y}px, transparent
  ${clear}, color-mix(in srgb, var(--scrim) 66%, transparent) ${fade})` with `clear 5.5rem, fade
  14rem` (**at 390: 2.5rem, 6rem** — on a 358px plate the wide figures cover the whole picture);
- the tags `<ul>` `relative z-[2]`.

**The aim.** The light is placed from the piece's own spot in the photograph, not from the tag:

```js
const p = plate.getBoundingClientRect(), b = photo.getBoundingClientRect();
const nw = photo.naturalWidth || 1, nh = photo.naturalHeight || 1;
const scale = Math.max(b.width / nw, b.height / nh);          // object-fit: cover
const painted = { w: nw * scale, h: nh * scale };
const origin = { x: b.left - p.left + (b.width - painted.w) / 2, y: b.top - p.top + (b.height - painted.h) / 2 };
spot = { x: origin.x + focus.x * painted.w, y: origin.y + focus.y * painted.h };
```

```js
const ROOM_HOTSPOTS = [
  { name: "Pendant Lamp",       spec: "Enamel & steel — €410",          left: "21.125rem",  top: "4.3125rem",
    at1024: { left: "14.75rem",  top: "3.4375rem" }, at768: { left: "11rem",      top: "2.0625rem" },  at390: { left: "3.5rem",    top: "0.09375rem" }, focus: { x: 0.205, y: 0.275 } },
  { name: "Table Lamp",         spec: "Glossy ceramic — €185",          left: "38rem",      top: "9.5625rem",
    at1024: { left: "26.5rem",   top: "7.625rem" },  at768: { left: "19.875rem",  top: "5rem" },       at390: { left: "8.9375rem", top: "1.625rem" },   focus: { x: 0.413, y: 0.39 } },
  { name: "Ansel Dining Table", spec: "Solid ash, 220 cm — €3,120",     left: "20.75rem",   top: "18.5625rem",
    at1024: { left: "14.5rem",   top: "14.75rem" },  at768: { left: "3.75rem",    top: "14.1875rem" }, at390: { left: "3.4375rem", top: "4.5rem" },     focus: { x: 0.22, y: 0.58 } },
  { name: "Sofa",               spec: "Upholstered in fabric — €2.950", left: "60.75rem",   top: "15.5rem",
    at1024: { left: "42.375rem", top: "12.3125rem" },at768: { left: "31.6875rem", top: "9.1875rem" },  at390: { left: "16.1875rem",top: "3.5rem" },     focus: { x: 0.8, y: 0.62 } },
  { name: "Ansel Dining Chair", spec: "Solid oak, woven seat — €640",   left: "35.1875rem", top: "30.5rem",
    at1024: { left: "24.5625rem",top: "22.625rem" }, at768: { left: "18.3125rem", top: "16.4375rem" }, at390: { left: "8.0625rem", top: "8.3125rem" },  focus: { x: 0.33, y: 0.67 } },
];
const ROOM_CAPTION = "Apartment, Christianshavn — photographed May 2026";
const ROOM_COUNTER = "01 / 06   ← →";
// band: innerWidth <= 548 → "390"; <= 768 → "768"; <= 1024 → "1024"; else "1440" — re-read on resize
```

**A tag** — an `Inview` `<li>` (`mode once`, `delayIn = 520 + index × 90`, `{ opacity: 0, scale:
0.82 }` → `{ opacity: 1, scale: 1 }`, `springWeight`) `absolute` at the band's coordinates, holding a
`<button aria-label="{name} — {spec}. Add to bag">` `group/hotspot relative flex items-center
gap-[0.75rem] overflow-hidden rounded-pill bg-surface-overlay/92 py-[0.375rem] pl-[0.375rem]
pr-[1.25rem] text-left`, `transition-opacity 250ms`; **dimmed** (another tag holds the light):
`opacity-0 pointer-events-none`. **At 390** the pill sheds everything but its mark: `gap-0
overflow-visible bg-transparent p-0`, with an `::after` ring at `-inset-[0.625rem]` as the finger's
target. Inside:

- the **mark** — `relative size-[1.875rem] shrink-0 overflow-hidden rounded-pill bg-surface-subtle`
  (`at390:` 1.375rem), `transition-[background-color,transform] 150ms`, red on `group-hover` /
  `group-focus-visible` and when **selected** (also `rotate-45`); a `Hover` span `absolute inset-0
  block text-plus` (`at390:` 0.825rem) from `{ rotate: 0 }` to `{ rotate: 90 }` on `{ tension: 200,
  friction: 14 }`, triggered by the button, colour to white on hover/focus/selected — the same two
  bars as the save chip;
- the **text** — `flex shrink-0 flex-col items-start gap-[0.5rem] whitespace-nowrap font-serif
  leading-normal` (`at390:` hidden): name `text-caption tracking-[0.01em] text-foreground-strong`,
  spec `text-micro tracking-[0.02em] text-foreground/40`, both cap-trimmed.

**Pointing.** `pointerenter` with `pointerType === "mouse"` → focus the piece (aim + set active);
`pointerleave` (mouse) → release; **click**: a mouse click pins the piece it is already pointing at,
a touch tap **toggles** it (tap opens, tap again closes); keyboard `focus` / `blur` do the same as
enter / leave. While one is active, the other four are `dimmed` — except at 390, where nothing dims.

**Caption and counter** — outside the plate: the caption `absolute left-[4rem] top-[44.875rem]
whitespace-nowrap font-serif text-label leading-normal tracking-[0.04em] text-foreground-on-band`
(`at1024:` 3.5 / 37.6875; `at768:` 2.5 / 27.5625, caption; `at390:` 1 / 22.0625, w 8.1875rem,
wrapping, leading 1.2, **`text-foreground`** — under the photograph on a phone), `Inview` `delayIn
420` opacity; the counter `left-[81.3125rem] top-[44.9375rem] whitespace-pre` (`at1024:` 55.8125 /
37.6875; `at768:` 45.5 / 27.5625, `-translate-x-full`, caption; `at390:` 23.375 / 22.0625,
`-translate-x-full`, black), `delayIn 460`. **At 390 while a piece is picked** both hide and the
chosen piece takes their line: `absolute left-[1rem] top-[22.0625rem] w-[22.375rem] flex
items-baseline justify-between gap-[0.75rem] font-serif text-caption tracking-[0.04em]` — the name
in brand red, the spec right-aligned in `text-foreground/50` — so tapping a mark never changes the
block's height.

## Block 6 — footer (Figma 2384:202, 1440×551)

`<footer data-header-accent>` `relative w-full overflow-hidden bg-surface-accent
text-foreground-on-accent`, height **34.4375rem** (`at1024:` 32.625rem; `at768:` 27.6875rem;
`at390:` 37.6875rem).

**Newsletter lockup** — `absolute left-[2rem] top-[2rem] flex w-[26.875rem] flex-col items-start
gap-[2rem]` (`at768:` left 1.5, w 21.5; `at390:` 1 / 1.5, w 22.375):

- `<h2>` `w-full font-serif text-headline font-normal leading-[1.0435]` (`at768:` 2.25rem;
  `at390:` 1.75rem). Two `LineReveal` spans `from "left"`, `block`, the second `delay 110`. The comp
  breaks the sentence by hand, and differently at 768, so each band gets its own copy and the other
  is removed from the document: **"Four emails a year," / "and none of them are sales"** everywhere
  except `at768`-only, where it is **"Four emails a year, and" / "none of them are sales"**.
- **Newsletter — variants B3 and B2.** An `Inview` row (`delayIn 280`, `{ opacity: 0, y: 18 }` →
  `{ opacity: 1, y: 0 }`, soft) `relative flex h-[3.25rem] w-full items-center justify-between
  overflow-hidden rounded-pill border border-solid py-[0.375rem] pl-[1.25rem] pr-[0.375rem]`,
  border `foreground-on-accent/35` → **`/90` while awake** (pointer over the row, or the input
  focused; 250 ms). A label `EMAIL` — `pointer-events-none absolute left-[1.25rem] font-display
  text-nano tracking-[0.14em] text-foreground-on-accent/75`, `opacity-0` → `-translate-y-[0.8rem]
  opacity-100` when awake (`transition-all 250ms`) — lifts out of the field. The input
  `min-w-0 flex-1 bg-transparent font-serif text-label leading-normal outline-none`, placeholder
  **"you@somewhere.dk"** in the same white, shifts `translate-y-[0.32rem]` when awake. The button
  `relative flex h-full shrink-0 items-center overflow-hidden rounded-pill
  bg-foreground-on-accent/25 px-[1.5rem] py-[0.75rem] font-serif text-body` (`hover:` `/40`, 150
  ms): **"Sign me up"** as a `Hover` span from `{ x: 0 }` to `{ x: -4 }` (`{ tension: 280, friction:
  26 }`), and an arrow **→** `absolute inset-y-0 right-[0.5rem] flex items-center` as a `Hover`
  from `{ x: -8, opacity: 0 }` to `{ x: 0, opacity: 1 }` — it grows from under the button's own right
  edge, out of flow so the resting button keeps its symmetric padding. No endpoint: the form does
  nothing.

**Link columns** — `<nav aria-label="Footer">` `absolute left-[52.625rem] top-[2rem] flex
items-center gap-[8rem] whitespace-nowrap font-serif leading-normal` (`at1024:` left 42.5, w
19.5rem, `justify-between gap-0`; `at768:` left 27.75, w auto, `justify-start gap-[3rem]`; `at390:`
1 / 13.125, gap 3rem). Three `Inview` columns (`delayIn = index × 110`, `{ opacity: 0, y: 20 }` →
`{ opacity: 1, y: 0 }`, soft) `flex shrink-0 flex-col items-start gap-[0.875rem]` (`at390:`
1.0625rem): a heading `<h3>` `text-label tracking-[0.08em] text-foreground-on-accent/55` and a
`<ul>` `gap-[0.875rem]` (`at390:` 1.4375rem) of **NavItem**s `onAccent` (white bar, red type on
hover) with `delay = index × 110 + linkIndex × 50`, `text-body`:

```js
const FOOTER_COLUMNS = [
  { heading: "Shop",     links: ["Catalogue", "In stock now", "Gift cards", "Trade & contract"] },
  { heading: "Workshop", links: ["Our story", "Materials", "Repairs", "Journal"] },
  { heading: "Help",     links: ["Delivery", "Returns", "Care guide", "Contact"] },
];   // every href is "#"
const FOOTER_LEGAL = [
  "Halden Møbler ApS  ·  Refshalevej 88, 1432 København K  ·  +45 32 96 11 04",
  "© 2026  ·  Made in Denmark, mostly by hand",
];
const FOOTER_SOCIAL = "Instagram  ·  Pinterest  ·  hello@halden.dk";   // double spaces are literal
```

- **Hairline** — `left-[2rem] top-[17.25rem] h-px w-[86rem] origin-left
  bg-foreground-on-accent/35` (`at1024:` w 60; `at768:` 1.5 / 13.875, w 45; `at390:` 1 / 24.5, w
  22.375), `Inview` `delayIn 340`, `scaleX 0 → 1`, `springWeight`.
- **Legal** — `absolute left-[2rem] top-[18.5625rem] flex flex-col items-start gap-[0.5rem]
  whitespace-pre font-serif text-label leading-normal` (`at768:` 1.5 / 15.9375, gap 0.3125;
  `at390:` 1 / 26.0625, gap 0.5, w 18.25rem, `whitespace-pre-wrap`, caption), `Inview` `delayIn
  420`, `{ opacity: 0, y: 14 }`, soft. **At 390 the last line leaves this stack** and is set again
  below the wordmark at `left-[1rem] top-[35.625rem]` (caption, `delayIn 500`), so the phone reads
  address, contacts, wordmark, then the closing line.
- **Social** — `absolute left-[75.8125rem] top-[18.5625rem] whitespace-pre font-serif text-label`
  (`at1024:` left 49.8125; `at768:` 35.1875 / 15.9375, 0.8125rem; `at390:` 1 / 27.5, caption),
  `Inview` `delayIn 470`, `{ opacity: 0, y: 14 }`, soft.
- **The wordmark** — an `aria-hidden` wrapper at `left-[2rem] top-[23.8125rem] -mt-[0.1875rem]`
  (`at768:` 1.5 / 20.625; `at390:` 1 / 30.6875); a `LetterReveal` in `whitespace-nowrap font-serif
  text-wordmark leading-[1.0204] tracking-[0.85em]` (`at1024:` 8.382rem; `at768:` 6.25rem; `at390:`
  5rem, `tracking-[0.31em]`), cap-trimmed: **HALDEN** — six letters already held apart by the
  tracking, each rising from `y: 110%` behind its clip 90 ms after the last, on `springWeight`.

## Responsive rules, in one place

- **Four bands, four comps.** Every block's height, every coordinate and every type size above has
  a value per band; the bands are `max-width` queries at 1024 / 768 / 548 px and the root font-size
  re-bases in each, so a rem taken off a comp lands exactly inside its band.
- **The header** loses the primary nav at 390 (the utility nav stays); its height steps 4.5 → 4 →
  3.1875rem.
- **The hero** at 390 keeps only the headline, its rule, the sofa plate and price, the two new
  arrivals, "new arrivals" / "//" and the welcome line; collection marker, dining chair and CTA
  labels hide (the CTA itself moves to the right edge).
- **Hover is off at or below 768px** — every `Hover` spring rests at `from`; the CSS `group-hover`
  swaps still fire where a pointer exists.
- **The catalogue** runs on its beat at or below 1024; the plates use the `at768` / `at390` areas
  at or below 768 / 548.
- **The room tags** carry their own coordinates per band, shed their text at 390, and nothing dims
  there; the spotlight pool shrinks to 2.5 / 6rem.
- **The products** go 4-across → 4 narrower (1024) → a 2-column grid (768) → one column (390).
- **The framing cursor** exists only for a fine pointer.

## Accessibility

One `<h1>` (the two hero words). Every split text carries its plain sentence once in an `sr-only`
span and hides the animated units with `aria-hidden`. The slash, the "//" mark, every hairline, the
chair gaps, the preloader's frame, the framing cursor and the wordmark are `aria-hidden`. The
preloader is `role="status" aria-live="polite" aria-label="Loading Halden"`; the search panel a
`role="dialog" aria-modal="true"` with a live result count; the save chips `aria-pressed` with the
product named in the label; the room tags name the piece, its spec and "Add to bag". The header's
navs are labelled "Primary" and "Account and basket", the footer's "Footer". Reduced motion skips
every spring to its end state and the preloader entirely.

## Fixed parameters (bake these in)

`springWeight { 2.4, 120, 34 }` · `springWeightSoft { 1.4, 170, 30 }` · `springInk { 1, 420, 34 }`
· hover disabled at `≤ 768` · root `coef 1` above 1440 · LineReveal `OUT/IN` tables, stagger 90,
`columnGap` 0.17 / 0.155 / 0.24 · LetterReveal `y 110% → 0%`, stagger 90 · Written `PACE 9`, `F` table
· preloader `FRAME_W 460`, ratio `288/460`, `FRAME_MIN 240`, `FRAME_SHARE 0.32`, `TICK 15`,
`STEP_MS 150`, `MIN_MS 900`, `MAX_MS 7000`, tick `40ms`, snap `0.048 / 0.049`, steps `300 / 520 /
620 / 1420 ms`, open scale `× 1.6`, every config in the run · header midline sampling, nav delays
`120 + 70i` / `400 + 70i` · NavItem bar `{ 280, 28 }`, insets `0.42em / 0.2em` · search `y −100 →
0` on `{ 1.2, 190, 30 }`, park `700ms` · cursor `{ 0.8, 380, 30 }`, marks `0.6875rem`, borders
`1.4px`, `gap = 5 + 17s`, `−11` · hero words `x ∓170`, delay `140`; caption delays `320 … 700`;
plate delays `340 … 660`; tint `{ 240, 30 }`; brackets `x ∓7` on `{ 240, 24 }` · manifesto `at()`
offsets, underline at `351ms`, chairs at `666 / 1017ms`, rock `−5°` on `{ 260, 11 }` about `50%
92%` · catalogue `RESTING_ROW 2`, `BAND_SPRING { 210, 30 }`, `STEP_MS 2400`, threshold `0.4`,
`ROW_LEAD 260`, `ROW_STEP 70`, `±26`, `FADE_SPRING { 200, 26 }`, `PLATE_AREA_REM` · products
`110i` card stagger, pills `260 + 70i`, fill `{ 260, 28 }`, chip `{ 200, 14 }`, crops · room
`520 + 90i`, `scale 0.82`, scrim `66%`, `5.5 / 14rem` (`2.5 / 6rem`), `focus` shares, band
coordinates · footer delays `0 / 110 / 280 / 340 / 420 / 470 / 500`, columns `110i + 50j`,
newsletter `{ 280, 26 }`, `x −4 / −8` · CSS transitions `150 / 250ms`, `cubic-bezier(0.2, 0, 0, 1)`.

## Assets

```
ASSET_BASE_URL = https://storage.getlayers.ai/assets/halden-5875ef9537
```

Load every file from that folder, absolutely (`ASSET(path) = ASSET_BASE_URL + "/" + path`). Every
photograph is a PNG at the size its comp exported; the catalogue previews' intrinsic sizes are in the
data above and drive the plate aspect.

| what | URL | size |
|---|---|---|
| hero — collection chair | `…/hero/collection-chair.png` | 2.26 MB |
| hero — dining chair | `…/hero/dining-chair.png` | 1.57 MB |
| hero — modular sofa | `…/hero/modular-sofa.png` | 2.42 MB |
| hero — new arrival 1 | `…/hero/new-arrival-1.png` | 2.40 MB |
| hero — new arrival 2 | `…/hero/new-arrival-2.png` | 2.52 MB |
| manifesto — blue armchair | `…/manifesto/armchair-blue.png` | 3.01 MB |
| manifesto — orange armchair | `…/manifesto/armchair-orange.png` | 2.42 MB |
| catalogue — beds 1 / 2 | `…/catalogue/beds-1.png` · `…/catalogue/beds-2.png` | 1.11 MB · 1.11 MB |
| catalogue — bedsides 1 / 2 | `…/catalogue/bedsides-1.png` · `…/catalogue/bedsides-2.png` | 395 KB · 281 KB |
| catalogue — lighting 1 / 2 | `…/catalogue/lighting-1.png` · `…/catalogue/lighting-2.png` | 474 KB · 604 KB |
| catalogue — mirrors 1 / 2 | `…/catalogue/mirrors-1.png` · `…/catalogue/mirrors-2.png` | 643 KB · 632 KB |
| catalogue — chairs left / right | `…/catalogue/preview-left.png` · `…/catalogue/preview-right.png` | 608 KB · 402 KB |
| catalogue — sofas 1 / 2 | `…/catalogue/sofas-1.png` · `…/catalogue/sofas-2.png` | 373 KB · 544 KB |
| catalogue — storage 1 / 2 | `…/catalogue/storage-1.png` · `…/catalogue/storage-2.png` | 441 KB · 381 KB |
| catalogue — tables 1 / 2 | `…/catalogue/tables-1.png` · `…/catalogue/tables-2.png` | 407 KB · 297 KB |
| products — Elara lounge chair | `…/products/elara-lounge-chair.png` | 1.44 MB |
| products — Ansel dining table | `…/products/ansel-dining-table.png` | 605 KB |
| products — Marlow two-seater | `…/products/marlow-two-seater.png` | 743 KB |
| products — Vega sideboard | `…/products/vega-sideboard.png` | 604 KB |
| room — Christianshavn apartment | `…/room/christianshavn.png` | 2.08 MB (1376×603) |
| font — Instrument Serif Regular | `…/fonts/InstrumentSerif-Regular.ttf` | 69 KB |
| font — Instrument Serif Italic | `…/fonts/InstrumentSerif-Italic.ttf` | 71 KB |
| font — Bebas Neue Regular | `…/fonts/BebasNeue-Regular.otf` | 70 KB |
| font — Bebas Neue Bold | `…/fonts/BebasNeue-Bold.otf` | 102 KB |

The preloader decodes the five hero files; the fonts count as one more unit of its progress
(`document.fonts.ready`). **A failed load must be visible**: if a photograph or a face cannot be
fetched, show a banner naming the URL rather than an empty plate over a correct layout — a hero
whose plates never uncover looks like a timing bug, not a missing file.
[/hidecontent]
← 返回首页