diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 2715ff5a9..cfefe769c 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1194,6 +1194,63 @@ describe("GSAP rules", () => { expect(finding).toBeDefined(); }); + it("errors when a style block's LAST declaration is opacity:0 without a semicolon", async () => { + const html = ` + +
+
Hello
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeDefined(); + }); + + it("does NOT error for inline opacity: 0.98 + gsap.from({opacity:0}) — fractional is not zero", async () => { + const html = ` + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeUndefined(); + }); + + it("still errors for inline opacity: 0 without a trailing semicolon", async () => { + const html = ` + +
+
Hello
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop"); + expect(finding).toBeDefined(); + }); + it("does NOT error when gsap.from({opacity:0}) and CSS has no opacity:0", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index b54b7c873..ec2e4c9b8 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1128,11 +1128,18 @@ export const gsapRules: LintRule[] = [ const findings: HyperframeLintFinding[] = []; const cssOpacityZeroSelectors = new Set(); + // Single owner of "this declaration list sets opacity to EXACTLY zero" — + // boundary-anchored so `opacity: 0.98` never matches. Works for both a CSS + // block body (brace already stripped by the block regex) and an inline + // style attribute: the declaration ends at `;` or at end of input, which + // also catches a final declaration without a trailing semicolon. + const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/; + for (const style of styles) { for (const [, selector, body] of style.content.matchAll( /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g, )) { - if (body && /opacity\s*:\s*0\s*[;}]/.test(body)) { + if (body && opacityExactlyZero.test(body)) { cssOpacityZeroSelectors.add((selector ?? "").trim()); } } @@ -1140,7 +1147,7 @@ export const gsapRules: LintRule[] = [ for (const tag of tags) { const inlineStyle = readAttr(tag.raw, "style"); - if (!inlineStyle || !/opacity\s*:\s*0/.test(inlineStyle)) continue; + if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue; const id = readAttr(tag.raw, "id"); const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []; if (id) cssOpacityZeroSelectors.add(`#${id}`); diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index cfd347445..82b564978 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -28,7 +28,7 @@ export function isTextBearingTag(tagName: string): boolean { return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName); } -const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; +import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading"; export function isElementVisibleThroughAncestors(el: HTMLElement): boolean { const win = el.ownerDocument.defaultView;