diff --git a/.changeset/all-humans-leave.md b/.changeset/all-humans-leave.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/all-humans-leave.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/bitter-pigs-type.md b/.changeset/bitter-pigs-type.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/bitter-pigs-type.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/bright-shadows-plan.md b/.changeset/bright-shadows-plan.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/bright-shadows-plan.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/calm-browsers-test.md b/.changeset/calm-browsers-test.md new file mode 100644 index 000000000..c4bef8fff --- /dev/null +++ b/.changeset/calm-browsers-test.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Preserve SDK spacing in Safari inside Shadow DOM and document roots by scoping the host CSS reset and inlining the fixed Tailwind spacing scale. diff --git a/.changeset/calm-firefox-focus.md b/.changeset/calm-firefox-focus.md new file mode 100644 index 000000000..a22574442 --- /dev/null +++ b/.changeset/calm-firefox-focus.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Skip the Bible version picker's version list scroll area in keyboard Tab order, keep focus on the outside target when dismissing from a language screen, and focus the dialog consistently when opening or rapidly reopening the picker. diff --git a/.changeset/calm-tabs-loop.md b/.changeset/calm-tabs-loop.md new file mode 100644 index 000000000..4385f0fb7 --- /dev/null +++ b/.changeset/calm-tabs-loop.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Preserve the Bible chapter picker's natural keyboard order and keep its scrolling book list out of Firefox's sequential focus order. diff --git a/.changeset/clean-shadows-agree.md b/.changeset/clean-shadows-agree.md new file mode 100644 index 000000000..2cbf1f7c0 --- /dev/null +++ b/.changeset/clean-shadows-agree.md @@ -0,0 +1,4 @@ +--- +--- + +Reconcile Shadow DOM release metadata and consumer documentation without adding another package release. diff --git a/.changeset/clear-packages-align.md b/.changeset/clear-packages-align.md new file mode 100644 index 000000000..74c95d091 --- /dev/null +++ b/.changeset/clear-packages-align.md @@ -0,0 +1,8 @@ +--- +'@youversion/platform-core': major +'@youversion/platform-react-hooks': major +--- + +Align Core and React Hooks with the UI package at version 3.0.0 as part of the +fixed package release. This version alignment does not add breaking API changes +to either package. diff --git a/.changeset/clever-wolves-fall.md b/.changeset/clever-wolves-fall.md new file mode 100644 index 000000000..285384aa9 --- /dev/null +++ b/.changeset/clever-wolves-fall.md @@ -0,0 +1,4 @@ +--- +--- + +Record YPE-6214 final Safari release-candidate validation without publishing a package release. diff --git a/.changeset/cool-settings-wait.md b/.changeset/cool-settings-wait.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/cool-settings-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/coordinated-shadow-dom-rollout.md b/.changeset/coordinated-shadow-dom-rollout.md new file mode 100644 index 000000000..dacebae1e --- /dev/null +++ b/.changeset/coordinated-shadow-dom-rollout.md @@ -0,0 +1,60 @@ +--- +'@youversion/platform-react-ui': major +--- + +Ship the coordinated Shadow DOM rollout as a breaking rendered-DOM change. The +compatible public UI boundary now mounts in client-created open shadow roots: +`YouVersionAuthButton`, the chapter and version picker roots, +`BibleReader.Root`, and standalone theme settings, scripture, card, avatar, +footnote, and separator surfaces. SDK-owned compound members and composed +children reuse their owning boundary. + +Server output for these components is a childless light-DOM host whose inline +`display` declares the boundary's intended layout until the passive-effect client +mount. Automatic shadow hosts therefore require strict CSP applications to allow +or hash style attributes as described in the +[UI Content Security Policy guidance](https://github.com/youversion/platform-sdk-react/blob/main/packages/ui/README.md#content-security-policy). +Document queries and global CSS no longer reach component internals, +forwarded element refs become available after that mount, and native events +observed outside a root are retargeted to its host. Keep form +ownership, native labels, and ARIA ID references within one tree; use public +props, callbacks, refs, and role-based automation instead of depending on +internal markup. Fonts remain document-owned, `rem` sizing follows the document +root, `direction` remains inherited, and ancestor layout can still constrain the +host. Shadow-local overlays use the native top layer, and opening a peer popover +dismisses the current peer. The native Popover API is required for isolated +floating content; there is no inline fallback, and an unhandled open error can +unmount the affected consumer React root. Popover content also respects Radix's +available collision height. Validated bubbling `click`, `keydown`, `keyup`, +`pointerdown`, `pointerup`, `mousedown`, `mouseup`, `dblclick`, `contextmenu`, +focus, blur, and native `input` paths reach light-DOM ancestor React handlers +once. The host corrects duplicate React `onInput` delivery; React `onChange` was +already delivered once because the outer event is retargeted to the non-form +host. Capture handlers and unlisted composed bubbling families can still receive +duplicate delivery. YPE-6210 covers `onClickCapture`; focused Chromium probes +observed duplicate move, wheel, keypress, drag-start, and copy delivery. This +list is representative, not exhaustive. + +`Textarea`, standalone `VerseActionPopover`, `YouVersionProvider`, and +`BaseYouVersionProvider` remain outside the automatic boundary for their +document-level form, anchor, context, style, or font contracts. The release does +not add runtime rollout controls or phased-release infrastructure. + +See the +[release-candidate evidence](https://github.com/youversion/platform-sdk-react/blob/main/docs/shadow-dom-isolation-plan.md#ype-5952-release-candidate-validation) +for the automated browser matrix, assistive-technology environments and +per-journey results, and remaining validation gaps. The +[final actual-Safari gate decision](https://github.com/youversion/platform-sdk-react/blob/main/docs/shadow-dom-isolation-plan.md#ype-6214-final-release-candidate-safari-validation) +records the accepted standalone-picker limitations and future rerun requirement. +The focused automated matrix covers 43 Storybook tests in Chromium, Firefox, +and Playwright WebKit; Playwright WebKit is not actual Safari. The completed +real assistive-technology pass is limited to Safari 26.6.2 on macOS 26.6.2 with +VoiceOver 10 and the seven required journeys. Chrome or Edge with NVDA on +Windows was not run because no environment was available. JAWS, mobile screen +readers, other browser and assistive-technology combinations, and WCAG +conformance remain unvalidated. + +Shared popover dialogs now derive an accessible name from their visible heading +unless the consumer provides an explicit label. The reader sign-in and sign-out +menu has an explicit accessible name, and the version picker places initial +focus on its language selector. diff --git a/.changeset/flat-shadows-report.md b/.changeset/flat-shadows-report.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/flat-shadows-report.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/focused-versions-tab.md b/.changeset/focused-versions-tab.md new file mode 100644 index 000000000..b706c2952 --- /dev/null +++ b/.changeset/focused-versions-tab.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep Firefox keyboard focus on Bible version options when tabbing forward through the version picker. diff --git a/.changeset/friendly-verses-scroll.md b/.changeset/friendly-verses-scroll.md new file mode 100644 index 000000000..0629b6cae --- /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 automatic Shadow DOM boundary's document-owned font and root-sizing dependencies without expanding its rollout scope. diff --git a/.changeset/full-flies-worry.md b/.changeset/full-flies-worry.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/full-flies-worry.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fuzzy-books-focus.md b/.changeset/fuzzy-books-focus.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/fuzzy-books-focus.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/iframe-highlight-pointer.md b/.changeset/iframe-highlight-pointer.md new file mode 100644 index 000000000..4bb6c5b49 --- /dev/null +++ b/.changeset/iframe-highlight-pointer.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Classify pending-highlight pointer targets with the reader document's element realm. diff --git a/.changeset/leaf-shadows-wait.md b/.changeset/leaf-shadows-wait.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/leaf-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/little-fans-burn.md b/.changeset/little-fans-burn.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/little-fans-burn.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/prove-production-shadow-overlays.md b/.changeset/prove-production-shadow-overlays.md new file mode 100644 index 000000000..2e0a581bf --- /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/quiet-events-cross.md b/.changeset/quiet-events-cross.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/quiet-events-cross.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/reader-shadows-wait.md b/.changeset/reader-shadows-wait.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/reader-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/safe-phrasing-hosts.md b/.changeset/safe-phrasing-hosts.md new file mode 100644 index 000000000..5b2eb826a --- /dev/null +++ b/.changeset/safe-phrasing-hosts.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep auth-button and Bible-picker Shadow DOM hosts valid in server-rendered phrasing content. diff --git a/.changeset/safe-shadows-fallback.md b/.changeset/safe-shadows-fallback.md new file mode 100644 index 000000000..dee4ef12c --- /dev/null +++ b/.changeset/safe-shadows-fallback.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep isolated UI content rendered with local styles when constructable stylesheet setup fails. diff --git a/.changeset/salty-lizards-dance.md b/.changeset/salty-lizards-dance.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/salty-lizards-dance.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/scripture-shadows-wait.md b/.changeset/scripture-shadows-wait.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/scripture-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/shadow-aware-verse-actions.md b/.changeset/shadow-aware-verse-actions.md new file mode 100644 index 000000000..cd961cbd0 --- /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/six-shrimps-stare.md b/.changeset/six-shrimps-stare.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/six-shrimps-stare.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tall-banks-jump.md b/.changeset/tall-banks-jump.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/tall-banks-jump.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tidy-shadow-events.md b/.changeset/tidy-shadow-events.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/tidy-shadow-events.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tidy-shadows-reset.md b/.changeset/tidy-shadows-reset.md new file mode 100644 index 000000000..6e15fb0e8 --- /dev/null +++ b/.changeset/tidy-shadows-reset.md @@ -0,0 +1,8 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep text direction as the only intentionally inherited CSS property across an +automatic Shadow DOM boundary. Ambient host custom properties cannot alter SDK +spacing or radius values, while document-root font sizing still affects rem-based +dimensions. diff --git a/.changeset/validate-shadow-dialog.md b/.changeset/validate-shadow-dialog.md new file mode 100644 index 000000000..e1e4a17f6 --- /dev/null +++ b/.changeset/validate-shadow-dialog.md @@ -0,0 +1,8 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep shadow-local top-layer dialogs focus-contained and their background inert +while mounted. Restore focus to the original opener after dismissal, including +close/reopen during exit, while ignoring disconnected targets and targets moved +outside their captured root. diff --git a/.github/scripts/major-release-signoff.test.sh b/.github/scripts/major-release-signoff.test.sh index 7da8a4a4d..beba5d85f 100644 --- a/.github/scripts/major-release-signoff.test.sh +++ b/.github/scripts/major-release-signoff.test.sh @@ -30,10 +30,58 @@ extract_step() { } extract_step "Resolve PR context" > "$TMP/context.sh" +extract_step "Restore release tooling from the base branch" > "$TMP/restore-tooling.sh" +extract_step "Compute release preview" > "$TMP/preview.sh" extract_step "Decide whether a signoff is required" > "$TMP/decision.sh" extract_step "Regenerate and verify release contents" > "$TMP/verify.sh" extract_step "Post an unresolved status" > "$TMP/unresolved.sh" +TOOLING_SOURCE="$TMP/tooling-source" +TOOLING_WORK="$TMP/tooling-work" +git init --quiet "$TOOLING_SOURCE" +git -C "$TOOLING_SOURCE" config commit.gpgsign false +git -C "$TOOLING_SOURCE" config user.name test +git -C "$TOOLING_SOURCE" config user.email test@example.com +mkdir -p "$TOOLING_SOURCE/scripts" "$TOOLING_SOURCE/.changeset" \ + "$TOOLING_SOURCE/packages/ui" "$TOOLING_SOURCE/examples/demo" "$TOOLING_SOURCE/tools/config" +for file in scripts/preview-release.mjs package.json pnpm-lock.yaml \ + pnpm-workspace.yaml .changeset/config.json .npmrc packages/ui/package.json \ + examples/demo/package.json tools/config/package.json; do + printf 'main %s\n' "$file" > "$TOOLING_SOURCE/$file" +done +git -C "$TOOLING_SOURCE" add -A +git -C "$TOOLING_SOURCE" commit --quiet -m main +git -C "$TOOLING_SOURCE" branch -M main +git -C "$TOOLING_SOURCE" switch --quiet -c journey +mkdir -p "$TOOLING_SOURCE/packages/pr-only" +printf 'journey-only importer\n' > "$TOOLING_SOURCE/packages/pr-only/package.json" +for file in scripts/preview-release.mjs package.json pnpm-lock.yaml \ + pnpm-workspace.yaml .changeset/config.json packages/ui/package.json; do + printf 'journey %s\n' "$file" > "$TOOLING_SOURCE/$file" +done +rm "$TOOLING_SOURCE/.npmrc" +printf 'journey pnpm hook\n' > "$TOOLING_SOURCE/.pnpmfile.cjs" +git -C "$TOOLING_SOURCE" add -A +git -C "$TOOLING_SOURCE" commit --quiet -m journey +TOOLING_HEAD=$(git -C "$TOOLING_SOURCE" rev-parse HEAD) +git -C "$TOOLING_SOURCE" switch --quiet main +git clone --quiet "$TOOLING_SOURCE" "$TOOLING_WORK" +git -C "$TOOLING_WORK" checkout --quiet "$TOOLING_HEAD" + +if (cd "$TOOLING_WORK" && bash "$TMP/restore-tooling.sh") >/dev/null 2>&1 && + [ "$(git -C "$TOOLING_WORK" rev-parse HEAD)" = "$TOOLING_HEAD" ] && + [ "$(cat "$TOOLING_WORK/package.json")" = 'main package.json' ] && + [ "$(cat "$TOOLING_WORK/pnpm-lock.yaml")" = 'main pnpm-lock.yaml' ] && + [ "$(cat "$TOOLING_WORK/packages/ui/package.json")" = 'main packages/ui/package.json' ] && + [ "$(cat "$TOOLING_WORK/.npmrc")" = 'main .npmrc' ] && + [ ! -e "$TOOLING_WORK/packages/pr-only/package.json" ] && + [ ! -e "$TOOLING_WORK/.pnpmfile.cjs" ]; then + pass "restores the complete release dependency graph from trusted main" +else + fail "restores the complete release dependency graph from trusted main" \ + "expected main-owned workspace manifests and install inputs with PR-only importers removed" +fi + mkdir "$TMP/bin" # The context step backs off between retries; real delays would add ~9s per error case. cat > "$TMP/bin/sleep" <<'EOF' @@ -376,7 +424,7 @@ run_decision_case "failed ordinary previews remain blocked, not major" \ 'blocked=release preview did not succeed (failure)' false false skipped '' failure '' extract_step "Restore release tooling from the base branch" > "$TMP/restore.sh" -PREVIEW_REPO="$TMP/preview-repo" +PREVIEW_REPO="$TMP/restore-preview-repo" git init --quiet --initial-branch=main "$PREVIEW_REPO" git -C "$PREVIEW_REPO" config commit.gpgsign false git -C "$PREVIEW_REPO" config user.name test @@ -440,6 +488,107 @@ else fail "generated release identity always stays out of the ordinary preview" "preview does not use generated release identity" fi +PREVIEW_REMOTE="$TMP/preview-remote.git" +PREVIEW_REPO="$TMP/preview-repo" +git init --quiet --bare "$PREVIEW_REMOTE" +git init --quiet "$PREVIEW_REPO" +git -C "$PREVIEW_REPO" config commit.gpgsign false +git -C "$PREVIEW_REPO" config user.name test +git -C "$PREVIEW_REPO" config user.email test@example.com +git -C "$PREVIEW_REPO" remote add origin "$PREVIEW_REMOTE" +mkdir -p "$PREVIEW_REPO/scripts" "$PREVIEW_REPO/.changeset" \ + "$PREVIEW_REPO/packages/core" "$PREVIEW_REPO/packages/hooks" "$PREVIEW_REPO/packages/ui" +cp "$ROOT/scripts/preview-release.mjs" "$PREVIEW_REPO/scripts/preview-release.mjs" +cp "$ROOT/scripts/signoff-token.mjs" "$PREVIEW_REPO/scripts/signoff-token.mjs" +cp "$ROOT/.changeset/config.json" "$PREVIEW_REPO/.changeset/config.json" +cat > "$PREVIEW_REPO/package.json" <<'EOF' +{"name":"preview-fixture","private":true,"packageManager":"pnpm@11.10.0"} +EOF +cat > "$PREVIEW_REPO/pnpm-workspace.yaml" <<'EOF' +packages: + - "packages/*" +EOF +for package in core hooks ui; do + case "$package" in + core) name='@youversion/platform-core' ;; + hooks) name='@youversion/platform-react-hooks' ;; + ui) name='@youversion/platform-react-ui' ;; + esac + printf '{"name":"%s","version":"1.0.0"}\n' "$name" > "$PREVIEW_REPO/packages/$package/package.json" +done +git -C "$PREVIEW_REPO" add -A +git -C "$PREVIEW_REPO" commit --quiet -m main +git -C "$PREVIEW_REPO" branch -M main +git -C "$PREVIEW_REPO" push --quiet origin main + +git -C "$PREVIEW_REPO" switch --quiet -c target +cat > "$PREVIEW_REPO/.changeset/target-major.md" <<'EOF' +--- +"@youversion/platform-core": major +--- + +Major change inherited from the target branch. +EOF +git -C "$PREVIEW_REPO" add -A +git -C "$PREVIEW_REPO" commit --quiet -m 'target major' +git -C "$PREVIEW_REPO" push --quiet origin target +PREVIEW_BASE_SHA=$(git -C "$PREVIEW_REPO" rev-parse HEAD) + +git -C "$PREVIEW_REPO" switch --quiet -c pr +printf 'non-major PR change\n' > "$PREVIEW_REPO/README.md" +git -C "$PREVIEW_REPO" add README.md +git -C "$PREVIEW_REPO" commit --quiet -m 'non-major PR change' +git -C "$PREVIEW_REPO" push --quiet origin pr +ln -s "$ROOT/node_modules" "$PREVIEW_REPO/node_modules" +PREVIEW_BIN="$TMP/preview-bin" +mkdir "$PREVIEW_BIN" +cat > "$PREVIEW_BIN/pnpm" < "\$output" +EOF +chmod +x "$PREVIEW_BIN/pnpm" + +run_preview_case() { + local name="$1" expected_introduced="$2" expected_output="$3" + local output="$TMP/preview-output" + local error="$TMP/preview-error" + local preview="$PREVIEW_REPO/preview.json" + local head_sha + head_sha=$(git -C "$PREVIEW_REPO" rev-parse HEAD) + : > "$output" + rm -f "$preview" + if (cd "$PREVIEW_REPO" && \ + PATH="$PREVIEW_BIN:$PATH" BASE_SHA="$PREVIEW_BASE_SHA" HEAD_SHA="$head_sha" \ + GITHUB_OUTPUT="$output" \ + bash "$TMP/preview.sh") >/dev/null 2> "$error" && + jq -e ".introduced_major == $expected_introduced" "$preview" >/dev/null && + grep -Fxq "is_major=$expected_output" "$output"; then + pass "$name" + else + fail "$name" \ + "expected introduced_major=$expected_introduced and is_major=$expected_output; preview: $(cat "$preview" 2>/dev/null || true); output: $(tr '\n' ' ' < "$output"); error: $(tr '\n' ' ' < "$error")" + fi +} + +run_preview_case "stacked PRs ignore a major inherited from their target branch" false 0 + +cat > "$PREVIEW_REPO/.changeset/pr-major.md" <<'EOF' +--- +"@youversion/platform-react-ui": major +--- + +Major change introduced by the pull request. +EOF +git -C "$PREVIEW_REPO" add .changeset/pr-major.md +git -C "$PREVIEW_REPO" commit --quiet -m 'PR major' +git -C "$PREVIEW_REPO" push --quiet origin pr +run_preview_case "stacked PRs require signoff for a newly introduced major" true 1 + if grep -Fq 'Generated release PR; major signoff is enforced on source PRs.' "$WORKFLOW"; then pass "generated releases publish an explicit lifecycle-aware success" else @@ -454,6 +603,17 @@ else pass "an unresolved context fails the status on every event" fi +if awk ' + /^ pull_request_target:/ { found = 1; next } + found && /^ [[:alnum:]_]+:/ { exit } + found { print } +' "$WORKFLOW" | grep -Fq 'types: [edited]'; then + pass "base retargets reevaluate signoff even when the PR conflicts" +else + fail "base retargets reevaluate signoff even when the PR conflicts" \ + "pull_request_target edited is required because pull_request does not run for conflicted PRs" +fi + # --- signoff is pinned to the release, not the commit (YPE-6068) --------------- if grep -q 'TOKEN_RE="(\^|\[\^0-9A-Za-z\])\${SIGNOFF_TOKEN}' "$WORKFLOW" \ diff --git a/.github/workflows/major-release-signoff.yml b/.github/workflows/major-release-signoff.yml index 525818570..ec50fcb22 100644 --- a/.github/workflows/major-release-signoff.yml +++ b/.github/workflows/major-release-signoff.yml @@ -3,6 +3,11 @@ name: Major Release Signoff on: pull_request: types: [opened, synchronize, reopened] + # Unlike pull_request, this still runs when a base retarget leaves the PR in + # conflict. The workflow resolves the current immutable endpoints through the + # API; PR-authored code remains isolated from every write-capable job. + pull_request_target: + types: [edited] issue_comment: # `deleted` ensures the gate re-runs when a signoff comment is removed, # so a previously-cached `success` status doesn't outlive its evidence. @@ -270,9 +275,9 @@ jobs: - name: Restore release tooling from the base branch # The PR supplies changeset *data* only. The detector that reads it, the dependency - # set it runs under, and the changesets config all come from `main`, so a branch - # cannot rewrite the check that gates it. This runs before pnpm is set up, since - # `pnpm/action-setup` reads `packageManager` out of package.json. + # graph it runs under, and the changesets config all come from trusted `main`, so a + # branch cannot rewrite the check that gates it. This runs before pnpm is set up, + # since `pnpm/action-setup` reads `packageManager` out of package.json. run: | set -euo pipefail git fetch --no-tags --force origin main:refs/heads/main @@ -280,6 +285,14 @@ jobs: # under, and the changesets config have to agree, so restoring some from main and # taking the rest from the PR gives a detector without its own dependencies. if git cat-file -e main:scripts/preview-release.mjs 2>/dev/null; then + # Frozen workspace installs validate every importer. Remove PR-only importers, + # then restore every manifest selected by main's workspace configuration so its + # lockfile and dependency graph remain internally consistent. + git ls-files | + grep -E '^(packages|examples|tools)/[^/]+/package[.]json$' | + while IFS= read -r f; do + git cat-file -e "main:$f" 2>/dev/null || rm -f "$f" + done for f in scripts/preview-release.mjs scripts/signoff-token.mjs package.json pnpm-lock.yaml \ pnpm-workspace.yaml .changeset/config.json; do git cat-file -e "main:$f" 2>/dev/null && git checkout main -- "$f" @@ -319,6 +332,7 @@ jobs: - name: Compute release preview id: preview env: + BASE_SHA: ${{ needs.context.outputs.base_sha }} HEAD_SHA: ${{ needs.context.outputs.head_sha }} run: | set -euo pipefail @@ -326,10 +340,11 @@ jobs: # "Failed to find where HEAD diverged from main" if it is missing. A PR checkout # has only origin/main, so create the local branch too. git fetch --no-tags --force origin main:refs/heads/main - # Merge-base, not the PR's base sha: on a synchronize event the base - # branch may have moved, and diffing against its tip would attribute - # someone else's changesets to this PR. - BASE=$(git merge-base main "$HEAD_SHA") + git fetch --no-tags --force origin "$BASE_SHA" + # Compare from the common ancestor of the immutable PR endpoints. This excludes + # major changesets inherited from a stacked PR's base without attributing later + # movement on that base branch to the PR being evaluated. + BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") node scripts/preview-release.mjs --base "$BASE" --head "$HEAD_SHA" > preview.json cat preview.json # `introduced_major`, not `is_major`: a major already pending on main diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 56e947c3a..3f1b5a712 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -39,3 +39,37 @@ jobs: YVP_APP_KEY: ${{ secrets.YVP_APP_KEY }} STORYBOOK_YOUVERSION_API_HOST: ${{ secrets.YVP_API_HOST }} STORYBOOK_YOUVERSION_APP_KEY: ${{ secrets.STORYBOOK_YOUVERSION_APP_KEY }} + + shadow-browser-tests: + name: Shadow DOM (${{ matrix.browser }}) + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.56.1-noble + strategy: + fail-fast: false + matrix: + browser: [firefox, webkit] + env: + VITEST_BROWSER: ${{ matrix.browser }} + STORYBOOK_YOUVERSION_APP_KEY: ${{ secrets.STORYBOOK_YOUVERSION_APP_KEY }} + STORYBOOK_AUTH_REDIRECT_URL: ${{ secrets.STORYBOOK_AUTH_REDIRECT_URL }} + steps: + - uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Run Shadow DOM browser tests + run: cd packages/ui && pnpm run test:shadow-browser + env: + HOME: /root diff --git a/.size-limit.js b/.size-limit.js index 02cf64291..e96960d9a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -51,7 +51,7 @@ export default [ name: 'ui / Separator only', path: 'packages/ui/dist/index.js', import: '{ Separator }', - limit: '19 KB', + limit: '24 KB', ignore: ['react', 'react-dom', 'react/jsx-runtime', '@tanstack/react-query'], }, { diff --git a/README.md b/README.md index 409f690a4..e660f70d5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ function App() { } ``` +> [!IMPORTANT] +> The UI package's coordinated major release moves its compatible public +> component boundary into client-created open shadow roots. This is a breaking +> rendered-DOM, styling, automation, and integration change even when React props +> are unchanged. Review the UI package's +> [Shadow DOM migration guidance](./packages/ui/README.md#shadow-dom-migration-guidance) +> before upgrading. + ### Custom Hooks ```tsx diff --git a/docs/adr/0010-automatic-shadow-dom-style-isolation.md b/docs/adr/0010-automatic-shadow-dom-style-isolation.md new file mode 100644 index 000000000..19e8e7277 --- /dev/null +++ b/docs/adr/0010-automatic-shadow-dom-style-isolation.md @@ -0,0 +1,190 @@ +# 10. Automatic Shadow DOM style isolation + +## Status + +Accepted + +Compatible public UI components use the automatic boundary defined by the +[production rollout policy](../shadow-dom-rollout-policy.md). The coordinated +major release treats that boundary as one breaking rendered-DOM change rather +than publishing partial component groups. + +The automatic boundary belongs to the SDK-owned top-level component instance. +Compound members and SDK components composed inside another isolated SDK +component reuse their owning boundary instead of creating accidental nested +roots. Excluded exports and their reasons are defined in the rollout policy. + +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 +decision therefore uses Shadow DOM as the browser-enforced style boundary. + +## Decision + +Each compatible top-level component named by the rollout policy automatically +creates an open shadow root and renders its existing implementation into it +through a React portal. Consumers continue to use the same component APIs; 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 boundary 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 +automatic boundary; 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`. The completed YPE-5400 inventory, +exact runtime-input exceptions, and compiled-CSS prevention guard are documented +in the rollout plan. + +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 ` + +

Consumer child

+ + +
+ + ), + play: async ({ canvasElement }) => { + const root = await waitForShadowRoot(canvasElement); + const renderer = await waitFor( + () => { + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); + if (!element) throw new Error('reader scripture not mounted'); + return element; + }, + { timeout: 10000 }, + ); + const { body, consumerChild, scroller, toolbar } = await waitFor(() => { + const body = root.querySelector( + '[data-yv-shadow-content-wrapper] > [data-yv-sdk]', + ); + const consumerChild = root.querySelector( + '[data-testid="consumer-reader-child"]', + ); + const scroller = root.querySelector('main'); + const toolbar = root.querySelector('section'); + if (!body || !consumerChild || !scroller || !toolbar) { + throw new Error('reader layout evidence not rendered'); + } + return { body, consumerChild, scroller, toolbar }; + }); + + await expect(canvasElement.querySelector('[data-testid="consumer-reader-child"]')).toBeNull(); + await expect(body).toHaveAttribute('data-yv-theme', 'dark'); + await expect(body).toHaveAttribute('dir', 'rtl'); + await expect(renderer).toHaveAttribute('dir', 'ltr'); + await expect(getComputedStyle(renderer).fontFamily).toContain('Inter'); + await waitFor(async () => { + await expect(getComputedStyle(renderer).fontSize).toBe('18px'); + }); + await expect(getComputedStyle(scroller).overflowY).toBe('auto'); + await expect(getComputedStyle(consumerChild).color).not.toBe('rgb(255, 0, 0)'); + await expect(consumerChild.getBoundingClientRect().width).toBeLessThanOrEqual(390); + await expect(toolbar.getBoundingClientRect().width).toBeLessThanOrEqual(390); + }, +}; + +async function openReaderHighlightAuthDialog(canvasElement: HTMLElement) { + const root = await waitForShadowRoot(canvasElement); + const verse = await waitFor(() => { + const element = root.querySelector('.yv-v[v="1"]'); + if (!element) throw new Error('highlight-auth verse not rendered'); + return element; + }); + await userEvent.click(verse); + const overlay = await waitFor(() => { + const element = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!element) throw new Error('reader auth overlay not mounted'); + return element; + }); + const overlays = within(overlay); + const verseActions = await overlays.findByRole('dialog', { name: 'Verse actions' }); + const colors = await overlays.findByRole('group', { name: 'Highlight colors' }); + const apply = Array.from(colors.querySelectorAll('button')).find((button) => + button.getAttribute('aria-label')?.includes('Apply'), + ); + if (!apply) throw new Error('highlight apply control not rendered'); + await waitFor(() => expect(root.activeElement).toBe(verseActions)); + await userEvent.click(apply); + // Exercise the full close/reopen path instead of reusing the exiting popover DOM. + await waitFor(() => expect(apply.isConnected).toBe(false)); + return { overlays, root }; +} + +export const SignedOutHighlightAuthOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + render: (args) => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const { overlays, root } = await openReaderHighlightAuthDialog(canvasElement); + const yes = await overlays.findByRole('button', { name: 'Yes Please' }); + const dialog = yes.closest('[role="dialog"]'); + if (!dialog) throw new Error('sign-in dialog not rendered'); + await expect(dialog.getRootNode()).toBe(root); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Yes Please' })).toBeNull()); + // The shared local overlay stays mounted because the verse-actions dialog + // remains open underneath the dismissed auth dialog. + const verseActions = overlays.getByRole('dialog', { name: 'Verse actions' }); + await expect(verseActions).toBeInTheDocument(); + await waitFor(() => expect(verseActions.contains(root.activeElement)).toBe(true)); + }, +}; + +function SignedInPermissionReader(args: BibleReaderRootProps) { + const [userInfo] = useState( + () => + new YouVersionUserInfo({ + id: 'mock-user-id', + name: 'Test User', + email: 'test@example.com', + }), + ); + + return ( + +
+ + + + +
+
+ ); +} + +export const SignedInReaderOverlayOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + parameters: { + msw: { + handlers: [ + http.get('*/v1/highlights', () => HttpResponse.json({ data: [], next_page_token: null })), + ...globalHandlers, + ], + }, + }, + render: (args) => , + play: async ({ canvasElement }) => { + await setupAuthenticatedUser(); + YouVersionPlatformConfiguration.clearGrantedPermissions(); + const { root: readerRoot, reader } = await getReaderStory(canvasElement); + const avatarTrigger = await reader.findByTestId('user-menu-trigger'); + const avatar = avatarTrigger.querySelector('[data-slot="avatar"]'); + await expect(avatar).toBeInTheDocument(); + await expect(avatar?.getRootNode()).toBe(readerRoot); + await expect(readerRoot.querySelector('[data-yv-shadow-host]')).toBeNull(); + + await userEvent.click(avatarTrigger); + const readerOverlays = await getReaderOverlay(readerRoot); + await expect( + await readerOverlays.findByRole('dialog', { name: 'Sign out' }), + ).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + await waitFor(() => + expect(readerOverlays.queryByRole('dialog', { name: 'Sign out' })).toBeNull(), + ); + await expect(readerRoot.activeElement).toBe(avatarTrigger); + + const { root: versionRoot, trigger: versionTrigger } = await getPickerQueries( + canvasElement, + 'Change Bible version', + ); + await userEvent.click(versionTrigger); + const versionCanvas = await getPickerOverlay(versionRoot); + const versionDialog = await versionCanvas.findByRole('dialog'); + await waitFor(async () => { + await expect(versionRoot.activeElement).toBe(versionDialog); + }); + await userEvent.click(versionCanvas.getByRole('button', { name: 'Close' })); + await waitFor(async () => { + await expect(versionCanvas.queryByRole('dialog')).toBeNull(); + await expect(versionRoot.activeElement).toBe(versionTrigger); + }); + + const { overlays, root } = await openReaderHighlightAuthDialog(canvasElement); + const continueButton = await overlays.findByRole('button', { name: 'Continue' }); + const dialog = continueButton.closest('[role="dialog"]'); + if (!dialog) throw new Error('permission dialog not rendered'); + await expect(dialog.getRootNode()).toBe(root); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Continue' })).toBeNull()); + // The reader-owned overlay container still serves the open verse-actions dialog. + const verseActions = overlays.getByRole('dialog', { name: 'Verse actions' }); + await expect(verseActions).toBeInTheDocument(); + await waitFor(() => expect(verseActions.contains(root.activeElement)).toBe(true)); + }, +}; + const rtlPassage = { id: 'JHN.1', content: @@ -214,13 +617,14 @@ export const RtlScriptureWithLtrChrome: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor(async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer).toHaveAttribute('dir', 'rtl'); await expect(renderer?.textContent).toContain('في البدء'); }); - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!; + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]')!; const poetry = renderer.querySelector('.q1')!; const table = renderer.querySelector('table'); const rendererStyle = getComputedStyle(renderer); @@ -229,24 +633,25 @@ export const RtlScriptureWithLtrChrome: Story = { await expect(Number.parseFloat(poetryStyle.paddingRight)).toBeGreaterThan(0); await expect(poetryStyle.paddingLeft).toBe('0px'); await expect(table).toBeInTheDocument(); - const heading = canvasElement.querySelector('h1')!; + const heading = root.querySelector('h1')!; await expect(heading).toHaveAttribute('dir', 'rtl'); await waitFor(async () => { await expect(heading.querySelectorAll('bdi[dir="auto"]')).toHaveLength(2); }); - await userEvent.click(screen.getByRole('button', { name: /footnote/i })); + await userEvent.click(reader.getByRole('button', { name: /footnote/i })); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const dialog = await screen.findByRole('dialog'); + const dialog = await overlays.findByRole('dialog'); await expect(dialog).toHaveAttribute('dir', 'ltr'); await expect(dialog.querySelector('[data-yv-sdk]')).toHaveAttribute('dir', 'rtl'); - await expect(await screen.findByText('حاشية عربية.')).toBeInTheDocument(); + await expect(await overlays.findByText('حاشية عربية.')).toBeInTheDocument(); }); - await userEvent.click(screen.getByRole('button', { name: /close/i })); + await userEvent.click(overlays.getByRole('button', { name: /close/i })); - await userEvent.click(screen.getByRole('button', { name: /settings/i })); + await userEvent.click(reader.getByRole('button', { name: /settings/i })); await waitFor(async () => { - const settings = await screen.findByText('Reader Settings'); + const settings = await overlays.findByText('Reader Settings'); await expect(settings.closest('[role="dialog"]')).toHaveAttribute('dir', 'ltr'); }); }, @@ -281,9 +686,10 @@ export const ArabicInterfaceAndScripture: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor(async () => { - await expect(canvasElement.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); - await expect(canvasElement.querySelector('[data-slot="yv-bible-renderer"]')).toHaveAttribute( + await expect(root.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); + await expect(root.querySelector('[data-slot="yv-bible-renderer"]')).toHaveAttribute( 'dir', 'rtl', ); @@ -323,9 +729,10 @@ export const ArabicInterfaceWithEnglishScripture: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); const renderer = await waitFor(async () => { - await expect(canvasElement.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); - const element = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(root.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(element).toHaveAttribute('dir', 'ltr'); return element!; }); @@ -365,17 +772,22 @@ export const ForcedRtlChromeGeometry: Story = { ), play: async ({ canvasElement }) => { - const previous = await screen.findByRole('button', { name: 'Previous chapter' }); - const chapter = screen.getByRole('button', { name: 'Change Bible book and chapter' }); - const next = screen.getByRole('button', { name: 'Next chapter' }); + const { root, reader } = await getReaderStory(canvasElement); + const { + root: chapterRoot, + picker: chapterPicker, + trigger: chapter, + } = await getPickerQueries(canvasElement, 'Change Bible book and chapter'); + const previous = chapterPicker.getByRole('button', { name: 'Previous chapter' }); + const next = chapterPicker.getByRole('button', { name: 'Next chapter' }); const renderer = await waitFor(async () => { - const element = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(element).toBeInTheDocument(); return element; }); await expect(renderer).toHaveAttribute('dir', 'ltr'); - await expect(canvasElement.querySelector('h1')).toHaveAttribute('dir', 'ltr'); + await expect(root.querySelector('h1')).toHaveAttribute('dir', 'ltr'); await expect(previous.getBoundingClientRect().left).toBeGreaterThan( chapter.getBoundingClientRect().left, ); @@ -387,8 +799,8 @@ export const ForcedRtlChromeGeometry: Story = { await expect(chapter).toBeEnabled(); }); await userEvent.click(chapter); - const chapterDialog = await screen.findByRole('dialog'); - const chapterCanvas = within(chapterDialog); + const chapterCanvas = await getPickerOverlay(chapterRoot); + const chapterDialog = chapterCanvas.getByRole('dialog'); const intro = chapterCanvas.getByTestId('intro-chapter-button'); const chapterOne = chapterCanvas.getByRole('button', { name: '1' }); const chapterTwo = chapterCanvas.getByRole('button', { name: '2' }); @@ -409,9 +821,15 @@ export const ForcedRtlChromeGeometry: Story = { ); await userEvent.click(chapterCanvas.getByRole('button', { name: 'Close' })); - await userEvent.click(screen.getByRole('button', { name: 'Change Bible version' })); - const versionDialog = await screen.findByRole('dialog'); - const versionCanvas = within(versionDialog); + await waitFor(async () => { + await expect(chapterCanvas.queryByRole('dialog')).toBeNull(); + }); + const { root: versionRoot, trigger: versionTrigger } = await getPickerQueries( + canvasElement, + 'Change Bible version', + ); + await userEvent.click(versionTrigger); + const versionCanvas = await getPickerOverlay(versionRoot); const firstVersion = versionCanvas.getAllByRole('listitem')[0]!; const tile = firstVersion.querySelector('[data-slot="item-media"]')!; const label = firstVersion.querySelector('[data-slot="item-content"]')!; @@ -424,8 +842,8 @@ export const ForcedRtlChromeGeometry: Story = { ).toHaveAttribute('dir', 'auto'); await userEvent.click(versionCanvas.getByRole('button', { name: 'Close' })); - await userEvent.click(screen.getByRole('button', { name: 'Settings' })); - const settingsDialog = await screen.findByRole('dialog'); + await userEvent.click(reader.getByRole('button', { name: 'Settings' })); + const settingsDialog = await (await getReaderOverlay(root)).findByRole('dialog'); const settingsCanvas = within(settingsDialog); const decrease = settingsCanvas.getByTestId('decrease-font-size'); const increase = settingsCanvas.getByTestId('increase-font-size'); @@ -576,9 +994,10 @@ export const CustomStyling: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, @@ -606,9 +1025,10 @@ export const FontSizeOutOfRange: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, @@ -653,15 +1073,16 @@ export const FootnotesPersistAfterFontSizeChange: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const getFootnoteButtons = () => canvasElement.querySelectorAll('[data-verse-footnote] button'); + const getFootnoteButtons = () => root.querySelectorAll('[data-verse-footnote] button'); await waitFor( async () => { @@ -673,14 +1094,15 @@ export const FootnotesPersistAfterFontSizeChange: Story = { const initialFootnoteCount = getFootnoteButtons().length; - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const increaseFontButton = screen.getByTestId('increase-font-size'); + const increaseFontButton = settings.getByTestId('increase-font-size'); await userEvent.click(increaseFontButton); await waitFor(async () => { @@ -688,7 +1110,7 @@ export const FootnotesPersistAfterFontSizeChange: Story = { await expect(footnoteButtons.length).toBe(initialFootnoteCount); }); - const decreaseFontButton = screen.getByTestId('decrease-font-size'); + const decreaseFontButton = settings.getByTestId('decrease-font-size'); await userEvent.click(decreaseFontButton); await userEvent.click(decreaseFontButton); @@ -719,32 +1141,34 @@ export const ThemeOverridesProvider: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const readerTheme = canvasElement.querySelector('[data-yv-theme="light"]'); + const readerTheme = root.querySelector('[data-yv-theme="light"]'); await expect(readerTheme).toBeInTheDocument(); await waitFor( async () => { - const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + const footnoteButton = root.querySelector('[data-verse-footnote] button'); await expect(footnoteButton).toBeInTheDocument(); }, { timeout: 5000 }, ); - const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + const footnoteButton = root.querySelector('[data-verse-footnote] button'); await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); await userEvent.click(footnoteButton!); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const popover = document.querySelector('[data-slot="popover-content"]'); + const popover = overlays.queryByRole('dialog'); await expect(popover).toBeInTheDocument(); await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); }); @@ -766,26 +1190,28 @@ export const SignInFlow: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger.querySelector('img')).not.toBeInTheDocument(); await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signInButton = await screen.findByRole('button', { name: /sign in/i }); + const signInButton = await overlays.findByRole('button', { name: /sign in/i }); await expect(signInButton).toBeInTheDocument(); }); - const signInButton = screen.getByRole('button', { name: /sign in/i }); + const signInButton = overlays.getByRole('button', { name: /sign in/i }); await userEvent.click(signInButton); await expect(signInMock).toHaveBeenCalled(); @@ -813,16 +1239,17 @@ export const SignOutFlow: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await waitFor(async () => { const avatar = userMenuTrigger.querySelector('[data-slot="avatar"]'); @@ -831,16 +1258,17 @@ export const SignOutFlow: Story = { await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signOutButton = await screen.findByRole('button', { name: /sign out/i }); + const signOutButton = await overlays.findByRole('button', { name: /sign out/i }); await expect(signOutButton).toBeInTheDocument(); }); - const signOutButton = screen.getByRole('button', { name: /sign out/i }); + const signOutButton = overlays.getByRole('button', { name: /sign out/i }); await userEvent.click(signOutButton); await waitFor(async () => { - const userMenuTriggerAfterSignOut = screen.getByTestId('user-menu-trigger'); + const userMenuTriggerAfterSignOut = reader.getByTestId('user-menu-trigger'); await expect(userMenuTriggerAfterSignOut.querySelector('img')).not.toBeInTheDocument(); }); }, @@ -867,16 +1295,17 @@ export const AuthenticatedWithAvatar: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); // Radix only renders the once it loads successfully. await waitFor(async () => { @@ -911,18 +1340,17 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); // Verify the saved settings were applied via CSS variables - const verseContainer = canvasElement.querySelector( - '[data-slot="yv-bible-renderer"]', - )!; + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]')!; await expect(verseContainer.style.getPropertyValue('--yv-reader-font-size')).toBe('18px'); // The legacy Source Serif value seeded above is migrated forward on hydrate. await expect(verseContainer.style.getPropertyValue('--yv-reader-font-family')).toBe( @@ -933,17 +1361,18 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { ); // Open settings and verify the correct font family button is active - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const untitledSerifButton = screen.getByRole('button', { name: /untitled/i }); + const untitledSerifButton = settings.getByRole('button', { name: /untitled/i }); await expect(untitledSerifButton).toHaveClass('yv:bg-primary'); - const interButton = screen.getByRole('button', { name: /inter/i }); + const interButton = settings.getByRole('button', { name: /inter/i }); await expect(interButton).not.toHaveClass('yv:bg-primary'); }, }; @@ -967,16 +1396,17 @@ export const AuthenticatedWithoutAvatar: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); // No image URL → initials fallback ("Test User" → "TU") inside the avatar circle. await expect(userMenuTrigger.querySelector('img')).not.toBeInTheDocument(); await waitFor(async () => { @@ -987,8 +1417,9 @@ export const AuthenticatedWithoutAvatar: Story = { await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signOutButton = await screen.findByRole('button', { name: /sign out/i }); + const signOutButton = await overlays.findByRole('button', { name: /sign out/i }); await expect(signOutButton).toBeInTheDocument(); }); }, @@ -1017,32 +1448,36 @@ export const WithoutAuth: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); // Wait for the Bible content to load await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); // Verify Bible content is displayed - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); // User menu should not be visible when auth is disabled - const userMenuTrigger = canvasElement.querySelector('[data-testid="user-menu-trigger"]'); + const userMenuTrigger = root.querySelector('[data-testid="user-menu-trigger"]'); await expect(userMenuTrigger).not.toBeInTheDocument(); // Chapter picker and version picker should still work - const chapterButton = screen.getByRole('button', { name: /change bible book and chapter/i }); + const { trigger: chapterButton } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); await expect(chapterButton).toBeInTheDocument(); - const versionButton = await screen.findByRole('button', { name: /bible version/i }); + const { trigger: versionButton } = await getPickerQueries(canvasElement, /bible version/i); await expect(versionButton).toBeInTheDocument(); // Settings should still work - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await expect(settingsButton).toBeInTheDocument(); }, }; @@ -1083,9 +1518,9 @@ export const VersionButtonLoadingStates: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { // Wait for the toolbar to mount, then capture the button in loading state - const versionButton = await screen.findByRole('button', { name: /bible version/i }); + const { trigger: versionButton } = await getPickerQueries(canvasElement, /bible version/i); // The delayed MSW handler guarantees the loading state is visible const spinner = versionButton.querySelector('[role="status"]'); @@ -1131,17 +1566,18 @@ export const JoshuaIntroChapter: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); // Wait for the intro content to fully load (not just the renderer element) await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); await expect(verseContainer?.textContent).toContain('Joshua'); }, { timeout: 5000 }, ); - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!; + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]')!; // The unavailable message should not appear const hasUnavailableText = verseContainer.textContent?.includes('not available'); @@ -1171,11 +1607,12 @@ export const JoshuaIntroChapter: Story = { await expect(verseContainer.textContent).toContain('Yahweh'); // Toolbar trigger should show "Intro" (title-case label), NOT "INTRO" (raw chapter ID) + const { trigger: chapterButton } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); await waitFor( async () => { - const chapterButton = screen.getByRole('button', { - name: /change bible book and chapter/i, - }); await expect(chapterButton.textContent).toContain('Intro'); await expect(chapterButton.textContent).not.toContain('INTRO'); }, @@ -1189,8 +1626,9 @@ export const JoshuaIntroChapter: Story = { // Click the footnote and verify the popover shows note content without verse reference await userEvent.click(footnoteButton!); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const popover = document.querySelector('[role="dialog"]'); + const popover = overlays.queryByRole('dialog'); await expect(popover).toBeInTheDocument(); // Should show note content (e.g., "See Rashi") @@ -1350,27 +1788,32 @@ export const ChapterChangeLoadingOverlay: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.1'); }, { timeout: 5000 }, ); - const nextButton = screen.getByRole('button', { name: /next chapter/i }); + const { picker: chapterPicker } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); + const nextButton = chapterPicker.getByRole('button', { name: /next chapter/i }); await userEvent.click(nextButton); - const rendererAfterClick = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const rendererAfterClick = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(rendererAfterClick?.textContent).toContain('JHN.1'); await waitFor( async () => { - const overlay = canvasElement.querySelector('[aria-label="Loading passage"]'); + const overlay = root.querySelector('[aria-label="Loading passage"]'); await expect(overlay).toBeInTheDocument(); await expect(overlay).toHaveAttribute('role', 'status'); - await expect(canvasElement.querySelector('[class*="opacity-40"]')).toBeInTheDocument(); - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(root.querySelector('[class*="opacity-40"]')).toBeInTheDocument(); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.1'); }, { timeout: 2000 }, @@ -1378,17 +1821,15 @@ export const ChapterChangeLoadingOverlay: Story = { await waitFor( async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.2'); - await expect( - canvasElement.querySelector('[aria-label="Loading passage"]'), - ).not.toBeInTheDocument(); - await expect(canvasElement.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument(); + await expect(root.querySelector('[aria-label="Loading passage"]')).not.toBeInTheDocument(); + await expect(root.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument(); }, { timeout: 5000 }, ); - const scroller = canvasElement.querySelector('main'); + const scroller = root.querySelector('main'); await expect(scroller?.scrollTop).toBe(0); }, }; diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index 5cd20b541..73d945ae8 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -3,13 +3,16 @@ */ // We stub ResizeObserver for jsdom (used by Radix/@floating-ui). The stub methods are intentionally no-ops. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState, type ReactElement } from 'react'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; import type { BibleBook, BibleVersion } from '@youversion/platform-core'; -import type { HookOverrides } from '@youversion/platform-react-hooks'; +import { YouVersionContext, type HookOverrides } from '@youversion/platform-react-hooks'; import { HookOverrideProvider } from '@/test/hook-overrides'; import { InterfaceDirectionProvider } from '@/lib/direction'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; import { BIBLE_READER_SPACING, BibleReader, @@ -80,7 +83,11 @@ function defaultOverrides(): HookOverrides { } function renderWithOverrides(ui: ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } function overridesRecordingVersionLanguage() { @@ -204,21 +211,161 @@ describe('createBibleThemeSettingsContentHandlers', () => { }); }); -it('injects component styles when theme settings render standalone', () => { - render( - , - ); +describe('BibleThemeSettingsContent public boundary', () => { + it('owns one childless styled SSR host and reuses it during hydration', async () => { + const element = ( + + ); + const serverMarkup = renderToString(element); + + expect(serverMarkup).toBe('
'); + + const container = document.createElement('div'); + container.innerHTML = serverMarkup; + document.body.append(container); + const serverHost = container.firstElementChild; + const recoverableErrors: unknown[] = []; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + let root: Root | undefined; + + try { + await act(async () => { + root = hydrateRoot(container, element, { + onRecoverableError: (error) => recoverableErrors.push(error), + }); + }); + + const host = await waitFor(() => { + const candidate = container.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot?.querySelector('[data-testid="line-spacing"]')) { + throw new Error('settings controls not mounted inside an open shadow root'); + } + return candidate; + }); + + expect(container.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1); + expect(host).toBe(serverHost); + expect(host.childNodes).toHaveLength(0); + expect(host.shadowRoot?.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(0); + expect(host.style.getPropertyValue('all')).toBe('initial'); + expect(host.style.getPropertyValue('display')).toBe('flex'); + expect(host.style.getPropertyPriority('display')).toBe('important'); + expect(host.style.getPropertyValue('direction')).toBe('inherit'); + expect(host.style.getPropertyValue('flex-direction')).toBe('column'); + expect(host.style.getPropertyValue('min-block-size')).toBe('0'); + expect(recoverableErrors).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + if (root) await act(async () => root?.unmount()); + consoleError.mockRestore(); + container.remove(); + } + }); - expect(document.head.querySelector('style[data-href="yv-sdk-components"]')).not.toBeNull(); + it('preserves standalone callbacks, limits, selected font, theme, direction, and controls', async () => { + const user = userEvent.setup(); + const onFontIncreased = vi.fn(); + const onFontDecreased = vi.fn(); + const onFontSelected = vi.fn<(fontFamily: FontFamily) => void>(); + const onChangeLineSpacing = vi.fn(); + + function StandaloneSettings({ + direction, + fontSize = 12, + fontFamily = UNTITLED_SERIF_FONT, + theme = 'light', + providerTheme = 'dark', + }: { + direction: 'ltr' | 'rtl'; + fontSize?: number; + fontFamily?: FontFamily; + theme?: 'light' | 'dark'; + providerTheme?: 'light' | 'dark'; + }) { + return ( + + + + + + ); + } + + const view = render(); + const host = await waitFor(() => { + const candidate = view.container.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot?.querySelector('[data-testid="line-spacing"]')) { + throw new Error('standalone settings not mounted'); + } + return candidate; + }); + const root = host.shadowRoot!; + const shadowScope = root.querySelector('[data-yv-shadow-content-wrapper]')!; + const settings = within(shadowScope); + const body = settings.getByTestId('line-spacing').closest('[data-yv-sdk]'); + const wrapper = root.querySelector('[data-yv-shadow-content-wrapper]'); + const decrease = settings.getByRole('button', { name: 'Decrease font size' }); + const increase = settings.getByRole('button', { name: 'Increase font size' }); + const lineSpacing = settings.getByRole('button', { name: 'Change line spacing' }); + const inter = settings.getByRole('button', { name: /Inter/ }); + const serif = settings.getByRole('button', { name: /Untitled Serif/ }); + + expect(settings.getAllByRole('button')).toHaveLength(5); + expect(wrapper).toHaveAttribute('data-yv-theme', 'light'); + expect(body).toHaveAttribute('data-yv-theme', 'light'); + expect(body).toHaveAttribute('dir', 'rtl'); + expect(decrease).toBeDisabled(); + expect(serif.className).toContain('yv:bg-primary'); + expect(inter.className).not.toContain('yv:bg-primary'); + + await user.click(decrease); + expect(onFontDecreased).not.toHaveBeenCalled(); + await user.click(increase); + expect(onFontIncreased).toHaveBeenCalledTimes(1); + await user.click(inter); + expect(onFontSelected).toHaveBeenCalledWith(INTER_FONT); + await user.click(lineSpacing); + expect(onChangeLineSpacing).toHaveBeenCalledTimes(1); + + view.rerender( + , + ); + expect(body).toHaveAttribute('dir', 'ltr'); + expect(wrapper).toHaveAttribute('data-yv-theme', 'dark'); + expect(body).toHaveAttribute('data-yv-theme', 'dark'); + expect(inter.className).toContain('yv:bg-primary'); + expect(serif.className).not.toContain('yv:bg-primary'); + expect(increase).toBeDisabled(); + const increaseCallCountAtMaximum = onFontIncreased.mock.calls.length; + await user.click(increase); + expect(onFontIncreased).toHaveBeenCalledTimes(increaseCallCountAtMaximum); + await user.click(decrease); + expect(onFontDecreased).toHaveBeenCalledTimes(1); + }); }); describe('BibleReader theme settings', () => { @@ -464,8 +611,8 @@ it('keeps chapter targets canonical while semantic icons follow RTL interface di it('keeps Inter and Untitled Serif in Arabic and Welsh reader font settings', async () => { const previousLanguage = i18n.language; - function renderFontSettings(): ReturnType { - return render( + async function renderFontSettings() { + const view = render( , ); + + const settings = await waitFor(() => { + const shadowContent = view.container + .querySelector('[data-yv-shadow-host]') + ?.shadowRoot?.querySelector('[data-yv-shadow-content-wrapper]'); + if (!shadowContent) throw new Error('font settings not mounted inside the shadow root'); + return within(shadowContent); + }); + + return { settings, view }; } try { await i18n.changeLanguage('ar'); - const arabic = renderFontSettings(); - expect(screen.getByRole('button', { name: /Inter/ })).toBeInTheDocument(); - expect(screen.queryByText('إدخال')).not.toBeInTheDocument(); - arabic.unmount(); + const arabic = await renderFontSettings(); + expect(arabic.settings.getByRole('button', { name: /Inter/ })).toBeInTheDocument(); + expect(arabic.settings.queryByText('إدخال')).not.toBeInTheDocument(); + arabic.view.unmount(); await i18n.changeLanguage('cy'); - renderFontSettings(); - expect(screen.getByRole('button', { name: /Untitled Serif/ })).toBeInTheDocument(); - expect(screen.queryByText('Serif Dideitl')).not.toBeInTheDocument(); + const welsh = await renderFontSettings(); + expect(welsh.settings.getByRole('button', { name: /Untitled Serif/ })).toBeInTheDocument(); + expect(welsh.settings.queryByText('Serif Dideitl')).not.toBeInTheDocument(); } finally { await i18n.changeLanguage(previousLanguage); } diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index fa9a000a7..5279d22aa 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -30,6 +30,7 @@ import { } from '@youversion/platform-react-hooks'; import React, { createContext, + useCallback, useContext, useEffect, useLayoutEffect, @@ -49,6 +50,7 @@ import { InfoIcon } from './icons/info'; import { LoaderIcon } from './icons/loader'; import { PersonIcon } from './icons/person'; import { ProfileAvatar } from './profile-avatar'; +import { ReuseShadowBoundary, ShadowIsolationBoundary } from '@/lib/shadow-isolation'; import { Button } from './ui/button'; import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from './ui/popover'; import { VerseActionPopover } from './verse-action-popover'; @@ -63,6 +65,8 @@ import { YouVersionPlatformConfiguration, getPassageAttribution } from '@youvers import { BibleReaderSearch, type BibleReaderSearchPressData } from './bible-reader-search'; import { useTransientVerseFocus, type VerseFocusRequest } from '@/lib/use-transient-verse-focus'; import { BibleReaderNavigation } from './bible-reader-navigation'; +import { VerseActionPopoverFocusRestoreProvider } from '@/lib/verse-action-popover-focus'; +import { isElementFromOwnerDocument } from '@/lib/shadow-root-host'; type BibleReaderContextType = { book: string; @@ -761,17 +765,19 @@ function Root({ }; return ( - - -
- {children} -
-
+ + + +
+ {children} +
+
+
); } @@ -875,6 +881,12 @@ function Content() { const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); + const verseFocusRestoreTargetRef = useRef(null); + const takeVerseFocusRestoreTarget = useCallback(() => { + const target = verseFocusRestoreTargetRef.current; + verseFocusRestoreTargetRef.current = null; + return target; + }, []); const [highlightRedirectPending, setHighlightRedirectPending] = useState(false); const lastSelectionRef = useRef([]); const highlightRedirectRef = useRef({ @@ -985,20 +997,27 @@ function Content() { // an outside click. Keep that dismiss alive until the prompt's modal is gone. useEffect(() => { if (!highlightRedirectPending || permissionDialogOpen || signInDialogOpen) return; + const ownerDocument = readerRef.current?.ownerDocument ?? document; function onPointerDown(event: PointerEvent) { - const target = event.target; - if (target instanceof Element && target.closest('.yv-v[v]')) return; + const interactedWithVerse = event + .composedPath() + .some( + (target) => + isElementFromOwnerDocument(target, ownerDocument.documentElement, 'Element') && + target.matches('.yv-v[v]'), + ); + if (interactedWithVerse) return; closeAndClearSelectionRef.current(); } function onKeyDown(event: KeyboardEvent) { if (event.key !== 'Escape') return; closeAndClearSelectionRef.current(); } - document.addEventListener('pointerdown', onPointerDown); - document.addEventListener('keydown', onKeyDown); + ownerDocument.addEventListener('pointerdown', onPointerDown); + ownerDocument.addEventListener('keydown', onKeyDown); return () => { - document.removeEventListener('pointerdown', onPointerDown); - document.removeEventListener('keydown', onKeyDown); + ownerDocument.removeEventListener('pointerdown', onPointerDown); + ownerDocument.removeEventListener('keydown', onKeyDown); }; }, [highlightRedirectPending, permissionDialogOpen, signInDialogOpen]); @@ -1226,6 +1245,14 @@ function Content() {
{ + let activeElement = event.currentTarget.ownerDocument.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + verseFocusRestoreTargetRef.current = + activeElement === event.currentTarget.ownerDocument.body ? null : activeElement; + }} >

- + + + {/* `verseActions="none"`: the host renders its own action UI (e.g. a @@ -1286,21 +1315,23 @@ function Content() { all — two action surfaces would stack. Selection, painting, and every payload above are untouched. */} {verseActions !== 'none' && ( - 0 && !highlightPromptOpen} - onOpenChange={handlePopoverOpenChange} - activeHighlights={activeHighlights} - selectedVerses={selectedVerses} - highlightedVerses={highlightedVerses} - highlightsEnabled={highlightsEnabled} - anchorElement={anchorElement} - scrollRoot={scrollContainerRef.current} - onHighlight={handleHighlight} - onClearHighlight={handleClearHighlight} - onCopy={handleCopy} - onShare={handleShare} - theme={background} - /> + + 0 && !highlightPromptOpen} + onOpenChange={handlePopoverOpenChange} + activeHighlights={activeHighlights} + selectedVerses={selectedVerses} + highlightedVerses={highlightedVerses} + highlightsEnabled={highlightsEnabled} + anchorElement={anchorElement} + scrollRoot={scrollContainerRef.current} + onHighlight={handleHighlight} + onClearHighlight={handleClearHighlight} + onCopy={handleCopy} + onShare={handleShare} + theme={background} + /> + )} {auth.isAuthenticated ? ( ) : ( - + + + + {({ chapterLabel, currentBook, loading }) => ( +
+ + + + + +
+ )} +
+
+
+ + + + + {({ version, loading }) => ( - - - - )} - - - - - - {({ version, loading }) => ( - - )} - - - + )} + + + + {showSearch ? : null} @@ -1822,16 +1868,18 @@ function Toolbar({ - + + + )} diff --git a/packages/ui/src/components/bible-theme-settings.stories.tsx b/packages/ui/src/components/bible-theme-settings.stories.tsx new file mode 100644 index 000000000..4b9b07a42 --- /dev/null +++ b/packages/ui/src/components/bible-theme-settings.stories.tsx @@ -0,0 +1,244 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { http, HttpResponse } from 'msw'; +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import i18n from '../i18n'; +import { INTER_FONT, UNTITLED_SERIF_FONT, type FontFamily } from '../lib/verse-html-utils'; +import { BibleThemeSettingsContent } from './bible-reader'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { YvComponentStyles } from '../lib/yv-styles-components'; + +function ConstrainedSettings(): React.ReactNode { + const [boundary, setBoundary] = useState(null); + const [fontFamily, setFontFamily] = useState(INTER_FONT); + const [fontSize, setFontSize] = useState(16); + return ( + <> + +
+
+ + Settings + + undefined} + onFontDecreased={() => setFontSize((current) => current - 1)} + onChangeLineSpacing={() => undefined} + /> + + +
+
+ + ); +} + +function MixedLightAndShadowControls(): React.ReactNode { + const [portalContainer, setPortalContainer] = useState(null); + return ( + <> +
+ + Settings + Header action} + onFocusOutside={(event) => event.preventDefault()} + > + undefined} + onFontIncreased={() => undefined} + onFontDecreased={() => undefined} + onChangeLineSpacing={() => undefined} + /> + {portalContainer + ? createPortal( + <> + + + , + portalContainer, + ) + : null} + + + + ); +} + +const meta = { + title: 'Components/BibleReader/Settings', + component: ConstrainedSettings, + tags: ['integration', 'shadow-dom', 'cross-browser'], + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + http.get('*/v1/fonts/1/stylesheet', () => + HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }), + ), + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FontControlsRemainReachableInConstrainedSpace: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId('settings-trigger')); + const ownerDocument = canvasElement.ownerDocument; + const dialog = await within(ownerDocument.body).findByRole('dialog'); + const settingsHost = await waitFor(() => { + const candidate = dialog.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot) throw new Error('settings shadow root not attached'); + return candidate; + }); + const settingsRoot = settingsHost.shadowRoot!; + const settingsScope = settingsRoot.querySelector( + '[data-yv-shadow-content-wrapper]', + )!; + const settings = within(settingsScope); + const controls = settings.getAllByRole('button'); + void expect(controls).toHaveLength(5); + const decreaseFontSizeButton = settings.getByTestId('decrease-font-size'); + void expect(controls[0]).toBe(decreaseFontSizeButton); + const serifButton = settings.getByRole('button', { + name: `${i18n.t('fontLabel')} ${i18n.t('untitledSerifFontName')}`, + }); + void expect(controls[controls.length - 1]).toBe(serifButton); + const closeButton = within(dialog).getByRole('button', { name: i18n.t('closeAriaLabel') }); + void expect(ownerDocument.activeElement).toBe(closeButton); + await userEvent.tab(); + void expect(settingsRoot.activeElement).toBe(decreaseFontSizeButton); + await userEvent.keyboard('{Enter}'); + void expect(canvas.getByTestId('settings-boundary')).toHaveAttribute('data-font-size', '15'); + + let focusedControlReceivedTab = false; + decreaseFontSizeButton.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Tab') focusedControlReceivedTab = true; + }, + { once: true }, + ); + for (const control of controls.slice(1)) { + await userEvent.tab(); + void expect(settingsRoot.activeElement).toBe(control); + } + void expect(focusedControlReceivedTab).toBe(true); + await userEvent.keyboard('{Enter}'); + void expect(canvas.getByTestId('settings-boundary')).toHaveAttribute( + 'data-font-family', + UNTITLED_SERIF_FONT, + ); + await userEvent.tab(); + void expect(ownerDocument.activeElement).toBe(closeButton); + for (const control of [...controls].reverse()) { + await userEvent.tab({ shift: true }); + void expect(settingsRoot.activeElement).toBe(control); + } + await userEvent.tab({ shift: true }); + void expect(ownerDocument.activeElement).toBe(closeButton); + // Use the real settings body and Radix collision cap. Unlike a click helper, + // hit testing can detect controls rendered but clipped below the panel. + const settingsBody = serifButton.closest('[data-yv-sdk]'); + if (!settingsBody) throw new Error('settings body not rendered'); + const boundary = canvas.getByTestId('settings-boundary'); + const header = within(dialog).getByRole('heading'); + await Promise.all(dialog.getAnimations().map((animation) => animation.finished)); + await waitFor(() => { + const panelRect = dialog.getBoundingClientRect(); + const boundaryRect = boundary.getBoundingClientRect(); + const availableHeight = Number.parseFloat( + getComputedStyle(dialog).getPropertyValue('--radix-popover-content-available-height'), + ); + void expect(availableHeight).toBeGreaterThan(0); + void expect(Math.abs(panelRect.height - availableHeight)).toBeLessThanOrEqual(1); + void expect( + header.getBoundingClientRect().height + settingsBody.scrollHeight, + ).toBeGreaterThan(availableHeight); + void expect(panelRect.top).toBeGreaterThanOrEqual(boundaryRect.top); + void expect(panelRect.bottom).toBeLessThanOrEqual(boundaryRect.bottom); + void expect(panelRect.left).toBeGreaterThanOrEqual(boundaryRect.left); + void expect(panelRect.right).toBeLessThanOrEqual(boundaryRect.right); + void expect(settingsBody.scrollHeight).toBeGreaterThan(settingsBody.clientHeight); + void expect(getComputedStyle(settingsBody).overflowY).toBe('auto'); + }); + const headerOffset = header.getBoundingClientRect().top - dialog.getBoundingClientRect().top; + for (const control of controls) { + control.scrollIntoView({ block: 'center' }); + await waitFor(() => { + const controlRect = control.getBoundingClientRect(); + const bodyRect = settingsBody.getBoundingClientRect(); + void expect(controlRect.top).toBeGreaterThanOrEqual(bodyRect.top); + void expect(controlRect.bottom).toBeLessThanOrEqual(bodyRect.bottom); + const hit = settingsRoot.elementFromPoint( + controlRect.left + controlRect.width / 2, + controlRect.top + controlRect.height / 2, + ); + void expect(control.contains(hit)).toBe(true); + const currentHeaderOffset = + header.getBoundingClientRect().top - dialog.getBoundingClientRect().top; + void expect(Math.abs(currentHeaderOffset - headerOffset)).toBeLessThanOrEqual(1); + }); + } + void expect(settingsBody.scrollTop).toBeGreaterThan(0); + await userEvent.keyboard('{Escape}'); + await waitFor(() => void expect(dialog).not.toBeInTheDocument()); + void expect(ownerDocument.activeElement).toBe(canvas.getByTestId('settings-trigger')); + }, +}; + +export const MixedAndPortaledControlsPreserveTabOrder: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId('mixed-controls-trigger')); + const ownerDocument = canvasElement.ownerDocument; + const dialog = await within(ownerDocument.body).findByRole('dialog'); + const headerAction = within(dialog).getByTestId('header-action'); + const closeButton = within(dialog).getByRole('button', { name: i18n.t('closeAriaLabel') }); + void expect(ownerDocument.activeElement).toBe(headerAction); + await userEvent.tab(); + void expect(ownerDocument.activeElement).toBe(closeButton); + + const settingsHost = await waitFor(() => { + const candidate = dialog.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot) throw new Error('settings shadow root not attached'); + return candidate; + }); + const settingsScope = settingsHost.shadowRoot!.querySelector( + '[data-yv-shadow-content-wrapper]', + )!; + const firstSetting = within(settingsScope).getByTestId('decrease-font-size'); + await userEvent.tab(); + void expect(settingsHost.shadowRoot!.activeElement).toBe(firstSetting); + + const firstPortalAction = canvas.getByTestId('portal-first'); + const secondPortalAction = canvas.getByTestId('portal-second'); + firstPortalAction.focus(); + void expect(ownerDocument.activeElement).toBe(firstPortalAction); + await userEvent.tab(); + void expect(ownerDocument.activeElement).toBe(secondPortalAction); + void expect(dialog).toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx new file mode 100644 index 000000000..ff1c7d9e1 --- /dev/null +++ b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx @@ -0,0 +1,994 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import type { BibleVersion, Language, Organization } from '@youversion/platform-core'; +import { YouVersionContext, type HookOverrides } from '@youversion/platform-react-hooks'; +import { useContext, useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { expect, userEvent, within } from 'storybook/test'; +import { ReuseShadowBoundary } from '../lib/shadow-isolation'; +import { ShadowRootHost } from '../lib/shadow-root-host'; +import mockBibles from '../test/mock-data/bibles.json'; +import mockLanguages from '../test/mock-data/languages.json'; +import mockOrganizations from '../test/mock-data/organizations.json'; +import { waitFor, waitForElement, waitForShadowRoot } from '../test/storybook-dom'; +import { BibleVersionPicker } from './bible-version-picker'; + +const VERSIONS = mockBibles.collections.default.data satisfies BibleVersion[]; +const LANGUAGES = mockLanguages.data.map(({ id, display_names }) => ({ + id, + language: display_names.en ?? id, + display_names: { en: display_names.en ?? id }, +})) satisfies Language[]; +const ORGANIZATIONS = Object.values(mockOrganizations) satisfies Organization[]; + +const HOOK_OVERRIDES = { + useVersion: (versionId) => ({ + version: VERSIONS.find((version) => version.id === versionId) ?? null, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersions: () => ({ + versions: { data: VERSIONS, next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguages: () => ({ + languages: { data: LANGUAGES, next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguage: (languageId) => ({ + language: LANGUAGES.find((language) => language.id === languageId) ?? null, + loading: false, + error: null, + refetch: () => undefined, + }), + useOrganizations: (organizationIds) => ({ + organizations: new Map( + ORGANIZATIONS.filter((organization) => organizationIds.includes(organization.id)).map( + (organization) => [organization.id, organization], + ), + ), + }), +} satisfies HookOverrides; + +function FixtureProviders({ children }: { children: ReactNode }): ReactNode { + const parentContext = useContext(YouVersionContext); + const value = parentContext + ? { ...parentContext, hookOverrides: HOOK_OVERRIDES } + : { appKey: 'test', hookOverrides: HOOK_OVERRIDES }; + + return {children}; +} + +function AutomaticBibleVersionPicker({ + side = 'top', +}: { + side?: 'top' | 'right' | 'bottom' | 'left'; +}): React.ReactNode { + const [versionId, setVersionId] = useState(111); + + return ( + + + + + ); +} + +function CustomTriggerBibleVersionPicker(): React.ReactNode { + const [versionId, setVersionId] = useState(111); + + return ( + + + + + + + ); +} + +function TwoPickersInOneIsland(): React.ReactNode { + const [firstVersionId, setFirstVersionId] = useState(111); + const [secondVersionId, setSecondVersionId] = useState(111); + + return ( + + +
+ + + + +
+
+ + + + +
+
+
+ ); +} + +function SameOriginIframeHarness(): React.ReactNode { + const [iframeDocument, setIframeDocument] = useState(null); + const [container, setContainer] = useState(null); + + useEffect(() => { + if (!iframeDocument) return; + + const portalContainer = iframeDocument.createElement('div'); + portalContainer.setAttribute('data-testid', 'iframe-portal-container'); + portalContainer.style.setProperty('display', 'grid'); + portalContainer.style.setProperty('min-block-size', '100vh'); + portalContainer.style.setProperty('place-items', 'center'); + iframeDocument.body.append(portalContainer); + setContainer(portalContainer); + + return () => portalContainer.remove(); + }, [iframeDocument]); + + return ( + <> +