* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)
The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.
- The image and layer writes move out of `mobile-browser-frame-state.ts` into
`browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
on the element RN Web sizes and flips the double buffer with one opacity write per
layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
become visible. The sibling arms the flip from an image decode instead, and the flip
itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
back into the same string. Measured in this tree against the `buffer` shim the page
bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
under a microsecond for the carried string.
Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): budget the mobile view's frame area against the bridge cap (OTA phase C, C6.3)
A screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES.
Measured here: the phone's mobile view at the native device scale factor asks for a
390x712 viewport at 2x, which is 1,110,720 device pixels, and at the worst case JPEG has
at quality 72 that is a 807,559-byte message against a 655,360-byte cap — 123% of it.
The `.web.ts` sibling holds the mobile view inside that. The budget is computed rather
than written down: the cap, less an envelope this measures from the frame's own shape at
its widest (435 bytes), is what the base64 may occupy; three quarters of that is the JPEG;
divided by one named worst-case constant of 0.545 bytes per pixel it is an area of 901,271
pixels. The phone lands on a device scale factor of 1.80 and a 654,205-byte message, 99.8%
of the cap. A cap that moved and a budget that did not would be a pane going dark on a
page it could have streamed.
Web view mode is untouched, and byte-identical to the native request: there the frame is a
desktop viewport letterboxed into maxWidth/maxHeight, which the page cannot predict, so C6
ruling 1's drop-the-over-cap-frame rule is its only protection. Native is unchanged.
The constants and the assembly move to a third module because a `.web.ts` cannot import a
value from the file it shadows — the bundler resolves the specifier back to the sibling
itself — and two copies of them would drift.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): put the browser pane's text inputs on the web font-size seam (OTA phase C, C6.4)
The pane has two text inputs, the address bar at 12px and the key row's "Type on page…"
at 14px, and neither went through TEXT_INPUT_FONT_SIZE. In a browser an input under 16px
makes iOS zoom the page on focus and never zoom back, and keyboard-occlusion.web.ts reads
a visual viewport scale other than 1 as "no keyboard" — so one focus would leave the
pane's keyboard lift at 0 for the rest of the typing session. C4.2's failure exactly, on a
screen its census does not walk: that census walks the source-control hub and the review
route, and the pane is in neither until C7 lists a route that mounts it.
The key row's input goes straight onto the seam, whose native value is the theme's body
size, so it renders at the 14px it already did. The address bar is a `.web.ts` split
instead, so native keeps the 12px meta size it has always shown; the input and the label
painted over it move together, or the address would resize on every focus.
The address bar also gets inputMode="url" on the web only. keyboardType is a native enum a
browser does not read, so the page's address bar was falling back to a plain keyboard;
inputMode takes precedence over keyboardType, so it stays undefined on both native
platforms.
One consequence recorded rather than fixed, and pinned in
config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs: the C4.2 census resolves
an import through .ts/.tsx only, never .web.ts, so it reads the native address style that
no browser loads and reports it as an offender. Whoever lists the pane's route either
teaches resolveLocal the extensions the builder already prefers, or moves the address bar
onto the seam natively at 14px.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)
C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.
The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the address field test inside the typecheck ratchet (OTA phase C, C6.4)
`findByType('TextInput')` does not typecheck: a host-component string is not an
`ElementType`, so the file fell out of `tsc -p tsconfig.test.json` and the tests-typecheck
ratchet reported it. Found by reading the ratchet's exit code rather than its piped tail,
which is how it was missed the first time. The element is looked up by its placeholder
instead, and the ratchet is green with 732 test files in the program.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)
Round 1 folds on #21754.
The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.
Both arms now answer for the frame they were armed with.
The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.
Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): find the address input by its imported type (OTA phase C, C6.4)
`findByType('TextInput')` does not typecheck — a host-component string is not an
`ElementType` — so the file dropped out of `tsc -p tsconfig.test.json` and the
tests-typecheck ratchet reported it. The imported component is what the element is looked
up by now; the react-native mock stands it up as that same string at runtime, so the
lookup is unchanged and the file is back inside the ratchet's program.
Why the earlier run reported 0: the command was
`node scripts/check-tests-typecheck-ratchet.mjs 2>&1 | tail -2; echo $?`, and `$?` after a
pipeline is the exit code of `tail`, which is always 0. The banner line that printed was
the last line of the failure banner, not the success one. Every gate in this branch's
report is now read from the command itself, unpiped.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(scripts): read a text input's size from the file the page loads (OTA phase C, C6.4)
The census followed an import through `.ts`/`.tsx` only, while the closure it walks comes
from esbuild, which prefers `.web.tsx`/`.web.ts`. So a style module with a platform
sibling was judged on the half no browser loads. That fails in the direction that matters:
a split whose web half sits under the focus-zoom floor reads as clean because its native
half is on the seam, which is the exact shape the seam exists to catch.
`resolveLocal` now tries the extensions in the builder's own order. The seam comparison
collapses a resolved path onto its module identity before matching, because the seam is
itself a split — `text-input-font-size.web.ts` is where the raise lives — and without that
every binding in the tree would stop naming the seam: deleting it reds both C4 route
closures.
The browser pane's own census flips from pinning its address field as an offender to
expecting none. The two C4 route closures still answer 0 offenders and 0 unresolved, run
with the closure tests enabled.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound the frame envelope above every double it can carry (OTA phase C, C6.4)
Round 1 folds on #21760.
The envelope estimate serialized each metadata field as a 16-character double, so the
bound was 435 where a real frame event at its widest measures 516. Since the budget spends
whatever the cap leaves, that 81-byte shortfall was the whole margin: a frame on the budget
would have gone over the cap and been dropped. The bound is now the skeleton plus the
widest a double can print, for the nine metadata keys imported from the protocol module
rather than copied, so a tenth field cannot be added without being paid for.
Two corrections to the fold as written, both measured here.
The widest is 25 characters, not 24. Exponential form tops out at 24
(`-1.7976931348623157e+308`), but ToString only leaves fixed notation below 1e-6, and just
above it a double prints as sign, `0.`, five zeros and seventeen digits:
`-0.0000012345678901234567`. A sweep over four million random bit patterns found 25 and
nothing longer; a deterministic sweep over both forms is in the test.
And the base64 expansion has to count padding. Three quarters of the room claims up to two
characters base64 does not have for an image of 3k+1 bytes, which at a margin the budget
now spends exactly is a dropped frame. The two agree at today's envelope size because the
room happens to divide by four, so this is a latent fix rather than a reproduced one, and
the test pins the discrepancy directly instead of implying it.
Budget moves from 901,271 to 901,161 pixels; the phone's mobile view stays at a device
scale factor of 1.80. Pinning the bound against C6.1's real encoder is C6.5's, once both
are on main; the docstring says so, and says what the bound does not cover: the metadata
object is loose, so unknown keys and web view mode's letterboxed frame are ruling 1's to
drop rather than this budget's to predict.
Also: the pane census listed its own closure by hand, so "no unresolved styles" said the
walk read those files, not that they are the pane's set. It now scans `src/browser` for
every non-test module that renders a `TextInput` and asserts the list matches; a third
module planted there reds it. And the seam's native-consumer pin names the key row and the
address bar directly rather than transitively, matched at the `fontSize` property instead
of anywhere in the file — a file-wide search survives the change, because the import line
does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)
Round 2 folds on #21754.
Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.
One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.
react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.
The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)
pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.
Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb