@hyperframes/lint 0.7.59 → 0.7.61

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/index.js CHANGED
@@ -686,6 +686,17 @@ var coreRules = [
686
686
  pattern: /crypto\.getRandomValues\s*\(/,
687
687
  label: "crypto.getRandomValues()",
688
688
  hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders."
689
+ },
690
+ {
691
+ pattern: /gsap\.utils\.random\s*\(/,
692
+ label: "gsap.utils.random()",
693
+ hint: "Each render worker initializes independently, so random values diverge across chunks. Use a seeded PRNG or fixed values."
694
+ },
695
+ {
696
+ // GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
697
+ pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
698
+ label: '"random(...)" tween value',
699
+ hint: "GSAP random string values re-roll at tween init and each render worker initializes independently. Use fixed values or precompute with a seeded PRNG."
689
700
  }
690
701
  ];
691
702
  for (const script of scripts) {
@@ -1311,19 +1322,24 @@ async function extractGsapWindows(script) {
1311
1322
  if (parsed.animations.length === 0) return [];
1312
1323
  const windows = [];
1313
1324
  for (const animation of parsed.animations) {
1314
- if (typeof animation.position !== "number") continue;
1325
+ const start = animation.resolvedStart ?? (typeof animation.position === "number" ? animation.position : null);
1326
+ if (start === null) continue;
1315
1327
  const repeat = extrasNumber(animation.extras?.repeat);
1316
- const cycleCount = repeat > 0 ? repeat + 1 : 1;
1328
+ const infiniteRepeat = repeat < 0;
1329
+ const cycleCount = infiniteRepeat ? 1 : repeat > 0 ? repeat + 1 : 1;
1317
1330
  const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
1318
1331
  windows.push({
1319
1332
  targetSelector: animation.targetSelector,
1320
1333
  targetIdentity: animation.targetIdentity,
1321
- position: animation.position,
1322
- end: animation.position + effectiveDuration,
1334
+ position: start,
1335
+ end: infiniteRepeat && animation.method !== "set" ? Number.POSITIVE_INFINITY : start + effectiveDuration,
1323
1336
  properties: Object.keys(animation.properties),
1324
1337
  propertyValues: animation.properties,
1338
+ fromPropertyValues: animation.fromProperties,
1325
1339
  overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
1340
+ immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true",
1326
1341
  method: animation.method,
1342
+ global: animation.global,
1327
1343
  raw: synthesizeWindowRaw(parsed.timelineVar, animation)
1328
1344
  });
1329
1345
  }
@@ -1352,6 +1368,30 @@ function isHiddenGsapState(values) {
1352
1368
  const display = stringValue(values.display)?.toLowerCase();
1353
1369
  return zeroValue(values.opacity) || zeroValue(values.autoAlpha) || visibility === "hidden" || display === "none";
1354
1370
  }
1371
+ function extractStandaloneHiddenSelectors(script) {
1372
+ const selectors = /* @__PURE__ */ new Set();
1373
+ const source = stripJsComments(script);
1374
+ const functionRanges = collectFunctionBodyRanges(source);
1375
+ const aliases = /* @__PURE__ */ new Map();
1376
+ for (const match2 of source.matchAll(
1377
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g
1378
+ )) {
1379
+ aliases.set(match2[1] ?? "", match2[3] ?? "");
1380
+ }
1381
+ const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
1382
+ let match;
1383
+ while ((match = pattern.exec(source)) !== null) {
1384
+ if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue;
1385
+ const target = (match[1] ?? "").trim();
1386
+ const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target);
1387
+ if (!selector) continue;
1388
+ const body = match[2] ?? "";
1389
+ if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) {
1390
+ selectors.add(selector);
1391
+ }
1392
+ }
1393
+ return selectors;
1394
+ }
1355
1395
  function oneValue(values, keys) {
1356
1396
  for (const key of keys) {
1357
1397
  const value = values[key];
@@ -1609,6 +1649,329 @@ function scanScriptsForRegexMatches(scripts, pattern, options) {
1609
1649
  }
1610
1650
  return hits;
1611
1651
  }
1652
+ var RELATIVE_TWEEN_VALUE = /^[+-]=/;
1653
+ function isRelativeTweenValue(value) {
1654
+ return typeof value === "string" && RELATIVE_TWEEN_VALUE.test(value.trim());
1655
+ }
1656
+ var TRANSFORM_SENSITIVE_READ = /\.getBoundingClientRect\s*\(|\bgetComputedStyle\s*\(|\bgsap\.getProperty\s*\(/;
1657
+ var TRANSFORM_INVARIANT_READ = /\.(?:getTotalLength|getBBox)\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/;
1658
+ var CALLBACK_MEASUREMENT_PATTERN = /\.(?:getBoundingClientRect|getTotalLength|getBBox)\s*\(|\bgetComputedStyle\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/;
1659
+ function indexTagsByToken(tags) {
1660
+ const tagsByToken = /* @__PURE__ */ new Map();
1661
+ const addToken = (token, tag) => {
1662
+ const list = tagsByToken.get(token);
1663
+ if (list) list.push(tag);
1664
+ else tagsByToken.set(token, [tag]);
1665
+ };
1666
+ for (const tag of tags) {
1667
+ const id = readAttr(tag.raw, "id");
1668
+ if (id) addToken(`#${id}`, tag);
1669
+ for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
1670
+ addToken(`.${cls}`, tag);
1671
+ }
1672
+ return tagsByToken;
1673
+ }
1674
+ function resolveSelectorTagIndexes(selector, tagsByToken) {
1675
+ const indexes = /* @__PURE__ */ new Set();
1676
+ for (const token of targetedSelectorTokens(selector)) {
1677
+ for (const tag of tagsByToken.get(token) ?? []) indexes.add(tag.index);
1678
+ }
1679
+ return indexes;
1680
+ }
1681
+ function selectorResolvesFaithfully(selector) {
1682
+ return selector.split(",").every((group) => {
1683
+ const token = group.trim();
1684
+ if (!token || token.includes("[")) return false;
1685
+ return !/[\s>+~]/.test(token);
1686
+ });
1687
+ }
1688
+ function targetsShareElement(a, b, tagsByToken) {
1689
+ if (!targetHasNoStableIdentity(a.selector, a.identity) && !targetHasNoStableIdentity(b.selector, b.identity) && (a.identity ?? a.selector) === (b.identity ?? b.selector)) {
1690
+ return true;
1691
+ }
1692
+ if (!selectorResolvesFaithfully(a.selector) || !selectorResolvesFaithfully(b.selector)) {
1693
+ return false;
1694
+ }
1695
+ const aTags = resolveSelectorTagIndexes(a.selector, tagsByToken);
1696
+ if (aTags.size === 0) return false;
1697
+ const bTags = resolveSelectorTagIndexes(b.selector, tagsByToken);
1698
+ for (const index of bTags) if (aTags.has(index)) return true;
1699
+ return false;
1700
+ }
1701
+ function matchBalanced(source, openIndex, open, close) {
1702
+ let depth = 0;
1703
+ for (let i = openIndex; i < source.length; i++) {
1704
+ const ch = source[i];
1705
+ if (ch === open) depth++;
1706
+ else if (ch === close) {
1707
+ depth--;
1708
+ if (depth === 0) return source.slice(openIndex, i + 1);
1709
+ }
1710
+ }
1711
+ return null;
1712
+ }
1713
+ function enclosingObjectLiteral(source, index) {
1714
+ let depth = 0;
1715
+ for (let i = index; i >= 0; i--) {
1716
+ const ch = source[i];
1717
+ if (ch === "}") depth++;
1718
+ else if (ch === "{") {
1719
+ if (depth === 0) return matchBalanced(source, i, "{", "}");
1720
+ depth--;
1721
+ }
1722
+ }
1723
+ return null;
1724
+ }
1725
+ function objectLiteralHasTopLevelRelativeValue(objectLiteral) {
1726
+ let depth = 0;
1727
+ let inString = null;
1728
+ for (let i = 0; i < objectLiteral.length; i++) {
1729
+ const ch = objectLiteral[i] ?? "";
1730
+ const prev = objectLiteral[i - 1] ?? "";
1731
+ if (inString) {
1732
+ if (ch === inString && prev !== "\\") inString = null;
1733
+ continue;
1734
+ }
1735
+ if (ch === '"' || ch === "'" || ch === "`") {
1736
+ inString = ch;
1737
+ if (depth === 1 && /^[+-]=/.test(objectLiteral.slice(i + 1))) return true;
1738
+ continue;
1739
+ }
1740
+ if (ch === "{" || ch === "(" || ch === "[") depth++;
1741
+ else if (ch === "}" || ch === ")" || ch === "]") depth--;
1742
+ }
1743
+ return false;
1744
+ }
1745
+ function isInsideGsapTweenVars(source, index, timelineVars) {
1746
+ let depth = 0;
1747
+ for (let i = index; i >= 0; i--) {
1748
+ const ch = source[i];
1749
+ if (ch === "}") depth++;
1750
+ else if (ch === "{") {
1751
+ if (depth === 0) {
1752
+ const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
1753
+ const receivers = ["gsap", ...timelineVars].map(escapeRegExp3).join("|");
1754
+ return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
1755
+ before
1756
+ );
1757
+ }
1758
+ depth--;
1759
+ }
1760
+ }
1761
+ return false;
1762
+ }
1763
+ function sliceExpression(source, start) {
1764
+ let depth = 0;
1765
+ for (let i = start; i < source.length; i++) {
1766
+ const ch = source[i] ?? "";
1767
+ if ("({[".includes(ch)) depth++;
1768
+ else if (")}]".includes(ch)) {
1769
+ if (depth === 0) return source.slice(start, i);
1770
+ depth--;
1771
+ } else if (ch === "," && depth === 0) return source.slice(start, i);
1772
+ }
1773
+ return source.slice(start);
1774
+ }
1775
+ function normalizeFirstParam(raw) {
1776
+ let param = raw.trim().replace(/=.*$/, "").trim();
1777
+ param = param.replace(/\s*:\s*[\w$|<>,\s[\].]+$/, "").trim();
1778
+ if (!param || /^[[{]/.test(param)) return null;
1779
+ if (!/^[A-Za-z_$][\w$]*$/.test(param)) return null;
1780
+ return param;
1781
+ }
1782
+ function parseFunctionValueSource(code) {
1783
+ const src = code.trim();
1784
+ const match = src.match(/^(?:async\s+)?function\s*[\w$]*\s*\(([^)]*)\)/) ?? src.match(/^(?:async\s*)?\(([^)]*)\)\s*=>/) ?? src.match(/^(?:async\s*)?([A-Za-z_$][\w$]*)\s*=>/);
1785
+ if (!match) return null;
1786
+ const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? "");
1787
+ return { firstParam, body: src.slice(match[0].length) };
1788
+ }
1789
+ function escapeRegExp3(value) {
1790
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1791
+ }
1792
+ var NUMBER_METHODS = /* @__PURE__ */ new Set([
1793
+ "toFixed",
1794
+ "toString",
1795
+ "toPrecision",
1796
+ "toExponential",
1797
+ "toLocaleString",
1798
+ "valueOf"
1799
+ ]);
1800
+ function firstParamMemberAccessHazard(fn) {
1801
+ if (!fn.firstParam) return null;
1802
+ const pattern = new RegExp(
1803
+ `\\b${escapeRegExp3(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`,
1804
+ "g"
1805
+ );
1806
+ let match;
1807
+ while ((match = pattern.exec(fn.body)) !== null) {
1808
+ const member = match[1] ?? "";
1809
+ const after = fn.body.slice(match.index + match[0].length);
1810
+ const isCall = /^\s*\(/.test(after);
1811
+ if (isCall && NUMBER_METHODS.has(member)) continue;
1812
+ return member;
1813
+ }
1814
+ return null;
1815
+ }
1816
+ function collectTimelineVarNames(source) {
1817
+ return [...source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*gsap\.timeline\b/g)].map((m) => m[1] ?? "").filter(Boolean);
1818
+ }
1819
+ function collectNamedFunctionBodies(source) {
1820
+ const bodies = /* @__PURE__ */ new Map();
1821
+ const declPattern = /(?:^|[^.\w$])function\s+([A-Za-z_$][\w$]*)\s*\(/g;
1822
+ let match;
1823
+ while ((match = declPattern.exec(source)) !== null) {
1824
+ const braceIndex = source.indexOf("{", declPattern.lastIndex);
1825
+ if (braceIndex < 0) continue;
1826
+ const body = matchBalanced(source, braceIndex, "{", "}");
1827
+ if (body) bodies.set(match[1] ?? "", body);
1828
+ }
1829
+ const assignPattern = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b[^{]*|\([^)]*\)\s*=>\s*|[A-Za-z_$][\w$]*\s*=>\s*)/g;
1830
+ while ((match = assignPattern.exec(source)) !== null) {
1831
+ const bodyStart = assignPattern.lastIndex;
1832
+ const body = source[bodyStart] === "{" ? matchBalanced(source, bodyStart, "{", "}") : sliceExpression(source, bodyStart);
1833
+ if (body) bodies.set(match[1] ?? "", body);
1834
+ }
1835
+ return bodies;
1836
+ }
1837
+ function collectMeasuringFunctionNames(bodies) {
1838
+ const measuring = /* @__PURE__ */ new Set();
1839
+ for (const [name, body] of bodies) {
1840
+ if (CALLBACK_MEASUREMENT_PATTERN.test(body)) measuring.add(name);
1841
+ }
1842
+ for (let pass = 0; pass < 3; pass++) {
1843
+ let grew = false;
1844
+ for (const [name, body] of bodies) {
1845
+ if (measuring.has(name)) continue;
1846
+ for (const measured of measuring) {
1847
+ if (new RegExp(`\\b${escapeRegExp3(measured)}\\s*\\(`).test(body)) {
1848
+ measuring.add(name);
1849
+ grew = true;
1850
+ break;
1851
+ }
1852
+ }
1853
+ }
1854
+ if (!grew) break;
1855
+ }
1856
+ return measuring;
1857
+ }
1858
+ function expressionReachesMeasurement(expression, measuring) {
1859
+ if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
1860
+ for (const name of measuring) {
1861
+ if (new RegExp(`\\b${escapeRegExp3(name)}\\b`).test(expression)) return true;
1862
+ }
1863
+ return false;
1864
+ }
1865
+ function resolveScriptElementTokens(source, tags) {
1866
+ const documentIds = tags.map((tag) => readAttr(tag.raw, "id")).filter((id) => id !== null);
1867
+ const tokensByVar = /* @__PURE__ */ new Map();
1868
+ const add = (name, token) => {
1869
+ const tokens = tokensByVar.get(name) ?? /* @__PURE__ */ new Set();
1870
+ tokens.add(token);
1871
+ tokensByVar.set(name, tokens);
1872
+ };
1873
+ for (const match of source.matchAll(
1874
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*(["'])([^"'`]+)\2/g
1875
+ )) {
1876
+ add(match[1] ?? "", `#${match[3] ?? ""}`);
1877
+ }
1878
+ for (const match of source.matchAll(
1879
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*`([^`]*)`/g
1880
+ )) {
1881
+ const template = match[2] ?? "";
1882
+ const staticParts = template.split(/\$\{[^}]*\}/);
1883
+ if (staticParts.every((part) => part === "")) continue;
1884
+ const idPattern = new RegExp(`^${staticParts.map(escapeRegExp3).join(".*")}$`);
1885
+ for (const id of documentIds) {
1886
+ if (idPattern.test(id)) add(match[1] ?? "", `#${id}`);
1887
+ }
1888
+ }
1889
+ for (const match of source.matchAll(
1890
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.querySelector\(\s*(["'])([^"'`]+)\2/g
1891
+ )) {
1892
+ for (const token of targetedSelectorTokens(match[3] ?? "")) add(match[1] ?? "", token);
1893
+ }
1894
+ for (const match of source.matchAll(
1895
+ /\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*(["'])class\2\s*,\s*(["'])([^"'`]*)\3/g
1896
+ )) {
1897
+ for (const cls of (match[4] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`);
1898
+ }
1899
+ for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.className\s*=\s*(["'])([^"'`]*)\2/g)) {
1900
+ for (const cls of (match[3] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`);
1901
+ }
1902
+ return tokensByVar;
1903
+ }
1904
+ function elementLevelTokens(tokens, tagsByToken) {
1905
+ const expanded = new Set(tokens);
1906
+ for (const token of [...expanded]) {
1907
+ for (const tag of tagsByToken.get(token) ?? []) {
1908
+ for (const own of tagSimpleSelectors(tag)) expanded.add(own);
1909
+ }
1910
+ }
1911
+ return expanded;
1912
+ }
1913
+ function isMultiComponentDasharray(value) {
1914
+ const normalized = value.replace(/!important\s*$/i, "").trim();
1915
+ if (!normalized || /^none$/i.test(normalized)) return false;
1916
+ return normalized.split(/[\s,]+/).filter(Boolean).length >= 2;
1917
+ }
1918
+ function gsapDasharrayValueLooksMultiComponent(valueSource) {
1919
+ const literal = valueSource.trim().match(/^(["'`])([\s\S]*)\1$/)?.[2];
1920
+ if (literal === void 0) return false;
1921
+ return isMultiComponentDasharray(literal.replace(/\$\{[^}]*\}/g, "0"));
1922
+ }
1923
+ function collectFunctionBodyRanges(source) {
1924
+ const ranges = [];
1925
+ const openerPatterns = [/\bfunction\b[^{;()]*\([^)]*\)\s*\{/g, /=>\s*\{/g];
1926
+ for (const pattern of openerPatterns) {
1927
+ let match;
1928
+ while ((match = pattern.exec(source)) !== null) {
1929
+ const braceIndex = match.index + match[0].length - 1;
1930
+ const body = matchBalanced(source, braceIndex, "{", "}");
1931
+ if (body) ranges.push({ start: braceIndex, end: braceIndex + body.length });
1932
+ }
1933
+ }
1934
+ return ranges;
1935
+ }
1936
+ function indexInsideAnyRange(index, ranges) {
1937
+ return ranges.some((range) => index > range.start && index < range.end);
1938
+ }
1939
+ function isIifeBody(source, range) {
1940
+ let j = range.end;
1941
+ while (j < source.length && /\s/.test(source[j])) j++;
1942
+ if (source[j] !== ")") return false;
1943
+ j++;
1944
+ while (j < source.length && /\s/.test(source[j])) j++;
1945
+ return source[j] === "(" || source.startsWith(".call", j) || source.startsWith(".apply", j);
1946
+ }
1947
+ function indexInsideNonIifeRange(index, source, ranges) {
1948
+ return ranges.some(
1949
+ (range) => index > range.start && index < range.end && !isIifeBody(source, range)
1950
+ );
1951
+ }
1952
+ function collectCssOpacityZeroSelectors(styles, tags) {
1953
+ const selectors = /* @__PURE__ */ new Set();
1954
+ const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;
1955
+ for (const style of styles) {
1956
+ for (const [, selector, body] of style.content.matchAll(
1957
+ /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
1958
+ )) {
1959
+ if (body && opacityExactlyZero.test(body)) {
1960
+ selectors.add((selector ?? "").trim());
1961
+ }
1962
+ }
1963
+ }
1964
+ for (const tag of tags) {
1965
+ const inlineStyle = readAttr(tag.raw, "style");
1966
+ if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
1967
+ const id = readAttr(tag.raw, "id");
1968
+ if (id) selectors.add(`#${id}`);
1969
+ for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []) {
1970
+ selectors.add(`.${cls}`);
1971
+ }
1972
+ }
1973
+ return selectors;
1974
+ }
1612
1975
  var gsapRules = [
1613
1976
  // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
1614
1977
  // fallow-ignore-next-line complexity
@@ -1726,7 +2089,11 @@ ${right.raw}`)
1726
2089
  message: `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`,
1727
2090
  selector,
1728
2091
  elementId: readAttr(tag.raw, "id") || void 0,
1729
- fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add \`tl.set("${selector}", { opacity: 0 }, 0)\` before the reveal tween.`,
2092
+ // gsap_timeline_set_initial_hide warns on `tl.set(..., 0)` initial hides
2093
+ // (a zero-duration set at 0 does not render at exactly t=0), so this hint
2094
+ // must not recommend that pattern — advise authored CSS or an immediate
2095
+ // gsap.set() instead, keeping the two rules' advice consistent.
2096
+ fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add an immediate \`gsap.set("${selector}", { opacity: 0 })\` (outside the timeline) before the reveal tween.`,
1730
2097
  snippet: truncateSnippet(firstVisible.raw)
1731
2098
  });
1732
2099
  }
@@ -2015,40 +2382,39 @@ ${right.raw}`)
2015
2382
  }
2016
2383
  return findings;
2017
2384
  },
2018
- // gsap_from_opacity_noop CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
2385
+ // CSS/GSAP-hidden reveal safety. A fromTo() whose from-vars make an element
2386
+ // visible but whose destination omits opacity works during sequential seeks,
2387
+ // yet cold render workers restore the authored hidden state and encode it
2388
+ // permanently invisible.
2019
2389
  // fallow-ignore-next-line complexity
2020
2390
  async ({ styles, scripts, tags }) => {
2021
2391
  const findings = [];
2022
- const cssOpacityZeroSelectors = /* @__PURE__ */ new Set();
2023
- const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;
2024
- for (const style of styles) {
2025
- for (const [, selector, body] of style.content.matchAll(
2026
- /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
2027
- )) {
2028
- if (body && opacityExactlyZero.test(body)) {
2029
- cssOpacityZeroSelectors.add((selector ?? "").trim());
2030
- }
2031
- }
2032
- }
2033
- for (const tag of tags) {
2034
- const inlineStyle = readAttr(tag.raw, "style");
2035
- if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
2036
- const id = readAttr(tag.raw, "id");
2037
- const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
2038
- if (id) cssOpacityZeroSelectors.add(`#${id}`);
2039
- for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
2040
- }
2041
- if (cssOpacityZeroSelectors.size === 0) return findings;
2392
+ const cssOpacityZeroSelectors = collectCssOpacityZeroSelectors(styles, tags);
2042
2393
  for (const script of scripts) {
2043
2394
  if (!/gsap\.timeline/.test(script.content)) continue;
2044
2395
  const windows = await cachedExtractGsapWindows(script.content);
2396
+ const hiddenSelectors = /* @__PURE__ */ new Set([
2397
+ ...cssOpacityZeroSelectors,
2398
+ ...extractStandaloneHiddenSelectors(script.content)
2399
+ ]);
2045
2400
  for (const win of windows) {
2401
+ const sel = win.targetSelector;
2402
+ const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
2403
+ if (!hiddenSelectors.has(cssKey)) continue;
2404
+ if (win.method === "fromTo" && win.fromPropertyValues && isVisibleGsapState(win.fromPropertyValues) && !win.properties.some((property) => property === "opacity" || property === "autoAlpha")) {
2405
+ findings.push({
2406
+ code: "gsap_cold_seek_hidden_fromto_missing_reveal",
2407
+ severity: "error",
2408
+ message: `"${sel}" starts hidden, but its gsap.fromTo() makes it visible only in the from-vars and omits opacity/autoAlpha from the destination. Cold render workers restore the hidden authored state, so the encoded element can stay invisible even when sequential snapshots look correct.`,
2409
+ selector: sel,
2410
+ fixHint: `Add \`opacity: 1\` (or \`autoAlpha: 1\`) to the destination vars for "${sel}" so every seek path establishes the visible end state explicitly.`,
2411
+ snippet: truncateSnippet(win.raw)
2412
+ });
2413
+ continue;
2414
+ }
2046
2415
  if (win.method !== "from") continue;
2047
2416
  if (!win.properties.includes("opacity")) continue;
2048
2417
  if (win.propertyValues["opacity"] !== 0) continue;
2049
- const sel = win.targetSelector;
2050
- const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
2051
- if (!cssOpacityZeroSelectors.has(cssKey)) continue;
2052
2418
  findings.push({
2053
2419
  code: "gsap_from_opacity_noop",
2054
2420
  severity: "error",
@@ -2085,18 +2451,7 @@ ${right.raw}`)
2085
2451
  const findings = [];
2086
2452
  const layoutSubtreeRanges = tags.filter((t) => t.name.toLowerCase() === "canvas" && /\blayoutsubtree\b/i.test(t.raw)).map((t) => ({ start: t.index, end: findTagEnd(source, t) }));
2087
2453
  const isHtmlInCanvas = (tag) => layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end);
2088
- const tagsByToken = /* @__PURE__ */ new Map();
2089
- const addToken = (token, tag) => {
2090
- const list = tagsByToken.get(token);
2091
- if (list) list.push(tag);
2092
- else tagsByToken.set(token, [tag]);
2093
- };
2094
- for (const tag of tags) {
2095
- const id = readAttr(tag.raw, "id");
2096
- if (id) addToken(`#${id}`, tag);
2097
- for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
2098
- addToken(`.${cls}`, tag);
2099
- }
2454
+ const tagsByToken = indexTagsByToken(tags);
2100
2455
  const allTargetsHtmlInCanvas = (selector) => {
2101
2456
  if (layoutSubtreeRanges.length === 0) return false;
2102
2457
  const matched = [...targetedSelectorTokens(selector)].flatMap(
@@ -2182,6 +2537,202 @@ ${right.raw}`)
2182
2537
  }
2183
2538
  return findings;
2184
2539
  },
2540
+ // gsap_relative_value_second_writer — a relative tween value ("+=..."/"-=...") on a
2541
+ // property that another writer is still ACTIVE on when the relative tween starts.
2542
+ // The relative tween captures its base at tween INIT, which happens on first render:
2543
+ // the sequential path inits it mid-flight of the other writer, a cold render worker
2544
+ // landing later inits it with the other writer's end state — the same frame then
2545
+ // renders at two different positions (a visible snap at chunk boundaries).
2546
+ // GSAP renders children in start-time order within a single seek pass, so a writer
2547
+ // that completes strictly BEFORE the relative tween's start yields identical bases
2548
+ // on every seek path and is never flagged. Single-writer relative values are
2549
+ // seek-stable. from()/fromTo() resolve their values at build (immediateRender), so
2550
+ // they are exempt. The position PARAMETER ("+=0.5") is not a tween value — the
2551
+ // parser keeps it out of properties — so it can never be flagged here.
2552
+ async ({ scripts, tags }) => {
2553
+ const findings = [];
2554
+ const tagsByToken = indexTagsByToken(tags);
2555
+ for (const script of scripts) {
2556
+ if (!/gsap\.timeline/.test(script.content)) continue;
2557
+ const windows = await cachedExtractGsapWindows(script.content);
2558
+ for (const win of windows) {
2559
+ if (win.method === "from" || win.method === "fromTo") continue;
2560
+ if (win.overwriteAuto) continue;
2561
+ if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
2562
+ const relativeProps = Object.entries(win.propertyValues).filter(([, value]) => isRelativeTweenValue(value)).map(([prop]) => prop);
2563
+ if (relativeProps.length === 0) continue;
2564
+ const target = { selector: win.targetSelector, identity: win.targetIdentity };
2565
+ for (const other of windows) {
2566
+ if (other === win) continue;
2567
+ if (other.position > win.position || other.end <= win.position) continue;
2568
+ const sharedProps = relativeProps.filter((prop) => other.properties.includes(prop));
2569
+ if (sharedProps.length === 0) continue;
2570
+ if (!targetsShareElement(
2571
+ target,
2572
+ { selector: other.targetSelector, identity: other.targetIdentity },
2573
+ tagsByToken
2574
+ )) {
2575
+ continue;
2576
+ }
2577
+ const values = sharedProps.map((prop) => `${prop}: "${win.propertyValues[prop]}"`).join(", ");
2578
+ const overlapEnd = Math.min(win.end, other.end);
2579
+ const formatTime = (t) => Number.isFinite(t) ? `${t.toFixed(2)}s` : "\u221E";
2580
+ findings.push({
2581
+ code: "gsap_relative_value_second_writer",
2582
+ severity: "error",
2583
+ message: `Relative value(s) ${values} on "${win.targetSelector}" start while another writer for the same propert${sharedProps.length > 1 ? "ies" : "y"} is active between ${formatTime(win.position)} and ${formatTime(overlapEnd)}. Relative tweens capture their base at tween init: the sequential path inits mid-flight of the other writer, a cold render worker landing later inits with its end state \u2014 the same frame renders at two different positions (snap at chunk boundaries).`,
2584
+ selector: win.targetSelector,
2585
+ fixHint: `Use absolute values for ${sharedProps.join(", ")}, or a fromTo() with explicit endpoints, so every seek path resolves the same state. Single-writer relative values are safe; the conflict is the second writer.`,
2586
+ snippet: truncateSnippet(`${win.raw}
2587
+ ${other.raw}`)
2588
+ });
2589
+ }
2590
+ }
2591
+ }
2592
+ return findings;
2593
+ },
2594
+ // gsap_repeat_refresh_relative_value — repeatRefresh re-resolves the tween's values
2595
+ // on every repeat iteration, so a relative value ACCUMULATES per cycle. A cold render
2596
+ // worker seeking non-linearly into iteration N skips the accumulation a sequential
2597
+ // playhead performed, so workers disagree on where the element is.
2598
+ ({ scripts }) => {
2599
+ const findings = [];
2600
+ for (const script of scripts) {
2601
+ const source = stripJsComments(script.content);
2602
+ const pattern = /repeatRefresh\s*:\s*true\b/g;
2603
+ let match;
2604
+ while ((match = pattern.exec(source)) !== null) {
2605
+ const objectLiteral = enclosingObjectLiteral(source, match.index);
2606
+ if (!objectLiteral || !objectLiteralHasTopLevelRelativeValue(objectLiteral)) continue;
2607
+ findings.push({
2608
+ code: "gsap_repeat_refresh_relative_value",
2609
+ severity: "error",
2610
+ message: '`repeatRefresh: true` combined with a relative value ("+="/"-=") accumulates per repeat iteration. A cold render worker seeking non-linearly into iteration N never performed the earlier iterations\' accumulation, so its rendered position diverges from the sequential path.',
2611
+ fixHint: "Remove `repeatRefresh: true`, or replace the relative value with absolute endpoints (e.g. a fromTo()) so each iteration resolves to the same state on every seek path.",
2612
+ snippet: truncateSnippet(objectLiteral)
2613
+ });
2614
+ }
2615
+ }
2616
+ return findings;
2617
+ },
2618
+ // gsap_function_value_hazard — function-valued tween vars re-run at tween INIT,
2619
+ // which is seek-order-dependent. A value reading transform-SENSITIVE geometry
2620
+ // (getBoundingClientRect/getComputedStyle/gsap.getProperty) captures whatever state
2621
+ // the worker's own seek order produced — error. Transform-INVARIANT layout reads
2622
+ // (offsetWidth, getTotalLength, ...) are deterministic across cold render workers
2623
+ // unless the measured layout itself animates — warning. GSAP function values receive
2624
+ // (index, target, targets) — index is a NUMBER, so a method call on the first
2625
+ // parameter (assuming it is the element) throws at init — error. Pure-index
2626
+ // arithmetic, gsap.utils.wrap/distribute, and closures over constants are statically
2627
+ // opaque or safe and are never flagged.
2628
+ //
2629
+ // Uses the raw parser output instead of the windows machinery: windows drop tweens
2630
+ // with string positions ("+=0.5", labels), and position is irrelevant to whether a
2631
+ // VALUE is hazardous.
2632
+ async ({ scripts }) => {
2633
+ const findings = [];
2634
+ const parseGsapScript = await loadParseGsapScript();
2635
+ for (const script of scripts) {
2636
+ if (!/gsap\.timeline/.test(script.content)) continue;
2637
+ const parsed = parseGsapScript(script.content);
2638
+ for (const anim of parsed.animations) {
2639
+ const raw = synthesizeWindowRaw(parsed.timelineVar, anim);
2640
+ const entries = [
2641
+ ...Object.entries(anim.properties),
2642
+ ...Object.entries(anim.fromProperties ?? {})
2643
+ ];
2644
+ for (const [prop, value] of entries) {
2645
+ if (typeof value !== "string" || !value.startsWith("__raw:")) continue;
2646
+ const fn = parseFunctionValueSource(value.slice(6));
2647
+ if (!fn) continue;
2648
+ const readsSensitive = TRANSFORM_SENSITIVE_READ.test(fn.body);
2649
+ const readsInvariant = TRANSFORM_INVARIANT_READ.test(fn.body);
2650
+ const badMember = firstParamMemberAccessHazard(fn);
2651
+ if (!readsSensitive && !readsInvariant && !badMember) continue;
2652
+ const reason = readsSensitive ? "reads transform-sensitive geometry, so its result depends on the worker's own seek order" : badMember ? `accesses .${badMember} on its first parameter \u2014 GSAP function values receive (index, target, targets), so the first parameter is a NUMBER and this throws at tween init` : "measures layout at tween init, which is deterministic across cold render workers only while the measured layout never animates";
2653
+ findings.push({
2654
+ code: "gsap_function_value_hazard",
2655
+ severity: readsSensitive || badMember ? "error" : "warning",
2656
+ message: `Function-valued tween var for ${prop} on "${anim.targetSelector}" ${reason}. Each render worker initializes tweens independently.`,
2657
+ selector: anim.targetSelector,
2658
+ fixHint: badMember ? "Use the SECOND parameter for the element: (index, target) => ... \u2014 or index arithmetic like (i) => i * 20." : "Compute the value once at build time (before the timeline is registered) and pass a constant, or derive it from fixed composition coordinates.",
2659
+ snippet: truncateSnippet(raw)
2660
+ });
2661
+ }
2662
+ }
2663
+ }
2664
+ return findings;
2665
+ },
2666
+ // gsap_callback_dom_measurement — DOM measurement reachable from timeline callbacks
2667
+ // (tl.add(fn) / tl.call(fn) / eventCallback / onStart-style vars). The capture path
2668
+ // seeks with suppressEvents=false (core/src/adapters/gsap.ts), so callbacks re-fire
2669
+ // on EVERY seek, including rewinds — and a cold render worker executes them against
2670
+ // whatever DOM state its own non-linear seek order produced. Geometry measured
2671
+ // inside a callback is therefore seek-order-dependent, and anything measured before
2672
+ // the callback ran (e.g. a build-time getTotalLength() on a path whose `d` the
2673
+ // callback assigns) is stale or zero. Warning, not error: gsap.getProperty-style
2674
+ // derived-output callbacks were excluded, but the remaining reads can still be
2675
+ // legitimate when the measured layout is static.
2676
+ ({ scripts }) => {
2677
+ const findings = [];
2678
+ for (const script of scripts) {
2679
+ const source = stripJsComments(script.content);
2680
+ if (!/gsap\.timeline/.test(source)) continue;
2681
+ const bodies = collectNamedFunctionBodies(source);
2682
+ const measuring = collectMeasuringFunctionNames(bodies);
2683
+ const callbackExpressionHazard = (expression) => {
2684
+ const trimmed = expression.trim();
2685
+ const inline = parseFunctionValueSource(trimmed);
2686
+ if (inline) return expressionReachesMeasurement(inline.body, measuring);
2687
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return measuring.has(trimmed);
2688
+ return false;
2689
+ };
2690
+ const report = (site, snippet) => {
2691
+ findings.push({
2692
+ code: "gsap_callback_dom_measurement",
2693
+ severity: "warning",
2694
+ message: "Timeline callback reaches DOM measurement (getBoundingClientRect/getTotalLength/getComputedStyle/...). The renderer seeks with suppressEvents=false, so callbacks re-fire on every seek \u2014 and a cold render worker runs them against whatever DOM state its own non-linear seek order produced. Measured geometry is seek-order-dependent, and values measured at build time (before the callback ran) are stale or zero.",
2695
+ selector: truncateSnippet(site, 120),
2696
+ fixHint: "Do all measurement and DOM setup synchronously at build time, before registering the timeline \u2014 or derive geometry from fixed composition coordinates instead of measuring.",
2697
+ snippet: truncateSnippet(snippet)
2698
+ });
2699
+ };
2700
+ const timelineVars = collectTimelineVarNames(source);
2701
+ for (const timelineVar of timelineVars) {
2702
+ const callPattern = new RegExp(
2703
+ `\\b${escapeRegExp3(timelineVar)}\\.(?:add|call)\\s*\\(`,
2704
+ "g"
2705
+ );
2706
+ let match2;
2707
+ while ((match2 = callPattern.exec(source)) !== null) {
2708
+ const parenIndex = match2.index + match2[0].length - 1;
2709
+ const argsWithParens = matchBalanced(source, parenIndex, "(", ")");
2710
+ if (!argsWithParens) continue;
2711
+ const firstArg = sliceExpression(argsWithParens.slice(1, -1), 0);
2712
+ const site = match2[0] + firstArg + ", ...)";
2713
+ if (callbackExpressionHazard(firstArg)) report(site, site);
2714
+ }
2715
+ const eventCallbackPattern = new RegExp(
2716
+ `\\b${escapeRegExp3(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`,
2717
+ "g"
2718
+ );
2719
+ while ((match2 = eventCallbackPattern.exec(source)) !== null) {
2720
+ const expression = sliceExpression(source, eventCallbackPattern.lastIndex);
2721
+ const site = match2[0] + expression + ")";
2722
+ if (callbackExpressionHazard(expression)) report(site, site);
2723
+ }
2724
+ }
2725
+ const varsCallbackPattern = /\bon(?:Start|Update|Complete|Repeat|ReverseComplete|Interrupt|Overwrite)\s*:\s*/g;
2726
+ let match;
2727
+ while ((match = varsCallbackPattern.exec(source)) !== null) {
2728
+ if (!isInsideGsapTweenVars(source, match.index, timelineVars)) continue;
2729
+ const expression = sliceExpression(source, varsCallbackPattern.lastIndex);
2730
+ const site = match[0] + expression;
2731
+ if (callbackExpressionHazard(expression)) report(site, site);
2732
+ }
2733
+ }
2734
+ return findings;
2735
+ },
2185
2736
  // gsap_group_selector_keyframes
2186
2737
  ({ scripts }) => {
2187
2738
  const findings = [];
@@ -2202,6 +2753,204 @@ ${right.raw}`)
2202
2753
  });
2203
2754
  }
2204
2755
  return findings;
2756
+ },
2757
+ // svg_drawon_css_dasharray_conflict — GSAP sets/tweens strokeDasharray on an element
2758
+ // whose CSS declares a MULTI-component stroke-dasharray (e.g. `10 10`). GSAP merges
2759
+ // dash lists per component, so `strokeDasharray: 641.4` over CSS `10 10` computes to
2760
+ // "641.4px, 10px" — the gap stays 10px and the hide-then-draw-on trick silently
2761
+ // fails: the line is visible the whole scene. A static two-component GSAP value is
2762
+ // the explicit fix form and is not flagged.
2763
+ // fallow-ignore-next-line complexity
2764
+ ({ scripts, styles, tags }) => {
2765
+ const findings = [];
2766
+ const tagsByToken = indexTagsByToken(tags);
2767
+ const multiDashTokens = /* @__PURE__ */ new Set();
2768
+ for (const style of styles) {
2769
+ for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) {
2770
+ if (!selectorList || !body) continue;
2771
+ const value = readStyleProperty(body, "stroke-dasharray");
2772
+ if (!value || !isMultiComponentDasharray(value)) continue;
2773
+ for (const group of selectorList.split(",")) {
2774
+ const trimmed = group.trim();
2775
+ if (!trimmed || /[\s>+~]/.test(trimmed)) continue;
2776
+ for (const token of targetedSelectorTokens(trimmed)) multiDashTokens.add(token);
2777
+ }
2778
+ }
2779
+ }
2780
+ for (const tag of tags) {
2781
+ const inlineValue = readStyleProperty(readAttr(tag.raw, "style") ?? "", "stroke-dasharray");
2782
+ if (!inlineValue || !isMultiComponentDasharray(inlineValue)) continue;
2783
+ for (const token of tagSimpleSelectors(tag)) multiDashTokens.add(token);
2784
+ }
2785
+ if (multiDashTokens.size === 0) return findings;
2786
+ for (const script of scripts) {
2787
+ const source = stripJsComments(script.content);
2788
+ const varTokens = resolveScriptElementTokens(source, tags);
2789
+ const reported = /* @__PURE__ */ new Set();
2790
+ const writerPattern = /\b[\w$]+\.(set|to|fromTo)\s*\(\s*(?:(["'])([^"'`]+)\2|([A-Za-z_$][\w$]*))\s*,\s*\{/g;
2791
+ let match;
2792
+ while ((match = writerPattern.exec(source)) !== null) {
2793
+ const method = match[1] ?? "";
2794
+ const braceIndex = match.index + match[0].length - 1;
2795
+ const firstVars = matchBalanced(source, braceIndex, "{", "}");
2796
+ if (!firstVars) continue;
2797
+ const varsObjects = [firstVars];
2798
+ if (method === "fromTo") {
2799
+ const afterFirst = source.slice(braceIndex + firstVars.length);
2800
+ const secondOpen = /^\s*,\s*\{/.exec(afterFirst);
2801
+ if (secondOpen) {
2802
+ const secondBrace = braceIndex + firstVars.length + secondOpen[0].length - 1;
2803
+ const secondVars = matchBalanced(source, secondBrace, "{", "}");
2804
+ if (secondVars) varsObjects.push(secondVars);
2805
+ }
2806
+ }
2807
+ const quotedSelector = match[3];
2808
+ const targetTokens = quotedSelector ? targetedSelectorTokens(quotedSelector) : varTokens.get(match[4] ?? "") ?? /* @__PURE__ */ new Set();
2809
+ if (targetTokens.size === 0) continue;
2810
+ const expanded = elementLevelTokens(targetTokens, tagsByToken);
2811
+ for (const varsObject of varsObjects) {
2812
+ const propMatch = varsObject.match(/\bstrokeDasharray\s*:\s*/) ?? varsObject.match(/["']stroke-dasharray["']\s*:\s*/);
2813
+ if (!propMatch || propMatch.index === void 0) continue;
2814
+ const valueSource = sliceExpression(varsObject, propMatch.index + propMatch[0].length);
2815
+ if (gsapDasharrayValueLooksMultiComponent(valueSource)) continue;
2816
+ const conflictToken = [...expanded].find((token) => multiDashTokens.has(token));
2817
+ if (!conflictToken) continue;
2818
+ const targetLabel = quotedSelector ?? match[4] ?? "";
2819
+ if (reported.has(targetLabel + conflictToken)) continue;
2820
+ reported.add(targetLabel + conflictToken);
2821
+ findings.push({
2822
+ code: "svg_drawon_css_dasharray_conflict",
2823
+ severity: "error",
2824
+ message: `GSAP writes strokeDasharray on "${targetLabel}", but its CSS ("${conflictToken}") declares a multi-component stroke-dasharray. GSAP merges dash lists per component, so the CSS gap survives (e.g. "641.4px, 10px") \u2014 the draw-on hide only hides one gap's worth and the line stays visible the whole scene.`,
2825
+ selector: quotedSelector ?? void 0,
2826
+ fixHint: `Remove the CSS stroke-dasharray from "${conflictToken}" (decorative dashes belong on a separate element), or set the full two-component value in GSAP: strokeDasharray: "\${len} \${len}".`,
2827
+ snippet: truncateSnippet(match[0] + firstVars.slice(1))
2828
+ });
2829
+ }
2830
+ }
2831
+ }
2832
+ return findings;
2833
+ },
2834
+ // gsap_timeline_set_initial_hide — a zero-duration tl.set(...) at position 0 inside
2835
+ // the paused timeline does NOT render while the playhead sits exactly at 0 (verified
2836
+ // against this repo's GSAP: tl.time(0) leaves the target untouched; only a seek past
2837
+ // 0 applies it). Frame 0 therefore shows the UN-hidden state, then the element pops
2838
+ // hidden on frame 1 — and only for the worker that renders frame 0. Targets already
2839
+ // hidden by authored CSS/inline styles or by a standalone gsap.set are exempt: the
2840
+ // tl.set is then a defensive re-assertion and frame 0 is hidden anyway.
2841
+ //
2842
+ // Only sets that precede every tween in source order qualify: the parser resolves a
2843
+ // mutated position variable (`var t = 0; ...; tl.set(sel, vars, t)`) to its INITIAL
2844
+ // binding, so late hard-kills can masquerade as position-0 sets. Genuine
2845
+ // initial-state hides are authored before the timeline's tweens.
2846
+ async ({ scripts, styles, tags }) => {
2847
+ const findings = [];
2848
+ const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
2849
+ const tagsByToken = indexTagsByToken(tags);
2850
+ for (const script of scripts) {
2851
+ if (!/gsap\.timeline/.test(script.content)) continue;
2852
+ const windows = await cachedExtractGsapWindows(script.content);
2853
+ const alreadyHidden = /* @__PURE__ */ new Set([
2854
+ ...cssHiddenSelectors,
2855
+ ...extractStandaloneHiddenSelectors(script.content)
2856
+ ]);
2857
+ const isInstantHold = (win) => win.method === "set" || (win.method === "to" || win.method === "fromTo") && win.end === win.position;
2858
+ const firstTweenIndex = windows.findIndex((win) => !isInstantHold(win));
2859
+ const initialHolds = firstTweenIndex < 0 ? windows : windows.slice(0, firstTweenIndex);
2860
+ for (const win of initialHolds) {
2861
+ if (!isInstantHold(win) || win.position !== 0) continue;
2862
+ if (win.global || win.immediateRender) continue;
2863
+ if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
2864
+ const targetTokens = [...targetedSelectorTokens(win.targetSelector)];
2865
+ const hiddenByToken = targetTokens.length > 0 && targetTokens.every((token) => alreadyHidden.has(token));
2866
+ const resolvedTags = targetTokens.flatMap((token) => tagsByToken.get(token) ?? []);
2867
+ const hiddenByElement = resolvedTags.length > 0 && resolvedTags.every(
2868
+ (tag) => tagSimpleSelectors(tag).some((token) => alreadyHidden.has(token))
2869
+ );
2870
+ if (hiddenByToken || hiddenByElement) continue;
2871
+ const offset = win.propertyValues["strokeDashoffset"];
2872
+ const hidesByOffset = numberValue(offset) !== null && !zeroValue(offset);
2873
+ const hides = isHiddenGsapState(win.propertyValues) || zeroValue(win.propertyValues["scale"]) || hidesByOffset;
2874
+ if (!hides) continue;
2875
+ findings.push({
2876
+ code: "gsap_timeline_set_initial_hide",
2877
+ severity: "warning",
2878
+ message: `Initial hidden state for "${win.targetSelector}" is set via tl.set(...) at position 0 inside the paused timeline. A zero-duration set at 0 does not render while the playhead sits exactly at 0, so frame 0 shows the un-hidden state.`,
2879
+ selector: win.targetSelector,
2880
+ fixHint: "Use gsap.set(...) (immediate, outside the timeline) for initial states, or author the hidden state directly in CSS/attributes.",
2881
+ snippet: truncateSnippet(win.raw)
2882
+ });
2883
+ }
2884
+ }
2885
+ return findings;
2886
+ },
2887
+ // svg_measure_before_path_d — getTotalLength() on a <path> that has no static `d`
2888
+ // attribute in the HTML. In Chrome getTotalLength() on a d-less path returns 0,
2889
+ // silently killing dash animations (offset 0 == length 0 == nothing to draw). If a
2890
+ // d assignment exists but only inside a function body, execution order is statically
2891
+ // undecidable — WARNING; if NO d assignment exists anywhere — ERROR. Element
2892
+ // identity is resolved conservatively (literal / template getElementById,
2893
+ // querySelector); createElementNS-built paths and unresolved variables are skipped.
2894
+ // fallow-ignore-next-line complexity
2895
+ ({ scripts, styles, tags }) => {
2896
+ const findings = [];
2897
+ const tagsByToken = indexTagsByToken(tags);
2898
+ const cssProvidesD = styles.some((style) => /\bd\s*:\s*path\(/.test(style.content));
2899
+ for (const script of scripts) {
2900
+ const source = stripJsComments(script.content);
2901
+ const varTokens = resolveScriptElementTokens(source, tags);
2902
+ const functionRanges = collectFunctionBodyRanges(source);
2903
+ const createdVars = new Set(
2904
+ [...source.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*document\.createElementNS\(/g)].map(
2905
+ (m) => m[1] ?? ""
2906
+ )
2907
+ );
2908
+ const dAssignments = [
2909
+ ...[...source.matchAll(/\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*["']d["']\s*,/g)].map(
2910
+ (m) => ({ varName: m[1] ?? "", index: m.index ?? 0 })
2911
+ ),
2912
+ ...[
2913
+ ...source.matchAll(
2914
+ /\.(?:set|to|fromTo)\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*\{[^{}]*\battr\s*:\s*\{[^{}]*\bd\s*:/g
2915
+ )
2916
+ ].map((m) => ({ varName: m[1] ?? "", index: m.index ?? 0 }))
2917
+ ];
2918
+ const reported = /* @__PURE__ */ new Set();
2919
+ const measurePattern = /\b([A-Za-z_$][\w$]*)\.getTotalLength\s*\(/g;
2920
+ let match;
2921
+ while ((match = measurePattern.exec(source)) !== null) {
2922
+ const varName = match[1] ?? "";
2923
+ if (createdVars.has(varName)) continue;
2924
+ const tokens = varTokens.get(varName);
2925
+ if (!tokens || tokens.size === 0) continue;
2926
+ const resolvedTags = [...tokens].flatMap((token) => tagsByToken.get(token) ?? []);
2927
+ const dLessPaths = resolvedTags.filter(
2928
+ (tag) => tag.name.toLowerCase() === "path" && readAttr(tag.raw, "d") === null
2929
+ );
2930
+ if (dLessPaths.length === 0 || dLessPaths.length !== resolvedTags.length) continue;
2931
+ if (cssProvidesD) continue;
2932
+ const measureIndex = match.index;
2933
+ const assignedBeforeInScope = dAssignments.some(
2934
+ (assign) => assign.varName === varName && assign.index < measureIndex && (!indexInsideAnyRange(assign.index, functionRanges) || functionRanges.some(
2935
+ (range) => assign.index > range.start && assign.index < range.end && measureIndex > range.start && measureIndex < range.end
2936
+ ))
2937
+ );
2938
+ if (assignedBeforeInScope) continue;
2939
+ const sameVarAssignmentExists = dAssignments.some((a) => a.varName === varName);
2940
+ const tokenLabel = [...tokens].join(", ");
2941
+ if (reported.has(tokenLabel)) continue;
2942
+ reported.add(tokenLabel);
2943
+ findings.push({
2944
+ code: "svg_measure_before_path_d",
2945
+ severity: sameVarAssignmentExists ? "warning" : "error",
2946
+ message: sameVarAssignmentExists ? `getTotalLength() is called on "${tokenLabel}", whose \`d\` is only assigned inside a function body \u2014 if the measure runs before that function (e.g. the function is a timeline callback), the length is 0 and the dash animation is dead.` : `getTotalLength() is called on "${tokenLabel}", but the path has no static \`d\` attribute and no d assignment exists anywhere \u2014 getTotalLength() returns 0 in Chrome, silently killing dash animations.`,
2947
+ selector: tokenLabel,
2948
+ fixHint: "Assign the path's `d` synchronously at build time (top level, before measuring), or author a static d attribute in the HTML.",
2949
+ snippet: truncateSnippet(match[0] + ")")
2950
+ });
2951
+ }
2952
+ }
2953
+ return findings;
2205
2954
  }
2206
2955
  ];
2207
2956
 
@@ -2417,11 +3166,36 @@ var captionRules = [
2417
3166
 
2418
3167
  // src/rules/composition.ts
2419
3168
  import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
3169
+ import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
2420
3170
  var MAX_COMPOSITION_LINES = 300;
2421
3171
  var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
2422
3172
  var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
2423
3173
  var CAPTION_CUE_TOKEN = /^(?:caption(?:[-_](?:group|word|line|block|cue|text))?|subtitle(?:[-_](?:group|line|cue|text))?|cg-.+)$/i;
3174
+ var HEAVY_OVERLAY_ELEMENT_COUNT_WARN = 25;
3175
+ var HEAVY_OVERLAY_EXEMPT_TAGS = /* @__PURE__ */ new Set([
3176
+ "audio",
3177
+ "body",
3178
+ "br",
3179
+ "defs",
3180
+ "head",
3181
+ "hr",
3182
+ "html",
3183
+ "link",
3184
+ "meta",
3185
+ "script",
3186
+ "source",
3187
+ "style",
3188
+ "template",
3189
+ "title",
3190
+ "use",
3191
+ "video"
3192
+ ]);
3193
+ var HEAVY_OVERLAY_CSS_PATTERN = /(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
3194
+ var INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
2424
3195
  var OVERLAP_EPSILON_SECONDS = 1e-6;
3196
+ function readTagTiming(rawTag) {
3197
+ return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
3198
+ }
2425
3199
  function countPhysicalLines(source) {
2426
3200
  if (source.length === 0) return 0;
2427
3201
  const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -2479,6 +3253,33 @@ function leftmostCompoundClasses(selector) {
2479
3253
  const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
2480
3254
  return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
2481
3255
  }
3256
+ function leftmostCompoundId(selector) {
3257
+ const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
3258
+ return leftmost.match(/#([\w-]+)/)?.[1] ?? null;
3259
+ }
3260
+ function collectHeavyOverlayHooks(styles) {
3261
+ const classes = /* @__PURE__ */ new Set();
3262
+ const ids = /* @__PURE__ */ new Set();
3263
+ for (const style of styles) {
3264
+ const noComments = style.content.replace(/\/\*[\s\S]*?\*\//g, "");
3265
+ const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
3266
+ let m;
3267
+ while ((m = ruleWithBody.exec(noComments)) !== null) {
3268
+ const header = (m[1] ?? "").trim();
3269
+ const body = m[2] ?? "";
3270
+ if (!header || header.startsWith("@")) continue;
3271
+ if (!HEAVY_OVERLAY_CSS_PATTERN.test(body)) continue;
3272
+ for (const sel of header.split(",")) {
3273
+ const trimmed = sel.trim();
3274
+ if (!trimmed) continue;
3275
+ for (const cls of leftmostCompoundClasses(trimmed)) classes.add(cls);
3276
+ const idToken = leftmostCompoundId(trimmed);
3277
+ if (idToken) ids.add(idToken);
3278
+ }
3279
+ }
3280
+ }
3281
+ return { classes, ids };
3282
+ }
2482
3283
  function rootClassStyledSelectors(styles, rootClasses) {
2483
3284
  const offenders = [];
2484
3285
  for (const style of styles) {
@@ -2629,7 +3430,7 @@ var compositionRules = [
2629
3430
  if (isCaptionCue(tag)) continue;
2630
3431
  if (isCompositionRootOrMount(tag.raw)) continue;
2631
3432
  if (!readAttr(tag.raw, "data-start")) continue;
2632
- const track = readAttr(tag.raw, "data-track-index");
3433
+ const track = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
2633
3434
  if (!track) continue;
2634
3435
  trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
2635
3436
  }
@@ -2678,7 +3479,8 @@ var compositionRules = [
2678
3479
  ({ tags }) => {
2679
3480
  const findings = [];
2680
3481
  for (const tag of tags) {
2681
- if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
3482
+ const timing = readTagTiming(tag.raw);
3483
+ if (timing.diagnostics.some(({ code }) => code === "deprecated-layer")) {
2682
3484
  const elementId = readAttr(tag.raw, "id") || void 0;
2683
3485
  findings.push({
2684
3486
  code: "deprecated_data_layer",
@@ -2689,7 +3491,7 @@ var compositionRules = [
2689
3491
  snippet: truncateSnippet(tag.raw)
2690
3492
  });
2691
3493
  }
2692
- if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
3494
+ if (timing.diagnostics.some(({ code }) => code === "deprecated-end")) {
2693
3495
  const elementId = readAttr(tag.raw, "id") || void 0;
2694
3496
  findings.push({
2695
3497
  code: "deprecated_data_end",
@@ -2779,14 +3581,12 @@ var compositionRules = [
2779
3581
  const findings = [];
2780
3582
  const trackMap = /* @__PURE__ */ new Map();
2781
3583
  for (const tag of tags) {
2782
- const startStr = readAttr(tag.raw, "data-start");
2783
- const durationStr = readAttr(tag.raw, "data-duration");
2784
- const trackStr = readAttr(tag.raw, "data-track-index");
2785
- if (!startStr || !durationStr || !trackStr) continue;
2786
- const start = Number(startStr);
2787
- const duration = Number(durationStr);
3584
+ const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
3585
+ if (!trackStr) continue;
3586
+ const timing = readTagTiming(tag.raw);
3587
+ const { start, duration } = timing;
2788
3588
  const track = trackStr;
2789
- if (Number.isNaN(start) || Number.isNaN(duration)) continue;
3589
+ if (start == null || duration == null) continue;
2790
3590
  const clips = trackMap.get(track) || [];
2791
3591
  clips.push({
2792
3592
  start,
@@ -3229,6 +4029,55 @@ ${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
3229
4029
  ];
3230
4030
  }
3231
4031
  return [];
4032
+ },
4033
+ // composition_heavy_overlay_count_high
4034
+ // Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition
4035
+ // with ~40 heavy overlay DOM elements — `filter:blur`, oversized
4036
+ // `radial-gradient`, and `clip-path` animations — captures solid-black for
4037
+ // the first ~half of the render, recovering near the end. Reproduces
4038
+ // identically via drawElement AND forced --no-browser-gpu screenshot
4039
+ // capture AND `snapshot`, so the offender is the capture layer itself, not
4040
+ // encoder/mux. Independent of duration (padding the timeline grows the bad
4041
+ // zone proportionally, doesn't shift it). Reporter's workaround was to
4042
+ // split into per-transition mini compositions + FFmpeg concat.
4043
+ //
4044
+ // Presence alone matters: opacity:0 and visibility:hidden overlays still
4045
+ // contribute to the capture-layer regression, so they're counted-in. The
4046
+ // only escape hatch is `display: none` — an element removed from the render
4047
+ // tree can't feed the compositor. Warn at 25, well below the observed
4048
+ // 40-element repro, to give authors lead time before hitting the bug.
4049
+ // fallow-ignore-next-line complexity
4050
+ ({ tags, styles, rawSource, options }) => {
4051
+ if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
4052
+ const { classes: heavyClassTokens, ids: heavyIds } = collectHeavyOverlayHooks(styles);
4053
+ let heavyCount = 0;
4054
+ for (const tag of tags) {
4055
+ if (HEAVY_OVERLAY_EXEMPT_TAGS.has(tag.name)) continue;
4056
+ if (isCompositionRootOrMount(tag.raw)) continue;
4057
+ const styleAttr = readJsonAttr(tag.raw, "style") ?? "";
4058
+ if (styleAttr && INLINE_STYLE_DISPLAY_NONE_PATTERN.test(styleAttr)) continue;
4059
+ let heavy = false;
4060
+ if (styleAttr && HEAVY_OVERLAY_CSS_PATTERN.test(styleAttr)) heavy = true;
4061
+ if (!heavy && (heavyClassTokens.size > 0 || heavyIds.size > 0)) {
4062
+ const classList = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
4063
+ if (classList.some((cls) => heavyClassTokens.has(cls))) heavy = true;
4064
+ if (!heavy) {
4065
+ const idValue = readAttr(tag.raw, "id");
4066
+ if (idValue && heavyIds.has(idValue)) heavy = true;
4067
+ }
4068
+ }
4069
+ if (heavy) heavyCount += 1;
4070
+ }
4071
+ if (heavyCount < HEAVY_OVERLAY_ELEMENT_COUNT_WARN) return [];
4072
+ const splitTarget = options.isSubComposition ? "Split this sub-composition further into per-transition mini-compositions" : "Split coherent scenes / transitions into separate .html files under compositions/";
4073
+ return [
4074
+ {
4075
+ code: "composition_heavy_overlay_count_high",
4076
+ severity: "warning",
4077
+ message: `This composition has ${heavyCount} elements carrying "heavy overlay" CSS (filter:blur, radial-gradient, or clip-path). Field signal: a composition with ~40 such elements \u2014 including opacity:0 / visibility:hidden ones \u2014 captures solid-black for the first ~half of the render, recovering near the end. Reproduces identically via drawElement, forced screenshot capture, and snapshot, so the capture layer itself is the offender (not encoder/mux). Independent of duration. Presence alone matters; only display:none elements are excluded here.`,
4078
+ fixHint: `${splitTarget} and concat the pieces (FFmpeg or the runtime's slideshow) so each capture only sees a small subset of heavy overlays at once. Even hidden overlays (opacity:0 / visibility:hidden) contribute \u2014 either remove truly unused ones from the source or scope them into their own per-transition sub-composition. If an overlay is genuinely inert for the whole clip, use display:none so it never enters the render tree. Field ref ts=1784040753 (#hyperframes-cli-feedback).`
4079
+ }
4080
+ ];
3232
4081
  }
3233
4082
  ];
3234
4083
 
@@ -3499,7 +4348,7 @@ function extractFontFaceFamilies(styles) {
3499
4348
  return families;
3500
4349
  }
3501
4350
  function normalizeUsedFontName(part) {
3502
- const name = part.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase();
4351
+ const name = part.trim().replace(/\s*!important\s*$/i, "").replace(/^['"]|['"]$/g, "").trim().toLowerCase();
3503
4352
  if (!name || name.includes("(") || name.includes(")")) return null;
3504
4353
  return name;
3505
4354
  }
@@ -3815,11 +4664,118 @@ function shouldBlockRender(strictErrors, strictAll, totalErrors, totalWarnings)
3815
4664
 
3816
4665
  // src/project.ts
3817
4666
  import { existsSync, readFileSync, readdirSync } from "fs";
3818
- import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "path";
3819
- import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
3820
- import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
4667
+ import { dirname, extname, join, relative, resolve } from "path";
4668
+ import { rewriteAssetPath as rewriteAssetPath2 } from "@hyperframes/parsers/asset-paths";
3821
4669
  import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
3822
4670
  import { parseHTML } from "linkedom";
4671
+ import {
4672
+ cleanAssetUrl as cleanAssetUrl2,
4673
+ isRemoteOrInlineUrl as isRemoteOrInlineUrl2,
4674
+ isWithinProjectRoot,
4675
+ maskNonScannableRanges as maskNonScannableRanges2,
4676
+ resolveExistingLocalAsset as resolveExistingLocalAsset2,
4677
+ resolveLocalAssetCandidates
4678
+ } from "@hyperframes/parsers/asset-resolution";
4679
+
4680
+ // src/hevcPreviewLint.ts
4681
+ import { execFile } from "child_process";
4682
+ import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
4683
+ import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
4684
+ import {
4685
+ cleanAssetUrl,
4686
+ isRemoteOrInlineUrl,
4687
+ maskNonScannableRanges,
4688
+ resolveExistingLocalAsset
4689
+ } from "@hyperframes/parsers/asset-resolution";
4690
+ var PROBE_TIMEOUT_MS = 4e3;
4691
+ var PROBE_CONCURRENCY = 8;
4692
+ function execFileAsync(file, args) {
4693
+ return new Promise((resolvePromise, reject) => {
4694
+ execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
4695
+ if (error) reject(error);
4696
+ else resolvePromise(stdout.toString());
4697
+ });
4698
+ });
4699
+ }
4700
+ function hasHevcStream(json) {
4701
+ if (typeof json !== "object" || json === null) return false;
4702
+ const streams = Reflect.get(json, "streams");
4703
+ if (!Array.isArray(streams)) return false;
4704
+ return streams.some((stream) => {
4705
+ if (typeof stream !== "object" || stream === null) return false;
4706
+ return Reflect.get(stream, "codec_name") === "hevc";
4707
+ });
4708
+ }
4709
+ async function probeIsHevc(ffprobePath, filePath) {
4710
+ try {
4711
+ const stdout = await execFileAsync(ffprobePath, [
4712
+ "-v",
4713
+ "error",
4714
+ "-select_streams",
4715
+ "v:0",
4716
+ "-show_entries",
4717
+ "stream=codec_name",
4718
+ "-of",
4719
+ "json",
4720
+ filePath
4721
+ ]);
4722
+ return hasHevcStream(JSON.parse(stdout));
4723
+ } catch {
4724
+ return false;
4725
+ }
4726
+ }
4727
+ function collectLocalVideoCandidates(projectDir, htmlSources) {
4728
+ const candidates = /* @__PURE__ */ new Map();
4729
+ const videoSrcRe = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
4730
+ for (const { html, compSrcPath } of htmlSources) {
4731
+ const scannable = maskNonScannableRanges(html);
4732
+ const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
4733
+ let match;
4734
+ while ((match = re.exec(scannable)) !== null) {
4735
+ const src = cleanAssetUrl(match[1] ?? "");
4736
+ if (!src) continue;
4737
+ if (isRemoteOrInlineUrl(src)) continue;
4738
+ if (/^__[A-Z_]+__$/.test(src)) continue;
4739
+ const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
4740
+ const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
4741
+ if (!resolvedAsset) continue;
4742
+ if (!candidates.has(resolvedAsset.resolved)) candidates.set(resolvedAsset.resolved, src);
4743
+ }
4744
+ }
4745
+ return candidates;
4746
+ }
4747
+ async function lintHevcPreviewCodec(candidates) {
4748
+ if (candidates.size === 0) return [];
4749
+ const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true });
4750
+ if (!ffprobePath) return [];
4751
+ const entries = [...candidates.entries()];
4752
+ const isHevc = new Array(entries.length).fill(false);
4753
+ let nextIndex = 0;
4754
+ const workerCount = Math.min(PROBE_CONCURRENCY, entries.length);
4755
+ await Promise.all(
4756
+ Array.from({ length: workerCount }, async () => {
4757
+ while (nextIndex < entries.length) {
4758
+ const index = nextIndex++;
4759
+ const entry = entries[index];
4760
+ if (!entry) break;
4761
+ isHevc[index] = await probeIsHevc(ffprobePath, entry[0]);
4762
+ }
4763
+ })
4764
+ );
4765
+ const hevcSrcs = entries.filter((_, i) => isHevc[i]).map(([, src]) => src);
4766
+ if (hevcSrcs.length === 0) return [];
4767
+ const unique = [...new Set(hevcSrcs)];
4768
+ return [
4769
+ {
4770
+ code: "hevc_preview_codec",
4771
+ severity: "info",
4772
+ message: `Video file(s) use the HEVC/H.265 codec: ${unique.join(", ")}. The render pipeline pre-decodes video with FFmpeg and never uses the browser's video decoder, so these render correctly. Live preview/player playback automatically uses a cached H.264 proxy when the browser cannot decode HEVC. If playback still fails, verify ffmpeg/ffprobe are installed and auto-proxying is enabled.`,
4773
+ fixHint: unique.length === 1 ? `If "${unique[0]}" fails to play in preview, run hyperframes doctor and confirm media.autoProxy is not false.` : "If these files fail to play in preview, run hyperframes doctor and confirm media.autoProxy is not false."
4774
+ }
4775
+ ];
4776
+ }
4777
+
4778
+ // src/project.ts
3823
4779
  function parseSubCompHtml(html) {
3824
4780
  return parseHTML(html).document;
3825
4781
  }
@@ -3844,7 +4800,7 @@ function collectLocalStylesheets(projectDir, document, compSrcPath) {
3844
4800
  const href = link.getAttribute("href") ?? "";
3845
4801
  if (!isLocalStylesheetHref(href)) continue;
3846
4802
  const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
3847
- const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
4803
+ const stylesheet = resolveExistingLocalAsset2(projectDir, rootRelative);
3848
4804
  if (!stylesheet) continue;
3849
4805
  styles.push({
3850
4806
  href,
@@ -3882,52 +4838,13 @@ function collectCssSources(projectDir, html, compSrcPath) {
3882
4838
  }
3883
4839
  return sources;
3884
4840
  }
3885
- function isRemoteOrInlineUrl(url) {
3886
- return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
3887
- }
3888
- function cleanAssetUrl(url) {
3889
- return url.trim().split(/[?#]/, 1)[0] ?? "";
3890
- }
3891
- function isWithinProjectRoot(projectDir, candidate) {
3892
- const projectRoot = resolve(projectDir);
3893
- const relativePath = relative(projectRoot, candidate);
3894
- return relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath);
3895
- }
3896
- function addCandidate(candidates, candidate) {
3897
- if (!candidates.includes(candidate)) candidates.push(candidate);
3898
- }
3899
- function resolveLocalAssetCandidates(projectDir, url) {
3900
- const cleanUrl = cleanAssetUrl(url);
3901
- const projectRoot = resolve(projectDir);
3902
- const candidates = [];
3903
- for (const variant of decodeUrlPathVariants(cleanUrl)) {
3904
- const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
3905
- const resolved = resolve(projectRoot, projectRelative);
3906
- if (isWithinProjectRoot(projectRoot, resolved)) {
3907
- addCandidate(candidates, resolved);
3908
- continue;
3909
- }
3910
- const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
3911
- const clamped = normalized.replace(/^(\.\.\/)+/, "");
3912
- if (clamped && !clamped.startsWith("..")) {
3913
- addCandidate(candidates, resolve(projectRoot, clamped));
3914
- }
3915
- }
3916
- return candidates;
3917
- }
3918
- function resolveExistingLocalAsset(projectDir, url) {
3919
- const projectRoot = resolve(projectDir);
3920
- const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
3921
- if (!resolved) return null;
3922
- return { resolved, rootRelativePath: relative(projectRoot, resolved) };
3923
- }
3924
4841
  function resolveCssAssetCandidates(projectDir, url, htmlCompSrcPath, cssRootRelativePath) {
3925
4842
  if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
3926
4843
  if (cssRootRelativePath) {
3927
4844
  return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
3928
4845
  }
3929
4846
  if (htmlCompSrcPath) {
3930
- return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
4847
+ return resolveLocalAssetCandidates(projectDir, rewriteAssetPath2(htmlCompSrcPath, url));
3931
4848
  }
3932
4849
  return resolveLocalAssetCandidates(projectDir, url);
3933
4850
  }
@@ -3992,7 +4909,8 @@ async function lintProject(projectDir, entryFile) {
3992
4909
  ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
3993
4910
  ...!entryFile ? lintMultipleRootCompositions(projectDir) : [],
3994
4911
  ...lintDuplicateAudioTracks(allHtmlSources),
3995
- ...lintMissingOrEmptySubComposition(projectDir, rootHtml)
4912
+ ...lintMissingOrEmptySubComposition(projectDir, rootHtml),
4913
+ ...await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))
3996
4914
  ];
3997
4915
  if (projectFindings.length > 0) {
3998
4916
  for (const finding of projectFindings) {
@@ -4044,7 +4962,7 @@ function lintAudioSrcNotFound(projectDir, htmlSources) {
4044
4962
  const src = match[1];
4045
4963
  if (/^(https?:|data:|blob:)/i.test(src)) continue;
4046
4964
  if (/^__[A-Z_]+__$/.test(src)) continue;
4047
- const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
4965
+ const rootRelative = compSrcPath ? rewriteAssetPath2(compSrcPath, src) : src;
4048
4966
  if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
4049
4967
  missingSrcs.push(src);
4050
4968
  }
@@ -4061,32 +4979,23 @@ function lintAudioSrcNotFound(projectDir, htmlSources) {
4061
4979
  }
4062
4980
  return findings;
4063
4981
  }
4064
- function maskRange(src, pattern) {
4065
- return src.replace(pattern, (m) => " ".repeat(m.length));
4066
- }
4067
- function maskNonScannableRanges(html) {
4068
- let out = maskRange(html, /<!--[\s\S]*?-->/g);
4069
- out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
4070
- out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
4071
- return out;
4072
- }
4073
4982
  function lintMissingLocalAsset(projectDir, htmlSources) {
4074
4983
  const findings = [];
4075
4984
  const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
4076
4985
  const missingByTag = /* @__PURE__ */ new Map();
4077
4986
  for (const { html, compSrcPath } of htmlSources) {
4078
- const scannable = maskNonScannableRanges(html);
4987
+ const scannable = maskNonScannableRanges2(html);
4079
4988
  const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
4080
4989
  let match;
4081
4990
  while ((match = re.exec(scannable)) !== null) {
4082
4991
  const tagName = (match[1] ?? "").toLowerCase();
4083
4992
  const rawSrc = match[2] ?? "";
4084
- const src = cleanAssetUrl(rawSrc);
4993
+ const src = cleanAssetUrl2(rawSrc);
4085
4994
  if (!src) continue;
4086
- if (isRemoteOrInlineUrl(src)) continue;
4995
+ if (isRemoteOrInlineUrl2(src)) continue;
4087
4996
  if (/^__[A-Z_]+__$/.test(src)) continue;
4088
- const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
4089
- const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
4997
+ const rootRelative = compSrcPath ? rewriteAssetPath2(compSrcPath, src) : src;
4998
+ const resolvedAsset = resolveExistingLocalAsset2(projectDir, rootRelative);
4090
4999
  if (resolvedAsset) continue;
4091
5000
  const resolvedKey = resolve(projectDir, rootRelative);
4092
5001
  let bucket = missingByTag.get(tagName);
@@ -4116,8 +5025,8 @@ function lintTextureMaskAssetNotFound(projectDir, htmlSources) {
4116
5025
  const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
4117
5026
  while ((match = pattern.exec(cssSource.content)) !== null) {
4118
5027
  const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
4119
- const url = cleanAssetUrl(rawUrl);
4120
- if (!url || isRemoteOrInlineUrl(url)) continue;
5028
+ const url = cleanAssetUrl2(rawUrl);
5029
+ if (!url || isRemoteOrInlineUrl2(url)) continue;
4121
5030
  if (/^__[A-Z_]+__$/.test(url)) continue;
4122
5031
  const candidates = resolveCssAssetCandidates(
4123
5032
  projectDir,
@@ -4217,7 +5126,7 @@ function lintMissingOrEmptySubComposition(projectDir, rootHtml) {
4217
5126
  const visited = /* @__PURE__ */ new Set();
4218
5127
  const walk = (html) => {
4219
5128
  const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
4220
- const scannable = maskNonScannableRanges(html);
5129
+ const scannable = maskNonScannableRanges2(html);
4221
5130
  let match;
4222
5131
  while ((match = compositionSrcRe.exec(scannable)) !== null) {
4223
5132
  const srcPath = (match[1] ?? "").trim();