Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
69841bf
feat(ui): prototype automatic Shadow DOM style isolation (#347)
cameronapak Aug 19, 2026
a794931
Merge branch 'main' into journey-to-the-shadow-dom
aharms24 Aug 24, 2026
3c25b80
feat(ui): prototype shadow-local top-layer popovers (#357)
abharms Aug 25, 2026
e8bad1b
feat(ui): validate shadow DOM dialog top layer (#362)
abharms Aug 27, 2026
a161758
Merge branch 'main' into journey-to-the-shadow-dom
aharms24 Aug 28, 2026
bc7fc0e
Merge branch 'journey-to-the-shadow-dom' of https://github.com/youver…
aharms24 Aug 28, 2026
8b04908
fix(ui): harden Shadow DOM CSS isolation (YPE-5352) (#367)
abharms Aug 31, 2026
c764982
fix(ui): route verse actions through shadow portals (#369)
abharms Sep 1, 2026
19c8a0b
Merge branch 'main' into journey-to-the-shadow-dom
aharms24 Sep 2, 2026
3dec29a
Merge remote-tracking branch 'origin/journey-to-the-shadow-dom' into …
aharms24 Sep 2, 2026
7504630
test(ui): validate Shadow DOM consumer compatibility (YPE-5436) (#380)
abharms Sep 9, 2026
0222ae9
test(ui): prove nested overlays through the production shadow seam (#…
cameronapak Sep 9, 2026
dae9a49
fix(ui): address shadow DOM review regressions (YPE-5713) (#388)
abharms Sep 10, 2026
0e595c3
Merge branch 'main' into journey-to-the-shadow-dom
aharms24 Sep 10, 2026
2daf44d
fix(ui): enforce Shadow DOM custom property contract (YPE-5400) (#394)
abharms Sep 14, 2026
e91afcc
test(ui): prove Shadow DOM SSR hydration contract (#399)
abharms Sep 14, 2026
8c240ed
test(ui): validate realistic Shadow DOM usage (YPE-5437) (#408)
abharms Sep 16, 2026
358839f
Merge branch 'main' into journey-to-the-shadow-dom
ampagent Sep 17, 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
2 changes: 2 additions & 0 deletions .changeset/all-humans-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/flat-shadows-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/friendly-verses-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Keep verse actions open when touch selection moves to another verse, restore focus for initially anchored or conditionally mounted verse actions, and keep reader settings scrollable in constrained popovers. Clarify the Shadow DOM prototype's document-owned font and root-sizing dependencies without expanding its rollout scope.
5 changes: 5 additions & 0 deletions .changeset/fuzzy-spiders-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Audit the compiled Shadow DOM stylesheet custom-property contract and fail builds on unexplained ambient references.
7 changes: 7 additions & 0 deletions .changeset/prototype-shadow-dom-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-core': major
'@youversion/platform-react-hooks': major
'@youversion/platform-react-ui': major
---

Prototype automatic Shadow DOM style isolation on `YouVersionAuthButton`, plus a shadow-local native top-layer strategy for isolated Popovers. The spike also constrains Popovers to Radix's available collision height.
4 changes: 4 additions & 0 deletions .changeset/prove-production-shadow-overlays.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Prove nested and concurrent overlays through the production ShadowRootHost seam (YPE-5355).
5 changes: 5 additions & 0 deletions .changeset/shadow-aware-verse-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Keep verse action popovers in their component's shadow-local portal and restore focus after dismissal.
2 changes: 2 additions & 0 deletions .changeset/six-shrimps-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/tidy-shadows-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Harden Shadow DOM style isolation so text direction is the only intentionally inherited CSS property and host custom properties cannot alter known SDK spacing or radius values. Document-root font sizing still affects the prototype's rem-based dimensions.
5 changes: 5 additions & 0 deletions .changeset/validate-shadow-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Validate shadow-local top-layer Dialog behavior, including focus containment and modal background inertness.
171 changes: 171 additions & 0 deletions docs/adr/0007-prototype-shadow-dom-style-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# ADR 0007: Prototype automatic Shadow DOM style isolation

Status: Proposed (validated experimentally; not approved for production rollout)

Host applications can apply unlayered global CSS, including Tailwind preflight,
that outranks the UI package's layered styles. Resets, stronger selectors,
`!important`, cascade layers, and `@scope` remain part of the host document's
cascade and cannot prevent outside selectors from matching SDK internals. The
prototype therefore uses Shadow DOM as the browser-enforced style boundary.

## Decision for the prototype

`YouVersionAuthButton` automatically creates an open shadow root and renders its
existing implementation into it through a React portal. Consumers continue to
use the same component API; they do not need to discover or enable isolation.
The SDK's compiled Tailwind component rules are installed inside the root, the light-DOM host
receives a protected box reset, and an internal wrapper resets inherited visual
properties.

Writing direction is the only intentional inherited CSS property: both reset
boundaries explicitly preserve `direction`, while `all: initial` restores
horizontal writing, mixed text orientation, SDK typography, and other visual
properties. Vertical host writing modes and inherited host typography are unsupported.
This is selector and inheritance isolation, not independent document sizing:
the prototype retains `rem` units, so the owning document's root font size still
scales SDK text, spacing, and controls. That sizing input is accepted for the
prototype; it is not reset by a shadow boundary.
Known ambient custom-property dependencies are closed by using SDK-owned
`--yv-spacing` and `--yv-radius` values and by defining a local `--spacing`
compatibility alias for `tw-animate-css`. The completed YPE-5400 inventory,
exact runtime-input exceptions, and compiled-CSS prevention guard are documented
in the rollout plan.

Constructable stylesheets are cached per owning `Document`, because a sheet from
the top-level document cannot be adopted into a same-origin iframe's shadow
root. Environments without constructable stylesheets receive a `<style>` element
instead. Font loading remains document-owned: `CSSStyleSheet.replaceSync()`
discards `@import`, so the adopted sheet does not load the Google Fonts import
from the compiled CSS (and Chromium warns once when the cached sheet is created).
`YouVersionProvider` installs the document stylesheet and brand-font stylesheet;
an isolated component still depends on those document-level font registrations.
The local `<style>` fallback retains the import but is not a substitute for
document-owned font loading. For iframe consumers, fonts must be loaded in the
iframe's owning document, not merely in the parent document.

The same infrastructure was exercised as an internal opt-in with
`BibleVersionPicker` and the shared Dialog and Popover primitives. Floating
content remains in its component's shadow tree so ID relationships remain
resolvable. When it must escape a clipping ancestor, a shadow-local portal
container enters the native top layer using `popover="manual"`.

Modal dialogs require additional shadow-aware focus coordination. Radix's
document-level tracking sees the shadow host rather than the focused descendant,
so the isolated Dialog uses composed focus events to contain programmatic focus
and `tabbable` to own Tab and Shift+Tab traversal. The non-dialog shadow content
remains inert until both Dialog Overlay and Content have unmounted, after which
focus is restored to the real opener.

## Considered options

- Client-side attachment emits an empty host from the server, hydrates that
host, and mounts isolated content after an effect attaches the shadow root.
This preserves React's ownership of the light-DOM tree and never exposes the
component internals to consuming-app CSS. It is the selected contract for the
prototype.
- Rendering the component in the light DOM on the server and moving or
remounting it after hydration would make content visible sooner, but exposes
that first paint to consuming-app CSS and risks replacement, duplicate
content, and lost DOM identity during hydration.
- Declarative Shadow DOM could provide isolated server content and an isolated
first paint. This spike did not identify a supported React 19.1
render-and-hydrate seam for the browser-created shadow tree, so adopting it
would require custom serialization and hydration behavior. It remains a
future option for a component that requires server-rendered content.
- Stronger selectors, resets, cascade layers, `@scope`, and `!important` reduce
collisions but do not create a selector boundary.
- A shared shadow overlay under `document.body` escapes clipping, but separates
triggers and floating content into different tree scopes and breaks resolved
ID relationships.
- A shadow-local inline portal preserves tree scope but remains vulnerable to
clipping ancestors.
- An iframe provides stronger document isolation, but does not compose naturally
with React context, refs, events, sizing, focus, or floating content.

The shadow-local native top-layer arrangement is the selected prototype because
it preserves tree scope and style isolation while escaping ancestor clipping.
The top-layer strategy requires the native Popover API; it does not silently
fall back to the clipped inline arrangement.

## SSR and hydration contract

`ShadowRootHost` is client-only. Its server markup is exactly an empty host:

```html
<div data-yv-shadow-host="true"></div>
```

The first client render matches that markup. After hydration, `useEffect`
attaches or reuses one open shadow root, installs the SDK stylesheet, and then
portals the component into that root. The component is not rendered in the
light DOM, so hydration does not replace server content or create a duplicate.
If constructable stylesheets are unavailable, the local `<style>` fallback and
the component mount in the shadow root together. A same-origin iframe uses a
stylesheet constructed for its own `Document`; it never adopts the parent
document's sheet.

The server contains no component content. If the browser paints before the
effect runs, that paint is also empty; on a slow client, the user can see this
empty state followed by the component appearing. If JavaScript does not run,
the component never appears. No placeholder space is reserved, so mounting can
move nearby content. Cumulative Layout Shift is a page-level result that also
depends on the consuming app's layout; this prototype neither guarantees zero
shift nor claims the delay is negligible.
The [YPE-5354 first-paint research](../ype-5354-client-only-first-paint-research.md)
records the measurement and reservation options for a future rollout decision.

A forwarded component ref remains `null` during server rendering. It becomes
available after the shadow content mounts and points to the real component
element inside the shadow root, not to the light-DOM host. Before that mount,
the component cannot receive focus or interaction.

This contract must be accepted separately for every component selected for
automatic isolation. A component that requires server content, no-JavaScript
content, or a stable first-paint footprint cannot use this host unchanged.
YPE-5356 owns that rollout policy, including whether a component needs reserved
space, a product timing budget, or a different SSR strategy.

## Consequences

The React props API remains unchanged, but the rendered DOM structure changes.
Consumer CSS and ordinary document queries cannot reach component internals.
Native events observed outside the root are retargeted to the shadow host.

The client-only SSR and hydration behavior above is part of the component
contract. Automatic isolation is therefore a breaking change rather than an
internal implementation detail.

Shadow DOM does not isolate document-scoped `@font-face` names; the prototype
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.

## Nested and concurrent overlays

YPE-5355 exercised nested and concurrent overlays through the production
`ShadowRootHost` seam. Both nesting directions preserve topmost-only Escape
dismissal and restore focus into the remaining parent overlay. Either
nested-overlay teardown order also works with the current architecture. Separate
component shadow roots own distinct portal containers, but do not isolate Radix
outside interaction: opening a peer popover dismisses the existing peer across
the same or separate roots. A verse action popover does not restore final focus
after its nested dialog and then the popover close, and rapid dialog close/reopen
also loses final focus restoration.

These observations do not select or design production overlay coordination.
YPE-5356 owns deciding whether and how to support concurrent peers and exact
final focus restoration. The detailed Chromium evidence and remaining
validation live in the rollout plan.

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.

Only `YouVersionAuthButton` is automatically isolated by this prototype.
`BibleVersionPicker` and other public exports do not gain automatic isolation
from the opt-in validation work. The internal `SignInDialog` is validated only
through an opt-in story. Any wider rollout requires a separate decision and
change.

The detailed evidence, unresolved audits, and rollout gates live in the
[Shadow DOM isolation validation and rollout plan](../shadow-dom-isolation-plan.md).
90 changes: 90 additions & 0 deletions docs/shadow-dom-consumer-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Shadow DOM Consumer Compatibility Contract

## Purpose

This contract records the Chromium evidence for consumer-facing behavior at the
SDK's Shadow DOM boundary. It is input to YPE-5356's production rollout policy,
not approval for automatic isolation beyond `YouVersionAuthButton`.

The executable evidence lives in
`consumer-compatibility.shadow-isolation.stories.tsx`. The existing
`bible-version-picker.shadow-isolation.stories.tsx` suite supplies additional
evidence for shadow-aware queries and relationships that stay within one tree
scope.

## Representative modules

- `YouVersionAuthButton` exercises the current automatic-isolation path and its
public event and forwarded-ref props.
- `Textarea`, rendered through the internal opt-in `ShadowRootHost`, isolates a
native form control without adding a production behavior or public wrapper.
- `BibleVersionPicker`, also rendered through the opt-in host, exercises a
composed public module with shadow-local floating content.

These modules validate the shared boundary and specific public interfaces they
exercise. They do not establish compatibility for every SDK component.

## Compatibility matrix

| Consumer scenario | Classification | Contract and evidence |
| --- | --- | --- |
| A light-DOM form natively owns or serializes a control inside an SDK shadow root | Unsupported | `FormsAndExternalRelationshipsStopAtTheTreeScope` verifies that the isolated textarea has no owner form, is absent from `form.elements`, and is absent from `FormData`. A rollout target that needs form participation requires an explicit component API or separately designed form-associated host contract. |
| A light-DOM `<label for>` labels or focuses a control inside an SDK shadow root | Unsupported | The same story verifies that `label.control` is `null` and clicking the label does not focus the isolated textarea. Put the label and control in the same tree scope or expose an explicit component labeling API. |
| An internal control resolves light-DOM `aria-labelledby` or `aria-describedby` ID references | Unsupported | The attributes remain present, but Chromium's reflected element arrays are empty across the boundary. Keep referenced nodes in the same tree scope. This DOM evidence is not a substitute for assistive-technology testing. |
| A native composed event crosses one shadow boundary | Supported with native retargeting | `EventsRefsAndDomQueriesExposeDifferentConsumerViews` clicks an internal label element and verifies that a light-DOM listener receives the shadow host as `event.target`; `composedPath()` begins with the label and includes the internal button and host. Consumers must not assume an external native listener's target is the internal control. |
| A React handler passed to `YouVersionAuthButton` receives its button event | Supported for this public component | The same story verifies that the consumer `onClick` handler receives the internal originating label as `target` and the internal button as `currentTarget`. Consumers may rely on the button current target, not on every event originating at the button itself. This is component-specific evidence, not a package-wide promise for every event prop. |
| A forwarded `YouVersionAuthButton` ref exposes the internal button | Supported after mount | The ref resolves to the exact internal `HTMLButtonElement`. It remains `null` through the consumer's first layout effect because the shadow root attaches in a passive effect; consumers must handle callback-ref updates or read object refs after a later commit. |
| An ordinary document or Storybook-canvas selector finds SDK internals | Unsupported | DOM selector APIs do not cross a shadow boundary. `document.querySelector` and Testing Library queries rooted at the document need explicit open-root traversal. Automation behavior is tool-specific: [Playwright locators pierce open roots by default](https://playwright.dev/docs/locators#locate-in-shadow-dom), except for XPath locators, while closed roots remain inaccessible. |
| A consumer traverses an open root and queries after attachment | Supported with timing and access constraints | Wait for the host's open `shadowRoot`, then query within it. The contract depends on the prototype's open-root policy and does not make internals a stable semantic API; prefer public refs, roles, and component callbacks where available. |
| An automatically isolated component is nested inside another open SDK shadow root | Supported for basic rendering, traversal, and composed events | `NestedRootsRequireTraversalAndRetargetAtEveryBoundary` verifies recursive root traversal and target retargeting to the inner host in the outer scope and to the outer host in the document scope. Consumers must traverse every root explicitly. |
| Nested or concurrent overlays inside shadow roots | Unsupported by this contract | YPE-5355 owns stacking, focus, inertness, dismissal, and restoration. Basic nested-root evidence here does not change that overlay boundary. |
| Shadow-local ID relationships inside `BibleVersionPicker` | Supported in current Chromium evidence | `TopLayerEscapesClippingAndPreservesSemantics` verifies that the trigger and controlled panel remain in one root and Chromium resolves their `aria-controls` relationship. This does not make cross-scope ID references supported. |

## Consumer risks

Automatic isolation changes rendered DOM even when React props stay the same.
Consumer selectors and global CSS stop reaching internals; native event targets
change at every boundary; external form and ID-reference relationships stop
resolving; and forwarded refs become available later. These are compatibility
and potentially breaking changes, not internal implementation details.

An open root permits inspection and mutation by same-page JavaScript, so it is
an automation and styling boundary rather than a security boundary. Selectors
that depend on internal markup remain fragile even when they traverse the root.

The executable suite currently runs only in Chromium. Firefox, WebKit, and real
assistive technologies remain unverified. Chromium's reflected ARIA element
properties demonstrate DOM relationship resolution, not announcements or other
assistive-technology behavior.

## Input for YPE-5356

The rollout policy should treat automatic isolation as a compatibility change
and require a component-specific audit before each rollout. In particular, it
must:

- identify consumers that rely on native outer-form participation, external
labels or ARIA ID references, document-rooted queries, synchronous refs, or
unretargeted native events;
- prefer rollout candidates whose public callbacks, refs, and internal labels
already avoid those cross-scope dependencies;
- define consumer automation guidance around roles, public refs, and
tool-specific shadow behavior: [Playwright locators pierce open roots by
default](https://playwright.dev/docs/locators#locate-in-shadow-dom), while DOM
selector APIs need explicit traversal after root attachment and internal
rendering;
- define required Firefox, WebKit, and assistive-technology evidence rather than
treating the Chromium results as universal; and
- preserve YPE-5355's separate ownership of nested and concurrent overlay
behavior.

## Follow-up work outside this ticket

No production defect is fixed by this validation ticket. If a selected rollout
component must participate in an outer native form or consume external labeling
relationships, create a component-specific implementation ticket for an
explicit public contract rather than relying on cross-scope browser behavior.
Cross-browser and assistive-technology validation, consumer-facing rollout
documentation, and any production implementation belong to YPE-5356 or
separately authorized follow-up tickets. No new Jira issue is created by this
document.
Loading
Loading