# 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 `