diff --git a/config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs b/config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs
new file mode 100644
index 00000000000..0482a51079f
--- /dev/null
+++ b/config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs
@@ -0,0 +1,108 @@
+/**
+ * The browser pane's two text inputs, read the way C4.2's census reads them.
+ *
+ * The pane is not in any page route today, so no route closure reaches it and the census that
+ * enforces the seam is not run against it: it walks the source-control hub and the review route,
+ * and the pane is in neither. C6 raises both inputs anyway, because the failure is not cosmetic —
+ * an input under 16px makes an iOS browser zoom the page on focus, and `keyboard-occlusion.web.ts`
+ * reads a visual viewport scale other than 1 as "no keyboard" for the rest of the typing session.
+ *
+ * This file is the census run by hand over the pane's own modules, so the raise is checked by the
+ * rule that will judge it once a route lists the pane.
+ */
+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 {
+ modulesDeclaringTextInput,
+ textInputFontSizeOffenders,
+ unresolvedTextInputStyles
+} from './mobile-web-app-text-input-font-size-seam.mjs'
+
+const mobileDir = join(fileURLToPath(new URL('../..', import.meta.url)), 'mobile')
+
+/**
+ * The pane's text-input modules, named as the bundler resolves them: a `.web.ts` where one exists,
+ * which is what `mobileWebAppRouteClosure` reports for a real route.
+ */
+const PANE_CLOSURE = {
+ local: [
+ 'src/browser/MobileBrowserPaneView.tsx',
+ 'src/browser/MobileBrowserAddressField.tsx',
+ 'src/browser/mobile-browser-pane-styles.ts',
+ 'src/browser/browser-address-field-styles.web.ts',
+ 'src/platform/text-input-font-size.web.ts'
+ ]
+}
+
+const KEY_ROW_STYLES = 'src/browser/mobile-browser-pane-styles.ts'
+
+/** The modules in `PANE_CLOSURE` that render an input, which is what the rule below judges. */
+const PANE_INPUT_MODULES = [
+ 'src/browser/MobileBrowserAddressField.tsx',
+ 'src/browser/MobileBrowserPaneView.tsx'
+]
+
+function offendingFiles() {
+ return textInputFontSizeOffenders(mobileDir, PANE_CLOSURE).map((entry) =>
+ entry.slice(0, entry.lastIndexOf(':'))
+ )
+}
+
+describe('the browser pane text inputs under the C4.2 census', () => {
+ /**
+ * The precondition the rest of this file rests on.
+ *
+ * `PANE_CLOSURE` is written by hand, because the pane is in no page route and there is no
+ * closure to derive it from. So "no unresolved styles" says the walk read the files listed, not
+ * that those files are the pane's inputs — a third input module added under `src/browser/` would
+ * leave every assertion below green and unchecked. This scans for it instead.
+ */
+ it('lists every module under src/browser that renders an input', () => {
+ expect(modulesDeclaringTextInput(mobileDir, 'src/browser')).toEqual(PANE_INPUT_MODULES)
+ expect(PANE_CLOSURE.local).toEqual(expect.arrayContaining(PANE_INPUT_MODULES))
+ })
+
+ it('reads every input the pane declares, so the verdicts below are complete', () => {
+ // The completeness half: an empty offender list means nothing if the walk found no input.
+ expect(unresolvedTextInputStyles(mobileDir, PANE_CLOSURE)).toEqual([])
+ expect(PANE_CLOSURE.local).toContain('src/platform/text-input-font-size.web.ts')
+ })
+
+ // A scan that happens to find two things is not a scan that would find a third. Planted in a
+ // scratch tree rather than in src/browser, so no other census ever walks the plant.
+ it('would report a third input module, and ignores tests and non-JSX mentions', () => {
+ const scratch = mkdtempSync(join(tmpdir(), 'orca-browser-pane-inputs-'))
+ try {
+ mkdirSync(join(scratch, 'src/browser'), { recursive: true })
+ for (const [name, source] of Object.entries({
+ 'Planted.tsx': 'export const P = () => ',
+ 'Planted.test.tsx': 'it("x", () => )',
+ 'mentions-only.ts': "import { TextInput } from 'react-native' // TextInput, named twice"
+ })) {
+ writeFileSync(join(scratch, 'src/browser', name), source)
+ }
+
+ expect(modulesDeclaringTextInput(scratch, 'src/browser')).toEqual(['src/browser/Planted.tsx'])
+ } finally {
+ rmSync(scratch, { recursive: true, force: true })
+ }
+ })
+
+ it('takes the key row input through the seam', () => {
+ expect(offendingFiles()).not.toContain(KEY_ROW_STYLES)
+ })
+
+ /**
+ * The address field is split, and the split is what the census now reads.
+ *
+ * Native keeps the 12px the toolbar has always rendered, because no phone has a page to zoom;
+ * the browser gets the seam. `resolveLocal` follows the `.web.ts` the builder would have
+ * resolved, so the size judged here is the size the page runs rather than the one it does not.
+ */
+ it('takes the address field through the seam, through its .web sibling', () => {
+ expect(offendingFiles()).toEqual([])
+ })
+})
diff --git a/config/scripts/mobile-web-app-source-control-text-inputs.test.mjs b/config/scripts/mobile-web-app-source-control-text-inputs.test.mjs
index 60b45370d73..332b78b98f7 100644
--- a/config/scripts/mobile-web-app-source-control-text-inputs.test.mjs
+++ b/config/scripts/mobile-web-app-source-control-text-inputs.test.mjs
@@ -269,6 +269,85 @@ describe('the size a text input declares, as the census reads it', () => {
})
})
+/**
+ * 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',
() => {
diff --git a/config/scripts/mobile-web-app-text-input-font-size-seam.mjs b/config/scripts/mobile-web-app-text-input-font-size-seam.mjs
index 77521f96bb3..5447f016081 100644
--- a/config/scripts/mobile-web-app-text-input-font-size-seam.mjs
+++ b/config/scripts/mobile-web-app-text-input-font-size-seam.mjs
@@ -8,7 +8,7 @@
* A rule per route closure rather than per known site: the first fix covered two inputs and eight
* others in the same closures still carried the old size.
*/
-import { existsSync, readFileSync } from 'node:fs'
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import ts from 'typescript-api'
@@ -28,13 +28,32 @@ function readOrNull(path) {
}
}
+/**
+ * The extensions a specifier is tried with, in the order the page bundle tries them.
+ *
+ * `.web` first, because that is what `resolveExtensions` in the builder does and therefore what a
+ * route closure is made of. A census that followed an import to the native sibling would be judging
+ * a module no browser loads, which fails in the direction that matters: a split whose web half sits
+ * under the floor reads as clean because its native half is on the seam.
+ */
+const RESOLVED_EXTENSIONS = [
+ '.web.tsx',
+ '.web.ts',
+ '.tsx',
+ '.ts',
+ '/index.web.tsx',
+ '/index.web.ts',
+ '/index.tsx',
+ '/index.ts'
+]
+
/** A relative specifier as a path under `mobile/`, or null for a package. */
function resolveLocal(mobileDir, fromFile, specifier) {
if (!specifier.startsWith('.')) {
return null
}
const base = resolve(dirname(join(mobileDir, fromFile)), specifier)
- for (const extension of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
+ for (const extension of RESOLVED_EXTENSIONS) {
if (existsSync(base + extension)) {
// Relative to the root rather than sliced by its length, which leaves a leading separator
// whenever the root is passed without a trailing one.
@@ -44,6 +63,17 @@ function resolveLocal(mobileDir, fromFile, specifier) {
return null
}
+/**
+ * A resolved path with its platform suffix dropped, so both siblings name one module.
+ *
+ * Needed because the seam is itself a split: `text-input-font-size.web.ts` is where the raise
+ * lives, so resolving an import of it now lands on the web file, and comparing that against the
+ * seam's native path would make every binding in the tree stop counting as the seam.
+ */
+function moduleIdentity(path) {
+ return path === null ? null : path.replace(/\.web(\.[jt]sx?)$/, '$1')
+}
+
/**
* The style expressions one `style` prop really applies, flattened.
*
@@ -93,6 +123,60 @@ function styleExpressions(expression) {
return [expression]
}
+/**
+ * Every non-test module under `directory` that renders a `TextInput`, as paths under `mobileDir`.
+ *
+ * Exported so a hand-written closure can state what it claims to cover and be held to it: an
+ * offender list over a list somebody typed proves the walk read those files, not that they are the
+ * screen's set. Uses the same JSX tag rule the walk below does, rather than a text search that
+ * would count an import or a comment.
+ */
+export function modulesDeclaringTextInput(mobileDir, directory) {
+ const found = []
+ const walk = (relativeDirectory) => {
+ const absolute = join(mobileDir, relativeDirectory)
+ if (!existsSync(absolute)) {
+ return
+ }
+ for (const entry of readdirSync(absolute, { withFileTypes: true })) {
+ const child = `${relativeDirectory}/${entry.name}`
+ if (entry.isDirectory()) {
+ walk(child)
+ continue
+ }
+ if (!/\.(tsx|ts)$/.test(entry.name) || /\.test\.(tsx|ts)$/.test(entry.name)) {
+ continue
+ }
+ const source = readOrNull(join(mobileDir, child))
+ if (source !== null && declaresTextInput(parse(child, source))) {
+ found.push(child)
+ }
+ }
+ }
+ walk(directory)
+ return found.sort()
+}
+
+/** Whether a parsed module renders a `TextInput` element, by tag rather than by mention. */
+function declaresTextInput(parsed) {
+ let found = false
+ const visit = (node) => {
+ if (found) {
+ return
+ }
+ if (
+ (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) &&
+ node.tagName.getText() === 'TextInput'
+ ) {
+ found = true
+ return
+ }
+ ts.forEachChild(node, visit)
+ }
+ ts.forEachChild(parsed, visit)
+ return found
+}
+
/**
* Every style one `TextInput` applies, as something the rule can answer for.
*
@@ -190,7 +274,8 @@ function isSeamBinding(mobileDir, parsed, file, initializer) {
if (
element.name.text === initializer.text &&
(element.propertyName ?? element.name).text === SEAM_EXPORT &&
- resolveLocal(mobileDir, file, statement.moduleSpecifier.text) === SEAM_MODULE
+ moduleIdentity(resolveLocal(mobileDir, file, statement.moduleSpecifier.text)) ===
+ SEAM_MODULE
) {
return true
}
diff --git a/mobile/src/browser/MobileBrowserAddressField.test.tsx b/mobile/src/browser/MobileBrowserAddressField.test.tsx
new file mode 100644
index 00000000000..a67ee2ea6ee
--- /dev/null
+++ b/mobile/src/browser/MobileBrowserAddressField.test.tsx
@@ -0,0 +1,66 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { TextInput } from 'react-native'
+import { MobileBrowserAddressField } from './MobileBrowserAddressField'
+
+// Hoisted with the mock factory, which runs before every module-level const in this file.
+const platform = vi.hoisted(() => ({ OS: 'ios' }))
+
+vi.mock('react-native', () => ({
+ Platform: platform,
+ StyleSheet: {
+ absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
+ create: (styles: unknown) => styles
+ },
+ Text: 'Text',
+ TextInput: 'TextInput',
+ View: 'View'
+}))
+
+function addressInput(os: string): Record {
+ platform.OS = os
+ let renderer: ReactTestRenderer | null = null
+ act(() => {
+ renderer = create(
+ createElement(MobileBrowserAddressField, {
+ disabled: false,
+ focused: false,
+ onBlur: () => {},
+ onChangeText: () => {},
+ onFocus: () => {},
+ onSubmit: () => {},
+ value: 'https://dashboard.example'
+ })
+ )
+ })
+ if (renderer === null) {
+ throw new Error('nothing mounted')
+ }
+ const mounted: ReactTestRenderer = renderer
+ // The imported component, not the string the mock stands it up as: a host-component string is
+ // not an `ElementType`, so `findByType('TextInput')` drops the file out of `tsc` and out of the
+ // tests-typecheck ratchet, and a pin that does not typecheck proves nothing.
+ return mounted.root.findByType(TextInput).props
+}
+
+afterEach(() => {
+ platform.OS = 'ios'
+})
+
+describe('the address field keyboard', () => {
+ // `keyboardType` is a native enum a browser does not read, so without this the page's address bar
+ // gets the plain keyboard and loses the slash and the .com key it has on a phone.
+ it('asks for the URL keyboard through inputMode on the web', () => {
+ expect(addressInput('web').inputMode).toBe('url')
+ })
+
+ // `inputMode` takes precedence over `keyboardType`, so anything other than undefined here would
+ // replace the iOS URL keyboard rather than add to it.
+ it('leaves inputMode unset on both native platforms', () => {
+ expect(addressInput('ios').inputMode).toBeUndefined()
+ expect(addressInput('ios').keyboardType).toBe('url')
+ expect(addressInput('android').inputMode).toBeUndefined()
+ expect(addressInput('android').keyboardType).toBe('default')
+ })
+})
diff --git a/mobile/src/browser/MobileBrowserAddressField.tsx b/mobile/src/browser/MobileBrowserAddressField.tsx
index ebb32110b3f..0e73a8ad0ea 100644
--- a/mobile/src/browser/MobileBrowserAddressField.tsx
+++ b/mobile/src/browser/MobileBrowserAddressField.tsx
@@ -1,5 +1,6 @@
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { colors, radii, spacing } from '../theme/mobile-theme'
+import { browserAddressFieldStyles } from './browser-address-field-styles'
import { compactMobileBrowserFileAddress } from './browser-url'
type Props = {
@@ -27,7 +28,7 @@ export function MobileBrowserAddressField({
return (
{fileLabel ? (
-
+
{fileLabel}
@@ -61,31 +70,11 @@ const styles = StyleSheet.create({
minWidth: 0,
height: 28
},
- input: {
- flex: 1,
- minWidth: 0,
- borderRadius: radii.input,
- backgroundColor: colors.bgRaised,
- color: colors.textPrimary,
- paddingHorizontal: spacing.sm,
- paddingVertical: 0,
- fontSize: 12,
- lineHeight: 16,
- includeFontPadding: false,
- textAlignVertical: 'center',
- fontFamily: typography.monoFamily
- },
fileLabelHost: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
paddingHorizontal: spacing.sm,
borderRadius: radii.input,
backgroundColor: colors.bgRaised
- },
- fileLabel: {
- color: colors.textPrimary,
- fontSize: 12,
- lineHeight: 16,
- fontFamily: typography.monoFamily
}
})
diff --git a/mobile/src/browser/browser-address-field-base-styles.ts b/mobile/src/browser/browser-address-field-base-styles.ts
new file mode 100644
index 00000000000..2824c276d0e
--- /dev/null
+++ b/mobile/src/browser/browser-address-field-base-styles.ts
@@ -0,0 +1,27 @@
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+/**
+ * The address bar's input and its overlaid label, minus the one thing that is a platform answer.
+ *
+ * Shared because a `.web.ts` cannot import a value from the file it shadows, and two copies of a
+ * style object is how the two platforms drift apart on everything except the difference that was
+ * meant to be between them.
+ */
+export const browserAddressFieldBase = {
+ input: {
+ flex: 1,
+ minWidth: 0,
+ borderRadius: radii.input,
+ backgroundColor: colors.bgRaised,
+ color: colors.textPrimary,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 0,
+ includeFontPadding: false,
+ textAlignVertical: 'center',
+ fontFamily: typography.monoFamily
+ },
+ fileLabel: {
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily
+ }
+} as const
diff --git a/mobile/src/browser/browser-address-field-styles.ts b/mobile/src/browser/browser-address-field-styles.ts
new file mode 100644
index 00000000000..04940189876
--- /dev/null
+++ b/mobile/src/browser/browser-address-field-styles.ts
@@ -0,0 +1,19 @@
+import { StyleSheet } from 'react-native'
+import { typography } from '../theme/mobile-theme'
+import { browserAddressFieldBase } from './browser-address-field-base-styles'
+
+/**
+ * Native: the address bar is a compact control and carries the theme's meta size, which is what it
+ * has always rendered at.
+ *
+ * The `.web.ts` sibling raises it, because in a browser an input under 16px zooms the page on focus
+ * and the page's keyboard seam reads that zoom as "no keyboard". The label and the input move
+ * together: the label is painted over the field whenever it is not focused, so a size that differed
+ * would resize the address on every focus.
+ */
+const ADDRESS_FONT_SIZE = typography.metaSize
+
+export const browserAddressFieldStyles = StyleSheet.create({
+ input: { ...browserAddressFieldBase.input, fontSize: ADDRESS_FONT_SIZE, lineHeight: 16 },
+ fileLabel: { ...browserAddressFieldBase.fileLabel, fontSize: ADDRESS_FONT_SIZE, lineHeight: 16 }
+})
diff --git a/mobile/src/browser/browser-address-field-styles.web.test.ts b/mobile/src/browser/browser-address-field-styles.web.test.ts
new file mode 100644
index 00000000000..a65e34550f3
--- /dev/null
+++ b/mobile/src/browser/browser-address-field-styles.web.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest'
+
+// StyleSheet.create is identity in React Native and on RN Web alike, and every other export of the
+// module reaches the native runtime this test does not have.
+vi.mock('react-native', () => ({
+ StyleSheet: { create: (styles: unknown) => styles }
+}))
+
+// The seam as the page bundle resolves it. Without this the `.web.ts` styles below would read the
+// native seam and the test would pass on a size that no browser ever renders.
+vi.mock(
+ '../platform/text-input-font-size',
+ async () => await import('../platform/text-input-font-size.web')
+)
+
+import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
+import { typography } from '../theme/mobile-theme'
+import { browserAddressFieldBase } from './browser-address-field-base-styles'
+import { browserAddressFieldStyles } from './browser-address-field-styles'
+import { browserAddressFieldStyles as browserAddressFieldStylesOnWeb } from './browser-address-field-styles.web'
+import { mobileBrowserPaneStyles } from './mobile-browser-pane-styles'
+
+/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */
+const IOS_FOCUS_ZOOM_FLOOR = 16
+
+describe('the browser pane text inputs on the web', () => {
+ it('raises the address field to the seam, above the focus-zoom floor', () => {
+ expect(browserAddressFieldStylesOnWeb.input.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
+ expect(browserAddressFieldStylesOnWeb.input.fontSize).toBeGreaterThanOrEqual(
+ IOS_FOCUS_ZOOM_FLOOR
+ )
+ // A raise rather than the same number twice: the native seam is the app's body size.
+ expect(TEXT_INPUT_FONT_SIZE).toBeGreaterThan(typography.bodySize)
+ })
+
+ it('raises the key row input to the same seam', () => {
+ expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
+ expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBeGreaterThanOrEqual(
+ IOS_FOCUS_ZOOM_FLOOR
+ )
+ })
+
+ it('keeps the overlaid label on the input size, so focus does not resize the address', () => {
+ expect(browserAddressFieldStylesOnWeb.fileLabel.fontSize).toBe(
+ browserAddressFieldStylesOnWeb.input.fontSize
+ )
+ expect(browserAddressFieldStylesOnWeb.fileLabel.lineHeight).toBe(
+ browserAddressFieldStylesOnWeb.input.lineHeight
+ )
+ })
+
+ it('gives the raised size a line box it fits in', () => {
+ expect(browserAddressFieldStylesOnWeb.input.lineHeight).toBeGreaterThanOrEqual(
+ browserAddressFieldStylesOnWeb.input.fontSize
+ )
+ })
+})
+
+describe('the browser address field natively', () => {
+ it('renders at exactly the size it did before the split', () => {
+ expect(browserAddressFieldStyles.input.fontSize).toBe(typography.metaSize)
+ expect(browserAddressFieldStyles.input.fontSize).toBe(12)
+ expect(browserAddressFieldStyles.input.lineHeight).toBe(16)
+ expect(browserAddressFieldStyles.fileLabel.fontSize).toBe(12)
+ expect(browserAddressFieldStyles.fileLabel.lineHeight).toBe(16)
+ })
+
+ // The split is one value, not a second style: everything the siblings do not differ on comes from
+ // the same object, so a padding or a colour cannot drift between the platforms.
+ it('differs from the web style in nothing but the size', () => {
+ expect(browserAddressFieldBase.input).not.toHaveProperty('fontSize')
+ expect(browserAddressFieldBase.input).not.toHaveProperty('lineHeight')
+ expect(browserAddressFieldStyles.input).toMatchObject(browserAddressFieldBase.input)
+ expect(browserAddressFieldStylesOnWeb.input).toMatchObject(browserAddressFieldBase.input)
+ expect(browserAddressFieldStyles.fileLabel).toMatchObject(browserAddressFieldBase.fileLabel)
+ expect(browserAddressFieldStylesOnWeb.fileLabel).toMatchObject(
+ browserAddressFieldBase.fileLabel
+ )
+ })
+})
diff --git a/mobile/src/browser/browser-address-field-styles.web.ts b/mobile/src/browser/browser-address-field-styles.web.ts
new file mode 100644
index 00000000000..585734380ba
--- /dev/null
+++ b/mobile/src/browser/browser-address-field-styles.web.ts
@@ -0,0 +1,30 @@
+import { StyleSheet } from 'react-native'
+import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
+import { browserAddressFieldBase } from './browser-address-field-base-styles'
+
+/**
+ * Web sibling: the address input goes on the text-input seam, which holds it at or above the size
+ * below which iOS zooms the page on focus.
+ *
+ * That zoom is not cosmetic here. `keyboard-occlusion.web.ts` reads a visual viewport scale other
+ * than 1 as "not a keyboard" and answers 0, so one focus of a 12px address bar would leave the
+ * pane's keyboard lift at 0 for the rest of the typing session — C4.2's failure, on a screen its
+ * census does not yet walk.
+ *
+ * The line height follows the size rather than staying at the native 16, which would clip a 16px
+ * glyph, and the file label follows both so the address does not resize as focus moves.
+ */
+const ADDRESS_LINE_HEIGHT = TEXT_INPUT_FONT_SIZE + 4
+
+export const browserAddressFieldStyles = StyleSheet.create({
+ input: {
+ ...browserAddressFieldBase.input,
+ fontSize: TEXT_INPUT_FONT_SIZE,
+ lineHeight: ADDRESS_LINE_HEIGHT
+ },
+ fileLabel: {
+ ...browserAddressFieldBase.fileLabel,
+ fontSize: TEXT_INPUT_FONT_SIZE,
+ lineHeight: ADDRESS_LINE_HEIGHT
+ }
+})
diff --git a/mobile/src/browser/browser-screencast-request-parameters.ts b/mobile/src/browser/browser-screencast-request-parameters.ts
new file mode 100644
index 00000000000..9603f0cd202
--- /dev/null
+++ b/mobile/src/browser/browser-screencast-request-parameters.ts
@@ -0,0 +1,94 @@
+import type { BrowserScreencastFormat } from '../transport/browser-screencast-protocol'
+
+/**
+ * Everything the pane asks a screencast for that is the same on every platform, and the assembly
+ * both `browser-screencast-request.ts` and its `.web.ts` sibling call.
+ *
+ * Separate from them because a `.web.ts` cannot import a value from the file it shadows — the
+ * bundler resolves the specifier back to the sibling itself — and the alternative is two copies of
+ * these constants drifting apart. Only the mobile view's device scale factor differs by platform,
+ * and it arrives as an argument.
+ */
+
+export type BrowserStreamLayout = {
+ width: number
+ height: number
+}
+
+export type MobileBrowserScreencastRequest = {
+ format: BrowserScreencastFormat
+ quality: number
+ maxWidth: number
+ maxHeight: number
+ viewportWidth?: number
+ viewportHeight?: number
+ deviceScaleFactor?: number
+ mobile?: boolean
+ everyNthFrame: number
+ minFrameIntervalMs: number
+}
+
+export type MobileBrowserViewMode = 'web' | 'mobile'
+
+export const BROWSER_FRAME_FORMAT: BrowserScreencastFormat = 'jpeg'
+export const BROWSER_FRAME_QUALITY = 72
+// Why: menus/popovers can be a single compositor update. Skipping CDP frames
+// can miss that final static state; time throttling below still caps throughput.
+const BROWSER_FRAME_EVERY_NTH_FRAME = 1
+export const MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS = 100
+const BROWSER_MIN_FRAME_WIDTH = 320
+const BROWSER_MIN_FRAME_HEIGHT = 240
+const BROWSER_MAX_FRAME_WIDTH = 2400
+const BROWSER_MAX_FRAME_HEIGHT = 2160
+const BROWSER_MAX_STREAM_SCALE = 2.5
+
+/** What a phone's mobile view asks for when nothing is bounding the frame it gets back. */
+export const MOBILE_VIEW_DEVICE_SCALE_FACTOR = 2
+
+export function assembleMobileBrowserScreencastRequest(
+ layout: BrowserStreamLayout | null,
+ pixelRatio: number,
+ viewMode: MobileBrowserViewMode,
+ mobileViewDeviceScaleFactor: number
+): MobileBrowserScreencastRequest | null {
+ if (!layout || layout.width <= 0 || layout.height <= 0) {
+ return null
+ }
+ // Why: mobile should improve image density without changing the desktop
+ // browser viewport. Sending viewport params puts Chromium in phone emulation.
+ const streamScale = clamp(
+ Math.min(Number.isFinite(pixelRatio) ? pixelRatio : 1, BROWSER_MAX_STREAM_SCALE),
+ 1,
+ BROWSER_MAX_STREAM_SCALE
+ )
+ return {
+ format: BROWSER_FRAME_FORMAT,
+ quality: BROWSER_FRAME_QUALITY,
+ maxWidth: clamp(
+ Math.round(layout.width * streamScale),
+ BROWSER_MIN_FRAME_WIDTH,
+ BROWSER_MAX_FRAME_WIDTH
+ ),
+ maxHeight: clamp(
+ Math.round(layout.height * streamScale),
+ BROWSER_MIN_FRAME_HEIGHT,
+ BROWSER_MAX_FRAME_HEIGHT
+ ),
+ everyNthFrame: BROWSER_FRAME_EVERY_NTH_FRAME,
+ minFrameIntervalMs: MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
+ ...(viewMode === 'mobile'
+ ? {
+ // Why: mobile view should trigger responsive CSS while matching the
+ // phone's measured browser area instead of a fixed device preset.
+ viewportWidth: Math.round(layout.width),
+ viewportHeight: Math.round(layout.height),
+ deviceScaleFactor: mobileViewDeviceScaleFactor,
+ mobile: true
+ }
+ : {})
+ }
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value))
+}
diff --git a/mobile/src/browser/browser-screencast-request.ts b/mobile/src/browser/browser-screencast-request.ts
index 3e1e85aff7a..5ec5f06dd49 100644
--- a/mobile/src/browser/browser-screencast-request.ts
+++ b/mobile/src/browser/browser-screencast-request.ts
@@ -1,81 +1,33 @@
-import type { BrowserScreencastFormat } from '../transport/browser-screencast-protocol'
+import {
+ assembleMobileBrowserScreencastRequest,
+ MOBILE_VIEW_DEVICE_SCALE_FACTOR,
+ type BrowserStreamLayout,
+ type MobileBrowserScreencastRequest,
+ type MobileBrowserViewMode
+} from './browser-screencast-request-parameters'
-export type BrowserStreamLayout = {
- width: number
- height: number
-}
-
-export type MobileBrowserScreencastRequest = {
- format: BrowserScreencastFormat
- quality: number
- maxWidth: number
- maxHeight: number
- viewportWidth?: number
- viewportHeight?: number
- deviceScaleFactor?: number
- mobile?: boolean
- everyNthFrame: number
- minFrameIntervalMs: number
-}
-
-export type MobileBrowserViewMode = 'web' | 'mobile'
-
-const BROWSER_FRAME_FORMAT: BrowserScreencastFormat = 'jpeg'
-const BROWSER_FRAME_QUALITY = 72
-// Why: menus/popovers can be a single compositor update. Skipping CDP frames
-// can miss that final static state; time throttling below still caps throughput.
-const BROWSER_FRAME_EVERY_NTH_FRAME = 1
-export const MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS = 100
-const BROWSER_MIN_FRAME_WIDTH = 320
-const BROWSER_MIN_FRAME_HEIGHT = 240
-const BROWSER_MAX_FRAME_WIDTH = 2400
-const BROWSER_MAX_FRAME_HEIGHT = 2160
-const BROWSER_MAX_STREAM_SCALE = 2.5
-const MOBILE_VIEW_DEVICE_SCALE_FACTOR = 2
+export { MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS } from './browser-screencast-request-parameters'
+export type {
+ BrowserStreamLayout,
+ MobileBrowserScreencastRequest,
+ MobileBrowserViewMode
+} from './browser-screencast-request-parameters'
+/**
+ * Native: nothing bounds one frame, so the mobile view asks for the density it wants.
+ *
+ * The socket delivers a screencast frame as its own message with no per-message ceiling above it.
+ * The `.web.ts` sibling has one, and budgets the area it asks for against it.
+ */
export function buildMobileBrowserScreencastRequest(
layout: BrowserStreamLayout | null,
pixelRatio: number,
viewMode: MobileBrowserViewMode = 'web'
): MobileBrowserScreencastRequest | null {
- if (!layout || layout.width <= 0 || layout.height <= 0) {
- return null
- }
- // Why: mobile should improve image density without changing the desktop
- // browser viewport. Sending viewport params puts Chromium in phone emulation.
- const streamScale = clamp(
- Math.min(Number.isFinite(pixelRatio) ? pixelRatio : 1, BROWSER_MAX_STREAM_SCALE),
- 1,
- BROWSER_MAX_STREAM_SCALE
+ return assembleMobileBrowserScreencastRequest(
+ layout,
+ pixelRatio,
+ viewMode,
+ MOBILE_VIEW_DEVICE_SCALE_FACTOR
)
- return {
- format: BROWSER_FRAME_FORMAT,
- quality: BROWSER_FRAME_QUALITY,
- maxWidth: clamp(
- Math.round(layout.width * streamScale),
- BROWSER_MIN_FRAME_WIDTH,
- BROWSER_MAX_FRAME_WIDTH
- ),
- maxHeight: clamp(
- Math.round(layout.height * streamScale),
- BROWSER_MIN_FRAME_HEIGHT,
- BROWSER_MAX_FRAME_HEIGHT
- ),
- everyNthFrame: BROWSER_FRAME_EVERY_NTH_FRAME,
- minFrameIntervalMs: MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
- ...(viewMode === 'mobile'
- ? {
- // Why: mobile view should trigger responsive CSS while matching the
- // phone's measured browser area instead of a fixed device preset.
- viewportWidth: Math.round(layout.width),
- viewportHeight: Math.round(layout.height),
- deviceScaleFactor: MOBILE_VIEW_DEVICE_SCALE_FACTOR,
- mobile: true
- }
- : {})
- }
-}
-
-function clamp(value: number, min: number, max: number): number {
- return Math.max(min, Math.min(max, value))
}
diff --git a/mobile/src/browser/browser-screencast-request.web.test.ts b/mobile/src/browser/browser-screencast-request.web.test.ts
new file mode 100644
index 00000000000..5b1b334a5b6
--- /dev/null
+++ b/mobile/src/browser/browser-screencast-request.web.test.ts
@@ -0,0 +1,208 @@
+import { describe, expect, it } from 'vitest'
+import { BRIDGE_MAX_MESSAGE_BYTES } from '../mobile-web-shell/bridge/bridge-caps'
+import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-envelope'
+import { METADATA_KEYS } from '../transport/browser-screencast-protocol'
+import { buildMobileBrowserScreencastRequest } from './browser-screencast-request'
+import {
+ BASE64_BYTES_PER_CHARACTER,
+ base64Characters,
+ binaryEventEnvelopeBytes,
+ budgetedMobileViewDeviceScaleFactor,
+ buildMobileBrowserScreencastRequest as buildOnWeb,
+ mobileBrowserFrameAreaBudget,
+ WORST_CASE_JPEG_BYTES_PER_PIXEL
+} from './browser-screencast-request.web'
+
+/** A phone's measured browser area, in CSS pixels. */
+const PHONE = { width: 390, height: 712 }
+
+/** What the shell would have to post for a frame of this area at the worst case JPEG has. */
+function frameMessageBytes(areaPixels: number): number {
+ const imageBytes = Math.ceil(areaPixels * WORST_CASE_JPEG_BYTES_PER_PIXEL)
+ return base64Characters(imageBytes) + binaryEventEnvelopeBytes()
+}
+
+function mobileFrameArea(
+ layout: { width: number; height: number },
+ request: { deviceScaleFactor?: number } | null
+): number {
+ const scale = request?.deviceScaleFactor
+ if (scale === undefined) {
+ throw new Error('the request carried no device scale factor')
+ }
+ return Math.round(layout.width) * Math.round(layout.height) * scale * scale
+}
+
+describe('the mobile-view area budget', () => {
+ // The control, and the reason the budget exists: what the native request asks for on this same
+ // phone does not fit in one bridge message, and the shell would answer it with a dropped frame.
+ it('is needed: the native request overflows the frame cap on a phone', () => {
+ const native = buildMobileBrowserScreencastRequest(PHONE, 2, 'mobile')
+
+ expect(native?.deviceScaleFactor).toBe(2)
+ expect(frameMessageBytes(mobileFrameArea(PHONE, native))).toBeGreaterThan(
+ BRIDGE_MAX_MESSAGE_BYTES
+ )
+ })
+
+ it('keeps the worst case frame inside one bridge message', () => {
+ const budgeted = buildOnWeb(PHONE, 2, 'mobile')
+
+ expect(frameMessageBytes(mobileFrameArea(PHONE, budgeted))).toBeLessThanOrEqual(
+ BRIDGE_MAX_MESSAGE_BYTES
+ )
+ })
+
+ // Derived from the same constants the module derives from, not written down: a budget the test
+ // restates is a budget that agrees with itself and with nothing else.
+ it('asks for the scale the cap and the worst case together allow', () => {
+ const expected = Math.sqrt(mobileBrowserFrameAreaBudget() / (PHONE.width * PHONE.height))
+
+ const scale = budgetedMobileViewDeviceScaleFactor(PHONE)
+
+ expect(scale).toBeLessThanOrEqual(expected)
+ // Floored to two decimals, so it is the largest such scale rather than merely a safe one.
+ expect(scale).toBeGreaterThan(expected - 0.01)
+ })
+
+ it('leaves web mode byte-identical to the native request', () => {
+ expect(buildOnWeb(PHONE, 2, 'web')).toEqual(
+ buildMobileBrowserScreencastRequest(PHONE, 2, 'web')
+ )
+ expect(buildOnWeb(PHONE, 2)).toEqual(buildMobileBrowserScreencastRequest(PHONE, 2))
+ })
+
+ it('never asks for more density than native, on a viewport the budget does not bind', () => {
+ expect(budgetedMobileViewDeviceScaleFactor({ width: 200, height: 200 })).toBe(2)
+ })
+
+ it('stops at one device pixel per CSS pixel on a viewport no scale would fit', () => {
+ // Past this the page would be asking for a blurrier frame than its own layout; a frame that
+ // still does not fit is C6 ruling 1's to drop.
+ expect(budgetedMobileViewDeviceScaleFactor({ width: 2000, height: 1400 })).toBe(1)
+ })
+
+ it('answers the native factor when there is no layout to budget against', () => {
+ expect(budgetedMobileViewDeviceScaleFactor(null)).toBe(2)
+ expect(buildOnWeb(null, 2, 'mobile')).toBeNull()
+ expect(buildOnWeb({ width: 0, height: 712 }, 2, 'mobile')).toBeNull()
+ })
+})
+
+describe('the budget derivation', () => {
+ it('spends the whole cap that the envelope leaves', () => {
+ const available = BRIDGE_MAX_MESSAGE_BYTES - binaryEventEnvelopeBytes()
+ const expectedArea = Math.floor(
+ (Math.floor(available / 4) * 3) / WORST_CASE_JPEG_BYTES_PER_PIXEL
+ )
+
+ expect(mobileBrowserFrameAreaBudget()).toBe(expectedArea)
+ // The ratio alone can claim two bytes base64 does not have, which at this margin is a frame
+ // the shell drops rather than sends.
+ expect(Math.floor(available / 4) * 3).toBeLessThanOrEqual(
+ Math.floor(available * BASE64_BYTES_PER_CHARACTER)
+ )
+ })
+
+ /**
+ * Why the budget counts whole base64 groups rather than three quarters of the room.
+ *
+ * The two agree at today's envelope size, because the room it leaves happens to divide by four.
+ * They do not agree in general, and the budget now spends the cap exactly, so the first envelope
+ * change that lands on another remainder would hand the shell a frame two characters over.
+ */
+ it('counts base64 padding, which the ratio does not for every image size', () => {
+ expect(base64Characters(3001)).toBe(4004)
+ expect(Math.ceil(3001 / BASE64_BYTES_PER_CHARACTER)).toBe(4002)
+ // Same statement from the budget's side: room for 4002 characters is not room for 3001 bytes.
+ expect(Math.floor(4002 / 4) * 3).toBeLessThan(Math.floor(4002 * BASE64_BYTES_PER_CHARACTER))
+ })
+
+ // The budget spends the cap exactly, so anything the expansion under-counts is a dropped frame.
+ it('leaves a frame of exactly the budgeted area inside the cap, padding included', () => {
+ expect(frameMessageBytes(mobileBrowserFrameAreaBudget())).toBeLessThanOrEqual(
+ BRIDGE_MAX_MESSAGE_BYTES
+ )
+ })
+
+ it('measures the envelope rather than naming a number, and leaves it room to grow', () => {
+ const envelope = binaryEventEnvelopeBytes()
+
+ // Big enough to be the real shape, small enough that the budget is not swallowed by it.
+ expect(envelope).toBeGreaterThan(200)
+ expect(envelope).toBeLessThan(BRIDGE_MAX_MESSAGE_BYTES / 100)
+ })
+})
+
+/**
+ * The widest a finite double serializes as: sign, `0.`, the five zeros fixed notation writes just
+ * above 1e-6, and seventeen significant digits. Exponential form is one shorter, because ToString
+ * only leaves fixed notation below 1e-6.
+ */
+const WIDEST_JSON_DOUBLE = -0.000001234567890123456_7
+
+/** A frame event with nothing in it but the widest every field can be, and no image. */
+function widestEnvelope(metadataValue: number): string {
+ return JSON.stringify({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'event',
+ id: 'a'.repeat(22),
+ seq: Number.MAX_SAFE_INTEGER,
+ binary: {
+ b64: '',
+ format: 'jpeg',
+ frameSeq: Number.MAX_SAFE_INTEGER,
+ metadata: Object.fromEntries(METADATA_KEYS.map((key) => [key, metadataValue]))
+ }
+ })
+}
+
+describe('the envelope bound', () => {
+ // The budget's whole margin is whatever this over-estimates by, so an under-estimate is a frame
+ // over the cap rather than a rounding difference.
+ it('is at least what a real frame event costs at its widest', () => {
+ expect(binaryEventEnvelopeBytes()).toBeGreaterThanOrEqual(
+ widestEnvelope(WIDEST_JSON_DOUBLE).length
+ )
+ })
+
+ it('is a bound on every finite double, not on the one it was written with', () => {
+ // Deterministic rather than seeded off the clock: a bound that fails one run in a thousand is
+ // a bound nobody believes. Bit patterns for the exponential forms, then the band just above
+ // 1e-6 where fixed notation is widest.
+ const bits = new ArrayBuffer(8)
+ const asDouble = new Float64Array(bits)
+ const asBits = new BigUint64Array(bits)
+ let state = 0x9e37_79b9n
+ const next = (): bigint => {
+ state =
+ (state * 6_364_136_223_846_793_005n + 1_442_695_040_888_963_407n) & 0xffff_ffff_ffff_ffffn
+ return state
+ }
+ let widest = 0
+ for (let index = 0; index < 200_000; index += 1) {
+ asBits[0] = next()
+ const value = asDouble[0]
+ if (Number.isFinite(value)) {
+ widest = Math.max(widest, JSON.stringify(value).length)
+ }
+ const nearTheBoundary = -(1e-6 + Number(next() % 9_000_000n) * 1e-12)
+ widest = Math.max(widest, JSON.stringify(nearTheBoundary).length)
+ }
+
+ expect(widest).toBe(JSON.stringify(WIDEST_JSON_DOUBLE).length)
+ expect(binaryEventEnvelopeBytes()).toBeGreaterThanOrEqual(
+ widestEnvelope(WIDEST_JSON_DOUBLE).length
+ )
+ })
+
+ // Not the other way round either: an envelope bound big enough to be safe and loose enough to
+ // cost real pixels is a budget nobody can reason about.
+ it('is not loose: it is within a metadata field of what that costs', () => {
+ const widest = widestEnvelope(WIDEST_JSON_DOUBLE).length
+
+ expect(binaryEventEnvelopeBytes() - widest).toBeLessThan(
+ JSON.stringify(WIDEST_JSON_DOUBLE).length
+ )
+ })
+})
diff --git a/mobile/src/browser/browser-screencast-request.web.ts b/mobile/src/browser/browser-screencast-request.web.ts
new file mode 100644
index 00000000000..f9e34cd34c4
--- /dev/null
+++ b/mobile/src/browser/browser-screencast-request.web.ts
@@ -0,0 +1,134 @@
+import { BRIDGE_MAX_MESSAGE_BYTES } from '../mobile-web-shell/bridge/bridge-caps'
+import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-envelope'
+import { METADATA_KEYS } from '../transport/browser-screencast-protocol'
+import {
+ assembleMobileBrowserScreencastRequest,
+ MOBILE_VIEW_DEVICE_SCALE_FACTOR,
+ type BrowserStreamLayout,
+ type MobileBrowserScreencastRequest,
+ type MobileBrowserViewMode
+} from './browser-screencast-request-parameters'
+
+export { MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS } from './browser-screencast-request-parameters'
+export type {
+ BrowserStreamLayout,
+ MobileBrowserScreencastRequest,
+ MobileBrowserViewMode
+} from './browser-screencast-request-parameters'
+
+/**
+ * The bytes one pixel of this pane's JPEG costs at its worst.
+ *
+ * Measured on this lane's fixtures at quality 72: uniform random noise, which is the image JPEG
+ * compresses least and the ceiling every real page sits under, encoded at 0.545 bytes per pixel.
+ * Photographic content measured near a tenth of that. The number is the worst case rather than a
+ * typical one because it is the one a budget has to survive.
+ */
+export const WORST_CASE_JPEG_BYTES_PER_PIXEL = 0.545
+
+/** Base64 carries three bytes in four characters, and a character is one UTF-8 byte here. */
+export const BASE64_BYTES_PER_CHARACTER = 3 / 4
+
+/** The characters base64 spends on `byteLength` bytes, padded up to a whole group as it always is. */
+export function base64Characters(byteLength: number): number {
+ return 4 * Math.ceil(byteLength / 3)
+}
+
+/**
+ * The widest `JSON.stringify` of a finite double.
+ *
+ * Sign, `0.`, the five zeros fixed notation writes just above 1e-6, and seventeen significant
+ * digits: `-0.0000012345678901234567`. Exponential form is one character shorter, because ToString
+ * only leaves fixed notation below 1e-6, so this covers both.
+ *
+ * It has to be an upper bound rather than a plausible width. The budget's entire margin is what
+ * this over-estimates by, so a metadata field wider than assumed is a frame over the cap.
+ */
+const WIDEST_JSON_DOUBLE_CHARS = 25
+
+/** `JSON.stringify(0)`, which is what the skeleton below spends per metadata field before widening. */
+const NARROWEST_JSON_DOUBLE_CHARS = 1
+
+/**
+ * An upper bound on what the frame's envelope costs, derived rather than typed, so the budget
+ * cannot drift from the shape the shell actually sends.
+ *
+ * Everything but the image at its widest: a full-length correlation id, both sequence counters at
+ * the largest integer they can hold, and every named metadata field present and as wide as a double
+ * can print. The field list comes from the protocol module rather than a copy of it, so a tenth
+ * field cannot be added to frames without being paid for here.
+ *
+ * Two things it does not cover, both of them C6 ruling 1's to drop rather than this budget's to
+ * predict: the metadata object is a loose one, so a shell may send keys this list has never heard
+ * of, and web view mode's frame is a letterboxed desktop viewport the page cannot size.
+ *
+ * Pinning this against C6.1's real encoder belongs to C6.5, once the encoder and this are both on
+ * main; until then the bound is checked against a serialized envelope of the same shape.
+ */
+export function binaryEventEnvelopeBytes(): number {
+ const skeleton = JSON.stringify({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'event',
+ id: 'a'.repeat(22),
+ seq: Number.MAX_SAFE_INTEGER,
+ binary: {
+ b64: '',
+ format: 'jpeg',
+ frameSeq: Number.MAX_SAFE_INTEGER,
+ metadata: Object.fromEntries(METADATA_KEYS.map((key) => [key, 0]))
+ }
+ }).length
+ return skeleton + METADATA_KEYS.length * (WIDEST_JSON_DOUBLE_CHARS - NARROWEST_JSON_DOUBLE_CHARS)
+}
+
+/**
+ * The frame area one bridge message can carry, in device pixels.
+ *
+ * The cap, less what the envelope costs, is the base64 the image may occupy; three quarters of that
+ * is the JPEG; divided by the worst case a pixel costs, it is an area. Computed from the cap rather
+ * than written down beside it, because a cap that moves and a budget that does not is a pane that
+ * goes dark on a page it could have streamed.
+ */
+export function mobileBrowserFrameAreaBudget(): number {
+ const available = BRIDGE_MAX_MESSAGE_BYTES - binaryEventEnvelopeBytes()
+ // Whole base64 groups, not three quarters of the room: an image of 3k+1 bytes costs two
+ // characters more than the ratio says, which at a margin this tight is a dropped frame.
+ const imageBytes = Math.floor(available / 4) * 3
+ return Math.floor(imageBytes / WORST_CASE_JPEG_BYTES_PER_PIXEL)
+}
+
+/**
+ * The device scale factor the mobile view may ask for and stay inside one message.
+ *
+ * Mobile view is the one mode where the page knows the frame exactly: it names the viewport, so the
+ * frame is that viewport times this factor squared. Web mode is a desktop viewport letterboxed into
+ * `maxWidth`/`maxHeight`, which the page cannot predict, so it is left alone and C6 ruling 1's
+ * drop-the-over-cap-frame rule is its only protection.
+ *
+ * Floored to two decimals so rounding cannot push the area back over the budget, and never above
+ * what native asks for: this bounds density, it does not raise it. Never below 1 either — past that
+ * the page would be asking for fewer device pixels than it has CSS pixels, which is a blurry frame
+ * rather than a working one, and a frame that still does not fit is ruling 1's to drop.
+ */
+export function budgetedMobileViewDeviceScaleFactor(layout: BrowserStreamLayout | null): number {
+ if (!layout || layout.width <= 0 || layout.height <= 0) {
+ return MOBILE_VIEW_DEVICE_SCALE_FACTOR
+ }
+ const viewportArea = Math.round(layout.width) * Math.round(layout.height)
+ const budgeted = Math.sqrt(mobileBrowserFrameAreaBudget() / viewportArea)
+ return Math.max(1, Math.min(MOBILE_VIEW_DEVICE_SCALE_FACTOR, Math.floor(budgeted * 100) / 100))
+}
+
+/** Web sibling: the same request, with the mobile view's density held inside the frame cap. */
+export function buildMobileBrowserScreencastRequest(
+ layout: BrowserStreamLayout | null,
+ pixelRatio: number,
+ viewMode: MobileBrowserViewMode = 'web'
+): MobileBrowserScreencastRequest | null {
+ return assembleMobileBrowserScreencastRequest(
+ layout,
+ pixelRatio,
+ viewMode,
+ budgetedMobileViewDeviceScaleFactor(layout)
+ )
+}
diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts
index b41428516ba..4d417f505a4 100644
--- a/mobile/src/browser/mobile-browser-pane-styles.ts
+++ b/mobile/src/browser/mobile-browser-pane-styles.ts
@@ -1,4 +1,5 @@
import { StyleSheet } from 'react-native'
+import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const mobileBrowserPaneStyles = StyleSheet.create({
@@ -150,7 +151,10 @@ export const mobileBrowserPaneStyles = StyleSheet.create({
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
- fontSize: 14,
+ // The seam, not the 14 it was: on the web an input under 16px zooms the page on focus and the
+ // keyboard seam reads that zoom as "no keyboard". Natively the seam is the theme's body size,
+ // which is what this already rendered at.
+ fontSize: TEXT_INPUT_FONT_SIZE,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
diff --git a/mobile/src/platform/text-input-font-size.test.ts b/mobile/src/platform/text-input-font-size.test.ts
index 89ce1494f28..09f1f60967b 100644
--- a/mobile/src/platform/text-input-font-size.test.ts
+++ b/mobile/src/platform/text-input-font-size.test.ts
@@ -11,6 +11,8 @@ vi.mock('react-native', () => ({
}
}))
+import { browserAddressFieldStyles } from '../browser/browser-address-field-styles'
+import { mobileBrowserPaneStyles } from '../browser/mobile-browser-pane-styles'
import { listStyles } from '../source-control/mobile-source-control-list-styles'
import { mobileDiffReviewControlStyles } from '../components/mobile-diff-review-control-styles'
import { typography } from '../theme/mobile-theme'
@@ -28,9 +30,22 @@ import { TEXT_INPUT_FONT_SIZE as WEB_TEXT_INPUT_FONT_SIZE } from './text-input-f
const MOBILE_ROOT = join(import.meta.dirname, '..', '..')
const STYLE_MODULES = [
'src/source-control/mobile-source-control-list-styles.ts',
- 'src/components/mobile-diff-review-control-styles.ts'
+ 'src/components/mobile-diff-review-control-styles.ts',
+ 'src/browser/mobile-browser-pane-styles.ts'
]
+/**
+ * The inputs that reach the seam through a `.web.ts` sibling instead of directly.
+ *
+ * The browser pane's address bar renders at the theme's meta size natively, so it cannot take the
+ * seam's value on both platforms the way the four above do. Its web half is where the raise lives,
+ * and that is the file that has to carry the binding.
+ */
+const SPLIT_STYLE_MODULES = ['src/browser/browser-address-field-styles.web.ts']
+
+/** The seam's export, so the source check below looks for a binding rather than for a mention. */
+const SEAM_EXPORT_NAME = 'TEXT_INPUT_FONT_SIZE'
+
describe('the font size the page-served text inputs carry', () => {
it('clears the size iOS zooms the page for, on the web', () => {
// 16 is the floor; below it a focus zooms the document and the keyboard seam, which reads a
@@ -42,15 +57,21 @@ describe('the font size the page-served text inputs carry', () => {
expect(TEXT_INPUT_FONT_SIZE).toBe(typography.bodySize)
expect(listStyles.commitInput.fontSize).toBe(typography.bodySize)
expect(mobileDiffReviewControlStyles.composerInput.fontSize).toBe(typography.bodySize)
+ expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBe(typography.bodySize)
+ // The pane's address bar is the one that is split: it keeps the compact size natively, so the
+ // seam reaches it through the `.web.ts` sibling rather than through this constant.
+ expect(browserAddressFieldStyles.input.fontSize).toBe(typography.metaSize)
})
- it('takes that size from the seam in both styles, which is what the web build swaps', () => {
- // Read as source: both modules resolve to the native constant here, so a style that went back
- // to `typography.bodySize` would pass every assertion above and ship 14px to the web.
+ it('takes that size from the seam in every style, which is what the web build swaps', () => {
+ // Read as source, and at the property rather than anywhere in the file: every one of these
+ // modules resolves to the native constant here, so a style that went back to a literal 14 or
+ // to `typography.bodySize` would pass every assertion above and ship 14px to the web. A
+ // file-wide search would not see it either, because the import line survives the change.
expect(
- STYLE_MODULES.filter(
+ [...STYLE_MODULES, ...SPLIT_STYLE_MODULES].filter(
(module) =>
- !readFileSync(join(MOBILE_ROOT, module), 'utf8').includes('TEXT_INPUT_FONT_SIZE')
+ !readFileSync(join(MOBILE_ROOT, module), 'utf8').includes(`fontSize: ${SEAM_EXPORT_NAME}`)
)
).toEqual([])
})
diff --git a/mobile/src/transport/browser-screencast-protocol.ts b/mobile/src/transport/browser-screencast-protocol.ts
index e3d3254560d..4dff973905d 100644
--- a/mobile/src/transport/browser-screencast-protocol.ts
+++ b/mobile/src/transport/browser-screencast-protocol.ts
@@ -1,7 +1,11 @@
const BROWSER_SCREENCAST_KIND = 0x62
const BROWSER_SCREENCAST_VERSION = 1
const HEADER_BYTES = 16
-const METADATA_KEYS = [
+/**
+ * Every metadata field a frame carries, exported because the page's frame-area budget sizes the
+ * envelope from this list rather than from a copy of it.
+ */
+export const METADATA_KEYS = [
'offsetTop',
'pageScaleFactor',
'deviceWidth',
diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json
index 0725c369fca..cda64e312bf 100644
--- a/mobile/web-entry/web-overrides.json
+++ b/mobile/web-entry/web-overrides.json
@@ -80,6 +80,14 @@
{
"file": "src/browser/use-browser-binary-screencast-grant.web.ts",
"reason": "Natively the socket carries the binary screencast frame and this app is both halves of that path, so there is nothing to negotiate. In the page the frames come through a shell that may predate the encoder, and subscribing with wantsBinary against one leaves the pane on a stream no frame can arrive on. This file asks the shell through the grants init carried, per C6 ruling 5."
+ },
+ {
+ "file": "src/browser/browser-screencast-request.web.ts",
+ "reason": "Not an RN Web API gap but a transport one that exists only in the page: a screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES, and a phone's mobile view at the native device scale factor produces a worst-case JPEG larger than that. This file budgets the mobile view's area against the cap, the envelope it measures rather than names, and one worst-case bytes-per-pixel constant. Web view mode is untouched, because a letterboxed desktop viewport is not an area the page can predict."
+ },
+ {
+ "file": "src/browser/browser-address-field-styles.web.ts",
+ "reason": "The address bar renders at the theme's 12px meta size, and in a browser an input under 16px makes iOS zoom the page on focus and never zoom back. keyboard-occlusion.web.ts reads that scale as 'no keyboard' and answers 0, so one focus would stop the pane lifting for the rest of the typing session. This file puts the input and its overlaid label on TEXT_INPUT_FONT_SIZE with a line box to match; the native sibling keeps 12px, which is what a phone has always rendered and where no page can zoom."
}
]
}