From 7ced60cc4b99e6ab07b88bfb47e34d52852d8d81 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 22 Sep 2026 20:12:35 +0100 Subject: [PATCH 1/2] Add optional setup and contextual teaching demonstrations --- src/App.test.tsx | 18 +++ src/App.tsx | 21 +-- src/help/Demonstration.test.tsx | 124 ++++++++++++++++++ src/help/Demonstration.tsx | 96 ++++++++++++++ src/help/PrototypeArtwork.tsx | 212 +++++++++++++++++++++++++++++++ src/help/TeachingArtwork.tsx | 39 ++++++ src/help/demonstrations.css | 15 +++ src/help/demonstrations.ts | 19 +++ src/settings/GeneralSection.tsx | 3 +- src/settings/ScanningSection.tsx | 2 + src/settings/SwitchesSection.tsx | 3 + 11 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 src/help/Demonstration.test.tsx create mode 100644 src/help/Demonstration.tsx create mode 100644 src/help/PrototypeArtwork.tsx create mode 100644 src/help/TeachingArtwork.tsx create mode 100644 src/help/demonstrations.css create mode 100644 src/help/demonstrations.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 61374cff..77294481 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -34,6 +34,24 @@ describe("Switchify PC shell", () => { return { settings, state: { settings, capture: { active: false, key: null, error: null }, supported: true, error: null, escapeHoldMs: 4000, unavailableKeys: [] }, pending: 0, error: null, capturing: false, unsaved: false, update: vi.fn(), remove: vi.fn(), capture: vi.fn(), cancelCapture: vi.fn().mockResolvedValue(undefined), setKeyboardEntry: vi.fn().mockResolvedValue(true), retry: vi.fn() }; } + it("offers contextual demonstrations without changing app settings", async () => { + const setSettings = vi.spyOn(api, "saveSettings"); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Mobile connection" })); + fireEvent.click(screen.getByRole("button", { name: "Show demonstration: Pair a mobile device" })); + expect(screen.getByRole("button", { name: "Pause Pair a mobile device" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Scanning" })); + expect(screen.queryByRole("button", { name: "Pause Pair a mobile device" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show demonstration: Automatic grid then line scanning" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show demonstration: Automatic line-only scanning" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Switches" })); + expect(screen.getByRole("button", { name: "Show demonstration: Connect a jack switch" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("button", { name: "Show demonstration: Understand startup behavior" })).toBeInTheDocument(); + expect(setSettings).not.toHaveBeenCalled(); + }); + it("shows local readiness without Bluetooth and explains Mobile suspension", async () => { browserState.accessibility = "granted"; browserState.bluetooth = "unsupported"; diff --git a/src/App.tsx b/src/App.tsx index 1f96ddb0..ebfc37d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { Demonstration, DemonstrationProvider } from "./help/Demonstration"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { Accessibility, Bluetooth, ChevronRight, CircleHelp, Download, @@ -91,7 +92,7 @@ function MobileConnection({ state, onDisconnect }: { state: AppState; onDisconne

Install Switchify for mobile

Need the mobile app? Scan the QR code or open Google Play to install Switchify.

Open Google Play
QR code for Switchify on Google Play
Scan to open Google Play and install Switchify.
-

Already installed? Connect to this computer

  1. Open Switchify on your mobile device and select this computer.
  2. Compare the pairing codes shown in both apps.
  3. Approve the pairing request on this computer only if the codes match.
+

Already installed? Connect to this computer

  1. Open Switchify on your mobile device and select this computer.
  2. Compare the pairing codes shown in both apps.
  3. Approve the pairing request on this computer only if the codes match.
; } @@ -331,12 +332,12 @@ function SupportView({ state, switches, busy, perform, openSetup, openUpdates }: return

Support

Switch setup and system diagnostics

{tab === "setup" ? <>
-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }

Local switches

{switches.state?.settings.bindings.length ?? 0} configured. Open Switches to assign Select, then open Scanning to adjust movement.

Optional mobile connection

{bluetoothLabels[state.bluetooth]}. Open Mobile connection for pairing and forwarding profiles.

:

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" ? : }
+

Input access

{state.accessibility === "required" ? : }

Application update

Switchify PC {state.version}

Diagnostics

Export sanitized health, capability, and recent event data

Recent Bluetooth changes

{state.diagnostics.recentBluetooth.length > 0 ? state.diagnostics.recentBluetooth.map((event) => event.status).join(" → ") : "No Bluetooth changes recorded yet"}

@@ -402,11 +403,11 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc {error &&
{error}
}
{step === 0 &&
-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }
} {step === 1 && } - {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select to start scanning. Select chooses a point and opens the action menu.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and menu timing, and Line speed for scanning lines. Holding a switch freezes movement; release runs the action shown.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement. Mobile connection is optional.

} - {step === 3 &&

Choose startup behavior

Switchify can start quietly when you sign in, ready for your switches.

} + {step === 2 &&

Use your switches

Focus the application you want to use, then press and release Select to start scanning. Select chooses a point and opens the action menu.

Automatic scanning moves the highlight for you. Use Auto scan rate for grid and menu timing, and Line speed for scanning lines. Holding a switch freezes movement; release runs the action shown.

Escape disables switch control. Assign Stop scanning or Pause / resume in Switches. Holding any switch for {switches.state ? switches.state.escapeHoldMs / 1000 : 4}s disables switch control.

Open Scanning after setup to adjust movement. Mobile connection is optional.

} + {step === 3 &&

Choose startup behavior

Switchify can start quietly when you sign in, ready for your switches.

} {step === 4 &&

Choose whether to share diagnostics

Optional anonymous app health and sanitized errors help improve Switchify. Typed text, commands, pairing secrets, device names, and full paths are never included.

Privacy policy
}

{navigationGuidance}

@@ -768,9 +769,9 @@ export function App() { }; if (!state || !settings) return
Starting Switchify PC...
; - return
+ return 0 || switches.capturing || !!switches.state?.capture.active}>
-
0}> + 0 || switches.capturing || !!switches.state?.capture.active}>
0}> {error &&
{error}
}

