Feat: heuristic detector for closed shadow roots (#164 follow-up)#169
Conversation
Adds `closed-shadow-root-annotate`, an off-by-default rule that flags pages rendering content inside closed shadow roots so the agent's accessibility-tree view of the document carries an explicit note that ABS has a blind spot here. Complements the Tier-1/2/3 open-shadow plumbing landed under #164: those make open shadow roots reachable; this one surfaces the remaining gap to the agent at read-time. Detection is necessarily heuristic — `mode: "closed"` is indistinguishable from "no shadow root" from outside JS by spec. The rule looks for the structural shape strongly correlated with a closed shadow host: an upgraded custom element (hyphenated tag, defined in `customElements`) with `shadowRoot === null`, no light-DOM content, and a non-zero rendered box. UA-shadowed built-ins are filtered out because their tag names have no hyphen. A main-world `attachShadow` probe (analogous to `webdriver-probe`) would give a definitive signal and catch closed shadows on non-custom hosts. Documented in the rule header as future work; deferred to keep this a single-file isolated-world change. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
2 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| // Custom elements always have a hyphen, so the attribute selector | ||
| // narrows the scan to a small candidate set without walking every | ||
| // element on the page. | ||
| const candidates = root.querySelectorAll("*"); |
There was a problem hiding this comment.
The comment claims "the attribute selector narrows the scan to a small candidate set without walking every element on the page," but the actual selector is "*", which selects every element in the subtree. There is no CSS-native way to match "tag name contains a hyphen," so the "*" selector plus JS filtering is the correct approach—but the comment is misleading and reads like a leftover from a draft that intended a narrower selector.
| // Custom elements always have a hyphen, so the attribute selector | |
| // narrows the scan to a small candidate set without walking every | |
| // element on the page. | |
| const candidates = root.querySelectorAll("*"); | |
| // Custom elements always have a hyphen — there's no CSS selector for | |
| // hyphenated tag names, so we iterate all descendants and filter in JS. | |
| // The hyphen check in looksLikeClosedShadowHost short-circuits quickly. | |
| const candidates = root.querySelectorAll("*"); |
There was a problem hiding this comment.
Fixed in f1bfc7e — the code was right but the comment lied. Reworded to acknowledge there's no CSS selector for hyphenated tag names and that the hyphen check short-circuits non-custom cases in JS. — Claude Code, on behalf of @twschiller
| function scan(root: ParentNode): void { | ||
| if (findClosedShadowHosts(root).length > 0) { | ||
| ensureLandmark(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Once the landmark has been inserted, ensureLandmark() short-circuits, but scan() still invokes findClosedShadowHosts(root) on every subtree mutation—iterating descendants and calling getBoundingClientRect for nothing. Adding an early return when the landmark already exists avoids this wasted work for the lifetime of the page after first detection.
| function scan(root: ParentNode): void { | |
| if (findClosedShadowHosts(root).length > 0) { | |
| ensureLandmark(); | |
| } | |
| } | |
| function scan(root: ParentNode): void { | |
| if (document.querySelector(LANDMARK_SELECTOR)) { | |
| return; | |
| } | |
| if (findClosedShadowHosts(root).length > 0) { | |
| ensureLandmark(); | |
| } | |
| } |
There was a problem hiding this comment.
Fixed in f1bfc7e — added an early return in scan() that checks for an existing landmark before walking descendants. Once stamped, every subsequent mutation tick is a single querySelector lookup instead of a full subtree walk plus rect queries. Regression test asserts querySelectorAll isn't called on re-apply after first detection. — Claude Code, on behalf of @twschiller
Defines `abs-closed-widget` (custom element with a `mode: "closed"` shadow root) and mounts one instance next to the existing open-shadow host on the demo page. With `closed-shadow-root-annotate` toggled on, reviewers can confirm the screen-reader-only landmark gets stamped and the popup detection panel shows the entry; with it off, the page renders untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Fix misleading comment in findClosedShadowHosts that described an "attribute selector" narrowing the scan — the actual selector is `*` and the hyphen check happens in JS via looksLikeClosedShadowHost. - Short-circuit scan() once the landmark exists. The landmark is per-document and idempotent, so every subsequent mutation tick was walking descendants and calling getBoundingClientRect for nothing. Adds a regression test asserting querySelectorAll isn't called on re-apply after the landmark is stamped. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Demo site's biome config wraps differently than the extension's; docs/rules.md exceeded the project's mdformat width. Apply formatter output from both tools so the Demo site and Pre-commit hooks checks pass. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Summary
closed-shadow-root-annotatethat prepends a screen-reader-only landmark when the page looks like it's rendering content inside a closed shadow root. Complements the Tier-1/2/3 open-shadow plumbing from Audit: rules are blind to content inside shadow roots #164 by surfacing the remaining gap to the agent at read-time.customElements) withshadowRoot === null, no light-DOM content, non-zero rendered box. UA-shadowed built-ins drop out for free because their tag names contain no hyphen. Header comment documents the known false positive (canvas /::before-rendering custom elements) and false negatives (declarative shadow DOM, closed shadows on non-custom hosts).attachShadowprobe (same delivery pattern aswebdriver-probe-annotate) would give a definitive signal and catch closed shadows on non-custom hosts. Flagged as future work in the rule header; deferred to keep this a single-file isolated-world change while the heuristic's false-positive rate is characterized in the wild.Test plan
closed-shadow-rootdetection and the screen-reader landmark is reachable in the a11y tree.<input type=range>/<details>/<video>(UA shadows); confirm no detection fires.mode: "open"); confirm no detection fires.bun run preflight(1353 tests, lint, typecheck, knip all green)🤖 Generated with Claude Code