@hyperframes/lint 0.7.42 → 0.7.44
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/browser.js +121 -0
- package/dist/browser.js.map +1 -1
- package/dist/index.js +127 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2009,6 +2009,127 @@ ${right.raw}`)
|
|
|
2009
2009
|
}
|
|
2010
2010
|
return findings;
|
|
2011
2011
|
},
|
|
2012
|
+
// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
|
|
2013
|
+
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
|
|
2014
|
+
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly
|
|
2015
|
+
// stutters at low deltas (slow tweens / ease-out tails): sub-pixel movement rounds to
|
|
2016
|
+
// the same pixel for several frames, then jumps a whole pixel. Transforms (x/y/scale)
|
|
2017
|
+
// interpolate sub-pixel and stay smooth.
|
|
2018
|
+
//
|
|
2019
|
+
// EXEMPTION: elements rasterized via the html-in-canvas API — those under a
|
|
2020
|
+
// `<canvas layoutsubtree>` ancestor (e.g. the liquid-glass blocks) — are NOT laid out
|
|
2021
|
+
// by the browser compositor. The canvas lib reads getComputedStyle().left/top (a
|
|
2022
|
+
// sub-pixel value) and draws the element to a bitmap, so animating a layout prop on
|
|
2023
|
+
// them does not integer-snap and does not stutter. We resolve each tween's target to
|
|
2024
|
+
// its element(s) and skip the finding only when EVERY target is html-in-canvas; a
|
|
2025
|
+
// grouped tween that also touches a plain-DOM element (which does stutter) still fires.
|
|
2026
|
+
//
|
|
2027
|
+
// No suppression by design: there is intentionally no per-line/per-file opt-out (unlike
|
|
2028
|
+
// eslint-disable). The stance is fix-the-motion, not silence-the-rule — a plain-DOM
|
|
2029
|
+
// layout-prop animation always has a faithful transform equivalent (per-glyph x for
|
|
2030
|
+
// spacing, scale for size, x/y for position). An author who has consciously accepted a
|
|
2031
|
+
// stutter still has no flag to flip; that is deliberate, not a missing feature.
|
|
2032
|
+
async ({ scripts, tags, source }) => {
|
|
2033
|
+
const findings = [];
|
|
2034
|
+
const layoutSubtreeRanges = tags.filter((t) => t.name.toLowerCase() === "canvas" && /\blayoutsubtree\b/i.test(t.raw)).map((t) => ({ start: t.index, end: findTagEnd(source, t) }));
|
|
2035
|
+
const isHtmlInCanvas = (tag) => layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end);
|
|
2036
|
+
const tagsByToken = /* @__PURE__ */ new Map();
|
|
2037
|
+
const addToken = (token, tag) => {
|
|
2038
|
+
const list = tagsByToken.get(token);
|
|
2039
|
+
if (list) list.push(tag);
|
|
2040
|
+
else tagsByToken.set(token, [tag]);
|
|
2041
|
+
};
|
|
2042
|
+
for (const tag of tags) {
|
|
2043
|
+
const id = readAttr(tag.raw, "id");
|
|
2044
|
+
if (id) addToken(`#${id}`, tag);
|
|
2045
|
+
for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
|
|
2046
|
+
addToken(`.${cls}`, tag);
|
|
2047
|
+
}
|
|
2048
|
+
const allTargetsHtmlInCanvas = (selector) => {
|
|
2049
|
+
if (layoutSubtreeRanges.length === 0) return false;
|
|
2050
|
+
const matched = [...targetedSelectorTokens(selector)].flatMap(
|
|
2051
|
+
(token) => tagsByToken.get(token) ?? []
|
|
2052
|
+
);
|
|
2053
|
+
return matched.length > 0 && matched.every(isHtmlInCanvas);
|
|
2054
|
+
};
|
|
2055
|
+
const LAYOUT_FIX = {
|
|
2056
|
+
left: ["x"],
|
|
2057
|
+
right: ["x"],
|
|
2058
|
+
top: ["y"],
|
|
2059
|
+
bottom: ["y"],
|
|
2060
|
+
margin: ["x", "y"],
|
|
2061
|
+
marginLeft: ["x"],
|
|
2062
|
+
marginRight: ["x"],
|
|
2063
|
+
marginTop: ["y"],
|
|
2064
|
+
marginBottom: ["y"]
|
|
2065
|
+
};
|
|
2066
|
+
const REFLOW_PROPS = ["letterSpacing", "wordSpacing", "fontSize"];
|
|
2067
|
+
const parseGsapScript = await loadParseGsapScript();
|
|
2068
|
+
for (const script of scripts) {
|
|
2069
|
+
if (!/gsap\.timeline/.test(script.content)) continue;
|
|
2070
|
+
const parsed = parseGsapScript(script.content);
|
|
2071
|
+
const calls = [
|
|
2072
|
+
...parsed.animations.map((anim) => ({
|
|
2073
|
+
method: anim.method,
|
|
2074
|
+
selector: anim.targetSelector,
|
|
2075
|
+
// Union the from-vars: a fromTo() can animate a layout/reflow prop that appears
|
|
2076
|
+
// only in its first ("from") object, which is just as stutter-prone as the to-vars.
|
|
2077
|
+
properties: [
|
|
2078
|
+
.../* @__PURE__ */ new Set([
|
|
2079
|
+
...Object.keys(anim.properties),
|
|
2080
|
+
...Object.keys(anim.fromProperties ?? {})
|
|
2081
|
+
])
|
|
2082
|
+
],
|
|
2083
|
+
raw: synthesizeWindowRaw(parsed.timelineVar, anim)
|
|
2084
|
+
})),
|
|
2085
|
+
...extractStandaloneGsapTransformCalls(stripJsComments(script.content))
|
|
2086
|
+
];
|
|
2087
|
+
for (const call of calls) {
|
|
2088
|
+
if (call.method === "set") continue;
|
|
2089
|
+
let layoutProps = call.properties.filter((p) => Object.hasOwn(LAYOUT_FIX, p));
|
|
2090
|
+
const reflowProps = call.properties.filter((p) => REFLOW_PROPS.includes(p));
|
|
2091
|
+
const usesRoundProps = call.properties.includes("roundProps");
|
|
2092
|
+
if (layoutProps.length > 0 && allTargetsHtmlInCanvas(call.selector)) layoutProps = [];
|
|
2093
|
+
if (layoutProps.length === 0 && reflowProps.length === 0 && !usesRoundProps) continue;
|
|
2094
|
+
const flagged = [...layoutProps, ...reflowProps, ...usesRoundProps ? ["roundProps"] : []];
|
|
2095
|
+
const message = `GSAP tween on "${call.selector}" uses motion that snaps to integer device pixels: ${flagged.join(", ")}. Layout and text-reflow properties snap during browser layout; roundProps rounds the tween value. Slow motion or an ease-out tail then stutters under the seek-by-frame capture engine \u2014 animate transforms (x/y/scale/opacity) instead.`;
|
|
2096
|
+
const fixes = [];
|
|
2097
|
+
if (layoutProps.length > 0) {
|
|
2098
|
+
const tokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))];
|
|
2099
|
+
fixes.push(
|
|
2100
|
+
`replace ${layoutProps.join("/")} with the transform equivalent (${tokens.join(", ")}) \u2014 e.g. tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation })`
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
if (reflowProps.length > 0) {
|
|
2104
|
+
const sizing = reflowProps.filter((p) => p === "fontSize");
|
|
2105
|
+
const spacing = reflowProps.filter((p) => p !== "fontSize");
|
|
2106
|
+
const parts = [];
|
|
2107
|
+
if (sizing.length > 0) {
|
|
2108
|
+
parts.push(`replace ${sizing.join("/")} with scale (same visual, no reflow)`);
|
|
2109
|
+
}
|
|
2110
|
+
if (spacing.length > 0) {
|
|
2111
|
+
parts.push(
|
|
2112
|
+
`for ${spacing.join("/")}, split the text into per-character elements and animate each glyph's x (the spread) \u2014 uniform scale is NOT equivalent \u2014 or hold the final value statically`
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
fixes.push(
|
|
2116
|
+
`do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions): ` + parts.join("; ")
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
if (usesRoundProps) fixes.push("remove roundProps");
|
|
2120
|
+
const fixHint = `${fixes.join("; ")}. Transforms interpolate sub-pixel and stay smooth at any speed.`;
|
|
2121
|
+
findings.push({
|
|
2122
|
+
code: "gsap_non_transform_motion",
|
|
2123
|
+
severity: "error",
|
|
2124
|
+
message,
|
|
2125
|
+
selector: call.selector,
|
|
2126
|
+
fixHint,
|
|
2127
|
+
snippet: truncateSnippet(call.raw)
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
return findings;
|
|
2132
|
+
},
|
|
2012
2133
|
// gsap_group_selector_keyframes
|
|
2013
2134
|
({ scripts }) => {
|
|
2014
2135
|
const findings = [];
|
|
@@ -3702,6 +3823,7 @@ async function lintProject(projectDir) {
|
|
|
3702
3823
|
const html = readFileSync(filePath, "utf-8");
|
|
3703
3824
|
const compSrcPath = `compositions/${file}`;
|
|
3704
3825
|
allHtmlSources.push({ html, compSrcPath });
|
|
3826
|
+
if (isSnippetFragment(html)) continue;
|
|
3705
3827
|
const result = await lintHyperframeHtml(html, {
|
|
3706
3828
|
filePath,
|
|
3707
3829
|
isSubComposition: true,
|
|
@@ -3984,6 +4106,11 @@ function lintMissingOrEmptySubComposition(projectDir, rootHtml) {
|
|
|
3984
4106
|
}
|
|
3985
4107
|
return findings;
|
|
3986
4108
|
}
|
|
4109
|
+
function isSnippetFragment(html) {
|
|
4110
|
+
const firstTag = html.match(/<[a-zA-Z][^>]*>/);
|
|
4111
|
+
if (!firstTag) return false;
|
|
4112
|
+
return /\bdata-hf-snippet\b/.test(firstTag[0]);
|
|
4113
|
+
}
|
|
3987
4114
|
export {
|
|
3988
4115
|
lintHyperframeHtml,
|
|
3989
4116
|
lintMediaUrls,
|