{updateNotice}

@@ -784,8 +785,8 @@ export function App() {
} {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} {view === "support" && void perform(operation)} openSetup={openSetup} openUpdates={openUpdates} />} - +
{setupOpen && 0} busy={busy} error={error} skip={skipSetup} finish={finishSetup} accessibility={() => perform(() => api.checkAccessibility(true))} />} {state.pendingPairings.length > 0 && perform(() => api.rejectPairing(requestId))} approve={(requestId) => perform(() => api.approvePairing(requestId))} />} -
; +
; } diff --git a/src/help/Demonstration.test.tsx b/src/help/Demonstration.test.tsx new file mode 100644 index 00000000..01569a18 --- /dev/null +++ b/src/help/Demonstration.test.tsx @@ -0,0 +1,124 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Demonstration, DemonstrationProvider } from "./Demonstration"; +import { demonstration, type DemonstrationKind } from "./demonstrations"; + +let reduced = false; +let mediaChange: (() => void) | undefined; +const advance = (ms = 3050) => act(() => { vi.advanceTimersByTime(ms); }); +const open = (title: string) => fireEvent.click(screen.getByRole("button", { name: `Show demonstration: ${title}` })); +const caption = () => document.querySelector(".teaching-caption")!.textContent; + +beforeEach(() => { + vi.useFakeTimers(); + reduced = false; + vi.stubGlobal("IntersectionObserver", undefined); + vi.stubGlobal("matchMedia", () => ({ get matches() { return reduced; }, addEventListener: (_: string, cb: () => void) => { mediaChange = cb; }, removeEventListener: vi.fn() })); +}); +afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +describe("teaching demonstrations", () => { + it("plays once, pauses without losing its place, replays and cancels work on collapse", () => { + render(); + expect(document.querySelector("svg")).toBeNull(); + open("Learn a switch key"); + advance(1000); + fireEvent.click(screen.getByRole("button", { name: "Pause Learn a switch key" })); + advance(5000); + expect(caption()).toMatch(/^1 \/ 3/); + fireEvent.click(screen.getByRole("button", { name: "Play Learn a switch key" })); + advance(2200); + expect(caption()).toMatch(/^2 \/ 3/); + advance(); advance(); + expect(caption()).toMatch(/^3 \/ 3/); + expect(screen.getByRole("button", { name: "Play Learn a switch key" })).toBeDisabled(); + expect(vi.getTimerCount()).toBe(0); + fireEvent.click(screen.getByRole("button", { name: "Replay Learn a switch key" })); + expect(caption()).toMatch(/^1 \/ 3/); + fireEvent.click(screen.getByRole("button", { name: "Hide demonstration: Learn a switch key" })); + expect(document.querySelector("svg")).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses manually selected still illustrations for reduced motion, including live preference changes", () => { + reduced = true; + render(); + open("Press, hold and release"); + expect(vi.getTimerCount()).toBe(0); + expect(screen.queryByRole("button", { name: /^Pause/ })).toBeNull(); + for (let i = 0; i < 4; i++) fireEvent.click(screen.getByRole("button", { name: /Next illustration/ })); + expect(caption()).toContain("Release to execute"); + expect(screen.getByRole("button", { name: /Next illustration/ })).toBeDisabled(); + act(() => { reduced = false; mediaChange?.(); }); + fireEvent.click(screen.getByRole("button", { name: /Replay/ })); + expect(vi.getTimerCount()).toBeGreaterThan(0); + act(() => { reduced = true; mediaChange?.(); }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("suspends during capture or pairing and requires explicit play afterward", () => { + const content = (suspended: boolean) => ; + const view = render(content(false)); + open("Pair a mobile device"); advance(1000); + view.rerender(content(true)); + expect(vi.getTimerCount()).toBe(0); + expect(screen.getByRole("button", { name: /Replay/ })).toBeDisabled(); + view.rerender(content(false)); + advance(); expect(caption()).toMatch(/^1 \/ 4/); + expect(screen.getByRole("button", { name: /Play Pair/ })).toBeEnabled(); + }); + + it("stops animation scheduling off-screen and while the document is hidden", () => { + let observe: (entries: { isIntersecting: boolean }[]) => void = () => {}; + const disconnect = vi.fn(); + vi.stubGlobal("IntersectionObserver", class { + constructor(cb: typeof observe) { observe = cb; } + observe() {} + disconnect = disconnect; + }); + const view = render(); + open("Connect a USB switch"); + expect(vi.getTimerCount()).toBe(0); + act(() => observe([{ isIntersecting: true }])); advance(1000); + act(() => observe([{ isIntersecting: false }])); + expect(vi.getTimerCount()).toBe(0); + act(() => observe([{ isIntersecting: true }])); + const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + fireEvent(document, new Event("visibilitychange")); + expect(vi.getTimerCount()).toBe(0); + hidden.mockReturnValue(false); + fireEvent(document, new Event("visibilitychange")); + expect(vi.getTimerCount()).toBeGreaterThan(0); + view.unmount(); expect(disconnect).toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps SVG definition IDs unique when the same artwork appears twice", () => { + render(<>); + for (const button of screen.getAllByRole("button", { name: /Show demonstration/ })) fireEvent.click(button); + const ids = [...document.querySelectorAll("svg [id]")].map(el => el.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + expect(document.querySelectorAll('svg[aria-hidden="true"][focusable="false"]')).toHaveLength(2); + }); + + it("shows Accessibility teaching only on macOS", () => { + const content = (platform: "windows" | "macos") => ; + const view = render(content("windows")); + expect(screen.queryByRole("button")).toBeNull(); + view.rerender(content("macos")); open("Allow Accessibility on macOS"); + expect(screen.getByRole("list")).toHaveTextContent("return to the app"); + }); + + it.each(["jack", "usb", "learn", "select", "grid", "line", "hold", "access", "pair", "startup"] as DemonstrationKind[])("renders every illustrated step for %s", kind => { + reduced = true; + render(); + const { title, steps } = demonstration(kind, "macos"); + open(title); + for (let i = 0; i < steps.length; i++) { + expect(document.querySelector("svg")).not.toBeNull(); + expect(caption()).toContain(steps[i]); + if (i < steps.length - 1) fireEvent.click(screen.getByRole("button", { name: /Next illustration/ })); + } + if (kind === "pair") expect(document.querySelectorAll(".pair-code-phone, .pair-code-pc")).toHaveLength(2); + }); +}); diff --git a/src/help/Demonstration.tsx b/src/help/Demonstration.tsx new file mode 100644 index 00000000..cba3935a --- /dev/null +++ b/src/help/Demonstration.tsx @@ -0,0 +1,96 @@ +import { createContext, useContext, useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { demonstration, type DemonstrationKind, type DemoPlatform } from "./demonstrations"; +import { PrototypeArtwork } from "./PrototypeArtwork"; +import { TeachingArtwork } from "./TeachingArtwork"; +import "./demonstrations.css"; + +const DemoContext = createContext<{ suspended: boolean; platform: DemoPlatform }>({ suspended: false, platform: "windows" }); + +export function DemonstrationProvider({ suspended, platform, children }: { suspended: boolean; platform: DemoPlatform; children: ReactNode }) { + return {children}; +} + +function useReducedMotion() { + const [reduced, setReduced] = useState(() => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false); + useEffect(() => { + const query = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!query) return; + const update = () => setReduced(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + return reduced; +} + +function Player({ kind }: { kind: DemonstrationKind }) { + const { suspended, platform } = useContext(DemoContext); + const { title, steps } = demonstration(kind, platform); + const reduced = useReducedMotion(); + const [step, setStep] = useState(0); + const [progress, setProgress] = useState(0); + const elapsed = useRef(0); + const [playing, setPlaying] = useState(!reduced); + const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined"); + const [pageVisible, setPageVisible] = useState(!document.hidden); + const root = useRef(null); + const id = useId(); + useEffect(() => { + if (!root.current || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { threshold: 0 }); + observer.observe(root.current); + return () => observer.disconnect(); + }, []); + useEffect(() => { + const changed = () => setPageVisible(!document.hidden); + document.addEventListener("visibilitychange", changed); + return () => document.removeEventListener("visibilitychange", changed); + }, []); + useEffect(() => { if (reduced || suspended) setPlaying(false); }, [reduced, suspended]); + useEffect(() => { + if (!playing || reduced || suspended || !visible || !pageVisible) return; + let frame = 0; + let previous: number | undefined; + const tick = (now: number) => { + if (previous !== undefined) elapsed.current += Math.min(now - previous, 100); + previous = now; + setProgress(Math.min(1, elapsed.current / 3000)); + if (elapsed.current >= 3000) { + if (step === steps.length - 1) setPlaying(false); + else { elapsed.current = 0; setProgress(0); setStep(step + 1); } + } else frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [playing, reduced, suspended, visible, pageVisible, step, steps.length]); + + return
+

Illustrated example — this does not operate your switches or change settings.

+
+ {kind === "grid" || kind === "line" || kind === "hold" ? : } +
+

{step + 1} / {steps.length}: {steps[step]}

+
+ {!reduced && <>} + {reduced && <>} +
+ {reduced &&

Reduced motion: use the buttons to view each still illustration.

} +
    {steps.map((text, index) =>
  1. {text}
  2. )}
+
; +} + +export function Demonstration({ kind }: { kind: DemonstrationKind }) { + const { platform } = useContext(DemoContext); + const [open, setOpen] = useState(false); + const id = useId(); + if (kind === "access" && platform !== "macos") return null; + const { title } = demonstration(kind, platform); + return
+ + {open &&
} +
; +} + +export function SwitchDemonstrations() { + return <>; +} diff --git a/src/help/PrototypeArtwork.tsx b/src/help/PrototypeArtwork.tsx new file mode 100644 index 00000000..e8f4978b --- /dev/null +++ b/src/help/PrototypeArtwork.tsx @@ -0,0 +1,212 @@ +// SVG artwork adapted from the local switch-interface-animation prototypes. +import { useId } from "react"; +import type { DemonstrationKind, DemoPlatform } from "./demonstrations"; + +export function PrototypeArtwork({ kind, step, platform }: { kind: DemonstrationKind; step: number; platform: DemoPlatform }) { + const prefix = useId(); + if (kind === "jack") return ( + + ); + if (kind === "usb") return ( + + ); + if (kind === "learn") return ( + + ); + if (kind === "select") return ( + + ); + if (kind === "access") return ( + + ); + if (kind === "pair") return ( + + ); + if (kind === "startup") return ( + + ); + return null; +} diff --git a/src/help/TeachingArtwork.tsx b/src/help/TeachingArtwork.tsx new file mode 100644 index 00000000..574614cd --- /dev/null +++ b/src/help/TeachingArtwork.tsx @@ -0,0 +1,39 @@ +import type { DemonstrationKind } from "./demonstrations"; + +function Switch({ down, x, y }: { down: boolean; x: number; y: number }) { + return ; +} + +export function TeachingArtwork({ kind, step, progress }: { kind: DemonstrationKind; step: number; progress: number }) { + if (kind === "hold") return ; + + const grid = kind === "grid"; + const phase = grid ? step : step === 0 ? 0 : step + 2; + const selected = progress < 0.75 ? Math.min(2, Math.floor(progress * 4)) : 1; + const column = phase === 2 ? selected : 1; + const row = phase === 1 ? selected : 1; + const travel = Math.min(1, progress / 0.8); + const lineX = phase === 3 ? (grid ? 187 : 40) + travel * (grid ? 103 : 250) : 290; + const lineY = phase === 4 ? (grid ? 143 : 50) + travel * (grid ? 37 : 130) : 180; + return ; +} diff --git a/src/help/demonstrations.css b/src/help/demonstrations.css new file mode 100644 index 00000000..e54789f6 --- /dev/null +++ b/src/help/demonstrations.css @@ -0,0 +1,15 @@ +.teaching-disclosure { margin: 12px 0; } +.teaching-disclosure > button { text-align: left; white-space: normal; } +.teaching-demo { margin-top: 12px; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); max-width: 760px; } +.teaching-art { width: 100%; margin: 12px 0; } +.teaching-art svg { display: block; width: 100%; height: auto; font-family: inherit; } +.teaching-art svg text { fill: var(--text); } +.teaching-art svg text[fill="#fff"] { fill: #fff; } +.teaching-art svg g, .teaching-art svg circle, .teaching-art svg rect { transition: transform 500ms ease, opacity 500ms ease, cy 500ms ease, cx 500ms ease, fill 500ms ease; } +.teaching-caption { line-height: 1.5; } +.teaching-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } +.teaching-steps { padding-left: 24px; font-size: 13px; line-height: 1.6; } +.teaching-steps li { margin: 8px 0; } +.teaching-steps li[aria-current="step"] { font-weight: 600; } +.teaching-demo[data-moving="false"] svg *, .teaching-demo[data-reduced="true"] svg * { transition: none; } +@media (prefers-reduced-motion: reduce) { .teaching-art svg * { transition: none; } } diff --git a/src/help/demonstrations.ts b/src/help/demonstrations.ts new file mode 100644 index 00000000..ae7ab1de --- /dev/null +++ b/src/help/demonstrations.ts @@ -0,0 +1,19 @@ +export type DemonstrationKind = "jack" | "usb" | "learn" | "select" | "grid" | "line" | "hold" | "access" | "pair" | "startup"; +export type DemoPlatform = "windows" | "macos" | "linux"; + +export function demonstration(kind: DemonstrationKind, platform: DemoPlatform) { + const location = platform === "macos" ? "menu bar" : "system tray"; + const examples = { + jack: { title: "Connect a jack switch", steps: ["Check which connector and socket your switch interface requires. This illustration uses socket 1 as an example.", "Insert the compatible switch plug fully into the interface.", "Connect the interface to your computer, then use Add switch to learn the key it sends."] }, + usb: { title: "Connect a USB switch", steps: ["A USB switch or keyboard interface sends a key when you press the switch.", "Connect it to a compatible USB port on your computer.", "Choose Add switch and learn its key. Connecting the cable alone does not configure Switchify."] }, + learn: { title: "Learn a switch key", steps: ["Choose Add switch, then Learn key to open the capture dialog.", "Press and release your physical switch once.", "The key it sends appears in the editor. F13 is only an example; your switch may send a different key."] }, + select: { title: "Assign Select", steps: ["After learning a key, open its Press and release action list.", "Choose Select so a short press and release chooses the highlighted item.", "Save the switch. Automatic scanning needs Select; manual scanning also needs Next and Previous."] }, + grid: { title: "Automatic grid then line scanning", steps: ["Focus the application you want to control. Press and release Select to start automatic scanning.", "Rows highlight in turn. Press and release Select to choose a row.", "Cells in that row highlight in turn. Press and release Select to choose a cell.", "Choose the horizontal position with Select as the vertical line moves across the cell.", "Choose the vertical position with Select as the horizontal line moves down the cell.", "With Auto select enabled, a countdown starts: wait for a left click, or press a switch to open the action menu.", "With Auto select disabled, the action menu opens immediately. Choose an action with Select. After a click, Select starts a new scan."] }, + line: { title: "Automatic line-only scanning", steps: ["Focus the application you want to control. Press and release Select to start automatic scanning.", "Choose the horizontal position with Select as the vertical line moves across the display.", "Choose the vertical position with Select as the horizontal line moves down the display.", "With Auto select enabled, wait through the countdown for a left click, or press a switch to open the action menu.", "With Auto select disabled, the action menu opens immediately. Choose an action with Select. After a click, Select starts a new scan."] }, + hold: { title: "Press, hold and release", steps: ["Press your switch briefly. Movement freezes while the switch is down.", "Release it to run its Press and release action, such as Select.", "Hold the switch longer to move through its configured hold actions. Scanning movement stays frozen.", "Keep holding until the action you want is offered. The actions shown here are examples, not your assignments.", "Release to execute the offered hold action. Holding never executes the offered action by itself; the emergency hold resets scanning."] }, + access: { title: "Allow Accessibility on macOS", steps: ["Choose Open Accessibility Settings in Switchify PC.", "In System Settings, open Privacy & Security, then Accessibility, and find Switchify PC.", "Enable Switchify PC, then return to the app so it can check access. This illustration does not grant permission."] }, + pair: { title: "Pair a mobile device", steps: ["Open Switchify on your mobile device and choose this computer while it is advertising.", "Compare the six-digit codes in both apps. These are example codes, not a live pairing request.", "Approve on this computer only if the codes match. If they differ or you did not request pairing, reject the request.", "A successful pairing trusts the mobile device. Check the real connection status in the app."] }, + startup: { title: "Understand startup behavior", steps: ["Start with system is optional. Choose whether Switchify should launch when you sign in.", `When enabled, Switchify starts quietly in the ${location}.`, `Open Switchify from the ${location} when you need its window. Start manually means opening the app yourself.`] }, + }; + return examples[kind]; +} diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index e4620cc6..9facce38 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -1,6 +1,7 @@ +import { Demonstration } from "../help/Demonstration"; import type { AppSettings } from "../types"; import { SettingGroup, Toggle, type SettingsUpdate } from "./controls"; export function GeneralSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { - return update("startWithSystem", value)} />; + return update("startWithSystem", value)} />; } diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 4b635ece..686953d0 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -1,3 +1,4 @@ +import { Demonstration } from "../help/Demonstration"; import { type ScanningController, validSwitches } from "../scanning/useScanning"; import { ScannerPreferences } from "./ScannerPreferences"; @@ -35,6 +36,7 @@ export function ScanningSection({ controller }: { controller: ScanningController

Assigned keys stay reserved while Switchify runs. Escape resets the scan. Mobile connections pause local scanning until they end.

On Windows, assigned keys remain switches with Shift, Ctrl, Alt or Windows held, even in the background. Other keys and Switchify-generated shortcuts still work normally.

+ ; } diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index faae3cc4..61a1a14a 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,3 +1,4 @@ +import { Demonstration, SwitchDemonstrations } from "../help/Demonstration"; import { SwitchPractice } from "../scanning/SwitchPractice"; import { createPortal } from "react-dom"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; @@ -126,6 +127,7 @@ function SwitchEditor({ }; return (
+ {!remote && }