Compare commits

...

6 Commits

Author SHA1 Message Date
core-uiux aeed658946 fix(canvas-mobile): set chat textarea font-size to 16px to prevent iOS zoom
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 7m14s
CI / Python Lint & Test (pull_request) Successful in 7m15s
CI / Canvas (Next.js) (pull_request) Successful in 9m58s
CI / all-required (pull_request) Successful in 6m38s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Failing after 5m12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m4s
audit-force-merge / audit (pull_request) Waiting to run
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 20:22:51 +00:00
core-uiux e656221c0e 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 20:22:51 +00:00
core-uiux c8e10b88f9 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 20:22:51 +00:00
core-uiux b6adc26086 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 20:22:51 +00:00
core-uiux 40c8883736 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 20:22:51 +00:00
core-uiux e8cefcaa8a 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 20:22:51 +00:00
8 changed files with 110 additions and 32 deletions
@@ -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,
@@ -218,6 +218,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
key={o.id}
type="button"
onClick={() => setFilter(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",
+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",