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 `