Commit Graph
10 Commits
Author SHA1 Message Date
Jinwoo Hong 8d42410e01 feat(mobile): render the HTML preview on the page in a sealed srcdoc frame (OTA phase C, C7.10 A) (#21862)
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener

Both shells cancelled every navigation off their own document in silence: iOS
`decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's
`shouldOverrideUrlLoading` dropped anything whose resolved path was not "/".
Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping
a link inside C7.10's sealed HTML-preview frame reaches the top frame as a
navigation request, and the shell was the only thing that could act on it.

A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and
goes through the same `Linking.openURL` the `externalLink` notify already uses.
The scheme list is not restated natively: the native side caps the string and
says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens
in the half that ships over the air. A subframe navigation is never offered,
because that is the sealed preview loading itself.

swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs).

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

* feat(mobile): render the HTML preview in a sealed srcdoc frame on the page

C7.6 gave the page the artifact's source, which is the native component's Source
tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes
back as an `<iframe sandbox srcdoc>` inside the page's own document.

`srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium
and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its
embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`,
while a `blob:` frame is refused by `frame-src` on both and refused a second time
in WebKit by the `frame-ancestors 'none'` it inherits.

Two independent fences seal it, and the render check measures each on its own:
the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the
inherited `script-src 'self'` refuses the artifact's inline script even when a
control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'`
govern its subresources, against a no-header control where the same three are
fetched.

`allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so
a tapped link becomes one top-frame navigation the shell now opens externally,
while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated
navigation produce none.

`lucideBarrelPlugin` is exported from the bundle builder so the check builds the
toolbar's icons the way the page does rather than carrying a second shim.

config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a
literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script
fence and the token census.

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

* test(mobile): pin the preview's sealed frame where the degradation was pinned

The three HTML-preview cases in this file described the state ruling 26 retires:
no toggle, no frame, the source only. They now pin the frame's shape through the
test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither
`allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source
takes the frame away with it -- and the "never renders the html itself" case
becomes "never puts it anywhere but the frame", counted rather than merely absent.
What a browser does with that frame stays in the render check, which is the only
thing that can answer it.

The rich Markdown editor's half is unchanged: it is still the plain field, and
item C is a later PR.

Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the
toggle renders one, and `lucide-react-native`, whose barrel imports a
`LucideProvider` its own context module does not export and so does not load under
vitest at all.

9 passed, exit 0.

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

* fix(mobile): refuse a link-activated top-frame navigation, even to the document

F1, blocking, with F5 and F6 folded in because they are the same decision and
splitting them would mean three rewrites of one function.

F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the
embedder's base, so both named the shell's own document URL -- which both shells
ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed
preview reloaded the shell's page: bridge target cleared, load state restarted,
page state gone. A navigation a human started is now never allowed, whatever it
names; it is offered instead, and `cancelledShellNavigationTarget` drops
`orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting
its own path carries no gesture and is still allowed.

F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a
redirect is cancelled and never opened externally.

F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a
download>` was dead on iOS and opened on Android. The early return goes; a
download is refused rather than allowed when nothing started it, and a
gesture-started one reaches the opener on both platforms.

The allow half and the offer half are now one function per platform
(`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so
they cannot drift. The gesture is the platform's own answer: `.linkActivated` on
iOS, `request.hasGesture()` on Android.

Native tests, both platforms: document URL + gesture refused and offered; document
URL without gesture allowed; foreign + gesture cancelled and offered; foreign
without gesture cancelled and silent; download both ways; subframe never offered.
swiftc OK; control run with the gesture rule removed exits 133. Gradle
MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0.

Also corrected: the screen comment that claimed the document's own reloads reach
the handler (they never do), and the prop doc, which now states the gesture rule.

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

* test(mobile): count own-origin top-frame navigations, and drop the goto cap

F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling
render check uses the 30 s default, so under load the first WebKit cases redded on
the navigation rather than on anything they assert. The cap goes; the per-action
timeouts that needed to be short are already passed at their call sites.

F1's page-side half: the rig now routes the page's own origin as well as the
foreign one and counts main-frame navigations to each separately, with two cases
pinning that `href="/"` and `href=""` each produce exactly one own-origin
top-frame request. Playwright is not the shell, so what these state is the request
the shell is handed; refusing it is the native tests' job and the docstring names
which ones. The own-origin route is registered after the initial load, because it
aborts main-frame navigations and the first `goto` is one.

The foreign-tap and meta-refresh cases now also assert zero own-origin
navigations, so a fix that merely moved the target would not pass.

16 passed, exit 0, no Errors line.

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

* test(mobile): wait for the preview frame's own load, never a clock

CI read the child frame before its srcdoc committed: frameUrl came back ''
and the control arm's script as not yet run. The frame list, the frame's URL
and anything read inside it settle at their own moments, and a 900 ms wait
reads whichever of them has happened -- on a loaded runner, none.

Polls for a child frame at about:srcdoc with its load fired, bounded by the
case's own timeout, and an override arm now resolves on the document its
srcdoc assignment commits rather than on the assignment.

Red-first: with a 2.5 s mount delay standing in for a loaded runner, the
paint case failed on both engines before this and all 16 cases pass after.

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

* docs(mobile): say whose violations the preview rig reads

The list is the main frame's: securitypolicyviolation does not cross into a
frame, so an empty one says the embedder raised none and says nothing about
the artifact's own style, image or font. A listener inside the frame cannot
be the fix -- the fence under test is that nothing in the artifact runs.

So the comment now claims what the reading supports, and names where the
frame's containment is actually measured: the pixel for its inline style,
the counting server for its img-src and font-src.

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

* fix(mobile): announce which side of the preview toggle is showing

The Preview/Source pair carried a label each and nothing else, so which one
was showing lived only in the active background -- invisible to a screen
reader on both surfaces. Each button is now a tab carrying its selected
state, inside a tablist, and the two files' toolbars stay character-identical
so the page and the phone announce the same thing.

Red-first: the new case renders both siblings and failed on both for the
missing role before this.

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

* test(mobile): type the WebView mock like the file's other hosts

The anti-slop gate refuses a bare `object` parameter. Takes the same shape as
the react-native mocks beside it, which pass it.

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

* fix(mobile): allow only the load the shell itself started

The document URL was allowed whenever the host reported no gesture, so a
navigation the shell never asked for could reload the page out from under the
session. Measured against a real WKWebView off-device: a sandboxed subframe
navigating the top frame to the document URL arrives as `.other` with no
gesture at all, and Chromium's own docs allow hasGesture() to be false for a
request a human started. Census first: nothing in the page navigates the top
frame -- no location assignment, reload, replace, window.open or form -- the
router moves by pushState and replaceState only, so the rule needs no gesture
and no page cooperation.

Both shells now raise a flag around their own load and drop it at commit, and
allow a main-frame navigation only while it is up. Everything else naming the
document is refused and never offered, since offering it would send the user
out of the app. iOS carries the second discriminator the same probe measured:
sourceFrame is the main frame for the shell's own load and the subframe for a
subframe's top navigation, so a subframe can never take the allow path.

Red-first: the Swift checks and the Kotlin tests were written first and failed
to compile against the old signature. 9 Kotlin tests, 54 in the module.

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

* test(mobile): point the meta-refresh arm at the embedder's own URL

The fixture pointed off-origin, so its own-origin assertion could not move
whatever the frame did. The new arm refreshes to `/`, which resolves against
the embedder's base, and pins zero top-frame requests on a counter the
`href="/"` case proves reads 1 in the same rig.

It also counts what the frame asks for itself, with a presence control that
attributes the fence: with `allow-same-origin` and no policy the same fixture
navigates the frame to the embedder's `/`, and with the policy dropped but the
product's token kept it navigates nothing, so the opaque origin is what
refuses it rather than the CSP.

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

* test(mobile): read what an action produced, not what a clock allowed

The 600 ms after every action is gone. An arm that expects a navigation now
returns the moment the route handler records it, with a deadline only so a
click that missed its target says so instead of spending the case's timeout.
An arm that expects none waits for two painted frames inside the page and one
200 ms drain for the popup queue, which is a browser-process event with no
in-page counterpart; the docstring says why that one is bounded.

Measured and reported rather than claimed: with the new wait replaced by a
no-op every arm still passes, because the reads that follow are each a round
trip. It is insurance against the runner load that produced the frame-commit
race, not a fix for a failure seen here.

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

* test(mobile): take the settling branch as a ternary

What oxlint's prefer-ternary asks for, and the changed-code gate with it.

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

* test(mobile): find the preview frame by its element, not its URL

CI timed out on all seven preview cases on one engine: the poll waited for a
child frame whose URL reads about:srcdoc, and that browser reports an empty
URL for a srcdoc frame, so every case ran to its own timeout. The same
difference had already shown as `expected '' to be 'about:srcdoc'`.

The frame is now the element: waitForSelector('iframe') then contentFrame(),
with readiness taken from the fixture's own marker inside it. Nothing compares
a frame URL any more -- the paint case reads the element's srcdoc attribute
and the absence of src instead, which is what "parsed inside the frame rather
than fetched into it" actually means. The one arm whose artifact navigates the
frame away says so rather than waiting for a marker that is not coming.

Red-first: with the old poll keyed on a URL the browser never reports, both
engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s
mount delay still injected.

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

* test(mobile): make a frame that never becomes ready say what it saw

The runner's Chrome read the preview frame's URL as empty where three
chromium builds here read about:srcdoc: bundled headless, the headless shell,
and --headless=old, all 147. So the difference is not reproducible locally and
the next CI run has to carry its own diagnosis.

The marker wait is bounded well inside the case timeout, and on expiry it
reports the frame's URL, the srcdoc attribute's length and the page's CSP
violation list -- which separates a frame the policy refused from one that was
merely slow, the two readings that look identical from a timeout.

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

* test(mobile): run the containment arms the comment only claimed

The comment said the fixture navigates nothing with the policy dropped and
the product's token kept, but no arm ran it: the control dropped both fences
at once. Both single-fence arms exist now, either of which would hold.

Measured rather than assumed, and one of them is not what the comment said.
The token alone: the navigation never starts, no request, no violation. The
policy alone, with allow-same-origin granted: the navigation does start and
frame-src refuses it, which the embedder reports as its own violation. The
engines differ only in what is left in the frame -- chromium an error page,
WebKit the artifact -- so neither is asserted; what is asserted is that the
request never reaches the server.

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

* fix(mobile): refuse a download that names the shell's own document

The document branch skipped downloads, so `<a href="/" download>` fell
through to the offer path carrying the shell's own URL. Harmless in practice,
because the opener's scheme list drops it, but it contradicted the policy's
own comment and the prop doc, and it left the one URL that must never be
offered reaching the boundary.

The branch now covers a download too: refused, from either frame, gesture or
not, and never offered. A gesture-started download of anything else still
reaches the opener.

Red-first on both platforms: the Swift checks exited 133 and the Kotlin row
failed against the old policy. 10 navigation tests, 55 in the module.

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

* fix(mobile): drop the own-load flag wherever a document ends

The flag lived beside the load call and had to remember every ending
separately, so iOS missed two: a prop update that fails before it loads, and a
renderer that died. Both left it raised, and a navigation to the document URL
during that window would have been allowed.

It now lives in the load state machine, which every ending already runs
through -- a commit, a failure, a dead renderer, a prop update, a reset -- on
both platforms, so there is nothing left to remember. The view raises it and
reads it, and drops it nowhere.

The Android residual is stated in the policy rather than papered over: between
loadUrl raising the flag and onPageStarted dropping it, a navigation to the
document URL from inside the preview frame would be allowed, because that
callback says nothing about which frame asked and no host discriminator
exists. It needs a generation switch and a tap in that window; iOS closes the
same gap with sourceFrame.

Red-first: the new Swift row failed to compile and the Kotlin row with it.
12 load-state tests, 56 in the module.

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

* fix(mobile): spend the own-load flag on the allow, not on the commit

The flag stayed raised from the load until didCommit, so a second main-frame
action naming the document inside that window was allowed too and replaced the
document. WebKit can decide a second action before the first one starts, so
the commit is too late to be what spends it.

The allow itself spends it now, before the decision goes back, and every
ending still drops it for a load that is allowed and never commits.

Red-first: the new check composes the machine with the policy -- the seam the
flag and the rule meet at -- and failed to compile against the old machine.

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

* fix(mobile): stop raising an own-load flag Android never consults

WebViewClient's javadoc, verbatim: "This callback is not called for all page
navigations. In particular, this is not called for navigations which the app
initiated with loadUrl(): this callback would not serve a purpose in this
case, because the app already knows about the navigation."

So the flag guarded nothing on this platform and, while raised, was the one
thing that could have let a competing request to the document URL through.
The view passes isShellLoad = false always now, the machine drops the field it
had no raiser for, and the policy comment carries the quote. Nothing reaching
that callback is the shell's own load, so nothing naming the document is
allowed there at all -- which also closes the generation-switch window the
residual named, so that paragraph goes.

No red to show: this is a removal, and the behaviour it leaves is the refusal
the existing rows already pin. What a device proof must check is stated in the
policy instead: a WebView that did route its own load here would have it
refused and the load state would sit at loading. 55 tests in the module.

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

* test(mobile): settle every arm, not only the ones that tap

An arm with no action read its counters as soon as the frame's marker
appeared, so a zero-delay meta refresh could dispatch after the reading. The
arms that pin zero were the ones relying on it.

Every arm settles now, and what it settles on is what it expects: the sealed
refresh arms take the bounded no-navigation path, and the loose arm waits for
a recorded navigation that is neither main-frame nor foreign -- its own
frame's -- rather than the main-frame wait it would never satisfy.

Red-first: with the settling removed and the refresh moved to 2 s, the loose
arm reads 0 on both engines; with it back, 1 on both, the delay still in.
A 0.4 s refresh passes either way, which is why the finding was invisible.

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

* test(mobile): wait for what the artifact's script wrote, not for the element

The two-fences control asserts the inline script ran, and the marker element
it waited for exists from parse time, so the arm could read window.__ran
before the script had touched it. Under a loaded runner that reads 0, which is
CI's "expected +0 to be 1" on chromium.

Readiness is now per-arm: 'script' waits for the script's own write, 'load'
for the arm whose artifact navigates the frame away, 'artifact' for the rest.

Red-first: with the inline script's write delayed 1.5 s, the old arm fails on
both engines with that exact message and the new one passes, delay still in.

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

* test(mobile): bound the rig's waits by the case timeout and nothing else

Two inner deadlines, 20 s and 15 s, were racing the outer one they sit
inside, so a slow runner could fail a case on a number this file picked
rather than on the one the case declares.

Both now run to vitest's own `ctx.signal`, which aborts when the case times
out. On abort the rig prints its reading -- the frame's URL, the srcdoc
length, the violation list, or the navigations it did record -- and lets the
case fail as the timeout it is. Nothing is rethrown from that path: a
rejection raised after vitest has given up on a case has nobody left to catch
it, and an unhandled one fails a run whose every test passed.

Red-first: with the marker selector pointed at an element that never appears
and the case timeout cut to 8 s, the diagnostic prints and the case fails as
`Test timed out in 8000ms` rather than hanging in silence.

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

* test(mobile): ask a stuck preview frame everything it can still answer

The old diagnostic said only that a frame never parsed, and its violation
list was the top document's -- securitypolicyviolation does not cross frames,
so it said nothing about what the frame itself refused.

It now prints the browser version, the arm it came from, the iframe element's
srcdoc length and sandbox, contentDocument.readyState and contentWindow.href
(which answer for a same-origin arm and report `refused` for an opaque one,
so the arm's own origin is in the log), and every Playwright frame with its
url, name, readyState, body length, marker presence, window.__ran and its own
violations. Per frame, because the page's init script installs the collector
in every frame -- measured on both engines -- and CDP evaluates inside an
opaque frame whose scripts are blocked.

Two corrections that the local probes forced. The reading is sampled while
waiting and printed from the last sample: read at the abort it lost its race
with vitest's teardown and printed nothing at all. And two arms had never been
given the case's signal, so their waits could not be bounded or diagnosed.

The diagnosis moves to its own module because the test file is at its line
limit, and because the bound and the reading it prints are one thing.

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

* test(mobile): build a widened control frame instead of relaxing a live one

A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context
when it is created, and Chrome 152 keeps the original ones through a srcdoc
reassignment while still parsing the new document -- so the control arms that
widened the product's own frame stayed sealed on the runner, and CI read a
script that never ran and a refresh that never navigated. Chromium 147 here
honours the relaxation, which is why it passed locally for a year of runs.

The override now clones the element, sets the sandbox on the clone, gives it
the artifact and replaces the product's frame with it, so the widened flags
are there from creation -- the way the product does it, since React sets the
attribute before insertion and never after. The product's own arms are
untouched: a null override still returns immediately.

And the control can no longer pass for the wrong reason on any engine. The
header-keeping arm now reads the violation raised inside the frame: a
script-src refusal can only happen if the sandbox let the script start, so it
separates "the policy held" from "the frame was never widened", which the old
arm could not. The loose arm pins an empty list beside it, the sealed arm pins
an empty one too, and those three readings are the whole fence story. The
violations come from each frame's own collector, because the embedder never
sees them.

Two diagnostic repairs the local probes forced: the browser version is read
once at open, since asking at the abort printed "browser unknown" in the CI
log this exists for, and the reading is sampled immediately as well as every
five seconds, since a wait that only prints "no reading was taken" says
nothing.

Red-first: with the widening disabled, both engines fail exactly as CI did --
180 s timeouts on the script arm -- and the diagnostic names the arm, the
version and the sandbox it actually had.

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

* fix(mobile): put the toggle's selected state where a browser reads it

CodeRabbit is right, and the browser says so: react-native-web's createDOMProps
never reads accessibilityState, so on the page the tab pair emitted role="tab"
and no aria-selected at all. The test renderer could not see it, because it
reports the props the component was handed rather than the DOM they become.

Both siblings carry aria-selected beside accessibilityState now -- the phone's
screen reader takes the latter, the browser the former -- and the toolbars stay
character-identical.

Red-first, in a real browser on both engines: the rig now reads every
[role="tab"] element's aria-selected before and after the tap, and it read null
for both positions before this line existed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 03:32:34 -04:00
Jinwoo Hong ec82173130 feat(mobile): mount the terminal document in the page over its own modules (OTA phase C, C7.5) (#21809)
* test(mobile): pin the terminal WebView document byte for byte

The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.

So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.

The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.

Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.

Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.

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

* test(mobile): compare two terminal documents as programs, not as bytes

The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.

It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.

So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.

Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.

Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.

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

* test(mobile): count each normalisation the move makes, separately

Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.

The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.

Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.

Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.

Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.

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

* refactor(mobile): make the mouse-report cell a module the page can import

The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.

The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.

Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.

`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.

The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.

The document is untouched, so the byte pin from 3006d8dfdf is still green.

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

* refactor(mobile): make the query-reply gate a module the page can import

The second of the twelve groups, and the one that corrects the scope table's
membership rule.

`terminalDataRepliesEnabled` is written from four places, so the whole-script
census counted it among the 57 variables that cannot stay free across modules.
All four writes are in this group. Once the script is modules, a variable
written only inside the module that declares it is that module's own state, not
the document's, and it stays a `let` there. So the scope object holds what
crosses a module boundary, and the 57 is an upper bound rather than the answer;
the qualifier count the flip commit pins will be lower than the 641 measured
over the single scope, and by how much is a function of where the boundaries
fall.

Two references do cross here and are qualified: the write-queue generation this
group compares against, and the observer-disposal list it pushes onto.

Counts pinned: two qualified references, one `var` to `let`, two one-statement
`if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved.

The document is untouched, so the byte pin is still green.

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

* refactor(mobile): make reflow a module, and give the generator its own tests

The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.

The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.

Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.

The document is untouched, so the byte pin is still green.

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

* refactor(mobile): make the keyboard-avoidance metrics a module

The fourth group, and the first that needed a non-null assertion.

`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.

Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.

Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.

The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.

The document is untouched, so the byte pin is still green.

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

* refactor(mobile): make WebGL loss recovery a module

The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.

The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.

Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.

The document is untouched, so the byte pin is still green.

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

* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class

The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.

Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.

The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.

The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.

Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.

The document is untouched, so the byte pin is still green.

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

* refactor(mobile): extract the terminal surface-swap group into a module

The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.

Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.

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

* feat(mobile): substitute build-time constants into the emitted document

The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.

The fixture script's TypeScript loader moves beside it rather than being
written twice.

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

* refactor(mobile): extract the terminal theme group into a module

The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.

Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.

Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.

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

* refactor(mobile): extract the terminal path-tap group into a module

The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.

Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.

One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.

Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.

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

* refactor(mobile): extract the terminal tap-dispatch group into a module

The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.

Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.

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

* refactor(mobile): extract the terminal mouse-click-drag group into a module

The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.

Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.

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

* refactor(mobile): extract the terminal url-tap group into three modules

The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.

Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.

Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.

Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.

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

* refactor(mobile): extract the mouse-mode DECSET scan slice into a module

The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.

Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.

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

* refactor(mobile): extract the terminal message-bridge slice into a module

The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.

The second catch keeps its binding: it names the error and reports it.

Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.

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

* refactor(mobile): give the document close its own slice file

The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.

Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.

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

* refactor(mobile): extract the terminal term-observers slice into modules

This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.

A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.

Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.

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

* refactor(mobile): extract the selection-state-and-eviction slice into a module

The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.

Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.

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

* refactor(mobile): extract the smooth-scroll and cell-geometry slice

Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.

Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.

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

* refactor(mobile): extract the terminal write-queue slice into a module

The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`,
which belong to other concerns but sit here because emitted-document order
pins them here; four names stop being externals as a result.

The observer disposal keeps its guard-as-expression form behind a line-scoped
disable: the rewrite the rule asks for is outside every counted class.

Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10,
unbound catches 1, number properties 0, shorthand properties 0.

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

* refactor(mobile): extract the terminal fit-scale slice into a module

The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.

Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.

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

* refactor(mobile): extract the terminal init-and-write slice into a module

The slice opens with the already-extracted webgl-recovery group, so its text
is two emissions joined. init() resets almost every field the document shares,
which makes this the densest qualifier site in the script.

The caret options were interpolated from the theme module, so they join
`document-constants.ts` as four exports: a substitution is keyed by name, not
by property path.

One local the document declares and never reads keeps a line-scoped
`no-unused-vars` disable. Removing it would be a different program, which is
the one thing this branch does not do.

Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18,
unbound catches 7, number properties 0, shorthand properties 0.

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

* refactor(mobile): extract the runtime-state and text-scaling slice

The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.

Two things this slice forced.

The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.

The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.

Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.

Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.

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

* style(mobile): format the two test files the last commit left unformatted

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

* refactor(mobile): extract the mouse-report and scroll-routing slice

Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.

Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.

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

* refactor(mobile): extract the host-message-router slice into modules

Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.

The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.

Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.

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

* refactor(mobile): extract the selection-overlay slice into modules

Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.

Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.

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

* refactor(mobile): extract the surface-touch-gestures slice into modules

The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.

The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.

Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.

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

* refactor(mobile): extract the document's opening declarations into a module

The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.

With this every line of the document's script has a module behind it.

Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.

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

* test(mobile): pin the whole document script against the modules

Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:

  qualifier            609 references + 73 declarations = 682 sites
  var rebindings       373, the document's 446 declarators less those 73
  curly braces         279, the number measured before any of this started
  unbound catches      36 of 38; two name their error and report it
  Number properties    17, also measured up front
  shorthand properties 4, two SGR flags written twice each
  unshadowed names     7

A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.

The module order lives in one file that both this test and the generator read,
so neither can drift from the other.

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

* style(mobile): keep only the lint directives that do something

Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.

The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.

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

* feat(mobile): generate the terminal document from its modules

The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.

The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:

  qualifiedReferences     609
  scopeFieldDeclarations   73
  rebindings              373
  bracedBodies            279
  unboundCatches           36
  numberProperties         17
  shorthandProperties       4
  unshadowedNames           7

Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.

Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.

Pattern changes, old -> new.

terminal-webview-scroll-routing.test.ts
  var deltaY = ts.lastY - y;                    -> const deltaY = ts.lastY - y;
  smoothScrollOffsetY -= deltaY;                -> scope.smoothScrollOffsetY -= deltaY;
  var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
                                                -> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
  'touchmove' single-quoted, one line           -> "touchmove" double-quoted, printer line break
  }, { capture: true, passive: false });        -> { capture: true, passive: false }
  function momentumStep()                       -> let momentumStep = function()
  pendingNormalScrollDeltaY += deltaY;          -> scope.pendingNormalScrollDeltaY += deltaY;
  if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
  normalScrollFrameId = requestAnimationFrame(  -> scope.normalScrollFrameId = requestAnimationFrame(
  pendingNormalScrollDeltaY = 0;                -> scope.pendingNormalScrollDeltaY = 0;
  cancelAnimationFrame(normalScrollFrameId);    -> cancelAnimationFrame(scope.normalScrollFrameId);
  var writeQueueHead = 0;                       -> scope.writeQueueHead = 0;
  writeQueueHead++;                             -> scope.writeQueueHead++;
  writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
  surface.style.transform = 'translate(' + panX  -> scope.surface.style.transform = "translate(" + scope.panX
  getVisualPanY() + 'px) scale('                -> getVisualPanY() + "px) scale("
  var FRICTION = 0.972;                         -> const FRICTION = 0.972;
  var MIN_VEL = 0.012;                          -> const MIN_VEL = 0.012;
  edgeScrollDir = dir;                          -> scope.edgeScrollDir = dir;
  term.scrollLines(edgeScrollDir);              -> scope.term.scrollLines(scope.edgeScrollDir);
  // Latching document-level touch dispatcher    -> function attachSurfaceEventHandlers(
  edgeScrollClientX = clientX;                  -> scope.edgeScrollClientX = clientX;
  edgeScrollClientY = clientY;                  -> scope.edgeScrollClientY = clientY;
  return mode !== 'none';                       -> return mode !== "none";
  var pixelX = cell.x;                          -> const pixelX = cell.x;
  var pixelY = cell.y;                          -> const pixelY = cell.y;
  ...isSafeSgrMouseCoordinate(cell.y)) return   -> ...isSafeSgrMouseCoordinate(cell.y)) {
  ...isSafeSgrMouseCoordinate(sgrRow)) return   -> ...isSafeSgrMouseCoordinate(sgrRow)) {
  if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
  if (mouseTrackingMode === 'x10') return sgrPress;   -> if (mouseTrackingMode === "x10") { return sgrPress;
  if (mouseTrackingMode === 'x10') return press;      -> if (mouseTrackingMode === "x10") { return press;
  if (col > 126 || row > 126) return '';        -> if (col > 126 || row > 126) { return "";
  document.addEventListener('touchend'          -> document.addEventListener( "touchend"
  }, { capture: true, passive: true });         -> { capture: true, passive: true }
  notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
  document.addEventListener('touchstart'        -> document.addEventListener( "touchstart"
  var clickInput = buildMouseClickInput         -> const clickInput = buildMouseClickInput
  notify({ type: 'open-url', url: tappedUrl });      -> notify({ type: "open-url", url: tappedUrl });
  notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });

terminal-webview-text-zoom.test.ts
  var CLAUDE_STATUS_DOT =                       -> scope.CLAUDE_STATUS_DOT =
  var PRIVATE_MODE_SCAN_TAIL_LIMIT              -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
  \n\n  function enqueueWrite                   -> \n  function enqueueWrite
  var terminalFontFamily =                      -> scope.terminalFontFamily =
  output = terminalFontFamily;                  -> output = scope.terminalFontFamily;
  String.fromCharCode(0x23fa)                   -> String.fromCharCode(9210)
  TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)  -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
  EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
  data.replace(CLAUDE_STATUS_DOT_PATTERN, ...)  -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
  writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
  var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")
  statusDotPendingSelector = false              -> scope.statusDotPendingSelector = false   (x2)
  term.open(surface)                            -> scope.term.open(scope.surface)
  term.unicode.activeVersion = '11'             -> scope.term.unicode.activeVersion = "11"
  enqueueWrite(ESC + '[0m' + replayData)        -> enqueueWrite(scope.ESC + "[0m" + replayData)
  fontFamily: terminalFontFamily                -> fontFamily: scope.terminalFontFamily
  fontWeight: '300'                             -> fontWeight: "300"
  fontWeightBold: '500'                         -> fontWeightBold: "500"

terminal-webview-engine.test.ts
  var webglAddon = null; .. var webglRecoveryTimer = null;
                                                -> the refreshTerminalSurface()..init( block, with the scope preamble
  window.addEventListener('resize'              -> window.addEventListener("resize"
  'terminal init failed'                        -> "terminal init failed"
  'terminal message failed'                     -> "terminal message failed"
  var everReady = false;                        -> scope.everReady = false;
  everReady = true;                             -> scope.everReady = true;
  fatal === undefined ? !everReady : !!fatal    -> fatal === void 0 ? !scope.everReady : !!fatal
  msg.type === 'init' && !everReady             -> msg.type === "init" && !scope.everReady
  /fatal === undefined \? !ready\b/             -> /fatal === void 0 \? !scope\.ready\b/
  if (msg.type === 'ping')                      -> if (msg.type === "ping")
  notify({ type: 'pong', pingId: msg.id })      -> notify({ type: "pong", pingId: msg.id })

terminal-webview-reflow.test.ts
  } else if (msg.type === 'reflow') {           -> } else if (msg.type === "reflow") {   (x2)
  var MIN_FIT_COLS = 20;                        -> scope.MIN_FIT_COLS = 20;
  if (cols < MIN_FIT_COLS) return;              -> if (cols < scope.MIN_FIT_COLS) {
  flog('measure-skip-small-width'               -> flog("measure-skip-small-width"
  notify({ type: 'measure-result', ... })       -> notify({ type: "measure-result", ... })
  var dispatch = { mode: 'idle'                 -> const dispatch = { mode: "idle"
  window.addEventListener('message'             -> window.addEventListener("message"

terminal-keyboard-avoidance-webview.test.ts
  \n  // reflow()                               -> \n  function reflow(
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")
  \n  var panX                                  -> \n  scope.panX
  TERMINAL_REFLOW_JS fragment import            -> the reflow(cols, rows)..notify( slice of the document

terminal-webview-query-reply.test.ts
  attachTerminalQueryReplyBridge(term, gen)     -> attachTerminalQueryReplyBridge(scope.term, gen)   (x2)
  term.attachCustomKeyEventHandler(function() { return false; })
                                                -> term.attachCustomKeyEventHandler(function() { \n return false; \n });
  term.textarea.readOnly = true                 -> term.textarea.readOnly = true;
  } else if (msg.type === 'clear') {            -> } else if (msg.type === "clear") {
  } else if (msg.type === 'measure')            -> } else if (msg.type === "measure")

terminal-webview-url-tap.test.ts
  notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });

terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.

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

* refactor(mobile): delete the slice constants and injected fragments

The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.

Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.

Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.

Moved, with every assertion kept and the `expect` count per file unchanged:

  terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts   34
  terminal-webview-theme-injected.test.ts   -> terminal-webview-theme.test.ts 14
  terminal-webview-query-reply.test.ts                                        14
  terminal-path-tap.test.ts                                                   25
  terminal-webview-url-tap.test.ts                                            33
  terminal-keyboard-avoidance-webview.test.ts                                 18
  terminal-webview-reflow.test.ts                                             22
  terminal-webview-text-zoom.test.ts                                          59
  terminal-webview-engine.test.ts                                             49

Pattern changes, old -> new.

terminal-webview-reflow.test.ts
  if (!term || isAlternateBufferActive()) return;
                        -> if (!scope.term || isAlternateBufferActive()) {
  term.resize(nextCols, nextRows);        -> scope.term.resize(nextCols, nextRows);
  var wasAtBottom = buffer.viewportY >= buffer.baseY;
                        -> const wasAtBottom = buffer.viewportY >= buffer.baseY;
  term.scrollToBottom();                  -> scope.term.scrollToBottom();
  if (nextCols === term.cols && nextRows === term.rows) return;
                        -> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {

The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.

Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.

  query-reply             terminalDataRepliesEnabled = true       -> query-reply test, 2 failed
  path-tap                const parsed = parsePathLineCol(...)    -> path-tap test, red
  keyboard-avoidance-metrics  contentBottomRow                    -> keyboard-avoidance test, 4 failed
  reflow                  scope.term.resize(nextCols, nextRows)   -> reflow test, 2 failed
  webgl-recovery          new window.WebglAddon.WebglAddon()      -> engine and text-zoom tests, 4 failed
  osc-link-tap            return parsePathLineCol(value)          -> url-tap test, 1 failed
  terminal-theme          scope.term.options.minimumContrastRatio = ...
                                                                  -> theme test, 4 failed
  write-queue             scope.writeQueue[scope.writeQueueHead] = undefined
                                                                  -> write-queue test, 4 failed

`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.

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

* test(mobile): name the right number of counted classes

The flip test's title still said seven; the table it asserts has eight.

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

* fix(mobile): name the shape applyTerminalTheme writes through

The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.

No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.

Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.

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

* test(mobile): retire the flip pin, leaving the byte golden as the document's fence

`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.

C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.

From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.

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

* feat(mobile): give the terminal document's host seams a field on its scope

Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:

  host-notify.ts      notify()             -> scope.postToHost
  viewport-transform  flog()               -> scope.postToHost
  terminal-init.ts    new Terminal(...)    -> scope.createTerminal
  terminal-init.ts    window.Unicode11Addon-> scope.createUnicode11Addon
  webgl-recovery.ts   window.WebglAddon    -> scope.createWebglAddon

Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.

Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.

Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:

  + (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
  + (new) function createEngineTerminal(options) { return new Terminal(options); }
  + (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
  + (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
  - "      pendingTerm: null"
  + "      pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
  - flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
  + flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
  - "    if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
  + "    if (!scope.term) {"
  - "      addon = new window.WebglAddon.WebglAddon();"
  + "      addon = scope.createWebglAddon();" then "      if (!addon) {" / "        return false;" / "      }"
  - "    scope.term = new Terminal({"
  + "    scope.term = scope.createTerminal({"
  - "    if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / "      try {" / "        scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / "      } catch {"
  + "    try {" / "      const unicodeAddon = scope.createUnicode11Addon();" / "      if (unicodeAddon) {" / "        scope.term.loadAddon(unicodeAddon);" / "    } catch {"
  - notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
  + "    scope.postToHost(msg);"

Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.

Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.

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

* build(mobile): write the xterm stylesheet as its own generated artifact

The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.

`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.

The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.

The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.

`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.

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

* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards

Round 1 fixes, all three folded here.

1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
   `document/url-tap.test.ts` deleted alongside it. The document is generated
   from its modules now, so that constant was a second copy of the URL-tap group
   with no consumer but its own tests. terminal-webview-url-tap.test.ts's
   resolver harness reads the document's own text instead, the path-tap,
   url-tap, osc-link-tap and surface-tap modules in document order through
   `generatedDocumentModule`, which refuses unless the document carries each
   verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
   file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
   pin of the three emissions against the constant, which the flip test's
   whole-document pin already covers. The file's other exports stay.

   The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
   concatenated terminal-webview-url-tap.ts into its `source`, and its
   `notify({ type: 'terminal-tap' });` assertion was matching the constant's
   single-quoted text, not the document. The read is dropped, since nothing else
   in that file needed it, and the assertion is the document's form:

     notify({ type: 'terminal-tap' });  ->  notify({ type: "terminal-tap" });

   Its 95 expects stay. Leaving the read in place would let a document assertion
   pass against a module source, which is the hazard this lane exists to remove.

2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
   exists, so it could not fail:

     expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
       ->  expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)

   Same intent against the generated document: the reflow module's emitted text
   is in the document exactly once. The case is renamed to say so and the
   comment above it describes the generator, not the deleted template.

3. Same file, the routine assertion still passed as a substring of the qualified
   call; qualified as line 30 already was:

     term.resize(nextCols, nextRows);  ->  scope.term.resize(nextCols, nextRows);

   Its 22 expects stay.

Controls, each verified to have changed the file first, all red, tree green
after restore:

  osc-link-tap  return parsePathLineCol(value)        -> url-tap test, 3 failed
  surface-tap   notify({ type: 'terminal-tap' })      -> scroll-routing, 1 failed
  reflow        scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
  module order  'reflow' listed twice                 -> reflow test, expected 2 to be 1

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

* feat(mobile): mount the terminal document in the page instead of a WebView

`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — 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`.

Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.

`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.

`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.

Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.

`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.

Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).

One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.

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

* test(config): render the page's terminal in a browser under the shell's policy

Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.

Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.

The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.

The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.

CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.

No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.

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

* refactor(mobile): retire the last module concatenator and guard the order list

Round 2 fixes, all five folded here.

1. Deleted terminal-webview-html-source.test-support.ts.
   `readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
   tests moved to the generated document, and it was the last thing that built a
   document-shaped string by concatenating module sources — its filter admitted
   `.test-support.ts` files too, so it could have grown one. Confirmed by grep
   that the only occurrence of either name in the repository was its own
   declaration.

2. New document-module-order.test.ts asserts both directions: the non-test,
   non-test-support `.ts` files under `document/` are exactly
   `{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
   and no name is listed twice. `document-constants` is the one exception
   because it is never emitted: its exports are substituted into the modules
   that import them as literals, so the document carries its values without
   carrying the module. A module added here and forgotten there would be dead
   code that reads as live; a name left after its file goes makes the generator
   throw at build time rather than at review time.

3. terminal-document-flip.test.ts's docstring now carries the retirement policy
   from ruling 18: the test is the proof of the flip and holds only while no
   module changes, the first lane that must change one retires it together with
   `terminal-document-pre-flip-script.txt`, and the standing pin from then on is
   `terminal-document-identity.test.ts`, whose fixture regeneration is a review
   event. Comment only.

4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
   and "Four classes and no others". It now says 73 declaration sites and eight
   classes, with each class's measured figure named. Two doc comments sat above
   the wrong declaration and were moved onto what they describe: the
   `NUMBER_GLOBALS` one down to that constant, and the printing one down to
   `significantTokens`, with `STRICT_DIRECTIVE` given its own line.

5. build-terminal-document-script.mjs substituted constants with
   `replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
   constant's value are read as replacement patterns. The substitution is now
   `substituteDocumentConstants`, exported so it can be tested directly, and
   replaces with a function.

Controls, each verified to have changed its input first, all red, tree green
after restore:

  plant document/zz-planted-module.ts   -> order guard, "+ zz-planted-module"
  drop 'wheel-scroll' from the order    -> order guard, "+ wheel-scroll"
  revert to the string replacer         -> 4 failed, "a $& b" became "a marker b"

The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.

The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.

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

* test(config): measure what the page terminal costs the session route's closure

The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:

  modules         4316 -> 4363        (+47)
  local modules    927 ->  971        (+44)
  minified bytes   3,930,787 -> 3,883,532   (-47,255)

The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.

The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.

The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.

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

* fix(mobile): clear the changed-code gate findings this lane introduced

Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.

Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.

Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.

Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.

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

* refactor(config): name the closure helper what main already named it

A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.

Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.

The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.

`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.

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

* refactor(config): put the two closure helpers in main's order

The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.

Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.

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

* test(mobile): make the flip comparator refuse what it was accepting

Round 2 items 6 and 7, both in the equivalence instrument.

6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
   the two were the same binding, so an unrelated rename ending in a digit would
   have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
   an explicit list of pre-flip name, generated name and declaring module. The
   whole script has one entry: `term2` -> `term` in `query-reply`, which is the
   `term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
   sites in all. That is stated in the docstring rather than encoded as a second
   pin, since the flip test already pins the total.

7. Brace absorption treated every unexpected `{` as a linter-added body and
   absorbed any later `}` while one was outstanding, so a bare block anywhere
   would have been swallowed. `isBraceableHeadBody` now requires the open to be
   the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
   `(` and reading the keyword before it — and `matchingCloseIndex` records the
   index the close must appear at, so the absorbed `}` is that body's own.

   That check had to move ahead of the equality check. Wherever a braced body
   ends a block, the baseline's next token is a `}` as well, so pairing them
   would consume the wrong one and leave the counts right for the wrong reason.

Both refusals are tested over snippets:

  function f() { return value2; }  vs  return value;
    -> token 6: expected name value2, generated name value
  let value = 1; use(value);       vs  { let value = 1; } use(value);
    -> token 0: expected name let, generated {

and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.

Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.

The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.

Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.

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

* docs(config): take main's docstrings for the two closure helpers

The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.

What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.

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

* fix(mobile): write the page terminal's notify sink in an effect, not during render

React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.

Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.

Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.

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

* test(mobile): teach C7.1's order guard the three modules this lane added

The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.

  document-host-seams    emitted, but ahead of the scope rather than inside the order
                         list, because the scope's defaults are its four functions and
                         the factory runs as the script is parsed
  document-terminal-shape  types only; esbuild emits nothing and an empty emission
                         would add a blank line to the document
  page-document-modules  the page's entry, not the WebView's, holding the same order
                         for a host that has no generator to splice them

Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.

Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.

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

* test(config): re-measure the session closure against the merged C7.1 base

Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.

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

* test(mobile): retire the flip comparator with the pin it was built for

The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.

So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.

`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.

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

* feat(mobile): make the document's error reporter the sixth host seam

Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.

So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.

The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.

Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:

  + (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
  - "      createWebglAddon: createEngineWebglAddon"
  + "      createWebglAddon: createEngineWebglAddon," and "      installErrorReporter: installWindowErrorReporter"
  - "  window.onerror = function(msg, source, line, column, err) {"
  + "  scope.installErrorReporter(function(msg, source, line, column, err) {"
  - "  };"
  + "  });"

`terminal-webview-payload-hash.test.ts` takes the new length and digest.

Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.

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

* test(config): empty the session closure's react-native-webview list

C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.

Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.

`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.

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

* test(config): pin the onerror seam against a handler the page actually owns

The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.

So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.

The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.

Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.

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

* fix(mobile): start the terminal document per mount (ruling 20)

Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.

Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).

installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.

M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.

L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.

Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.

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

* test(mobile): read the parse-time census tree without assertions

The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.

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

* fix(mobile): move the document's state onto the scope (ruling 21)

Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.

Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.

Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.

Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.

L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.

Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.

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

* fix(mobile): refuse frames from a stopped document

The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.

The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.

Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.

Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.

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

* fix(mobile): style only what the page mount owns

CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.

The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.

A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.

Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.

The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.

Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.

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

* test(mobile): give the page mount's rules and frames their own oracles

Round 3 blocks on evidence, not on shipped behaviour. Each item:

H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.

H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.

The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.

L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.

Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.

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

* fix(mobile): make the page document's dispose idempotent and owner-checked

CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.

Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.

Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.

Golden unchanged at 108,329 bytes; payload and hash unchanged.

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

* fix(mobile): let a pending page mount be disposed before its import lands

Round 4 on #21809.

H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.

M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.

M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.

L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.

L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.

L4. Separated the stacked docstrings in the parse-time-effects census.

The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.

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

* test(config): count the page document's leaked frames from dispose, not from detach

CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.

What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.

Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.

The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.

Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.

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

* fix(mobile): stop a page mount that lost its claim before it writes the scope

Round 5 on #21809.

F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.

`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.

F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.

Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.

The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.

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

* fix(config): give the render fixture's server and scratch tree back when it cannot start

CodeRabbit on the render fixture, plus its note on `release`.

The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.

`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.

Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.

Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.

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

* test(config): read the render fixture's rollback in a temp root of its own

Two defects in the case I committed in cb1833e675, both found by running it.

The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.

Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.

The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.

Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.

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

* fix(mobile): hand the started document to the mount in the turn that started it

Round 6's two LOW items, and the pins for the owner-checked release.

LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.

The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.

That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.

Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.

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

* test(mobile): type the listener wrappers the mount pins install

The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.

Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.

Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.

The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.

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

* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart

Round 7's five items.

1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.

2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.

3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.

4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.

5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.

Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 18:49:38 -04:00
Jinwoo Hong e6aa90ff36 test(mobile): certify the browser pane's golden families and render it in a page (OTA phase C, C6.5) (#21777)
* test(mobile): pin the browser pane's golden families

The half pin for C6: 4 families, 15 goldens, every verdict the one C2's
rule predicts. Measured per family with vitest `-t` over the full
787-golden corpus, with C1's 103 reproduced golden-for-golden as the
control: 6 byte-identical, 9 result-absent-settlement.

No composed `c6-page-closure.ts`: a composed table is pinned against a
route and the browser is a pane, so C7's route is what composes this
with C1's.

The derivation census does not wait for that route. `mobileWebAppRoute-
Closure` becomes one case of `mobileWebAppModuleClosure`, which takes
any entries, so the pane's own closure can be read from the module. Two
cases: the pane alone reaches exactly the pinned four, and the pane
beside `app/h/_layout` adds exactly those four and no other, with the
layout reproducing C1's 22 as the control for the difference.

Closure at this base: 48 local modules alone, 34 beyond the layout, 30
under `src/browser` and four through the web siblings. The design said
23, all under `src/browser`; it was measured before C6.2 and C6.3 added
those siblings, so the pin carries the re-measured number.

`browser.screencast` has no golden at all, so this certifies the input
path and says nothing about the frame path.

Red first: with `browser.wheel` dropped from the table, both census
cases fail naming the missing family; restored, the file's 10 cases pass
and the parity suite reports "15 goldens in 4 families, 6 byte-identical".

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

* test(mobile): pin the frame budget against the shell's real frame

Ruling 2's pin. `binaryEventEnvelopeBytes()` sizes the mobile view's
device scale from a skeleton it builds itself, and until now its only
check was another skeleton of the same shape in the same file: two
copies of one assumption agreeing with each other.

This measures the real thing. A frame with CDP's nine metadata fields
and a real `Page.screencastFrame` timestamp, encoded by C6.1's
`encodeBridgeScreencastFrame` and serialized by the real
`BridgeHostSubscriptions`, posted through the host harness: 303 bytes
besides the image, against a bound of 516.

Held above is not enough on its own — 213 bytes of slack is room for the
shell to grow the envelope by a field the page never hears about — so
the bound is reconstructed exactly instead. Every byte of that slack is
a number this frame prints narrower than a double can; adding those back
gives 516 on the nose.

The budget cases run a generated noise image at the budgeted scale, not
a committed fixture: the worst case is the image JPEG compresses least,
and a photograph sits a tenth of the way to it. 901,161 px at 0.545
bytes per pixel is 491,132 bytes, which the shell posts at 654,857 of
the 655,360-byte cap. One envelope more and the shell drops it, which is
ruling 1 read from the budget's side.

Red first, two ways. Drop the metadata widening from the bound and three
cases fail, the sharpest being the real shell answering the frame the
page thought it could send with zero posts. Add a field to the shell's
own envelope and the reconstruction fails at 516 against 548, where the
existing suite stays green on all 14 — which is the drift this file
exists for.

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

* docs(mobile): record the measured frame bytes, correcting e7cd24ef10

The previous commit message says the shell posts 654,857 bytes for a
frame at the budgeted area. That number was not measured; I wrote it
from the budget arithmetic instead of reading it off the harness. The
measured value is 655,147, which is 213 under the cap rather than 503.

Nothing in the assertions changes — they compare against the cap and
the bound, never against a literal — but the figure now lives in the
file where it was measured rather than only in a message that has it
wrong.

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

* test(mobile): render the browser pane in a page and paint a real frame

The only place C6's whole frame path runs. Every other check reads one
half: the shell suites drive the host with no page, the page suites
drive the hooks with no shell, and the parity pin certifies the input
path from a recording.

Ruling 4: no route is added. `bundleMobileWebApp` already takes an
`appDir`, so this builds a one-route tree of its own and nothing under
`mobile/app` moves. The shell double grows a screencast lane to serve
it: it accepts a subscribe, posts `event.binary`, and prices each frame
the way `BridgeHostSubscriptions` does, so an over-cap frame is dropped
where the page can watch the stream survive it.

Six cases: the pane subscribes with `wantsBinary` and paints the frame
it is handed; a second frame flips the double buffer; an over-cap frame
is dropped and the next one paints on the same subscription; the grant
withheld produces the update-the-app copy and no subscribe at all; a tap
issues one `browser.mouseClick` at the centre of the source viewport;
and no request leaves the bundle's own origin.

Measured. The frame the pane asks this viewport for is 390x698, which as
noise is 201,924 base64 characters. The phone's mobile-mode frame is
780x1424 and encodes to 811,168, which is 124% of the cap and the reason
the area budget exists; the over-cap case uses 2400x2160 at 3,761,580,
574% of it. The tap maps to (194, 356) against a 390x712 source, one
device pixel off centre because the rendered width is 382.33 CSS pixels
for 390 source pixels.

One finding, recorded rather than fixed because it is not the pane's.
The page files a CSP `script-src` violation on every load, on any route:
Zod 4 feature-detects its compiled path with `new Function('')`, the
shell's `script-src 'self'` blocks it, Zod catches the throw and takes
the interpreted path. The page is correct and the report is filed
anyway. One case names it so a second `eval` is visible, and every other
case asserts no violation beyond it.

Red first, twice, both by reverting behaviour C6.2 landed. Stub out the
decode probe in `whenBrowserFrameDisplayable` and the flip case fails on
two identical frame digests. Point `updateBrowserImageSource` at the
host element instead of the surface child and the paint and flip cases
both fail. The first case's comment is corrected by the first of those:
it claimed a visible layer proved the decode-then-flip, and the frame
still paints with the probe gone, so the flip case is what proves it.

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

* fix(mobile): sweep the worst-case JPEG cost instead of taking one point

The reviewer is right, and it is worse than the report says. At 0.545
bytes per pixel, 90 of 143 viewports posted a frame over the cap, and
59 of the 111 the budget claims to fit were dropped outright by the
real shell — 390x712 at scale 1.8 among them.

The old number came from one 2400x2160 frame. A single large frame is
the cheapest per pixel in the whole range, so a worst case measured
there is not a worst case anywhere else.

Swept 143 viewports, widths 320 to 1400 and heights 480 to 1600, each
encoded by Chromium at the scale the real budget picks for it. Across
the 111 the budget fits, the cost ranges 0.54470 to 0.55351 bytes per
pixel. The constant is now 0.56: that maximum plus 0.00649, about 1.2%,
for the encoder version it was not swept on. The docstring carries the
sweep, the range, the margin and the date.

0.56 is a fixed point, not a guess. Raising the constant shrinks the
budget, which lowers the scale, which moves the cost; 0.555, 0.56 and
0.565 all leave the same 31 viewports over the cap, and every one of
those sits at the scale floor of 1, where the module already declines
to go blurrier and C6 ruling 1's drop rule is the protection. The new
test asserts both halves: nothing the budget fits goes over, and the
largest viewport it cannot fit is dropped by the real shell.

The sweep lives in `config/scripts` because it needs Chromium: the
frames are CDP screencast frames, so Chromium's encoder is the oracle
and a Node JPEG library would calibrate against the wrong bytes. It
drives the real budget, the real scale function and the real
`BridgeHostSubscriptions`, and runs in about 4 seconds.

Ruling 2's block in `browser-screencast-budget-at-the-shell.test.ts`
now says plainly what it measures. It feeds `noise(area * theConstant)`,
a byte count the constant itself produced, so it can falsify the
expansion and the drop rule but never the constant. It read as if it
validated the worst case, and it did not.

Red first: put 0.545 back and the sweep fails with 59 viewports, each
naming its scale and reporting `null` — the real shell dropping the
frame rather than posting it over the cap.

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

* fix(mobile): turn Zod's JIT probe off for the page, before any module

The page filed a CSP `script-src` violation on every load: Zod decides
whether it may compile by constructing `new Function('')` and reading
the throw as "no JIT here", the shell's `script-src 'self'` is exactly
that throw, and the browser reports it before Zod catches it. Zod's own
source gates the probe on `jitless` for this case.

`z.config({ jitless: true })` at the entry does not work, and the
reviewer's suggestion of putting it there was measured losing the race.
`$ZodObject` reads `allowsEval` when a schema is constructed, not when
one is parsed, so the first module-scope `z.object(...)` in the bundle
fires the probe — and esbuild evaluates the chunk holding zod and its
callers before the chunk holding any module of ours that imports zod. A
Function-constructor trap in the page put the call under `new ZodObject`
ahead of the entry's first statement.

`globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with
`??=` rather than replacing, so the banner can set the flag before any
module runs. That is where it now lives, beside the `process` shim and
under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is
applied. Nothing is lost: the compiled path was never reachable in a
page under this policy.

The render check's `newCsp()` filter is gone. It dropped violations by
`blockedURI === 'eval'`, which would have hidden a real one, and every
case now asserts zero. The first case walks load and first paint, which
is where the second of the two reports fired. The dead `violations`
array is deleted.

Red first: blank the banner constant and four of the six cases fail,
each naming a `blockedUri: 'eval'` the filter used to swallow.

Finding, not fixed here and reported instead: the page bundles two
copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because
`src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate
source. Aliasing `zod` to one copy in the builder fixes it and was
measured working, but it changes which zod shared code runs in the
shipped page, which is a call to make on its own rather than inside a
CSP fix.

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

* test(mobile): give the shell double the whole canCarry rule and the acks

The double reproduced one arm of `BridgeHostSubscriptions.canCarry`, the
message cap, and silently carried anything the other two would have
refused: a window already holding its maximum frames, and a window whose
bytes the frame would push past the limit. It also ignored the page's
`ack` frames, so its window never reopened — which was invisible only
because no case streamed far enough to close it.

Both arms are in now, and the `ack` arm consumes the page's acks exactly
as the host does. The three caps are read out of `bridge-caps.ts` and
`bridge-host-subscriptions.ts` rather than retyped, the same way the
harness already reads the protocol version and the CSP, so a double
carrying a stale number is not possible. The render check's own
`640 * 1024` is gone with them.

One case for it: thirty frames of about 200 KB, roughly 6 MB through a
4 MiB window, nothing over the message cap, so a drop can only come from
the window. Every frame posts, nothing is dropped, and the page's ack
seqs are read back to show the window stayed open because the page acked
rather than because the double was generous.

The file docstring said the double answers no RPC. It serves a
screencast stream now, so it says that instead, and says what it still
is not: it decides no domain behaviour.

The dead `violations` array is gone, folded with the CSP commit.

Red first: make the `ack` arm inert, as it was before this commit, and
the case fails with `Set{'posted','dropped'}` against `Set{'posted'}`.
A first attempt at that mutation left the byte subtraction in place and
stayed green, which is the mutation being wrong rather than the case.

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

* ci(mobile): run the whole mobile-web-app family, not a list that goes stale

The `mobile_web_app` job hand-listed ten files. The render check this
chain added was not among them, so it would have skipped in CI — and it
was not the first: three landed censuses were already unlisted, and
their closure blocks only run with `ORCA_MOBILE_WEB_APP_DEPS_REQUIRED=1`,
so they are green in the sharded `test` job whether or not they ever
ran here. Nobody could see it.

The list is now two vitest filename filters, `config/scripts/mobile-web-
app-` and the one builder test outside that prefix. Quoted, because
vitest matches a positional as a substring against the discovered files
rather than expanding a glob: `mobile-web-app-*.test.mjs` finds nothing,
and it fails by reporting no test files rather than by running fewer.
Both forms were tried before this one was written.

It runs 18 files and 205 cases, against 10 files before. With mobile
dependencies absent, 111 of those 205 skip, which is the measure of what
only this job runs. Per file, cases CI has never run:

  browser-pane-render            7 of 7   (this chain)
  source-control-external-links  9 of 9
  source-control-keyboard        6 of 6
  source-control-text-inputs     6 of 19  (C4.2)
  frame-budget-sweep             4 of 4   (this chain)
  route-manifest                 2 of 17

Two more files the filter adds run fully in the sharded job already and
change nothing here: `browser-pane-text-inputs` (C6.4 — it censuses a
hand-written closure and never bundles, so unlike the report it was not
skipping) and `external-link-seam`.

The sweep is renamed into the family for the same reason. As
`mobile-browser-frame-budget-sweep.test.ts` it matched neither the job's
filter nor `pr-code-change-scope.mjs`'s `config/scripts/mobile-web-app-`
prefix, so a change to it alone would not have run the job that runs it.
It is also gated on the dependency check now: it needs no
react-native-web, but it launches Chromium, and that flag is what tells
the job with a browser from the one without. Unguarded it would have
failed the sharded `test` job outright.

That filter is a prefix match. `config/scripts/mobile-web-app-` and
`mobile/src/` both fire this job, and `.github/workflows/pr.yml` is in
GLOBAL_FORCE_PREFIXES, so this commit runs everything.

Also: `postedFrame` in the ruling-2 pin and in the sweep both reached
the binary lane through `?.`, so a subscribe that opened no stream read
as zero posts — indistinguishable from a dropped frame, which is the
verdict both files are about. They throw now. The render check's
restated `640 * 1024` went with the window caps in c73b405f81; the cap
is read from `bridge-caps.ts`.

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

* ci(mobile): record what the mobile-web-app job costs to run

The filter that replaced the hand list runs 18 files where the list ran
10, so the step's cost is now a function of what anyone names into the
family rather than of what a reviewer remembered to add. Measured on
this machine: 25-30s wall for the whole step, of which the frame-budget
sweep is 2.5s.

The sweep is the one part whose cost is a choice. It encodes 111 noise
JPEGs in Chromium, one per viewport the budget fits, so adding rows to
that set is a decision about this job's runtime and the comment says so
where someone would make it.

Found, not fixed, and reported for its own PR rather than folded here:
the page bundles two copies of zod, mobile's 4.4.3 and the repo root's
4.5.4, reached through `src/shared/zod-salvage.ts`, which resolves
upward while `mobile/src/` resolves to mobile's. That is 808 KB of
duplicate source and two module instances in the shipped page. Aliasing
`zod` in `mobileWebAppBuildOptions` fixes it and was measured working
during this chain; it is reverted and stays reverted, because it changes
which zod shared code runs in the page and that is not a call to make
inside a CI commit. The CSP fix in 71254ab3a9 does not depend on it:
`globalConfig` lives on `globalThis`, so the banner covers both copies.

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

* fix(mobile): load the frame-budget sweep's mobile modules after the dependency guard

vite transforms every file under mobile/ against mobile/tsconfig.json, which extends
expo/tsconfig.base.json; the sharded test job installs no mobile dependencies, so the
sweep's static imports failed the file at load before describe.skip ran. Type-only imports
stay static; the values load in beforeAll behind mobileWebAppDependenciesPresent().

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

* test(mobile): certify the frame budget at the quality the pane ships

Round 2: the sweep and the render check encoded fixtures at a retyped 0.72; both now read
BROWSER_FRAME_QUALITY (the sweep from the module, the render check through the harness reader),
so a quality change fails the certification instead of leaving it green. Every render case now
asserts zero CSP violations; the sweep pins the 32 viewports left at scale 1; three references to
a renamed file and a file that never existed are corrected; a shim count comment is made
count-agnostic.

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

* test(mobile): assert the frame-budget sweep against the constant, not the measured maximum

The margin above the measured 0.55351 is what an encoder drift is allowed to spend; pinning the
measurement made a drift inside the margin fail a budget that still held. The number stays in the
docstring as the sweep's record.

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

* test(mobile): move the sweep's measured-maximum note beside the assertion it explains

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 07:43:14 -04:00
Jinwoo Hong b6e8b1a7b2 feat(mobile): serve the tasks screen from the page, with its seams (OTA phase C, C2.1 + C2.5) (#21694)
* fix(mobile): encode the host id in the tasks workspace-creation href (OTA phase C, C2.1)

`use-mobile-tasks-workspace-create-actions.tsx` built
`/h/${hostId}/session/...` with the host id interpolated raw — the C1.2 class.
A host id carrying `/`, `#`, `?` or whitespace reaches the wire as an href
`BRIDGE_ROUTE_HREF_PATTERN` refuses, the handoff falls through to the local
router, and expo-router's Unmatched paints over the page.

Deleted rather than patched: `hostNewWorktreeSessionRoute` already builds
this exact href with both segments encoded, and already has the test that
pins it. The screen now calls it.

The census that caught it stays: no module under `src/tasks` may interpolate
into `/h/${...}` without encoding, which is the rule rather than this one
line. Three refactor-parity hashes move with the statement change and are
recorded in that file the way every earlier movement is.

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

* feat(mobile): route the tasks tree's external links through the seam (OTA phase C, C2.1)

Ten of the twelve call sites in the tasks page closure: the nine under
`src/tasks`, swapped by one export in the dependency barrel, and
`MobileMarkdown.tsx`, which imports react-native directly and is edited in
place.

Inside the shell's WebView react-native-web's `openURL` calls
`window.open(url, '_blank')`, which both shells refuse — iOS returns nil from
`createWebViewWith`, Android false from `onCreateWindow` — and resolves
regardless. Every one of these sites would have reported success into a tap
that opened nothing.

The barrel's `Linking` is typed `{ openURL: (url: string) => void }`, so a
`.catch` on it is a compile error rather than a handler for a rejection that
cannot arrive; the seam names its own failures. `MobileMarkdown`'s own
`.catch(() => {})` goes with the swap for the same reason.

No parity hash moved: the barrel and `MobileMarkdown` are outside the
refactor-parity family's source set.

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

* feat(mobile): route the shared screens' external links through the seam, with a census (OTA phase C, C2.1)

The last two of the twelve call sites in the tasks page closure:
`ProtocolBlockScreen.tsx` and the `openExternalUrl` prop wiring at
`host-screen-overlays.tsx`.

Both are shared with native routes and with the already-live `/h/[hostId]`
page, so this changes that page too: its external links go from the measured
`window.open` no-op — which both shells refuse and which resolves anyway — to
a URL handed to the shell. Nothing changes on a phone, where the seam is
`Linking.openURL` unchanged.

The `openExternalUrl` prop chain is retyped `(url: string) => void` with it,
and `SmartWorkspaceSourceField`'s `.catch(() => {})` goes: the seam names its
own failures and never rejects, so that was a handler for a rejection that
cannot arrive.

The census is the rule rather than today's twelve sites: no module in the
tasks page closure may reach react-native's `Linking`, by name or through a
namespace import. It reads the closure from a new builder export —
`metafile.inputs` for `_layout` plus the route, which is one definition of
what a page contains — and checks which module the name comes from, not which
text a call site writes, since the tasks tree still calls `Linking.openURL`
and that `Linking` is now the barrel's seam-backed export. Confirmed to
discriminate: restoring one react-native import turns it red.

A second case pins that the seam is in the closure, so an empty offender list
cannot also mean a page that reaches no link code at all.

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

* feat(mobile): write the tasks clipboard through the shell's verb (OTA phase C, C2.1)

The two `Clipboard.setStringAsync` sites in the tasks page closure move onto
a seam, `src/platform/clipboard.ts` with a `.web.ts` sibling, registered in
the overrides.

A hook rather than a function because the web form needs the page's bridge
client, which is React context. Native is `expo-clipboard` unchanged. Web
calls `native.clipboard.write` through `useNativeVerbs`, because
`expo-clipboard` on the web is `navigator.clipboard` and needs a secure
context: the iOS shell serves the page from a custom scheme and Android from
`https`, so that path would work on one platform and silently not on the
other, with nothing at the call site able to tell.

Both seams reject rather than return false, and both call sites already wrap
the write in a `catch` that puts the message on screen — so a write that did
not land says so instead of showing "Copied". A route that has not declared
`native.clipboard.write` is refused before a frame is sent and lands in that
same `catch`; the route declares it in the entry commit.

Two parity hashes move, the hook list and the statement hash, each by one
entry, and are recorded in that file. `semantics` holds, as do render and
style: no RPC call, method literal or JSX host signature changed.

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

* feat(mobile): hand the tasks Back button to the shell (OTA phase C, C2.1)

The tasks header's `router.back()` reached expo-router through the dependency
barrel, and inside the page that moves nothing: the document holds the single
history entry the entry wrote with `replaceState`. The stack with somewhere
to go is the native one the shell pushed the page onto.

One line in the barrel, as with `Linking`: `useRouteHandoff` is router-shaped,
so every call site is unchanged. On a phone it is expo-router. Inside the page
it keeps a route the page renders and posts `navigate-back` for a Back the
document cannot serve — the C2.2 seam, which until now had no consumer.

No parity hash moved: the barrel is outside the refactor-parity source set,
and no call site changed.

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

* feat(mobile): render mermaid as its own source box on the web (OTA phase C, C2.5)

`MermaidDiagram` is in the tasks page closure, reached through
`MobileMarkdown`, and it renders the diagram inside a sandboxed `WebView`.
`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.

The web sibling renders the labelled source box the native component already
falls back to on a parse or render error, with that component's own styles, so
the degradation looks like a state the product already has rather than a
second design.

Not a browser renderer, and the reason is not reach: mermaid is a browser
library and the engine bundle is vendored. It is that the native path's safety
comes from the WebView it runs in — `buildHtml` escapes `</script>` and the
U+2028/U+2029 separators because diagram source is untrusted agent and PR
content — and a DOM path has no such sandbox, so it needs its own escaping and
its own proof. That is a change of its own, not a smaller version of this one.

Registered in the overrides, whose gate fails on an unlisted `.web.*` file.

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

* feat(mobile): turn the tasks route on for the page (OTA phase C, C2.1)

The entry: `/h/[hostId]/tasks` joins `MOBILE_WEB_PAGE_ROUTES`, the route file
becomes the shell's flag switch in `index.tsx`'s shape, and a `.web.tsx`
sibling renders the screen directly, registered in the overrides.

The screen moves to `src/tasks/MobileTasksScreen.tsx` first, verbatim — body
byte-identical, imports rewritten to `./`. It has to: under the builder's
`resolveExtensions` a web sibling importing `./tasks` resolves back to
itself, which is why every other shell route's screen already lives in `src`.

The parity family follows the file rather than the path. `TASKS_ROUTE` leaves
`MOBILE_TASKS_SOURCE_FILES` — `SOURCE_PATTERN` already matches
`MobileTasks*.tsx`, so listing it too would double-count — and the execution
reader points at the new file. Measured rather than predicted: all six
refactor-parity cases pass unchanged. No hash moved, including the family
text and declaration list, because the new name sorts where the route path
sat.

The route declares `navigate`, `storage`, `externalLink` and
`native.clipboard.write`, which the grammar fold made expressible and
per-route scoping makes meaningful: it is granted those and not the rest of
what this shell implements.

The browser check covers what only a browser answers — every module in the
closure evaluating under React Native Web, `taskSource` surviving the
handshake into the page's own URL, and the route's chunk arriving on a
client-side navigation. It states plainly what it does not cover: the three
seams are reached from controls that need provider data the double does not
serve, so a case posting those frames directly would prove the transport and
read as a tap it never performed. Both new checks join the `mobile_web_app`
job.

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

* fix(config): resolve a route closure the way the bundle ships it (OTA phase C, C2.1)

`mobileWebAppRouteClosure` took the route's explicit `.tsx` path as an entry
point, so esbuild used that file directly and `resolveExtensions` never ran.
For a route with a `.web.tsx` sibling that measured the native switch, which
no browser loads: the tasks closure came back carrying
`MobileWebShellScreen`, and with it a `Linking` import the census then
reported as an offender.

Extensionless now, so the closure is the one the page actually contains:
3775 modules, 428 local, with `external-link.web.ts` and `clipboard.web.ts`
in it and the shell screen out.

The route-manifest pins move with the tasks route joining
`MOBILE_WEB_PAGE_ROUTES`, in both the declaration check and the built
manifest.

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

* fix(mobile): cover the clipboard seam, close two page escapes, share the mermaid props (OTA phase C, C2.1)

Four from round 1.

The clipboard seam shipped untested. Both halves have one now: the native
form rejects when `setStringAsync` answers false and resolves when it does
not, and the web form is driven through the real port pair — resolving on a
reply, rejecting when the shell says the pasteboard refused, and rejecting on
an ungranted route without putting a frame on the wire.

The tasks barrel still re-exported `expo-clipboard` with no consumer, which
kept `ExpoClipboard.web.js` — the `navigator.clipboard` path this series
exists to avoid — inside the page closure. Deleted, and asserted as the
module's absence from that closure rather than as a count of importers: a new
import puts the file back whoever writes it.

`ProtocolBlockScreen` reached expo-router's singleton for its way out to the
host list. A singleton is the one shape the handoff cannot intercept — it is
not a hook, so the page's bridge client is never consulted — and `/` is a
route the page does not carry, so inside the shell that replace rendered the
root route in the WebView instead of leaving it. Pre-existing and live via
`/h/[hostId]`; routed through the handoff now. Two suites' `expo-router`
mocks gain the hook the handoff reads.

`MermaidDiagram.web.tsx` redeclared its props; it imports the native
component's type, so drift fails tsc.

No parity hash moved: none of these files is in the refactor-parity source
set.

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

* style(config): use endsWith for the clipboard module check

The changed-code gate refuses a dollar-anchored regex where `String#endsWith`
says the same thing. No behaviour change.

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

* fix(mobile): close the href census gap, read route params through firstParam (OTA phase C, C2.1)

Five from round 2, two of them real.

The raw-interpolation census inspected only the leading `${...}`, so
`` `/h/${encodeURIComponent(hostId)}/session/${worktreeId}` `` passed it — and
a worktree id carrying `/`, `#`, `?` or whitespace breaks the href exactly as
a host id does. It now refuses any hand-built `/h/...` template with any
interpolation left raw, whichever segment it is. Proved against exactly that
shape in a throwaway before the change, which the old rule admitted.

The tasks switch read `hostId` and `taskSource` as plain strings. expo-router
hands back an array for a repeated query key, so a duplicate `?hostId=` built
`/h/host-a%2Chost-b/tasks`; both go through `firstParam` now, as the
agent-history switch does. `index.tsx` is untouched, per the Phase D list.

Three in the render check's prose: the header claimed the browser proves the
three seams fire from a tap, which the file's own closing note denies; a
module count repeated a number the closure test already pins; and a `replies`
parameter was threaded through without ever being supplied.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 13:40:26 -04:00
Jinwoo Hong 428558b941 fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)
* fix(mobile): let the shell's page paint a file preview

A file preview has one shape on the wire: the desktop answers a base64 body
and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>`
for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to
load it, so every image preview in the page paints nothing — reproduced in the
render check, which logged the refusal naming `img-src 'self'` before this.

`data:` is granted to images and to nothing else, so what it admits is what the
page itself composed out of a reply it already holds; `script-src 'self'` and
`connect-src 'self'` are untouched, and `blob:` is not added because nothing in
the closure needs one. Both platform pins narrow from "the header contains no
`data:`" to "`data:` appears on `img-src` and nowhere else", which is the check
that still fails if a later directive grows one.

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

* docs(mobile): say what the data: image case actually loads through

Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img>
the Image component renders carries `alt`, `style`, `draggable`, `ref` and
`src` and no load handlers at all — it is there for the browser's image context
menu and for `getBackgroundSize()`. The load signal comes from
`ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it,
so the `new Image()` in this case is the same mechanism the screen's own load
runs through rather than a stand-in for it.

And the screen maps `onImageError` to "Unable to load preview"
(`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the
normalizer's `binary_file` branch, which a CSP refusal never reaches.

Comment only. No assertion moves.

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

* docs(mobile): state the img-src data: bound as the destination, not provenance

"admits only what the page itself built" read as a provenance guarantee, and CSP
has none to give: `data:` is matched as a scheme, so the directive admits any
`data:` image URL and the browser cannot tell one the page composed from one it
was handed. Nor is the content the page's own — the mime type and the base64
body both come from the host, and `normalizeImagePreviewResult` only checks the
mime type is a non-empty string.

The true bound is where the URL goes: it is never fetched as anything but an
image, `img-src` is the only directive admitting it, an image fetch executes
nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`,
`connect-src 'self'` and `object-src 'none'` are untouched.

Both copies reworded identically, since they are kept in step by the render
check's own policy comparison.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:36:39 -04:00
Jinwoo Hong edd9e3125b fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9)

The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no
height, and every box react-native-web lays out below the mount is `flex: 1` against a parent
that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`,
the route tree commits, `innerText` holds every row, and the accessibility tree reports each one
at the offset it would have had. Nothing is painted below the header, and nothing takes a tap —
the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no
row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator,
and the same bytes reproduce it in headless Chromium.

The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline
because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web
injects at runtime; a linked asset would paint the collapsed layout until it landed.

The render check gains the assertion that would have caught it: the root's box measured against
the viewport, and the one control this route paints with no RPC answered — the New Workspace
button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked
for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit
test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`.

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

* fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9)

