Capture & emission fidelity wave: dotLottie support, scroll-state isolation, authored-geometry trust, honest quality scoring

Capture:
- Preserve real asset extensions (.lottie no longer truncated); extract the
  default animation JSON from dotLottie ZIP containers at materialization
  (minimal built-in ZIP reader, no new deps)
- Reset scroll + settle before every viewport snapshot so scroll-linked
  styles can't bake into captured computed values
- Marquee detection now requires content overflow (>1.35x), sustained
  velocity across separated windows, scroll independence (jiggle test), and
  genuine content repetition — kills scroll-settle-lerp false positives
- Sizing probe trusts authored explicit heights (px/vh/calc) over circular
  parent/child reflow verdicts, fixing dropped 100vh heroes

Normalize/generate:
- Canonicalize identity transforms to "none" per viewport; emit explicit
  transform resets across bands when any band has a real transform
- DittoLottie keeps the captured placeholder until DOMLoaded; failed loads
  no longer blank the container
- mx-auto only for true auto-margin centering; literal per-band margins are
  preserved (fixes full-bleed regressions)
- Spacing-scale snap tightened to 0.25px (3.5px stays p-[3.5px]); emit
  whitespace-nowrap for wrap-vulnerable single-line text leaves
- Grid recipes no longer override captured track counts or column spans;
  responsive re-flow plans apply only when geometry agrees

Quality scoring:
- Rewritten 6-dimension rubric (payload, decomposition, duplication,
  semantics, hygiene, runtime) with catastrophe caps and per-dimension
  reporting; thresholds are documented calibration guides

