scripts/shoot.mjs
scripts/shoot.mjsBrowse 22 files
9,134 tokens
32,798 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**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 video6 * to actually settle at each one, screenshots it, and reports what the engine7 * thinks is on screen. Then tiles the frames into one contact sheet, because a8 * dead middle only shows up in contiguous frames: any single screenshot is a9 * frame the transition may not survive.10 *11 * node shoot.mjs --url http://localhost:4500 --out lab/shots --steps 1212 * node shoot.mjs --url ... --width 375 --height 812 --out lab/mobile13 * node shoot.mjs --url ... --reduced-motion --out lab/reduced14 *15 * Uses the INSTALLED Chrome, not bundled Chromium: Chromium ships without the16 * h264 decoder, so every scrub clip would silently fail to paint and the run17 * would "pass" against posters.18 */19import fs from "node:fs";20import path from "node:path";21import { createRequire } from "node:module";22 23// The skill lives outside the project it is building, so resolve playwright24// 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.26let chromium;27try {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 34const argv = process.argv.slice(2);35const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };36const has = (n) => argv.includes(n);37 38const URL = arg("--url", "http://localhost:4500");39const OUT = path.resolve(arg("--out", "lab/shots"));40const STEPS = parseInt(arg("--per-act", arg("--steps", "6")), 10); // samples PER ACT41const W = parseInt(arg("--width", "1440"), 10);42const H = parseInt(arg("--height", "900"), 10);43const REDUCED = has("--reduced-motion");44 45const CHROME = [46 process.env.SCROLLCRAFT_CHROME,47 // Windows48 "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 // macOS52 "/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 // Linux56 "/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 63if (!CHROME) {64 console.error("No installed Chrome found. Set SCROLLCRAFT_CHROME to its path.");65 process.exit(1);66}67 68fs.mkdirSync(OUT, { recursive: true });69 70const browser = await chromium.launch({ executablePath: CHROME, headless: true });71const page = await browser.newPage({72 viewport: { width: W, height: H },73 deviceScaleFactor: 2,74 reducedMotion: REDUCED ? "reduce" : "no-preference",75});76 77const consoleErrors = [];78page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });79page.on("pageerror", (e) => consoleErrors.push(String(e)));80const failed = [];81page.on("requestfailed", (r) => failed.push(`${r.failure()?.errorText} ${r.url()}`));82 83// Not networkidle: the engine keeps clips in flight as you scroll, and a84// webfont connection can stay open, so idle may never arrive. Wait for the85// engine's own ready signal and for the faces to land, since line splitting86// measures real line boxes and is wrong before the real face is applied.87await page.goto(URL, { waitUntil: "domcontentloaded" });88await page.waitForSelector("html.sc-ready", { timeout: 15000 });89await page.evaluate(() => document.fonts.ready);90await page.waitForTimeout(700);91 92const 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: world99 ? {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});110const WORLD = doc.world;111const maxScroll = doc.height - doc.vh;112 113if (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 lerps123// currentTime toward a target on its own rAF loop, so after any scroll jump124// there is a stretch of ~15 frames during which every clip on the page is125// somewhere it will never be again. Screenshot in that window and the sheet is a126// set of frames the reader is never shown, the dead-scroll comparison runs on127// mid-lerp noise, and the whole run is unrepeatable.128async 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 watching136 // 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 sampling153// distributes positions by page length, so a short act gets one sample that154// lands wherever it lands, and adding a section elsewhere silently moves every155// sample. That produces "this cue never reaches full opacity" reports that come156// and go with unrelated edits. Per-act sampling hits the same fractions of157// 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 its160// geometry lives entirely in the weights, so positions are computed from the161// track rather than measured off the DOM. Both sides of every seam are added on162// top: the crossfade is the frame this mode is judged on, and it occupies about163// a tenth of a viewport, so uniform sampling steps straight over it.164const 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 two195 // 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 begins200 // and a viewport AFTER it ends, and the loop above samples only inside the201 // travel. Those two slides are exactly where a clip mapped to pinned202 // progress sits frozen on its first or last frame, so not sampling them is203 // 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 the208 // frozen-clip check needs consecutive samples that are both well past its209 // 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 in212 out.push(Math.round(top + h - innerHeight * v)); // sliding out213 });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 221const report = [];222for (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; the231 // engine forces the element itself to 1. Reading the element therefore232 // reports every kinetic headline as fully present, including on frames233 // 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 rather245 // than an act's progress, but they are the same thing to a reader: type that246 // 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-viewport251 // at opacity 1 is not a visible cue, and counting it produces phantom252 // 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 act259 // stack, driven by the page's own scroll value rather than by an act's260 // progress. Falling back to the clip's own class keeps that case261 // reporting instead of taking the whole run down before it writes262 // 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 frozen267 // 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 without276 // 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 pin288 // point the stage slides up the screen while its progress is still clamped289 // to 0, so the clip and cues are frozen and yet the view is very much290 // 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 a295 // 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 all307 // visible motion happens outside the engine's pin/scrub devices. Those308 // pages publish a compact representation of their actual visual state so309 // 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 the322 // 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 top328 // 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, and330 // sample the real background under each line. Text over a scrubbing clip is331 // the one contrast case a static audit cannot cover: the frame beneath a332 // headline changes as you scroll, so it can pass on the poster and fail three333 // hundred pixels later. The direction is picked per line: light type fails on334 // 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 of337 // whatever scrolls under it, so its own mark is not the background behind a338 // headline passing beneath it, and leaving it in reports a spurious failure339 // 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 the344 // exact opposite case: the stage IS the background behind every line, and345 // the copy layer carries the scrim that makes the line legible. Hiding346 // them samples the page ground instead of the film and reports the whole347 // 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 copy383 // that has scrolled above the fold is not on screen, so whatever sits in384 // 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) is405 // an ordinary static contrast case: grade its text against that fill, not406 // against whatever the page happens to show behind it. Hiding the element407 // to sample the backdrop necessarily hides its background too, so without408 // 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 fails421 // on the brightest patch under it; dark type on a light page (a high-key422 // world, ink over media) fails on the DARKEST patch, and grading that423 // against maxL is the most lenient reading available, so a page can report424 // clean over text that is failing. Compare the ink to the mean background425 // 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 452fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify({ doc, report, consoleErrors, failed }, null, 2));453 454if (consoleErrors.length) console.log("\nCONSOLE ERRORS:\n " + consoleErrors.join("\n "));455if (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 which460// cues exist would call a crossfade dead.461const 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 across470// two sample positions is a page behaving correctly, not dead scroll, and471// flagging it trains you to ignore the signal.472const PINNED = new Set(["scrub", "pin", "pan"]);473const dead = [];474if (WORLD) {475 // Every pixel of a worldflight track is pinned by construction, so there is476 // 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 leg478 // dissolving into the next, and a copy window opening or closing. Dead scroll479 // 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, so486 // the middle of a leg holds a single still frame and every pair of samples in487 // it is identical BY DESIGN. Flagging that reports the accessibility path as488 // broken every single run, which is how a real finding gets ignored. What489 // matters here is whether the story still reads, and the copy-window and490 // 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 a494 // 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 close506 // or the stable accessibility frame under reduced motion. It has to be507 // declared by the visible stage; ordinary flow content stays exempt as it508 // 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 gap511 // 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}516console.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 its521// playhead is not moving: a still photograph sliding up the page. Dead scroll522// 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 middle525// 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.527if (!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 a566// seam that is wrong: the reader is shown a permanent dissolve between two567// clips and never the leg itself. A leg stuck on its poster is a clip that568// never loaded or never decoded, and it passes every other check on this page569// because a poster looks exactly like a paused film.570if (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 is580 // legitimately on its poster, and reporting that as a fault buries the one581 // 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 peaking593// at 0.6 is a mis-set cue window, and it is invisible as a bug because the594// element IS there, just never quite arriving.595const peak = {};596report.forEach((s) => s.cues.forEach((c) => { peak[c.t] = Math.max(peak[c.t] || 0, c.o); }));597const weak = Object.entries(peak).filter(([, o]) => o < 0.8);598if (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.601const worstBy = {};602report.forEach((s) => (s.contrast || []).forEach((c) => {603 if (!worstBy[c.t] || c.worst < worstBy[c.t].worst) worstBy[c.t] = c;604}));605const fails = Object.values(worstBy).filter((c) => c.worst < 3);606const thin = Object.values(worstBy).filter((c) => c.worst >= 3 && c.worst < 4.5);607if (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 "));609if (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 "));611if (!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 614await browser.close();615 616// Contact sheet. The point of shooting contiguously is to look at the frames617// side by side; a folder of 20 PNGs does not get looked at that way.618const 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 631if (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 644console.log(`\nshots + report.json in ${OUT}`);645