Files
orca/mobile/web-entry/web-overrides.json
T
Jinwoo Hong e9b180685b feat(mobile): render Mermaid diagrams on the page from one deferred engine artifact (OTA phase C, C7.10 B) (#21871)
* test(mobile): measure mermaid rendered in the page

Red-first for C7.10 item B. The check mounts the real web sibling in
chromium and webkit under the shipped shell CSP and asks four things of
it: that a diagram renders with zero policy violations and zero eval /
new Function calls, that the SVG is the native buildHtml's own output
once the diagram id and xmlns:xlink are normalised away, that a hostile
diagram lands inert, and that a source change, an unmount and a remount
leave exactly one SVG and no listener of the first mount.

The equality oracle is buildHtml itself, bundled for Node behind a
Proxy stub for its native imports and served as its own document in the
same browser, so neither side of the comparison is retyped.

All eight cases fail on this commit: the sibling is still the labelled
source box.

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

* test(mobile): fence the session download rather than its module list

Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds
dynamically imported modules under splitting: true exactly as it does
under splitting: false, so it cannot say "on demand" about anything: an
on-demand mermaid moves the session route's module list 4320 -> 6362
while its download does not move at all.

So the fence moves to entryStaticClosure. The new helper walks the
emitted chunks from the output the route's own module landed in and
follows import-statement edges only, and hands back both halves, because
mermaid's absence from the download is only a measurement while its 66
files are present in the deferred half.

The module list's new total is recorded in the docstring with its reason
and asserted beside the engine's own file count, which moves only when
the pinned mermaid version does.

Red on this commit: no mermaid in the closure yet.

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

* feat(mobile): render mermaid in the page

The web sibling stops being a source box. mermaid is a browser library,
so the page imports it inside the render effect and draws the diagram in
this document: no WebView, no 3.7 MB engine string, and nothing of the
engine downloaded by a session with no diagram on it.

What replaces the sandbox is mermaid's own securityLevel: 'strict',
which runs its serialized SVG through DOMPurify. The native path's
</script> escaping has no analogue here and needs none, because the
source is a JS string argument rather than text spliced into an inline
script. Measured in both engines: a script in a label, a </script>, an
onerror and a javascript: click all land inert.

The configuration is now one object both hosts read, so the theme cannot
drift between the page and the phone; buildHtml serializes it instead of
holding a second copy. It gains suppressErrorRendering, because mermaid
otherwise draws its own error diagram into a temporary element and leaves
that element behind when it rethrows -- an orphan SVG on the page, and on
native a diagram the component is about to replace with the source box
anyway.

The dispose clears the host on unmount and on a source change; the id is
a useId, because mermaid writes it into the stylesheet inside the SVG and
it has to be a CSS identifier.

Also re-records the closure total the previous commit pinned: with the
real component the session route's module list is 6376, not the design
probe's 6362, and the reason is in that file's docstring.

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

* fix(mobile): budget the deferred engine's chunks apart from the routes

Putting mermaid on the page took the app bundle from 69 emitted scripts
to 172, and the asset budget failed: 215 assets against a ceiling of
115. The cause is not a page split running away, which is what that
ceiling is for -- it is that mermaid lazily imports each of its own
diagram types, so one import() lands 103 scripts no route count
predicts.

So the ceiling gains a second term, named and measured (172 scripts with
mermaid against 69 with it aliased to a stub, at 11.17.2), rather than
the route term being raised to cover it. A page split running away still
fails on the route term, and the failure still says which of the two
grew.

The consequence is worth reading twice: the derived ceiling has to stay
inside the 256 assets the shell will load, and with 42 images it now
crosses that at 24 routes instead of 50. The bundle is at 215 today with
14 routes, so there is room for about ten more routes before a green
build produces a manifest no phone will open.

Measured by the config/scripts suite failing on this head, not predicted.

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

* refactor(mobile): pre-bundle the page's mermaid into one artifact

import('mermaid') from inside the app bundle emitted 103 scripts, not
one: mermaid lazily imports each of its own diagram types and esbuild
splits along those boundaries. Every one of those scripts sits inside
the OTA generation the phone has already downloaded, so the split moved
no bytes over the wire and spent 103 of the 256 manifest assets the
shell will load -- which is the scarce resource here, and the reason the
previous commit had to invent a second ceiling term.

So a sibling generator bundles the package into one ESM module beside
the WebView engine it already builds, emitted by the same postinstall
run, gitignored and lint-ignored with the others. The page imports that
artifact on demand instead, through a loader whose return type names the
two calls the component makes -- checked against the artifact's own
inferred export rather than cast to it.

Measured, at 14 routes:

  emitted scripts   172 -> 69   (68 with no deferred engine at all)
  manifest assets   215 -> 112  (111 with none)
  session modules  6376 -> 4323 (+3 over main: config, loader, artifact)
  chunks fetched for one graph TD   27 -> 1
  bytes fetched      837,530 -> 3,482,965

The static-closure fence is unchanged in meaning and now reads on the
artifact: absent from every chunk the route reaches by an import
statement, present in the deferred half. The rendered SVG is byte-for-
byte what it was, so the equality against the native document still
holds on both engines.

Also adds the diagram to the webview-consumers list, which is what that
list means: its native component imports the package and its sibling is
what the builder resolves instead.

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

* revert(mobile): drop the deferred-engine ceiling term, keep the control

With the engine pre-bundled into one artifact the bundle emits 69 scripts
at 14 routes against the route term's 72, so the second term this series
added has nothing left to do and the route count is the only term again.
mobileWebAppBundleMaxChunks and the asset ceiling derived from it are
back to what main has; the shell's 256 assets are crossed at 50 routes
again rather than at 24.

What stays is why. A ceiling raised to admit 172 scripts would have
admitted any split at all, so the budget test gains the control that
holds the line: the single-artifact count passes the ceiling and the
lazily-chunked count fails it, both measured at 14 routes, with mermaid
named as what produced the second.

Red before the term came out: the control failed asserting 172 > 175.

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

* test(mobile): keep build output out of the raw-request-port census

The census walks mobile/src for AST reaches into the unvalidated request
port, and the pre-bundled mermaid artifact is the first generated file
under src that is executable code rather than a string literal. Two of
its own vendored dependencies contain the token `sendRequest`, so the
walk read minified third-party code as a new call site and asked for an
inventory line nobody can ever migrate.

So `*.generated.ts` joins node_modules and test files in that file's
stated list of what it does not scan, with the reason. The scripts that
emit those artifacts are ordinary source and are still scanned, which is
where a real reach would be.

Two halves to the new control, because a filter that skipped everything
would satisfy either alone: nothing generated is left in the scan, and
the matcher still finds the port when handed one line of code.

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

* fix(mobile): escape the shared config into the native inline script

buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into
the inline <script>, twenty lines below the function that exists because
JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert
at today's five hex colours, and not inert for a themeCSS or a font stack,
which is free text going into the same script element.

So the escaping splits from the stringify and both callers use it: the
source keeps its own wrapper, the config gets one. Those characters only
ever appear inside JSON string literals, so escaping them is valid for an
object serialization exactly as it is for a string.

Red first: a config carrying `</script><script>` put four raw closers in
the document where a benign build has two.

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

* fix(mobile): pin the page mermaid type against the package's own

The loader returned the artifact's default as PageMermaid, which checked
that two names exist and nothing about their shapes: the artifact is
minified vendor output and both members infer as `any` there -- a probe
assigning engine.render to a number compiles -- and `any` satisfies every
signature there is.

So the shapes are asserted against the package's `Mermaid`, which is
precise. A PageMermaid member whose signature the engine does not really
have now fails at this line rather than at a call the page makes.

In the product module, not a test: mobile/tsconfig.json excludes test
files, so a type-only assertion in one is never compiled. Underscored
because it is a compile-time statement with no runtime reader, which is
the form the linter asks for.

Control, verified both ways: changing render to (id: number) => Promise<{
svg: number }> reds tsc naming both parameter and return, and the real
signatures compile.

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

* docs(mobile): re-measure the chunk series and say what it does not show

The four-point series was stale and read as a slope it is not. Measured
again on this head, by copying the route tree and dropping routes from
the end of the sorted key list -- both siblings of each, because deleting
a .web.tsx alone leaves the native file for the builder to resolve and
measures an entirely different closure, which is how the first attempt
at this produced 77 scripts for 14 routes:

  8 routes  -> 32 scripts
  10 routes -> 43
  12 routes -> 61
  14 routes -> 69   (the real tree)

Between four and nine more per route depending on which route, so 4r + 16
is a bound and not a fit, and the justification now says that instead of
claiming three per route. It also says the part that matters more: at 14
routes the tree measures 69 against 72, and the last two routes cost the
8 the ceiling grants for two. The fence is at break-even, and the new
assertion states that slope from the function rather than from a comment.

Also records what the generation weighs, since every chunk ships in it
whether or not a phone fetches one: 8,016,714 bytes across 112 assets
against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before
item B, and the engine is the difference.

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

* test(mobile): pin the native fallback under suppressErrorRendering

The shared config reaches the phone too, and it gained a key the native
path did not have. So the native document is now loaded for a diagram
that throws, in both engines, with window.ReactNativeWebView standing in
for the host: mermaid's run still rethrows, the document's own catch
still posts `error`, and that is the message the component turns into the
source box.

Measured both ways, so the case says which half the key owns. Whether
the fallback fires does not depend on it -- `error` is posted with the
key and without it. What depends on it is that nothing is drawn behind
the fallback: removing the key leaves mermaid's own error diagram in the
document and reds this case at 1 SVG against 0, on chromium and webkit
alike.

The control is the same document for a diagram that parses: a height,
not `error`, and one SVG.

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

* refactor(mobile): one walk for every source census, without build output

Nine censuses under mobile/src each held a copy of the same recursive
walk, and each decided for itself what a source file is: seven had no
opinion about generated files, one excluded them in its own regex, and
one had the exclusion I added last round. So all nine read 7.9 MB of
emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it
for the page -- and the two largest censuses TypeScript-parsed all of it,
looking for call sites nobody wrote and nobody can move.

That is what took rpc-params-contract-type-only-boundary over its 5 s
timeout in CI once the fifth artifact arrived. Measured here, median of
3, import plus tests:

  main, 4 artifacts, no exclusion   1004 ms   (slowest case  831 ms)
  with the 5th, no exclusion        1513 ms   (slowest case 1358 ms)
  with the 5th, this commit          947 ms   (slowest case  788 ms)

So it lands below where main has it, not merely below where I left it.
Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441,
rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary
1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps
1028 -> 516. The two that already excluded generated files do not move.

What each census counts as interesting -- extensions, whether test files
are in -- stays its own, because they genuinely disagree. What counts as
a source file at all is now said once.

The control is the file that started it: a *.generated.ts whose text
holds exactly the import a census is hunting, planted beside an ordinary
file carrying the same text. The generated one is not returned and the
ordinary one is, so the absence is a measurement. A second control reads
mobile/.gitignore and holds the predicate to every artifact the tree
generates, and a third fences the walk itself to one spelling, so a tenth
census cannot paste the cost back in.

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

* docs(mobile): correct why the type pin sits in the product module

The comment said a type-only pin in a test file "is never compiled".
That is false: mobile/tsconfig.json excludes *.test.ts, but
tsconfig.test.json is a second program that does check them, run by
check:tests-typecheck and held by the tests-typecheck ratchet.

The conclusion is unchanged and the reason is now the true one. The app's
own typecheck is the unconditional gate and would not cover a pin written
in a test; the test program is real but carries a grandfathered baseline
and a few files held outside it on purpose. And the assertion is about
this module's own type either way, so it belongs beside it.

Comment only; tsc, the ratchet and both lints re-run on the file.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 00:36:51 -04:00

126 lines
19 KiB
JSON

{
"$comment": "Every .web.* sibling the Route A web build resolves ahead of its native file. One entry per documented React Native Web gap; config/scripts/mobile-web-app-web-overrides.test.mjs fails on an unlisted one, a listed file that is gone, or one with no native sibling.",
"overrides": [
{
"file": "src/transport/client-context.web.tsx",
"reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: it serves the BridgeRpcClient the entry built, for every hostId, because the bridge protocol names no host. The entry mounts nothing until init lands, so this provider never holds a client that cannot answer."
},
{
"file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts",
"reason": "Vendored with the module, not added for Route A. The dictation hook imports @orca/expo-two-way-audio, whose native module is a Swift/Kotlin JSI binding with no browser counterpart; the web file answers the same surface with denied microphone permission and no playback."
},
{
"file": "src/navigation/route-handoff.web.ts",
"reason": "The page is one document standing in for one screen, so a route it does not render has to be handed to the app that does. The native file is the router; this one posts a navigate notify for a target outside the page's routes and pushes locally for one inside them."
},
{
"file": "src/transport/host-store.web.ts",
"reason": "expo-secure-store resolves to {} on web, so the real store answers loadHosts() with an empty array and every screen that looks this host up paints \"Host not found\" over the host the shell just opened. This one serves the profile init carried, without the credential the bridge already holds."
},
{
"file": "src/transport/host-device-token-store.web.ts",
"reason": "expo-secure-store resolves to {} on web, and the bridge carries the RPC, so the page holds no device token."
},
{
"file": "app/h/[hostId]/index.web.tsx",
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
},
{
"file": "src/platform/text-input-font-size.web.ts",
"reason": "iOS zooms the page on focus of any text input under 16px and does not zoom back out, which leaves the document at a scale other than 1 for a whole typing session. keyboard-occlusion.web.ts reads a scale other than 1 as 'not a keyboard' on purpose, because geometry cannot separate a zoom from a keyboard, so the commit bar and the note composer would never lift on the platform they were written for. The web file raises the app's 14px body size to that floor; the native file is the body size, so a phone renders exactly what it rendered before. maximum-scale=1 on the viewport meta was the other fix and was rejected: Android WebView honours it and it would take deliberate pinch zoom away from low-vision users."
},
{
"file": "src/platform/haptics.web.ts",
"reason": "expo-haptics has a web build that fakes an iOS haptic by appending a hidden <label><input type=\"checkbox\" switch> to document.head, clicking it and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to that stray click, and the file explorer calls triggerSelection on every row tap. So the page asks the shell for the device's own haptic instead, over the native.haptics.trigger notify behind the haptics grant: 70 to 77 bytes per frame, no reply, and haptics.ts's own Platform.OS split on the other side. A notify rather than a verb because nothing is owed back and a reply would spend a slot in the same in-flight window a forwarded request does, at 90 call sites (rulings-ota-c7.md ruling 30). The five exports are the five kinds the frame admits; mobile-web-app-haptics-seam.test.mjs is the fence."
},
{
"file": "app/h/[hostId]/files/[worktreeId].web.tsx",
"reason": "The shell renders this page for the file explorer, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser — reproduced in the render check, which painted that console error instead of the route."
},
{
"file": "app/h/[hostId]/files/preview/[worktreeId].web.tsx",
"reason": "The shell renders this page for the file preview, for the same reason as the explorer beside it: no nested shell, and a native file that reaches requireNativeViewManager at import. Normalizing the route params stays here, because the page reads them back out of its own URL exactly as the native screen reads them out of the router."
},
{
"file": "app/h/[hostId]/agent-history/[worktreeId].web.tsx",
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
},
{
"file": "app/h/[hostId]/web.web.tsx",
"reason": "The hybrid shell route opens a WebView on this very page, so on web it redirects to the host instead of nesting the shell inside itself. Its native file pulls in OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
},
{
"file": "src/platform/external-link.web.ts",
"reason": "The page runs inside the shell's WebView, where react-native-web's Linking.openURL calls window.open(url, '_blank', 'noopener') and resolves whether or not anything opened; only a tel: URL assigns window.location, and none of the three allowed schemes is one. Both shells refuse window.open outright: iOS sets javaScriptCanOpenWindowsAutomatically = false and returns nil from WKUIDelegate's createWebViewWith, and Android sets javaScriptCanOpenWindowsAutomatically = false, setSupportMultipleWindows(false) and returns false from onCreateWindow. So the native path reports success into a tap that did nothing. This one posts the externalLink notify instead, after the same scheme check the frame enforces, and names its refusal rather than throwing inside a tap handler."
},
{
"file": "src/platform/keyboard-occlusion.web.ts",
"reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document \u2014 the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing."
},
{
"file": "src/platform/clipboard.web.ts",
"reason": "expo-clipboard resolves to navigator.clipboard on the web, which needs a secure context; the iOS shell serves the page from the custom scheme orca-mobile-web://<session>/ while Android serves https, so that path would work on one platform and silently not on the other. This one asks the shell through the native.clipboard.write and native.clipboard.read verbs, where the pasteboard is the device's, and rejects when the route was not granted one so the caller's own catch puts that on screen. An image is the same pasteboard reached a different way: native.clipboard.read admits only text, and a 24 MiB base64 image cannot cross an 8 MiB reply cap, so readImage runs native.media.pick with source 'clipboard', reads the staged bytes back a chunk at a time and gives the handle back. contents answers per grant without probing, because the shell serves no 'is there text' verb and reading to find out would raise iOS's paste-consent prompt on every mount."
},
{
"file": "src/components/pr-sidebar/MermaidDiagram.web.tsx",
"reason": "The native component seals the diagram inside a WebView whose document embeds the whole mermaid bundle as a string, because react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one diagram. This one renders the same diagram in this document instead — mermaid is a browser library, so it is an import() inside the render effect rather than a 3.7 MB literal, and the two hosts share one MERMAID_DIAGRAM_CONFIG. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs the serialized SVG through DOMPurify; the native path's </script> escaping has no analogue because the source is a JS string argument rather than text spliced into an inline script. Both are measured in both engines under the shipped CSP by config/scripts/mobile-web-app-mermaid-render.test.mjs, which also pins the rendered SVG byte for byte against the native document's own render."
},
{
"file": "app/h/[hostId]/tasks.web.tsx",
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and one throwing route module takes the whole bundle down because the manifest imports them all."
},
{
"file": "src/browser/browser-frame-layer-paint.web.ts",
"reason": "setNativeProps does not exist on React Native Web: an Image or View ref is the DOM node itself, so both native writes throw rather than paint and the pane never shows a frame. This file writes the same two things through the element — the frame as a background-image on the child RN Web sizes, the double buffer as one opacity write per layer — so the pane still never re-renders while it streams. It also answers whenBrowserFrameDisplayable, which is a no-op natively: a background write fires no load event, so the flip to the pending layer is armed from an image decode instead of the Image's onLoad."
},
{
"file": "src/browser/browser-frame-data-uri.web.ts",
"reason": "The bridge hands the page its frame as base64 and the data URI wants base64, so the native file encodes the bytes back into the string they arrived as. The page resolves buffer to the JS shim rather than Node's native encoder, which makes that the largest per-frame cost the page pays. This file reads the base64 the bridge kept and falls back to the native encoding for a frame that carried none."
},
{
"file": "src/browser/use-browser-binary-screencast-grant.web.ts",
"reason": "Natively the socket carries the binary screencast frame and this app is both halves of that path, so there is nothing to negotiate. In the page the frames come through a shell that may predate the encoder, and subscribing with wantsBinary against one leaves the pane on a stream no frame can arrive on. This file asks the shell through the grants init carried, per C6 ruling 5."
},
{
"file": "src/browser/browser-screencast-request.web.ts",
"reason": "Not an RN Web API gap but a transport one that exists only in the page: a screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES, and a phone's mobile view at the native device scale factor produces a worst-case JPEG larger than that. This file budgets the mobile view's area against the cap, the envelope it measures rather than names, and one worst-case bytes-per-pixel constant. Web view mode is untouched, because a letterboxed desktop viewport is not an area the page can predict."
},
{
"file": "src/session/terminal-snapshot-byte-budget.web.ts",
"reason": "Not an RN Web API gap but a transport one that exists only in the page. A terminal's first frame is its scrollback snapshot, which the desktop trims to 512 KiB of raw terminal text while the bridge measures the serialized event against BRIDGE_MAX_MESSAGE_BYTES. An ANSI snapshot is mostly ESC bytes and JSON spends six on each, so a colour-dense 80-column screen comes back at 669,268 bytes against a 655,360-byte cap and the stream ends with overflow before a live byte is painted. This file derives the budget the page sends in terminal.subscribe from the cap less the event envelope; the native sibling sends nothing, because a socket frame has no ceiling above it and a budget there would shrink a phone's scrollback for a limit that does not exist."
},
{
"file": "src/session/mobile-native-chat-input-styles.web.ts",
"reason": "The chat's composer and its question field render one point above the app's body size, which is 15 and under the floor below which iOS zooms the page on focus. keyboard-occlusion.web.ts reads a scale other than 1 as 'no keyboard' and answers 0, and on this screen that lift is the terminal's only feedback that its hidden input has focus, so one focus of the composer would cost the rest of the session. This file puts both fields on TEXT_INPUT_FONT_SIZE; the native sibling keeps 15."
},
{
"file": "src/browser/browser-address-field-styles.web.ts",
"reason": "The address bar renders at the theme's 12px meta size, and in a browser an input under 16px makes iOS zoom the page on focus and never zoom back. keyboard-occlusion.web.ts reads that scale as 'no keyboard' and answers 0, so one focus would stop the pane lifting for the rest of the typing session. This file puts the input and its overlaid label on TEXT_INPUT_FONT_SIZE with a line box to match; the native sibling keeps 12px, which is what a phone has always rendered and where no page can zoom."
},
{
"file": "src/terminal/TerminalWebView.web.tsx",
"reason": "react-native-webview has no web build that renders anything: on the page it paints the line \"React Native WebView does not support this platform\" where the terminal was, which is a red line and no terminal rather than a crash. This file mounts the same document the WebView loads — xterm imported from @xterm/xterm with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical TerminalWebViewProps and TerminalWebViewHandle, so nothing above the contract can tell the two apart."
},
{
"file": "src/terminal/terminal-webview-html.web.ts",
"reason": "The native file composes the whole WebView document, which splices in the 612 KiB minified xterm engine string. On the page that string is unusable — the shell's CSP is script-src 'self' with frame-src 'none', so there is no nested document to load it into — and it would be the largest single module in the session route's closure. The web file answers the caret options, the markup and the stylesheet, which is everything the page mounts, and nothing else; mobile-web-terminal-engine-closure.test.mjs is the fence."
},
{
"file": "src/platform/media-picker.web.ts",
"reason": "expo-image-picker and expo-document-picker are native modules whose import runs a codegen lookup that throws in a browser, and the route manifest imports every route, so one of them in a page closure takes the whole bundle down rather than one picker. This one asks the shell through native.media.pick, reads the bytes back a chunk at a time over native.media.read because a picked image reaches 18 MiB raw against an 8 MiB reply ceiling, and releases every handle it was handed, including the ones its caller never took. The pasteboard is not here: clipboard.web.ts owns it on both platforms and reaches the same verbs for an image."
},
{
"file": "src/session/mobile-clipboard-image-resize.web.ts",
"reason": "expo-image-manipulator is a native module with no browser counterpart, and the two expo-file-system writes its native path makes exist to work around an iOS loader that cannot decode a large base64 data URI, which a browser does not need. img-src 'self' data: already admits the source, so this file decodes one <img>, draws it into a canvas at the target size and reads the PNG back out of toDataURL, with no file anywhere in it. It reads the size back off the canvas rather than echoing the size asked for; the two are the same number, because a browser reflects the width it was assigned, and reading it back keeps the dimensions and the bytes coming from one element. Measured in a real browser under the shipped header: a 1400x1000 noise PNG of 5,476,032 base64 characters converges in one pass to 368x263 and 397,220 characters, 75.8% of the upload path's 512 KiB chunk."
},
{
"file": "src/components/MobileRichMarkdownEditor.web.tsx",
"reason": "The native editor is a rich document inside a WebView, and react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one editor. This one renders the Markdown source in a plain field on the text-input seam, which is the state the screen around it already handles — the text, every edit through onChange, and Save, Discard, Copy and Refresh unchanged. The degradation is the formatting toolbar and the rendered view: the toolbar's fifteen commands are the rich document's, and a contenteditable reimplementation is a different surface with its own escaping and its own proof rather than a smaller version of this one (rulings-ota-c7.md ruling 8). onKeyboardInsetChange is never called, which is correct rather than missing: it exists because native Keyboard events under-report a WebView's covered area, and on the page keyboard-occlusion.web.ts is the only measurement there is."
},
{
"file": "src/components/MobileHtmlPreview.web.tsx",
"reason": "The native preview renders an agent-produced HTML artifact inside a sandboxed WebView with navigation locked to the initial inline document, and react-native-webview throws at import in a browser for the reason above. This one renders the labelled source, which is the component's own Source tab. Rendering the HTML instead is a different change rather than a smaller one: the page has no nested frame to sandbox untrusted source in, because the shell's policy carries frame-src 'none' and child-src 'none', so a browser renderer would need its own sanitiser and its own proof (ruling 8). The Preview/Source toggle goes with the preview, because a control that can only be in one position is a control that lies."
}
]
}