Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Inactive `NativeSheet` hosts may remain mounted for WebView pre-warming, but the

Mounted immediately with empty placeholder data to cold-start the WebView during page load.

### Bundled Native Module (hanging indent)

The UI package ships its own Expo native module — `YouVersionScriptureParagraph` (`packages/ui/ios/`, `packages/ui/android/`, `packages/ui/expo-module.config.json`) — for the faithful poetry/list **hanging indent** that RN `<Text>` can't express (`NSParagraphStyle` on iOS, `LeadingMarginSpan` on Android; ADR 0011). It **autolinks** when the package is installed, so consumers author no native module. The JS wrapper is `src/native/scripture/scripture-paragraph-native.tsx` (`requireNativeView('YouVersionScriptureParagraph')`, web-guarded), and `ScriptureTextView` wires it as the **default** `renderHangingParagraph` on native (web falls back to the renderer's RN approximation); the prop stays as a consumer override. The module ships **source** (podspec `source_files` + gradle build-from-source), so unlike `@expo/dom-webview` it needs no `buildFromSource` entry. This makes `@youversion/platform-react-native-expo-ui` a native module package: `package.json` `files` ships `ios`/`android`/`expo-module.config.json`, and `.npmignore` keeps `android/build` artifacts out. In jest, `requireNativeView` is stubbed in `jest.setup.js`.

### Font/Theme Overrides

