engine/scrollcraft.js
engine/scrollcraft.jsBrowse 22 files
14,834 tokens
56,343 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1/* ============================================================================2 scrollcraft: a scroll-driven interaction runtime3 ----------------------------------------------------------------------------4 Vanilla JS. Zero dependencies. Zero DOM generation.5 6 The engine does NOT build your page. You author real, semantic HTML and mark7 it up with data-sc-* attributes; the engine reads them and drives them from a8 single scroll value on a single rAF loop. That is deliberate: a runtime that9 generates its own DOM from a config object makes every site it touches look10 identical, which is the failure mode this replaces.11 12 ---------------------------------------------------------------------------13 ACTS: the unit of scroll time14 ---------------------------------------------------------------------------15 <section data-sc-act="pin" data-sc-span="2.5"> ... </section>16 17 data-sc-act scrub | pin | pan | flow (default: flow)18 data-sc-clip-map="travel" opts a pinned act OUT of full-life clip mapping.19 By DEFAULT a scrub clip is mapped across the stage's entire20 on-screen life, not across its pinned travel, because a pinned21 stage is visible for a viewport BEFORE the pin begins (sliding22 up into view) and a viewport AFTER it ends (sliding off the23 top). Mapped to pinned travel, the clip sits on its first24 frame through the whole entry and its last frame through the25 whole exit: the reader watches a still photograph while the26 page moves. Pair it with data-sc-dwell, which moves quickly at27 the edges and settles in the middle, so the fast motion lands28 on the two slides and the settle lands inside the pin where29 the copy is. data-sc-runout is accepted and ignored; it named30 the old opt-in for the exit half of this.31 data-sc-span viewport-heights of scroll this act owns. Pinned devices only32 (scrub/pin/pan). Default 1.5. The engine sets the outer33 height and sticks the first .sc-stage / [data-sc-stage] child.34 35 Every act exposes a normalized progress p (0..1):36 pinned p = (y - top) / (height - vh)37 flow p = (y + vh - top) / (height + vh)38 and publishes it as --sc-p on the act element, so CSS can read it too.39 40 ---------------------------------------------------------------------------41 DEVICES: what p drives42 ---------------------------------------------------------------------------43 data-sc-scrub on <video>. p scrubs currentTime. Blob-loaded, so44 it seeks without needing HTTP range support.45 data-sc-sequence="a/{i}.webp:120:1"46 on <canvas>. p scrubs an image sequence47 (path template : frameCount : startIndex).48 data-sc-pan="0.6" on a wide rail inside data-sc-act="pan". p drives49 horizontal travel. Value = extra travel multiplier.50 data-sc-parallax="-0.2" translateY by rate * act-progress * viewport.51 Negative = moves up faster than scroll (recedes).52 data-sc-cue="0.1 0.5" opacity/rise keyed to p. One value = enter+hold.53 Two = enter..leave. Add a third for the hold point.54 data-sc-kinetic="lines" lines | words | chars. Splits the element and55 staggers its reveal across the cue window.56 data-sc-reveal="up" up | down | left | right | iris. clip-path wipe.57 data-sc-count="0 4200" number bloom across the cue window.58 data-sc-in flow-section reveal, fires once on entry via59 IntersectionObserver (cheaper, and content that60 re-hides on scroll-up is a defect, not an effect).61 data-sc-stagger="60" on the parent staggers kids.62 data-sc-drift="#07090c" page background interpolates toward this colour63 while the act is on screen.64 65 ---------------------------------------------------------------------------66 WORLDFLIGHT: the page mode for one continuous flight67 ---------------------------------------------------------------------------68 <div data-sc-mode="worldflight" data-sc-seam="0.12">69 <div data-sc-world>70 <div data-sc-segment data-sc-w="1.4" data-sc-linger="0.3"71 data-sc-waypoint="Approach">72 <img class="sc-world__poster" src="p1.webp" alt="">73 <video data-sc-src="leg1.mp4" data-sc-src-mobile="leg1-m.mp4"></video>74 </div>75 ... more segments, in flight order ...76 </div>77 <div data-sc-world-copy>78 <div class="sc-world__scrim sc-scrim sc-scrim--band"></div>79 <div data-sc-copy data-sc-window="hero"> ... </div>80 <div data-sc-copy data-sc-window="0.34 0.56"> ... </div>81 <div data-sc-copy data-sc-window="finale"> ... </div>82 </div>83 <div data-sc-spacer aria-hidden="true"></div>84 </div>85 86 Acts cut the page into pinned blocks, which is the right shape for a page of87 chapters and the wrong shape for one continuous camera move: the reader hits88 the end of an act, the stage unsticks, a static page slides past, and the next89 act starts the whole thing again. Worldflight removes the seams by removing90 the blocks. There is ONE fixed stage for the entire page. The only element in91 document flow is a spacer, whose height the engine sets to the sum of the92 segment weights plus one viewport so the last flight can finish. Scroll drives93 the film timeline and the overlay opacity. Nothing else moves.94 95 data-sc-w viewport-heights this segment owns. Default 1.3.96 data-sc-linger dwell remap for this leg only (see lingerEase). Max 0.6.97 data-sc-seam crossfade band, in viewport-heights of scroll. Default 0.12.98 data-sc-waypoint label published on --sc-seg / the sc:waypoint event, so a99 page can draw its own route rail. The engine draws none.100 data-sc-window on a copy block: "hero" | "finale" | "from to [in [out]]"101 as fractions of the WHOLE track.102 103 Every clip stays mounted for the life of the page. Segments crossfade by104 opacity over the seam band; nothing ever swaps a src, because a src swap is a105 black frame and a black frame is the cut this mode exists to avoid.106 107 ---------------------------------------------------------------------------108 POINTER: interactivity that isn't scroll109 ---------------------------------------------------------------------------110 data-sc-tilt="8" 3D tilt toward the pointer, spring-damped, degrees.111 data-sc-magnet="0.35" element drifts toward the pointer inside its bounds.112 data-sc-spotlight publishes --sc-mx/--sc-my (0..1) for a light that113 follows the pointer across the surface.114 All three are gated to (hover: hover) and (pointer: fine) and disabled under115 prefers-reduced-motion. Touch never fires them.116 117 ---------------------------------------------------------------------------118 REDUCED MOTION119 ---------------------------------------------------------------------------120 Fewer and gentler, not zero. Cues still fade (comprehension survives), but121 translation collapses, video clips are never fetched (the poster holds), and122 pointer devices are inert. A worldflight still tells its whole story: the123 posters cross-dissolve through the same seams and the same copy windows.124 125 ---------------------------------------------------------------------------126 THE PLAYHEAD127 ---------------------------------------------------------------------------128 Every scrub clip on the page, act or worldflight, is driven by one smoothed129 playhead. Scroll only ever writes a TARGET; a standalone rAF loop walks the130 current time toward it at a fixed fraction per frame (data-sc-lerp, default131 0.18; 1.0 under reduced motion, which is no smoothing at all). Writes are132 gated by a deadband and skipped while the decoder is still seeking. Without133 the smoothing a trackpad flick reads as a stutter, because wheel events do134 not arrive at a constant rate and a 1:1 write reproduces every gap in them.135 ========================================================================== */136 137(function (global) {138 'use strict';139 140 var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;141 var fineMQ = matchMedia('(hover: hover) and (pointer: fine)');142 var smallMQ = matchMedia('(max-width: 860px)');143 var coarse = matchMedia('(hover: none) and (pointer: coarse)').matches;144 var isMobile = function () { return coarse || smallMQ.matches; };145 146 var clamp = function (x, a, b) { return x < a ? a : x > b ? b : x; };147 var clamp01 = function (x) { return clamp(x, 0, 1); };148 var smooth = function (x) { x = clamp01(x); return x * x * (3 - 2 * x); };149 var lerp = function (a, b, t) { return a + (b - a) * t; };150 151 // Monotone dwell remap. Settles the camera mid-act (where the copy peaks) and152 // moves quicker at the edges. f(0)=0 and f(1)=1 always, so seam frames between153 // consecutive clips are untouched and the chain stays invisible.154 function dwell(x, L) {155 if (!L) return x;156 L = clamp01(L);157 var c = x - 0.5;158 return (1 - L) * x + L * (4 * c * c * c + 0.5);159 }160 161 // Same curve, capped harder, and used per worldflight segment. Above ~0.6 the162 // derivative at the midpoint gets low enough that the camera visibly stops163 // dead mid-leg, which reads as a stall rather than a hold. The endpoints are164 // fixed by construction: f(0)=0, f(1)=1, so the first and last frames of every165 // leg are still exactly the frames the seam law matched, and the chain between166 // legs stays invisible no matter how much dwell a leg asks for.167 function lingerEase(x, L) {168 if (!L) return x;169 L = clamp(L, 0, 0.6);170 var c = x - 0.5;171 return (1 - L) * x + L * (4 * c * c * c + 0.5);172 }173 174 // The lerp is a mechanism, not a taste knob. It is exposed so a page whose175 // clips are unusually short (where 0.18 lags behind the pointer) can tighten176 // it, and it is never read as 0: a 0 lerp is a playhead that never moves.177 function lerpRate(el) {178 var v = parseFloat(el && el.getAttribute && el.getAttribute('data-sc-lerp'));179 return isNaN(v) || v <= 0 ? 0 : clamp(v, 0.02, 1);180 }181 182 // ---- colour -------------------------------------------------------------183 function parseColor(str) {184 str = (str || '').trim();185 var m = str.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);186 if (m) {187 var h = m[1];188 if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];189 return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];190 }191 m = str.match(/rgba?\(([^)]+)\)/i);192 if (m) {193 var p = m[1].split(/[,\s/]+/).filter(Boolean).map(parseFloat);194 return [p[0], p[1], p[2]];195 }196 return null;197 }198 function mixColor(a, b, t) {199 return 'rgb(' + Math.round(lerp(a[0], b[0], t)) + ',' +200 Math.round(lerp(a[1], b[1], t)) + ',' +201 Math.round(lerp(a[2], b[2], t)) + ')';202 }203 204 // ---- text splitting -----------------------------------------------------205 // Wraps each unit in a masked span so the reveal can slide from behind a clean206 // edge instead of just fading. Lines are measured after layout, so this must207 // run once fonts are ready or the line boxes are wrong.208 function splitText(el, mode) {209 if (el.__scSplit) return el.__scSplit;210 var text = el.textContent;211 var units = [];212 213 if (mode === 'chars' || mode === 'words') {214 var parts = mode === 'chars' ? Array.from(text) : text.split(/(\s+)/);215 el.textContent = '';216 parts.forEach(function (t) {217 if (/^\s+$/.test(t)) { el.appendChild(document.createTextNode(t)); return; }218 var mask = document.createElement('span');219 mask.className = 'sc-split';220 var inner = document.createElement('span');221 inner.className = 'sc-split__i';222 inner.textContent = t;223 mask.appendChild(inner);224 el.appendChild(mask);225 units.push(inner);226 });227 } else {228 // lines: wrap every word, measure offsetTop, regroup words into line spans229 var words = text.split(/\s+/).filter(Boolean);230 el.textContent = '';231 var probes = words.map(function (w, i) {232 var s = document.createElement('span');233 s.textContent = w;234 el.appendChild(s);235 if (i < words.length - 1) el.appendChild(document.createTextNode(' '));236 return s;237 });238 var lines = [], cur = null, lastTop = null;239 probes.forEach(function (s) {240 var top = s.offsetTop;241 if (lastTop === null || Math.abs(top - lastTop) > 1) { cur = []; lines.push(cur); lastTop = top; }242 cur.push(s.textContent);243 });244 el.textContent = '';245 lines.forEach(function (words, li) {246 var mask = document.createElement('span');247 mask.className = 'sc-split sc-split--line';248 var inner = document.createElement('span');249 inner.className = 'sc-split__i';250 inner.textContent = words.join(' ');251 mask.appendChild(inner);252 el.appendChild(mask);253 // Whitespace between the line spans. Without it the line boxes abut in254 // the text layer, so selecting and copying the heading yields255 // "even whenbreakfast" and a screen reader hears the same.256 if (li < lines.length - 1) el.appendChild(document.createTextNode(' '));257 units.push(inner);258 });259 }260 el.classList.add('sc-is-split');261 el.__scSplit = units;262 return units;263 }264 265 // ---- number formatting --------------------------------------------------266 function formatNum(v, template) {267 var decimals = 0;268 var dot = template.indexOf('.');269 if (dot > -1) decimals = template.length - dot - 1;270 var s = v.toFixed(decimals);271 if (/,/.test(template) || Math.abs(v) >= 10000) {272 var bits = s.split('.');273 bits[0] = bits[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');274 s = bits.join('.');275 }276 return s;277 }278 279 // =========================================================================280 function mount(root, opts) {281 root = typeof root === 'string' ? document.querySelector(root) : (root || document);282 opts = opts || {};283 284 var acts = [];285 var worlds = [];286 var drifts = [];287 var playheads = [];288 var scrollEls = [];289 var vh = innerHeight, vw = innerWidth;290 var y = 0, needsLayout = true;291 var progressBar = root.querySelector('[data-sc-progress]');292 var docEl = document.documentElement;293 294 // One rate for the page, overridable per clip. Read from the mount root, the295 // document element, or the option bag, in that order.296 var LERP = lerpRate(root.nodeType === 1 ? root : null) ||297 lerpRate(docEl) ||298 (opts.lerp > 0 ? clamp(opts.lerp, 0.02, 1) : 0) ||299 0.18;300 301 // Every scrub clip on the page lives here, whatever drives it. tick() walks302 // this one list, so an act clip and a worldflight leg get the same playhead.303 function makeClip(v, host) {304 var rec = {305 el: v, host: host || v,306 ready: false, loading: false, painted: false,307 cur: 0, target: 0, live: false, stuckAt: 0,308 lerp: lerpRate(v) || LERP309 };310 v.muted = true; v.playsInline = true; v.preload = 'none';311 v.setAttribute('muted', ''); v.setAttribute('playsinline', '');312 playheads.push(rec);313 return rec;314 }315 316 // ---- collect acts -----------------------------------------------------317 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-act]'), function (el) {318 var device = el.getAttribute('data-sc-act') || 'flow';319 var pinned = device === 'scrub' || device === 'pin' || device === 'pan';320 var act = {321 el: el,322 device: device,323 pinned: pinned,324 span: parseFloat(el.getAttribute('data-sc-span')) || (pinned ? 1.5 : 0),325 dwell: parseFloat(el.getAttribute('data-sc-dwell')) || 0,326 clipTravel: pinned && el.getAttribute('data-sc-clip-map') === 'travel',327 p: 0, raw: 0, top: 0, height: 0, live: false,328 cues: [], parallax: [], reveals: [], counts: [],329 video: null, seq: null, rail: null330 };331 332 if (pinned) {333 act.stage = el.querySelector('[data-sc-stage]') || el.querySelector('.sc-stage');334 if (act.stage) act.stage.classList.add('sc-stage');335 el.classList.add('sc-act--pinned');336 }337 338 // scrub video339 var v = el.querySelector('video[data-sc-scrub]');340 if (v) act.video = makeClip(v, el);341 342 // image sequence343 var cv = el.querySelector('canvas[data-sc-sequence]');344 if (cv) {345 var spec = cv.getAttribute('data-sc-sequence').split(':');346 act.seq = {347 el: cv, ctx: cv.getContext('2d', { alpha: false }),348 tpl: spec[0], count: parseInt(spec[1], 10) || 1, start: parseInt(spec[2], 10) || 0,349 frames: [], loaded: 0, drawn: -1350 };351 }352 353 // horizontal rail354 act.rail = el.querySelector('[data-sc-pan]');355 if (act.rail) act.railExtra = parseFloat(act.rail.getAttribute('data-sc-pan')) || 0;356 357 // cues358 Array.prototype.forEach.call(el.querySelectorAll('[data-sc-cue]'), function (c) {359 var nums = (c.getAttribute('data-sc-cue') || '').trim().split(/\s+/).map(parseFloat);360 // from [to [rampIn [rampOut]]]361 // rampIn/rampOut are fractions OF THE WINDOW, defaulting to 0.3 each.362 // That leaves a ~40% plateau at full opacity in the middle. Without a363 // plateau a cue is a triangle: it touches opacity 1 for a single364 // instant, so the reader has to stop on exactly the right pixel to see365 // the line at full strength, and every heading reads as slightly faded.366 var cue = {367 el: c,368 from: isNaN(nums[0]) ? 0 : nums[0],369 to: nums.length > 1 && !isNaN(nums[1]) ? nums[1] : null,370 rIn: nums.length > 2 && !isNaN(nums[2]) ? clamp01(nums[2]) : 0.3,371 rOut: nums.length > 3 && !isNaN(nums[3]) ? clamp01(nums[3]) : null,372 rise: parseFloat(c.getAttribute('data-sc-rise')),373 kinetic: c.getAttribute('data-sc-kinetic'),374 units: null, state: -1375 };376 if (cue.rOut === null) cue.rOut = (nums.length > 2 && !isNaN(nums[2])) ? 0.3 : 0.3;377 if (isNaN(cue.rise)) cue.rise = 1;378 act.cues.push(cue);379 });380 381 // parallax382 Array.prototype.forEach.call(el.querySelectorAll('[data-sc-parallax]'), function (c) {383 act.parallax.push({ el: c, rate: parseFloat(c.getAttribute('data-sc-parallax')) || 0 });384 });385 386 // reveals387 Array.prototype.forEach.call(el.querySelectorAll('[data-sc-reveal]'), function (c) {388 var nums = (c.getAttribute('data-sc-reveal-at') || '0 0.5').trim().split(/\s+/).map(parseFloat);389 act.reveals.push({ el: c, dir: c.getAttribute('data-sc-reveal') || 'up', from: nums[0] || 0, to: nums[1] || 0.5 });390 });391 392 // counters393 Array.prototype.forEach.call(el.querySelectorAll('[data-sc-count]'), function (c) {394 var nums = (c.getAttribute('data-sc-count') || '').trim().split(/\s+/);395 var at = (c.getAttribute('data-sc-count-at') || '0.1 0.55').trim().split(/\s+/).map(parseFloat);396 // Strip separators before parsing so the author can write the target397 // exactly as it should render. Without this, "0 3,500" parses the target398 // as 3 (the comma stops parseFloat) and "0 3500" renders "3500", because399 // formatNum only adds separators above 10,000. So any real figure between400 // 1,000 and 9,999 could not be shown the way it is written everywhere401 // else on the page. The template still drives the formatting.402 var num = function (s) { return parseFloat(String(s).replace(/,/g, '')) || 0; };403 act.counts.push({404 el: c, a: num(nums[0]), b: num(nums[1]),405 tpl: nums[1] || '0', from: at[0], to: at[1], last: null406 });407 });408 409 acts.push(act);410 411 var d = el.getAttribute('data-sc-drift');412 if (d) { var rgb = parseColor(d); if (rgb) drifts.push({ act: act, rgb: rgb }); }413 });414 415 // ---- collect worldflights --------------------------------------------416 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-mode="worldflight"]'), function (el) {417 var W = {418 el: el,419 stage: el.querySelector('[data-sc-world]') || el.querySelector('.sc-world'),420 copyLayer: el.querySelector('[data-sc-world-copy]') || el.querySelector('.sc-world__copy'),421 spacer: el.querySelector('[data-sc-spacer]') || el.querySelector('.sc-world__spacer'),422 seam: 0, segs: [], copies: [], total: 0, top: 0, index: -1, checked: false423 };424 var seam = parseFloat(el.getAttribute('data-sc-seam'));425 // A seam wider than the shortest leg would have three clips dissolving at426 // once and no leg ever fully present. Cap it well under that.427 W.seam = isNaN(seam) || seam <= 0 ? 0.12 : clamp(seam, 0.02, 0.4);428 if (W.stage) W.stage.classList.add('sc-world');429 if (W.copyLayer) W.copyLayer.classList.add('sc-world__copy');430 if (W.spacer) W.spacer.classList.add('sc-world__spacer');431 432 Array.prototype.forEach.call(el.querySelectorAll('[data-sc-segment]'), function (s) {433 var seg = {434 el: s,435 w: parseFloat(s.getAttribute('data-sc-w')) || 1.3,436 linger: clamp(parseFloat(s.getAttribute('data-sc-linger')) || 0, 0, 0.6),437 label: s.getAttribute('data-sc-waypoint') || '',438 poster: s.querySelector('[data-sc-poster]') || s.querySelector('.sc-world__poster') || s.querySelector('img'),439 c0: 0, c1: 0, local: 0, op: -1, z: -1, clip: null440 };441 s.classList.add('sc-world__seg');442 if (seg.poster) seg.poster.classList.add('sc-world__poster');443 var v = s.querySelector('video');444 if (v) {445 // Mark it as a scrub clip whether or not the author did. Everything446 // downstream, the stylesheet and the verification harness included,447 // finds scrub media by this attribute.448 v.setAttribute('data-sc-scrub', '');449 seg.clip = makeClip(v, s);450 }451 W.segs.push(seg);452 });453 454 var run = 0;455 W.segs.forEach(function (s) { s.c0 = run; run += Math.max(s.w, 0.1); s.c1 = run; });456 W.total = Math.max(run, 0.001);457 458 Array.prototype.forEach.call(el.querySelectorAll('[data-sc-copy]'), function (cEl) {459 var spec = (cEl.getAttribute('data-sc-window') || '').trim();460 var q = { el: cEl, from: 0, to: 1, rIn: 0.3, rOut: 0.3, state: -1 };461 var first = W.segs[0], last = W.segs[W.segs.length - 1];462 if (spec === 'hero') {463 // Present the instant the reader lands. A hero that fades IN has to464 // fade in from nothing over an empty first screen, which is the one465 // moment on the page where there is nothing else to look at.466 q.from = 0;467 q.to = first ? (0.62 * first.w) / W.total : 0.3;468 q.rIn = 0; q.rOut = 0.65;469 } else if (spec === 'finale') {470 q.from = last ? (last.c0 + 0.4 * last.w) / W.total : 0.7;471 q.to = 1; q.rIn = 0.55; q.rOut = 0;472 } else {473 var n = spec.split(/\s+/).map(parseFloat);474 q.from = isNaN(n[0]) ? 0 : clamp01(n[0]);475 q.to = (n.length > 1 && !isNaN(n[1])) ? clamp01(n[1]) : clamp01(q.from + 0.18);476 if (n.length > 2 && !isNaN(n[2])) q.rIn = clamp01(n[2]);477 if (n.length > 3 && !isNaN(n[3])) q.rOut = clamp01(n[3]);478 }479 if (q.to <= q.from) q.to = clamp01(q.from + 0.05);480 W.copies.push(q);481 });482 483 worlds.push(W);484 });485 486 // ---- flow reveals (fire once) ----------------------------------------487 var io = null;488 if ('IntersectionObserver' in window) {489 io = new IntersectionObserver(function (entries) {490 entries.forEach(function (e) {491 if (!e.isIntersecting) return;492 var el = e.target;493 el.classList.add('sc-in');494 var stagger = parseFloat(el.getAttribute('data-sc-stagger'));495 if (!isNaN(stagger)) {496 Array.prototype.forEach.call(el.children, function (kid, i) {497 kid.style.transitionDelay = (i * stagger) + 'ms';498 kid.classList.add('sc-in');499 });500 }501 io.unobserve(el);502 });503 }, { rootMargin: '0px 0px -12% 0px', threshold: 0.01 });504 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-in]'), function (el) { io.observe(el); });505 } else {506 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-in]'), function (el) { el.classList.add('sc-in'); });507 }508 509 // ---- layout -----------------------------------------------------------510 function layout() {511 vh = innerHeight; vw = innerWidth;512 acts.forEach(function (a) {513 if (a.pinned) a.el.style.height = (a.span * 100) + 'vh';514 });515 // The spacer is the whole document flow of a worldflight. Its height is516 // the sum of the leg weights plus one viewport: without that extra screen517 // the track runs out at the moment the last leg reaches p=1, so the last518 // leg's final second is a place the reader can never actually stop.519 // Set in pixels, not vh, because .sc-world is sized in svh on phones and a520 // vh/svh mismatch would put the track and the stage on different rulers.521 worlds.forEach(function (W) {522 if (W.spacer) W.spacer.style.height = Math.round((W.total + 1) * vh) + 'px';523 });524 acts.forEach(function (a) {525 var r = a.el.getBoundingClientRect();526 a.top = r.top + scrollY;527 a.height = r.height;528 });529 if (acts.length) {530 acts.forEach(function (a) {531 if (a.seq && a.seq.el) {532 var box = a.seq.el.getBoundingClientRect();533 var dpr = Math.min(devicePixelRatio || 1, 2);534 a.seq.el.width = Math.round(box.width * dpr);535 a.seq.el.height = Math.round(box.height * dpr);536 a.seq.drawn = -1;537 }538 });539 }540 // A pinned act whose stage is not actually sticky fails silently: the541 // stage just scrolls by, the copy cues correctly against a frame nobody542 // can see, and every automated check passes. Any author rule that sets543 // `position` on the stage causes it. Say so.544 acts.forEach(function (a) {545 if (!a.pinned || !a.stage || a.stageChecked) return;546 a.stageChecked = true;547 var pos = getComputedStyle(a.stage).position;548 if (pos !== 'sticky' && pos !== '-webkit-sticky') {549 console.warn('[scrollcraft] act "' + (a.el.id || a.device) + '" will not pin: its stage computes ' +550 'position:' + pos + ', not sticky. Something is overriding .sc-stage.', a.stage);551 }552 });553 554 worlds.forEach(function (W) {555 W.top = W.el.getBoundingClientRect().top + scrollY;556 if (W.checked) return;557 W.checked = true;558 if (!W.stage) {559 console.warn('[scrollcraft] worldflight has no [data-sc-world] stage; nothing will fly.', W.el);560 return;561 }562 if (!W.spacer) {563 console.warn('[scrollcraft] worldflight has no [data-sc-spacer]; the page has no scroll track.', W.el);564 }565 // The same silent failure as an unpinned act, one level worse: a stage566 // that is not fixed scrolls away and takes the whole page with it, while567 // every progress number still reads correctly.568 var wp = getComputedStyle(W.stage).position;569 if (wp !== 'fixed') {570 console.warn('[scrollcraft] worldflight stage computes position:' + wp + ', not fixed. ' +571 'Something is overriding .sc-world, and the flight will scroll off screen.', W.stage);572 }573 });574 575 needsLayout = false;576 read();577 }578 579 // ---- video ------------------------------------------------------------580 function loadVideo(a) { loadClip(a.video); }581 function loadClip(V) {582 // Under reduced motion the clip is never fetched. The poster holds the583 // frame and the copy still cues, so the page reads without the decode.584 if (reduce || !V || V.loading) return;585 var src = V.el.getAttribute('data-sc-src') ||586 (isMobile() && V.el.getAttribute('data-sc-src-mobile')) ||587 V.el.currentSrc || V.el.src;588 if (isMobile() && V.el.getAttribute('data-sc-src-mobile')) src = V.el.getAttribute('data-sc-src-mobile');589 if (!src) return;590 V.loading = true;591 fetch(src).then(function (r) { if (!r.ok) throw new Error(r.status); return r.blob(); })592 .then(function (blob) {593 // Listeners and preload BEFORE src. Assigning src starts the load, so594 // attaching afterwards and then calling load() restarts it and aborts595 // the first request (visible as ERR_ABORTED on the blob URL).596 V.el.addEventListener('loadedmetadata', function () {597 V.ready = true;598 // Force one seek even when the target is already 0. The reveal is599 // gated on a 'seeked' event, and the raf loop only seeks when the600 // time actually needs to change, so a clip sitting at the very top601 // of its act would never seek, never fire 'seeked', and never602 // replace its poster until the reader scrolled it off zero.603 try { V.el.currentTime = Math.max(V.target * (V.el.duration || 1), 0.001); } catch (e) {}604 read();605 // Prime the decoder the moment the clip can be primed, without606 // waiting for a gesture. A muted inline play() is allowed with no607 // user activation on every platform except a restricted one (Low608 // Power Mode, data saver), and there the rejection is harmless: the609 // gesture listeners below retry on the next real touch.610 primeClip(V);611 });612 // Reveal only once a real frame has painted. iOS keeps a seeked-but-613 // never-played muted video blank, so hiding the poster on metadata614 // alone flashes an empty stage.615 var reveal = function () {616 if (V.painted) return;617 V.painted = true;618 V.host.classList.add('sc-has-clip');619 V.el.classList.add('sc-has-clip');620 };621 V.el.addEventListener('seeked', reveal, { once: true });622 // Never let visibility depend only on an event that may not arrive.623 // On iOS a clip that has not been played can accept a seek and never624 // fire 'seeked', which leaves the element at opacity 0 for the life of625 // the page: the poster stays up, the act looks frozen, and every later626 // act is fine because by then the reader has touched the screen and627 // the decoder is live. Reveal on a timer as well. A stage that is628 // briefly blank is a smaller fault than one that never shows its clip.629 setTimeout(reveal, 2500);630 V.el.preload = 'auto';631 V.el.muted = true; // as a property, not only an attribute632 V.el.playsInline = true;633 V.el.src = URL.createObjectURL(blob);634 })635 .catch(function () { V.loading = false; });636 }637 638 // ---- image sequence ---------------------------------------------------639 function loadSeq(a) {640 var S = a.seq;641 if (!S || S.frames.length) return;642 for (var i = 0; i < S.count; i++) {643 (function (i) {644 var img = new Image();645 img.decoding = 'async';646 img.src = S.tpl.replace('{i}', String(S.start + i))647 .replace('{ii}', String(S.start + i).padStart(2, '0'))648 .replace('{iii}', String(S.start + i).padStart(3, '0'))649 .replace('{iiii}', String(S.start + i).padStart(4, '0'));650 img.onload = function () { S.loaded++; if (S.loaded === 1) S.drawn = -1; };651 S.frames[i] = img;652 })(i);653 }654 }655 function drawSeq(a) {656 var S = a.seq;657 if (!S || !S.frames.length) return;658 var idx = clamp(Math.round(a.p * (S.count - 1)), 0, S.count - 1);659 if (idx === S.drawn) return;660 var img = S.frames[idx];661 if (!img || !img.complete || !img.naturalWidth) return;662 var cw = S.el.width, ch = S.el.height;663 // cover fit664 var scale = Math.max(cw / img.naturalWidth, ch / img.naturalHeight);665 var w = img.naturalWidth * scale, h = img.naturalHeight * scale;666 S.ctx.drawImage(img, (cw - w) / 2, (ch - h) / 2, w, h);667 S.drawn = idx;668 }669 670 // ---- worldflight ------------------------------------------------------671 // t is the position along the flight, measured in viewport-heights of672 // scroll, so every number here is in the same unit the author wrote the673 // weights in. There is no per-segment geometry to read: the stage never674 // moves, so nothing needs measuring on scroll.675 function readWorld(W) {676 if (!W.segs.length) return;677 var S = W.seam;678 var t = clamp((y - W.top) / Math.max(vh, 1), 0, W.total);679 var pr = t / W.total;680 var i, s;681 682 // The current leg is the last one whose crossfade has begun.683 var k = 0;684 for (i = 0; i < W.segs.length; i++) if (t >= W.segs[i].c0 - S / 2) k = i;685 686 for (i = 0; i < W.segs.length; i++) {687 s = W.segs[i];688 var local = clamp01((t - s.c0) / Math.max(s.w, 0.001));689 s.local = local;690 691 // Fetch a leg only while it is within reach. Loading the whole flight up692 // front is tens of megabytes before the first frame paints; loading it693 // on arrival means arriving at a poster.694 if (s.clip && t > s.c0 - 1.6 && t < s.c1 + 1.6) loadClip(s.clip);695 696 // Opacity. The incoming leg fades UP over the outgoing one, which holds697 // at full strength underneath until it is completely covered. Fading698 // both halves of a crossfade is what puts the page ground through the699 // middle of the seam and reads as a flash.700 var op;701 if (i > k) op = 0;702 else if (i === k) op = i === 0 ? 1 : smooth((t - (s.c0 - S / 2)) / S);703 else op = t < s.c1 + S / 2 ? 1 : 0;704 705 var z = i === k ? 120 : Math.round(100 + op * 10);706 if (op !== s.op) {707 s.el.style.opacity = op.toFixed(3);708 // A leg at zero must stop compositing entirely. Six full-bleed video709 // layers all painting at opacity 0 costs the same as painting them.710 s.el.style.visibility = op > 0.002 ? 'visible' : 'hidden';711 s.op = op;712 }713 if (z !== s.z) { s.el.style.zIndex = String(z); s.z = z; }714 715 if (s.clip) {716 s.clip.live = op > 0.002;717 s.clip.target = lingerEase(local, s.linger);718 }719 // Until a real frame has painted, the poster carries the move. A still720 // that sits perfectly still while the page scrolls announces itself as a721 // placeholder; a slow push in reads as the camera already flying.722 if (s.poster && !reduce && !(s.clip && s.clip.painted) && op > 0.002) {723 s.poster.style.transform = 'scale(' + (1.03 + local * 0.14).toFixed(4) + ')';724 }725 }726 727 for (var c = 0; c < W.copies.length; c++) {728 var q = W.copies[c];729 var win = Math.max(q.to - q.from, 0.001);730 var inEnd = q.from + win * q.rIn;731 var outStart = q.to - win * q.rOut;732 var vis;733 // A hero declares rIn 0, so inEnd sits exactly on `from` and the ramp734 // branch is skipped: the block is simply present from the first pixel.735 if (pr < q.from) vis = 0;736 else if (pr < inEnd) vis = smooth((pr - q.from) / Math.max(inEnd - q.from, 0.001));737 else if (pr <= outStart) vis = 1;738 else vis = smooth(1 - (pr - outStart) / Math.max(q.to - outStart, 0.001));739 vis = clamp01(vis);740 741 // The ONLY transform on the copy side, and it is capped at 4vh across742 // the whole window. Anything larger stops reading as a parallax layer743 // over a moving world and starts reading as a second page scrolling at a744 // different speed, which is the exact cheapness this mode replaces.745 var wp = clamp01((pr - q.from) / win);746 q.el.style.opacity = vis.toFixed(3);747 q.el.style.transform = reduce ? 'none'748 : 'translate3d(0,' + ((0.5 - wp) * 4).toFixed(2) + 'vh,0)';749 var on = vis > 0.5;750 if (on !== (q.state === 1)) { q.state = on ? 1 : 0; q.el.style.pointerEvents = on ? 'auto' : 'none'; }751 }752 753 // Publish the route, draw none of it. A gauge, a map, a leg counter and a754 // set of chapter dots are all the same two numbers, and a runtime that755 // ships one of them ships it to every page that uses this mode.756 var cur = W.segs[k];757 W.el.style.setProperty('--sc-seg', String(k));758 W.el.style.setProperty('--sc-segp', cur.local.toFixed(4));759 docEl.style.setProperty('--sc-seg', String(k));760 docEl.style.setProperty('--sc-segp', cur.local.toFixed(4));761 if (k !== W.index) {762 W.index = k;763 try {764 W.el.dispatchEvent(new CustomEvent('sc:waypoint', {765 bubbles: true,766 detail: { index: k, count: W.segs.length, label: cur.label, el: cur.el, progress: pr }767 }));768 } catch (e) {}769 }770 }771 772 // ---- per-frame scroll read -------------------------------------------773 function read() {774 y = scrollY || pageYOffset;775 var driftA = null, driftB = null, driftT = 0;776 var maxY = Math.max((document.documentElement.scrollHeight || 0) - vh, 1);777 778 for (var i = 0; i < acts.length; i++) {779 var a = acts[i];780 var raw;781 if (a.pinned) {782 var travel = Math.max(a.height - vh, 1);783 raw = clamp01((y - a.top) / travel);784 } else {785 raw = clamp01((y + vh - a.top) / (a.height + vh));786 }787 a.raw = raw;788 a.p = a.dwell ? dwell(raw, a.dwell) : raw;789 790 // Clip time is NOT cue time. Cues belong to the pin, so they keep using791 // `p`. The clip belongs to the stage, and the stage is on screen for one792 // viewport before the pin starts and one after it ends. Driving the clip793 // from `p` therefore parks it on frame one for the whole entry slide and794 // on its last frame for the whole exit slide, which is a still795 // photograph moving up the screen under the reader's hand.796 //797 // So map the clip across the stage's entire visible life instead. Both798 // ends are clamped to scroll that actually exists: an act at the top of799 // the document has no entry slide and must still start on frame one, and800 // an act near the bottom must still reach its last frame while the page801 // can still scroll.802 a.vp = a.p;803 if (a.pinned && !a.clipTravel) {804 var startY = a.top - Math.min(vh, a.top);805 var endY = Math.min(a.top + a.height, maxY);806 var vraw = clamp01((y - startY) / Math.max(endY - startY, 1));807 a.vp = a.dwell ? dwell(vraw, a.dwell) : vraw;808 }809 a.live = (y > a.top - vh * 1.25) && (y < a.top + a.height + vh * 1.25);810 a.el.style.setProperty('--sc-p', a.p.toFixed(4));811 812 // Fetch earlier than we drive. A 1080p clip is megabytes, and a reader813 // who scrolls briskly will otherwise arrive at a stage that is still814 // downloading and see a poster where the camera move should be.815 var warm = (y > a.top - vh * 3) && (y < a.top + a.height + vh * 1.5);816 if (warm) {817 if (a.video) loadVideo(a);818 if (a.seq) loadSeq(a);819 }820 if (a.live && a.seq) drawSeq(a);821 if (a.video) { a.video.live = a.live; if (a.video.ready) a.video.target = a.vp; }822 823 // horizontal rail824 if (a.rail) {825 var over = a.rail.scrollWidth - vw;826 if (over > 0) {827 var extra = over * (a.railExtra || 0);828 a.rail.style.transform = 'translate3d(' + (-(over + extra) * a.p).toFixed(2) + 'px,0,0)';829 }830 }831 832 if (!a.live) {833 // An act that scrolls out of range must release its cues rather than834 // freezing them at whatever they last were. A "hold" cue left at835 // opacity 1 keeps its element hit-testable and paintable long after836 // its act is gone, which shows up as a stray headline over a later837 // section. Zero once, then leave it alone.838 if (a.parked !== true) {839 for (var z = 0; z < a.cues.length; z++) {840 var pq = a.cues[z];841 pq.el.style.opacity = '0';842 pq.el.style.pointerEvents = 'none';843 pq.state = 0;844 if (pq.units) for (var zu = 0; zu < pq.units.length; zu++) pq.units[zu].style.opacity = '0';845 }846 a.parked = true;847 }848 continue;849 }850 a.parked = false;851 852 // cues853 for (var c = 0; c < a.cues.length; c++) {854 var q = a.cues[c];855 var vis;856 if (q.to === null) {857 vis = smooth((a.p - q.from) / 0.18);858 } else {859 var win = Math.max(q.to - q.from, 0.001);860 var inEnd = q.from + win * q.rIn; // full opacity from here861 var outStart = q.to - win * q.rOut; // starts leaving here862 if (a.p < inEnd) vis = smooth((a.p - q.from) / Math.max(inEnd - q.from, 0.001));863 else if (a.p <= outStart) vis = 1; // the plateau864 else vis = smooth(1 - (a.p - outStart) / Math.max(q.to - outStart, 0.001));865 }866 vis = clamp01(vis);867 868 if (q.kinetic) {869 if (!q.units) q.units = splitText(q.el, q.kinetic);870 var n = q.units.length;871 // Stagger across the reveal window. 0.62 spread leaves the tail of872 // the window for the last unit to finish, so the line lands together873 // rather than trickling.874 for (var u = 0; u < n; u++) {875 var uStart = (u / Math.max(n, 1)) * 0.62;876 var uv = clamp01((vis - uStart) / (1 - 0.62 + 0.001));877 uv = smooth(uv);878 q.units[u].style.opacity = uv.toFixed(3);879 q.units[u].style.transform = reduce ? 'none'880 : 'translate3d(0,' + ((1 - uv) * 100).toFixed(2) + '%,0)';881 }882 q.el.style.opacity = '1';883 } else {884 q.el.style.opacity = vis.toFixed(3);885 q.el.style.transform = reduce ? 'none'886 : 'translate3d(0,' + ((1 - vis) * 2.4 * q.rise).toFixed(2) + 'vh,0)';887 }888 var on = vis > 0.5;889 if (on !== (q.state === 1)) { q.state = on ? 1 : 0; q.el.style.pointerEvents = on ? 'auto' : 'none'; }890 }891 892 // parallax893 if (!reduce) {894 for (var pz = 0; pz < a.parallax.length; pz++) {895 var pp = a.parallax[pz];896 pp.el.style.transform = 'translate3d(0,' + (pp.rate * (a.p - 0.5) * 100).toFixed(2) + 'px,0)';897 }898 }899 900 // reveals901 for (var rv = 0; rv < a.reveals.length; rv++) {902 var R = a.reveals[rv];903 var t = smooth((a.p - R.from) / Math.max(R.to - R.from, 0.001));904 var pct = ((1 - t) * 100).toFixed(2);905 R.el.style.clipPath =906 R.dir === 'down' ? 'inset(' + pct + '% 0 0 0)' :907 R.dir === 'left' ? 'inset(0 ' + pct + '% 0 0)' :908 R.dir === 'right' ? 'inset(0 0 0 ' + pct + '%)' :909 R.dir === 'iris' ? 'circle(' + (t * 78).toFixed(2) + '% at 50% 50%)' :910 'inset(0 0 ' + pct + '% 0)';911 }912 913 // counters914 for (var ct = 0; ct < a.counts.length; ct++) {915 var K = a.counts[ct];916 var kt = smooth((a.p - K.from) / Math.max(K.to - K.from, 0.001));917 var val = lerp(K.a, K.b, kt);918 var out = formatNum(val, K.tpl);919 if (out !== K.last) { K.el.textContent = out; K.last = out; }920 }921 }922 923 for (var w = 0; w < worlds.length; w++) readWorld(worlds[w]);924 925 // background drift926 for (var d = 0; d < drifts.length; d++) {927 var D = drifts[d];928 if (D.act.raw > 0 && D.act.raw < 1) {929 driftA = D; driftT = smooth(D.act.raw / 0.35);930 driftB = drifts[d - 1] || D;931 break;932 }933 if (D.act.raw >= 1) { driftA = D; driftB = D; driftT = 1; }934 }935 if (driftA) {936 docEl.style.setProperty('--sc-canvas', mixColor(driftB.rgb, driftA.rgb, driftT));937 }938 939 if (progressBar) {940 var max = Math.max(document.body.scrollHeight - vh, 1);941 progressBar.style.transform = 'scaleX(' + clamp01(y / max).toFixed(4) + ')';942 }943 }944 945 // ---- video seek loop --------------------------------------------------946 // Split from read() on purpose: seeking is asynchronous and rate-limited by947 // the decoder, while read() must stay cheap enough to run on every scroll948 // event. The lerp here is also what turns a jittery wheel into a glide.949 function tick() {950 // Deadband. A phone decoder cannot service a seek every frame, so asking951 // for one costs more than it shows; 20ms of clip is under a frame of952 // footage anyway.953 var eps = isMobile() ? 0.02 : 0.008;954 for (var i = 0; i < playheads.length; i++) {955 var V = playheads[i];956 if (!V.ready) continue;957 // Never queue a seek while the decoder is still resolving the last one.958 // On a phone a fast flick otherwise piles seeks up and freezes the clip.959 // But a seek that never completes would freeze the clip for the life of960 // the page through this very guard, so a seek stuck past 700ms is961 // re-issued rather than waited on forever.962 if (V.el.seeking) {963 var nw = performance.now();964 if (!V.stuckAt) V.stuckAt = nw;965 else if (nw - V.stuckAt > 700) {966 V.stuckAt = nw;967 try { V.el.currentTime = V.el.currentTime + 0.001; } catch (e) {}968 }969 continue;970 }971 V.stuckAt = 0;972 // An offscreen clip that has already arrived stops costing anything.973 if (!V.live && Math.abs(V.cur - V.target) < 0.002) continue;974 V.cur += (V.target - V.cur) * (reduce ? 1 : V.lerp);975 var dur = V.el.duration || 1;976 var t = clamp(V.cur, 0, 0.999) * dur;977 if (Math.abs(V.el.currentTime - t) > eps) { try { V.el.currentTime = t; } catch (e) {} }978 }979 requestAnimationFrame(tick);980 }981 982 // iOS will not paint a muted video that has never been handed a user983 // gesture, so a clip can be loaded, seeked and still show nothing.984 //985 // This used to fire once, on the first touch, across every playhead. That986 // loses a race it cannot win. The first act's clip is still being fetched987 // when the reader's first touch arrives (they touch the screen in order to988 // scroll, and the clip is megabytes) so play() is called on a video with989 // no source, does nothing, and the one shot is spent. Every later act loads990 // long after that touch, with user activation already granted, and works991 // without ever needing this. That is the exact shape of the bug: the hero992 // frozen, everything below it fine.993 //994 // So: keep listening, prime each clip once it actually has a source, and995 // stop only when they are all done. And force a seek afterwards, because a996 // primed decoder still shows the stale frame until something asks it for a997 // different time, and the deadband in tick() will not ask if the playhead is998 // already where it should be.999 var primedCount = 0;1000 function primeClip(V) {1001 // priming guards the retry: play() is a promise, and calling it again1002 // while the last one is unsettled produces "interrupted by pause" and can1003 // leave the element playing.1004 if (V.primed || V.priming || !V.el.src) return;1005 V.priming = true;1006 var done = function () {1007 V.priming = false;1008 if (!V.primed) { V.primed = true; primedCount++; }1009 try { V.el.pause(); } catch (e) {}1010 // repaint: nudge past the deadband so the next tick writes for real1011 try {1012 var dur = V.el.duration || 1;1013 V.cur = clamp(V.cur, 0, 0.999);1014 V.el.currentTime = clamp(V.cur * dur + 0.05, 0, dur * 0.999);1015 } catch (e) {}1016 };1017 var fail = function () { V.priming = false; };1018 // A play() promise is allowed to stay pending forever (iOS does this to a1019 // video it has decided not to start). If that happened here, `priming`1020 // would jam and no gesture could ever retry, so the flag is released on a1021 // timer as well. A late resolution after the release still lands in1022 // done(), which is idempotent.1023 setTimeout(function () { if (V.priming) fail(); }, 2000);1024 var pr;1025 try { pr = V.el.play(); } catch (e) { fail(); return; }1026 if (pr && pr.then) pr.then(done, fail);1027 else done();1028 }1029 function prime() {1030 for (var i = 0; i < playheads.length; i++) primeClip(playheads[i]);1031 if (playheads.length && primedCount >= playheads.length) {1032 removeEventListener('touchstart', prime);1033 removeEventListener('touchend', prime);1034 removeEventListener('pointerdown', prime);1035 removeEventListener('click', prime);1036 removeEventListener('scroll', prime);1037 }1038 }1039 // touchend and click are here because the HTML spec's list of activation-1040 // triggering events includes touchend but NOT touchstart. A restricted1041 // device (Low Power Mode) that rejects the touchstart prime for lacking1042 // activation gets a second, valid chance the moment the finger lifts.1043 addEventListener('touchstart', prime, { passive: true });1044 addEventListener('touchend', prime, { passive: true });1045 addEventListener('pointerdown', prime, { passive: true });1046 addEventListener('click', prime, { passive: true });1047 addEventListener('scroll', prime, { passive: true });1048 1049 // ---- pointer devices --------------------------------------------------1050 var tilts = [], magnets = [], spots = [];1051 function initPointer() {1052 if (reduce || !fineMQ.matches) return;1053 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-tilt]'), function (el) {1054 tilts.push({ el: el, max: parseFloat(el.getAttribute('data-sc-tilt')) || 6, x: 0, ty: 0, tx: 0, y: 0 });1055 });1056 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-magnet]'), function (el) {1057 magnets.push({ el: el, k: parseFloat(el.getAttribute('data-sc-magnet')) || 0.3, x: 0, y: 0, tx: 0, ty: 0 });1058 });1059 Array.prototype.forEach.call(root.querySelectorAll('[data-sc-spotlight]'), function (el) {1060 spots.push(el);1061 });1062 if (!tilts.length && !magnets.length && !spots.length) return;1063 1064 addEventListener('pointermove', function (e) {1065 if (e.pointerType !== 'mouse') return;1066 for (var i = 0; i < tilts.length; i++) {1067 var T = tilts[i], r = T.el.getBoundingClientRect();1068 if (r.bottom < -200 || r.top > vh + 200) { T.tx = 0; T.ty = 0; continue; }1069 var nx = (e.clientX - (r.left + r.width / 2)) / (r.width / 2);1070 var ny = (e.clientY - (r.top + r.height / 2)) / (r.height / 2);1071 var inside = Math.abs(nx) < 1.6 && Math.abs(ny) < 1.6;1072 T.tx = inside ? clamp(ny, -1, 1) * -T.max : 0;1073 T.ty = inside ? clamp(nx, -1, 1) * T.max : 0;1074 }1075 for (var m = 0; m < magnets.length; m++) {1076 var M = magnets[m], mr = M.el.getBoundingClientRect();1077 var dx = e.clientX - (mr.left + mr.width / 2);1078 var dy = e.clientY - (mr.top + mr.height / 2);1079 var near = Math.abs(dx) < mr.width && Math.abs(dy) < mr.height * 2.5;1080 M.tx = near ? dx * M.k : 0;1081 M.ty = near ? dy * M.k : 0;1082 }1083 for (var s = 0; s < spots.length; s++) {1084 var sr = spots[s].getBoundingClientRect();1085 spots[s].style.setProperty('--sc-mx', clamp01((e.clientX - sr.left) / sr.width).toFixed(3));1086 spots[s].style.setProperty('--sc-my', clamp01((e.clientY - sr.top) / sr.height).toFixed(3));1087 }1088 }, { passive: true });1089 1090 (function pointerTick() {1091 // Interpolate toward the target rather than tracking the pointer1092 // directly. Direct tracking reads as artificial because it carries no1093 // momentum; the lerp gives it weight.1094 for (var i = 0; i < tilts.length; i++) {1095 var T = tilts[i];1096 T.x += (T.tx - T.x) * 0.09; T.y += (T.ty - T.y) * 0.09;1097 if (Math.abs(T.x) > 0.001 || Math.abs(T.y) > 0.001) {1098 T.el.style.transform = 'perspective(1100px) rotateX(' + T.x.toFixed(3) + 'deg) rotateY(' + T.y.toFixed(3) + 'deg)';1099 }1100 }1101 for (var m = 0; m < magnets.length; m++) {1102 var M = magnets[m];1103 M.x += (M.tx - M.x) * 0.12; M.y += (M.ty - M.y) * 0.12;1104 M.el.style.transform = 'translate3d(' + M.x.toFixed(2) + 'px,' + M.y.toFixed(2) + 'px,0)';1105 }1106 requestAnimationFrame(pointerTick);1107 })();1108 }1109 1110 // ---- wiring -----------------------------------------------------------1111 var ticking = false;1112 addEventListener('scroll', function () {1113 if (!ticking) { ticking = true; requestAnimationFrame(function () { read(); ticking = false; }); }1114 }, { passive: true });1115 1116 // Keyboard focus on a pinned or panning act. The browser's own1117 // scroll-into-view parks the focused element barely on screen, which is1118 // exactly the scroll position at which its cue has not opened yet, so focus1119 // lands on a control nobody can see and every automated opacity check still1120 // passes. Centring the act instead is a position where the cue is lit by1121 // definition. behavior:'instant' on purpose: scrollcraft.css sets1122 // scroll-behavior:smooth, so the default animates the jump and focus sits1123 // off screen for the whole of a multi-screen glide.1124 addEventListener('focusin', function (e) {1125 var el = e.target;1126 if (!el || !el.closest) return;1127 var act = el.closest('[data-sc-act]');1128 if (!act || !root.contains(act)) return;1129 var cue = el.closest('[data-sc-cue]');1130 if (!cue) return;1131 if (parseFloat(getComputedStyle(cue).opacity || '1') > 0.85) return;1132 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });1133 });1134 1135 var lastW = innerWidth;1136 addEventListener('resize', function () {1137 // Ignore URL-bar-only height changes on phones. Relaying out on those1138 // makes the page jump under the reader's thumb for no reason.1139 if (innerWidth === lastW && isMobile()) { vh = innerHeight; return; }1140 lastW = innerWidth;1141 layout();1142 }, { passive: true });1143 1144 if (document.fonts && document.fonts.ready) {1145 // Line splitting measures line boxes, so it has to wait for the real face.1146 document.fonts.ready.then(function () {1147 acts.forEach(function (a) { a.cues.forEach(function (q) { if (q.kinetic && q.units) { q.el.__scSplit = null; q.units = null; } }); });1148 layout();1149 });1150 }1151 1152 layout();1153 initPointer();1154 requestAnimationFrame(tick);1155 document.documentElement.classList.add('sc-ready');1156 1157 var api = { layout: layout, read: read, acts: acts, worlds: worlds, clips: playheads, lerp: LERP };1158 global.ScrollCraft.instances.push(api);1159 return api;1160 }1161 1162 // The instance list is not an API for the page. It is how the verification1163 // harness reads the real playhead records: a screenshot taken mid-lerp is a1164 // frame the page never actually holds, so the harness has to be able to ask1165 // whether the playhead has arrived rather than guess with a timeout.1166 global.ScrollCraft = { mount: mount, reduce: reduce, instances: [] };1167})(window);1168