Skip to content

feat(ui): prototype shared shadow overlay for portals (YPE-5138) - #350

Open
abharms wants to merge 5 commits into
journey-to-the-shadow-domfrom
spike/shadow-dom-complex-component
Open

feat(ui): prototype shared shadow overlay for portals (YPE-5138)#350
abharms wants to merge 5 commits into
journey-to-the-shadow-domfrom
spike/shadow-dom-complex-component

Conversation

@abharms

@abharms abharms commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add one shared, styled overlay shadow root for portalled SDK content
  • route internal Radix Popover and Dialog portals to that root when rendered under ShadowRootHost
  • prove the architecture with BibleVersionPicker and SignInDialog Storybook browser integration tests
  • constrain popovers to Radix's available viewport height and namespace the picker spacing token
  • preserve the existing document.body portal behavior outside shadow isolation
  • record findings and remaining accessibility risks in ADR 0005

Scope

This is an architecture spike, not a BibleVersionPicker rollout. The public BibleVersionPicker export is unchanged; the ShadowRootHost wrapper exists only in the spike stories.

Findings

The Chromium proof covers host-CSS isolation, ancestor and viewport clipping, internal scrolling, positioning, focus, Escape and outside-click dismissal, shared-overlay reuse, and actual SignInDialog composition. Cross-root aria-controls resolution and real VoiceOver/NVDA behavior remain explicit follow-up gates.

Known limitation

If the shared overlay host is externally removed, new isolated mounts create a replacement root. Already-mounted providers retain their original portal container. Reconnecting the detached root was explored but caused test/document-reset isolation problems.

Verification

  • UI unit suite: 408 tests passed
  • Storybook Chromium integration: 11 tests passed
  • UI build and style verification passed
  • UI typecheck and lint passed
  • git diff --check passed

The root pnpm test command could not complete locally because the core test setup requires YVP_API_HOST, which was not available. The complete UI suite passed independently.

Greptile Summary

The PR introduces a shared styled shadow overlay for isolated Radix portals while retaining document-level portal behavior outside ShadowRootHost.

  • Routes popover and dialog content through a document-scoped shared overlay shadow root.
  • Adds shadow-isolation stories and tests covering portal placement, styling, focus cycling, dismissal, clipping, and overlay reuse.
  • Constrains popover height and namespaces the picker spacing token.
  • Documents the prototype architecture and remaining assistive-technology validation gates.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/ui/src/lib/shadow-root-host.tsx Creates and provides a reusable, styled document-level overlay shadow root while preserving component-root isolation.
packages/ui/src/components/ui/dialog.tsx Routes isolated dialogs to the shared overlay and adds shadow-root tab-cycle handling that addresses the previous containment-coverage thread.
packages/ui/src/components/SignInDialog.shadow-isolation.stories.tsx Adds browser coverage for dialog placement, accessible references, focus cycling, and blocked background pointer interaction.
packages/ui/src/components/ui/popover.tsx Routes isolated popovers to the shared overlay and constrains content to Radix’s available viewport height.
packages/ui/src/components/BibleVersionPicker.shadow-isolation.stories.tsx Exercises shared-overlay placement, styling isolation, clipping escape, focus, dismissal, sizing, and lifecycle behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Host[ShadowRootHost] --> ComponentRoot[Component Shadow Root]
  ComponentRoot --> Trigger[Dialog or Popover Trigger]
  ComponentRoot --> Context[ShadowPortalContext]
  Context --> SharedOverlay[Shared Document-Level Overlay Shadow Root]
  SharedOverlay --> Portal[Radix Portal Content]
  SharedOverlay --> Styles[SDK Shadow Styles]
  Outside[Components outside ShadowRootHost] --> Body[document.body portal fallback]
Loading

Reviews (3): Last reviewed commit: "fix(ui): support ES2020 dialog build" | Re-trigger Greptile

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1ef7d48

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@youversion/platform-react-ui Patch
vite-react Patch
@youversion/platform-core Patch
@youversion/platform-react-hooks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@abharms
abharms marked this pull request as ready for review August 20, 2026 16:43
Comment thread packages/ui/src/components/ui/dialog.tsx

@cameronapak cameronapak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec

Review of 1ef7d48 vs journey-to-the-shadow-dom. Spec is YPE-5138 (spike) under YPE-4113 (host CSS must not distort Bible UI). No GEAR tickets exist.

ShadowRootHost
  component shadow root
    trigger
    ShadowPortalContext
  shared overlay shadow root (document.body)
    Radix popover / dialog

issue (must-fix): Public BibleLanguagePickerContent TabsList uses var(--yv-spacing). The SDK does not define that token. Tailwind still uses --spacing. The host-spacing story can pass because the invalid var() is ignored.

suggestion (worth): PopoverContent now sets max-h-[min(66svh,var(--radix-popover-content-available-height))] for every consumer. The ticket asked to route isolated popovers. This height change is extra.

suggestion (worth): A patch changeset ships overlay routing and the global height change. ADR 0005 says this is a checkpoint, not a release.

nitpick: Scrolling is manual Chromium only. The ADR already says that.

Pass: shared overlay, conditional portals, public picker not auto-isolated, Chromium proofs, AT documented as unproven.

Standards

No hard documented-standard break.

suggestion (worth): KeyboardFocusAndEscapeCrossShadowRoots and PopoverDialogSemanticsAndFocusAcrossShadowRoots repeat one play script. docs/testing.md prefers fewer, longer workflow tests.

suggestion (worth): Every ShadowRootHost eagerly creates the overlay root, including leaves that never portal.

nitpick: Overlay stylesheet adopt is copied from the component host. Custom tab trap is thinner than Radix. ADR says Radix focus does not cross shadows.

Summary

  • Standards: 0 must-fix, 2 worth, 3 nits. Worst: two play stories repeat one keyboard workflow.
  • Spec: 1 must-fix, 2 worth, 1 nit. Worst: undefined --yv-spacing on a public picker.

Code Reviewer bot, sent on behalf of Cam.

onValueChange={setLanguageTab}
>
<TabsList className="yv:mx-4 yv:w-[calc(100%-4*var(--spacing)*2)]">
<TabsList className="yv:mx-4 yv:w-[calc(100%-4*var(--yv-spacing)*2)]">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: --yv-spacing is not an SDK token.

BibleLanguagePickerContent is a public export. The TabsList width used var(--spacing) (Tailwind theme). This change points at var(--yv-spacing), which global.css never defines.

An invalid var() drops the width declaration. HostSpacingCustomPropertyDoesNotAffectOverlay can pass for that reason. Host --spacing no longer reaches a designed token.

YPE-4113: partners must see the designed Bible UI. Restore a real token, or keep --spacing and isolate it inside the overlay stylesheet.

Code Reviewer bot, sent on behalf of Cam.

collisionPadding={16}
className={cn(
'yv:bg-popover yv:text-popover-foreground yv:data-[state=open]:animate-in yv:data-[state=closed]:animate-out yv:data-[state=closed]:fade-out-0 yv:data-[state=open]:fade-in-0 yv:data-[state=closed]:zoom-out-95 yv:data-[state=open]:zoom-in-95 yv:data-[side=bottom]:slide-in-from-top-2 yv:data-[side=left]:slide-in-from-right-2 yv:data-[side=right]:slide-in-from-left-2 yv:data-[side=top]:slide-in-from-bottom-2 yv:z-50 yv:origin-(--radix-popover-content-transform-origin) yv:outline-hidden yv:grid yv:grid-rows-[auto_1fr_auto] yv:p-0 yv:h-full yv:max-h-[66svh] yv:max-sm:max-w-[calc(100vw-2rem)] yv:w-sm yv:sm:max-w-sm yv:overflow-hidden yv:rounded-2xl yv:border-0 yv:shadow-lg',
'yv:bg-popover yv:text-popover-foreground yv:data-[state=open]:animate-in yv:data-[state=closed]:animate-out yv:data-[state=closed]:fade-out-0 yv:data-[state=open]:fade-in-0 yv:data-[state=closed]:zoom-out-95 yv:data-[state=open]:zoom-in-95 yv:data-[side=bottom]:slide-in-from-top-2 yv:data-[side=left]:slide-in-from-right-2 yv:data-[side=right]:slide-in-from-left-2 yv:data-[side=top]:slide-in-from-bottom-2 yv:z-50 yv:origin-(--radix-popover-content-transform-origin) yv:outline-hidden yv:grid yv:grid-rows-[auto_1fr_auto] yv:p-0 yv:max-h-[min(66svh,var(--radix-popover-content-available-height))] yv:max-sm:max-w-[calc(100vw-2rem)] yv:w-sm yv:sm:max-w-sm yv:overflow-hidden yv:rounded-2xl yv:border-0 yv:shadow-lg',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This max-h applies to every Radix popover, not only isolated ones.

YPE-5138 asked to route isolated popovers and validate clipping. The shared primitive now uses min(66svh, --radix-popover-content-available-height) and drops h-full.

If the height change is required for the spike, say so in the ADR. If it is not, keep it off the default primitive.

Code Reviewer bot, sent on behalf of Cam.

},
};

export const KeyboardFocusAndEscapeCrossShadowRoots: Story = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: KeyboardFocusAndEscapeCrossShadowRoots and PopoverDialogSemanticsAndFocusAcrossShadowRoots repeat one play script.

docs/testing.md prefers fewer, longer workflow tests. Keep the extra aria-* / role="dialog" asserts in one play.

Code Reviewer bot, sent on behalf of Cam.

@cameronapak

Copy link
Copy Markdown
Collaborator

thought: If this spike started again with a clean slate, I would not rebuild the shared overlay shadow.

The #350 split is the pain. The trigger lives in one shadow root. The overlay lives in another. Chromium then cannot resolve aria-controls across those roots. Focus then needs a hand-rolled tab trap. That is why assistive technology stayed a follow-up gate.

Shape I would build

One shadow tree per island. Keep the popover in that same tree. Use the top layer to escape clipping.

partner page
  light-DOM host (layout only)
    component shadow
      trigger
      native dialog / popover   ← same tree, top layer
      :host theme tokens        ← SDK owns --spacing
- component shadow  →  shared overlay shadow  →  Radix Portal
+ component shadow  →  top-layer node in the same root

What I would keep

I would keep Shadow DOM. Host unlayered CSS still wins over cascade layers and !important. iframe isolation is worse for focus and layout.

YouVersionProvider would attach isolation. Public BibleVersionPicker would not grow a story-only wrapper.

What I would drop

  • the shared overlay root
  • --yv-spacing (the SDK does not define that token)
  • a global PopoverContent max-height change
  • a custom tab trap
  • eager overlay create on every ShadowRootHost

Tokens

I would own theme tokens on :host. Custom properties inherit into shadows. That is why --spacing leaked. A rename is a guess. Redeclare the Tailwind tokens on :host. That is the real wall.

Guess

Radix portal-to-body is the part to replace, not Radix itself. Native <dialog> or popover can stay in-tree and still escape clip. I have not proven that path in this SDK yet.

This remains a spike checkpoint, not a release path.

Grok Bot Code Reviewer, sent on behalf of Cameron.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants