mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(mobile): budget the browser pane's frames and put its inputs on the web seam (OTA phase C, C6.3+C6.4) (#21760)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) The pane's frame path is written against React Native's native-prop writer, which does not exist on React Native Web: a ref there is the DOM node, so both writes throw and the pane never shows a frame. Three `.web.ts` siblings, each for a measured gap. - The image and layer writes move out of `mobile-browser-frame-state.ts` into `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image` on the element RN Web sizes and flips the double buffer with one opacity write per layer. The pane still never re-renders while it streams. - A `background-image` write fires no load event, so the offscreen layer would never become visible. The sibling arms the flip from an image decode instead, and the flip itself is shared with the native `onLoad` path rather than written twice. - The data URI keeps the base64 the bridge already carried instead of encoding the bytes back into the same string. Measured in this tree against the `buffer` shim the page bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against under a microsecond for the carried string. Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and renders its existing stream-error state otherwise, so a page never waits on frames a shell without the encoder cannot send. The grant name is a placeholder until C6.1 reports it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): budget the mobile view's frame area against the bridge cap (OTA phase C, C6.3) A screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES. Measured here: the phone's mobile view at the native device scale factor asks for a 390x712 viewport at 2x, which is 1,110,720 device pixels, and at the worst case JPEG has at quality 72 that is a 807,559-byte message against a 655,360-byte cap — 123% of it. The `.web.ts` sibling holds the mobile view inside that. The budget is computed rather than written down: the cap, less an envelope this measures from the frame's own shape at its widest (435 bytes), is what the base64 may occupy; three quarters of that is the JPEG; divided by one named worst-case constant of 0.545 bytes per pixel it is an area of 901,271 pixels. The phone lands on a device scale factor of 1.80 and a 654,205-byte message, 99.8% of the cap. A cap that moved and a budget that did not would be a pane going dark on a page it could have streamed. Web view mode is untouched, and byte-identical to the native request: there the frame is a desktop viewport letterboxed into maxWidth/maxHeight, which the page cannot predict, so C6 ruling 1's drop-the-over-cap-frame rule is its only protection. Native is unchanged. The constants and the assembly move to a third module because a `.web.ts` cannot import a value from the file it shadows — the bundler resolves the specifier back to the sibling itself — and two copies of them would drift. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): put the browser pane's text inputs on the web font-size seam (OTA phase C, C6.4) The pane has two text inputs, the address bar at 12px and the key row's "Type on page…" at 14px, and neither went through TEXT_INPUT_FONT_SIZE. In a browser an input under 16px makes iOS zoom the page on focus and never zoom back, and keyboard-occlusion.web.ts reads a visual viewport scale other than 1 as "no keyboard" — so one focus would leave the pane's keyboard lift at 0 for the rest of the typing session. C4.2's failure exactly, on a screen its census does not walk: that census walks the source-control hub and the review route, and the pane is in neither until C7 lists a route that mounts it. The key row's input goes straight onto the seam, whose native value is the theme's body size, so it renders at the 14px it already did. The address bar is a `.web.ts` split instead, so native keeps the 12px meta size it has always shown; the input and the label painted over it move together, or the address would resize on every focus. The address bar also gets inputMode="url" on the web only. keyboardType is a native enum a browser does not read, so the page's address bar was falling back to a plain keyboard; inputMode takes precedence over keyboardType, so it stays undefined on both native platforms. One consequence recorded rather than fixed, and pinned in config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs: the C4.2 census resolves an import through .ts/.tsx only, never .web.ts, so it reads the native address style that no browser loads and reports it as an offender. Whoever lists the pane's route either teaches resolveLocal the extensions the builder already prefers, or moves the address bar onto the seam natively at 14px. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2) C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the placeholder this PR landed with while C6.1 was still choosing. The placeholder was also unusable, which the test added here would have caught: `GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed verb and nothing else, so a route declaring `browser.screencast.binary` would have been refused by the bundle before any shell saw it, and the pane would have taken its stream-error branch for a reason no screen could report. The name is now checked against `MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted spelling as the failing case beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the address field test inside the typecheck ratchet (OTA phase C, C6.4) `findByType('TextInput')` does not typecheck: a host-component string is not an `ElementType`, so the file fell out of `tsc -p tsconfig.test.json` and the tests-typecheck ratchet reported it. Found by reading the ratchet's exit code rather than its piped tail, which is how it was missed the first time. The element is looked up by its placeholder instead, and the ratchet is green with 732 test files in the program. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2) Round 1 folds on #21754. The undecodable arm freed the pending slot without checking whose frame had failed, while the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame 3, then frame 3 decodes and the flip is refused because the slot no longer names its layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has gone still sends no further frame to recover with. Web only; native never calls this. Both arms now answer for the frame they were armed with. The displayable arm's own guard had no test: deleting it left `src/browser/` and the full suite green, because the case that exercised it settled both decodes and asserted an end state both orders produce. The harness now settles one decode at a time, keyed on the source it was given, and the ordered case reds without the guard. Also: the paint sibling's opacity test claimed "no re-render" while asserting two style strings, so it is named for what it checks and the claim is counted where React is — across ten streamed frames the three state setters are called once each, on the mount frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so two pre-existing reasons keep their literal em dash instead of a re-serialized escape. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the address input by its imported type (OTA phase C, C6.4) `findByType('TextInput')` does not typecheck — a host-component string is not an `ElementType` — so the file dropped out of `tsc -p tsconfig.test.json` and the tests-typecheck ratchet reported it. The imported component is what the element is looked up by now; the react-native mock stands it up as that same string at runtime, so the lookup is unchanged and the file is back inside the ratchet's program. Why the earlier run reported 0: the command was `node scripts/check-tests-typecheck-ratchet.mjs 2>&1 | tail -2; echo $?`, and `$?` after a pipeline is the exit code of `tail`, which is always 0. The banner line that printed was the last line of the failure banner, not the success one. Every gate in this branch's report is now read from the command itself, unpiped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(scripts): read a text input's size from the file the page loads (OTA phase C, C6.4) The census followed an import through `.ts`/`.tsx` only, while the closure it walks comes from esbuild, which prefers `.web.tsx`/`.web.ts`. So a style module with a platform sibling was judged on the half no browser loads. That fails in the direction that matters: a split whose web half sits under the focus-zoom floor reads as clean because its native half is on the seam, which is the exact shape the seam exists to catch. `resolveLocal` now tries the extensions in the builder's own order. The seam comparison collapses a resolved path onto its module identity before matching, because the seam is itself a split — `text-input-font-size.web.ts` is where the raise lives — and without that every binding in the tree would stop naming the seam: deleting it reds both C4 route closures. The browser pane's own census flips from pinning its address field as an offender to expecting none. The two C4 route closures still answer 0 offenders and 0 unresolved, run with the closure tests enabled. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the frame envelope above every double it can carry (OTA phase C, C6.4) Round 1 folds on #21760. The envelope estimate serialized each metadata field as a 16-character double, so the bound was 435 where a real frame event at its widest measures 516. Since the budget spends whatever the cap leaves, that 81-byte shortfall was the whole margin: a frame on the budget would have gone over the cap and been dropped. The bound is now the skeleton plus the widest a double can print, for the nine metadata keys imported from the protocol module rather than copied, so a tenth field cannot be added without being paid for. Two corrections to the fold as written, both measured here. The widest is 25 characters, not 24. Exponential form tops out at 24 (`-1.7976931348623157e+308`), but ToString only leaves fixed notation below 1e-6, and just above it a double prints as sign, `0.`, five zeros and seventeen digits: `-0.0000012345678901234567`. A sweep over four million random bit patterns found 25 and nothing longer; a deterministic sweep over both forms is in the test. And the base64 expansion has to count padding. Three quarters of the room claims up to two characters base64 does not have for an image of 3k+1 bytes, which at a margin the budget now spends exactly is a dropped frame. The two agree at today's envelope size because the room happens to divide by four, so this is a latent fix rather than a reproduced one, and the test pins the discrepancy directly instead of implying it. Budget moves from 901,271 to 901,161 pixels; the phone's mobile view stays at a device scale factor of 1.80. Pinning the bound against C6.1's real encoder is C6.5's, once both are on main; the docstring says so, and says what the bound does not cover: the metadata object is loose, so unknown keys and web view mode's letterboxed frame are ruling 1's to drop rather than this budget's to predict. Also: the pane census listed its own closure by hand, so "no unresolved styles" said the walk read those files, not that they are the pane's set. It now scans `src/browser` for every non-test module that renders a `TextInput` and asserts the list matches; a third module planted there reds it. And the seam's native-consumer pin names the key row and the address bar directly rather than transitively, matched at the `fontSize` property instead of anywhere in the file — a file-wide search survives the change, because the import line does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2) Round 2 folds on #21754. Every test for the web paint sibling handed it a `div > div` of its own making, so the assumption it rests on — that the host's first element child is the one carrying the frame — was only ever checked against a shape written to match it. React Native Web also renders an accessibility `<img>` in there, and a release that reorders those children would keep all of them green while the pane painted nothing. One test now renders the real component, asks it which child it painted, and checks the write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the hand-built cases passing, which is the gap. A second case records what the `<img>` does: the streaming path writes styles and never props, so it keeps the source it mounted with for the life of the pane, and that is what a screen reader and the image context menu see. react-native-web ships no type declarations, so the component comes through `createRequire`, whose return is `any` at its own signature; the one prop it renders with is declared rather than asserted, and the file stays inside the tests-typecheck ratchet. The module docstring also claimed more than the code does. The frame path adds no render, but a render from any of the pane's other state — address focus, a dialog, the view mode, zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`, so both land on the newest frame whether or not it has decoded. Native clobbers the same way through `setNativeProps`. Said plainly, along with what restores the buffering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2) pullfrog is right, and the test carried the same wrong claim. The note said the hidden `<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after saying a render from the pane's other state passes `renderedFrameSource` as `source` — and React Native Web derives that image's `src` from the same prop it paints the background from, so the first such render moves it. Measured here rather than reasoned about: rendering the real component, writing a frame imperatively, then re-rendering with a new source moves the `src` and leaves the background where the imperative write put it. The two halves are now two cases, named for what each one shows, and the note says the streaming writes never touch it while a render does — so it holds the frame the pane last rendered with, not the one on screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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 = () => <TextInput style={styles.input} />',
|
||||
'Planted.test.tsx': 'it("x", () => <TextInput />)',
|
||||
'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([])
|
||||
})
|
||||
})
|
||||
@@ -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 = () => <TextInput style={styles.input} />'
|
||||
].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',
|
||||
() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<View style={styles.field}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={browserAddressFieldStyles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
@@ -38,6 +39,10 @@ export function MobileBrowserAddressField({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
|
||||
// Why: `keyboardType` is a native-only enum, so in a browser the URL keyboard is lost and
|
||||
// the field falls back to a plain one. `inputMode` is what a browser reads, and it takes
|
||||
// precedence over `keyboardType`, so it must stay undefined everywhere else.
|
||||
inputMode={Platform.OS === 'web' ? 'url' : undefined}
|
||||
numberOfLines={1}
|
||||
returnKeyType="go"
|
||||
placeholder="URL"
|
||||
@@ -46,7 +51,11 @@ export function MobileBrowserAddressField({
|
||||
/>
|
||||
{fileLabel ? (
|
||||
<View pointerEvents="none" style={styles.fileLabelHost}>
|
||||
<Text style={styles.fileLabel} numberOfLines={1} ellipsizeMode="middle">
|
||||
<Text
|
||||
style={browserAddressFieldStyles.fileLabel}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{fileLabel}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user