218 tests pass (74 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 23:09:16 -07:00
co-authored by Claude Fable 5
parent 77be868ee3
commit d3ec154bae
19 changed files with 2345 additions and 386 deletions
+63 -4
View File
@@ -14,6 +14,7 @@ import {
import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js";
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
@@ -115,13 +116,19 @@ export function isRetryableAssetFailure(type: string, status: number | null): bo
return status >= 500 || status === 429;
}
function extFromUrl(url: string): string {
// Bound on a preserved file extension. Real extensions are short, but a hard 5-char cap
// silently truncates legitimate ones (`.lottie` → `.lotti`, `.webmanifest` → `.webma`),
// which then mis-materializes the asset. Keep the guard generous enough for the longest
// real extensions and reject anything absurdly long (a dotted path segment, not an ext).
const MAX_EXT_LEN = 12;
export function extFromUrl(url: string): string {
try {
const p = new URL(url).pathname;
const dot = p.lastIndexOf(".");
if (dot >= 0 && dot > p.lastIndexOf("/")) {
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5);
if (/^[a-z0-9]+$/.test(ext)) return ext;
const ext = p.slice(dot + 1).toLowerCase();
if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
}
} catch { /* ignore */ }
return "";
@@ -209,6 +216,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
await page.waitForTimeout(250);
}
/**
* Stage 2 — scroll-state reset immediately before a per-viewport snapshot. The motion /
* dwell-scroll / carousel / element-screenshot passes above all leave the page scrolled or
* mid-transition; scroll-linked styles (Webflow scroll-state transforms, position:sticky
* offsets) then get baked into the captured computed styles for THAT viewport only, so a
* scroll-driven translateY leaks into one band and cascades. Reset window + inner scrollers
* to the top, wait for scroll-linked effects to settle across a few rAF ticks plus a short
* quiescence window, THEN let the caller snapshot. Bounded and deterministic.
*/
async function settleScrollTopBeforeSnapshot(page: import("playwright").Page): Promise<void> {
try {
await Promise.race([
page.evaluate(async () => {
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
const resetAll = () => {
window.scrollTo(0, 0);
for (const el of Array.from(document.querySelectorAll("*"))) {
if (el.scrollLeft) el.scrollLeft = 0;
if (el.scrollTop) el.scrollTop = 0;
}
};
resetAll();
// Let scroll-linked effects (scroll-state classes, sticky offsets, JS scroll handlers)
// recompute at scroll 0 over several frames, re-asserting the top position each tick in
// case a handler nudged it, then hold briefly for quiescence.
for (let i = 0; i < 6; i++) { await raf(); resetAll(); }
await new Promise<void>((r) => setTimeout(r, 120));
resetAll();
await raf();
}),
new Promise<void>((r) => setTimeout(r, 4000)),
]);
} catch { /* ignore — a best-effort reset never blocks the snapshot */ }
}
export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean };
/**
@@ -579,7 +621,18 @@ export async function captureSite(opts: {
if (type === "video" && !looksLikeVideoFile(bytes)) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
// `path:` loader does a JSON.parse and throws on the ZIP bytes, blanking the container. So
// unwrap it here: extract the default animation JSON and store THAT, materializing every
// lottie source as plain JSON regardless of the container it arrived in.
let extOverride: string | null = null;
if (type === "lottie" && isZipArchive(bytes)) {
const json = extractDotLottieJson(bytes);
if (!json) return; // unreadable dotLottie — leave unstored rather than ship a broken ZIP
bytes = json;
extOverride = "json";
}
const ext = extOverride || extFromUrl(url) || extFromContentType(a.contentType) ||
(type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
const name = `${sha1_12(url)}.${ext}`;
@@ -973,6 +1026,12 @@ export async function captureSite(opts: {
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
// Final scroll-state reset immediately before the walk: every preceding pass (dwell
// scroll, carousel settle, element screenshots) can leave the page scrolled, which bakes
// scroll-linked transforms/offsets into this viewport's computed styles. Scroll to top and
// let scroll-linked effects settle so the snapshot records the genuine at-rest values.
await settleScrollTopBeforeSnapshot(page);
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([
+123
View File
@@ -0,0 +1,123 @@
import { inflateRawSync } from "node:zlib";
/**
* dotLottie (.lottie) extraction.
*
* A `.lottie` file is NOT bare lottie-web JSON — it is a ZIP archive (the "dotLottie"
* container) holding `manifest.json` plus one or more `animations/*.json` documents. Feeding
* the raw ZIP bytes to lottie-web via `path:` makes it try to JSON.parse a ZIP, which throws
* an InvalidStateError at runtime and leaves the container blank. So at materialization time
* we detect the ZIP, pick the default/first animation, and rewrite the stored asset as plain
* lottie JSON that lottie-web can parse directly.
*
* Node ships `zlib` (raw DEFLATE) but no ZIP reader, and pulling a zip dependency for this
* one shape is overkill — so this implements the minimal slice of the ZIP spec needed:
* walking the End-Of-Central-Directory + central directory to locate entries, then reading
* each local file header to decompress a stored (method 0) or deflated (method 8) entry.
* Deterministic: no timestamps, no randomness, entries resolved by name.
*/
/** ZIP local file header signature (`PK\x03\x04`). dotLottie archives always start with it. */
export function isZipArchive(bytes: Buffer): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
}
const EOCD_SIG = 0x06054b50; // End Of Central Directory
const CEN_SIG = 0x02014b50; // Central directory file header
const LOC_SIG = 0x04034b50; // Local file header
type ZipEntry = { name: string; method: number; compSize: number; uncompSize: number; localOffset: number };
/** Locate + read the End-Of-Central-Directory record, then walk the central directory. */
function readCentralDirectory(buf: Buffer): ZipEntry[] {
// The EOCD lives at the tail; it is >=22 bytes and may carry a trailing comment, so scan
// backwards for its signature within the max comment window (64KiB) + record size.
const minEocd = 22;
if (buf.length < minEocd) return [];
let eocd = -1;
const scanFrom = Math.max(0, buf.length - (0xffff + minEocd));
for (let i = buf.length - minEocd; i >= scanFrom; i--) {
if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
}
if (eocd < 0) return [];
const entryCount = buf.readUInt16LE(eocd + 10);
let ptr = buf.readUInt32LE(eocd + 16); // central directory offset
const entries: ZipEntry[] = [];
for (let i = 0; i < entryCount; i++) {
if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== CEN_SIG) break;
const method = buf.readUInt16LE(ptr + 10);
const compSize = buf.readUInt32LE(ptr + 20);
const uncompSize = buf.readUInt32LE(ptr + 24);
const nameLen = buf.readUInt16LE(ptr + 28);
const extraLen = buf.readUInt16LE(ptr + 30);
const commentLen = buf.readUInt16LE(ptr + 32);
const localOffset = buf.readUInt32LE(ptr + 42);
const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen);
entries.push({ name, method, compSize, uncompSize, localOffset });
ptr += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** Decompress one central-directory entry by reading its local header for the data offset. */
function readEntry(buf: Buffer, e: ZipEntry): Buffer | null {
const p = e.localOffset;
if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) return null;
// The central directory's name/extra lengths can differ from the local header's, so read the
// local header's own lengths to find where the file data begins.
const nameLen = buf.readUInt16LE(p + 26);
const extraLen = buf.readUInt16LE(p + 28);
const dataStart = p + 30 + nameLen + extraLen;
const dataEnd = dataStart + e.compSize;
if (dataEnd > buf.length) return null;
const comp = buf.subarray(dataStart, dataEnd);
if (e.method === 0) return Buffer.from(comp); // stored
if (e.method === 8) {
try { return inflateRawSync(comp); } catch { return null; }
}
return null; // unsupported compression method
}
/** Read a named entry (exact path match) from a ZIP buffer, or null if absent/unreadable. */
export function readZipEntry(buf: Buffer, name: string): Buffer | null {
const entry = readCentralDirectory(buf).find((e) => e.name === name);
return entry ? readEntry(buf, entry) : null;
}
/**
* Extract the lottie animation JSON from a dotLottie ZIP. Picks the manifest's first/default
* animation (falling back to the first `animations/*.json` entry), returning it as a Buffer of
* plain lottie JSON ready to hand to lottie-web via `path:`. Returns null when the bytes are
* not a dotLottie ZIP or no animation JSON is present.
*/
export function extractDotLottieJson(bytes: Buffer): Buffer | null {
if (!isZipArchive(bytes)) return null;
const entries = readCentralDirectory(bytes);
if (!entries.length) return null;
const animEntries = entries
.filter((e) => /^animations\/.+\.json$/i.test(e.name))
.sort((a, b) => a.name.localeCompare(b.name)); // deterministic ordering
if (!animEntries.length) return null;
// Prefer the animation the manifest names first (its default), when the manifest is readable.
let chosen = animEntries[0]!;
const manifestBuf = readZipEntry(bytes, "manifest.json");
if (manifestBuf) {
try {
const manifest = JSON.parse(manifestBuf.toString("utf8")) as { animations?: Array<{ id?: string }> };
const firstId = manifest.animations?.[0]?.id;
if (firstId) {
const match = animEntries.find((e) => e.name === `animations/${firstId}.json`);
if (match) chosen = match;
}
} catch { /* manifest unreadable — keep the name-sorted first animation */ }
}
const json = readEntry(bytes, chosen);
if (!json) return null;
// Validate it parses as JSON (lottie-web's `path:` loader does a bare JSON.parse).
try { JSON.parse(json.toString("utf8")); } catch { return null; }
return json;
}
+190 -13
View File
@@ -60,6 +60,83 @@ export type MarqueeSpec = {
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
};
// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit
// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body —
// module-scope functions do NOT cross the serialization boundary — so any change here must
// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads.
/**
* Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a
* fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which
* is a large jump opposite the travel direction when the track seamlessly restarts.
*/
export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
}
/**
* Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady
* velocity across time; a scroll-settle lerp (still easing toward its scroll-target after
* `scrollIntoView`) decays, so its velocity in a later observation window is a small
* fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass
* only if the later window's speed is BOTH non-trivial AND close to the earlier window's
* (within a relative tolerance) — i.e. it did not decay and did not reverse.
*
* @param window1Deltas per-sample translateX deltas from the first observation window
* @param window2Deltas per-sample translateX deltas from the second (later) window
* @param sampleMs cadence between samples within a window
* @param minPxPerSec minimum sustained |velocity| to be considered moving at all
* @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign
*/
export function classifyVelocitySamples(
window1Deltas: number[],
window2Deltas: number[],
sampleMs: number,
minPxPerSec = 4,
relTol = 0.5,
): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } {
const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs);
const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs);
// The reported velocity is the first window's (measured closest to a clean, post-settle
// marquee; also what the old code reported), kept for output determinism vs. the old path.
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker
// v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed).
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
}
/**
* Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2×
* (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row
* of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose
* shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural
* fallback, an equal consecutive width sequence (some marquees clone then tweak attributes
* so hashes differ but the geometry repeats).
*
* @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths`
* @param widths per-child rounded offsetWidth
*/
export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
// (a) two consecutive children with an identical hash — a literal cloned copy.
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
// (b) structural fallback: the first half's width sequence repeats in the second half
// (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real
// width so a row of zero-width nodes can't spuriously "repeat".
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
}
export type MotionCapture = {
waapi: WaapiAnim[];
rotators: RotatorSpec[];
@@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise<void> {
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR.
*
* Discriminators (ALL must pass — a candidate is only classified as a marquee if every one
* holds), added to defeat scroll-LINKED easing false positives (a static logo row on a
* scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`,
* which reads as a constant velocity over a single short window):
* 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll;
* a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee).
* 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle
* lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples).
* 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity
* responds to the nudge the motion is scroll-linked, not a self-driven ticker.
* 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an
* equal consecutive width sequence), the shape real marquees use for a seamless loop.
*
* Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the
* few candidates that already pass the cheap synchronous gates (overflow + translated +
* clip + duplication), and the candidate list is capped at 8. Per surviving candidate the
* async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms
* jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size.
*/
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
try {
@@ -143,32 +239,113 @@ async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
return false;
};
// cheap 32-bit string hash (djb2-ish) — deterministic, for the duplication test.
const hashStr = (s: string): number => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; };
// ---- Pure discriminators (mirror of the exported module-scope functions; kept inline
// because module scope does not cross the page.evaluate serialization boundary). ----
const medianVelocityPxPerSec = (deltas: number[], sampleMs: number): number => {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
};
const classifyVelocitySamples = (w1: number[], w2: number[], sampleMs: number, minPxPerSec = 4, relTol = 0.5) => {
const v1 = medianVelocityPxPerSec(w1, sampleMs);
const v2 = medianVelocityPxPerSec(w2, sampleMs);
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 };
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
};
const hasRepeatedChildren = (hashes: number[], widths: number[]): boolean => {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
};
// sample per-child outerHTML hash + width for the duplication test.
const childSignature = (el: Element): { hashes: number[]; widths: number[] } => {
const hashes: number[] = [], widths: number[] = [];
const kids = Array.from(el.children).slice(0, 24) as HTMLElement[];
for (const k of kids) { hashes.push(hashStr(k.outerHTML)); widths.push(Math.round(k.getBoundingClientRect().width)); }
return { hashes, widths };
};
// one observation window: `count` deltas at `sampleMs` cadence.
const sampleWindow = async (el: Element, count: number, sampleMs: number): Promise<number[]> => {
const xs: number[] = [txOf(el)];
for (let i = 0; i < count; i++) { await sleep(sampleMs); xs.push(txOf(el)); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
return dxs;
};
const SAMPLE_MS = 120;
const WINDOW_SAMPLES = 5; // 5 deltas ≈ 600ms per window
const INTER_WINDOW_MS = 1500; // ≥1.5s between windows (discriminator 2)
const JIGGLE_PX = 50; // scroll nudge for the independence test (discriminator 3)
// Candidates: tagged, already translated (paused tickers keep their offset), with
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape.
// GENUINE duplicated content inside an overflow-clip viewport, AND real content
// overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous
// so the expensive async windows only run for the few survivors.
const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue;
if (!inClip(el)) continue;
// Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth;
// a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll.
if (el.scrollWidth <= el.clientWidth * 1.35) continue;
// Discriminator 4: genuine repetition, not merely ≥2 distinct children.
const sig = childSignature(el);
if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue;
cand.push(el);
if (cand.length >= 16) break;
if (cand.length >= 8) break;
}
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>();
for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle
const xs: number[] = [];
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
if (dxs.length < 3) continue;
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
const pxPerSec = Math.round((med / 120) * 1000);
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
// direction must be consistent in all but (at most) the one wrap step.
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
// Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
// a real marquee holds constant velocity.
const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
await sleep(INTER_WINDOW_MS);
const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
if (w1.length < 3 || w2.length < 3) continue;
// direction must be consistent within window 1, but for (at most) the one wrap step.
const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue;
const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS);
if (!cls.isMarquee) continue;
const pxPerSec = cls.pxPerSec;
// Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample
// velocity; a self-driven ticker is unaffected, a scroll-linked animation changes.
const baseY = window.scrollY;
const jiggle = async (dy: number): Promise<number> => {
try { window.scrollTo(0, Math.max(0, baseY + dy)); } catch { /* ignore */ }
await sleep(250); // let any scroll-linked easing react and settle at the new offset
const dxs = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
return medianVelocityPxPerSec(dxs, SAMPLE_MS);
};
const vDown = await jiggle(JIGGLE_PX);
const vUp = await jiggle(-JIGGLE_PX);
try { window.scrollTo(0, baseY); } catch { /* ignore */ }
// Scroll-linked motion responds to the nudge (velocity reverses, dies, or spikes as
// it re-lerps to the new target). A real marquee holds ~pxPerSec through both nudges.
const stableUnderJiggle = (v: number): boolean =>
Math.sign(v) === Math.sign(pxPerSec) && Math.abs(v) >= Math.abs(pxPerSec) * 0.5 && Math.abs(v) <= Math.abs(pxPerSec) * 2;
if (!stableUnderJiggle(vDown) || !stableUnderJiggle(vUp)) continue;
const periodPx = Math.round(el.scrollWidth / 2);
if (periodPx < 40) continue;
seen.add(cap);
+61 -2
View File
@@ -380,9 +380,22 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const hf = el.getBoundingClientRect().height;
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly,
// so a drop can't accumulate a visible shift across many elements (favours fidelity).
let hAuto = Math.abs(ha - r.height) <= 0.5;
let hFill = Math.abs(hf - r.height) <= 0.5;
// Circular-height guard: a box whose fill child (height:100%) pins it back up makes BOTH
// `height:auto` and `height:100%` reproduce the box, so the raw verdict reads hAuto (drop) —
// even though the height is authored (e.g. `100vh` on a hero, an explicit px section). Both
// sides then wait on each other and the box collapses. When this element's own cascade/inline
// style declares an explicit definite height, trust that declaration: the height is authored,
// so it is neither content-sized (hAuto) nor a parent fill (hFill). Only overrides when auto
// actually reproduced — a genuine explicit height that auto already shrinks stays hAuto:false.
if ((hAuto || hFill) && r.height > 2 && authorsExplicitHeight(el, sh)) {
hAuto = false;
hFill = false;
}
sizing = {
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5,
hFill: Math.abs(hf - r.height) <= 0.5,
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto,
hFill,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
};
} finally {
@@ -492,6 +505,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const cssUrlSet = new Set<string>();
const keyframes: string[] = [];
const cssVars: Record<string, string> = {};
// Selectors that AUTHOR an explicit, definite height/min-height (px/rem/em/vh/vw/…,
// NOT auto, NOT a percentage, NOT a keyword). Harvested once from the cascade below and
// consulted by the sizing probe to break circular parent/child height verdicts: when a box
// and its fill child mutually justify each other's height, `height:auto` reproduces the box
// for a reason that is NOT "content-sized", so the probe alone reads hAuto:true and the
// authored dimension gets dropped downstream. A declared explicit length is ground truth that
// the height is load-bearing, so we trust the declaration over the reflow verdict.
const explicitHeightSelectors: string[] = [];
const absUrl = (u: string): string => {
try { return new URL(u, document.baseURI).href; } catch { return u; }
@@ -507,6 +528,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
}
};
// True when a `height`/`min-height` value is an explicit, definite length the browser resolves
// to a fixed px box (px/rem/em/vh/vw/vmin/vmax/ch/…, or a calc/min/max/clamp over them). False for
// `auto`, an empty value, a pure percentage (resolves against the parent — that's the fill case the
// hFill probe already handles), `0`, and intrinsic keywords (fit-/min-/max-content). A definite
// authored height is load-bearing and must survive even when the reflow probe reads hAuto:true.
const isExplicitHeight = (raw: string): boolean => {
const v = (raw || "").trim().toLowerCase();
if (!v || v === "auto" || v === "0" || v === "0px" || v === "none") return false;
if (v === "fit-content" || v === "min-content" || v === "max-content" || v === "inherit" ||
v === "initial" || v === "unset" || v === "revert" || v === "revert-layer") return false;
// A bare percentage resolves against the parent (fill), not an authored definite length.
if (/^[\d.]+%$/.test(v)) return false;
// A definite length unit (or a calc()/min()/max()/clamp() that contains one) anchors the box.
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|svh|lvh|dvh|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
};
// Does this element author an explicit definite height — via its own inline style (passed as
// `inlineHeight`, already read by the probe) or via any matched cascade rule harvested into
// `explicitHeightSelectors`? getComputedStyle resolves height to used px (so `100vh` reads as a
// plain number and is indistinguishable from a content height there); the specified value is only
// recoverable from the inline declaration and the cascade, which is why we consult both.
const authorsExplicitHeight = (el: Element, inlineHeight: string): boolean => {
if (isExplicitHeight(inlineHeight)) return true;
try {
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-height") || "";
if (isExplicitHeight(inlineMin)) return true;
} catch { /* ignore */ }
for (const sel of explicitHeightSelectors) {
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
}
return false;
};
const readRules = (rules: CSSRuleList): void => {
for (const rule of Array.from(rules)) {
const type = rule.constructor.name;
@@ -532,6 +586,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
} else if (type === "CSSStyleRule") {
const r = rule as CSSStyleRule;
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
if (r.selectorText && r.style &&
(isExplicitHeight(r.style.getPropertyValue("height")) ||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
explicitHeightSelectors.push(r.selectorText);
}
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
} else if (type === "CSSImportRule") {