Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Feat: heuristic detector for closed shadow roots (#164 follow-up)#169

Merged
twschiller merged 4 commits into
mainfrom
feat/closed-shadow-root-annotate
Jun 5, 2026
Merged

Feat: heuristic detector for closed shadow roots (#164 follow-up)#169
twschiller merged 4 commits into
mainfrom
feat/closed-shadow-root-annotate

Conversation

@twschiller

Copy link
Copy Markdown
Contributor

Summary

  • New off-by-default rule closed-shadow-root-annotate that 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.
  • Heuristic: upgraded custom element (hyphenated tag, defined in customElements) with shadowRoot === 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).
  • A main-world attachShadow probe (same delivery pattern as webdriver-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

  • Toggle the rule on in the popup; load a page with a real closed-shadow widget (e.g., a hardened chat embed); confirm the popup shows a closed-shadow-root detection and the screen-reader landmark is reachable in the a11y tree.
  • Load a page with only <input type=range> / <details> / <video> (UA shadows); confirm no detection fires.
  • Load a page with a regular open-shadow custom element (e.g., a Lit widget with mode: "open"); confirm no detection fires.
  • Toggle the rule off; confirm the landmark is removed.
  • bun run preflight (1353 tests, lint, typecheck, knip all green)

🤖 Generated with Claude Code

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]>
@vercel

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-browser-shield-demo-site Ready Ready Preview, Comment Jun 5, 2026 4:33pm

Request Review

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +123 to +126
// 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("*");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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("*");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +174 to +178
function scan(root: ParentNode): void {
if (findClosedShadowHosts(root).length > 0) {
ensureLandmark();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@twschiller twschiller self-assigned this Jun 5, 2026
@twschiller twschiller merged commit 62592d6 into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the feat/closed-shadow-root-annotate branch June 5, 2026 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant