Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ Save persists only the selected section and preserves neighboring sections. An u
- Scroll content clears the bottom tab bar, gesture area, and home indicator at maximum text size.
- No essential interaction depends on animation, gestures, precise timing, or simultaneous touch.

## Switch scanning (web)

- Every interactive control can be reached and activated by scanning. Activation does exactly what a press does.
- The highlight is a 4px ring in the text colour, visible on surfaces, selected (red) buttons and dialogs in light and dark themes. It is never conveyed by colour alone.
- Sections show an outline while entered, and a labelled "Leave section" stop.
- Hold prompts and pauses are shown as text in a polite live region.
- Dialogs confine scanning, and hidden tabs are never scanned.
- Assigning a key never lets the switch press or release activate the focused control.

## Manual smoke test

On both platforms, clear the app data before starting. Enable the screen reader and then the platform switch-access feature. Traverse both setup steps and confirm the headings, step positions, explanations, desktop download, Continue, Back, Not now, and Allow Bluetooth are announced in logical order. Confirm no Bluetooth prompt appears before Allow Bluetooth, and that the layout remains usable in both orientations and at 200% text. Then navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. In live typing, send several lines with software Return and the visible Enter control. Confirm each sends one Enter, clears only after success, restores the text field and software keyboard, and accepts the next line without backspaces. Simulate a failed Enter and confirm the text remains, focus returns, and Retry Enter sends no duplicate text. On each connected Remote control screen, scroll past the title and confirm the Surface selector stays visible, gains its backdrop only while pinned, remains one scan stop, and causes no focus move or announcement. Confirm Scroll to top then appears as one 48-point scan stop above the PC switcher, does not cover the final control, returns to the title, announces completion once, and disappears at the top. Interrupt the return gesture and confirm it remains usable without a stale announcement. Repeat with Reduce Motion and confirm the return is immediate. Confirm disconnected, recovering, and unavailable states do not expose Scroll to top. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector while it is pinned and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, with reduced motion enabled, and with iOS Reduce Transparency enabled.
Expand Down
9 changes: 9 additions & 0 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ The web app is a progressive web app, so Chrome and Edge offer to install it to
- The service worker fetches pages from the network first and falls back to the cache when offline, so a new deployment is never stuck behind an old copy. Content-hashed bundles and assets under `/_expo/static/` and `/assets/` are cached once and reused. Bluetooth still needs the PC nearby; offline only means the app opens.
- `vercel.json` serves `sw.js` and the manifest with `no-cache`, and the hashed bundles as immutable. To change caching behaviour, change the `CACHE` name in `sw.js` so old caches are cleared on activation.

## Switch scanning

