fix(cli): check no longer flags a hidden scene child or a small animated sprite (#6783)

* fix(lint): a scene child hidden at zero through a scoped query is not an overlay that starts visible

* fix(lint): a descendant selector inside a scoped query hides the overlay too

* fix(cli): check sees a small sprite move across a large canvas

* style(cli): wrap the media pixel channel comment

* fix(cli): only the liveness signature samples canvases finely, the sweep guard stays coarse
This commit is contained in:
Miguel Ángel
2026-10-30 05:11:06 +00:00
committed by GitHub
parent 9fd90d23c4
commit 8c36f3254f
4 changed files with 101 additions and 9 deletions
@@ -568,6 +568,23 @@ describe.skipIf(!RUNS_CHROMIUM)("motion-signature.browser in Chromium", () => {
expect(after).toEqual(before);
});
it("sees a small sprite move across a large canvas", async () => {
await load(
composition(
"#c { width: 640px; height: 360px; }",
'<canvas id="c" width="1080" height="1920"></canvas>',
),
);
const draw = (x: number) =>
`{ const ctx = document.getElementById("c").getContext("2d"); ctx.clearRect(0, 0, 1080, 1920); ctx.fillStyle = "#fc8"; ctx.fillRect(${x}, 500, 24, 24); }`;
await mutate(draw(300));
const before = await sample();
await mutate(draw(308));
const after = await sample();
expect(after.liveness).not.toBe(before.liveness);
});
it("ignores a repaint of a canvas whose contents are skipped by content-visibility:hidden", async () => {
await load(
composition(
@@ -6,6 +6,8 @@
(function () {
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
const MEDIA_TAGS = new Set(["CANVAS", "VIDEO", "IMG"]);
const COARSE_MEDIA_SAMPLE_SIDE = 8;
const LIVENESS_MEDIA_SAMPLE_SIDE = 32;
const FNV_OFFSET_BASIS = 2166136261;
const FNV_PRIME = 16777619;
const LIVENESS_POSITION_BUCKET_PX = 2;
@@ -221,9 +223,10 @@
}
// Pixel-only media motion (canvas repaint, playing video, same-size <img> swap) is invisible to DOM channels,
// so fold in an 8x8 downsample. Unreadable media hashes to a constant; iframe media is a separate document.
// so fold in a downsample: fine for liveness (a small sprite moving), coarse for the frozen-sweep guard,
// which a wall-clock repaint between seeks would fool. Unreadable media hashes to a constant.
// fallow-ignore-next-line complexity
function mediaPixelChannel(element) {
function mediaPixelChannel(element, ctx) {
if (!MEDIA_TAGS.has(element.tagName)) return "";
try {
const rect = element.getBoundingClientRect();
@@ -231,12 +234,14 @@
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 side = ctx && ctx.quantize ? LIVENESS_MEDIA_SAMPLE_SIDE : COARSE_MEDIA_SAMPLE_SIDE;
off.width = side;
off.height = side;
const ctx2d = off.getContext("2d");
if (!ctx2d) return "x";
ctx2d.drawImage(element, 0, 0, 8, 8);
const data = ctx2d.getImageData(0, 0, 8, 8).data;
if (side === LIVENESS_MEDIA_SAMPLE_SIDE) ctx2d.imageSmoothingQuality = "high";
ctx2d.drawImage(element, 0, 0, side, side);
const data = ctx2d.getImageData(0, 0, side, side).data;
let hash = 0;
for (let i = 0; i < data.length; i++) hash = (hash * 31 + data[i]) >>> 0;
return String(hash);
+55
View File
@@ -3721,6 +3721,61 @@ describe("SVG draw-on rules", () => {
expect(finding).toBeUndefined();
});
describe("gsap_fullscreen_overlay_starts_visible — a scene child hidden through a scoped query", () => {
const scene = (hide: string) => `
<html><body>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<div class="ov" style="position:absolute;inset:0;background:#fff"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const root = document.querySelector('[data-composition-id="scene"]');
${hide}
const tl = gsap.timeline({ paused: true });
tl.to(".ov", { opacity: 1, duration: 0.5 }, 1).to(".ov", { opacity: 0, duration: 0.5 }, 3);
window.__timelines["scene"] = tl;
</script>
</body></html>`;
const flagged = async (html: string) =>
(await lintHyperframeHtml(html)).findings.some(
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
);
it("accepts gsap.set on a querySelector call with a literal selector", async () => {
expect(await flagged(scene('gsap.set(root.querySelector(".ov"), { opacity: 0 });'))).toBe(
false,
);
});
it("accepts gsap.set on a variable bound to a querySelector call", async () => {
expect(
await flagged(scene('const ov = root.querySelector(".ov"); gsap.set(ov, { opacity: 0 });')),
).toBe(false);
});
it("accepts a descendant selector inside the query", async () => {
expect(
await flagged(
scene(
'gsap.set(document.querySelector("[data-composition-id=scene] .ov"), { opacity: 0 });',
),
),
).toBe(false);
});
it("accepts a querySelector variable written without semicolons", async () => {
expect(
await flagged(
scene('const ov = root.querySelector(".ov")\n gsap.set(ov, { opacity: 0 })'),
),
).toBe(false);
});
it("still flags an overlay nothing hides at zero", async () => {
expect(await flagged(scene(""))).toBe(true);
});
});
describe("gsap_fullscreen_overlay_starts_visible — the from() shape is not a defect", () => {
const overlay = (style: string, script: string) => `
<html><body>
+18 -3
View File
@@ -253,7 +253,10 @@ function hiddenSetTargetSelectors(target: string, aliases: Map<string, string>):
return parts
.flatMap((part) => {
const trimmed = part.trim();
const resolved = /^(["'`])([^"'`]+)\1$/.exec(trimmed)?.[2] ?? aliases.get(trimmed);
const resolved =
/^(["'`])([^"'`]+)\1$/.exec(trimmed)?.[2] ??
/^[\w$.]+\.querySelector(?:All)?\(\s*(["'`])([^"'`]+)\1\s*\)$/.exec(trimmed)?.[2] ??
aliases.get(trimmed);
return resolved === undefined ? [] : resolved.split(",");
})
.map((selector) => selector.trim())
@@ -270,8 +273,13 @@ function extractStandaloneHiddenSelectors(script: string): Set<string> {
)) {
aliases.set(match[1] ?? "", match[3] ?? "");
}
for (const match of source.matchAll(
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[\w$.]+\.querySelector(?:All)?\(\s*(["'`])([^"'`]+)\2\s*\)/g,
)) {
aliases.set(match[1] ?? "", match[3] ?? "");
}
const pattern =
/gsap\.set\s*\(\s*(\[[^[\]]*\]|"[^"]*"|'[^']*'|`[^`]*`|[^,()[\]]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
/gsap\.set\s*\(\s*(\[[^[\]]*\]|"[^"]*"|'[^']*'|`[^`]*`|[\w$.]+\.querySelector(?:All)?\(\s*(?:"[^"]*"|'[^']*'|`[^`]*`)\s*\)|[^,()[\]]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
// Skip callback/handler bodies; keep IIFEs (they run at parse time).
@@ -1194,7 +1202,14 @@ export const gsapRules: LintRule<LintContext>[] = [
(win.fromPropertyValues !== undefined && isHiddenGsapState(win.fromPropertyValues))),
);
if (startsHiddenAtZero) continue;
if (selectors.some((selector) => authoredHiddenSelectors.has(selector))) continue;
if (
selectors.some((selector) =>
[...authoredHiddenSelectors].some(
(hidden) => hidden === selector || targetedSelectorTokens(hidden).has(selector),
),
)
)
continue;
const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));
if (!firstVisible) continue;
const selector =