diff --git a/src/App.tsx b/src/App.tsx index 103ca7e5..119ffbfd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,10 +44,11 @@ const bluetoothDescriptions: Record = { }; function NavButton({ active, icon, children, onClick }: { active: boolean; icon: ReactNode; children: ReactNode; onClick: () => void }) { - return ; + return ; } -function StatusIcon({ ok, children }: { ok: boolean; children: ReactNode }) { +// `ok` omitted marks a row that has no state to report, only a subject. +function StatusIcon({ ok, children }: { ok?: boolean; children: ReactNode }) { return {children}; } @@ -76,9 +77,9 @@ 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"}

+

Scanning

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

-

Mobile connection

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

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

Mobile connection

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

{state.bluetooth === "connected" && }
; } @@ -99,9 +100,9 @@ function MobileConnection({ state, onDisconnect }: { state: AppState; onDisconne } function DevicesView({ state, forget }: { state: AppState; forget: (id: string) => void }) { - return

Paired devices

Mobile devices trusted by this computer

- {state.pairedDevices.length === 0 ?

No paired devices

Open Switchify on your mobile device to pair while this computer is advertising.

: -
{state.pairedDevices.map((device) =>

{device.deviceName}

{device.lastSeenAt !== null ? `Last connected ${new Date(device.lastSeenAt).toLocaleString()}` : "Not connected yet"}

)}
} + return

Paired devices

Mobile devices trusted by this computer

+ {state.pairedDevices.length === 0 ?

No paired devices

Open Switchify on your mobile device to pair while this computer is advertising.

: +
{state.pairedDevices.map((device) =>

{device.deviceName}

{device.lastSeenAt !== null ? `Last connected ${new Date(device.lastSeenAt).toLocaleString()}` : "Not connected yet"}

)}
}
; } @@ -304,10 +305,10 @@ function ProfileEditor({ profile, profiles, onClose, onSave, onDelete, onDuplica function ProfilesView({ profiles, platform, saveProfile, deleteProfile, onDirtyChange, nativeExitRequest, onConfirmNativeExit, onCancelNativeExit, busy }: { profiles: SwitchProfile[]; platform: AppState["capabilities"]["platform"]; saveProfile: (profile: SwitchProfile) => Promise; deleteProfile: (id: string) => Promise; onDirtyChange: (dirty: boolean) => void; nativeExitRequest: ProfileExitAction | null; onConfirmNativeExit: () => void; onCancelNativeExit: () => void; busy: boolean }) { const [editing, setEditing] = useState(null); const openerRef = useRef(null); - const closeEditor = () => { setEditing(null); requestAnimationFrame(() => (openerRef.current?.isConnected ? openerRef.current : document.querySelector(".page-header button"))?.focus()); }; + const closeEditor = () => { setEditing(null); requestAnimationFrame(() => (openerRef.current?.isConnected ? openerRef.current : document.querySelector(".section-header button"))?.focus()); }; const openEditor = (profile: SwitchProfile, opener: HTMLButtonElement) => { openerRef.current = opener; setEditing(profile); }; - return

Switch Forwarding

Profiles available to physical switch sessions

-
{profiles.map((profile) => )}
+ return

Switch Forwarding

Profiles available to physical switch sessions

+
{profiles.map((profile) => )}
{platform === "macos" &&

Grid 3 profiles are available on Windows only.

} {editing && { closeEditor(); onConfirmNativeExit(); }} onCancelNativeExit={onCancelNativeExit} onClose={closeEditor} onDuplicate={() => setEditing(duplicateProfile(editing, profiles))} onSave={async (profile) => { await saveProfile(profile); closeEditor(); }} onDelete={editing.builtIn || !profiles.some((profile) => profile.id === editing.id) ? null : async () => { await deleteProfile(editing.id); closeEditor(); }} />}
; @@ -331,20 +332,20 @@ const supportTabs = [{ id: "setup", label: "Setup" }, { id: "troubleshooting", l function SupportView({ state, switches, busy, perform, openSetup, openUpdates }: { state: AppState; switches: SwitchController; busy: boolean; perform: (operation: () => Promise) => void; openSetup: () => void; openUpdates: () => void }) { const [tab, setTab] = useState<(typeof supportTabs)[number]["id"]>("setup"); - return

Help

+ return

Help

Set up Switchify PC and fix common problems.

{tab === "setup" ? <>

Input access

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

Local switches

{switches.state?.settings.bindings.length ?? 0} configured. Open Switches to assign Select, then open Scanning to adjust movement.

-

Optional mobile connection

{bluetoothLabels[state.bluetooth]}. Open Mobile connection for pairing and forwarding profiles.

+

Local switches

{switches.state?.settings.bindings.length ?? 0} configured. Open Switches to assign Select, then open Scanning to adjust movement.

+

Optional mobile connection

{bluetoothLabels[state.bluetooth]}. Open Mobile connection for pairing and forwarding profiles.

:
-

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" ? : }
-

Application update

Switchify PC {state.version}

-

Diagnostics

Export sanitized health, capability, and recent event data

-

Recent Bluetooth changes

{state.diagnostics.recentBluetooth.length > 0 ? state.diagnostics.recentBluetooth.map((event) => event.status).join(" → ") : "No Bluetooth changes recorded yet"}

-

Last disconnect

{state.diagnostics.lastDisconnect ? `${state.diagnostics.lastDisconnect.detail ?? state.diagnostics.lastDisconnect.status}` : "No disconnect recorded yet"}

-

Recent errors

{state.diagnostics.recentErrors.length > 0 ? state.diagnostics.recentErrors.map((event) => event.detail ?? event.status).join(" · ") : "No recent errors"}

+

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

+

Input access

{state.accessibility === "required" ? : }
+

Application update

Switchify PC {state.version}

+

Diagnostics

Export sanitized health, capability, and recent event data

+

Recent Bluetooth changes

{state.diagnostics.recentBluetooth.length > 0 ? state.diagnostics.recentBluetooth.map((event) => event.status).join(" → ") : "No Bluetooth changes recorded yet"}

+

Last disconnect

{state.diagnostics.lastDisconnect ? `${state.diagnostics.lastDisconnect.detail ?? state.diagnostics.lastDisconnect.status}` : "No disconnect recorded yet"}

+

Recent errors

{state.diagnostics.recentErrors.length > 0 ? state.diagnostics.recentErrors.map((event) => event.detail ?? event.status).join(" · ") : "No recent errors"}

}
; } @@ -778,10 +779,10 @@ export function App() {

{updateNotice}

{view === "home" && void perform(api.disconnectAll)} onAccessibility={() => void perform(() => api.checkAccessibility(true))} />} - {view === "switches" &&

Switches

} - {view === "scanning" &&

Scanning

Choose how your switches control the pointer.

} + {view === "switches" &&

Switches

The switches you use to control this computer.

} + {view === "scanning" &&

Scanning

Choose how your switches control the pointer.

} {view === "mouse" && } - {(view === "mobile" || view === "devices" || view === "profiles") &&

Mobile

+ {(view === "mobile" || view === "devices" || view === "profiles") &&

Mobile

Control this computer from Switchify on a phone or tablet.

{view === "mobile" && void perform(api.disconnectAll)} />} {view === "devices" && void perform(() => api.forgetDevice(id))} />} {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />} diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index 5961bb82..f990d425 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Settings } from "lucide-react"; import type { AppSettings, AppState } from "../types"; import { Tabs, TabPanel, type TabDefinition } from "../Tabs"; import { GeneralSection } from "./GeneralSection"; @@ -51,7 +50,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value }); - return

Settings

How Switchify PC behaves on this computer

+ return

Settings

How Switchify PC behaves on this computer

{active === "general" && } diff --git a/src/ui/app.css b/src/ui/app.css index 1858e7cc..fb5b5cfa 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -168,26 +168,31 @@ h1, h2, h3, p { margin: 0; letter-spacing: 0; } .field { display: grid; gap: 6px; min-width: 0; margin-top: 20px; color: var(--muted); } .field-error { color: var(--status-error); line-height: 1.35; } -/* --- Shell ------------------------------------------------------------------ */ +/* --- Shell ------------------------------------------------------------------ + The sidebar is quiet: the active place is a raised surface with a brand bar + and brand-coloured icon, so the page's own primary action stays the only + solid red on screen. */ .app-shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; border-top: 3px solid var(--chrome); background: var(--app-background); } -.app-shell > aside { position: sticky; top: 0; display: flex; flex-direction: column; gap: 12px; height: 100vh; overflow-y: auto; padding: 20px 12px 14px; border-right: 1px solid var(--border); background: var(--surface-muted); } -.brand { display: flex; align-items: center; gap: 11px; min-height: 44px; padding: 0 8px 18px; } +.app-shell > aside { position: sticky; top: 0; display: flex; flex-direction: column; gap: 8px; height: 100vh; overflow-y: auto; padding: 20px 12px 16px; border-right: 1px solid var(--border); background: var(--surface-muted); } +.brand { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 8px 16px; } .brand-mark { display: block; width: 36px; height: 36px; border-radius: 8px; object-fit: contain; } .brand strong, .brand small { display: block; } -.brand strong { color: var(--brand-label); } -.brand small { margin-top: 2px; color: var(--muted); } -.app-shell nav { display: grid; gap: 12px; } -.btn.nav-button { justify-content: flex-start; width: 100%; border-color: transparent; color: var(--muted); background: transparent; text-align: left; } -.btn.nav-button:hover { background: var(--surface-hover); } -.btn.nav-button[data-active="true"] { color: #ffffff; background: var(--chrome); } -.sidebar-footer { margin-top: auto; padding: 12px 9px 0; color: var(--muted); } +.brand strong { color: var(--brand-label); line-height: 1.2; } +.brand small { margin-top: 2px; color: var(--muted); line-height: 1.2; } +.app-shell nav { display: grid; gap: 4px; } +.btn.nav-button { justify-content: flex-start; width: 100%; border-color: transparent; padding: 12px 16px; color: var(--muted); background: transparent; text-align: left; } +.btn.nav-button:hover { color: var(--text); background: var(--surface-hover); } +.btn.nav-button[data-active="true"] { color: var(--text); background: var(--surface); box-shadow: inset 4px 0 0 var(--brand), 0 1px 2px rgba(28, 27, 31, 0.08); } +.btn.nav-button[data-active="true"] svg { color: var(--brand); } +.sidebar-footer { margin-top: auto; padding: 12px 16px 0; color: var(--muted); } main { min-width: 0; } -.view { width: min(100%, 1100px); margin: 0 auto; padding: 32px; } -.page-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 24px; } +.view { width: min(100%, 1100px); margin: 0 auto; padding: 32px; container-type: inline-size; } +.page-header, .section-header { display: flex; align-items: center; justify-content: space-between; gap: 16px 24px; } +.page-header { margin-bottom: 24px; } .page-header h1 { font-size: 30px; line-height: 1.2; font-weight: 700; } -.page-header p { margin-top: 5px; color: var(--muted); } -.page-header > svg { color: var(--muted); } +.page-header p, .section-header p { max-width: 64ch; margin-top: 6px; color: var(--muted); } +.section-header { flex-wrap: wrap; margin-bottom: 16px; } .loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 100vh; color: var(--muted); } .spin { animation: spin 1s linear infinite; } @@ -205,31 +210,37 @@ main { min-width: 0; } .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; place-items: center; width: 38px; height: 38px; border-radius: 50%; color: var(--muted); background: var(--surface-muted); } +.status-icon { 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-list { margin-top: 22px; border-top: 1px solid var(--border); } -.status-list .status-icon { width: 34px; height: 34px; } -.status-list p { margin-top: 3px; color: var(--muted); } -.task-list { border-top: 1px solid var(--border); } -.task-list article > svg { margin: 0 9px; color: var(--muted); } -.task-list p { margin-top: 4px; color: var(--muted); line-height: 1.4; } +/* 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 + actions drop under the text rather than squeezing it. */ +.status-list, .task-list, .device-list, .profile-list, .setup-statuses { border: 1px solid var(--border); border-radius: var(--radius-card); overflow: hidden; background: var(--surface); container-type: inline-size; } +.status-list + .status-list { margin-top: 16px; } +.connection-band + .status-list { margin-top: 24px; } +:is(.status-list, .task-list, .device-list, .setup-statuses) > article { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-auto-columns: auto; grid-auto-flow: column; align-items: center; gap: 12px 16px; min-height: 80px; border-bottom: 1px solid var(--border); padding: 16px 20px; } +:is(.status-list, .task-list, .device-list, .setup-statuses) > article:last-child { border-bottom: 0; } +:is(.status-list, .task-list, .device-list, .setup-statuses) > article > .btn { justify-self: end; } +:is(.status-list, .task-list, .device-list, .setup-statuses) p { margin-top: 2px; color: var(--muted); } .task-list .permission-recovery { margin-top: 8px; color: var(--text); } -.status-list article, .task-list article { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 16px; min-height: 76px; border-bottom: 1px solid var(--border); padding: 16px 4px; } -.status-list article > .btn, .task-list article > .btn { grid-column: 1 / -1; justify-self: start; } -.setup-launch { margin-bottom: 18px; } - -.empty-state { display: grid; justify-items: center; border: 1px dashed var(--border); padding: 52px 24px; color: var(--muted); text-align: center; } -.empty-state h2 { margin-top: 14px; color: var(--text); } -.empty-state p { margin-top: 6px; } -.device-list, .profile-list { border-top: 1px solid var(--border); } -.device-list article, .btn.profile-row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; min-height: 72px; border-bottom: 1px solid var(--border); padding: 16px 4px; } -.device-list p, .profile-list p { margin-top: 4px; color: var(--muted); } -.btn.profile-row { justify-content: stretch; width: 100%; grid-template-columns: auto 1fr auto auto; border-width: 0 0 1px; border-radius: 0; color: inherit; background: transparent; text-align: left; } +@container (max-width: 560px) { + :is(.status-list, .task-list, .device-list, .setup-statuses) > article { grid-auto-flow: row; padding: 16px; } + :is(.status-list, .task-list, .device-list, .setup-statuses) > article > .btn { grid-column: 2; justify-self: start; } +} +.setup-launch { margin-bottom: 24px; } + +.empty-state { display: grid; justify-items: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-card); padding: 48px 24px; color: var(--muted); text-align: center; } +.empty-state :is(h2, h3) { margin-top: 14px; color: var(--text); } +.empty-state p { max-width: 56ch; margin-top: 6px; } +.btn.profile-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; justify-content: stretch; gap: 16px; width: 100%; min-height: 80px; border-width: 0 0 1px; border-color: var(--border); border-radius: 0; padding: 16px 20px; color: inherit; background: transparent; font-weight: 400; text-align: left; } +.btn.profile-row:last-child { border-bottom: 0; } .btn.profile-row:hover { background: var(--surface-hover); } -.profile-row > span { color: var(--muted); } -.profile-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 6px; color: var(--muted); background: var(--surface-muted); } +.btn.profile-row:focus-visible { outline-offset: -3px; } +.profile-row p { margin-top: 2px; color: var(--muted); } +.profile-row > span, .profile-row > svg { color: var(--muted); } .capability-note { margin-top: 16px; color: var(--muted); } +.subview { display: grid; } /* --- Settings controls ------------------------------------------------------ */ .setting-group { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; border-top: 1px solid var(--border); padding: 24px 0; } @@ -436,8 +447,6 @@ main { min-width: 0; } .setup-progress span[data-active="true"] { background: var(--brand); } .setup-content { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 16px 4px; } .setup-content p { margin-top: 8px; color: var(--muted); } -.setup-statuses { border-top: 1px solid var(--border); } -.setup-statuses article { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; min-height: 92px; border-bottom: 1px solid var(--border); padding: 14px 4px; } .setup-choices { margin-top: 22px; } .setup-choices .btn[aria-pressed="true"] { border-color: var(--brand); color: var(--on-brand-container); background: var(--segment-selected); box-shadow: inset 0 0 0 1px var(--brand-border); } .setup-privacy { margin-top: 18px; color: var(--brand-label); } @@ -476,13 +485,20 @@ main { min-width: 0; } @keyframes capture-pulse { 50% { border-color: var(--brand); box-shadow: var(--shadow-dialog), 0 0 0 4px var(--brand-container); } } @media (prefers-reduced-motion: reduce) { .capture-dialog, .btn { animation: none; transition: none; } } +@media (forced-colors: active) { + .btn.nav-button[data-active="true"] { outline: 2px solid Highlight; outline-offset: -2px; } + /* Focus must still read as focus on the current page's own button. */ + .btn.nav-button[data-active="true"]:focus-visible { outline: 3px solid CanvasText; outline-offset: 3px; } +} + /* --- Responsive --------------------------------------------------------------- */ @media (max-width: 800px) { .app-shell { grid-template-columns: 1fr; } .app-shell > aside { position: static; height: auto; padding: 12px; } .app-shell > aside .brand, .app-shell .sidebar-footer { display: none; } .app-shell nav { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .btn.nav-button { flex-direction: column; justify-content: center; text-align: center; } + .btn.nav-button { flex-direction: column; justify-content: center; gap: 4px; padding: 8px; text-align: center; } + .btn.nav-button[data-active="true"] { box-shadow: inset 0 -4px 0 var(--brand), 0 1px 2px rgba(28, 27, 31, 0.08); } .view { padding: 16px; } .switch-row-summary { grid-template-columns: auto minmax(0, 1fr); } .switch-row-summary > .btn:first-of-type, .switch-row-summary > .btn:last-of-type { width: 100%; grid-row: auto; grid-column: auto; } @@ -500,8 +516,6 @@ main { min-width: 0; } .profile-dialog > footer > span { flex-basis: 100%; height: 0; } .update-banner { grid-template-columns: auto 1fr; } .update-banner .btn.text-button { grid-column: 2; justify-self: start; padding-left: 0; } - .setup-statuses article { grid-template-columns: auto 1fr; } - .setup-statuses article > .btn { grid-column: 1 / -1; } .mobile-download img { width: 150px; height: 150px; } } @media (max-width: 480px) {