CSS custom properties on `[data-slot="yv-bible-renderer"]`: `--yv-reader-font-size`, `--yv-reader-font-family`, `--yv-reader-bg`, `--yv-reader-fg`
Expand Down
6 changes: 4 additions & 2 deletions apps/example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"plugins": [
"expo-router"
"expo-router",
"expo-localization"
],
"experiments": {
"typedRoutes": true
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.youversion.platform"
"bundleIdentifier": "com.youversion.platform",
"appleTeamId": "64J8D24PR8"
},
"android": {
"package": "com.youversion.platform",
Expand Down
4 changes: 4 additions & 0 deletions apps/example/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export default function Layout() {
<NativeTabs.Trigger.Label>Bible</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="book.fill" md="menu_book" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="native-reader">
<NativeTabs.Trigger.Label>Native</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="text.book.closed.fill" md="auto_stories" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="verse-of-the-day">
<NativeTabs.Trigger.Label>VOTD</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="sun.max.fill" md="wb_sunny" />
Expand Down
123 changes: 123 additions & 0 deletions apps/example/app/(tabs)/native-reader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
Inter_400Regular,
Inter_400Regular_Italic,
Inter_700Bold,
Inter_700Bold_Italic,
} from '@expo-google-fonts/inter'
import {
SourceSerif4_400Regular,
SourceSerif4_400Regular_Italic,
SourceSerif4_700Bold,
SourceSerif4_700Bold_Italic,
} from '@expo-google-fonts/source-serif-4'
import { ScriptureTextView } from '@youversion/platform-react-native-expo-ui'
import { useFonts } from 'expo-font'
import { useState } from 'react'
import { Alert, Pressable, ScrollView, Text, useColorScheme, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

/**
* Passages chosen to exercise the native renderer's open device-proof items
* (ADR 0010). Each isolates a fidelity gap so an Android pass can confirm or refute
* it against the WebView `BibleReader` on the "Bible" tab.
*/
const PASSAGES = [
// Control: every footnote here sits on a plain `.p` prose paragraph (RN bubble path),
// so tapping one does NOT go through the native hanging module. Use it to tell whether
// the footnote-sheet shift is the sheet itself vs the native paragraph.
{ label: 'John 3', usfm: 'JHN.3', proves: 'prose footnotes (no native module)' },
// Prose + footnotes, including verses that run across blocks (the cross-block
// footnote fix). Tap a footnote marker → the drawer should show the whole verse.
{ label: 'Acts 15', usfm: 'ACT.15', proves: 'footnotes · cross-block verses' },
// OT prose dense with the divine name (`.nd` → small-caps). On Android, watch for
// "LORD"/"GOD" rendering as plain caps — fontVariant small-caps is spotty there.
{ label: 'Exodus 20', usfm: 'EXO.20', proves: 'small caps (divine name)' },
// Poetry (`.q1`/`.q2`) plus the divine name. With the native hanging-paragraph module
// wired (ADR 0011), wrapped poetic lines should hang at the wrapped-line position —
// narrow/rotate the device to force a wrap and confirm. Small caps still iOS-only.
{ label: 'Psalm 23', usfm: 'PSA.23', proves: 'native hang-indent · small caps' },
] as const

/**
* Native (non-WebView) scripture reader demo. Fetches the selected passage live via
* the hooks package (`usePassage`, inside `ScriptureTextView`) and renders it with
* the curated USFM → React Native style map. Compare against the WebView
* `BibleReader` on the "Bible" tab — Android is the priority surface.
*/
export default function NativeReaderScreen() {
const isDark = useColorScheme() === 'dark'
const { top } = useSafeAreaInsets()
const background = isDark ? '#121212' : '#ffffff'
const foreground = isDark ? '#ffffff' : '#121212'

const [selected, setSelected] = useState(0)
const passage = PASSAGES[selected]!

const [fontsLoaded] = useFonts({
SourceSerif4_400Regular,
SourceSerif4_700Bold,
SourceSerif4_400Regular_Italic,
SourceSerif4_700Bold_Italic,
Inter_400Regular,
Inter_700Bold,
Inter_400Regular_Italic,
Inter_700Bold_Italic,
})

if (!fontsLoaded) {
return <View style={{ flex: 1, backgroundColor: background }} />
}

return (
<View style={{ flex: 1, paddingTop: top, backgroundColor: background }}>
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
paddingHorizontal: 16,
paddingBottom: 8,
}}
>
{PASSAGES.map((p, index) => {
const active = index === selected
return (
<Pressable
key={p.usfm}
onPress={() => setSelected(index)}
style={{
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 16,
borderWidth: 1,
borderColor: active ? '#2563eb' : foreground + '33',
backgroundColor: active ? '#2563eb' : 'transparent',
}}
>
<Text style={{ color: active ? '#ffffff' : foreground, fontWeight: '600' }}>
{p.label}
</Text>
</Pressable>
)
})}
</View>
<Text style={{ paddingHorizontal: 16, paddingBottom: 8, color: foreground + '99', fontSize: 12 }}>
Proves: {passage.proves}
</Text>
<ScrollView>
<ScriptureTextView
key={passage.usfm}
versionId={111}
usfm={passage.usfm}
// Poetry/list (`.q*`/`.li*`) blocks render through the SDK's bundled native
// hanging-indent module (ADR 0011) by default — first line flush, wrapped lines
// hung. Exodus 20's list items and Psalm 23's poetry are the proof cases. No
// app-side wiring needed; pass `renderHangingParagraph` only to override.
onVersePress={(verse) =>
Alert.alert('Verse selected', `${passage.label}:${verse}`)
}
/>
</ScrollView>
</View>
)
}
12 changes: 11 additions & 1 deletion apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo-google-fonts/inter": "0.4.2",
"@expo-google-fonts/source-serif-4": "0.4.1",
"@expo/dom-webview": "56.0.5",
"@expo/metro-runtime": "56.0.15",
"@gorhom/bottom-sheet": "5.2.14",
Expand All @@ -23,9 +25,10 @@
"expo": "56.0.12",
"expo-dev-client": "56.0.20",
"expo-linking": "56.0.14",
"expo-localization": "~56.0.6",
"expo-router": "56.2.11",
"expo-status-bar": "56.0.4",
"expo-secure-store": "56.0.4",
"expo-status-bar": "56.0.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
Expand All @@ -45,5 +48,12 @@
"@types/react": "19.2.14",
"typescript": "6.0.3"
},
"expo": {
"autolinking": {
"buildFromSource": [
"expo-dom-webview"
]
}
},
"private": true
}
145 changes: 145 additions & 0 deletions docs/adr/0010-native-scripture-rendering-spike.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Native (non-WebView) scripture rendering

> Status: **spike / experimental** (branch `YPE-3169`). Ships behind the new
> `ScriptureTextView` export; the WebView `BibleReader`/`BibleTextView` remain the
> supported path. This ADR records the route investigated, not a migration.

## The problem

Every Bible surface renders the Web SDK HTML inside an Expo DOM Component (a
WebView) — see [`0001`](0001-reuse-web-sdk-content-with-native-presentation.md).
That path carries recurring WebView pain: the Android `@expo/dom-webview`
null-`localStorage` blank-render (`lib/dom-local-storage.ts`), Fabric codegen
blanks (`buildFromSource`), release-build blanks, the iOS injection-quote bug
([`0009`](0009-bridge-safe-font-tokens.md)), and WebView cold-start. This spike
asks whether the **reading surface** can be rendered natively instead, removing
the WebView (and its failure modes) for scripture.

## The decision

