From e0a0e77626133afc33e25cb3af19cf3e41ef875d Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 11:19:06 +0100 Subject: [PATCH 1/3] Make the Home view a clear status summary The Home band becomes a status hero whose title names the state (Ready, Finish setting up, Paused, Paused for mobile, or needs attention), with a matching leading-edge tone and one action that is primary only while something is left to do. Input access, switches, scanning and the optional mobile connection share one readiness checklist. Status tiles show done or needs-attention with both colour and a corner badge, and are hidden from assistive tech since the row text already says it. Closes #884 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.tsx | 22 ++++++++++++++-------- src/ui/app.css | 30 ++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 119ffbfd..a76c6530 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,7 @@ function NavButton({ active, icon, children, onClick }: { active: boolean; icon: // `ok` omitted marks a row that has no state to report, only a subject. function StatusIcon({ ok, children }: { ok?: boolean; children: ReactNode }) { - return {children}; + return ; } function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) { @@ -67,27 +67,33 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi const hasSelect = saved.some((binding) => binding.pressAction === "select" || binding.holdActions.includes("select")); const ready = state.accessibility === "granted" && hasSelect && !switches.error && !scanning.error && state.bluetooth !== "connected" && scanning.state?.supported && scanning.state.enabled && !scanning.state.paused; const message = switches.error ?? scanning.error ?? (state.accessibility !== "granted" ? "Allow input access to control this computer." : !scanning.state ? "Loading switch control..." : !scanning.state.supported ? scanning.state.message : scanning.state.remote ? "Remote controls scanning. Use the switches in Remote; PC Escape stops the session." : state.bluetooth === "connected" ? "Local scanning is paused while a mobile device is connected." : !hasSelect ? "Add a switch with the Select action to begin scanning." : scanning.state.paused ? "Scanning is paused. Use your Pause / resume switch to continue." : scanning.state.enabled ? "Focus the application you want to use, then press and release your Select switch." : scanning.state.message); + // The hero names the state in words, so its colour is never the only cue. + const failed = !!(switches.error ?? scanning.error); + const setupNeeded = state.accessibility !== "granted" || !hasSelect; + const tone = ready ? "ready" : failed || setupNeeded ? "attention" : "neutral"; + const title = ready ? "Ready" : failed ? "Switch control needs attention" : setupNeeded ? "Finish setting up" : state.bluetooth === "connected" ? "Paused for mobile" : scanning.state?.paused ? "Paused" : "Switch control"; + const mobileConnected = state.bluetooth === "connected"; return

Switchify PC

Control your computer with switches.

-
- -

{ready ? "Ready" : "Switch control"}

{message}

- +
+ +

{title}

{message}

+

Input access

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

Saved switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

Scanning

Select starts {scanning.config.controlMode === "mouse" ? "Mouse scanning" : "Point scanning"} · {areaOptions(scanning.config, scanning.config.controlMode).automatic ? "Automatic" : "Manual"}

+

Mobile connection

{mobileConnected ? state.connectedDeviceName ?? bluetoothLabels.connected : `Optional. ${bluetoothLabels[state.bluetooth]}`}

{mobileConnected ? : }
-