Switch users can operate Remote with keyboard-style switch interfaces, which send key presses such as Space or Enter. Scanning uses [@switchify/scanning](https://github.com/switchifyapp/switchify-scanning), the TypeScript port of Switchify PC's item scanner, so timing, passes, groups and hold actions behave as they do on the PC.

- **Settings:** turn it on in Settings → Switch scanning, which is off by default. Then choose automatic or manual movement, the scan speed, and "Sections first" (grouped) or "Every control" (linear). Assign each switch by choosing Set key and pressing the switch. Each switch has a press action and an optional hold action. Settings that cannot drive scanning are refused, for example ones with no Select, or manual scanning without Next and Previous.
- **Using it:** Select starts scanning, and Escape stops it. In grouped mode each surface section is one stop. Select enters it, its controls are scanned in turn, and a final "Leave section" stop returns to the page. Open dialogs (selectors, the PC switcher, the layout editor and the action picker) confine scanning to themselves. Controls on hidden tabs are never scanned, and the highlighted control scrolls into view. Automatic movement pauses while a switch is held.
- **Implementation:** `src/scanning/` holds the provider, the `useScannable` hook used by `ControlButton`, `ActionButton`, `IconButton`, `ListRow`, `SelectorField`, the PC switcher and the tab bar, the highlight rings, and the settings card. Scanning settings live in `switchify.remote.preferences.v1` under `scanning`, and unknown or unusable values fall back on their own.
- **Limits:** keys still type into a text field while one has focus, so live typing is not yet switch-accessible. Native builds have no global key events, so scanning is web-only for now.

## Supported browsers

Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and ChromeOS. Safari, Firefox and every iOS browser lack it. In those browsers the PCs screen shows that Bluetooth is unavailable and names the supported browsers.
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"@noble/hashes": "^2.3.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.3.16",
"@switchify/scanning": "github:switchifyapp/switchify-scanning#v0.3.1",
"base64-js": "^1.5.1",
"expo": "~57.0.27",
"expo-blur": "~57.0.3",
Expand Down
2 changes: 2 additions & 0 deletions src/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { computeBottomTabBarHeight } from '@/navigation/bottomTabBarLayout';
import { tabDefinitions } from '@/navigation/tabDefinitions';
import { tabSceneLayout } from '@/navigation/tabSceneLayout';
import { ScannableTabButton } from '@/navigation/ScannableTabButton';
import { useTheme } from '@/theme/ThemeContext';

const icons = { index: 'computer', remote: 'settings-remote', settings: 'settings' } as const;
Expand All @@ -22,6 +23,7 @@ export default function TabsLayout() {
tabBarActiveTintColor: scheme === 'dark' ? colors.brandText : colors.brand,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border, height: tabBarHeight },
tabBarButton: (props) => <ScannableTabButton {...props} />,
tabBarIcon: ({ color, size }) => <MaterialIcons color={color} name={icons[route.name as keyof typeof icons]} size={size} />,
})}>
{tabDefinitions.map((tab) => <Tabs.Screen key={tab.name} name={tab.name} options={{ title: tab.title }} />)}
Expand Down
8 changes: 6 additions & 2 deletions src/app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { PrivacyPolicyLink } from '@/components/PrivacyPolicyLink';
import { RemoteNameEditor, type RemoteNameSaveResult } from '@/components/RemoteNameEditor';
import { Screen } from '@/components/Screen';
import { SelectorField } from '@/components/SelectorField';
import { ScanningSettings } from '@/scanning/ScanningSettings';
import { ScanSection } from '@/scanning/ScanSection';
import { switchInputSupported } from '@/scanning/SwitchInput';
import { useConnectionManager, useConnectionState } from '@/connection/ConnectionContext';
import type { SavedPc } from '@/storage/PairingStore';
import { preferencesStore } from '@/storage/PreferencesStore';
Expand All @@ -37,6 +40,7 @@ export default function SettingsScreen() {
return <Screen title="Settings" description="Preferences are stored only on this device.">
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: spacing.md }}>
<View style={cardWidth}><SettingCard title="Opening surface" description="Shown first when the remote connects."><SelectorField label="Opening surface" options={surfaces.map((surface) => ({ key: surface, label: surface[0]!.toUpperCase() + surface.slice(1) }))} selectedKey={preferences.surface} onSelect={(surface) => preferencesStore.update({ surface })} /></SettingCard></View>
{switchInputSupported ? <View style={cardWidth}><SettingCard title="Switch scanning" description="Use switches to move a highlight through the controls and select them."><ScanningSettings /></SettingCard></View> : null}
<View style={cardWidth}><SettingCard title="Remote name" description="Shown on computers when pairing and in their saved device list."><RemoteNameEditor remoteName={preferences.remoteName} onSave={saveRemoteName} /></SettingCard></View>
<View style={cardWidth}><SettingCard title="Typing mode" description="Choose whether text sends live or as a draft."><View style={{ flexDirection: 'row', gap: spacing.sm }}><ControlButton label="Live" selected={preferences.typingMode === 'live'} onPress={() => void preferencesStore.update({ typingMode: 'live' })} /><ControlButton label="Draft" selected={preferences.typingMode === 'draft'} onPress={() => void preferencesStore.update({ typingMode: 'draft' })} /></View></SettingCard></View>
<View style={cardWidth}><SettingCard title="Default PC" description="Select the computer used for automatic connection.">{saved.length === 0 ? <EmptyState icon="computer" title="No saved PCs" body="Pair a PC before choosing a default." /> : <View style={{ gap: spacing.sm }}><ControlButton label="Most recently connected" selected={defaultId === null} onPress={() => void setDefault(null)} />{saved.map((pc) => <ControlButton key={pc.desktopId} label={pc.displayName} selected={defaultId === pc.desktopId} onPress={() => void setDefault(pc.desktopId)} />)}</View>}</SettingCard></View>
Expand All @@ -48,6 +52,6 @@ export default function SettingsScreen() {
}

function SettingCard({ title, description, children }: { title: string; description: string; children: ReactNode }) {
const { spacing } = useTheme();
return <Card><View style={{ gap: spacing.xs }}><AppText accessibilityRole="header" variant="heading">{title}</AppText><AppText muted variant="caption">{description}</AppText></View>{children}</Card>;
const { radii, spacing } = useTheme();
return <ScanSection radius={radii.lg}><Card><View style={{ gap: spacing.xs }}><AppText accessibilityRole="header" variant="heading">{title}</AppText><AppText muted variant="caption">{description}</AppText></View>{children}</Card></ScanSection>;
}
3 changes: 2 additions & 1 deletion src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import { diagnosticsScreenOptions } from '@/navigation/diagnosticsScreenOptions'
import { FirstRunSetupGate } from '@/onboarding/FirstRunSetup';
import { StoreCaptureRoot, storeCaptureEnabled } from '@/store-capture/StoreCaptureRoot';
import { installPlatform } from '@/platform/installPlatform';
import { ScanningProvider } from '@/scanning/ScanningProvider';
import { ThemeProvider, useTheme } from '@/theme/ThemeContext';

installPlatform();

