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: "all", label: "All", n: items.length },
@@ -216,8 +220,34 @@ export function MobileComms({ dark }: { dark: boolean }) {
return (
setFilter(o.id)}
+ onKeyDown={(e) => {
+ const filters = ["all", "errors"] as const;
+ const idx = filters.indexOf(o.id as "all" | "errors");
+ let next: number | null = null;
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
+ next = (idx + 1) % filters.length;
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
+ next = (idx - 1 + filters.length) % filters.length;
+ } else if (e.key === "Home") {
+ next = 0;
+ } else if (e.key === "End") {
+ next = filters.length - 1;
+ }
+ if (next !== null) {
+ e.preventDefault();
+ setFilter(filters[next]!);
+ setTimeout(() => {
+ const btns = document.querySelectorAll('[role="radio"]');
+ (btns[next!] as HTMLButtonElement | null)?.focus();
+ }, 0);
+ }
+ }}
+ className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
display: "inline-flex",
alignItems: "center",
@@ -233,13 +263,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
}}
>
{o.label}
-
+
{o.n}
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 })}
-
+
{Icons.more({ 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 })}
+ {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 {