# Recreate this site as a single HTML file: ARTEFAKT
You are an expert creative front-end developer. Produce a **single self-contained
`index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and
interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline
in a
`
```
From `three/addons/` you need `loaders/GLTFLoader.js`, `loaders/DRACOLoader.js` and
`environments/RoomEnvironment.js`. The DRACO decoder path is
`https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/libs/draco/gltf/` — the model is
Draco-compressed and **will not load without it**.
- **The display face is IBM 3270**, served from the bucket (see Assets). Everything on the
page is
set in it; there is no second typeface.
```css
@font-face {
font-family: "3270";
src: url("https://storage.getlayers.ai/assets/artefakt-5f7a0c9962/fonts/3270-Regular.otf")
format("opentype");
font-weight: 400; font-style: normal; font-display: swap;
}
```
Fall back to `ui-monospace, SFMono-Regular, Menlo, monospace`. **Monospace is
load-bearing**: the
scramble decoder substitutes characters in place and nothing may reflow while it runs.
- **Smooth scroll, via Lenis:** `new Lenis({ lerp: 0.075, smoothWheel: !reduced, syncTouch:
!reduced })`,
driven from a `requestAnimationFrame` loop. `0.075` rather than the library default of `0.1`
— a
wheel notch settles over about 25 frames instead of 15. Anything asking for
`prefers-reduced-motion: reduce` gets the platform's own scrolling untouched. Scroll to the
top on
load.
- **One shared rAF ticker.** A `Set` of subscribers, each with its own minimum frame gap
read live
every frame; the loop starts on the first subscriber and `cancelAnimationFrame`s when the
last
leaves, and one subscriber throwing must not kill it. The pointer field, the scramble
decoders,
the badge idles, the wordmark sheen, the technology reel, the card walk, the preloader
count and
the WebGL scene all subscribe here. **Do not start a second rAF loop anywhere.**
- `html { background-color: #000; }` — the canvas colour is taken from `html` deliberately, so
a
rubber-band overscroll shows the page's own black rather than a white flash.
- The page shell is `position: relative; min-height: 100lvh; overflow-x: clip` (clip, **not**
hidden — `hidden` makes it a scroll container and breaks sticky positioning for the pinned
sections).
## Fixed parameters (bake these in)
```js
/* ── Palette
─────────────────────────────────────────────────────────
────── */
--hero-surface: #000000 /* the hairlines between lattice cells, and the page ground */
--hero-surface-lattice: #0d0d0d /* the cell fill */
--hero-content: #ffffff
--hero-content-muted: rgb(255 255 255 / 0.4) /* body prose */
--hero-content-faint: rgb(255 255 255 / 0.25) /* index numerals, legal line */
--hero-rule: rgb(255 255 255 / 0.25) /* every border on the page */
/* ── Type ramp, rem against the 1440 base (16px root) ──────────────────────
*/
caption 0.75rem (floored at 12px) badge captions, the loading count
body 1.125rem (floored at 12px) nav, markers, rows, most copy
lede 1.25rem standfirsts, the CTA labels
fine 0.8125rem (floored at 12px) the newsletter consent line
chip 0.875rem (floored at 12px) tag chips
title 1.625rem product card titles
display 4.375rem (70px) section headlines, frame layout only
display-tablet 3.125rem (50px) 640–1023
display-compact 2.5rem (40px) below 640
leading: display 0.9 · headline 0.8 · caption/prose 1.1
tracking: -0.02em on captions and display
/* The four smallest roles carry `max(, 12px)`. At 1024 the artboard renders
at 71% and 12 units becomes 8.5px — the badge captions and chips stopped being
readable while remaining perfectly proportional. At 1440 the floor is a no-op. */
/* ── Lattice
─────────────────────────────────────────────────────────
────── */
pitch 0.625rem (10 units) gap 0.125rem (2 units) offset -gap/2
reach 12rem (192px) /* the pointer highlight's radius */
stage-fade 17rem (272px) /* how far the product canvas dissolves at its foot */
/* ── Motion
─────────────────────────────────────────────────────────
─────── */
duration-fast 150ms duration-normal 250ms ease-entrance cubic-bezier(0.2, 0, 0, 1)
```
**The lattice must land on whole pixels or it stops being squares.** Pitch and gap are rem —
they
are artboard units and scale with the frame — and a rem is not a whole number of pixels at
most
window sizes (at 1920 the pitch resolves to 13.33px). A repeating gradient with fractional
stops is
antialiased per bar, and because the fraction only comes round every three cells the error
repeats
on a three-cell beat: a ripple of light and dark cells across the screen that crawls when
anything
moves. Round all three to the device pixel grid, guarded:
```css
@supports (width: round(1px, 1px)) {
:root {
--lattice-pitch: round(0.625rem, 1px);
--lattice-gap: round(0.125rem, 1px);
--lattice-offset: round(calc(var(--lattice-gap) / -2), 1px);
}
}
```
## The lattice, and the light in it
The page's surface is **two elements, not one painted background**, and the order is
load-bearing.
```css
/* The shell keeps only its colour. */
.lattice-shell { background-color: #0d0d0d; }
/* FIRST in the tree: the pointer highlight, position: fixed. */
.lattice-beam {
position: fixed; inset: 0; pointer-events: none;
background-image:
radial-gradient(circle var(--lattice-reach) at var(--pointer-x) var(--pointer-y),
rgb(255 255 255 / 0.07) 0%, rgb(255 255 255 / 0.055) 26%,
rgb(255 255 255 / 0.034) 52%, rgb(255 255 255 / 0.016) 74%,
rgb(255 255 255 / 0.005) 89%, transparent 100%),
radial-gradient(circle calc(var(--lattice-reach) * 1.75) at var(--pointer-x) var(--pointer-y),
rgb(255 255 255 / 0.017) 0%, rgb(255 255 255 / 0.009) 48%, transparent 100%),
radial-gradient(circle calc(var(--lattice-reach) * 1.2) at var(--pointer-lag-x)
var(--pointer-lag-y),
rgb(255 255 255 / 0.032) 0%, rgb(255 255 255 / 0.014) 52%, transparent 100%);
}
@media (hover: none) and (pointer: coarse) { .lattice-beam { display: none; } }
/* SECOND in the tree: the grid, position: absolute across the shell. */
.lattice-bars {
position: absolute; inset: 0; pointer-events: none;
background-image:
repeating-linear-gradient(to right,
#000 0 var(--lattice-gap), transparent var(--lattice-gap) var(--lattice-pitch)),
repeating-linear-gradient(to bottom,
#000 0 var(--lattice-gap), transparent var(--lattice-gap) var(--lattice-pitch));
background-position: var(--lattice-offset) var(--lattice-offset);
}
```
**The bars paint over the light, and that is the whole effect.** The gap gradients are opaque
black,
so the highlight survives only inside the cells: it reads as the grid *brightening* in squares,
never
as a lamp shining across it. Swap the two elements and it stops looking like the page.
**The falloff curve is what stops it reading as a blob**, not the radius. A plain
colour→transparent
radial is a linear cone with a flat core and a visible rim; the convex decay through explicit
stops,
plus the much wider halo at a sixth of the strength, plus the same treatment on the trailing
pair,
is what dissolves it into the page. Do not simplify these gradients.
**Why two elements rather than one.** A single element painting all five layers needs
`background-attachment: fixed` for the highlight, and a fixed background cannot be scrolled
by the
compositor — it is repainted every frame over the element's whole visible area, which for the
page
shell is the entire viewport for the length of the document. Measured at 4× CPU throttle that
one
element held the content sections at 16ms frames; split, they run at 8.5.
**Opaque lattice panels.** The specification rows, the technology cards, the FAQ rows, the
nav
submenu, the CTA buttons and the preloader all sit on an opaque copy of the same surface
— they
*knock out* the artwork behind them rather than being transparent cards over it. One
element, five
background layers, attachments `scroll, scroll, fixed, fixed, fixed` (one value per layer — a
short
list is repeated and silently hands the last layer the wrong attachment):
```css
.lattice-panel {
background-color: #0d0d0d;
background-image: /* the two bar gradients, then the three radial gradients, in that order */;
background-position: var(--lattice-offset) var(--lattice-offset),
var(--lattice-offset) var(--lattice-offset), 0 0, 0 0, 0 0;
background-attachment: scroll, scroll, fixed, fixed, fixed;
}
```
Because the highlight layers are viewport-anchored, every panel lights up **in phase** with
the page
behind it. Never put this on an element that carries a transform — the transform becomes
the
containing block and the `fixed` half falls out of step.
### The pointer field
One writer for the whole page. Publish the cursor, in viewport pixels, as four custom
properties on
`:root`, smoothed on the shared ticker:
```js
const LERP = 0.12, LAG_LERP = 0.045; // per 60Hz frame
const frameLerp = (k, dt) => 1 - Math.pow(1 - k, dt / (1000 / 60));
// --pointer-x/y chase the raw position at LERP
// --pointer-lag-x/y chase it at LAG_LERP — the glow's tail
```
Park them at `-100vw / -100vh` so nothing is lit before the pointer has been seen; land on
the
**first** reading rather than easing in from off-screen. Bail entirely — never start, never write
—
on `(hover: none) and (pointer: coarse)` or `prefers-reduced-motion: reduce`. **No
`pointerleave`
handler**: sending the coordinates off-screen makes the glow *slide* away rather than stop,
and a
spurious leave event snaps it back on the next move.
## The scramble decoder
Every label on this page decodes from random glyphs. It is the page's signature.
```js
const CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*<>";
const STEP_MS = 40; // ms between substitution steps, on the shared ticker
const MAX_STEPS = 22; // step ceiling — a longer label resolves several chars per step
// charsPerStep = max(1, ceil(text.length / MAX_STEPS))
```
Resolution runs **left to right**, one step at a time; every character not yet resolved is a
random
glyph from the pool, and **whitespace is held fixed** so on a monospaced face the word
shapes and
wrap points are identical on every step. The tick rate never changes, so a long label and a
short one
share one texture — which is what stops a mixed cascade reading as several unrelated
effects.
Triggers: `mouseenter` and `focus` always; plus **once on arrival** — on load for anything
above the
fold, on scrolling into view for everything below it.
**Accessibility, and it is not optional.** Render the scrambling glyphs in an `aria-hidden` span
and
keep the real label in a visually-hidden sibling, so the accessible name stays stable while the
visible text churns. A screen reader must never be handed `X#@%R`. Skip the effect entirely
under
`prefers-reduced-motion: reduce` — rapidly mutating glyphs are exactly what that setting is
for.
### Typesetting the prose: bind the small words
Before a paragraph is decoded, replace the space after every **function word** with a
non-breaking
space, so no line ends on a preposition or an article. In a monospaced face a hanging "and"
is a
visible stub of identical cells at the edge of the column; measured at 390px the page had 23
of them.
```js
const TIED = new Set([
"a","an","the", "and","or","but","as","if","so",
"of","to","in","on","at","by","for","with","from","into",
"over","under","per","via","no","not",
"without","within","onto","upon","against","across","around",
"after","before","through","during","between",
"is","are","was","be","we","it","its",
"my","our","your","their","his","her","i",
"can","may","do","does","did","has","have","had","will","&",
]);
```
A word already carrying sentence punctuation (`.,;:!?`) is left alone — that break is a pause
the
reader wants. **On a narrow column (below 640px) cap a bound pair at 20 characters and
refuse to
chain** one function word to another: gluing "without compromising" costs twenty characters
of a line
that holds twenty-nine, and "can be returned" carries a whole phrase down a line. A wide
column takes
no ceiling.
The hero's claim gets a different treatment — `keepSentences`: make **every** space
non-breaking
except the ones after a full stop, so the two sentences on each line are the only hinges. Left
to
wrap freely it split "WEATHER-RESISTANT. THERMAL / INSULATION." mid-sentence on a
phone.
### Reveal-on-arrival
Two `IntersectionObserver`s per element, **whichever fires first**, and this is not
redundancy:
```js
const REVEAL_ROOT_MARGIN = "0px 0px -64px 0px";
watch({ rootMargin: REVEAL_ROOT_MARGIN }); // the ordinary case: scrolling up into
view
watch({ threshold: 1 }); // anything already fully on screen
```
A hold-back alone has a failure mode that bit this page three times: anything anchored near
the
bottom of a viewport-tall section — the footer's copyright sits 40px clear against a 64px band
— comes
to rest closer to the bottom edge than the margin is deep and **never reveals at all**. Neither
observer can be tightened into the other. Fire the callback at most once and disconnect both.
## The loading curtain
A full-viewport `z-index: 100` overlay wearing the plain lattice (cells and bars, no highlight),
fixed, `display: grid; place-items: center`.
**Gated on real readiness, not on a timer.** Three gates: `document.fonts.ready`,
`window.load`, and
a `hero-scene-ready` event the WebGL scene dispatches once its model is compiled. A
duration would be
a promise about the network that nothing can keep; this way a warm cache leaves almost
immediately
and a cold one is told the truth.
```js
const PATIENCE_MS = 8000; // backstop — a preloader that can outlive its gates is worse
than none
const FLOOR_MS = 700; // minimum on screen, so a warm cache does not flash the
curtain
const COUNT_LERP = 0.08; // how fast the readout chases the real figure, per 60Hz frame
```
The readout **chases** the target rather than showing it, and creeps toward the next gate
rather than
sitting still between them — `ceiling = finished ? 1 : min(0.97, target + 0.12)` — so the count
reads
as something filling rather than as three jumps.
Contents, centred in a 16rem column (20rem below the frame):
- the ARTEFAKT logo mark, `height: 3rem` (4rem below the frame), which scales to `1.05`
on exit;
- a 1px rule at 25% white with a white fill inside it, driven by `transform: scaleX(progress)`
from
`transform-origin: left` — one compositor job per frame, never a width;
- `LOADING 000` — zero-padded to three digits, tabular numerals, caption size at 25% white
(body size
at 40% below the frame, where 25% is present without being legible).
Four `2.5rem` corner brackets inset `2.5rem` from each corner (two borders each). On exit
they
`scale(1.5)` and fade over 700ms while the curtain fades over 500ms — the same gesture
the buttons
make when the pointer finds them, at the scale of the whole viewport. Remove the element
from the DOM
700ms after it is done.
Render it in the initial HTML so there is no flash of the page before it, and add
`` — where
scripts never
run the curtain can never lift.
## The WebGL product
One canvas, one context, one subscription to the shared ticker, **for the whole journey
across two
screens**. The garment does not hand off from a hero renderer to a details renderer — it
never
changes hands at all; only its framing does, driven by scroll. Two canvases would mean two
contexts,
two loads of the same model and a cross-fade at the seam.
### Where it lives
Wrap the hero and details sections in one **product region**. From the frame breakpoint the
canvas
box is `position: sticky` inside that region, `height: 50rem`, `top: calc((100lvh - 50rem) / 2)`,
and
the two screens are pulled back over it with a negative margin so the sticky box does not
also occupy
a screen of its own. The box is **one artboard tall, not viewport-tall**: viewport-tall it
overhangs
the details screen by the difference and the garment spills up into the seam (89px at
1280×800).
Below the frame breakpoint **there is no travel**: the canvas is absolutely positioned over
the
hero's own product box (`aspect-ratio: 4/3`, `16/9` from `md`) and the details screen has no
product
at all. Measure the box's offset from the region's top with a `ResizeObserver` and publish it
as a
custom property — the markers lead the screen on a phone, and a canvas pinned to the
region's top
would draw the garment straight over them.
**The bottom edge is dissolved, not cut.** At rest the canvas comes to rest over exactly the
details
screen, so its bottom edge lands on the seam with the section below and the garment —
which runs off
the bottom of that screen — was being sliced by a hard horizontal line. Mask it:
```css
.stage-mask {
mask-image: linear-gradient(to bottom,
#000 0,
#000 calc(100% - var(--stage-fade)),
rgb(0 0 0 / 0.97) calc(100% - var(--stage-fade) * 0.68),
rgb(0 0 0 / 0.82) calc(100% - var(--stage-fade) * 0.52),
rgb(0 0 0 / 0.55) calc(100% - var(--stage-fade) * 0.36),
rgb(0 0 0 / 0.26) calc(100% - var(--stage-fade) * 0.20),
rgb(0 0 0 / 0.08) calc(100% - var(--stage-fade) * 0.08),
transparent 100%);
}
```
**Opaque for the first third, then a long even decay.** Both extremes were wrong: a mask
makes the
garment *translucent*, so a linear 160px ramp ghosted the 10px lattice across the whole
lower body,
and cutting the ramp to 48px put back the hard line the fade exists to hide. 272px of travel,
the
first 87 at full density.
Give the canvas container `transform: translateZ(0); backface-visibility: hidden` — without its
own
compositor layer, a neighbouring fixed element repainting during a scroll (this page has a
fixed
lattice beam and a sticky header) invalidates the WebGL composite on WebKit and the
whole scene
flickers.
### Renderer, camera, environment
```js
renderer = new WebGLRenderer({ alpha: true, antialias: !coarse,
powerPreference: coarse ? "default" : "high-performance",
stencil: false });
renderer.setClearAlpha(0);
renderer.autoClear = false;
renderer.outputColorSpace = SRGBColorSpace;
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.95;
camera = new PerspectiveCamera(30, 1, 0.1, 100);
scene.environment = new PMREMGenerator(renderer).fromScene(new
RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.28;
keyLight = new DirectionalLight(0x2f63ff, 4.5); keyLight.position.set(-2.4, 2.2, 2.6);
rimLight = new DirectionalLight(0xffd9c0, 2.4); rimLight.position.set( 2.8, 0.6, -2.2);
```
The canvas is cleared to alpha 0 so the wordmark plate and the lattice show through
wherever the
garment does not cover. Set `role="img"` and an `aria-label` of *"Oversized weather-resistant
thermal
jacket, iridescent black"* on the canvas element.
### The shell's look
The GLB ships one near-black, fully rough, untextured material. The product is a
**lacquered,
iridescent shell**, which needs `MeshPhysicalMaterial` — `MeshStandardMaterial` has no
iridescence
layer at all, and the oil-slick sheen is exactly that layer. Upgrade every mesh in place,
carrying
`map` and `normalMap` across:
```js
const LOOK = {
colour: 0x070a14,
metalness: 0.15,
roughness: 0.32,
envMapIntensity: 0.45,
iridescence: 1,
iridescenceIOR: 1.3,
iridescenceThicknessRange: [120, 360],
clearcoat: 1,
clearcoatRoughness: 0.06,
};
```
Two things here are easy to get wrong and both were measured off the reference render
(median
luminance 0.055, median saturation 0.86, 69% of lit pixels in the 195–270° azure band):
- **The blue is the light, not the material.** A neutral environment cannot make two thirds of
an
image one hue, so the key light carries it and the environment is turned well down.
- **The film must stay thin.** 120–360nm is the blue/violet part of the interference range;
past
~450 the greens and magentas take over and the jacket goes pink.
- The shell is not pure black because it cannot be — at RGB 14 it matched the lattice behind
it and
the silhouette dissolved.
Switch every layer on with a non-zero value at construction: three compiles a *different
program*
when one of these crosses zero, and flipping that at runtime stalls a frame.
### Framing, in artboard units
```js
const FRAME_WIDTH = 1440, FRAME_HEIGHT = 800;
/* Hero */ SUBJECT_HEIGHT = 570; SUBJECT_RISE = 36; // above the section's
centre
/* Details, at rest*/ DETAILS_HEIGHT = 795; DETAILS_DROP = 140; DETAILS_SHIFT_X
= -92;
/* the turn it makes on the way */ SCROLL_TURN = 40°
/* Boxed (below the frame): fill the tighter axis */
BOXED_FILL_Y = 0.97; BOXED_FILL_X = 0.93; BOXED_DROP = 0.025;
const ease = t => t*t*t*(t*(t*6 - 15) + 10); // smootherstep — the pinned approach
const DRIFT_TAIL = 2.2;
const drift = t => 1 - Math.pow(1 - t, DRIFT_TAIL); // the journey: leaves early, arrives long
```
Place the product by projecting the artboard's own coordinates. `scale = containerHeight /
800`.
Interpolate `heightUnits` from `SUBJECT_HEIGHT` to `DETAILS_HEIGHT`, `riseUnits` from
`SUBJECT_RISE`
to `-DETAILS_DROP`, and the x offset from 0 to `DETAILS_SHIFT_X`, all on `drift(journey)`.
Then:
```js
const halfTan = Math.tan((30 * Math.PI / 180) / 2);
const targetPx = fullBleed ? heightUnits * scale : cssHeight * BOXED_FILL_Y;
const forHeight = (modelSize.y * cssHeight) / (2 * targetPx * halfTan);
const forWidth = (modelSize.x * cssHeight) / (2 * cssWidth * BOXED_FILL_X * halfTan);
const distance = fullBleed ? forHeight : Math.max(forHeight, forWidth);
camera.position.set(0, 0, distance); camera.lookAt(0, 0, 0);
worldPerPixel = (2 * distance * halfTan) / cssHeight;
```
Fit the **tighter axis** when boxed: on a phone the box is 350×262 and the jacket, sleeves
out, is
half again as wide as it is tall, so 93% of the height asked for 373px of width inside 350 and
the
sleeves were clipped at both edges.
**Snap the buffer to a 50px cell pitch and centre the canvas.** Round `cssWidth`/`cssHeight`
**up**
to the next whole cell so a box edge never lands mid-pixel, hold the device pixel ratio to a
whole
step, and then centre the canvas with `left:50%; top:50%; transform: translate(-50%,-50%)`
— the
snap grows the buffer past its container by up to a cell in each axis, and left at the default
top-left that overhang all falls off the bottom-right and pushes the product off centre
(measured, it
sat 9% higher in the section at 1024 than at 1440). Take `scale` from the **raw** container
height,
not the snapped one.
`journey` is read once per frame from `-section.getBoundingClientRect().top /
section.offsetHeight`,
clamped to 0..1 — one rect read inside the ticker, never a scroll listener.
### The exit ramp
A pinned element's apparent velocity goes from zero to the page's own in a single frame
when it
releases, and that step is what is felt as the picture slamming into place.
```js
const RELEASE_LAG = 0.15; // viewport heights
released = max(0, -sectionTop - sectionHeight); // scroll past the pin
lagPx = cssHeight * RELEASE_LAG;
releaseDrop = lagPx * (1 - Math.exp(-released / lagPx));
```
The derivative is exactly 1 at the seam, so the product starts leaving perfectly still and eases
up
to the page's speed over the following λ.
### Rotation: the cursor turns it
**No press.** Horizontal cursor position turns the product about its vertical axis; vertical
position
rolls it in the screen plane. Both are **absolute targets**, so the cursor's place in the section
*is* the angle and a sweep can always be undone.
```js
TURN_MAX = 24° ROLL_MAX = 12° TILT_MAX_X = 3° PARALLAX = 12 // artboard
units of drift
TILT_STIFFNESS = 0.08 TILT_DAMPING = 0.52 // one damped integrator per axis
```
Normalise the cursor to −1..+1 across the canvas, **clamped** (the listeners cover the whole
region,
so the cursor can be outside the box the coordinates are measured against). Feed the raw
position,
not the smoothed one — the spring below is the smoothing, and running it through the
pointer field's
lerp first adds ~390ms of dead time.
**Hover rotation belongs to the frame layout and to nothing else.** Gate it on the frame
media query,
not on a coarse-pointer test: a desktop window narrowed to a tablet's width has a cursor, and
showed
the garment leaning out of the flow layout's box, which reads as broken rather than
responsive. Touch
gets a **press-and-drag turntable** instead — `DRAG_DEGREES_PER_PX = 0.4`, released
with
`SPIN_FRICTION = 0.94` and stopped below `0.05°`.
**Authority is taken from the cursor while the page is travelling and handed back
afterwards**, and
what the gate reads is the scroll's *speed*, not the bare fact that it moved:
```js
TRAVEL_MUTE_SPEED = 0.02; // journey delta per 60Hz step at which the cursor is fully
muted
TRAVEL_MUTE_MS = 200; // time constant while authority is being taken
TRAVEL_SETTLE_MS = 900; // …and while it is given back — asymmetric on purpose
// active = pointerIntensity * ease(travelSettle)
```
Two pivots, and **the nesting is load-bearing**: the roll is outermost so it always reads as a
roll
*on screen*; the turntable sits inside it, in the product's own frame. Swap them and a rolled
product
turns about a tilted axis and the two visibly fight. Recentre the model on its bounding-box
centre
before adding it.
An idle that never stops and **adds** to the rest pose rather than replacing it:
`IDLE_PERIOD_S = 7`, `IDLE_ROTATION = 1.5°`, `IDLE_RISE = 4` artboard units, driven
by
`sin(elapsed / 7 * 2π)`.
### The sleeves trail the turn
The rig names its chains. Collect every `Bone` matching
`/^\s*(shoulder|arm|forearm)_[LR]$/i` or
`/^belt-\d+_[LR]$/i`, record its rest quaternion and its **depth** in the chain, and give straps a
scale of 1.6. Then, on a **fixed 60Hz timestep** (`PHYSICS_STEP_MS = 1000/60`, at most
4 steps per
frame — the physics is never throttled with the frame budget, precisely so the feel cannot
follow
the display):
```js
const raw = (rollVelZ + turnVelY * 0.6) * 7 // sleeveLag — the hover spring
+ journeyTurnVel * 1.5 // travelLag — the scroll's own turn
+ spinVelocity * 0.63; // dragLag — a thumb
jiggleDrive += (raw - jiggleDrive) * 0.18; // JIGGLE_DRIVE_FOLLOW
const limit = 30° ; // sleeveLimitDeg
for (const item of bones) {
const rawTarget = -jiggleDrive * item.scale * (1 + item.depth * 0.4); //
JIGGLE_DEPTH_GAIN
const target = limit * Math.tanh(rawTarget / limit);
item.velocity = (item.velocity + (target - item.angle) * 0.15) * 0.75; // stiffness, damping
item.angle += item.velocity;
item.bone.quaternion.copy(item.rest)
.multiply(scratch.setFromAxisAngle(new Vector3(0,0,1), item.angle));
}
```
**Three inputs, three gains, and they must stay separate.** They are the same quantity —
angular
velocity — arriving at wildly different magnitudes. `DRAG_DEGREES_PER_PX` turns a
30px/frame swipe
into 12° *per step*, where the hover spring peaks near 1.7°; one gain sized for either pins
every
chain against the ceiling for the whole gesture and releases them together, which is a
garment made
of rubber. **Apply the limit per bone, after its own scale and depth** — limiting the shared
drive
lets a deep strap multiply its way past the ceiling anyway. `tanh` eases into the limit; a hard
clamp
pins a chain at maximum through a whole sweep and releases it all at once.
The body itself follows the scroll on the same clock (`JOURNEY_FOLLOW = 0.12`), so the
velocity the
sleeves trail is bounded and continuous rather than the raw scroll delta.
### Device tiers and the frame budget
Read the tier **once**, at construction — a device does not change tier mid-session:
```js
coarse = matchMedia("(hover: none) and (pointer: coarse)").matches;
tier = !coarse ? "desktop" : (innerWidth < 768 ? "mobile" : "tablet");
RATIO_STEPS = { mobile: [1, 0.85, 0.75], tablet: [1.25, 1, 0.85], desktop: [1.5, 1.25, 1] };
FRAME_BUDGET = { mobile: 1000/30, tablet: 1000/45, desktop: 0 }; // ms between renders
FPS_FLOOR = 50; // below this, step down the pixel ratio — never the physics
```
A 3× phone renders nine times the fragments of a 1× screen and this scene is fill-bound: one
full-screen lit, iridescent, clearcoated garment. Mobile stops at 1.0 rather than lower — the
jacket
has hard silhouette edges and a zip, and those alias visibly below 1. Measure fps over
500ms windows
with hysteresis so the ratio cannot flap.
Render only when something changed (`needsFrame`), `renderer.clear()` then
`renderer.render()`.
Pause the ticker subscription on `document.hidden` and when an `IntersectionObserver`
with
`rootMargin: "10%"` says the **region** (not the hero — the loop must run for the whole
journey) has
left the viewport. Listen for `resize` on a fine pointer but **`orientationchange` on a coarse
one**:
iOS Safari fires `resize` every time the URL bar collapses during a scroll, and rebuilding the
framebuffer mid-scroll reads as a whole-scene flash. Debounce 150ms.
Call `renderer.compile(scene, camera)` after the model loads and before the first visible
frame, then
dispatch `hero-scene-ready`. Fade the canvas in on `duration-normal` once ready. A failed
model load
must not take the rest of the frame down with it.
### The pinned second instance (FAQ)
The same component with a fixed framing instead of the journey:
```js
const STILL = { height: 765, offsetX: -285, offsetY: -140,
entrance: { shiftX: -110, turn: -28° } };
// turn defaults to SCROLL_TURN (40°) — both screens show the garment from the same
// three-quarter angle, so they cannot drift apart if that angle is ever retuned.
```
It has no journey but it has an **approach**, driven by scroll position alone (so it runs
backwards
when the reader does), measured against its own screen over a window from `0.55`
viewports above the
fold to `0.3` below it — it starts while the screen is still coming up and finishes after it has
arrived, so the garment is still settling while the screen is being read. The weight is on the
**rotation**, not the slide: 28° of turn against 110 units of shift. The garment turns into its final
pose more than it arrives at it, which is also what gives the sleeves something to trail. The
first
frame places the body on its target outright — chasing it from zero opens the screen with a
swing
nobody asked for. It gets **no** drag target (a 760-unit grab area would swallow taps meant
for the
questions underneath) and **no** bottom mask is optional: keep it, because the section
takes the
larger of `100lvh` and 800 units and those scale differently — on 1920×900 the garment is
cut dead
flat by the section's bottom edge without it.
This instance is **frame-layout only**. Below it there is no corner to run off, and a full-width
photograph of a jacket the page has already shown twice reads as a repeat. `display: none`
also means
the box never lays out and the second WebGL context is never built.
## Layout & sections (in order)
Copy below is **verbatim**. Every `href` is `#` — no routes exist behind them.
### Header — pinned for the whole page
Three groups pinned independently, not distributed: the logo and cart sit against the 40-unit
margins while the nav is **centred on the canvas**, which a `justify-content: space-between`
row
cannot reproduce.
- Logo `ui/logo-mark.png` at `1.875rem × 4.6875rem` (30×75), `top: 1.5rem; left: 2.5rem`.
- Nav, centred at `top: 1.5rem; left: 50%; translateX(-50%)`, items 4rem apart:
`SHOP` · `COLLECTIONS ▾` · `TECHNOLOGY` · `ABOUT`. Body size, `line-height: 0.9`.
- Cart, `top: 1.5rem; right: 2.5rem`: `CART [ 0 ]`.
The wrapper is `position: sticky; top: 0; z-index: 50`, and **from the frame breakpoint it is
given
zero height** with the header inside going back to absolute positioning — so the banner
keeps the
artboard's coordinates and takes no space in the flow, and the hero still starts at the top of
the
canvas. Below the breakpoint the header is in flow, the wrapper takes its height, and it needs
an
**opaque lattice backdrop and a bottom hairline** — pinned and transparent, the whole page
scrolled
straight through it and spec-card copy read out from behind the logo. Put the fill on the
*wrapper*,
not the header: the header carries an entrance transform, and a transform makes
`background-attachment: fixed` resolve against the element instead of the viewport.
Header entrance: fade from `opacity: 0, translateY(-8px)`. Labels decode at `index * 60ms`,
cart at
`240ms`.
**COLLECTIONS opens a submenu on hover** with a 6×5 caret that rotates 180°. Items:
`SHADOW PUFFER JACKET` · `TACTICAL HOODED JACKET` · `THERMAL BOMBER
JACKET` · `TECH SHELL JACKET`.
The panel is an opaque lattice panel, `min-width: 14rem`, bordered, `padding: 1rem`, items
0.75rem
apart at 40% white brightening to full on hover. Three details matter:
- **The 16 units between the label and the panel are padding on the wrapper, not a margin
on the
panel.** As a margin they are dead space the pointer crosses on its way down, which
counts as
leaving the wrapper — the menu closed before it could be reached.
- **Close on a 220ms timer**, cancelled by re-entering; close on `Escape` and on a
pointerdown
outside; close on blur leaving the group.
- **Focus does not open it, activation does.** Tapping a button focuses it and *then* clicks it,
and
focus arrives first — opening on focus and toggling on click cancelled each other out inside
a
single tap (traced: `aria-expanded` went true at 155ms and false at 157ms). Use a
`