mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
* fix(mobile-web): drop the UA focus ring from page text inputs Chromium rings every focused text field (:focus-visible); no native TextInput paints one. A zero-specificity rule in its own inline block beside the Expo root reset removes it for every page input; buttons keep the browser's ring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): draw page hairlines one device pixel thick react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device pixels on a 480 dpi phone; native draws one. A build shim replaces that one assignment with React Native's own formula (roundToNearestPixel(0.4), else 1/ratio), so every page hairline matches native without touching components. A rendered check at a real device scale (Playwright's emulated scale floors borders to CSS px, which no phone does) measures both parity fixes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): apply the hairline shim to react-native-web's CommonJS build The page's dependencies require react-native, so esbuild resolves every importer to react-native-web's dist/cjs build, which the previous filter did not match: the shipped bundle still assigned hairlineWidth=1. The filter now matches both builds, the rendered check requires the package the way the page does, and a builder test reads every hairlineWidth assignment in the bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): draw page hairlines at a width WebKit paints too 1/ratio is exactly one device pixel, and WebKit floors that to 0 and paints nothing (0.3333px at a scale of 3), so the iOS shell would have lost every hairline. The shim now uses native's device-pixel count plus half a pixel; both engines floor a border to whole device pixels, so each paints what React Native paints at ratios 1, 2, 3, 3.5 and 4, measured per engine. The rendered parity check now runs in WebKit at a device scale of 3 as well as Chromium. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): check the parity style in the existing root-reset build Drops a second full build that read one HTML string, plus two assertions that tested the constant against itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): round the page hairline up to the 1/64 px layout step Half a pixel over native's count kept borders at one device pixel but made the separators drawn as `height: StyleSheet.hairlineWidth` straddle two rows at about half of all offsets. Both engines lay out in 1/64 CSS px, and WebKit stores an exact 1/3 as 21/64 and paints nothing, so the width is now native's device-pixel count over the ratio, rounded up to the next 1/64 (22/64 at 3). The rendered check adds a separator at a 10.1 px offset in Chromium and WebKit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
172 lines
6.4 KiB
JavaScript
172 lines
6.4 KiB
JavaScript
/**
|
|
* Where the browser's defaults paint something the native app does not, measured in the engine the
|
|
* Android shell runs, at a phone's density, with the plugins and document styles the page ships.
|
|
*/
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import * as esbuild from 'esbuild'
|
|
import { chromium, webkit } from 'playwright-core'
|
|
import pngjs from 'pngjs'
|
|
import {
|
|
MOBILE_WEB_APP_NATIVE_PARITY_STYLE,
|
|
MOBILE_WEB_APP_ROOT_RESET,
|
|
mobileWebAppBuildOptions
|
|
} from './build-mobile-web-app-bundle.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
import { createBundleServer, readShellCsp } from './mobile-web-app-render-harness.mjs'
|
|
|
|
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
|
|
|
// The emulator the audit measured on is 480 dpi, which a WebView reports as 3. Chromium by launch
|
|
// flag, not Playwright's emulated scale, under which it floors borders to CSS px as no phone does.
|
|
// WebKit (the iOS shell) takes the emulated scale, which is how it paints a sub-pixel border.
|
|
const ENGINES = [
|
|
{
|
|
name: 'chromium',
|
|
launch: () => {
|
|
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
|
return chromium.launch({
|
|
headless: true,
|
|
args: ['--force-device-scale-factor=3'],
|
|
...(executablePath ? { executablePath } : {})
|
|
})
|
|
},
|
|
pageOptions: { viewport: null }
|
|
},
|
|
{
|
|
name: 'webkit',
|
|
launch: () => webkit.launch({ headless: true }),
|
|
pageOptions: { viewport: { width: 427, height: 952 }, deviceScaleFactor: 3 }
|
|
}
|
|
]
|
|
|
|
// Required, not imported: the page's own dependencies require react-native, and esbuild then
|
|
// resolves every importer to the package's CommonJS build, so that is the copy the page ships.
|
|
const PAGE_ENTRY = `
|
|
import { createElement as h } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
const { StyleSheet, TextInput, View } = require('react-native')
|
|
const styles = StyleSheet.create({
|
|
separatorBox: { paddingTop: 10.1, width: 12, backgroundColor: '#ffffff' },
|
|
separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#000000' },
|
|
hairline: { height: 0, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#222222' },
|
|
input: { height: 40 }
|
|
})
|
|
createRoot(document.getElementById('root')).render(
|
|
h(View, null, h(View, { testID: 'separator-box', style: styles.separatorBox }, h(View, { style: styles.separator })), h(View, { testID: 'hairline', style: styles.hairline }), h(TextInput, { testID: 'input', style: styles.input }))
|
|
)
|
|
`
|
|
|
|
const bundles = mobileWebAppDependenciesPresent()
|
|
const describeParity = bundles ? describe : describe.skip
|
|
|
|
let scratch = null
|
|
let server = null
|
|
let origin = null
|
|
const browsers = new Map()
|
|
|
|
beforeAll(async () => {
|
|
if (!bundles) {
|
|
return
|
|
}
|
|
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
|
|
scratch = await mkdtemp(join(mobileDir, '.tmp', 'native-parity-render-'))
|
|
const outDir = join(scratch, 'bundle')
|
|
await mkdir(outDir, { recursive: true })
|
|
// The page's own options minus its entry and chunking, so a shim the builder drops fails here.
|
|
const shipped = mobileWebAppBuildOptions([])
|
|
await esbuild.build({
|
|
...shipped,
|
|
entryPoints: undefined,
|
|
stdin: { contents: PAGE_ENTRY, resolveDir: mobileDir, loader: 'js', sourcefile: 'parity.js' },
|
|
splitting: false,
|
|
write: true,
|
|
outdir: outDir,
|
|
entryNames: 'parity',
|
|
metafile: false
|
|
})
|
|
await writeFile(
|
|
join(outDir, 'index.html'),
|
|
`<!doctype html><html><head><meta charset="utf-8">${MOBILE_WEB_APP_ROOT_RESET}` +
|
|
`${MOBILE_WEB_APP_NATIVE_PARITY_STYLE}</head><body><div id="root"></div>` +
|
|
'<script type="module" src="/parity.js"></script></body></html>'
|
|
)
|
|
const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() })
|
|
server = served.server
|
|
origin = served.origin
|
|
for (const engine of ENGINES) {
|
|
browsers.set(engine.name, await engine.launch())
|
|
}
|
|
}, 180_000)
|
|
|
|
afterAll(async () => {
|
|
for (const browser of browsers.values()) {
|
|
await browser.close()
|
|
}
|
|
server?.close()
|
|
if (scratch) {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
async function openPage(engine) {
|
|
const page = await browsers.get(engine.name).newPage(engine.pageOptions)
|
|
await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' })
|
|
await page.waitForSelector('[data-testid="input"]')
|
|
return page
|
|
}
|
|
|
|
describeParity.each(ENGINES)('the page against native, at a phone density, in $name', (engine) => {
|
|
it('draws StyleSheet.hairlineWidth one device pixel thick, as native does', async () => {
|
|
const page = await openPage(engine)
|
|
try {
|
|
const measured = await page.evaluate(() => {
|
|
const line = document.querySelector('[data-testid="hairline"]')
|
|
const ratio = window.devicePixelRatio
|
|
return { ratio, devicePixels: Math.round(line.getBoundingClientRect().height * ratio) }
|
|
})
|
|
expect(measured).toEqual({ ratio: 3, devicePixels: 1 })
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
})
|
|
|
|
it('paints a StyleSheet.hairlineWidth-tall separator one device pixel tall at a fractional offset', async () => {
|
|
const page = await openPage(engine)
|
|
try {
|
|
// 10.1 CSS px down: a 0.5 px separator straddles two device rows there in both engines.
|
|
const shot = pngjs.PNG.sync.read(
|
|
await page.locator('[data-testid="separator-box"]').screenshot({ scale: 'device' })
|
|
)
|
|
const column = Math.floor(shot.width / 2)
|
|
let darkRows = 0
|
|
for (let y = 0; y < shot.height; y++) {
|
|
// Pure black only: the clip's last row can catch the #222222 border drawn below it.
|
|
if (shot.data[(y * shot.width + column) * 4] < 16) {
|
|
darkRows++
|
|
}
|
|
}
|
|
expect(darkRows).toBe(1)
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
})
|
|
|
|
it('paints no focus ring on a focused text input, as no native TextInput does', async () => {
|
|
const page = await openPage(engine)
|
|
try {
|
|
await page.focus('[data-testid="input"]')
|
|
const outline = await page.evaluate(() => {
|
|
const input = document.querySelector('[data-testid="input"]')
|
|
const style = getComputedStyle(input)
|
|
return { focused: document.activeElement === input, style: style.outlineStyle }
|
|
})
|
|
expect(outline).toEqual({ focused: true, style: 'none' })
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
})
|
|
})
|