Compare commits

...

8 Commits

Author SHA1 Message Date
core-uiux 80f949a528 fix(canvas): add missing focus-visible to settings buttons (WCAG 2.4.7)
sop-tier-check / tier-check (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 4s
E2E Chat / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 5m41s
CI / Canvas (Next.js) (pull_request) Successful in 8m12s
CI / Python Lint & Test (pull_request) Successful in 6m41s
CI / all-required (pull_request) Successful in 5m2s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m2s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 2s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 3s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-sm
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 25s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 34s
Harness Replays / Harness Replays (pull_request) Successful in 29s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 30s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m48s
E2E Chat / E2E Chat (pull_request) Failing after 11m54s
- settings-panel.css: add focus-visible to
  secrets-tab__clear-search, delete-dialog__confirm-btn,
  delete-dialog__cancel-btn, secrets-tab__add-btn,
  secrets-tab__refresh-btn
- globals.css: add global button:focus-visible and a:focus-visible
  fallback so Tailwind buttons in TokensTab / OrgTokensTab are
  keyboard-navigable

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux 977a3a7c9c fix(canvas/mobile): add ARIA radio role + keyboard nav to MobileComms filter buttons
Add role="radiogroup", role="radio", aria-checked, tabIndex roving,
and arrow/Home/End keyboard navigation to the All/Errors filter buttons
in MobileComms.tsx. Screen readers now announce these as a radio group
rather than unlabeled toggle buttons. The count span is aria-hidden since
it repeats the label context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux a338fa3807 fix(canvas-mobile): set chat textarea font-size to 16px to prevent iOS zoom
iOS Safari zooms in when a text input receives focus if its font-size is
below 16px. Change the MobileChat composer textarea from 14.5px to 16px
to prevent this unwanted page zoom on iOS devices.

Fixes #1434.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux 8772fa5433 fix(canvas/mobile): add aria-hidden to decorative check icon in MobileSpawn
The check-mark span inside the selected-tier button is purely decorative
visual feedback — screen readers already get the tier name from the button
text. Mark it aria-hidden to avoid redundant announcements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux 03540edaa4 fix(canvas/mobile): add ARIA tab roles + keyboard nav to MobileDetail tabs
Add role="tablist", role="tab", aria-selected, tabIndex roving, and
arrow/Home/End keyboard navigation to the Overview/Activity/Config/Memory
tab buttons in MobileDetail.tsx. Matches the pattern already applied to
the bottom TabBar and the MobileChat sub-tabs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux a9c18e066d fix(canvas/mobile): add ARIA tab pattern + keyboard nav to MobileChat sub-tabs
Add role="tab", aria-selected, tabIndex roving, and arrow/Home/End
keyboard navigation to the "My Chat / Agent Comms" tab buttons in
MobileChat.tsx — matching the WCAG 2.1.1 pattern already used in the
bottom TabBar. Without ARIA roles, screen readers treat these as plain
buttons with no tab-group semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux 3dbaf4bcc8 fix(canvas/mobile): WCAG 2.4.7 focus-visible + aria-label on MobileCanvas buttons
Adds focus-visible ring + aria-label to the three inline-styled buttons in
MobileCanvas.tsx:

- Reset zoom: focus-visible ring
- Agent node: aria-label="Open {name}" + focus-visible ring (was missing both)
- Spawn FAB: focus-visible ring

Also adds MobileCanvas to the PR scope; refs #1395.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
core-uiux b935e48449 fix(canvas/mobile): WCAG 2.4.7 focus-visible rings on all mobile canvas buttons
Adds keyboard focus indicators (focus-visible:ring-2 focus-visible:ring-accent
focus-visible:ring-offset) to every keyboard-navigable button across the mobile
canvas layer. Buttons using inline style props now carry a Tailwind className
pair that shows the ring on keyboard focus without affecting mouse/touch users.

Components fixed:
- MobileChat: Back, More, tab-switch, Retry, Remove file, Attach, Send
- MobileHome: Spawn FAB
- MobileSpawn: Close, template select, tier select, Spawn agent
- MobileMe: Accent swatches, Theme/Density segmented controls
- MobileDetail: Back, More, tab-switch, Open chat CTA
- MobileComms: Filter chips (All, Errors)
- components.tsx: AgentCard, FilterChips

Refs #1384

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:08:42 +00:00
10 changed files with 179 additions and 40 deletions
+13
View File
@@ -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;
}
}
@@ -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,
+65 -30
View File
@@ -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({
<button
type="button"
aria-label="More"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
style={{
width: 36,
height: 36,
@@ -402,35 +404,62 @@ export function MobileChat({
</button>
</div>
{/* Sub-tabs */}
<div style={{ display: "flex", gap: 18, marginTop: 12, paddingLeft: 4 }}>
{(
[
{ id: "my", label: "My Chat" },
{ id: "a2a", label: "Agent Comms" },
] as const
).map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
padding: "4px 0 8px",
border: "none",
background: "transparent",
fontSize: 13.5,
cursor: "pointer",
color: on ? p.text : p.text3,
fontWeight: on ? 600 : 500,
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
}}
>
{t.label}
</button>
);
})}
</div>
{(
[
{ id: "my", label: "My Chat" },
{ id: "a2a", label: "Agent Comms" },
] as const
).map((t) => {
const on = tab === t.id;
return (
<button
key={t.id}
role="tab"
type="button"
tabIndex={on ? 0 : -1}
aria-selected={on}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
const tabs = ["my", "a2a"] as const;
const idx = tabs.indexOf(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]!);
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: "4px 0 8px",
marginTop: 12,
marginLeft: 4,
marginRight: 14,
border: "none",
background: "transparent",
fontSize: 13.5,
cursor: "pointer",
color: on ? p.text : p.text3,
fontWeight: on ? 600 : 500,
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
}}
>
{t.label}
</button>
);
})}
</div>
{/* 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,
+32 -8
View File
@@ -205,7 +205,11 @@ export function MobileComms({ dark }: { dark: boolean }) {
</p>
</div>
<div style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}>
<div
role="radiogroup"
aria-label="Filter communications"
style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}
>
{(
[
{ id: "all", label: "All", n: items.length },
@@ -216,8 +220,34 @@ export function MobileComms({ dark }: { dark: boolean }) {
return (
<button
key={o.id}
role="radio"
type="button"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => 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}
<span
style={{
fontSize: 10.5,
opacity: 0.7,
fontFamily: MOBILE_FONT_MONO,
}}
>
<span aria-hidden="true" style={{ fontSize: 10.5, opacity: 0.7, fontFamily: MOBILE_FONT_MONO }}>
{o.n}
</span>
</button>
+30 -1
View File
@@ -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 })}
</button>
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
<button type="button" aria-label="More" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={iconButtonStyle(p, dark)}>
{Icons.more({ size: 18 })}
</button>
</div>
@@ -168,6 +169,8 @@ export function MobileDetail({
{/* Tabs */}
<div
role="tablist"
aria-label="Agent detail sections"
style={{
display: "flex",
gap: 4,
@@ -181,8 +184,33 @@ export function MobileDetail({
return (
<button
key={t.id}
role="tab"
type="button"
tabIndex={on ? 0 : -1}
aria-selected={on}
onClick={() => 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,
@@ -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,
@@ -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",
+5 -1
View File
@@ -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 })}
<span aria-hidden="true">{Icons.check({ size: 10, sw: 2.5 })}</span>
</span>
)}
</button>
@@ -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,
@@ -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",
+25
View File
@@ -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 {