diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 382184309a..2713d22133 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -656,8 +656,9 @@ code: "content_overlap", severity: "warning", time, - selector: selectorFor(a.element), - containerSelector: selectorFor(b.element), + // Unique both ends: the collapse key names this pair by its selectors alone, so aliased siblings must not share one. + selector: uniqueSelectorFor(a.element), + containerSelector: uniqueSelectorFor(b.element), text: textContentFor(a.element), message: "Two text blocks overlap and may render unreadable.", rect: a.rect, @@ -1017,8 +1018,9 @@ code: "text_occluded", severity: "error", time, - selector: selectorFor(element), - containerSelector: selectorFor(occluder), + // Unique both ends: the collapse key names this pair by its selectors alone, so aliased siblings must not share one. + selector: uniqueSelectorFor(element), + containerSelector: uniqueSelectorFor(occluder), text, message: "Text is hidden beneath an opaque element.", rect: textRect, diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 13ea482e32..5abb8185ad 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -255,6 +255,69 @@ describe("persistence-tiered severity (#U10)", () => { expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); }); + it("promotes a content_overlap whose text changes every sample (count-up over a label)", () => { + // The colliding pair is named by both selectors, so per-sample text must not split one held collision into transient groups. + const collapsed = collapseStaticLayoutIssues( + [ + { + ...issue("content_overlap", "warning"), + time: 4.0, + containerSelector: ".num", + text: "$1,204", + }, + { + ...issue("content_overlap", "warning"), + time: 4.5, + containerSelector: ".num", + text: "$8,930", + }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); + }); + + it("keeps two content_overlap pairs on different containers in separate groups", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0, containerSelector: ".num" }, + { ...issue("content_overlap", "warning"), time: 4.5, containerSelector: ".pct" }, + ], + 73, + ); + + expect(collapsed).toHaveLength(2); + }); + + it("does not bridge two separate transients on one pair into a held collision", () => { + // Both blips sit under the 500ms floor; spanning them would fabricate a 4.1s collision that never happened. + const blip = { ...issue("content_overlap", "warning"), containerSelector: ".label" }; + const collapsed = collapseStaticLayoutIssues( + [ + { ...blip, time: 1.0 }, + { ...blip, time: 1.125 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); + }); + + it("still separates two distinct text_box_overflow findings that differ only by text", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("text_box_overflow", "warning"), time: 4.0, text: "first" }, + { ...issue("text_box_overflow", "warning"), time: 4.5, text: "second" }, + ], + 73, + ); + + expect(collapsed).toHaveLength(2); + }); + it("promotes content_overlap whose two occurrences span exactly 500ms (at the floor)", () => { const collapsed = collapseStaticLayoutIssues( [ diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 5f68cb7007..d535df09a0 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -158,6 +158,7 @@ export function formatLayoutIssue(issue: LayoutIssue): string { return issue.fixHint ? `${line}\n Fix: ${issue.fixHint}` : line; } +// Keeps raw `text` where `staticIssueKey` drops it: this key pins an exact time, so text still separates concurrent findings rather than spanning samples. export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { const seen = new Set(); const result: LayoutIssue[] = []; @@ -204,6 +205,15 @@ const PERSISTENCE_TIERED_CODES: ReadonlySet = new Set([ "connector_detached", ]); +// Codes whose collapse key is the selector pair alone. Both builders uniquely +// select their two ends, so the pair IS the identity — and keeping per-sample +// text would split one held finding into transient groups whenever the subject's +// text animates (count-up, typewriter, rotating word). +const TEXT_AGNOSTIC_KEY_CODES: ReadonlySet = new Set([ + "content_overlap", + "text_occluded", +]); + export function collapseStaticLayoutIssues( issues: LayoutIssue[], totalSampleCount?: number, @@ -342,7 +352,7 @@ function staticIssueKey(issue: LayoutIssue): string { issue.severity, issue.selector, issue.containerSelector ?? "", - issue.text ?? "", + TEXT_AGNOSTIC_KEY_CODES.has(issue.code) ? "" : (issue.text ?? ""), issue.overflow ? formatOverflow(issue.overflow) : "", framePositionKey(issue), ].join("|");