The shell's WKWebView is built with the default text interaction, so WebKit installs its selection
assistant over the page. A hold on a worktree row raises the selection loupe over the row's own
text and the touch is cancelled before the page's responder sees it, which leaves every
long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same
injected hold opens the row action sheet on the native list and on the page in the Android
WebView, and does nothing in the page on iOS.

It is not the document's to fix, which the device disproved one rule at a time:
`-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe
and left the hold undelivered, and headless Chromium confirms the property computes to `none` on
the page's text, so the CSS reaches it and WebKit's own gesture wins anyway.

The cost is real and named here rather than discovered later: the page has no text selection on
iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there
until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and
Android is untouched.

No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import
WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in.

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

* fix(mobile): make the root reset the template's bytes, not a copy with an addition

The comment said the reset is what Expo's web template ships, and `margin:0` was not in it.
`@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box
and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime,
so the addition only covered the frames before that sheet landed. Nothing pinned it either:
removing it left all 60 tests green, which is the other way of saying it was never load-bearing.

Dropping it makes the string one thing with one source instead of a copy to keep in step with two.
The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still
has a definite height before the first paint, which is what the collapse needed.

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

* style(mobile): format the render check with the root formatter

`config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char
line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by
`./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change.

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

* docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes

"The bytes Expo's web template ships" is false and checkable: the template's own block is
pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is
actually true, and what the next reader needs, is that it carries the same declaration set and the
same `id="expo-reset"`, minified.

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

