Skip to content

fix(web): scope shadow landmarks to outer sections - #943

Merged
okwasniewski merged 3 commits into
tester-army:mainfrom
rudycelekli:fix/web-shadow-section-landmarks-20261006
Oct 8, 2026
Merged

okwasniewski merged 3 commits into
tester-army:mainfrom
rudycelekli:fix/web-shadow-section-landmarks-20261006

Conversation

@rudycelekli

@rudycelekli rudycelekli commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What changed

Keep implicit header/footer landmarks scoped by article, section, navigation, aside, and region ancestors across shadow hosts. isPageLevel previously checked only the current shadow tree, so a nested card header inside an article became a page banner in the agent's observation. Top-level shadow headers and footers retain their page landmark roles.

Adds native open/closed shadow regressions and a Chrome/Playwright/axe crosscheck fixture. Updates the browser docs and adds a patch changeset. No public type or wire format changes.

Verified

Ran it locally: yes.

Upstream main This branch
Native observation marks the article card header/footer as page landmarks; the public CLI observation check fails. Card nodes retain article scoping; the same public CLI check passes, and top-level landmarks remain.
  • Built upstream CLI with real Chromium: a card header inside an article is correctly absent from the page banner locator, but incorrectly appears as a banner in the native observation; the observation consumer fails. This branch passes the same scenario.
  • Native open/closed shadow regressions fail on upstream and pass here; top-level landmark controls remain valid.
  • Focused role and oracle crosschecks: 30 passed, 1 skipped. Existing oracle exception rules unchanged.
  • Full web suite: 509 passed, 1 skipped, 55 files. Public CLI testbed: 71 passed, 1 skipped, 14 files.
  • Exact signed head rebuilt and rechecked with the public CLI scenario, focused tests, typecheck and lint.
  • Full monorepo pnpm check remains blocked by existing dead-code/mobile dependency failures. The Next.js benchmark and Android runtime are unavailable in this lean local environment; hosted checks are pending and are not claimed green.

Screened current open PR files. #871 also touches role semantics but changes presentational roles on focusable controls, leaving this helper unchanged; #926 changes text flow. The existing role fallback, ARIA state, and select choice PRs address different defects.

AI assistance: implemented and verified with Codex on behalf of Rudy Celekli; the exact signed patch was reviewed in a separate source-review context before publication.

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/web/src/in-page/read-semantics.ts">

<violation number="1" location="packages/web/src/in-page/read-semantics.ts:136">
P2: A header slotted into a shadow-root `<section>` is still classified as a page-level `banner`, because this walk never follows its composed-tree slot ancestor. Walk element parents individually and follow `assignedSlot` before crossing to the shadow host.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment on lines +136 to +138
if (candidate.closest(SECTIONING_SCOPE) !== null) return false;
const root = candidate.getRootNode();
candidate = root instanceof ShadowRoot ? root.host : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: A header slotted into a shadow-root <section> is still classified as a page-level banner, because this walk never follows its composed-tree slot ancestor. Walk element parents individually and follow assignedSlot before crossing to the shadow host.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/web/src/in-page/read-semantics.ts, line 136:

<comment>A header slotted into a shadow-root `<section>` is still classified as a page-level `banner`, because this walk never follows its composed-tree slot ancestor. Walk element parents individually and follow `assignedSlot` before crossing to the shadow host.</comment>

<file context>
@@ -131,7 +131,14 @@ export const readSemanticsFunction = <Mode extends SemanticMode>(
-  const isPageLevel = (el: Element): boolean => el.closest(SECTIONING_SCOPE) === null;
+  const isPageLevel = (el: Element): boolean => {
+    for (let candidate: Element | null = el; candidate !== null;) {
+      if (candidate.closest(SECTIONING_SCOPE) !== null) return false;
+      const root = candidate.getRootNode();
+      candidate = root instanceof ShadowRoot ? root.host : null;
</file context>
Suggested change
if (candidate.closest(SECTIONING_SCOPE) !== null) return false;
const root = candidate.getRootNode();
candidate = root instanceof ShadowRoot ? root.host : null;
if (candidate.matches(SECTIONING_SCOPE)) return false;
if (candidate.assignedSlot !== null) {
candidate = candidate.assignedSlot;
continue;
}
if (candidate.parentElement !== null) {
candidate = candidate.parentElement;
continue;
}
const root = candidate.getRootNode();
candidate = root instanceof ShadowRoot ? root.host : null;

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.

[gpt-6.1-sol] responding on behalf of rudycelekli

I checked this with native Chromium and Playwright 1.63. A header/footer assigned to a slot inside a shadow <section> is sectionheader/sectionfooter in Chrome's AX tree, while the public Playwright getByRole('banner', { name: 'Card' }) and getByRole('contentinfo', { name: 'Card footer' }) each return one match. Its aria snapshot also includes those landmark roles inside the region. The current observation follows the public role locator's DOM/host ancestry here; following the slot would change that relationship. This PR fixes section ancestry across shadow hosts where the oracles agree. I am leaving this thread open for the maintainer's decision on which oracle contract slotted landmark observations should follow, rather than claiming the disagreement is fixed.

@okwasniewski

Copy link
Copy Markdown
Member

Thanks! I merged main and dropped the browser.mdx paragraph. On the slot thread: we follow Playwright's role locator, so not following slots is right for now.

@okwasniewski
okwasniewski merged commit 9e33593 into tester-army:main Oct 8, 2026
23 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 8, 2026
DeryFerd added a commit to DeryFerd/e2e that referenced this pull request Oct 8, 2026
The shadow section landmarks fixture added with tester-army#943 pinned the scoping
for two of the ten shapes isPageLevel scopes a header or footer to: an
article element and a region role. The other eight (aside, main, nav,
section, and the article, complementary, main, and navigation roles) had
no shadow-nested host in any fixture, so a regression in one of them
would pass the cross-check until another fixture happened to trip it.

The fixture now nests a header and footer two shadow roots deep under
each of the ten scoping shapes, with the page-level control beside
them. The run stays green with no new expected.txt lines: the tree and
both oracles agree on every header and footer the added hosts carry.
DeryFerd added a commit to DeryFerd/e2e that referenced this pull request Oct 8, 2026
The shadow section landmarks fixture added with tester-army#943 pinned the scoping
for two of the ten shapes isPageLevel scopes a header or footer to: an
article element and a region role. The other eight (aside, main, nav,
section, and the article, complementary, main, and navigation roles) had
no shadow-nested host in any fixture, so a regression in one of them
would pass the cross-check until another fixture happened to trip it.

The fixture now nests a header and footer two shadow roots deep under
each of the ten scoping shapes, with the page-level control beside
them. The run stays green with no new expected.txt lines: the tree and
both oracles agree on every header and footer the added hosts carry.
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.

2 participants