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
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.
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 only text direction crosses the boundary and host custom properties cannot alter known SDK spacing or radius values.
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.
93 changes: 93 additions & 0 deletions docs/adr/0006-prototype-shadow-dom-style-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# ADR 0006: 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 CSS is 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 visual input: 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 host typography are unsupported.
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.

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.

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
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).
107 changes: 107 additions & 0 deletions docs/shadow-dom-isolation-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Shadow DOM Isolation Validation and Rollout Plan

## Why this doc exists

[ADR 0006](adr/0006-prototype-shadow-dom-style-isolation.md) records the durable
architectural decision behind the Shadow DOM prototype. This document tracks
the implementation evidence, unresolved audits, and conditions for expanding
automatic isolation beyond `YouVersionAuthButton`.

This is a working plan, not approval for package-wide rollout.

## Current scope

- `YouVersionAuthButton` creates its Shadow DOM boundary automatically.
- `BibleVersionPicker` validates shadow-local inline and native top-layer
floating content through opt-in stories.
- The shared Dialog and Popover primitives support opt-in shadow-local portals.
- `VerseActionPopover` uses the shared portal-state infrastructure while
retaining its specialized direct Radix composition.
- `BibleVersionPicker` and other public exports do not automatically create
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.

## Validation matrix

| Area | Evidence today | Status | Remaining work |
| --- | --- | --- | --- |
| Host CSS isolation | Hostile-CSS demo and focused Chromium coverage exercise element selectors, direction inheritance, vertical writing and typography resets, hostile custom properties, universal `!important` rules, host attacks, and generated pseudo-content. | Validated for the prototype | Repeat against each component selected for rollout. |
| Component behavior | Auth button interaction works through the React portal; Strict Mode does not attach the root twice. | Validated for the prototype | Audit component-specific refs, events, and consumer integrations during rollout. |
| Owner-document handling | Focused coverage mounts into a same-origin iframe and verifies document-compatible stylesheet construction. | Validated for the prototype | Verify stylesheet failure recovery. |
| Inline floating content | The picker negative control preserves tree-scope relationships but demonstrates clipping beyond a constrained ancestor. | Validated as a negative control | None; clipping is why inline placement is not the selected escaping strategy. |
| Native top-layer floating content | Picker stories verify clipping escape, hit testing, collision handling, hostile-CSS isolation, and resolved `aria-controls` relationships. | Validated in Chromium | Expand browser and assistive-technology coverage. |
| 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 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. |

## Direct overlay inventory

| Location | Classification | Shadow portal requirement |
| --- | --- | --- |
| `components/ui/dialog.tsx` | Shared Radix Dialog infrastructure | Already owns shadow-aware portal and modal-focus coordination. |
| `components/ui/popover.tsx` | Shared Radix Popover infrastructure | Already owns shadow-aware portal state. |
| `components/verse-action-popover.tsx` | Intentional direct Radix Popover consumer | Uses the shared portal-state seam while retaining its virtual anchor, reader-edge docking, custom pill surface, and verse-selection interaction rules. |
| `components/verse.tsx` | React portals into existing YVDOM footnote anchors, not floating overlays | No overlay migration required; each target remains in the rendered verse tree. |

No other production direct-overlay bypass was found. The inventory therefore
produced no equivalent low-risk migration and no materially different case that
requires follow-up work. Nested and concurrent overlay ownership remains a
separate decision.

## Blocking production-readiness decisions

- Decide whether isolation is enabled per component instance, per public export,
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.
- 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
values, but `all: initial` does not reset unknown custom properties.

## Functional and compatibility audits

- Verify native form participation and external `label`, `aria-labelledby`, and
`aria-describedby` relationships when controls cross tree scopes.
- Preserve `direction` as the only intentional inherited visual input. Vertical
writing modes, text orientation, host typography, and undeclared host custom
properties are not supported customization inputs.
- Document event retargeting, nested-root behavior, supported customization, and
shadow-aware consumer test and automation queries.
- Verify stylesheet construction and adoption failure recovery beyond the
current feature fallback.
- Audit realistic component density and the cost of many roots, effects,
wrappers, and local portal containers.

## Accepted boundaries and unresolved environment coverage

- Host `@font-face` registrations are document-scoped and can collide with the
public font family names used inside a shadow root. This limitation is
accepted for the prototype; avoiding it requires private family names and
controlled font declarations.
- Shadow DOM cannot prevent a host from hiding, clipping, transforming, or
constraining the component host or its ancestors.
- Open shadow roots prevent CSS selector crossover; they do not prevent
same-page JavaScript from inspecting or mutating the root.
- Browser automation currently focuses on Chromium. Firefox and WebKit remain
to be evaluated.
- Chromium DOM relationship reflection is not a substitute for VoiceOver, NVDA,
or other real assistive-technology verification.

## Rollout sequence

1. Complete YPE-5400's custom-property inventory and prevention guard.
2. Resolve SSR/hydration, rollout-control, and overlay-ownership decisions.
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,
accessibility, and the loss of global CSS styling.
5. Repeat the validation matrix for each component rather than assuming that the
infrastructure proof covers its component-specific behavior.
4 changes: 3 additions & 1 deletion examples/vite-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { Navbar } from '@/components/navbar';
import { BibleReaderPage } from '@/pages/BibleReaderPage';
import { VotdPage } from '@/pages/VotdPage';
import { BibleCardPage } from '@/pages/BibleCardPage';
import { HostileCssPage } from '@/pages/HostileCssPage';

export type Page = 'bible-reader' | 'votd' | 'bible-card';
export type Page = 'bible-reader' | 'votd' | 'bible-card' | 'hostile-css';

function App() {
const [currentPage, setCurrentPage] = useState<Page>('bible-reader');
Expand All @@ -16,6 +17,7 @@ function App() {
{currentPage === 'bible-reader' && <BibleReaderPage />}
{currentPage === 'votd' && <VotdPage />}
{currentPage === 'bible-card' && <BibleCardPage />}
{currentPage === 'hostile-css' && <HostileCssPage />}
</main>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions examples/vite-react/src/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const navItems: { label: string; page: Page }[] = [
{ label: 'Bible Reader', page: 'bible-reader' },
{ label: 'Verse of the Day', page: 'votd' },
{ label: 'Bible Card', page: 'bible-card' },
{ label: 'Hostile CSS', page: 'hostile-css' },
];

interface NavbarProps {
Expand Down
Loading
Loading