Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"packages/cli/src/commands/layout-audit.browser.js",
"packages/cli/src/commands/contrast-audit.browser.js",
"packages/cli/src/commands/motion-sample.browser.js",
"packages/cli/src/commands/motion-signature.browser.js",
// Worker entry points loaded dynamically by their *Pool.ts companions.
"packages/producer/src/services/pngDecodeBlitWorker.ts",
"packages/producer/src/services/shaderTransitionWorker.ts",
Expand Down
11 changes: 11 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,17 @@ HTML. Without a sidecar, nothing changes.
| `staysInFrame(selector)` | Off-frame drift — once visible, the box never leaves the canvas (`motion_off_frame`) |
| `keepsMoving(withinSelector?)` | A frozen shot — no fully static window longer than `maxStaticSec`, default 2s (`motion_frozen`) |

`keepsMoving` uses the same motion classifier as the frozen-sweep guard: box
geometry, opacity, text and generated content, form-control state, painted CSS
counters, clip-path, variable-font axes, SVG stroke dashing
(`stroke-dasharray` / `stroke-dashoffset`), and the pixels of visible
canvas/video/img elements all count as motion — so a playing same-origin (or
CORS-readable) background video keeps a scope live on its own. Elements under
`data-layout-ignore` inside the scope never count; the opt-out does not apply to
the scope element itself, since naming it in an assertion outranks it. Content
the browser does not paint (`display: none` subtrees, `visibility: hidden`
boxes, skipped `content-visibility` contents) never counts as motion either.

`duration`, `keepsMoving.withinSelector`, and `keepsMoving.maxStaticSec` are
optional. Findings use the same shape and envelope as layout findings and are
errors by default, so a failed assertion fails the run. A selector that matches
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/scripts/build-copy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ async function main() {
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
}

const motionSignatureScript = join(CLI_ROOT, "src", "commands", "motion-signature.browser.js");
if (existsSync(motionSignatureScript)) {
cpSync(motionSignatureScript, join(DIST, "commands", "motion-signature.browser.js"));
}

const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js");
if (existsSync(motionSampleScript)) {
cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js"));
Expand Down
76 changes: 4 additions & 72 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1629,78 +1629,10 @@
return contentOverlapIssues(root, time);
};

// Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
// fingerprint of every visible element's box + opacity, in DOM order. Node
// calls this once per seeked grid point and compares the strings across the
// whole run — if every sample produces the identical string, the seek never
// actually moved anything and the whole audit run is unreliable. Deliberately
// a single opaque string (not a structured array) since Node only ever needs
// equality, not per-element diffing.
// Pixel-only media motion (a 2D/WebGL canvas repainting or a playing video
// without any element moving) is invisible to a geometry+opacity fingerprint
// and false-positives sweep_static. Downsample each visible canvas/video to
// 8x8 and fold its pixels into the fingerprint. Tainted, zero-sized, or
// unreadable media hashes to a constant — no worse than geometry-only
// detection and never a new false negative for DOM-motion compositions.
// Media inside iframes is intentionally outside this fingerprint: it lives
// in a separate document, and cross-origin frames are inaccessible under SOP.
function mediaPixelHash(element) {
try {
const rect = element.getBoundingClientRect();
const sourceWidth = element.videoWidth || element.width || rect.width;
const sourceHeight = element.videoHeight || element.height || rect.height;
if (!sourceWidth || !sourceHeight) return "x";
const off = document.createElement("canvas");
off.width = 8;
off.height = 8;
const ctx = off.getContext("2d");
if (!ctx) return "x";
ctx.drawImage(element, 0, 0, 8, 8);
const data = ctx.getImageData(0, 0, 8, 8).data;
let hash = 0;
for (let i = 0; i < data.length; i++) hash = (hash * 31 + data[i]) >>> 0;
return String(hash);
} catch {
return "x";
}
}

