Files
orca/mobile/src/components/TextInputModal.tsx
T
Jinwoo Hong 841d06a969 feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099)
* feat(mobile): the editor document reads its surface from its host's root

The markup gives the editable surface an id, and inside the WebView that is
unambiguous because the document is the page. On the page it is not: a stack
transition keeps the outgoing session screen mounted while the incoming one
starts, so two hosts carry `#editor` at once and a page-wide `getElementById`
hands both documents the first one. The seventh seam is the root, exactly as it
is the terminal's ninth (ruling 24): the WebView names none of them and gets the
whole page, the page names the element its mount planted the markup in.

Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts`
against the page-wide read: 4 failed, 1 passed — content written into the second
host landed in the first, both documents serialized the first surface, an edit in
the second reported through the first document's host, and stopping the first
took the listeners off the surface the second was still using. The one that
passed is the control: a document with no root still reads the whole page, which
is what the WebView gets.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): hold the editor's document rules under its host element

The editor's sheet says `:root`, `*`, `html` and `body` because inside the
WebView it owns the page. Appended to the head of a React Native Web application
all four restyle every screen the shell can show, so the page mount may inject
only what it owns — ruling 19's rule for `window.onerror`, applied to CSS.

The terminal's half of the scoper drops those rules and repaints through a seam,
because the colour `html, body` was setting belongs to the application. The
editor has no such seam and needs none: its host element *is* that editor's page,
so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the
variables every other rule reads, the surface colour, the font, the box model —
and everything else hangs under it. A selector that merely starts at the document
(`body p`) throws rather than being rewritten into something it did not say.

Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`:
6 failed, 0 passed, all on the absent export.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the fifteen toolbar commands are one row both surfaces render

The row of controls is not the WebView's: a press becomes an injected
`runCommand` there and a call on the page, and neither difference belongs in the
toolbar. Extracted so the page's editor does not declare fifteen rows of its own
that would drift from the phone's.

`MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have
needed: the row names every command in the contract, exactly once. Verified red
by dropping `codeBlock` from the row — "names every command in the contract,
once" failed on the 14-member list before the case went back. The native
component's own test and the web fallbacks file stay green unchanged, which is
what says the extraction moved nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the toolbar test inside the tests-typecheck ratchet

`check-tests-typecheck-ratchet.mjs` reported the new file as newly failing
`tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any
`createElement` overload, and comparing a node's `ElementType` against the string
`'Pressable'` is a no-overlap comparison. Host strings for the mock and
`String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800
files.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the rich Markdown editor mounts on the page

`react-native-webview` renders nothing in a browser, so C7.6 gave the page a
plain Markdown field and recorded the toolbar and the rendered view as a
degradation. Ruling 26 makes that debt rather than done: the page mounts the
document itself. `rich-markdown-web-document-mount.ts` is the editor's half of
what `terminal-web-document-mount.ts` does for the terminal — the sheet held
under the host's class, the markup planted in the host, one factory call, and a
dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the
component over it, with the same fifteen-command toolbar and the same controller
the phone uses, so `MarkdownReader` cannot tell which sibling it has.

Three seams are the page's rather than the window's. Messages reach
`handleMessage` directly and never `window.ReactNativeWebView`, which on the page
is the shell's bridge. The URL for Link and Image comes from `TextInputModal`:
`window.prompt` was measured to return null in both shells, so those two commands
silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange`
is never called — the screen's `keyboard-occlusion.web.ts` measures the same
viewport with the same formula, and a report here would lift its bar twice.

Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the
absent module, and its listener case is the one that holds ruling 21 — a second
mount reports its own edits and the first mount's detached surface reports
nothing, with an event dispatched on it to say so. The four new cases in
`mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still
in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput`
the page is meant to have lost.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it

`#editor` computed to 14 px, measured in both engines. iOS zooms the page on
focus of any editable under 16 px and does not zoom back, and
`keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale
other than 1 — the exact failure the floor exists for, on the page's only
full-screen writing surface. The size now comes from the text-input seam, which
is also where the two hosts part: the phone keeps the app's body size because a
WebView has no page to zoom, the page gets the raise, and one binding moves both
if the floor ever does.

The `TextInput` census could not have caught it. `modulesDeclaringTextInput`
matches JSX tags and `style` props, and this is a `contenteditable` in a markup
string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs`
starts from the markup instead: it finds every editable host a closure declares,
follows its id to the rule beside it, and reads the size the same way — a literal
at or above the floor, or the seam's own export imported from the seam's module.
An editable with no id, or one no sibling sheet styles, is reported unresolved
rather than passed.

