Repository navigation
feat(scanning): add switch scanning of the Remote interface - #197
Conversation
- Scan Remote's own controls with @switchify/scanning (switchifyapp/switchify-scanning v0.3.1), the TypeScript port of Switchify PC's item scanner - Read keyboard-style switch interfaces on the web: press and hold actions, Escape to stop, automatic movement paused while a switch is held, and keys left to text fields - Make buttons, rows, selectors, the PC switcher and tabs scannable through a shared useScannable hook with a high-contrast ring; scan surface sections and settings cards as groups with a labelled "Leave section" stop - Confine scanning to open dialogs, never scan hidden tabs, and scroll the highlighted control into view - Add a Switch scanning settings card (off by default): automatic or manual, speed, pattern, and switches assigned by pressing them, with validation - Store settings under preferences.scanning with per-field fallback; native is unchanged - Cover preferences, key capture and scannable controls with tests, and document it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
| <ScanSection exclusive radius={0} style={{ flex: 1 }}> | ||
| <ActionPickerContent {...props} /> | ||
| </ScanSection> |
There was a problem hiding this comment.
Action choices cannot be scanned
The new exclusive ScanSection confines scanning to ActionPicker, but its action rows are plain Pressable controls that never register with useScannable. A switch user can open the picker from an empty layout cell, but scanning only reaches Close, not any action. This blocks adding an action through switch input and should be fixed before merging. Register the action rows with the same highlight and activation behavior as the shared buttons.
Knowledge Base Used: Layout editing, dragging, and surface rendering
Artifacts
- Rendered the base picker, pressed Next eight times and Select, then clicked an action; base has no scan target but pointer assignment works.
Base ActionPicker after eight Next switch presses
- Captured the rendered base picker after switch navigation; the controller reports no highlighted item.
- Rendered head and exercised Next, Select, and pointer assignment; switches reach only Close while clicking an action assigns it.
Head ActionPicker after eight Next switch presses
- Captured the rendered head picker after repeated switch navigation; the controller and DOM highlight identify Close as the sole target.
- Recorded actual controller snapshots, callback events, and assertions with command, working directory, and exit code; head confirms Close-only scanning.
- Captured the actual Metro build command and output; the executable web harness bundled successfully with exit code zero.
- Contains the executed wrapper that records command, directory, exit code, and browser output; the validation is reproducible.
- Contains base extraction, Metro build, HTTP serving, Chromium interaction, assertions, and recording capture; it executes both sides without production edits.
- Mounts the real picker and keyboard-switch integration with three catalog actions and observed callbacks; no scanning registration or activation handlers are mocked.
- Extracted the picker from the specified base revision and redirected its ActionButton import to the extracted base control; this preserves the before behavior.
- Extracted the base ActionButton and adjusted local dependency paths; the before comparison does not accidentally inherit head Close scanning.
- Contains the Metro-generated JavaScript served to Chromium for both runs; this is the exact compiled execution source.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/layouts/ActionPicker.tsx
Line: 35-37
Comment:
**Action choices cannot be scanned**
The new exclusive `ScanSection` confines scanning to `ActionPicker`, but its action rows are plain `Pressable` controls that never register with `useScannable`. A switch user can open the picker from an empty layout cell, but scanning only reaches Close, not any action. This blocks adding an action through switch input and should be fixed before merging. Register the action rows with the same highlight and activation behavior as the shared buttons.
**Knowledge Base Used:** [Layout editing, dragging, and surface rendering](https://app.greptile.com/owen-mcgirr/-/custom-context/knowledge-base/switchifyapp/switchify-remote/-/docs/layout-editor-and-rendering.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const onKeyUp = (event: KeyboardEvent) => { | ||
| swallow(event); | ||
| if (pressed !== null && event.code === pressed) close(); | ||
| }; | ||
| window.addEventListener('keydown', onKeyDown, true); | ||
| window.addEventListener('keyup', onKeyUp, true); |
There was a problem hiding this comment.
If the browser loses focus while the assigned key is held and the release happens elsewhere, captureNextKey never receives the matching keyup. Capture stays active after the user returns, swallows every later key including Escape, and keeps SwitchInput disabled until that same key is pressed and released again. Cancel capture on window blur or when the page becomes hidden, and keep its cleanup handle until capture actually closes.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/scanning/keyCapture.ts
Line: 45-50
Comment:
**Key assignment can trap input**
If the browser loses focus while the assigned key is held and the release happens elsewhere, `captureNextKey` never receives the matching `keyup`. Capture stays active after the user returns, swallows every later key including Escape, and keeps `SwitchInput` disabled until that same key is pressed and released again. Cancel capture on window blur or when the page becomes hidden, and keep its cleanup handle until capture actually closes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| <SelectorField label="Press" options={PRESS_ACTIONS.map((action) => ({ key: action, label: ACTION_LABELS[action] }))} selectedKey={binding.pressAction} onSelect={(pressAction) => updateBinding(binding.id, { pressAction, name: ACTION_LABELS[pressAction] }).then(() => undefined)} /> | ||
| <SelectorField label="Hold" options={HOLD_CHOICES.map(({ key, label }) => ({ key, label }))} selectedKey={holdKey(binding.holdActions)} onSelect={(key) => updateBinding(binding.id, { holdActions: HOLD_CHOICES.find((choice) => choice.key === key)?.actions ?? [] }).then(() => undefined)} /> |
There was a problem hiding this comment.
Changing the only Select binding to Next fails validation, but the Press selector discards the refused save with .then(() => undefined). SelectorField consequently closes, displays Next, and announces it as selected while the saved binding remains Select. The unchanged selectedKey does not reset that display. This is a non-blocking settings feedback problem: propagate the failed save so the field keeps its saved value instead of presenting an action the switch will not perform.
Artifacts
- This authored Playwright script exercises the real settings controls and asserts UI, announcement, and persisted-state behavior, making the reproduction executable.
- This authored shell wrapper runs the browser script and records the command, working directory, exit code, and captured output, making the execution trace reproducible.
- Chromium enables scanning and leaves one Select switch, then opens its selected option, showing the valid starting configuration.
Settings with the sole switch set to Select
- The browser captures the settings after dismissing the initial selector, recording the configuration before the rejected edit.
- Chromium chooses Next and reopens the selector after validation rejects the change, capturing the misleading Next display despite unchanged persisted Select.
Settings after the rejected edit and selector reopening
- The browser captures the final settings state after reopening and dismissing the selector, recording the rejected edit’s lingering UI state.
- The successful execution records DOM values, announcement text, stored preferences, and fresh-render behavior, confirming that Next is presented while Select remains persisted.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/scanning/ScanningSettings.tsx
Line: 87-88
Comment:
**Rejected settings look saved**
Changing the only Select binding to Next fails validation, but the Press selector discards the refused save with `.then(() => undefined)`. `SelectorField` consequently closes, displays Next, and announces it as selected while the saved binding remains Select. The unchanged `selectedKey` does not reset that display. This is a non-blocking settings feedback problem: propagate the failed save so the field keeps its saved value instead of presenting an action the switch will not perform.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| return { | ||
| enabled: value.enabled === true, | ||
| automatic: options.automatic, | ||
| intervalMs: options.intervalMs, | ||
| pattern: options.pattern, | ||
| switches: validateSwitchSettings(switches, options.automatic) === null ? switches : DEFAULT_SCANNING.switches, |
There was a problem hiding this comment.
When stored manual settings have missing or unusable switches, normalizeScanning falls back to DEFAULT_SCANNING.switches but keeps automatic: false. Those defaults have Select and Next but no Previous, so the returned settings still fail validateSwitchSettings. Every settings save then fails, including Off. This is a non-blocking recovery problem for damaged or outdated preferences: use manual-compatible fallback switches, or restore automatic movement with the fallback, and test that the resulting combination passes validation.
Knowledge Base Used: Persistence and settings
Artifacts
- This authored Jest test exercises real normalization, validation, preference reload and Settings handlers with valid and unusable manual inputs, establishing the F4 reproduction.
- This executed runner invokes both focused Jest cases and captures each command, working directory, exit code and observed output, making the comparison reproducible.
- The valid-manual control run validates successfully and persists On and Off with one write each, showing that the tested persistence path works.
- The reproduction run returns invalid manual defaults and rejects On, Off and pattern saves without writing storage, confirming F4.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/scanning/preferences.ts
Line: 54-59
Comment:
**Manual fallback stays invalid**
When stored manual settings have missing or unusable switches, `normalizeScanning` falls back to `DEFAULT_SCANNING.switches` but keeps `automatic: false`. Those defaults have Select and Next but no Previous, so the returned settings still fail `validateSwitchSettings`. Every settings save then fails, including Off. This is a non-blocking recovery problem for damaged or outdated preferences: use manual-compatible fallback switches, or restore automatic movement with the fallback, and test that the resulting combination passes validation.
**Knowledge Base Used:** [Persistence and settings](https://app.greptile.com/owen-mcgirr/-/custom-context/knowledge-base/switchifyapp/switchify-remote/-/docs/persistence-and-settings.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const save = async (next: ScanningPreferences): Promise<boolean> => { | ||
| const problem = validateSwitchSettings(next.switches, next.automatic); | ||
| if (problem) { setError(problem); return false; } | ||
| setError(null); | ||
| await preferencesStore.update({ scanning: next }); | ||
| return true; | ||
| }; | ||
| const updateBinding = (id: string, patch: Partial<SwitchBinding>) => save({ ...scanning, switches: { ...scanning.switches, bindings: scanning.switches.bindings.map((binding) => binding.id === id ? { ...binding, ...patch } : binding) } }); |
There was a problem hiding this comment.
Second edits undo earlier changes
save replaces all of scanning using the value from the current render. If a user changes two settings while the first storage write is still pending, both saves use the old value. The second queued write then undoes the first change; selecting Every control and then Off persisted the old grouped pattern. This is a non-blocking persistence concern that silently loses a preference edit. Apply each change to the latest settings inside the write queue, or prevent further edits until the save finishes.
Knowledge Base Used: Persistence and settings
Artifacts
- The authored test renders the real settings component, gates AsyncStorage completion, and checks persistence and reload, reproducing the lost edit without replacing application logic.
- The executed shell script runs the focused Jest test twice and captures each command, working directory, exit code, and observed output, making both conditions reproducible.
- The first executed run completes the pattern write before pressing Off and checks memory, storage, and reload, showing that both edits survive.
- The second executed run presses Off before releasing the pattern write and checks both queued payloads and reload, showing that the second save restores the stale grouped pattern.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/scanning/ScanningSettings.tsx
Line: 37-44
Comment:
**Second edits undo earlier changes**
`save` replaces all of `scanning` using the value from the current render. If a user changes two settings while the first storage write is still pending, both saves use the old value. The second queued write then undoes the first change; selecting Every control and then Off persisted the old grouped pattern. This is a non-blocking persistence concern that silently loses a preference edit. Apply each change to the latest settings inside the write queue, or prevent further edits until the save finishes.
**Knowledge Base Used:** [Persistence and settings](https://app.greptile.com/owen-mcgirr/-/custom-context/knowledge-base/switchifyapp/switchify-remote/-/docs/persistence-and-settings.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Closes #196
Summary
Switch users can now operate Switchify Remote itself in the web app. It uses @switchify/scanning v0.3.1, the TypeScript port of Switchify PC's item scanner, pinned to a GitHub tag. npm builds it on install through its
preparescript.ControlButton,ActionButton,IconButton,ListRow,SelectorField, the PC switcher and the tab buttons all go through oneuseScannablehook, which draws a 4px ring in the text colour. Surface sections and settings cards scan as groups, ending with a labelled "Leave section" stop.preferences.scanning, and unknown or unusable values fall back field by field.Library changes made for this, each merged with CI: exclusive groups (switchifyapp/switchify-scanning#1), hooks that work outside a provider (#2), and a fix where undefined options reset a controller to automatic (#3). That bug had made Jest hang on exit.
Validation
npm run validate: lint, typecheck, 685 Jest tests (including new preferences, key-capture and scannable-control tests) and 21/21 Expo Doctor checks pass. Jest exits cleanly.mouse.clickto the PC.Digit1without that press acting on the page.Digit1then started scanning, Space was unbound, and the assignment survived a reload.Not yet: live typing is not switch-accessible, because keys go to the text field. Physical switch-interface and screen-reader checks are still needed.
🤖 Generated with Claude Code