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,644 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scrollcraft verification harness: shoot the page's own scroll.
4
+ *
5
+ * Walks the page in N evenly spaced scroll positions, waits for the scrub video
6
+ * to actually settle at each one, screenshots it, and reports what the engine
7
+ * thinks is on screen. Then tiles the frames into one contact sheet, because a
8
+ * dead middle only shows up in contiguous frames: any single screenshot is a
9
+ * frame the transition may not survive.
10
+ *
11
+ * node shoot.mjs --url http://localhost:4500 --out lab/shots --steps 12
12
+ * node shoot.mjs --url ... --width 375 --height 812 --out lab/mobile
13
+ * node shoot.mjs --url ... --reduced-motion --out lab/reduced
14
+ *
15
+ * Uses the INSTALLED Chrome, not bundled Chromium: Chromium ships without the
16
+ * h264 decoder, so every scrub clip would silently fail to paint and the run
17
+ * would "pass" against posters.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { createRequire } from "node:module";
22
+
23
+ // The skill lives outside the project it is building, so resolve playwright
24
+ // from the BUILD project's node_modules (cwd), not from next to this file.
25
+ // Run `npm i playwright-core` in the build project once.
26
+ let chromium;
27
+ try {
28
+ ({ chromium } = createRequire(path.join(process.cwd(), "package.json"))("playwright-core"));
29
+ } catch {
30
+ console.error("playwright-core not found. Run this in the build project after:\n npm i playwright-core");
31
+ process.exit(1);
32
+ }
33
+
34
+ const argv = process.argv.slice(2);
35
+ const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
36
+ const has = (n) => argv.includes(n);
37
+
38
+ const URL = arg("--url", "http://localhost:4500");
39
+ const OUT = path.resolve(arg("--out", "lab/shots"));
40
+ const STEPS = parseInt(arg("--per-act", arg("--steps", "6")), 10); // samples PER ACT
41
+ const W = parseInt(arg("--width", "1440"), 10);
42
+ const H = parseInt(arg("--height", "900"), 10);
43
+ const REDUCED = has("--reduced-motion");
44
+
45
+ const CHROME = [
46
+ process.env.SCROLLCRAFT_CHROME,
47
+ // Windows
48
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
49
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
50
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
51
+ // macOS
52
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
53
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
54
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
55
+ // Linux
56
+ "/usr/bin/google-chrome",
57
+ "/usr/bin/google-chrome-stable",
58
+ "/usr/bin/chromium",
59
+ "/usr/bin/chromium-browser",
60
+ "/snap/bin/chromium",
61
+ ].find((p) => p && fs.existsSync(p));
62
+
63
+ if (!CHROME) {
64
+ console.error("No installed Chrome found. Set SCROLLCRAFT_CHROME to its path.");
65
+ process.exit(1);
66
+ }
67
+
68
+ fs.mkdirSync(OUT, { recursive: true });
69
+
70
+ const browser = await chromium.launch({ executablePath: CHROME, headless: true });
71
+ const page = await browser.newPage({
72
+ viewport: { width: W, height: H },
73
+ deviceScaleFactor: 2,
74
+ reducedMotion: REDUCED ? "reduce" : "no-preference",
75
+ });
76
+
77
+ const consoleErrors = [];
78
+ page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
79
+ page.on("pageerror", (e) => consoleErrors.push(String(e)));
80
+ const failed = [];
81
+ page.on("requestfailed", (r) => failed.push(`${r.failure()?.errorText} ${r.url()}`));
82
+
83
+ // Not networkidle: the engine keeps clips in flight as you scroll, and a
84
+ // webfont connection can stay open, so idle may never arrive. Wait for the
85
+ // engine's own ready signal and for the faces to land, since line splitting
86
+ // measures real line boxes and is wrong before the real face is applied.
87
+ await page.goto(URL, { waitUntil: "domcontentloaded" });
88
+ await page.waitForSelector("html.sc-ready", { timeout: 15000 });
89
+ await page.evaluate(() => document.fonts.ready);
90
+ await page.waitForTimeout(700);
91
+
92
+ const doc = await page.evaluate(() => {
93
+ const world = document.querySelector('[data-sc-mode="worldflight"]');
94
+ return {
95
+ height: document.body.scrollHeight,
96
+ vh: innerHeight,
97
+ acts: [...document.querySelectorAll("[data-sc-act]")].map((a) => a.dataset.scAct),
98
+ world: world
99
+ ? {
100
+ seam: parseFloat(world.dataset.scSeam) || 0.12,
101
+ segs: [...world.querySelectorAll("[data-sc-segment]")].map((s) => ({
102
+ w: parseFloat(s.dataset.scW) || 1.3,
103
+ linger: parseFloat(s.dataset.scLinger) || 0,
104
+ label: s.dataset.scWaypoint || "",
105
+ })),
106
+ }
107
+ : null,
108
+ };
109
+ });
110
+ const WORLD = doc.world;
111
+ const maxScroll = doc.height - doc.vh;
112
+
113
+ if (WORLD) {
114
+ const total = WORLD.segs.reduce((s, g) => s + g.w, 0);
115
+ console.log(`page: worldflight, ${WORLD.segs.length} legs over ${total.toFixed(2)}vh ` +
116
+ `(track ${(doc.height / doc.vh).toFixed(1)} viewport-heights), seam ${WORLD.seam}vh`);
117
+ console.log(` legs: ${WORLD.segs.map((g, i) => `${i}:${g.label || "-"}@${g.w}vh`).join(" ")}`);
118
+ } else {
119
+ console.log(`page: ${(doc.height / doc.vh).toFixed(1)} viewport-heights, acts: ${doc.acts.join(" > ")}`);
120
+ }
121
+
122
+ // Wait for the playhead to ARRIVE, not merely to stop seeking. The engine lerps
123
+ // currentTime toward a target on its own rAF loop, so after any scroll jump
124
+ // there is a stretch of ~15 frames during which every clip on the page is
125
+ // somewhere it will never be again. Screenshot in that window and the sheet is a
126
+ // set of frames the reader is never shown, the dead-scroll comparison runs on
127
+ // mid-lerp noise, and the whole run is unrepeatable.
128
+ async function settle(timeout = 4000) {
129
+ const t0 = Date.now();
130
+ let last = null;
131
+ for (;;) {
132
+ const now = await page.evaluate(() => {
133
+ const insts = (window.ScrollCraft && window.ScrollCraft.instances) || [];
134
+ const clips = [].concat(...insts.map((i) => i.clips || [])).filter((c) => c.ready);
135
+ // Pages on an older engine expose no instances; fall back to watching
136
+ // currentTime go quiet, which reaches the same state more slowly.
137
+ if (clips.length) {
138
+ const arrived = clips.every((c) => Math.abs(c.cur - c.target) < 0.002 && !c.el.seeking);
139
+ return arrived ? "arrived" : "moving";
140
+ }
141
+ return [...document.querySelectorAll("video[data-sc-scrub]")]
142
+ .map((v) => (v.seeking ? "seeking" : v.currentTime.toFixed(3))).join("|");
143
+ });
144
+ if (now === "arrived") return true;
145
+ if (now !== "moving" && now === last && !now.includes("seeking")) return true;
146
+ if (Date.now() - t0 > timeout) return false;
147
+ last = now;
148
+ await page.waitForTimeout(60);
149
+ }
150
+ }
151
+
152
+ // Sample WITHIN each act, not uniformly down the document. Uniform sampling
153
+ // distributes positions by page length, so a short act gets one sample that
154
+ // lands wherever it lands, and adding a section elsewhere silently moves every
155
+ // sample. That produces "this cue never reaches full opacity" reports that come
156
+ // and go with unrelated edits. Per-act sampling hits the same fractions of
157
+ // every act every run, so the findings mean something.
158
+ //
159
+ // A worldflight has no acts to sample within; its unit is the leg, and its
160
+ // geometry lives entirely in the weights, so positions are computed from the
161
+ // track rather than measured off the DOM. Both sides of every seam are added on
162
+ // top: the crossfade is the frame this mode is judged on, and it occupies about
163
+ // a tenth of a viewport, so uniform sampling steps straight over it.
164
+ const positions = WORLD ? await page.evaluate((perSeg) => {
165
+ const root = document.querySelector('[data-sc-mode="worldflight"]');
166
+ const segs = [...root.querySelectorAll("[data-sc-segment]")];
167
+ const top = root.getBoundingClientRect().top + scrollY;
168
+ const seam = parseFloat(root.dataset.scSeam) || 0.12;
169
+ const fracs = Array.from({ length: perSeg }, (_, i) => (perSeg === 1 ? 0.5 : i / (perSeg - 1)));
170
+ const out = [];
171
+ let c = 0;
172
+ segs.forEach((s, i) => {
173
+ const w = parseFloat(s.dataset.scW) || 1.3;
174
+ fracs.forEach((f) => {
175
+ const p = 0.02 + f * 0.96;
176
+ out.push(Math.round(top + (c + w * p) * innerHeight));
177
+ });
178
+ c += w;
179
+ if (i < segs.length - 1) {
180
+ [-0.5, -0.2, 0.2, 0.5].forEach((k) => out.push(Math.round(top + (c + k * seam) * innerHeight)));
181
+ }
182
+ });
183
+ const max = document.body.scrollHeight - innerHeight;
184
+ out.push(0, max);
185
+ return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
186
+ }, Math.max(2, Math.round(STEPS))) : await page.evaluate((perAct) => {
187
+ const out = [];
188
+ const fracs = Array.from({ length: perAct }, (_, i) => (perAct === 1 ? 0.5 : i / (perAct - 1)));
189
+ document.querySelectorAll("[data-sc-act]").forEach((el) => {
190
+ const top = el.getBoundingClientRect().top + scrollY;
191
+ const h = el.offsetHeight;
192
+ const pinned = ["scrub", "pin", "pan"].includes(el.dataset.scAct);
193
+ fracs.forEach((f) => {
194
+ // Nudge off the exact endpoints: p=0 and p=1 sit on the seam between two
195
+ // acts, where which one you are "in" is ambiguous.
196
+ const p = 0.02 + f * 0.96;
197
+ out.push(Math.round(pinned ? top + (h - innerHeight) * p : top - innerHeight + (h + innerHeight) * p));
198
+ });
199
+ // A pinned stage is on screen for a viewport BEFORE its pinned travel begins
200
+ // and a viewport AFTER it ends, and the loop above samples only inside the
201
+ // travel. Those two slides are exactly where a clip mapped to pinned
202
+ // progress sits frozen on its first or last frame, so not sampling them is
203
+ // why a frozen clip could pass this harness. Sample them.
204
+ if (el.dataset.scAct === "scrub") {
205
+ // `v` is the fraction of the viewport the stage covers at that position.
206
+ // Sample the part of each slide where the stage is still MOSTLY on screen,
207
+ // because that is where a frozen frame is conspicuous, and because the
208
+ // frozen-clip check needs consecutive samples that are both well past its
209
+ // visibility gate before it will call anything.
210
+ [0.6, 0.75, 0.9].forEach((v) => {
211
+ out.push(Math.round(top - innerHeight * (1 - v))); // sliding in
212
+ out.push(Math.round(top + h - innerHeight * v)); // sliding out
213
+ });
214
+ }
215
+ });
216
+ const max = document.body.scrollHeight - innerHeight;
217
+ out.push(max);
218
+ return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
219
+ }, Math.max(2, Math.round(STEPS)));
220
+
221
+ const report = [];
222
+ for (let i = 0; i < positions.length; i++) {
223
+ const y = positions[i];
224
+ const p = maxScroll ? y / maxScroll : 0;
225
+ await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
226
+ await page.waitForTimeout(180);
227
+ const settled = await settle();
228
+
229
+ const state = await page.evaluate(() => {
230
+ // A kinetic heading carries its real opacity on the split line units; the
231
+ // engine forces the element itself to 1. Reading the element therefore
232
+ // reports every kinetic headline as fully present, including on frames
233
+ // where every one of its lines is at 0. Take the strongest line instead:
234
+ // the heading is "peaked" when at least one unit has arrived.
235
+ const cueOpacity = (el) => {
236
+ const o = parseFloat(getComputedStyle(el).opacity) || 0;
237
+ const units = el.querySelectorAll(".sc-split__i");
238
+ if (!units.length) return o;
239
+ let m = 0;
240
+ units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
241
+ return o * m;
242
+ };
243
+ const vis = [];
244
+ // A worldflight's copy blocks are windowed against the whole track rather
245
+ // than an act's progress, but they are the same thing to a reader: type that
246
+ // has to arrive, hold, and leave. Grade them identically.
247
+ document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
248
+ const o = cueOpacity(el);
249
+ if (o <= 0.02) return;
250
+ // On screen, not merely non-transparent. An element parked off-viewport
251
+ // at opacity 1 is not a visible cue, and counting it produces phantom
252
+ // findings that send you chasing a bug the reader never sees.
253
+ const r = el.getBoundingClientRect();
254
+ if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) return;
255
+ vis.push({ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 46), o: +o.toFixed(2) });
256
+ });
257
+ const clips = [...document.querySelectorAll("video[data-sc-scrub]")].map((v) => ({
258
+ // A continuous world legitimately keeps its clip chain outside the act
259
+ // stack, driven by the page's own scroll value rather than by an act's
260
+ // progress. Falling back to the clip's own class keeps that case
261
+ // reporting instead of taking the whole run down before it writes
262
+ // anything.
263
+ painted: (v.closest("[data-sc-act]") ?? v).classList.contains("sc-has-clip"),
264
+ t: +(v.currentTime || 0).toFixed(2),
265
+ dur: +(v.duration || 0).toFixed(2),
266
+ // How much of the viewport this clip's stage actually covers. A frozen
267
+ // playhead only matters while the reader can see the stage.
268
+ vis: (() => {
269
+ const st = v.closest("[data-sc-stage]") || v.parentElement;
270
+ if (!st) return 0;
271
+ const b = st.getBoundingClientRect();
272
+ return +(Math.max(0, Math.min(b.bottom, innerHeight) - Math.max(b.top, 0)) / innerHeight).toFixed(3);
273
+ })(),
274
+ }));
275
+ // Rails and wipes move without changing any cue or clip time, so without
276
+ // these a panning section reads as dead scroll.
277
+ const rails = [...document.querySelectorAll("[data-sc-pan]")]
278
+ .map((r) => Math.round(new DOMMatrixReadOnly(getComputedStyle(r).transform).m41));
279
+ const wipes = [...document.querySelectorAll("[data-sc-reveal]")]
280
+ .map((r) => getComputedStyle(r).clipPath);
281
+ // Which act owns the middle of the viewport right now.
282
+ let act = "-";
283
+ document.querySelectorAll("[data-sc-act]").forEach((a) => {
284
+ const r = a.getBoundingClientRect();
285
+ if (r.top <= innerHeight / 2 && r.bottom >= innerHeight / 2) act = a.dataset.scAct;
286
+ });
287
+ // Where each pinned stage physically sits. Before an act reaches its pin
288
+ // point the stage slides up the screen while its progress is still clamped
289
+ // to 0, so the clip and cues are frozen and yet the view is very much
290
+ // moving. Without this the run-up to every pinned act reads as dead scroll.
291
+ const stages = [...document.querySelectorAll("[data-sc-stage]")]
292
+ .map((s) => Math.round(s.getBoundingClientRect().top));
293
+ // Worldflight legs. Opacity IS the crossfade, so it is state, not styling:
294
+ // two samples with the same clip times but different leg opacities are a
295
+ // dissolve in progress, not dead scroll.
296
+ const segs = [...document.querySelectorAll("[data-sc-segment]")].map((el, i) => {
297
+ const v = el.querySelector("video");
298
+ return {
299
+ i, label: el.dataset.scWaypoint || "",
300
+ op: +(parseFloat(getComputedStyle(el).opacity) || 0).toFixed(3),
301
+ painted: el.classList.contains("sc-has-clip"),
302
+ t: v ? +(v.currentTime || 0).toFixed(3) : null,
303
+ };
304
+ });
305
+ const world = document.querySelector('[data-sc-mode="worldflight"]');
306
+ // Bespoke fixed stages can use flow markers for document travel while all
307
+ // visible motion happens outside the engine's pin/scrub devices. Those
308
+ // pages publish a compact representation of their actual visual state so
309
+ // dead-scroll verification does not silently skip the whole experience.
310
+ const customEls = [...document.querySelectorAll("[data-sc-verify-state]")];
311
+ const custom = customEls.map((el) => el.getAttribute("data-sc-verify-state") || "");
312
+ const customHold = customEls.some((el) => el.getAttribute("data-sc-verify-hold") === "true");
313
+ return {
314
+ cues: vis, clips, rails, wipes, act, stages, segs, custom, customHold,
315
+ seg: world ? +(world.style.getPropertyValue("--sc-seg") || -1) : null,
316
+ segp: world ? +(world.style.getPropertyValue("--sc-segp") || 0) : null,
317
+ bg: getComputedStyle(document.documentElement).getPropertyValue("--sc-canvas").trim(),
318
+ };
319
+ });
320
+
321
+ // Flat NN.png so ffmpeg can read the set as a numbered sequence for the
322
+ // contact sheet. The scroll offset lives in report.json, not the filename.
323
+ const name = `${String(i).padStart(2, "0")}.png`;
324
+ await page.screenshot({ path: path.join(OUT, name) });
325
+
326
+ // Contrast, measured on the COMPOSITED page rather than on the source media.
327
+ // Sampling the video directly ignores every scrim, gradient and blend on top
328
+ // of it, so a page can read as failing while looking fine, or the reverse.
329
+ // Hide the text, shoot the same frame, hand the pixels back to the page, and
330
+ // sample the real background under each line. Text over a scrubbing clip is
331
+ // the one contrast case a static audit cannot cover: the frame beneath a
332
+ // headline changes as you scroll, so it can pass on the poster and fail three
333
+ // hundred pixels later. The direction is picked per line: light type fails on
334
+ // the brightest patch, dark type on the darkest one.
335
+ //
336
+ // Fixed chrome is hidden along with the text. A fixed bar paints in FRONT of
337
+ // whatever scrolls under it, so its own mark is not the background behind a
338
+ // headline passing beneath it, and leaving it in reports a spurious failure
339
+ // on an act that is fine.
340
+ await page.evaluate(() => {
341
+ document.querySelectorAll("body *").forEach((el) => {
342
+ if (getComputedStyle(el).position !== "fixed") return;
343
+ // A worldflight's stage and copy layer are fixed too, and they are the
344
+ // exact opposite case: the stage IS the background behind every line, and
345
+ // the copy layer carries the scrim that makes the line legible. Hiding
346
+ // them samples the page ground instead of the film and reports the whole
347
+ // page as failing while it looks fine.
348
+ if (el.closest("[data-sc-world],[data-sc-world-copy]")) return;
349
+ el.setAttribute("data-sc-shot-fixed", "");
350
+ });
351
+ });
352
+ await page.addStyleTag({
353
+ content: "[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *," +
354
+ "[data-sc-shot-fixed]{visibility:hidden!important}",
355
+ });
356
+ const bare = (await page.screenshot({ type: "jpeg", quality: 80 })).toString("base64");
357
+ const contrast = await page.evaluate(async ({ b64, dpr }) => {
358
+ const img = new Image();
359
+ img.src = "data:image/jpeg;base64," + b64;
360
+ await img.decode();
361
+ const c = document.createElement("canvas");
362
+ const g = c.getContext("2d", { willReadFrequently: true });
363
+ const lum = (r, gr, b) => {
364
+ const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
365
+ return 0.2126 * f(r) + 0.7152 * f(gr) + 0.0722 * f(b);
366
+ };
367
+ const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
368
+ const cueOpacity = (el) => {
369
+ const o = parseFloat(getComputedStyle(el).opacity) || 0;
370
+ const units = el.querySelectorAll(".sc-split__i");
371
+ if (!units.length) return o;
372
+ let m = 0;
373
+ units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
374
+ return o * m;
375
+ };
376
+ const out = [];
377
+ document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
378
+ if (cueOpacity(el) < 0.85) return;
379
+ if (!(el.textContent || "").trim()) return;
380
+ const r = el.getBoundingClientRect();
381
+ if (r.width < 8 || r.height < 8 || r.bottom < 0 || r.top > innerHeight) return;
382
+ // Clamp the sampled rect to the viewport. The part of a pinned act's copy
383
+ // that has scrolled above the fold is not on screen, so whatever sits in
384
+ // those pixels is not the background behind anything the reader can see.
385
+ const vl = Math.max(0, r.left), vt = Math.max(0, r.top);
386
+ const vr = Math.min(innerWidth, r.right), vb = Math.min(innerHeight, r.bottom);
387
+ if (vr - vl < 8 || vb - vt < 8) return;
388
+ const x = vl * dpr, y2 = vt * dpr;
389
+ const w = Math.min((vr - vl) * dpr, img.width - x), h = Math.min((vb - vt) * dpr, img.height - y2);
390
+ if (w < 2 || h < 2) return;
391
+ c.width = 32; c.height = 16;
392
+ g.drawImage(img, x, y2, w, h, 0, 0, 32, 16);
393
+ const d = g.getImageData(0, 0, 32, 16).data;
394
+ let maxL = 0, minL = 1, sum = 0, n = 0;
395
+ for (let k = 0; k < d.length; k += 4) {
396
+ const L = lum(d[k], d[k + 1], d[k + 2]);
397
+ if (L > maxL) maxL = L;
398
+ if (L < minL) minL = L;
399
+ sum += L; n++;
400
+ }
401
+ const cs = getComputedStyle(el);
402
+ const fg = cs.color.match(/[\d.]+/g).map(Number);
403
+ const fl = lum(fg[0], fg[1], fg[2]);
404
+ // An element that paints its own opaque background (a button, a chip) is
405
+ // an ordinary static contrast case: grade its text against that fill, not
406
+ // against whatever the page happens to show behind it. Hiding the element
407
+ // to sample the backdrop necessarily hides its background too, so without
408
+ // this every solid CTA reports a spurious failure.
409
+ const bg = (cs.backgroundColor.match(/[\d.]+/g) || []).map(Number);
410
+ const opaqueBg = bg.length >= 3 && (bg.length < 4 || bg[3] > 0.5);
411
+ if (opaqueBg) {
412
+ const bl = lum(bg[0], bg[1], bg[2]);
413
+ out.push({
414
+ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
415
+ dir: "own-fill",
416
+ worst: +ratio(fl, bl).toFixed(2), mean: +ratio(fl, bl).toFixed(2),
417
+ });
418
+ return;
419
+ }
420
+ // Pick the direction from the foreground. Light type on a dark page fails
421
+ // on the brightest patch under it; dark type on a light page (a high-key
422
+ // world, ink over media) fails on the DARKEST patch, and grading that
423
+ // against maxL is the most lenient reading available, so a page can report
424
+ // clean over text that is failing. Compare the ink to the mean background
425
+ // and grade against whichever extreme is on the ink's own side.
426
+ const meanL = sum / n;
427
+ const dark = fl < meanL;
428
+ out.push({
429
+ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
430
+ dir: dark ? "dark-on-light" : "light-on-dark",
431
+ worst: +ratio(fl, dark ? minL : maxL).toFixed(2),
432
+ mean: +ratio(fl, meanL).toFixed(2),
433
+ });
434
+ });
435
+ return out;
436
+ }, { b64: bare, dpr: 2 });
437
+ await page.evaluate(() => {
438
+ const t = [...document.querySelectorAll("style")].pop();
439
+ if (t && t.textContent.includes("data-sc-cue")) t.remove();
440
+ document.querySelectorAll("[data-sc-shot-fixed]").forEach((el) => el.removeAttribute("data-sc-shot-fixed"));
441
+ });
442
+
443
+ report.push({ i, y, pct: +(p * 100).toFixed(0), settled, contrast, ...state });
444
+ if (WORLD) {
445
+ console.log(` ${name} settled=${settled} copy=${state.cues.length} leg=${state.seg}@${state.segp} ` +
446
+ `legs=${state.segs.map((g) => `${g.op > 0.002 ? (g.painted ? g.t : "poster") : "-"}${g.op > 0.002 && g.op < 0.998 ? "*" + g.op : ""}`).join(",")}`);
447
+ } else {
448
+ console.log(` ${name} settled=${settled} cues=${state.cues.length} clips=${state.clips.map((c) => (c.painted ? c.t : "poster")).join(",")}`);
449
+ }
450
+ }
451
+
452
+ fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify({ doc, report, consoleErrors, failed }, null, 2));
453
+
454
+ if (consoleErrors.length) console.log("\nCONSOLE ERRORS:\n " + consoleErrors.join("\n "));
455
+ if (failed.length) console.log("\nFAILED REQUESTS:\n " + failed.join("\n "));
456
+
457
+ // Dead-scroll detector: consecutive positions where nothing visibly changed.
458
+ // This is the failure the eye misses and the reason to shoot contiguously.
459
+ // Opacity counts as change: a cue mid-fade is motion, and comparing only which
460
+ // cues exist would call a crossfade dead.
461
+ const sig = (s) => JSON.stringify([
462
+ s.cues.map((c) => c.t + ":" + c.o),
463
+ s.clips.map((c) => c.t),
464
+ s.rails,
465
+ s.wipes,
466
+ s.stages,
467
+ s.custom || [],
468
+ ]);
469
+ // Only inside pinned acts. A flow section or a footer that holds still across
470
+ // two sample positions is a page behaving correctly, not dead scroll, and
471
+ // flagging it trains you to ignore the signal.
472
+ const PINNED = new Set(["scrub", "pin", "pan"]);
473
+ const dead = [];
474
+ if (WORLD) {
475
+ // Every pixel of a worldflight track is pinned by construction, so there is
476
+ // no "correctly still" region to exclude and the whole page is fair game.
477
+ // Three independent things can carry the motion: the film advancing, a leg
478
+ // dissolving into the next, and a copy window opening or closing. Dead scroll
479
+ // is all three holding at once.
480
+ const wsig = (s) => JSON.stringify([
481
+ s.segs.map((g) => g.t),
482
+ s.segs.map((g) => g.op),
483
+ s.cues.map((c) => c.t + ":" + c.o),
484
+ ]);
485
+ // Not under reduced motion. There the film is deliberately never fetched, so
486
+ // the middle of a leg holds a single still frame and every pair of samples in
487
+ // it is identical BY DESIGN. Flagging that reports the accessibility path as
488
+ // broken every single run, which is how a real finding gets ignored. What
489
+ // matters here is whether the story still reads, and the copy-window and
490
+ // contrast passes below answer that.
491
+ for (let i = 1; !REDUCED && i < report.length; i++) {
492
+ const a = report[i - 1], b = report[i];
493
+ // Tighter than the act gate: a leg is about one viewport of scroll, so a
494
+ // quarter-viewport window would only ever compare four points per leg.
495
+ if (b.y - a.y < doc.vh * 0.12) continue;
496
+ if (wsig(a) === wsig(b)) {
497
+ dead.push(`${a.pct}% -> ${b.pct}% (leg ${a.seg} > ${b.seg})`);
498
+ }
499
+ }
500
+ } else {
501
+ for (let i = 1; i < report.length; i++) {
502
+ const a = report[i - 1], b = report[i];
503
+ const hasCustomState = (a.custom?.length || 0) > 0 || (b.custom?.length || 0) > 0;
504
+ if (!PINNED.has(a.act) && !PINNED.has(b.act) && !hasCustomState) continue;
505
+ // A page may explicitly declare an authored hold, such as a resolved close
506
+ // or the stable accessibility frame under reduced motion. It has to be
507
+ // declared by the visible stage; ordinary flow content stays exempt as it
508
+ // was before this custom-state path existed.
509
+ if (a.customHold && b.customHold) continue;
510
+ // Two samples a few dozen pixels apart SHOULD look the same. Only flag a gap
511
+ // wide enough that a reader would notice nothing happening in it.
512
+ if (b.y - a.y < doc.vh * 0.25) continue;
513
+ if (sig(a) === sig(b)) dead.push(`${a.pct}% -> ${b.pct}% (${a.act} > ${b.act})`);
514
+ }
515
+ }
516
+ console.log(dead.length ? `\nDEAD SCROLL between: ${dead.join(", ")}`
517
+ : WORLD && REDUCED ? "\ndead-scroll check skipped: reduced motion holds each leg on one still frame by design"
518
+ : "\nno dead scroll detected");
519
+
520
+ // FROZEN CLIP. The reader is scrolling, a scrub stage is on screen, and its
521
+ // playhead is not moving: a still photograph sliding up the page. Dead scroll
522
+ // cannot see this, because the stage IS moving, which is the whole problem.
523
+ //
524
+ // A hold on the first or last frame is always a defect. A hold in the middle
525
+ // can be an intentional `dwell` settle, so it only counts once it outlasts one.
526
+ // Skipped under reduced motion, where no clip is ever fetched on purpose.
527
+ if (!REDUCED) {
528
+ const nClips = report[0]?.clips?.length || 0;
529
+ const VIS = 0.55, EPS = 0.012, MIN = doc.vh * 0.15;
530
+ const frozen = [];
531
+ for (let c = 0; c < nClips; c++) {
532
+ let run = null;
533
+ const flush = () => {
534
+ if (!run) return;
535
+ const kind = run.t < 0.05 ? "entry" : (run.dur && run.t > run.dur - 0.08 ? "exit" : "mid");
536
+ const need = kind === "mid" ? doc.vh * 0.5 : MIN;
537
+ if (run.to - run.from >= need) frozen.push({ c, kind, ...run });
538
+ run = null;
539
+ };
540
+ for (let i = 1; i < report.length; i++) {
541
+ const a = report[i - 1].clips?.[c], b = report[i].clips?.[c];
542
+ if (!a || !b) { flush(); continue; }
543
+ const seen = a.vis >= VIS && b.vis >= VIS;
544
+ const stuck = Math.abs(b.t - a.t) < EPS;
545
+ if (seen && stuck && b.painted) {
546
+ if (!run) run = { from: report[i - 1].y, to: report[i].y, t: b.t, dur: b.dur };
547
+ else run.to = report[i].y;
548
+ } else flush();
549
+ }
550
+ flush();
551
+ }
552
+ if (frozen.length) {
553
+ console.log("\nFROZEN CLIP (still image while the page moves):\n " + frozen.map((f) => {
554
+ const px = f.to - f.from;
555
+ const where = f.kind === "entry" ? "held on its FIRST frame while the stage slides in"
556
+ : f.kind === "exit" ? "held on its LAST frame while the stage slides out"
557
+ : `held mid-clip at ${f.t.toFixed(2)}s, longer than a dwell settle`;
558
+ return `clip ${f.c}: ${px}px (${(px / doc.vh).toFixed(2)} viewports) ${where}`;
559
+ }).join("\n ") + "\n Fix: let the clip map across the stage's whole visible life. That is the\n engine default; data-sc-clip-map=\"travel\" turns it off. See devices.md.");
560
+ } else if (nClips) {
561
+ console.log(`all ${nClips} scrub clip(s) keep moving whenever they are on screen`);
562
+ }
563
+ }
564
+
565
+ // Worldflight findings. A leg that never reaches full opacity is a weight or a
566
+ // seam that is wrong: the reader is shown a permanent dissolve between two
567
+ // clips and never the leg itself. A leg stuck on its poster is a clip that
568
+ // never loaded or never decoded, and it passes every other check on this page
569
+ // because a poster looks exactly like a paused film.
570
+ if (WORLD) {
571
+ const segPeak = {};
572
+ report.forEach((s) => (s.segs || []).forEach((g) => {
573
+ const k = `${g.i}${g.label ? ' "' + g.label + '"' : ""}`;
574
+ segPeak[k] = segPeak[k] || { op: 0, painted: false, hasClip: g.t !== null };
575
+ segPeak[k].op = Math.max(segPeak[k].op, g.op);
576
+ segPeak[k].painted = segPeak[k].painted || g.painted;
577
+ }));
578
+ const faint = Object.entries(segPeak).filter(([, v]) => v.op < 0.99);
579
+ // Under reduced motion no clip is ever fetched, on purpose. Every leg is
580
+ // legitimately on its poster, and reporting that as a fault buries the one
581
+ // finding this pass exists for: whether the story still reads without motion.
582
+ const posters = REDUCED ? [] : Object.entries(segPeak).filter(([, v]) => v.hasClip && !v.painted);
583
+ if (faint.length) console.log("\nLEGS THAT NEVER REACH FULL OPACITY:\n " +
584
+ faint.map(([k, v]) => `${v.op.toFixed(2)} leg ${k}`).join("\n "));
585
+ if (posters.length) console.log("\nLEGS STUCK ON POSTER (clip never painted):\n " +
586
+ posters.map(([k]) => `leg ${k}`).join("\n "));
587
+ if (!faint.length && !posters.length)
588
+ console.log(`all ${Object.keys(segPeak).length} legs reach full opacity` +
589
+ (REDUCED ? " (posters only, as reduced motion requires)" : " and paint a real frame"));
590
+ }
591
+
592
+ // Cues that never reach full strength anywhere on the page. A headline peaking
593
+ // at 0.6 is a mis-set cue window, and it is invisible as a bug because the
594
+ // element IS there, just never quite arriving.
595
+ const peak = {};
596
+ report.forEach((s) => s.cues.forEach((c) => { peak[c.t] = Math.max(peak[c.t] || 0, c.o); }));
597
+ const weak = Object.entries(peak).filter(([, o]) => o < 0.8);
598
+ if (weak.length) console.log("\nCUES THAT NEVER PEAK:\n " + weak.map(([t, o]) => `${o} "${t}"`).join("\n "));
599
+
600
+ // Contrast over media, graded at the worst frame each line is ever shown on.
601
+ const worstBy = {};
602
+ report.forEach((s) => (s.contrast || []).forEach((c) => {
603
+ if (!worstBy[c.t] || c.worst < worstBy[c.t].worst) worstBy[c.t] = c;
604
+ }));
605
+ const fails = Object.values(worstBy).filter((c) => c.worst < 3);
606
+ const thin = Object.values(worstBy).filter((c) => c.worst >= 3 && c.worst < 4.5);
607
+ if (fails.length) console.log("\nCONTRAST FAIL (worst frame < 3:1):\n " +
608
+ fails.map((c) => `${c.worst}:1 (mean ${c.mean}) "${c.t}"`).join("\n "));
609
+ if (thin.length) console.log("\nCONTRAST THIN (3:1 to 4.5:1, ok for large display type only):\n " +
610
+ thin.map((c) => `${c.worst}:1 "${c.t}"`).join("\n "));
611
+ if (!fails.length && !thin.length && Object.keys(worstBy).length)
612
+ console.log("\ncontrast over media: all cues clear 4.5:1 at their worst frame");
613
+
614
+ await browser.close();
615
+
616
+ // Contact sheet. The point of shooting contiguously is to look at the frames
617
+ // side by side; a folder of 20 PNGs does not get looked at that way.
618
+ const FFMPEG = [
619
+ process.env.SCROLLCRAFT_FFMPEG,
620
+ ...(fs.existsSync(path.join(process.env.HOME || "", "AppData/Local/Microsoft/WinGet/Packages"))
621
+ ? fs.readdirSync(path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages"))
622
+ .filter((d) => d.startsWith("Gyan.FFmpeg"))
623
+ .flatMap((d) => {
624
+ const base = path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages", d);
625
+ return fs.readdirSync(base).map((v) => path.join(base, v, "bin/ffmpeg.exe"));
626
+ })
627
+ : []),
628
+ "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", "ffmpeg",
629
+ ].find((p) => p && (p === "ffmpeg" || fs.existsSync(p)));
630
+
631
+ if (FFMPEG) {
632
+ const cols = Math.min(5, report.length);
633
+ const rows = Math.ceil(report.length / cols);
634
+ const { spawnSync } = await import("node:child_process");
635
+ const r = spawnSync(FFMPEG, [
636
+ "-y", "-v", "error", "-i", path.join(OUT, "%02d.png"),
637
+ "-vf", `scale=520:-1,tile=${cols}x${rows}`, "-frames:v", "1",
638
+ path.join(OUT, "sheet.png"),
639
+ ]);
640
+ if (r.status === 0) console.log(`contact sheet: ${path.join(OUT, "sheet.png")}`);
641
+ else console.log("contact sheet skipped (needs a full ffmpeg build; scale/tile are missing from stripped ones)");
642
+ }
643
+
644
+ console.log(`\nshots + report.json in ${OUT}`);