* test(mobile): pin every rule of the root reset, not two substrings of itself

The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two
substrings taken off that same constant. A rule dropped from it took the assertion with it, so
`body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check
stays green without the overflow rule, so nothing else held them either.

Each rule is now a literal written here, named one at a time so a failure says which one went, and
the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the
id each fails this test and only this test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:28:11 -04:00
Jinwoo Hong e2afb5eef9 feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

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

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

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

* feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4)

Three gaps the census named, and the last of them is why the page needed a second
grant.

`expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with
an empty array and the list painted "Host not found" over the host the shell had
just opened it for. `init` gains an optional `host`, and `host-store.web.ts`
serves it: the profile the screens read, with no device token and no public key,
because the bridge already carries the connection those would have opened. The
native writes it cannot make — pairing, renaming, recency — settle rather than
throw, since recency orders a list the page never shows.

AsyncStorage's web build is `window.localStorage`, and the page has none worth
having: Android turns DOM storage off and on iOS the origin host is the session
id, so a pin set in the page was gone on the next remount. The builder aliases
the module to a page store whose values are the app's own — `init` primes the
allowlisted keys, a write is applied locally and posted over a new `storage`
grant, and the app is where it lands. The allowlist is two keys and is the whole
fence: everything the app stores shares one namespace, the hybrid shell flag
included, so a page that could write any of it could turn the feature on for a
build that never offered it. A key outside the list is refused and, crucially,
not kept locally either — a pin that looks set and is not is the failure the
grant exists to avoid.

