From 841d06a9690c551ec8d4f70a72376632ffa3c2e5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:54:32 -0400 Subject: [PATCH] feat(mobile): the rich Markdown editor mounts on the page (OTA phase C, C7.10 C2) (#22099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): the editor document reads its surface from its host's root The markup gives the editable surface an id, and inside the WebView that is unambiguous because the document is the page. On the page it is not: a stack transition keeps the outgoing session screen mounted while the incoming one starts, so two hosts carry `#editor` at once and a page-wide `getElementById` hands both documents the first one. The seventh seam is the root, exactly as it is the terminal's ninth (ruling 24): the WebView names none of them and gets the whole page, the page names the element its mount planted the markup in. Red first, `vitest run src/components/rich-markdown/document-host-root.test.ts` against the page-wide read: 4 failed, 1 passed — content written into the second host landed in the first, both documents serialized the first surface, an edit in the second reported through the first document's host, and stopping the first took the listeners off the surface the second was still using. The one that passed is the control: a document with no root still reads the whole page, which is what the WebView gets. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold the editor's document rules under its host element The editor's sheet says `:root`, `*`, `html` and `body` because inside the WebView it owns the page. Appended to the head of a React Native Web application all four restyle every screen the shell can show, so the page mount may inject only what it owns — ruling 19's rule for `window.onerror`, applied to CSS. The terminal's half of the scoper drops those rules and repaints through a seam, because the colour `html, body` was setting belongs to the application. The editor has no such seam and needs none: its host element *is* that editor's page, so `scopeDocumentStyleToHost` moves the document's own rules onto the host — the variables every other rule reads, the surface colour, the font, the box model — and everything else hangs under it. A selector that merely starts at the document (`body p`) throws rather than being rewritten into something it did not say. Red first, `vitest run src/components/rich-markdown/page-stylesheet.test.ts`: 6 failed, 0 passed, all on the absent export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the fifteen toolbar commands are one row both surfaces render The row of controls is not the WebView's: a press becomes an injected `runCommand` there and a call on the page, and neither difference belongs in the toolbar. Extracted so the page's editor does not declare fifteen rows of its own that would drift from the phone's. `MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have needed: the row names every command in the contract, exactly once. Verified red by dropping `codeBlock` from the row — "names every command in the contract, once" failed on the 14-member list before the case went back. The native component's own test and the web fallbacks file stay green unchanged, which is what says the extraction moved nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the toolbar test inside the tests-typecheck ratchet `check-tests-typecheck-ratchet.mjs` reported the new file as newly failing `tsc -p tsconfig.test.json`: the `ScrollView` mock's spread did not match any `createElement` overload, and comparing a node's `ElementType` against the string `'Pressable'` is a no-overlap comparison. Host strings for the mock and `String(node.type)` for the read, rather than a cast. Ratchet back to OK at 800 files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the rich Markdown editor mounts on the page `react-native-webview` renders nothing in a browser, so C7.6 gave the page a plain Markdown field and recorded the toolbar and the rendered view as a degradation. Ruling 26 makes that debt rather than done: the page mounts the document itself. `rich-markdown-web-document-mount.ts` is the editor's half of what `terminal-web-document-mount.ts` does for the terminal — the sheet held under the host's class, the markup planted in the host, one factory call, and a dispose that gives the host back. `MobileRichMarkdownEditor.web.tsx` is the component over it, with the same fifteen-command toolbar and the same controller the phone uses, so `MarkdownReader` cannot tell which sibling it has. Three seams are the page's rather than the window's. Messages reach `handleMessage` directly and never `window.ReactNativeWebView`, which on the page is the shell's bridge. The URL for Link and Image comes from `TextInputModal`: `window.prompt` was measured to return null in both shells, so those two commands silently did nothing. And no inset source is supplied, so `onKeyboardInsetChange` is never called — the screen's `keyboard-occlusion.web.ts` measures the same viewport with the same formula, and a report here would lift its bar twice. Red first, two runs. `rich-markdown-web-document-mount.test.ts`: 9 failed on the absent module, and its listener case is the one that holds ruling 21 — a second mount reports its own edits and the first mount's detached surface reports nothing, with an event dispatched on it to say so. The four new cases in `mobile-webview-editor-web-fallbacks.test.tsx`, run against the plain field still in the tree: 4 failed, 6 passed — no toolbar, no URL modal, and the `TextInput` the page is meant to have lost. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it `#editor` computed to 14 px, measured in both engines. iOS zooms the page on focus of any editable under 16 px and does not zoom back, and `keyboard-occlusion.web.ts` answers 0 for the rest of the session at a scale other than 1 — the exact failure the floor exists for, on the page's only full-screen writing surface. The size now comes from the text-input seam, which is also where the two hosts part: the phone keeps the app's body size because a WebView has no page to zoom, the page gets the raise, and one binding moves both if the floor ever does. The `TextInput` census could not have caught it. `modulesDeclaringTextInput` matches JSX tags and `style` props, and this is a `contenteditable` in a markup string sized by a rule in a stylesheet. `mobile-web-app-editable-host-font-size.mjs` starts from the markup instead: it finds every editable host a closure declares, follows its id to the rule beside it, and reads the size the same way — a literal at or above the floor, or the seam's own export imported from the seam's module. An editable with no id, or one no sibling sheet styles, is reported unresolved rather than passed. Red first. The rule's own file reported `src/components/rich-markdown/document-style.ts:36` as the offender before the fix (4 failed, 3 passed on the first run, the other three being the brace scanner and the line-start anchor the fixtures found). The closure case in `mobile-web-app-session-terminal-closure.test.mjs` now names the editor as the one editable in the session route's closure and its offender list is empty: 1 passed, 4 skipped under `-t editables`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the caret survives the host's URL dialog, so Link and Image insert Measured in the render check, on both engines: the Link and Image commands opened the modal, took the URL, and inserted nothing. The dialog is what takes the caret — the modal focuses its own field — and `execCommand` on a document that does not hold the selection does nothing at all. So the page had swapped one silent failure for another: `window.prompt` returning null on the phone, and a command with no selection on the page. Two halves. The document remembers its caret before it waits and puts it back after (`restoreRememberedSelection`, unconditional where `restoreSelectionOrEnd` needs a flag, because the wait itself is the blur); if the host replaced the content while the dialog was open, the remembered range is gone from the document and the caret goes to the end instead. And the component answers the promise from the drawer's `onAfterClose` rather than from the submit, because WebKit would not take the focus back while the field still held it — with the answer released on submit, chromium inserted and WebKit did not. `TextInputModal` forwards `onAfterClose` for that, which is the one thing it did not already pass through to `BottomDrawer`. Red first, `editor-selection.test.ts` against the previous `editor-commands.ts`: 2 failed, 7 passed — the caret was left in the dialog's field, and a replaced document did not fall back to the end. The render check's Link/Image case went from failing on both engines to inserting on both, with the inserted image's `naturalWidth` above zero under the shipped policy. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the page's rich Markdown editor, in both engines under the shipped header `config/scripts/mobile-web-app-rich-markdown-render.test.mjs`: the real component, mounted by the real React, driven through its toolbar in chromium and webkit under the policy read out of the shell's own Kotlin source. Sixteen cases, eight per engine. What it measures rather than asserts: all fifteen commands change the document, each with the precondition that what it produces was not there first; the surface computes to the 16 px floor and the document's own `--editor-surface` variable is set on the host and nowhere on the root element; `ready` and `change` cross the seam while `window.ReactNativeWebView` — defined by the rig so its absence is a reading — is never touched; Link and Image are answered by the modal, and the inserted image paints with a non-zero `naturalWidth`; one change per checkbox tap and one per inline code; a link tap reaches the host instead of navigating; a remount leaves the listener snapshot and the scheduler exactly where one whole cycle left them (rulings 20 and 21); and two editors on one page hold their own content and report their own edits. Four harness facts the first runs found, each now in a comment: the entry needs four of `MOBILE_WEB_APP_SHIMS` (`isFabric` threw `global is not defined` and every case failed at `data-ready`); `.web.jsx` in `resolveExtensions` or `react-native-svg` resolves its Fabric components; a `SafeAreaProvider`, which the route's navigator supplies and a bare mount does not; and the document's markup, not its text, as the oracle for a content reset — `### body text here` and `body text here` read the same, so a text wait passed on the document it was replacing. One finding, reported not fixed: WebKit's `insertUnorderedList` nests the `