window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
isVisibleElement(element),
);
const parts = elements.map((element) => {
const rect = toRect(element.getBoundingClientRect());
const opacity = round(opacityChain(element));
// Variable-font axis animation (font-variation-settings) is a real,
// visible motion channel that moves no geometry and no opacity, so a
// box+opacity fingerprint reads it as a frozen timeline. Worse in a
// DUPLEXED face (Recursive holds an identical advance width at every
// weight by design), where not even the line width shifts — the whole
// run then false-positives sweep_static. Fold the computed axis string
// in; it is "normal" for every element that does not use it, so this
// adds nothing to the fingerprint of an ordinary composition.
const axes = getComputedStyle(element).fontVariationSettings;
return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity},${
axes && axes !== "normal" ? axes : ""
}`;
});
// img shares the same pixel-only-motion blind spot as canvas/video: an
// equal-size, equal-position opaque src/visibility swap moves no
// geometry and no opacity. mediaPixelHash already handles it generically
// (drawImage accepts any CanvasImageSource; width/height fall back to
// rect.width/height same as canvas/video), so only the element selector
// needed widening.
for (const media of root.querySelectorAll("canvas, video, img")) {
if (!isVisibleElement(media)) continue;
parts.push(`p:${mediaPixelHash(media)}`);
}
return parts.join("|");
};
// The frozen-sweep fingerprint (window.__hyperframesLayoutGeometry, #U10 in
// checkPipeline.ts) lives in motion-signature.browser.js, the single owner
// of "what counts as motion" shared with motion-sample.browser.js's
// liveness signature.

// Rotation-pivot sampling (rotation_pivot_drift). Per sample, report every
// rotatable candidate's bbox center, size, and current rotation angle. Node
Expand Down
186 changes: 0 additions & 186 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,192 +31,6 @@ describe("layout-audit.browser", () => {
clearGeometryCollector();
});

it("changes the sweep fingerprint when visible video pixels advance", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<video id="footage"></video>
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
footage: rect({ left: 0, top: 0, width: 640, height: 360 }),
});

let pixelValue = 20;
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage() {},
getImageData() {
return { data: new Uint8ClampedArray(8 * 8 * 4).fill(pixelValue) };
},
} as unknown as CanvasRenderingContext2D);

installAuditScript();
const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string })
.__hyperframesLayoutGeometry;
const before = collect();
pixelValue = 220;
const after = collect();

expect(after).not.toBe(before);
});

// PRINFRA-666: an equal-size, equal-position opaque <img> src/visibility
// swap (the authoring pattern for a paused-GSAP-cursor-driven "reveal
// frame N of a still sequence" composition) moves no geometry and no
// opacity, so it was invisible to the fingerprint and false-positived
// sweep_static — mediaPixelHash already existed for exactly this pixel-only
// motion class, it just wasn't applied to img.
it("changes the sweep fingerprint when a same-size opaque img is swapped", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<img id="frame" />
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
frame: rect({ left: 0, top: 0, width: 640, height: 360 }),
});

let pixelValue = 20;
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage() {},
getImageData() {
return { data: new Uint8ClampedArray(8 * 8 * 4).fill(pixelValue) };
},
} as unknown as CanvasRenderingContext2D);

installAuditScript();
const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string })
.__hyperframesLayoutGeometry;
const before = collect();
pixelValue = 220;
const after = collect();

expect(after).not.toBe(before);
});

// Opacity-reveal fixture (CLI feedback digest 2026-07-14): code-typing style
// scenes reveal pre-laid-out characters via opacity only — no geometry ever
// moves. The sweep fingerprint must treat that as motion, both while a glyph
// fades (opacity value changes) and when it crosses the 0.2 visibility floor
// (element enters the signature); otherwise `check` misfires `sweep_static`
// and authors reach for geometry hacks (a slow host y-drift) to pass.
it("changes the sweep fingerprint when text reveals via opacity alone", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="code"><span id="char">c</span></div>
</div>
`;

let charOpacity = "0";
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
code: rect({ left: 40, top: 40, width: 560, height: 48 }),
char: rect({ left: 40, top: 40, width: 18, height: 48 }),
},
{
char: {
get opacity() {
return charOpacity;
},
} as Partial<CSSStyleDeclaration>,
},
);

installAuditScript();
const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string })
.__hyperframesLayoutGeometry;

const hidden = collect(); // below the 0.2 visibility floor — not in the signature
charOpacity = "0.5";
const fading = collect(); // mid-fade — present, opacity part of the signature
charOpacity = "1";
const revealed = collect(); // settled

expect(fading).not.toBe(hidden);
expect(revealed).not.toBe(fading);
});

// Variable-font axis animation (registry block `weight-wave`): a crest of
// weight travels along a headline by rewriting each character's
// font-variation-settings, and NOTHING else changes — no geometry, no
// opacity, no canvas. A duplexed face makes it total: Recursive holds one
// advance width at every weight by design, so not even the line width
// shifts and all six sweep samples hashed identically until the axis string
// joined the fingerprint. `check` then failed a working composition with
// sweep_static, and the documented remedies (spread the reveal, keep an
// element animating) cannot help — the motion is real, the fingerprint was
// just blind to it.
it("changes the sweep fingerprint when only font-variation-settings moves", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="line"><span id="char">P</span></div>
</div>
`;

let axes = '"wght" 400, "slnt" 0';
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
line: rect({ left: 40, top: 40, width: 560, height: 48 }),
char: rect({ left: 40, top: 40, width: 18, height: 48 }),
},
{
char: {
get fontVariationSettings() {
return axes;
},
} as Partial<CSSStyleDeclaration>,
},
);

installAuditScript();
const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string })
.__hyperframesLayoutGeometry;

const rest = collect();
axes = '"wght" 1000, "slnt" -12'; // the crest arrives over this character
const crest = collect();

expect(crest).not.toBe(rest);
});

// The other direction, and it guards the more dangerous failure: a
// fingerprint that varies on its own would make sweep_static unfireable and
// every green layout verdict meaningless. Identical scene, axes included,
// must hash identically.
it("keeps the sweep fingerprint identical when nothing moves, font axes included", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="line"><span id="char">P</span></div>
</div>
`;

installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
line: rect({ left: 40, top: 40, width: 560, height: 48 }),
char: rect({ left: 40, top: 40, width: 18, height: 48 }),
},
{
char: { fontVariationSettings: '"wght" 400, "slnt" 0' } as Partial<CSSStyleDeclaration>,
},
);

installAuditScript();
const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string })
.__hyperframesLayoutGeometry;

expect(collect()).toBe(collect());
});

it("uses authored canvas dimensions when the root bounding rect is degenerate", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ async function runMotionPass(
width: window.innerWidth,
height: window.innerHeight,
}));
await page.addScriptTag({ content: loadBrowserScript("motion-signature.browser.js") });
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
const frames = await collectMotionFrames(page, times, selectors, livenessScopes);
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
Expand Down
Loading
Loading