类型:Site模板 · 分类:未分类 · 收录:2026-09-15 23:37:14 · 源码:会员可见
Build a single, self-contained `index.html` file — one file containing all markup, CSS, and
JavaScript inline. No build step, no bundler, no frameworks, no external JS libraries, no
Tailwind. Vanilla HTML + CSS + ES5-compatible JS only. The result must be a pixel-faithful
recreation of the spec below.
================================================================================
0. WHAT THIS PAGE IS
================================================================================
A dark-on-light, editorial landing page for a Web3 marketing agency called "Ascend."
Three stacked sections (hero, agency statement, clients) float above ONE fixed,
full-viewport background video whose playhead is scrubbed by scroll position. The
background layer never translates or scales — it only changes frame. Aesthetic: pale
cool-grey studio light (#e9ebf1), frosted-glass UI surfaces, a single orange accent,
tight negative letter-spacing, and italic serif accents inside a neo-grotesque headline.
Page title: `Ascend — Powering the next generation of web3 brands`
`<html lang="en">`, `<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
================================================================================
1. EXACT ASSET URLS — USE THESE VERBATIM, DO NOT SUBSTITUTE OR INVENT
================================================================================
Hero poster image (frame 0 of the video, so the photo→video handover is invisible):
https://d2ol7oe51mr4n9.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/1b38db1d-46ed-4a05-a669-9edaad8625b6.jpg
Scroll-scrubbed background video, 1920x1080 all-intra H.264 (large screens):
https://d2ol7oe51mr4n9.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/019940d3-f06f-491c-bd49-41cfb8183cc1.mp4
Scroll-scrubbed background video, 1280x720 all-intra H.264 (small / low-DPR / save-data):
https://d2ol7oe51mr4n9.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/b067715a-adae-4480-ae1e-178287f5ed6d.mp4
The poster <img> carries an inline onerror that falls back to a local file once only:
onerror="this.onerror=null;this.src='assets/hero-background.jpg'"
Video content, for reference when tuning the timeline (duration ~9.92s): it is two
chained 5s keyframe-interpolated renders joined with a 0.2s crossfade at a shared frame.
Beats: 0–25% a glass arch (frame 1); 30–48% the arch rises and rolls; 50–70% a glass
tower standing on the right (frame 2); 75–81% glass sweeps past the lens; 85–100% a
glass column (frame 3). Both MP4s are re-encoded all-intra — every frame is a keyframe —
which is why arbitrary seeks decode in ~3ms and the scrub feels smooth. Do not assume
normal GOP video; the scrub design depends on all-intra.
================================================================================
2. FONTS — FOUR FAMILIES, FOUR JOBS
================================================================================
Declare @font-face families with SHORT names, all `font-display:block` (no FOUT on a
type-driven layout), sourced from local woff2 files in `assets/`:
'IT' Inter Tight 400 assets/inter-tight-regular.woff2 — all UI + body
'IT' Inter Tight 500 assets/inter-tight-medium.woff2 — nav, buttons, lede
'IS' Instrument Serif 400 italic assets/instrument-serif-italic.woff2 — accents
'HD' TeX Gyre Heros 400 assets/tex-gyre-heros-regular.woff2 — the h1 only
(a Helvetica clone; this is the headline's exact face)
Also load Instrument Serif Regular from Google Fonts for the two large serif statements,
with preconnects:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap">
Fallback stacks (use exactly these):
body / UI : 'IT', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif
headline : 'HD', 'Helvetica Neue', Helvetica, Arial, sans-serif
italic : 'IS', Georgia, serif
--serif : 'Instrument Serif', 'IS', Georgia, 'Times New Roman', serif
If the local woff2 files are unavailable, the fallback stacks must still produce a correct
layout — never swap in a different Google font for 'HD'.
================================================================================
3. THE SCALE SYSTEM (this is the core idea — implement it first)
================================================================================
The desktop design is a uniformly scaled artboard of 1280 x 853. Define one custom
property equal to the size of one reference pixel at the current viewport:
:root{ --u: min(100vw / 1280, 100vh / 853); }
@supports (height:100dvh){ :root{ --u: min(100vw / 1280, 100dvh / 853); } }
EVERY desktop measurement — position, size, radius, gap, font-size, line-height, blur
radius, animation distance — is written as `calc(N * var(--u))` where N is the reference
pixel value. The composition therefore miniaturises rather than reflowing, and can never
collide or overflow. Do not convert these to rem, %, or vw.
Ultra-wide / short screens (min-aspect-ratio: 23/10) widen the reference frame so the
composition keeps breathing:
:root{ --u: min(100vw / 1600, 100vh / 853); } plus the 100dvh @supports twin.
Design tokens:
--ink:#000 --ink-ui:#111114 --ink-body:#0a0a0a --navy:#0f1b2d --orange:#f74407
--glass-blue: rgba(200,219,248,.62) --glass-white: rgba(255,255,255,.95)
Easings (declare as variables, use nothing else):
--e-expo: cubic-bezier(.16,1,.3,1) headline — confident, decisive
--e-quint: cubic-bezier(.23,1,.32,1) supporting elements — smooth arrival
--e-settle: cubic-bezier(.2,.85,.3,1) buttons — firm, no overshoot
hover/micro-interactions: cubic-bezier(.2,.7,.3,1)
--lift: calc(9 * var(--u)) the shared entrance rise distance
Reset: `, `html{scroll-behavior:smooth}`,
body background #e9ebf1, antialiased (-webkit-font-smoothing + -moz-osx-font-smoothing),
`overflow-x:hidden`.
================================================================================
4. THE FIXED SCENE LAYER
================================================================================
First child of <body>, before <main>: `<div class="scene" aria-hidden="true">` containing
1. `<img class="scene-media scene-poster" alt="" fetchpriority="high">` with the poster
URL and the onerror fallback
2. `<video class="scene-media scene-video" muted playsinline preload="auto"
disablepictureinpicture disableremoteplayback tabindex="-1">` — NO src attribute;
JS picks HD or SD at runtime
3. `<div class="scene-veil"></div>`
CSS:
.scene position:fixed; inset:0; z-index:0; overflow:hidden;
background:#e9ebf1; pointer-events:none
.scene-media position:absolute; inset:0; width:100%; height:100%;
object-fit:cover; object-position:50% 50%; display:block;
user-select:none; -webkit-user-drag:none
.scene-video opacity:0; transition:opacity .7s ease
.scene.is-ready .scene-video opacity:1
.scene-veil position:absolute; inset:0; background:#eef1f7; opacity:0
(a pale scrim the scrub loop fades up after the hero on PORTRAIT screens
only, because a narrow crop puts the glass directly behind the copy)
Sections 2 and 3 get `position:relative; z-index:1` so they paint above the scene.
================================================================================
5. MARKUP — EXACT STRUCTURE AND COPY
================================================================================
<main> wraps three <section>s.
--- SECTION 1: <section class="stage" id="top"> ---
`.stage`: position:relative (NO z-index — the fixed header inside it must be able to
stack above later sections); width:100%; height:100vh then height:100svh; overflow:hidden.
<div class="topbar"> containing, in this DOM order:
a) <a class="brand" href="#top">
<svg class="mark" viewBox="0 0 39 39" fill="none" aria-hidden="true"> — an
octagonal "web/constellation" monogram: 8 vertices at
(19.10,2.55) (6.90,7.60) (1.85,19.80) (6.90,32.00) (19.10,37.05) (31.30,32.00)
(36.35,19.80) (31.30,7.60), joined by the outer octagon ring, then every
long diagonal chord across it, then an inner 8-point rosette using the midpoints
(26.45,19.80) (19.10,12.45) (24.30,14.60) (13.90,14.60) (11.75,19.80)
(13.90,25.00) (24.30,25.00) (19.10,27.15). All lines: stroke #3a3a42,
stroke-width .8, fill none. Each of the 8 outer vertices also carries a
<circle r="1.55" fill="#26262c">.
<b>Ascend.</b>
b) <div class="bar-menu" id="site-menu"> — on desktop this is `display:contents`, so it
creates no box and its children position exactly as if they were direct children of
the bar. It becomes the collapsed dropdown panel at smaller sizes. Inside:
<nav class="nav" id="site-nav" aria-label="Primary"> with exactly three links:
<a href="#about">About</a> <a href="#clients">Clients</a>
<a href="#contact">Contact</a>
<div class="head-cta">
<a class="pill" href="#contact">Get in Touch</a>
<a class="square" href="#contact" aria-label="Get in touch"> containing
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true"> with
<path d="M3.1 8.9 8.9 3.1M4.2 3.1h4.7v4.7" stroke="#fff" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"/> (a NE arrow-out glyph)
</div>
c) <button class="burger" type="button" aria-label="Open menu" aria-expanded="false"
aria-controls="site-nav"> containing exactly TWO <span></span> bars.
<h1> — two lines, each on its own measured baseline, with nested reveal spans:
<h1>
<span class="l1"><span class="rv">Powering the <i class="i1">next</i></span></span><br>
<span class="l2"><span class="rv"><i class="i2">generation</i> of web3 brands.</span></span>
</h1>
The `.rv` inner span is the element that translates during the entrance; `.l1`/`.l2`
carry the clip mask. Never remove this nesting.
<div class="actions">
<a class="btn btn-primary" href="#contact">Get in Touch<span class="dot"><svg
viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M3.2 8.8 8.8 3.2M4.3
3.2h4.5v4.5" stroke="#111114" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round"/></svg></span></a>
<a class="btn btn-ghost" href="#about">Learn More</a>
</div>
<p class="lede">We help blockchain, crypto, NFT, and DeFi projects grow through
community-first strategy, creative campaigns, and cutting-edge digital marketing.</p>
<a class="scroll" href="#about"><svg viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 1.4v11.2M2.4 8.4 7 12.8l4.6-4.4" stroke="#141414" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round"/></svg><span>Scroll down</span></a>
--- SECTION 2: <section class="agency" id="about" aria-labelledby="agency-label"> ---
<span class="eyebrow" id="agency-label" data-reveal>Our Agency</span>
<span class="rule" data-reveal style="--d:.08s" aria-hidden="true"></span>
<p class="statement" data-reveal style="--d:.16s">We specialize in marketing for the
decentralized world. From launching token campaigns to building loyal communities, we
connect Web3 projects with the audiences that matter most.</p>
--- SECTION 3: <section class="clients" id="clients" aria-labelledby="clients-title"> ---
<div class="clients-lead"> (sticky column)
<span class="eyebrow" data-reveal>Clients</span>
<h2 id="clients-title" data-reveal style="--d:.08s"><i>Trusted</i> by Leading<br>
Innovators in Web3.</h2>
<a class="btn btn-primary" href="#contact" data-reveal style="--d:.18s">Get Started
<span class="dot">…same 12x12 arrow SVG as the hero primary CTA…</span></a>
</div>
<div class="clients-body">
<p class="clients-copy" data-reveal>We partner with forward-thinking startups,
protocols, marketplaces, and metaverse brands shaping the future of the internet.
Whether you’re launching your first project or scaling globally, our clients rely on
us to turn attention into adoption.</p> (note the curly apostrophe in "you’re")
<ul class="logo-grid" role="list" aria-label="Clients"> with two
<li class="logo-col" style="list-style:none"> columns, each holding three
<div class="card" data-reveal> — six glass cards total. Each card contains one inline
SVG typographic wordmark (these are stand-ins for official client logos; leave an HTML
comment saying so). Use `fill="currentColor"` so they inherit --navy, set width in `em`
so they scale with the card's font-size, give each a role="img" + aria-label, and set
font-family="IT, Arial, sans-serif" font-weight="500" on every <text>.
Column 1, reveal delays 0 / .06s / .12s:
1. "Animoca Brands" — viewBox 0 0 200 110, width 12.2em. <text> "animoca"
x=100 y=46 anchor middle size 40 letter-spacing -.5 currentColor; then a
trapezoid banner <path d="M12 61 L192 50 L178 99 L26 106 Z" fill currentColor>;
then <text> "BRANDS" x=100 y=84 anchor middle size 30 letter-spacing 1 fill #fff
transform="rotate(-4 100 80)".
2. "The Sandbox" — viewBox 0 0 210 60, width 12.4em. <text> "THE" x=105 y=14
anchor middle size 11 letter-spacing 1.5; <text> "SANDBOX" x=105 y=50 anchor
middle size 38 letter-spacing 1, both fill and stroke currentColor with
stroke-width 1.6 stroke-linejoin round; plus a small
<rect x=201 y=30 w=6 h=6 transform="rotate(45 204 33)" fill="#2aa3e6">.
3. "Polygon" — viewBox 0 0 196 48, width 11.2em. A <g fill="none" stroke="#7b3fe4"
stroke-width="4.5" stroke-linejoin="round"> with two interlocking hexagons:
<path d="M8 16 L18 10 L28 16 V28 L18 34 L8 28 Z"/> and
<path d="M24 22 L34 16 L44 22 V34 L34 40 L24 34"/>; then <text> "polygon"
x=56 y=36 size 32 letter-spacing -.8 currentColor.
Column 2, reveal delays .1s / .16s / .22s:
4. "Immutable" — viewBox 0 0 220 56, width 13.6em. <circle cx=28 cy=28 r=27
fill currentColor>; a <g fill="none" stroke="#fff" stroke-width="3.6"
stroke-linecap="round"> with the two mirrored arcs
<path d="M17 33c4-12 14-17 22-13"/> and <path d="M39 23c-4 12-14 17-22 13"/>;
then <text> "Immutable" x=68 y=38 size 30 letter-spacing -.3 currentColor.
5. "Seedify" — viewBox 0 0 204 56, width 12.6em. Outer teardrop
<path d="M18 4 C27 16 32 24 32 33 A14 14 0 0 1 4 33 C4 24 9 16 18 4 Z"
fill="none" stroke currentColor stroke-width 4>; inner filled teardrop
<path d="M18 18 C22 25 24 29 24 34 A6 6 0 0 1 12 34 C12 29 14 25 18 18 Z"
fill currentColor>; then <text> "Seedify" x=44 y=42 size 36
letter-spacing -.6 currentColor.
6. "Blur" — viewBox 0 0 120 48, width 7.4em. <text> "BLUR" x=60 y=40 anchor
middle size 44 letter-spacing 3, fill AND stroke #ff6b00, stroke-width 1.4.
================================================================================
6. DESKTOP GEOMETRY — every number below is a reference pixel, written calc(N * var(--u))
================================================================================
HEADER — `.topbar` is position:fixed; left:0; top:0; width:100%; HEIGHT:0; z-index:20.
Height 0 means the bar itself never blocks pointer events, while its absolutely
positioned children keep their artboard coordinates across the full viewport width, and
being fixed carries the header through every section.
.brand absolute; left 95; top 33.5; flex, align-items center, gap 7.5;
text-decoration none
.mark 39 x 39, display block
.brand b font-weight 500; font-size 22.6; letter-spacing -.045em;
line-height 39; color var(--ink); display block;
transform translateY(-0.8) ← an optical offset; it is the RESTING value
.nav absolute; left 50%; transform translateX(-50%); top 44; flex;
align-items center; gap 30.6; white-space nowrap
.nav a font-size 15.07; font-weight 500; letter-spacing -.033em; line-height 20;
color var(--ink-ui); transition opacity .25s ease; :hover opacity .55
.head-cta absolute; right 66; top 33.5; flex; align-items center; gap 8
.pill 132 x 39; radius 6; background var(--glass-white);
backdrop-filter blur(10) (+ -webkit- prefix); centred flex;
font-size 15.07; font-weight 500; letter-spacing -.03em;
color var(--ink-ui); border 0;
transition transform .3s cubic-bezier(.2,.7,.3,1), background .3s ease
:hover transform translateY(-1) and background #fff
.square position relative; 19 x 19; radius 5; background #0a0a0a; centred flex;
transition transform .3s cubic-bezier(.2,.7,.3,1);
:hover transform rotate(45deg); svg 9.5 x 9.5 block
::after gives it an expanded touch target with no visual change:
content ""; position absolute; border-radius inherit;
top -11; right -11; bottom -11; LEFT -4 — the left edge stops half-way
across the 8u gap so it never steals taps from the pill beside it.
HEADLINE — `h1` absolute; left 0; right 0; top 112; margin 0; text-align center;
font-family 'HD' stack; font-weight 400; font-size 56.44; line-height 60;
color var(--ink)
h1 .l1, h1 .l2, h1 .rv display:inline-block
h1 .l1 letter-spacing -.0425em
h1 .l2 letter-spacing -.064em; transform translateX(3.5)
h1 i font-family 'IS' stack; line-height 0 (so the serif never grows the line box);
font-weight 400; font-style italic; letter-spacing 0
h1 .i1 font-size 58.95 ← "next"
h1 .i2 font-size 59.2 ← "generation"
HERO BUTTONS — `.actions` absolute; left 50%;
transform translateX(calc(-50% + .5 * var(--u))) ← the half-pixel optical nudge;
top 264.5; flex; align-items flex-start; gap 11; white-space nowrap
.btn height 40; radius 6; flex align-items center; font-size 15.07;
font-weight 500; border 0; transition transform .3s cubic-bezier(.2,.7,.3,1),
filter .3s ease, background .3s ease; :hover translateY(-1.5)
.btn-primary width 146; background var(--orange); color #fff; letter-spacing -.03em;
padding-left 21; gap 10; :hover filter saturate(1.08) brightness(1.04)
.dot 18 x 18; border-radius 50%; background #fff; centred flex; flex none;
svg 9 x 9 block
.btn-ghost width 120; justify-content center; background var(--glass-blue);
backdrop-filter blur(8); color var(--ink-ui); letter-spacing -.015em;
:hover background rgba(210,226,252,.8)
BOTTOM COPY
.lede absolute; right 169; bottom 45.5; width 276; font-size 16.5; font-weight 500;
line-height 21.2; letter-spacing .01em; color var(--ink-body)
.scroll absolute; left 80.5; bottom 47.5; flex align-items center; gap 8;
color var(--ink-ui); svg 13.5 x 13.5 block flex:none;
span font-size 13.7, font-weight 500, letter-spacing .023em, line-height 14,
transform translateY(.5)
SECTION 2 — `.agency` position relative; z-index 1; min-height 100vh then 100svh;
padding: 256 (top) 95 (right) 120 (bottom) 218 (left)
.eyebrow (shared) display block; font-family 'IT' stack; font-weight 500;
font-size 11.6; line-height 14; letter-spacing .13em; text-transform uppercase;
color var(--ink-ui)
.agency .rule display block; width 543; height 1px (a literal 1px hairline, NOT
scaled); margin-top 22; background rgba(17,17,20,.13); transform-origin 0 50%
.statement width 543; margin-top 52; font-family var(--serif); font-weight 400;
font-size 55; line-height 51.5 (tighter than the size — deliberate);
letter-spacing -.018em; color var(--ink)
SECTION 3 — `.clients` position relative; z-index 1; display grid;
grid-template-columns: minmax(0,1fr) minmax(0,1fr);
padding: 0 95 150 91
.clients-lead position sticky; top 0; height 100vh then 100svh; align-self start;
padding-top 248 ← the claim pins while the cards scroll past it
.clients h2 margin-top 34; font-family var(--serif); font-weight 400; font-size 61;
line-height 57; letter-spacing -.015em; color var(--ink)
.clients h2 i font-family 'IS', Georgia, serif; italic; 400; letter-spacing -.01em
.clients .eyebrow letter-spacing .28em ← wider than the section-2 eyebrow
.clients .btn-primary width auto; margin-top 40; height 44;
padding 0 12 0 22; display inline-flex; font-size 15.5
.clients .btn-primary .dot 20 x 20
.clients-body padding-top 251
.clients-copy max-width 680; font-size 15.4; font-weight 400; line-height 18.8;
letter-spacing -.012em; color var(--ink-body)
.logo-grid display grid; grid-template-columns minmax(0,1fr) minmax(0,1fr);
column-gap 36; margin-top 40
.logo-col flex column; gap 21
.logo-col + .logo-col margin-top 157 ← the stagger that offsets the second column
.card position relative; height 212; radius 10;
background rgba(255,255,255,.58);
backdrop-filter blur(16) saturate(1.15) (+ -webkit-);
border 1px solid rgba(214,224,240,.9);
box-shadow inset 0 1px 0 rgba(255,255,255,.85);
centred flex; font-size 16; color var(--navy);
transition transform .5s cubic-bezier(.2,.7,.3,1), background .4s ease
:hover transform translateY(-3); background rgba(255,255,255,.78)
.card svg display block; height auto
FOCUS RING (global): a:focus-visible, button:focus-visible →
outline calc(2 * var(--u)) solid #0a0a0a; outline-offset calc(3 * var(--u));
border-radius calc(4 * var(--u))
================================================================================
7. ENTRANCE SEQUENCE — plays exactly ONCE, then deletes itself
================================================================================
Motion vocabulary is deliberately limited to four behaviours:
A MASKED LINE RISE — headline lines rise from behind a clip edge
B QUIET LIFT — brand, nav, header CTA, body copy, scroll cue
C SETTLE — buttons (lift + micro scale)
D STILLNESS — the scene layer NEVER animates on its own; it moves only on scroll
All distances are expressed in --u so the choreography scales with the composition.
An inline <script> in <head>, BEFORE first paint, adds `anim` and `sr` to
<html> — but only if `matchMedia('(prefers-reduced-motion: reduce)')` does NOT match.
Wrap it in try/catch. If JS never runs, the class is absent and the page renders in its
finished state: the design must never depend on JS.
ARMED state (`.anim`):
opacity:0 on .mark, .brand b, .nav a, .pill, .square, .burger, .stage .btn, .lede, .scroll
.anim .l1, .anim .l2 clip-path: inset(0% -6% -22% -6%) ← bleeds sideways/below so
descenders and side bearings are never clipped at rest
.anim .rv transform: translateY(1.15em)
RUNNING state (`.anim-run`): add will-change:transform,opacity to all the above.
Timeline (delay / element / behaviour) — reproduce these numbers exactly:
.06s logo mark lift .60s var(--e-quint) both
.12s wordmark lift-word .60s var(--e-quint) both
.18s nav link 1 lift .55s var(--e-quint) both
.235s nav link 2 lift .55s var(--e-quint) both
.29s nav link 3 lift .55s var(--e-quint) both
.26s header pill lift .58s var(--e-quint) both
.30s burger lift .58s var(--e-quint) both
.34s header icon square pop .52s var(--e-settle) both
.32s headline line 1 .rv reveal 1.00s var(--e-expo) both ← starts while the
.45s headline line 2 .rv reveal 1.00s var(--e-expo) both header is resolving
.74s hero primary CTA settle .60s var(--e-settle) both ← starts while line 2
.81s hero ghost CTA settle .60s var(--e-settle) both is still easing
.86s paragraph (.lede) lift .58s var(--e-quint) both
.96s scroll cue lift .52s var(--e-quint) both (quietest, arrives last)
Target the nav links with :nth-child(1/2/3) and the hero buttons via
`.anim-run .stage .btn-primary` / `.btn-ghost` so the section-3 CTA is not affected.
Keyframes:
@keyframes lift from{opacity:0;transform:translateY(var(--lift))} to{opacity:1;transform:none}
@keyframes lift-word from{opacity:0;transform:translateY(calc(8.2 * var(--u)))}
to {opacity:1;transform:translateY(calc(-.8 * var(--u)))}
← the wordmark must land on its authored -0.8u optical offset,
not on `none`, or it will jump at the end
@keyframes pop from{opacity:0;transform:translateY(calc(6 * var(--u))) scale(.94)} to{opacity:1;transform:none}
@keyframes reveal from{transform:translateY(1.15em)} to{transform:none}
@keyframes settle from{opacity:0;transform:translateY(calc(10 * var(--u))) scale(.985)} to{opacity:1;transform:none}
Driver script (in the body, IIFE, ES5):
- Bail immediately if <html> lacks `anim`.
- TOTAL = 1650ms (last delay .96s + .52s duration + buffer).
- begin(): Promise.all of [document.fonts.ready, poster.decode().catch(noop)], raced
against a 1200ms timeout so asset loading can never hold the entrance hostage; then
requestAnimationFrame(start).
- start(): add `anim-run`, setTimeout(finish, TOTAL).
- finish(): guard against running twice; clear the timer; remove `anim-run` THEN `anim`.
Removing both deletes every animation declaration and every will-change, and returns
each element to its authored CSS. The page is then completely static.
- If document.visibilityState === 'hidden' at load, wait for a visibilitychange that
leaves 'hidden' before calling begin(), and remove the listener.
SECTION REVEALS (`.sr` on <html>): the same quiet-lift language, fired once per element
as it enters the viewport.
.sr [data-reveal] opacity 0; transform translateY(calc(18 * var(--u)));
transition opacity .9s var(--e-quint) var(--d,0s),
transform 1.1s var(--e-expo) var(--d,0s)
.sr .rule[data-reveal] opacity 1; transform scaleX(0);
transition transform 1.2s var(--e-expo) var(--d,0s)
← the hairline DRAWS from its left edge instead of lifting
.sr [data-reveal].in opacity 1; transform none
.sr .card[data-reveal].in re-declare the transition and append `background .4s ease`
so the hover still works after arrival
Per-element stagger comes from an inline `style="--d:…"`.
Script: IIFE; bail if <html> lacks `sr`; if IntersectionObserver is missing, remove `sr`
and return (everything is simply visible). Otherwise observe every [data-reveal] with
{rootMargin:'0px 0px -12% 0px', threshold:0}, add `.in` on intersect, then unobserve.
Nothing ever re-hides.
REDUCED MOTION (@media (prefers-reduced-motion:reduce)) — belt and braces, since the
script does not arm the sequence at all:
html{scroll-behavior:auto}; force opacity 1 on all the armed elements;
clip-path none on .l1/.l2; transform none + animation none on .rv;
`.anim-run *{animation:none!important}`;
`.sr [data-reveal]{opacity:1!important;transform:none!important}`;
`*{transition:none!important}`.
================================================================================
8. THE SCROLL-SCRUBBED SCENE SCRIPT
================================================================================
IIFE, ES5, bails if .scene or .scene-video is missing.
SOURCE CHOICE: px = max(innerWidth, innerHeight) * min(devicePixelRatio || 1, 2);
saveData = !!(navigator.connection && navigator.connection.saveData);
v.src = (px > 1500 && !saveData) ? SRC_HD : SRC_SD. HD wherever the screen can show it —
a portrait phone crops about a quarter of the frame's width and enlarges it, so dense
phones need HD too; SD only for small low-density screens and readers asking to save data.
TIMELINE ANCHORS: `var KEYS = [['top', 0, 50], ['about', .56, 78], ['clients', .86, 46]];`
Each entry is [section id, position on the video timeline 0..1, portrait crop-x in %].
Mapping between anchors is linear; the end of the page is always the last frame.
`var TAU = 120;` // ms — lower is tighter, higher is floatier
measure() → max = max(1, scrollHeight - innerHeight); build `stops` as
[min(elementTopInDocument, max), timelinePos] for each KEY, then push
[max, 1].
progress(y)→ walk `stops`, linearly interpolate the timeline position, clamp 0..1.
pan(p) → walk KEYS, linearly interpolate the crop-x percentage.
orient() → portrait = innerWidth / innerHeight < 1. When NOT portrait, clear
v.style.objectPosition and veil.style.opacity and reset their caches to -1.
THE rAF LOOP — scroll never sets the frame directly; it sets a TARGET, and one loop eases
the playhead toward it with a frame-rate-independent exponential:
dt = last ? min(64, now - last) : 16
target = progress(pageYOffset) * dur
cur += (target - cur) * (reduce ? 1 : 1 - Math.exp(-dt / TAU))
if (abs(target - cur) < 1/300) cur = target
off = abs(v.currentTime - cur) > 1/90
if (ready && off && !v.seeking) v.currentTime = cur ← never queue a seek while the
decoder is still busy
if (portrait && dur):
p = cur / dur; x = pan(p);
o = clamp((p - .18) / .3, 0, 1) * .5 ← scrim: 0 in the hero, .5 by section 2
only write objectPosition when abs(x - panX) > .05, and only write veil opacity
when abs(o - veilO) > .004 (cache both; avoid redundant style writes)
if (cur !== target || off || v.seeking) request another frame; else last = 0 — the loop
SLEEPS as soon as the playhead has arrived.
kick() starts a frame only if one is not already queued.
EVENTS:
'loadedmetadata' → dur = max(0, v.duration - 1/30) (never land on the ended state);
measure(); cur = target = progress(pageYOffset) * dur; ready = true;
v.currentTime = cur
'seeked' → add `is-ready` to .scene on the first one (this is what fades the
video in over the poster); then kick()
'loadeddata' → prime(), {once:true} ← iOS only paints a video that has been
window 'touchstart' → prime(), {once:true, passive:true} played once; a muted
play/pause is allowed without a gesture, and the first touch retries
if it was refused.
prime(): p = v.play(); if (p && p.then) p.then(() => { v.pause(); v.currentTime = cur; })
.catch(noop)
window 'scroll' → kick(), {passive:true}
window 'resize' → orient(); measure(); kick()
ResizeObserver on document.body (if supported) → measure(); kick()
document.fonts.ready → measure(); kick() (type metrics change section offsets)
Read prefers-reduced-motion into `reduce` inside a try/catch; when true the playhead
snaps instead of easing.
================================================================================
9. COLLAPSED NAVIGATION (tablet + mobile share ONE component)
================================================================================
The panel IS the existing <nav> plus the existing .head-cta pair. There is exactly one
set of links and one CTA in the document — nothing is duplicated, nothing is invented.
`.bar-menu` is `display:contents` on desktop and `.burger` is `display:none`.
Burger CSS (measured in --u so it scales): 39 x 39; border 0; padding 0; cursor pointer;
radius 6; background var(--glass-white); backdrop-filter blur(10);
flex-direction column; align-items center; justify-content center; gap 5;
transition transform .3s cubic-bezier(.2,.7,.3,1), background .3s ease.
Bars: display block; width 16; height 1.6; background var(--ink-ui); border-radius 1px;
transition transform .28s cubic-bezier(.23,1,.32,1), opacity .2s ease.
Open state → `.burger[aria-expanded="true"] span:first-child{transform:translateY(3.3) rotate(45deg)}`
and `span:last-child{transform:translateY(-3.3) rotate(-45deg)}` (an X from two bars).
Panel — apply inside a media query list covering BOTH the tablet and mobile conditions
(see §10), so the component is defined once:
.topbar{position:relative}
.bar-menu display flex; flex-direction column; z-index 3; position absolute;
top calc(100% + 10 * var(--u)); right 0; min-width 200; padding 8; radius 6;
background var(--glass-white); backdrop-filter blur(14);
opacity 0; visibility hidden; pointer-events none;
transform translateY(-6) scale(.98); transform-origin 100% 0;
transition opacity .18s ease, transform .18s cubic-bezier(.23,1,.32,1),
visibility .18s
.topbar.is-open .bar-menu opacity 1; visibility visible; pointer-events auto;
transform none; transition opacity .26s ease,
transform .26s cubic-bezier(.23,1,.32,1), visibility .26s
.bar-menu .nav flex-direction column; align-items stretch; text-align left;
gap 0; width 100%
.bar-menu .nav a padding 12 14; radius 5; line-height 20;
transition background .25s ease, opacity .25s ease;
:hover background rgba(213,225,245,.55); opacity 1
.bar-menu .head-cta margin-top 6; padding 8; radius 5; background var(--glass-blue);
gap 8 ← the CTA pair keeps its exact components and sits on the
site's other glass surface so the white pill still reads against it
Script (IIFE): a single `open` boolean; set(state) toggles `.is-open` on .topbar and
updates BOTH aria-expanded and aria-label ("Open menu" / "Close menu") on the burger.
- burger click: stopPropagation, toggle; when opening, setTimeout 40ms then focus the
first <a> in the panel (the panel un-hides on the next frame, and focusing a
visibility:hidden element is refused).
- document click: close if open and the target is outside both the panel and the burger.
- document keydown: Escape (or 'Esc') closes and returns focus to the burger.
- panel click: if the target closest('a'), close (so in-page anchors work).
- matchMedia('(min-width:760px)') change: if open and getComputedStyle(burger).display
=== 'none', close — the panel must not survive the bar widening back out. Support
both addEventListener and the legacy addListener.
================================================================================
10. RESPONSIVE ARCHITECTURE — THREE REGIMES, AND WHY EACH BOUNDARY IS THERE
================================================================================
The desktop artboard cannot collide or overflow; it miniaturises. So the breakpoints are
set by TYPE AND TOUCH-TARGET LEGIBILITY, not by layout failure. Measured decay of the
1280-wide canvas: at 1180w (scale .92) body 15.2px / nav 13.9px / pill 36.0px — ok;
1112w (.87) 14.3 / 13.1 / 33.9 — ok; 1080w (.84) 13.9 / 12.7 / 32.9 — degrading;
1024w (.80) 13.2 / 12.1 / 31.2 — failed. Body copy drops under 14px and the pill under
33px at about 1090, so the canvas is KEPT until 1100px — as late as it holds.
--- TABLET REGIME ---
@media (min-width:600px) and (min-height:600px) and (max-width:1100px),
(min-width:600px) and (min-height:600px) and (max-aspect-ratio:105/100)
:root{ --u: clamp(.9px, .06vw + .38px, 1.06px); }
← components return to about their DESIGN SIZE instead of shrinking with the canvas
.scene-media{object-position:62% 50%} ← keep the glass in a narrower crop
Unpin from the artboard onto a grid that preserves the same corner anchors:
.brand,.nav,.head-cta,h1,.actions,.lede,.scroll → position static; all offsets auto;
transform none
.topbar,h1,.actions,.lede,.scroll → position relative; z-index 1
.topbar → z-index 5; width auto; height auto (the open panel sits above hero copy)
.stage → display grid; grid-template-columns auto minmax(0,1fr);
grid-template-rows auto auto auto minmax(0,1fr) auto;
grid-template-areas: "bar bar" / "head head" / "cta cta" / "gap gap" / "cue copy";
column-gap clamp(24px,4vw,56px);
padding clamp(28px,3.4vw,44px) clamp(32px,4.6vw,56px) clamp(28px,3.4vw,44px);
padding-bottom max(clamp(28px,3.4vw,44px), env(safe-area-inset-bottom));
overflow visible
.topbar → grid-area bar; flex; space-between; align-items center;
gap clamp(16px,3vw,40px); .burger{display:flex}
The bar now carries only the brand and the burger; the three links and the
Get in Touch pair move into the panel UNCHANGED — nothing lost, nothing redesigned.
h1 → grid-area head; text-align center; margin-top clamp(44px,8.5vh,104px);
font-size clamp(42px, 5.6vw + 3px, 50px); line-height 1.063
(line 2 measures 10.52x the font-size, so 50px needs 526px of column — this keeps
the two-line structure at every tablet width)
h1 .i1{font-size:1.0445em} h1 .i2{font-size:1.0489em} ← the desktop italic ratios
expressed in em; h1 .l2{transform:none}
.actions → grid-area cta; justify-self center; margin-top clamp(24px,3.6vh,40px)
.scroll → grid-area cue; align-self end; justify-self start
.lede → grid-area copy; align-self end; justify-self end;
width clamp(240px,34vw,320px); font-size clamp(15px,.55vw + 10.4px,16.5px);
line-height 1.29
.eyebrow → font-size 12px; line-height 16px
.agency → min-height auto; padding clamp(120px,18vh,200px) clamp(32px,4.6vw,56px)
clamp(120px,18vh,200px) clamp(32px,9vw,120px)
.agency .rule, .statement → width min(100%, 600px); rule margin-top 20px
.statement → margin-top 40px; font-size clamp(38px,5vw + 4px,54px); line-height .96
.clients → display block; padding clamp(120px,16vh,180px) clamp(32px,4.6vw,56px)
clamp(96px,14vh,160px) ← claim on top, cards below, NOTHING pinned
.clients-lead → position static; height auto; padding-top 0
.clients h2 → margin-top 24px; font-size clamp(44px,5.4vw + 6px,60px); line-height .96
.clients .btn-primary → margin-top 32px; height 46px; font-size 15px;
padding 0 12px 0 20px; .dot 20x20
.clients-body → padding-top clamp(48px,7vh,72px)
.clients-copy → max-width 620px; font-size 16px; line-height 1.45
.logo-grid → margin-top 36px; column-gap 16px; .logo-col gap 16px
.logo-col + .logo-col → margin-top clamp(64px,10vw,110px)
.card → height clamp(160px,22vw,210px); border-radius 10px;
font-size clamp(12px,1.35vw + 2px,16px)
--- MOBILE REGIME ---
@media (max-aspect-ratio:105/100) and (max-width:599px)
600px is the tablet regime's own floor, so the wider layout is kept for every width it
still serves. What failed below it was a phone canvas scaled by viewport HEIGHT
(min(100vw/430, 100vh/860)), which miniaturised everything on short phones:
390x844 body 14.5 / button 39.9 / burger 35.4; 360x800 13.4 / 36.8 / 32.6;
360x640 11.9 / 32.7 / 29.0; 320x568 10.6 / 29.0 / 25.8. Mobile therefore STOPS scaling
the composition and uses standard mobile sizing: 16px body, 14px labels, >=44px touch
targets, 20–24px gutters, headline fluid on WIDTH ONLY.
:root{ --u: clamp(1px, .28vw, 1.14px); } ← width-driven only; height never shrinks UI
.scene-media{object-position:64% 50%}
.stage → display flex; flex-direction column;
padding 22px clamp(20px,5.4vw,24px) 24px;
padding-bottom max(24px, env(safe-area-inset-bottom)); overflow visible
.brand,.nav,.head-cta,h1,.actions,.lede,.scroll → position static; offsets auto;
transform none
.topbar,h1,.actions,.lede,.scroll → position relative; z-index 1 (static content must
paint above the absolutely positioned photo); .topbar z-index 5; height auto
HEADLINE MASK SWAP — the headline re-wraps here, so a per-line clip would cut
mid-block. Same rise + fade language, without the clip:
.anim .l1,.anim .l2{clip-path:none;opacity:0}
.anim .rv{transform:translateY(10px)}
.anim-run .l1{animation:fade .7s var(--e-expo) .32s both}
.anim-run .l2{animation:fade .7s var(--e-expo) .42s both}
.anim-run .l1 .rv{animation:shift .7s var(--e-expo) .32s both}
.anim-run .l2 .rv{animation:shift .7s var(--e-expo) .42s both}
@keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes shift{from{transform:translateY(10px)}to{transform:none}}
HEADER (logo + menu only) → .topbar flex; space-between; align-items center; width 100%
.brand gap 8px; .mark 34x34;
.brand b font-size 20px; line-height 34px; transform translateY(-.5px)
.burger display flex; 44x44 (the touch-target minimum); gap 5px;
span 17px x 1.6px; open-state translateY(±3.3px) rotate(±45deg)
HEADLINE → margin-top clamp(30px,7vh,60px); font-size clamp(30px,8.2vw,38px);
line-height 1.1; letter-spacing -.02em
h1 .rv{text-wrap:balance} ← balanced wrapping, no orphan line
h1 .l1{letter-spacing:-.02em} h1 .l2{letter-spacing:-.03em}
h1 .i1{font-size:1.0445em} h1 .i2{font-size:1.0489em}
BUTTONS — the two CTAs stay SIDE BY SIDE; they fit down to 320px. The hero stays
centred, as on desktop and tablet:
.actions margin clamp(20px,3vh,28px) auto 0; justify-self auto; display flex;
justify-content center; flex-wrap wrap; gap 10px
.btn height 46px; font-size 15px
.btn-primary width clamp(142px,40vw,156px); padding-left 18px; gap 8px
.btn-ghost width clamp(112px,32vw,126px)
.dot 20x20; .dot svg 9.5x9.5
BOTTOM BLOCK keeps the desktop reading order — copy, then cue:
.lede width min(100%,340px); margin-top auto (pushes it to the bottom of the flex
column); font-size 16px; line-height 1.5; letter-spacing 0
.scroll margin-top 18px; gap 9px; svg 14x14;
span font-size 14px; line-height 16px; letter-spacing .01em; transform none
.eyebrow → font-size 12px; line-height 16px
.agency → min-height auto; padding clamp(96px,16vh,140px) clamp(20px,5.4vw,24px)
clamp(96px,16vh,140px); .rule and .statement width 100%; rule margin-top 18px
.statement margin-top 30px; font-size clamp(34px,9.6vw,42px); line-height .98;
letter-spacing -.012em
.clients → display block; padding clamp(96px,14vh,130px) clamp(20px,5.4vw,24px) 96px
.clients-lead position static; height auto; padding-top 0
.clients h2 margin-top 20px; font-size clamp(38px,10.6vw,46px); line-height .98
.clients .btn-primary width auto; margin-top 28px; height 46px; font-size 15px;
padding 0 12px 0 20px
.clients-body padding-top 44px
.clients-copy font-size 16px; line-height 1.5; letter-spacing -.005em
.logo-grid margin-top 32px; column-gap 12px; .logo-col gap 12px
.logo-col + .logo-col margin-top 56px
.card height clamp(120px,34vw,150px); border-radius 9px;
font-size clamp(9.5px,2.7vw,11px)
--- ULTRA-WIDE ---
@media (min-aspect-ratio:23/10) → the widened reference frame from §3.
================================================================================
11. ACCEPTANCE CRITERIA
================================================================================
1. One file. Opening it from a static server renders the finished page with no console
errors. Works with JavaScript disabled (static, fully visible, no hidden elements).
2. The three CloudFront URLs appear verbatim; the poster falls back to
assets/hero-background.jpg exactly once on error.
3. At 1280x853 the layout matches the reference-pixel coordinates exactly. Resizing the
window scales the whole desktop composition uniformly with no reflow above 1100px.
4. The background layer never translates or scales at any scroll position — only the
video frame changes. Scrolling the full page walks the playhead from frame 0 to the
last frame, passing 0.56 at the top of #about and 0.86 at the top of #clients.
5. The scrub eases behind the scroll with a ~120ms time constant, never queues a seek
while `v.seeking` is true, and the rAF loop stops when the playhead has arrived
(verify: no frames requested while idle).
6. On portrait screens the crop pans 50% → 78% → 46% across the timeline and the veil
fades 0 → .5 between timeline .18 and .48. On landscape both are cleared.
7. The entrance runs once in ~1.65s in the documented order, then BOTH `anim` and
`anim-run` are gone from <html> and no element retains will-change.
8. With prefers-reduced-motion: reduce, no entrance, no reveals, no transitions, and the
playhead snaps instead of easing.
9. At <=1100px (or aspect <=1.05) the burger appears and the panel contains the same
three links and the same Get in Touch pair — no duplicated nav markup in the DOM.
Panel closes on outside click, Escape (restoring focus to the burger), link click,
and on widening back to desktop.
10. On mobile: body copy 16px, labels 14px, burger and buttons >=44px, both hero CTAs on
one row down to 320px, headline two-to-three balanced lines with no orphan.
11. Keyboard-only navigation reaches every link and the burger, each showing the black
focus ring. The scene layer is aria-hidden and the video is tabindex="-1".
12. Section reveals fire once each and never re-hide on scroll-back.[/hidecontent]