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..3add346cce2
--- /dev/null
+++ b/config/scripts/mobile-web-app-editable-host-font-size.mjs
@@ -0,0 +1,309 @@
+/**
+ * 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'
+
+/**
+ * Each editable tag in a markup string, whole.
+ *
+ * The tag is what an editable *is*; its id is optional and is read out of the tag afterwards. A
+ * pattern that started from the id matched only the hosts that have one, so a file holding a named
+ * host and an anonymous one reported the named host and said nothing about the other.
+ */
+const EDITABLE_TAG = /<[A-Za-z][^>]*\bcontenteditable="true"[^>]*>/g
+
+/** The id a matched tag carries, or null for one that carries none. */
+const TAG_ID = /\bid="([A-Za-z][\w-]*)"/
+
+function readOrNull(path) {
+ try {
+ return readFileSync(path, 'utf8')
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Every editable host a closure declares, as `{ file, id }`, one entry per tag.
+ *
+ * The completeness half of the verdict below: an empty offender list is only evidence when the
+ * walk found the editables it is judging. A host with no id lands here with `id: null` and is
+ * reported as unresolved rather than passing, and it does so whether or not a named host sits
+ * beside it in the same file.
+ */
+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
+ }
+ for (const [tag] of source.matchAll(EDITABLE_TAG)) {
+ found.push({ file, id: TAG_ID.exec(tag)?.[1] ?? null })
+ }
+ }
+ return found.sort((left, right) =>
+ left.file === right.file
+ ? String(left.id).localeCompare(String(right.id))
+ : left.file < right.file
+ ? -1
+ : 1
+ )
+}
+
+/** The selector as a pattern, with the boundary that keeps `#editor` off `#editor-notes`. */
+function selectorPattern(selector) {
+ return `${selector.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&')}(?![\\w-])`
+}
+
+/** The declarations of the rule whose block opens at `open`, or null for one that never closes. */
+function blockFrom(source, open) {
+ 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 source.slice(open, at)
+ }
+ }
+ }
+ return null
+}
+
+/**
+ * Every rule in a stylesheet string whose selector list mentions the selector, in source order.
+ *
+ * The list runs from a line start, the end of the rule before it, a comma, or the backtick the
+ * template literal opens with, up to the `{`; requiring the selector somewhere inside it is what
+ * keeps the surrounding TypeScript's own braces out of the walk. Kept as a list rather than
+ * collapsed to one selector because a comma binds every selector in it to the same declarations,
+ * so the host can be hiding in any of them.
+ *
+ * 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 rulesMentioning(source, selector) {
+ const pattern = new RegExp(
+ `(?:^|[},\`])([^{};\`]*${selectorPattern(selector)}[^{};\`]*)\\{`,
+ 'dgm'
+ )
+ const rules = []
+ for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
+ // 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 declarations = blockFrom(source, match.index + match[0].length)
+ if (declarations === null) {
+ continue
+ }
+ const list = match[1]
+ rules.push({
+ selectors: list
+ .split(',')
+ .map((one) => one.trim())
+ .filter((one) => one !== ''),
+ declarations,
+ // The selector's own start, not the anchor's: the anchor is the previous rule's `}`, a line up.
+ index: match.indices[1][0] + (list.length - list.trimStart().length)
+ })
+ }
+ return rules
+}
+
+/** The last compound of a selector — the element the rule is about, not one of its ancestors. */
+function subjectCompound(selector) {
+ return selector.split(/[\s>+~]+/).at(-1) ?? ''
+}
+
+/**
+ * The `font-size` the cascade actually uses out of one rule.
+ *
+ * A rule may declare the property more than once, and CSS takes the last of equal importance, with
+ * `!important` outranking every declaration that is not. Reading the first one reported
+ * `font-size: 16px; font-size: 14px;` as compliant for a surface the browser renders at 14 px.
+ *
+ * The flag is stripped from the value it returns, so a compliant size that carries it is read as
+ * the size it sets rather than as a shape this walk does not model.
+ */
+function winningFontSize(declarations) {
+ const found = []
+ // Split on the separator rather than matching a value pattern: a size read from the seam is
+ // written `${TEXT_INPUT_FONT_SIZE}px`, whose own closing brace ends any value pattern that
+ // excludes one, and the last declaration in a rule need not carry a trailing semicolon.
+ for (const piece of declarations.split(';')) {
+ const match = /(?:^|[^\w-])font-size:\s*([\s\S]*)$/.exec(piece)
+ if (match === null) {
+ continue
+ }
+ const raw = match[1].trim()
+ found.push({
+ text: raw.replace(/\s*!\s*important$/i, '').trim(),
+ important: /!\s*important$/i.test(raw)
+ })
+ }
+ if (found.length === 0) {
+ return null
+ }
+ return found.findLast((one) => one.important) ?? found.at(-1)
+}
+
+/**
+ * What the winning `font-size` is worth: a literal, a seam substitution, or something else.
+ *
+ * Null for a rule that declares no size at all, which is unresolved rather than a pass: the value
+ * an editable then takes comes from a rule this walk does not read — the host element's own, or the
+ * page's root — so it can be 14 px and the census cannot prove otherwise. Where the `TextInput`
+ * half treats an absent prop as inheritance and lets it through, that policy is main's and about a
+ * prop; this is CSS, and the inherited value is genuinely out of view.
+ */
+function readFontSize(mobileDir, source, declarations) {
+ const winning = winningFontSize(declarations)
+ if (winning === null) {
+ return null
+ }
+ const text = winning.text
+ 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) }
+}
+
+/**
+ * Every rule in a sheet that applies exactly this selector, in source order.
+ *
+ * All of them rather than the first: rules of equal specificity are ranked by source order, so a
+ * sheet that declares 16 px and then 14 px renders at 14 px, and reading only the first one called
+ * that surface compliant.
+ */
+function exactRules(source, selector) {
+ return rulesMentioning(source, selector).filter((rule) => rule.selectors.includes(selector))
+}
+
+/**
+ * Every rule in a sheet that sizes the host through a selector this walk cannot rank against the
+ * exact one.
+ *
+ * A subject of higher specificity that still targets the host (`main#editor`, `#editor.x`,
+ * `div > #editor`, `#editor:empty`) beats the exact rule, and this census does no specificity
+ * arithmetic: such a rule declaring `font-size` makes the host unresolved rather than compliant. A
+ * descendant (`#editor p`) is about another element and a pseudo-element (`#editor:empty::before`)
+ * is a box the host generates, so neither one is in the way.
+ */
+function unrankableHostRules(source, selector) {
+ return rulesMentioning(source, selector).filter(
+ (rule) =>
+ winningFontSize(rule.declarations) !== null &&
+ rule.selectors.some(
+ (one) =>
+ one !== selector &&
+ !one.includes('::') &&
+ new RegExp(selectorPattern(selector)).test(subjectCompound(one))
+ )
+ )
+}
+
+/**
+ * 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 directly 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 or over its subtree.
+ */
+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('/'))
+ // The immediate directory, not the subtree: the walk stops at the first file whose sheet opens
+ // the host's selector, and the closure's order is the bundler's rather than alphabetical, so a
+ // sheet one directory down could answer for the sibling the host actually gets.
+ const siblings = closure.local.filter(
+ (file) => file.slice(0, file.lastIndexOf('/')) === directory
+ )
+ const selector = `#${host.id}`
+ let resolved = null
+ for (const file of siblings) {
+ const source = readOrNull(join(mobileDir, file))
+ if (source === null) {
+ continue
+ }
+ const exact = exactRules(source, selector)
+ if (exact.length === 0) {
+ continue
+ }
+ const unrankable = unrankableHostRules(source, selector)
+ const named = unrankable[0] ?? exact[0]
+ resolved = {
+ at: `${file}:${source.slice(0, named.index).split('\n').length}`,
+ // Joined in source order because that is the cascade among rules of equal specificity, and
+ // `winningFontSize` already reads the last of equal importance out of a declaration string.
+ size:
+ unrankable.length > 0
+ ? null
+ : readFontSize(mobileDir, source, exact.map((one) => one.declarations).join(';'))
+ }
+ 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, one whose selector no stylesheet
+ * beside it opens, or one a higher-specificity rule sizes out of this walk's reach, 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..21d7ff09b17
--- /dev/null
+++ b/config/scripts/mobile-web-app-editable-host-font-size.test.mjs
@@ -0,0 +1,293 @@
+/**
+ * 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'] } }
+}
+
+/** The same tree with a second stylesheet one directory down, and a closure that reads it first. */
+async function fixtureWithNested(name, markup, style, nestedStyle) {
+ const { root } = await fixture(name, markup, style)
+ await mkdir(join(root, 'src/doc/nested'), { recursive: true })
+ await writeFile(join(root, 'src/doc/nested/style.ts'), nestedStyle, 'utf8')
+ return {
+ root,
+ // Nested first, which is what makes this a measurement: the closure's order is the bundler's,
+ // so a walk that accepted any file under the directory would stop here.
+ closure: { local: ['src/doc/nested/style.ts', '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('counts a no-id editable beside a named one, rather than only the named one', async () => {
+ // The tag is what an editable is, and its id is optional: a walk that started from the id
+ // matched the named host and never saw the one beside it, so a file holding both reported the
+ // named one as clean and said nothing at all about the other.
+ const { root, closure } = await fixture(
+ 'mixed',
+ 'export const NAMED = \'\'\n' +
+ 'export const ANONYMOUS = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n'
+ )
+ expect(editableHostsIn(root, closure)).toEqual([
+ { file: 'src/doc/markup.ts', id: 'editor' },
+ { file: 'src/doc/markup.ts', id: null }
+ ])
+ expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/markup.ts'])
+ })
+
+ it('reads the sheet beside the markup, not one a directory down', async () => {
+ // The walk stops at the first file whose sheet opens `#editor`, and the closure's order is the
+ // bundler's rather than alphabetical, so a nested sheet could answer for a sibling that is the
+ // one the host actually gets.
+ const { root, closure } = await fixtureWithNested(
+ 'nested',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 14px;\n }`\n}\n',
+ 'export function nested() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n'
+ )
+ expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2'])
+ })
+
+ 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('reads the declaration CSS uses, not the first one in the rule', async () => {
+ // Equal importance, so the last one wins. A walk that stopped at the first read 16 px and
+ // called a 14 px surface compliant.
+ const { root, closure } = await fixture(
+ 'repeated',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 16px;\n' +
+ ' font-size: 14px;\n }`\n}\n'
+ )
+ expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2'])
+ })
+
+ it('lets an important declaration outrank a later one, as the cascade does', async () => {
+ const important = await fixture(
+ 'important-wins',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px !important;\n' +
+ ' font-size: 14px;\n }`\n}\n'
+ )
+ expect(editableHostFontSizeOffenders(important.root, important.closure)).toEqual([])
+
+ // And an important declaration is still read as the size it sets, rather than as a shape the
+ // walk does not model: without stripping the flag, a compliant `!important` size on the seam
+ // would have been reported as an offender.
+ const offending = await fixture(
+ 'important-offends',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 14px !important;\n }`\n}\n'
+ )
+ expect(editableHostFontSizeOffenders(offending.root, offending.closure)).toEqual([
+ 'src/doc/style.ts:2'
+ ])
+ })
+
+ it('cannot judge an editable that declares no size, and says so', async () => {
+ // Inheritance is not a pass here. The value would come from a rule in a file this walk does not
+ // read — the host element's own, or the page's root — so "no declaration" is "cannot say" and
+ // belongs in the unresolved list, which the closure census holds at empty.
+ const { root, closure } = await fixture(
+ 'inherits',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n padding: 8px;\n }`\n}\n'
+ )
+ expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:2'])
+ // Not an offender either: an offender is a size this walk read and found under the floor.
+ expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
+ })
+
+ it('reads every exact rule in the sheet, in source order, as the cascade does', async () => {
+ // Equal specificity, so the last rule wins. Reading only the first called a 14 px surface
+ // compliant because a compliant rule happened to sit above it.
+ const later = await fixture(
+ 'later-exact',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
+ ' #editor {\n font-size: 14px;\n }`\n}\n'
+ )
+ expect(editableHostFontSizeOffenders(later.root, later.closure)).toEqual(['src/doc/style.ts:2'])
+ })
+
+ it('takes source order rather than the lowest exact rule in the sheet', async () => {
+ // The control the reading above needs: the same two rules the other way round are compliant,
+ // so the verdict is the cascade rather than "any rule under the floor anywhere in the sheet".
+ const earlier = await fixture(
+ 'earlier-exact',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 14px;\n }\n' +
+ ' #editor {\n font-size: 18px;\n }`\n}\n'
+ )
+ expect(editableHostFontSizeOffenders(earlier.root, earlier.closure)).toEqual([])
+ })
+
+ it('cannot rank a higher-specificity subject rule, and says so rather than passing', async () => {
+ // `main#editor` outranks `#editor` and this census does no specificity arithmetic, so a rule
+ // like it declaring a size is a hole, named at its own line.
+ const { root, closure } = await fixture(
+ 'subject-specificity',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
+ ' main#editor {\n font-size: 14px;\n }`\n}\n'
+ )
+ expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:5'])
+ // Not an offender either: an offender is a size this walk read and could rank.
+ expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
+ })
+
+ it('splits a selector list, so a host riding in one still reaches the verdict', async () => {
+ const { root, closure } = await fixture(
+ 'subject-in-list',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
+ ' h1, main#editor {\n font-size: 14px;\n }`\n}\n'
+ )
+ expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:5'])
+ })
+
+ it('reads the host’s own id, not a longer one that starts with it', async () => {
+ // The selector list is now read whole, so `#editor` has to stop at an id boundary: without one,
+ // a rule for the element beside the host would have made the host unresolved.
+ const { root, closure } = await fixture(
+ 'neighbour-id',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
+ ' #editor-notes {\n font-size: 14px;\n }`\n}\n'
+ )
+ expect(unresolvedEditableHostStyles(root, closure)).toEqual([])
+ expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
+ })
+
+ it('leaves a descendant rule and a pseudo-element rule out of the way', async () => {
+ // Neither one is the host: `#editor p` is about another element, and `::before` is a box the
+ // host generates. Counting either as a hole would report the shipped sheet unresolved.
+ const { root, closure } = await fixture(
+ 'not-the-host',
+ 'export const MARKUP = \'\'\n',
+ 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' +
+ ' #editor::before {\n font-size: 12px;\n }\n' +
+ ' #editor p {\n font-size: 0.9em;\n }`\n}\n'
+ )
+ expect(unresolvedEditableHostStyles(root, closure)).toEqual([])
+ expect(editableHostFontSizeOffenders(root, closure)).toEqual([])
+ })
+})
diff --git a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs
new file mode 100644
index 00000000000..46bc797267a
--- /dev/null
+++ b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs
@@ -0,0 +1,674 @@
+/**
+ * The rich Markdown editor in the page, in a real browser, under the policy the shell ships.
+ *
+ * The native component puts a hand-written document inside a `WebView` and talks to it over
+ * `postMessage` and `injectJavaScript`. The page has no WebView, so it mounts the same modules and
+ * calls them. That makes four claims this file measures rather than asserts: that all fifteen
+ * toolbar commands change the document under the shipped header with no violation; that `ready`
+ * and `change` reach the component through its own seam and never through the shell's bridge
+ * object; that a remount leaves nothing of the first mount behind (rulings 20 and 21); and that the
+ * surface is on the 16 px floor and the two URL commands are answered by a modal rather than by the
+ * `null` both shells return from `window.prompt`.
+ *
+ * Both engines, because the shell is WKWebView on one platform and a Chromium WebView on the other,
+ * and `document.execCommand` — which the whole program is built on — is the engine's.
+ */
+import { Buffer } from 'node:buffer'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import * as esbuild from 'esbuild'
+import { chromium, webkit } from 'playwright-core'
+import { MOBILE_WEB_APP_ROOT_RESET, lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs'
+import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
+import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs'
+import {
+ createBundleServer,
+ installCspViolationRecorder,
+ installListenerRecorder,
+ installSchedulerRecorder,
+ readShellCsp
+} from './mobile-web-app-render-harness.mjs'
+
+const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
+const componentDir = join(mobileDir, 'src/components')
+
+/**
+ * The surface's floor, read out of the seam's own module rather than retyped.
+ *
+ * The number was a literal `16` under a comment claiming it was read, which is the shape the seam
+ * exists to prevent: a theme that raised the body size past the floor would move what the page
+ * computes and leave this asserting the old number. `textInputFontSizeFloor` is the same reader the
+ * closure census uses, and it throws rather than defaulting when the seam is gone.
+ */
+const FLOOR = textInputFontSizeFloor(mobileDir)
+
+/** Every command, with how to set the document up for it and what it must produce. */
+const COMMANDS = [
+ { label: 'H1', command: 'heading1', select: 'all', expect: 'h1' },
+ { label: 'H2', command: 'heading2', select: 'all', expect: 'h2' },
+ { label: 'H3', command: 'heading3', select: 'all', expect: 'h3' },
+ { label: 'Bold', command: 'bold', select: 'word', expect: 'b,strong' },
+ { label: 'Italic', command: 'italic', select: 'word', expect: 'i,em' },
+ { label: 'Strike', command: 'strike', select: 'word', expect: 'strike,s,del' },
+ { label: 'Bullet list', command: 'bulletList', select: 'all', expect: 'ul' },
+ { label: 'Numbered list', command: 'orderedList', select: 'all', expect: 'ol' },
+ { label: 'Checklist', command: 'taskList', select: 'all', expect: 'ul[data-type="taskList"]' },
+ { label: 'Quote', command: 'quote', select: 'all', expect: 'blockquote' },
+ { label: 'Inline code', command: 'inlineCode', select: 'word', expect: 'code' },
+ { label: 'Code block', command: 'codeBlock', select: 'all', expect: 'pre' }
+]
+
+/** Paragraph is the fifteenth, and it is the only one whose proof is a document it undoes. */
+const PARAGRAPH = { label: 'Body', command: 'paragraph' }
+
+/**
+ * The two that need a URL, and the element each inserts.
+ *
+ * The image's URL is this server's own, because an inserted `` is fetched: a name that does
+ * not resolve put a load failure in the console, and WebKit reports it where chromium does not.
+ * Serving it is also the stronger reading — the element the command inserted actually painted
+ * under the shipped policy rather than merely appearing in the markup.
+ */
+const IMAGE_PATH = '/inserted.png'
+const URL_COMMANDS = [
+ { label: 'Link', title: 'Link URL', path: '/linked', expect: 'a[href]' },
+ { label: 'Image', title: 'Image URL', path: IMAGE_PATH, expect: 'img' }
+]
+
+/** One transparent pixel, served for the image the Image command inserts. */
+const PIXEL = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==',
+ 'base64'
+)
+
+const ENGINES = [
+ {
+ name: 'chromium',
+ // CI runs this against the runner's Google Chrome rather than paying for a download, the same
+ // override shape as every other render check here.
+ launch: () => {
+ const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
+ return chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
+ }
+ },
+ { name: 'webkit', launch: () => webkit.launch({ headless: true }) }
+]
+
+/**
+ * The page under test: the real component, mounted by the real React, with a handle on its props.
+ *
+ * Not a re-implementation. The controller, the mount, the document's own modules and the toolbar
+ * are all the behaviour under test, and a probe that called `runCommand` itself would prove nothing
+ * about any of them.
+ *
+ * `content` is fed back from `onChange`, which is what `MarkdownReader` does: a harness that held
+ * the prop still would have the controller replacing the document under every edit.
+ */
+const PAGE_ENTRY = `
+import { createElement, useEffect, useRef, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import { SafeAreaProvider } from 'react-native-safe-area-context'
+import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor'
+
+/**
+ * What the route's own navigator supplies and a bare mount does not: react-navigation wraps every
+ * screen in a safe-area provider, and the URL modal's drawer reads the insets from it. Without one
+ * the modal throws where the page has no problem at all.
+ */
+const METRICS = {
+ frame: { x: 0, y: 0, width: 390, height: 844 },
+ insets: { top: 0, left: 0, right: 0, bottom: 0 }
+}
+
+function Harness() {
+ const [state, setState] = useState({
+ mounted: true,
+ generation: 0,
+ content: '',
+ secondContent: '',
+ editable: true,
+ both: false
+ })
+ const handle = useRef(null)
+ useEffect(() => {
+ globalThis.__orcaEditor = {
+ set: (next) => setState((previous) => ({ ...previous, ...next })),
+ changes: [],
+ secondChanges: [],
+ links: [],
+ insets: [],
+ dismiss: () => handle.current?.dismissKeyboard()
+ }
+ document.body.setAttribute('data-ready', 'yes')
+ }, [])
+ // Each editor holds its own content, which is what makes the two-surface case a measurement:
+ // sharing one prop would show the second's edit on the first whatever the document did.
+ const editor = (key, withHandle, content, onChanged) =>
+ createElement(MobileRichMarkdownEditor, {
+ key,
+ ref: withHandle ? handle : undefined,
+ content,
+ editable: state.editable,
+ onChange: onChanged,
+ onOpenLink: (url) => globalThis.__orcaEditor.links.push(url),
+ onKeyboardInsetChange: (bottom) => globalThis.__orcaEditor.insets.push(bottom)
+ })
+ return createElement(
+ SafeAreaProvider,
+ { initialMetrics: METRICS },
+ createElement(
+ 'div',
+ { style: { display: 'flex', flexDirection: 'row', height: '100vh' } },
+ state.mounted
+ ? createElement(
+ 'div',
+ { id: 'first-surface', style: { flex: 1, display: 'flex', minHeight: 0 } },
+ editor('first-' + state.generation, true, state.content, (next) => {
+ globalThis.__orcaEditor.changes.push(next)
+ setState((previous) => ({ ...previous, content: next }))
+ })
+ )
+ : null,
+ state.both
+ ? createElement(
+ 'div',
+ { id: 'second-surface', style: { flex: 1, display: 'flex', minHeight: 0 } },
+ editor('second', false, state.secondContent, (next) => {
+ globalThis.__orcaEditor.secondChanges.push(next)
+ setState((previous) => ({ ...previous, secondContent: next }))
+ })
+ )
+ : null
+ )
+ )
+}
+
+createRoot(document.getElementById('root')).render(createElement(Harness))
+`
+
+/**
+ * A recorder over `window.ReactNativeWebView`, installed before the bundle runs.
+ *
+ * Ruling 19's claim on the page is an absence, and an absence needs an instrument: on the shell
+ * that object is the bridge's, so an editor message posted through it would put editor JSON into
+ * the bridge's own channel. Defined rather than left undefined, so "the page never reaches for it"
+ * is measured against something that would have answered.
+ */
+function installBridgeObjectRecorder() {
+ globalThis.__orcaBridgeReads = []
+ const bridge = {
+ postMessage: (message) => globalThis.__orcaBridgeReads.push(`post ${String(message)}`)
+ }
+ Object.defineProperty(globalThis, 'ReactNativeWebView', {
+ configurable: true,
+ get: () => {
+ globalThis.__orcaBridgeReads.push('read')
+ return bridge
+ }
+ })
+}
+
+const bundles = mobileWebAppDependenciesPresent()
+const describeEditor = bundles ? describe : describe.skip
+
+let scratch = null
+let server = null
+let origin = null
+
+beforeAll(async () => {
+ if (!bundles) {
+ return
+ }
+ // Inside mobile/ rather than the system temp dir: the entry resolves the component beside it,
+ // and esbuild resolves a bare specifier from the importer upward.
+ await mkdir(join(mobileDir, '.tmp'), { recursive: true })
+ scratch = await mkdtemp(join(mobileDir, '.tmp', 'rich-markdown-render-'))
+ const outDir = join(scratch, 'bundle')
+ await mkdir(outDir, { recursive: true })
+ await esbuild.build({
+ absWorkingDir: mobileDir,
+ stdin: {
+ contents: PAGE_ENTRY,
+ resolveDir: componentDir,
+ loader: 'ts',
+ sourcefile: 'rich-markdown-check.ts'
+ },
+ bundle: true,
+ format: 'esm',
+ outdir: outDir,
+ entryNames: 'rich-markdown-check',
+ target: ['es2022'],
+ jsx: 'automatic',
+ logLevel: 'silent',
+ nodePaths: [join(mobileDir, 'node_modules')],
+ alias: { 'react-native': 'react-native-web' },
+ // The barrel re-exports a `LucideProvider` its own context module does not export, which is the
+ // same shape the app bundle carries this plugin for.
+ plugins: [lucideBarrelPlugin],
+ // `.web.jsx` and `.web.js` are here for the reason the app bundle has them: without them
+ // `react-native-svg`, which the toolbar's icons pull in, resolves its Fabric components and
+ // fails on `codegenNativeComponent`.
+ resolveExtensions: ['.web.tsx', '.web.ts', '.web.jsx', '.web.js', '.tsx', '.ts', '.jsx', '.js'],
+ // Four of `MOBILE_WEB_APP_SHIMS`, because this entry reaches the same React Native modules the
+ // app bundle does: RN ships untranspiled JSX in `.js`, reads `process.env` at module scope, and
+ // assumes a Metro `global` — measured, `isFabric` threw `global is not defined` before the page
+ // mounted at all, and every case in this file failed at `data-ready`.
+ loader: { '.js': 'jsx' },
+ banner: {
+ js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };"
+ },
+ define: {
+ global: 'globalThis',
+ __DEV__: 'false',
+ 'process.env.NODE_ENV': '"production"',
+ 'process.env.EXPO_OS': '"web"'
+ }
+ })
+ await writeFile(
+ join(outDir, 'index.html'),
+ // The root reset the shipped document carries: every box below the mount is `flex: 1`, so
+ // without a definite height on all three the editor measures 0 and paints nothing.
+ `
` it was given, and the serializer walks back out with the same + * text — so re-setting the same string after it is a no-op, and the next command ran against the + * list rather than against a paragraph. + */ +const bodyFor = (index) => `body text ${String(index)}` + +/** The surface's markup, which is what every command is read off. */ +function readSurface(page, surface = '#first-surface') { + return page.evaluate((id) => document.querySelector(`${id} #editor`)?.innerHTML ?? null, surface) +} + +/** Selects the whole surface, or its first word, through the browser's own selection. */ +async function select(page, how) { + await page.evaluate((mode) => { + const editor = document.querySelector('#first-surface #editor') + editor.focus() + const selection = window.getSelection() + selection.removeAllRanges() + const range = document.createRange() + if (mode === 'all') { + range.selectNodeContents(editor) + } else { + const text = editor.querySelector('p')?.firstChild ?? editor.firstChild + range.setStart(text, 0) + range.setEnd(text, 4) + } + selection.addRange(range) + }, how) +} + +/** One toolbar press, as a user makes it. */ +async function press(page, label) { + await page.locator(`[aria-label="${label}"]`).click() +} + +describeEditor( + 'the rich Markdown editor on the page', + () => { + for (const engine of ENGINES) { + describe(engine.name, () => { + let browser = null + + beforeAll(async () => { + browser = await engine.launch() + }, 180_000) + + afterAll(async () => { + await browser?.close() + }) + + it('mounts the document, reports ready through the seam and never touches the bridge', async () => { + const { page, consoleErrors } = await openPage(browser) + try { + await setContent(page, '# Title', '
${body}
`) + // The precondition each command needs: what it is about to produce is not there yet. + // Without it a command the controller never delivered would pass on the document the + // one before it left. + expect( + await page.evaluate( + (selector) => + document.querySelector('#first-surface #editor')?.querySelector(selector), + entry.expect + ), + `${entry.label} was already applied before it ran` + ).toBeNull() + await select(page, entry.select) + await press(page, entry.label) + await page.waitForFunction( + (selector) => + document.querySelector('#first-surface #editor')?.querySelector(selector) !== + null, + entry.expect, + { timeout: 15_000 } + ) + } + + // Paragraph is the fifteenth and the only one whose effect is to undo another's: it is + // a true no-op on a ``, which is why a run that starts from one measures nothing. + await setContent(page, '# Title', '
${body}
`) + await select(page, 'word') + await press(page, entry.label) + // The seam both shells could not answer: neither implements the delegate + // `window.prompt` needs, so on the phone these two commands silently do nothing. + await expect + .poll(() => page.locator(`text=${entry.title}`).count(), { timeout: 15_000 }) + .toBeGreaterThan(0) + const field = page.locator('input[placeholder="https://"]') + await field.waitFor({ state: 'visible', timeout: 15_000 }) + await field.fill(`${origin}${entry.path}`) + await page.locator('text=Insert').first().click() + await page.waitForFunction( + (selector) => + document.querySelector('#first-surface #editor')?.querySelector(selector) !== + null, + entry.expect, + { timeout: 15_000 } + ) + } + // The inserted image painted: `naturalWidth` is 0 for an element the browser refused + // or never fetched, which is what a policy that did not admit it would leave. + expect( + await page.evaluate( + () => document.querySelector('#first-surface #editor img')?.naturalWidth ?? 0 + ) + ).toBeGreaterThan(0) + expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([]) + expect(consoleErrors).toEqual([]) + } finally { + await page.close() + } + }, 600_000) + + it('reports one change per checkbox tap and one per inline code', async () => { + const { page } = await openPage(browser) + try { + await setContentWithin(page, '- [ ] one', 'input[type="checkbox"]') + await page.evaluate(() => { + globalThis.__orcaEditor.changes.length = 0 + }) + await page.locator('#first-surface #editor input[type="checkbox"]').first().click() + await page.waitForFunction(() => globalThis.__orcaEditor.changes.length > 0) + // One tap raises click, input and change, and each of the three used to report. + expect(await page.evaluate(() => globalThis.__orcaEditor.changes)).toHaveLength(1) + expect(await page.evaluate(() => globalThis.__orcaEditor.changes[0])).toContain('[x]') + + await setContent(page, bodyFor(0), `${bodyFor(0)}
`) + await select(page, 'word') + await page.evaluate(() => { + globalThis.__orcaEditor.changes.length = 0 + }) + await press(page, 'Inline code') + await page.waitForFunction(() => globalThis.__orcaEditor.changes.length > 0) + expect(await page.evaluate(() => globalThis.__orcaEditor.changes)).toHaveLength(1) + } finally { + await page.close() + } + }, 600_000) + + it('opens a link through the host rather than navigating the page', async () => { + const { page } = await openPage(browser) + try { + await setContentWithin(page, '[a](https://example.com/a)', 'a[href]') + await page.locator('#first-surface #editor a').first().click() + await page.waitForFunction(() => globalThis.__orcaEditor.links.length > 0) + expect(await page.evaluate(() => globalThis.__orcaEditor.links)).toEqual([ + 'https://example.com/a' + ]) + expect(page.url()).toBe(`${origin}/`) + } finally { + await page.close() + } + }, 600_000) + + it('leaves no listener, timer or frame of the first mount in the second', async () => { + const { page, consoleErrors } = await openPage(browser) + const listeners = () => page.evaluate(() => globalThis.__orcaListeners.snapshot()) + try { + await setContent(page, 'first document', 'first document
') + + // A first mount installs listeners no dispose can take off — react-native-web's + // responder system arms itself on the first `View` the page renders — so the baseline a + // per-mount leak would move is the snapshot after one whole cycle, not before it. + await page.evaluate(() => globalThis.__orcaEditor.set({ mounted: false })) + await page.waitForFunction(() => document.querySelector('#first-surface') === null) + const afterOneCycle = await listeners() + // The precondition that makes the comparison below non-vacuous: the recorder is reading + // real listeners, so a snapshot of nothing cannot pass as a snapshot of no leak. + expect(Object.keys(afterOneCycle).length).toBeGreaterThan(0) + + await page.evaluate(() => { + globalThis.__orcaScheduler.watching = true + globalThis.__orcaEditor.set({ mounted: true, generation: 1 }) + }) + await page.waitForSelector('#first-surface #editor') + await setContent(page, 'second document', 'second document
') + await select(page, 'all') + await press(page, 'Bold') + await page.waitForFunction( + () => + document.querySelector('#first-surface #editor')?.querySelector('b,strong') !== null + ) + + // The second mount is live, and the page is listening to exactly what it was after the + // first cycle. A mount that registered anything of its own would show up here. + expect(await listeners()).toEqual(afterOneCycle) + expect(await page.evaluate(() => globalThis.__orcaScheduler.leaked)).toEqual([]) + expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([]) + expect(consoleErrors).toEqual([]) + // The sheet stays in the head across mounts, and there is one of it. + expect( + await page.evaluate( + () => document.querySelectorAll('#orca-rich-markdown-document-style').length + ) + ).toBe(1) + } finally { + await page.close() + } + }, 600_000) + + it('gives two editors on one page their own surfaces', async () => { + const { page, consoleErrors } = await openPage(browser) + try { + await setContent(page, 'first only', 'first only
') + await page.evaluate(() => + globalThis.__orcaEditor.set({ both: true, secondContent: 'second only' }) + ) + await page.waitForSelector('#second-surface #editor') + // The markup's id is the same in both hosts, so a page-wide read would have handed the + // second document the first one's surface: one would be empty and the other would hold + // both documents' content and both documents' listeners. + await page.waitForFunction(() => + (document.querySelector('#second-surface #editor')?.textContent ?? '').includes( + 'second only' + ) + ) + expect(await readSurface(page, '#first-surface')).toContain('first only') + expect(await readSurface(page, '#second-surface')).not.toContain('first only') + + await page.evaluate(() => { + const editor = document.querySelector('#second-surface #editor') + editor.innerHTML = 'typed in the second
' + editor.dispatchEvent(new Event('input', { bubbles: true })) + }) + await page.waitForFunction(() => + globalThis.__orcaEditor.secondChanges.includes('typed in the second') + ) + // The edit reached the second editor's own host, and the first document neither + // reported it nor lost its content. + expect(await page.evaluate(() => globalThis.__orcaEditor.changes)).not.toContain( + 'typed in the second' + ) + expect(await readSurface(page, '#first-surface')).toContain('first only') + expect(consoleErrors).toEqual([]) + } finally { + await page.close() + } + }, 600_000) + }) + } + }, + 1_800_000 +) 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 7b1aa65deda..6522ecc5220 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 @@ -216,7 +221,33 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * entered the page bundle between C7.7's measurement on `f07bf8544c` and its merge. Named by * diffing the closure at `f07bf8544c` against `2739246058`; nothing on the C7.7 side moved. * - * Then ruling 36 gave the screen to the microphone and two local modules left: + * Then C7.10 item C put the rich Markdown editor on the page, and the list moved again. Its own + * reading, both sides measured with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `9267423f22` + * with all five postinstall generators run first: + * + * modules 4333 -> 4360 (+27) + * local modules 991 -> 1018 (+27) + * + * Every one of the 27 is local and none is vendored, because the editor is the app's own code + * rather than a library: the document's 24 modules under `src/components/rich-markdown/` — which + * C7.6's plain field did not reach at all — plus the page's mount, the toolbar both siblings render, + * and the controller and keyboard-inset module the native component already had. Nothing leaves: + * the web sibling replaces its own native file, which was never in this closure. Named by diffing + * the two `local` lists rather than inferred from the total. + * + * `terminal-webview-html/document-style-scoping.ts` is in the reading on both sides and costs + * nothing: the terminal's own mount already brings it, and the editor's mount imports the second + * export it grew rather than a module of its own. + * + * What the generation weighs, measured the same way on both sides: 8,028,418 -> 8,056,166 bytes + * (+27,748) across 109 assets, against the 9 MiB ceiling in `verify-mobile-web-app-bundle.mjs` — + * 85.1% of it before and 85.4% after. The script count does not move at all (67, against the + * 76 the chunk fence allows for 15 routes) and neither does the entry's static closure + * (1,612,052 bytes against a 3 MiB bound): the editor is code the session route already reached + * for, not a new chunk boundary. + * + * Main's own paragraph for the same pin, kept because the two provenances are independent: ruling + * 36 gave the screen to the microphone and two local modules left, * `src/hooks/mobile-dictation-keep-awake.ts` and * `src/hooks/mobile-dictation-foreground-keep-awake.ts`, the page's wake-tag owner and its Android * foreground re-acquire. Both are deleted rather than moved — the device module that opens the @@ -225,11 +256,6 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * modules 4333 -> 4331 (-2) * local modules 991 -> 989 (-2) * - * Measured on this merge rather than subtracted from the line above, and the two local lists - * diffed to name the difference: those two leave and nothing joins. The same measurement, taken - * before #22067 landed, is how this branch read main's pin of 4,330 as three modules stale — the - * three the paragraph above names. - * * Then the two other table parsers gave up their own row splitters and read the editor's * `src/components/rich-markdown/markdown-table-rows.ts` instead, which the session page reaches * through the PR comment renderer. It is the one module that joins, and the only one it can be: it @@ -246,8 +272,28 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * * modules 4332 -> 4333 (+1) * local modules 990 -> 991 (+1) + * + * C7.10 item C then put the whole of that directory on the page, and the merge of the two is + * measured rather than summed — which is what this reading keeps having to do. The arithmetic of + * 4,358 on this branch and 4,332 on main double-counts `markdown-table-rows.ts`: main reached it + * first through the comment renderer, and it is also one of the 27 the editor brings. Measured on + * the merged head with every generator run first: + * + * modules 4333 -> 4359 (+26) + * local modules 991 -> 1017 (+26) + * + * and the two local lists diffed against main's, which names the difference module by module: the + * editor's own 26, with `markdown-table-rows.ts` already on both sides and the dictation pair + * already gone from both. 26 rather than 27 for exactly that reason — main reached the row splitter + * first, so it is not this branch's to add twice. + * + * The 4,333 the merge is measured against is main's corrected reading, not the 4,332 it pinned at + * `059ee59a48`: that census failed, `expected [ …(4333) ] to have a length of 4332`, because the + * icon above joined beside the row splitter and was not counted. #22119 (`197550c952`) repinned + * main to 4,333 with the paragraph above, and folding it here moves nothing — the icon was already + * on both sides of the +26, so the pin below is this merge's own measurement, unchanged. */ -const SESSION_ROUTE_MODULES = 4333 +const SESSION_ROUTE_MODULES = 4359 /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ @@ -335,6 +381,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/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index 022908bbb6e..197092b7159 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -5,30 +5,13 @@ import { useImperativeHandle, useMemo, useRef, - type ComponentType, type ForwardedRef } from 'react' -import { Keyboard, Pressable, ScrollView, StyleSheet, View } from 'react-native' +import { Keyboard, StyleSheet, View } from 'react-native' import { openExternalLink } from '../platform/external-link' -import { - Bold, - Code2, - FileCode2, - Heading1, - Heading2, - Heading3, - ImageIcon, - Italic, - Link, - List, - ListOrdered, - ListTodo, - Pilcrow, - Quote, - Strikethrough -} from 'lucide-react-native' import WebView, { type WebViewMessageEvent } from 'react-native-webview' -import { colors, radii, spacing } from '../theme/mobile-theme' +import { colors } from '../theme/mobile-theme' +import { MobileRichMarkdownToolbar } from './MobileRichMarkdownToolbar' import type { MobileRichMarkdownCommand, MobileRichMarkdownEditorMessage, @@ -55,30 +38,6 @@ export type MobileRichMarkdownEditorHandle = { dismissKeyboard: () => void } -type ToolbarItem = { - command: MobileRichMarkdownCommand - label: string - icon: ComponentType<{ size?: number; color?: string }> -} - -const TOOLBAR_ITEMS: ToolbarItem[] = [ - { command: 'paragraph', label: 'Body', icon: Pilcrow }, - { command: 'heading1', label: 'H1', icon: Heading1 }, - { command: 'heading2', label: 'H2', icon: Heading2 }, - { command: 'heading3', label: 'H3', icon: Heading3 }, - { command: 'bold', label: 'Bold', icon: Bold }, - { command: 'italic', label: 'Italic', icon: Italic }, - { command: 'strike', label: 'Strike', icon: Strikethrough }, - { command: 'bulletList', label: 'Bullet list', icon: List }, - { command: 'orderedList', label: 'Numbered list', icon: ListOrdered }, - { command: 'taskList', label: 'Checklist', icon: ListTodo }, - { command: 'quote', label: 'Quote', icon: Quote }, - { command: 'link', label: 'Link', icon: Link }, - { command: 'image', label: 'Image', icon: ImageIcon }, - { command: 'inlineCode', label: 'Inline code', icon: Code2 }, - { command: 'codeBlock', label: 'Code block', icon: FileCode2 } -] - function MobileRichMarkdownEditorInner( { content, @@ -171,34 +130,7 @@ function MobileRichMarkdownEditorInner( return (from the first
' + surfaceOf(second.host).innerHTML = 'from the second
' + + expect(first.started.send.currentMarkdown()).toBe('from the first') + expect(second.started.send.currentMarkdown()).toBe('from the second') + }) + + it('reports an edit in the second host to the second host’s own host', () => { + const first = mountedIn('first') + const second = mountedIn('second') + first.posts.length = 0 + second.posts.length = 0 + + surfaceOf(second.host).innerHTML = 'typed
' + surfaceOf(second.host).dispatchEvent(new Event('input', { bubbles: true })) + + expect(second.posts).toEqual([{ type: 'change', markdown: 'typed', generation: 0 }]) + expect(first.posts).toEqual([]) + }) + + it('leaves the other document live when one of the two is stopped', () => { + const first = mountedIn('first') + const second = mountedIn('second') + first.started.stop() + second.posts.length = 0 + + surfaceOf(second.host).innerHTML = 'still here
' + surfaceOf(second.host).dispatchEvent(new Event('input', { bubbles: true })) + + expect(second.posts).toEqual([{ type: 'change', markdown: 'still here', generation: 0 }]) + }) + + it('reads the whole page when no root is named, which is what the WebView gets', () => { + document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP + const posts: MobileRichMarkdownEditorMessage[] = [] + const started = createRichMarkdownEditorDocument({ + postToHost: (message) => posts.push(message), + keyboardInsetSource: () => null + }) + startedDocuments.push(started) + + started.send.setMarkdown('# native', 1) + + expect(document.querySelector('#editor')!.textContent).toBe('native') + }) +}) diff --git a/mobile/src/components/rich-markdown/document-host-seams.ts b/mobile/src/components/rich-markdown/document-host-seams.ts index 1d61ddc972e..1948b2f525a 100644 --- a/mobile/src/components/rich-markdown/document-host-seams.ts +++ b/mobile/src/components/rich-markdown/document-host-seams.ts @@ -4,7 +4,7 @@ import type { } from '../mobile-rich-markdown-editor-contract' /** - * The six seams between the editor document and whatever is hosting it, as the document's own + * The seven seams between the editor document and whatever is hosting it, as the document's own * defaults. * * Inside the WebView the host is React Native and every seam is the window read the hand-written @@ -65,6 +65,20 @@ export type RichMarkdownEditorHostSeams = { getSelection: () => Selection | null /** `editor-commands`, `editor-selection`: the document ranges, elements and `execCommand` come from. */ getDocument: () => Document + /** + * `editor-surface`: where this document's own markup is, which is the last thing two of them + * shared. + * + * The surface's id is in the markup every host plants, so a page-wide read hands both documents + * whichever host came first in the tree — and two at once is not a corner on the page, because a + * stack transition keeps the outgoing screen mounted while the incoming one starts. Inside the + * WebView the document *is* the page, so it names nothing and gets the whole of it. + * + * Null rather than `document` as the default, because this is the one seam whose value is data: a + * default of `document` would be read when the scope is built rather than when the surface is, + * and the rule for every seam above it is that the window read happens at the call. + */ + root: ParentNode | null } /** @@ -90,8 +104,13 @@ export function postToReactNativeWebView(message: MobileRichMarkdownEditorMessag } } -/** The labels the WebView's dialog carried, which is the whole of what the prompt kind means there. */ -const URL_PROMPT_LABELS: Record