@hyperframes/lint 0.7.41 → 0.7.43

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 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 = [];