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 {children};
}
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
-
-
- {ready ? "Ready" : "Switch control"}
{message}
-
+
+
+
+
{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) {