Red first. The rule's own file reported
`src/components/rich-markdown/document-style.ts:36` as the offender before the
fix (4 failed, 3 passed on the first run, the other three being the brace scanner
and the line-start anchor the fixtures found). The closure case in
`mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the
one editable in the session route's closure and its offender list is empty:
1 passed, 4 skipped under `-t editables`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the caret survives the host's URL dialog, so Link and Image insert

Measured in the render check, on both engines: the Link and Image commands opened
the modal, took the URL, and inserted nothing. The dialog is what takes the
caret — the modal focuses its own field — and `execCommand` on a document that
does not hold the selection does nothing at all. So the page had swapped one
silent failure for another: `window.prompt` returning null on the phone, and a
command with no selection on the page.

Two halves. The document remembers its caret before it waits and puts it back
after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd`
needs a flag, because the wait itself is the blur); if the host replaced the
content while the dialog was open, the remembered range is gone from the document
and the caret goes to the end instead. And the component answers the promise from
the drawer's `onAfterClose` rather than from the submit, because WebKit would not
take the focus back while the field still held it — with the answer released on
submit, chromium inserted and WebKit did not.

`TextInputModal` forwards `onAfterClose` for that, which is the one thing it did
not already pass through to `BottomDrawer`.

Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`:
2 failed, 7 passed — the caret was left in the dialog's field, and a replaced
document did not fall back to the end. The render check's Link/Image case went
from failing on both engines to inserting on both, with the inserted image's
`naturalWidth` above zero under the shipped policy.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the page's rich Markdown editor, in both engines under the shipped header

`config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component,
mounted by the real React, driven through its toolbar in chromium and webkit under
the policy read out of the shell's own Kotlin source. Sixteen cases, eight per
engine.

What it measures rather than asserts: all fifteen commands change the document,
each with the precondition that what it produces was not there first; the surface
computes to the 16 px floor and the document's own `--editor-surface` variable is
set on the host and nowhere on the root element; `ready` and `change` cross the
seam while `window.ReactNativeWebView` — defined by the rig so its absence is a
reading — is never touched; Link and Image are answered by the modal, and the
inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap
and one per inline code; a link tap reaches the host instead of navigating; a
remount leaves the listener snapshot and the scheduler exactly where one whole
cycle left them (rulings 20 and 21); and two editors on one page hold their own
content and report their own edits.

Four harness facts the first runs found, each now in a comment: the entry needs
four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every
case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or
`react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the
route's navigator supplies and a bare mount does not; and the document's markup,
not its text, as the oracle for a content reset — `### body text here` and `body
text here` read the same, so a text wait passed on the document it was replacing.

One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `<ul>`
inside the `<p>` it was given and the serializer walks back out with the same
text, so a bullet list does not survive a round trip there. The phone's WebView is
the same engine, so this is not something the page introduces; the case names the
command's own element and the reset is numbered per command to work around it.

Run 5 of 5: 16 passed, 0 failed, 0 errors, exit 0, 6.58s.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the session route's closure for the editor on the page

Both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base
`9267423f22`, all five postinstall generators run first, the before side a scratch
worktree detached at that sha:

  modules        4333 -> 4360   (+27)
  local modules   991 -> 1018   (+27)

All 27 are local and none is vendored, which is the point: the editor is the app's
own code, not a library. The document's 24 modules under `src/components/rich-markdown/`
were reachable from nothing on the page while it rendered a plain field, and the
other three are the mount, the shared toolbar, and the controller with its
keyboard-inset module. Nothing leaves, because the web sibling replaces its own
native file and that file was never in this closure. Named by diffing the two
`local` lists, not inferred from the total.

`document-style-scoping.ts` is on both sides: the terminal's mount already brings
it, so the editor's second export costs no module.

The generation, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes
(+27,748) across 109 assets against the 9 MiB ceiling, 85.1% -> 85.4%. The script
count does not move (67 against the 76 the chunk fence allows for 15 routes) and
neither does the entry's static closure (1,612,052 bytes against 3 MiB) — this is
code the route already reached for, not a new chunk boundary.

The grant census needs nothing: `openExternalLink` is the editor's only seam with a
grant behind it, and the session route already declares `externalLink` for six
other openers. `mobile-web-app-page-grant-call-sites.test.mjs` passes unchanged.

Closure, webview-consumer and grant censuses together: 28 passed, 0 failed, exit 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop an oxlint directive the changed-code gate reads as unused

`check-changed-code-quality.mjs` failed with one finding: the mount effect's
`react-hooks/exhaustive-deps` disable reports no problem under that config, so the
directive itself is the finding. The reason it carried is worth keeping and now
reads as a plain comment — the effect mounts once, with `promptForUrl` taken from
the closure, because re-running it would throw away a live document and the caret
in it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(config): the editable-host census counts every editable tag, not every id

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:50`, and right on the
code: the pattern started from `id="…"`, so it matched only hosts that carry one.
The no-id guard fired for a file with *no* named host at all, which means a file
holding a named host beside an anonymous one reported the named one as clean and
said nothing about the other. An editable is its tag; the id is read out of the
tag afterwards.

