From 00770bb2aa0712ce905229441162acdc3d13abff Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 18:06:15 -0400 Subject: [PATCH] fix(mobile): put the editor's surface on the 16 px floor, and grow a census that can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#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 --- ...mobile-web-app-editable-host-font-size.mjs | 187 ++++++++++++++++++ ...e-web-app-editable-host-font-size.test.mjs | 137 +++++++++++++ ...-web-app-session-terminal-closure.test.mjs | 18 ++ .../rich-markdown/document-style.ts | 9 +- 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 config/scripts/mobile-web-app-editable-host-font-size.mjs create mode 100644 config/scripts/mobile-web-app-editable-host-font-size.test.mjs diff --git a/config/scripts/mobile-web-app-editable-host-font-size.mjs b/config/scripts/mobile-web-app-editable-host-font-size.mjs new file mode 100644 index 00000000000..d112d973c59 --- /dev/null +++ b/config/scripts/mobile-web-app-editable-host-font-size.mjs @@ -0,0 +1,187 @@ +/** + * The 16 px floor for an editable the page styles with CSS rather than with a `TextInput` prop. + * + * `mobile-web-app-text-input-font-size-seam.mjs` reads the floor and holds every `TextInput` in a + * route's closure to it. It cannot see the rich Markdown editor: that surface is a + * `contenteditable` element in a string of markup, sized by a rule in a stylesheet the same module + * emits, and the walk there matches JSX `TextInput` tags and `style` props. So the editor shipped + * at 14 px and was measured at 14 px in both engines — the exact condition the floor exists for, + * because iOS zooms the page on focus of any editable under 16 px, never zooms back, and + * `keyboard-occlusion.web.ts` then answers 0 for the rest of the session at a scale other than 1. + * + * The rule is over the closure rather than over a list of known editors, for the same reason the + * `TextInput` one is: the next editable host is the one nobody remembers to add. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs' + +/** The seam's export, which is how a size states the floor rather than restating the number. */ +const SEAM_EXPORT = 'TEXT_INPUT_FONT_SIZE' + +/** `contenteditable="true"` in a markup string, with the id the element carries. */ +const EDITABLE_MARKUP = + /id="([A-Za-z][\w-]*)"[^>]*contenteditable="true"|contenteditable="true"[^>]*id="([A-Za-z][\w-]*)"/g + +function readOrNull(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +/** + * Every editable host a closure declares, as `{ file, id }`. + * + * The completeness half of the verdict below: an empty offender list is only evidence when the + * walk found the editables it is judging. A module that plants an editable with no id lands here + * with `id: null` and is reported as unresolved rather than passing. + */ +export function editableHostsIn(mobileDir, closure) { + const found = [] + for (const file of closure.local) { + const source = readOrNull(join(mobileDir, file)) + if (source === null || !source.includes('contenteditable="true"')) { + continue + } + const ids = [...source.matchAll(EDITABLE_MARKUP)].map((match) => match[1] ?? match[2] ?? null) + if (ids.length === 0) { + found.push({ file, id: null }) + continue + } + for (const id of ids) { + found.push({ file, id }) + } + } + return found.sort((left, right) => + left.file === right.file + ? String(left.id).localeCompare(String(right.id)) + : left.file < right.file + ? -1 + : 1 + ) +} + +/** + * The rule a selector opens in a stylesheet string, with where it opens, or null for none. + * + * The selector has to be the whole of a rule's selector, so the character before it is a line + * start, the end of the rule before it, a comma, or the backtick the template literal opens with. + * A descendant rule that merely mentions the surface is a different rule and is not this one. + * + * Textual, and flat: the sheets this reads have no at-rules and no nesting, which is the same + * assumption `document-style-scoping.ts` makes and refuses to exceed. + */ +function ruleFor(source, selector) { + const pattern = new RegExp( + `(?:^|[},\`])[^\\S\\n]*${selector.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&')}\\s*\\{`, + 'm' + ) + const match = pattern.exec(source) + if (match === null) { + return null + } + // Counted rather than matched to the first `}`: a declaration reading the seam is written + // `${TEXT_INPUT_FONT_SIZE}px`, whose own closing brace would have ended the block one + // declaration early and left the size looking absent. + const open = match.index + match[0].length + let depth = 1 + for (let at = open; at < source.length; at += 1) { + if (source[at] === '{') { + depth += 1 + continue + } + if (source[at] === '}') { + depth -= 1 + if (depth === 0) { + return { declarations: source.slice(open, at), index: match.index } + } + } + } + return null +} + +/** What a `font-size` declaration is worth: a literal, a seam substitution, or something else. */ +function readFontSize(mobileDir, source, declarations) { + const match = /font-size:\s*([^;]+);/.exec(declarations) + if (match === null) { + // No size of its own, so it inherits, and the floor is about the size an editable declares. + return { text: null, onSeam: true } + } + const text = match[1].trim() + const literal = /^(\d+(?:\.\d+)?)px$/.exec(text) + if (literal !== null) { + return { text, onSeam: Number(literal[1]) >= textInputFontSizeFloor(mobileDir) } + } + // A substitution, which is only the seam when this module imported the seam's export: the same + // name declared locally, or imported from somewhere else, is exactly the regression the seam + // exists to stop wearing its name. + const substituted = /^\$\{([A-Za-z_$][\w$]*)\}px$/.exec(text) + if (substituted === null) { + return { text, onSeam: false } + } + const imported = new RegExp( + `import\\s*\\{[^}]*\\b${SEAM_EXPORT}\\b[^}]*\\}\\s*from\\s*'[^']*text-input-font-size'` + ) + return { text, onSeam: substituted[1] === SEAM_EXPORT && imported.test(source) } +} + +/** + * Where each editable host's size is declared, as `{ at, size }`. + * + * The size is looked for in the same module the markup came from and in the modules beside it: a + * document's markup and its stylesheet are two exports of one program, so the rule is stated over + * that program's own directory rather than over the whole closure. + */ +function editableHostSizes(mobileDir, closure) { + const resolutions = [] + for (const host of editableHostsIn(mobileDir, closure)) { + if (host.id === null) { + resolutions.push({ at: host.file, size: null }) + continue + } + const directory = host.file.slice(0, host.file.lastIndexOf('/')) + const siblings = closure.local.filter((file) => file.startsWith(`${directory}/`)) + let resolved = null + for (const file of siblings) { + const source = readOrNull(join(mobileDir, file)) + if (source === null) { + continue + } + const rule = ruleFor(source, `#${host.id}`) + if (rule === null) { + continue + } + const line = source.slice(0, rule.index).split('\n').length + resolved = { at: `${file}:${line}`, size: readFontSize(mobileDir, source, rule.declarations) } + break + } + resolutions.push(resolved ?? { at: `${host.file} (#${host.id})`, size: null }) + } + return resolutions +} + +/** + * Every editable host whose size this walk could not follow to a rule, as it names it. + * + * A hole rather than a pass: an editable planted with no id, or one whose selector no stylesheet + * beside it opens, is a surface the rule cannot judge and has to say so. + */ +export function unresolvedEditableHostStyles(mobileDir, closure) { + return editableHostSizes(mobileDir, closure) + .filter((entry) => entry.size === null) + .map((entry) => entry.at) + .sort() +} + +/** Every editable host in a closure sized below the floor and off the seam, as `path:line`. */ +export function editableHostFontSizeOffenders(mobileDir, closure) { + return [ + ...new Set( + editableHostSizes(mobileDir, closure) + .filter((entry) => entry.size !== null && !entry.size.onSeam) + .map((entry) => entry.at) + ) + ].sort() +} diff --git a/config/scripts/mobile-web-app-editable-host-font-size.test.mjs b/config/scripts/mobile-web-app-editable-host-font-size.test.mjs new file mode 100644 index 00000000000..cf18bc69223 --- /dev/null +++ b/config/scripts/mobile-web-app-editable-host-font-size.test.mjs @@ -0,0 +1,137 @@ +/** + * The editable-host rule, over the tree it ships against and over fixtures of its own. + * + * Two halves, because an offender list is only evidence when the walk read something. The first + * runs the rule over the rich Markdown editor's real modules and says which line carries the size; + * the second drives the readings the real tree does not have — a size below the floor, a name that + * merely spells the seam's, an editable with no id — against a fixture tree whose only reason to + * exist is that those readings have to be observable somewhere. + */ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + editableHostFontSizeOffenders, + editableHostsIn, + unresolvedEditableHostStyles +} from './mobile-web-app-editable-host-font-size.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url)) + +/** The editor's own two modules, as a closure naming nothing else. */ +const EDITOR_CLOSURE = { + local: [ + 'src/components/rich-markdown/document-markup.ts', + 'src/components/rich-markdown/document-style.ts' + ] +} + +/** The seam's web half, copied into a fixture tree so the floor is read rather than restated. */ +const SEAM_SOURCE = `export const TEXT_INPUT_FONT_SIZE_FLOOR = 16\n` + +let fixtureDir = null + +/** A fixture tree with the seam in it, plus whatever markup and stylesheet a case needs. */ +async function fixture(name, markup, style) { + const root = join(fixtureDir, name) + await mkdir(join(root, 'src/platform'), { recursive: true }) + await mkdir(join(root, 'src/doc'), { recursive: true }) + await writeFile(join(root, 'src/platform/text-input-font-size.web.ts'), SEAM_SOURCE, 'utf8') + await writeFile(join(root, 'src/doc/markup.ts'), markup, 'utf8') + await writeFile(join(root, 'src/doc/style.ts'), style, 'utf8') + return { root, closure: { local: ['src/doc/markup.ts', 'src/doc/style.ts'] } } +} + +beforeAll(async () => { + fixtureDir = await mkdtemp(join(tmpdir(), 'orca-editable-host-')) +}) + +afterAll(async () => { + if (fixtureDir) { + await rm(fixtureDir, { recursive: true, force: true }) + } +}) + +describe('the editable-host font-size rule', () => { + it('finds the editor the TextInput census cannot see', () => { + // The precondition every verdict below needs: this walk reads the editor's real markup and + // names the surface the page mounts. + expect(editableHostsIn(mobileDir, EDITOR_CLOSURE)).toEqual([ + { file: 'src/components/rich-markdown/document-markup.ts', id: 'editor' } + ]) + }) + + it('follows the surface to the rule in the stylesheet beside it', () => { + expect(unresolvedEditableHostStyles(mobileDir, EDITOR_CLOSURE)).toEqual([]) + expect(editableHostFontSizeOffenders(mobileDir, EDITOR_CLOSURE)).toEqual([]) + }) + + it('reds on an editable under the floor', async () => { + const { root, closure } = await fixture( + 'under', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 14px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2']) + }) + + it('accepts a literal that already clears the floor, and a size read from the seam', async () => { + const literal = await fixture( + 'literal', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(literal.root, literal.closure)).toEqual([]) + + const bound = await fixture( + 'bound', + 'export const MARKUP = \'
\'\n', + "import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'\n" + + 'export function style() {\n return ` #editor {\n font-size: ${TEXT_INPUT_FONT_SIZE}px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(bound.root, bound.closure)).toEqual([]) + }) + + it('refuses a name that only spells the seam’s', async () => { + // A local `const TEXT_INPUT_FONT_SIZE = 14` two lines up is exactly the regression the seam + // exists to stop, wearing its name. + const { root, closure } = await fixture( + 'local', + 'export const MARKUP = \'
\'\n', + 'const TEXT_INPUT_FONT_SIZE = 14\n' + + 'export function style() {\n return ` #editor {\n font-size: ${TEXT_INPUT_FONT_SIZE}px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:3']) + }) + + it('reports an editable it cannot judge rather than passing it', async () => { + const noId = await fixture( + 'no-id', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` main { font-size: 18px; }`\n}\n' + ) + expect(unresolvedEditableHostStyles(noId.root, noId.closure)).toEqual(['src/doc/markup.ts']) + expect(editableHostFontSizeOffenders(noId.root, noId.closure)).toEqual([]) + + const noRule = await fixture( + 'no-rule', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` main { font-size: 18px; }`\n}\n' + ) + expect(unresolvedEditableHostStyles(noRule.root, noRule.closure)).toEqual([ + 'src/doc/markup.ts (#editor)' + ]) + }) + + it('passes an editable that declares no size, because it inherits one', async () => { + const { root, closure } = await fixture( + 'inherits', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n padding: 8px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual([]) + expect(unresolvedEditableHostStyles(root, closure)).toEqual([]) + }) +}) diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 4c50878a32e..cd53e1e2d01 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -4,6 +4,11 @@ import { describe, expect, it } from 'vitest' import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { mobileWebAppRouteChunkClosure } from './mobile-web-app-route-chunk-closure.mjs' +import { + editableHostFontSizeOffenders, + editableHostsIn, + unresolvedEditableHostStyles +} from './mobile-web-app-editable-host-font-size.mjs' import { textInputFontSizeOffenders, unresolvedTextInputStyles @@ -303,6 +308,19 @@ describeClosure( expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([]) expect(textInputFontSizeOffenders(mobileDir, closure)).toHaveLength(EXPECTED_OFFENDERS) }, 300_000) + + it('holds the editables the TextInput census cannot see to the same floor', async () => { + const closure = await mobileWebAppRouteClosure(SESSION_ROUTE) + // The rich Markdown editor's surface is a `contenteditable` in a markup string, sized by a + // rule in a stylesheet: `modulesDeclaringTextInput` matches JSX tags and never sees it, so it + // shipped at 14 px and was measured at 14 px in both engines. The same floor, read by a rule + // that starts from the markup instead of from a prop. + expect(editableHostsIn(mobileDir, closure)).toEqual([ + { file: 'src/components/rich-markdown/document-markup.ts', id: 'editor' } + ]) + expect(unresolvedEditableHostStyles(mobileDir, closure)).toEqual([]) + expect(editableHostFontSizeOffenders(mobileDir, closure)).toEqual([]) + }, 300_000) }, 900_000 ) diff --git a/mobile/src/components/rich-markdown/document-style.ts b/mobile/src/components/rich-markdown/document-style.ts index 5a4d116ad5d..b8199f0cc83 100644 --- a/mobile/src/components/rich-markdown/document-style.ts +++ b/mobile/src/components/rich-markdown/document-style.ts @@ -1,4 +1,5 @@ import { colors } from '../../theme/mobile-theme' +import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size' /** * The editor document's stylesheet: the theme variables and every rule that reads them. @@ -6,6 +7,12 @@ import { colors } from '../../theme/mobile-theme' * A function rather than a constant because the variables are the app's own theme values, read * when the document is built. The native host wraps it in the document's `