diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css index 7f93dc53b..2c44ab164 100644 --- a/canvas/src/app/globals.css +++ b/canvas/src/app/globals.css @@ -287,4 +287,17 @@ body { outline: 2px solid var(--accent, #3b5bdb); outline-offset: 2px; } + + /* Global focus-visible fallback for Tailwind buttons (WCAG 2.4.7). + Specific component rules (e.g. .settings-panel__close:focus-visible) + override this via higher specificity. */ + button:focus-visible { + outline: 2px solid var(--accent, #3b5bdb); + outline-offset: 2px; + } + + a:focus-visible { + outline: 2px solid var(--accent, #3b5bdb); + outline-offset: 2px; + } } diff --git a/canvas/src/components/mobile/MobileCanvas.tsx b/canvas/src/components/mobile/MobileCanvas.tsx index acdaa1689..53a462c4c 100644 --- a/canvas/src/components/mobile/MobileCanvas.tsx +++ b/canvas/src/components/mobile/MobileCanvas.tsx @@ -205,6 +205,7 @@ export function MobileCanvas({ type="button" onClick={resetView} aria-label="Reset zoom" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ position: "absolute", right: 14, @@ -272,6 +273,8 @@ export function MobileCanvas({ key={l.agent.id} type="button" onClick={() => onOpen(l.agent.id)} + aria-label={`Open ${l.agent.name}`} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ position: "absolute", left: `${l.x}%`, @@ -376,6 +379,7 @@ export function MobileCanvas({ type="button" onClick={onSpawn} aria-label="Spawn new agent" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" style={{ position: "absolute", right: 24, diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index 375bd37a8..e361619b4 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -339,6 +339,7 @@ export function MobileChat({ type="button" onClick={onBack} aria-label="Back" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ width: 36, height: 36, @@ -385,6 +386,7 @@ export function MobileChat({ - ); - })} - + {( + [ + { id: "my", label: "My Chat" }, + { id: "a2a", label: "Agent Comms" }, + ] as const + ).map((t) => { + const on = tab === t.id; + return ( + + ); + })} {/* Messages */} @@ -478,6 +507,7 @@ export function MobileChat({ onClick={() => { loadInitial(); }} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ padding: "6px 14px", borderRadius: 14, @@ -619,6 +649,7 @@ export function MobileChat({ type="button" onClick={() => removePendingFile(i)} aria-label={`Remove ${f.name}`} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ border: "none", background: "transparent", @@ -659,6 +690,7 @@ export function MobileChat({ onClick={() => fileInputRef.current?.click()} disabled={!reachable || sending || uploading} aria-label="Attach" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ width: 32, height: 32, @@ -703,7 +735,9 @@ export function MobileChat({ border: "none", outline: "none", background: "transparent", - fontSize: 14.5, + // 16px minimum prevents iOS from zooming the page when the + // textarea receives focus (iOS triggers zoom for font-size < 16). + fontSize: 16, lineHeight: 1.4, color: p.text, padding: "6px 0", @@ -719,6 +753,7 @@ export function MobileChat({ onClick={send} disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading} aria-label="Send" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ width: 36, height: 36, diff --git a/canvas/src/components/mobile/MobileComms.tsx b/canvas/src/components/mobile/MobileComms.tsx index ff3da4d47..1427eb749 100644 --- a/canvas/src/components/mobile/MobileComms.tsx +++ b/canvas/src/components/mobile/MobileComms.tsx @@ -205,7 +205,11 @@ export function MobileComms({ dark }: { dark: boolean }) {

-
+
{( [ { id: "all", label: "All", n: items.length }, @@ -216,8 +220,34 @@ export function MobileComms({ dark }: { dark: boolean }) { return ( diff --git a/canvas/src/components/mobile/MobileDetail.tsx b/canvas/src/components/mobile/MobileDetail.tsx index 96d1bd621..0e18e7063 100644 --- a/canvas/src/components/mobile/MobileDetail.tsx +++ b/canvas/src/components/mobile/MobileDetail.tsx @@ -83,11 +83,12 @@ export function MobileDetail({ type="button" onClick={onBack} aria-label="Back" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={iconButtonStyle(p, dark)} > {Icons.back({ size: 18 })} -
@@ -168,6 +169,8 @@ export function MobileDetail({ {/* Tabs */}
setTab(t.id)} + onKeyDown={(e) => { + const idx = TABS.findIndex((x) => x.id === t.id); + let nextIdx: number | null = null; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + nextIdx = (idx + 1) % TABS.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + nextIdx = (idx - 1 + TABS.length) % TABS.length; + } else if (e.key === "Home") { + nextIdx = 0; + } else if (e.key === "End") { + nextIdx = TABS.length - 1; + } + if (nextIdx !== null) { + e.preventDefault(); + setTab(TABS[nextIdx]!.id); + setTimeout(() => { + const btns = document.querySelectorAll('[role="tab"]'); + (btns[nextIdx!] as HTMLButtonElement | null)?.focus(); + }, 0); + } + }} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ padding: "8px 14px", borderRadius: 999, @@ -215,6 +243,7 @@ export function MobileDetail({ type="button" onClick={onChat} data-testid="mobile-chat-cta" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" style={{ width: "100%", height: 52, diff --git a/canvas/src/components/mobile/MobileHome.tsx b/canvas/src/components/mobile/MobileHome.tsx index 271fa511f..2569ada91 100644 --- a/canvas/src/components/mobile/MobileHome.tsx +++ b/canvas/src/components/mobile/MobileHome.tsx @@ -183,6 +183,7 @@ export function MobileHome({ type="button" onClick={onSpawn} aria-label="Spawn new agent" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" style={{ position: "absolute", right: 24, diff --git a/canvas/src/components/mobile/MobileMe.tsx b/canvas/src/components/mobile/MobileMe.tsx index c1735083d..ca859e9be 100644 --- a/canvas/src/components/mobile/MobileMe.tsx +++ b/canvas/src/components/mobile/MobileMe.tsx @@ -83,6 +83,7 @@ export function MobileMe({ type="button" onClick={() => setAccent(c)} aria-label={`Set accent ${c}`} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ width: 36, height: 36, @@ -173,6 +174,7 @@ function SegmentedRow({ key={o.id} type="button" onClick={() => onChange(o.id)} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ flex: 1, padding: "10px 8px", diff --git a/canvas/src/components/mobile/MobileSpawn.tsx b/canvas/src/components/mobile/MobileSpawn.tsx index 7ee62e89d..fd66c783f 100644 --- a/canvas/src/components/mobile/MobileSpawn.tsx +++ b/canvas/src/components/mobile/MobileSpawn.tsx @@ -148,6 +148,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v type="button" onClick={onClose} aria-label="Close" + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ width: 32, height: 32, @@ -214,6 +215,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v setTplId(t.id); setTier(tCode); }} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ background: on ? dark @@ -286,7 +288,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v justifyContent: "center", }} > - {Icons.check({ size: 10, sw: 2.5 })} + )} @@ -330,6 +332,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v key={t} type="button" onClick={() => setTier(t)} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ flex: 1, padding: "10px 8px", @@ -377,6 +380,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v type="button" onClick={handleSpawn} disabled={busy || !tplId || templates.length === 0} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" style={{ width: "100%", height: 52, diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx index 592604a52..4d06ca210 100644 --- a/canvas/src/components/mobile/components.tsx +++ b/canvas/src/components/mobile/components.tsx @@ -291,6 +291,7 @@ export function AgentCard({ data-testid="workspace-card" aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`} onClick={onClick} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ display: "block", width: "100%", @@ -444,6 +445,7 @@ export function FilterChips({ type="button" aria-checked={on} onClick={() => onChange(o.id)} + className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={{ display: "inline-flex", alignItems: "center", diff --git a/canvas/src/styles/settings-panel.css b/canvas/src/styles/settings-panel.css index 5d4be4514..cd7a2b6a0 100644 --- a/canvas/src/styles/settings-panel.css +++ b/canvas/src/styles/settings-panel.css @@ -411,6 +411,11 @@ color: #f4f4f5; } +.secrets-tab__add-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + /* ── Shared UI ─────────────────────────────────────── */ .key-value-field { @@ -585,6 +590,11 @@ background: #1e40af; } +.secrets-tab__refresh-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .secrets-tab__no-results { text-align: center; padding: 24px; @@ -601,6 +611,11 @@ font-size: 14px; } +.secrets-tab__clear-search:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + /* ── Delete confirmation dialog ────────────────────── */ .delete-dialog__overlay { @@ -661,6 +676,16 @@ .delete-dialog__confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.delete-dialog__confirm-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + +.delete-dialog__cancel-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + /* ── Unsaved changes guard ─────────────────────────── */ .guard-dialog__overlay {