luckiest-co 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
  5. package/skills/luckiest-design-website/CHANGELOG.md +104 -0
  6. package/skills/luckiest-design-website/LICENSE +21 -0
  7. package/skills/luckiest-design-website/SKILL.md +372 -0
  8. package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
  9. package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
  10. package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
  11. package/skills/luckiest-design-website/evals/evals.json +33 -0
  12. package/skills/luckiest-design-website/references/assets.md +286 -0
  13. package/skills/luckiest-design-website/references/device-diag.html +214 -0
  14. package/skills/luckiest-design-website/references/devices.md +466 -0
  15. package/skills/luckiest-design-website/references/feel.md +277 -0
  16. package/skills/luckiest-design-website/references/registry-examples.md +305 -0
  17. package/skills/luckiest-design-website/references/taste.md +304 -0
  18. package/skills/luckiest-design-website/references/template.html +138 -0
  19. package/skills/luckiest-design-website/references/uniqueness.md +480 -0
  20. package/skills/luckiest-design-website/references/verify.md +381 -0
  21. package/skills/luckiest-design-website/references/worldflight.md +349 -0
  22. package/skills/luckiest-design-website/references/worlds.md +178 -0
  23. package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
  24. package/skills/luckiest-design-website/scripts/encode.sh +80 -0
  25. package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
  26. package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
  27. package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
  28. package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
  29. package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
  30. package/skills/luckiest-design-website/templates/FINGERPRINTS.md +65 -0
