Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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/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.
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.
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.
119 changes: 119 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,119 @@
# 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`. YPE-5400 owns the full custom-property
inventory and a compiled-CSS prevention guard.

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

- 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.

## 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 root currently attaches in `useEffect`, so server output contains an empty
host, isolated content appears after hydration, and forwarded refs become
available later. 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