Mobile connection

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" && }
; } function MobileConnection({ state, onDisconnect }: { state: AppState; onDisconnect: () => void }) { const bluetoothOk = state.bluetooth === "advertising" || state.bluetooth === "connected"; return
-
-

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

+
+

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

{state.bluetooth === "connected" && }

Optional. Connecting a mobile device pauses local scanning.

diff --git a/src/ui/app.css b/src/ui/app.css index fb5b5cfa..401c6520 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -206,12 +206,31 @@ main { min-width: 0; } .update-banner .btn.text-button { color: var(--on-brand-container); } /* --- Status and lists ------------------------------------------------------- */ -.connection-band { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 16px; min-height: 94px; border: 1px solid var(--border); border-left: 4px solid var(--inactive); border-radius: var(--radius-card); padding: 20px; background: var(--surface); } -.connection-band[data-connected="true"] { border-left-color: var(--status-ok); } -.connection-band p { margin-top: 4px; color: var(--muted); } -.connection-band > .btn { grid-column: 1 / -1; } -.status-icon { display: grid; flex: none; place-items: center; width: 40px; height: 40px; border-radius: 50%; color: var(--muted); background: var(--surface-muted); } +/* A status band names its state in words, tints its icon and marks its + leading edge; the tone is never carried by colour alone. */ +.connection-band { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px 20px; border: 1px solid var(--border); border-left: 6px solid var(--inactive); border-radius: var(--radius-card); padding: 20px 24px; background: var(--surface); } +.connection-band[data-tone="ready"] { border-left-color: var(--status-ok); } +.connection-band[data-tone="attention"] { border-left-color: var(--status-attention); } +.connection-band p { max-width: 60ch; margin-top: 4px; color: var(--muted); } +.status-hero { padding: 28px 24px; } +.status-hero .status-icon { width: 56px; height: 56px; } +.status-hero h2 { font-size: 26px; line-height: 1.25; font-weight: 700; } +.status-hero p { font-size: 18px; } +@container (max-width: 600px) { + .connection-band { grid-template-columns: auto minmax(0, 1fr); } + .connection-band > .btn { grid-column: 1 / -1; } +} + +/* Status tiles: green with a tick when done, amber with a mark when + something needs doing, neutral when a row only names its subject. The + corner badge is a second, shape-based cue beside the colour. */ +.status-icon { position: relative; display: grid; flex: none; place-items: center; width: 40px; height: 40px; border-radius: 50%; color: var(--muted); background: var(--surface-muted); } .status-icon[data-ok="true"] { color: var(--status-ok); background: var(--status-ok-soft); } +.status-icon[data-ok="false"] { color: var(--status-attention); background: var(--status-attention-soft); } +.status-icon:is([data-ok="true"], [data-ok="false"])::after { position: absolute; right: -3px; bottom: -3px; display: grid; place-items: center; width: 18px; height: 18px; border: 2px solid var(--surface); border-radius: 50%; color: var(--surface); font: 800 11px/1 system-ui, sans-serif; } +.status-icon[data-ok="true"]::after { background: var(--status-ok); content: "✓"; } +.status-icon[data-ok="false"]::after { background: var(--status-attention); content: "!"; } +.status-hero .status-icon::after { width: 22px; height: 22px; font-size: 13px; } /* Grouped rows: one card per list, hairlines between rows. A row is an icon, its text, then any actions on the right; when the list is narrow the @@ -521,7 +540,6 @@ main { min-width: 0; } @media (max-width: 480px) { .app-shell nav { grid-template-columns: repeat(2, minmax(0, 1fr)); } .view { padding: 12px; } - .connection-band { grid-template-columns: minmax(0, 1fr); } .btn.profile-row { display: flex; flex-wrap: wrap; } } @media (max-width: 520px) { From 1aa37c95cc5e30b0d2b411c37c1341230176d89d Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 11:49:14 +0100 Subject: [PATCH 2/3] Derive the Home title, tone and message from one ordered list The title was computed separately from the message and could contradict it: unavailable input access said "Finish setting up", Remote in control or a mobile connection without a local Select said "Finish setting up", and every launch flashed amber while switches loaded. homeStatus now returns all three from one ordered list of cases, amber only for things a person can fix here, with a test for each case. The mobile row reads "Mobile connection (optional)", and the Mobile page band stays neutral for Bluetooth that is starting, unsupported or failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.tsx | 17 ++++++++--------- src/homeStatus.test.ts | 31 +++++++++++++++++++++++++++++++ src/homeStatus.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 src/homeStatus.test.ts create mode 100644 src/homeStatus.ts diff --git a/src/App.tsx b/src/App.tsx index a76c6530..af63fb3e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { TabPanel, Tabs } from "./Tabs"; import { useSwitches, type SwitchController } from "./scanning/useSwitches"; import { useScanning, type ScanningController } from "./scanning/useScanning"; import { areaOptions } from "./scanning/preferences"; +import { homeStatus } from "./homeStatus"; import { SwitchesSection } from "./settings/SwitchesSection"; import { ScanningSection } from "./settings/ScanningSection"; @@ -65,13 +66,9 @@ function AccessibilityCopy({ state, detailed = false }: { state: AppState; detai function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessibility }: { state: AppState; switches: SwitchController; scanning: ScanningController; navigate: (view: View) => void; onDisconnect: () => void; onAccessibility: () => void }) { const saved = switches.state?.settings.bindings ?? []; const hasSelect = saved.some((binding) => binding.pressAction === "select" || binding.holdActions.includes("select")); - const ready = state.accessibility === "granted" && hasSelect && !switches.error && !scanning.error && state.bluetooth !== "connected" && scanning.state?.supported && scanning.state.enabled && !scanning.state.paused; - const message = switches.error ?? scanning.error ?? (state.accessibility !== "granted" ? "Allow input access to control this computer." : !scanning.state ? "Loading switch control..." : !scanning.state.supported ? scanning.state.message : scanning.state.remote ? "Remote controls scanning. Use the switches in Remote; PC Escape stops the session." : state.bluetooth === "connected" ? "Local scanning is paused while a mobile device is connected." : !hasSelect ? "Add a switch with the Select action to begin scanning." : scanning.state.paused ? "Scanning is paused. Use your Pause / resume switch to continue." : scanning.state.enabled ? "Focus the application you want to use, then press and release your Select switch." : scanning.state.message); // The hero names the state in words, so its colour is never the only cue. - const failed = !!(switches.error ?? scanning.error); - const setupNeeded = state.accessibility !== "granted" || !hasSelect; - const tone = ready ? "ready" : failed || setupNeeded ? "attention" : "neutral"; - const title = ready ? "Ready" : failed ? "Switch control needs attention" : setupNeeded ? "Finish setting up" : state.bluetooth === "connected" ? "Paused for mobile" : scanning.state?.paused ? "Paused" : "Switch control"; + const { title, tone, message } = homeStatus({ accessibility: state.accessibility, bluetooth: state.bluetooth, switchesLoaded: !!switches.state, hasSelect, error: switches.error ?? scanning.error, scanning: scanning.state }); + const ready = tone === "ready"; const mobileConnected = state.bluetooth === "connected"; return

Switchify PC

Control your computer with switches.

@@ -84,16 +81,18 @@ function HomeView({ state, switches, scanning, navigate, onDisconnect, onAccessi

Input access

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

Saved switches

{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}

Scanning

Select starts {scanning.config.controlMode === "mouse" ? "Mouse scanning" : "Point scanning"} · {areaOptions(scanning.config, scanning.config.controlMode).automatic ? "Automatic" : "Manual"}

-

Mobile connection

{mobileConnected ? state.connectedDeviceName ?? bluetoothLabels.connected : `Optional. ${bluetoothLabels[state.bluetooth]}`}

{mobileConnected ? : }
+

Mobile connection (optional)

{mobileConnected ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}

{mobileConnected ? : }
; } function MobileConnection({ state, onDisconnect }: { state: AppState; onDisconnect: () => void }) { const bluetoothOk = state.bluetooth === "advertising" || state.bluetooth === "connected"; + // Amber only when the person can do something about it here. + const bluetoothFixable = state.bluetooth === "poweredOff" || state.bluetooth === "unauthorized" || state.bluetooth === "conflict"; return
-
-

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

+
+

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

{state.bluetooth === "connected" && }

Optional. Connecting a mobile device pauses local scanning.

diff --git a/src/homeStatus.test.ts b/src/homeStatus.test.ts new file mode 100644 index 00000000..19e3aa25 --- /dev/null +++ b/src/homeStatus.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { homeStatus, type HomeStatusInput } from "./homeStatus"; +import { defaultPointScanConfig, type PointScanState } from "./scanning/useScanning"; + +const scanning: PointScanState = { config: defaultPointScanConfig, enabled: true, paused: false, phase: "idle", message: "Scanning is off.", supported: true }; +const ready: HomeStatusInput = { accessibility: "granted", bluetooth: "advertising", switchesLoaded: true, hasSelect: true, error: null, scanning }; + +describe("homeStatus", () => { + it.each<[string, Partial, string, string]>([ + ["ready", {}, "Ready", "ready"], + ["an error", { error: "Could not save switches." }, "Switch control needs attention", "attention"], + ["input access required", { accessibility: "required" }, "Finish setting up", "attention"], + ["input access unavailable", { accessibility: "unavailable", hasSelect: false }, "Input access unavailable", "neutral"], + ["scanning still loading", { scanning: null }, "Switch control", "neutral"], + ["switches still loading", { switchesLoaded: false, hasSelect: false }, "Switch control", "neutral"], + ["scanning unsupported", { scanning: { ...scanning, supported: false, message: "Unsupported." }, hasSelect: false }, "Switch control unavailable", "neutral"], + ["Remote in control without a local Select", { scanning: { ...scanning, remote: true }, bluetooth: "connected", hasSelect: false }, "Remote in control", "neutral"], + ["a mobile connection without a local Select", { bluetooth: "connected", hasSelect: false }, "Paused for mobile", "neutral"], + ["no Select switch", { hasSelect: false }, "Finish setting up", "attention"], + ["paused", { scanning: { ...scanning, paused: true } }, "Paused", "neutral"], + ["scanning off", { scanning: { ...scanning, enabled: false } }, "Switch control", "neutral"], + ])("names %s", (_, change, title, tone) => { + expect(homeStatus({ ...ready, ...change })).toMatchObject({ title, tone }); + }); + + it("keeps the message on the same case as the title", () => { + expect(homeStatus({ ...ready, bluetooth: "connected", hasSelect: false }).message).toBe("Local scanning is paused while a mobile device is connected."); + expect(homeStatus({ ...ready, switchesLoaded: false, hasSelect: false }).message).toBe("Loading switch control..."); + expect(homeStatus({ ...ready, scanning: { ...scanning, supported: false, message: "Unsupported." }, hasSelect: false }).message).toBe("Unsupported."); + }); +}); diff --git a/src/homeStatus.ts b/src/homeStatus.ts new file mode 100644 index 00000000..dd7d4ef0 --- /dev/null +++ b/src/homeStatus.ts @@ -0,0 +1,33 @@ +import type { AppState } from "./types"; +import type { PointScanState } from "./scanning/useScanning"; + +export type HomeTone = "ready" | "attention" | "neutral"; +export type HomeStatus = { title: string; tone: HomeTone; message: string }; + +export type HomeStatusInput = { + accessibility: AppState["accessibility"]; + bluetooth: AppState["bluetooth"]; + /** False until the switch settings have loaded. */ + switchesLoaded: boolean; + hasSelect: boolean; + error: string | null; + scanning: PointScanState | null; +}; + +// One ordered list of cases, so the title, tone and message always describe +// the same state. Amber ("attention") is only for something a person can fix +// here; states outside their control, or still loading, stay neutral. +export function homeStatus({ accessibility, bluetooth, switchesLoaded, hasSelect, error, scanning }: HomeStatusInput): HomeStatus { + if (error) return { title: "Switch control needs attention", tone: "attention", message: error }; + if (accessibility === "required") return { title: "Finish setting up", tone: "attention", message: "Allow input access to control this computer." }; + if (accessibility === "unavailable") return { title: "Input access unavailable", tone: "neutral", message: "Input access is unavailable on this system." }; + if (!scanning) return { title: "Switch control", tone: "neutral", message: "Loading switch control..." }; + if (!scanning.supported) return { title: "Switch control unavailable", tone: "neutral", message: scanning.message }; + if (scanning.remote) return { title: "Remote in control", tone: "neutral", message: "Remote controls scanning. Use the switches in Remote; PC Escape stops the session." }; + if (bluetooth === "connected") return { title: "Paused for mobile", tone: "neutral", message: "Local scanning is paused while a mobile device is connected." }; + if (!switchesLoaded) return { title: "Switch control", tone: "neutral", message: "Loading switch control..." }; + if (!hasSelect) return { title: "Finish setting up", tone: "attention", message: "Add a switch with the Select action to begin scanning." }; + if (scanning.paused) return { title: "Paused", tone: "neutral", message: "Scanning is paused. Use your Pause / resume switch to continue." }; + if (scanning.enabled) return { title: "Ready", tone: "ready", message: "Focus the application you want to use, then press and release your Select switch." }; + return { title: "Switch control", tone: "neutral", message: scanning.message }; +} From fc784ddc6b7064285b85989d22bd5234ddcf2449 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 12:00:08 +0100 Subject: [PATCH 3/3] Cover Remote in control when everything else is ready Co-Authored-By: Claude Opus 5.5 (1M context) --- src/homeStatus.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/homeStatus.test.ts b/src/homeStatus.test.ts index 19e3aa25..aa30e84e 100644 --- a/src/homeStatus.test.ts +++ b/src/homeStatus.test.ts @@ -14,6 +14,7 @@ describe("homeStatus", () => { ["scanning still loading", { scanning: null }, "Switch control", "neutral"], ["switches still loading", { switchesLoaded: false, hasSelect: false }, "Switch control", "neutral"], ["scanning unsupported", { scanning: { ...scanning, supported: false, message: "Unsupported." }, hasSelect: false }, "Switch control unavailable", "neutral"], + ["Remote in control when otherwise ready", { scanning: { ...scanning, remote: true } }, "Remote in control", "neutral"], ["Remote in control without a local Select", { scanning: { ...scanning, remote: true }, bluetooth: "connected", hasSelect: false }, "Remote in control", "neutral"], ["a mobile connection without a local Select", { bluetooth: "connected", hasSelect: false }, "Paused for mobile", "neutral"], ["no Select switch", { hasSelect: false }, "Finish setting up", "attention"],