@@ -0,0 +1,1167 @@
1
+ /* ============================================================================
2
+ scrollcraft: a scroll-driven interaction runtime
3
+ ----------------------------------------------------------------------------
4
+ Vanilla JS. Zero dependencies. Zero DOM generation.
5
+
6
+ The engine does NOT build your page. You author real, semantic HTML and mark
7
+ it up with data-sc-* attributes; the engine reads them and drives them from a
8
+ single scroll value on a single rAF loop. That is deliberate: a runtime that
9
+ generates its own DOM from a config object makes every site it touches look
10
+ identical, which is the failure mode this replaces.
11
+
12
+ ---------------------------------------------------------------------------
13
+ ACTS: the unit of scroll time
14
+ ---------------------------------------------------------------------------
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 entire
20
+ on-screen life, not across its pinned travel, because a pinned
21
+ stage is visible for a viewport BEFORE the pin begins (sliding
22
+ up into view) and a viewport AFTER it ends (sliding off the
23
+ top). Mapped to pinned travel, the clip sits on its first
24
+ frame through the whole entry and its last frame through the
25
+ whole exit: the reader watches a still photograph while the
26
+ page moves. Pair it with data-sc-dwell, which moves quickly at
27
+ the edges and settles in the middle, so the fast motion lands
28
+ on the two slides and the settle lands inside the pin where
29
+ the copy is. data-sc-runout is accepted and ignored; it named
30
+ the old opt-in for the exit half of this.
31
+ data-sc-span viewport-heights of scroll this act owns. Pinned devices only
32
+ (scrub/pin/pan). Default 1.5. The engine sets the outer
33
+ 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 drives
42
+ ---------------------------------------------------------------------------
43
+ data-sc-scrub on <video>. p scrubs currentTime. Blob-loaded, so
44
+ it seeks without needing HTTP range support.
45
+ data-sc-sequence="a/{i}.webp:120:1"
46
+ on <canvas>. p scrubs an image sequence
47
+ (path template : frameCount : startIndex).
48
+ data-sc-pan="0.6" on a wide rail inside data-sc-act="pan". p drives
49
+ 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 and
55
+ 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 via
59
+ IntersectionObserver (cheaper, and content that
60
+ 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 colour
63
+ while the act is on screen.
64
+
65
+ ---------------------------------------------------------------------------
66
+ WORLDFLIGHT: the page mode for one continuous flight
67
+ ---------------------------------------------------------------------------
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 of
87
+ chapters and the wrong shape for one continuous camera move: the reader hits
88
+ the end of an act, the stage unsticks, a static page slides past, and the next
89
+ act starts the whole thing again. Worldflight removes the seams by removing
90
+ the blocks. There is ONE fixed stage for the entire page. The only element in
91
+ document flow is a spacer, whose height the engine sets to the sum of the
92
+ segment weights plus one viewport so the last flight can finish. Scroll drives
93
+ 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 a
99
+ 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 by
104
+ opacity over the seam band; nothing ever swaps a src, because a src swap is a
105
+ black frame and a black frame is the cut this mode exists to avoid.
106
+
107
+ ---------------------------------------------------------------------------
108
+ POINTER: interactivity that isn't scroll
109
+ ---------------------------------------------------------------------------
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 that
113
+ follows the pointer across the surface.
114
+ All three are gated to (hover: hover) and (pointer: fine) and disabled under
115
+ prefers-reduced-motion. Touch never fires them.
116
+
117
+ ---------------------------------------------------------------------------
118
+ REDUCED MOTION
119
+ ---------------------------------------------------------------------------
120
+ Fewer and gentler, not zero. Cues still fade (comprehension survives), but
121
+ translation collapses, video clips are never fetched (the poster holds), and
122
+ pointer devices are inert. A worldflight still tells its whole story: the
123
+ posters cross-dissolve through the same seams and the same copy windows.
124
+
125
+ ---------------------------------------------------------------------------
126
+ THE PLAYHEAD
127
+ ---------------------------------------------------------------------------
128
+ Every scrub clip on the page, act or worldflight, is driven by one smoothed
129
+ playhead. Scroll only ever writes a TARGET; a standalone rAF loop walks the
130
+ current time toward it at a fixed fraction per frame (data-sc-lerp, default
131
+ 0.18; 1.0 under reduced motion, which is no smoothing at all). Writes are
132
+ gated by a deadband and skipped while the decoder is still seeking. Without
133
+ the smoothing a trackpad flick reads as a stutter, because wheel events do
134
+ 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) and
152
+ // moves quicker at the edges. f(0)=0 and f(1)=1 always, so seam frames between
153
+ // 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 the
162
+ // derivative at the midpoint gets low enough that the camera visibly stops
163
+ // dead mid-leg, which reads as a stall rather than a hold. The endpoints are
164
+ // fixed by construction: f(0)=0, f(1)=1, so the first and last frames of every
165
+ // leg are still exactly the frames the seam law matched, and the chain between
166
+ // 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 whose
175
+ // clips are unusually short (where 0.18 lags behind the pointer) can tighten
176
+ // 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 clean
206
+ // edge instead of just fading. Lines are measured after layout, so this must
207
+ // 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 spans
229
+ 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 in
254
+ // the text layer, so selecting and copying the heading yields
255
+ // "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, the
295
+ // 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() walks
302
+ // 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) || LERP
309
+ };
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: null
330
+ };
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 video
339
+ var v = el.querySelector('video[data-sc-scrub]');
340
+ if (v) act.video = makeClip(v, el);
341
+
342
+ // image sequence
343
+ 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: -1
350
+ };
351
+ }
352
+
353
+ // horizontal rail
354
+ act.rail = el.querySelector('[data-sc-pan]');
355
+ if (act.rail) act.railExtra = parseFloat(act.rail.getAttribute('data-sc-pan')) || 0;
356
+
357
+ // cues
358
+ 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 a
363
+ // plateau a cue is a triangle: it touches opacity 1 for a single
364
+ // instant, so the reader has to stop on exactly the right pixel to see
365
+ // 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: -1
375
+ };
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
+ // parallax
382
+ 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
+ // reveals
387
+ 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
+ // counters
393
+ 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 target
397
+ // exactly as it should render. Without this, "0 3,500" parses the target
398
+ // as 3 (the comma stops parseFloat) and "0 3500" renders "3500", because
399
+ // formatNum only adds separators above 10,000. So any real figure between
400
+ // 1,000 and 9,999 could not be shown the way it is written everywhere
401
+ // 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: null
406
+ });
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: false
423
+ };
424
+ var seam = parseFloat(el.getAttribute('data-sc-seam'));
425
+ // A seam wider than the shortest leg would have three clips dissolving at
426
+ // 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: null
440
+ };
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. Everything
446
+ // 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 to
464
+ // fade in from nothing over an empty first screen, which is the one
465
+ // 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 is
516
+ // the sum of the leg weights plus one viewport: without that extra screen
517
+ // the track runs out at the moment the last leg reaches p=1, so the last
518
+ // 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 a
520
+ // 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: the
541
+ // stage just scrolls by, the copy cues correctly against a frame nobody
542
+ // can see, and every automated check passes. Any author rule that sets
543
+ // `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 stage
566
+ // that is not fixed scrolls away and takes the whole page with it, while
567
+ // 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 the
583
+ // 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, so
594
+ // attaching afterwards and then calling load() restarts it and aborts
595
+ // 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 is
599
+ // gated on a 'seeked' event, and the raf loop only seeks when the
600
+ // time actually needs to change, so a clip sitting at the very top
601
+ // of its act would never seek, never fire 'seeked', and never
602
+ // 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, without
606
+ // waiting for a gesture. A muted inline play() is allowed with no
607
+ // user activation on every platform except a restricted one (Low
608
+ // Power Mode, data saver), and there the rejection is harmless: the
609
+ // 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 metadata
614
+ // 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 never
624
+ // fire 'seeked', which leaves the element at opacity 0 for the life of
625
+ // the page: the poster stays up, the act looks frozen, and every later
626
+ // act is fine because by then the reader has touched the screen and
627
+ // the decoder is live. Reveal on a timer as well. A stage that is
628
+ // 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 attribute
632
+ 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 fit
664
+ 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 of
672
+ // scroll, so every number here is in the same unit the author wrote the
673
+ // weights in. There is no per-segment geometry to read: the stage never
674
+ // 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 up
692
+ // front is tens of megabytes before the first frame paints; loading it
693
+ // 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 holds
697
+ // at full strength underneath until it is completely covered. Fading
698
+ // both halves of a crossfade is what puts the page ground through the
699
+ // 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 video
709
+ // 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 still
720
+ // that sits perfectly still while the page scrolls announces itself as a
721
+ // 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 ramp
734
+ // 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 across
742
+ // the whole window. Anything larger stops reading as a parallax layer
743
+ // over a moving world and starts reading as a second page scrolling at a
744
+ // 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 a
754
+ // set of chapter dots are all the same two numbers, and a runtime that
755
+ // 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 using
791
+ // `p`. The clip belongs to the stage, and the stage is on screen for one
792
+ // viewport before the pin starts and one after it ends. Driving the clip
793
+ // from `p` therefore parks it on frame one for the whole entry slide and
794
+ // on its last frame for the whole exit slide, which is a still
795
+ // photograph moving up the screen under the reader's hand.
796
+ //
797
+ // So map the clip across the stage's entire visible life instead. Both
798
+ // ends are clamped to scroll that actually exists: an act at the top of
799
+ // the document has no entry slide and must still start on frame one, and
800
+ // an act near the bottom must still reach its last frame while the page
801
+ // 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 reader
813
+ // who scrolls briskly will otherwise arrive at a stage that is still
814
+ // 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 rail
824
+ 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 than
834
+ // freezing them at whatever they last were. A "hold" cue left at
835
+ // opacity 1 keeps its element hit-testable and paintable long after
836
+ // its act is gone, which shows up as a stray headline over a later
837
+ // 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
+ // cues
853
+ 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 here
861
+ var outStart = q.to - win * q.rOut; // starts leaving here
862
+ 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 plateau
864
+ 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 of
872
+ // the window for the last unit to finish, so the line lands together
873
+ // 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
+ // parallax
893
+ 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
+ // reveals
901
+ 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
+ // counters
914
+ 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 drift
926
+ 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 by
947
+ // the decoder, while read() must stay cheap enough to run on every scroll
948
+ // 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 asking
951
+ // for one costs more than it shows; 20ms of clip is under a frame of
952
+ // 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 of
960
+ // the page through this very guard, so a seek stuck past 700ms is
961
+ // 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 user
983
+ // 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. That
986
+ // loses a race it cannot win. The first act's clip is still being fetched
987
+ // when the reader's first touch arrives (they touch the screen in order to
988
+ // scroll, and the clip is megabytes) so play() is called on a video with
989
+ // no source, does nothing, and the one shot is spent. Every later act loads
990
+ // long after that touch, with user activation already granted, and works
991
+ // without ever needing this. That is the exact shape of the bug: the hero
992
+ // frozen, everything below it fine.
993
+ //
994
+ // So: keep listening, prime each clip once it actually has a source, and
995
+ // stop only when they are all done. And force a seek afterwards, because a
996
+ // primed decoder still shows the stale frame until something asks it for a
997
+ // different time, and the deadband in tick() will not ask if the playhead is
998
+ // 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 again
1002
+ // while the last one is unsettled produces "interrupted by pause" and can
1003
+ // 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 real
1011
+ 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 a
1019
+ // 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 a
1021
+ // timer as well. A late resolution after the release still lands in
1022
+ // 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 restricted
1041
+ // device (Low Power Mode) that rejects the touchstart prime for lacking
1042
+ // 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 pointer
1092
+ // directly. Direct tracking reads as artificial because it carries no
1093
+ // 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 own
1117
+ // scroll-into-view parks the focused element barely on screen, which is
1118
+ // exactly the scroll position at which its cue has not opened yet, so focus
1119
+ // lands on a control nobody can see and every automated opacity check still
1120
+ // passes. Centring the act instead is a position where the cue is lit by
1121
+ // definition. behavior:'instant' on purpose: scrollcraft.css sets
1122
+ // scroll-behavior:smooth, so the default animates the jump and focus sits
1123
+ // 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 those
1138
+ // 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 verification
1163
+ // harness reads the real playhead records: a screenshot taken mid-lerp is a
1164
+ // frame the page never actually holds, so the harness has to be able to ask
1165
+ // whether the playhead has arrived rather than guess with a timeout.
1166
+ global.ScrollCraft = { mount: mount, reduce: reduce, instances: [] };
1167
+ })(window);