The bridge host is built only once both have been read, because `init` is
answered once per `ready` and carries them: a host that started without them
would have to be torn down to carry them, and the list would already have mounted
against a host it could not name.

`Alert.alert` on a failed host removal is a silent no-op in React Native Web, so
inside the page that failure had no surface at all. It routes to the error the
list already shows, on both platforms.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

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

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

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

* fix(mobile): write the storage-write ref after the commit

The last render-phase ref write in this hook joins the commit-phase effect.

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

* fix(mobile): name the confirm setter this callback already calls in its deps

A `useState` setter is stable, so the identity of the callback is unchanged; the list now
says what the body reads. Reported on the line this branch rewrote.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

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

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

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

* fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4)

Five, from one round of review.

A removal that fails no longer writes the identity error: that one is an early return
over the header, the list and the overlays, with nothing to dismiss it and nothing left
to render the confirm it re-opens. It goes to a dismissible line above the list instead,
on both platforms, cleared by the next confirmed refresh.

`init` reads the allowlisted keys on every answer rather than capturing them at mount, so
a document that reloads inside one mount is primed from after its own writes. The read
stays synchronous: the page refuses every member until `init` lands and the golden
recorder mounts a screen in the same turn it drains one, so a promise here moves the
first render of every bridged replay.

A profile read that rejects is now a shell failure with a diagnostic instead of a `ready`
session with no host behind it and a page asking forever. The page bounds a value by the
envelope's own constant rather than caching what the wire drops. And a write is held to
the keys this page was handed, so one host's page cannot rewrite another's pinned list.

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

