From a8f030f9038a97e033cee1aeedaa11cd3bd5a2fd Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:22:06 +0100 Subject: [PATCH] Explain setup navigation requirements and skip scopes (#824) --- src/App.test.tsx | 34 +++++++++++++++++++++++++++++++++- src/App.tsx | 23 +++++++++++++++++++++-- src/styles.css | 3 +++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index b0d5ccf6..5d35f7b2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -73,6 +73,8 @@ describe("Switchify PC shell", () => { fireEvent.click(screen.getByRole("button", { name: "Add switch" })); await waitFor(() => expect(screen.getByRole("button", { name: "Next" })).toBeDisabled()); expect(screen.getByRole("button", { name: "Skip switch configuration" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip setup for now" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription("Save or cancel the new switch before continuing."); fireEvent.click(screen.getByRole("button", { name: "Cancel new switch" })); await waitFor(() => expect(screen.getByRole("button", { name: "Next" })).toBeEnabled()); switches.unsaved = true; @@ -80,6 +82,30 @@ describe("Switchify PC shell", () => { rerender(); expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Retry save" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription("Your switch changes have not saved. Retry saving before continuing."); + }); + + it.each([ + ["capture", "Finish capturing a switch or cancel capture before continuing."], + ["save", "Wait for your switch changes to finish saving before continuing."], + ])("explains and preserves navigation guards during %s", async (operation, message) => { + browserState.setup.autoOpenEligible = true; + const switches = localSwitches(); + vi.spyOn(switchHooks, "useSwitches").mockReturnValue(switches); + const { rerender } = render(); + fireEvent.click(await screen.findByRole("button", { name: "Next" })); + if (operation === "capture") switches.capturing = true; + else switches.pending = 1; + rerender(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription(message); + expect(screen.getByRole("button", { name: "Skip setup for now" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip switch configuration" })).toBeDisabled(); + switches.capturing = false; + switches.pending = 0; + rerender(); + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); + expect(screen.queryByText(message)).not.toBeInTheDocument(); }); it("suspends setup for pairing and returns to the same step", async () => { @@ -319,9 +345,12 @@ describe("Switchify PC shell", () => { await waitFor(() => expect(guide).toHaveFocus()); expect(screen.getByLabelText("Step 1 of 5")).toBeInTheDocument(); await waitFor(() => expect(markShown).toHaveBeenCalledOnce()); - fireEvent.click(screen.getByRole("button", { name: "Skip for now" })); + fireEvent.click(screen.getByRole("button", { name: "Skip setup for now" })); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Input access" })).not.toBeInTheDocument()); expect(markShown).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole("button", { name: "Support" })); + fireEvent.click(screen.getByRole("button", { name: "Open setup guide" })); + expect(await screen.findByRole("dialog", { name: "Input access" })).toBeInTheDocument(); }); it("does not force setup on an existing paired user", async () => { @@ -355,13 +384,16 @@ describe("Switchify PC shell", () => { fireEvent.click(await screen.findByRole("button", { name: "Next" })); expect(screen.getByRole("dialog", { name: "Add your switch" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription(/If you use only remote switches/); fireEvent.click(screen.getByRole("button", { name: "Skip switch configuration" })); expect(screen.getByRole("dialog", { name: "Scanning basics" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Next" })); expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toHaveAccessibleDescription("Choose Start with system or Start manually to enable Next."); fireEvent.click(screen.getByRole("button", { name: "Start manually" })); fireEvent.click(screen.getByRole("button", { name: "Next" })); expect(screen.getByRole("button", { name: "Finish" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Finish" })).toHaveAccessibleDescription("Choose whether to share diagnostics to enable Finish."); fireEvent.click(screen.getByRole("button", { name: "Don’t share" })); fireEvent.click(screen.getByRole("button", { name: "Finish" })); await waitFor(() => expect(complete).toHaveBeenCalledWith(false, false)); diff --git a/src/App.tsx b/src/App.tsx index b0852801..8de86337 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { Accessibility, Bluetooth, ChevronRight, CircleHelp, Download, Copy, Home, Keyboard, Plus, Power, RefreshCw, Save, Settings, @@ -361,6 +361,24 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc const switchBusy = capturing || !!switches.pending || switches.unsaved || switchDraft; const titles = ["Input access", "Add your switch", "Scanning basics", "Start with system", "Anonymous diagnostics"]; const canContinue = step === 1 ? !switchBusy && !!switches.state?.settings.bindings.length : step === 3 ? startupChoice !== null : step === 4 ? diagnosticsChoice !== null : true; + const guidanceId = useId(); + const navigationGuidance = capturing + ? "Finish capturing a switch or cancel capture before continuing." + : switches.pending + ? "Wait for your switch changes to finish saving before continuing." + : switches.unsaved + ? "Your switch changes have not saved. Retry saving before continuing." + : switchDraft + ? "Save or cancel the new switch before continuing." + : busy + ? "Please wait while setup saves your changes." + : step === 1 && !switches.state?.settings.bindings.length + ? "Add and save a local keyboard switch to enable Next. If you use only remote switches or want to configure switches later, choose Skip switch configuration to continue setup. Remote presets alone do not confirm a connected switch." + : step === 3 && startupChoice === null + ? "Choose Start with system or Start manually to enable Next." + : step === 4 && diagnosticsChoice === null + ? "Choose whether to share diagnostics to enable Finish." + : null; useEffect(() => { if (!suspended && !capturing) dialogRef.current?.focus(); }, [step, suspended]); @@ -387,7 +405,8 @@ function SetupGuide({ state, switches, suspended, busy, error, skip, finish, acc {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
} -
{step === 1 && }
+

{navigationGuidance}

+ ; } diff --git a/src/styles.css b/src/styles.css index 488afc26..13a39be7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -441,3 +441,6 @@ button:disabled { cursor: default; opacity: 0.55; } .scan-rate-stepper { display: flex; align-items: center; gap: 8px; } .scan-rate-stepper button { min-width: 44px; min-height: 44px; font-size: 22px; } .scan-rate-stepper output { min-width: 72px; text-align: center; font-variant-numeric: tabular-nums; } + +.setup-navigation-guidance { flex: 0 0 auto; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } +.setup-navigation-guidance:not(:empty) { padding-bottom: 16px; }