Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
39cfb7f
docs(ui): define shadow overlay ownership (YPE-5355)
aharms24 Sep 1, 2026
4e825d3
docs(ui): simplify overlay ownership guidance
aharms24 Sep 1, 2026
fec6b1a
docs(ui): clarify overlay dismissal ordering
aharms24 Sep 1, 2026
3bb4ef1
Merge remote-tracking branch 'origin/journey-to-the-shadow-dom' into …
aharms24 Sep 1, 2026
0336397
docs(ui): cover direct overlay consumers
aharms24 Sep 1, 2026
9a9e3fc
docs(ui): preserve active modal scope
aharms24 Sep 1, 2026
0c62006
chore: add empty changeset for YPE-5355
aharms24 Sep 1, 2026
6eca15c
feat(ui): prove shadow overlay ownership
aharms24 Sep 1, 2026
3c50f1b
refactor(ui): simplify shadow overlay proof
aharms24 Sep 1, 2026
7353201
test(ui): close overlay proof gaps
aharms24 Sep 1, 2026
0481b00
fix(ui): enforce overlay interaction ownership
aharms24 Sep 1, 2026
57fb02f
fix(ui): defer cascade focus restoration
aharms24 Sep 1, 2026
6270cd8
fix(ui): harden overlay ownership edges
aharms24 Sep 1, 2026
0323bce
test(ui): name overlay edge contracts
aharms24 Sep 1, 2026
4ce1652
fix(ui): preserve overlay subtree order
aharms24 Sep 1, 2026
48f40d5
test(ui): prove modal focus containment
aharms24 Sep 1, 2026
5dd0b89
test(ui): scope overlay escape ownership
aharms24 Sep 1, 2026
03333e1
fix(ui): clarify shadow overlay launch and restoration contract
aharms24 Sep 1, 2026
017360f
fix(ui): preserve overlay dismissal ownership
aharms24 Sep 1, 2026
8fd4650
fix(ui): keep concurrent overlay launchers clickable
aharms24 Sep 2, 2026
1ef3dee
fix(ui): preserve rejected overlay exit deadlines
aharms24 Sep 2, 2026
d61a6fa
fix(ui): fall back from unfocusable overlay openers
aharms24 Sep 2, 2026
bc6e4df
fix(ui): skip unfocusable overlay openers in ownership model
cursoragent Sep 3, 2026
0d6b80f
fix(ui): defer focus acceptance to overlay adapter
aharms24 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/define-shadow-overlay-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Document the nested and concurrent Shadow DOM overlay ownership contract without releasing package changes.
7 changes: 4 additions & 3 deletions docs/adr/0006-prototype-shadow-dom-style-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ accepts that host registrations can collide with SDK family names. It also
cannot protect a component host from constraints applied to its ancestors. Open
roots are a CSS boundary, not a security boundary.

Concurrent independent overlays in one shadow root and nested overlays launched
from an open dialog are unsupported until the package defines stacking, focus
ownership, and dismissal contracts. Radix's development-only relationship
[ADR 0007](0007-shadow-overlay-ownership-stack.md) selects an ownership
contract for concurrent and nested overlays. The current prototype does not
implement that contract, so those cases remain unsupported until the layer
registry and browser evidence land. Radix's development-only relationship
checks can also emit warnings for valid IDs inside a shadow root because those
checks query the document rather than the root.

Expand Down
94 changes: 94 additions & 0 deletions docs/adr/0007-shadow-overlay-ownership-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# ADR 0007: Model shadow-root overlays as an ownership stack

Status: Proposed (contract and executable proof selected by YPE-5355; production
runtime support not implemented)

Nested and concurrent overlays inside one component shadow root will share one
shadow-local top-layer container and register with a root-owned LIFO overlay
stack. An overlay registration identifies its stable instance, modal or
non-modal kind, logical launch parent when nested, optional focus restoration
target, and mounted phase. The launch parent is the overlay whose interaction
opened the new overlay; it does not require DOM or React ancestry. A focus
restoration target may be absent for a default-open overlay, virtual anchor, or
launch without a focusable control. This keeps stacking, focus, dismissal,
inertness, and restoration behind the `ShadowRootHost` interface instead of
making each Dialog or Popover coordinate with its siblings.

The topmost eligible mounted overlay owns interaction, focus, Escape, and
outside-click dismissal. With no modal mounted, every overlay is eligible. With
a modal mounted, eligibility is limited to the topmost modal and its descendant
chain; a later unrelated non-modal overlay remains mounted but non-interactive
until that modal unmounts. If the owner is not dismissible, dismissal attempts
do not fall through to a lower overlay. A nested overlay remains inside its
ancestor modal's focus scope. While a modal is mounted, Tab and Shift+Tab loop
within the current owner and programmatic focus outside it redirects back to the
owner. Shadow content stays inert through the modal's exit animation. An
ancestor close may start its own and its descendants' exit phases together, but
every descendant must unmount before the ancestor; a new child cannot mount
beneath an exiting parent. Repeated exit requests are idempotent and do not
extend an existing descendant's teardown deadline. Reopening a stable overlay
ID during exit cancels that exit and refreshes its registration,
including its focus restoration target, parent, kind, and dismissal policy.
Focus restoration starts only when the current owner unmounts. The ownership
model returns the eligible opener followed by the remaining top eligible layer,
or the outer opener when the last modal leaves. The DOM adapter attempts those
candidates in order and advances when a candidate is absent, disconnected, or
does not accept focus. Removing a lower layer does not steal focus from its
owner. Descendant unmounts during an ancestor-close cascade suppress focus
restoration; the ancestor's final unmount performs the single restore. Parent
updates must remain acyclic: an overlay cannot register under itself or one of
its descendants. Refreshing a stable ID moves its existing subtree to the top
so every parent remains before its children in registration order.

## Scenario classification and proof

The ownership contract supports these scenarios in the committed executable
proof:

- a non-modal popover opening a modal dialog, including the verse action
popover opening the highlights permission dialog during the highlight auth
flow;
- a modal dialog containing a non-modal popover;
- two independent non-modal overlays; and
- closing and reopening the same modal during its exit animation.

`shadow-overlay-ownership.ts` is the deterministic state model for the
contract. Its unit tests cover the four scenarios plus modal-scope exclusion,
ancestor/descendant exit ordering, dismissal blocking during exit, and
focus-restoration candidate ordering. The `Shadow overlay ownership` integration
story exercises the four required scenarios in Chromium inside one real shadow
root and one native top-layer container. It verifies LIFO DOM ordering, focus
and dismissal ownership, modal inertness through exit, rejected-candidate
fallback, restoration, and stable identity during a rapid reopen.

These scenarios remain unsupported by production SDK Dialog and Popover
callers; the proof module is internal and deliberately not connected to those
primitives. See Consequences below for what production integration requires.

## Considered options

- A shared container without ownership was rejected because independent focus
traps can compete and unrelated overlays can escape modal inertness.
- Permanently forbidding nested and concurrent overlays was rejected because
composite SDK surfaces such as `BibleReader` legitimately coordinate several
dialogs and popovers.
- One native top-layer container per overlay was rejected because ownership,
ordering, and restoration would be distributed across callers rather than
localized at the shadow-root seam.

## Consequences

