From 5983688c484eb3c29bf76b6970630f6d00057a92 Mon Sep 17 00:00:00 2001 From: abharms Date: Fri, 7 Aug 2026 07:29:02 -0500 Subject: [PATCH 01/11] feat(ui)!: prototype automatic Shadow DOM isolation --- .changeset/prototype-shadow-dom-isolation.md | 7 ++ ...05-prototype-shadow-dom-style-isolation.md | 45 +++++++ ...ionAuthButton.shadow-isolation.stories.tsx | 110 ++++++++++++++++++ .../YouVersionAuthButton.stories.tsx | 32 +++-- .../src/components/YouVersionAuthButton.tsx | 14 ++- packages/ui/src/lib/shadow-isolation.tsx | 25 ++++ packages/ui/src/lib/shadow-root-host.test.tsx | 34 ++++++ packages/ui/src/lib/shadow-root-host.tsx | 82 +++++++++++++ 8 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 .changeset/prototype-shadow-dom-isolation.md create mode 100644 docs/adr/0005-prototype-shadow-dom-style-isolation.md create mode 100644 packages/ui/src/components/YouVersionAuthButton.shadow-isolation.stories.tsx create mode 100644 packages/ui/src/lib/shadow-isolation.tsx create mode 100644 packages/ui/src/lib/shadow-root-host.test.tsx create mode 100644 packages/ui/src/lib/shadow-root-host.tsx diff --git a/.changeset/prototype-shadow-dom-isolation.md b/.changeset/prototype-shadow-dom-isolation.md new file mode 100644 index 00000000..6c11fe80 --- /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` so host-page selectors cannot override its internal styles. diff --git a/docs/adr/0005-prototype-shadow-dom-style-isolation.md b/docs/adr/0005-prototype-shadow-dom-style-isolation.md new file mode 100644 index 00000000..7e7661d5 --- /dev/null +++ b/docs/adr/0005-prototype-shadow-dom-style-isolation.md @@ -0,0 +1,45 @@ +# ADR 0005: Prototype automatic Shadow DOM style isolation + +Status: Proposed proof of concept + +## Problem + +Host applications can apply unlayered global rules such as `button { ... }` or +Tailwind v3 preflight to SDK markup. Unlayered author CSS outranks the SDK's +layered CSS, so selector specificity alone cannot guarantee isolation. + +## Prototype + +`YouVersionAuthButton` automatically creates an open shadow root and renders its +existing implementation inside it. The SDK's compiled CSS is installed inside +that root. Consumers continue to write ``; isolation is +not an option they must discover or enable. + +This PR intentionally applies the architecture to one representative component. +It asks whether automatic Shadow DOM boundaries are the right foundation before +the same pattern is rolled out across the UI package. + +The constructable stylesheet is cached per owner `Document`, because a sheet +created in the top-level document cannot be adopted by a shadow root rendered in +a same-origin iframe. Browsers without constructable stylesheets receive a +` : null} + {/* Host selectors cannot reach this reset boundary. */} +
{children}
+ , + shadowRoot, + ) + : null} + + ); +} From 7108e77a7e66da782e477691a9f65cf56bf0ed0c Mon Sep 17 00:00:00 2001 From: abharms Date: Fri, 7 Aug 2026 10:00:44 -0500 Subject: [PATCH 02/11] docs(examples): add hostile CSS POC demo --- examples/vite-react/src/App.tsx | 4 +- examples/vite-react/src/components/navbar.tsx | 1 + .../vite-react/src/pages/HostileCssPage.tsx | 161 ++++++++++++++++++ .../src/components/YouVersionAuthButton.tsx | 4 +- 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 examples/vite-react/src/pages/HostileCssPage.tsx diff --git a/examples/vite-react/src/App.tsx b/examples/vite-react/src/App.tsx index 7aa7d508..ed33c609 100644 --- a/examples/vite-react/src/App.tsx +++ b/examples/vite-react/src/App.tsx @@ -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('bible-reader'); @@ -16,6 +17,7 @@ function App() { {currentPage === 'bible-reader' && } {currentPage === 'votd' && } {currentPage === 'bible-card' && } + {currentPage === 'hostile-css' && } ); diff --git a/examples/vite-react/src/components/navbar.tsx b/examples/vite-react/src/components/navbar.tsx index a9c9ecb6..9d6ee951 100644 --- a/examples/vite-react/src/components/navbar.tsx +++ b/examples/vite-react/src/components/navbar.tsx @@ -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 { diff --git a/examples/vite-react/src/pages/HostileCssPage.tsx b/examples/vite-react/src/pages/HostileCssPage.tsx new file mode 100644 index 00000000..35003890 --- /dev/null +++ b/examples/vite-react/src/pages/HostileCssPage.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { YouVersionAuthButton } from '@youversion/platform-react-ui'; + +interface HostileVector { + key: string; + label: string; + expectation: string; + css: string; +} + +const HOSTILE_VECTORS: HostileVector[] = [ + { + key: 'type-selectors', + label: 'Type selectors', + expectation: 'The plain button changes; the SDK button should not.', + css: ` +.hostile-zone button, +.hostile-zone [role='button'] { + appearance: none !important; + background: #b91c1c !important; + border: 8px dashed #84cc16 !important; + border-radius: 0 !important; + color: #fff !important; + font: 28px/1 fantasy !important; + padding: 28px !important; + text-transform: uppercase !important; +}`, + }, + { + key: 'inherited', + label: 'Inherited properties', + expectation: 'Host text changes; the SDK button should retain its typography.', + css: ` +.hostile-zone { + color: #d600d6 !important; + cursor: crosshair !important; + font-family: 'Comic Sans MS', fantasy !important; + font-size: 24px !important; + font-style: italic !important; + letter-spacing: 0.25em !important; + line-height: 2.4 !important; + text-transform: uppercase !important; +}`, + }, + { + key: 'universal-important', + label: 'Universal selector with !important', + expectation: 'Every reachable host element changes; shadow internals should not.', + css: ` +.hostile-zone * { + color: #d600d6 !important; + font-family: 'Comic Sans MS', fantasy !important; + letter-spacing: 0.2em !important; + text-transform: uppercase !important; +}`, + }, + { + key: 'shadow-host', + label: 'Shadow-host box attack', + expectation: 'The witness disappears; the SDK host should remain usable.', + css: ` +.hostile-zone [data-yv-shadow-host], +.hostile-zone [data-host-box-witness] { + display: none !important; + opacity: 0 !important; + pointer-events: none !important; + transform: scale(0.5) !important; +}`, + }, + { + key: 'font-face', + label: '@font-face family collision (known limitation)', + expectation: + 'Registers a document-level Inter collision that can reach the shadow tree; the visible result depends on locally installed fonts.', + css: ` +@font-face { + font-family: 'Inter'; + src: local('Comic Sans MS'), local('Chalkboard SE'); +} +@font-face { + font-family: 'Untitled Serif'; + src: local('Comic Sans MS'), local('Chalkboard SE'); +}`, + }, +]; + +export function HostileCssPage() { + const [enabled, setEnabled] = useState>({ + 'type-selectors': true, + inherited: false, + 'universal-important': false, + 'shadow-host': false, + 'font-face': false, + }); + + const toggle = (key: string) => { + setEnabled((current) => ({ ...current, [key]: !current[key] })); + }; + + return ( +
+ {HOSTILE_VECTORS.filter((vector) => enabled[vector.key]).map((vector) => ( + + ))} + +
+

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) => ( + + ))} +
+
+ +
+
+

