diff --git a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts index 5107c6e..4039ffd 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.property.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.property.test.ts @@ -100,6 +100,63 @@ describe("hidden-text-strip (property)", () => { ); }); + // Live-region opt-ins that turn `display:none` into a strip target. + // The narrow carve-out around tab panels means the bypass surface + // here is the cross-product of (live-region attribute) × (subtree + // shape), and a future refactor that special-cased one role at the + // expense of another would only fail a single unit test. The + // property pins the whole class so the next regression is caught + // before it ships. + const LIVE_REGION_ATTR = fc.constantFrom( + { role: "status" }, + { role: "alert" }, + { role: "log" }, + { role: "marquee" }, + { role: "timer" }, + { role: "alertdialog" }, + { "aria-live": "polite" }, + { "aria-live": "assertive" }, + ); + + it("strips display:none on any live-region role / aria-live opt-in", () => { + fc.assert( + fc.property( + LIVE_REGION_ATTR, + PAYLOAD, + CHILD_COUNT, + (attributes, payload, childCount) => { + document.body.innerHTML = ""; + const target = document.createElement("div"); + target.id = "target"; + target.setAttribute("style", "display: none"); + for (const [name, value] of Object.entries(attributes)) { + target.setAttribute(name, value); + } + target.append(document.createTextNode(payload)); + + const markers: HTMLSpanElement[] = []; + for (let i = 0; i < childCount; i++) { + const marker = document.createElement("span"); + marker.id = `marker-${i}`; + marker.textContent = `marker-${i}`; + target.append(marker); + markers.push(marker); + } + document.body.append(target); + + hiddenTextStripRule.apply(document.body); + + expect(target.isConnected).toBe(true); + expect(target.textContent).toBe(""); + for (const marker of markers) { + expect(marker.isConnected).toBe(true); + expect(marker.parentElement).toBe(target); + } + }, + ), + ); + }); + // Every shape the matrix → scale check is supposed to recognize as // "x or y axis collapsed to zero". Unit tests pin one example each; // the property test pins the whole class so a future tweak to the diff --git a/extension/src/rules/__tests__/hidden-text-strip.test.ts b/extension/src/rules/__tests__/hidden-text-strip.test.ts index 2dfe179..f7e6553 100644 --- a/extension/src/rules/__tests__/hidden-text-strip.test.ts +++ b/extension/src/rules/__tests__/hidden-text-strip.test.ts @@ -757,6 +757,71 @@ describe("hiddenTextStripRule", () => { hiddenTextStripRule.apply(document.body); expect(document.querySelector("#panel")).not.toBeNull(); + expect(document.querySelector("#panel")?.textContent).toContain( + "tab panel content", + ); + }); + + it("scrubs display:none on role=status (live-region smuggling)", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("scrubs display:none on role=alert", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("scrubs display:none on aria-live=polite", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + it("scrubs display:none on aria-live=assertive", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expectScrubbed("#x"); + }); + + // `aria-live="off"` is explicit opt-out — the page is telling AT not + // to announce the region. The display:none carve-out for tab panels + // still applies. + it("leaves display:none with aria-live=off alone", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#panel")?.textContent).toContain( + "tab content", + ); + }); + + it("scrubs display:none on role=status while preserving descendant element nodes", () => { + document.body.innerHTML = ` + + `; + hiddenTextStripRule.apply(document.body); + + expect(document.querySelector("#inner")).not.toBeNull(); + expect(document.querySelector("#x")?.textContent).toBe(""); }); it("leaves visible text alone", () => { diff --git a/extension/src/rules/hidden-text-strip.ts b/extension/src/rules/hidden-text-strip.ts index b1530a9..9d3f822 100644 --- a/extension/src/rules/hidden-text-strip.ts +++ b/extension/src/rules/hidden-text-strip.ts @@ -41,9 +41,16 @@ // not catching a hypothetical adversary who specifically targets agents // via 1×1 boxes. That tradeoff is intentional. // -// display:none is intentionally NOT a trigger: collapsed menus, tab panels, +// display:none is generally NOT a trigger: collapsed menus, tab panels, // and dropdowns commonly toggle display, and stripping their text content // would corrupt the underlying app state once the user/agent expands them. +// The narrow exception is display:none on a live region (role=status / +// alert / log / marquee / timer / alertdialog, or aria-live=polite / +// assertive): those elements are explicitly opted in to "announce my +// contents," so the carrier reaches agents walking textContent or the +// a11y tree while staying invisible to sighted users. Tab panels never +// wear those attributes, so the narrower trigger preserves the original +// carve-out. // // When a non-allowlisted match is found, we blank every text node inside // the element rather than detaching the element itself. Frameworks @@ -131,6 +138,28 @@ function isLandmark(element: Element): boolean { return role !== null && LANDMARK_ROLES.has(role); } +// Live-region attributes that opt an element in to assistive-tech +// announcements. `display:none` on one of these is the narrow shape we +// strip — see the file-level comment for why this carve-out is narrower +// than the general display:none exclusion. +const LIVE_REGION_ROLES: ReadonlySet = new Set([ + "status", + "alert", + "log", + "marquee", + "timer", + "alertdialog", +]); + +function isLiveRegion(element: Element): boolean { + const role = element.getAttribute("role"); + if (role !== null && LIVE_REGION_ROLES.has(role)) { + return true; + } + const live = element.getAttribute("aria-live"); + return live === "polite" || live === "assertive"; +} + // Match reasons whose only signal is "the element is positioned out of // the visible viewport / clipped to zero area". These are the shapes a // landmark or aria-hidden subtree legitimately uses to keep content @@ -433,6 +462,15 @@ function detectHiddenByCss( element: Element, style: CSSStyleDeclaration, ): MatchDetail | null { + if (style.display === "none" && isLiveRegion(element)) { + return { + reason: "display-none-live-region", + details: { + role: element.getAttribute("role") ?? "", + ariaLive: element.getAttribute("aria-live") ?? "", + }, + }; + } if (style.visibility === "hidden" || style.visibility === "collapse") { return { reason: `visibility-${style.visibility}`,