类型:Site模板 · 收录:2026-10-17 12:02:10 · 源码:会员可见
# Prompt: Recreate the Sur(face) site (new-16) exactly
Copy everything below the line into your AI coding tool.
---
Recreate the **Sur(face)** site: a one-page, scroll-driven site for an avant-garde model agency for unconventional
faces. It must match **exactly**, so reproduce the files at the end of this prompt **byte for byte**, with the same
file names and folder layout. Do not "improve", restyle, rename, re-time or refactor anything.
## What it is
- **Hero.** A fixed stage with a full-screen canvas.
- The canvas plays a 121-frame clip as you scroll: the camera comes down from above to the face of Eva K., a model in a red top.
- Over it sit the wordmark «Sur(face)» and the number «26», both at mega size in white with `mix-blend-mode: difference`, plus three lines of copy and two stacked buttons (accent over ink).
- Intro on load: the footage fades and settles, the header rises, the title characters develop from blur, and the copy and buttons rise.
- At the middle of the clip, the title, number and copy leave by the same moves played backwards.
- **Header.** A white bar at the bottom centre with sharp corners: the «Sur(face)®» logo block, then «New faces (6) / Models (24) / Become a model / Shortlist (0)».
- **Section 2 (faces), on the same stage.**
1. The footage blurs into a grainy grey haze.
2. A true circle of 8 tilted square portrait cards appears and spins (4°/s), growing ×0.5 → ×1.
3. The statement «Sur(face) / Bare Skin / Uneven Features / UNCUT / Bone × Light / Seen – Kept» appears word by word from blur, black first, then red.
4. The ring folds into one row of 3:4 cards (4 per screen) with captions «Eva (21)», «Malick (24)», «Liv (22)» and the title «(faces)».
5. The row travels sideways with the scroll.
- **Model card (Eva).** Clicking Eva's card opens a dialog:
- the photo FLIPs out of the card;
- a white panel holds the name and age, bio, hairline-ruled stats, and «Contact her manager» / «Add to shortlist» (which toggles «Shortlist (0/1)»);
- an endless smooth-scrolling strip of 3 frames runs beside it;
- «(21)» sits at mega size in the corner;
- Esc or the close button folds it all back.
- **Type.** Helvetica Neue, local only, at −0.08em tracking. Without it, Inter Tight / Inter load from Google Fonts. Four sizes only (mega, display, body, small), weights 400 and 500. Full rules in `DESIGN.md` below.
- **Reduced motion:** rest states, no scrubbed motion.
## Stack (load exactly these)
- GSAP 3.13.0 + ScrollTrigger, SplitText and CustomEase from jsDelivr, plus Locomotive Scroll 5.0.0 (bundled, with Lenis inside). All are plain `defer` scripts that run before the `main.js` module.
- Vanilla ES modules: `main.js` + `lib/*.js`. No framework, no build step.
- Serve over HTTP (for example `npx serve . -l 5196`). `file://` won't work because ES modules need an origin.
## Media (all remote: Higgsfield storage on CloudFront)
- **Hero clip:** 121 WebP stills per set.
- Set `d` is 1920×1078 for desktop. Set `m` is an 808×1078 centre crop for phones (≤767px).
- Their media ids are in `lib/media.js`; `frameUrl(set, i)` builds `https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/<id>.webp`.
- **Cards and frames:** the 8 ring/row card images, the 3 model-card frames and the 2 poster preloads are absolute URLs in `index.html`.
- **No canvas read-back:** no pixel is read from the canvas, so no CORS setup is needed.
## How to rebuild
1. Create exactly these files with exactly this content: `index.html`, `style.css`, `main.js`, `lib/beats.js`, `lib/faces.js`, `lib/gsap.js`, `lib/header.js`, `lib/hero.js`, `lib/media.js`, `lib/model.js`, `lib/scroll.js`, `lib/sequence.js`, `lib/vars.js`, `lib/words.js`.
2. Serve the folder and open it at 1440×900 and at 390×844. Check that:
- frame 0 shows on load, followed by the intro;
- scrolling scrubs the clip, and the type leaves at the middle;
- the ring, statement, fold and row play in order, with captions;
- clicking Eva opens her model card.
## Acceptance check (1440×900, top of page)
- The footage of Eva fills the screen, hair on the top edge.
- The huge white-difference «Sur(face)» sits top-left on the 16px gutter, with «26» at the right.
- Three lines of body copy and two stacked buttons sit in the lower area.
- The white header bar sits at the bottom centre.
---
## Design spec (`DESIGN.md`, verbatim)
# new-16 · Avant-garde model agency — Design system
**Status:** typography only. No layout, no code yet. Casting/imagery lives in `MODELS-PROMPT.md`.
**Where from.** Typography — bergerfohr.com (Berger & Föhr, Neue Haas Grotesk Display + Neue Haas Text, both
commercial → Inter Tight + Inter from Google Fonts). Measured in the browser on 2026-10-14 at their 1680px canvas.
---
## 0. In one paragraph
Swiss, black on white, almost nothing but type and faces. Two optical sizes of one grotesk idea: a display cut for
everything big, a text cut for everything you read. Four sizes, and the jump between the two display tiers and the
reading text is violent (×3+) — giant names, small calm text, nothing in between. Weights 400 and 500 only, tracking
never positive, caps only on the big pill navigation.
## 1. Source — what bergerfohr.com actually does
Fonts loaded: `NeueHaasDisplay55Roman` 400 (all headings, class `font-display`), `NeueHaasTextPro` 400/500 (body, UI).
Body `16px / 1.3`, `-webkit-font-smoothing: antialiased`, `letter-spacing: normal` everywhere, section padding 20px.
Scale is Tailwind `text-N` on fluid `clamp()`, where N = px on a 1680px canvas; fixed px from 1720px up.
| class | @1680 px | clamp | lh | face / weight | where |
|---|---|---|---|---|---|
| wordmark (SVG) | 1640 × 257 box | fit to width | — | Display | «Berger & Föhr» footer, «B&F» logo 395 × 170 |
| `text-200` | 199 | `clamp(8.4375rem, 3.817rem + 8.2143vw, 18.5rem)` | — | Display 400 | defined, giant |
| `md:text-100` | 100 | `clamp(4.25rem, 1.9541rem + 4.0816vw, 9.25rem)` | 1.0 | Display 400, UPPERCASE | pill nav «DESIGN» «ART» |
| `md:text-70` | 70 | `clamp(2.9375rem, 1.3017rem + 2.9082vw, 6.5rem)` | 1.05 | Display 400 | manifesto statements |
| `md:text-60` | 60 | `clamp(2.5625rem, 1.1849rem + 2.449vw, 5.5625rem)` | 1.15 | Display 400 | «Berger», «Föhr», «Email Newsletter», «Works» |
| `md:text-20` | 20.7 | `clamp(0.875rem, 0.3911rem + 0.8602vw, 1.875rem)` | 1.3 | Text 400 | descriptions, contacts |
| `lg:text-14` | 14 | `clamp(0.5625rem, 0.1996rem + 0.6452vw, 1.3125rem)` | 1.3 | Text 500 | menu, cart |
| `lg:text-11` | 10.8 | `clamp(0.4375rem, 0.1653rem + 0.4839vw, 1rem)` | 1.2 | Text 400 · 500 links | case tags, «View Case Study» |
Colour (observed, not adopted yet): `#000`, `#fff`, `#808080` secondary/ghost text («Subscribe»), `#ECECEC` panels.
## 2. Typeface — Helvetica Neue first, Inter as the fallback (current, 2026-10-14)
The user switched the whole site to Helvetica with **−8% tracking (−0.08em) on every size**. The original Google Fonts match below is kept as the fallback.
- **Primary: Helvetica Neue, local only.** Every Mac and iPhone has it, with the 500 (Medium) the UI uses; plain Helvetica has no Medium. Nothing is downloaded for these visitors.
- **Fallback: Inter Tight (display) / Inter (text) from Google Fonts**, for devices without Helvetica Neue (Windows, Android, Linux).
- The CSS stack falls to them on its own: `'Helvetica Neue', 'Inter Tight' | 'Inter', …, Arial`.
- An inline script in `<head>` checks for Helvetica Neue before first paint: it compares a test string's width against the generic monospace fallback. Without the face it adds `html.no-helvetica` and only then injects the Google Fonts link.
- **Plain `Helvetica` is deliberately not in the stack.** Windows silently substitutes it with Arial, which would win over Inter.
- **Optical numbers are per face**, measured in the browser with canvas metrics at −0.08em. They are tokens in `style.css`, and `html.no-helvetica` overrides them.
| token | Helvetica Neue | Inter fallback | what it does |
|---|---|---|---|
| `--cap-pad` | .1035 | .0875 | mega line-box top → capital top at lh .9 (title capitals 16px off the top) |
| `--title-lsb` | .037em | .0332em | S left side-bearing → title ink on the 16px gutter |
| `--about-lsb` | .037em | .0566em | first letter of the copy → same 16px line |
| `--num-rsb` | −.043em | −.0439em | last digit of «26» incl. trailing tracking → ink 16px off the right edge |
Re-measure `--num-rsb` if the number changes, and `--title-lsb` / `--about-lsb` if the first letters change.
- The section 2 statement is set at `max-width: 8.5em`. Tighter tracking packs more words per line, and 8.5em keeps every word inside the ring from the first word on. Checked with an oriented-rectangle test: 6 624 checks, 0 hits.
### 2a. The original match (bergerfohr.com → Google Fonts), now the fallback
Tested side by side on their own page (same string, same size) against Inter Tight, Inter, Geist, Host Grotesk,
Schibsted Grotesk, Archivo, Instrument Sans, Hanken Grotesk. Widths from canvas `measureText` at 100px:
| original | match | width without tracking | tracking for equal width | cap-height (orig → match) |
|---|---|---|---|---|
| Neue Haas Display 55 | **Inter Tight 400** | +3.3 % (others +10…15 %) | **−0.015em** | 71.5 → 72.8 |
| Neue Haas Text Pro | **Inter 400 / 500** | +2.7 % | **−0.01em** | 74.4 → 72.8 |
- Inter Tight for text would need **+0.036em** — positive tracking is against the house rule, so text goes to Inter.
- The pair mirrors the original: a display optical cut for big type, a text cut for small type.
- Both cover **Cyrillic** (checked by render: «МОДЕЛИ Лицо 2026») — the site is Russian.
- Inter Tight is already the house face (new-14).
```html
<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+Tight:wght@400;500&family=Inter:wght@400;500&display=swap" rel="stylesheet">
```
Offline build: self-host 400/500 of both, subsets latin + latin-ext + cyrillic + cyrillic-ext, with an Arial-based
metric fallback (as `Inter Tight Fallback` in new-14).
## 3. Scale — four sizes
Compressed from their eight: 200/100 → **mega**, 70/60 → **display**, 20 → **body**, 14/11 → **small**.
| token / class | role | face | @390 → @1440 → @1680 | lh | tracking | weight |
|---|---|---|---|---|---|---|
| `--t-mega` · `.t-mega` | giant headlines, model names, wordmark, hero number | Inter Tight | 72 → 222 → 256 | 0.9 | −0.02em | 400 |
| `--t-display` · `.t-display` | statements, section titles, pill nav (UPPERCASE allowed here only) | Inter Tight | 30 → 63 → 70 | 1.05 | −0.015em | 400 |
| `--t-body` · `.t-body` | reading text, bios | Inter | 15 → 18.6 → 20.7 | 1.3 | −0.01em | 400 |
| `--t-small` · `.t-small` | model stats (height, city), menu, meta, links | Inter | 11 → 11.4 → 12.3 | 1.2 | 0 | 400 · 500 for UI/links |
Steps at 1680: mega/display ×3.7 · display/body ×3.4 · body/small ×1.7.
Mega was raised on 2026-10-14 to the hero mock (wordmark ≈ 57% of the width); the bergerfohr value was 200 @1680.
```css
:root {
--font-display: 'Inter Tight', -apple-system, 'Helvetica Neue', Arial, sans-serif;
--font-text: 'Inter', -apple-system, 'Helvetica Neue', Arial, sans-serif;
--t-mega: clamp(4.5rem, 1.025rem + 14.26vw, 24rem); /* 72 @390 · 256 @1680 · cap 384 */
--t-display: 1.875rem; /* 30 below 768 */
--t-body: clamp(0.9375rem, 0.39rem + 0.86vw, 1.875rem); /* 15 → 20.7 @1680 */
--t-small: clamp(0.6875rem, 0.4rem + 0.35vw, 0.9375rem); /* 11 → 12.3 @1680 */
--lh-mega: .9; --ls-mega: -.02em;
--lh-display: 1.05; --ls-display: -.015em;
--lh-body: 1.3; --ls-body: -.01em;
--lh-small: 1.2; --ls-small: 0;
--w-text: 400;
--w-ui: 500;
}
@media (min-width: 768px) {
:root { --t-display: clamp(2.9375rem, 1.3017rem + 2.9082vw, 6.5rem); } /* bergerfohr text-70, 70 @1680 */
}
body { font: var(--w-text) var(--t-body)/var(--lh-body) var(--font-text); letter-spacing: var(--ls-body);
-webkit-font-smoothing: antialiased; }
.t-mega { font: 400 var(--t-mega)/var(--lh-mega) var(--font-display); letter-spacing: var(--ls-mega); }
.t-display { font: 400 var(--t-display)/var(--lh-display) var(--font-display); letter-spacing: var(--ls-display); }
.t-body { font: 400 var(--t-body)/var(--lh-body) var(--font-text); letter-spacing: var(--ls-body); }
.t-small { font: 400 var(--t-small)/var(--lh-small) var(--font-text); letter-spacing: var(--ls-small); }
```
## 4. Rules
- Four sizes, no in-between sizes. Need emphasis → weight 500 or colour, not a fifth size.
- Weights 400 and 500 only. Display tiers are 400 (as the source), 500 is for UI and links in `small`.
- Tracking never positive; it tightens with size (0 → −.01 → −.015 → −.02em).
- UPPERCASE only in `display` pill navigation (as «DESIGN» «ART»). No caps eyebrows, no mono, no numbering.
- `small` floor raised from the source's 7px to 11px — Cyrillic stays readable.
- `mega` at lh .9: Cyrillic capitals with diacritics (Й, Ё) and Latin Ö touch the line above — leave
`overflow: visible` on mega lines or give the block top padding ≈ .1em.
## 5. Section 2 · faces (built 2026-10-14)
From paypath.ai's second section: a ring of tilted cards around a statement that appears word by word. Their ending, where everything blurs and flies apart, is replaced here: the ring folds into one row (4 cards per screen) that then travels sideways.
- **Engine.** Everything sits on the hero's fixed stage. `.faces` is only a scroll runway that starts where the clip ends. Windows are in viewport heights in `lib/beats.js`; the motion is in `lib/faces.js`.
- **Background.**
- The footage gets a CSS blur of up to 16px, gated so it only exists while the blur leg runs.
- At the same time it crossfades into a haze: the last frame squeezed until its short side is 10px, scaled back up in doubling steps, and stretched over the screen by the GPU. 30% black is baked into the haze.
- The footage and the haze zoom 1 → 1.4 from the centre (power2.inOut).
- **Ring** — paypath's, measured (rebuilt 2026-10-14 after the user's review: no ellipse, no lean, no fly-in).
- A true circle: 8 square cards exactly 45° apart, each tangent (rotation = angle + 90°, upright at the top, upside down at the bottom).
- The whole ring grows as one, linearly ×0.5 → ×1 over 2H: the radius goes .30B → .60B and the square .073B → .146B. B = min(H, 1.25W). By the end the circle runs off the top and bottom of the screen.
- All eight appear together, small, with an opacity fade only.
- It spins steadily in time only (4°/s) and brakes to a standstill (smoothstep) just before the fold.
- **Statement** — set after the KI/KI poster (2026-10-14):
```
Sur(face)
Bare Skin
Uneven Features
UNCUT
Bone × Light
Seen – Kept
```
- Every line `t-display` at 500 with lh 1.02, centred, no wrapping.
- Pauses: one empty line after the name, half a line between the blocks. The dash gets .2em of air on each side.
- Silhouette as on the poster: the second line of the first block is the widest, and the lower block is narrower.
- Short words only. The user found long words bulky, and the brand is not tied to any city.
- Colour `--accent` printed with `mix-blend-mode: multiply`, so the grain shows through.
- Word by word with new-14's `lib/words.js`. Each word develops from blur in black, then fills to red three steps later, so a red wave follows the black one.
- The poster's pauses are also pauses in time: 2 empty steps after the name and 5 before the second block (`--skip`). Words run over 1.05 → 2.0H.
- No word touches a card: checked with an oriented-rectangle test at 18 ring angles, 0 hits on desktop and mobile.
- **Fold** — a dealer laying out a row: all eight on one clock and one curve (`inOut` .77,0,.175,1), no stagger.
- Each card's slot is its rank by x on the still circle — with one exception: the same face must never stand twice in a row (user, 2026-10-14).
- The spin stops at a timed angle, so no ring order alone can guarantee that.
- Instead, ring neighbours on opposite arcs may trade places (`rowOrder` in `lib/faces.js`): the fewest swaps that leave no repeat, and on a tie the pair farthest apart in height. They pass each other in x while far apart in y.
- The ring order M W M W A M W A is the best of the 7 orders without a face next to itself: at every stop angle the swapped pairs are ≥ .77R apart vertically.
- Checked at 120 stop angles on 1470×956 and 390×844: 0 repeats, 0 card overlaps through the fold (closest pass 15px).
- First the cards spread sideways into their columns and turn upright (x over the first 60% of the leg); then the columns close vertically into the line (y over the last 60%). Order never changes, so no path crosses another — checked with an oriented-rectangle test at five stop angles.
- Size and the square → 3:4 opening (`clip-path` inset → 0) run across the whole leg.
- Row card: `(W − 2·16 − 3·16)/4` wide, 3:4, 16px gaps. The row then travels left 1:1 with the scroll.
- **Captions** (user, 2026-10-14) — what an agency board shows: the name left, the age in brackets right.
- The set: Eva (21), Malick (24), Liv (22). Two digits, because three don't fit at mega in the model card's corner. A model's angles share one caption, like polaroids.
- Type: one `small` line, black, .65em under the card. It is a `figcaption`, so it rides with the card along the row; the ring's square clip sits on the `img`, not the card, so the caption isn't cut off.
- Timing: once the fold is done (fold ≥ .98) they come in one by one, left to right by slot — 100ms apart, the height 70ms after the name. Each rises .6em and develops from a 6px blur over .9s expo. Going back out of the row, all leave together in .3s.
- The blur is armed only from fold ≥ .9 and is `none` at rest.
- **Row title** — «(faces)», lowercase as the user wrote it: one word in brackets, the caption language. It is section 2's H2: `display`, Medium, cap tops on the gutter at the top-left, where the hero's H1 sits. It comes in with the captions (rise .15em, blur 12px, 1.1s expo).
## 6. Model card · Eva (built 2026-10-14, `lib/model.js`)
Opened by a click (or Enter / Space) on either of Eva's cards, once the row has landed. A fixed dialog over everything (`--z-model: 80`) on section 2's grey plate with its grain.
- **Grid** — one scroll container with three columns, `[16 + card] [1fr] [16 + card]`. They are symmetric, so the frames sit dead centre, next to the panel.
- **Panel** — white, the row card's width (`--pw` = `faces.geo().cw`, 347px @1470), full height with 16px insets. It is literally the row's first column stretched to full height.
- Inside, Fibonacci: padding 21; rows 38.2fr / 61.8fr.
- Head: «Eva / Age 21» — two `display` lines, Medium, lh 1.02, set like the statement before the ring. Cap tops sit on the 21px line (`--cap-pad-display`).
- Then «( New face )» and a three-sentence bio (`body`).
- Stats in `small`: one hairline-ruled row each (rgb 0 0 0 / .1), label left, value right (Medium, tabular), like the captions.
- At the bottom, two Synara blocks: «Contact her manager» (accent; `#contact` is a placeholder) and «Add to shortlist» (ink). The second really toggles «Shortlist (0/1)» in the bar.
- **Corner** — «(21)» at the hero H1's size (`mega`), set like «26»: white + difference, bottom-right. Its ink is on both gutters: `--paren-rsb` and `--paren-sink` were measured with canvas, per face. The close is a 44px ink block at the top-right; its hover is accent.
- **Slider** — 3 clip frames (000 / 080 / 120), 3:4, `100svh − 32` tall, 16px apart, the clicked one first. The user dropped 040 as «криповая».
- A plain smooth scroll, no snapping (user, 2026-10-14). The wheel moves a target and the strip follows it with the page's Lenis feel (lerp .07, wheel ×.85), so it carries a little inertia.
- Endless downward: a copy of the set follows the real one and the position wraps by one set height, so the seam never shows.
- Upward it stops on the first photo (target ≥ 0).
- Arrow keys, PgUp, PgDn and Space move one frame.
- Phones keep native scroll, one column (frame 1 → panel → the rest), with no copy.
- Two earlier versions were rejected. Stepping one frame per gesture swallowed swipes in a trackpad's inertia tail. Native mandatory snap reads a wheel tick as 120px and snaps back.
- **Open** (Emil + the site's curves):
- 0s — the photo FLIPs from the card into place (transform only, 3:4 → 3:4, 1.0s `inOut`).
- 0s — the other row cards drift apart, left ones left and right ones right, ±.6W, .9s `inOut`; a numeric proxy writes each card's `translate`. Their images fade, and every caption goes.
- 0s — the panel appears as the first row card (its clip is a number written by hand, because the browser shortens `inset(a 0 a 0)` and a tweened string loses its bottom value) and stretches to full height, .9s `inOut`.
- .3s — the plate fades in.
- .55s — the name, age and corner number come in like the hero title.
- .7s — the label, bio lines and stat rows come in like the statement words.
- .9s — the buttons get the hero widget's entrance; the close comes in from .9 scale.
- **Close** (cross / Esc) — shorter than the open, not a reverse:
- the content goes in .2s, and the plate first;
- the panel folds back to the card;
- the photo flies home if it is still on screen, otherwise it dissolves;
- the row slides back together (.7s).
- **While open** — Lenis is stopped, `main` and the bar are `inert`, and focus sits on the slider; on close it returns to the card. Reduced motion: a .2s crossfade.
- The hero's title, number and copy are `pointer-events: none`: once gone they still lay over the row and swallowed the card clicks.
---
## Source files (reproduce byte for byte)
### `index.html`
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Sur(face) — model agency for unconventional faces</title>
<meta name="description" content="Sur(face) is a model agency for unconventional faces. Street-cast, never retouched, booked worldwide.">
<meta name="theme-color" content="#f6f7f9">
<!-- Set before first paint, inline (a deferred module would run too late and flash):
1. .js — the hidden/rested states key off it;
2. the typeface (DESIGN.md §2): Helvetica Neue is local-only. If this device
has it, nothing is downloaded. If not, html.no-helvetica switches the
optical numbers and Inter Tight / Inter are fetched from Google Fonts —
the CSS stack already falls to them. Detection: a test string's width in
"Helvetica Neue" vs the generic monospace it would fall back to. -->
<script>
(function () {
var d = document.documentElement;
d.classList.add('js');
/* the page always starts at the top: set before the first paint — Safari restores
the old scroll on reload otherwise, after main.js has already scrolled to 0 */
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
var c = document.createElement('canvas').getContext('2d'), t = 'mmmmmlliiWWQ@';
c.font = '72px monospace';
var base = c.measureText(t).width;
c.font = '72px "Helvetica Neue", monospace';
if (c.measureText(t).width !== base) return;
d.classList.add('no-helvetica');
var l = document.createElement('link');
l.rel = 'stylesheet';
l.href = 'https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&family=Inter:wght@400;500&display=swap';
document.head.appendChild(l);
})();
</script>
<link rel="stylesheet" href="style.css">
<!-- the poster: frame 0 of the hero sequence, desktop set (the phone set loads its own) —
CDN build: every still is on Higgsfield storage (lib/media.js, media-manifest.json) -->
<link rel="preload" as="image" href="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/5f55f68c-b7b2-415e-b3f3-cb0e73c205e6.webp" media="(min-width: 768px)">
<link rel="preload" as="image" href="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/fee305e6-6957-4257-998f-020018a2c048.webp" media="(max-width: 767px)">
<!-- engine: GSAP 3.13 + Locomotive Scroll 5 (Lenis inside) — the new-14 stack.
Plain deferred scripts execute in order, before the module below. -->
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/CustomEase.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/locomotive-scroll@5.0.0/bundled/locomotive-scroll.min.js"></script>
<script type="module" src="main.js"></script>
</head>
<body>
<!-- ── header (Synara): a white bar at the bottom centre, sharp corners — the logo
as a solid accent block, then the sections split by slashes, the current one
in the accent. One plate, so it reads on the footage without any blending. ── -->
<header class="header t-small" data-header>
<div class="header__bar" data-header-item>
<a class="header__logo" href="#top" aria-label="Sur(face) — home">Sur(face)®</a>
<nav aria-label="Primary">
<ul class="header__nav">
<li><a href="#new-faces" aria-current="page">New faces (6)</a></li>
<li class="is-wide"><a href="#models">Models (24)</a></li>
<li class="is-wide"><a href="#casting">Become a model</a></li>
<li><a href="#shortlist">Shortlist (0)</a></li>
</ul>
</nav>
</div>
</header>
<main id="top">
<!-- ── hero: the section is only a scroll runway (100svh + --run); everything you
see lives in one fixed stage (design-dna P28), so the title's blend and
the canvas share a stacking context. The clip plays with the scroll
(lib/sequence.js); title, number and copy leave at the middle of it. ── -->
<section class="hero" data-hero aria-label="New faces">
<div class="hero__stage">
<div class="hero__media" data-hero-media>
<canvas data-seq role="img" aria-label="Eva K., a Sur(face) model in a red top: the camera comes down from above to her face"></canvas>
<div class="hero__grain" aria-hidden="true"></div>
</div>
<!-- ── section 2 on the same stage (lib/faces.js) ──
the plate the footage dissolves into: flat light grey with its own
grain, over the whole stage, phone panel included -->
<div class="faces__haze" data-haze aria-hidden="true">
<div class="hero__grain"></div>
</div>
<!-- the ring: cards laid out at their row size, scaled down on the ring.
Three faces, several angles each, round the ring as M W M W A M W A — of the
seven orders with no face next to itself, the one whose row can always be
kept free of repeats with the widest gaps (lib/faces.js rowOrder): the hero
model (two frames of the clip) and two more generated in her shoot —
Nano Banana 1k with the hero frame as reference (2026-10-14, LEDGER.md),
then the hero's "magazine scan" print added, luminance only
(refs/faces-scan.py; sources in refs/faces-src).
data-focus: where the ring's square sits in the portrait (0 top · .5 · 1 bottom).
Captions, as on an agency board: the name | the age in brackets — they
come in one by one once the ring has laid out its row (lib/faces.js). -->
<div class="faces__ring" data-ring>
<!-- the row's title, one word in brackets — the site's caption language.
Display, Medium, top-left on the gutter like the hero's H1; in with the captions. -->
<h2 class="faces__title t-display">(faces)</h2>
<figure class="face-card" data-person="malick" data-focus="0"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/fbe20760-fd5c-417b-ba8c-5cd6d6e5ff90.webp" alt="A model with a shaved head in a ripped grey sweater, leaning toward the lens" decoding="async"><figcaption class="face-card__cap t-small"><span>Malick</span><span>(24)</span></figcaption></figure>
<figure class="face-card" data-person="liv" data-focus=".3"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/e83b85a8-a2c7-4d9a-bfde-da579d9172bd.webp" alt="A pale, freckled model with a platinum bob and red lips, in a red strappy top" decoding="async"><figcaption class="face-card__cap t-small"><span>Liv</span><span>(22)</span></figcaption></figure>
<figure class="face-card" data-person="malick" data-focus="0"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f5450d9d-e24a-46ad-a9dd-a697a85c4c8e.webp" alt="The model in the ripped grey sweater in profile, a small red earring turned to the camera" decoding="async"><figcaption class="face-card__cap t-small"><span>Malick</span><span>(24)</span></figcaption></figure>
<figure class="face-card" data-person="liv" data-focus="0"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/62f8a6b6-4f28-4f9f-ac65-7df2ee4964c4.webp" alt="The platinum-haired model looking back over a bare shoulder" decoding="async"><figcaption class="face-card__cap t-small"><span>Liv</span><span>(22)</span></figcaption></figure>
<figure class="face-card" data-person="eva" data-model-open="eva" role="button" tabindex="0" aria-label="Open Eva K." data-focus=".5"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/fee305e6-6957-4257-998f-020018a2c048.webp" alt="Eva K. in a red top, from above" decoding="async"><figcaption class="face-card__cap t-small"><span>Eva</span><span>(21)</span></figcaption></figure>
<figure class="face-card" data-person="malick" data-focus=".15"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/6d267ca1-3d60-4ee7-9891-fc2d5e5dedbb.webp" alt="The model in the ripped grey sweater squatting, hands reaching toward the lens" decoding="async"><figcaption class="face-card__cap t-small"><span>Malick</span><span>(24)</span></figcaption></figure>
<figure class="face-card" data-person="liv" data-focus=".25"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/25408451-ea77-4876-a8a0-7bcbc1378f80.webp" alt="The platinum-haired model sitting on the floor, hugging her knees" decoding="async"><figcaption class="face-card__cap t-small"><span>Liv</span><span>(22)</span></figcaption></figure>
<figure class="face-card" data-person="eva" data-model-open="eva" role="button" tabindex="0" aria-label="Open Eva K." data-focus=".5"><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/3a7c227e-a4ff-4a5e-8f41-b86e42a74b3a.webp" alt="Eva K. in a red turtleneck, facing the camera" decoding="async"><figcaption class="face-card__cap t-small"><span>Eva</span><span>(21)</span></figcaption></figure>
</div>
<!-- the statement, set like the KI/KI poster: the name; a three-line block
(two words · two longer words · one in caps); a two-line block held
together by text marks (× · –). Word by word (lib/words.js). -->
<p class="faces__text t-display" data-words>
<span class="faces__logo">Sur(face)</span>
<span class="faces__block">Bare Skin<br>Uneven Features<br>UNCUT</span>
<span class="faces__block">Bone × Light<br>Seen <span class="faces__mark">–</span> Kept</span>
</p>
<!-- the wordmark: white + difference, like «Ear (stick)» — dark on the
backdrop, white on the hair; «(face)» lands on her face -->
<h1 class="hero__title t-mega" data-hero-title>Sur(face)</h1>
<span class="hero__num t-mega" data-hero-num aria-hidden="true">26</span>
<p class="hero__about t-body" data-hero-about>
<span class="line">Skin keeps a record. We read</span>
<span class="line">what the street forgot to correct</span>
<span class="line">and leave it on the surface.</span>
</p>
<!-- widget (Synara): two flat blocks of one width, stacked — accent over ink -->
<nav class="hero__widget t-small" aria-label="Next" data-hero-widget>
<a class="btn btn--accent" href="#new-faces">Explore new faces</a>
<a class="btn btn--ink" href="#about">More about Sur(face)</a>
</nav>
</div>
</section>
<!-- section 2 runway: starts where the clip ends; its height comes from
lib/beats.js (--faces-run). What you see of it lives on the hero stage. -->
<section class="faces" id="new-faces" data-faces aria-label="New faces"></section>
</main>
<!-- ── the model card (lib/model.js) — Eva, opened from her cards once the row has
landed. The photo grows out of the card into the centre of the screen, full
height; the white panel is the row's first card stretched to full height
(its width IS a row card's). Head: the name and the age, two lines set like
the statement before the ring; the age in brackets sits in the screen's
bottom-right corner at the hero H1's size, as under the card (two digits —
three don't fit at mega). One scroll container:
16+card | 1fr | 16+card — the frames sit dead centre, next to the panel,
and step one by one (lib/model.js); the panel is sticky in the left column. ── -->
<div class="model" id="model-eva" data-model hidden role="dialog" aria-modal="true" aria-labelledby="model-eva-title">
<div class="model__plate" aria-hidden="true"><div class="hero__grain"></div></div>
<div class="model__scroll" data-model-scroll data-lenis-prevent tabindex="-1">
<aside class="model__panel" data-model-panel>
<h2 class="model__name t-display" id="model-eva-title"><span class="line">Eva</span><span class="line">Age 21</span></h2>
<div class="model__body t-body">
<p class="model__label" data-model-item>( New face )</p>
<p class="model__bio" data-model-bio>Street-cast after a night shift. Cuts her own fringe. Holds a look a beat too long.</p>
<dl class="model__specs t-small">
<div data-model-item><dt>Height</dt><dd>174</dd></div>
<div data-model-item><dt>Bust</dt><dd>80</dd></div>
<div data-model-item><dt>Waist</dt><dd>59</dd></div>
<div data-model-item><dt>Hips</dt><dd>86</dd></div>
<div data-model-item><dt>Shoes</dt><dd>38</dd></div>
<div data-model-item><dt>Hair</dt><dd>Black</dd></div>
<div data-model-item><dt>Eyes</dt><dd>Brown</dd></div>
</dl>
<div class="model__actions t-small">
<a class="btn btn--accent" href="#contact" data-model-btn>Contact her manager</a>
<button class="btn btn--ink" type="button" data-model-btn data-shortlist aria-pressed="false">Add to shortlist</button>
</div>
</div>
</aside>
<div class="model__frames">
<img class="model__frame" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/fee305e6-6957-4257-998f-020018a2c048.webp" alt="Eva K. from above, in a red top" width="808" height="1078" decoding="async">
<img class="model__frame" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/97a687de-3439-49f6-b7fd-0905674064ea.webp" alt="Eva K. facing the camera" width="808" height="1078" decoding="async" loading="lazy">
<img class="model__frame" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/3a7c227e-a4ff-4a5e-8f41-b86e42a74b3a.webp" alt="Eva K. in a red turtleneck, facing the camera" width="808" height="1078" decoding="async">
</div>
</div>
<!-- the age, as under her card — at the hero H1's size (mega, like «26»), in the screen's free corner -->
<p class="model__num t-mega" aria-label="Age 21"><span class="line">(21)</span></p>
<button class="model__close" type="button" data-model-close aria-label="Close Eva K."></button>
</div>
</body>
</html>
```
### `style.css`
```css
/* new-16 · Sur(face) · style.css
Type system: DESIGN.md — Helvetica Neue (local), Inter Tight / Inter as the fallback; four sizes, tracking −8%.
Layers follow new-14: tokens are values, components are boxes, motion derives
transform/opacity/filter from 0→1 custom props written by lib/*.js. */
@layer reset, tokens, base, components, motion;
/* vertical-metric fallbacks so line boxes do not jump when the faces land
(Inter Tight / Inter: ascender 2728 · descender −680 on a 2816 UPM).
The hero waits for document.fonts.ready anyway; these cover a slow font. */
@font-face {
font-family: 'Inter Tight Fallback';
src: local('Arial');
ascent-override: 96.88%;
descent-override: 24.15%;
line-gap-override: 0%;
size-adjust: 100%;
}
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 96.88%;
descent-override: 24.15%;
line-gap-override: 0%;
size-adjust: 100%;
}
@layer reset {
*, ::before, ::after { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; }
html, body { width: 100%; overscroll-behavior: none; }
img, svg, video, canvas { display: block; max-width: 100%; }
img, video { height: auto; }
button, input, select, textarea { font: inherit; letter-spacing: inherit; color: inherit; background: none; }
button { cursor: pointer; }
ul, ol { list-style: none; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; }
:focus:not(:focus-visible) { outline: none; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
}
@layer tokens {
:root {
/* Helvetica Neue — local only (every Mac / iPhone has it, with the 500 the UI
needs; plain Helvetica has no Medium). Where it is missing the stack falls
to Inter Tight / Inter, which index.html loads from Google Fonts ONLY then
(html.no-helvetica). Plain 'Helvetica' is deliberately NOT in the stack:
Windows silently maps it to Arial, and Arial would win over Inter. */
--font-display: 'Helvetica Neue', 'Inter Tight', 'Inter Tight Fallback', Arial, sans-serif;
--font-text: 'Helvetica Neue', 'Inter', 'Inter Fallback', Arial, sans-serif;
/* the four sizes — DESIGN.md §3. Mega raised to the user's hero mock
(2026-10-14): the wordmark spans ~57% of the width, 256px @1680. */
--t-mega: clamp(4.5rem, 1.025rem + 14.26vw, 24rem); /* 72 @390 · 226 @1470 · 256 @1680 */
--t-display: 1.875rem; /* 30 below 768 */
--t-body: clamp(0.9375rem, 0.39rem + 0.86vw, 1.875rem); /* 15 → 20.7 @1680 */
--t-small: clamp(0.6875rem, 0.4rem + 0.35vw, 0.9375rem); /* 11 → 12.3 @1680 */
/* tracking −8% on every size (user, 2026-10-14) */
--lh-mega: .9; --ls-mega: -.08em;
--lh-display: 1.05; --ls-display: -.08em;
--lh-body: 1.3; --ls-body: -.08em;
--lh-small: 1.2; --ls-small: -.08em;
--w-text: 400;
--w-ui: 500;
/* colour: ink + the plate sampled from the hero backdrop (#f6f7f9), so the
footage has no edge against the page; one accent, sampled from her top */
--k: #000;
--w: #fff;
--bg: #f6f7f9;
--accent: #c32515;
--accent-hover: #a51d10;
--faces-plate: #dcdcde; /* section 2: the light grey the footage dissolves into (trial, 2026-10-14) */
--ink-40: rgb(0 0 0 / .4); /* the slashes between sections */
--gutter: 16px; /* every hero element sits 16px off the viewport edges */
/* runway lengths — written by lib/beats.js (applyBeats); these are the pre-JS defaults */
--run: 150svh;
--faces-run: 430svh;
/* header bar (Synara): the logo block's height + the bar's inset on both sides */
--bar-pad: 4px;
--logo-pad-y: .45em;
--bar-h: calc(var(--t-small) * (var(--lh-small) + .9) + 2 * var(--bar-pad)); /* .9 = 2 × logo-pad-y */
/* optical numbers — measured per face in the browser (canvas metrics, tracking −.08em):
--cap-pad gap between a mega line box's top and its capital top at lh .9
(Helvetica Neue: ascent .95 · descent .215 · cap .714 → .1035em)
--title-lsb the S's left side-bearing, so the title's ink sits on the gutter
--about-lsb the same for the copy's first letter
--num-rsb the number's last digit (the 6 of «26»): right side-bearing incl. the
trailing tracking (negative: at −8% the ink runs past its box).
Re-measure it if the number changes. */
--cap-pad: .1035;
--title-lsb: .037em;
--about-lsb: .037em;
--num-rsb: -.043em;
/* the model card's «(21)» at mega: the ) runs past its box (rsb, as --num-rsb),
and the brackets sink below the line box by --paren-sink em (ink descent
.197 − box-to-baseline .0805 at lh .9) — so the ink meets both gutters */
--paren-rsb: -.033em;
--paren-sink: .1165;
--ease-magic: cubic-bezier(.33, 0, 0, 1); /* UI, hovers */
--ease-out-expo: cubic-bezier(.19, 1, .22, 1);
--ease-expo: cubic-bezier(.16, 1, .3, 1); /* blur reveals (house default) */
--ease-out: cubic-bezier(0, 0, .2, 1); /* :active */
--z-header: 60;
--z-model: 80; /* the model card covers everything, the bar included */
/* the same cap gap for a display line (lh 1.05 instead of mega's .9) */
--cap-pad-display: calc(var(--cap-pad) + (var(--lh-display) - var(--lh-mega)) / 2);
}
@media (min-width: 768px) {
:root { --t-display: clamp(2.9375rem, 1.3017rem + 2.9082vw, 6.5rem); } /* bergerfohr text-70, 70 @1680 */
}
/* the Inter fallback's own optical numbers (Inter Tight display, Inter text) */
html.no-helvetica {
--cap-pad: .0875;
--title-lsb: .0332em;
--about-lsb: .0566em;
--num-rsb: -.0439em;
--paren-rsb: .003em;
--paren-sink: .05;
}
}
@layer base {
html {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
background: var(--bg);
}
body {
font: var(--w-text) var(--t-body)/var(--lh-body) var(--font-text);
letter-spacing: var(--ls-body);
background: var(--bg);
color: var(--k);
}
::selection { background: var(--k); color: var(--w); }
.t-mega { font: 400 var(--t-mega)/var(--lh-mega) var(--font-display); letter-spacing: var(--ls-mega); }
.t-display { font: 400 var(--t-display)/var(--lh-display) var(--font-display); letter-spacing: var(--ls-display); }
.t-body { font: 400 var(--t-body)/var(--lh-body) var(--font-text); letter-spacing: var(--ls-body); }
.t-small { font: 400 var(--t-small)/var(--lh-small) var(--font-text); letter-spacing: var(--ls-small); }
/* Lenis (lenis.css 1.3.17, inlined — as new-14) */
html.lenis, html.lenis body { height: auto; }
.lenis:not(.lenis-autoToggle).lenis-stopped { overflow: clip; }
.lenis [data-lenis-prevent], .lenis [data-lenis-prevent-wheel], .lenis [data-lenis-prevent-touch] { overscroll-behavior: contain; }
.lenis.lenis-smooth iframe { pointer-events: none; }
.lenis.lenis-autoToggle { transition-property: overflow; transition-duration: 1ms; transition-behavior: allow-discrete; }
}
@layer components {
/* ── header (Synara): a white bar at the bottom centre, sharp corners,
content-wide. Logo = a solid accent block inset by the bar padding;
sections follow, split by slashes; the current one is in the accent. ── */
.header {
position: fixed; left: 50%; bottom: var(--gutter); z-index: var(--z-header);
translate: -50% 0;
font-weight: var(--w-ui);
white-space: nowrap;
}
.header__bar {
display: flex; align-items: center; gap: 3em;
padding: var(--bar-pad) .75em var(--bar-pad) var(--bar-pad);
background: var(--w);
color: var(--k);
}
.header__logo {
padding: var(--logo-pad-y) .65em;
background: var(--accent);
color: var(--w);
}
.header__nav { display: flex; align-items: center; }
.header__nav li + li::before {
content: '/';
margin-inline: .85em;
color: var(--ink-40);
}
.header__nav a { transition: color .3s var(--ease-magic); }
.header__nav a[aria-current="page"] { color: var(--accent); }
@media (hover: hover) and (pointer: fine) {
.header__nav a:hover { color: var(--accent); }
.header__logo { transition: background-color .3s var(--ease-magic); }
.header__logo:hover { background: var(--accent-hover); }
}
/* ── hero: .hero is only the scroll runway — 100svh + --run, the clip plays
across --run (lib/sequence.js). Everything visible lives in .hero__stage,
one fixed layer (design-dna P28): the canvas and the blended type share its
stacking context, so the blend sees the footage. No overflow / will-change /
isolation on the stage — the crop lives on .hero__media only (P13). ── */
.hero { height: calc(100svh + var(--run)); } /* --run: lib/beats.js (heroRun·H) */
/* the stage is its own stacking context, so the blended type only sees what is
painted inside it — the plate colour gives it a backdrop where the footage
is not (the phone's copy panel under the 62svh clip) */
.hero__stage { position: fixed; inset: 0; background: var(--bg); }
.hero__media { position: absolute; inset: 0; overflow: hidden; background: var(--bg); }
.hero__media canvas { width: 100%; height: 100%; }
/* film grain over the footage — new-7's tile, verbatim: feTurbulence in a data
URI (no request), desaturated (raw feTurbulence is coloured speckle), contrast
pushed by feComponentTransfer. Drawn at 200% and jogged by transform only, so
the 20 Hz flicker stays on the compositor; .hero__media clips it. */
.hero__grain {
position: absolute; top: -50%; left: -50%; width: 200%; height: 200%;
pointer-events: none;
opacity: var(--noise-opacity);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='3.5' intercept='-1.48'/%3E%3CfeFuncG type='linear' slope='3.5' intercept='-1.48'/%3E%3CfeFuncB type='linear' slope='3.5' intercept='-1.48'/%3E%3CfeFuncA type='linear' slope='0' intercept='1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 200px 200px;
}
.hero { --noise-opacity: .08; } /* new-7: .08 ≈ 2% modulation — grain without milking the blacks */
/* a 5% veil over the footage (user, 2026-10-14). Under the type, so the
blended title still inverts against it. */
.hero__media::after { content: ''; position: absolute; inset: 0; background: rgb(0 0 0 / var(--veil)); pointer-events: none; }
.hero { --veil: .05; }
/* the blended type: white + difference, like «Ear (stick)» — dark on the
backdrop, white on the hair */
.hero__title, .hero__num, .hero__about { color: var(--w); mix-blend-mode: difference; }
/* set type, never a target: once it has left it still lies over the row and
would swallow the clicks meant for the cards (the model card) */
.hero__title, .hero__num, .hero__about { pointer-events: none; }
/* wordmark: top-left corner, capitals 16px off the top and the left */
.hero__title {
position: absolute;
top: calc(var(--gutter) - var(--cap-pad) * 1em);
left: var(--gutter);
margin-left: calc(-1 * var(--title-lsb)); /* optical: the S side-bearing, so the ink sits on the gutter */
white-space: nowrap;
}
/* the number: right edge, capitals starting just above the middle (mock: 49%) */
.hero__num {
position: absolute;
top: calc(49% - var(--cap-pad) * 1em);
right: var(--gutter);
margin-right: calc(-1 * var(--num-rsb)); /* optical: the last digit's side-bearing + trailing tracking */
white-space: nowrap;
}
/* description: left edge, centred on the middle of the screen; three set lines */
.hero__about {
position: absolute; left: var(--gutter); top: 50%;
translate: 0 -50%;
margin-left: calc(-1 * var(--about-lsb)); /* optical: the first letter's side-bearing — its ink meets the title's S on 16px */
line-height: 1.3;
}
.hero__about .line { display: block; white-space: nowrap; }
/* ── widget (Synara): top-right; flat blocks of one width (the widest label
sets it), stacked with a hairline gap, sharp corners, centred labels ── */
.hero__widget {
position: absolute; top: var(--gutter); right: var(--gutter);
display: grid; gap: 4px;
}
.btn {
display: block;
padding: .6em 1.6em;
text-align: center;
font-weight: var(--w-ui);
color: var(--w);
transition: background-color .3s var(--ease-magic), transform .2s var(--ease-out);
}
.btn--accent { background: var(--accent); }
.btn--ink { background: var(--k); }
.btn:active { transform: scale(.98); }
@media (hover: hover) and (pointer: fine) {
.btn--accent:hover { background: var(--accent-hover); }
.btn--ink:hover { background: #222; }
}
/* ── section 2 · faces (lib/faces.js). .faces is only its runway; the layers
below live on the hero stage, between the footage and the hero type. ── */
.faces { height: var(--faces-run); }
/* the plate the footage dissolves into: flat light grey over the whole stage,
with its own grain; it fades in over the blurring footage */
.faces__haze { position: absolute; inset: 0; overflow: hidden; visibility: hidden; background: var(--faces-plate); }
.faces__haze.is-on { visibility: visible; }
/* the ring: cards are absolutely placed at 0,0 and moved by transform alone */
/* z-index 0: its own stacking context, so the cards' slot z-indexes stay inside
the ring and the statement always paints over them */
.faces__ring { position: absolute; inset: 0; z-index: 0; pointer-events: none; visibility: hidden; }
.faces__ring.is-on { visibility: visible; }
.face-card {
position: absolute; left: 0; top: 0; margin: 0;
width: var(--card-w, 347px); height: var(--card-h, 463px);
opacity: 0;
transform-origin: 50% 50%;
will-change: transform, opacity;
pointer-events: auto;
}
/* the image carries the ring's square clip (lib/faces.js) and the dark plate
until it lands — the card itself stays unclipped, so its caption shows */
.face-card img { width: 100%; height: 100%; object-fit: cover; background: #1a1414; }
/* the row's title — section 2's H2: display, Medium (the statement's weight),
cap tops on the gutter at the top-left, where the hero's H1 sits */
.faces__title {
position: absolute; left: var(--gutter); top: var(--gutter);
margin-top: calc(var(--cap-pad-display) * -1em);
font-weight: var(--w-ui);
line-height: 1.02;
white-space: nowrap;
color: var(--k);
}
/* the caption, as on an agency board: the name left, the age in brackets
right, one small line under the card; it rides with the card along the row */
.face-card__cap {
position: absolute; left: 0; right: 0; top: calc(100% + .65em);
display: flex; justify-content: space-between; gap: 1em;
white-space: nowrap;
color: var(--k);
pointer-events: none;
}
.face-card__cap > span + span { font-variant-numeric: tabular-nums; }
/* the statement, set like the KI/KI poster: every line one size, Medium,
tight leading, centred, set lines (no wrapping); the name, a pause of about
one line, a three-line block, a half-line pause, a two-line block. Red
printed INTO the plate (multiply), so the grain shows through the letters
the way the hair does on the poster. Widest line ≈ 661px @63px — inside the
ring by the time it appears. */
.faces__text {
position: absolute; left: 50%; top: 50%;
font-weight: var(--w-ui);
line-height: 1.02;
text-align: center;
white-space: nowrap;
color: var(--accent);
mix-blend-mode: multiply;
pointer-events: none;
}
.faces__logo, .faces__block { display: block; }
.faces__logo + .faces__block { margin-top: 1em; } /* ≈ one empty line, as after KI/KI */
.faces__block + .faces__block { margin-top: .45em; } /* half a line between the blocks */
.faces__mark { margin-inline: .2em; } /* air around the dash, as in «Lilen – Zpectrum» */
/* ── the model card (lib/model.js): fixed over everything. Plate = section 2's
grey with its grain, so the white panel reads as a card. One scroll
container, three columns: [16 + card] [1fr] [16 + card] — symmetric, so
the frames sit dead centre, next to the panel, and never meet it.
Fibonacci rhythm inside the panel: 8 · 13 · 21 · 34 · 55. ── */
.model {
position: fixed; inset: 0; z-index: var(--z-model);
--noise-opacity: .08;
--pw: 347px; /* a row card's width — lib/model.js writes the measured one */
--frame-h: calc(100svh - 2 * var(--gutter));
}
.model[hidden] { display: none; }
.model__plate { position: absolute; inset: 0; overflow: hidden; background: var(--faces-plate); }
.model__scroll {
position: absolute; inset: 0;
display: grid;
grid-template-columns: calc(var(--gutter) + var(--pw)) minmax(0, 1fr) calc(var(--gutter) + var(--pw));
/* desktop: lib/model.js drives scrollTop — a smooth, endless strip (the real
set between two copies, the position wraps); native scroll on phones */
overflow-y: hidden; overscroll-behavior: contain;
scrollbar-width: none;
outline: none;
}
.model__scroll::-webkit-scrollbar { display: none; }
/* the panel: a row card stretched to full height — x and width are the row's
first column; its rows split the height by the golden ratio */
.model__panel {
grid-column: 1; grid-row: 1;
position: sticky; top: var(--gutter); align-self: start;
margin-left: var(--gutter);
height: var(--frame-h);
display: grid; grid-template-rows: minmax(min-content, 38.2fr) minmax(min-content, 61.8fr);
padding: 21px;
overflow-y: auto; scrollbar-width: none; /* low windows only */
background: var(--w);
}
/* head: the name and the age, two lines set like the statement before the
ring — Medium, tight leading; cap tops on the panel's 21px line */
.model__name {
align-self: start;
margin-top: calc(var(--cap-pad-display) * -1em);
font-weight: var(--w-ui);
line-height: 1.02;
white-space: nowrap;
}
.model__name .line { display: block; }
/* the age in brackets, as under her card, at the hero H1's size (mega, set
like «26»: white + difference, so it reads dark on the plate and inverts
where it crosses the frame) — the screen's bottom-right corner, across the
diagonal from the name; the close keeps the top-right. Ink on both gutters. */
.model__num {
position: absolute; z-index: 1;
right: var(--gutter);
bottom: calc(var(--gutter) + var(--paren-sink) * 1em);
margin-right: calc(-1 * var(--paren-rsb));
color: var(--w); mix-blend-mode: difference;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.model__num .line { display: block; }
.model__body { display: flex; flex-direction: column; }
.model__label { margin-bottom: 8px; }
.model__bio { margin-bottom: 55px; }
/* stats: the buttons' size, one hairline-ruled row each — label left, value
right, as under the cards */
.model__specs > div {
display: flex; justify-content: space-between; gap: 1em;
padding-block: 8px;
border-top: 1px solid rgb(0 0 0 / .1);
}
.model__specs > div:last-child { border-bottom: 1px solid rgb(0 0 0 / .1); }
.model__specs dd { font-weight: var(--w-ui); font-variant-numeric: tabular-nums; }
.model__actions { display: grid; gap: 4px; margin-top: auto; padding-top: 34px; font-weight: var(--w-ui); }
.model__actions .btn { width: 100%; }
.model__frames {
grid-column: 2; grid-row: 1;
display: flex; flex-direction: column; align-items: center; gap: var(--gutter);
padding-block: var(--gutter);
}
.model__frame {
width: min(100%, var(--frame-h) * 3 / 4); height: auto; aspect-ratio: 3 / 4;
object-fit: cover;
background: #1a1414;
}
/* close: a square block in the top-right corner, the ink button's look; hover
goes accent like the bar's logo block */
.model__close {
position: absolute; top: var(--gutter); right: var(--gutter); z-index: 1;
width: 44px; height: 44px;
background: var(--k); color: var(--w);
transition: background-color .3s var(--ease-magic), scale .2s var(--ease-out);
}
.model__close::before, .model__close::after {
content: ''; position: absolute; left: 50%; top: 50%;
width: 16px; height: 1.5px; background: currentColor;
translate: -50% -50%; rotate: 45deg;
}
.model__close::after { rotate: -45deg; }
.model__close:active { scale: .97; }
@media (hover: hover) and (pointer: fine) {
.model__close:hover { background: var(--accent); }
}
/* ── mobile: same DOM, one column — the first frame, the panel, the rest ── */
@media (max-width: 767px) {
.model__scroll { grid-template-columns: minmax(0, 1fr); overflow-y: auto; scroll-snap-type: y proximity; scroll-padding-block: var(--gutter); }
.model__frames { display: contents; }
.model__frame { order: 2; width: calc(100% - 2 * var(--gutter)); margin: 0 var(--gutter) var(--gutter); justify-self: center; scroll-snap-align: start; }
.model__frame.is-clone { display: none; } /* the endless loop is desktop only */
.model__frame:not(.is-clone):first-child,
.model__frame.is-clone + .model__frame:not(.is-clone) { order: 0; margin-top: var(--gutter); }
.model__panel {
order: 1; grid-column: 1; grid-row: auto;
position: static; height: auto; overflow: visible;
margin: 0 var(--gutter) var(--gutter);
display: block;
}
.model__name { margin-bottom: 55px; }
.model__num { display: none; } /* the height is in the stats on a phone */
}
/* ── mobile: same DOM. The footage is the top 62svh (a full-bleed portrait crop
would put the copy on the red top); under it, a grid: copy | number, then
the two blocks full width. The bar stays at the bottom, condensed. ── */
@media (max-width: 767px) {
.header__bar { gap: 1.5em; }
.header__nav .is-wide { display: none; } /* its slash goes with it */
.hero__stage {
display: grid; grid-template-columns: 1fr auto; align-content: start;
column-gap: 1rem; row-gap: 1.5rem;
padding: calc(62svh + 1.25rem) var(--gutter) 0;
}
.hero__media { inset: 0 0 auto 0; height: 62svh; }
.hero__about, .hero__num, .hero__widget { position: static; translate: none; margin: 0; }
.hero__about { grid-area: 1 / 1; align-self: center; }
.hero__num { grid-area: 1 / 2; line-height: .8; }
.hero__widget { grid-area: 2 / 1 / 3 / -1; }
.btn { display: grid; place-items: center; min-height: 2.75rem; } /* full-width blocks, 44px touch targets */
}
}
@layer motion {
/* ── hero intro / exit: JS tweens --in on each element (lib/hero.js); CSS turns
it into opacity/translate/scale. Blur exists only under .is-anim, removed
when a tween ends — a permanent blur(0) keeps a render surface alive.
Before the intro the chrome is held hidden (html.js), released by JS. ── */
html.js .header, html.js .hero { visibility: hidden; }
[data-hero-media] { --in: 1; }
[data-hero-media] canvas {
opacity: var(--in);
scale: calc(1.06 - .06 * var(--in));
transform-origin: 50% 0;
}
.hero__title .char, .hero__num .char {
--in: 1; display: inline-block;
opacity: var(--in);
translate: 0 calc((1 - var(--in)) * .15em);
}
.hero__title.is-anim .char, .hero__num.is-anim .char { filter: blur(calc((1 - var(--in)) * 12px)); }
/* ── section 2: the footage blurs (≤16px — Emil: heavier is expensive, worst in
Safari) and zooms 1 → 1.4 from the centre while the grey plate fades in over
it. Blur only under .is-blurring; once the plate fully covers it the sharp
canvas is hidden, so no filter surface is kept alive. ── */
.hero__media { scale: var(--zoom, 1); transform-origin: 50% 50%; }
[data-seq].is-blurring { filter: blur(calc(var(--b) * 16px)); }
[data-seq].is-covered { visibility: hidden; }
.faces__haze { opacity: var(--haze, 0); }
/* the statement, word by word (new-14's words, retuned): word i owns the front
f = p·(n + skips + 4) − (i − 1 + skip). It develops from blur over 2 steps of
the front IN BLACK, then fills to red over the next 3 — a red wave following
the black one; all red exactly at p = 1. The poster's pauses are also pauses
in TIME: 2 empty steps after the name (its blank line), 5 more before the
second block (which also lets the ring grow past the widest line first).
--out fades + lifts + blurs the whole block. (--wp, not --w: --w is white.) */
[data-words] { --p: 0; --out: 0; --n: 14; --skip: 0; --skips: 7; opacity: calc(1 - var(--out)); translate: -50% calc(-50% - var(--out) * 16px); }
.faces__block { --skip: 2; }
.faces__block + .faces__block { --skip: 7; }
[data-words].is-anim { filter: blur(calc(var(--out) * 10px)); }
[data-words] .word {
display: inline-block;
--f: calc(var(--p) * (var(--n) + var(--skips) + 4) - (var(--word, 1) - 1 + var(--skip)));
--wp: clamp(0, calc(var(--f) / 2), 1);
--fill: clamp(0, calc((var(--f) - 2) / 3), 1);
opacity: var(--wp);
translate: 0 calc((1 - var(--wp)) * .35em);
color: color-mix(in srgb, var(--k), var(--accent) calc(var(--fill) * 100%));
}
[data-words].is-words .word { filter: blur(calc((1 - var(--wp)) * 8px)); }
[data-words].is-hidden { visibility: hidden; }
/* the captions: once the row has landed (.is-captioned) they rise and develop
from blur, one card after another left to right (--slot, written by
lib/faces.js), the height 70ms after the name — .9s expo. Leaving is quick
and all at once (.3s, no delay). The blur is armed only near the end of the
fold (.is-row) and is `none` at rest — no filter surface on the ring. */
.face-card__cap > span {
display: inline-block;
opacity: 0;
translate: 0 .6em;
transition: opacity .3s var(--ease-out), translate .3s var(--ease-out), filter .3s var(--ease-out);
}
.face-card__cap > span + span { --side: 1; }
.faces__ring.is-row .face-card__cap > span { filter: blur(6px); }
/* the row's title comes in with them, like the hero title: rise .15em, blur 12px */
.faces__title {
opacity: 0; translate: 0 .15em;
transition: opacity .3s var(--ease-out), translate .3s var(--ease-out), filter .3s var(--ease-out);
}
.faces__ring.is-row .faces__title { filter: blur(12px); }
.faces__ring.is-captioned .faces__title {
opacity: 1; translate: 0 0; filter: none;
transition-duration: 1.1s; transition-timing-function: var(--ease-expo);
}
.faces__ring.is-captioned .face-card__cap > span {
opacity: 1; translate: 0 0; filter: none;
transition-duration: .9s;
transition-timing-function: var(--ease-expo);
transition-delay: calc(var(--slot, 0) * 100ms + var(--side, 0) * 70ms);
}
/* the cards that open a model card: pointer + the caption goes accent, as a
menu item does — only once the row has landed, only with a real pointer */
.face-card__cap { transition: color .3s var(--ease-magic); }
@media (hover: hover) and (pointer: fine) {
.faces__ring.is-captioned [data-model-open] { cursor: pointer; }
.faces__ring.is-captioned [data-model-open]:hover .face-card__cap { color: var(--accent); }
}
/* ── the model card's contents (lib/model.js tweens --in 0 → 1): the name, the
age and the height come in like the hero title (rise .15em, blur 12px);
the label, the bio lines and the stat rows like the statement words
(rise 16px, blur 6px). Blur only while .is-anim is on the dialog. ── */
.model__name .line, .model__num .line { --in: 1; opacity: var(--in); translate: 0 calc((1 - var(--in)) * .15em); }
.model.is-anim .model__name .line, .model.is-anim .model__num .line { filter: blur(calc((1 - var(--in)) * 12px)); }
[data-model-item], .model__ln { --in: 1; opacity: var(--in); translate: 0 calc((1 - var(--in)) * 16px); }
.model.is-anim [data-model-item], .model.is-anim .model__ln { filter: blur(calc((1 - var(--in)) * 6px)); }
/* grain: 10 held offsets per cycle = 20 Hz; steps(1) snaps rather than glides,
and that snap is the whole film-grain read (new-7) */
.hero__grain { animation: grain 500ms steps(1) infinite; }
@keyframes grain {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-4%, -6%); }
20% { transform: translate(-8%, 3%); }
30% { transform: translate( 4%, -8%); }
40% { transform: translate(-6%, 6%); }
50% { transform: translate(-9%, -3%); }
60% { transform: translate( 6%, 4%); }
70% { transform: translate(-3%, 8%); }
80% { transform: translate( 7%, -5%); }
90% { transform: translate(-4%, 7%); }
}
@media (prefers-reduced-motion: reduce) {
.btn, .header__nav a, .header__logo { transition: none; }
.hero__grain { animation: none; } /* a flickering layer is exactly this rule's target */
.face-card__cap > span, .faces__title { transition: none; } /* property none — the longhands above can't revive it */
}
}
```
### `main.js`
```js
/* new-16 · Sur(face) · main.js — entry. Order: eases/plugins → scroll (owner of
Lenis) → header → sequence (frame 0 = the poster) → hero (owns the intro and the
exit) → faces (section 2) → the model card → beats (runway heights, which need the faces row
measured) → the two triggers. Every module is a no-op when its markup is absent. */
import { gsap, ScrollTrigger } from './lib/gsap.js';
import { initScroll, getScroll } from './lib/scroll.js';
import { initHeader } from './lib/header.js';
import { initSequence } from './lib/sequence.js';
import { initHero } from './lib/hero.js';
import { initWords } from './lib/words.js';
import { initFaces } from './lib/faces.js';
import { initModel } from './lib/model.js';
import { applyBeats } from './lib/beats.js';
ScrollTrigger.config({ ignoreMobileResize: true });
const scroll = initScroll();
const header = initHeader();
const seq = initSequence();
const hero = initHero({ header, ready: seq?.ready });
const words = initWords();
/* the clip's other 120 frames wait until the page has settled — the intro has
played, the visitor moves first, or 2.5s pass: loading and decoding them during
the intro made Safari stutter (lib/sequence.js) */
if (seq) {
const firstInput = new Promise((r) => ['wheel', 'touchstart', 'keydown', 'pointerdown']
.forEach((t) => addEventListener(t, r, { once: true, passive: true })));
Promise.race([hero?.introDone ?? Promise.resolve(), firstInput, new Promise((r) => setTimeout(r, 2500))])
.then(() => seq.loadRest());
}
const faces = initFaces({ seq, words });
const model = initModel({ faces }); /* Eva's card, opened from the row */
/* runway heights: the hero's from the table, section 2's from the table + the
row's measured overflow. Re-measured before every refresh (resize). */
const remeasure = () => applyBeats(faces ? faces.measure() : {});
remeasure();
ScrollTrigger.addEventListener('refreshInit', remeasure);
/* the hero runway: progress 0→1 over --run (CSS owns the length — no second table).
The clip scrubs linearly (it must track the finger); the type leaves at the middle. */
const OUT_AT = 0.5;
const run = { p: 0 };
const trigger = ScrollTrigger.create({
trigger: '[data-hero]',
start: 'top top',
end: 'bottom bottom',
onUpdate: (self) => {
run.p = self.progress;
seq?.seek(self.progress);
hero?.setOut(self.progress >= OUT_AT);
},
});
/* section 2: starts where the clip ends (its top meets the viewport bottom exactly
when the hero runway is done) and hands faces.js its distance in H */
const facesRun = { pH: 0 };
const facesTrigger = faces && ScrollTrigger.create({
trigger: '[data-faces]',
start: 'top bottom',
end: 'bottom bottom',
onUpdate: (self) => {
facesRun.pH = (self.progress * (self.end - self.start)) / window.innerHeight;
faces.update(facesRun.pH);
},
});
faces?.update(0);
/* dev hook (house convention): drive(y) is synchronous, state() returns numbers —
the preview pane throttles RAF, so verification reads values back. */
const rect = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: +r.x.toFixed(1), y: +r.y.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) };
};
const overlap = (a, b) => !!a && !!b && a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
const lines = (el) => { const cs = getComputedStyle(el); return +(el.getBoundingClientRect().height / parseFloat(cs.lineHeight)).toFixed(2); };
window.__site = {
gsap, ScrollTrigger, scroll, header, seq, hero, trigger, faces, facesTrigger, words, model,
drive: (y) => { getScroll()?.scrollTo(y, { immediate: true, force: true }); ScrollTrigger.update(); },
runLength: () => trigger.end - trigger.start,
/* section 2 by its own clock: jump to pH viewport-heights past the end of the clip */
driveFaces: (pH) => window.__site.drive(facesTrigger.start + pH * window.innerHeight),
facesState: () => faces && {
...Object.fromEntries(Object.entries(faces.state).map(([k, v]) => [k, typeof v === 'number' ? +v.toFixed(3) : v])),
cards: faces.live(),
haze: document.querySelector('.faces__haze').classList.contains('is-on'),
blurring: document.querySelector('[data-seq]').classList.contains('is-blurring'),
covered: document.querySelector('[data-seq]').classList.contains('is-covered'),
wordsShown: [...document.querySelectorAll('[data-words] .word')].filter((w) => +getComputedStyle(w).opacity > 0.5).length,
wordsTotal: document.querySelectorAll('[data-words] .word').length,
textOpacity: +(+getComputedStyle(document.querySelector('[data-words]')).opacity).toFixed(2),
},
state: () => {
const q = (s) => document.querySelector(s);
const els = { header: q('[data-header] .header__bar'), title: q('[data-hero-title]'), num: q('[data-hero-num]'),
about: q('[data-hero-about]'), widget: q('[data-hero-widget]') };
const r = Object.fromEntries(Object.entries(els).map(([k, el]) => [k, rect(el)]));
const names = Object.keys(r);
const hits = [];
names.forEach((a, i) => names.slice(i + 1).forEach((b) => { if (overlap(r[a], r[b])) hits.push(`${a}×${b}`); }));
const ch = q('[data-hero-title] .char');
return {
W: innerWidth, H: innerHeight, y: +(window.scrollY).toFixed(1),
p: +run.p.toFixed(3), out: hero?.state.out,
seq: seq && { index: seq.state.index, drawn: seq.state.drawn, loaded: seq.state.loaded, total: seq.state.total, set: seq.state.set },
charOpacity: ch ? +(+getComputedStyle(ch).opacity).toFixed(2) : null,
lineOpacity: q('[data-hero-about] .line') ? +(+getComputedStyle(q('[data-hero-about] .line')).opacity).toFixed(2) : null,
fonts: { face: document.documentElement.classList.contains('no-helvetica') ? 'Inter (fallback)' : 'Helvetica Neue (local)',
fallbackLoaded: document.fonts.check('400 1em "Inter Tight"') && [...document.fonts].some((f) => f.family.includes('Inter') && f.status === 'loaded') },
size: { title: parseFloat(getComputedStyle(els.title).fontSize), body: parseFloat(getComputedStyle(els.about).fontSize) },
lines: { header: lines(q('.header__nav')), title: lines(els.title), about: lines(els.about) },
blend: { title: getComputedStyle(els.title).mixBlendMode, num: getComputedStyle(els.num).mixBlendMode, about: getComputedStyle(els.about).mixBlendMode },
rects: r, overlaps: hits,
overflowX: document.documentElement.scrollWidth - innerWidth,
intro: hero?.state.introRunning, anim: document.querySelectorAll('.is-anim').length,
};
},
};
```
### `lib/beats.js`
```js
/* The single beat table (playbook: one table, so CSS heights and JS windows can
never disagree). H = one viewport height. The hero runway is `heroRun`·H; the
faces section (section 2) starts exactly where the clip ends and its windows
are measured in H from there. applyBeats() writes the runway heights.
Section 2 follows paypath.ai's ring, measured: one container that grows
linearly (×0.5 → ×1 over 2H), a true circle, cards square and tangent, a
steady spin in time. The fold into the row is ours: the spin dies first, then
all eight cards move as one. */
import { isMobile, reduced } from './gsap.js';
const DESKTOP = {
heroRun: 1.5, /* the clip scrubs across this */
/* section 2 — windows [from, to] in H from the end of the clip */
blur: [0, 0.6], /* footage blur 0→16px + crossfade into the haze; veil 5%→30% */
zoom: [0.15, 0.9], /* footage + haze scale 1 → 1.4 */
ringAppear: [0.45, 0.65], /* all eight at once, small, opacity 0 → 1 */
grow: [0.45, 2.45], /* the whole ring ×0.5 → ×1, linear (R .30B → .60B) */
spinStop: [2.2, 2.45], /* the spin eases to a standstill before the fold */
words: [1.05, 2.0], /* the statement, word by word — starts once the ring is big enough
that the widest line (× · ×, lower block) clears the cards */
textOut: [2.05, 2.35],
fold: [2.45, 3.25], /* ring → row, all cards in one move */
tail: 0.15, /* a breath after the strip has run out */
};
const MOBILE = { ...DESKTOP, heroRun: 1.2 };
export function beats() { return isMobile() ? MOBILE : DESKTOP; }
/* the strip leg starts where the fold ends and runs 1:1 with the scroll, so its
length is the row's overflow in H — measured by faces.js, passed in here */
export function applyBeats({ stripH = 0 } = {}, root = document.documentElement) {
const b = beats();
root.style.setProperty('--run', (reduced ? 0 : b.heroRun * 100) + 'svh');
root.style.setProperty('--faces-run', ((b.fold[1] + stripH + b.tail) * 100) + 'svh');
return b;
}
```
### `lib/faces.js`
```js
/* Section 2 · faces. Driven by one number: pH = how far past the end of the
clip we are, in viewport heights (main.js). Windows live in lib/beats.js.
blur the footage canvas blurs (CSS, ≤16px, gated) while the light grey
PLATE (with its own grain) fades in over it — the footage
dissolves into flat light grey. (The dark blurred-frame haze this
replaced lives in ../new-16-backup-2026-10-14-dark-haze.)
zoom the footage scales 1 → 1.4 from the centre as it goes (power2.inOut)
ring paypath's ring, measured: a TRUE circle, 8 cards 45° apart, each
square and tangent (rotation = angle + 90°), the whole ring grown
as one (×0.5 → ×1, linear) — radius and card size together, until
the circle runs off the screen. It spins steadily in time only.
words the statement, word by word (lib/words.js)
fold the spin has died; slot = rank by x. All eight on ONE clock: the
cards first spread sideways into their columns (turning upright),
then the columns close into the line — a dealer laying out a row,
no path crossing another. On the way each square opens to 3:4.
strip the row travels left 1:1 with the scroll
captions once the row has landed: name | (age) under each card, in one
by one, left to right (CSS transitions keyed off --slot)
Cards are laid out at their ROW size (3:4); on the ring they are clipped to a
square and scaled down, so the fold is transform + clip only and the row is
sharp. Transforms are written straight onto each card (Emil). */
import { gsap, reduced, isMobile } from './gsap.js';
import { beats } from './beats.js';
import { setVar, setClass, segT, smoothstep } from './vars.js';
const N = 8;
const GUTTER = 16, GAP = 16;
const SPIN = 4; /* deg / s, steady */
const RING_R = 0.60, RING_SIDE = 0.146; /* of B at full growth — paypath: 574 / 140 px at H 956 */
const G0 = 0.5; /* the ring starts at half size */
/* every set of disjoint neighbour swaps over a row of N (34 for 8) — rowOrder */
const SWAPS = (function all(j, cur, out) {
if (j >= N - 1) { out.push(cur); return out; }
all(j + 1, cur, out);
return all(j + 2, [...cur, j], out);
})(0, [], []);
const easeZoom = gsap.parseEase('power2.inOut');
const easeFold = gsap.parseEase('inOut');
const mix = (a, b, t) => a + (b - a) * t;
const rad = (d) => d * Math.PI / 180;
export function initFaces({ stage = document.querySelector('.hero__stage'), seq, words } = {}) {
const media = stage?.querySelector('[data-hero-media]');
const plate = stage?.querySelector('[data-haze]');
const ring = stage?.querySelector('[data-ring]');
const cards = ring ? [...ring.querySelectorAll('.face-card')] : [];
if (!media || !plate || !ring || cards.length !== N) return null;
/* the clip lives on the image, not the card, so the caption under it shows */
const imgs = cards.map((el) => el.querySelector('img'));
const person = cards.map((el) => el.dataset.person || '');
const state = { pH: 0, blur: 0, zoom: 1, grow: 0, fold: 0, strip: 0, words: 0, out: 0, spin: 0, spinSpeed: SPIN, slots: [], travel: 0, captioned: false };
const geo = {};
let slots = cards.map((_, i) => i);
let unwrap = new Array(N).fill(0); /* each card's whole turns at fold start, so its angle unwinds the short way */
const live = cards.map(() => ({ x: 0, y: 0, r: 0, s: 1, o: 0, clip: 0 }));
const focus = cards.map((el) => Math.min(1, Math.max(0, parseFloat(el.dataset.focus ?? '0.5'))));
/* ── geometry: everything from the viewport, re-run on refreshInit ── */
function measure() {
const W = document.documentElement.clientWidth, H = window.innerHeight;
const mobile = isMobile();
let cw = mobile ? W * 0.72 : (W - 2 * GUTTER - 3 * GAP) / 4;
let ch = cw * 4 / 3;
const maxH = H * (mobile ? 0.56 : 0.62);
if (ch > maxH) { ch = maxH; cw = ch * 3 / 4; }
const B = Math.min(H, W * 1.25); /* the ring's base: H on desktop; a phone keeps the circle on screen */
Object.assign(geo, {
W, H, B, cx: W / 2, cy: H / 2, cw, ch,
R1: RING_R * B, side1: RING_SIDE * B,
clip0: (ch - cw) / 2, /* top/bottom inset that makes the 3:4 card a square */
rowY: H / 2,
slot: (j) => GUTTER + j * (cw + GAP) + cw / 2,
});
const rowW = N * cw + (N - 1) * GAP;
state.travel = Math.max(0, rowW - (W - 2 * GUTTER));
ring.style.setProperty('--card-w', cw.toFixed(1) + 'px');
ring.style.setProperty('--card-h', ch.toFixed(1) + 'px');
return { stripH: state.travel / H };
}
/* ── a card on the ring: one radius, one size, one rule for all eight ── */
function ringPose(i, b, pH) {
const g = mix(G0, 1, segT(pH, b.grow[0], b.grow[1]));
const theta = i * 45 + state.spin;
const R = geo.R1 * g;
return {
theta,
x: geo.cx + R * Math.cos(rad(theta)),
y: geo.cy + R * Math.sin(rad(theta)),
r: theta + 90,
s: (geo.side1 * g) / geo.cw,
o: reduced ? (pH > b.ringAppear[0] ? 1 : 0) : segT(pH, b.ringAppear[0], b.ringAppear[1]),
};
}
/* the row: rank by x, so no two paths cross — yet the ring stops at a TIMED
angle, and at some angles a face would land next to itself. Rank by x only
reverses where two ring neighbours lie on opposite arcs, so neighbours in the
rank may trade places: they pass each other in x while still far apart in y
(≥ .77R for the ring order in index.html, at every stop angle — a search over
all orders). Pick the fewest swaps that leave no face twice in a row; on a
tie, the pair farthest apart in height. */
function rowOrder(poses) {
const byX = poses.map((_, i) => i).sort((a, c) => poses[a].x - poses[c].x);
let best = byX, bestKey = Infinity;
for (const sw of SWAPS) {
const o = byX.slice();
let dy = Infinity;
for (const j of sw) {
const a = o[j], c = o[j + 1];
dy = Math.min(dy, Math.abs(poses[a].y - poses[c].y));
o[j] = c; o[j + 1] = a;
}
if (o.some((ci, k) => k > 0 && person[ci] === person[o[k - 1]])) continue;
const key = sw.length * 1e6 - dy;
if (key < bestKey) { bestKey = key; best = o; }
}
return best;
}
function render() {
const b = beats(), pH = state.pH;
/* blur + plate + zoom */
state.blur = reduced ? (pH > 0 ? 1 : 0) : segT(pH, b.blur[0], b.blur[1]);
state.zoom = 1 + 0.4 * easeZoom(segT(pH, b.zoom[0], b.zoom[1]));
setVar(seq.canvas, '--b', state.blur);
setClass(seq.canvas, 'is-blurring', state.blur > 0 && state.blur < 1);
setClass(seq.canvas, 'is-covered', state.blur >= 1);
setVar(media, '--zoom', state.zoom);
setVar(plate, '--haze', state.blur);
setClass(plate, 'is-on', state.blur > 0);
/* words */
state.words = reduced ? (pH > b.words[0] ? 1 : 0) : segT(pH, b.words[0], b.words[1]);
state.out = segT(pH, b.textOut[0], b.textOut[1]);
words?.setP(state.words);
words?.setOut(state.out);
/* ring → row → strip */
state.grow = segT(pH, b.grow[0], b.grow[1]);
const foldRaw = reduced ? 1 : segT(pH, b.fold[0], b.fold[1]);
state.fold = foldRaw;
state.strip = segT(pH, b.fold[1], b.fold[1] + state.travel / geo.H);
const shift = state.travel * state.strip;
const poses = cards.map((_, i) => ringPose(i, b, pH));
if (foldRaw <= 0) {
/* each card's slot is its rank by x on the still circle — but one face never
stands twice in a row (rowOrder) */
rowOrder(poses).forEach((ci, slot) => { slots[ci] = slot; });
unwrap = poses.map((p) => 360 * Math.round(p.r / 360));
}
state.slots = slots.slice();
/* the fold is a dealer laying out a row, all eight on one clock:
1. the cards spread sideways into their columns, each keeping its height
and turning upright — x order never changes (but for rowOrder's swapped
neighbours, which pass each other far apart in height), so no paths cross;
2. the columns then close vertically into the line — each card is alone
in its column by now, so nothing can meet.
Size and the square → 3:4 opening run across the whole move. */
const e = reduced ? 1 : easeFold(foldRaw);
const eX = reduced ? 1 : easeFold(segT(foldRaw, 0, 0.6));
const eY = reduced ? 1 : easeFold(segT(foldRaw, 0.4, 1));
let visible = false;
cards.forEach((el, i) => {
const p = poses[i], L = live[i];
L.x = mix(p.x, geo.slot(slots[i]) - shift, eX);
L.y = mix(p.y, geo.rowY, eY);
L.r = mix(p.r - unwrap[i], 0, eX);
L.s = mix(p.s, 1, e);
L.o = Math.max(p.o, e);
/* square on the ring, opening to 3:4. The card's data-focus (0 top · .5 centre ·
1 bottom) decides where the square sits in the portrait — wide-angle shots
keep the head near the top, a model lying down has it near the bottom. */
const inset = 2 * geo.clip0 * (1 - e);
const top = inset * focus[i], bottom = inset - top;
L.clip = inset / 2;
/* an off-centre square would ride off the circle: move the card back by the
square's offset, rotated and scaled with it, so the SQUARE sits on (x, y) */
const c = ((top - bottom) / 2) * L.s, a = rad(L.r);
const ox = -c * Math.sin(a), oy = c * Math.cos(a);
if (L.o > 0) visible = true;
el.style.transform = `translate3d(${(L.x - ox - geo.cw / 2).toFixed(1)}px, ${(L.y - oy - geo.ch / 2).toFixed(1)}px, 0) rotate(${L.r.toFixed(2)}deg) scale(${L.s.toFixed(4)})`;
el.style.opacity = L.o.toFixed(3);
const clip = `inset(${top.toFixed(1)}px 0px ${bottom.toFixed(1)}px)`;
if (imgs[i].__clip !== clip) { imgs[i].__clip = clip; imgs[i].style.clipPath = clip; }
/* the visible slots ride over the ones leaving for the right */
const z = String(16 - slots[i]);
if (el.style.zIndex !== z) el.style.zIndex = z;
setVar(el, '--slot', slots[i]); /* the caption's turn in the row (style.css) */
});
setClass(ring, 'is-on', visible);
/* captions: the row has landed → they come in one by one, left to right
(CSS transitions, delay by --slot); back out of it → they leave at once.
is-row arms their blur only near the end of the fold — no filters on the ring. */
state.captioned = foldRaw >= 0.98;
setClass(ring, 'is-row', foldRaw >= 0.9);
setClass(ring, 'is-captioned', state.captioned);
}
/* the spin: steady in time while the ring is up; it eases to a standstill over
the spinStop window, so the fold starts from a still circle */
let last = 0;
gsap.ticker.add((time) => {
const dt = last ? time - last : 0; last = time;
if (reduced) return;
const b = beats();
state.spinSpeed = SPIN * (1 - smoothstep(b.spinStop[0], b.spinStop[1], state.pH));
if (state.pH <= b.ringAppear[0] || state.spinSpeed <= 0) return;
/* no modulo: a wrap mid-fold would jump the unwound angle */
state.spin += dt * state.spinSpeed;
render();
});
return {
state, cards, measure,
update(pH) { state.pH = pH; render(); },
live: () => live.map((L) => ({ x: +L.x.toFixed(1), y: +L.y.toFixed(1), r: +L.r.toFixed(2), s: +L.s.toFixed(4), o: +L.o.toFixed(2), clip: +L.clip.toFixed(1) })),
geo: () => ({ ...geo, slot: undefined }),
};
}
```
### `lib/gsap.js`
```js
/* GSAP + plugins from the CDN globals (loaded with <script defer> before main.js),
registered once. Copied from new-14, trimmed to what this page uses
(ScrollTrigger + SplitText + CustomEase; no Flip). The named eases are the
CSS eases, so the JS and CSS halves of one motion share a curve. */
const { gsap, ScrollTrigger, SplitText, CustomEase } = window;
if (!gsap) throw new Error('gsap missing — check the CDN <script> order in index.html');
gsap.registerPlugin(ScrollTrigger, SplitText, CustomEase);
CustomEase.create('magic', '0.33, 0, 0, 1'); // --ease-magic
CustomEase.create('outExpo', '0.19, 1, 0.22, 1'); // --ease-out-expo
CustomEase.create('expoOut', '0.16, 1, 0.3, 1'); // --ease-expo (house blur-reveal curve)
CustomEase.create('inOut', '0.77, 0, 0.175, 1'); // Emil's strong ease-in-out: on-screen movement (the ring → row fold)
export const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
export const isMobile = () => matchMedia('(max-width: 767px)').matches;
export { gsap, ScrollTrigger, SplitText, CustomEase };
```
### `lib/header.js`
```js
/* Header: fixed, full width on every screen. Its colours ride --pol (lib/pol.js),
so there is no fold and no Flip here — only the entrance (beat 0 of the page
intro): items rise 1em and fade in, .6s magic, 80ms apart. If the page is
scrolled before that is over, the tween is killed as a whole and the items
are settled — nothing else ever writes these transforms. */
import { gsap, reduced } from './gsap.js';
export function initHeader({ root = document.querySelector('[data-header]') } = {}) {
if (!root) return null;
const items = gsap.utils.toArray('[data-header-item]', root);
let entrance = null;
function playIntro() {
if (reduced) return null;
gsap.set(items, { transition: 'none' });
entrance = gsap.fromTo(items, { y: '1em', autoAlpha: 0 }, {
y: 0, autoAlpha: 1, duration: 0.6, ease: 'magic', stagger: 0.08,
clearProps: 'transform,opacity,visibility',
onComplete: () => { entrance = null; gsap.set(items, { clearProps: 'transition' }); },
});
return entrance;
}
function settleIntro() {
if (!entrance) return;
entrance.kill();
entrance = null;
gsap.set(items, { clearProps: 'transition,transform,opacity,visibility' });
}
return { root, items, playIntro, settleIntro };
}
```
### `lib/hero.js`
```js
/* Hero · Sur(face).
Intro (timed, after fonts.ready ∧ frame 0 of the sequence; 4s failsafe):
0 footage --in opacity 0→1, scale 1.06→1 (top-anchored) 1.6s expoOut
.1 header bar y 1em → 0 + fade .6s magic (lib/header.js)
.2 title + number chars --in blur 12px → 0, y .15em → 0 1.1s expoOut, stagger .04
.6 copy lines + widget blocks y 56 → 0, scale .96 → 1 .85s outExpo, stagger .07
(the new-14 widget group entrance, values as approved)
Exit (the user's call: «исчезают по аналогичной анимации на середине прокрутки»):
the same moves played backwards on the title, the number and the copy — a
timed timeline, play() when the clip passes its middle, reverse() when it
comes back (the new-14 threshold + reverse pattern; a scrubbed wipe read as
cheap). Header and widget never leave.
Blur exists only while the type carries .is-anim. Reduced motion: rest state. */
import { gsap, SplitText, reduced } from './gsap.js';
export function initHero({ root = document.querySelector('[data-hero]'), header = null, ready = Promise.resolve() } = {}) {
if (!root) return null;
const media = root.querySelector('[data-hero-media]');
const type = [root.querySelector('[data-hero-title]'), root.querySelector('[data-hero-num]')].filter(Boolean);
const lines = [...root.querySelectorAll('[data-hero-about] .line')];
const widget = [...root.querySelectorAll('[data-hero-widget] > *')];
const chrome = [header?.root, root].filter(Boolean);
const state = { introRunning: false, started: false, out: false };
let splits = [], intro = null, exit = null;
/* resolves once the page is settled: the intro has played (or was skipped) —
main.js holds the rest of the clip's frames until then */
let settled;
const introDone = new Promise((r) => { settled = r; });
const show = () => gsap.set(chrome, { visibility: 'visible' });
const chars = () => splits.flatMap((s) => s.chars);
const anim = (on) => type.forEach((el) => el.classList.toggle('is-anim', on));
function buildExit() {
exit = gsap.timeline({
paused: true,
onStart: () => anim(true),
onComplete: () => anim(false),
onReverseComplete: () => {
anim(false);
gsap.set(lines, { clearProps: 'transform,opacity,visibility' });
},
});
exit.to(chars(), { '--in': 0, duration: 1.1, ease: 'expoOut', stagger: 0.04 }, 0);
exit.to(lines, { y: 56, scale: 0.96, autoAlpha: 0, transformOrigin: '0% 100%', duration: 0.85, ease: 'outExpo', stagger: 0.07 }, 0.1);
}
/* animate:false is the failsafe — the same split and exit, no intro */
function start({ animate = true } = {}) {
if (state.started) return;
state.started = true;
splits = type.map((el) => new SplitText(el, { type: 'chars', charsClass: 'char', tag: 'span' }));
if (reduced) { show(); settled(); return; }
if (!animate) {
show();
buildExit();
if (state.out) setOut(true, true);
settled();
return;
}
const group = [...lines, ...widget];
state.introRunning = true;
gsap.set(media, { '--in': 0 });
gsap.set(chars(), { '--in': 0 });
gsap.set(group, { y: 56, scale: 0.96, autoAlpha: 0, transformOrigin: '0% 100%' });
anim(true);
intro = gsap.timeline({
onComplete: () => {
state.introRunning = false;
anim(false);
gsap.set(group, { clearProps: 'transform,opacity,visibility' });
settled();
},
});
intro.to(media, { '--in': 1, duration: 1.6, ease: 'expoOut' }, 0);
intro.add(() => header?.playIntro(), 0.1);
intro.to(chars(), { '--in': 1, duration: 1.1, ease: 'expoOut', stagger: 0.04 }, 0.2);
intro.to(group, { y: 0, scale: 1, autoAlpha: 1, duration: 0.85, ease: 'outExpo', stagger: 0.07 }, 0.6);
show(); /* after the first values are written — no flash of the rested state */
buildExit();
if (state.out) setOut(true, true); /* the page was reloaded past the middle */
}
/* past the middle of the clip → out; back before it → in */
function setOut(on, force = false) {
if (on === state.out && !force) return;
state.out = on;
if (!exit || reduced) return;
if (intro?.isActive()) intro.progress(1); /* settle the intro first — one owner per property */
if (on) exit.play(); else exit.reverse();
}
Promise.all([document.fonts.ready, ready.catch(() => {})]).then(() => requestAnimationFrame(() => start()));
/* failsafe: a stalled font or frame never leaves the hero hidden */
let failsafe;
const arm = () => { failsafe = setTimeout(() => {
if (state.started) return;
if (document.hidden) return arm();
start({ animate: false });
}, 4000); };
arm();
return {
state, setOut, introDone,
get intro() { return intro; },
get exit() { return exit; },
revert() { clearTimeout(failsafe); intro?.kill(); exit?.kill(); splits.forEach((s) => s.revert()); },
};
}
```
### `lib/media.js`
```js
/* Where the media live — the CDN build. The hero clip's 242 stills live on Higgsfield
storage (CloudFront, byte-identical to ../new-16/assets/seq, sha-256 checked); this
file is the only one in lib/ that differs from the source, and media-manifest.json
maps every local path to its URL. (The <img> srcs live in index.html.) */
const BASE = 'https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/';
/* set 'd' (1920×1078) and 'm' (808×1078 centre crop), frames 0…120, by media id */
const SEQ = {
d: [
'5f55f68c-b7b2-415e-b3f3-cb0e73c205e6',
'41089220-94e0-437a-9da8-05e2747b9b3c',
'9f9be7b8-1215-4851-8743-cf012e24b149',
'12290bb4-d194-4752-ad2d-af9dc938ff79',
'2e08f0f0-b4ce-4637-abe3-be7b64f4e204',
'1b77c60b-00b4-4d7e-9643-fb95f9ccac33',
'3ba9f730-80a3-4011-9f37-765cf728615e',
'cd0d0bcf-d159-4395-a52f-3f94794a72bb',
'f8f0c4eb-a278-4f4f-9c36-334b36cc97e9',
'b60c9d93-616e-45e3-b791-49551bfeda62',
'9d305f6b-22f9-437d-85fe-a542036ffc55',
'e2d39ac6-008a-4fb5-b00a-74d7326f9012',
'4f9e3d12-2afc-488c-9088-3d15eae0f299',
'd87a5b2c-4567-4c0c-9ec9-c632e983a209',
'a4969ac4-dd2b-4c25-bcc9-4bf68b71e544',
'd4f29ff9-7241-448c-9f28-01c88f675378',
'c471ece7-1fdd-4b94-811b-15ed1d9e569c',
'a189df26-0e9e-4e86-b5c9-35351ac4fab0',
'ec755f54-559c-4105-8750-40be747df597',
'8c19dc82-fc17-482c-8cd7-ee4f04688135',
'5e867037-4f52-4809-9a0c-a03a0cbcaa71',
'3fd9911b-0c05-4b0f-8d2c-312161725f57',
'3f017cb3-c85e-4a0b-aa96-8e692235c75c',
'8c2c5a73-9875-476a-aef5-1f1562ee4763',
'8ede5e5c-2148-4924-9556-b4fcb5093859',
'8f7b6150-46de-40e5-abd9-679f79510c4f',
'2bca683e-5e1a-4025-89c6-811c48b417e3',
'dca80c98-ece7-4842-a333-667cff165c22',
'fa030f22-2452-496d-aad9-0af4fc60c31c',
'eec08c62-323a-4c55-9a98-90cbe6c87d0b',
'f47a1edc-17d3-4e67-b2cd-0bef65be12f2',
'e5cf0ce1-a22f-4618-b7fe-567a78c3f51e',
'93080e38-99b7-4a00-a675-a037032cdefe',
'101c5efc-a2b3-4dce-9fc8-74d0f73696f2',
'1ab0c42f-5bf9-4985-b1a8-478763baa9a6',
'4fc11d30-74fa-4761-87b3-886177704c85',
'58fc0af2-933e-4322-904f-54b7d953ef0a',
'7b197d88-c48d-4bce-9edf-250cbde7b84c',
'badc7f63-714c-4c6f-848f-1173f4d05a6a',
'aecd41ae-bbed-4d8d-a9e4-27a6febd0d1a',
'cc5329e8-bfda-4382-bc56-45861e1fb84a',
'1dc1f794-07c5-4da8-80f1-02ae1d26ce81',
'98f2ba5e-428c-4ca5-83e3-71a62d5082b7',
'52a88d5a-fc44-425d-a6f7-e84176a523fb',
'40de12e9-5b1a-456e-805d-5df1bc812aeb',
'7b4b8f10-7175-4af5-b05f-1876ba927d5b',
'f13ac19f-48c1-4e52-80c9-a12259ef8671',
'c277ff13-10ea-41c0-972c-c6053255abeb',
'e81f801c-2891-4331-9c38-982c236e875f',
'1e612e17-6d4d-4908-9d71-a2ce2af73c6e',
'69db35f0-dfd0-458e-850a-28af88b65829',
'd0345d3e-7bfc-49ae-a81a-844b30af85e4',
'5ac5c461-81c0-4825-834d-39f997d7bbac',
'024d7561-8a20-4184-b12b-3eaf1a2a0469',
'17f31164-fef5-495f-9191-eb56bb608e40',
'a4aeb3f6-03db-4b32-b809-a91d64b1ceb0',
'3a9218c5-0938-4610-bb86-e8741214a8b5',
'a39fd4fb-6139-4434-bf06-22888f94193b',
'5f98777e-946c-4f3c-87b8-7a1ad4b42e18',
'1d9bc409-51da-43cf-b14b-ae262290c21a',
'8d79e9f6-93f2-457b-befe-2384895101ff',
'71a8f950-8399-4637-9880-684495991152',
'd66a3f32-8d99-4bb8-a910-e0df1f570242',
'a2b233a8-66f8-47b7-8853-9f0e746298f9',
'f57f844f-6332-4d1d-85a7-5a0c4711e872',
'89ffdd36-d082-4a05-8ab1-b1b4ed3b5fc6',
'9ad65cb0-a2d9-45f6-8772-06b94dd4d0af',
'7f393d3f-02cd-49f1-ad24-6f9921c81339',
'5bdf70f9-9ec6-436b-b3c6-7792a1356d14',
'83cdcd07-77e8-43f0-a12d-b5ddbec60cdd',
'd39940aa-396e-4aff-a61c-087b09431b69',
'cebfa8f2-6619-4bf7-9b8f-84e12e226c65',
'7878287a-73eb-460d-a652-8f5ffb869d85',
'71dd4581-c1b0-4db6-8fad-f1007693c107',
'80feff76-a2e4-4bf8-8ffc-34623d984716',
'0369b18e-07f8-4605-af66-9ea660a0c953',
'db3e0265-c219-46de-af21-37953554dbe4',
'cb93b770-1007-4b42-96ea-16b8f977995f',
'e4d9795e-e760-4c07-9eb0-211278d40ae1',
'2ae6919a-3786-476d-b529-8d545b107332',
'd6144f0e-eeaa-4c5e-b4e2-fc10455cbdaf',
'43e82327-1808-4a3e-91c5-765e4a77a0d1',
'4dcbb3a1-45db-48e3-87da-4c4d547569c6',
'42600b93-08de-4bf8-b5b3-f60a16645f7b',
'272fa46f-2bdf-4993-9992-39ac9376dd08',
'67a4d919-037d-43bc-9a37-35021917158e',
'd95c9482-a50b-490e-834b-c81ffac9f2db',
'b05c9140-eae9-43ea-851c-26f4d8eb2fb3',
'0c55ffcf-109c-4780-ae1e-39877b823828',
'bb0a9e08-1cac-450a-8fd2-64ddc3b5e7c7',
'552e68d3-1452-4cdc-912b-6b6ee36fc6d3',
'002e0515-55aa-4868-8d7a-5fc325e67def',
'99b533f9-67dc-4369-a3b3-8a0c566963ae',
'205a7c7e-6ce7-4f7b-ba05-9f929cdc793d',
'78252dae-dabf-4546-b4b9-5f36fcceb543',
'08afd88c-51b0-4df0-9f94-3f1ad56dd91a',
'99c0674e-2394-418c-9d37-5dc01db42536',
'6c9098c7-8316-4be7-bcd0-394f8c626935',
'8bcac303-d424-498b-a79b-e8e06b4ea0c5',
'6fac0b74-07f4-4840-af64-1e7291ad845f',
'72c04b08-c018-4352-b603-73a077925725',
'3960f2e8-5127-4229-9928-bcd7e948b54c',
'6b13f5f7-bda2-49ce-9029-494d2b605392',
'9ba1945a-4dac-4c74-8c4e-752eb1241aac',
'7943a431-c6e6-434e-ad7d-e966b21fb8d2',
'adbbdf45-0044-42dd-8140-58098a2f1192',
'eaa9bbfe-6ebb-4355-aee1-727e7a20288c',
'0b842593-0092-458f-a966-96b4be059678',
'7f8563f0-f68d-4688-9367-1261e55e6935',
'ff2da3ed-3635-44ff-9aeb-50f2c73dcfd6',
'aaf8414e-9194-4978-a06d-ea255b35bcd8',
'a15e4841-152b-4fb9-8d82-294a8186751a',
'51d9855e-12bd-45b8-a64f-37d968fa0d97',
'ddcef654-d14c-410b-84bf-1f65444c384d',
'3ee3d3cb-4149-47ee-abdf-a71f58e2a4fc',
'bc1292bc-ab21-41ca-84c9-9ed3691a0b41',
'f312f101-dec0-4f5a-82e1-033385b3021a',
'9ccb98d9-b008-4bb9-bf4c-943c786a2a07',
'aad31a47-5126-47fa-ae10-fef68379275b',
'cb77d855-44aa-4eb5-b92c-745c653a08ba',
'152bf232-8035-4042-b6c0-b67b9b0f42bf',
],
m: [
'fee305e6-6957-4257-998f-020018a2c048',
'283203bd-069b-4e39-8754-682ca5a8b5d8',
'c9e7d0f6-5ecb-440e-a53a-f1b762596b41',
'af7b79d8-e774-4428-b0cc-3aacc7a32d58',
'274c4e83-82e4-49b7-bea6-96c39b62d7f7',
'f3b4997a-5675-4d51-9c70-40bc4f532819',
'2b34addd-3d6a-4e4a-b604-ba8163245ec1',
'692f683a-c0fb-4797-8b98-718cfb167d03',
'762a756d-7228-4bb2-b0cc-1199630966cd',
'c0924395-eb47-453c-af35-1b3234d7b741',
'2d2ede9a-269b-49ea-8cc9-f1bf164ef218',
'ba27c894-6e2a-4b05-953b-e6cc06960ecb',
'd345a743-4dca-42b4-b674-f0038456bd21',
'376c303b-faa4-48e0-bd47-9769dfd5b3bd',
'1e390fb9-6d6a-4aa9-82f5-76eba4bfac73',
'e42e6b3a-46d5-4590-9d57-68573ab06355',
'273bcbe1-214d-41e4-9318-8eeb995571ee',
'30977b8d-6c28-4894-9e67-dad1595d6ba4',
'fc6e8452-0aef-4d83-aa4f-116aeb5b7216',
'5f6fce54-2772-4785-939f-ad309d6b822f',
'c1da310e-3b61-4cb5-b2d3-253ecf03bbe6',
'a0023eec-e81a-45cb-9914-91fb1b52f4d1',
'fa8ae000-4bf0-48a8-8edd-e63c49184bdc',
'76b2a30b-3a18-458c-ac3c-c460251c498e',
'f5848716-e6ec-424d-b079-1b7ef34c01fc',
'984bfdde-7fdc-499c-9643-5317524795f7',
'b54887bd-5e42-48c3-b486-058248e888bd',
'80254f52-1389-4988-a3fd-1f0e8a15987e',
'505c5160-4a67-4eb8-bb21-a64e029a288c',
'9064340d-5526-47b7-b643-f0115d54fe9f',
'dc799f23-0a21-4d4c-9345-76726c5d3deb',
'f8001d18-0659-4f10-a665-623a74aedb78',
'70d1f26d-389d-4766-aab4-fa013130ba54',
'b76aa89a-41b4-4e33-b860-c8e14199520e',
'a7fbdf0f-714e-4a33-a3b7-6a6bd9acb4ae',
'70d65558-8bb2-4aed-b33e-8be48901fae9',
'3596f164-bc07-4838-b012-3efe96b22830',
'641bb45b-1229-4865-a0be-91ae9760ed57',
'dde2a5c1-e47c-4052-a4fe-ef61e6df3dac',
'69a923b1-8a09-4f2c-98f3-1b9fa9b2e5d1',
'ac56348b-4440-4632-9ea4-87cf83dba3f2',
'd1fddbe6-866a-49f0-bf97-fe2cf5a962ca',
'1e1f22d7-ea04-4a4a-b04f-1062543df028',
'68f8653b-e5cb-4098-8579-bb7d67f75d89',
'720b546d-5747-45f1-90a6-91a4017a977f',
'27db83b5-af77-484a-b723-f52bcac05101',
'efcb5b22-19a1-4301-b08c-cad3f7cfe617',
'1a2628b9-5085-4881-893a-c5a2ceccdd7c',
'942af439-66a3-42c6-a7a2-b841cfa25d52',
'cb7b26c8-eecf-4fb0-a127-858ee12ab854',
'c6d6dccb-a7e7-4cbb-8361-95723b103a11',
'3f36a3d8-66d0-4efc-9795-02f47ced1371',
'1b3ad76d-a59f-4da4-a9d4-2c6f93089817',
'50911bab-962b-416b-b6ab-18f9a717ac46',
'7e3c82cd-5128-4da6-9ee9-71e902cde1f6',
'e9028591-48df-4b18-b760-853cd9938848',
'8c15fa21-662e-402f-97df-07280169d1d2',
'cb1cc378-84a8-49d3-8979-fc39fb5560c1',
'df217cb7-819e-4452-a429-b5c0bce9390c',
'f02fb8be-50ee-435c-ae0c-abe74c0f28f9',
'a94a5e68-f395-46c7-995e-d179775ece7f',
'79fad860-2253-4cef-869c-327d689284b3',
'a30b3930-b42f-4c5b-9133-3b185ea50690',
'ac81f5d9-c8c4-4370-b6d0-8b0040fb31c5',
'e92c77f0-e323-48fc-88b2-4cf1eb0bc40c',
'a372a3f6-93fc-49c1-9ca4-a4410d0dce58',
'8f817e70-4388-4945-bb67-df20d9262432',
'30931b39-e26a-424a-9540-e1174d01205c',
'a31e9531-2508-48a2-ba63-12e7b036e3c4',
'a84969e2-9982-4814-8099-8c3f694ca6dc',
'd69f3d69-7e42-42ad-9d79-6bf51894cb90',
'cea26b67-360f-4665-a4ec-23d9d62ac6b5',
'88388431-94be-40f3-8bc6-4f10d3752edb',
'887f5405-3bd0-4700-910d-e7986cc39ce8',
'aebba588-c83b-429c-88c7-e9240891f4be',
'5d80496f-3459-462e-864b-752763a35dba',
'9a5acca0-6f13-4b1b-b938-b43d4ba230e5',
'22ecced8-0918-4523-a04d-c65fde33dc47',
'c35390a6-f9a6-48cb-9ad3-6f52ca7fa035',
'2b99e049-8a21-4485-bcf0-f0af71e9c58d',
'97a687de-3439-49f6-b7fd-0905674064ea',
'fbbde38c-164e-493c-82e0-2c0a4a6cf01c',
'df39c80a-26a3-4fd7-a23f-e84db897e245',
'79a81b36-cac0-4661-8288-02c7724a1cb4',
'6a20e99d-136c-48b8-acae-ce63fda6f26a',
'6823e635-7dfd-4b23-b9df-254b4833bf84',
'877a0b73-2ac1-4818-89b3-8e6563dfeb0c',
'f5354297-e8d8-4108-b756-0b76fdf4e02e',
'6c3b61a4-d35b-4bc4-a005-ee41134a4573',
'fbc0898f-aba6-4902-8d3b-74a5d4f306e9',
'9532cc82-5259-4dc3-8115-a5a8093ff719',
'b2e47403-c828-4231-9f13-cf2385d05386',
'7a3f4907-56c4-4279-ac29-72c5c1282e66',
'5298f690-5d18-4122-83c5-f221378d40dd',
'5d86b868-71f2-4dd8-9169-043997bf0c4c',
'3921e3ad-8be6-442c-a998-ac53c57d05ff',
'dbde45b5-21cd-4645-9c45-fc6387ae1a69',
'cf52fe28-4b4f-48d3-9aca-1bdf5b0185cd',
'9f3f7e2b-e810-4f0b-a2b4-519c90e1957a',
'0f1cfc18-64ce-40c0-93f6-8059c41b6563',
'fb576ae3-85f2-45ee-9f0c-b6e2ea0ce07c',
'fc2ff250-ebd6-4101-ab40-e1b99c3d599d',
'89da1dfe-0b28-44a7-bf75-cb26724241f4',
'3dd6694e-582c-45c7-95ff-20a9788e0fa4',
'0a5e6635-bca9-4392-83f5-ed9a630c628c',
'4d7637f9-7f33-4666-8f0f-dcf67b9c8dcc',
'eaaddc0c-9622-4fd2-813d-ac84c966b8b6',
'b49aa3ab-b15b-4a36-a174-7c5b5ea4fa00',
'0e3d25d9-f1f5-4e1d-a3a2-0145ecc7c700',
'1ea9188f-f09f-4f7e-8c3e-793bcb762436',
'cab6b36e-6358-45b4-addf-8acd06219c38',
'2bbca4d2-29cd-4a28-99a6-bc58af98a11d',
'6774a859-a161-405f-bd9d-4073affc08a9',
'b7bd9de4-5486-4a7c-9e64-a7b3baa33594',
'9a6322dc-11b4-44d5-ad15-f45d92497dd4',
'36352b3c-c9f2-4529-909e-8c7d14089a20',
'38acf683-08a5-4f51-b657-5ca8601de515',
'518f1d4f-9457-46cb-996e-79bf1b99e574',
'93f3f561-4eb0-461e-a837-5fd197099cb6',
'863af171-4f66-4035-9c16-15b72c294d59',
'3a7c227e-a4ff-4a5e-8f41-b86e42a74b3a',
],
};
export const frameUrl = (set, i) => `${BASE}${SEQ[set][i]}.webp`;
/* frames fetched at once after the intro: CloudFront answers in ~0.8s whatever the
size (latency-bound), and HTTP/2 multiplexes — 16 in flight brings the whole
clip in within seconds instead of ~40 */
export const FRAME_CONCURRENCY = 16;
```
### `lib/model.js`
```js
/* The model card · Sur(face) — Eva, opened from her cards in the row (DESIGN.md §6).
Open — only once the row has landed (faces.state.captioned):
0 photo FLIP: the first frame starts as the clicked card and grows
into place, transform only 1.0s inOut
0 the rest the other row cards drift apart — left of the click to
the left, right of it to the right (±.6W) .9s inOut,
their images fade .6s from .15; all captions out .2s
0 panel appears as the row's first card (clip to the card's height),
then stretches to full height .3s fade · .9s inOut from .1
.3 plate opacity 0 → 1 — covers the bar and the widget .6s ease-out
.55 name · age · (age, corner) --in: rise .15em + blur 12px (the hero title) 1.1s expoOut, .08 apart
.7 label · bio lines · stat rows: rise 16px + blur 6px .8s expoOut, .04 apart
.9 buttons the hero widget's entrance (y 56, scale .96) .85s outExpo, .07 apart
.9 close opacity + scale .9 → 1 (never from 0) .5s expoOut
Close — the cross or Esc. Shorter than the way in, and not a reverse (Emil):
the content leaves at once (.2s); the plate goes first (.35s) so the row is
seen coming back; the panel folds back to the card (.5s inOut) and fades; the
photo flies back into its card if it is still on screen (.7s inOut), else it
dissolves; the other cards slide home (.7s inOut) and their captions return.
While open the page holds still (Lenis stopped), main + the bar are inert and
focus lives in the dialog; closing hands it back to the card.
Reduced motion: a .2s crossfade, nothing moves. */
import { gsap, ScrollTrigger, SplitText, reduced, isMobile } from './gsap.js';
import { getScroll } from './scroll.js';
export function initModel({ root = document.querySelector('[data-model]'), faces = null } = {}) {
if (!root) return null;
const plate = root.querySelector('.model__plate');
const panel = root.querySelector('[data-model-panel]');
const frames = root.querySelector('.model__frames');
const headLines = [...root.querySelectorAll('.model__name .line, .model__num .line')];
const label = root.querySelector('.model__label');
const rows = [...root.querySelectorAll('.model__specs > div')];
const bio = root.querySelector('[data-model-bio]');
const buttons = [...root.querySelectorAll('[data-model-btn]')];
const closeBtn = root.querySelector('[data-model-close]');
const scroller = root.querySelector('[data-model-scroll]');
const outside = [document.querySelector('main'), document.querySelector('[data-header]')].filter(Boolean);
const state = { open: false, card: null };
let tl = null, split = null, bioLines = [];
const anim = (on) => root.classList.toggle('is-anim', on);
const lead = () => frames.querySelector('.model__frame:not(.is-clone)'); /* the clicked frame, first of the real set */
const otherFrames = () => [...frames.querySelectorAll('.model__frame')].filter((f) => f !== lead());
const content = () => [...headLines, label, ...bioLines, ...rows, ...buttons, closeBtn];
/* the row around the clicked card */
const rest = (card) => [...card.parentElement.querySelectorAll('.face-card')].filter((c) => c !== card);
const parts = (cards, sel) => cards.map((c) => c.querySelector(sel));
const centreX = (el) => { const r = el.getBoundingClientRect(); return r.left + r.width / 2; };
/* the panel is a row card stretched to full height: its width is the row's */
function writePanelWidth() {
const cw = faces?.geo?.().cw;
if (cw) root.style.setProperty('--pw', cw.toFixed(1) + 'px');
}
ScrollTrigger.addEventListener('refresh', () => { if (state.open) writePanelWidth(); });
/* the transform (origin 0 0) that lays `el` exactly over `rect`, measured
from its untransformed box — safe mid-tween */
function flipTo(el, rect) {
const cur = { x: gsap.getProperty(el, 'x'), y: gsap.getProperty(el, 'y'), scale: gsap.getProperty(el, 'scaleX') };
gsap.set(el, { x: 0, y: 0, scale: 1 });
const r = el.getBoundingClientRect();
gsap.set(el, cur);
return { x: rect.left - r.left, y: rect.top - r.top, scale: rect.width / r.width };
}
/* the rest of the row drifts apart — left of the click to the left, right of
it to the right. One number, written as each card's `translate` by hand (no
CSS variable to parse back), so the way home is exact. */
const drift = { p: 0, cards: [], dx: [] };
const writeDrift = () => drift.cards.forEach((c, i) => { c.style.translate = `${(drift.dx[i] * drift.p).toFixed(1)}px 0px`; });
const clearDrift = () => { drift.cards.forEach((c) => c.style.removeProperty('translate')); drift.p = 0; };
/* the panel's clip, written by hand: the browser shortens inset(a 0 a 0) to two
values and a tweened string then loses the bottom one — so tween a number */
const clip = { cut: 0 };
const writeClip = () => { panel.style.clipPath = `inset(${clip.cut.toFixed(1)}px 0px ${clip.cut.toFixed(1)}px 0px)`; };
/* how far it eats in from top and bottom to be one row card tall */
function cardCut() {
const ch = faces?.geo?.().ch;
return ch ? Math.max(0, (panel.getBoundingClientRect().height - ch) / 2) : 0;
}
function open(card) {
if (state.open || !card) return;
state.open = true;
state.card = card;
const img = card.querySelector('img');
const src = img.getAttribute('src');
loopSet(src);
writePanelWidth();
getScroll()?.stop();
outside.forEach((el) => { el.inert = true; });
tl?.kill();
gsap.set(root, { clearProps: 'opacity,visibility' });
root.hidden = false;
pos = target = 0;
place();
split?.revert();
split = new SplitText(bio, { type: 'lines', linesClass: 'model__ln' });
bioLines = split.lines;
scroller.focus({ preventScroll: true }); /* arrows turn the frames; Tab reaches the panel and the close */
if (reduced) {
tl = gsap.fromTo(root, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2, ease: 'power1.out' });
return;
}
const frame = lead();
const from = img.getBoundingClientRect();
const others = rest(card);
const c0 = centreX(card), push = innerWidth * 0.6;
img.style.visibility = 'hidden'; /* the photo IS the card now */
gsap.set(frame, { transformOrigin: '0 0' });
anim(true);
tl = gsap.timeline({
onComplete: () => {
anim(false);
gsap.set([frame, plate, panel], { clearProps: 'transform,opacity,visibility,clipPath' });
gsap.set([...buttons, closeBtn], { clearProps: 'transform,opacity,visibility' });
},
});
tl.fromTo(frame, flipTo(frame, from), { x: 0, y: 0, scale: 1, duration: 1, ease: 'inOut' }, 0);
drift.cards = others;
drift.dx = others.map((el) => (centreX(el) < c0 ? -push : push)); /* mid-close reopen: they are still on their side */
tl.to(drift, { p: 1, duration: 0.9, ease: 'inOut', onUpdate: writeDrift }, 0);
tl.to(parts(others, 'img'), { opacity: 0, duration: 0.6, ease: 'power2.out' }, 0.15);
tl.to(parts([card, ...others], '.face-card__cap'), { opacity: 0, duration: 0.2, ease: 'power2.out' }, 0);
tl.fromTo(plate, { opacity: 0 }, { opacity: 1, duration: 0.6, ease: 'power2.out' }, 0.3);
if (isMobile()) {
tl.fromTo(panel, { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: 0.9, ease: 'expoOut' }, 0.2);
} else {
clip.cut = cardCut();
writeClip();
tl.fromTo(panel, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 0);
tl.to(clip, { cut: 0, duration: 0.9, ease: 'inOut', onUpdate: writeClip }, 0.1);
}
tl.fromTo(headLines, { '--in': 0 }, { '--in': 1, duration: 1.1, ease: 'expoOut', stagger: 0.08 }, 0.55);
tl.fromTo([label, ...bioLines, ...rows], { '--in': 0 },
{ '--in': 1, duration: 0.8, ease: 'expoOut', stagger: 0.04 }, 0.7);
tl.fromTo(buttons, { y: 56, scale: 0.96, autoAlpha: 0, transformOrigin: '0% 100%' },
{ y: 0, scale: 1, autoAlpha: 1, duration: 0.85, ease: 'outExpo', stagger: 0.07 }, 0.9);
tl.fromTo(closeBtn, { autoAlpha: 0, scale: 0.9 }, { autoAlpha: 1, scale: 1, duration: 0.5, ease: 'expoOut' }, 0.9);
}
function close() {
if (!state.open) return;
state.open = false;
const card = state.card;
const img = card.querySelector('img');
const frame = lead();
const others = rest(card);
const caps = parts([card, ...others], '.face-card__cap');
tl?.kill();
target = pos; /* the strip stops where it is */
anim(false);
const done = () => {
root.hidden = true;
img.style.visibility = '';
gsap.set([root, plate, panel, frames, ...frames.children, ...content()], { clearProps: 'all' });
clearDrift();
gsap.set([...parts(others, 'img'), ...caps], { clearProps: 'opacity' });
clip.cut = 0;
outside.forEach((el) => { el.inert = false; });
getScroll()?.start();
card.focus({ preventScroll: true });
tl = null;
};
if (reduced) {
tl = gsap.to(root, { autoAlpha: 0, duration: 0.2, ease: 'power1.out', onComplete: done });
return;
}
const fr = frame.getBoundingClientRect();
const onScreen = fr.bottom > innerHeight * 0.5 && fr.top < innerHeight * 0.5;
tl = gsap.timeline({ onComplete: done });
tl.to(content(), { autoAlpha: 0, duration: 0.2, ease: 'power2.out' }, 0);
tl.to(otherFrames(), { autoAlpha: 0, duration: 0.2, ease: 'power2.out' }, 0);
tl.to(plate, { opacity: 0, duration: 0.35, ease: 'power2.out' }, 0);
tl.to(drift, { p: 0, duration: 0.7, ease: 'inOut', onUpdate: writeDrift }, 0);
tl.to(parts(others, 'img'), { opacity: 1, duration: 0.5, ease: 'power2.out' }, 0.1);
tl.to(caps, { opacity: 1, duration: 0.3, ease: 'power2.out' }, 0.5);
if (isMobile()) {
tl.to(panel, { autoAlpha: 0, y: 24, duration: 0.3, ease: 'power2.out' }, 0);
} else {
tl.to(clip, { cut: cardCut(), duration: 0.5, ease: 'inOut', onUpdate: writeClip }, 0.05); /* from wherever it is — mid-open too */
tl.to(panel, { autoAlpha: 0, duration: 0.2, ease: 'power2.out' }, 0.4);
}
if (onScreen) {
gsap.set(frame, { transformOrigin: '0 0' });
tl.to(frame, { ...flipTo(frame, img.getBoundingClientRect()), duration: 0.7, ease: 'inOut' }, 0);
} else {
img.style.visibility = ''; /* the card comes back with the row */
tl.to(frame, { autoAlpha: 0, duration: 0.3, ease: 'power2.out' }, 0);
}
}
/* the strip (desktop): a plain smooth scroll, endless DOWNWARDS only — no
snapping (the user, 2026-10-14: «просто плавный скролл, фиксировать не
нужно»; «обратно — возвращаемся на первую фотографию»). The wheel moves a
target, never above 0 — the first photo is the top stop; the strip follows
it with a lerp and the page's own wheel factor (Lenis: lerp .07 · .85), so it
glides with a little inertia. A copy of the set follows the real one and the
position wraps by one set height — the seam never shows. Keys move one
frame. A panel that overflows (low windows) keeps its own wheel. Mobile:
native scroll, one column, no copy. */
const GAP = 16, LERP = 0.07, MULT = 0.85;
let pos = 0, target = 0;
const real = () => [...frames.querySelectorAll('.model__frame:not(.is-clone)')];
/* the clicked frame first, then the real set copied below it */
function loopSet(src) {
frames.querySelectorAll('.is-clone').forEach((c) => c.remove());
const set = real();
const match = set.find((f) => f.getAttribute('src') === src);
if (match && match !== set[0]) frames.prepend(match);
const copy = () => real().map((f) => {
const c = f.cloneNode(true);
c.classList.add('is-clone');
c.alt = '';
c.setAttribute('aria-hidden', 'true');
c.removeAttribute('loading');
return c;
});
frames.append(...copy());
}
/* one set's height, and the scrollTop that puts the real set's first frame on the gutter */
const setH = () => { const r = real(); return r.length * (r[0].offsetHeight + GAP); };
const origin = () => lead().offsetTop - GAP;
const wrap = (v, m) => ((v % m) + m) % m;
function place() {
if (isMobile()) { scroller.scrollTop = 0; return; }
scroller.scrollTop = origin() + wrap(pos, setH());
}
gsap.ticker.add((time, dt) => {
if (!state.open || isMobile() || Math.abs(target - pos) < 0.1) return;
pos += (target - pos) * (1 - Math.pow(1 - LERP, dt / 16.667));
if (Math.abs(target - pos) < 0.1) pos = target;
place();
});
root.addEventListener('wheel', (e) => {
if (!state.open || isMobile()) return;
if (panel.contains(e.target) && panel.scrollHeight > panel.clientHeight + 1) return;
e.preventDefault();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
target = Math.max(0, target + e.deltaY * unit * MULT);
}, { passive: false });
root.addEventListener('keydown', (e) => {
if (!state.open || isMobile()) return;
const onControl = e.target.closest('a, button');
const step = { ArrowDown: 1, PageDown: 1, ' ': 1, ArrowUp: -1, PageUp: -1 }[e.key];
if (step && !(e.key === ' ' && onControl)) {
e.preventDefault();
target = Math.max(0, target + step * (lead().offsetHeight + GAP));
}
});
/* triggers: Eva's cards, only once the row stands */
document.querySelectorAll('[data-model-open]').forEach((card) => {
const go = () => { if (faces?.state?.captioned) open(card); };
card.addEventListener('click', go);
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
});
});
closeBtn.addEventListener('click', close);
addEventListener('keydown', (e) => { if (e.key === 'Escape' && state.open) close(); });
/* the shortlist: one model, so the count in the bar is 0 or 1 */
const shortlistBtn = root.querySelector('[data-shortlist]');
const shortlistLink = document.querySelector('[data-header] a[href="#shortlist"]');
shortlistBtn?.addEventListener('click', () => {
const on = shortlistBtn.getAttribute('aria-pressed') !== 'true';
shortlistBtn.setAttribute('aria-pressed', String(on));
shortlistBtn.textContent = on ? 'In your shortlist' : 'Add to shortlist';
if (shortlistLink) shortlistLink.textContent = `Shortlist (${on ? 1 : 0})`;
});
return { root, state, open, close, get timeline() { return tl; } };
}
```
### `lib/scroll.js`
```js
/* Smooth scroll + the class contract.
Locomotive Scroll 5 (bundled, Lenis 1.3.17 inside) owns the scroll:
- Lenis with a heavier glide than its defaults: lerp .07 (≈ .55s to 90% of the
way instead of .4s) and a wheel step 15% shorter — the page slides rather
than jumps (Dmitriy, stage 7: "резковато"); touch keeps native inertia;
- the ticker is gsap's, so Lenis, ScrollTrigger and every tween step in
one frame; ScrollTrigger.update rides the lenis scroll event;
- [data-scroll] elements get `.is-inview` from Locomotive's IntersectionObserver
(rootMargin -1px) honouring data-scroll-offset / -repeat / -speed / -css-progress.
Exactly the reference's <Scroll /> component. */
import { gsap, ScrollTrigger } from './gsap.js';
let instance = null;
const listeners = new Set();
export function initScroll() {
if (instance) return instance;
instance = new window.LocomotiveScroll({
lenisOptions: { lerp: 0.07, wheelMultiplier: 0.85, touchMultiplier: 1.3, syncTouch: false },
initCustomTicker: (render) => gsap.ticker.add(render),
destroyCustomTicker: (render) => gsap.ticker.remove(render),
scrollCallback: (e) => listeners.forEach((fn) => fn(e)),
});
if (history.scrollRestoration) {
history.scrollRestoration = 'manual';
window.scrollTo(0, 0);
}
instance.lenisInstance?.on('scroll', ScrollTrigger.update);
gsap.ticker.lagSmoothing(0);
bindAnchors();
return instance;
}
export const getScroll = () => instance;
export const onScroll = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
/* in-page anchors glide for 2s through Lenis instead of jumping */
function bindAnchors() {
const isSamePage = (href) => {
if (href.startsWith('#')) return true;
const u = new URL(href, location.origin);
return u.pathname === location.pathname && u.hash.length > 0;
};
document.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href');
if (!href || !href.includes('#')) return;
const hash = href.slice(href.indexOf('#'));
if (hash && hash !== '#' && isSamePage(href) && document.querySelector(hash)) {
e.preventDefault();
instance?.scrollTo(hash, { duration: 2 });
}
});
if (location.hash) {
const hash = location.hash;
requestAnimationFrame(() => instance?.scrollTo(hash, { duration: 2 }));
}
}
```
### `lib/sequence.js`
```js
/* Scroll-scrubbed hero clip as a frame sequence on <canvas>.
Why not <video>: the title is white + mix-blend-mode:difference, and WebKit will
not blend against a <video> layer (design-dna P13, hit on new-4 and new-5). A 2D
canvas is an ordinary layer. The source clip (refs/hero-src.mp4, 121 frames,
24 fps, one keyframe) is cut into WebP stills: assets/seq/d (1920×1078) and
assets/seq/m (808×1078 centre crop — the phone's 62svh box only ever shows the
middle of the frame).
- frame 0 loads first → `ready` (the hero intro waits on it: it is the poster);
it is retried up to 3× — a missing poster left the canvas blank;
- the rest wait for loadRest() (main.js: the intro has played, the first input,
or 2.5s) — loading and decoding 120 big frames DURING the intro made Safari
stutter; then FRAME_CONCURRENCY at a time (lib/media.js), coarse to fine (every 8th, then 4th, 2nd, all), so
an early scroll already lands near the right picture; a failed frame is
retried twice (300 / 900ms);
- decode() runs one frame at a time, and seek() warms the frames just ahead of
the playhead: Safari evicts decoded 1920px frames (121 × 8MB) and would
otherwise decode them synchronously, mid-scrub, on the main thread;
- seek(p) → index round(p·(n−1)); the nearest LOADED frame is drawn, and only
when the drawn frame or the canvas size changes;
- drawn like object-fit:cover + object-position:50% 0 (the hair stays on the top
edge), DPR capped at 2; a ResizeObserver resizes the backing store. */
import { isMobile } from './gsap.js';
import { frameUrl, FRAME_CONCURRENCY } from './media.js';
const N = 121;
const RETRY = [300, 900]; /* ms before each retry of a failed frame */
const AHEAD = 6; /* frames warmed ahead of the playhead */
function loadOrder(n) {
const order = [], seen = new Set();
for (const step of [8, 4, 2, 1]) {
for (let i = 0; i < n; i += step) if (!seen.has(i)) { seen.add(i); order.push(i); }
}
if (!seen.has(n - 1)) order.push(n - 1);
return order;
}
export function initSequence(canvas = document.querySelector('[data-seq]')) {
if (!canvas) return null;
const ctx = canvas.getContext('2d', { alpha: false });
const set = isMobile() ? 'm' : 'd';
const frames = new Array(N).fill(null);
const state = { index: 0, drawn: -1, loaded: 0, total: N, set };
let size = { w: 0, h: 0 };
/* decoding, one frame at a time: decode() only warms a frame — its promise can
hang in a throttled / hidden tab, so each wait is capped and nothing gates on it */
const queue = [], queued = new Set();
let decoding = false;
async function pump() {
if (decoding) return;
decoding = true;
while (queue.length) {
const i = queue.shift();
queued.delete(i);
const img = frames[i];
if (img?.decode) await Promise.race([img.decode().catch(() => {}), new Promise((r) => setTimeout(r, 400))]);
}
decoding = false;
}
function warm(i) {
if (!frames[i] || queued.has(i)) return;
queued.add(i);
queue.push(i);
while (queue.length > 2 * AHEAD) queued.delete(queue.shift()); /* a fast scrub: drop the stale ones */
pump();
}
/* a frame counts on `load`; a failed one is retried after each RETRY delay */
const waiters = new Map(); /* i → [resolve] for whenLoaded(i) */
const load = (i, retries = RETRY) => new Promise((resolve) => {
const img = new Image();
img.decoding = 'async';
img.onload = () => {
frames[i] = img; state.loaded++;
(waiters.get(i) || []).forEach((fn) => fn(img)); waiters.delete(i);
resolve(img);
};
img.onerror = () => {
if (!retries.length) return resolve(null);
setTimeout(() => load(i, retries.slice(1)).then(resolve), retries[0]);
};
img.src = frameUrl(set, i);
});
/* another module's hook into a frame (faces.js builds its haze from the last one) */
const whenLoaded = (i) => frames[i] ? Promise.resolve(frames[i])
: new Promise((resolve) => waiters.set(i, [...(waiters.get(i) || []), resolve]));
/* nearest loaded frame to the target, searching outwards */
function nearest(i) {
if (frames[i]) return i;
for (let d = 1; d < N; d++) {
if (i - d >= 0 && frames[i - d]) return i - d;
if (i + d < N && frames[i + d]) return i + d;
}
return -1;
}
function draw(force = false) {
const k = nearest(state.index);
if (k < 0 || (!force && k === state.drawn)) return;
const img = frames[k];
const { width: cw, height: ch } = canvas;
const s = Math.max(cw / img.naturalWidth, ch / img.naturalHeight);
const w = img.naturalWidth * s, h = img.naturalHeight * s;
ctx.drawImage(img, (cw - w) / 2, 0, w, h); /* cover, anchored to the top */
state.drawn = k;
}
function resize() {
const r = canvas.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = Math.round(r.width * dpr), h = Math.round(r.height * dpr);
if (!w || !h || (w === size.w && h === size.h)) return;
size = { w, h };
canvas.width = w; canvas.height = h;
draw(true);
}
new ResizeObserver(resize).observe(canvas);
const ready = load(0, [300, 900, 2000]).then(() => { resize(); draw(true); warm(0); });
/* the rest, on demand: a small pool walking the coarse-to-fine order; every
landed frame redraws if it is now the nearest one to the current target */
let resting = false;
function loadRest() {
if (resting) return;
resting = true;
ready.then(() => {
const order = loadOrder(N).filter((i) => i !== 0);
const worker = async () => {
while (order.length) {
const i = order.shift();
await load(i);
if (nearest(state.index) !== state.drawn) draw();
}
};
for (let c = 0; c < FRAME_CONCURRENCY; c++) worker();
});
}
const seek = (p01) => {
const i = Math.round(Math.min(1, Math.max(0, p01)) * (N - 1));
if (i === state.index) return;
const dir = Math.sign(i - state.index) || 1;
state.index = i;
draw();
for (let k = 1; k <= AHEAD; k++) warm(i + dir * k); /* the frames the scrub reaches next */
};
return { canvas, ready, seek, state, whenLoaded, loadRest, last: N - 1 };
}
```
### `lib/vars.js`
```js
/* Memoised writers for the motion contract: JS writes 0→1 custom properties ON
the element (never :root), rounded to 3dp, skipped when unchanged; state
classes toggled once per crossing. (new-9 main.js:107-120 pattern.) */
export const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
export const segT = (p, a, b) => clamp01((p - a) / (b - a));
export const smoothstep = (a, b, x) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); };
export function setVar(el, name, v, unit = '') {
if (!el) return;
const s = (+v).toFixed(3) + unit;
const memo = el.__vars || (el.__vars = {});
if (memo[name] === s) return;
memo[name] = s;
el.style.setProperty(name, s);
}
export function setClass(el, cls, on) {
if (!el) return;
const memo = el.__cls || (el.__cls = {});
if (memo[cls] === on) return;
memo[cls] = on;
el.classList.toggle(cls, on);
}
/* a 0→1 proxy for GSAP: tween `p.v`, the onUpdate writes the var + gates the
blur class while the value is strictly between 0 and 1 */
export function prop(el, name, { anim = null } = {}) {
const p = { v: 0 };
p.apply = () => {
setVar(el, name, p.v);
if (anim) setClass(el, anim, p.v > 0 && p.v < 1);
};
return p;
}
```
### `lib/words.js`
```js
/* Screen 3 · the statement that appears word by word from blur (animos.app
lineage, scroll-scrubbed). SplitText hands every word its index (--word);
CSS derives each word's own 0→1 from the block's --p. The block also carries
--out (fade + lift + blur) and two gates: .is-words while the sweep runs
(per-word blur), .is-anim while the exit runs, .is-hidden at rest. */
import { SplitText, reduced } from './gsap.js';
import { setVar, setClass } from './vars.js';
export function initWords({ root = document.querySelector('[data-words]') } = {}) {
if (!root) return null;
const state = { p: 0, out: 0, n: 0 };
let split = null;
setClass(root, 'is-hidden', !reduced);
document.fonts.ready.then(() => requestAnimationFrame(() => {
split = SplitText.create(root, { type: 'words', wordsClass: 'word', propIndex: true, autoSplit: false });
state.n = split.words.length;
setVar(root, '--n', state.n);
}));
const apply = () => {
setVar(root, '--p', state.p);
setVar(root, '--out', state.out);
setClass(root, 'is-words', state.p > 0 && state.p < 1);
setClass(root, 'is-anim', state.out > 0 && state.out < 1);
setClass(root, 'is-hidden', state.p <= 0 || state.out >= 1);
};
return {
state, root,
setP(p) { state.p = p; apply(); },
setOut(o) { state.out = o; apply(); },
revert() { split?.revert(); },
};
}
```[/hidecontent]