* test(mobile): state the two test fixtures' types instead of asserting them

The casting gate reads a `SAFETY:` rationale off the line directly above the assertion,
and a wrapped comment puts a comment there instead. Two of the four were not assertions
worth keeping at all: a hoisted fixture says its own type, and the router comes from the
mock the file already installs.

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

* test(mobile): assert the two fixtures in place, not through a widened binding

`const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate
refuses, and rightly: the evidence is discarded and then invented again. The assertion
belongs at the literal, with its rationale on the line above it.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

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

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

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

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

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

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

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

* fix(mobile): name the key a refused page write reached for

The last diagnostic still falling through to the line about a view outliving its
host, on the branch that added it. The key is the evidence: it says which host's
pinned list the page was reaching into.

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

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

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

* test(mobile): give the ref-refresh probe this branch's storage writes too

Same merge, one branch further: a probe here also collects what the page asked
the screen to write.

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

* style(mobile): format the web shell route entry

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

* fix(mobile): hand the page the app's storage as it stands, not one init late

The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes
two of them from its own native screens. The shell's map was only re-read after a ready, so a
native write between two readies reached the init after next: the drawer opened on the repo the
user left. The map is now module-scoped and every writer of an allowlisted key notes it as it
writes, so the init that answers a ready already carries it. The store read only seats the map,
and a read that started before a write no longer puts the older value back.

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

* test(mobile): find the banner's dismiss without an assertion

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

* refactor(mobile): keep the storage mirror in the storage tree

The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the
dependency the wrong way round: the shell is what is built on the app's storage, not the other way.
The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller
names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour
change.

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 22:20:49 -04:00
Jinwoo Hong ac4dc6599b feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1)

The Route A entry built no client and mounted the route tree immediately, so the
web provider minted its own: it read the page channel, built `BridgeRpcClient`
and fell back to a placeholder that rejected every call. A tree that mounts
before `init` reads synchronous getters against a client that knows no host, no
state and no build, and the first render it records is the wrong one.

The entry now owns the page's one client. It builds it from the channel at
module scope, mounts nothing until `onReady` fires, and stamps the session and
build ids `getShellSession()` returns on the document beside the mount state, so
a screenshot, the render check and a device console read the same three facts.
`client-context.web.tsx` takes that client by injection and serves it from
`acquire()` for every hostId, because the bridge protocol names no host; the
placeholder and its `BridgeTransportUnavailableError` are gone, along with the
entry that pointed at them in the unvalidated-port inventory.

A document with no channel is not inside the shell, so it says `unbridged` and
stops rather than waiting out a backoff nobody answers. The render check gains a
shell double that answers `ready` with `init`, reads the stamped session back off
the document, and proves the gate is real by opening the same route with no
double and finding an empty `#root`.

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

* feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2)

The shell serves its document at `/` and refuses every other path, so the page's
own location matches no route in the tree it carries and expo-router paints
Unmatched. Nothing in the document can tell it otherwise, so the screen has to
cross the bridge.

`init` gains an optional `route: { pathname, params }`. The pathname is held to
what a path may be rather than to what a screen may want: rooted, single-slash,
no query and no fragment. A protocol-relative `//host` would make
`history.replaceState` throw a cross-origin SecurityError and take the mount down
with it, and the params are a field of their own so neither side parses a URL.
The shell route supplies it, the screen passes it to B4's hook, and the hook
holds it for the life of one host: the page routes once, before its first render,
so a route that changed afterwards has nothing left to change.

The page writes that URL into its history and then mounts. It also hands the same
URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots
`window.location.href` when its module is imported, which is before any frame has
crossed the bridge: without it the router reads the `/` the shell served and
replaces the page's own path right back. A shell too old to name a route leaves
the page with nothing to open, so it paints a panel saying to update the app,
built as elements outside React because the route tree is exactly what cannot
mount there.

Both platforms stop reading the document's URL to decide a load finished. The
page rewrites its own path before its first render, so a document that committed
at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready`
forever and left the Android WebView hidden behind it. What is left is whether
the load committed, which is the question the state machine already answers.

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

* feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3)

The worktree list now renders from the desktop's bundle, and which routes do is
negotiated rather than decided on one side. The manifest gains
`routes: [{ pathname, grants }]`, written from one declared list the builder
checks against the tree it bundled, so a declaration naming a screen with no
module fails the build instead of reaching a phone as a page that paints
Unmatched. The field is additive because the phone reads the manifest loosely and
pins no schema version; the desktop's own writer stays `.strict()`, and the stale
comment saying there was no additive path is corrected.

The shell answers for what it can do. A route the bundle does not list, or lists
needing a grant this app does not implement, settles as `native-route` and
downloads nothing; so does a desktop that ships no bundle at all, which is the
one blocked verdict that is not a wall, because a desktop with no bundle declares
no page route and there is no workspace to refuse. The route is answered before
the compat verdict for the same reason: a bundle this shell cannot open is not a
reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx`
mounts the shell when the flag is on and takes the native list back as the
fallback, and both routes read the flag through one hook so the census stays the
whole census.