The current production active-ID sets prove lazy portal lifetime but do not
implement this contract. The executable state model reduces implementation
ambiguity but is not a production registry. Production support requires wiring
the model's rules into `ShadowRootHost`, descendant close ordering, topmost
interaction gating, modal-aware focus containment, and direct browser evidence
through the actual overlay primitives. The production adapter must register the
logical launch parent even when overlays are DOM or React siblings. It must omit
a focus restoration target unless that target belongs to the launch parent's
active focus scope, attempt the model's restoration candidates in order, and
advance whenever the browser rejects focus. It must also make shadow-specific
focus containment follow the current owner while other layers yield. Until that
work lands, nested or concurrent production overlays in one shadow root remain
unsupported. The contract may still be revised in response to cross-browser or
assistive-technology findings.
16 changes: 10 additions & 6 deletions docs/shadow-dom-isolation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ This is a working plan, not approval for package-wide rollout.
Shadow DOM boundaries.
- The internal `SignInDialog` is validated only through an opt-in
`ShadowRootHost` story.
- Concurrent independent overlays in one shadow root and nested overlays
launched from an open dialog are unsupported.
- Concurrent and nested overlays in one shadow root remain unsupported.
[ADR 0007](adr/0007-shadow-overlay-ownership-stack.md) selects the contract
to implement them; see "Blocking production-readiness decisions" below.

## Validation matrix

Expand All @@ -36,8 +37,9 @@ This is a working plan, not approval for package-wide rollout.
| Portal lifecycle | Unit and browser coverage exercise lazy creation, exit-animation retention, cleanup, immediate reopen behavior, and the direct-Radix `VerseActionPopover` consumer. | Validated for shared primitives and the known bypass | Repeat the consumer audit when adding another direct overlay primitive. |
| Dialog relationships | Chromium resolves title and description relationships inside the component tree. | Validated in Chromium | Verify announcements with real assistive technology. |
| Dialog keyboard containment | Browser coverage exercises initial focus, programmatic escape redirection, forward and reverse traversal, radio-group collapsing, negative `tabindex`, and wraparound. | Validated in Chromium | Expand the browser and assistive-technology matrix. |
| Dialog modal lifetime | Coverage verifies inert background content while open and through staggered Content and Overlay exit animations. | Validated for one modal | Define ownership before supporting nested or competing overlays. |
| Dialog modal lifetime | Coverage verifies inert background content while open and through staggered Content and Overlay exit animations. | Validated for one modal | Implement ADR 0007 before supporting nested or competing overlays. |
| Dialog dismissal and restoration | Coverage exercises Escape, backdrop click, full-viewport hit testing, overlay-only focus, and restoration after both modal nodes unmount. | Validated in Chromium | Verify real screen-reader and cross-browser behavior. |
| Nested and concurrent ownership | A committed state model and unit suite exercise both nesting directions, concurrent siblings, modal-scope exclusion, ancestor-close cascading, exit-animation lifetime, rapid reopen, logical launch parentage, and absent or disconnected focus restoration fallback. A Chromium integration story proves both nesting directions, two independent overlays, and rapid close/reopen inside one shadow root and native top-layer container. | Contract proved; production runtime unsupported | Integrate the registry with `ShadowRootHost`, then repeat the browser proof through shared primitives and every inventoried direct overlay consumer. |

## Direct overlay inventory

Expand All @@ -59,8 +61,9 @@ separate decision.
or package-wide.
- Define SSR, hydration, and first-paint behavior. The current effect-attached
root renders an empty host on the server and delays content and forwarded refs.
- Define stacking, focus ownership, and dismissal contracts for nested or
competing modal and non-modal overlays.
- Implement and browser-validate ADR 0007's root-owned overlay stack before
enabling isolation on a component that can launch nested or concurrent
overlays.
- Complete the package-wide custom-property inventory and prevention guard in
YPE-5400. The known `BibleVersionPicker`, `InputGroup`, and `tw-animate-css`
dependencies now resolve through locally-defined SDK-owned spacing and radius
Expand Down Expand Up @@ -98,7 +101,8 @@ separate decision.
## Rollout sequence

1. Complete YPE-5400's custom-property inventory and prevention guard.
2. Resolve SSR/hydration, rollout-control, and overlay-ownership decisions.
2. Resolve SSR/hydration and rollout-control decisions, then implement and
browser-validate the selected overlay-ownership contract.
3. Select the next public component and add component-specific compatibility,
browser, and accessibility coverage before enabling isolation.
4. Publish consumer guidance for DOM queries, automation, customization, forms,
Expand Down
Loading
Loading