/**
* Every text input the source-control hub and the diff review page reach, and the size it declares.
*
* iOS zooms the page on focus of any input under 16px and does not zoom back out, so the document
* spends the rest of that typing session at a scale other than 1 — which `keyboard-occlusion.web.ts`
* reads as "not a keyboard" on purpose, because geometry cannot separate a zoom from a keyboard.
* One 14px input anywhere in the closure is therefore enough to stop the commit bar and the note
* composer lifting, whatever those two inputs themselves declare.
*
* So the rule is the closure rather than the two seam-served sites: the first version of this fix
* raised those two and left eight others in the same closures at 14px, which made the seam's own
* rationale false page-wide.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
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 {
TEXT_INPUT_FONT_SIZE_SEAM,
textInputFontSizeFloor,
textInputFontSizeOffenders,
unresolvedTextInputStyles
} from './mobile-web-app-text-input-font-size-seam.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx'
/** The seam module itself, which a fixture needs on disk for an import of it to resolve. */
const SEAM_SOURCE = {
'src/platform/text-input-font-size.ts': 'export const TEXT_INPUT_FONT_SIZE = 14'
}
/**
* The seam's web half, seeded into every scratch tree below.
*
* Not a fixture detail: the floor is declared here and the census reads it here, so a tree without
* this file is one the rule refuses to judge at all. Seeding it makes every case a tree with a
* seam, which is what a real one is; the case that checks the refusal writes its own over the top.
*/
const FLOOR_SOURCE = {
'src/platform/text-input-font-size.web.ts': [
'export const TEXT_INPUT_FONT_SIZE_FLOOR = 16',
'export const TEXT_INPUT_FONT_SIZE = 16'
].join('\n')
}
const SEAM_IMPORT = "import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'"
/** A scratch module tree, so a planted offender never lands in the tree other censuses walk. */
function plant(files) {
const root = mkdtempSync(join(tmpdir(), 'orca-text-input-census-'))
for (const [path, source] of Object.entries({ ...FLOOR_SOURCE, ...files })) {
mkdirSync(join(root, path.slice(0, path.lastIndexOf('/'))), { recursive: true })
writeFileSync(join(root, path), source)
}
return root
}
describe('the size a text input declares, as the census reads it', () => {
it('names the line the size is set on, which may not be the file the input is in', () => {
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => '
].join('\n'),
'src/ui/field-styles.ts': [
'export const styles = {',
' label: { fontSize: 12 },',
' input: { fontSize: 14 }',
'}'
].join('\n')
})
try {
expect(
textInputFontSizeOffenders(root, { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] })
).toEqual(['src/ui/field-styles.ts:3'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('follows a spread into the module that really holds the key', () => {
// Both screens this rule exists for reach their input through `{ ...base, ...list }`. A walk
// that stopped at the first module would find no size here and report the offence as absent.
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => '
].join('\n'),
'src/ui/field-styles.ts': [
"import { listStyles } from './list-styles'",
'export const styles = { ...listStyles }'
].join('\n'),
'src/ui/list-styles.ts': 'export const listStyles = { input: { fontSize: 14 } }'
})
try {
expect(
textInputFontSizeOffenders(root, {
local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts', 'src/ui/list-styles.ts']
})
).toEqual(['src/ui/list-styles.ts:1'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('separates a style it could not follow from one that sets no size', () => {
// An offender list only says every input is on the seam if every input was read. A style the
// walk cannot follow has to surface here rather than pass as a clean input.
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
"import { missing } from 'some-package'",
'export const Bare = () => ',
'export const Gone = () => '
].join('\n'),
'src/ui/field-styles.ts': 'export const styles = { bare: { padding: 8 } }'
})
try {
const closure = { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual([])
expect(unresolvedTextInputStyles(root, closure)).toEqual(['src/ui/Field.tsx:4 (input)'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('takes the seam as the answer, and an absent size as nothing to answer for', () => {
// A style with no `fontSize` inherits; the floor is about the size an input declares.
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => ',
'export const Other = () => '
].join('\n'),
// Imported, not merely spelled: the rule reads the binding now, so a fixture that wrote the
// name without importing it from the seam would be an offender like any other shadow.
'src/ui/field-styles.ts': [
SEAM_IMPORT,
'export const styles = {',
' input: { fontSize: TEXT_INPUT_FONT_SIZE },',
' bare: { padding: 8 }',
'}'
].join('\n'),
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] })
).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('reads an inline style literal in place rather than losing it', () => {
// `style={{ fontSize: 14 }}` names no style key, so a walk that only followed `styles.key`
// recorded nothing at all for it: neither an offender nor a hole.
const root = plant({
'src/ui/Inline.tsx': [
'export const Sized = () => ',
'export const Bare = () => '
].join('\n')
})
try {
const closure = { local: ['src/ui/Inline.tsx'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual(['src/ui/Inline.tsx:1'])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('reads an inline literal beside a style key, and the condition between them', () => {
// `[styles.input, disabled && styles.disabled]` is the shape this tree actually uses, so the
// members of an array — and the right of an `&&` — have to be followed, not walked as a blob.
const root = plant({
'src/ui/Mixed.tsx': [
"import { styles } from './mixed-styles'",
'export const Mixed = () => (',
' ',
')'
].join('\n'),
'src/ui/mixed-styles.ts': [
SEAM_IMPORT,
'export const styles = {',
' input: { fontSize: TEXT_INPUT_FONT_SIZE },',
' disabled: { opacity: 0.5 }',
'}'
].join('\n'),
...SEAM_SOURCE
})
try {
const closure = {
local: ['src/ui/Mixed.tsx', 'src/ui/mixed-styles.ts', ...Object.keys(SEAM_SOURCE)]
}
expect(textInputFontSizeOffenders(root, closure)).toEqual(['src/ui/Mixed.tsx:3'])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* The floor is the rule and the seam is the mechanism, so a literal already clear of the floor
* satisfies it without binding to anything.
*
* Written as three sizes rather than one: a rule that only proved 22 passes would also be
* satisfied by a census that stopped reading literals at all, and the 15 is the case the whole
* seam exists for. The boundary is included because "at or above" is where an off-by-one lives.
*/
it.each([
['under the floor, which is the offence the seam exists for', 15, ['src/ui/Sized.tsx:1']],
['exactly the floor', 16, []],
['well above the floor, which no binding could keep', 22, []]
])('reads a literal %s', (_label, size, expected) => {
const root = plant({
'src/ui/Sized.tsx': `export const Sized = () => `,
...SEAM_SOURCE
})
try {
const closure = { local: ['src/ui/Sized.tsx'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual(expected)
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* The floor really is the seam's, checked against a seam that does not say 16.
*
* The first version of this case planted a floor of 16 and asserted the census read 16, which a
* census carrying its own copy of the number passes just as happily. A tree whose seam says 20
* is the only fixture that can tell the two apart, and the 18 below is the size that is clean
* under one floor and an offence under the other.
*/
it('judges against the floor the seam declares, not against a number of its own', () => {
const root = plant({
'src/ui/Sized.tsx': 'export const Sized = () => ',
'src/platform/text-input-font-size.web.ts': [
'export const TEXT_INPUT_FONT_SIZE_FLOOR = 20',
'export const TEXT_INPUT_FONT_SIZE = 20'
].join('\n')
})
try {
expect(textInputFontSizeFloor(root)).toBe(20)
expect(textInputFontSizeOffenders(root, { local: ['src/ui/Sized.tsx'] })).toEqual([
'src/ui/Sized.tsx:1'
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('refuses to judge a tree whose seam declares no floor, rather than assuming one', () => {
const root = plant({
'src/ui/Sized.tsx': 'export const Sized = () => ',
'src/platform/text-input-font-size.ts': 'export const TEXT_INPUT_FONT_SIZE = 14',
'src/platform/text-input-font-size.web.ts': 'export const TEXT_INPUT_FONT_SIZE = 16'
})
try {
expect(() => textInputFontSizeOffenders(root, { local: ['src/ui/Sized.tsx'] })).toThrow(
/declares no numeric/
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('names a style shape it cannot follow rather than dropping it', () => {
const root = plant({
'src/ui/Called.tsx': 'export const Called = () => '
})
try {
expect(unresolvedTextInputStyles(root, { local: ['src/ui/Called.tsx'] })).toEqual([
'src/ui/Called.tsx:1 (makeStyle())'
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('lets a later spread beat a direct key, as the runtime object does', () => {
// `{ input: safe, ...legacy }` is `legacy.input` at runtime. Answering the direct key first
// read the safe one and called the override clean.
const root = plant({
'src/ui/Order.tsx': [
"import { styles } from './order-styles'",
'export const Order = () => '
].join('\n'),
'src/ui/order-styles.ts': [
SEAM_IMPORT,
"import { legacy } from './legacy-styles'",
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE }, ...legacy }'
].join('\n'),
'src/ui/legacy-styles.ts': 'export const legacy = { input: { fontSize: 14 } }',
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, {
local: [
'src/ui/Order.tsx',
'src/ui/order-styles.ts',
'src/ui/legacy-styles.ts',
...Object.keys(SEAM_SOURCE)
]
})
).toEqual(['src/ui/legacy-styles.ts:1'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.each([
['a local constant wearing the name', 'const TEXT_INPUT_FONT_SIZE = 14'],
['an import of the name from elsewhere', "import { TEXT_INPUT_FONT_SIZE } from './elsewhere'"]
])('reads the seam as a binding, not a spelling: %s', (_label, preamble) => {
const root = plant({
'src/ui/Shadow.tsx': [
"import { styles } from './shadow-styles'",
'export const Shadow = () => '
].join('\n'),
'src/ui/shadow-styles.ts': [
preamble,
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
'src/ui/elsewhere.ts': 'export const TEXT_INPUT_FONT_SIZE = 14',
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, {
local: [
'src/ui/Shadow.tsx',
'src/ui/shadow-styles.ts',
'src/ui/elsewhere.ts',
...Object.keys(SEAM_SOURCE)
]
})
).toEqual(['src/ui/shadow-styles.ts:2'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
/**
* The platform sibling, which is the file the page actually runs.
*
* `mobileWebAppRouteClosure` reports what esbuild resolved, and esbuild prefers `.web.tsx`/`.web.ts`
* ahead of the native file. A census that followed an import to the native sibling would judge a
* module no browser loads: it would clear a split whose web half is off the seam, and report one
* whose web half is on it. Both directions are below, because only one of them is a false pass.
*/
describe('a style module with a platform sibling', () => {
const FIELD = [
"import { styles } from './field-styles'",
'export const Field = () => '
].join('\n')
it('is read through its .web sibling, so a raised web size clears the native one', () => {
const root = plant({
'src/ui/Field.tsx': FIELD,
'src/ui/field-styles.ts': 'export const styles = { input: { fontSize: 12 } }',
'src/ui/field-styles.web.ts': [
SEAM_IMPORT.replace('../platform', '../platform'),
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
...SEAM_SOURCE
})
try {
const closure = { local: ['src/ui/Field.tsx', 'src/ui/field-styles.web.ts'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual([])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('is reported when the .web half is the one off the seam, native half notwithstanding', () => {
const root = plant({
'src/ui/Field.tsx': FIELD,
'src/ui/field-styles.ts': [
SEAM_IMPORT,
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
'src/ui/field-styles.web.ts': 'export const styles = { input: { fontSize: 12 } }',
...SEAM_SOURCE
})
try {
expect(
textInputFontSizeOffenders(root, {
local: ['src/ui/Field.tsx', 'src/ui/field-styles.web.ts']
})
).toEqual(['src/ui/field-styles.web.ts:1'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
/**
* The seam has a `.web.ts` sibling of its own, and that is the whole point of it: the native file
* is the app's body size and the web one raises it past the focus-zoom floor. Preferring the web
* file when following an import must not make every seam binding stop naming the seam.
*/
it('still counts the seam as the seam when the seam itself is the split one', () => {
const root = plant({
'src/ui/Field.tsx': FIELD,
'src/ui/field-styles.ts': [
SEAM_IMPORT,
'export const styles = { input: { fontSize: TEXT_INPUT_FONT_SIZE } }'
].join('\n'),
...SEAM_SOURCE,
'src/platform/text-input-font-size.web.ts': 'export const TEXT_INPUT_FONT_SIZE = 16'
})
try {
const closure = { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] }
expect(textInputFontSizeOffenders(root, closure)).toEqual([])
expect(unresolvedTextInputStyles(root, closure)).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describeClosure(
'the text inputs the source-control and review pages reach',
() => {
it.each([HUB, REVIEW])('takes every input size through the seam: %s', async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(textInputFontSizeOffenders(mobileDir, closure)).toEqual([])
})
it.each([HUB, REVIEW])(
'reads every input it found, so the list above is complete: %s',
async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([])
}
)
it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => {
// Without this an empty offender list would also be what a closure reaching no text input at
// all produces, and the census would pass against a page that has nothing to raise.
const closure = await mobileWebAppRouteClosure(route)
expect(closure.local).toContain(TEXT_INPUT_FONT_SIZE_SEAM)
})
},
240_000
)