templates/scroll-flight-engine.js
templates/scroll-flight-engine.jsBrowse 27 files
10,135 tokens
35,442 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1/* ============================================================================2 auteur · scroll-flight-engine.js3 ----------------------------------------------------------------------------4 Portable scroll-scrubbed camera-flight engine. Photoreal AI-video "fly through5 the world" — auteur's video-scrub tier, complementary to its real-time WebGL6 recipes. Asset-source-agnostic: feed it ANY .mp4 clips (image→video models,7 Gemini, real footage). See references/scroll-flight.md for the full recipe.8 9 VENDORED from scroll-world (github.com/cth9191/scroll-world), MIT (c) 2026 cyw.10 Adapted for auteur — three changes from upstream:11 1. default --sw-accent shifted off the 250-290 deg AI-purple (auteur taste);12 every real site overrides --sw-accent anyway.13 2. the auteur-allow line below, so slopscan permits the engine's ONE scroll14 listener (rAF-gated + passive; smoothed-scroll libs like Lenis fight15 frame-accurate currentTime scrubbing, so native scroll is correct here).16 3. a scroll-driven camera dolly (see "auteur scroll-dolly" in read()): the17 scene pushes IN and drifts down as you scroll through it, manufacturing18 forward/descent travel on top of AI clips that only animate ambiently and19 don't move the camera themselves.20 ========================================================================== */21/* auteur-allow: RAW_SCROLL_LISTENER -- frame-accurate video scrub needs a native rAF-gated passive scroll listener; smoothed-scroll libraries fight currentTime seeking */22/* ============================================================================23 scroll-world — portable scroll-scrubbed camera-flight engine24 ----------------------------------------------------------------------------25 Framework-agnostic. Vanilla JS, zero dependencies. It builds its own DOM and26 injects its own (namespaced) CSS into a container you give it, so it drops into27 plain HTML, Next.js (call from a ref/useEffect), Vue (onMounted), a server-28 rendered page, anything.29 30 USAGE31 mountScrollWorld(document.getElementById('world'), {32 brand: { name: 'Pearl & Co.', href: '#top' },33 diveScroll: 1.3, // viewport-heights of scroll per dive clip34 connScroll: 0.9, // ...per connector clip35 hint: 'scroll to fly in',36 nav: true, // show the top section nav37 atmosphere: true, // subtle gradient + drifting particles behind the clips38 scrollMobileFactor: 1.2, // extra scroll distance per segment on mobile (small39 // viewports read the same flight as faster; industry40 // pattern is a LONGER mobile scroll run)41 sections: [42 { id, label, still, poster, posterMobile, clip, clipMobile, accent,43 // `poster` = the EXTRACTED FIRST FRAME of the encoded clip44 // (pipeline.md §5b). Shown while the clip loads, so the45 // still→video swap is pixel-identical (no crop/render pop).46 // `posterMobile` = same, extracted from the mobile/portrait47 // encode (wire it whenever clipMobile has different framing).48 // Falls back to `still` when absent; `still` remains the49 // stills-mode / no-clip artwork.50 scroll: 1.6, // optional per-section override of diveScroll — more scroll51 // distance = a slower, longer dwell in this scene52 linger: 0.5, // optional 0..1 — remaps time so the camera settles mid-scene53 // (exactly where the copy peaks) and moves quicker at the54 // edges. 0 = linear (default). Keep ≤ 0.6; 1 = full pause.55 eyebrow, title, body, tags:[…],56 cta:{ primary:{label,href}, secondary:{label,href} } }, // last section only57 …58 ],59 connectors: [clipUrl, …], // length = sections.length - 1 (nulls allowed)60 connectorsMobile: [clipUrl, …], // optional lighter connectors for phones (same length)61 62 MOBILE (the clipMobile/connectorsMobile variants are the opt-in mobile tiers;63 the rest of the phone handling below is always on)64 Two independent axes, deliberately separate:65 - CLIP TIER (which file): decided by device class — screen short side ≤600 CSS px66 = phone → `clipMobile`/`posterMobile`; tablets (iPad Pro included) and desktops67 get the full master. NOT decided by pointer type: iPadOS reports a coarse68 pointer and a Mac UA, but has a desktop-class screen + decoder.69 - BEHAVIOUR hardening (how it acts): on any coarse-pointer / ≤860px viewport the70 engine coalesces seeks (never issues a new currentTime while the decoder is71 still `seeking` — fast flicks can't pile up and freeze), takes a coarser seek72 step, keeps the poster up until the clip actually paints, primes each video73 (muted play→pause) on first touch (iOS blank-video fix), lengthens the scroll74 run (`scrollMobileFactor`), drops the drifting particles, and ignores75 URL-bar-only resizes (no scroll jump).76 STILLS MODE (automatic fallback, never configured): the page falls back to the77 stills cross-dissolving as you scroll — no video load or decode — when the user78 asked for it (`prefers-reduced-motion`, data-saver) or the OS blocks video at79 runtime (iOS Low Power Mode rejects even muted play(); detected on first touch).80 Chromium-only network signals (`navigator.connection.saveData`/`effectiveType`)81 are used strictly as downgrade signals — saveData → stills mode, 2g/3g → shrink82 the clip prefetch window. iOS exposes none of these, so the baseline stays83 conservative (posters first, lazy blob fetch near the viewport) for everyone.84 Nothing here is required — a config with only `clip`/`connectors` still works on85 phones; the mobile variants just make it lighter and smoother.86 87 THEME (CSS custom properties; set on the container or :root to override)88 --sw-bg page background (match your scene bg for seamless posters)89 --sw-ink primary text90 --sw-ink-soft secondary text91 --sw-accent default accent (each section overrides via its `accent`)92 --sw-font-display / --sw-font-body93 94 SEO / STATIC COPY95 The engine builds its DOM client-side, so on its own the page has no crawlable96 copy. Put a plain-markup version of the copy (h1 + per-section h2/p, real links)97 inside the container in a block marked `data-sw-seo` — the engine hides it on98 mount and it never fights the visual layer, but it exists in the served HTML for99 crawlers, link previews, and no-JS visitors (see index-template.html).100 101 REQUIREMENTS ON YOUR ASSETS102 - clips encoded native-res, crf~20, -g 8, +faststart, no audio (see pipeline.md)103 - connectors' endpoints are the neighbouring dives' ACTUAL frames (see SKILL Step 5)104 - posters extracted from the ENCODED clips' first frames (pipeline.md §5b)105 - (optional) mobile variants at ~720p, -g 4 for smoother phone scrubbing106 The engine loads each clip as a Blob (always seekable) and scrubs currentTime; it does107 NOT depend on HTTP byte-range support.108 ========================================================================== */109 110function mountScrollWorld(container, config) {111 const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;112 // BEHAVIOUR hardening (seek step, priming, particles, resize gating) keys off input113 // type + viewport: `coarse` is captured once (input type doesn't change mid-session);114 // the ≤860px query is read live via isMobile() so a desktop resize/DevTools toggle115 // switches seek behaviour without a reload.116 const coarse = window.matchMedia('(hover: none) and (pointer: coarse)').matches;117 const smallMQ = window.matchMedia('(max-width: 860px)');118 const isMobile = () => coarse || smallMQ.matches;119 // CLIP TIER keys off device class, NOT input type: an iPad Pro is coarse-pointer but120 // has a desktop-class screen and decoder — it gets the 1080p master, with the touch121 // hardening above still on. screen.* is stable across rotation and window resizes;122 // a phone's short side is ≤ ~500 CSS px, tablets start at 744.123 const phoneClass = Math.min(screen.width, screen.height) <= 600;124 // Network signals are Chromium-only (iOS/Safari/Firefox expose nothing) — treat them125 // strictly as a *downgrade* signal on top of a conservative default, never as a gate126 // for the good experience.127 const conn = navigator.connection;128 const dataSaver = !!(conn && conn.saveData);129 const slowNet = !!(conn && /^(slow-2g|2g|3g)$/.test(conn.effectiveType || ''));130 // Stills mode: the page becomes the stills cross-dissolving as you scroll — no video131 // load, no decode. Entered up-front for prefers-reduced-motion and data-saver, and at132 // runtime when iOS Low Power Mode blocks video (see enterStillsMode/primeVideo).133 let stillsOnly = reduce || dataSaver;134 const SECTIONS = config.sections || [];135 const CONNECTORS = config.connectors || [];136 const CONNECTORS_M = config.connectorsMobile || [];137 const DIVE_W = config.diveScroll || 1.3;138 const CONN_W = config.connScroll || 0.9;139 const CROSSFADE = (config.crossfade != null) ? config.crossfade : 0.12; // seam dissolve width (vh)140 const N = SECTIONS.length;141 if (!N) return;142 143 injectCSS();144 container.classList.add('sw-root');145 // Server-rendered SEO copy (crawlers/no-JS read it from the HTML); once the146 // engine mounts, the visual layer takes over and the static block hides.147 container.querySelectorAll('[data-sw-seo]').forEach(n => { n.hidden = true; });148 149 // ---- build the interleaved segment chain: dive0, conn0, dive1, … diveN-1 ----150 const SEGMENTS = [];151 SECTIONS.forEach((s, i) => {152 const dive = { kind: 'dive', si: i, clip: s.clip, clipM: s.clipMobile, still: s.still,153 poster: s.poster, posterM: s.posterMobile,154 accent: s.accent, w: s.scroll || DIVE_W, linger: s.linger || 0 };155 SEGMENTS.push(dive);156 s._seg = dive;157 // A connector is optional: if connectors[i] is falsy, the two dives simply158 // crossfade directly (no fly-over). Lets a page complete even when a159 // connector can't be generated (e.g. a content-filter false-positive).160 if (i < N - 1 && CONNECTORS[i]) {161 SEGMENTS.push({ kind: 'conn', si: i, clip: CONNECTORS[i], clipM: CONNECTORS_M[i],162 still: SECTIONS[i + 1].still, poster: SECTIONS[i + 1].poster,163 posterM: SECTIONS[i + 1].posterMobile,164 accent: SECTIONS[i + 1].accent, w: CONN_W });165 }166 });167 const NSEG = SEGMENTS.length;168 169 // ---- DOM ----170 const sky = el('div', 'sw-sky');171 if (config.atmosphere !== false) {172 sky.appendChild(el('div', 'sw-sky__grad'));173 sky.appendChild(el('div', 'sw-sky__glow'));174 }175 const particles = el('div', 'sw-particles'); sky.appendChild(particles);176 177 const scrollbar = el('div', 'sw-scrollbar');178 const scrollbarFill = el('span'); scrollbar.appendChild(scrollbarFill);179 180 const topbar = el('div', 'sw-topbar');181 if (config.brand) {182 const brand = el('a', 'sw-brand'); brand.href = (config.brand.href || '#');183 brand.appendChild(el('span', 'sw-brand__mark'));184 const nm = el('span', 'sw-brand__name'); nm.textContent = config.brand.name || ''; brand.appendChild(nm);185 topbar.appendChild(brand);186 }187 const nav = el('nav', 'sw-nav'); if (config.nav !== false) topbar.appendChild(nav);188 if (config.cta && config.cta.label) {189 const c = el('a', 'sw-topcta'); c.href = config.cta.href || '#'; c.textContent = config.cta.label;190 topbar.appendChild(c);191 }192 193 const stage = el('div', 'sw-stage');194 const copylayer = el('div', 'sw-copylayer');195 const route = el('div', 'sw-route');196 const hint = el('div', 'sw-hint');197 const hintText = el('span'); hintText.textContent = config.hint || 'scroll'; hint.appendChild(hintText);198 hint.appendChild(el('i'));199 const track = el('div', 'sw-track');200 201 [sky, scrollbar, topbar, stage, copylayer, route, hint, track].forEach(n => container.appendChild(n));202 203 // segment scenes204 SEGMENTS.forEach(s => {205 const scene = el('div', 'sw-scene'); scene.style.setProperty('--sw-accent', s.accent || '');206 const img = el('img', 'sw-scene__still'); img.alt = ''; img.decoding = 'async'; img.loading = 'lazy';207 // Prefer the extracted-frame poster (pixel-identical to the clip's first frame,208 // so the still→video swap can't pop) — matching the encode the device will get.209 // In stills mode the clip never loads, so the higher-fidelity source still is the210 // better permanent image.211 const pref = phoneClass ? (s.posterM || s.poster) : s.poster;212 const posterSrc = (!stillsOnly && pref) ? pref : s.still;213 if (posterSrc) img.src = posterSrc;214 scene.appendChild(img); stage.appendChild(scene);215 s.el = scene; s.img = img; s.video = null; s.hasClip = false;216 s.loading = false; s.ready = false; s.cur = 0; s.target = 0; s.visible = false;217 });218 219 // per-section copy / route / nav220 const copies = [], dots = [];221 SECTIONS.forEach((s, i) => {222 const c = el('article', 'sw-copy'); c.style.setProperty('--sw-accent', s.accent || '');223 c.innerHTML =224 `<span class="sw-copy__num">${pad(i + 1)} / ${pad(N)}</span>` +225 (s.eyebrow ? `<span class="sw-copy__eyebrow">${esc(s.eyebrow)}</span>` : '') +226 (s.title ? `<h2 class="sw-copy__title">${esc(s.title)}</h2>` : '') +227 (s.body ? `<p class="sw-copy__body">${esc(s.body)}</p>` : '') +228 (s.tags && s.tags.length ? `<ul class="sw-copy__tags">${s.tags.map(t => `<li>${esc(t)}</li>`).join('')}</ul>` : '') +229 (s.cta ? `<div class="sw-copy__cta">${ctaBtns(s.cta)}</div>` : '');230 copylayer.appendChild(c); copies.push(c);231 232 const dot = el('button', 'sw-route__dot'); dot.style.setProperty('--sw-accent', s.accent || '');233 dot.innerHTML = `<span class="sw-route__label">${esc(s.label || '')}</span><i></i>`;234 dot.addEventListener('click', () => jumpTo(i)); route.appendChild(dot); dots.push(dot);235 236 if (config.nav !== false) {237 const b = el('button', 'sw-nav__item'); b.textContent = s.label || '';238 b.addEventListener('click', () => jumpTo(i)); nav.appendChild(b);239 }240 });241 242 // ---- math ----243 const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));244 const smooth = x => { x = clamp(x); return x * x * (3 - 2 * x); };245 // Per-section dwell: monotone remap of scroll→time so the camera settles mid-scene246 // (where the copy peaks) and moves quicker near the seams. L=0 linear, L=1 full247 // mid-scene pause. f(0)=0, f(1)=1 always, so seam frames are untouched.248 const lingerEase = (x, L) => { L = clamp(L); const c = x - 0.5; return (1 - L) * x + L * (4 * c * c * c + 0.5); };249 let vh = window.innerHeight, stageX = 0, totalW = 0, activeIndex = -1, ticking = false;250 let laidOutW = window.innerWidth; // width the current layout was computed at (see onResize)251 252 function layout() {253 vh = window.innerHeight;254 laidOutW = window.innerWidth;255 stageX = window.innerWidth > 860 ? 4 : 0;256 // Small viewports read a camera flight as faster than big ones do, so give each257 // segment more scroll distance on mobile (industry pattern: mobile scroll runs are258 // LONGER than desktop's for the same sequence). Override via scrollMobileFactor.259 const wf = isMobile() ? (config.scrollMobileFactor != null ? config.scrollMobileFactor : 1.2) : 1;260 let off = 0;261 SEGMENTS.forEach(s => { s.start = off * vh; off += s.w * wf; s.end = off * vh; });262 totalW = off;263 track.style.height = (totalW * vh + vh) + 'px'; // +1vh so the last flight completes264 read();265 }266 267 function jumpTo(i) {268 const seg = SECTIONS[i]._seg;269 window.scrollTo({ top: seg.start + (seg.end - seg.start) * 0.5, behavior: reduce ? 'auto' : 'smooth' });270 }271 272 function enterStillsMode() {273 if (stillsOnly) return;274 stillsOnly = true;275 SEGMENTS.forEach(s => {276 if (s.video) {277 try { s.video.pause(); } catch (e) {}278 try { URL.revokeObjectURL(s.video.src); } catch (e) {}279 s.video.remove();280 }281 s.el.classList.remove('has-clip');282 s.video = null; s.hasClip = false; s.ready = false; s.loading = false;283 });284 read();285 }286 287 function loadClip(s) {288 if (stillsOnly || s.loading || !s.clip) return;289 s.loading = true;290 // Serve the lighter mobile encode on phone-class devices when one was provided291 // (tablets and desktops get the full master — see phoneClass above).292 const url = (phoneClass && s.clipM) ? s.clipM : s.clip;293 fetch(url).then(r => r.ok ? r.blob() : Promise.reject(new Error('404')))294 .then(blob => {295 const v = document.createElement('video');296 v.className = 'sw-scene__video';297 v.muted = true; v.playsInline = true; v.preload = 'auto';298 v.setAttribute('muted', ''); v.setAttribute('playsinline', '');299 v.src = URL.createObjectURL(blob);300 v.addEventListener('loadedmetadata', () => { s.ready = true; read(); });301 // Reveal the video (hide the still poster) only once a real frame has302 // painted — on iOS a seeked-but-never-played muted video stays blank, so303 // hiding the still on metadata alone would flash an empty scene.304 v.addEventListener('seeked', () => { s.el.classList.add('has-clip'); }, { once: true });305 v.addEventListener('loadeddata', () => { try { v.pause(); } catch (e) {} if (userReady) primeVideo(v); });306 s.el.appendChild(v); s.video = v; s.hasClip = true;307 }).catch(() => { s.loading = false; });308 }309 310 function read() {311 const y = window.scrollY || window.pageYOffset;312 const fade = CROSSFADE * vh;313 let ci = 0;314 for (let i = 0; i < NSEG; i++) if (y >= SEGMENTS[i].start) ci = i;315 316 // On a slow connection (Chromium signal only) shrink the prefetch window: fetch the317 // clip you're in, not the neighbourhood. Everyone else prefetches ±1.6 viewports.318 const lookahead = slowNet ? 0.4 : 1.6;319 for (let i = 0; i < NSEG; i++) {320 const s = SEGMENTS[i];321 if (y > s.start - lookahead * vh && y < s.end + lookahead * vh) loadClip(s);322 const local = clamp((y - s.start) / (s.end - s.start), 0, 1);323 s.target = s.linger ? lingerEase(local, s.linger) : local;324 let outside = 0;325 if (y < s.start) outside = s.start - y; else if (y > s.end) outside = y - s.end;326 const op = smooth(1 - outside / fade);327 s.el.style.opacity = op; s.visible = op > 0.001;328 s.el.style.zIndex = (i === ci) ? '120' : String(100 + Math.round(op * 10));329 // auteur scroll-dolly: push the camera INTO the scene and drift down as you scroll330 // through it. Manufactures forward/descent travel on top of near-static AI clips (the331 // clip's currentTime scrub still runs below). Applied to the scene (video + still).332 // dy stays inside the scale overscan so scene edges never reveal the page background.333 const push = reduce ? 1 : 1.05 + local * 0.14;334 const dy = reduce ? 0 : (0.5 - local) * 2.4;335 s.el.style.transform = `translateY(${dy.toFixed(2)}vh) scale(${push.toFixed(3)})`;336 }337 338 for (let i = 0; i < N; i++) {339 const seg = SECTIONS[i]._seg;340 const pr = clamp((y - seg.start) / (seg.end - seg.start), 0, 1);341 const before = y < seg.start, after = y > seg.end;342 let cop;343 if (i === 0) cop = after ? 0 : smooth(1 - pr / 0.62); // greets on landing344 else if (i === N - 1) cop = before ? 0 : smooth(pr / 0.4); // holds CTA at the end345 else cop = (before || after) ? 0 : smooth(1 - Math.abs(pr - 0.5) / 0.5);346 const c = copies[i];347 c.style.opacity = cop;348 c.style.transform = reduce ? 'none' : `translateY(${(0.5 - pr) * 4}vh)`;349 c.style.pointerEvents = cop > 0.5 ? 'auto' : 'none';350 }351 352 const cur = SEGMENTS[ci];353 const near = clamp(cur.kind === 'dive' ? cur.si354 : (((y - cur.start) / (cur.end - cur.start)) > 0.5 ? cur.si + 1 : cur.si), 0, N - 1);355 if (near !== activeIndex) {356 activeIndex = near;357 dots.forEach((d, k) => d.classList.toggle('is-active', k === near));358 nav.querySelectorAll('.sw-nav__item').forEach((n, k) => n.classList.toggle('is-active', k === near));359 container.style.setProperty('--sw-accent', SECTIONS[near].accent || '');360 }361 scrollbarFill.style.transform = `scaleX(${clamp(y / (totalW * vh))})`;362 hint.style.opacity = clamp(1 - y / (0.5 * vh));363 if (particles) particles.style.transform = `translate3d(0, ${-y * 0.05}px, 0)`;364 ticking = false;365 }366 367 function raf() {368 const eps = isMobile() ? 0.02 : 0.008; // coarser seek step on phones = fewer decodes369 for (let i = 0; i < NSEG; i++) {370 const s = SEGMENTS[i];371 if (!s.hasClip || !s.ready || !s.video) continue;372 // Never queue a seek while the decoder is still resolving the last one.373 // On phones a fast flick would otherwise pile up seeks and freeze the clip;374 // cur keeps lerping, so we snap to the latest target the moment it's free.375 if (s.video.seeking) continue;376 if (!s.visible && Math.abs(s.cur - s.target) < 0.002) continue;377 s.cur += (s.target - s.cur) * (reduce ? 1 : 0.18);378 const dur = s.video.duration || 1;379 const t = clamp(s.cur, 0, 0.999) * dur;380 if (Math.abs(s.video.currentTime - t) > eps) { try { s.video.currentTime = t; } catch (e) {} }381 }382 requestAnimationFrame(raf);383 }384 385 // iOS needs a user gesture before a muted video will decode/paint reliably. On the386 // first touch we prime every loaded clip (muted play→pause) so the first seek is387 // instant instead of showing a blank frame. `userReady` also makes freshly-loaded388 // clips prime themselves (see loadClip).389 let userReady = false;390 function primeVideo(v) {391 if (!isMobile() || !v) return;392 // A muted, playsinline play() that REJECTS on a user gesture means the OS is393 // blocking video — in practice iOS Low Power Mode, where currentTime scrubbing394 // doesn't work either. Fall back to stills for the whole page instead of showing395 // frozen/blank scenes.396 try { const p = v.play(); if (p && p.then) p.then(() => { try { v.pause(); } catch (e) {} }).catch(() => { enterStillsMode(); }); }397 catch (e) {}398 }399 function onFirstGesture() {400 if (userReady) return;401 userReady = true;402 SEGMENTS.forEach(s => primeVideo(s.video));403 }404 window.addEventListener('pointerdown', onFirstGesture, { once: true, passive: true });405 window.addEventListener('touchstart', onFirstGesture, { once: true, passive: true });406 407 // Particles are a per-frame cost we can't afford alongside video scrubbing on a phone.408 seedParticles(particles, reduce || coarse);409 window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(read); } }, { passive: true });410 // Mobile browsers fire `resize` every time the URL bar slides in/out. Re-running411 // layout() there rebuilds the track height and yanks the scroll position, so on412 // touch we ignore height-only changes and only relayout when the width actually413 // changes (rotation still comes through orientationchange). layout() records the414 // width it laid out at.415 function onResize() {416 if (coarse && window.innerWidth === laidOutW) return;417 layout();418 }419 window.addEventListener('resize', onResize);420 window.addEventListener('orientationchange', layout);421 window.addEventListener('load', layout);422 layout();423 requestAnimationFrame(raf);424 425 // ---- helpers ----426 function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; }427 function pad(n) { return String(n).padStart(2, '0'); }428 function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }429 function ctaBtns(cta) {430 let h = '';431 if (cta.primary) h += `<a class="sw-btn sw-btn--primary" href="${esc(cta.primary.href || '#')}">${esc(cta.primary.label)}</a>`;432 if (cta.secondary) h += `<a class="sw-btn sw-btn--ghost" href="${esc(cta.secondary.href || '#')}">${esc(cta.secondary.label)}</a>`;433 return h;434 }435}436 437function seedParticles(host, reduce) {438 if (!host || reduce) return;439 const kinds = ['dot', 'dot', 'ring'];440 const seeds = [7, 23, 41, 58, 71, 88, 12, 34, 52, 66, 83, 95, 18, 29, 47, 63, 77, 91, 5, 38, 55, 69, 82, 97];441 for (let k = 0; k < 20; k++) {442 const s = document.createElement('span');443 s.className = 'sw-pt sw-pt--' + kinds[k % kinds.length];444 s.style.left = seeds[k % seeds.length] + 'vw';445 s.style.top = ((seeds[(k * 3) % seeds.length] * 1.3) % 100) + 'vh';446 s.style.setProperty('--sw-sc', (0.5 + ((seeds[(k * 5) % seeds.length] % 60) / 60) * 1.1).toFixed(2));447 const dur = 14 + (seeds[(k * 7) % seeds.length] % 22);448 s.style.animationDuration = dur + 's';449 s.style.animationDelay = (-(seeds[(k * 2) % seeds.length] % dur)) + 's';450 host.appendChild(s);451 }452}453 454function injectCSS() {455 if (document.getElementById('sw-css')) return;456 const css = `457 .sw-root{--sw-bg:#F5EDE0;--sw-ink:#241d2b;--sw-ink-soft:#6a6072;--sw-accent:#b8623a;458 --sw-font-display:ui-rounded,"SF Pro Rounded","Segoe UI",system-ui,sans-serif;459 --sw-font-body:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif;460 color:var(--sw-ink);font-family:var(--sw-font-body);}461 html,body{margin:0;background:var(--sw-bg,#F5EDE0);overflow-x:hidden;}462 .sw-sky{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:var(--sw-bg);}463 .sw-sky__grad{position:absolute;inset:-10%;background:linear-gradient(178deg,color-mix(in srgb,var(--sw-accent) 12%,var(--sw-bg)) 0%,var(--sw-bg) 55%,color-mix(in srgb,var(--sw-accent) 6%,var(--sw-bg)) 100%);}464 .sw-sky__glow{position:absolute;inset:0;background:radial-gradient(60% 42% at 74% 16%,color-mix(in srgb,var(--sw-accent) 22%,transparent),transparent 70%),radial-gradient(46% 34% at 50% 50%,color-mix(in srgb,#fff 45%,transparent),transparent 70%);}465 .sw-particles{position:absolute;inset:-6% -2%;will-change:transform;}466 .sw-pt{position:absolute;width:13px;height:13px;transform:scale(var(--sw-sc,1));opacity:0;animation:sw-drift linear infinite;}467 .sw-pt::before{content:"";position:absolute;inset:0;border-radius:50%;}468 .sw-pt--dot::before{background:radial-gradient(circle at 34% 30%,color-mix(in srgb,var(--sw-accent) 60%,#000),#000 82%);}469 .sw-pt--ring::before{background:transparent;border:2px solid color-mix(in srgb,var(--sw-accent) 55%,transparent);}470 @keyframes sw-drift{0%{opacity:0;transform:scale(var(--sw-sc)) translate(0,12vh) rotate(0)}12%{opacity:.5}88%{opacity:.45}100%{opacity:0;transform:scale(var(--sw-sc)) translate(4vw,-22vh) rotate(210deg)}}471 .sw-scrollbar{position:fixed;top:0;left:0;right:0;height:3px;z-index:60;background:color-mix(in srgb,var(--sw-accent) 14%,transparent);}472 .sw-scrollbar span{display:block;height:100%;width:100%;transform-origin:0 50%;transform:scaleX(0);background:var(--sw-accent);}473 .sw-topbar{position:fixed;top:0;left:0;right:0;z-index:50;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:clamp(14px,2.4vw,26px) clamp(18px,5vw,64px);}474 .sw-brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--sw-ink);}475 .sw-brand__mark{width:24px;height:28px;border-radius:7px 7px 10px 10px;background:linear-gradient(160deg,var(--sw-accent),color-mix(in srgb,var(--sw-accent) 60%,#000));box-shadow:0 6px 14px color-mix(in srgb,var(--sw-accent) 40%,transparent);}476 .sw-brand__name{font-family:var(--sw-font-display);font-weight:700;font-size:1.1rem;}477 .sw-nav{display:flex;gap:4px;padding:5px;background:color-mix(in srgb,#fff 55%,transparent);backdrop-filter:blur(10px);border:1px solid color-mix(in srgb,var(--sw-accent) 16%,transparent);border-radius:999px;}478 .sw-nav__item{font:inherit;font-size:.82rem;color:var(--sw-ink-soft);border:0;background:transparent;cursor:pointer;padding:7px 14px;border-radius:999px;transition:color .25s,background .25s;}479 .sw-nav__item:hover{color:var(--sw-ink);} .sw-nav__item.is-active{color:#fff;background:var(--sw-accent);}480 .sw-topcta{text-decoration:none;font-weight:600;font-size:.9rem;color:#fff;background:var(--sw-ink);padding:10px 20px;border-radius:999px;white-space:nowrap;}481 .sw-stage{position:fixed;inset:0;z-index:10;pointer-events:none;}482 .sw-scene{position:absolute;inset:0;opacity:0;overflow:hidden;will-change:opacity,transform;transform-origin:50% 46%;}483 .sw-scene__video,.sw-scene__still{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center 42%;}484 .sw-scene__still{will-change:transform;} .sw-scene.has-clip .sw-scene__still{opacity:0;} .sw-scene__video{z-index:1;}485 .sw-copylayer{position:fixed;inset:0;z-index:20;pointer-events:none;}486 .sw-copylayer::before{content:"";position:absolute;inset:0;width:min(58vw,780px);background:linear-gradient(90deg,var(--sw-bg) 0%,color-mix(in srgb,var(--sw-bg) 82%,transparent) 34%,color-mix(in srgb,var(--sw-bg) 40%,transparent) 62%,transparent 100%);}487 .sw-copy{position:absolute;left:clamp(18px,5vw,64px);top:50%;transform:translateY(-50%);width:min(42vw,460px);opacity:0;will-change:opacity,transform;}488 .sw-copy__num{font-family:ui-monospace,Menlo,monospace;font-size:.74rem;letter-spacing:.12em;color:var(--sw-ink-soft);}489 .sw-copy__eyebrow{display:block;margin-top:18px;font-family:var(--sw-font-display);font-weight:700;font-size:.8rem;letter-spacing:.16em;text-transform:uppercase;color:var(--sw-accent);}490 .sw-copy__title{font-family:var(--sw-font-display);font-weight:700;color:var(--sw-ink);font-size:clamp(2rem,4.4vw,3.5rem);line-height:1.03;margin:12px 0 0;letter-spacing:-.01em;text-shadow:0 2px 20px color-mix(in srgb,var(--sw-bg) 70%,transparent);}491 .sw-copy__body{margin-top:18px;font-size:clamp(1rem,1.25vw,1.14rem);line-height:1.55;color:color-mix(in srgb,var(--sw-ink) 78%,var(--sw-ink-soft));max-width:40ch;text-shadow:0 1px 12px color-mix(in srgb,var(--sw-bg) 90%,transparent);}492 .sw-copy__tags{list-style:none;display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 0;padding:0;}493 .sw-copy__tags li{font-size:.82rem;font-weight:600;color:color-mix(in srgb,var(--sw-accent) 70%,#000);padding:7px 14px;border-radius:999px;background:color-mix(in srgb,var(--sw-accent) 14%,#fff);border:1px solid color-mix(in srgb,var(--sw-accent) 30%,transparent);}494 .sw-copy__cta{display:flex;flex-wrap:wrap;gap:12px;margin-top:28px;pointer-events:auto;}495 .sw-btn{text-decoration:none;font-weight:600;font-size:.95rem;padding:13px 24px;border-radius:999px;transition:transform .2s;}496 .sw-btn--primary{color:#fff;background:var(--sw-ink);} .sw-btn--primary:hover{transform:translateY(-2px);}497 .sw-btn--ghost{color:var(--sw-ink);border:1.5px solid color-mix(in srgb,var(--sw-ink) 25%,transparent);} .sw-btn--ghost:hover{transform:translateY(-2px);}498 .sw-route{position:fixed;right:clamp(14px,2.4vw,30px);top:50%;z-index:40;transform:translateY(-50%);display:flex;flex-direction:column;gap:22px;padding:18px 10px;}499 .sw-route::before{content:"";position:absolute;left:50%;top:22px;bottom:22px;width:2px;transform:translateX(-50%);background:var(--sw-accent);opacity:.28;}500 .sw-route__dot{position:relative;border:0;background:transparent;cursor:pointer;width:14px;height:14px;display:grid;place-items:center;}501 .sw-route__dot i{width:9px;height:9px;border-radius:50%;background:color-mix(in srgb,var(--sw-accent) 40%,transparent);transition:transform .3s,background .3s,box-shadow .3s;}502 .sw-route__dot:hover i{transform:scale(1.25);background:var(--sw-accent);}503 .sw-route__dot.is-active i{background:var(--sw-accent);transform:scale(1.4);box-shadow:0 0 0 5px color-mix(in srgb,var(--sw-accent) 22%,transparent);}504 .sw-route__label{position:absolute;right:24px;top:50%;transform:translateY(-50%) translateX(6px);white-space:nowrap;font-size:.78rem;font-weight:600;color:var(--sw-ink);background:color-mix(in srgb,#fff 85%,transparent);backdrop-filter:blur(6px);padding:5px 11px;border-radius:999px;opacity:0;pointer-events:none;transition:opacity .25s,transform .25s;border:1px solid color-mix(in srgb,var(--sw-accent) 14%,transparent);}505 .sw-route__dot:hover .sw-route__label,.sw-route__dot.is-active .sw-route__label{opacity:1;transform:translateY(-50%) translateX(0);}506 .sw-hint{position:fixed;left:50%;bottom:26px;z-index:30;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:10px;font-size:.76rem;letter-spacing:.14em;text-transform:uppercase;color:var(--sw-ink-soft);transition:opacity .3s;}507 .sw-hint i{width:22px;height:34px;border-radius:12px;border:2px solid color-mix(in srgb,var(--sw-ink) 28%,transparent);position:relative;}508 .sw-hint i::after{content:"";position:absolute;left:50%;top:7px;width:4px;height:7px;border-radius:2px;background:var(--sw-accent);transform:translateX(-50%);animation:sw-wheel 1.7s ease-in-out infinite;}509 @keyframes sw-wheel{0%{opacity:0;top:6px}40%{opacity:1}100%{opacity:0;top:17px}}510 .sw-track{position:relative;z-index:1;width:100%;pointer-events:none;}511 @media (max-width:860px){512 .sw-nav{display:none;}513 .sw-copylayer::before{width:100%;height:60%;top:auto;bottom:0;background:linear-gradient(0deg,var(--sw-bg) 8%,color-mix(in srgb,var(--sw-bg) 70%,transparent) 46%,transparent 100%);}514 /* Anchor copy to the bottom, clear of the home indicator / collapsing URL bar.515 dvh + env() are progressive: browsers that lack them keep the vh fallback line. */516 .sw-copy{left:clamp(18px,5vw,64px);right:clamp(18px,5vw,64px);top:auto;bottom:clamp(64px,14vh,120px);transform:none;width:auto;max-width:560px;}517 .sw-copy{bottom:calc(clamp(56px,12dvh,110px) + env(safe-area-inset-bottom));}518 .sw-copy__title{font-size:clamp(1.9rem,7.5vw,2.7rem);}519 .sw-copy__body{max-width:none;font-size:clamp(.98rem,3.6vw,1.1rem);} .sw-scene__video,.sw-scene__still{object-position:center 46%;}520 .sw-hint{bottom:calc(20px + env(safe-area-inset-bottom));}521 .sw-route{gap:16px;right:6px;} .sw-route__label{display:none;}522 }523 /* Portrait phones crop a 16:9 clip hard; keep the framing centred so the focal524 subject (which the camera dives toward) stays in view. */525 @media (max-width:860px) and (orientation:portrait){526 .sw-scene__video,.sw-scene__still{object-position:center 44%;}527 }528 /* Touch: give the route dots a finger-sized hit area without growing the visible dot. */529 @media (hover:none) and (pointer:coarse){530 .sw-route{padding:14px 6px;}531 .sw-route__dot{width:28px;height:28px;}532 .sw-btn{padding:15px 26px;}533 }534 @media (prefers-reduced-motion:reduce){ .sw-hint i::after{animation:none;} .sw-pt{display:none;} }535 `;536 // Wrap in a cascade layer so the page's own theme tokens (unlayered537 // :root / .sw-root { --sw-bg / --sw-ink / --sw-accent … }) always win over538 // these defaults, regardless of injection order. Enables clean dark themes.539 const style = document.createElement('style'); style.id = 'sw-css';540 style.textContent = '@layer sw {\n' + css + '\n}';541 document.head.appendChild(style);542}543 544// Expose for module + global use.545if (typeof module !== 'undefined' && module.exports) module.exports = { mountScrollWorld };546if (typeof window !== 'undefined') window.mountScrollWorld = mountScrollWorld;547