diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index f939a9dcee..61ad31e929 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -431,9 +431,13 @@ } function textOverflowIssues(element, root, rootRect, time, tolerance) { - const textRect = textRectFor(element); + // Controls often contain decorative descendants (ripples, glows, icons) whose transformed + // boxes are included when a Range selects the whole element. Measure direct text nodes when + // present so those effects cannot inflate the reported text bounds. + const directOnly = hasOwnTextCandidate(element, true); + const textRect = textRectFor(element, directOnly); if (!textRect) return []; - const text = textContentFor(element); + const text = textContentFor(element, directOnly); const selector = selectorFor(element); const issues = []; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 821bad8c14..57cac5b886 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -145,6 +145,41 @@ describe("layout-audit.browser", () => { expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true); }); + it("measures a control's own text without including a decorative child effect", () => { + document.body.innerHTML = ` +
+
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 240, height: 80 }), + headline: rect({ left: 100, top: 140, width: 180, height: 40 }), + ripple: rect({ left: -200, top: -100, width: 720, height: 288 }), + }); + vi.mocked(document.createRange).mockImplementation(() => { + let selected: Node | null = null; + return { + selectNodeContents(node: Node) { + selected = node; + }, + getClientRects() { + const ownText = rect({ left: 120, top: 148, width: 100, height: 24 }); + const descendantUnion = rect({ left: -200, top: -100, width: 720, height: 288 }); + return [selected?.nodeType === 3 ? ownText : descendantUnion] as unknown as DOMRectList; + }, + detach() {}, + } as unknown as Range; + }); + installAuditScript(); + + expect( + runAudit().some( + (issue) => issue.code === "text_box_overflow" && issue.selector === "#headline", + ), + ).toBe(false); + }); + it("keeps auditing visible descendants beyond the second element", () => { document.body.innerHTML = `
diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 2715ff5a9c..5fb3f34e62 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -191,6 +191,28 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("does not treat an inset child of a constrained parent as a full-frame overlay", async () => { + const html = ` + + + + +`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "gsap_fullscreen_overlay_starts_visible"), + ).toBeUndefined(); + }); + it("does not error when GSAP hides a full-frame transition flash at frame zero", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index b54b7c8732..7d9de0ba82 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -377,6 +377,24 @@ function styleLooksFullFrameOverlay(style: string): boolean { return coversFrame && styleHasOpaqueBackground(style); } +function isNestedInExplicitlySizedBox( + tag: OpenTag, + tags: OpenTag[], + styleRules: Map, +): boolean { + const parent = tags + .filter( + (candidate) => + candidate.index < tag.index && + candidate.closeIndex != null && + candidate.closeIndex > tag.index, + ) + .sort((a, b) => b.index - a.index)[0]; + if (!parent || readAttr(parent.raw, "data-composition-id")) return false; + const parentStyle = combinedTagStyle(parent, styleRules); + return !!(readStyleProperty(parentStyle, "width") && readStyleProperty(parentStyle, "height")); +} + function collectSimpleStyleRules(styles: LintContext["styles"]): Map { const rules = new Map(); for (const style of styles) { @@ -667,6 +685,12 @@ export const gsapRules: LintRule[] = [ if (reportedVisibleOverlayKeys.has(overlayKey)) continue; const authoredStyle = combinedTagStyle(tag, styleRules); if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue; + if ( + readStyleProperty(authoredStyle, "position")?.toLowerCase() === "absolute" && + isNestedInExplicitlySizedBox(tag, tags, styleRules) + ) { + continue; + } if (styleHasHiddenInitialState(authoredStyle)) continue; const visibilityWindows = gsapWindows