Skip to content
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
25 changes: 15 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <span className="status-icon" data-ok={ok}>{children}</span>;
return <span className="status-icon" data-ok={ok} aria-hidden="true">{children}</span>;
}

function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) {
Expand All @@ -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 <div className="view">
<header className="page-header"><div><h1>Switchify PC</h1><p>Control your computer with switches.</p></div></header>
<section className="connection-band" data-connected={!!ready}>
<StatusIcon ok={!!ready}><Keyboard size={20} /></StatusIcon>
<div><h2>{ready ? "Ready" : "Switch control"}</h2><p role="status">{message}</p></div>
<Button className="primary" onClick={() => navigate("switches")}>{hasSelect ? "Edit switches" : "Set up switches"}</Button>
<section className="connection-band status-hero" data-tone={tone} aria-labelledby="home-status-title">
<StatusIcon ok={ready ? true : tone === "attention" ? false : undefined}><Keyboard size={26} /></StatusIcon>
<div><h2 id="home-status-title">{title}</h2><p role="status">{message}</p></div>
<Button className={ready ? "secondary" : "primary"} onClick={() => navigate("switches")}>{hasSelect ? "Edit switches" : "Set up switches"}</Button>
</section>
<section className="status-list" aria-label="Switch control status">
<article><StatusIcon ok={state.accessibility === "granted"}><Accessibility size={19} /></StatusIcon><div><h3>Input access</h3><AccessibilityCopy state={state} /></div>{state.accessibility === "required" && <Button className="text-button" onClick={onAccessibility}>Open Accessibility Settings</Button>}</article>
<article><StatusIcon ok={hasSelect}><Keyboard size={19} /></StatusIcon><div><h3>Saved switches</h3><p>{saved.length} saved · {hasSelect ? "Select assigned" : "Select action needed"}</p></div></article>
<article><StatusIcon><SlidersHorizontal size={19} /></StatusIcon><div><h3>Scanning</h3><p>Select starts {scanning.config.controlMode === "mouse" ? "Mouse scanning" : "Point scanning"} · {areaOptions(scanning.config, scanning.config.controlMode).automatic ? "Automatic" : "Manual"}</p></div><Button className="text-button" onClick={() => navigate("scanning")}>Change scanning</Button></article>
<article><StatusIcon ok={mobileConnected || undefined}><Smartphone size={19} /></StatusIcon><div><h3>Mobile connection (optional)</h3><p>{mobileConnected ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}</p></div>{mobileConnected ? <Button className="secondary" onClick={onDisconnect}>Disconnect</Button> : <Button className="text-button" onClick={() => navigate("mobile")}>Connect mobile</Button>}</article>
</section>
<section className="status-list" aria-label="Optional mobile connection"><article><StatusIcon><Smartphone size={19} /></StatusIcon><div><h3>Mobile connection</h3><p>{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothLabels.connected : bluetoothLabels[state.bluetooth]}</p></div><Button className="text-button" onClick={() => navigate("mobile")}>Connect mobile</Button>{state.bluetooth === "connected" && <Button className="secondary" onClick={onDisconnect}>Disconnect</Button>}</article></section>
</div>;
}

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 <section>
<section className="connection-band" data-connected={state.bluetooth === "connected"}>
<StatusIcon ok={bluetoothOk}><Bluetooth size={20} /></StatusIcon><div><h2>{bluetoothLabels[state.bluetooth]}</h2><p>{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}</p></div>
<section className="connection-band" data-tone={state.bluetooth === "connected" ? "ready" : bluetoothFixable ? "attention" : "neutral"}>
<StatusIcon ok={bluetoothOk || (bluetoothFixable ? false : undefined)}><Bluetooth size={20} /></StatusIcon><div><h2>{bluetoothLabels[state.bluetooth]}</h2><p>{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}</p></div>
{state.bluetooth === "connected" && <Button className="secondary" onClick={onDisconnect}>Disconnect</Button>}
</section>
<p>Optional. Connecting a mobile device pauses local scanning.</p>
Expand Down
32 changes: 32 additions & 0 deletions src/homeStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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<HomeStatusInput>, 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.");
});
});
33 changes: 33 additions & 0 deletions src/homeStatus.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
30 changes: 24 additions & 6 deletions src/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading