Skip to content

WIP: feat!: YPE-4113 - #320

Open
cameronapak wants to merge 20 commits into
mainfrom
ype-4113-spike-react-web-sdk-uidom-guarantee-consuming-app-global-css-cant-distort-bible-components-2
Open

WIP: feat!: YPE-4113#320
cameronapak wants to merge 20 commits into
mainfrom
ype-4113-spike-react-web-sdk-uidom-guarantee-consuming-app-global-css-cant-distort-bible-components-2

Conversation

@cameronapak

@cameronapak cameronapak commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

This is for demo purposes at this time.

YPE-4113 | Artifacts | Task | PR Walkthrough (alpha)

Breaking change. Consumer CSS can no longer override SDK declarations.
The migration path is in the changeset and repeated at the bottom of this
description.

What problems was I solving

A partner puts BibleTextView into their app. Their button { padding: 1rem }
reshapes our version picker trigger. Their body { font-family: Comic Sans }
rewrites our verse text. The partner sees a broken Bible experience and has no
supported way to correct it.

The cause was our own design, not an accident. SDK CSS sat in yv-sdk-* cascade
layers. CSS Cascade 5 §6.1
compares layer order before specificity, and unlayered CSS joins an implicit
final layer. A consumer button {} rule at 0,0,1 thus overrode every yv:
utility at any specificity. Raising specificity inside the layer could never
help. That choice protected the consumer from us, and it guaranteed the leak in
the other direction.

There were two leak channels, not one:

Channel Example Why it won
Direct match button { padding: 1rem } matches our button Unlayered CSS beats layered CSS at any specificity
Inheritance body { color: green } flows into our text The SDK declared no color, so the value was inherited

After this PR:

  • A host reset, a bare element rule, Tailwind Preflight, and inherited body
    typography all leave SDK components unchanged. Measured, not assumed.
  • The one remaining hole is a consumer !important rule, and it is documented
    with per-component numbers.
  • Shadow DOM is recommended against, on those numbers.

Measured result. Baseline was 124 to 4,041 leaks per component. Four of the
five hostile groups now report 0 on every component. The fifth group,
!important, reports 880 leaks across 155 <button> elements. No element that
is not a <button> leaks anywhere.

What user-facing changes did I ship

How I implemented it

Five phases, in order. Each one had to move the number the phase before it
produced.

1. Measure first

Nothing was fixed here. The point was a baseline the later phases had to beat.

  • packages/ui/src/test/hostile-host.ts — five groups of adversarial global CSS: preflight, bareElements, aggressiveReset, inheritedTypography, important.
  • packages/ui/src/test/style-diff.ts — reads 32 computed properties on every element of an SDK subtree, once clean and once under attack, and reports the pairs that moved. Longhands only. Elements are identified by DOM path, because any identity based on styling would be circular.
  • packages/ui/src/components/style-isolation.stories.tsx — 14 stories: one per exported component, plus a check that consumer token overrides still apply.
  • packages/ui/.storybook/preview.tsx — a parameters.hostileHost decorator. A layout effect, so the injected tags always land after the hoisted SDK style tag. That is the source order a real consumer app produces.

2. Stamp data-yv-sdk on every exported root

The gate is worthless on a root that carries no attribute.

  • scope-attribute.test.tsx asserts every public export renders a root with both attributes. A new component that forgets the attribute renders unstyled, and this test catches it at the boundary.
  • dialog.tsx needed DialogOverlay stamped. It portals to document.body, outside every stamped ancestor.
  • bible-version-picker.tsx and bible-reader.tsx had roots outside the ones the plan listed.

3. Declare the inherited properties

theme.css does three things:

  1. :where([data-yv-sdk]) becomes [data-yv-sdk]. The old form dropped every rule inside it to 0,0,0, which was correct while SDK CSS was meant to lose.
  2. The inherited set is declared on the root. It works at zero specificity for the same reason the leak worked: there is no competing declaration to lose to.
  3. color: inherit on every descendant, and the box model repeated on the root itself. Both are explained under Deviations.

theme-inherited-properties.test.ts parses the file and asserts the set, so the block cannot be trimmed by accident.

4. Drop the layers and gate every selector

scope-selectors.mjs rewrites the compiled sheet after Tailwind runs:

.yv\:mt-4  →  :is([data-yv-sdk], [data-yv-sdk] *).yv\:mt-4

It uses Lightning CSS, not a regular expression and not postcss-prefix-selector:

  • Its Selector visitor is a typed structural API, so it cannot corrupt an escaped class name such as .yv\:mt-4.
  • @keyframes, @font-face and @property produce no Selector nodes, so the script skips them by construction rather than by an exclusion list.
  • It flattens CSS nesting. bible-reader.css and @utility touch-hitbox both emit nested & rules, and a prefix on a nested selector corrupts it.
  • postcss-prefix-selector@2.1.1 was tested against this repo's real output and rejected. It rewrites :host into [data-yv-sdk] :host, which can never match, and thus drops every theme variable without a warning.

Tailwind v4 has no built-in equivalent. prefix(yv) only renames classes,
@import "tailwindcss" important takes no selector argument, and the v3
important: '#app' option has no v4 replacement.

Three build gates make the guarantee real:

  • The script re-parses its own output and throws on any ungated selector.
  • verify-styles.js asserts dist/index.js contains the gate string, and does not contain @layer yv-sdk-. The yv-sdk- prefix is deliberate: Tailwind emits its own @layer properties block for the @property fallback, whatever our directives say.
  • package.json writes the raw Tailwind output to a gitignored .cache/, so the ungated sheet can never reach dist/ and be published.

5. Record the decision and the residual

  • ADR-0005 records five decisions, the three times this decision already flipped, and the rejected alternatives. Only the changelog recorded the last flip. This ADR exists to prevent a fourth flip by accident.
  • The residual report names the elements a partner would actually see break, with clean and hostile values for each.

The residual, and the shadow DOM recommendation

Group Leaks
preflight 0
bareElements 0
aggressiveReset 0
inheritedTypography 0
important 880

Zero means zero on every component, not an average. The 880 leaks come from two
declarations:

button { padding: 2rem !important }        /* reaches 155 buttons */
button { border-radius: 0 !important }     /* reaches 65 of those 155 */

Recommendation: do not adopt shadow DOM. ADR-0005 fixed the condition before
the harness produced a number: recommend shadow DOM only if the residual
includes rules that do not use !important. There is no such residual, and the
result is not close to the threshold.

The shadow DOM costs are large and are enumerated in the ADR. Radix portals
mount outside the shadow root, so FocusScope, aria-hidden and
dismissable-layer break at the boundary. React 19 <style precedence> hoisting
into a shadow root is undocumented. Tailwind Labs state that v4 does not target
shadow DOM, because v4 depends on @property. Theming moves per-root.

If a partner reports a break from a rule that does not use !important, the
harness measures that case. Add their rule to HOSTILE_GROUPS, run the suite,
and read the number.

Deviations from the plan

Compared against
.humanlayer/tasks/.../04-structure-outline-style-isolation.md.

Implemented as planned

  • The hostile-host fixture, the computed-style diff harness, the 14 stories, and the Storybook decorator.
  • data-yv-sdk and data-yv-theme on profile-avatar, separator, textarea, dialog, plus scope-attribute.test.tsx.
  • The inherited-property block on [data-yv-sdk], with direction left out on purpose. bible-reader.css owns RTL, and text-align: start already follows direction.
  • @layer removal, the Lightning CSS rewriter (the plan already required Lightning CSS after its own verification), the .d.mts types, and the unit tests.
  • :where([data-yv-sdk]) to [data-yv-sdk], with the specificity comment inverted.
  • The build split, .cache/ in .gitignore, the rewritten verify-styles.js, ADR-0005, the residual report, README and AGENTS updates, and the major-bump changeset.

Deviations and surprises

  • The reset is wider than the plan's diff. The plan added nine inherited properties. The implementation also added color: inherit on descendants and the box model on the root. Eleven of fourteen stories stayed red without both. color on the root alone does not stop a consumer ul { color } rule, which matches an SDK element directly rather than arriving by inheritance. The universal block is [data-yv-sdk] *, which misses the marked element, and a consumer * { box-sizing } does not miss it.
  • ui/button.tsx was never stamped, contrary to the phase 2 file list. It is not in src/components/index.ts, and every internal <Button> call site already sits inside a stamped ancestor. Stamping it would need an audit of all 31 call sites for the correct local theme, which is out of scope for a phase whose contract was "no visual change". Two roots the plan did not name were stamped instead: the version picker's language trigger and FootnoteContent.
  • tsconfig.build.json now excludes src/test. Not in the plan. The phase 1 harness iterates NodeList, and the declaration build's lib (ES2020, DOM, no DOM.Iterable) cannot compile it. This surfaced only when phase 4 ran a full pnpm build. The directory ships no public type.
  • The important assertion changed shape. The plan asserted one global "greater than zero". The real assertion is per story, conditioned on whether the component renders a <button>. BibleCard, FootnoteContent, ProfileAvatar, Separator and Textarea render none, and correctly report zero.

Additions not in the plan

  • A final pass rewriting the comments and prose added by this branch to the simple-english rules (commit 89e5807). Comment and prose only. No selector, declaration or code path changed, and the full suite still passes.
  • That commit also moved the inherited-properties comment in theme.css down to the block it describes. It had sat above the box-model block, which made it read as documentation for the wrong four declarations.

Items planned but not implemented

None. All five phases have corresponding code in the diff.

How to verify it

git fetch origin
git worktree add ../ype-4113-review ype-4113-spike-react-web-sdk-uidom-guarantee-consuming-app-global-css-cant-distort-bible-components-2
cd ../ype-4113-review
pnpm install

Manual testing

  • Run pnpm --filter @youversion/platform-react-ui storybook, open Style Isolation, and look at any story. The component renders correctly under CSS built to break it.
  • Open ConsumerTokenOverrideStillApplies. The supported customization path still works after the lockdown.
  • Read docs/style-isolation-residual-leak.md and check the numbers against the console output of a story run.
  • Break the gate on purpose: delete pnpm build:css:scope from the build script and run pnpm --filter @youversion/platform-react-ui build. The build fails.

Automated tests

# Full UI suite, including the 14 hostile-host browser stories
pnpm --filter @youversion/platform-react-ui build
pnpm --filter @youversion/platform-react-ui test:integration   # 43 files, 523 tests

# Core and hooks. Core needs the env vars its handler tests read.
YVP_API_HOST=api.youversion.com YVP_APP_KEY=test pnpm --filter @youversion/platform-core test    # 382 tests
YVP_API_HOST=api.youversion.com YVP_APP_KEY=test pnpm --filter @youversion/platform-react-hooks test  # 289 tests

pnpm turbo typecheck lint

All of the above pass on 89e5807.

Migration for consumers

Replace each consumer CSS override with a supported path:

  1. Set --yv-* design tokens on [data-yv-sdk].
  2. Use the theme prop on YouVersionProvider, or the background prop on a component.
  3. Open an issue if neither path covers the case.

Rules that use !important still win. That is the one remaining hole, and
docs/style-isolation-residual-leak.md records it.

Description for the changelog

Scope all SDK CSS to data-yv-sdk subtrees, so the global CSS of a host app can no longer change SDK components.

Greptile Summary

This PR introduces a CSS isolation pipeline that scopes and hardens SDK styles, protects consumer-provided slots, and adds regression coverage and migration documentation. The watcher’s startup fix still leaves a race that can miss Tailwind’s initial output.

  • Rewrites compiled selectors under data-yv-sdk boundaries and excludes data-yv-slot consumer content.
  • Splits declarations by cascade behavior, converts rem lengths to px, and verifies the generated stylesheet.
  • Adds component stamps, hostile-host tests, architectural decisions, and consumer migration guidance.

Confidence Score: 4/5

The PR should not merge until the scope watcher cannot miss Tailwind’s initial stylesheet write during concurrent startup.

The missing-directory crash from the previous thread is corrected, but runOnce still executes before watcher registration; an initial Tailwind write in that interval receives no event and no retry, leaving development or Storybook with missing or stale scoped CSS.

Files Needing Attention: packages/ui/scripts/scope-selectors.mjs and packages/ui/package.json

Important Files Changed

Filename Overview
packages/ui/scripts/scope-selectors.mjs Adds the CSS transformation and verification pipeline, but watcher registration occurs after the initial rewrite and can miss Tailwind’s first concurrent write.
packages/ui/package.json Adds the ordered production CSS build and concurrent development watchers; the latter provide no readiness coordination.
packages/core/src/styles/theme.css Establishes scoped inherited-property and box-model defaults while respecting consumer slot boundaries.
packages/ui/src/lib/consumer-slot.tsx Introduces the explicit boundary used to prevent SDK selectors from styling consumer-owned content.
packages/ui/src/styles/scope-selectors.test.ts Thoroughly exercises transformation behavior but does not cover watcher startup ordering.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[dev or storybook] --> B[Tailwind watcher]
  A --> C[Scope watcher]
  C --> D[Initial runOnce]
  D -->|raw CSS missing| E[Return without output]
  E --> F[Register fs.watch]
  B -->|write occurs before F| G[Event missed]
  G --> H[dist/tailwind.css missing or stale]
Loading

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
### Issue 1
packages/ui/scripts/scope-selectors.mjs:1442
**Initial stylesheet write is missed**

When Tailwind creates `.cache/tailwind.raw.css` after the initial `runOnce` check but before `fs.watch` is registered, the write produces no observed event and has no retry, leaving `dist/tailwind.css` missing or stale until another Tailwind rebuild occurs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (4): Last reviewed commit: "docs: record the :where() placement and ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

cameronapak and others added 6 commits August 6, 2026 11:24
Measures how much consuming-app global CSS reaches SDK components. Five
hostile CSS groups (preflight, bareElements, aggressiveReset,
inheritedTypography, important) inject after the SDK style tag, matching
real consumer source order.

Snapshots poll until two consecutive reads agree. A single-frame read
measured CSS transitions rather than leaks, reporting ~101 leaks for
every group including `important`, which sets only two properties.

Preflight embeds the CLI-compiled output, not the source. The source
uses `--theme(...)` build-time calls that a browser drops as invalid.

Assertions document the current leak. Phases 3 and 4 narrow them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4 gates all SDK CSS on [data-yv-sdk], so an unstamped root loses
its styling. Five roots carried no attribute: ProfileAvatar, Separator,
Textarea, the Dialog overlay, and BibleVersionPickerLanguageTrigger.

The stamp is not inert. theme.css declares light tokens on the bare
[data-yv-sdk] selector with dark as a nested override, so an element
that gains the attribute inside a dark scope reverts to light. Every
stamp names the enclosing scope's theme, not the provider's.

FootnoteContent wrote data-yv-theme from an optional prop with no
default, so React dropped the attribute and it rendered light inside a
dark reader. It now falls back to useTheme().

ui/button.tsx is deliberately not stamped. It has no theme in scope,
it is not a public export, and Phase 4's descendant arm covers it.

scope-attribute.test.tsx asserts every component export carries the
attribute, so a new component cannot forget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A consumer's `body { letter-spacing: .5em }` never matches an SDK
element, so no specificity or cascade rank stops it. The value arrives
by inheritance, which only applies where the element declares nothing.
Declaring each property closes that channel.

Pins color, font-family, font-variant, letter-spacing, line-height,
text-align, text-indent, text-shadow, text-transform, white-space, and
word-spacing. `direction` stays out: bible-reader.css handles RTL and
`text-align: start` is direction-aware.

font-family was not in the plan. The reset applied it only to
`[data-yv-sdk] *`, so the marked element itself always inherited the
host font. The reader font still wins at 0,1,0 over the reset's 0,0,0.

Story assertions moved from a combined total to per-group. The groups
cancel: preflight sets box-sizing: border-box and aggressiveReset sets
content-box, so the combined value hid a real leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…layers

SDK CSS sat in @layer yv-sdk-*, which loses to a consumer's unlayered
CSS at any specificity. That is what layers are for, so raising
specificity inside the layer could never help. Protection now comes
from territory, not cascade rank.

scripts/scope-selectors.mjs rewrites the compiled sheet after Tailwind
runs, prepending :is([data-yv-sdk], [data-yv-sdk] *) to every selector
that is not already gated. Built on Lightning CSS: its Selector visitor
is a typed structural API, so escaped class names like .yv\:mt-4 cannot
be mangled, and @keyframes/@font-face/@Property produce no Selector
nodes and are skipped by construction. It re-parses its own output and
fails the build on any ungated selector. :root and :host stay unscoped;
they define only --yv-* variables and render nothing.

build:css now writes .cache/tailwind.raw.css, gitignored, so the
ungated sheet can never reach dist/ and be published.

Two extra declarations in theme.css that gating alone cannot replace,
because both are specificity-independent:
- color: inherit on descendants. A consumer's `ul { color: … }` matches
  SDK elements directly, so pinning color on the root does nothing.
