@hyperframes/core 0.7.13 → 0.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compiler/staticGuard.js +1 -1
- package/dist/compiler/staticGuard.js.map +1 -1
- package/dist/core.types.d.ts +2 -369
- package/dist/core.types.d.ts.map +1 -1
- package/dist/core.types.js +1 -84
- package/dist/core.types.js.map +1 -1
- package/dist/generators/hyperframes.d.ts +1 -1
- package/dist/generators/hyperframes.d.ts.map +1 -1
- package/dist/generators/hyperframes.js +1 -1
- package/dist/generators/hyperframes.js.map +1 -1
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/lint/index.d.ts +2 -2
- package/dist/lint/index.d.ts.map +1 -1
- package/dist/lint/index.js +2 -1
- package/dist/lint/index.js.map +1 -1
- package/dist/parsers/gsapConstants.d.ts +2 -12
- package/dist/parsers/gsapConstants.d.ts.map +1 -1
- package/dist/parsers/gsapConstants.js +2 -110
- package/dist/parsers/gsapConstants.js.map +1 -1
- package/dist/parsers/gsapParserAcorn.d.ts +2 -48
- package/dist/parsers/gsapParserAcorn.d.ts.map +1 -1
- package/dist/parsers/gsapParserAcorn.js +2 -1057
- package/dist/parsers/gsapParserAcorn.js.map +1 -1
- package/dist/parsers/gsapParserExports.d.ts +2 -20
- package/dist/parsers/gsapParserExports.d.ts.map +1 -1
- package/dist/parsers/gsapParserExports.js +2 -8
- package/dist/parsers/gsapParserExports.js.map +1 -1
- package/dist/parsers/gsapWriterAcorn.d.ts +2 -90
- package/dist/parsers/gsapWriterAcorn.d.ts.map +1 -1
- package/dist/parsers/gsapWriterAcorn.js +2 -2016
- package/dist/parsers/gsapWriterAcorn.js.map +1 -1
- package/dist/parsers/hfIds.d.ts +2 -16
- package/dist/parsers/hfIds.d.ts.map +1 -1
- package/dist/parsers/hfIds.js +2 -127
- package/dist/parsers/hfIds.js.map +1 -1
- package/dist/parsers/springEase.d.ts +2 -28
- package/dist/parsers/springEase.d.ts.map +1 -1
- package/dist/parsers/springEase.js +2 -69
- package/dist/parsers/springEase.js.map +1 -1
- package/dist/slideshow/index.d.ts +1 -0
- package/dist/slideshow/index.d.ts.map +1 -1
- package/dist/slideshow/index.js +1 -0
- package/dist/slideshow/index.js.map +1 -1
- package/dist/studio-api/helpers/draftMarkers.d.ts +2 -10
- package/dist/studio-api/helpers/draftMarkers.d.ts.map +1 -1
- package/dist/studio-api/helpers/draftMarkers.js +2 -10
- package/dist/studio-api/helpers/draftMarkers.js.map +1 -1
- package/dist/studio-api/helpers/finiteMutation.d.ts +2 -10
- package/dist/studio-api/helpers/finiteMutation.d.ts.map +1 -1
- package/dist/studio-api/helpers/finiteMutation.js +2 -20
- package/dist/studio-api/helpers/finiteMutation.js.map +1 -1
- package/dist/studio-api/helpers/manualEditsRenderScript.d.ts +2 -12
- package/dist/studio-api/helpers/manualEditsRenderScript.d.ts.map +1 -1
- package/dist/studio-api/helpers/manualEditsRenderScript.js +2 -632
- package/dist/studio-api/helpers/manualEditsRenderScript.js.map +1 -1
- package/dist/studio-api/helpers/screenshotClip.d.ts +2 -7
- package/dist/studio-api/helpers/screenshotClip.d.ts.map +1 -1
- package/dist/studio-api/helpers/screenshotClip.js +2 -21
- package/dist/studio-api/helpers/screenshotClip.js.map +1 -1
- package/dist/studio-api/helpers/studioMotionRenderScript.d.ts +2 -8
- package/dist/studio-api/helpers/studioMotionRenderScript.d.ts.map +1 -1
- package/dist/studio-api/helpers/studioMotionRenderScript.js +2 -207
- package/dist/studio-api/helpers/studioMotionRenderScript.js.map +1 -1
- package/dist/studio-api/index.d.ts +2 -9
- package/dist/studio-api/index.d.ts.map +1 -1
- package/dist/studio-api/index.js +2 -8
- package/dist/studio-api/index.js.map +1 -1
- package/package.json +16 -18
- package/dist/generated/runtime-inline.js +0 -11
- package/dist/hyperframe.manifest.json +0 -22
- package/dist/hyperframe.runtime.mjs +0 -325
- package/dist/lint/context.d.ts +0 -17
- package/dist/lint/context.d.ts.map +0 -1
- package/dist/lint/context.js +0 -37
- package/dist/lint/context.js.map +0 -1
- package/dist/lint/hyperframeLinter.d.ts +0 -14
- package/dist/lint/hyperframeLinter.d.ts.map +0 -1
- package/dist/lint/hyperframeLinter.js +0 -132
- package/dist/lint/hyperframeLinter.js.map +0 -1
- package/dist/lint/rules/adapters.d.ts +0 -3
- package/dist/lint/rules/adapters.d.ts.map +0 -1
- package/dist/lint/rules/adapters.js +0 -40
- package/dist/lint/rules/adapters.js.map +0 -1
- package/dist/lint/rules/captions.d.ts +0 -3
- package/dist/lint/rules/captions.d.ts.map +0 -1
- package/dist/lint/rules/captions.js +0 -242
- package/dist/lint/rules/captions.js.map +0 -1
- package/dist/lint/rules/composition.d.ts +0 -5
- package/dist/lint/rules/composition.d.ts.map +0 -1
- package/dist/lint/rules/composition.js +0 -694
- package/dist/lint/rules/composition.js.map +0 -1
- package/dist/lint/rules/core.d.ts +0 -3
- package/dist/lint/rules/core.d.ts.map +0 -1
- package/dist/lint/rules/core.js +0 -432
- package/dist/lint/rules/core.js.map +0 -1
- package/dist/lint/rules/fonts.d.ts +0 -3
- package/dist/lint/rules/fonts.d.ts.map +0 -1
- package/dist/lint/rules/fonts.js +0 -205
- package/dist/lint/rules/fonts.js.map +0 -1
- package/dist/lint/rules/gsap.d.ts +0 -4
- package/dist/lint/rules/gsap.d.ts.map +0 -1
- package/dist/lint/rules/gsap.js +0 -1039
- package/dist/lint/rules/gsap.js.map +0 -1
- package/dist/lint/rules/media.d.ts +0 -3
- package/dist/lint/rules/media.d.ts.map +0 -1
- package/dist/lint/rules/media.js +0 -491
- package/dist/lint/rules/media.js.map +0 -1
- package/dist/lint/rules/slideshow.d.ts +0 -4
- package/dist/lint/rules/slideshow.d.ts.map +0 -1
- package/dist/lint/rules/slideshow.js +0 -74
- package/dist/lint/rules/slideshow.js.map +0 -1
- package/dist/lint/rules/textures.d.ts +0 -3
- package/dist/lint/rules/textures.d.ts.map +0 -1
- package/dist/lint/rules/textures.js +0 -192
- package/dist/lint/rules/textures.js.map +0 -1
- package/dist/lint/types.d.ts +0 -36
- package/dist/lint/types.d.ts.map +0 -1
- package/dist/lint/types.js +0 -2
- package/dist/lint/types.js.map +0 -1
- package/dist/lint/utils.d.ts +0 -55
- package/dist/lint/utils.d.ts.map +0 -1
- package/dist/lint/utils.js +0 -264
- package/dist/lint/utils.js.map +0 -1
- package/dist/parsers/gsapInline.d.ts +0 -47
- package/dist/parsers/gsapInline.d.ts.map +0 -1
- package/dist/parsers/gsapInline.js +0 -513
- package/dist/parsers/gsapInline.js.map +0 -1
- package/dist/parsers/gsapParser.d.ts +0 -158
- package/dist/parsers/gsapParser.d.ts.map +0 -1
- package/dist/parsers/gsapParser.js +0 -2634
- package/dist/parsers/gsapParser.js.map +0 -1
- package/dist/parsers/gsapParser.test-helpers.d.ts +0 -49
- package/dist/parsers/gsapParser.test-helpers.d.ts.map +0 -1
- package/dist/parsers/gsapParser.test-helpers.js +0 -97
- package/dist/parsers/gsapParser.test-helpers.js.map +0 -1
- package/dist/parsers/gsapSerialize.d.ts +0 -191
- package/dist/parsers/gsapSerialize.d.ts.map +0 -1
- package/dist/parsers/gsapSerialize.js +0 -410
- package/dist/parsers/gsapSerialize.js.map +0 -1
- package/dist/parsers/gsapUnroll.d.ts +0 -7
- package/dist/parsers/gsapUnroll.d.ts.map +0 -1
- package/dist/parsers/gsapUnroll.js +0 -137
- package/dist/parsers/gsapUnroll.js.map +0 -1
- package/dist/parsers/htmlParser.d.ts +0 -27
- package/dist/parsers/htmlParser.d.ts.map +0 -1
- package/dist/parsers/htmlParser.js +0 -731
- package/dist/parsers/htmlParser.js.map +0 -1
- package/dist/studio-api/createStudioApi.d.ts +0 -10
- package/dist/studio-api/createStudioApi.d.ts.map +0 -1
- package/dist/studio-api/createStudioApi.js +0 -32
- package/dist/studio-api/createStudioApi.js.map +0 -1
- package/dist/studio-api/helpers/backupJournal.d.ts +0 -9
- package/dist/studio-api/helpers/backupJournal.d.ts.map +0 -1
- package/dist/studio-api/helpers/backupJournal.js +0 -81
- package/dist/studio-api/helpers/backupJournal.js.map +0 -1
- package/dist/studio-api/helpers/hfIdPersist.d.ts +0 -11
- package/dist/studio-api/helpers/hfIdPersist.d.ts.map +0 -1
- package/dist/studio-api/helpers/hfIdPersist.js +0 -39
- package/dist/studio-api/helpers/hfIdPersist.js.map +0 -1
- package/dist/studio-api/helpers/mediaValidation.d.ts +0 -20
- package/dist/studio-api/helpers/mediaValidation.d.ts.map +0 -1
- package/dist/studio-api/helpers/mediaValidation.js +0 -56
- package/dist/studio-api/helpers/mediaValidation.js.map +0 -1
- package/dist/studio-api/helpers/mime.d.ts +0 -4
- package/dist/studio-api/helpers/mime.d.ts.map +0 -1
- package/dist/studio-api/helpers/mime.js +0 -39
- package/dist/studio-api/helpers/mime.js.map +0 -1
- package/dist/studio-api/helpers/previewAdapter.d.ts +0 -43
- package/dist/studio-api/helpers/previewAdapter.d.ts.map +0 -1
- package/dist/studio-api/helpers/previewAdapter.js +0 -118
- package/dist/studio-api/helpers/previewAdapter.js.map +0 -1
- package/dist/studio-api/helpers/projectSignature.d.ts +0 -5
- package/dist/studio-api/helpers/projectSignature.d.ts.map +0 -1
- package/dist/studio-api/helpers/projectSignature.js +0 -154
- package/dist/studio-api/helpers/projectSignature.js.map +0 -1
- package/dist/studio-api/helpers/safePath.d.ts +0 -12
- package/dist/studio-api/helpers/safePath.d.ts.map +0 -1
- package/dist/studio-api/helpers/safePath.js +0 -38
- package/dist/studio-api/helpers/safePath.js.map +0 -1
- package/dist/studio-api/helpers/sourceMutation.d.ts +0 -28
- package/dist/studio-api/helpers/sourceMutation.d.ts.map +0 -1
- package/dist/studio-api/helpers/sourceMutation.js +0 -339
- package/dist/studio-api/helpers/sourceMutation.js.map +0 -1
- package/dist/studio-api/helpers/subComposition.d.ts +0 -20
- package/dist/studio-api/helpers/subComposition.d.ts.map +0 -1
- package/dist/studio-api/helpers/subComposition.js +0 -289
- package/dist/studio-api/helpers/subComposition.js.map +0 -1
- package/dist/studio-api/helpers/waveform.d.ts +0 -4
- package/dist/studio-api/helpers/waveform.d.ts.map +0 -1
- package/dist/studio-api/helpers/waveform.js +0 -80
- package/dist/studio-api/helpers/waveform.js.map +0 -1
- package/dist/studio-api/routes/files.d.ts +0 -4
- package/dist/studio-api/routes/files.d.ts.map +0 -1
- package/dist/studio-api/routes/files.js +0 -1200
- package/dist/studio-api/routes/files.js.map +0 -1
- package/dist/studio-api/routes/fonts.d.ts +0 -3
- package/dist/studio-api/routes/fonts.d.ts.map +0 -1
- package/dist/studio-api/routes/fonts.js +0 -157
- package/dist/studio-api/routes/fonts.js.map +0 -1
- package/dist/studio-api/routes/lint.d.ts +0 -4
- package/dist/studio-api/routes/lint.d.ts.map +0 -1
- package/dist/studio-api/routes/lint.js +0 -29
- package/dist/studio-api/routes/lint.js.map +0 -1
- package/dist/studio-api/routes/preview.d.ts +0 -4
- package/dist/studio-api/routes/preview.d.ts.map +0 -1
- package/dist/studio-api/routes/preview.js +0 -331
- package/dist/studio-api/routes/preview.js.map +0 -1
- package/dist/studio-api/routes/projects.d.ts +0 -4
- package/dist/studio-api/routes/projects.d.ts.map +0 -1
- package/dist/studio-api/routes/projects.js +0 -45
- package/dist/studio-api/routes/projects.js.map +0 -1
- package/dist/studio-api/routes/registry.d.ts +0 -4
- package/dist/studio-api/routes/registry.d.ts.map +0 -1
- package/dist/studio-api/routes/registry.js +0 -31
- package/dist/studio-api/routes/registry.js.map +0 -1
- package/dist/studio-api/routes/render.d.ts +0 -4
- package/dist/studio-api/routes/render.d.ts.map +0 -1
- package/dist/studio-api/routes/render.js +0 -269
- package/dist/studio-api/routes/render.js.map +0 -1
- package/dist/studio-api/routes/storyboard.d.ts +0 -4
- package/dist/studio-api/routes/storyboard.d.ts.map +0 -1
- package/dist/studio-api/routes/storyboard.js +0 -65
- package/dist/studio-api/routes/storyboard.js.map +0 -1
- package/dist/studio-api/routes/thumbnail.d.ts +0 -4
- package/dist/studio-api/routes/thumbnail.d.ts.map +0 -1
- package/dist/studio-api/routes/thumbnail.js +0 -107
- package/dist/studio-api/routes/thumbnail.js.map +0 -1
- package/dist/studio-api/routes/waveform.d.ts +0 -4
- package/dist/studio-api/routes/waveform.d.ts.map +0 -1
- package/dist/studio-api/routes/waveform.js +0 -41
- package/dist/studio-api/routes/waveform.js.map +0 -1
- package/dist/studio-api/types.d.ts +0 -120
- package/dist/studio-api/types.d.ts.map +0 -1
- package/dist/studio-api/types.js +0 -2
- package/dist/studio-api/types.js.map +0 -1
package/dist/lint/rules/gsap.js
DELETED
|
@@ -1,1039 +0,0 @@
|
|
|
1
|
-
// Use the acorn read parser: it resolves computed timelines (helpers, bounded
|
|
2
|
-
// loops) so lint findings like overlapping_gsap_tweens reflect true positions
|
|
3
|
-
// instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast
|
|
4
|
-
// out of the lint graph entirely. Dynamic import preserves the lazy load.
|
|
5
|
-
async function loadParseGsapScript() {
|
|
6
|
-
const mod = await import("../../parsers/gsapParserAcorn.js");
|
|
7
|
-
return mod.parseGsapScriptAcorn;
|
|
8
|
-
}
|
|
9
|
-
import { readAttr, truncateSnippet, stripJsComments, WINDOW_TIMELINE_ASSIGN_PATTERN, } from "../utils.js";
|
|
10
|
-
const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
|
11
|
-
// ── GSAP parsing utilities ─────────────────────────────────────────────────
|
|
12
|
-
function countClassUsage(tags) {
|
|
13
|
-
const counts = new Map();
|
|
14
|
-
for (const tag of tags) {
|
|
15
|
-
const classAttr = readAttr(tag.raw, "class");
|
|
16
|
-
if (!classAttr)
|
|
17
|
-
continue;
|
|
18
|
-
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
|
|
19
|
-
counts.set(className, (counts.get(className) || 0) + 1);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return counts;
|
|
23
|
-
}
|
|
24
|
-
function readRegisteredTimelineCompositionId(script) {
|
|
25
|
-
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
|
26
|
-
return match?.[1] || match?.[2] || null;
|
|
27
|
-
}
|
|
28
|
-
/** Strip a `__raw:` prefix the parser adds to unresolvable values. */
|
|
29
|
-
function unwrapRaw(value) {
|
|
30
|
-
if (typeof value === "number")
|
|
31
|
-
return value;
|
|
32
|
-
if (typeof value !== "string")
|
|
33
|
-
return undefined;
|
|
34
|
-
const code = value.startsWith("__raw:") ? value.slice(6) : value;
|
|
35
|
-
return code.replace(/^\s*["']|["']\s*$/g, "");
|
|
36
|
-
}
|
|
37
|
-
function extrasNumber(value) {
|
|
38
|
-
const unwrapped = unwrapRaw(value);
|
|
39
|
-
const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped);
|
|
40
|
-
return Number.isFinite(numeric) ? numeric : 0;
|
|
41
|
-
}
|
|
42
|
-
/** A readable single-line snippet of a tween for finding messages. */
|
|
43
|
-
function synthesizeWindowRaw(timelineVar, anim) {
|
|
44
|
-
const entries = Object.entries(anim.properties).map(([k, v]) => {
|
|
45
|
-
if (typeof v === "string" && v.startsWith("__raw:"))
|
|
46
|
-
return `${k}: ${v.slice(6)}`;
|
|
47
|
-
return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`;
|
|
48
|
-
});
|
|
49
|
-
if (anim.duration !== undefined)
|
|
50
|
-
entries.push(`duration: ${anim.duration}`);
|
|
51
|
-
if (anim.ease)
|
|
52
|
-
entries.push(`ease: ${JSON.stringify(anim.ease)}`);
|
|
53
|
-
const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position);
|
|
54
|
-
return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
|
|
55
|
-
}
|
|
56
|
-
const gsapWindowsCache = new Map();
|
|
57
|
-
async function cachedExtractGsapWindows(scriptContent) {
|
|
58
|
-
const cached = gsapWindowsCache.get(scriptContent);
|
|
59
|
-
if (cached)
|
|
60
|
-
return cached;
|
|
61
|
-
const windows = await extractGsapWindows(scriptContent);
|
|
62
|
-
gsapWindowsCache.set(scriptContent, windows);
|
|
63
|
-
return windows;
|
|
64
|
-
}
|
|
65
|
-
// fallow-ignore-next-line complexity
|
|
66
|
-
async function extractGsapWindows(script) {
|
|
67
|
-
if (!/gsap\.timeline/.test(script))
|
|
68
|
-
return [];
|
|
69
|
-
const parseGsapScript = await loadParseGsapScript();
|
|
70
|
-
const parsed = parseGsapScript(script);
|
|
71
|
-
if (parsed.animations.length === 0)
|
|
72
|
-
return [];
|
|
73
|
-
const windows = [];
|
|
74
|
-
for (const animation of parsed.animations) {
|
|
75
|
-
// Skip animations with string positions (e.g. "+=1", "<") — their absolute
|
|
76
|
-
// timing depends on runtime evaluation and can't be statically linted.
|
|
77
|
-
if (typeof animation.position !== "number")
|
|
78
|
-
continue;
|
|
79
|
-
const repeat = extrasNumber(animation.extras?.repeat);
|
|
80
|
-
const cycleCount = repeat > 0 ? repeat + 1 : 1;
|
|
81
|
-
const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
|
|
82
|
-
windows.push({
|
|
83
|
-
targetSelector: animation.targetSelector,
|
|
84
|
-
position: animation.position,
|
|
85
|
-
end: animation.position + effectiveDuration,
|
|
86
|
-
properties: Object.keys(animation.properties),
|
|
87
|
-
propertyValues: animation.properties,
|
|
88
|
-
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
|
|
89
|
-
method: animation.method,
|
|
90
|
-
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
return windows;
|
|
94
|
-
}
|
|
95
|
-
function numberValue(value) {
|
|
96
|
-
if (typeof value === "number")
|
|
97
|
-
return value;
|
|
98
|
-
if (typeof value === "string" && value.trim()) {
|
|
99
|
-
const numeric = Number(value);
|
|
100
|
-
return Number.isFinite(numeric) ? numeric : null;
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
function stringValue(value) {
|
|
105
|
-
if (typeof value === "string")
|
|
106
|
-
return value;
|
|
107
|
-
if (typeof value === "number")
|
|
108
|
-
return String(value);
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
function zeroValue(value) {
|
|
112
|
-
if (typeof value === "number")
|
|
113
|
-
return value === 0;
|
|
114
|
-
if (typeof value !== "string")
|
|
115
|
-
return false;
|
|
116
|
-
return Number(value.trim()) === 0;
|
|
117
|
-
}
|
|
118
|
-
function isHiddenGsapState(values) {
|
|
119
|
-
const visibility = stringValue(values.visibility)?.toLowerCase();
|
|
120
|
-
const display = stringValue(values.display)?.toLowerCase();
|
|
121
|
-
return (zeroValue(values.opacity) ||
|
|
122
|
-
zeroValue(values.autoAlpha) ||
|
|
123
|
-
visibility === "hidden" ||
|
|
124
|
-
display === "none");
|
|
125
|
-
}
|
|
126
|
-
function oneValue(values, keys) {
|
|
127
|
-
for (const key of keys) {
|
|
128
|
-
const value = values[key];
|
|
129
|
-
if (value !== undefined)
|
|
130
|
-
return value;
|
|
131
|
-
}
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
function isVisibleGsapState(values) {
|
|
135
|
-
const opacity = oneValue(values, ["opacity", "autoAlpha"]);
|
|
136
|
-
if (typeof opacity === "number")
|
|
137
|
-
return opacity > 0;
|
|
138
|
-
if (typeof opacity === "string" && opacity.trim()) {
|
|
139
|
-
const numeric = Number(opacity);
|
|
140
|
-
if (Number.isFinite(numeric))
|
|
141
|
-
return numeric > 0;
|
|
142
|
-
}
|
|
143
|
-
const visibility = stringValue(values.visibility)?.toLowerCase();
|
|
144
|
-
if (visibility === "visible" || visibility === "inherit")
|
|
145
|
-
return true;
|
|
146
|
-
const display = stringValue(values.display)?.toLowerCase();
|
|
147
|
-
if (display && display !== "none")
|
|
148
|
-
return true;
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
function makesOverlayVisible(win) {
|
|
152
|
-
if (win.method === "from" && isHiddenGsapState(win.propertyValues))
|
|
153
|
-
return true;
|
|
154
|
-
return isVisibleGsapState(win.propertyValues);
|
|
155
|
-
}
|
|
156
|
-
function isSceneBoundaryExit(win) {
|
|
157
|
-
if (win.end <= win.position)
|
|
158
|
-
return false;
|
|
159
|
-
if (win.method !== "to" && win.method !== "fromTo")
|
|
160
|
-
return false;
|
|
161
|
-
return isHiddenGsapState(win.propertyValues);
|
|
162
|
-
}
|
|
163
|
-
function isHardKillSet(win, selector, boundary) {
|
|
164
|
-
return (win.method === "set" &&
|
|
165
|
-
win.targetSelector === selector &&
|
|
166
|
-
Math.abs(win.position - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS &&
|
|
167
|
-
isHiddenGsapState(win.propertyValues));
|
|
168
|
-
}
|
|
169
|
-
function hiddenStateLiteral(values) {
|
|
170
|
-
if (zeroValue(values.autoAlpha))
|
|
171
|
-
return "{ autoAlpha: 0 }";
|
|
172
|
-
if (zeroValue(values.opacity))
|
|
173
|
-
return "{ opacity: 0 }";
|
|
174
|
-
if (stringValue(values.visibility)?.toLowerCase() === "hidden")
|
|
175
|
-
return '{ visibility: "hidden" }';
|
|
176
|
-
if (stringValue(values.display)?.toLowerCase() === "none")
|
|
177
|
-
return '{ display: "none" }';
|
|
178
|
-
return "{ opacity: 0 }";
|
|
179
|
-
}
|
|
180
|
-
function findTagEnd(source, tag) {
|
|
181
|
-
const escapedTagName = tag.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
182
|
-
const pattern = new RegExp(`<\\/?${escapedTagName}\\b[^>]*>`, "gi");
|
|
183
|
-
pattern.lastIndex = tag.index;
|
|
184
|
-
let depth = 0;
|
|
185
|
-
let match;
|
|
186
|
-
while ((match = pattern.exec(source)) !== null) {
|
|
187
|
-
const raw = match[0];
|
|
188
|
-
const isClosing = /^<\s*\//.test(raw);
|
|
189
|
-
const isSelfClosing = /\/\s*>$/.test(raw);
|
|
190
|
-
if (!isClosing && !isSelfClosing)
|
|
191
|
-
depth += 1;
|
|
192
|
-
if (isClosing)
|
|
193
|
-
depth -= 1;
|
|
194
|
-
if (depth === 0)
|
|
195
|
-
return pattern.lastIndex;
|
|
196
|
-
}
|
|
197
|
-
return source.length;
|
|
198
|
-
}
|
|
199
|
-
function collectCompositionRanges(source, tags) {
|
|
200
|
-
return tags
|
|
201
|
-
.map((tag) => {
|
|
202
|
-
const id = readAttr(tag.raw, "data-composition-id");
|
|
203
|
-
if (!id)
|
|
204
|
-
return null;
|
|
205
|
-
return {
|
|
206
|
-
id,
|
|
207
|
-
start: tag.index,
|
|
208
|
-
end: findTagEnd(source, tag),
|
|
209
|
-
};
|
|
210
|
-
})
|
|
211
|
-
.filter((range) => range !== null);
|
|
212
|
-
}
|
|
213
|
-
function findContainingCompositionId(tag, ranges) {
|
|
214
|
-
let match = null;
|
|
215
|
-
for (const range of ranges) {
|
|
216
|
-
if (tag.index < range.start || tag.index >= range.end)
|
|
217
|
-
continue;
|
|
218
|
-
if (!match || range.start >= match.start)
|
|
219
|
-
match = range;
|
|
220
|
-
}
|
|
221
|
-
return match?.id || null;
|
|
222
|
-
}
|
|
223
|
-
function collectClipStartBoundariesByComposition(source, tags) {
|
|
224
|
-
const ranges = collectCompositionRanges(source, tags);
|
|
225
|
-
const boundaries = new Map();
|
|
226
|
-
for (const tag of tags) {
|
|
227
|
-
const classAttr = readAttr(tag.raw, "class") || "";
|
|
228
|
-
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
229
|
-
if (!classes.includes("clip"))
|
|
230
|
-
continue;
|
|
231
|
-
const compositionId = findContainingCompositionId(tag, ranges);
|
|
232
|
-
if (!compositionId)
|
|
233
|
-
continue;
|
|
234
|
-
const start = numberValue(readAttr(tag.raw, "data-start") ?? undefined);
|
|
235
|
-
if (start == null || start <= 0)
|
|
236
|
-
continue;
|
|
237
|
-
const compositionBoundaries = boundaries.get(compositionId) ?? new Set();
|
|
238
|
-
compositionBoundaries.add(start);
|
|
239
|
-
boundaries.set(compositionId, compositionBoundaries);
|
|
240
|
-
}
|
|
241
|
-
return new Map([...boundaries.entries()].map(([compositionId, values]) => [
|
|
242
|
-
compositionId,
|
|
243
|
-
[...values].sort((a, b) => a - b),
|
|
244
|
-
]));
|
|
245
|
-
}
|
|
246
|
-
function findMatchingSceneBoundary(time, boundaries) {
|
|
247
|
-
for (const boundary of boundaries) {
|
|
248
|
-
if (Math.abs(time - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS)
|
|
249
|
-
return boundary;
|
|
250
|
-
}
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
function isSuspiciousGlobalSelector(selector) {
|
|
254
|
-
if (!selector)
|
|
255
|
-
return false;
|
|
256
|
-
if (selector.includes("[data-composition-id="))
|
|
257
|
-
return false;
|
|
258
|
-
if (selector.startsWith("#"))
|
|
259
|
-
return false;
|
|
260
|
-
return selector.startsWith(".") || /^[a-z]/i.test(selector);
|
|
261
|
-
}
|
|
262
|
-
function getSingleClassSelector(selector) {
|
|
263
|
-
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
|
|
264
|
-
return match?.groups?.name || null;
|
|
265
|
-
}
|
|
266
|
-
function readStyleProperty(style, property) {
|
|
267
|
-
const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
268
|
-
const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"));
|
|
269
|
-
return match?.[1]?.trim() || null;
|
|
270
|
-
}
|
|
271
|
-
function cssZero(value) {
|
|
272
|
-
if (!value)
|
|
273
|
-
return false;
|
|
274
|
-
return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim());
|
|
275
|
-
}
|
|
276
|
-
function styleHasHiddenInitialState(style) {
|
|
277
|
-
const opacity = readStyleProperty(style, "opacity");
|
|
278
|
-
if (opacity && Number(opacity) === 0)
|
|
279
|
-
return true;
|
|
280
|
-
if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden")
|
|
281
|
-
return true;
|
|
282
|
-
if (readStyleProperty(style, "display")?.toLowerCase() === "none")
|
|
283
|
-
return true;
|
|
284
|
-
return false;
|
|
285
|
-
}
|
|
286
|
-
function styleHasOpaqueBackground(style) {
|
|
287
|
-
const background = readStyleProperty(style, "background") || readStyleProperty(style, "background-color");
|
|
288
|
-
if (!background)
|
|
289
|
-
return false;
|
|
290
|
-
const normalized = background.toLowerCase().replace(/\s+/g, "");
|
|
291
|
-
if (normalized === "transparent" || normalized === "none")
|
|
292
|
-
return false;
|
|
293
|
-
if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized))
|
|
294
|
-
return false;
|
|
295
|
-
if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized))
|
|
296
|
-
return false;
|
|
297
|
-
return true;
|
|
298
|
-
}
|
|
299
|
-
function styleLooksFullFrameOverlay(style) {
|
|
300
|
-
const position = readStyleProperty(style, "position")?.toLowerCase();
|
|
301
|
-
if (position !== "fixed" && position !== "absolute")
|
|
302
|
-
return false;
|
|
303
|
-
const coversFrame = cssZero(readStyleProperty(style, "inset")) ||
|
|
304
|
-
(cssZero(readStyleProperty(style, "top")) &&
|
|
305
|
-
cssZero(readStyleProperty(style, "right")) &&
|
|
306
|
-
cssZero(readStyleProperty(style, "bottom")) &&
|
|
307
|
-
cssZero(readStyleProperty(style, "left")));
|
|
308
|
-
return coversFrame && styleHasOpaqueBackground(style);
|
|
309
|
-
}
|
|
310
|
-
function collectSimpleStyleRules(styles) {
|
|
311
|
-
const rules = new Map();
|
|
312
|
-
for (const style of styles) {
|
|
313
|
-
for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
|
|
314
|
-
if (!selectorList || !body)
|
|
315
|
-
continue;
|
|
316
|
-
for (const selector of selectorList.split(",")) {
|
|
317
|
-
const token = selector.trim();
|
|
318
|
-
if (!/^[#.][A-Za-z0-9_-]+$/.test(token))
|
|
319
|
-
continue;
|
|
320
|
-
rules.set(token, `${rules.get(token) || ""};${body}`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return rules;
|
|
325
|
-
}
|
|
326
|
-
function tagSimpleSelectors(tag) {
|
|
327
|
-
const selectors = [];
|
|
328
|
-
const id = readAttr(tag.raw, "id");
|
|
329
|
-
if (id)
|
|
330
|
-
selectors.push(`#${id}`);
|
|
331
|
-
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
|
332
|
-
for (const className of classes)
|
|
333
|
-
selectors.push(`.${className}`);
|
|
334
|
-
return selectors;
|
|
335
|
-
}
|
|
336
|
-
function combinedTagStyle(tag, styleRules) {
|
|
337
|
-
const styles = [readAttr(tag.raw, "style") || ""];
|
|
338
|
-
for (const selector of tagSimpleSelectors(tag)) {
|
|
339
|
-
const ruleStyle = styleRules.get(selector);
|
|
340
|
-
if (ruleStyle)
|
|
341
|
-
styles.push(ruleStyle);
|
|
342
|
-
}
|
|
343
|
-
return styles.filter(Boolean).join(";");
|
|
344
|
-
}
|
|
345
|
-
// fallow-ignore-next-line complexity
|
|
346
|
-
function cssTransformToGsapProps(cssTransform) {
|
|
347
|
-
const parts = [];
|
|
348
|
-
// translate(-50%, -50%) or translate(X, Y)
|
|
349
|
-
const translateMatch = cssTransform.match(/translate\(\s*(-?[\d.]+)(%|px)?\s*,\s*(-?[\d.]+)(%|px)?\s*\)/);
|
|
350
|
-
if (translateMatch) {
|
|
351
|
-
const [, xVal, xUnit, yVal, yUnit] = translateMatch;
|
|
352
|
-
if (xUnit === "%")
|
|
353
|
-
parts.push(`xPercent: ${xVal}`);
|
|
354
|
-
else
|
|
355
|
-
parts.push(`x: ${xVal}`);
|
|
356
|
-
if (yUnit === "%")
|
|
357
|
-
parts.push(`yPercent: ${yVal}`);
|
|
358
|
-
else
|
|
359
|
-
parts.push(`y: ${yVal}`);
|
|
360
|
-
}
|
|
361
|
-
// translateX(-50%) or translateX(px)
|
|
362
|
-
const txMatch = cssTransform.match(/translateX\(\s*(-?[\d.]+)(%|px)?\s*\)/);
|
|
363
|
-
if (txMatch) {
|
|
364
|
-
const [, val, unit] = txMatch;
|
|
365
|
-
parts.push(unit === "%" ? `xPercent: ${val}` : `x: ${val}`);
|
|
366
|
-
}
|
|
367
|
-
// translateY(-50%) or translateY(px)
|
|
368
|
-
const tyMatch = cssTransform.match(/translateY\(\s*(-?[\d.]+)(%|px)?\s*\)/);
|
|
369
|
-
if (tyMatch) {
|
|
370
|
-
const [, val, unit] = tyMatch;
|
|
371
|
-
parts.push(unit === "%" ? `yPercent: ${val}` : `y: ${val}`);
|
|
372
|
-
}
|
|
373
|
-
// scale(N)
|
|
374
|
-
const scaleMatch = cssTransform.match(/scale\(\s*([\d.]+)\s*\)/);
|
|
375
|
-
if (scaleMatch) {
|
|
376
|
-
parts.push(`scale: ${scaleMatch[1]}`);
|
|
377
|
-
}
|
|
378
|
-
return parts.length > 0 ? parts.join(", ") : null;
|
|
379
|
-
}
|
|
380
|
-
// ── CSS-transform ↔ GSAP-transform conflict matching ─────────────────────────
|
|
381
|
-
// Transform components that COMBINE with a CSS translate/scale on the same
|
|
382
|
-
// element. GSAP bakes the element's existing CSS transform in when it seeks, so
|
|
383
|
-
// these stack rather than override in the capture path (e.g. CSS translateX(-50%)
|
|
384
|
-
// + xPercent:-50 renders as -100% — off-centre). `rotation` is excluded: it maps
|
|
385
|
-
// to CSS rotate(), which this rule treats separately (no false positive on spin).
|
|
386
|
-
const CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"];
|
|
387
|
-
const CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"];
|
|
388
|
-
// Decompose a (possibly grouped / descendant / compound) GSAP target selector
|
|
389
|
-
// into the simple `#id` / `.class` tokens of the elements it actually targets —
|
|
390
|
-
// the RIGHTMOST compound of each comma group is the targeted element. This lets a
|
|
391
|
-
// CSS rule keyed by a simple selector (`.m04-label`) match a scoped GSAP selector
|
|
392
|
-
// (`"#root .m04-label, #root .m04-sub"`), which the prior exact-string lookup
|
|
393
|
-
// missed — so every scoped/grouped selector slipped past the rule entirely.
|
|
394
|
-
function targetedSelectorTokens(selector) {
|
|
395
|
-
const tokens = new Set();
|
|
396
|
-
for (const group of selector.split(",")) {
|
|
397
|
-
const compounds = group
|
|
398
|
-
.trim()
|
|
399
|
-
.split(/[\s>+~]+/)
|
|
400
|
-
.filter(Boolean);
|
|
401
|
-
const last = compounds[compounds.length - 1];
|
|
402
|
-
if (!last)
|
|
403
|
-
continue;
|
|
404
|
-
const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
|
|
405
|
-
if (simple)
|
|
406
|
-
for (const token of simple)
|
|
407
|
-
tokens.add(token);
|
|
408
|
-
}
|
|
409
|
-
return tokens;
|
|
410
|
-
}
|
|
411
|
-
// Find a CSS transform conflicting with a GSAP target selector: exact-string
|
|
412
|
-
// match first (fast path + back-compat with the original behaviour), then a
|
|
413
|
-
// token match so scoped/grouped/descendant selectors resolve to their class/id.
|
|
414
|
-
function matchCssTransform(gsapSelector, cssMap) {
|
|
415
|
-
if (cssMap.size === 0)
|
|
416
|
-
return undefined;
|
|
417
|
-
const direct = cssMap.get(gsapSelector);
|
|
418
|
-
if (direct)
|
|
419
|
-
return direct;
|
|
420
|
-
const tokens = targetedSelectorTokens(gsapSelector);
|
|
421
|
-
for (const [cssSelector, value] of cssMap) {
|
|
422
|
-
if (tokens.has(cssSelector))
|
|
423
|
-
return value;
|
|
424
|
-
}
|
|
425
|
-
return undefined;
|
|
426
|
-
}
|
|
427
|
-
// Scan for STANDALONE `gsap.set/to/from/fromTo("selector", { ...props })` calls.
|
|
428
|
-
// The acorn timeline parser only captures calls rooted on the timeline var
|
|
429
|
-
// (`tl.to`, `tl.set`, …); a top-level `gsap.set("#root .label", { xPercent: -50 })`
|
|
430
|
-
// — a common way to seat shared base transforms before the timeline runs — is
|
|
431
|
-
// invisible to it, so the conflict rule never saw it. Variable selectors
|
|
432
|
-
// (`gsap.set(kicker, …)`) can't be resolved statically and are skipped.
|
|
433
|
-
function extractStandaloneGsapTransformCalls(script) {
|
|
434
|
-
const calls = [];
|
|
435
|
-
const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g;
|
|
436
|
-
let match;
|
|
437
|
-
while ((match = pattern.exec(script)) !== null) {
|
|
438
|
-
const method = match[1] ?? "set";
|
|
439
|
-
const selector = match[3] ?? "";
|
|
440
|
-
const propsBody = match[4] ?? "";
|
|
441
|
-
const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? "");
|
|
442
|
-
calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] });
|
|
443
|
-
}
|
|
444
|
-
return calls;
|
|
445
|
-
}
|
|
446
|
-
// ── GSAP rules ─────────────────────────────────────────────────────────────
|
|
447
|
-
// fallow-ignore-next-line complexity
|
|
448
|
-
export const gsapRules = [
|
|
449
|
-
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
|
|
450
|
-
// fallow-ignore-next-line complexity
|
|
451
|
-
async ({ source, tags, scripts, styles, rootCompositionId }) => {
|
|
452
|
-
const findings = [];
|
|
453
|
-
const clipIds = new Map();
|
|
454
|
-
const clipClasses = new Map();
|
|
455
|
-
for (const tag of tags) {
|
|
456
|
-
const classAttr = readAttr(tag.raw, "class") || "";
|
|
457
|
-
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
458
|
-
if (!classes.includes("clip"))
|
|
459
|
-
continue;
|
|
460
|
-
const id = readAttr(tag.raw, "id");
|
|
461
|
-
const info = {
|
|
462
|
-
tag: tag.name,
|
|
463
|
-
id: id || "",
|
|
464
|
-
classes: classAttr,
|
|
465
|
-
};
|
|
466
|
-
if (id)
|
|
467
|
-
clipIds.set(`#${id}`, info);
|
|
468
|
-
for (const cls of classes) {
|
|
469
|
-
if (cls !== "clip")
|
|
470
|
-
clipClasses.set(`.${cls}`, info);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
const classUsage = countClassUsage(tags);
|
|
474
|
-
const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags);
|
|
475
|
-
const styleRules = collectSimpleStyleRules(styles);
|
|
476
|
-
const reportedVisibleOverlayKeys = new Set();
|
|
477
|
-
for (const script of scripts) {
|
|
478
|
-
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
|
|
479
|
-
const gsapWindows = await cachedExtractGsapWindows(script.content);
|
|
480
|
-
const clipStartBoundaries = clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];
|
|
481
|
-
// overlapping_gsap_tweens
|
|
482
|
-
for (let i = 0; i < gsapWindows.length; i++) {
|
|
483
|
-
const left = gsapWindows[i];
|
|
484
|
-
if (!left)
|
|
485
|
-
continue;
|
|
486
|
-
if (left.end <= left.position)
|
|
487
|
-
continue;
|
|
488
|
-
for (let j = i + 1; j < gsapWindows.length; j++) {
|
|
489
|
-
const right = gsapWindows[j];
|
|
490
|
-
if (!right)
|
|
491
|
-
continue;
|
|
492
|
-
if (right.end <= right.position)
|
|
493
|
-
continue;
|
|
494
|
-
if (left.targetSelector !== right.targetSelector)
|
|
495
|
-
continue;
|
|
496
|
-
const overlapStart = Math.max(left.position, right.position);
|
|
497
|
-
const overlapEnd = Math.min(left.end, right.end);
|
|
498
|
-
if (overlapEnd <= overlapStart)
|
|
499
|
-
continue;
|
|
500
|
-
if (left.overwriteAuto || right.overwriteAuto)
|
|
501
|
-
continue;
|
|
502
|
-
const sharedProperties = left.properties.filter((prop) => right.properties.includes(prop));
|
|
503
|
-
if (sharedProperties.length === 0)
|
|
504
|
-
continue;
|
|
505
|
-
findings.push({
|
|
506
|
-
code: "overlapping_gsap_tweens",
|
|
507
|
-
severity: "warning",
|
|
508
|
-
message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
|
|
509
|
-
selector: left.targetSelector,
|
|
510
|
-
fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
|
|
511
|
-
snippet: truncateSnippet(`${left.raw}\n${right.raw}`),
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
// gsap_exit_missing_hard_kill
|
|
516
|
-
if (clipStartBoundaries.length > 0) {
|
|
517
|
-
for (const win of gsapWindows) {
|
|
518
|
-
if (!isSceneBoundaryExit(win))
|
|
519
|
-
continue;
|
|
520
|
-
const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
|
|
521
|
-
if (boundary == null)
|
|
522
|
-
continue;
|
|
523
|
-
const hasHardKill = gsapWindows.some((candidate) => isHardKillSet(candidate, win.targetSelector, boundary));
|
|
524
|
-
if (hasHardKill)
|
|
525
|
-
continue;
|
|
526
|
-
findings.push({
|
|
527
|
-
code: "gsap_exit_missing_hard_kill",
|
|
528
|
-
severity: "error",
|
|
529
|
-
message: `GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` +
|
|
530
|
-
"without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
|
|
531
|
-
selector: win.targetSelector,
|
|
532
|
-
fixHint: `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` +
|
|
533
|
-
"after the exit tween.",
|
|
534
|
-
snippet: truncateSnippet(win.raw),
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
// gsap_fullscreen_overlay_starts_visible
|
|
539
|
-
for (const tag of tags) {
|
|
540
|
-
const selectors = tagSimpleSelectors(tag);
|
|
541
|
-
if (selectors.length === 0)
|
|
542
|
-
continue;
|
|
543
|
-
const overlayKey = readAttr(tag.raw, "id") || String(tag.index);
|
|
544
|
-
if (reportedVisibleOverlayKeys.has(overlayKey))
|
|
545
|
-
continue;
|
|
546
|
-
const authoredStyle = combinedTagStyle(tag, styleRules);
|
|
547
|
-
if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle))
|
|
548
|
-
continue;
|
|
549
|
-
if (styleHasHiddenInitialState(authoredStyle))
|
|
550
|
-
continue;
|
|
551
|
-
const visibilityWindows = gsapWindows
|
|
552
|
-
.filter((win) => {
|
|
553
|
-
const tokens = targetedSelectorTokens(win.targetSelector);
|
|
554
|
-
if (!selectors.some((selector) => tokens.has(selector)))
|
|
555
|
-
return false;
|
|
556
|
-
return win.properties.some((prop) => ["opacity", "autoAlpha", "visibility", "display"].includes(prop));
|
|
557
|
-
})
|
|
558
|
-
.sort((a, b) => a.position - b.position);
|
|
559
|
-
const startsHiddenAtZero = visibilityWindows.some((win) => win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues));
|
|
560
|
-
if (startsHiddenAtZero)
|
|
561
|
-
continue;
|
|
562
|
-
const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));
|
|
563
|
-
if (!firstVisible)
|
|
564
|
-
continue;
|
|
565
|
-
const selector = selectors.find((candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate)) ||
|
|
566
|
-
selectors[0] ||
|
|
567
|
-
tag.name;
|
|
568
|
-
const laterHidden = visibilityWindows.some((win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues));
|
|
569
|
-
if (firstVisible.method !== "from" && !laterHidden)
|
|
570
|
-
continue;
|
|
571
|
-
reportedVisibleOverlayKeys.add(overlayKey);
|
|
572
|
-
findings.push({
|
|
573
|
-
code: "gsap_fullscreen_overlay_starts_visible",
|
|
574
|
-
severity: "error",
|
|
575
|
-
message: `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` +
|
|
576
|
-
`${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
|
|
577
|
-
selector,
|
|
578
|
-
elementId: readAttr(tag.raw, "id") || undefined,
|
|
579
|
-
fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add ` +
|
|
580
|
-
`\`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
|
|
581
|
-
snippet: truncateSnippet(firstVisible.raw),
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
// gsap_animates_clip_element — only error when GSAP animates visibility/display
|
|
585
|
-
for (const win of gsapWindows) {
|
|
586
|
-
const sel = win.targetSelector;
|
|
587
|
-
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
|
|
588
|
-
if (!clipInfo)
|
|
589
|
-
continue;
|
|
590
|
-
const conflictingProps = win.properties.filter((p) => p === "visibility" || p === "display");
|
|
591
|
-
if (conflictingProps.length === 0)
|
|
592
|
-
continue;
|
|
593
|
-
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
|
|
594
|
-
findings.push({
|
|
595
|
-
code: "gsap_animates_clip_element",
|
|
596
|
-
severity: "error",
|
|
597
|
-
message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`,
|
|
598
|
-
selector: sel,
|
|
599
|
-
elementId: clipInfo.id || undefined,
|
|
600
|
-
fixHint: "Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
|
|
601
|
-
snippet: truncateSnippet(win.raw),
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
// unscoped_gsap_selector
|
|
605
|
-
if (!localTimelineCompId || localTimelineCompId === rootCompositionId)
|
|
606
|
-
continue;
|
|
607
|
-
for (const win of gsapWindows) {
|
|
608
|
-
if (!isSuspiciousGlobalSelector(win.targetSelector))
|
|
609
|
-
continue;
|
|
610
|
-
const className = getSingleClassSelector(win.targetSelector);
|
|
611
|
-
if (className && (classUsage.get(className) || 0) < 2)
|
|
612
|
-
continue;
|
|
613
|
-
findings.push({
|
|
614
|
-
code: "unscoped_gsap_selector",
|
|
615
|
-
severity: "error",
|
|
616
|
-
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
|
617
|
-
selector: win.targetSelector,
|
|
618
|
-
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
|
619
|
-
snippet: truncateSnippet(win.raw),
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
return findings;
|
|
624
|
-
},
|
|
625
|
-
// gsap_css_transform_conflict
|
|
626
|
-
// fallow-ignore-next-line complexity
|
|
627
|
-
async ({ styles, scripts, tags }) => {
|
|
628
|
-
const findings = [];
|
|
629
|
-
const cssTranslateSelectors = new Map();
|
|
630
|
-
const cssScaleSelectors = new Map();
|
|
631
|
-
// Check <style> blocks for transform rules
|
|
632
|
-
for (const style of styles) {
|
|
633
|
-
for (const [, selector, body] of style.content.matchAll(/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g)) {
|
|
634
|
-
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
|
|
635
|
-
if (!tMatch || !tMatch[1])
|
|
636
|
-
continue;
|
|
637
|
-
const transformVal = tMatch[1].trim();
|
|
638
|
-
if (/translate/i.test(transformVal))
|
|
639
|
-
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
|
|
640
|
-
if (/scale/i.test(transformVal))
|
|
641
|
-
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
// Also check inline style="..." attributes on tags
|
|
645
|
-
for (const tag of tags) {
|
|
646
|
-
const inlineStyle = readAttr(tag.raw, "style");
|
|
647
|
-
if (!inlineStyle)
|
|
648
|
-
continue;
|
|
649
|
-
const tMatch = inlineStyle.match(/transform\s*:\s*([^;]+)/);
|
|
650
|
-
if (!tMatch || !tMatch[1])
|
|
651
|
-
continue;
|
|
652
|
-
const transformVal = tMatch[1].trim();
|
|
653
|
-
// Derive selectors from the tag's id and all classes
|
|
654
|
-
const id = readAttr(tag.raw, "id");
|
|
655
|
-
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
|
656
|
-
const selectors = [];
|
|
657
|
-
if (id)
|
|
658
|
-
selectors.push(`#${id}`);
|
|
659
|
-
for (const cls of classes)
|
|
660
|
-
selectors.push(`.${cls}`);
|
|
661
|
-
if (selectors.length === 0)
|
|
662
|
-
continue;
|
|
663
|
-
for (const sel of selectors) {
|
|
664
|
-
if (/translate/i.test(transformVal) && !cssTranslateSelectors.has(sel))
|
|
665
|
-
cssTranslateSelectors.set(sel, transformVal);
|
|
666
|
-
if (/scale/i.test(transformVal) && !cssScaleSelectors.has(sel))
|
|
667
|
-
cssScaleSelectors.set(sel, transformVal);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
if (cssTranslateSelectors.size === 0 && cssScaleSelectors.size === 0)
|
|
671
|
-
return findings;
|
|
672
|
-
for (const script of scripts) {
|
|
673
|
-
if (!/gsap\.timeline/.test(script.content))
|
|
674
|
-
continue;
|
|
675
|
-
const windows = await cachedExtractGsapWindows(script.content);
|
|
676
|
-
// Two sources of transform-setting calls: timeline-rooted tweens (from the
|
|
677
|
-
// acorn parser) and standalone gsap.* calls (regex — the parser ignores
|
|
678
|
-
// these). Normalize both into one shape and run the same conflict check.
|
|
679
|
-
const calls = [
|
|
680
|
-
...windows.map((win) => ({
|
|
681
|
-
method: win.method,
|
|
682
|
-
selector: win.targetSelector,
|
|
683
|
-
properties: win.properties,
|
|
684
|
-
raw: win.raw,
|
|
685
|
-
})),
|
|
686
|
-
...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
|
|
687
|
-
];
|
|
688
|
-
const conflicts = new Map();
|
|
689
|
-
for (const call of calls) {
|
|
690
|
-
// from() and fromTo() both supply explicit start values so GSAP owns
|
|
691
|
-
// the full transform from t=0, making the CSS conflict moot
|
|
692
|
-
if (call.method === "fromTo" || call.method === "from")
|
|
693
|
-
continue;
|
|
694
|
-
const sel = call.selector;
|
|
695
|
-
const translateProps = call.properties.filter((p) => CONFLICTING_TRANSLATE_PROPS.includes(p));
|
|
696
|
-
const scaleProps = call.properties.filter((p) => CONFLICTING_SCALE_PROPS.includes(p));
|
|
697
|
-
const cssFromTranslate = translateProps.length > 0 ? matchCssTransform(sel, cssTranslateSelectors) : undefined;
|
|
698
|
-
const cssFromScale = scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : undefined;
|
|
699
|
-
if (!cssFromTranslate && !cssFromScale)
|
|
700
|
-
continue;
|
|
701
|
-
const existing = conflicts.get(sel) ?? {
|
|
702
|
-
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
|
|
703
|
-
props: new Set(),
|
|
704
|
-
raw: call.raw,
|
|
705
|
-
};
|
|
706
|
-
for (const p of [...translateProps, ...scaleProps])
|
|
707
|
-
existing.props.add(p);
|
|
708
|
-
conflicts.set(sel, existing);
|
|
709
|
-
}
|
|
710
|
-
for (const [sel, { cssTransform, props, raw }] of conflicts) {
|
|
711
|
-
const propList = [...props].join("/");
|
|
712
|
-
const gsapEquivalent = cssTransformToGsapProps(cssTransform);
|
|
713
|
-
const fixHint = gsapEquivalent
|
|
714
|
-
? `Remove \`transform: ${cssTransform}\` from CSS and replace with GSAP properties: ${gsapEquivalent}. ` +
|
|
715
|
-
`Example: tl.fromTo('${sel}', { ${gsapEquivalent} }, { ${gsapEquivalent}, ...yourAnimation }). ` +
|
|
716
|
-
`tl.fromTo is exempt from this rule.`
|
|
717
|
-
: `Remove the transform from CSS and use tl.fromTo('${sel}', ` +
|
|
718
|
-
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
|
|
719
|
-
`the full transform state. tl.fromTo is exempt from this rule.`;
|
|
720
|
-
findings.push({
|
|
721
|
-
code: "gsap_css_transform_conflict",
|
|
722
|
-
severity: "error",
|
|
723
|
-
message: `"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
|
|
724
|
-
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
|
|
725
|
-
`translateX(-50%) centering or CSS scale value.`,
|
|
726
|
-
selector: sel,
|
|
727
|
-
fixHint,
|
|
728
|
-
snippet: truncateSnippet(raw),
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
return findings;
|
|
733
|
-
},
|
|
734
|
-
// missing_gsap_script
|
|
735
|
-
({ scripts, rawSource, options }) => {
|
|
736
|
-
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
|
737
|
-
const allScriptSrcs = scripts
|
|
738
|
-
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
|
|
739
|
-
.filter(Boolean);
|
|
740
|
-
const canInheritGsapFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
|
|
741
|
-
const usesGsap = allScriptTexts.some((t) => /gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t));
|
|
742
|
-
const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));
|
|
743
|
-
// Detect GSAP bundled inline (no src attribute). Match:
|
|
744
|
-
// - Producer's CDN-inlining comment: /* inlined: ...gsap... */
|
|
745
|
-
// - GSAP library internals: _gsScope, GreenSock, gsap.config
|
|
746
|
-
// - Large inline scripts (>5KB) that reference gsap (likely bundled library)
|
|
747
|
-
const hasInlineGsap = allScriptTexts.some((t) => /\/\*\s*inlined:.*gsap/i.test(t) ||
|
|
748
|
-
/\b_gsScope\b/.test(t) ||
|
|
749
|
-
/\bGreenSock\b/.test(t) ||
|
|
750
|
-
/\bgsap\.(config|defaults|version)\b/.test(t) ||
|
|
751
|
-
(t.length > 5000 && /\bgsap\b/i.test(t)));
|
|
752
|
-
if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost)
|
|
753
|
-
return [];
|
|
754
|
-
return [
|
|
755
|
-
{
|
|
756
|
-
code: "missing_gsap_script",
|
|
757
|
-
severity: "error",
|
|
758
|
-
message: "Composition uses GSAP but no GSAP script is loaded. The animation will not run.",
|
|
759
|
-
fixHint: 'Add <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> before your animation script.',
|
|
760
|
-
},
|
|
761
|
-
];
|
|
762
|
-
},
|
|
763
|
-
// audio_reactive_single_tween_per_group
|
|
764
|
-
// fallow-ignore-next-line complexity
|
|
765
|
-
({ scripts, styles }) => {
|
|
766
|
-
const findings = [];
|
|
767
|
-
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
|
768
|
-
if (!isCaptionFile)
|
|
769
|
-
return findings;
|
|
770
|
-
for (const script of scripts) {
|
|
771
|
-
const content = script.content;
|
|
772
|
-
// Detect audio data loading
|
|
773
|
-
const hasAudioData = /AUDIO|audio[-_]?data|bands\[/.test(content);
|
|
774
|
-
if (!hasAudioData)
|
|
775
|
-
continue;
|
|
776
|
-
// Detect caption group loop
|
|
777
|
-
const hasCaptionLoop = /forEach/.test(content) && /caption|group|cg-/.test(content);
|
|
778
|
-
if (!hasCaptionLoop)
|
|
779
|
-
continue;
|
|
780
|
-
// Check if audio-reactive tweens are created at intervals (loop inside the group loop)
|
|
781
|
-
// vs a single tween per group (no inner time-sampling loop)
|
|
782
|
-
const hasInnerSamplingLoop = /for\s*\(\s*var\s+\w+\s*=\s*group\.start/.test(content) ||
|
|
783
|
-
/for\s*\(\s*var\s+at\s*=/.test(content) ||
|
|
784
|
-
/while\s*\(\s*\w+\s*<\s*group\.end/.test(content);
|
|
785
|
-
if (!hasInnerSamplingLoop) {
|
|
786
|
-
// Check if there's at least a peak-based single tween (the minimal pattern)
|
|
787
|
-
const hasPeakTween = /peak(?:Bass|Treble|Energy)/.test(content) && /group\.start/.test(content);
|
|
788
|
-
if (hasPeakTween) {
|
|
789
|
-
findings.push({
|
|
790
|
-
code: "audio_reactive_single_tween_per_group",
|
|
791
|
-
severity: "warning",
|
|
792
|
-
message: "Audio-reactive captions use a single tween per group based on peak values. " +
|
|
793
|
-
"This sets one static value at group.start — not perceptible as audio reactivity.",
|
|
794
|
-
fixHint: "Sample audio data at 100-200ms intervals throughout each group's lifetime " +
|
|
795
|
-
"(for loop from group.start to group.end) and create a tween at each sample " +
|
|
796
|
-
"point for visible pulsing.",
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
return findings;
|
|
802
|
-
},
|
|
803
|
-
// gsap_infinite_repeat
|
|
804
|
-
({ scripts }) => {
|
|
805
|
-
const findings = [];
|
|
806
|
-
for (const script of scripts) {
|
|
807
|
-
const content = stripJsComments(script.content);
|
|
808
|
-
// Match repeat: -1 in GSAP tweens or timeline configs
|
|
809
|
-
const pattern = /repeat\s*:\s*-1(?!\d)/g;
|
|
810
|
-
let match;
|
|
811
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
812
|
-
const contextStart = Math.max(0, match.index - 60);
|
|
813
|
-
const contextEnd = Math.min(content.length, match.index + match[0].length + 60);
|
|
814
|
-
const snippet = content.slice(contextStart, contextEnd).trim();
|
|
815
|
-
findings.push({
|
|
816
|
-
code: "gsap_infinite_repeat",
|
|
817
|
-
severity: "error",
|
|
818
|
-
message: "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
|
|
819
|
-
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
|
|
820
|
-
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
|
|
821
|
-
fixHint: "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
|
|
822
|
-
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
|
|
823
|
-
snippet: truncateSnippet(snippet),
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
return findings;
|
|
828
|
-
},
|
|
829
|
-
// gsap_repeat_ceil_overshoot
|
|
830
|
-
({ scripts }) => {
|
|
831
|
-
const findings = [];
|
|
832
|
-
for (const script of scripts) {
|
|
833
|
-
const content = script.content;
|
|
834
|
-
// Match patterns like: repeat: Math.ceil(duration / X) - 1
|
|
835
|
-
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
|
|
836
|
-
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
|
|
837
|
-
let match;
|
|
838
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
839
|
-
const contextStart = Math.max(0, match.index - 40);
|
|
840
|
-
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
|
|
841
|
-
const snippet = content.slice(contextStart, contextEnd).trim();
|
|
842
|
-
findings.push({
|
|
843
|
-
code: "gsap_repeat_ceil_overshoot",
|
|
844
|
-
severity: "warning",
|
|
845
|
-
message: "GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
|
|
846
|
-
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
|
|
847
|
-
fixHint: "Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
|
|
848
|
-
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
|
|
849
|
-
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
|
|
850
|
-
snippet: truncateSnippet(snippet),
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
return findings;
|
|
855
|
-
},
|
|
856
|
-
// scene_layer_missing_visibility_kill
|
|
857
|
-
({ scripts, tags }) => {
|
|
858
|
-
const findings = [];
|
|
859
|
-
// Detect multi-scene compositions: multiple elements with "scene" in their id
|
|
860
|
-
const sceneElements = tags.filter((t) => {
|
|
861
|
-
const id = readAttr(t.raw, "id") || "";
|
|
862
|
-
return /^scene\d+$/i.test(id);
|
|
863
|
-
});
|
|
864
|
-
if (sceneElements.length < 2)
|
|
865
|
-
return findings;
|
|
866
|
-
for (const script of scripts) {
|
|
867
|
-
const content = stripJsComments(script.content);
|
|
868
|
-
// For each scene, check if there's a visibility:hidden set after exit tweens
|
|
869
|
-
for (const tag of sceneElements) {
|
|
870
|
-
const id = readAttr(tag.raw, "id") || "";
|
|
871
|
-
// Check if this scene has exit tweens (opacity: 0)
|
|
872
|
-
const exitPattern = new RegExp(`["']#${id}["'][^)]*opacity\\s*:\\s*0`);
|
|
873
|
-
const hasExit = exitPattern.test(content);
|
|
874
|
-
if (!hasExit)
|
|
875
|
-
continue;
|
|
876
|
-
// Check if there's a hard visibility kill
|
|
877
|
-
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
|
|
878
|
-
const hasKill = killPattern.test(content);
|
|
879
|
-
if (!hasKill) {
|
|
880
|
-
findings.push({
|
|
881
|
-
code: "scene_layer_missing_visibility_kill",
|
|
882
|
-
severity: "error",
|
|
883
|
-
elementId: id,
|
|
884
|
-
message: `Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
|
|
885
|
-
"When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.",
|
|
886
|
-
fixHint: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`,
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return findings;
|
|
892
|
-
},
|
|
893
|
-
// gsap_timeline_not_registered
|
|
894
|
-
({ scripts, rawSource, options }) => {
|
|
895
|
-
const findings = [];
|
|
896
|
-
const canInheritFromHost = options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
|
|
897
|
-
for (const script of scripts) {
|
|
898
|
-
const content = script.content;
|
|
899
|
-
if (!/gsap\.timeline/.test(content))
|
|
900
|
-
continue;
|
|
901
|
-
const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content);
|
|
902
|
-
if (hasRegistration || canInheritFromHost)
|
|
903
|
-
continue;
|
|
904
|
-
findings.push({
|
|
905
|
-
code: "gsap_timeline_not_registered",
|
|
906
|
-
severity: "error",
|
|
907
|
-
message: "GSAP timeline is created but never registered in window.__timelines. " +
|
|
908
|
-
"The runtime discovers timelines from this registry — without registration, " +
|
|
909
|
-
"animations will not play during preview or render.",
|
|
910
|
-
fixHint: "Add `window.__timelines = window.__timelines || {};` and " +
|
|
911
|
-
'`window.__timelines["root"] = tl;` after creating the timeline (use the ' +
|
|
912
|
-
"composition's data-composition-id as the key).",
|
|
913
|
-
});
|
|
914
|
-
}
|
|
915
|
-
return findings;
|
|
916
|
-
},
|
|
917
|
-
// gsap_timeline_registered_before_async_build — registering window.__timelines[id]
|
|
918
|
-
// BEFORE the timeline is built inside document.fonts.ready (or any async callback)
|
|
919
|
-
// leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate
|
|
920
|
-
// treats "key present" as "ready" and nests the child ONCE, while still empty — so the
|
|
921
|
-
// animation never renders when this composition is mounted as a sub-composition.
|
|
922
|
-
// Register only AFTER the build completes (the documented async-setup contract).
|
|
923
|
-
({ scripts }) => {
|
|
924
|
-
const findings = [];
|
|
925
|
-
for (const script of scripts) {
|
|
926
|
-
const content = stripJsComments(script.content);
|
|
927
|
-
const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
|
|
928
|
-
if (regIdx < 0)
|
|
929
|
-
continue;
|
|
930
|
-
const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
|
|
931
|
-
if (fontsReadyIdx < 0)
|
|
932
|
-
continue;
|
|
933
|
-
// Registering after the async boundary is the correct pattern — skip it.
|
|
934
|
-
if (regIdx >= fontsReadyIdx)
|
|
935
|
-
continue;
|
|
936
|
-
// Confirm the build is actually deferred past the boundary (a tween/build call
|
|
937
|
-
// appears after document.fonts.ready), i.e. the registered timeline starts empty.
|
|
938
|
-
const tail = content.slice(fontsReadyIdx);
|
|
939
|
-
if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail))
|
|
940
|
-
continue;
|
|
941
|
-
findings.push({
|
|
942
|
-
code: "gsap_timeline_registered_before_async_build",
|
|
943
|
-
severity: "error",
|
|
944
|
-
message: "window.__timelines is assigned BEFORE the timeline is built inside " +
|
|
945
|
-
"document.fonts.ready. An empty timeline registered early gets nested empty " +
|
|
946
|
-
"when this composition is used as a sub-composition (the readiness gate treats " +
|
|
947
|
-
'"key present" as "ready" and never re-nests), so the animation renders blank.',
|
|
948
|
-
fixHint: "Move the `window.__timelines[id] = tl;` assignment to the END of the " +
|
|
949
|
-
"document.fonts.ready callback, after the tweens are added. Optionally call " +
|
|
950
|
-
"window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.",
|
|
951
|
-
});
|
|
952
|
-
}
|
|
953
|
-
return findings;
|
|
954
|
-
},
|
|
955
|
-
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
|
|
956
|
-
// fallow-ignore-next-line complexity
|
|
957
|
-
async ({ styles, scripts, tags }) => {
|
|
958
|
-
const findings = [];
|
|
959
|
-
const cssOpacityZeroSelectors = new Set();
|
|
960
|
-
for (const style of styles) {
|
|
961
|
-
for (const [, selector, body] of style.content.matchAll(/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g)) {
|
|
962
|
-
if (body && /opacity\s*:\s*0\s*[;}]/.test(body)) {
|
|
963
|
-
cssOpacityZeroSelectors.add((selector ?? "").trim());
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
for (const tag of tags) {
|
|
968
|
-
const inlineStyle = readAttr(tag.raw, "style");
|
|
969
|
-
if (!inlineStyle || !/opacity\s*:\s*0/.test(inlineStyle))
|
|
970
|
-
continue;
|
|
971
|
-
const id = readAttr(tag.raw, "id");
|
|
972
|
-
const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
|
|
973
|
-
if (id)
|
|
974
|
-
cssOpacityZeroSelectors.add(`#${id}`);
|
|
975
|
-
for (const cls of classes)
|
|
976
|
-
cssOpacityZeroSelectors.add(`.${cls}`);
|
|
977
|
-
}
|
|
978
|
-
if (cssOpacityZeroSelectors.size === 0)
|
|
979
|
-
return findings;
|
|
980
|
-
for (const script of scripts) {
|
|
981
|
-
if (!/gsap\.timeline/.test(script.content))
|
|
982
|
-
continue;
|
|
983
|
-
const windows = await cachedExtractGsapWindows(script.content);
|
|
984
|
-
for (const win of windows) {
|
|
985
|
-
if (win.method !== "from")
|
|
986
|
-
continue;
|
|
987
|
-
if (!win.properties.includes("opacity"))
|
|
988
|
-
continue;
|
|
989
|
-
// Only a noop when the tween animates FROM 0 (same as the CSS value)
|
|
990
|
-
if (win.propertyValues["opacity"] !== 0)
|
|
991
|
-
continue;
|
|
992
|
-
const sel = win.targetSelector;
|
|
993
|
-
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
|
994
|
-
if (!cssOpacityZeroSelectors.has(cssKey))
|
|
995
|
-
continue;
|
|
996
|
-
findings.push({
|
|
997
|
-
code: "gsap_from_opacity_noop",
|
|
998
|
-
severity: "error",
|
|
999
|
-
message: `"${sel}" has CSS \`opacity: 0\` and a gsap.${win.method}() that also sets opacity to 0. ` +
|
|
1000
|
-
`gsap.from() animates FROM the specified value TO the current CSS value — ` +
|
|
1001
|
-
`since CSS is already 0, the element animates from 0→0 and never becomes visible.`,
|
|
1002
|
-
selector: sel,
|
|
1003
|
-
fixHint: `Remove \`opacity: 0\` from the CSS/inline style on "${sel}". ` +
|
|
1004
|
-
`Let gsap.from({opacity: 0}) handle the initial hidden state — ` +
|
|
1005
|
-
`it will animate FROM 0 TO the CSS value (1 by default).`,
|
|
1006
|
-
snippet: truncateSnippet(win.raw),
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
return findings;
|
|
1011
|
-
},
|
|
1012
|
-
// gsap_group_selector_keyframes
|
|
1013
|
-
({ scripts }) => {
|
|
1014
|
-
const findings = [];
|
|
1015
|
-
for (const script of scripts) {
|
|
1016
|
-
const content = stripJsComments(script.content);
|
|
1017
|
-
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
|
|
1018
|
-
let match;
|
|
1019
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
1020
|
-
const selector = match[1];
|
|
1021
|
-
const count = selector.split(",").length;
|
|
1022
|
-
const contextStart = Math.max(0, match.index - 20);
|
|
1023
|
-
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
|
|
1024
|
-
findings.push({
|
|
1025
|
-
code: "gsap_group_selector_keyframes",
|
|
1026
|
-
severity: "warning",
|
|
1027
|
-
message: `GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
|
|
1028
|
-
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
|
|
1029
|
-
`Split into individual tweens for per-element keyframe control.`,
|
|
1030
|
-
fixHint: `Replace the group selector with individual tl.to() calls per element, ` +
|
|
1031
|
-
`each with their own keyframes object.`,
|
|
1032
|
-
snippet: truncateSnippet(content.slice(contextStart, contextEnd)),
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
return findings;
|
|
1037
|
-
},
|
|
1038
|
-
];
|
|
1039
|
-
//# sourceMappingURL=gsap.js.map
|