export default function RootLayout() {
return (
<SafeAreaProvider>
<ThemeProvider>{storeCaptureEnabled() ? <StoreCaptureRoot /> : <ThemedApp />}</ThemeProvider>
<ThemeProvider>{storeCaptureEnabled() ? <StoreCaptureRoot /> : <ScanningProvider><ThemedApp /></ScanningProvider>}</ThemeProvider>
</SafeAreaProvider>
);
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@ import { ActivityIndicator, Animated, Platform, Pressable, Text, View, type View
import { webControlAccessibility } from './webControlAccessibility';
import { useTheme } from '@/theme/ThemeContext';
import { usePressScale } from './usePressScale';
import { ScanItemHighlight } from '@/scanning/ScanHighlight';
import { useScannable } from '@/scanning/useScannable';

type Tone = 'primary' | 'secondary' | 'tertiary' | 'danger';
type IconName = ComponentProps<typeof MaterialIcons>['name'];

export function ActionButton({ label, onPress, disabled = false, busy = false, secondary = false, tone, icon, controlRef }: { label: string; onPress: () => void; disabled?: boolean; busy?: boolean; secondary?: boolean; tone?: Tone; icon?: IconName; controlRef?: Ref<NativeView> }) {
const { colors, radii, spacing, typography } = useTheme();
const press = usePressScale();
const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress, disabled: disabled || busy, controlRef });
const resolvedTone = tone ?? (secondary ? 'secondary' : 'primary');
const background = resolvedTone === 'primary' ? colors.brand : resolvedTone === 'danger' ? colors.dangerTint : resolvedTone === 'secondary' ? colors.surfaceRaised : 'transparent';
const foreground = resolvedTone === 'primary' ? colors.onBrand : resolvedTone === 'danger' ? colors.danger : resolvedTone === 'tertiary' ? colors.brandText : colors.text;
return (
<Animated.View style={{ transform: [{ scale: press.scale }], width: '100%' }}><Pressable ref={controlRef} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled, busy }} {...webControlAccessibility(Platform.OS, { disabled, busy })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} onPressOut={press.pressOut} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}>
<Animated.View style={{ transform: [{ scale: press.scale }], width: '100%' }}><Pressable ref={scanAttach} onLayout={scanLayout} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled, busy }} {...webControlAccessibility(Platform.OS, { disabled, busy })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} onPressOut={press.pressOut} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}>
<View style={{ alignItems: 'center', flexDirection: 'row', gap: spacing.sm }}>
{busy ? <ActivityIndicator color={foreground} /> : icon ? <MaterialIcons color={foreground} importantForAccessibility="no" name={icon} size={20} /> : null}
<Text style={[typography.label, { color: foreground, flexShrink: 1, textAlign: 'center' }]}>{label}</Text>
</View>
<ScanItemHighlight highlighted={scanHighlighted} groupHighlighted={scanGroupHighlighted} radius={radii.md} />
</Pressable></Animated.View>
);
}
7 changes: 6 additions & 1 deletion src/components/ControlButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "react-native";
import { useTheme } from "@/theme/ThemeContext";
import { usePressScale } from "./usePressScale";
import { ScanItemHighlight } from '@/scanning/ScanHighlight';
import { useScannable } from '@/scanning/useScannable';
import { webControlAccessibility } from './webControlAccessibility';

type IconName = ComponentProps<typeof MaterialIcons>["name"];
Expand Down Expand Up @@ -47,6 +49,7 @@ export function ControlButton({
}) {
const { colors, radii, spacing, typography } = useTheme();
const press = usePressScale(selected);
const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress, disabled, controlRef });
return (
<Animated.View
style={{
Expand All @@ -58,7 +61,8 @@ export function ControlButton({
}}
>
<Pressable
ref={controlRef}
ref={scanAttach}
onLayout={scanLayout}
accessibilityRole={role}
accessibilityLabel={accessibilityLabel}
accessibilityHint={hint}
Expand Down Expand Up @@ -146,6 +150,7 @@ export function ControlButton({
size={18}
/>
) : null}
<ScanItemHighlight highlighted={scanHighlighted} groupHighlighted={scanGroupHighlighted} radius={radii.md} />
</Pressable>
</Animated.View>
);
Expand Down
6 changes: 6 additions & 0 deletions src/components/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ComponentProps } from "react";
import { Animated, Platform, Pressable } from "react-native";
import { useTheme } from "@/theme/ThemeContext";
import { usePressScale } from "./usePressScale";
import { ScanItemHighlight } from '@/scanning/ScanHighlight';
import { useScannable } from '@/scanning/useScannable';
import { webControlAccessibility } from './webControlAccessibility';

export function IconButton({
Expand All @@ -22,6 +24,7 @@ export function IconButton({
}) {
const { colors, radii } = useTheme();
const press = usePressScale(selected);
const { attach: scanAttach, onLayout: scanLayout, highlighted: scanHighlighted, groupHighlighted: scanGroupHighlighted } = useScannable({ onActivate: onPress, disabled });
return (
<Animated.View
style={{
Expand All @@ -32,6 +35,8 @@ export function IconButton({
}}
>
<Pressable
ref={scanAttach}
onLayout={scanLayout}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint={hint}
Expand Down Expand Up @@ -64,6 +69,7 @@ export function IconButton({
size={24}
color={selected ? colors.onBrand : colors.text}
/>
<ScanItemHighlight highlighted={scanHighlighted} groupHighlighted={scanGroupHighlighted} radius={radii.md} />
</Pressable>
</Animated.View>
);
Expand Down
Loading
Loading