* fix(mobile): keep a painted frame under the page until its first paint
The shell tore its own frame down the moment a generation was on screen
(`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws
nothing until its document paints. What showed for the whole of the page's boot
was the surface behind it with nothing on it: 1.42 s on a cached generation,
against a one-frame budget.
The page is the only thing that knows when it has a frame, so it says so. It
declares `painted` in `ready.reports` and posts the notify after the browser has
painted its first commit; the shell holds the same neutral frame it was already
painting while it opened the generation, then fades it out. The wait is bounded
by the declaration and never by a timer: a generation served by an older desktop
declares nothing and is uncovered on `ready`, which is what every shell did
before this.
iOS painted white rather than nothing: a WKWebView is opaque by default, so the
shell's own surface never showed through. It is now transparent, as the Android
view already was.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold the cover through the compositor handover
The page reports the paint its own renderer made; putting that on the app's
surface costs another frame or two. A linear fade from the report left two
frames of bare surface between the two on an emulator, which is the hole the
cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity
across that handover: five reopens now show 0-21 ms of bare surface against
102-2043 ms on the build without it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): negotiate the paint report in both directions
The page posted `painted` whatever shell it met, and `notify` is a closed union:
every shell installed before this answered it with an error frame, once per
mount. The shell now advertises the name in `init.accepts` beside the param
clear and the client identity, and the page posts only when it was advertised.
The declaration in `ready.reports` stays unconditional, because it is an
optional field an older reader strips rather than a new opcode, and because the
first `ready` — the only one that matters for the first paint — is sent before
any `init` has arrived.
The accepts list moves into `bridge-init-frame.ts` beside the grants, which is
the module that builds the frame carrying it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the cover's colour instead of asserting its shape
Two gate findings on the round-two head. The cover test reached the background
through a cast of the style prop; it now reads it through a checked narrowing,
so the test proves the shape it depends on rather than declaring it.
`use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args
gained `onPagePainted`: its harness is a literal, so a new required handler is a
missing property. The probe now counts paints and one case spends the counter,
which is what a handler wired only to satisfy a type would not do.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): move the cached-generation opening out of the reducer
`mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused-
update work and the paint handling both grew it. What comes out is one thing —
putting a generation already on disk on screen, and deciding whether this route
is one that bundle carries. It is the reducer's cache path and its refused-
update path both, and it was already three functions sitting together.
`step` goes into a module of its own because the two now share it; a copy in
each would be two spellings of one transition, and exporting it from either
would point the dependency the wrong way.
No behaviour moves: the reducer's table tests are unchanged and the page closure
is unchanged at 4,211, since neither new module is reachable from a page route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop the previous document's paint when a new one starts
A document that replaced a painted one inside the same mount inherited its
`pagePainted`, so the cover lifted before the replacement had drawn anything.
The native view already reports `loading`; the screen dropped it. It now
reaches the reducer as `document-started` and clears the page document state.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the declaration case call the frame policy
The case compared the name to itself and never called `shellPageFrame`, so it
passed for a policy that ignored the declaration entirely.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): report the page's frame from the route screen, not the router
Every route screen is behind `import()`, so the wrapper above expo-router
commits with a suspense fallback while the chunk is still arriving. The paint
report hung there, which uncovered the shell's view over an empty body on a
cold chunk. It now hangs on the screen the manifest resolves, layouts excluded.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): retire the readiness wait a replaced document armed
`document-started` cleared the page document state and left the flow alone, so
the previous document's readiness deadline passed the flow check, read
`pageReady` as false and failed a session whose replacement was still loading.
The flow moves with the document, for the reason `remounted` already moves it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let a departing route screen take its paint report back
The report waits two frames, and nothing cancelled the second one, so a screen
unmounted in between still told the shell to uncover. The reporter now answers
with a take-back the wrapper returns as its cleanup, and the once-per-document
latch frees only when a report was cancelled before it landed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let the screen that arrived take over a frame still owed
A screen committing inside the two frames an earlier one was owed found the
latch taken and reported nothing; the earlier screen then freed that latch on
its way out and nobody was left to lift the cover. The newest commit now
supersedes the pending report, and only a posted one spends the latch.
Covers the redirect window with a render check against the pr route, whose
target chunk is held open while the document sits on the hub's fallback.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the take-over case turn on the take-over
The case cancelled the first screen's frame through the cleanup path, so it
passed with the take-over deleted. It now leaves that screen mounted and reads
the clock: the frame after the replacement commits is the replacement's first,
not the one the screen behind it was still owed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb