diff --git a/.changeset/prototype-shadow-dom-isolation.md b/.changeset/prototype-shadow-dom-isolation.md new file mode 100644 index 00000000..1bd59695 --- /dev/null +++ b/.changeset/prototype-shadow-dom-isolation.md @@ -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. diff --git a/.changeset/shadow-aware-verse-actions.md b/.changeset/shadow-aware-verse-actions.md new file mode 100644 index 00000000..cd961cbd --- /dev/null +++ b/.changeset/shadow-aware-verse-actions.md @@ -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. diff --git a/.changeset/tidy-shadows-reset.md b/.changeset/tidy-shadows-reset.md new file mode 100644 index 00000000..69a5c445 --- /dev/null +++ b/.changeset/tidy-shadows-reset.md @@ -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. diff --git a/.changeset/validate-shadow-dialog.md b/.changeset/validate-shadow-dialog.md new file mode 100644 index 00000000..28f500ca --- /dev/null +++ b/.changeset/validate-shadow-dialog.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Validate shadow-local top-layer Dialog behavior, including focus containment and modal background inertness. diff --git a/docs/adr/0006-prototype-shadow-dom-style-isolation.md b/docs/adr/0006-prototype-shadow-dom-style-isolation.md new file mode 100644 index 00000000..76835e91 --- /dev/null +++ b/docs/adr/0006-prototype-shadow-dom-style-isolation.md @@ -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 ` + ))} + +
+

Automatic Shadow DOM isolation POC

+

+ This branch automatically isolates only YouVersionAuthButton. The plain host + controls are positive witnesses: they should look broken when an attack is active, while + the SDK button should remain stable. The font-face option demonstrates a known Shadow DOM + limitation. +

+ +
+ Hostile stylesheet vectors + {HOSTILE_VECTORS.map((vector) => ( +
+ + + {vector.example} + +
+ ))} +
+
+ +
+
+

LIGHT DOM — SHOULD BE AFFECTED

+
+ +

Plain host text for inherited-property attacks.

+
+ Host-box witness — this should disappear during the host attack. +
+
+ Pseudo-element witness — generated content should appear above this text. +
+

+ Host text requesting Inter for the font-face collision. +

+
+
+ +
+

SDK POC — SHOULD RESIST

+
+ console.error('Auth error:', error)} + /> +
+

+ Other SDK components are intentionally absent: automatic isolation has not been rolled + out to them on this POC branch. +

+
+
+ + ); +} diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index ada2ee13..06c4549d 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -38,10 +38,20 @@ export — treat those two as public API and breaking-change territory. ## STYLING **React 19 ` + ) : null} + {/* Host selectors cannot reach this reset boundary. */} +
{ + if ( + isElementFromOwnerDocument(event.target, event.currentTarget, 'HTMLElement') && + event.currentTarget.contains(event.target) + ) { + lastFocusedElementRef.current = event.target; + } + }} + > + {children} +
+ , + shadowRoot, + ) + : null} + + ); +} diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index 216ea626..090d7f5b 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -53,6 +53,37 @@ layer(yv-sdk-fonts); @import '@youversion/platform-core/browser/styles/bible-reader.css' layer(yv-sdk-bible-reader); @import 'tw-animate-css'; +/* The light-DOM shadow host remains selectable by the consumer page. Prevent + hostile host-page rules from generating content around the isolated SDK UI. + For !important declarations on a shadow host, the shadow-tree declaration + outranks an outer author declaration by design. */ +:host::before, +:host::after { + content: none !important; + display: none !important; +} + +/* A shadow-local portal container can enter the native top layer without + becoming a full-screen hit target itself. Its Radix-owned children remain + interactive while the zero-sized container only supplies tree scope. */ +[data-yv-shadow-local-overlay] { + position: fixed; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 0; + block-size: 0; + margin: 0; + padding: 0; + border: 0; + overflow: visible; + background: transparent; + pointer-events: none; +} + +[data-yv-shadow-local-overlay] > * { + pointer-events: auto; +} + /* Untitled Serif has no @font-face here on purpose. Its stylesheet URL needs the consumer's app key, which this file cannot know — it is frozen into __YV_STYLES__ at build time. It is loaded instead by (src/lib/yv-fonts.tsx), rendered from @@ -66,6 +97,10 @@ layer(yv-sdk-fonts); @layer yv-sdk-theme { [data-yv-sdk] { + /* Keep Tailwind and tw-animate-css spacing independent of host custom properties. */ + --yv-spacing: 0.25rem; + --spacing: var(--yv-spacing); + @theme inline { /* Untitled Serif is the brand serif, loaded from the gated Fonts API stylesheet endpoint by — see docs/adr/0004-adopt-untitled-serif-via-fonts-api.md. diff --git a/packages/ui/src/test/dom-stubs.ts b/packages/ui/src/test/dom-stubs.ts index 9e106bf7..469a891f 100644 --- a/packages/ui/src/test/dom-stubs.ts +++ b/packages/ui/src/test/dom-stubs.ts @@ -26,3 +26,9 @@ export function requireHtmlElement(node: Element | null): HTMLElement { } return node; } + +export function requireShadowRoot(container: ParentNode): ShadowRoot { + const host = container.querySelector('[data-yv-shadow-host]'); + if (!host?.shadowRoot) throw new Error('shadow root not attached'); + return host.shadowRoot; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a69468a..02e22343 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: '@radix-ui/react-accordion': specifier: 1.2.12 version: 1.2.12(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': + specifier: 1.1.2 + version: 1.1.2(@types/react@19.1.2)(react@19.1.2) '@radix-ui/react-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) @@ -274,6 +277,9 @@ importers: react-i18next: specifier: ^17.0.0 version: 17.0.2(i18next@26.0.4(typescript@7.0.2))(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(typescript@7.0.2) + tabbable: + specifier: 6.5.0 + version: 6.5.0 tailwind-merge: specifier: 3.3.1 version: 3.3.1 @@ -6132,6 +6138,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -12554,6 +12563,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tagged-tag@1.0.0: {} tailwind-merge@3.3.1: {}