Skip to content

fix(mobile-web): page inputs lose the browser focus ring and hairlines draw one device pixel (OTA phase C follow-up) - #22569

Merged
Jinwoo-H merged 6 commits into
mainfrom
ota-fu-page-polish
Sep 23, 2026
Merged

Jinwoo-H merged 6 commits into
mainfrom
ota-fu-page-polish

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​185 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​185
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​56 $\color{#cf222e}{\Huge{\mathbf{−}}}$​2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​54

Why

The page-vs-native visual audit found two discrepancies where the browser's defaults differ from what the native app paints. Native is the reference; both fixes are page-side only, in the page bundle builder, and leave native pixels untouched.

  • Chromium rings a focused text input; no native TextInput paints one.
  • StyleSheet.hairlineWidth is pinned to 1 CSS px by react-native-web, which is 3 device px on a phone, while native draws one device pixel.

What

  • A zero-specificity parity style block in the page document: :where(input:focus,textarea:focus){outline:none}. Inputs and textareas only, so a button reached by a hardware keyboard keeps the browser's ring; RNW Switch's hidden checkbox draws its own focus state.
  • An esbuild onLoad shim that replaces react-native-web's StyleSheet.hairlineWidth = 1; assignment (matched whole, so an upgrade that moves it fails the build; applied to the ESM and CommonJS builds) with React Native's own device-pixel count over the ratio, rounded up to the 1/64 CSS px both engines lay out in. Why not 1/ratio exactly: WebKit stores 1/3 as 21/64 and paints nothing, so the iOS shell would lose every hairline; 22/64 is the smallest step that paints, and it also keeps height: hairlineWidth separators at one device pixel where a wider value straddled two rows half the time. A runtime assignment cannot do this: consumers read the value inside module-scope StyleSheet.create (162 uses), and the bundle banner runs before react-native-web's StyleSheet exists. The shim registers in the existing MOBILE_WEB_APP_SHIMS contract like the lucide shim.

Skipped after measurement (not simple, or not a discrepancy): row heights differ by Android font padding (page text boxes 54 px vs native 61) with no page-side rule that matches native within 1–2 px; the audit's red "Waiting for desktop…" flashes did not reproduce on a healthy link and trace to a separate socket stall that native's shared screens show the same way.

Proof

  • Red first: builder test on the outline ('auto' vs 'none'); rendered parity test 3 device px vs 1; WebKit case devicePixels: +0 with the exact 1/ratio value; separator case expected 2 to be 1 with a 0.5 px value.
  • Rendered parity test runs Chromium and WebKit at device scale 3 for the border and a hairline-height separator at a fractional offset.
  • Opus design review, three rounds; the last confirmed nothing else must change (boundary, shim registry, accessibility, test behaviour all verified).
  • Emulator (OrcaReopenPaint, page served from this branch): focus ring gone (was #e59700 at x=33–38); files header border 3 → 1 device px at y=330 = native; filter-sheet separator 1 px.
  • Lead gate on e2b9233: every lint/typecheck line 0; config/scripts 260 files / 2404 tests; mobile 867 files / 9474 tests; closure pins unmoved.

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
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
…JS 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
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
…uild

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
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
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The mobile web build now calculates StyleSheet.hairlineWidth from window.devicePixelRatio and injects CSS that removes focus outlines from focused inputs and textareas. Bundle tests check the generated hairline assignment and HTML style. New Chromium and WebKit tests check hairline rendering, a fractional-offset separator, and focused-input outline styling at device scale factor 3.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to e2b92

Task checkboxes may be difficult to navigate by keyboard, and hairlines may appear too thick on higher-density displays. These bounded issues warrant a fix or explicit acceptance before merging.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the motivation, implementation, scope, and test evidence, but it does not follow the required template. It omits the ELI5, Linked Issue, Visual Proof, Testing, Review, boundar… Add all required template sections. Include the issue link after “Fixes #”, attach before-and-after visual proof or state why it is not applicable, document manual and automated testing with platforms, complete the checklist, and provide th…
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies both primary changes: removing browser focus rings from mobile web inputs and correcting hairline rendering. The phase reference is additional but not misleading.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the motivation, implementation, scope, and test evidence, but it does not follow the required template. It omits the ELI5, Linked Issue, Visual Proof, Testing, Review, boundary, Notes, and Checklist sections.

Resolution

Add all required template sections. Include the issue link after “Fixes #”, attach before-and-after visual proof or state why it is not applicable, document manual and automated testing with platforms, complete the checklist, and provide the required review, compatibility, security, and AI disclosure information.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e62cd492-bb3f-4748-9fed-bdd959c0450c

📥 Commits

Reviewing files that changed from the base of the PR and between 6847390 and e2b9233.

📒 Files selected for processing (3)
  • config/scripts/build-mobile-web-app-bundle.mjs
  • config/scripts/build-mobile-web-app-bundle.test.mjs
  • config/scripts/mobile-web-app-native-parity-render.test.mjs

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

* Those only: a button reached by a hardware keyboard keeps the browser's ring.
*/
export const MOBILE_WEB_APP_NATIVE_PARITY_STYLE =
'<style id="orca-native-parity">:where(input:focus,textarea:focus){outline:none}</style>'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '88,125p' mobile/src/components/rich-markdown/document-style.ts
sed -n '25,43p' mobile/src/components/rich-markdown/markdown-list-render.ts
sed -n '132,146p' config/scripts/build-mobile-web-app-bundle.mjs

Repository: stablyai/orca

Length of output: 2847


🏁 Script executed:

rg -n -i 'checkbox|focus-visible|focus-within|:focus|outline|box-shadow' mobile/src/components/rich-markdown mobile/src/components/MobileRichMarkdownEditor.web.tsx config/scripts/build-mobile-web-app-bundle.mjs

Repository: stablyai/orca

Length of output: 5311


Preserve focus indication on rich-markdown task checkboxes.

When an editable Markdown document contains a task list, markdown-list-render.ts emits an enabled input[type="checkbox"]. The checkbox uses appearance: none without a focus style, while the shipped parity rule removes its outline. Keyboard users can therefore lose visible focus.

🐛 Suggested fix
     ul[data-type="taskList"] input[type="checkbox"]:disabled {
       opacity: 0.65;
     }
+    ul[data-type="taskList"] input[type="checkbox"]:focus-visible {
+      outline: 2px solid var(--primary);
+      outline-offset: 2px;
+    }

Comment on lines +243 to +258
// Both builds: once a dependency requires it, esbuild resolves every importer to cjs.
{ filter: /react-native-web[\\/]dist[\\/](cjs[\\/])?exports[\\/]StyleSheet[\\/]index\.js$/ },
async (args) => {
const source = await readFile(args.path, 'utf8')
if (!source.includes(RNW_HAIRLINE_ASSIGNMENT)) {
throw new Error(`${HAIRLINE_PLUGIN_NAME}: ${args.path} no longer assigns hairlineWidth`)
}
return {
contents: source.replace(RNW_HAIRLINE_ASSIGNMENT, DEVICE_PIXEL_HAIRLINE_ASSIGNMENT),
loader: 'js'
}
}
)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '225,263p' config/scripts/build-mobile-web-app-bundle.mjs
sed -n '115,157p' config/scripts/mobile-web-app-native-parity-render.test.mjs

Repository: stablyai/orca

Length of output: 3679


🏁 Script executed:

set -eu
printf '%s\n' '--- changed files and hunk ---'
git diff --stat 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b e2b9233cff730de378d30f403b2c09fe6129d185
git diff --unified=35 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b e2b9233cff730de378d30f403b2c09fe6129d185 -- config/scripts/build-mobile-web-app-bundle.mjs config/scripts/mobile-web-app-native-parity-render.test.mjs

printf '%s\n' '--- hairline references ---'
rg -n -S -g '!node_modules' -g '!dist' -g '!build' 'hairline-device-pixel|hairlineWidth|data-testid="hairline"|separator-box|devicePixelRatio|pixelRatio|native.?width|nativeWidth' .

printf '%s\n' '--- relevant build-options region ---'
sed -n '250,370p' config/scripts/build-mobile-web-app-bundle.mjs

printf '%s\n' '--- parity test setup and fixtures ---'
sed -n '1,180p' config/scripts/mobile-web-app-native-parity-render.test.mjs

Repository: stablyai/orca

Length of output: 45544


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 39315


🏁 Script executed:

set -eu
printf '%s\n' '--- changed files and hunk ---'
git diff --stat 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b e2b9233cff730de378d30f403b2c09fe6129d185
git diff --unified=35 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b e2b9233cff730de3782e2d9f412c4d6f9cc9bc6d5b -- config/scripts/build-mobile-web-app-bundle.mjs config/scripts/mobile-web-app-native-parity-render.test.mjs

printf '%s\n' '--- hairline references ---'
rg -n -S -g '!node_modules' -g '!dist' -g '!build' 'hairline-device-pixel|hairlineWidth|data-testid="hairline"|separator-box|devicePixelRatio|pixelRatio|native.?width|nativeWidth' .

printf '%s\n' '--- relevant build-options region ---'
sed -n '250,370p' config/scripts/build-mobile-web-app-bundle.mjs

printf '%s\n' '--- parity test setup and fixtures ---'
sed -n '1,180p' config/scripts/mobile-web-app-native-parity-render.test.mjs

Repository: stablyai/orca

Length of output: 510


🏁 Script executed:

set -eu
git diff --stat 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b e2b9233cff730de378d30f403b2c09fe6129d185
git diff --unified=25 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b e2b9233cff730de378d30f403b2c09fe6129d185 -- config/scripts/build-mobile-web-app-bundle.mjs config/scripts/mobile-web-app-native-parity-render.test.mjs
printf '%s\n' '--- references ---'
rg -n -S -g '!node_modules' -g '!dist' -g '!build' 'hairline-device-pixel|hairlineWidth|data-testid="hairline"|separator-box|devicePixelRatio|pixelRatio|native.?width|nativeWidth' .
printf '%s\n' '--- build options ---'
sed -n '250,370p' config/scripts/build-mobile-web-app-bundle.mjs
printf '%s\n' '--- parity test ---'
sed -n '1,180p' config/scripts/mobile-web-app-native-parity-render.test.mjs

Repository: stablyai/orca

Length of output: 45544


🏁 Script executed:

set -eu
printf '%s\n' '--- build hairline declarations ---'
sed -n '80,108p' config/scripts/build-mobile-web-app-bundle.mjs
sed -n '188,252p' config/scripts/build-mobile-web-app-bundle.mjs

printf '%s\n' '--- build tests around hairline ---'
sed -n '320,365p' config/scripts/build-mobile-web-app-bundle.test.mjs

printf '%s\n' '--- native substitute exception ---'
sed -n '1,115p' mobile/src/test-support/rpc-recording/screen-native-substitutes.ts
sed -n '60,90p' mobile/src/test-support/rpc-recording/screen-native-substitutes.test.ts

printf '%s\n' '--- focused production consumers ---'
sed -n '1,35p' mobile/src/tasks/mobile-tasks-diff-comment-styles.ts
sed -n '132,154p' mobile/src/tasks/mobile-tasks-diff-comment-styles.ts
sed -n '1,22p' mobile/src/tasks/mobile-tasks-detail-styles.ts

Repository: stablyai/orca

Length of output: 14500


Keep StyleSheet.hairlineWidth at one device pixel.

At devicePixelRatio 4, the replacement computes Math.round(0.4 * 4) = 2, so the width is 0.5 CSS pixels and paints two device pixels. DevicePixelRatio 5 also produces a width above two device pixels. This reaches production styles that use StyleSheet.hairlineWidth and violates the documented one-device-pixel contract.

Suggested fix
-// React Native's device-pixel count (roundToNearestPixel(0.4), else one) over the ratio, rounded up
+// One native device pixel over the ratio, rounded up
 // to the 1/64 CSS px both engines lay out in. WebKit stores an exact 1/3 as 21/64, under one device
 // pixel at 3, and paints nothing; 22/64 is the smallest step that paints. Any width above 1/ratio
 // can still straddle two rows at some offsets; the smallest such step does so least.
 const DEVICE_PIXEL_HAIRLINE_ASSIGNMENT =
   'StyleSheet.hairlineWidth = (function (ratio) {' +
-  ' return Math.ceil((64 * (Math.round(0.4 * ratio) || 1)) / ratio) / 64; })' +
+  ' return Math.ceil(64 / ratio) / 64; })' +
   "(typeof window !== 'undefined' && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1);"

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — page-side native-parity polish for the Route A mobile-web bundle; native pixels and the native bundle are untouched.

  • Focus ring parity — a new MOBILE_WEB_APP_NATIVE_PARITY_STYLE (:where(input:focus,textarea:focus){outline:none}) is injected into the built document head after the root reset. Verified against react-native-web@0.21.2: Switch draws its own focus state (handleFocusState sets the thumb boxShadow), and the app renders no RNW Checkbox, so no input currently loses its only focus affordance.
  • Hairline device-pixel shim — hairlineDevicePixelPlugin matches RNW's whole StyleSheet.hairlineWidth = 1; assignment in both its ESM and CJS StyleSheet/index.js and replaces it with RN's device-pixel count over the ratio, rounded up to the 1/64 CSS-px step. I confirmed both upstream builds carry the exact string the throw guards on, and confirmed via a local esbuild transform that the minified replacement still matches the build test's \.hairlineWidth=[^;]{0,120} window (so the assertion genuinely fails if the shim is dropped). The formula tracks RN's roundToNearestPixel(0.4) || 1 device px for ratios 0.5–5.
  • Registry + tests — the shim is registered in MOBILE_WEB_APP_SHIMS and the new render check measures chromium (--force-device-scale-factor=3) and webkit at scale 3 for the hairline/separator pixels and the computed input outline.

The one thing worth keeping in mind (not blocking): input:focus covers every input type, not just text fields, so it would also strip the ring from a checkbox/radio that relied on the UA outline. Nothing shipped today does — RNW Switch self-draws and no page <input type=checkbox> outside the editor document is reachable — so this is latent breadth rather than a regression.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@Jinwoo-H
Jinwoo-H merged commit 63866c1 into main Sep 23, 2026
31 checks passed
@Jinwoo-H
Jinwoo-H deleted the ota-fu-page-polish branch September 23, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant