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