mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
debug-github-rate-limit
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e736a9f29b |
feat(mobile): take the session screen's inputs, links, clipboard and routers through the platform seams (OTA phase C, C7.2) (#21790)
* feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2) The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`, reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the app's body size and move in place, which is the same number natively. Three do not — a 22px key-capture field and the chat's two 15px fields — so each gets a `.web.ts` sibling of the address bar's shape, with a shared base so the two halves can differ in nothing but the size. The capture field is the one the move shrinks rather than raises: 22 already clears the focus-zoom floor, and the census reads the seam as a binding rather than as a number, so there is no expression that keeps 22 and still says where the size came from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the theme import the composer's style split left behind `oxlint` over the whole tree, which CI runs, reads it as an error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): open the session screen's three external URLs through the platform seam (OTA phase C, C7.2) The landed external-link census, run over the session route's closure, reports three modules reaching react-native's `Linking`: a terminal link tap whose open mode is the phone's browser, and the two WebView-backed readers, each of which sends a tapped link to the system browser rather than navigating the artifact away. Inside the shell `Linking.openURL` calls `window.open`, which both shells refuse and which resolves either way, so all three reported success into a tap that did nothing. The seam also stops swallowing the failure: each site caught and discarded, and `openExternalLink` names it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): take the session screen's seven clipboard sites through the platform seam (OTA phase C, C7.2) `expo-clipboard` resolves to `navigator.clipboard` on the web, which needs a secure context — iOS serves the page from a custom scheme and Android from `https`, so that path works on one platform and silently not on the other. The landed census now reports the module out of the route's closure entirely. The seam grows its reader half, on the landed `native.clipboard.read` verb: text, a PNG, and a presence probe. Two degradations are recorded rather than implied. No shell serves an image, so the page answers null and the terminal's paste takes the branch an empty clipboard already took; and the shell serves no presence verb, so `contents` answers what this side knows rather than reading to find out, which would raise iOS's paste-consent prompt on every foreground. The copy-path sheet gains the failure toast its two neighbours already had: it showed "Path copied" before the write, and the seam rejects rather than returning false. The route parity pin moves with it: five clipboard hooks join the expanded route and one runtime string joins the sheet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): take the session domain's three routers through the handoff seam (OTA phase C, C7.2) Inside the page a screen is one document standing in for one screen, and `useRouteHandoff` is the only thing that knows which targets the page keeps and which it hands back to the app. The three holders here are the workspace-missing bounce, the file-tap preview push, and the pane-tap param consume. The domain's census is narrower than the two landed ones because it has to be: eight of its hooks take `useFocusEffect` and two take `useLocalSearchParams`, neither of which can navigate, so the rule is a closed list of names rather than a ban on any value import — which also catches expo-router's module-singleton `router`, a spelling a `useRouter` rule would have read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the two style siblings the session screen's inputs added (OTA phase C, C7.2) The overrides census fails on an unlisted `.web.*`. One raises the chat's two 15px fields past the focus-zoom floor; the other lowers a 22px capture field onto the seam, and its entry says why a reduction is the right answer there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12) The floor is the rule and the seam is the mechanism. A binding rule alone made the custom-key capture field an offender at 22, where nothing can zoom, and the only way to satisfy it was to lower a one-character field to 16 — the tail wagging the dog. The seam's web half now exports the floor it already computed `Math.max` against, and the census reads that number out of that file rather than carrying a second copy of 16. The rule becomes "the seam's binding, or a literal at or above the floor", with no per-site exemption: a literal under the floor is still reported, which is the case the seam exists for. A tree whose seam declares no floor is refused rather than judged against a number the census invented. So the capture field goes back to 22 on both platforms and its split, its override entry and its parity test go with it. The chat's two fields stay split, because 15 is under the floor however it is spelled. Red-first: with the rule removed, a planted literal 16 and a literal 22 are both reported and the refusal case does not throw; a literal 15 is reported either way. All three route closures that run this census — session, source-control, review — report 0 offenders and 0 unresolved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): answer clipboard reads on the read grant and catch refused writes `contents()` reported no text on a route that granted `native.clipboard.read` without the write, because it read `verbs.granted`, which is write AND read. The verbs hook now exposes the two grants separately and the web seam answers on the read one; `granted` keeps its meaning for the callers that need both. The Markdown copy action was the one write of eight in the session domain with nowhere for a rejection to go: the seam rejects when the pasteboard refused the text, the callback had no failure branch, and its caller drops the promise, so a refused write raised an unhandled rejection and still left "Copied" on screen. It now takes the error haptic and the "Couldn't copy" toast the other copy paths show. A census over `src/session` fails if any `writeText` call site lacks a failure branch, so the ninth site cannot arrive without one. The route parity pin moves with it: one callback body, one runtime string. Its refresh note claimed six clipboard hook sites for a delta of five; the walk from `SessionScreen` reaches five, and the terminal's paste is not among them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): probe both clipboard kinds at once and judge sizes against the floor Moving the two clipboard probes into an object literal serialised them: the migrated `contents()` awaited `hasStringAsync` before `hasImageAsync` was called, where both callers had used `Promise.all`. That path runs on mount, on every AppState foreground and on every select-mode toggle. Restored, with an ordering probe that deadlocks unless both probes start before either answers. The floor case could not fail for the reason it named: its fixture declared 16, so a census carrying its own copy of 16 passed it. It now plants a seam declaring 20 and a literal 18, the size that is clean under one floor and an offence under the other. Two stale wordings from the reverted split: one closure case still said "both split style modules" over a one-element list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): buzz the quick-command row when a copy is refused The last of the seven migrated writes without the error haptic. The row already said "Couldn't copy" on its own control, in red, for the 1500 ms the toast the other six show would have lasted, so it never claimed a refused write had landed; what it had no way to say was anything the thumb still on the button could feel. Its first test, on the harness its list already uses: the seam rejects when the pasteboard refuses, and the two cases are the difference between the row that shows a green check over nothing copied and the row that does not. The list's own test gains the haptics mock the row's new import needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the clipboard census require the await its rule depends on `hasFailureBranch` accepted any enclosing `try` with a `catch`, so the one shape the census exists to stop passed it: `void clipboard.writeText(...)` inside a try/catch is an unhandled rejection with a handler three lines above it that can never run, because the block returns before the promise settles. It now requires the call to be awaited inside the try's own block, or to carry a `.catch` along its own chain. The boundary walk stopped only at function and method declarations, so a `catch` outside an arrow answered for the call left running inside it; every function-like node ends the search now. Five cases over snippets read through the same reader, because a `void` write would have to be committed to be tested against the real tree. Control on a real site: making the Markdown write un-awaited inside its own try reports it. Two provenance fixes. The runtime-string delta across C7.2 is two literals, not one: "Couldn't copy path" took the count from 532 to 533 and "Couldn't copy" took it to 534. And main's C7.4 made `BRIDGE_CLIPBOARD_MIMES` `['text']`, so an image mime is a value the schema does not admit rather than a refusal the verb spells out, with `native.media.pick { source: 'clipboard' }` waiting on C7.6; the web seam and its test said otherwise. Behaviour unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let an unmounted quick-command row emit nothing on a refusal The haptic I added ran before the mounted guard, so a copy pressed on a row that then scrolled out of the list, or a sheet closed over it, still buzzed when the rejection arrived. A buzz with no row to explain it is feedback for nothing, and the guard was already there for the feedback state one line below. Red-first: press, unmount, then reject. The success path already guarded first. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the clipboard census require a catch with something in it Any `.catch` property access counted as a failure branch, so two shapes that handle nothing passed: `clipboard.writeText(text).catch` reads the handler's name and registers nothing, and `.catch()` swallows the rejection while the caller goes on to say the write landed. The rule now requires `.catch` to be the callee of a call carrying at least one argument. Red-first with both shapes in the snippet reader, the accepting cases unchanged. Control on the real tree: emptying the notes sheet's handler reports `MobileSessionSheets.tsx:174`, and restoring it greens. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
96c1dd8b70 |
feat(mobile): budget the browser pane's frames and put its inputs on the web seam (OTA phase C, C6.3+C6.4) (#21760)
* 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 |
||
|
|
ee61e3bd41 |
fix(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2) (#21735)
* feat(mobile): measure the keyboard from visualViewport inside the page (OTA phase C, C4.2) react-native-web's `Keyboard` is a stub: `addListener` returns a subscription that never fires and `isVisible()` is always false. A screen inside the shell's page that waits for `keyboardDidShow` waits for the life of the document, and the software keyboard covers whatever sits at the bottom of it. Two C4 screens are text entry at the bottom. `platform/keyboard-occlusion` is the pair. The native file carries the source-control hook's logic unchanged, events and clamp and the comment that travels with it. The web sibling reads `visualViewport`: the layout viewport keeps its size and the visual one shrinks, so the occluded strip is `innerHeight - (height + offsetTop)`. `offsetTop` is in it because a scrolled or pinched visual viewport sits partway down the layout viewport and the strip below it is not keyboard; dropping the term reds two cases. It listens on `resize` and `scroll` — the browser scrolling a focused input into view moves the offset without resizing anything — and reads once at mount, because a composer opened over an already-raised keyboard receives no event at all; dropping that read reds a third case. `useKeyboardAvoidingPadding` is a second name rather than a `Platform.OS` branch at the call site. Natively it is 0 and subscribes to nothing, so a composer that asks for it renders exactly as often as it does today; `KeyboardAvoidingView` has already moved it and padding would move it twice. On the web it is the whole of the avoidance, that view being driven by the events this file exists because the page never receives. No `visualViewport` answers 0 rather than guessing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): lift the commit bar and the note composer inside the page (OTA phase C, C4.2) The two consumers move onto the seam. The hub's hook becomes one line and keeps its name, which is what the hub's state calls the number. The note composer takes the padding as a style on the `KeyboardAvoidingView` it already had: natively that is 0, so the prop is `undefined` and the phone renders exactly what it rendered before; inside the page it is the strip the keyboard covers, which is the only thing that moves the composer there. The census is over both future route closures rather than over the two call sites: `platform/keyboard-occlusion` is the one module in either closure allowed to name the stub. Red first at the base commit — run in a throwaway worktree at `9309350864` rather than by setting the fix aside — it named `use-mobile-source-control-keyboard-lift.ts` as a subscriber outside the seam and found the seam's web file in neither closure. `mounted-bottom-drawer.tsx` is exempt by name, and the census asserts the exemption is really in both closures so it cannot outlive its subject. It reads more than a height — `Keyboard.metrics()` for a sheet opened over a raised keyboard, and each event's `duration` to animate with it — which the seam does not model, and it sits in C1's, C2's, C3's and C5's closures too, so moving it is a change to every page rather than to this domain. Its listeners are inert on the web the same way, which is why the composer inside it takes its own padding rather than inheriting one. No render-check case: measured, none of the five registered routes reaches the seam, the commit bar or the composer, and a headless browser cannot shrink the visual viewport independently of the layout one anyway. C4.4 carries it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the keyboard harness instead of asserting its fields (OTA phase C, C4.2) The changed-code gate flagged the two `as` casts in the hoisted harness. A return type on the `vi.hoisted` callback says the same thing and is checked rather than asserted, which is the shape the host-list route test already uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a pinch zoom as no keyboard, and test the clamp (OTA phase C, C4.2 round 1) Round-1 folds plus CodeRabbit's exemption point. **A pinch zoom read as a keyboard.** A 2x zoom shrinks the visual viewport by exactly as much as a half-screen keyboard, so the commit bar and the composer moved on a page nobody was typing into. A `scale` other than 1 answers 0. Geometry alone cannot tell the two apart and a stored "no keyboard" baseline would be a heuristic, so a keyboard raised while zoomed is the accepted rare case rather than a guess. `scale` is read defensively because older WebViews do not implement it, and taking its absence for zoomed would answer 0 for every keyboard on them; mutating the guard to key on absence reds both cases. **The clamp had no test.** A bare subtraction left all nine cases green. The case is a visual viewport taller than the layout one, which mobile Safari reports mid-scroll and which would have pushed the commit bar down the screen instead of up. **One guard, where the test reaches it.** `occlusion`'s `viewport === undefined` arm was unreachable: the effect returns before calling it, and the absence case exercised that one. Deleted, and the remaining case says which guard it proves. **The census exempts two files, not a directory.** `startsWith('src/platform/')` would wave through a later `src/platform/*.web.ts` that subscribed to the stub directly, which is the defect this census exists for. Named exactly, with a planted subscriber beside the seam as the fixture; restoring the directory filter reds it. **And the moved comment claimed an inset it never subtracted.** Deleted. Correcting a comment that was false where it came from is not a rewrite of the logic the move carried: no statement moved with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the page at scale 1 so the zoom guard is not the keyboard path (OTA phase C, C4.2 round 2) Round 2's finding changes what the zoom guard costs. iOS auto-zooms on focus of any input under 16px; both consumers' inputs are 14px (`typography.bodySize`), and the page's viewport meta set no `maximum-scale`. So `scale !== 1` was not the rare pinch the guard was written for, it was every focus — and the seam would have answered 0 on the one flow it exists for. The guard stays and the premise is fixed instead: `maximum-scale=1` in both places the page's meta is written, the built document in `build-mobile-web-app-bundle.mjs` and the bootstrap `index.html`. iOS honours it for the focus auto-zoom and has ignored `user-scalable=no` since 10, so a deliberate pinch still works; the input sizes are untouched. C4.6 step i is what settles it on a device. Three test changes and one correction. The census took a `rootDir`, as `findWebSiblings` does: it planted `src/platform/other.web.ts` in the real tree while the overrides census walks `mobile/src` in a parallel worker and would read it as an unlisted override. It plants under `mkdtemp` now, and writes the two seam files there too, so the empty result for them is the name exemption working rather than those files happening not to subscribe. A case for the ruling itself: scale 2 with a viewport shrunk past what the zoom explains answers 0. Dropping the guard reds it and the pinch case together. `useKeyboardAvoidingPadding` is rendered through the test renderer now instead of called outside one, with a counter on `Keyboard.addListener`. Making the native hook return `useKeyboardOcclusion()` reds it at two calls; the old shape could not see that, because a hook read outside a component never runs its effects. Item 4 did not hold as written. `window.visualViewport ?? undefined` is not a no-op: the DOM declares the property `VisualViewport | null` and an older WebView omits it entirely, so the coalesce was normalising both shapes into one `=== undefined` check. Removing it and testing only for `null` throws on the absent-viewport case (reproduced: `Cannot read properties of undefined (reading 'scale')`). The coalesce is gone and the guard names both shapes instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): raise the two page inputs to 16px on web instead of pinning the page scale (OTA phase C, C4.2 round 2) `maximum-scale=1` is reverted from both metas. It fixed the right problem in the wrong place: Android WebView honours it and iOS ignores it for pinch, so the cost of stopping an iOS focus auto-zoom was deliberate zoom on Android, taken from the users who need it most. The font size is where it belongs. `src/platform/text-input-font-size.ts` is the app's body size and `.web.ts` is that raised to 16, the size below which iOS zooms on focus and does not zoom back. The commit bar and the review note composer take their `fontSize` from it. A phone renders what it rendered before: the native constant is `typography.bodySize`, so both style objects are unchanged there. `Math.max` rather than the literal, so a theme that raises the body size past 16 keeps its own value. The zoom guard stays and its rationale is rewritten to say what now keeps the ordinary path off it: the inputs clear the floor, so a scale other than 1 means a user pinched rather than an input took focus. The pin is a unit case because the render check has no route to open yet. Three assertions and what reds each: the web constant below 16 reds the first, and a style going back to `typography.bodySize` reds the third, which reads the two stylesheets as source because a node test resolves the native sibling and would otherwise pass while shipping 14px to the web. The overrides census covers the swap itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put every text input in the two closures on the size seam (OTA phase C, C4.2 round 2) The 16px floor reached two inputs and the rationale claimed a page. Eight more text inputs in the same two closures still declared 14px, so a focus on any of them zoomed the document and the occlusion seam — which reads a scale other than 1 as no keyboard — stopped lifting for the rest of that session. "A scale other than 1 means a pinch" was false while they were there. All eight go through `TEXT_INPUT_FONT_SIZE`, named by the census before the change: src/components/MobileSearchField.tsx:175 src/components/SmartWorkspaceAdvancedFields.tsx:84 src/components/SmartWorkspaceSourceField.tsx:137 src/components/new-worktree-form-styles.ts:125 src/components/pr-sidebar/MobileLinkPrForm.tsx:120 src/components/pr-sidebar/mobile-pr-sidebar-styles.ts:299 src/components/pr-sidebar/pr-comment-composer-styles.ts:20 src/components/smart-workspace-source-drawer-styles.ts:60 Every one declared `typography.bodySize`, so there was no input carrying a size of its own to preserve and the phone is byte-identical again. Each of those style keys was checked for consumers first: all of them are read by a `TextInput` and nothing else, so raising the web value moves no other element. The census is the rule rather than the list. Over both closures it resolves each `TextInput`'s style to the module that really declares the size — following a spread, because both seam-served inputs are reached through `{ ...base, ...list }` and a walk that stopped at the first module would have called their offence absent — and names anything not on the seam as `path:line`. A style with no `fontSize` inherits and is not an offender. Presence precondition: the seam's web file is in the closure, so an empty list cannot mean a page with no inputs. Run against the previous head it prints exactly those eight for both routes; three fixtures under mkdtemp cover the cross-module line, the spread, and the two non-offender shapes. The web test's rationale named `maximum-scale=1`, which is gone; it names the input floor now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the input census prove its own enumeration (OTA phase C, C4.2 round 2 addendum) The offender list only says every text input is on the seam if every text input was read, and the walk could not tell "this key sets no size" from "I could not follow this style" — both answered nothing, so a resolution failure would have read as a clean input and the rule would have gone quietly vacuous. `resolveStyleKey` answers three ways now: not found, found with no size, found with one. `unresolvedTextInputStyles` reports the first as `path:line (key)`, and the census asserts it is empty for both closures beside asserting the offender list is. Measured rather than assumed, which is what the addendum asks for. The two closures hold 12 `TextInput` elements and 13 style references; none uses an inline style object and none is without a style prop. All 13 resolve, 12 to `TEXT_INPUT_FONT_SIZE` and one — `styles.disabled`, combined with `styles.input` on the same input — to a style that really sets no size. The reviewer picker is in that list at `mobile-pr-sidebar-styles.ts:300`; it was already on the seam from the previous commit, which enumerated from the closure rather than from the review. A fourth fixture plants both shapes side by side: a style with no size, which is not an offender, and a style reached through a package import, which is named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close three holes in the text-input census (OTA phase C, C4.2 fold 3) All three of CodeRabbit's findings are on the completeness property the addendum bought, and all three reproduced before the change: each shape below answered 0 offenders and 0 unresolved, which is to say it vanished. Inline style literals. The walk recorded only `object.key` references, so `style={{ fontSize: 14 }}` was neither an offender nor a hole. Style props are flattened structurally now — arrays, spreads, `?:`, `&&` and parentheses down to the expressions that can really land — rather than walked as a subtree, which had the second bug of descending into an inline literal's own properties. `&&` is followed because `[styles.input, disabled && styles.disabled]` is the shape this tree actually uses; `null`, `undefined` and `false` branches contribute no style and are dropped rather than called unfollowable. An inline literal resolves in place, and any other shape — a call, a bare identifier — lands in the unresolved list. Source-order precedence. `{ input: safe, ...legacy }` is `legacy.input` at runtime, and answering direct keys before spreads read `safe` and called the override clean. Properties are walked in reverse source order now, direct keys and spreads in one pass, first answer wins. The seam by binding. `size.text !== SEAM_EXPORT` accepted anything spelled `TEXT_INPUT_FONT_SIZE`, so a local `const TEXT_INPUT_FONT_SIZE = 14` two lines up passed, and so did an import of that name from any other module — the regression the seam exists to stop, wearing its name. The identifier is resolved in the declaring module and accepted only as an import from `src/platform/text-input-font-size`. That last one changes what a fixture must say: the existing seam case spelled the name without importing it, so it plants the seam module and imports from it now. Six new fixtures, all six red on the previous walk. Re-measured at this head, both closures: 12 `TextInput` elements, 13 style references, 12 on the seam, 1 sizeless (`styles.disabled`, combined with `styles.input` on one element), 0 offenders, 0 unresolved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --------- Co-authored-by: Claude <noreply@anthropic.com> |