- box model on the root itself. The universal block is [data-yv-sdk] *,
  which misses the marked element; `* { box-sizing }` does not.

tsconfig.build.json excludes src/test. Phase 1's harness iterates
NodeList under a lib without DOM.Iterable, breaking the declaration
build.

dist/tailwind.css grows 15.7% raw, 3.2% gzipped. Selector, @Property
and @Keyframes counts are unchanged, so no rules were lost.

BREAKING CHANGE: consumer CSS can no longer override SDK declarations.
--yv-* token overrides on [data-yv-sdk] still work and remain the
supported customization surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-0005 covers four decisions in one place, because the layer question
has already flipped three times and the last reversal was documented
only in a changelog entry. It records why layers can never win, why
@scope was rejected, why shadow DOM is not adopted, and why there is no
consumer opt-out prop.

The residual report is generated from harness output, not written by
hand. Two consumer declarations still reach SDK components, both using
!important: `button { padding: 2rem !important }` reaches 155 buttons,
`button { border-radius: 0 !important }` reaches 65 of them. All four
non-!important groups report zero on every component.

ADR-0005 set the shadow DOM threshold before the numbers existed:
recommend it if and only if the residual includes a non-!important
rule. There is none, so it is not adopted.

AGENTS.md drops the strip-layers.js reference, which named a script
deleted in 694325f, and documents the real build chain. README.md
states that consumer CSS targeting SDK internals is unsupported and
now blocked, while --yv-* token overrides still work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment and prose only. No selector, declaration or code path changes.
The full suite still passes, including the 14 hostile-host browser
tests at zero leaks.

theme.css also moves the inherited-properties comment down to the block
it describes. It sat above the box-model block, which made it read as
documentation for the wrong four declarations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0117634

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-core Major
@youversion/platform-react-hooks Major
@youversion/platform-react-ui Major
vite-react 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

Comment thread packages/ui/scripts/scope-selectors.mjs
Comment thread packages/ui/README.md
Comment on lines +69 to +72
- **You cannot style SDK internals, and we never supported it.** If you target
our elements from your stylesheet, our rule overrides yours. Class names,
`data-slot` values and DOM structure are internal. They change without a major
version.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

issue: Aggressive lol

@cameronapak cameronapak changed the title feat!: YPE-4113 WIP: feat!: YPE-4113 Aug 7, 2026
Document the Dialog portal stamp exception and add hostile-host stories for the public language-picker exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cameronapak cameronapak self-assigned this Aug 7, 2026
cameronapak and others added 13 commits August 7, 2026 11:21
Use partner/product language for the style-isolation fixture and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Finish the hostile→consumer rename across stories, tests, Storybook, and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Splits the compiled sheet in two, by property. Every property on a new
exemption list stays unlayered and normal, exactly where it was. Every
other declaration goes into a declared `@layer yv` and gets `!important`.

CSS Cascade 5 §6.1 sorts by importance, then by layer, then by
specificity, and §6.4.4 reverses layer order for important declarations
while ranking unlayered CSS last. A layered important SDK declaration
therefore beats an unlayered important consumer rule at any specificity,
and beats an id selector too, because importance is compared first.

The layer cannot hold the whole sheet. A layered normal declaration
loses to ordinary unlayered consumer CSS, which is the failure mode
ADR-0005 was written to escape. Wrapping everything measured 14 harness
failures, all in font-size, line-height and border-*-width, all exempt
properties demoted below plain Tailwind Preflight.

The exemption list is by property family, with a reason each: keyframe
animated properties, the transform pipeline, animation and transition
controls, the properties Radix and floating-ui write inline, the ones
SDK components write inline, and every custom property. An author
!important outranks both the inline style attribute and the CSS
animation origin, so importantizing any of them removes runtime
behaviour rather than winning an argument.