LIGHT DOM — SHOULD BE AFFECTED

+ +

Plain host text for inherited-property attacks.

+
+ Host-box witness — this should disappear during the host attack. +
+

+ 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/src/components/YouVersionAuthButton.tsx b/packages/ui/src/components/YouVersionAuthButton.tsx index 33c9affb..cf5de8df 100644 --- a/packages/ui/src/components/YouVersionAuthButton.tsx +++ b/packages/ui/src/components/YouVersionAuthButton.tsx @@ -198,7 +198,7 @@ const YouVersionAuthButtonImpl = React.forwardRef Date: Fri, 7 Aug 2026 11:43:33 -0500 Subject: [PATCH 03/11] docs(examples): clarify hostile CSS vectors --- .../vite-react/src/pages/HostileCssPage.tsx | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/examples/vite-react/src/pages/HostileCssPage.tsx b/examples/vite-react/src/pages/HostileCssPage.tsx index 35003890..4be4b364 100644 --- a/examples/vite-react/src/pages/HostileCssPage.tsx +++ b/examples/vite-react/src/pages/HostileCssPage.tsx @@ -5,6 +5,7 @@ interface HostileVector { key: string; label: string; expectation: string; + example: string; css: string; } @@ -13,6 +14,7 @@ const HOSTILE_VECTORS: HostileVector[] = [ key: 'type-selectors', label: 'Type selectors', expectation: 'The plain button changes; the SDK button should not.', + example: 'button { … }', css: ` .hostile-zone button, .hostile-zone [role='button'] { @@ -30,6 +32,7 @@ const HOSTILE_VECTORS: HostileVector[] = [ key: 'inherited', label: 'Inherited properties', expectation: 'Host text changes; the SDK button should retain its typography.', + example: '.hostile-zone { font-family: … }', css: ` .hostile-zone { color: #d600d6 !important; @@ -46,6 +49,7 @@ const HOSTILE_VECTORS: HostileVector[] = [ key: 'universal-important', label: 'Universal selector with !important', expectation: 'Every reachable host element changes; shadow internals should not.', + example: '* { … !important }', css: ` .hostile-zone * { color: #d600d6 !important; @@ -58,6 +62,7 @@ const HOSTILE_VECTORS: HostileVector[] = [ key: 'shadow-host', label: 'Shadow-host box attack', expectation: 'The witness disappears; the SDK host should remain usable.', + example: '[data-yv-shadow-host] { display: none !important }', css: ` .hostile-zone [data-yv-shadow-host], .hostile-zone [data-host-box-witness] { @@ -72,6 +77,7 @@ const HOSTILE_VECTORS: HostileVector[] = [ label: '@font-face family collision (known limitation)', expectation: 'Registers a document-level Inter collision that can reach the shadow tree; the visible result depends on locally installed fonts.', + example: "@font-face { font-family: 'Inter'; … }", css: ` @font-face { font-family: 'Inter'; @@ -115,18 +121,26 @@ export function HostileCssPage() {
Hostile stylesheet vectors {HOSTILE_VECTORS.map((vector) => ( - +
+ + + {vector.example} + +
))}
From 66a5774fe59dbbf4d78d9eb81d300df7c4b1c9ba Mon Sep 17 00:00:00 2001 From: abharms Date: Fri, 7 Aug 2026 11:46:29 -0500 Subject: [PATCH 04/11] docs(ui): align shadow DOM prototype ADR --- ...05-prototype-shadow-dom-style-isolation.md | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/adr/0005-prototype-shadow-dom-style-isolation.md b/docs/adr/0005-prototype-shadow-dom-style-isolation.md index 7e7661d5..a4ec882d 100644 --- a/docs/adr/0005-prototype-shadow-dom-style-isolation.md +++ b/docs/adr/0005-prototype-shadow-dom-style-isolation.md @@ -11,9 +11,11 @@ layered CSS, so selector specificity alone cannot guarantee isolation. ## Prototype `YouVersionAuthButton` automatically creates an open shadow root and renders its -existing implementation inside it. The SDK's compiled CSS is installed inside -that root. Consumers continue to write ``; isolation is -not an option they must discover or enable. +existing implementation inside it through a React portal. The SDK's compiled +Tailwind CSS—generated from `src/styles/global.css` and embedded as +`__YV_STYLES__`—is installed inside that root. Consumers continue to write +``; isolation is not an option they must discover or +enable. This PR intentionally applies the architecture to one representative component. It asks whether automatic Shadow DOM boundaries are the right foundation before @@ -24,13 +26,36 @@ created in the top-level document cannot be adopted by a shadow root rendered in a same-origin iframe. Browsers without constructable stylesheets receive a ` : null} + {needsStyleFallback ? ( + + ) : null} {/* Host selectors cannot reach this reset boundary. */}
{children}
,