((props, ref) => {
+ const implementationProps: PropsWithoutRef & RefAttributes = {
+ ...props,
+ ref,
+ };
+
+ return (
+ {createElement(Implementation, implementationProps)}
+ );
+ });
+ Isolated.displayName = displayName;
+ return Isolated;
+}
diff --git a/packages/ui/src/lib/shadow-root-host.test.tsx b/packages/ui/src/lib/shadow-root-host.test.tsx
new file mode 100644
index 00000000..514234e2
--- /dev/null
+++ b/packages/ui/src/lib/shadow-root-host.test.tsx
@@ -0,0 +1,63 @@
+import { StrictMode } from 'react';
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { ShadowRootHost } from './shadow-root-host';
+
+describe('ShadowRootHost', () => {
+ it('attaches one shadow root under StrictMode', () => {
+ let container!: HTMLElement;
+ expect(() => {
+ ({ container } = render(
+
+
+ content
+
+ ,
+ ));
+ }).not.toThrow();
+
+ const host = container.querySelector('[data-yv-shadow-host]');
+ expect(host).not.toBeNull();
+ expect(host?.shadowRoot).not.toBeNull();
+ expect(host?.shadowRoot?.textContent).toContain('content');
+ });
+
+ it('applies important inline declarations to stabilize the host box', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const host = container.querySelector('[data-yv-shadow-host]');
+ expect(host).not.toBeNull();
+ expect(host?.style.getPropertyValue('all')).toBe('initial');
+ expect(host?.style.getPropertyValue('display')).toBe('contents');
+ expect(host?.style.getPropertyValue('writing-mode')).toBe('inherit');
+ expect(host?.style.getPropertyValue('text-orientation')).toBe('inherit');
+
+ // jsdom's cssstyle backing does not track priority for `all`,
+ // `writing-mode`, or `text-orientation` (real browsers do), so only
+ // `display` can assert getPropertyPriority here. The value-only checks
+ // above still prove the other properties were set.
+ expect(host?.style.getPropertyPriority('display')).toBe('important');
+ });
+
+ it('gives the fallback stylesheet a stable React resource identity', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const style = container
+ .querySelector('[data-yv-shadow-host]')
+ ?.shadowRoot?.querySelector('style');
+
+ // jsdom does not implement constructable stylesheets, so this exercises
+ // the fallback path. React uses href + precedence to hoist and de-duplicate
+ // stylesheet resources within the shadow root.
+ expect(style?.getAttribute('data-href')).toBe('yv-sdk-shadow-styles');
+ expect(style?.getAttribute('data-precedence')).toBe('yv-sdk');
+ });
+});
diff --git a/packages/ui/src/lib/shadow-root-host.tsx b/packages/ui/src/lib/shadow-root-host.tsx
new file mode 100644
index 00000000..fd4b4061
--- /dev/null
+++ b/packages/ui/src/lib/shadow-root-host.tsx
@@ -0,0 +1,88 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+
+declare const __YV_STYLES__: string;
+
+const sdkStyleSheets = new WeakMap();
+const SDK_SHADOW_STYLE_HREF = 'yv-sdk-shadow-styles';
+const SDK_SHADOW_STYLE_PRECEDENCE = 'yv-sdk';
+
+function getStyleSheetConstructor(root: ShadowRoot): typeof CSSStyleSheet | undefined {
+ return root.ownerDocument.defaultView?.CSSStyleSheet;
+}
+
+function supportsAdoptedStyleSheets(root: ShadowRoot): boolean {
+ const StyleSheet = getStyleSheetConstructor(root);
+ return (
+ StyleSheet !== undefined &&
+ typeof StyleSheet.prototype.replaceSync === 'function' &&
+ 'adoptedStyleSheets' in root
+ );
+}
+
+function getOrCreateSdkStyleSheet(root: ShadowRoot): CSSStyleSheet {
+ const ownerDocument = root.ownerDocument;
+ const existing = sdkStyleSheets.get(ownerDocument);
+ if (existing) return existing;
+
+ const StyleSheet = getStyleSheetConstructor(root)!;
+ const sheet = new StyleSheet();
+ sheet.replaceSync(__YV_STYLES__);
+ sdkStyleSheets.set(ownerDocument, sheet);
+ return sheet;
+}
+
+function resetHost(host: HTMLDivElement): void {
+ // The host page can select this light-DOM element, including with !important.
+ // Inline author-important declarations establish the smallest stable box.
+ host.style.setProperty('all', 'initial', 'important');
+ host.style.setProperty('display', 'contents', 'important');
+ host.style.setProperty('writing-mode', 'inherit', 'important');
+ host.style.setProperty('text-orientation', 'inherit', 'important');
+}
+
+interface ShadowRootHostProps {
+ children: ReactNode;
+}
+
+/** @internal Proof-of-concept primitive; not part of the public API. */
+export function ShadowRootHost({ children }: ShadowRootHostProps): ReactNode {
+ const hostRef = useRef(null);
+ const [shadowRoot, setShadowRoot] = useState(null);
+ const [needsStyleFallback, setNeedsStyleFallback] = useState(false);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ // React StrictMode replays effects against the same DOM node. Inspect the
+ // live node instead of captured state so attachShadow is called only once.
+ if (!host || host.shadowRoot) return;
+
+ resetHost(host);
+ const root = host.attachShadow({ mode: 'open' });
+ if (supportsAdoptedStyleSheets(root)) {
+ root.adoptedStyleSheets = [getOrCreateSdkStyleSheet(root)];
+ } else {
+ setNeedsStyleFallback(true);
+ }
+ setShadowRoot(root);
+ }, []);
+
+ return (
+
+ {shadowRoot
+ ? createPortal(
+ <>
+ {needsStyleFallback ? (
+
+ ) : null}
+ {/* Host selectors cannot reach this reset boundary. */}
+
{children}
+ >,
+ shadowRoot,
+ )
+ : null}
+
+ );
+}
diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css
index 01568998..5134c60d 100644
--- a/packages/ui/src/styles/global.css
+++ b/packages/ui/src/styles/global.css
@@ -45,6 +45,16 @@ 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;
+}
+
/* 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