The pass is text surgery on printed CSS, not a Lightning CSS visitor.
Lightning CSS 1.31.1 cannot round-trip a declaration whose value holds
var() through the JS visitor API; it throws "failed to deserialize;
expected an object-like struct named Specifier". Safety comes from
re-parsing the finished text. The verification now also fails on a
non-exempt normal declaration, an exempt important declaration, an
!important inside @Keyframes, and a missing or misplaced layer block.
verify-styles.js asserts @layer yv{ reaches dist/index.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a sixth consumer-host group, highSpecificity, at 1,0,1:

  #yv-consumer-host-root button { padding; margin; border-radius }

The id goes on document.body, not on #storybook-root. Radix portals the
popovers and dialogs straight into document.body, and a fixture that
cannot reach the portalled DOM tests half of the components. It is set
on every injection, including the clean baseline, so the DOM the diff
compares is identical across snapshots.

The important group changes from a recorded residual to an asserted
zero, and highSpecificity asserts zero as well. Both fixtures target
button, so a component that renders none measures zero for an
uninteresting reason. Every story that does render one now also asserts
that the button matches the fixture selector and that the style tag is
still in the document, so a zero cannot come from a fixture that
stopped matching.

Measured before this change, on the ADR-0005 sheet: important 944
leaks, highSpecificity 1570, over fifteen components. After: 0 and 0.
ConsumerTokenOverrideStillApplies and every existing zero-leak
assertion still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dual

Adds ADR-0006 with the cascade math, the split design and why the whole
sheet cannot go in the layer, the full property exemption list with a
reason per family, the Lightning CSS var() visitor limitation, and the
two residuals that remain. ADR-0005 keeps its gate decision and gains a
cross-link plus superseded markers on the two statements that changed.

Rewrites the residual-leak report against the new measurement. All six
consumer-CSS groups now report zero on all fifteen components. The two
residuals are a consumer rule that is important and in a layer declared
before yv, and the exempt properties, which keep their pre-change
cascade position. Both are deliberate and neither is in the fixture.
The report also names what no automated check covers: popover placement
and enter/exit animation appearance.

The README tells consumers that !important and id selectors no longer
override us, and which properties are still theirs. The changeset says
the same to anyone reading the release notes. packages/ui/AGENTS.md
replaces the "SDK CSS is in no layer, on purpose" bullet with the split,
and records the rule that keeps the exemption list honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a seventh consumer-CSS group, `remRebase`, that injects nothing but
`html { font-size: 62.5% }`, and assert zero leaks on every component.

A `rem` resolves against the document root element. A host page that
rescales the root therefore rescales every `rem` the SDK ships, and no
selector, cascade layer or `!important` reaches that. The leak is in the
unit, not in the cascade, so the six existing groups could never see it.

The group leaks 2,098 computed-style differences against the current
sheet. The assertions here are red until the build converts the sheet's
`rem` lengths to `px`.

Each story also asserts that the document root really computes to 10px.
The harness injects into `document.head`, so without that positive
control a zero could mean the fixture stopped applying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host `html { font-size: 62.5% }` shrank every SDK size by 37.5 percent.
The `remRebase` harness group measured 2,098 leaks. It now measures 0.

Three changes close it:

1. `scope-selectors.mjs` gains a Lightning CSS `Length` visitor that
   rewrites every `rem` length to `px` at 1rem = 16px. That is the
   browser default, so nothing changes on an unmodified host page. The
   visitor reaches declaration values, custom properties, `calc()`,
   `var()` fallbacks, media query conditions and `@keyframes` frames. A
   text replace was not an option: the string `rem` also appears in
   escaped class names Tailwind generates from arbitrary values, and in
   the `.rem` class from bible-reader.css.

2. `global.css` declares `font-size: 16px` on `[data-yv-sdk]`. That
   closes the second channel, inheritance: theme.css declares every
   other inherited text property on the root but not this one, so the
   root took the consumer's body value. `font-size` is exempt, so the
   declaration stays unlayered and normal and a consumer can still
   override it. That override is the accessibility escape hatch.

3. Three inline `rem` literals in component source become `px`. An
   inline style never passes through the build, so the rebase cannot
   reach it.

`verifyOutput` re-parses the result and fails the build on any `rem`
that survives.

Media query `rem` is converted for a different reason than the rest.
Media Queries Level 4 resolves a relative unit in a query against the
initial value of `font-size`, not the root element's computed value, so
a host `html { font-size }` never moved our breakpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR-0007 holds the decision, the measurement and the trade-off.

It also records the alternative the handoff proposed, with data. Setting
an explicit `font-size` on the `[data-yv-sdk]` root removed 10 of 120
leaks on the experiment story, or 8.3 percent, because a `rem` resolves
against the document root and never against an ancestor. It is not a
rival to the rem rebase; it is the smaller half of the same fix, and it
closes the inheritance channel. Both shipped.

The cost is stated plainly: the SDK no longer grows when a reader raises
their browser's default font size. Browser zoom still works, because
zoom scales `px`. A consumer who needs type scaling can override
`[data-yv-sdk] { font-size }`, raise a `--yv-*` token, or pass a
component's `fontSize` prop.

The residual-leak report gains the seventh group, the per-component
before and after, and a third residual. The changeset gains one
paragraph on the consumer-visible change. README and AGENTS.md gain the
same in short form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gate's descendant arm matched every element inside a stamped subtree,
including content a consumer passed in as children or as render-prop
output. theme.css recolored that content and reset its box model, and
after the layered-important change those declarations carried !important
too. Nobody had measured that direction.

The gate's descendant arm is now
`[data-yv-sdk] *:not([data-yv-slot], [data-yv-slot] *)`, and the
descendant blocks in theme.css carry the same exclusion. A new
ConsumerSlot wrapper stamps data-yv-slot on the two trigger sites that
can take a wrapper without changing the rendered DOM shape.

The harness gains a reverse direction. It diffs a consumer child's
computed styles inside a slot against the same markup outside every SDK
subtree, in one document, with the SDK sheet present for both. The three
slotted placements read 0, down from 255, 68 and 68.

The gate now adds 0,2,0 instead of 0,1,0. The dark custom variant gains
the same exclusion, so dark utilities stay above base utilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR-0008 has the decision, the before and after numbers, the baseline it
measured against, and the three sites that cannot take a wrapper.
ADR-0005, 0006 and 0007 gain a Status amendment pointing at it.

The residual-leak report gains the reverse direction and two new
residuals. One is consumer content that is not in a slot: the gate's
rise from 0,1,0 to 0,2,0 lifted the exempt half's normal declarations
above an ordinary consumer rule, so that content went from 255 leaks to
416. The other is a selector whose subject sits past the gate compound.

AGENTS.md gains the slot-stamping rule. The README and the changeset
tell consumers their own content inside our components keeps their
styling, and name the two places they must stamp themselves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The slot exclusion shipped as a bare :not(). That takes the specificity
of its most specific argument, so it raised the gate from 0,1,0 to
0,2,0 across the whole sheet.

Uniform is not harmless here. The sheet ships in two halves, and the
unlayered half holds normal declarations whose only defence is
specificity. The rise lifted font-size, background-color and the
border-*-width longhands above an ordinary consumer rule at 0,1,0.
Measured on the harness: consumer content that is not in a slot went
from 255 leaks to 416, which is worse than before the slot work.

The exclusion now sits inside :where(), which contributes nothing. The
gate is back at 0,1,0, the no-slot control is back at 255, and the three
slotted placements stay at 0. The dark custom variant needed no
compensation once the gate stopped moving: a base utility is 0,2,0 and a
dark: utility is 0,3,0, the same one-step margin as before.

The build check accepts a hand-written bare :not() too. It excludes the
same elements; it just pays for the privilege.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR-0008 now says the gate stays at 0,1,0, why the placement of
:where() is the whole decision, and what the bare :not() cost when it
shipped first. The alternatives section keeps the rejected form with its
number rather than dropping it.

The residual report's control row goes back to 255 and says why that
number has to hold: content the SDK still reaches must not be reached
harder than before. Residual 4 loses its regression and keeps the
history. ADR-0005 and ADR-0006 amendments now say their specificity
claims still stand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

function main() {
const options = parseArguments(process.argv.slice(2));
const ok = runOnce(options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Initial stylesheet write is missed

When Tailwind creates .cache/tailwind.raw.css after the initial runOnce check but before fs.watch is registered, the write produces no observed event and has no retry, leaving dist/tailwind.css missing or stale until another Tailwind rebuild occurs.

Knowledge Base Used: packages/ui

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/scripts/scope-selectors.mjs
Line: 1442

Comment:
**Initial stylesheet write is missed**

When Tailwind creates `.cache/tailwind.raw.css` after the initial `runOnce` check but before `fs.watch` is registered, the write produces no observed event and has no retry, leaving `dist/tailwind.css` missing or stale until another Tailwind rebuild occurs.

**Knowledge Base Used:** [packages/ui](https://app.greptile.com/youversion/-/custom-context/knowledge-base/youversion/platform-sdk-react/-/docs/ui-package.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Cursor Fix in Codex

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.

1 participant