diff --git a/src/App.tsx b/src/App.tsx index 119ffbfd..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"; @@ -49,7 +50,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 }) { @@ -65,29 +66,33 @@ 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 { 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.

-
- -

{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 (optional)

{mobileConnected ? state.connectedDeviceName ?? bluetoothLabels.connected : 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"; + // 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..aa30e84e --- /dev/null +++ b/src/homeStatus.test.ts @@ -0,0 +1,32 @@ +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 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"], + ["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 }; +} 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) {