Also `:145`, also right: the sibling search was `startsWith(directory + '/')`,
which reaches the subtree, and the walk stops at the first file whose sheet opens
the host's selector. The closure's order is the bundler's rather than
alphabetical, so a sheet one directory down could answer for the sibling the host
actually gets. Now the immediate directory only.

Red first, both cases in the census's own file. The mixed fixture reported one
host where two were planted (1 failed, 7 passed); the nested fixture, with the
nested sheet first in the closure and a compliant 18 px rule in it, hid a 14 px
sibling and reported no offender (1 failed, 8 passed). 9 passed after.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(config): an editable with no declared size is unresolved, not a pass

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:110`, and right for the
CSS case: `readFontSize` answered `onSeam: true` for a rule that declares no
`font-size`, so the offender check accepted the host without being able to say
what size it gets. The inherited value comes from a rule this walk does not read —
the host element's own, or the page's root — and it can be 14 px.

So "no declaration" becomes "cannot say" and lands in
`unresolvedEditableHostStyles`, which the session closure census holds at empty.
Not an offender: an offender is a size this walk read and found under the floor.

The `TextInput` half of the seam still lets an absent `fontSize` through as
inheritance. That is main's policy and it is about a prop rather than a cascade, so
it is not touched here; the divergence is stated in the reader's own comment.

Red first: the inheritance fixture reported no unresolved host where the size is
unknowable (1 failed, 8 passed), 9 passed after. The real tree is unaffected —
the editor declares its size on the seam — and the closure census still reads an
empty unresolved list.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(config): the editable-host census reads the font-size the cascade uses

CodeRabbit, `mobile-web-app-editable-host-font-size.mjs:119`, and right: the walk
read the first `font-size` in a rule, and CSS takes the last of equal importance.
`font-size: 16px; font-size: 14px;` was therefore reported compliant for a surface
the browser renders at 14 px. `!important` outranks every declaration that is not,
whatever the order.

The flag is also stripped from the value, which the finding did not name but the
fixture caught: without that, a compliant size carrying `!important` was reported
as an offender, because it matched neither the literal nor the substitution shape.

The declarations are split on the separator rather than matched with a value
pattern. A pattern excluding `}` cut `${TEXT_INPUT_FONT_SIZE}px` at the brace of
its own interpolation and reported the real editor as an offender — caught on the
first run of the fix, and the reason the split is the shape here.

Red first: 2 failed, 9 passed — the repeated-declaration fixture reported no
offender, and the important-declaration pair reported the wrong one of the two.
11 passed after.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): the render check reads the floor from the seam instead of retyping it

pullfrog, `mobile-web-app-rich-markdown-render.test.mjs:37`, and right: the comment
said the floor was read from the seam and the constant was the literal `16`, which
is the shape the seam exists to prevent. It now comes from
`textInputFontSizeFloor(mobileDir)`, the same reader the closure census uses, which
throws rather than defaulting when the seam is gone.

The assertion becomes "at or above the floor" rather than equal to it. The seam is
`Math.max(bodySize, floor)`, so a theme raising the body size past the floor raises
what the page computes; equality against the floor would have been the same stale
literal one module further away.

Two controls, both run. Raising `TEXT_INPUT_FONT_SIZE_FLOOR` to 18 in the seam
keeps the case green on both engines, because the stylesheet reads the same module
and the page computed 18 — the two moving together is the point. Replacing the
stylesheet's `${TEXT_INPUT_FONT_SIZE}px` with a literal `14px` reds it on both
engines, `expected 14 to be greater than or equal to 16`, which is what says the
assertion carries weight. Both files were restored.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the editable-host census reads every rule that sizes the host (round 3)

`ruleFor` returned the first exact `#id` rule and the walk stopped there, so a
later exact rule of equal specificity, or a higher-specificity subject rule that
still targets the host, could lower the rendered size unseen.

Every exact rule in the sheet is now collected in source order and read as one
cascade, and any other rule whose subject compound targets the host and declares
`font-size` makes the host unresolved rather than compliant. No specificity
arithmetic, and the sibling walk is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 22:54:32 -04:00

178 lines
4.7 KiB
TypeScript

import { useState } from 'react'
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Platform,
type KeyboardTypeOptions
} from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { BottomDrawer } from './BottomDrawer'
type Props = {
visible: boolean
title: string
message?: string
defaultValue?: string
placeholder?: string
submitLabel?: string
selectTextOnFocus?: boolean
allowEmpty?: boolean
keyboardType?: KeyboardTypeOptions
onSubmit: (value: string) => void
onCancel: () => void
/** Called once the drawer has gone, which is when the field stops holding the focus. */
onAfterClose?: () => void
}
export function TextInputModal({
visible,
title,
message,
defaultValue = '',
placeholder,
submitLabel = 'Save',
selectTextOnFocus = false,
allowEmpty = false,
keyboardType,
onSubmit,
onCancel,
onAfterClose
}: Props) {
const [value, setValue] = useState(defaultValue)
const [previousVisible, setPreviousVisible] = useState(visible)
const [previousDefaultValue, setPreviousDefaultValue] = useState(defaultValue)
// Why: reset before the opening commit so the drawer never paints the
// previous modal value while preserving the existing close animation state.
const shouldResetValue = visible && (!previousVisible || defaultValue !== previousDefaultValue)
if (visible !== previousVisible || shouldResetValue) {
setPreviousVisible(visible)
if (shouldResetValue) {
setPreviousDefaultValue(defaultValue)
setValue(defaultValue)
}
}
function handleSubmit() {
const trimmed = value.trim()
if (trimmed || allowEmpty) {
onSubmit(trimmed)
}
}
const canSubmit = allowEmpty || value.trim().length > 0
return (
<BottomDrawer visible={visible} onClose={onCancel} onAfterClose={onAfterClose}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="none"
autoCorrect={false}
selectTextOnFocus={selectTextOnFocus}
keyboardType={keyboardType}
returnKeyType="done"
onSubmitEditing={handleSubmit}
selectionColor={colors.accentBlue}
/>
<View style={styles.actions}>
<Pressable
style={({ pressed }) => [styles.cancelButton, pressed && styles.buttonPressed]}
onPress={onCancel}
>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.submitButton,
pressed && styles.buttonPressed,
!canSubmit && styles.submitButtonDisabled
]}
disabled={!canSubmit}
onPress={handleSubmit}
>
<Text style={styles.submitText}>{submitLabel}</Text>
</Pressable>
</View>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
message: {
fontSize: 13,
color: colors.textMuted,
marginTop: 2
},
// Why: matches NewWorktreeModal's input — bgRaised on the modal
// background reads as a tappable surface (brighter than the wrapper)
// rather than a recessed pit (darker than the wrapper, which is what
// bgBase looked like inside a bgPanel group).
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.md
},
cancelButton: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
submitButton: {
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
buttonPressed: {
opacity: 0.7
},
submitButtonDisabled: {
opacity: 0.4
},
cancelText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
submitText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
})