Render scripture **natively** from the YouVersion API's HTML, mapping its USFM
CSS classes to React Native styles. No WebView, no DOM, no `transformBibleHtml`.

- **Parser** — `node-html-parser` (pure JS, Hermes-safe) parses the HTML into a
small `ScriptureNode` AST (`native/scripture/parse-scripture-html.ts`). The Web
SDK's own parse path is browser-bound (`isomorphic-dompurify` +
`dangerouslySetInnerHTML` + `document.createTreeWalker` in
`platform-core/bible-html-transformer.ts`), none of which exist in Hermes, so
it cannot be lifted; we reuse the _algorithm's intent_, not the runtime.
- **Curated style map** — `native/scripture/scripture-class-styles.ts` translates
`platform-core/src/styles/bible-reader.css` (the complete USFM catalog) and the
`theme.css` color tokens **verbatim** into `BLOCK_STYLES` / `INLINE_STYLES`.
Em-based CSS values are kept as multipliers and resolved against the reader's
base font size so size changes cascade like the web reader.
- **Renderer** — `native/scripture/scripture-renderer.tsx` walks the AST to nested
`<Text>`/`<View>`. The load-bearing RN fact: **nested `<Text>` flows mixed-style
inline runs**, which is exactly a scripture paragraph. Verses are grouped at
render time from `.yv-v[v]` boundaries (empty marker = raw start; populated =
transformed wrapper) into pressable runs; footnotes are surfaced from `.yv-n.f`
(raw) or `[data-verse-footnote]` (transformed) as pressable markers.
- **Footnote drawer** — tapping a marker opens a **fully native** drawer
(`native/scripture/scripture-footnote-sheet.tsx`), the non-WebView analog of the
DOM `FootnoteContent` sheet, mirroring its layout: the **verse reference**, the
**verse text** (with a superscript letter at each note's position), then the
verse's **notes** listed below (each prefixed with its matching `a`/`b`/… letter,
wrapping at `z`). The reference is the human passage reference (`passage.reference`,
e.g. `Acts 15`) + `:verse` → `Acts 15:2`, falling back to `usfm.verse` then
`Verse N` when no reference is available (offline `html` path). The renderer
threads this verse context to each marker (verse tokens + lettered notes,
mirroring the Web SDK's `getVerseHtmlFromDom` which swaps anchors for `<sup>`
letters), so a tapped note surfaces every note in its verse, not just itself. It
reuses the shared `NativeSheet` (a `@gorhom/bottom-sheet`, not a WebView) and
renders each note **richly** via `renderFootnoteHtml`, which runs the note's
inner HTML back through the same inline renderer so USFM footnote character
styles (`fr` reference, `ft` text, `fq`/`fqa` quotes) match reader styling —
keeping the whole reading surface WebView-free. Both footnote shapes feed it: the
transformed shape supplies the note as `data-verse-footnote-content`, and for the
raw `.yv-n` shape the parser captures the footnote's inner HTML as
`footnoteContent`. The drawer is **internal** — `ScriptureTextView` always owns
it; footnote handling is not a consumer-facing prop or export.
- **Data** — `ScriptureTextView` fetches via `usePassage` from
`@youversion/platform-react-hooks` (`passage.content` is raw HTML). The hooks
`YouVersionProvider` is mounted natively with a **resolved** theme (never
`'system'`, which would hit `window.matchMedia`) and the RN `x-yvp-sdk` header.
This is the first web-SDK React provider mounted in the native tree; it is
renderer-agnostic context + `fetch` (no react-dom) and its
`YouVersionPlatformConfiguration` writes are idempotent with core's.

Because the API HTML renders directly, **no transform and no browser DOM is
needed anywhere** on the native path, even with notes enabled.

## Alternatives rejected

- **`react-native-render-html`** — heavyweight, maintenance-stalled general CSS
engine; its `classesStyles` maps poorly onto nested USFM semantics and it fights
verse/footnote press wiring. We want a thin tree we fully control.
- **Shim the DOM** (`document`/`TreeWalker`/jsdom) to run the SDK's transformer
verbatim — recreates the WebView dependency we are removing; DOMPurify alone
needs broad DOM surface.
- **Pre-transform to `[data-verse-footnote]` HTML** before rendering — needs a DOM
on native. Unnecessary: the renderer handles raw `.yv-n.f` directly.

## Known RN fidelity gaps

