Skip to content
Merged
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` so host-page selectors cannot override its internal styles.
93 changes: 93 additions & 0 deletions docs/adr/0005-prototype-shadow-dom-style-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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.

Resets, stronger selectors, `!important`, cascade layers, and `@scope` all
continue participating in the host document's cascade. They can reduce
accidental conflicts but cannot prevent an outside selector from matching SDK
internals. Shadow DOM was selected because it creates a browser-enforced
selector boundary.

## Prototype

`YouVersionAuthButton` automatically creates an open shadow root and renders its
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
`<YouVersionAuthButton />`; 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
`<style>` element in the root. The light-DOM host gets an inline-important box
reset; an inner, unreachable wrapper resets inherited standard properties.
The shadow stylesheet also suppresses `::before` and `::after` on the
light-DOM host with shadow-context important declarations, preventing host-page
CSS from injecting generated content around the isolated component.
Because that reset removes the light-DOM font inheritance the button previously
relied on, the implementation now applies its intended `font-sans` utility
explicitly.

The Vite example includes a Hostile CSS page with a light-DOM positive control
beside the isolated SDK button. It demonstrates type selectors, inherited
properties, universal `!important` rules, direct shadow-host attacks, and a known
document-wide `@font-face` limitation.

## What this proves

- Ordinary and `!important` host selectors cannot select the button internals.
- Isolation is automatic without changing the component's React props API.
- Existing click behavior continues to work through the React portal.
- Strict Mode does not attach the root twice.
- Constructed stylesheets are created per owner document, so mounting the shadow
host in a same-origin iframe does not cause a cross-document adoption error.

Focused Chromium stories verify hostile button rules, host-generated
pseudo-content, existing interactions, and mounting in a same-origin iframe.
Unit tests verify Strict Mode behavior and the inline-important host reset. The
remaining hostile vectors are available for manual inspection on the demo page.

## Compatibility impact

Although the React props API is unchanged, the rendered DOM structure is not.
Consumers that query or style internal light-DOM markup must instead account for
the shadow root. Because the prototype attaches the shadow root in `useEffect`,
server output contains an empty host. The button appears after hydration, and
its forwarded ref becomes available later than it did previously. This is
therefore represented as a breaking change rather than an implementation
detail.

## Deliberately deferred

- Rollout to all exported components.
- Radix popover/dialog portal placement and focus management.
- Form association when controls live outside their form's tree scope.
- SSR/hydration and the first client paint.
- A package-wide custom-property audit. `all: initial` does not reset custom
properties; the larger investigation branch tested redeclaring Tailwind v4's
generated theme tokens on the protected internal wrapper.
- A deliberate inheritance policy for writing direction and future custom
properties. Some host values may be intentional localization inputs, while
SDK-owned visual tokens need shadow-local defaults.
- Host `@font-face` rules, which are not scoped by Shadow DOM.
- Ancestor layout constraints, which Shadow DOM cannot isolate.
- Event retargeting, nested-root behavior, and a supported consumer customization
model.
- Stylesheet construction/adoption failure recovery beyond feature fallback.
- A full browser and assistive-technology matrix; current browser verification is
Chromium-focused.
- Consumer test-query migration guidance and a component-by-component rollout.

This prototype should be reviewed as an architectural checkpoint, not as a
complete solution ready for release.
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
199 changes: 199 additions & 0 deletions examples/vite-react/src/pages/HostileCssPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { useState } from 'react';
import { YouVersionAuthButton } from '@youversion/platform-react-ui';

interface HostileVector {
key: string;
label: string;
expectation: string;
example: string;
css: string;
}

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'] {
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.',
example: '.hostile-zone { font-family: … }',
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.',
example: '* { … !important }',
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.',
example: '[data-yv-shadow-host] { display: none !important }',
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: 'host-pseudo-elements',
label: 'Shadow-host pseudo-elements',
expectation: 'The witness gains generated content; the SDK host should not.',
example: '[data-yv-shadow-host]::before { content: … !important }',
css: `
.hostile-zone [data-yv-shadow-host]::before,
.hostile-zone [data-yv-shadow-host]::after,
.hostile-zone [data-host-pseudo-witness]::before {
content: 'HOSTILE' !important;
display: block !important;
background: #b91c1c !important;
color: #fff !important;
padding: 8px !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.',
example: "@font-face { font-family: 'Inter'; … }",
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<Record<string, boolean>>({
'type-selectors': true,
inherited: false,
'universal-important': false,
'shadow-host': false,
'host-pseudo-elements': false,
'font-face': false,
});

const toggle = (key: string) => {
setEnabled((current) => ({ ...current, [key]: !current[key] }));
};

return (
<div className="flex flex-col gap-8 p-6 md:p-12">
{HOSTILE_VECTORS.filter((vector) => enabled[vector.key]).map((vector) => (
<style key={vector.key}>{vector.css}</style>
))}

<section className="rounded-lg border p-5">
<h1 className="text-xl font-semibold">Automatic Shadow DOM isolation POC</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
This branch automatically isolates only <code>YouVersionAuthButton</code>. 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.
</p>

<fieldset className="mt-5 flex flex-col gap-3">
<legend className="mb-2 font-medium">Hostile stylesheet vectors</legend>
{HOSTILE_VECTORS.map((vector) => (
<div
key={vector.key}
className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<label className="flex items-start gap-3 text-sm">
<input
className="mt-1"
type="checkbox"
checked={enabled[vector.key] ?? false}
onChange={() => toggle(vector.key)}
/>
<span>
<span className="font-medium">{vector.label}</span>
<span className="block text-muted-foreground">{vector.expectation}</span>
</span>
</label>
<code className="w-fit max-w-full overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-1 text-xs">
{vector.example}
</code>
</div>
))}
</fieldset>
</section>

<div className="grid gap-8 md:grid-cols-2">
<section className="flex flex-col gap-5 rounded-lg border border-dashed p-5">
<h2 className="text-sm font-semibold">LIGHT DOM — SHOULD BE AFFECTED</h2>
<div className="hostile-zone flex flex-col gap-5">
<button type="button">Plain host-app button</button>
<p>Plain host text for inherited-property attacks.</p>
<div data-host-box-witness className="rounded border p-3">
Host-box witness — this should disappear during the host attack.
</div>
<div data-host-pseudo-witness className="rounded border p-3">
Pseudo-element witness — generated content should appear above this text.
</div>
<p style={{ fontFamily: 'Inter, sans-serif' }}>
Host text requesting Inter for the font-face collision.
</p>
</div>
</section>

<section className="flex flex-col gap-5 rounded-lg border p-5">
<h2 className="text-sm font-semibold">SDK POC — SHOULD RESIST</h2>
<div className="hostile-zone">
<YouVersionAuthButton
size="short"
onAuthError={(error) => console.error('Auth error:', error)}
/>
</div>
<p className="text-sm text-muted-foreground">
Other SDK components are intentionally absent: automatic isolation has not been rolled
out to them on this POC branch.
</p>
</section>
</div>
</div>
);
}
Loading
Loading