From 8c36f3254f0b98aa097f9b02d229d69596a592c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 30 Oct 2026 05:11:06 +0000 Subject: [PATCH] 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 --- .../motion-signature.browser.chromium.test.ts | 17 ++++++ .../src/commands/motion-signature.browser.js | 17 ++++-- packages/lint/src/rules/gsap.test.ts | 55 +++++++++++++++++++ packages/lint/src/rules/gsap.ts | 21 ++++++- 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts index 40d1746453..54c8c4b9c0 100644 --- a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts +++ b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts @@ -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; }", + '', + ), + ); + 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( diff --git a/packages/cli/src/commands/motion-signature.browser.js b/packages/cli/src/commands/motion-signature.browser.js index 29705291ac..c3922e7329 100644 --- a/packages/cli/src/commands/motion-signature.browser.js +++ b/packages/cli/src/commands/motion-signature.browser.js @@ -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 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); diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 17779e5435..efc9d00fe6 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -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) => ` + +
+
+
+ +`; + 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) => ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index d0611c9413..fef7ced7b2 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -253,7 +253,10 @@ function hiddenSetTargetSelectors(target: string, aliases: Map): 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 { )) { 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[] = [ (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 =