- **text-indent / hanging indent** — RN `<Text>` has none. Prose first-line indent
(`.p`/`.pi`/`.po`, positive `text-indent`) is emulated with a leading em-space (first
line only). Poetry/list **hanging** indent (CSS `padding-inline-start` + **negative**
`text-indent`) is **not reproduced**; the renderer applies the net first-line position
as a uniform `paddingLeft` (`q1` flush, `q2`/`q3` +1em, `q4` +2em; lists at the SDK
`padding-inline-start`), so single-line verses staircase correctly but wrapped lines do
not hang. Four approaches were tried and abandoned:
(1) a per-word `flexWrap` row — the negative-margin pull was clamped by Yoga and
isolating words broke superscript baselines and small-caps/colour inheritance;
(2) `onTextLayout` line-text word-counting — per-line `text` is empty under the new
architecture; (3) a binary-search on `lines.length` via a hidden measuring `<Text>` —
`onTextLayout` produced no usable line data on the Fabric dev build;
(4) a **flex-row gutter** (verse number in a fixed `width = wrapped − first` gutter,
verse text in a `flex:1` column) — **device-tested and rejected:** it produces a
*label + block* layout, not a hang. Because the text column has a single left edge, the
**whole verse** (including the first line) sits at the wrapped-line position with a gap
after the number — the opposite of a hanging indent, where the first line is *less*
indented than the wrapped lines. Flowing verse text must continue on the first line
immediately after the number (at `first`) and only *wrapped* lines indent to `wrapped`;
that is precisely the negative first-line indent RN cannot express on a single `<Text>`.
This rules out a pure-RN solution: the platforms expose the capability natively
(`NSParagraphStyle.firstLineHeadIndent`/Android `LeadingMarginSpan`) but RN does not
surface it, so **a native paragraph-style module is the only faithful fix** — scoped in
[`0011`](0011-native-paragraph-style-module-for-hanging-indent.md).
- **super/subscript** — RN has no inline `vertical-align`/OpenType `sups`, and `transform`
is ignored on a *nested* `<Text>` (merged into the parent's attributed string). We instead
render an **inline `<View>`** (a real view) inside the flowing `<Text>` and raise/lower it
with `transform: translateY`; the inner `<Text>` keeps the **real characters**, so it works
for any numeral system/script (i18n-safe, unlike Unicode glyph substitution). Used for verse
numbers and `.sup`/`.sub`/`.ord`/`.vp`/`.fv`. Because the shift wraps the run in a real
`<View>`, RN text inheritance does **not** cross into the inner `<Text>`: the reader font
(whose metrics the rise is tuned against) and the themed foreground colour must be threaded
in explicitly, or the glyph falls back to the system font (dropped baseline) and black
(invisible in dark mode). The renderer now passes both for body super/subscripts, matching the
verse-label and footnote-drawer paths. Inline views in text remain platform-sensitive
(baseline alignment, line-height) — verify on **iOS and Android** and tune
`SUPERSCRIPT_RISE_EM`/`SCRIPT_SCALE`.
- **footnote marker** — rendered as the Web SDK's note-bubble icon (the same 24×24
`Footnote` SVG path, via `react-native-svg`) rather than a placeholder glyph, so
markers match the web reader. Like the super/subscript shift it flows as an inline
`<View>` in the verse `<Text>` (the SVG owns the press target), so its vertical
placement is platform-sensitive — tune `FOOTNOTE_ICON_EM`/`FOOTNOTE_ICON_DROP_EM`
on device. `react-native-svg` is already a peer dep.
- **small-caps (`nd`/`sc`)** — `fontVariant: ['small-caps']` is iOS-only / Android-
spotty.
- **fonts** — full parity needs Source Serif 4 + Inter registered via `expo-font`
(the example app loads them via `@expo-google-fonts/*`); unloaded variants fall
back to the system font.

## Verification

- Layer-1: `parse-scripture-html` (raw Acts 15 + transformed-footnote fixtures) and
`scripture-class-styles` (class → style, light/dark, font cascade).
- Layer-3: `renderScriptureHtml` renders verse text, verse press → `onVersePress`,
and footnote markers carry verse context (reference, verse text, all notes);
`ScriptureTextView` marker press opens the built-in native drawer showing that
context (`native/scripture/__tests__/`).
- Bundle: `apps/example` exports cleanly for iOS via Metro (hooks package,
`node-html-parser`, fonts all resolve in Hermes).
- Device (manual, remaining): the example "Native" tab fetches Acts 15 live and
renders it; compare against the WebView reader on iOS **and** Android — Android
is the priority, being where the WebView blanks this is meant to eliminate.
Loading