A tap on a worktree row still opens the native session screen. The page posts
`notify { name: 'navigate', href }` behind the `navigate` grant, which is not a
convention: `notify` is a closed union, so an older shell refuses the whole frame
and the page checks the grant before it posts. The shell pushes the target over
the still-mounted view, so Back reveals the page with nothing reloaded.
`route-handoff.ts` and its web sibling are the seam, router-shaped so the list's
own hook and the recorder's adapter are untouched and no golden moves: the web
file wraps the three members that leave the document and hands back any target
outside the page routes `init` named.

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

* feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1)

A page that throws where it renders has nowhere to report it: the shell
sees a document that loaded and a view that never painted, so it waits
on a blank page forever. This adds the one frame that says so.

`notify { name: 'fault' }` carries the capture an `error` frame already
carries, so both directions share one bound and one reader. It rides a
grant because `notify` is a closed list on both sides: a page served by
a newer desktop into an older shell would have the whole frame refused,
so the page asks `init.grants.native` first and stays quiet on a no.

The shell answers it as `document-load-failed`, which is what happened.
That reason drops the generation and downloads once, so a page broken
by bytes this host has since replaced recovers, and one broken by its
own code stops at the failure screen rather than a blank one.

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

* refactor(mobile): give the bridge's notifications and the host's errors their own modules

The fault report took both files over the 300-line cap, so each gives up
the group that was already separable. The page's one-way members move to
`bridge-client-notifications.ts`, which is also where the two policies
that split them can be stated: the two the native contract declares throw
before a session, and the fault report never throws at all. The host's
three error classes move to `bridge-host-errors.ts`, the mirror of the
page's own `bridge-client-errors.ts`.

No behaviour changes. The commit before this one is over the cap on its
own, which a forward-only history is the reason to say rather than hide.

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

* feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1)

Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides
no global one, so a throw while a route renders — or a route module that
rejects once the manifest is lazy — unmounts the tree and leaves a blank
document. The shell sees a load that finished and waits on it forever.

The entry now wraps what it mounts on `init` in one boundary that posts
the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a
route that cannot be resolved throws where the router renders it, and a
boundary below the router never sees that.

It renders nothing and offers nothing to press. The generation is on disk
and was hash-checked before the view loaded it, so the same bytes throw
again and a retry here would only throw twice; recovery belongs to the
shell, which drops the generation on the report.

The render check now grants the fault and collects what the page posts
into the errors every case already asserts empty, because a throw the
boundary caught paints nothing and logs nothing a `pageerror` listener
would hear.

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

* fix(mobile): write the page-fault callback ref after the commit, not during render

React may replay or discard a render, so the write belongs in the commit phase. Layout,
not passive, and declared above the host's effect: a native frame can arrive between a
commit and a passive effect, and the host must already hold this render's callback.

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

* fix(mobile): write the route ref after the commit, not during render

Same class as the page-fault ref: render must stay pure because React can replay or
discard it. Folded into the one commit-phase effect above the host's.

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

* fix(mobile): write the page-route and navigate refs after the commit

Same class again: the last two writes this branch adds join the commit-phase effect, so
nothing this hook holds is written while React is rendering.

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

* test(mobile): take the boundary test to C0.5's fake-client pair

`createBridgePortPair` is generic over the shell client now; the fake-client form this
test wants is `createFakeBridgePortPair`.

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

* feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1)

A route module that throws while the bundle is evaluated takes the entry with it. The
document still commits and the WebView still reports it loaded, but no boundary mounts,
no fault is posted and no frame is ever sent, so the session sat in `ready` behind a
blank view forever.

The native view's finished load starts a clock; the page's first `ready` stops it;
expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing
cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner
owns a clock and the reducer owns every decision.

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

* test(config): make a route chunk throw, so the render check proves the boundary reports

The check folded page faults into its errors but nothing ever produced one, so a boundary
that stopped reporting would have stayed green. The server now serves one real route
chunk with a throw in front of it: the module still links, so the failure is an
evaluation throw where the router renders, which is exactly what the boundary is for.

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

* fix(mobile): make the host enforce the grants it issued, and hear nothing before ready

`forwardNotify` acted on any frame that parsed, including a `fault` from a page that had
never asked for a session and therefore held no grant. Both refusals now go through one
rule the host shares with the frame it sends, so the list a page is told about and the
list it will be served cannot drift.

Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the
moment a grant belongs to a route rather than to the protocol.

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

* fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2)

`sendInit` put `options.route` straight on the wire and only the page's decoder checked
it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on
its 2 s backoff forever, and the shell un-hide a view that would never paint. The only
trace was a `console.warn` inside the WebView.

Three changes, one failure mode. The host parses the route at construction and serves no
session at all when it will not do, reporting it as a shell failure. The pathname rule
refuses empty segments, dot segments and backslashes anywhere, because `replaceState`
normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders
whatever came out. And the producer encodes the host id it interpolates, which is how
one carrying a query, a fragment or whitespace got there.

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

* fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3)

`handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left
the page and never whether the shell accepted it. Two hrefs the app builds today were
posted, answered true and suppressed the local fallback, so the tap did nothing at all:
the Connection-log link's object form, which `String` turns into `[object Object]`, and
any href carrying a fragment, because the pathname is stripped to match and the whole
href is what goes on the wire.

Object hrefs now resolve the way the router resolves them, and the string is checked
against the envelope's own pattern and cap before it is posted; anything that fails
falls through to the local router, which is the policy this module already states.
Whether a target names a screen that exists is shape's business no longer, and the
comment says C1.7 owns it.

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

* fix(mobile): start a new flow when the shell view remounts

A remount cleared `pageReady` but left the flow alone, so the wait the retired
document armed still matched. It expired onto the page that replaced it, took a
ready workspace to `document-load-failed`, and deleted the generation on the way.

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

* fix(mobile): say which page notification the bridge refused and why

A refused `notify` fell through to the line about a view outliving its host,
which is a different fault and names neither the notification nor the reason.
The two refusals now get a line each, so a page that was told nothing cannot
bury one reaching past what it was told.

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

* test(mobile): pin the ready deadline to the page's own retry ceiling

The margin was stated in a comment and asserted against itself, so changing
either number left the suite green.

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

* fix(mobile): say what was wrong with the screen a refused shell named

C1.1's per-kind log lands on a branch that also refuses a route, and that
diagnostic was still falling through to the line about a view outliving its
host. It names the shell's own bug now, and carries the issue.

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

* fix(mobile): refuse a dot segment however the route spells it

A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x`
climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on
a screen nobody asked for, with no refusal anywhere. The one segment rule both
patterns share now reads the encoded spellings as the dot segments they are, and
still lets an escape inside a name through.

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

* test(mobile): name routes in the manifest field list the builder emits

C1.3 added `routes` to every manifest this builder writes, and the Phase A
contract test still listed eight keys, which is what went red in CI.

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

* test(mobile): hold a navigate target to the same segment rule as the shell's

The href pattern is built from the segment source C1.2 tightened, and nothing
said so: a spelling one pattern refused while the other took it would be a hole
with a `notify` already pointed at it.

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

* test(mobile): give the ref-refresh probe the navigations this branch added

C1.1's new case builds its own probe, and on this branch a probe also collects
the hrefs the page hands back. The file stopped typechecking on the merge, which
the tests ratchet caught.

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

* style(mobile): format the web shell route entry

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

* test(mobile): split the bridge frame suite along the modules the merge created

`bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1
both added cases to it, over the 800 the lint allows. The split follows the two
modules those changes extracted, so each suite now names the module it covers.

`bridge client page faults` moves to `bridge-client-notifications.test.ts` (the
outbound notify surface) and `bridge client refusals and send failures` to
`bridge-client-inbound-frames.test.ts` (the reader, including the refused-event
release that cancels at the shell). The seven suites that exercise the client as
a whole stay put. The fake port all three drive moves to
`bridge-page-client-test-harness.ts` rather than being copied three times.

No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after,
and all nine `describe` bodies compare byte-identical to their originals.

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

* test(mobile): type the shared init fixture as the member a case reads

The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local
const, control flow narrowed it to the `init` member at each use, so
`INIT.grants` read fine. An imported binding keeps its declared type instead, so
the same read lost `grants` to the union and the tests ratchet went red.

Declared as the init member, which is what every case already treats it as. No
cast: the object literal is checked against the narrower type directly.

`INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`,
`GRANTS` is inferred, and nothing reads a member off an `eventFrame` result.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 21:53:58 -04:00
Jinwoo Hong 209d2d8df6 build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5)

The page bundled as one 8.16 MB script because every route was a static
import. The route manifest now defers each screen behind `import()`, the
build is esm with splitting on, and the document loads the entry as a
module. What the browser parses before the first route can paint drops
from 8.16 MB to 908 KiB; the whole page still weighs the same.

Two budgets hold it: the chunk count, which catches a split running away,
and the bytes the entry reaches by static import, which catches it
collapsing back. The second is the one that matters, and it is measured
from esbuild's metafile because only that says which import is static.

The RequireContext stays synchronous, since expo-router reads keys() to
build the route tree before anything renders. A lazy module cannot answer
`unstable_settings` or `ErrorBoundary`, which expo-router reads off the
namespace, so a test holds that no route in the subtree exports either.

The render check now waits for the route's own text: the entry's mount
signal lands while the route chunk is still being fetched.

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

* fix(mobile): read a route's synchronous exports from esbuild, not a regex

`export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all
reach the namespace without matching the declaration pattern the guard was
matching, so the lazy manifest dropped the boundary and the page painted blank.
A star re-export is now reported rather than read as clean.

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

* docs(mobile): say that the entry budget is not a per-route opt-out

Measured: statically importing one route already breaks the 3 MiB bound for 5 of
the 14. The hatch only works for a layout node, which is the only place
expo-router reads a synchronous export from.

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

* build(mobile): derive the chunk ceiling from the route count

64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's
routes would have failed on a number measured before they existed. Four per
route plus 16 tracks the measured slope; the entry-bytes bound stays the real
budget.

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

* refactor(mobile): drop the web entry's dead suspense boundary

expo-router wraps every screen in its own, so this one never fires; all nine
render checks stay green without it.

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

* test(mobile): pin that a client-side navigation fetches the next route's chunk

Goes red with splitting off: the tasks screen paints out of the entry and no new
script is fetched.

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

* fix(mobile): name every bundle output by its bytes, not by esbuild's path hash

esbuild's [hash] is over the metafile's input keys, which are paths relative to
absWorkingDir, so a checkout at another depth or with node_modules as a symlink
named a byte-identical chunk differently and shipped a different buildId for one
commit. Outputs are now renamed leaves-first to the sha256 of their final bytes.

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

* fix(mobile): fail the build on a route the lazy manifest would strip

The guard ran only in a test while the docstring said it failed the build. It
now runs in bundleMobileWebApp and names the route and the export.

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

* build(mobile): derive the asset ceiling from the chunk ceiling and the images

A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset
count would have failed first and named the count instead of the split. Chunks
plus images plus the document keeps the chunk ceiling the one that trips.

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

* test(mobile): split the route-manifest tests out of the bundle builder's

The builder's test file passed 600 lines. The route manifest, the synthesized
RequireContext and the web entry are their own subject and move together.

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

* fix(mobile): give the export guard the builder's route-source loaders

Without .js as jsx the guard reported a React Native .js route carrying JSX as
"JSX syntax extension is not enabled" instead of reading its exports.

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

* test(mobile): assert the navigation fetches the tasks route's own chunk

"some new script arrived" passed on any fetch. The builder now names the chunk
each route lands in, read off the metafile, and the check asserts that exact
path arrived and was not already loaded.

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

* fix(mobile): resolve a route's realpath before matching it to its chunk

esbuild writes metafile input keys after resolving symlinks, so every scratch
route tree under /var on macOS reached no output and failed the build.

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

* fix(mobile): fail the build when the asset ceiling outgrows the shell's map

The derived ceiling had no upper bound, and the native shells return null for a
manifest over their own 256 rather than truncating it. At 42 images the formula
crosses that at 50 routes, inside what Phase C adds, so the build would stay
green while the phone got nothing. The number is read from the contract through
esbuild, not restated here.

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

* test(mobile): cover the two hard stops in the content-addressed naming

Both throws only ran through a whole bundle before, where neither can be
provoked. A cycle and a route no output claims are now asserted directly; each
test goes red when its throw is removed.

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

* fix(mobile): exit the app-bundle build on one line, not a stack

The route-export guard fails this script by design, and a raw stack put the
route and the export name under twelve frames of node internals. Mirrors the
verifier's exit; the message is printed as thrown because every throw on this
path already names its source.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 11:58:42 -04:00
Jinwoo Hong 381a3da46f feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder

Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and
the helpers the Phase C app builder needs become exports. No behaviour change
to the shipped bootstrap bundle.

The CRLF guard grows two exemptions it needs once it is pointed at mobile/src:
the image and font extensions .gitattributes already pins -text, and the
gitignored webview engine modules the postinstall writes.

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

* feat(mobile): web entry for the host route tree, and its two transport siblings

The entry mounts app/h on react-native-web through expo-router's own ExpoRoot.
It lives inside mobile/ so one React resolves, and supplies RpcClientProvider
itself: the route tree starts below the native root layout that owns it.

route-manifest.ts is a real typed module whose body the builder replaces --
esbuild has no require.context. A virtual specifier would need an ambient
declaration and would leave the entry unchecked.

Two .web.* siblings, both listed with a reason in web-overrides.json: the
transport substitution point (a placeholder client until C0.4 lands
BridgeRpcClient) and the device token store, whose native path imports
expo-secure-store, which is {} on web.

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

* feat(build): build:mobile-web:app, the phone's host routes bundled for the web

Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app,
with the same manifest and the same two-scratch-build determinism check. Dark:
build:mobile-web, packaging and the A2 census are untouched, and C1 is what
flips build:release.

Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin
hashed assets rather than data: URLs, because the shell's CSP sets img-src
'self'; the render check under that exact header is what found it. The script is
referenced root-absolute for the same reason a <base> tag cannot be used: the
document is served at every route depth and base-uri is 'none'.

The budget sits below the contract's per-asset ceiling so growth trips a build
rather than a refused asset on a phone. esbuild splitting does not lower it:
one entry with only static imports emits one chunk (measured).

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

* fix(mobile): let React Native Web paint under the shell CSP

RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so
style-src 'self' blocks every rule and the page renders unstyled. Measured, not
predicted: the render check serves the document under this exact header and
reported the violation.

'unsafe-inline' is granted to style-src and nothing else. script-src 'self'
holds, which is the directive that decides whether page code can arrive any way
other than as a fetched same-origin script. The test now pins that scoping
rather than rejecting the token everywhere.

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

* ci: prove the Route A app bundle on every PR

A dedicated job, for the same reason the browser provider has one: it needs
mobile/node_modules and a real browser, and the sharded test matrix would pay
for both on every shard. It builds the bundle, verifies it, and runs the
builder, override-census and render suites. It ships nothing.

The mobile_web_app signal is lifted out of should_run the way static_analysis
is. A mobile-only diff is desktop-irrelevant and skips every gated job, and
that is exactly the diff that changes the page this job builds.

Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml
join the installer cache keys in the two workflows that build an installer off
a hashFiles key, since beforePack requires out/mobile-web and a mobile-only
change must miss those caches rather than reuse a stale build.

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

* test(build): pin the shipped builder against the app builder's own module name

The assertion named a specifier that no longer exists, so it held vacuously.

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

* test(mobile): assert the RN Web style-src grant in the Swift checks

The Swift twin of the Kotlin CSP test still required style-src 'self' and
no unsafe-inline anywhere, so it trapped on the approved grant.

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

* test(build): make the Route A render check name what each route paints

The check asserted only "some html, no errors", which expo-router's Unmatched
screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green.
Each route now asserts content only its own component produces, and the
unmatched case asserts the screen positively so the negatives discriminate.

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

* test(build): read the shell CSP past the comments that quote directives

Both constants document themselves with // comments containing quoted
directive text, which the quoted-string scan picked up as directives. One
parser now drops comment lines, and iOS and Android go through it.

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

* feat(build): honour a .web.* route sibling in the app bundle

Routes were imported by absolute path with the extension, so esbuild's
resolveExtensions never applied and a .web.tsx under app/ was dead code the
census still accepted. The manifest now carries a key and a module: the key
stays the native filename so the URL does not move, and the module is the web
sibling when one exists.

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

* test(build): tie each named shim to the esbuild option that implements it

The shim list was asserted against a literal copy of itself, which passes
however the build is configured. Each entry now carries an appliesTo that
reads its own option, checked against the real options object.

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

* chore(build): line up the CRLF exemptions, the budget comment, and the job scope

The builder loads .gif as a file but neither .gitattributes nor the CRLF scan
exempted it, so the blanket eol=lf pin would have rewritten one. A test now
keeps the two lists in step. The Phase C byte budget's comment sat on the
asset count, and a root package.json edit could change build:mobile-web:app
without running the job that proves it.

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

* style(build): satisfy the index-check lint rule in the CSP parser

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

* ci: key the installer caches on the mobile page trees too

beforePack builds the mobile web bundle into the installer. Today those bytes
are Phase A's, which src/** already covers, but once C1 flips the entry to
mobile/app a page-only change would hit a cache holding a stale installer.

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

* test(build): skip the bundling tests where mobile dependencies are absent

The sharded `test` job collects config/scripts/**/*.test.mjs and installs no
mobile dependencies, so the two new suites failed there on "Could not resolve
react-native-web". They now skip themselves with a message naming the job that
runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing
install fails it instead of skipping everything it exists to prove.

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

* fix(build): scan mobile/packages in the .web.* census

The census claimed the app entry never resolves into packages/, but the
dictation hook imports @orca/expo-two-way-audio and the built script carries
ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and
planting a .web.* in each scanned tree proves the scan is not passing because
a tree happens to be empty.

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

* test(build): assert the route exclusions against a tree that has them

mobile/app holds no test, spec or +api file, so the exclusion rule was
asserted against a tree it could not fire on. A scratch tree plants one of
each; dropping the rule now fails this test.

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

* test(build): 404 unknown file paths in the render check's page server

The server answered every path with the document, so pointing publicPath at
/wrong-prefix still rendered three green routes: the script is fetched from
the one prefix that is served. A path naming a file now has to come out of the
bundle, which is what the shell's manifest map does.

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

* test(build): cover the app bundle verifier's own checks

The verifier had no test. One doctors the buildId, which the packaged assert
catches; the other rewrites the tree so every digest still agrees and only the
two fresh builds can tell, which is what a stale out/ looks like. Deleting
either check now fails a test.

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

* chore(build): tidy the app bundle comments and the job's path prefixes

Drops an export nothing read, merges two comments that had drifted apart from
the constant they describe, and corrects the claim that the job runs on every
PR when it is path-gated. package.json leaves the prefix list because
GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it,
since the page resolves a .web.ts out of there.

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

* style(build): merge the duplicate node:fs/promises import in the census

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

* fix(mobile): redirect the hybrid shell route on the web page

app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls
requireNativeViewManager at import. In a browser that throws before React
mounts, and the route manifest imports every route statically, so one native
route left the whole page blank at every URL.

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

* test(build): fail the render check with the error that stopped the mount

The check waited on "#root has children" with Playwright's animation-frame
polling, so a route module that threw at import read as a bare 30s timeout
naming nothing. It now waits on a mount attribute the entry sets after the
router commits, polls on a timer, and races the wait against the first
uncaught error so the failure carries it.

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

* test(build): answer the favicon the render browser asks for

CI resolves the runner's Google Chrome, which requests /favicon.ico; the
bundled headless shell does not. The bundle carries no icon, so the server
answers 204 rather than turning a browser habit into a console error the
render assertions read as a page fault.

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

* test(build): settle the render check's uncaught-error race without rejecting

The entry throws during goto, before anything awaits the race, so a rejected
promise surfaced as an unhandled rejection beside the real failure. The same
signal now resolves with the error.

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

* chore(mobile): list the page transport in the raw request port inventory

The placeholder client implements the port, so the boundary test counts it as
an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient
replaces it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 09:50:37 -04:00