scrollcraft

Premium scroll-driven landing pages; scroll = timeline.

  • web-development
  • landing-page
  • scrollytelling
  • animation
  • design
  • frontend

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/web-development/scrollcraft'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md
#!/usr/bin/env node/** * scrollcraft verification harness: shoot the page's own scroll. * * Walks the page in N evenly spaced scroll positions, waits for the scrub video * to actually settle at each one, screenshots it, and reports what the engine * thinks is on screen. Then tiles the frames into one contact sheet, because a * dead middle only shows up in contiguous frames: any single screenshot is a * frame the transition may not survive. * *   node shoot.mjs --url http://localhost:4500 --out lab/shots --steps 12 *   node shoot.mjs --url ... --width 375 --height 812 --out lab/mobile *   node shoot.mjs --url ... --reduced-motion --out lab/reduced * * Uses the INSTALLED Chrome, not bundled Chromium: Chromium ships without the * h264 decoder, so every scrub clip would silently fail to paint and the run * would "pass" against posters. */import fs from "node:fs";import path from "node:path";import { createRequire } from "node:module"; // The skill lives outside the project it is building, so resolve playwright// from the BUILD project's node_modules (cwd), not from next to this file.// Run `npm i playwright-core` in the build project once.let chromium;try {  ({ chromium } = createRequire(path.join(process.cwd(), "package.json"))("playwright-core"));} catch {  console.error("playwright-core not found. Run this in the build project after:\n  npm i playwright-core");  process.exit(1);} const argv = process.argv.slice(2);const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };const has = (n) => argv.includes(n); const URL = arg("--url", "http://localhost:4500");const OUT = path.resolve(arg("--out", "lab/shots"));const STEPS = parseInt(arg("--per-act", arg("--steps", "6")), 10);  // samples PER ACTconst W = parseInt(arg("--width", "1440"), 10);const H = parseInt(arg("--height", "900"), 10);const REDUCED = has("--reduced-motion"); const CHROME = [  process.env.SCROLLCRAFT_CHROME,  // Windows  "C:/Program Files/Google/Chrome/Application/chrome.exe",  "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",  "C:/Program Files/Microsoft/Edge/Application/msedge.exe",  // macOS  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",  "/Applications/Chromium.app/Contents/MacOS/Chromium",  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",  // Linux  "/usr/bin/google-chrome",  "/usr/bin/google-chrome-stable",  "/usr/bin/chromium",  "/usr/bin/chromium-browser",  "/snap/bin/chromium",].find((p) => p && fs.existsSync(p)); if (!CHROME) {  console.error("No installed Chrome found. Set SCROLLCRAFT_CHROME to its path.");  process.exit(1);} fs.mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch({ executablePath: CHROME, headless: true });const page = await browser.newPage({  viewport: { width: W, height: H },  deviceScaleFactor: 2,  reducedMotion: REDUCED ? "reduce" : "no-preference",}); const consoleErrors = [];page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });page.on("pageerror", (e) => consoleErrors.push(String(e)));const failed = [];page.on("requestfailed", (r) => failed.push(`${r.failure()?.errorText} ${r.url()}`)); // Not networkidle: the engine keeps clips in flight as you scroll, and a// webfont connection can stay open, so idle may never arrive. Wait for the// engine's own ready signal and for the faces to land, since line splitting// measures real line boxes and is wrong before the real face is applied.await page.goto(URL, { waitUntil: "domcontentloaded" });await page.waitForSelector("html.sc-ready", { timeout: 15000 });await page.evaluate(() => document.fonts.ready);await page.waitForTimeout(700); const doc = await page.evaluate(() => {  const world = document.querySelector('[data-sc-mode="worldflight"]');  return {    height: document.body.scrollHeight,    vh: innerHeight,    acts: [...document.querySelectorAll("[data-sc-act]")].map((a) => a.dataset.scAct),    world: world      ? {          seam: parseFloat(world.dataset.scSeam) || 0.12,          segs: [...world.querySelectorAll("[data-sc-segment]")].map((s) => ({            w: parseFloat(s.dataset.scW) || 1.3,            linger: parseFloat(s.dataset.scLinger) || 0,            label: s.dataset.scWaypoint || "",          })),        }      : null,  };});const WORLD = doc.world;const maxScroll = doc.height - doc.vh; if (WORLD) {  const total = WORLD.segs.reduce((s, g) => s + g.w, 0);  console.log(`page: worldflight, ${WORLD.segs.length} legs over ${total.toFixed(2)}vh ` +    `(track ${(doc.height / doc.vh).toFixed(1)} viewport-heights), seam ${WORLD.seam}vh`);  console.log(`  legs: ${WORLD.segs.map((g, i) => `${i}:${g.label || "-"}@${g.w}vh`).join("  ")}`);} else {  console.log(`page: ${(doc.height / doc.vh).toFixed(1)} viewport-heights, acts: ${doc.acts.join(" > ")}`);} // Wait for the playhead to ARRIVE, not merely to stop seeking. The engine lerps// currentTime toward a target on its own rAF loop, so after any scroll jump// there is a stretch of ~15 frames during which every clip on the page is// somewhere it will never be again. Screenshot in that window and the sheet is a// set of frames the reader is never shown, the dead-scroll comparison runs on// mid-lerp noise, and the whole run is unrepeatable.async function settle(timeout = 4000) {  const t0 = Date.now();  let last = null;  for (;;) {    const now = await page.evaluate(() => {      const insts = (window.ScrollCraft && window.ScrollCraft.instances) || [];      const clips = [].concat(...insts.map((i) => i.clips || [])).filter((c) => c.ready);      // Pages on an older engine expose no instances; fall back to watching      // currentTime go quiet, which reaches the same state more slowly.      if (clips.length) {        const arrived = clips.every((c) => Math.abs(c.cur - c.target) < 0.002 && !c.el.seeking);        return arrived ? "arrived" : "moving";      }      return [...document.querySelectorAll("video[data-sc-scrub]")]        .map((v) => (v.seeking ? "seeking" : v.currentTime.toFixed(3))).join("|");    });    if (now === "arrived") return true;    if (now !== "moving" && now === last && !now.includes("seeking")) return true;    if (Date.now() - t0 > timeout) return false;    last = now;    await page.waitForTimeout(60);  }} // Sample WITHIN each act, not uniformly down the document. Uniform sampling// distributes positions by page length, so a short act gets one sample that// lands wherever it lands, and adding a section elsewhere silently moves every// sample. That produces "this cue never reaches full opacity" reports that come// and go with unrelated edits. Per-act sampling hits the same fractions of// every act every run, so the findings mean something.//// A worldflight has no acts to sample within; its unit is the leg, and its// geometry lives entirely in the weights, so positions are computed from the// track rather than measured off the DOM. Both sides of every seam are added on// top: the crossfade is the frame this mode is judged on, and it occupies about// a tenth of a viewport, so uniform sampling steps straight over it.const positions = WORLD ? await page.evaluate((perSeg) => {  const root = document.querySelector('[data-sc-mode="worldflight"]');  const segs = [...root.querySelectorAll("[data-sc-segment]")];  const top = root.getBoundingClientRect().top + scrollY;  const seam = parseFloat(root.dataset.scSeam) || 0.12;  const fracs = Array.from({ length: perSeg }, (_, i) => (perSeg === 1 ? 0.5 : i / (perSeg - 1)));  const out = [];  let c = 0;  segs.forEach((s, i) => {    const w = parseFloat(s.dataset.scW) || 1.3;    fracs.forEach((f) => {      const p = 0.02 + f * 0.96;      out.push(Math.round(top + (c + w * p) * innerHeight));    });    c += w;    if (i < segs.length - 1) {      [-0.5, -0.2, 0.2, 0.5].forEach((k) => out.push(Math.round(top + (c + k * seam) * innerHeight)));    }  });  const max = document.body.scrollHeight - innerHeight;  out.push(0, max);  return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);}, Math.max(2, Math.round(STEPS))) : await page.evaluate((perAct) => {  const out = [];  const fracs = Array.from({ length: perAct }, (_, i) => (perAct === 1 ? 0.5 : i / (perAct - 1)));  document.querySelectorAll("[data-sc-act]").forEach((el) => {    const top = el.getBoundingClientRect().top + scrollY;    const h = el.offsetHeight;    const pinned = ["scrub", "pin", "pan"].includes(el.dataset.scAct);    fracs.forEach((f) => {      // Nudge off the exact endpoints: p=0 and p=1 sit on the seam between two      // acts, where which one you are "in" is ambiguous.      const p = 0.02 + f * 0.96;      out.push(Math.round(pinned ? top + (h - innerHeight) * p : top - innerHeight + (h + innerHeight) * p));    });    // A pinned stage is on screen for a viewport BEFORE its pinned travel begins    // and a viewport AFTER it ends, and the loop above samples only inside the    // travel. Those two slides are exactly where a clip mapped to pinned    // progress sits frozen on its first or last frame, so not sampling them is    // why a frozen clip could pass this harness. Sample them.    if (el.dataset.scAct === "scrub") {      // `v` is the fraction of the viewport the stage covers at that position.      // Sample the part of each slide where the stage is still MOSTLY on screen,      // because that is where a frozen frame is conspicuous, and because the      // frozen-clip check needs consecutive samples that are both well past its      // visibility gate before it will call anything.      [0.6, 0.75, 0.9].forEach((v) => {        out.push(Math.round(top - innerHeight * (1 - v)));  // sliding in        out.push(Math.round(top + h - innerHeight * v));    // sliding out      });    }  });  const max = document.body.scrollHeight - innerHeight;  out.push(max);  return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);}, Math.max(2, Math.round(STEPS))); const report = [];for (let i = 0; i < positions.length; i++) {  const y = positions[i];  const p = maxScroll ? y / maxScroll : 0;  await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);  await page.waitForTimeout(180);  const settled = await settle();   const state = await page.evaluate(() => {    // A kinetic heading carries its real opacity on the split line units; the    // engine forces the element itself to 1. Reading the element therefore    // reports every kinetic headline as fully present, including on frames    // where every one of its lines is at 0. Take the strongest line instead:    // the heading is "peaked" when at least one unit has arrived.    const cueOpacity = (el) => {      const o = parseFloat(getComputedStyle(el).opacity) || 0;      const units = el.querySelectorAll(".sc-split__i");      if (!units.length) return o;      let m = 0;      units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });      return o * m;    };    const vis = [];    // A worldflight's copy blocks are windowed against the whole track rather    // than an act's progress, but they are the same thing to a reader: type that    // has to arrive, hold, and leave. Grade them identically.    document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {      const o = cueOpacity(el);      if (o <= 0.02) return;      // On screen, not merely non-transparent. An element parked off-viewport      // at opacity 1 is not a visible cue, and counting it produces phantom      // findings that send you chasing a bug the reader never sees.      const r = el.getBoundingClientRect();      if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) return;      vis.push({ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 46), o: +o.toFixed(2) });    });    const clips = [...document.querySelectorAll("video[data-sc-scrub]")].map((v) => ({      // A continuous world legitimately keeps its clip chain outside the act      // stack, driven by the page's own scroll value rather than by an act's      // progress. Falling back to the clip's own class keeps that case      // reporting instead of taking the whole run down before it writes      // anything.      painted: (v.closest("[data-sc-act]") ?? v).classList.contains("sc-has-clip"),      t: +(v.currentTime || 0).toFixed(2),      dur: +(v.duration || 0).toFixed(2),      // How much of the viewport this clip's stage actually covers. A frozen      // playhead only matters while the reader can see the stage.      vis: (() => {        const st = v.closest("[data-sc-stage]") || v.parentElement;        if (!st) return 0;        const b = st.getBoundingClientRect();        return +(Math.max(0, Math.min(b.bottom, innerHeight) - Math.max(b.top, 0)) / innerHeight).toFixed(3);      })(),    }));    // Rails and wipes move without changing any cue or clip time, so without    // these a panning section reads as dead scroll.    const rails = [...document.querySelectorAll("[data-sc-pan]")]      .map((r) => Math.round(new DOMMatrixReadOnly(getComputedStyle(r).transform).m41));    const wipes = [...document.querySelectorAll("[data-sc-reveal]")]      .map((r) => getComputedStyle(r).clipPath);    // Which act owns the middle of the viewport right now.    let act = "-";    document.querySelectorAll("[data-sc-act]").forEach((a) => {      const r = a.getBoundingClientRect();      if (r.top <= innerHeight / 2 && r.bottom >= innerHeight / 2) act = a.dataset.scAct;    });    // Where each pinned stage physically sits. Before an act reaches its pin    // point the stage slides up the screen while its progress is still clamped    // to 0, so the clip and cues are frozen and yet the view is very much    // moving. Without this the run-up to every pinned act reads as dead scroll.    const stages = [...document.querySelectorAll("[data-sc-stage]")]      .map((s) => Math.round(s.getBoundingClientRect().top));    // Worldflight legs. Opacity IS the crossfade, so it is state, not styling:    // two samples with the same clip times but different leg opacities are a    // dissolve in progress, not dead scroll.    const segs = [...document.querySelectorAll("[data-sc-segment]")].map((el, i) => {      const v = el.querySelector("video");      return {        i, label: el.dataset.scWaypoint || "",        op: +(parseFloat(getComputedStyle(el).opacity) || 0).toFixed(3),        painted: el.classList.contains("sc-has-clip"),        t: v ? +(v.currentTime || 0).toFixed(3) : null,      };    });    const world = document.querySelector('[data-sc-mode="worldflight"]');    // Bespoke fixed stages can use flow markers for document travel while all    // visible motion happens outside the engine's pin/scrub devices. Those    // pages publish a compact representation of their actual visual state so    // dead-scroll verification does not silently skip the whole experience.    const customEls = [...document.querySelectorAll("[data-sc-verify-state]")];    const custom = customEls.map((el) => el.getAttribute("data-sc-verify-state") || "");    const customHold = customEls.some((el) => el.getAttribute("data-sc-verify-hold") === "true");    return {      cues: vis, clips, rails, wipes, act, stages, segs, custom, customHold,      seg: world ? +(world.style.getPropertyValue("--sc-seg") || -1) : null,      segp: world ? +(world.style.getPropertyValue("--sc-segp") || 0) : null,      bg: getComputedStyle(document.documentElement).getPropertyValue("--sc-canvas").trim(),    };  });   // Flat NN.png so ffmpeg can read the set as a numbered sequence for the  // contact sheet. The scroll offset lives in report.json, not the filename.  const name = `${String(i).padStart(2, "0")}.png`;  await page.screenshot({ path: path.join(OUT, name) });   // Contrast, measured on the COMPOSITED page rather than on the source media.  // Sampling the video directly ignores every scrim, gradient and blend on top  // of it, so a page can read as failing while looking fine, or the reverse.  // Hide the text, shoot the same frame, hand the pixels back to the page, and  // sample the real background under each line. Text over a scrubbing clip is  // the one contrast case a static audit cannot cover: the frame beneath a  // headline changes as you scroll, so it can pass on the poster and fail three  // hundred pixels later. The direction is picked per line: light type fails on  // the brightest patch, dark type on the darkest one.  //  // Fixed chrome is hidden along with the text. A fixed bar paints in FRONT of  // whatever scrolls under it, so its own mark is not the background behind a  // headline passing beneath it, and leaving it in reports a spurious failure  // on an act that is fine.  await page.evaluate(() => {    document.querySelectorAll("body *").forEach((el) => {      if (getComputedStyle(el).position !== "fixed") return;      // A worldflight's stage and copy layer are fixed too, and they are the      // exact opposite case: the stage IS the background behind every line, and      // the copy layer carries the scrim that makes the line legible. Hiding      // them samples the page ground instead of the film and reports the whole      // page as failing while it looks fine.      if (el.closest("[data-sc-world],[data-sc-world-copy]")) return;      el.setAttribute("data-sc-shot-fixed", "");    });  });  await page.addStyleTag({    content: "[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *," +             "[data-sc-shot-fixed]{visibility:hidden!important}",  });  const bare = (await page.screenshot({ type: "jpeg", quality: 80 })).toString("base64");  const contrast = await page.evaluate(async ({ b64, dpr }) => {    const img = new Image();    img.src = "data:image/jpeg;base64," + b64;    await img.decode();    const c = document.createElement("canvas");    const g = c.getContext("2d", { willReadFrequently: true });    const lum = (r, gr, b) => {      const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };      return 0.2126 * f(r) + 0.7152 * f(gr) + 0.0722 * f(b);    };    const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);    const cueOpacity = (el) => {      const o = parseFloat(getComputedStyle(el).opacity) || 0;      const units = el.querySelectorAll(".sc-split__i");      if (!units.length) return o;      let m = 0;      units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });      return o * m;    };    const out = [];    document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {      if (cueOpacity(el) < 0.85) return;      if (!(el.textContent || "").trim()) return;      const r = el.getBoundingClientRect();      if (r.width < 8 || r.height < 8 || r.bottom < 0 || r.top > innerHeight) return;      // Clamp the sampled rect to the viewport. The part of a pinned act's copy      // that has scrolled above the fold is not on screen, so whatever sits in      // those pixels is not the background behind anything the reader can see.      const vl = Math.max(0, r.left), vt = Math.max(0, r.top);      const vr = Math.min(innerWidth, r.right), vb = Math.min(innerHeight, r.bottom);      if (vr - vl < 8 || vb - vt < 8) return;      const x = vl * dpr, y2 = vt * dpr;      const w = Math.min((vr - vl) * dpr, img.width - x), h = Math.min((vb - vt) * dpr, img.height - y2);      if (w < 2 || h < 2) return;      c.width = 32; c.height = 16;      g.drawImage(img, x, y2, w, h, 0, 0, 32, 16);      const d = g.getImageData(0, 0, 32, 16).data;      let maxL = 0, minL = 1, sum = 0, n = 0;      for (let k = 0; k < d.length; k += 4) {        const L = lum(d[k], d[k + 1], d[k + 2]);        if (L > maxL) maxL = L;        if (L < minL) minL = L;        sum += L; n++;      }      const cs = getComputedStyle(el);      const fg = cs.color.match(/[\d.]+/g).map(Number);      const fl = lum(fg[0], fg[1], fg[2]);      // An element that paints its own opaque background (a button, a chip) is      // an ordinary static contrast case: grade its text against that fill, not      // against whatever the page happens to show behind it. Hiding the element      // to sample the backdrop necessarily hides its background too, so without      // this every solid CTA reports a spurious failure.      const bg = (cs.backgroundColor.match(/[\d.]+/g) || []).map(Number);      const opaqueBg = bg.length >= 3 && (bg.length < 4 || bg[3] > 0.5);      if (opaqueBg) {        const bl = lum(bg[0], bg[1], bg[2]);        out.push({          t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),          dir: "own-fill",          worst: +ratio(fl, bl).toFixed(2), mean: +ratio(fl, bl).toFixed(2),        });        return;      }      // Pick the direction from the foreground. Light type on a dark page fails      // on the brightest patch under it; dark type on a light page (a high-key      // world, ink over media) fails on the DARKEST patch, and grading that      // against maxL is the most lenient reading available, so a page can report      // clean over text that is failing. Compare the ink to the mean background      // and grade against whichever extreme is on the ink's own side.      const meanL = sum / n;      const dark = fl < meanL;      out.push({        t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),        dir: dark ? "dark-on-light" : "light-on-dark",        worst: +ratio(fl, dark ? minL : maxL).toFixed(2),        mean: +ratio(fl, meanL).toFixed(2),      });    });    return out;  }, { b64: bare, dpr: 2 });  await page.evaluate(() => {    const t = [...document.querySelectorAll("style")].pop();    if (t && t.textContent.includes("data-sc-cue")) t.remove();    document.querySelectorAll("[data-sc-shot-fixed]").forEach((el) => el.removeAttribute("data-sc-shot-fixed"));  });   report.push({ i, y, pct: +(p * 100).toFixed(0), settled, contrast, ...state });  if (WORLD) {    console.log(`  ${name}  settled=${settled}  copy=${state.cues.length}  leg=${state.seg}@${state.segp}  ` +      `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(",")}`);  } else {    console.log(`  ${name}  settled=${settled}  cues=${state.cues.length}  clips=${state.clips.map((c) => (c.painted ? c.t : "poster")).join(",")}`);  }} fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify({ doc, report, consoleErrors, failed }, null, 2)); if (consoleErrors.length) console.log("\nCONSOLE ERRORS:\n  " + consoleErrors.join("\n  "));if (failed.length) console.log("\nFAILED REQUESTS:\n  " + failed.join("\n  ")); // Dead-scroll detector: consecutive positions where nothing visibly changed.// This is the failure the eye misses and the reason to shoot contiguously.// Opacity counts as change: a cue mid-fade is motion, and comparing only which// cues exist would call a crossfade dead.const sig = (s) => JSON.stringify([  s.cues.map((c) => c.t + ":" + c.o),  s.clips.map((c) => c.t),  s.rails,  s.wipes,  s.stages,  s.custom || [],]);// Only inside pinned acts. A flow section or a footer that holds still across// two sample positions is a page behaving correctly, not dead scroll, and// flagging it trains you to ignore the signal.const PINNED = new Set(["scrub", "pin", "pan"]);const dead = [];if (WORLD) {  // Every pixel of a worldflight track is pinned by construction, so there is  // no "correctly still" region to exclude and the whole page is fair game.  // Three independent things can carry the motion: the film advancing, a leg  // dissolving into the next, and a copy window opening or closing. Dead scroll  // is all three holding at once.  const wsig = (s) => JSON.stringify([    s.segs.map((g) => g.t),    s.segs.map((g) => g.op),    s.cues.map((c) => c.t + ":" + c.o),  ]);  // Not under reduced motion. There the film is deliberately never fetched, so  // the middle of a leg holds a single still frame and every pair of samples in  // it is identical BY DESIGN. Flagging that reports the accessibility path as  // broken every single run, which is how a real finding gets ignored. What  // matters here is whether the story still reads, and the copy-window and  // contrast passes below answer that.  for (let i = 1; !REDUCED && i < report.length; i++) {    const a = report[i - 1], b = report[i];    // Tighter than the act gate: a leg is about one viewport of scroll, so a    // quarter-viewport window would only ever compare four points per leg.    if (b.y - a.y < doc.vh * 0.12) continue;    if (wsig(a) === wsig(b)) {      dead.push(`${a.pct}% -> ${b.pct}% (leg ${a.seg} > ${b.seg})`);    }  }} else {  for (let i = 1; i < report.length; i++) {    const a = report[i - 1], b = report[i];    const hasCustomState = (a.custom?.length || 0) > 0 || (b.custom?.length || 0) > 0;    if (!PINNED.has(a.act) && !PINNED.has(b.act) && !hasCustomState) continue;    // A page may explicitly declare an authored hold, such as a resolved close    // or the stable accessibility frame under reduced motion. It has to be    // declared by the visible stage; ordinary flow content stays exempt as it    // was before this custom-state path existed.    if (a.customHold && b.customHold) continue;    // Two samples a few dozen pixels apart SHOULD look the same. Only flag a gap    // wide enough that a reader would notice nothing happening in it.    if (b.y - a.y < doc.vh * 0.25) continue;    if (sig(a) === sig(b)) dead.push(`${a.pct}% -> ${b.pct}% (${a.act} > ${b.act})`);  }}console.log(dead.length ? `\nDEAD SCROLL between: ${dead.join(", ")}`  : WORLD && REDUCED ? "\ndead-scroll check skipped: reduced motion holds each leg on one still frame by design"  : "\nno dead scroll detected"); // FROZEN CLIP. The reader is scrolling, a scrub stage is on screen, and its// playhead is not moving: a still photograph sliding up the page. Dead scroll// cannot see this, because the stage IS moving, which is the whole problem.//// A hold on the first or last frame is always a defect. A hold in the middle// can be an intentional `dwell` settle, so it only counts once it outlasts one.// Skipped under reduced motion, where no clip is ever fetched on purpose.if (!REDUCED) {  const nClips = report[0]?.clips?.length || 0;  const VIS = 0.55, EPS = 0.012, MIN = doc.vh * 0.15;  const frozen = [];  for (let c = 0; c < nClips; c++) {    let run = null;    const flush = () => {      if (!run) return;      const kind = run.t < 0.05 ? "entry" : (run.dur && run.t > run.dur - 0.08 ? "exit" : "mid");      const need = kind === "mid" ? doc.vh * 0.5 : MIN;      if (run.to - run.from >= need) frozen.push({ c, kind, ...run });      run = null;    };    for (let i = 1; i < report.length; i++) {      const a = report[i - 1].clips?.[c], b = report[i].clips?.[c];      if (!a || !b) { flush(); continue; }      const seen = a.vis >= VIS && b.vis >= VIS;      const stuck = Math.abs(b.t - a.t) < EPS;      if (seen && stuck && b.painted) {        if (!run) run = { from: report[i - 1].y, to: report[i].y, t: b.t, dur: b.dur };        else run.to = report[i].y;      } else flush();    }    flush();  }  if (frozen.length) {    console.log("\nFROZEN CLIP (still image while the page moves):\n  " + frozen.map((f) => {      const px = f.to - f.from;      const where = f.kind === "entry" ? "held on its FIRST frame while the stage slides in"        : f.kind === "exit" ? "held on its LAST frame while the stage slides out"        : `held mid-clip at ${f.t.toFixed(2)}s, longer than a dwell settle`;      return `clip ${f.c}: ${px}px (${(px / doc.vh).toFixed(2)} viewports) ${where}`;    }).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.");  } else if (nClips) {    console.log(`all ${nClips} scrub clip(s) keep moving whenever they are on screen`);  }} // Worldflight findings. A leg that never reaches full opacity is a weight or a// seam that is wrong: the reader is shown a permanent dissolve between two// clips and never the leg itself. A leg stuck on its poster is a clip that// never loaded or never decoded, and it passes every other check on this page// because a poster looks exactly like a paused film.if (WORLD) {  const segPeak = {};  report.forEach((s) => (s.segs || []).forEach((g) => {    const k = `${g.i}${g.label ? ' "' + g.label + '"' : ""}`;    segPeak[k] = segPeak[k] || { op: 0, painted: false, hasClip: g.t !== null };    segPeak[k].op = Math.max(segPeak[k].op, g.op);    segPeak[k].painted = segPeak[k].painted || g.painted;  }));  const faint = Object.entries(segPeak).filter(([, v]) => v.op < 0.99);  // Under reduced motion no clip is ever fetched, on purpose. Every leg is  // legitimately on its poster, and reporting that as a fault buries the one  // finding this pass exists for: whether the story still reads without motion.  const posters = REDUCED ? [] : Object.entries(segPeak).filter(([, v]) => v.hasClip && !v.painted);  if (faint.length) console.log("\nLEGS THAT NEVER REACH FULL OPACITY:\n  " +    faint.map(([k, v]) => `${v.op.toFixed(2)} leg ${k}`).join("\n  "));  if (posters.length) console.log("\nLEGS STUCK ON POSTER (clip never painted):\n  " +    posters.map(([k]) => `leg ${k}`).join("\n  "));  if (!faint.length && !posters.length)    console.log(`all ${Object.keys(segPeak).length} legs reach full opacity` +      (REDUCED ? " (posters only, as reduced motion requires)" : " and paint a real frame"));} // Cues that never reach full strength anywhere on the page. A headline peaking// at 0.6 is a mis-set cue window, and it is invisible as a bug because the// element IS there, just never quite arriving.const peak = {};report.forEach((s) => s.cues.forEach((c) => { peak[c.t] = Math.max(peak[c.t] || 0, c.o); }));const weak = Object.entries(peak).filter(([, o]) => o < 0.8);if (weak.length) console.log("\nCUES THAT NEVER PEAK:\n  " + weak.map(([t, o]) => `${o} "${t}"`).join("\n  ")); // Contrast over media, graded at the worst frame each line is ever shown on.const worstBy = {};report.forEach((s) => (s.contrast || []).forEach((c) => {  if (!worstBy[c.t] || c.worst < worstBy[c.t].worst) worstBy[c.t] = c;}));const fails = Object.values(worstBy).filter((c) => c.worst < 3);const thin = Object.values(worstBy).filter((c) => c.worst >= 3 && c.worst < 4.5);if (fails.length) console.log("\nCONTRAST FAIL (worst frame < 3:1):\n  " +  fails.map((c) => `${c.worst}:1 (mean ${c.mean}) "${c.t}"`).join("\n  "));if (thin.length) console.log("\nCONTRAST THIN (3:1 to 4.5:1, ok for large display type only):\n  " +  thin.map((c) => `${c.worst}:1 "${c.t}"`).join("\n  "));if (!fails.length && !thin.length && Object.keys(worstBy).length)  console.log("\ncontrast over media: all cues clear 4.5:1 at their worst frame"); await browser.close(); // Contact sheet. The point of shooting contiguously is to look at the frames// side by side; a folder of 20 PNGs does not get looked at that way.const FFMPEG = [  process.env.SCROLLCRAFT_FFMPEG,  ...(fs.existsSync(path.join(process.env.HOME || "", "AppData/Local/Microsoft/WinGet/Packages"))    ? fs.readdirSync(path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages"))        .filter((d) => d.startsWith("Gyan.FFmpeg"))        .flatMap((d) => {          const base = path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages", d);          return fs.readdirSync(base).map((v) => path.join(base, v, "bin/ffmpeg.exe"));        })    : []),  "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", "ffmpeg",].find((p) => p && (p === "ffmpeg" || fs.existsSync(p))); if (FFMPEG) {  const cols = Math.min(5, report.length);  const rows = Math.ceil(report.length / cols);  const { spawnSync } = await import("node:child_process");  const r = spawnSync(FFMPEG, [    "-y", "-v", "error", "-i", path.join(OUT, "%02d.png"),    "-vf", `scale=520:-1,tile=${cols}x${rows}`, "-frames:v", "1",    path.join(OUT, "sheet.png"),  ]);  if (r.status === 0) console.log(`contact sheet: ${path.join(OUT, "sheet.png")}`);  else console.log("contact sheet skipped (needs a full ffmpeg build; scale/tile are missing from stripped ones)");} console.log(`\nshots + report.json in ${OUT}`); 
Referenced from SKILL.md