Skip to content

Commit 899b8fa

Browse files
fix: flag visible GSAP transition overlays (#1686)
* fix: flag visible GSAP transition overlays * fix: cover GSAP overlay lint review cases
1 parent ba77a0b commit 899b8fa

4 files changed

Lines changed: 354 additions & 2 deletions

File tree

packages/core/src/lint/rules/core.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ describe("core rules", () => {
2626
expect(finding?.severity).toBe("error");
2727
});
2828

29+
it("accepts body as the composition root", async () => {
30+
const html = `
31+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
32+
<div id="overlay-flash"></div>
33+
<script>window.__timelines = window.__timelines || {};</script>
34+
</body></html>`;
35+
const result = await lintHyperframeHtml(html);
36+
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
37+
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
38+
});
39+
2940
it("reports error when timeline registry is missing", async () => {
3041
const html = `
3142
<html><body>

packages/core/src/lint/rules/gsap.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,171 @@ describe("GSAP rules", () => {
144144
expect(finding).toBeUndefined();
145145
});
146146

147+
it("errors when a full-frame transition flash starts visible before GSAP controls it", async () => {
148+
const html = `
149+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
150+
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
151+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
152+
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
153+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
154+
<script>
155+
window.__timelines = window.__timelines || {};
156+
const tl = gsap.timeline({ paused: true });
157+
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
158+
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
159+
window.__timelines["c1"] = tl;
160+
</script>
161+
</body></html>`;
162+
const result = await lintHyperframeHtml(html);
163+
const finding = result.findings.find(
164+
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
165+
);
166+
expect(finding).toBeDefined();
167+
expect(finding?.severity).toBe("error");
168+
expect(finding?.selector).toBe("#tr-flash-1");
169+
expect(finding?.message).toContain("blank/white video");
170+
});
171+
172+
it("does not error when a full-frame transition flash is initially hidden", async () => {
173+
const html = `
174+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
175+
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:990"></div>
176+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
177+
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
178+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
179+
<script>
180+
window.__timelines = window.__timelines || {};
181+
const tl = gsap.timeline({ paused: true });
182+
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
183+
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
184+
window.__timelines["c1"] = tl;
185+
</script>
186+
</body></html>`;
187+
const result = await lintHyperframeHtml(html);
188+
const finding = result.findings.find(
189+
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
190+
);
191+
expect(finding).toBeUndefined();
192+
});
193+
194+
it("does not error when GSAP hides a full-frame transition flash at frame zero", async () => {
195+
const html = `
196+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
197+
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
198+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
199+
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
200+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
201+
<script>
202+
window.__timelines = window.__timelines || {};
203+
const tl = gsap.timeline({ paused: true });
204+
tl.set("#tr-flash-1", { opacity: 0 }, 0);
205+
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
206+
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
207+
window.__timelines["c1"] = tl;
208+
</script>
209+
</body></html>`;
210+
const result = await lintHyperframeHtml(html);
211+
const finding = result.findings.find(
212+
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
213+
);
214+
expect(finding).toBeUndefined();
215+
});
216+
217+
it("reports one full-frame transition flash finding when multiple scripts touch it", async () => {
218+
const html = `
219+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
220+
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
221+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
222+
<section class="clip" data-start="8" data-duration="8"><h1>Scene 2</h1></section>
223+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
224+
<script>
225+
window.__timelines = window.__timelines || {};
226+
const tl = gsap.timeline({ paused: true });
227+
tl.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
228+
tl.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
229+
window.__timelines["c1"] = tl;
230+
</script>
231+
<script>
232+
const tl2 = gsap.timeline({ paused: true });
233+
tl2.to("#tr-flash-1", { opacity: 1, duration: 0.08 }, 9.92);
234+
tl2.to("#tr-flash-1", { opacity: 0, duration: 0.18 }, 10.00);
235+
</script>
236+
</body></html>`;
237+
const result = await lintHyperframeHtml(html);
238+
expect(
239+
result.findings.filter((f) => f.code === "gsap_fullscreen_overlay_starts_visible"),
240+
).toHaveLength(1);
241+
});
242+
243+
it("errors when a full-frame transition flash uses a GSAP from reveal", async () => {
244+
const html = `
245+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
246+
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
247+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
248+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
249+
<script>
250+
window.__timelines = window.__timelines || {};
251+
const tl = gsap.timeline({ paused: true });
252+
tl.from("#tr-flash-1", { opacity: 0, duration: 0.18 }, 7.92);
253+
window.__timelines["c1"] = tl;
254+
</script>
255+
</body></html>`;
256+
const result = await lintHyperframeHtml(html);
257+
const finding = result.findings.find(
258+
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
259+
);
260+
expect(finding).toBeDefined();
261+
expect(finding?.selector).toBe("#tr-flash-1");
262+
});
263+
264+
it("errors when a grouped GSAP selector targets a visible full-frame flash", async () => {
265+
const html = `
266+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
267+
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
268+
<div id="unused"></div>
269+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
270+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
271+
<script>
272+
window.__timelines = window.__timelines || {};
273+
const tl = gsap.timeline({ paused: true });
274+
tl.to("#unused, #tr-flash-1", { opacity: 1, duration: 0.08 }, 7.92);
275+
tl.to("#unused, #tr-flash-1", { opacity: 0, duration: 0.18 }, 8.00);
276+
window.__timelines["c1"] = tl;
277+
</script>
278+
</body></html>`;
279+
const result = await lintHyperframeHtml(html);
280+
const finding = result.findings.find(
281+
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
282+
);
283+
expect(finding).toBeDefined();
284+
expect(finding?.selector).toBe("#tr-flash-1");
285+
});
286+
287+
it("errors when full-frame transition flash styles come from a style block", async () => {
288+
const html = `
289+
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
290+
<style>
291+
.tr-flash { position: fixed; inset: 0; background: #fff; pointer-events: none; z-index: 990; }
292+
</style>
293+
<div id="tr-flash-1" class="tr-flash"></div>
294+
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
295+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
296+
<script>
297+
window.__timelines = window.__timelines || {};
298+
const tl = gsap.timeline({ paused: true });
299+
tl.to(".tr-flash", { opacity: 1, duration: 0.08 }, 7.92);
300+
tl.to(".tr-flash", { opacity: 0, duration: 0.18 }, 8.00);
301+
window.__timelines["c1"] = tl;
302+
</script>
303+
</body></html>`;
304+
const result = await lintHyperframeHtml(html);
305+
const finding = result.findings.find(
306+
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
307+
);
308+
expect(finding).toBeDefined();
309+
expect(finding?.selector).toBe(".tr-flash");
310+
});
311+
147312
it("does NOT error when GSAP animates opacity on a clip element", async () => {
148313
const html = `
149314
<html><body>

packages/core/src/lint/rules/gsap.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,39 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
170170
);
171171
}
172172

173+
function oneValue(
174+
values: Record<string, string | number>,
175+
keys: string[],
176+
): string | number | undefined {
177+
for (const key of keys) {
178+
const value = values[key];
179+
if (value !== undefined) return value;
180+
}
181+
return undefined;
182+
}
183+
184+
function isVisibleGsapState(values: Record<string, string | number>): boolean {
185+
const opacity = oneValue(values, ["opacity", "autoAlpha"]);
186+
if (typeof opacity === "number") return opacity > 0;
187+
if (typeof opacity === "string" && opacity.trim()) {
188+
const numeric = Number(opacity);
189+
if (Number.isFinite(numeric)) return numeric > 0;
190+
}
191+
192+
const visibility = stringValue(values.visibility)?.toLowerCase();
193+
if (visibility === "visible" || visibility === "inherit") return true;
194+
195+
const display = stringValue(values.display)?.toLowerCase();
196+
if (display && display !== "none") return true;
197+
198+
return false;
199+
}
200+
201+
function makesOverlayVisible(win: GsapWindow): boolean {
202+
if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true;
203+
return isVisibleGsapState(win.propertyValues);
204+
}
205+
173206
function isSceneBoundaryExit(win: GsapWindow): boolean {
174207
if (win.end <= win.position) return false;
175208
if (win.method !== "to" && win.method !== "fromTo") return false;
@@ -282,6 +315,81 @@ function getSingleClassSelector(selector: string): string | null {
282315
return match?.groups?.name || null;
283316
}
284317

318+
function readStyleProperty(style: string, property: string): string | null {
319+
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
320+
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
321+
return match?.[1]?.trim() || null;
322+
}
323+
324+
function cssZero(value: string | null): boolean {
325+
if (!value) return false;
326+
return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim());
327+
}
328+
329+
function styleHasHiddenInitialState(style: string): boolean {
330+
const opacity = readStyleProperty(style, "opacity");
331+
if (opacity && Number(opacity) === 0) return true;
332+
if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden") return true;
333+
if (readStyleProperty(style, "display")?.toLowerCase() === "none") return true;
334+
return false;
335+
}
336+
337+
function styleHasOpaqueBackground(style: string): boolean {
338+
const background =
339+
readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
340+
if (!background) return false;
341+
const normalized = background.toLowerCase().replace(/\s+/g, "");
342+
if (normalized === "transparent" || normalized === "none") return false;
343+
if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
344+
if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false;
345+
return true;
346+
}
347+
348+
function styleLooksFullFrameOverlay(style: string): boolean {
349+
const position = readStyleProperty(style, "position")?.toLowerCase();
350+
if (position !== "fixed" && position !== "absolute") return false;
351+
const coversFrame =
352+
cssZero(readStyleProperty(style, "inset")) ||
353+
(cssZero(readStyleProperty(style, "top")) &&
354+
cssZero(readStyleProperty(style, "right")) &&
355+
cssZero(readStyleProperty(style, "bottom")) &&
356+
cssZero(readStyleProperty(style, "left")));
357+
return coversFrame && styleHasOpaqueBackground(style);
358+
}
359+
360+
function collectSimpleStyleRules(styles: LintContext["styles"]): Map<string, string> {
361+
const rules = new Map<string, string>();
362+
for (const style of styles) {
363+
for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
364+
if (!selectorList || !body) continue;
365+
for (const selector of selectorList.split(",")) {
366+
const token = selector.trim();
367+
if (!/^[#.][A-Za-z0-9_-]+$/.test(token)) continue;
368+
rules.set(token, `${rules.get(token) || ""};${body}`);
369+
}
370+
}
371+
}
372+
return rules;
373+
}
374+
375+
function tagSimpleSelectors(tag: OpenTag): string[] {
376+
const selectors: string[] = [];
377+
const id = readAttr(tag.raw, "id");
378+
if (id) selectors.push(`#${id}`);
379+
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
380+
for (const className of classes) selectors.push(`.${className}`);
381+
return selectors;
382+
}
383+
384+
function combinedTagStyle(tag: OpenTag, styleRules: Map<string, string>): string {
385+
const styles = [readAttr(tag.raw, "style") || ""];
386+
for (const selector of tagSimpleSelectors(tag)) {
387+
const ruleStyle = styleRules.get(selector);
388+
if (ruleStyle) styles.push(ruleStyle);
389+
}
390+
return styles.filter(Boolean).join(";");
391+
}
392+
285393
// fallow-ignore-next-line complexity
286394
function cssTransformToGsapProps(cssTransform: string): string | null {
287395
const parts: string[] = [];
@@ -399,7 +507,7 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[
399507
export const gsapRules: LintRule<LintContext>[] = [
400508
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
401509
// fallow-ignore-next-line complexity
402-
async ({ source, tags, scripts, rootCompositionId }) => {
510+
async ({ source, tags, scripts, styles, rootCompositionId }) => {
403511
const findings: HyperframeLintFinding[] = [];
404512

405513
// Build clip element selector map
@@ -424,6 +532,8 @@ export const gsapRules: LintRule<LintContext>[] = [
424532

425533
const classUsage = countClassUsage(tags);
426534
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
535+
const styleRules = collectSimpleStyleRules(styles);
536+
const reportedVisibleOverlayKeys = new Set<string>();
427537

428538
for (const script of scripts) {
429539
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
@@ -486,6 +596,59 @@ export const gsapRules: LintRule<LintContext>[] = [
486596
}
487597
}
488598

599+
// gsap_fullscreen_overlay_starts_visible
600+
for (const tag of tags) {
601+
const selectors = tagSimpleSelectors(tag);
602+
if (selectors.length === 0) continue;
603+
const overlayKey = readAttr(tag.raw, "id") || String(tag.index);
604+
if (reportedVisibleOverlayKeys.has(overlayKey)) continue;
605+
const authoredStyle = combinedTagStyle(tag, styleRules);
606+
if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue;
607+
if (styleHasHiddenInitialState(authoredStyle)) continue;
608+
609+
const visibilityWindows = gsapWindows
610+
.filter((win) => {
611+
const tokens = targetedSelectorTokens(win.targetSelector);
612+
if (!selectors.some((selector) => tokens.has(selector))) return false;
613+
return win.properties.some((prop) =>
614+
["opacity", "autoAlpha", "visibility", "display"].includes(prop),
615+
);
616+
})
617+
.sort((a, b) => a.position - b.position);
618+
const startsHiddenAtZero = visibilityWindows.some(
619+
(win) =>
620+
win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues),
621+
);
622+
if (startsHiddenAtZero) continue;
623+
const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));
624+
if (!firstVisible) continue;
625+
const selector =
626+
selectors.find((candidate) =>
627+
targetedSelectorTokens(firstVisible.targetSelector).has(candidate),
628+
) ||
629+
selectors[0] ||
630+
tag.name;
631+
const laterHidden = visibilityWindows.some(
632+
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues),
633+
);
634+
if (firstVisible.method !== "from" && !laterHidden) continue;
635+
636+
reportedVisibleOverlayKeys.add(overlayKey);
637+
findings.push({
638+
code: "gsap_fullscreen_overlay_starts_visible",
639+
severity: "error",
640+
message:
641+
`Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` +
642+
`${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
643+
selector,
644+
elementId: readAttr(tag.raw, "id") || undefined,
645+
fixHint:
646+
`Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` +
647+
`\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
648+
snippet: truncateSnippet(firstVisible.raw),
649+
});
650+
}
651+
489652
// gsap_animates_clip_element — only error when GSAP animates visibility/display
490653
for (const win of gsapWindows) {
491654
const sel = win.targetSelector;

0 commit comments

Comments
 (0)