diff --git a/.changeset/flat-shadows-report.md b/.changeset/flat-shadows-report.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/flat-shadows-report.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/friendly-verses-scroll.md b/.changeset/friendly-verses-scroll.md new file mode 100644 index 00000000..42d2d7f3 --- /dev/null +++ b/.changeset/friendly-verses-scroll.md @@ -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. 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/prove-production-shadow-overlays.md b/.changeset/prove-production-shadow-overlays.md new file mode 100644 index 00000000..2e0a581b --- /dev/null +++ b/.changeset/prove-production-shadow-overlays.md @@ -0,0 +1,4 @@ +--- +--- + +Prove nested and concurrent overlays through the production ShadowRootHost seam (YPE-5355). 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..3f033cda --- /dev/null +++ b/.changeset/tidy-shadows-reset.md @@ -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. 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/0007-prototype-shadow-dom-style-isolation.md b/docs/adr/0007-prototype-shadow-dom-style-isolation.md new file mode 100644 index 00000000..ea6b77b1 --- /dev/null +++ b/docs/adr/0007-prototype-shadow-dom-style-isolation.md @@ -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 ` + ))} + +
+

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: {}