Skip to content

Fix radio group keyboard navigation - #992

Merged
thomasluizon merged 35 commits into
redesign/mainfrom
fix/ticket-543-radio-keys
Sep 18, 2026
Merged

thomasluizon merged 35 commits into
redesign/mainfrom
fix/ticket-543-radio-keys

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • Keep web radio groups on roving tab stops and move Android radio groups to native focus routing.
  • Support every arrow key with wrapping and disabled-item skipping on web and Android.
  • Support Home and End on web. React Native 0.86.3 does not emit those keys on Android.
  • Follow Android native focus with selection while preserving existing pointer and touch behavior.
  • Cover shared primitives and affected callers with focused Vitest regressions.

Platform evidence

  • ReactViewManager.kt:204-211 sets Android's four directional focus ids from nextFocusUp, nextFocusDown, nextFocusLeft, and nextFocusRight.
  • BaseViewConfig.android.js:348-352 registers those four props and nextFocusForward.
  • View.js:82-84 maps tabIndex to Android focusability, so enabled mobile options use native focus and explicit directional targets.
  • Native Android radio options remain individually focusable in forward traversal. Web keeps its ARIA roving tab stop.
  • KeyEvent.kt:132-153 does not emit Home or End. Those keys remain web only.

Test evidence

  • Before the original fix, existing web focused tests passed: 2 files and 10 tests.

  • Before the original fix, existing mobile focused tests passed: 3 files and 17 tests.

  • The strengthened web segmented-control test then failed because ArrowDown did not select completed.

  • The strengthened mobile segmented-control test then failed because tab indexes were unset.

  • Round 5 unchanged mobile segmented-control test passed with the defect present: 1 file and 3 tests. The old mock allowed focus on tabIndex={-1}.

  • After correcting the mock, the same mobile test failed as intended: 1 failed and 2 passed. Focus remained undefined for segment-completed-unselected-enabled.

  • The new Android startup regression failed as intended: 1 failed and 6 passed. The plugin registered no mainApplication mod.

  • Final web focused tests passed: 4 files and 15 tests.

  • Final mobile focused tests passed: 7 files and 32 tests.

  • Earlier prebuild evidence generated the former override before super.onCreate(); round 3 replaces that unsafe startup order.

  • Round 3 unchanged npm test --workspace @orbit/mobile -- __tests__/plugins/with-android-release-build-fixes.test.ts passed 1 file and 7 tests with the startup crash still present.

  • The strengthened round 3 regression then failed as intended: 1 failed and 6 passed because ReactNativeFeatureFlags.override( still appeared once.

  • After the fix, the same focused command passed 1 file and 7 tests.

  • Round 3 npm run type-check passed all 3 workspace tasks.

  • Round 3 npm run lint passed all 3 workspace tasks with 7 existing warnings.

  • npm run type-check passed all 3 workspace tasks.

  • npm run lint passed all 3 workspace tasks with 7 existing warnings.

  • npm test passed shared, ESLint rules, and all 2,572 mobile tests in the original round.

  • All 2,918 web assertions passed in the original round, but the command exited 1 after progress.test.tsx:210 timed out closing Chrome after 30 seconds.

  • The teardown-only timeout occurred during the parallel full-suite run and is outside this change. Closes thomasluizon/orbit-tickets#543

  • Round 10 unchanged focused tests passed with both defects present: mobile 3 files and 25 tests; web 1 file and 5 tests.

  • Round 10 strengthened mobile coverage failed as intended: 4 failed and 23 passed. The tracked plugin registered no mainApplication mod, the checked-entry focus observer saw zero calls, and the host mock did not model focusable View focus. One initial wrap assertion also exposed a test-authoring mistake and was corrected to inspect native hosts.

  • Round 10 strengthened web coverage failed as intended: 1 failed and 5 passed. ArrowDown from Top level left focus on Top level because it was outside the group.

  • After the two fixes, the focused commands passed: mobile 3 files and 27 tests; web 1 file and 6 tests.

  • The review-found no-selection fallback regression failed against 8eefbea: 1 failed and 8 passed because redirected focus selected first. At d0631c3, the same suite passed all 9 tests.

  • Final radio coverage passed: mobile 9 files and 77 tests; web 5 files and 21 tests.

  • Root npm run type-check passed all 3 workspace tasks.

  • Root npm run lint passed all 3 workspace tasks with 9 existing unrelated warnings and no errors.

  • Round 11 unchanged tests passed before the new assertions: mobile 1 file and 9 tests; web 2 files and 10 tests.

  • Round 11 mobile red proof failed 1 test and passed 8 because immediate re-entry incorrectly selected the first radio.

  • Round 11 web red proof failed 2 tests and passed 9 because Move Parent was unnamed and TimeField had multiple tab stops.

  • Round 11 focused green passed 8 mobile files and 70 tests, plus 6 web files and 26 tests.

  • Final npm test passed all 4 tasks: mobile 2,671 tests, web 2,960 tests, and ESLint rules 392 tests.

  • Final npm run type-check passed all 3 workspace tasks.

  • Final npm run lint passed all 3 workspace tasks with 9 existing unrelated warnings and no errors.

  • Final suppression ratchets passed: web 20 to 20 and mobile 16 to 16.

  • Round 13 unchanged move-parent-overlay.test.tsx failed against 42bda168 with the one unfinished edit reverted: 1 failed and 5 passed, expected "vi.fn()" to be called with arguments: [ null ], Number of calls: 0.

  • Round 13 with that edit finished, the same suite passed 1 file and 6 tests.

  • Round 13 focused web radio suites passed 4 files and 27 tests: move-parent-overlay, preference-picker-sheet, select-check and time-field.

  • Round 13 focused mobile radio suites passed 5 files and 46 tests: select-check, time-field, list-primitives, preference-picker-sheet and move-parent-dialog.

  • Round 13 npx turbo run type-check --concurrency=1 --force passed 3 of 3 tasks, exit 0. The type-check was forced.

  • Round 13 npx turbo run lint --concurrency=1 passed 3 of 3 tasks, exit 0, with 2 pre-existing web warnings and no errors.

  • Round 13 npx turbo run test --concurrency=1 passed 4 of 4 tasks on f104e11a: eslint-rules 392 tests, shared 2,081 tests, mobile 2,679 tests, web 2,967 tests. This is the full suite run that round 12 recorded as missing.

  • Round 13 GITHUB_BASE_REF=redesign/main node tools/check-suppressions-ratchet.mjs passed: web 14 to 14 and mobile 16 to 16.

Review harness

  • execution lane: fixed immediate mobile leave and re-entry, the unnamed web Move Parent group, and web TimeField keyboard parity
  • motion lane: not applicable: no changed animation
  • gates lane: focused tests, full tests, type-check, lint, diff check, and both suppression ratchets passed
  • interface-review: no actionable introduced or regression findings remain across the complete branch diff
  • better-interface (full mode): accessibility, layout, writing, typography, color, and UI are clear; verdict Approve
  • design-reviewer: no findings on the branch through round 13; re-run on round 14's diff it found two
    DESIGN.md breaks in the relocated move-parent eyebrow and both are fixed in 29998d8d, detailed under
    Round 14
  • completeness-critic: no gaps across mobile provenance, web listbox navigation, localized group names, callers, and regressions

Manual steps

  • When the redesign APK is generated, connect a hardware keyboard and confirm arrow keys move and wrap selection in a radio group.
  • Confirm Tab visits each visible enabled radio option and never lands on an invisible target.
  • Confirm entering a radio group focuses its checked option without changing its value.
  • Confirm leaving a group and immediately re-entering treats the focus as entry, not directional movement.
  • Open a picker in Preferences with a hardware keyboard, move through it with arrow keys, and confirm the account time zone and week start are written once, on the press, and never per keypress.
  • Open the Move Parent sheet with a screen reader and confirm the group counts only destinations, so a position report matches the number of rows.
  • No repository test can execute Android framework focus traversal, so these checks are not claimed as passed.

Round 11: focus provenance and complete web parity

Heads 44f96483 and b027b223.

44f96483 merges origin/redesign/main. The suppressions conflict took the base file wholesale and npm run lint:prune --workspace=@orbit/web preserved the 20 to 20 web ratchet. The Move Parent conflict keeps the base 4px spacing and this branch's single radio group.

The mobile blur timer could not distinguish a directional move from a real leave followed by immediate re-entry. b027b223 removes the timer. The root focus capture now records actual focus provenance, and each radio group decides entry versus movement from the preceding native target. Checked-entry redirects remain selection-neutral.

The installed React Native interface was checked directly. ViewPropTypes.js:116,118 declares onBlurCapture and onFocusCapture. BaseViewManager.java:751-759 registers captured and bubbled focus phases. FocusEvent.kt:20-23 and BlurEvent.kt:20-23 emit only target, so a blur event cannot identify the next target and cannot classify the transition by itself.

Web Move Parent now gives its radio group the same localized destination name as mobile. Web TimeField now has one selected tab stop per listbox and supports wrapped Arrow keys plus Home and End. This fixes the unthreaded concurrent Pullfrog finding rather than merely recording it.

Both requested review threads were replied to with fixed in b027b223 and resolved. The architecture map was regenerated for inspection and its generated artifacts were not committed, per repository policy.

Round 3: the override no longer runs twice, and the merge-forward Head 144254cb. Two commits since the last review.

109d93c7 fixes a startup crash this pull request would otherwise have shipped Pullfrog was right and the chain is proven from installed source, not from the review body: 1. node_modules/@react-native/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GenerateEntryPointTask.kt:96 generates loadReactNative, which calls DefaultNewArchitectureEntryPoint.load() whenever BuildConfig.IS_NEW_ARCHITECTURE_ENABLED is true. 2. node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultNewArchitectureEntryPoint.kt:88-99 calls ReactNativeFeatureFlags.override(...) for every one of the three release levels. 3. node_modules/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp:1688-1693 throws std::runtime_error("Feature flags cannot be overridden more than once") once wasOverridden_ is true. The old plugin injected its override before super.onCreate(), so React Native's own call was the second one and the process died before it rendered. The override now runs AFTER loadReactNative(this) and goes through ReactNativeFeatureFlags.dangerouslyForceOverride, declared at ReactNativeFeatureFlags.kt:640-645. That path cannot hit the once-only guard: ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp:406-416 builds a FRESH accessor, overrides that one, and swaps it in. The provider delegates to whichever release-level provider DefaultNewArchitectureEntryPoint.releaseLevel names, so React Native's own overrides are preserved rather than replaced, and only enableKeyEvents and enableImperativeFocus are changed. No try/catch was added: a defensive branch around a crash this code causes is banned by root CLAUDE.md code standard 1. The other two threads are the same finding. radio-group.tsx needed no change: ReactNativeFeatureFlagsOverrides_RNOSS_Stable_Android.kt is ReactNativeNewArchitectureFeatureFlagsDefaults() with nothing added, and the plugin pins enableImperativeFocus through dangerouslyForceOverride. Correction, made at 8ddcca0: this sentence originally claimed ReactNativeFeatureFlagsDefaults.kt:86,92 leave both flags false. Read again from the installed tree, line 86 is override fun enableImperativeFocus(): Boolean = true and line 92 is override fun enableKeyEvents(): Boolean = true, so both defaults are already on in React Native 0.86.3 and the original citation was false. The Installed interface evidence line under Round 7 states the flags correctly and stands unchanged.

144254cb is a merge-forward and carries one conflict resolution redesign/main moved 16 commits ahead while this branch was open. The only conflict was apps/mobile/components/ui/motion-pressable.tsx, and it is the union of two changes, neither of which is wrong:

  • pull request 988 added the optional active prop, so a dragged Progresso goal card reads as pressed; - this branch wrapped the component in forwardRef, so a radio row can move focus imperatively. Both are kept. The shared body already referenced ref and active, so only the signature needed resolving. local/react19-api bans forwardRef on web only and says so in its own header, deliberately, because React Native's renderer version is not the React package version, so the wrapper is correct here. Nothing else was changed in the merge-forward. git diff origin/redesign/main...HEAD --stat reports 30 files, which is this branch's own change and nothing more.

Test evidence

  • npx turbo run type-check --force from the repository ROOT: 3 successful, 3 total, 0 cached. Forced, because a cached FULL TURBO on a file just changed proves nothing.
  • npm test -w @orbit/mobile -- __tests__/screens/progress-screen.test.tsx __tests__/components/ui/segmented-control.test.tsx __tests__/components/ui/select-check.test.tsx __tests__/components/ui/selection-field.test.tsx __tests__/components/ui/time-field.test.tsx __tests__/components/habits/goal-type-selector.test.tsx __tests__/components/profile/preference-picker-sheet.test.tsx __tests__/plugins/with-android-release-build-fixes.test.ts: 8 files, 115 tests, all passed. That set is every consumer of MotionPressable and every surface the radio work touches, run after the merge.
  • The plugin's own red-first evidence is in the worker's round-3 report: the new assertions on the generated MainApplication.kt fail against the old plugin and pass against the new one.

What only a device can confirm That the two flags actually take effect at runtime on a release build. Nothing in this repository builds apps/mobile/android/, which is generated and untracked. Thomas tests the whole redesign once from an APK he generates himself.

Round 4: rendered radio order survives filtering Head 3f274fea. The mobile registry now stores each required index and sorts enabled items by that current rendered position. Every mobile caller supplies its array index, so remounted timezone rows return to their visible position before keyboard navigation runs. Disabled-item filtering remains unchanged. Web needs no matching code change. apps/web/components/ui/radio-row.tsx orders registered buttons with compareDocumentPosition, which follows current DOM order after a remount. apps/web/components/ui/radio-navigation.ts only calculates the next index from that supplied order. origin/redesign/main remained at 0600332b before push, so no merge-forward was needed.

Test evidence

  • With the defect present at 144254cb, the unchanged npm test -w @orbit/mobile -- __tests__/components/profile/preference-picker-sheet.test.tsx passed 1 file and 1 test. It did not exercise filter, unmount, and remount ordering.
  • With the new regression added but the old implementation retained, the same command failed as intended: 1 failed and 1 passed. ArrowDown from the retained row selected Delta/Keep instead of the rendered adjacent row Charlie/Three.
  • At 3f274fea, the same command passed 1 file and 2 tests.
  • Mobile radio suites passed 6 files and 26 tests: goal-type-selector.test.tsx, preference-picker-sheet.test.tsx, segmented-control.test.tsx, select-check.test.tsx, selection-field.test.tsx, and time-field.test.tsx.
  • Web radio suites passed 5 files and 17 tests: goal-type-selector.test.tsx, preference-picker-sheet.test.tsx, radio-navigation.test.ts, segmented-control.test.tsx, and select-check.test.tsx.
  • npx turbo run type-check --force from the repository root passed 3 tasks, with 0 cached. The forced run verified every required index call site.
  • npm run lint passed 3 tasks with 7 existing warnings and no errors. No suppression count changed.

Round 5b: Android follows native focus Head 522fb746. This result is superseded by Round 6 below because its shared forward target was an invisible focus stop. The Android radio group no longer handles onKeyDown or calls preventDefault(). Every enabled option is focusable, disabled options are not, and selection follows onFocus. Each option receives nextFocusUp, nextFocusDown, nextFocusLeft, and nextFocusRight handles for the adjacent enabled option, including wrap at both ends. All enabled options share one nextFocusForward target after the group. The registry still sorts by the required rendered index, so filtered and remounted rows keep visual navigation order. The test host mock now assigns distinct native handles and only permits imperative focus for focusable hosts. Web needs no code change. Its DOM keyboard handler can cancel key events and its registry follows current DOM order. Android nextFocus* and focusable are platform adapter props, so the existing parity position remains. 386fd3c7 merges the latest origin/redesign/main before final verification. The merge carried no radio behavior change and had no conflicts.

Test evidence

  • With the defect present, the unchanged six mobile radio suites passed 6 files and 26 tests. They called onKeyDown directly and could not exercise ReactRootView.dispatchKeyEvent.
  • With strengthened tests and the old implementation retained, three focused suites failed as intended: 3 failed and 10 passed. Enabled segments had no focusable prop, the radio group had no forward target, and time options had no onFocus handler.
  • After the fix, all six mobile radio suites passed 6 files and 26 tests.
  • The four web radio suites passed 4 files and 15 tests.
  • npx turbo run type-check --force from the repository root passed 3 tasks with 0 cached.
  • npm run lint passed 3 tasks with 8 existing warnings and no errors. No suppression count changed.

Manual steps

  • Superseded by the current ## Manual steps section above.

Round 6: visible native focus targets only Head 0cbcd183. The invisible forward target and every nextFocusForward assignment are removed. Every enabled option remains focusable, disabled options remain excluded, the four directional ids still wrap and skip disabled options, and selection still follows focus. Forward traversal now matches native Android radio behavior. It enters at the first focusable option and visits the visible enabled options in order. Moving with arrows also selects, so reaching the checked option is direct and predictable. The release feature flags are also removed. A production search found no mobile onKeyDown consumer and no generic View.focus() call. The only remaining .focus() calls target TextInput refs in input.tsx and otp-input.tsx, which use the TextInput native path and do not consume those flags. The red Unit Tests check had three failures in keyboard-aware-scroll-view.test.tsx. Its synthetic input fixture was not created by the host mock, so the corrected findNodeHandle returned null before measureInWindow. The fixture now mounts the mocked TextInput, receives its real test host handle, and keeps the original behavior assertions.

Test evidence

  • With the sentinel defect present, the unchanged six mobile radio suites passed 6 files and 26 tests. They did not reject an empty focusable target.
  • With the new sentinel regression added and production unchanged, select-check.test.tsx failed as intended: 1 failed and 2 passed. The rendered group contained one empty focusable host.
  • The unchanged keyboard-aware suite reproduced CI locally: 3 failed and 13 passed because measureInWindow was never reached.
  • After the fix, the six mobile radio suites, keyboard-aware suite, and plugin suite passed 8 files and 49 tests.
  • npx turbo run type-check --force passed 3 tasks with 0 cached.
  • npm run lint passed 3 tasks with 8 existing warnings and no errors. No suppression count changed.
  • origin/redesign/main remained at 43bc28ad, already contained by this branch, so no merge-forward was needed.

Round 7: focus entry preserves selection

Head bb3ae15a.

Mobile now distinguishes entering a radio group from directional movement inside it. Initial focus does not select an unchecked option. It redirects to the checked enabled option through the stored native View.focus() method. A group with no checked option leaves focus on Android's first enabled landing target. Focus movement between items still selects the destination. A zero-delay blur timer clears group focus only when another item does not focus first.

Web needs no change. apps/web/components/ui/radio-row.tsx:111-114 gives tabIndex={0} to the selected enabled option, or the first enabled option when none is selected.

Installed interface evidence

  • ViewPropTypes.d.ts:93,100 declares real onBlur and onFocus View props.
  • ReactNativeTypes.d.ts:106,252 declares focus(): void and exports HostInstance = ReactNativeElement.
  • View.d.ts:22 makes a View ref a HostInstance.
  • ReactViewManager.kt:445,472-474 maps the focus command to requestFocusFromJS().
  • ReactNativeFeatureFlagsDefaults.kt:86,92 enables imperative focus and key events by default in the installed React Native 0.86.3.
  • The only repository-local flag override is in generated apps/mobile/android/, ignored by .gitignore:86. No tracked plugin ships an override.

Test evidence

  • With the defect present, npm test -w @orbit/mobile -- __tests__/components/ui/select-check.test.tsx __tests__/components/ui/time-field.test.tsx passed 2 files and 11 tests. Existing tests did not distinguish entry from movement.
  • With the new regressions and the old implementation, the same command failed as intended: 5 failed and 12 passed. Initial entry changed selection, and radio items exposed no blur handler.
  • After the fix, the same command passed 2 files and 17 tests.
  • The six mobile radio suites passed 6 files and 33 tests after their movement tests first established focus inside each group.
  • Root npm run type-check passed all 3 workspace tasks.
  • Root npm run lint passed all 3 workspace tasks with 8 existing warnings and no errors.

Device verification boundary

Only a hardware-keyboard device run can confirm Android's real forward entry and directional focus traversal. No repository test claims that platform behavior passed.

Round 8: the D76 step 6 sweep, and the web defect it found

Head 69e43526.

The Redesign Review Harness gate failed at bb3ae15a because this pull request changes 14 UI
files on redesign/main. The sweep was then run against the complete diff.

interface-review and better-interface were fetched by listing every blob under their directories
in jakubkrehel/skills through the Trees API, not through npx ui-skills get, which does not serve
them. The inventory was checked first: --jq .truncated printed false over 89 entries, so the
listing did not under-fetch.

It found one defect, on web. apps/web/components/ui/radio-row.tsx:125 called the destination's
selection handler on every directional move, including a move that lands on the option already
selected. Pressing End on the last radio when it is already checked fired onChange again. Mobile
already guarded this through its onFocus check; web did not. 69e43526 fixes it with
if (!nextItem.selected), which is the same rule stated once on each platform.

Test evidence for 69e43526

The worker that produced this commit was killed for low memory after committing and before
pushing. None of its numbers are quoted.
The runs below were produced by the orchestrator, from a
clean tree at that commit.

Red proved after the fact, by reverting only the source file:

git checkout 69e43526~1 -- apps/web/components/ui/radio-row.tsx
<repo>/node_modules/vitest/vitest.mjs run __tests__/components/ui/select-check.test.tsx

FAIL  __tests__/components/ui/select-check.test.tsx > select-check RadioRow group >
      keeps one tab stop, skips disabled rows, wraps, and follows selection with focus
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
Test Files  1 failed (1)
Tests  1 failed | 1 passed (2)

Restored with git checkout 69e43526 -- apps/web/components/ui/radio-row.tsx, after which
git status --porcelain printed nothing.

Green, the four web radio suites at 69e43526:

Test Files  4 passed (4)
Tests  15 passed (15)

covering select-check.test.tsx, segmented-control.test.tsx, goal-type-selector.test.tsx and
preference-picker-sheet.test.tsx.

Round 9: one mobile radio primitive, and the callers that were left behind

Head fb520fb1.

The review was right that this pull request had added a SECOND mobile radio implementation instead
of extending the existing one. That is a parity break rather than a style preference: a caller got
keyboard navigation depending on which module it happened to import, while web had only one.

apps/mobile/components/ui/radio-group.tsx is deleted, 204 lines, and its behaviour lives in
apps/mobile/components/ui/radio-row.tsx, which is the survivor because it already had the callers.
A grep for components/ui/radio-group across apps/mobile returns nothing, so no importer was left
pointing at a file that no longer exists.

Migrated callers: apps/mobile/app/support.tsx,
apps/mobile/components/habit-list/move-parent-dialog.tsx,
apps/mobile/components/habits/create-goal-from-habit/goal-type-selector.tsx and
apps/mobile/components/profile/preferences-sections.tsx. The first two are exactly the ones the
finding named as stranded on the no-navigation implementation.

The imperative focus chain, re-read rather than asserted back

The review says element.focus() is a no-op for generic View hosts in the shipped stable
configuration. Every step was re-read in the installed tree under
node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/:

  1. internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Stable_Android.kt:10 declares
    class ReactNativeFeatureFlagsOverrides_RNOSS_Stable_Android() : ReactNativeNewArchitectureFeatureFlagsDefaults(),
    and a grep of that file for enableImperativeFocus or enableKeyEvents returns zero hits.
  2. internal/featureflags/ReactNativeNewArchitectureFeatureFlagsDefaults.kt:23 declares the class it
    extends, and the same grep there also returns zero hits.
  3. So both inherit internal/featureflags/ReactNativeFeatureFlagsDefaults.kt:86,
    override fun enableImperativeFocus(): Boolean = true, and :92 for enableKeyEvents.
  4. views/view/ReactViewManager.kt:473-474 runs root.requestFocusFromJS() inside
    if (ReactNativeFeatureFlags.enableImperativeFocus()).
  5. views/view/ReactViewGroup.kt:429-435 is that method: super.requestFocus(FOCUS_DOWN, null) when
    isAttachedToWindow, otherwise it defers with focusOnAttach = true.

On that chain the flag is true by inheritance and the command reaches View.requestFocus, which
succeeds for a focusable attached view. This group makes every enabled option focusable, so the
redirect has a real target.

An earlier round of this pull request wrote the opposite into this same body, claiming
ReactNativeFeatureFlagsDefaults.kt:86,92 leave both flags false. That claim is wrong against the
installed tree and is corrected here rather than left to contradict itself.

Whether requestFocus actually lands on a real device is the one thing no repository test can
execute. It is recorded under ## Manual steps and is not claimed as passing.

Test evidence for fb520fb1

Green, the nine suites covering every migrated caller and every radio surface: 9 files, 74 tests.

Red proved after the fact by reverting only apps/mobile/components/ui/radio-row.tsx to
fb520fb1~1, which is the migration itself:

FAIL  __tests__/screens/support-screen.test.tsx > SupportScreen >
      redirects subject entry to the checked row without changing the value
FAIL  __tests__/screens/support-screen.test.tsx > SupportScreen >
      selects a subject when focus moves within the group
FAIL  __tests__/components/habit-list/move-parent-dialog.test.tsx > MoveParentDialog >
      redirects entry to the checked destination without changing selection
FAIL  __tests__/components/habit-list/move-parent-dialog.test.tsx > MoveParentDialog >
      selects a destination when focus moves within the group
Test Files  2 failed (2)

Those four are precisely the parity the finding reported missing. Restored with
git checkout fb520fb1 -- apps/mobile/components/ui/radio-row.tsx, after which
git status --porcelain printed nothing.

The worker that produced this commit was killed for low memory after committing and before pushing.
None of its numbers are quoted; every run above is the orchestrator's from a clean tree.

The two red checks on the previous head

Redesign Review Harness failed because this body had lost every newline, so `

  • Round 10 unchanged focused tests passed with both defects present: mobile 3 files and 25 tests; web 1 file and 5 tests.
  • Round 10 strengthened mobile coverage failed as intended: 4 failed and 23 passed. The tracked plugin registered no mainApplication mod, the checked-entry focus observer saw zero calls, and the host mock did not model focusable View focus. One initial wrap assertion also exposed a test-authoring mistake and was corrected to inspect native hosts.
  • Round 10 strengthened web coverage failed as intended: 1 failed and 5 passed. ArrowDown from Top level left focus on Top level because it was outside the group.
  • After the two fixes, the focused commands passed: mobile 3 files and 27 tests; web 1 file and 6 tests.
  • The review-found no-selection fallback regression failed against 8eefbea: 1 failed and 8 passed because redirected focus selected first. At d0631c3, the same suite passed all 9 tests.
  • Final radio coverage passed: mobile 9 files and 77 tests; web 5 files and 21 tests.
  • Root npm run type-check passed all 3 workspace tasks.
  • Root npm run lint passed all 3 workspace tasks with 9 existing unrelated warnings and no errors.

Review harness` sat

mid-paragraph and was not a heading. The block had been there since round 5. The body is reflowed
and check-review-harness.mjs now passes locally against it. Build failed at Run npm ci with a
null conclusion after 45 minutes, which is a hang rather than a diff failure, and was re-run.
SonarCloud is red and does not gate redesign/main.

Round 10: checked entry focus and Move Parent parity

Heads 8eefbea and d0631c3.

Android keeps every enabled native radio focusable and stores each rendered host ref. First entry now requests focus on the checked enabled item, or the first enabled item when nothing is checked. The entry redirect records the destination before requesting focus, so neither the original landing event nor the redirected focus event changes selection. Directional focus inside the group still selects the new destination, and no sentinel or nextFocusForward target returns.

The tracked Expo plugin again edits generated MainApplication.kt, after loadReactNative(this). It delegates to the installed release-level provider and force-overrides only enableImperativeFocus; enableKeyEvents is untouched. The plugin test uses the generated Kotlin anchors, rejects the deleted two-flag legacy override, and proves idempotent output. The installed Kotlin native default currently returns true while the JavaScript fallback returns false; the explicit tracked override makes the Android release contract independent of that inherited default.

Web Move Parent now places Top level and every tree destination inside one RadioGroup, matching mobile wrap and disabled-item behavior. This closes a cross-platform parity gap the original ticket assigned to ORB-67. The touched web layout also replaces off-scale 10px and 6px gaps with the existing 8px scale step, allowing the spacing suppression baseline to shrink by two.

No repository test executes Android framework focus traversal. A release APK with a hardware keyboard must still confirm that the imperative focus command lands on the checked item and that native arrows wrap correctly.

Round 12: an arrow key no longer closes the picker it is navigating

Head 8ddcca0e.

The blocking defect. moveSelection called the destination row's registered handler, and for
the four preference pickers that handler was selectAndClose. One ArrowDown in Preferences, Time
zone therefore ran closeSheet, dismissed the sheet and committed the adjacent timezone. The roving
tabIndex this pull request introduced had already made every unchecked row tabIndex={-1}, so the
arrow keys were the only remaining keyboard route and every option except the adjacent one was
unreachable. This was a regression this pull request introduced, not a pre-existing fault.

Where the fix lives. Selection and commit are now separate channels on the group, not on the
row, so no call site can re-couple them. RadioGroup takes onCommit and useRadioGroupItem
returns onActivate. A directional move calls only the destination row's value handler. onCommit
runs on an explicit activation: on web that is the native button click a pointer press, Enter and
Space all raise, and on mobile it is onPress. selectAndClose is deleted from both picker sheets
and replaced by one commitSelection passed to each RadioGroup. A row has no close-capable
channel any more, so a fifth caller cannot reinherit the defect.

Red first, against the code at 78c38628

The three suites that asserted the closing behaviour as correct were corrected to require that the
sheet survives a directional key and closes only on an explicit commit. Against the unfixed code:

FAIL  __tests__/components/preferences/preference-picker-sheet.test.tsx >
      keeps the language sheet open when ArrowDown moves the selection
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
 > __tests__/components/preferences/preference-picker-sheet.test.tsx:123:31
    123|     expect(props.onClose).not.toHaveBeenCalled()

FAIL  __tests__/components/preferences/preference-picker-sheet.test.tsx >
      closes the language sheet only once Enter commits the moved selection
FAIL  __tests__/components/preferences/preference-picker-sheet.test.tsx >
      keeps the timezone sheet open when ArrowDown moves the selection
 Test Files  1 failed (1)
      Tests  3 failed | 7 passed (10)
FAIL  __tests__/components/profile/preference-picker-sheet.test.tsx >
      uses rendered timezone order after filtered options remount
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
 > __tests__/components/profile/preference-picker-sheet.test.tsx:146:35
    146|     expect(props.closePicker).not.toHaveBeenCalled()

FAIL  __tests__/components/ui/selection-field.test.tsx >
      skips a disabled option in native traversal and selects the focused option
AssertionError: expected +0 to be 1 // Object.is equality
 > __tests__/components/ui/selection-field.test.tsx:189:30
    189|     expect(sheetCount(tree)).toBe(1)
 Test Files  2 failed (2)
      Tests  5 failed | 8 passed (13)

onClose, closePicker and sheetCount each prove the same thing from a different angle: one
directional key dismissed the sheet.

The four review findings

  1. fallbackFocusedHandleRef deleted. It duplicated the provenance the root layout's
    FocusProvenanceView already owns and survived only because most suites skipped the provider.
    The six suites that drive focus now mount the real provider and record a real focus capture
    before each onFocus, through the new apps/mobile/__tests__/support/focus-provenance.tsx.
    Suites that merely render a radio group are left alone deliberately: usePreviousFocusTarget
    still reads the context rather than throwing, because making a leaf primitive hard require a root
    provider would force eight screen suites to mount a provider they have no stake in. With the
    duplicate tracker gone, a suite that skips the provider can no longer pass a focus movement
    assertion, which is the protection that matters.
  2. redirectTargetIdRef scoped. It is now armedRedirectRef, holding the target id and the
    native tag focus was on when the redirect was armed. It is cleared on every focus event and
    suppresses selection only when both the id and the originating tag match, so a redirect that
    never lands expires instead of swallowing a later key.
  3. apps/web/components/ui/radio-navigation.ts deleted. It was a one line re-export with no
    production importer. Its only consumer, apps/web/__tests__/components/ui/radio-navigation.test.ts,
    now imports getRadioNavigationIndex from @orbit/shared/utils.
  4. The flag citation corrected in the Round 3 section above. The review's premise was inverted:
    the installed file reads true at both lines, so Round 7's wording was already right and Round
    3's was the false one.

The judgement call: selection-field.tsx is deleted

apps/mobile/components/ui/selection-field.tsx had no production caller anywhere in apps/mobile;
its only importer was its own test. There is no web counterpart, so it is not a platform adapter,
and the redesign queue that might have consumed it is finished. Code standard 2 says delete unused
code immediately and standard 9 says build the simplest thing that meets current requirements, so
keeping a component alive for a caller that does not exist, and adding a disabled option to it,
fails both. It is deleted with its test.

Its test was the only mobile coverage of RadioGroup plus Sheet plus closeSheet together, so
that coverage is replaced where the rule actually lives rather than dropped: select-check.test.tsx
now asserts on both platforms that a focus move changes the value without committing, and that a
press commits. The mobile and web picker sheet suites keep the end to end proof.

Test evidence for 8ddcca0e

  • npx turbo run test --force passed 4 of 4 tasks: eslint-rules 392 tests, shared 2,041, mobile
    2,674, web 2,968. That run predates three later edits made to clear the pre-commit lint gate: nine
    unnecessary non null assertions removed from three mobile test files, one banned line comment
    removed from radio-row.tsx, and the test helper's tree parameter widened.
  • npx turbo run type-check --force passed 3 of 3 tasks on the pushed bytes, exit 0. Its first run
    caught seven real TS2345 errors from that helper's over narrow tree type.
  • The eight mobile radio suites passed 8 files and 71 tests on the pushed bytes, exit 0. The seven
    web radio suites passed 7 files and 33 tests.
  • The pre-commit hook runs eslint over every changed file plus the dashes, i18n-usage and
    root-allowlist gates. It rejected the first commit attempt with 10 errors and passed on the
    second, so 8ddcca0e exists only because those gates are green.

Not re-run on the pushed bytes: the full npx turbo run test --force. The three edits after the
green full run are a comment deletion, test-only assertion removals and a test-helper type widening,
and the affected suites plus a forced type-check were re-run green, but the complete suite has not
been executed against 8ddcca0e itself.

Round 13: the unfinished test edit, and the first full suite run on the pushed bytes

Head f104e11a.

Provenance of every number in this section. The previous worker was killed at the 45 minute
ceiling with one file open and nothing pushed. None of its figures are quoted. Every result in this
section comes from a command run in this session against this worktree, captured to a file rather
than read through a pipe. The sections for rounds 1 to 12 keep the numbers those rounds recorded.

What was left open. apps/web/__tests__/components/habits/move-parent-overlay.test.tsx was the
only dirty file. It threads a selectedMoveParentId argument through the renderOverlay helper and
passes 'parent' in the first case. That edit is correct, and it is now finished and committed.

The reason it is needed is in fdb88336 itself. useRadioGroupItem's onActivate now reads
if (!selected) onSelect?.(), so a press on the row that is already checked commits without
selecting again. The case rendered with selectedMoveParentId null, and MoveParentOverlay marks
the root row selected when rootOption.id === selectedMoveParentId, which is null equals null. So
the case pressed the checked row and then asserted the handler fired. Rendering with the tree
destination checked leaves the root row unchecked, which is what a case named for a selectable root
row should assert.

Red first, against 42bda168 with the edit reverted:

FAIL  __tests__/components/habits/move-parent-overlay.test.tsx >
      MoveParentOverlay > renders a selectable root row and the destinations eyebrow
AssertionError: expected "vi.fn()" to be called with arguments: [ null ]

Number of calls: 0
 > __tests__/components/habits/move-parent-overlay.test.tsx:59:28
    59|     expect(onSelectOption).toHaveBeenCalledWith(null)
 Test Files  1 failed (1)
      Tests  1 failed | 5 passed (6)

With the edit restored, the same command passed 1 file and 6 tests.

The two P1s of fdb88336, verified against the tree rather than against its commit message

A preference is written once, on a press. Both platforms hold the picker value in a local
PickerOptions draft. apps/mobile/components/profile/preferences-sections.tsx and
apps/web/app/(app)/preferences/_components/preference-picker-sheet.tsx each keep a draft plus a
draftRef, pass selectDraft as the row handler, and pass commitDraft as the group's onCommit.
That is the shape time-field.tsx already used. onTimeZoneChange, onWeekStartChange,
onLanguageChange and onThemeModeChange are reachable only through commitSelection, which runs
them inside closePicker on mobile and closeSheet on web, so the value also changes after the
sheet is gone rather than during its dismissal.

Which surfaces commit on focus, in one list:

  • Preferences language, theme, time zone and week start commit on a press, on both platforms. Focus
    moves a draft only.
  • The time-field.tsx hour, minute and meridiem columns move pickerDraft on focus and commit in
    applyDraft. That was already true before this round.
  • Support subject, Move Parent destinations, goal type and every segmented control select on focus,
    because selection is the whole interaction there and no separate commit step exists.

nextFocus* is unset on mobile. grep -rn "nextFocus" apps/ --include=*.ts --include=*.tsx
returns no production hit. The only matches are three test files asserting the absence and the
navigation-render.tsx helper's prop type. useRadioGroupItem returns focusable and onFocus and
nothing else, so Android's own geometric focus search runs and an arrow key can leave the hours
column for the minutes column, the meridiem column and the Done button. The mobile-only index prop
is gone from every caller with it.

Web keeps its roving tabIndex through getTabIndex in apps/web/components/ui/radio-row.tsx.
That is the named platform adapter on this pull request: focusable and nextFocus* are Android
props while tabIndex and onKeyDown are DOM ones, so the mechanism differs and the behaviour does
not.

The branch diff carries no unreviewed work

42bda168 is the merge-forward the killed worker took, and no second one was added.
git diff origin/redesign/main...HEAD --stat names 41 files. Every one is a radio primitive, one of
its callers, a test, the Expo plugin, the mobile focus provenance view or the shared navigation
helper. No generated baseline, no DESIGN.md, no canvas file and no manifest appears.

Still deferred

tools/surface-manifest.mjs:103, tools/__tests__/surface-manifest.mjs:38-39 and the four entries
in .claude/manifests/surfaces.json still name components/ui/selection-field, which this branch
deletes. Pull request 1024 regenerates that manifest and adds the Surface Manifest Drift job, and
it is not merged. This branch rebases onto it and regenerates in one small round afterwards, with
the ids moving into the deleted section of tools/redesign-groups.json. A generated baseline is
resolved by regenerating it, never by hand.

Round 14: the template the plugin really patches, and the group that owned a text node

Head 29998d8d. Two commits. The first carries the two P2s and six P3s across 14 files; the second carries the design review fixes. Every number below comes from a command
run in this session against this worktree and captured to a file.

P2-1: the config plugin test now runs the pristine path

apps/mobile/__tests__/plugins/with-android-release-build-fixes.test.ts had one fixture and it
already carried the legacy override block, so the path a release build actually takes was never
executed. Root .gitignore:86 ignores apps/mobile/android/, so every CI and EAS build starts from
the untouched Expo template.

PRISTINE_MAIN_APPLICATION_TEMPLATE is that fixture with the two feature flag imports and the legacy
block removed, and it asserts not.toContain('ReactNativeFeatureFlags') before the plugin runs. The
new case feeds it to resolveMainApplication and asserts all five imports land, that the plain
ReactNativeFeatureFlags import appears exactly once as a whole line, that all three release levels
are present, that enableKeyEvents is not enabled, and that the override sits between
loadReactNative(this) and ApplicationLifecycleDispatcher.onApplicationCreate(this).

Proof that the new case notices what the old one could not: removing
ReactNativeFeatureFlagsProvider from FEATURE_FLAGS_IMPORTS reddens only the pristine case.

 × patches the pristine template a release build actually starts from 9ms
 Test Files  1 failed (1)
      Tests  1 failed | 10 passed (11)

A second case reindents the load anchor to two spaces and asserts the plugin throws
MainApplication.kt is missing the React Native load anchor rather than skipping the insert. That
case passes against the current plugin, so it records that the guard already fires; it is coverage,
not a fix. Deeper indentation is not a valid break for it, because loadReactNative(this) is a
substring of a more indented line and the guard still matches.

P3-4: the legacy import outlives a block this plugin cannot match

The plugin dropped import ...ReactNativeFeatureFlagsDefaults unconditionally while removing its
block only on an exact string match. An android/ tree an older revision patched therefore lost an
import its surviving block still needed, which is a Kotlin unresolved reference on a gitignored
directory. The import now goes only once nothing constructs the class.

Import detection also changed. FEATURE_FLAGS_IMPORTS[0] is a prefix of the longer
ReactNativeFeatureFlagsProvider import, so a substring test let the longer one stand in for the
shorter one. hasImportLine matches a whole trimmed line instead, which is CRLF safe.

Red first, against the plugin at f104e11a:

 × keeps the legacy import while a block an older revision wrote still constructs it 9ms
AssertionError: expected 'package org.useorbit.app...' to contain
  'import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults'

P3-5: the return value of dangerouslyForceOverride is logged

node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt:640
declares public fun dangerouslyForceOverride(provider: ReactNativeFeatureFlagsProvider): String?,
and its KDoc at :631-635 says the value holds the flags read before the call and that it should
still be logged somehow. The generated onCreate now keeps it in
featureFlagsReadBeforeOverride and warns through android.util.Log.w when it is not null, so a
future React Native that reads enableImperativeFocus during load fails loudly. The fully qualified
call needs no extra import anchor.

Red first: × logs the flags React Native read before the override instead of discarding them 11ms.

P2-2: the radiogroup owns radio children only

apps/mobile/components/habit-list/move-parent-dialog.tsx and
apps/web/components/habits/habit-list/move-parent-overlay.tsx render the
habits.moveParent.destinations eyebrow above the group rather than inside it, and mobile moves the
noSearchResults text out as well. role="radiogroup" and accessibilityRole="radiogroup" expect
owned radio children, so a text node inside made TalkBack and NVDA count an item that is not a
destination. The eyebrow string stays the group's accessible name on both platforms, so the two
still name the group identically. The change is symmetric and is not a parity break either way.

P3-3: a picker checks its current row once the profile resolves

PickerOptions seeded draft and draftRef from selected once and never resynced, so a first
render with no profile left every row unchecked after the value arrived. The draft now starts null
and the rendered value is draft ?? selected, with commitDraft falling back the same way. Focus
still wins over the incoming value, so an in progress choice is never clobbered.

An effect that reset the draft was written first and rejected by the gate:
react-hooks/set-state-in-effect at preferences-sections.tsx:47 and
preference-picker-sheet.tsx:33. The fallback needs no effect and no render time setState.

Red first on both platforms, against f104e11a:

mobile  × checks the current timezone once the profile resolves after a cold start 17ms
        AssertionError: expected [] to deeply equal [ 'Bravo/Keep' ]
        Tests  1 failed | 5 passed (6)

web     × checks the current option once the profile resolves after the first render 159ms
        TestingLibraryElementError: Unable to find an accessible element with the role "radio"
        Tests  1 failed | 11 passed (12)

P3-2: the unused disabled prop is gone from both select-check rows

apps/mobile/components/ui/select-check.tsx and apps/web/components/ui/select-check.tsx exported a
RadioRow with a disabled prop that no production caller passed. The only importers are
preferences-sections.tsx and preference-picker-sheet.tsx, and neither passes it. The prop is
deleted along with the mobile styles.disabled entry and the web disabled class variants. There is
no upcoming caller to name.

The disabled skipping coverage that fixture carried moved to the two suites where a disabled option
is real production data. apps/web/__tests__/components/habits/move-parent-overlay.test.tsx asserts
that a disabled destination never takes a tab stop and that ArrowDown from the root lands past it on
the next enabled destination. apps/mobile/__tests__/components/habit-list/move-parent-dialog.test.tsx
asserts the disabled destination is not focusable, has no press handler, and is skipped when focus
moves. Both use components/ui/radio-row, which carries disabled from the shared contract and is
what move-parent renders.

P3-1: the focusable target test counts instead of searching for nothing

apps/mobile/__tests__/components/ui/select-check.test.tsx looked for a childless host with
focusable === true, which nothing in RadioGroup, RadioRow or RadioGlyph can render. It now
asserts that the focusable host count equals the option count, so it reddens both if a sentinel
returns and if a row loses focusable. Proved by setting focusable: false in
useRadioGroupItem:

 × makes one focusable host per radio option and nothing else 34ms
AssertionError: expected [] to deeply equal [ 'radio', 'radio', 'radio', 'radio' ]

P3-6: the dead blur call is deleted

second.props.onBlur?.() in the re-entry case was always a no op, because useRadioGroupItem
returns no onBlur. It is removed and the case still passes for the right reason: the outside
focus is what resets provenance.

A correction to the record, and both halves of it differ from the tree

The round order asked me to record two readings. I read both in this worktree, in installed
react-native@0.86.3, and both come out the other way. Nothing in the order was built on them, so
no code changes, but the record should carry what the files say.

Android does map Home and End.
node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyEvent.kt
lines 114 and 115 read AndroidKeyEvent.KEYCODE_MOVE_HOME to "Home" and
AndroidKeyEvent.KEYCODE_MOVE_END to "End", and the same pair appears again at :143 and :144.
grep -c "MOVE_HOME\|MOVE_END" on that file returns 4, not 0. KEYCODE_TAB to "Tab" sits at :111
and :140.

Home and End are still web only, and the true reason is the one the order gives: mobile consumes no
key events at all. grep -rn "onKeyDown" apps/mobile --include=*.tsx --include=*.ts outside
__tests__ returns 0 hits, so the adapter is web roving tabindex plus ARIA keys against mobile
platform focus search.

Both feature flags default to true, not false.
.../internal/featureflags/ReactNativeFeatureFlagsDefaults.kt:86 reads
override fun enableImperativeFocus(): Boolean = true and :92 reads
override fun enableKeyEvents(): Boolean = true. The release provider inherits both:
ReactNativeFeatureFlagsOverrides_RNOSS_Stable_Android.kt:10-11 extends
ReactNativeNewArchitectureFeatureFlagsDefaults(), and grepping either class for those two names
returns 0 hits.

The JavaScript side reads the native value rather than carrying its own default.
src/private/featureflags/ReactNativeFeatureFlags.js:331 is
createNativeFlagGetter('enableImperativeFocus', false), and createNativeFlagGetter in
ReactNativeFeatureFlagsBase.js calls NativeReactNativeFeatureFlags?.enableImperativeFocus?.()
and uses its second argument only when that native module is unavailable. So
ReactNativeElement.focus() at ReactNativeElement.js:155-161 reaches ViewCommands.focus on this
version without any override.

What that means for this branch, stated plainly: the config plugin's
dangerouslyForceOverride(enableImperativeFocus = true) is not the mechanism that makes entry focus
work on 0.86.3, because the flag is already true by inheritance. It pins an experimental flag the
entry redirect depends on, so it stays and it is now covered by the pristine template case. It is
not load bearing today, and this section is the honest version of the claim earlier rounds made.

Test evidence

Every run below was captured to a file. No result is read through a pipe.

run result
mobile, the four suites this round touches Test Files 4 passed (4), Tests 41 passed (41)
web, the three suites this round touches Test Files 3 passed (3), Tests 24 passed (24)
mobile radio suites, nine files Test Files 9 passed (9), Tests 87 passed (87)
web radio suites, eight files Test Files 8 passed (8), Tests 44 passed (44)
npx turbo run type-check --force --concurrency=1 from the root Tasks: 3 successful, 3 total, Cached: 0 cached
npm run lint from the root Tasks: 3 successful, 3 total, 0 errors, 4 pre-existing warnings
GITHUB_BASE_REF=redesign/main node tools/check-suppressions-ratchet.mjs web 14 to 14, mobile 16 to 16, both ok

The type-check was forced, so the parameter and prop deletions were checked against every call site
rather than against a cache.

The deferred manifest round can now run, and this pull request will go red until it does

Pull request 1024 is MERGED. It landed on redesign/main as 0f96925b at head cc5e9c8a, about
four minutes after the round order recorded it as unmerged, and it added the Surface Manifest Drift
job to .github/workflows/guards.yml. That job runs node tools/surface-manifest.mjs --check on any
pull request touching apps/web/ or apps/mobile/, which this one does.

Measured rather than predicted. Running the base copy of the checker against this worktree with the
base manifest in place exits 1:

surface-manifest: the committed inventory does not describe this tree
  - cellCount: committed 800, derived from this tree 796
  - surfaceCount: committed 186, derived from this tree 185
  - committed surface no longer exists in the tree: m-overlay-ui-selection-field

m-overlay-ui-selection-field is this branch's deletion. The block-chat-* entries it also lists
come from this branch sitting one commit behind the base and will resolve in the merge forward, so
the exact counts after that merge will differ.

This round did not take it, because the round order deferred it twice in writing and named it as its
own small round: merge redesign/main forward, regenerate the manifest, delete the dead
"components/ui/selection-field" entry from MOBILE_OVERLAY_BASES at tools/surface-manifest.mjs:103,
and move m-overlay-ui-selection-field into the deleted section of tools/redesign-groups.json
with the decision that removed it. That round can run now.

One follow-up to record rather than fix

CLOSED in round 16, recorded here as it stood at the time. This round noted that the web time
picker was role="listbox" with role="option" while mobile was accessibilityRole="radiogroup"
with radio, and deferred it. Round 16 converted web onto RadioGroup with role="radio" and
aria-checked instead of filing it, because this pull request already edited both files and the
parity rule put it here.

The design review of this round, and the two breaks it found

design-reviewer was re-run against 398103ae's diff alone, because moving the eyebrow is the one
user visible change in it. It found two real DESIGN.md breaks in the element this round relocated,
both confirmed against the spec before the fix and both fixed in 29998d8d.

The web eyebrow was in the wrong type family. DESIGN.md:720 defines the eyebrow role as Geist
Mono 12/500, 1.20 line height, +0.08em uppercase on fg-3. eyebrowStyle in
move-parent-overlay.tsx used var(--font-sans), while the mobile copy already used
GeistMono_500Medium, so the same relocated string rendered in two families across platforms. It now
uses var(--font-mono), which apps/web/components/ui/section-label.tsx:6 has always used for the
same role. The review called this a parity break introduced by the move; the wrong family predates
the move, and the move is what made the two copies comparable. Either way it is a break and it is
fixed.

The mobile eyebrow sat one rhythm unit above its own group. DESIGN.md:351 requires the gap
between two groups to be at least twice the gap within one. styles.eyebrow.marginBottom was 4 and
styles.moveOptionsContent.gap is 4, so a label meant to head the whole destination list read as one
more row. The bottom margin is now 8. The web side already cleared this: its column supplies
gap: 8 against an internal gap: 4.

The review's third note, a leftover margin: '2px 0 0' on the web eyebrow from its old nested
position, is fixed in the same commit. The column gap is now the only spacing mechanism there.

It recorded no finding on the accessibility naming itself, on the eyebrow reading correctly as a
label over the whole group including the root row, on the disabled deletion, or on the
draft ?? selected fallback.

Assumptions

  • The pristine fixture is the existing MAIN_APPLICATION_TEMPLATE with the plugin's own additions
    removed, rather than a copy of expo-template-bare-minimum. That package is not installed in this
    worktree and apps/mobile/android/ on this machine is a stale tree an older plugin revision
    already patched, so neither could serve as the source. The round order specified this derivation.
  • Deleting the disabled prop takes the select-check suites' disabled cases with it, so the
    equivalent coverage moved to the two move-parent suites rather than being dropped. The rejected
    alternative was keeping a prop with no production caller purely to host a test.
  • The draft falls back to the incoming value rather than resetting through an effect or a remount
    key. The rejected alternatives were an effect, which the lint gate blocks, and a key on selected,
    which would discard focus inside the group every time the value arrives.

Round 15: the manifest catch-up, and the node_modules edit behind the react-native dispute

Head 24b2f7f6. Every figure in this section comes from a run made in this worktree after the
reinstall. No number is carried over from an earlier round.

The reinstall did not repair the tree, so I measured instead

npm install reported removed 11 packages, and audited 1680 packages in 7s and left
react-native untouched, because the lockfile entry already matched what was on disk. Both
ReactNativeFeatureFlagsDefaults.kt:86 and the 6779 byte KeyEvent.kt still read the way this
branch had recorded them.

So I scanned every file under node_modules/react-native for an mtime later than the package's own
extraction. Exactly two came back:

2026-09-16T10:13:05Z  node_modules/react-native/package.json   (extraction)
2026-09-16T13:06:22Z  .../internal/featureflags/ReactNativeFeatureFlagsDefaults.kt
2026-09-16T13:06:22Z  .../uimanager/events/KeyEvent.kt

Nothing else in the package moved. Those two files were hand edited three hours after install, which
is the patch-package round this ticket reverted in git on 2026-09-16 and never reverted inside
node_modules. The root package-lock.json and node_modules/.package-lock.json agree on one
react-native entry, 0.86.3, with one integrity hash, so npm had no reason to re-extract.

rm -rf node_modules/react-native && npm install reports added 16 packages, and audited 1680 packages in 25s and restores the published bytes. The worktree now agrees with the other five
checkouts on this machine:

  • ReactNativeFeatureFlagsDefaults.kt:86 is override fun enableImperativeFocus(): Boolean = false
  • :92 is override fun enableKeyEvents(): Boolean = false
  • KeyEvent.kt is 6547 bytes, grep -c "MOVE_HOME\|MOVE_END" returns 0, and KEY_NAME_MAP runs
    :132 to :154, ending on KEYCODE_DPAD_UP, DOWN, LEFT and RIGHT
  • KEYCODE_TAB to "Tab" is real and present in that map

#601 owns the root cause. Its symptom in this worktree was a hand edit to two files, not a
different install.

Two corrections to this body, both against the restored bytes

The section titled A correction to the record, and both halves of it differ from the tree is
withdrawn on both halves, and so is the Correction, made at 8ddcca0e sentence under Round 3.

  1. Android maps no Home and no End. The ## Platform evidence line saying those keys stay
    web only was right from the start.
  2. Both flags default to false. That makes the config plugin the mechanism the entry redirect
    depends on rather than a redundant pin: ReactViewManager.kt:472-476 gates handleFocus behind
    ReactNativeFeatureFlags.enableImperativeFocus(), which is off until the plugin turns it on. The
    earlier claim that the plugin was "not load bearing today" is wrong and is withdrawn with it.

The suites re-run on the restored bytes

The reinstall changes what a mobile test sees, so both halves ran again before anything else changed.
No assertion moved.

run result
mobile, eight radio and plugin suites Test Files 8 passed (8), Tests 80 passed (80)
web, seven radio suites Test Files 7 passed (7), Tests 37 passed (37)

The manifest, regenerated rather than hand merged

origin/redesign/main carries pull request 1024, so Surface Manifest Drift is live and this branch
went red on a surface whose source file it deletes.

node tools/surface-manifest.mjs now writes 185 surfaces and 796 cells, one fewer surface than the
committed 186. Comparing the two parsed manifests by id, the regeneration removes exactly
m-overlay-ui-selection-field and adds nothing. node tools/surface-manifest.mjs --check reports
the committed inventory matches this tree (185 surfaces, 796 cells).

m-overlay-ui-selection-field moves out of Orbit Sobreposicoes in tools/redesign-groups.json
into deleted, with the decision that removed it. deleted is the right bucket because
tools/redesign-coverage.mjs:120 refuses a deleted id that is still present in the manifest, and it
no longer is. node tools/redesign-coverage.mjs exits 0.

The dead MOBILE_OVERLAY_BASES entry, and what removing it exposed

"components/ui/selection-field" is gone from tools/surface-manifest.mjs, with the comment that
described callers the tree does not have. A grep for selection-field across apps/**/*.tsx and
apps/**/*.ts returns zero hits on this branch, because the component and its test are deleted here.

With that entry gone the web and mobile base lists hold the same value, so both call sites now read
the single OVERLAY_BASES rather than two aliases for one list.

The round order expected tools/__tests__/surface-manifest.mjs to stay green through this and it
does not.
Removing the entry turns
FAIL a caller reaching Sheet only through the selection-field wrapper is inventoried red, because
the fixture stages a frequency-field whose only route to Sheet is that wrapper, and
isOverlaySource is direct import only. The case tests a real mechanism, so it keeps testing it
against a wrapper the tree still has: the fixture wrapper is now confirm-sheet, which is in
OVERLAY_BASES. A fixture naming a file no tree has would test nothing.

The determinism assertion the drift gate rests on, shown green over a dead process

tools/__tests__/surface-manifest.mjs discarded both run(...) results around its byte comparison,
and the generator writes nothing on a non-zero exit. So a crashed second run left the first run's
bytes on disk and the comparison agreed with itself.

Proved with a [DEBUG-c7d1] probe at the top of buildManifest that throws on the third derivation
only, so the case module still reaches the comparison:

state result
committed test, generator crashing PASS two runs over one unchanged tree write the same bytes
strengthened test, generator crashing that case still PASS, and FAIL the second determinism run writes a manifest beside it
strengthened test, probe removed 41 assertions, 0 failures, ORBIT TOOLS GATE OK

Both derivations now assert status === 0 before the comparison. grep -rn "DEBUG-c7d1" tools/ .claude/
returns nothing.

The five comment and correctness items 1024's review left owed

  • trackedFiles de-duplicates through a Set. git ls-files prints an unmerged path once per
    index stage, so a regeneration during an unresolved widget conflict wrote that path into
    ownedFiles two or three times. Unreachable in CI, reachable locally.
  • The determinism comment says two runs on one machine prove the generator is a function of the tree
    and the machine, and names chatBlockEntries as the owner of the cross-platform ordering class it
    cannot see.
  • chatBlockEntries states the real condition. The order flips only when the character after the
    shared prefix sorts between / (0x2F) and the backslash (0x5C), so a sibling chat2.tsx flips
    and a sibling chat-header.tsx never does.
  • The local.properties comment says the file is excluded for being untracked, not for being
    ignored, because git ls-files reads the index and never consults ignore rules. The fixture
    .gitignore entry changes no outcome and the comment now says so, so nobody later reads the case
    as licence to swap git ls-files for git check-ignore.
  • The android/(build|.gradle|.cxx) exclusion stays, and its new comment says why: root .gitignore
    means git never lists one of those paths, so the filter now only guards a build artifact somebody
    force added. The comment does not cite the fixture, which would be circular.

The fixture staging for local.properties and the widget build trees is otherwise untouched.

Round 15 assumptions

  • The branch merges origin/redesign/main forward at 0f96925b rather than rebasing onto it. A
    rebase would need a force push, which the delivery boundaries forbid, and this branch already
    carries merge commits from earlier rounds. git merge-tree reported no conflict and the merge
    needed no resolution. The rejected alternative was the literal rebase the round order asked for.
  • WEB_OVERLAY_BASES is deleted alongside the mobile entry rather than kept as an alias for
    OVERLAY_BASES. Two names for one value is the "just in case" export code standard 2 bans. The
    rejected alternative was keeping both names so the lists could diverge again later.
  • The confirm-sheet fixture wrapper is named from OVERLAY_BASES rather than invented, so the
    case covers the same mechanism with a module the real tree still has.

Round 15 verification, every run captured to a file

CLAUDE.md requires both harnesses after a tools/** change and neither runs in CI, so these two
local runs are the only proof.

run final line
node tools/test-tools.mjs ORBIT TOOLS GATE OK, Assertions: 1811 | Elapsed: 798.649s, 0 failures
node .claude/hooks/test-hooks.mjs ORBIT HOOKS OK, 455 passing checks, 0 failures
npx turbo run type-check --force --concurrency=1 Tasks: 3 successful, 3 total, forced
npm run lint Tasks: 3 successful, 3 total, 0 errors, 9 pre-existing warnings
node tools/surface-manifest.mjs --check the committed inventory matches this tree (185 surfaces, 796 cells)
node tools/redesign-coverage.mjs exit 0
GITHUB_BASE_REF=redesign/main node tools/check-suppressions-ratchet.mjs 14 -> 14 web, 16 -> 16 mobile, unchanged

The type-check was forced. No result above is read through a pipe.

The branch's own diff against origin/redesign/main names 41 files. This round touches four of them:
tools/surface-manifest.mjs, tools/__tests__/surface-manifest.mjs, tools/redesign-groups.json
and .claude/manifests/surfaces.json.

Still open, and named so it is not lost

CORRECTED in round 16. This block previously said the web time picker was role="listbox" with
role="option" while mobile was radiogroup with radio. That split is closed. a6b71b43
moved web onto RadioGroup plus useRadioGroupItem with role="radio" and aria-checked, and the
scroll-into-view query moved to [role="radio"][aria-checked="true"]. Both platforms now declare the
same widget. What remains open is #608 and #609, filed rather than widened into this branch.

Round 16: one widget contract, a guard that can actually fail, and the plugin split

Round 15 shipped an absence assertion that could not fail on the axis that mattered. An independent
review at 24b2f7f6 found it, and this round closes it along with the widget-contract split above.

The absence assertion could not fail on the horizontal axis, and now it can

apps/mobile/__tests__/components/ui/time-field.test.tsx:271-292 previously read:

expect(hours.filter((hour) => hourHandles.has(hour.props[direction]))).toEqual([])

hourHandles holds only the handles inside the SAME column. Restore the exact defect an earlier
round shipped, nextFocusLeft and nextFocusRight on each hour cell pointing at the adjacent
minutes column, and those handles are not in hourHandles, so the filter is empty and the test
stays green. Horizontal is the axis that trapped the D-pad, and it was the one axis the
assertion could not see.

It now collects the host nodes of BOTH columns and asserts props[direction] === undefined for all
five nextFocus* props, with length > 1 guards so .every() cannot be vacuous.

One widget contract, on both platforms

Web moved onto RadioGroup plus useRadioGroupItem with role="radio" and aria-checked,
matching mobile. And the mobile half was wrong in a way nothing caught:
accessibilityState={{ selected }} on an accessibilityRole="radio" never makes the node checkable.
Confirmed against the installed source at
node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt:632-640,
where only the checked key sets isCheckable and isChecked while selected sets isSelected
alone. So TalkBack announced neither "checked" nor "not checked" on a radio.

All five accessibilityRole="radio" sites now use checked: radio-row.tsx:221,227,
segmented-control.tsx:35, select-check.tsx:114, time-field.tsx:79, goal-type-selector.tsx:70.
Zero radios are left on selected.

The rest of the round

  • with-react-native-imperative-focus.js:9 supplies import com.facebook.react.common.ReleaseLevel,
    and TEMPLATE_WITHOUT_RELEASE_LEVEL strips it from the fixture so only the plugin can supply it.
    That removes the fixture-agrees-with-the-guess shape code standard 8 forbids.
  • The focus plugin is split out of with-android-release-build-fixes.js into its own file and suite.
    That file and its test are now byte-identical to redesign/main.
  • usePreviousFocusTarget() returning null is no longer ambiguous:
    onFocus = getPreviousFocusTarget ? handleFocus : null plus a standalone fallback at
    radio-row.tsx:117,165-172, with a test.
  • The pointless LANGUAGE_OPTIONS.find(...) is deleted; selectedLanguage is SupportedLocale,
    narrowed once in use-preference-controls.ts:39.
  • MotionPressable loses its focusRef prop, which was always identical to ref, and its one
    caller passes ref. Three converted web controls gain an explicit focus-visible ring.

One visual change, named rather than hidden

preferences-sections.tsx:113-118 against preferences-styles.ts:50-52: timeZoneOptions sets
gap: 8 and the rows are now wrapped in the RadioGroup view, so they sit flush instead of carrying
8px of air. This is kept rather than restored: web was already flush, and DESIGN.md:776 makes the
hairline the only owner of row separation.

Round 16 test evidence

The absence assertion, proved red and green in both directions. The unchanged test was run
against a shimmed @/components/ui/radio-row through an alias override in a scratchpad config, with
nothing in the repository touched, restoring the exact defect: nextFocusLeft: 90001,
nextFocusRight: 90002, handles deliberately outside the hour column's own tag set.

run result
this head's test, pass-through shim (control) Test Files 1 passed (1), Tests 11 passed (11)
this head's test, defect restored 1 failed / 10 passed, AssertionError: expected false to be true at time-field.test.tsx:290
round 15's test at 24b2f7f6, same defect restored PASSES

The third row is the proof that matters: the old assertion was green with the D-pad trap present.

Gates, all from the worktree at this head:

node tools/surface-manifest.mjs --check -> matches this tree, 185 surfaces, 796 cells, exit 0
node tools/redesign-coverage.mjs                                                       exit 0
GITHUB_BASE_REF=redesign/main node tools/check-suppressions-ratchet.mjs
                                        -> web 14 -> 14 ok, mobile 16 -> 16 ok,        exit 0
node tools/test-tools.mjs               -> ORBIT TOOLS GATE OK, 1811 assertions,       exit 0
node .claude/hooks/test-hooks.mjs       -> ORBIT HOOKS OK,                             exit 0
npx turbo run type-check --concurrency=1 --force -> Tasks: 3 successful, 3 total
npx turbo run lint --concurrency=1      -> Tasks: 3 successful, 3 total, 0 errors
npx turbo run test --concurrency=1 --force -- --coverage -> Tasks: 4 successful, 4 total
                                           eslint-rules 392, shared 2081, mobile 2688, web 2969
mobile focused, 9 files                 -> 9 files passed, 93 tests passed
web focused, 7 files                    -> 7 files passed, 37 tests passed

Round 16 follow-ups, filed rather than widened

  • #608, two RadioRow implementations and the unselected glyph at 1.5px --hairline-strong
    against DESIGN.md:778's 2px --track-empty. Entirely pre-existing on redesign/main.
  • #609, the reminder timing and frequency unit pickers. Their accessibilityRole="button" with
    accessibilityState={{ selected }} is correct usage for a selectable button, so it is a different
    subject from the radio fix above.
  • packages/shared/src/utils/radio-navigation.ts is tested only from apps/web, against D84. Shared
    coverage passes today at 95.3 statements against a 95 threshold, which is 0.30 points of margin.
  • Nothing asserts that app.config.js still registers the focus plugin. Drop it from the
    withLocalPlugin chain and enableImperativeFocus() stays false, making every Android radio
    group's focus() a silent no-op while every test stays green.

One correction to the salvage commit message

74b891a3 says the radio conversion "replaced the browser's default focus ring on the web pickers,
so a keyboard user lost the only cue". All three touched controls were already <button> elements
and globals.css carries no rule stripping their outline. Nothing was lost; the commit ADDS an
accent ring where the browser default was showing. The change is right and the claim was not.

Review substitution

Pullfrog could not review this pull request: it and Codex share one OpenAI meter, exhausted until
2026-09-22 07:23. A separate agent reviewed at this exact head, in the worktree carrying it, and its
full review is posted as a pull request comment. That substitution is named here so nobody later
reads this merge as having had the usual reviewer.

@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
orbit-ui-mobile-web Ignored Ignored Sep 18, 2026 9:48pm UTC

Request Review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The mobile keyboard and focus path is unavailable in the shipped React Native stable runtime, so the mobile half of this fix does not work and removes the previous native focus traversal from unselected radios.

Reviewed changes the full initial PR at 42a97313, including all 28 changed files and the surrounding radio-group callers and installed React Native implementation.

  • Shared navigation — centralizes wrapped Arrow, Home, and End index calculation in @orbit/shared.
  • Web radio groups — reuses the existing registry for segmented controls, select-check rows, goal-type controls, and preference pickers while preserving DOM-order navigation.
  • Mobile radio groups — adds a registry and roving tab stops for segmented controls, select-check rows, goal-type controls, selection fields, preference pickers, and time-picker columns.
  • Regression coverage — adds focused web and mobile tests for selection, focus movement, disabled-option skipping, wrapping, and pointer or touch behavior.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/components/ui/radio-group.tsx Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 5 is 8cdef1b4. Thread PRRT_kwDOR5Siws6i5epx is closed by making the path RUN, not by deleting it.

Thomas decided this on 2026-09-16: "just build it, we dont test now ... when we finish the WHOLE REDESIGN, i will generate an apk and test on my phone." So the earlier plan to delete the inert handler is withdrawn, and #73's round 4 order is superseded on this point.

The two flags are on, through the public API

ReactNativeFeatureFlags.override(object : ReactNativeFeatureFlagsDefaults() {
  override fun enableKeyEvents(): Boolean = true
  override fun enableImperativeFocus(): Boolean = true
})

No dependency patch, no patch-package, no new dependency. ReactNativeFeatureFlags.kt:599-600 declares @JvmStatic public fun override(provider: ReactNativeFeatureFlagsProvider) and its KDoc at :594-596 gives this exact shape.

The worker put it in a config plugin rather than in MainApplication.kt, and it was right to. .gitignore:86 ignores apps/mobile/android/, so a direct edit would be destroyed by the next prebuild. withMainApplication in plugins/with-android-release-build-fixes.js applies it every time instead, and it THROWS when either anchor is missing, so a React Native upgrade that moves the import fails loudly rather than silently dropping the override.

What the review was right about, and what changes

  • ReactSurfaceView.kt:76-78 builds the JSKeyDispatcher only under enableKeyEvents(), and :200-207 returns early without it. That is why nothing reached JavaScript. It reaches it now.
  • test-mocks/react-native.ts:76 made every mocked ref focus successfully, which is why a dead path looked tested. It now honours tabIndex !== -1, matching the platform's focusability rule, and the radio group had to gain a roving tab index so the target is tabbable before focus is called.
  • Home and End stay web only. KeyEvent.kt:132-153 maps the complete non-printable set and neither appears, so Android cannot produce them. The shared helper at packages/shared/src/utils/radio-navigation.ts:7 still handles them for web. No mobile test fabricates either key.

Manual steps, for the end-of-redesign APK

enableImperativeFocus changes focus dispatch for every screen, and no test here can observe that. On the APK, check that focus still behaves on the habit form, Perfil, the calendar and Astra chat, and that arrow keys move selection in a radio group. None of that is claimed to have passed.

Evidence, captured in this session on the pushed head

Red first, by reverting the plugin, the mock and the radio group to 8cdef1b4~1:

Test Files  2 failed (2)   Tests  2 failed | 8 passed (10)

Then restored:

Test Files  6 passed (6)   Tests  31 passed (31)
npx turbo run type-check --force   Tasks: 3 successful   Cached: 0 cached   exit 0

@pullfrog review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

The generated Android app now performs React Native's one-shot feature-flag override twice during startup, so the application will terminate in MainApplication.onCreate before React Native initializes.

Reviewed changes since the prior Pullfrog review at 42a97313, focusing on round 5 8cdef1b4:

  • Enabled native event gates — injected the Android key-event and imperative-focus overrides through the Expo config plugin.
  • Corrected roving focus timing — made the destination tabbable before focusing it and tightened the React Native host mock to reject tabIndex={-1}.
  • Aligned Android coverage — removed fabricated Home and End events from mobile tests while retaining the shared web behavior.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/plugins/with-android-release-build-fixes.js Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

The generated Android startup now installs two React Native feature-flag providers and throws before the app renders.

Reviewed changes since the prior Pullfrog review at 42a97313, including the new Android runtime adapter and the resulting mobile focus sequence.

  • Enabled Android interfaces — Added a generated MainApplication override for React Native key events and imperative focus.
  • Deferred mobile focus — Made the arrow-key destination tabbable before moving focus after render.
  • Scoped Android keys — Removed unsupported Home and End expectations from mobile coverage while retaining all arrow directions.
  • Strengthened regressions — Made the host mock reject focus on tabIndex={-1} and added config-plugin output coverage.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/plugins/with-android-release-build-fixes.js Outdated
thomasluizon and others added 2 commits September 16, 2026 14:04
Resolves apps/mobile/components/ui/motion-pressable.tsx as the union of both
sides: 988 added the optional `active` prop for the dragged Progresso card and
this branch added `forwardRef` so a radio row can move focus imperatively. Both
are kept and the shared body already used both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

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 since the prior Pullfrog review at 8cdef1b4, including the startup correction and subsequent merge-forward:

  • Replaced the conflicting override — Moved custom key and focus flags after loadReactNative(this) and used a fresh accessor so React Native's one-shot release provider remains intact.
  • Preserved release-level behavior — Delegated every existing flag to the selected experimental, canary, or stable provider while changing only key events and imperative focus.
  • Strengthened startup coverage — Rejected the former override(...) call and asserted the generated provider selection and lifecycle ordering.
  • Resolved the merge conflict — Retained both MotionPressable's base-branch active state and this PR's forwarded native ref.

Pullfrog  | View workflow run | Using GPT Sol𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The mobile registry can navigate out of visual order after filtering, so the searchable timezone picker may select the wrong adjacent option.

Reviewed changes the full PR at 144254cb, including all 30 changed files and the surrounding radio-group callers and installed React Native implementation.

  • Shared navigation — centralizes wrapped arrow, Home, and End index calculation in @orbit/shared.
  • Web radio groups — extends the existing roving registry to goal types, preference pickers, select rows, and segmented controls while preserving DOM-order navigation.
  • Mobile radio groups — adds roving tab stops, disabled-item skipping, selection movement, and post-render focus across the corresponding Android controls.
  • Android runtime adapter — configures React Native key events and imperative focus after the release-level provider initializes, while delegating all other feature flags.
  • Regression coverage — exercises web and mobile selection, focus, wrapping, disabled options, touch or pointer behavior, host focusability, and generated Android startup ordering.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/components/ui/radio-group.tsx Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

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 since the prior Pullfrog review at 144254cb, focusing on the rendered-order correction:

  • Preserved rendered order — Required each mobile radio item to supply its current array index and sorted enabled registry entries by that position, so remounts no longer change arrow-key order.
  • Updated every mobile caller — Passed rendered positions through goal types, preference pickers, segmented controls, selection rows, time fields, and navigation test fixtures.
  • Covered the remount regression — Added a searchable-timezone test that narrows and restores the list before verifying ArrowDown selects the visually adjacent option.

Pullfrog  | View workflow run | Using GPT Sol𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

Android's native directional focus traversal still runs after the new JavaScript handler, so repeated arrow navigation can escape the radio group instead of advancing selection reliably.

Reviewed changes at a559477e, covering the complete radio-navigation implementation and its Android runtime integration:

  • Shared navigation — Centralizes wrapped Arrow, Home, and End index calculation in @orbit/shared.
  • Web radio groups — Extends roving focus and selection to segmented controls, select rows, goal types, and preference pickers while preserving DOM order.
  • Mobile radio groups — Adds indexed item registration, disabled-item skipping, roving tab stops, and post-render focus across the corresponding Android controls and time columns.
  • Android runtime adapter — Enables key events and imperative focus after React Native installs its release-level provider while delegating all other flags.
  • Regression coverage — Exercises selection, focus, wrapping, filtering, disabled options, touch or pointer behavior, and generated startup ordering.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/components/ui/radio-group.tsx Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

Android forward focus now changes values on entry and inserts an invisible stop on exit; these regressions must be corrected before merge.

Reviewed changes since the prior Pullfrog review at a559477e, covering the native-focus rewrite and its follow-up typing change:

  • Replaced synthetic key handling — Removed mobile onKeyDown, preventDefault(), roving tab stops, and imperative focus in favor of Android focus traversal and focus-driven selection.

  • Added native focus routing — Wired every enabled option to adjacent native handles for directional wrapping and to a shared post-group target for forward traversal.

  • Reworked mobile coverage — Assigned distinct host handles in the React Native mock and changed focused suites to assert focusability, native targets, and onFocus selection.

  • Merged the latest base — Brought redesign/main forward without a radio behavior change, then typed the Android focus props at the final head.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/components/ui/radio-group.tsx Outdated
Comment thread apps/mobile/components/ui/radio-group.tsx Outdated
Comment thread apps/mobile/plugins/with-android-release-build-fixes.js Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The hidden focus stop and production feature override are fixed, but the existing forward-entry thread remains unresolved: default Tab focus can still select an unchecked radio and close picker sheets. __setFocusImpl also remains as the test-only cleanup from the feature-flag thread.

Reviewed changes since the prior Pullfrog review at 522fb746, focusing on 0cbcd183:

  • Removed the invisible focus stop — Deleted the focusable sentinel and all nextFocusForward routing while preserving directional wrapping among visible enabled radios.

  • Removed unused Android feature overrides — Deleted the generated MainApplication feature-flag injection and its generated-source regression now that native focus routing consumes neither flag.

  • Aligned the host fixtures — Made findNodeHandle reject synthetic objects without a native tag and updated keyboard-aware scroll tests to mount a real mocked TextInput host.

Pullfrog  | Fix it ➔View workflow run | Using GPT Sol𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The Android entry redirect depends on a generic focus command disabled in the shipped stable runtime, so hardware-keyboard entry still lands on an unchecked radio.

Reviewed changes since the prior Pullfrog review at 0cbcd183, covering the focus-entry correction and its regression coverage:

  • Distinguished entry from movement — Tracked focus within each mobile radio group so initial entry does not select an unchecked landing item while directional movement still selects its destination.
  • Redirected entry to selection — Attempted to move initial focus to the checked enabled item and left focus in place when no item is checked.
  • Reset group focus state — Deferred blur cleanup so focus transfers within a group remain movement while a completed exit makes the next focus an entry.
  • Strengthened mobile coverage — Added entry, re-entry, no-selection, and time-column regressions and initialized existing focus-selection tests from an in-group item.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using GPT Sol𝕏

Comment thread apps/mobile/components/ui/radio-group.tsx Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The web correction introduces no new issue, but this PR remains blocked by the open Android focus-entry thread from the prior review.

Reviewed changes since the prior Pullfrog review at bb3ae15a, focusing on the web duplicate-selection correction:

  • Guarded selected destinations — Focused the keyboard-navigation destination while invoking its selection handler only when that radio is not already selected.
  • Covered endpoint keys — Added an exact regression proving End on the already-selected last radio retains focus without firing onChange again.

Pullfrog  | Fix it ➔View workflow run | Using GPT Sol𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

@thomasluizon

Copy link
Copy Markdown
Owner Author

Independent review at head 8ddcca0e: REQUEST CHANGES.

Codex and Pullfrog share one exhausted OpenAI allowance until 2026-09-22, so a separate Claude agent reviewed this and the orchestrator verified the load-bearing citations against installed source. Stating the substitution rather than hiding it: this is not a Pullfrog verdict.

First, a correction to a correction this repository published yesterday

A review on this pull request cited ReactNativeFeatureFlagsDefaults.kt:86,92 as reading false. A later comment "corrected" that to true. The original citation was right and the correction was wrong. Read in installed react-native 0.86.3 on this machine, node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt:

:86  override fun enableImperativeFocus(): Boolean = false
:92  override fun enableKeyEvents(): Boolean = false

Both read false. That strengthens this pull request's case for the config plugin rather than weakening it: ReactViewManager.kt:472-476 gates handleFocus behind a flag that is off until the plugin turns it on.

P1-1: every arrow key press in the Timezone and Week-start pickers writes to the server

radio-row.tsx now runs selection-follows-focus: if (!focusedItem.selected) onSelect?.() inside the group's onFocus. select-check.tsx passes onSelect: onPress, preferences-sections.tsx passes onSelect={onTimeZoneChange}, apps/mobile/app/preferences.tsx:186 routes that to use-preference-controls.ts:85-94, and that calls performQueuedApiMutation({ type: 'setTimeZone', endpoint: API.profile.timezone, method: 'PUT' }), which apps/mobile/lib/queued-api-mutation.ts:26-41 executes immediately when online.

Open Preferences, open Timezone, attach a hardware keyboard, press ArrowDown ten times to reach your zone. Ten onFocus events, ten onSelect calls, nine wrong time zones written to the account plus nine optimistic patchProfile cache writes before the right one. Offline the SQLite queue collapses them on dedupeKey: 'profile-timezone'; online nothing does. profile-settings-content.tsx:196 carries the identical wiring and weekStartDay behaves the same way.

Web is the same code path, not an adapter difference: apps/web/components/ui/radio-row.tsx calls handlersRef.current.get(nextItem.id)?.() on the same rule, and preference-picker-sheet.tsx passes onSelect={onTimeZoneChange} through to apps/web/app/(app)/preferences/page.tsx:120.

This is new in this pull request, on both platforms. Before it the web RadioRow was a plain <button onClick> with no group registration, no tabIndex and no onKeyDown, and the mobile one was a plain Pressable with no focusable and no onFocus. Arrow keys reached neither list. So the change trades "one wrong write plus a close" for "one write per keypress".

Both new tests assert the defect as correct. The mobile test asserts onTimeZoneChange was called exactly once after two focusHost calls; the web test asserts it after one ArrowDown.

The shape that is already right in this codebase is apps/mobile/components/ui/time-field.tsx:278-283: focus moves a DRAFT, and only applyDraft commits. TimeField's own onSelect only updates pickerDraft, so it is not affected. Do the same for the preference pickers: focus moves a draft, onCommit persists.

Red first: focus three timezone rows in sequence and assert onTimeZoneChange was called once. It fails with three calls today.

Secondary effect of the same wiring: ReactViewManager.kt:377-389 installs an OnClickListener on any focusable view and Libraries/Pressability/Pressability.js:531-549 turns that click into onPress, so a D-pad centre press on a row focus already selected calls onSelect a second time through onActivate.

P1-2: every radio group becomes an arrow-key trap on Android

navigationProps returns focusable: true plus nextFocusDown, nextFocusLeft, nextFocusRight and nextFocusUp, all four resolved from a modulo wrap over the group's own enabled items. ReactViewManager.kt:204-227 maps those onto View.nextFocusDownId, nextFocusForwardId, nextFocusLeftId, nextFocusRightId and nextFocusUpId, which are Android's user-specified focus overrides. So this replaces the platform's geometric focus search in all four directions for every radio group, and the last item points back at the first.

time-field.tsx now builds one RadioGroup per column. Focus hour 14, press ArrowRight to reach minutes: focus goes to hour 15 and the draft hour changes, because nextFocusRight is the next hour in the same column. No arrow direction reaches the minutes column, the AM/PM column, or the common.done button. ArrowDown cycles 15, 16 ... 23, 00 ... forever. segmented-control.tsx is a horizontal control and still binds nextFocusUp and nextFocusDown to the previous and next segment, so ArrowDown from a segment on Habits selects the next segment instead of moving into the habit list.

The pull request's own test asserts the trap: expect(first.props.nextFocusUp).toBe(handles[3]) and expect(last.props.nextFocusDown).toBe(handles[0]).

This is the platform-adapter defect named in the earlier rounds, one level up. The group already makes every option individually focusable, which IS the platform-correct answer. Overriding all four D-pad directions on top of it imports the web wrap convention into the native adapter, and on the web wrap is right only because Tab exits the group. A D-pad has no Tab. Leave nextFocus* unset and keep focusable and onFocus.

Red first: assert that no option in the hours column carries a nextFocusLeft or nextFocusRight equal to another hour's __nativeTag. It fails on every option today.

P2-1: the value now changes while the sheet is still dismissing

The pull request replaces closePicker(() => { onHidden(); apply() }) with commitSelection = () => closePicker(onHidden) and moves apply to press time. So onThemeModeChange re-resolves every token and the sheet repaints mid-dismiss, and handleLanguageChange at use-preference-controls.ts:38-56 calls i18n.changeLanguage synchronously, re-translating the closing sheet's own labels.

apps/mobile/CLAUDE.md says Sheet owns the whole close path and that closeSheet(action) exists for work that has to run after the sheet is gone. That seam is still used for onHidden and no longer for the value change. The one test that covered the ordering is deleted with selection-field.tsx, and nothing in the new preference-picker-sheet.test.tsx uses sheetTestControls.defer.

Red first: make the closePicker double defer its exitAction, press a language row, and assert onLanguageChange has not been called before the deferred action runs.

P2-2: deleting selection-field.tsx leaves three committed artifacts pointing at it

  • tools/surface-manifest.mjs:103 still lists "components/ui/selection-field" in MOBILE_OVERLAY_BASES.
  • tools/__tests__/surface-manifest.mjs:38-39 still builds a fixture creating selection-field.tsx and a frequency-field.tsx importing SelectionField.
  • .claude/manifests/surfaces.json:11572, 11586, 11600, 11614 still declare four surfaces whose sourceFile is that file.

Neither guards.yml nor tsc reads a string inside a .mjs array or a JSON manifest, which is exactly why CI stayed green. The completeness gate reads that manifest as its scope list and would enumerate four surfaces nobody can open.

This pull request deletes a drawn surface, so the repository rule applies in full: regenerate the manifest AND move the ids into tools/redesign-groups.json's deleted section with the decision that removed them, in the same commit. Coordinate with #595, which is regenerating that manifest for an unrelated reason; whichever lands second rebases onto the other rather than hand-merging, because a generated baseline is resolved by regenerating.

P3-1: two new tests cannot go red

  • apps/web/__tests__/components/ui/select-check.test.tsx clicks a row that renders <button disabled> and asserts onChange was called once. React does not deliver a synthetic click to a disabled form control, so the assertion holds no matter what the component does; drop the disabled attribute and it still passes. Replace it with expect(...).toBeDisabled().
  • apps/mobile/__tests__/components/ui/select-check.test.tsx, "models imperative focus for TextInput and focusable views", renders a bare <View focusable /> and a bare <TextInput /> and asserts the mock's focusImpl ran. The only module under test is apps/mobile/test-mocks/react-native.ts. It is a test of the test double, in a suite named for select-check.

P3-2: index is a required mobile-only prop that duplicates child order

Mobile types RadioRow as RadioRowProps & { index: number } while web keeps plain RadioRowProps, and the shared contract is not extended, so the divergence lives in an ad-hoc intersection type. No call site passes a wrong index today, so there is no failure to name. It is a hazard with no gate: a caller that miscounts silently reorders D-pad navigation.

The two direct questions, answered

Call sites that can still reach the old close-on-arrow behaviour: none. RadioGroup.onFocus never calls commit; only onActivate does, and that runs only from onPress. Every RadioRow consumer on both platforms now sits inside a RadioGroup: mobile support.tsx:183, move-parent-dialog.tsx, all four branches of preferences-sections.tsx; web support-form.tsx:129, move-parent-overlay.tsx and the four preference branches. The useRadioGroupItem fallback does select on bare focus with no entry guard, but no call site reaches it.

Focusable elements with nothing visible: none. The transparent sentinel from the earlier attempt is gone, and the only nextFocusForward left is a test asserting it is undefined.

Verified clean

selectAndClose is removed on both platforms. RadioGroup gained onCommit and useRadioGroupItem returns onActivate. No mobile test fabricates a Home or End key: KeyEvent.kt:149-152 carries only the four DPAD arrows and :56 falls back to UNIDENTIFIED, and the mobile side asserts onKeyDown === undefined; Home and End appear only in web tests. The focus-provenance mechanism is real: BaseViewManager.java:178-181 attaches a focus-change listener unconditionally, :1036-1038 dispatches FocusEvent, :750-759 registers topFocus with bubbled: onFocus, and FocusEvent.kt:21 puts the view tag in target. The config plugin compiles against the real prebuild template and its import injection is idempotent. move-parent-dialog's layout is preserved because styles.sheetBody carries no gap. Disabled rows are correctly unfocusable, since navigationProps returns { focusable: false } for any id absent from enabledItems and Pressable.js:258 maps that through.

thomasluizon and others added 3 commits September 18, 2026 14:27
…roid

Arrow keys inside the Timezone and Week-start pickers wrote one server
mutation per keypress, because selection followed focus straight into
onTimeZoneChange and onWeekStartChange. The pickers now hold a draft and
persist it from onCommit, the shape time-field.tsx already used, so the
value changes once and after the sheet has finished closing.

Mobile radio groups no longer set nextFocusDown, nextFocusLeft,
nextFocusRight or nextFocusUp. Those are Android focus overrides, so a
per-group modulo wrap trapped every arrow key inside one time column and
inside the segmented control. Every enabled option stays focusable and
the platform's own focus search runs. Web keeps its roving tabIndex.

A press on the row that focus already selected now commits without
selecting again, so a D-pad centre press cannot write twice.

The mobile-only index prop is gone: entry focus redirects to the checked
option, and when none is checked it stays where the platform put it, so
no caller has to count its own children.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A press on a row that is already selected now commits without calling
onSelect again, so the first MoveParentOverlay case could no longer
observe onSelectOption. It rendered with selectedMoveParentId null,
which makes the root row the checked one, and then clicked the root row.

The case renders with the tree destination checked instead, so the root
row is unchecked and the press that follows selects it. That is what the
case is named for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

Independent re-review at head f104e11a: both P1s are genuinely fixed on both platforms, each covered by a test that goes red on the old code. Two P2 and several P3 are owed in this same pull request under the maximum-implementation rule.

Codex and Pullfrog share one exhausted OpenAI allowance until 2026-09-22, so a separate Claude agent reviewed this against the worktree carrying this head. Stating the substitution rather than hiding it: this is not a Pullfrog verdict.

One finding is REJECTED, and the rejection matters more than the finding

The review claimed that node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/KeyEvent.kt:134-144 maps KEYCODE_MOVE_HOME to "Home" and KEYCODE_MOVE_END to "End", and that lines 149-152 are the ALT and META codes rather than the DPAD ones. That is wrong, and the orchestrator checked it rather than repeating it.

$ grep -n "MOVE_HOME\|MOVE_END\|KEYCODE_TAB\|UNIDENTIFIED" KeyEvent.kt
56:      else -> KEY_NAME_MAP[keyCode] ?: UNIDENTIFIED
61:    return CODE_MAP[keyCode] ?: UNIDENTIFIED
65:    private const val UNIDENTIFIED = "Unidentified"
111:              AndroidKeyEvent.KEYCODE_TAB to "Tab",
138:              AndroidKeyEvent.KEYCODE_TAB to "Tab",

Zero matches for MOVE_HOME and zero for MOVE_END in the entire file. KEY_NAME_MAP runs from :132 to :154 and its last four entries are KEYCODE_DPAD_UP, DOWN, LEFT and RIGHT at exactly :149-152. So the existing record is right: Android's key map carries no Home and no End, and a mobile test that fabricates either asserts something the platform cannot produce.

One real correction does come out of it: Android DOES map Tab, at :111 and :138. So the divergence should not be justified by "a D-pad has no Tab". The correct reason is the one this pull request actually implements and it is stronger: mobile uses no key events at all, zero onKeyDown in apps/mobile production, so the adapter is "web roving tabindex plus ARIA keys, mobile platform focus search". That sentence belongs on the ticket.

This is the second citation tonight that looked authoritative and was not. Read the file.

P1-1 is clean, end to end, both platforms

Mobile: radio-row.tsx:114 still runs onSelect on focus, and onSelect is no longer the writer. select-check.tsx:104-108 passes onSelect: onPress, preferences-sections.tsx:42-51,56-62 makes that selectDraft(option.value) which writes only draftRef.current and setDraft, and commitDraft is the only thing that calls onCommit. radio-row.tsx:170-173 makes onActivate the sole caller of commitGroup(), and onActivate is bound only to onPress at select-check.tsx:114. From there preferences-sections.tsx:523 reaches onTimeZoneChange, apps/mobile/app/preferences.tsx:186 reaches the mutation, and use-preference-controls.ts:85-94 reaches the PUT. Ten ArrowDown presses now produce zero network calls. Web is the same chain through radio-row.tsx:142 and preference-picker-sheet.tsx:34-37,202.

The two tests that asserted the defect as correct now assert the fix rather than having been deleted: the web pair asserts onTimeZoneChange was NOT called after three arrow moves and once after the activation, and the mobile twin is new and does the same through focus moves and a press.

P1-2 is clean, and its consequence holds

Zero mobile production hits for nextFocus, reproduced independently. Every option is still individually focusable: radio-row.tsx:177 returns focusable: !disabled, and Pressable.js:258 maps that through, so disabled rows are correctly excluded. index is genuinely gone rather than relocated: packages/shared/src/contracts/lists/RadioRow.ts:3-18 has no index, and the exact-width type test at RadioRow.type-test.ts:50-62 enumerates eleven fields, so a re-added prop fails tsc.

The test that asserted the trap now asserts its absence, in the right shape. time-field.test.tsx:280 asserts hours.filter((hour) => hourHandles.has(hour.props[direction])) is empty for all four directions, which reddens exactly when a direction points back into the same column.

The other three ordered confirmations are clean

The value no longer changes mid-dismiss: preferences-sections.tsx:481-484 runs onHidden() then apply() inside closePicker, and sheet.tsx:82-90 runs exitAction() inside handleDidDismiss, after the native dismissal. A new test proves it and replaces the coverage lost with selection-field.test.tsx: it captures the exit action, asserts onLanguageChange was not called at press time, runs the action, and asserts it fires.

Both non-reddenable tests are gone and their replacements are real: the web one now clicks the ENABLED row and asserts the disabled one toBeDisabled(), which reddens if disabled={disabled} is dropped; the bare-View mobile one is replaced by a genuine out-of-group focus target that reddens if the movedWithinGroup guard at radio-row.tsx:103-112 is removed.

P2: the config plugin's test only exercises the already-patched file, never the pristine one a release build produces

apps/mobile/plugins/with-android-release-build-fixes.js:162-193 rewrites MainApplication.kt on every prebuild, and root .gitignore:86 ignores apps/mobile/android/, so a CI or EAS build always starts from a pristine Expo template with no ReactNativeFeatureFlags* imports and no override block.

apps/mobile/__tests__/plugins/with-android-release-build-fixes.test.ts:16-67 defines one fixture, MAIN_APPLICATION_TEMPLATE, and it already contains the legacy block at :48-51. The single new test at :222-241 feeds only that fixture, so the pristine path, where the plugin must add all five imports after the anchor and insert the block after loadReactNative(this), is never executed.

If the Expo template changes that indentation or the DefaultNewArchitectureEntryPoint import line, the plugin throws or silently skips, enableImperativeFocus stays false, and every entry redirect at radio-row.tsx:109 becomes a silent no-op on the shipped APK, with a green suite. Add a second fixture that is the pristine template, feed it to resolveMainApplication, and assert the five conditions plus the ReactNativeFeatureFlagsProvider import.

P3s, all small, all in the same round

  • One vacuous test. select-check.test.tsx, "renders no empty focusable target alongside the visible radio options", searches for a childless host with focusable === true. No product change in this pull request can produce one: nothing in RadioGroup, RadioRow or RadioGlyph renders a childless focusable host, and styles.radio's empty View never sets focusable. Make it real by asserting the focusable-host count equals the option count, which reddens both if a sentinel returns and if a row loses focusable.
  • radiogroup now owns non-radio children on both platforms. move-parent-dialog.tsx:156-175 and move-parent-overlay.tsx:158-170 moved the destinations eyebrow, and on mobile the noSearchResults text, inside the group. TalkBack and NVDA announce item positions including the text node, so "2 of 4" stops matching the four destinations. Move the eyebrow above the group on both platforms, keeping it as the group's accessible name.
  • An unused disabled prop added to both select-check RadioRows at apps/mobile/components/ui/select-check.tsx:91 and apps/web/components/ui/select-check.tsx:83; neither production caller passes it and only the new tests use it. Delete it, or name the upcoming caller.
  • PickerOptions derives state from a prop and never resyncs. preferences-sections.tsx:42-43 and preference-picker-sheet.tsx:28-29 initialise draft and draftRef from selected with no effect. Open the timezone picker on a cold start before profile resolves and draft is null, so no row is checked once the profile arrives and radio-row.tsx:106 finds no checkedItem, which also breaks D-pad entry. Key PickerOptions on selected or reset the draft in an effect.
  • The plugin deletes the ReactNativeFeatureFlagsDefaults import unconditionally and its usage conditionally (:171-174 versus :185), so a stale android/ tree patched by an older revision loses the import, keeps the block, and fails Kotlin with an unresolved reference. Gate the import removal on the block removal.
  • dangerouslyForceOverride's return value is discarded at :184-196. Installed ReactNativeFeatureFlags.kt:631-645 says it returns the flags accessed before the call and that you should log it, and the call sits after loadReactNative(this), which is exactly when flags may already have been read. Assign it and log it.
  • One dead line, second.props.onBlur?.() in the re-entry test: useRadioGroupItem returns no onBlur, so it is always a no-op and falsely suggests blur takes part in the entry logic.

Verified clean, with the line that settles each

The focus mechanism is sound rather than invented: BaseViewConfig.android.js:127-131 registers topFocus with captured: 'onFocusCapture', BaseViewManager.java:175-181,1013-1041 attaches the listener and dispatches new FocusEvent(surfaceId, view.getId()), and FocusEvent.kt:21 puts the tag in target, which is what focus-provenance-view.tsx:29 reads. findNodeHandle is real at RendererImplementation.js:48, Pressability.js:439-444 really delivers onFocus, and View.js:82-84 reads processedProps.focusable = !tabIndex verbatim.

SelectionField is fully deleted, zero hits across apps/. No new i18n key and no ICU risk. No banned final name. The any in the new test helpers is gate-sanctioned by apps/mobile/eslint.config.js:261-267, so re-flagging it would be noise under core rule 4. MotionPressable.focusRef is not dead; progress-content.tsx:301 still uses it.

Every other new test was checked by naming the product change that reddens it, across the mobile select-check, goal-type-selector, segmented-control, move-parent-dialog, time-field and support-screen suites and the web select-check and time-field ones.

One pre-existing divergence worth a follow-up ticket rather than a block: the web time picker is role="listbox" with role="option" while mobile is radiogroup with radio. This pull request only adds tabIndex, onKeyDown and refs on web, so it did not introduce it.

The tools/surface-manifest.mjs:103 and .claude/manifests/surfaces.json drift is deliberately excluded and waits on pull request 1024.

thomasluizon and others added 2 commits September 18, 2026 15:36
The plugin test fed one fixture that already carried the legacy override
block, so the pristine Expo template path, the one every CI and EAS build
starts from because .gitignore:86 ignores apps/mobile/android/, was never
executed. A second fixture now runs that path and asserts all five imports
land after the anchor. Removing the Provider import reddens the new test
and leaves the old one green, which is the gap it closes.

The plugin used to drop the ReactNativeFeatureFlagsDefaults import
unconditionally while removing its block only on an exact match, so a tree
an older revision patched lost an import its surviving block still needed.
The import now goes only once nothing constructs the class. Import
detection matches a whole line, so the longer ReactNativeFeatureFlagsProvider
no longer stands in for the shorter ReactNativeFeatureFlags.

dangerouslyForceOverride returns the flags read before the call and its
KDoc at ReactNativeFeatureFlags.kt:631-645 says to log it. The generated
onCreate now keeps that value and warns when it is not null.

The move-parent eyebrow and the mobile no-results text moved out of the
radiogroup on both platforms. role=radiogroup owns radio children, so a
text node inside it made TalkBack and NVDA count a fifth item.

PickerOptions took its draft from a prop once and never resynced, so a
cold start where the profile resolves after the first render left every
row unchecked. An effect on the incoming value now moves the draft with it.

The unused disabled prop is gone from both select-check RadioRows; no
production caller passed it. The disabled-skipping coverage moved to the
move-parent suites, where the list RadioRow carries a real disabled
destination. The mobile focusable-target test now counts focusable hosts
against options, and the dead onBlur call in the re-entry test is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design review of 398103a found two DESIGN.md breaks in the eyebrow
this branch just moved above the move-parent group.

DESIGN.md:720 defines the eyebrow role as Geist Mono 12/500 +0.08em
uppercase on fg-3. The web copy used var(--font-sans), so the same string
rendered in two families across platforms while mobile already used
GeistMono_500Medium. It now uses var(--font-mono), which is what
components/ui/section-label.tsx has always used for the same role.

DESIGN.md:351 requires the gap between two groups to be at least twice the
gap within one. The mobile eyebrow sat 4px above a group whose rows are 4px
apart, so a label meant to head the whole list read as one more row. Its
bottom margin is now 8.

The web eyebrow also carried margin 2px 0 0 from its old position inside the
group. The parent column already supplies an 8px gap, so the margin is gone
and the gap is the only spacing mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

The record on KeyEvent.kt and ReactNativeFeatureFlagsDefaults.kt is settled, and nobody involved was careless

Round 3's report says both "corrections to the record" in its order are wrong, that KeyEvent.kt:114,115,143,144 map Home and End, and that ReactNativeFeatureFlagsDefaults.kt:86,92 both read true. That is accurate about the tree it read and wrong about the tree this branch will merge into, and the reason is worth more than either reading.

Two worktrees on this machine hold different react-native 0.86.3 source from one lockfile entry. Measured across every worktree, reading line 86 of ReactNativeFeatureFlagsDefaults.kt:

false  installed 2026-09-17  orbit-ui-mobile            (main checkout)
true   installed 2026-09-16  ticket-543-radio-keys      (this branch's worktree)
false  installed 2026-09-17  ticket-67-onboarding
false  installed 2026-09-17  ticket-460-notify-announce
false  installed 2026-09-18  ticket-595-surface-manifest
false  installed 2026-09-18  ticket-598-claude-engine

Five to one. Every install from 2026-09-17 onward reads false; only the 2026-09-16 install reads true. Both report version === "0.86.3", package-lock.json carries one entry with one integrity hash, and there is no patches/ directory and no patch-package postinstall.

The two files differ concretely:

file 2026-09-17 onward this worktree, 2026-09-16
ReactNativeFeatureFlagsDefaults.kt 6910 bytes, :86 and :92 both false 6908 bytes, both true
.../events/KeyEvent.kt 6547 bytes, no Home, no End anywhere, KEY_NAME_MAP ending on the four DPAD codes at :149-152 6779 bytes, MOVE_HOME and MOVE_END at :114,115 and :143,144

So every citation in this chain was honest. A review said false; a comment "corrected" it to true; a third read confirmed false; this round reported true with Home and End. Each read the installed source, as code standard 8 requires. The standard assumes one installed source per repository, and that assumption was false here.

What it means for this pull request, and it is good news

Against the bytes this branch merges into, enableImperativeFocus defaults false. So the config plugin is the MECHANISM the focus behaviour depends on, not a redundant pin, and the pull request's premise holds. The no Home, no End constraint also holds against those bytes, so a mobile test must not fabricate either key.

Filed as #601, which owns establishing why the installs diverge and giving a session a way to check its own tree before citing it: https://github.com/thomasluizon/orbit-tickets/issues/601

Two things this branch owes because of it

  1. Reinstall this worktree before the next round, so its suites run against the same react-native every other worktree has. Do it deliberately and re-run the branch's suites afterwards, because it changes what the tests see.
  2. Correct the pull request body, which currently records the true reading and the Home/End mapping as facts about react-native 0.86.3. State the divergence instead, and say which bytes the claim is made against.

Round 3 itself is accepted

Two commits, 398103ae carrying the two P2s and six P3s across 14 files with each new assertion shown red first, and 29998d8d fixing two DESIGN.md breaks a design review found in the eyebrow the round had just moved: the web copy sat in the sans family where DESIGN.md:720 says Geist Mono, and the mobile label sat 4px above a group whose rows are 4px apart, against the 2x rule at DESIGN.md:351. Catching a design break inside your own fix, in the same round, is the right instinct. Root type-check forced green, root lint 0 errors, suppressions flat, 131 tests across 17 radio suites.

Its last observation is also right and already true: pull request 1024 merged as 0f96925b, four minutes after the order called it unmerged, so Surface Manifest Drift is live on redesign/main and this branch goes red on committed surface no longer exists in the tree: m-overlay-ui-selection-field. The deferred manifest round can run now, and it carries the reinstall above.

thomasluizon and others added 2 commits September 18, 2026 16:37
…rash

The branch deletes apps/mobile/components/ui/selection-field.tsx, so the
committed inventory still named a surface the tree no longer has and the new
Surface Manifest Drift job went red on it. Regenerate the manifest, move
m-overlay-ui-selection-field into the deleted section with the decision that
removed it, and drop the MOBILE_OVERLAY_BASES entry that named the same file.
With that entry gone the web and mobile base lists are the same value, so one
OVERLAY_BASES serves both call sites instead of two aliases for one list.

The determinism assertion the whole drift gate rests on passed on a dead
process: the generator writes nothing on a non-zero exit, so a crashed second
run left the first run's bytes on disk and the byte comparison agreed with
itself. Both derivations now assert their exit code before the comparison.

Also close what 1024's review left owed on these two files: de-duplicate
trackedFiles, because git ls-files prints an unmerged path once per index
stage; narrow the chat-order comment to the real condition, a character
between 0x2F and 0x5C after the shared prefix; say that an untracked file is
excluded for being untracked rather than for being ignored, because git
ls-files reads the index; and record that the android build-tree exclusion now
only guards a force-added artifact.

The fixture wrapper moves to confirm-sheet. The order expected it to stay
green, and it does not: with selection-field gone from the base list the
fixture caller drops out of the inventory, and a fixture naming a file no tree
has tests nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

Independent review, head 24b2f7f6f187080a3071da5d085a10b5dd60a70e

Pullfrog could not run this review. Codex and Pullfrog share one OpenAI meter and it is exhausted until 2026-09-22 07:23, so a separate agent reviewed this pull request instead, against the worktree that carries this exact head, and the full result is posted here rather than summarized. The substitution is named so nobody later reads a merge on this branch as having had the usual reviewer.

Verdict: REQUEST CHANGES. Both claimed P1 fixes verify clean and nothing here ships a defect a person hits today. One test does not catch the defect it was written for, and one accessibility divergence sits in two files this pull request already edits.


P2, and it is the one that blocks: the absence assertion cannot fail for the axis that actually broke

apps/mobile/__tests__/components/ui/time-field.test.tsx:280-282

expect(hours.filter((hour) => hourHandles.has(hour.props[direction]))).toEqual([])

hourHandles holds the handles inside the SAME column. Restore the exact defect an earlier round shipped, nextFocusLeft and nextFocusRight on each hour cell pointing at the adjacent minutes column, and those handles are not in hourHandles, the filter is empty, and this test stays green. Only the up and down axis is guarded, and horizontal is the axis that trapped the D-pad.

The two sibling tests already do it right: select-check.test.tsx:222 and move-parent-dialog.test.tsx:367-368 assert toBeUndefined() on length-pinned, non-empty collections, so [].every() cannot make them vacuous.

Fix: assert hour.props[direction] === undefined for all four directions, matching the siblings.

P2: the time picker ships two different widget contracts on the two platforms

apps/web/components/ui/time-field.tsx:89,105-106 is role="listbox" with role="option" and aria-selected. apps/mobile/components/ui/time-field.tsx:78,126 is now RadioGroup with accessibilityRole="radiogroup", accessibilityRole="radio" and accessibilityState.checked.

A person setting a reminder to 07:30 on Android with TalkBack hears "radio button, 07, selected, 1 of 24". The same control on web with NVDA announces "listbox, 07, selected". The keyboard contract they learn on one platform is announced as a different widget on the other, and the wrap promise a radio group carries is not one a listbox makes.

The divergence pre-dates this pull request, so it is not a regression, and Cross-Platform Parity is green because both sides changed: that gate does not compare semantics. There is no parity:exempt justification in the body and no enumerated DESIGN.md shell divergence that covers it. Under the parity rule and "Maximum implementation" it belongs in this pull request, which already edits both files, rather than in a follow-up.

Fix: move web's TimeColumn onto RadioGroup plus useRadioGroupItem with role="radio" and aria-checked, the way every other picker converted in this pull request went. Mobile is already there.

One more thing the reviewer could not reach, confirmed separately against the installed source. apps/mobile/components/ui/time-field.tsx:107 sets accessibilityState={{ selected: isSelected }} on an accessibilityRole="radio". node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt:632-640 sets isCheckable and isChecked only for the checked key; selected sets isSelected and nothing else. So the node is never checkable and TalkBack announces neither "checked" nor "not checked". Checked for a dependency edit first, as the record now requires: node_modules/react-native/package.json is 2026-09-17T07:37:07 and that delegate is 07:37:10, three seconds apart, so it is extraction order and not a patch. Fix it with the same conversion above.

P2: the injected Kotlin assumes a template detail no anchor asserts

apps/mobile/plugins/with-android-release-build-fixes.js:33-38 references ReleaseLevel.EXPERIMENTAL, CANARY and STABLE, but ReleaseLevel is absent from FEATURE_FLAGS_IMPORTS at :21-27 and no anchor check asserts it. The plugin deliberately throws when DefaultNewArchitectureEntryPoint (:172) or loadReactNative(this) (:186) is missing, then silently assumes a third detail.

The only local proof is MAIN_APPLICATION_TEMPLATE, a hand-written literal in __tests__/plugins/with-android-release-build-fixes.test.ts:16,26 that itself supplies import com.facebook.react.common.ReleaseLevel. That is a fixture agreeing with the assumption, which code standard 8 forbids by name.

A future SDK bump that drops the import leaves expo prebuild succeeding, the unit test green, and ./gradlew bundleRelease failing with Unresolved reference: ReleaseLevel and no pointer back here.

Fix: add import com.facebook.react.common.ReleaseLevel to FEATURE_FLAGS_IMPORTS. The existing hasImportLine de-duplication already makes that safe when the template still carries it.

P3, three of them

  • apps/mobile/components/ui/radio-row.tsx:58,103-110: usePreviousFocusTarget() returning null, which happens for a RadioGroup mounted outside FocusProvenanceView, is indistinguishable from "focus arrived from outside the group". movedWithinGroup is then always false and every focus event arms a redirect to the checked row. Every mobile radio group today sits under the provider at app/_layout.tsx:516, so nothing ships broken, but nothing covers the no-provider case either. Throw, or fall back to the no-group path, when useRadioGroupItem finds a RadioGroupContext and no PreviousFocusTargetContext.
  • apps/mobile/plugins/with-android-release-build-fixes.js:101: withReactNativeImperativeFocus now lives in a plugin named for release-build fixes and injects a runtime feature-flag override that applies to debug builds too. Rename it or split it.
  • apps/web/app/(app)/preferences/_components/preference-picker-sheet.tsx:167-168: LANGUAGE_OPTIONS.find((option) => option.value === selectedLanguage)?.value ?? null evaluates to selectedLanguage for every value the type admits. The mobile twin at preferences-sections.tsx:504 just passes selectedLanguage. Delete the lookup.

What was checked and found clean

  • Every nextFocus production use is gone. grep -rn "nextFocus" apps/ packages/ tools/ returns test files and the unrelated nextFocusIndex in packages/shared/src/utils/auth-login.ts, nothing else.
  • No dependency edit under this feature. Every file in node_modules/react-native/.../internal/featureflags/ shares one extraction mtime and ReactNativeFeatureFlagsDefaults.kt:86,92 reads enableImperativeFocus(): Boolean = false and enableKeyEvents(): Boolean = false. The focus work rests on the config plugin, which is the real mechanism.
  • The config plugin runs. apps/mobile/android/ is gitignored at .gitignore:86 and regenerated by npx expo prebuild --platform android --no-install in android-release.yml:304, app.config.js:55 registers the plugin, and Android release build is SUCCESS at this head, which is producer-derived proof the injected Kotlin compiles against the real template.
  • dangerouslyForceOverride after loadReactNative is right, not sloppy. DefaultNewArchitectureEntryPoint.load() at :91-101 calls ReactNativeFeatureFlags.override(...) itself, so following the KDoc literally would be clobbered. Re-applying the same provider by delegation afterwards and logging featureFlagsReadBeforeOverride is the correct shape.
  • The mobile focus mechanism is real. BaseViewManager.addEventEmitters:174-181 attaches a focus listener to every view with no flag gate, BaseVMFocusChangeListener.onFocusChange:1021-1042 dispatches FocusEvent to topFocus, and BaseViewConfig.android.js:127-131 registers it with bubbled: 'onFocus'. enableImperativeFocus is needed only for the element.focus() redirect, which is what the plugin turns on, and enableKeyEvents is correctly left off.
  • Sheets do not break focus provenance. TrueSheet renders native components inline with no portal and no second React root, so capture-phase dispatch reaches FocusProvenanceView through the fiber tree.
  • The reanimated ref resolves to the host. AnimatedComponent._setComponentRef forwards the child's ref, so findNodeHandle(element) and element.focus() act on the real Pressable.
  • Web arrow order is DOM-derived. radio-row.tsx:112-124 sorts by compareDocumentPosition, so it survives filtering, paging and re-registration.
  • The draft-commit fix holds on both platforms. preference-picker-sheet.tsx:23-60 and preferences-sections.tsx:31-68 both hold a draft that only onCommit writes, and the tests assert absence: not.toHaveBeenCalled() after three arrows, then toHaveBeenCalledExactlyOnceWith.
  • The surface manifest describes this tree. node tools/surface-manifest.mjs --check printed the committed inventory matches this tree (185 surfaces, 796 cells) and exited 0. The gate's changed-file grep matches ^apps/, so it did look. The base moved only by .claude/handoffs/NEXT.md and .claude/specs/orbit-prod-release.md since the merge-base, neither a surface input.
  • selection-field.tsx is fully retired. No production or test reference survives, and tools/redesign-groups.json records the retirement with a reason rather than dropping the id.
  • Mobile StyleSheet: zero className added. No i18n keys added, so no en.json or pt-BR.json gap.

Commands run

git -C <worktree> rev-parse HEAD                     -> 24b2f7f6f187080a3071da5d085a10b5dd60a70e
git diff --name-status <merge-base>..HEAD            -> 45 files, +3215 -1456
gh pr view 992 --json statusCheckRollup              -> 50 checks, all SUCCESS, auto-merge SKIPPED
node tools/surface-manifest.mjs --check              -> matches this tree, 185 surfaces, 796 cells, exit 0
apps/mobile: npx vitest run <7 files> --maxWorkers=2 -> 7 files, 57 tests passed
apps/web:    npx vitest run <7 files> --maxWorkers=2 -> 7 files, 37 tests passed

Method caveat, stated rather than hidden. The reviewer was read-only, so it did not reintroduce each defect and watch a test go red. Every falsifiability claim above is read from the assertion itself, and the one hole that reasoning found is the first P2.

thomasluizon and others added 2 commits September 18, 2026 17:28
The time picker announced itself two different ways. Web used listbox and
option with aria-selected, while Android used radiogroup and radio. Web now
uses the shared RadioGroup and useRadioGroupItem, so both platforms announce
a radio group. The mobile option also set accessibilityState.selected next to
checked; only checked makes a node checkable, so the extra key is gone.

The absence guard on the mobile time column could not fail on the axis that
broke. It asked whether a nextFocus handle belonged to the same column, so a
handle pointing at the next column passed. It now asserts every nextFocus
prop is undefined, on both columns.

ReleaseLevel was missing from the config plugin import list, and only a
hand-written fixture that supplied the import kept the generated Kotlin
compiling. The plugin now adds the import itself, and a template without it
proves that.

withReactNativeImperativeFocus applies to debug builds too, so it moves out
of the release-build plugin into plugins/with-react-native-imperative-focus.js
with its own tests and registration.

A mobile RadioGroup with no FocusProvenanceView ancestor now throws. Without
the provider every focus reads a null previous target, which reads as entry,
so the group would redirect focus on every move.

The web language picker dropped a lookup that returned its own input. The
prop is now SupportedLocale, narrowed once in usePreferenceControls, which is
the shape the mobile twin already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…enance

usePreviousFocusTarget() returns null outside FocusProvenanceView, and a null
previous target reads the same as "focus arrived from outside the group". The
group therefore armed a redirect to the checked row on every focus event, not
only on entry.

RadioGroup now publishes a null onFocus in that case, so useRadioGroupItem
takes the standalone path and selects the focused option. A group under the
provider keeps the entry redirect it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

…sRef prop

The radio conversion replaced the browser's default focus ring on the web
pickers, so a keyboard user lost the only cue that told them where they were.
Each converted control now carries an explicit focus-visible outline in
--primary.

MotionPressable's focusRef prop is gone: the split focus plugin made it
redundant, and its one caller now forwards an ordinary ref.

The surface manifest is regenerated at this head.

#543
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using GPT Sol𝕏

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon

Copy link
Copy Markdown
Owner Author

Independent review, head 74b891a3a7d7c4d012929e138ae019f3800eceb2

Pullfrog could not run this review. Codex and Pullfrog share one OpenAI meter, exhausted until 2026-09-22 07:23. A separate agent reviewed in the worktree carrying this exact head, and the full result is posted rather than summarized. The substitution is named so nobody later reads this merge as having had the usual reviewer.

Verdict: APPROVE. Safe to merge to redesign/main. Every ordered item is done, the P1 absence assertion now goes red on the horizontal axis, proved in both directions, and every gate run is green. Two P2s and two P3s remain and none of them ships a defect.

Merge-base 0f96925b, 49 files, working tree clean.


The horizontal-axis question: yes, and it was proved rather than argued

apps/mobile/__tests__/components/ui/time-field.test.tsx:271-292 now collects the host nodes of both columns and asserts props[direction] === undefined for all five nextFocus* props. useRadioGroupItem returns focusable: !disabled and TimeOption spreads that onto the same Pressable carrying accessibilityRole="radio" (apps/mobile/components/ui/time-field.tsx:68-79), so any handle the hook ever returns lands on exactly the node the test inspects.

The test was then run against a shimmed @/components/ui/radio-row, through an alias override in a scratchpad config with nothing in the repository touched, restoring the exact defect: nextFocusLeft: 90001, nextFocusRight: 90002, handles deliberately outside the hour column's own tag set.

run result
this head's test, pass-through shim (control) Test Files 1 passed (1), Tests 11 passed (11)
this head's test, defect restored 1 failed / 10 passed, AssertionError: expected false to be true at :290
the previous head 24b2f7f6's test, same defect restored PASSES

That last row is the whole point. The old expect(hours.filter((hour) => hourHandles.has(hour.props[direction]))).toEqual([]) stayed green with the D-pad trap present, because adjacent-column handles are never in hourHandles. The new assertion reddens on it. The guard is real.

Earlier findings

# finding status
P1 vacuous absence assertion, horizontal axis fixed, proved red at :290 and green with the defect removed
P1 web time picker is listbox/option/aria-selected fixed, apps/web/components/ui/time-field.tsx:62-113 is RadioGroup plus useRadioGroupItem, role="radio", aria-checked, and the scroll-into-view query moved to [role="radio"][aria-checked="true"]
P1 mobile accessibilityState={{ selected }} on a radio fixed and swept. All five accessibilityRole="radio" sites in apps/mobile use checked: radio-row.tsx:221,227, segmented-control.tsx:35, select-check.tsx:114, time-field.tsx:79, goal-type-selector.tsx:70. Zero radios left on selected. Confirmed against installed source: ReactAccessibilityDelegate.kt:632-640 sets isCheckable and isChecked only for STATE_CHECKED
P2 ReleaseLevel absent from FEATURE_FLAGS_IMPORTS fixed, with-react-native-imperative-focus.js:9, plus TEMPLATE_WITHOUT_RELEASE_LEVEL which strips it so only the plugin can supply it. ReleaseLevel.kt is package com.facebook.react.common with exactly EXPERIMENTAL, CANARY, STABLE, so the generated when is exhaustive
P3 usePreviousFocusTarget() null ambiguity fixed, and better than ordered: a6b71b43 made it throw, c77458a3 replaced that with onFocus = getPreviousFocusTarget ? handleFocus : null plus a standalone fallback at radio-row.tsx:117,165-172, with a test
P3 focus plugin inside a release-build plugin fixed, split into its own file and suite, registered through withLocalPlugin. with-android-release-build-fixes.js and its test are byte-identical to redesign/main
P3 pointless LANGUAGE_OPTIONS.find(...) fixed, deleted; selectedLanguage is now SupportedLocale, narrowed once in use-preference-controls.ts:39
standing zero production nextFocus held, grep -rn "nextFocus" apps/ returns only test files and a test type helper
standing drafts commit only on press held on both platforms, with red-capable tests
standing no node_modules edits held. Every cited react-native file has an mtime within 3 seconds of the package extraction, KeyEvent.kt is 6547 bytes with zero MOVE_HOME/MOVE_END, and ReactNativeFeatureFlagsDefaults.kt:86,92 both read = false. #601's symptom is gone from this tree

The salvage commit: nothing was left behind

74b891a3 was written by tools/salvage-worker.mjs after a ceiling kill, and it excluded apps/web/components/ui/radio-row.tsx as pure CRLF churn. That exclusion is safe: the focus ring the commit adds elsewhere already existed in that file on redesign/main (git show 0f96925b:apps/web/components/ui/radio-row.tsx line 177), so nothing was dropped. useRadioGroupItem is exported and consumed by select-check.tsx and time-field.tsx, so the file's real content is intact.

The commit is coherent on its own: MotionPressable loses a focusRef prop that was always identical to ref, its one caller now passes ref, three converted web controls gain an explicit focus ring, one stale Rubik doc comment is corrected, and the manifest is regenerated.

#608 and #609: the split is right

Neither belongs here. #608 (two RadioRow implementations, the unselected glyph at 1.5px --hairline-strong against DESIGN.md:778's 2px --track-empty, goal-type not on SegmentedControl) is entirely pre-existing on redesign/main and needs token and visual changes this ticket's scope excludes. #609's two controls declare accessibilityRole="button" with accessibilityState={{ selected }}, which is correct for a selectable button and not the false promise this pull request chased, which exists only on a radio. Both are untouched by this diff.

New findings, none blocking

  • P2, the pull request body is stale and the round's mandated evidence is missing. The body ends at Round 15 and its ### Still open, and named so it is not lost block still asserts "The web time picker is role="listbox" with role="option" while mobile is radiogroup with radio", which a6b71b43 fixed. A squash merge would carry a false statement about the merged code into the history. Being fixed before the merge, with this review's red/green table going into ## Test evidence.
  • P2, a shared util with zero coverage in its own workspace. packages/shared/src/utils/radio-navigation.ts:1-16 is tested only from apps/web. npx turbo run test -- --coverage reports shared at 95.3 / 89.07 / 96.36 / 97.39 against thresholds of 95 / 88 / 96 / 97, and the file itself at 0 | 0 | 0 | 0, uncovered 6-15. It passes today with 0.30 points of statement margin, and the next pull request adding a few uncovered shared statements tips that gate red with the blame landing elsewhere. It also breaks D84. Fix: move the test to packages/shared/src/__tests__/radio-navigation.test.ts; it already imports from @orbit/shared/utils, so the body needs no change. Filed rather than held, because it changes no behaviour.
  • P2, nothing asserts app.config.js registers the focus plugin. Drop "./plugins/with-react-native-imperative-focus" from the withLocalPlugin chain and enableImperativeFocus() stays false, ReactViewManager.kt:472-476 gates handleFocus behind it, and radio-row.tsx:110's focus() becomes a silent no-op on every Android radio group while every test stays green, because every test drives focus through the mock. The registration is correct at this head. Pre-existing for with-android-release-build-fixes too; this pull request doubles the exposure rather than creating it. Filed.
  • P3, an undeclared spacing change on the mobile timezone picker. preferences-sections.tsx:113-118 against preferences-styles.ts:50-52: timeZoneOptions sets gap: 8 and the rows are now wrapped in the RadioGroup view, so they sit flush. It is the right direction, web was already flush and DESIGN.md:776 makes the hairline the only owner of row separation, so it is being named in the body rather than restored.
  • P3, the salvage commit message overstates its own fix. It says the conversion "replaced the browser's default focus ring", but all three touched controls were already <button> elements and globals.css carries no rule stripping their outline. Nothing was lost; the commit adds an accent ring where the browser default showed. The change is right, the claim is not.

Checked and found clean

Every declared radio group on both platforms routes through the one primitive, symmetrically: six mobile and six web. The eyebrow fix holds on both, and the web eyebrow moving to var(--font-mono) brings it onto DESIGN.md:720 and into parity with mobile's GeistMono_500Medium. The entry-redirect state machine cannot loop or write: armedRedirect is cleared at the top of every handleFocus, and even if the arm check missed, the redirect target is the checked item so if (!focusedItem.selected) is false. Mobile Sheet uses plain Views rather than RN Modal, so the root onFocusCapture genuinely covers the pickers. No new i18n keys. Zero className in the mobile diff.

Commands run

git -C <worktree> rev-parse HEAD   -> 74b891a3a7d7c4d012929e138ae019f3800eceb2
git status --porcelain             -> empty, before and after the probe
node tools/surface-manifest.mjs --check -> matches this tree, 185 surfaces, 796 cells, exit 0
node tools/redesign-coverage.mjs                                              exit 0
GITHUB_BASE_REF=redesign/main node tools/check-suppressions-ratchet.mjs
                                   -> web 14 -> 14 ok, mobile 16 -> 16 ok,    exit 0
node tools/test-tools.mjs          -> ORBIT TOOLS GATE OK, 1811 assertions,   exit 0
node .claude/hooks/test-hooks.mjs  -> ORBIT HOOKS OK,                         exit 0
npx turbo run type-check --concurrency=1 --force -> 3 successful, 3 total
npx turbo run lint --concurrency=1 -> 3 successful, 0 errors
npx turbo run test --concurrency=1 --force -- --coverage -> 4 successful, 4 total
                                      eslint-rules 392, shared 2081, mobile 2688, web 2969
mobile focused, 9 files            -> 9 files passed, 93 tests passed
web focused, 7 files               -> 7 files passed, 37 tests passed

One recorded artifact, not a finding: a first run of the tools harness launched while the coverage suite was running reported two create-worktree.mjs lock-reclaim failures. That file is untouched by this pull request and both cases pass in the clean re-run above.

@thomasluizon
thomasluizon merged commit 672a4cb into redesign/main Sep 18, 2026
50 checks passed
@thomasluizon
thomasluizon deleted the fix/ticket-543-radio-keys branch September 18, 2026 22:43
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