↗↙ Communications ({comms.length})
diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx
index 61a834c06..ccc874dde 100644
--- a/canvas/src/components/ConversationTraceModal.tsx
+++ b/canvas/src/components/ConversationTraceModal.tsx
@@ -125,7 +125,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
- ✕
+ ✕
diff --git a/canvas/src/components/OrgImportPreflightModal.tsx b/canvas/src/components/OrgImportPreflightModal.tsx
index 6bc4ea480..4b5f8b830 100644
--- a/canvas/src/components/OrgImportPreflightModal.tsx
+++ b/canvas/src/components/OrgImportPreflightModal.tsx
@@ -406,7 +406,7 @@ function StrictEnvRow({
{envKey}
{configured ? (
-
✓ set
+
✓ set
) : (
<>
{isConfigured ? (
-
✓ set
+
✓ set
) : (
<>
>)
}}
className="flex items-center gap-1.5 mt-1 w-full bg-accent/10 px-2 py-1 rounded-md border border-accent/40 hover:bg-accent/20 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
>
-
↻
+
↻
Restart to apply changes
)}
diff --git a/canvas/src/components/__tests__/BroadcastBanner.test.tsx b/canvas/src/components/__tests__/BroadcastBanner.test.tsx
new file mode 100644
index 000000000..d9199148a
--- /dev/null
+++ b/canvas/src/components/__tests__/BroadcastBanner.test.tsx
@@ -0,0 +1,274 @@
+// @vitest-environment jsdom
+/**
+ * WCAG 2.1 AA accessibility + functional tests for BroadcastBanner.
+ *
+ * Pattern matches ActivityTab.test.tsx — uses the real subscribeSocketEvents
+ * bus (no module mock) so the component's useEffect registers its listener
+ * normally. Tests call emitSocketEvent to fire fake events into the bus,
+ * which delivers to all registered listeners including the component's.
+ *
+ * _resetSocketEventListenersForTests() clears the listeners Set between tests
+ * so each case starts clean.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
+
+import {
+ emitSocketEvent,
+ _resetSocketEventListenersForTests,
+} from "@/store/socket-events";
+import type { WSMessage } from "@/store/socket";
+import { BroadcastBanner } from "../BroadcastBanner";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+const broadcastMsg = (
+ sender = "Test Agent",
+ senderId = "ws-agent-1",
+ message = "All agents: please check your memory for stale data.",
+): WSMessage => ({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-recipient-1",
+ timestamp: new Date().toISOString(),
+ payload: {
+ message,
+ sender_id: senderId,
+ sender,
+ } as unknown as Record
,
+});
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("BroadcastBanner — empty state", () => {
+ beforeEach(() => {
+ _resetSocketEventListenersForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ _resetSocketEventListenersForTests();
+ });
+
+ it("renders nothing when no BROADCAST_MESSAGE events have been received", () => {
+ render();
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+});
+
+describe("BroadcastBanner — renders banner on BROADCAST_MESSAGE", () => {
+ beforeEach(() => {
+ _resetSocketEventListenersForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ _resetSocketEventListenersForTests();
+ });
+
+ it("shows a status banner when a BROADCAST_MESSAGE is received", async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole("status")).toBeTruthy();
+ });
+ });
+
+ it("displays the sender name", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg("PM Agent"));
+ });
+ await waitFor(() => {
+ expect(screen.getByText("PM Agent")).toBeTruthy();
+ });
+ });
+
+ it("displays the broadcast message", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Sprint review in 30 minutes."));
+ });
+ await waitFor(() => {
+ expect(screen.getByText("Sprint review in 30 minutes.")).toBeTruthy();
+ });
+ });
+});
+
+describe("BroadcastBanner — WCAG 1.1.1 Non-text Content", () => {
+ beforeEach(() => {
+ _resetSocketEventListenersForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ _resetSocketEventListenersForTests();
+ });
+
+ it("broadcast emoji is aria-hidden=true", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+ await waitFor(() => {
+ expect(screen.getByText("📣")).toBeTruthy();
+ });
+ expect(screen.getByText("📣").getAttribute("aria-hidden")).toBe("true");
+ });
+});
+
+describe("BroadcastBanner — WCAG 4.1.2 Name, Role, Value", () => {
+ beforeEach(() => {
+ _resetSocketEventListenersForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ _resetSocketEventListenersForTests();
+ });
+
+ it("container has role=status", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("status")).toBeTruthy();
+ });
+ });
+
+ it("container has aria-live=polite", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("status").getAttribute("aria-live")).toBe("polite");
+ });
+ });
+
+ it("dismiss button has aria-label describing the broadcast", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Sprint review in 30 minutes."));
+ });
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: /dismiss broadcast from pm agent/i }),
+ ).toBeTruthy();
+ });
+ const btn = screen.getByRole("button", { name: /dismiss broadcast from pm agent/i });
+ expect(btn.getAttribute("aria-label")).toContain("Sprint review in 30 minutes.");
+ });
+
+ it("dismiss button has focus-visible ring class", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /dismiss broadcast/i })).toBeTruthy();
+ });
+ const btn = screen.getByRole("button", { name: /dismiss broadcast/i });
+ // Component uses focus-visible:ring-2 for keyboard focus indication (WCAG 2.4.7).
+ expect(btn.classList.contains("focus-visible:ring-2")).toBe(true);
+ });
+});
+
+describe("BroadcastBanner — auto-dismiss", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ _resetSocketEventListenersForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ _resetSocketEventListenersForTests();
+ vi.useRealTimers();
+ });
+
+ it("banner auto-dismisses after 10 seconds", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("status")).toBeTruthy();
+ });
+
+ // Advance 10 seconds — the setTimeout fires.
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+ });
+
+ it("banner disappears immediately on dismiss button click", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg());
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("status")).toBeTruthy();
+ });
+
+ const dismissBtn = screen.getByRole("button", { name: /dismiss broadcast/i });
+ fireEvent.click(dismissBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+ });
+});
+
+describe("BroadcastBanner — deduplication", () => {
+ beforeEach(() => {
+ _resetSocketEventListenersForTests();
+ });
+
+ afterEach(() => {
+ cleanup();
+ _resetSocketEventListenersForTests();
+ });
+
+ it("shows one banner when the same sender sends multiple messages rapidly", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "First message."));
+ emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Second message."));
+ });
+
+ await waitFor(() => {
+ // Only one banner per sender — the second replaces the first.
+ expect(screen.getAllByRole("status")).toHaveLength(1);
+ expect(screen.getByText("Second message.")).toBeTruthy();
+ });
+ });
+
+ it("shows separate banners for different senders", async () => {
+ render();
+ act(() => {
+ emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "PM message."));
+ emitSocketEvent(broadcastMsg("Research Lead", "ws-rl", "Research message."));
+ });
+
+ await waitFor(() => {
+ // The outer container has role="status" (1); each child banner does not.
+ // Verify both senders appear as text instead.
+ expect(screen.getByText("PM Agent")).toBeTruthy();
+ expect(screen.getByText("Research Lead")).toBeTruthy();
+ expect(screen.getByText("PM message.")).toBeTruthy();
+ expect(screen.getByText("Research message.")).toBeTruthy();
+ });
+ });
+});
diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx
index 375bd37a8..ee6844680 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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
width: 36,
height: 36,
@@ -385,6 +386,7 @@ export function MobileChat({
-
@@ -183,6 +184,7 @@ export function MobileDetail({
key={t.id}
type="button"
onClick={() => setTab(t.id)}
+ className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
padding: "8px 14px",
borderRadius: 999,
@@ -215,6 +217,7 @@ export function MobileDetail({
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
+ className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
width: "100%",
height: 52,
diff --git a/canvas/src/components/mobile/MobileHome.tsx b/canvas/src/components/mobile/MobileHome.tsx
index 271fa511f..b4b2961d7 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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
position: "absolute",
right: 24,
diff --git a/canvas/src/components/mobile/MobileMe.tsx b/canvas/src/components/mobile/MobileMe.tsx
index c1735083d..0ed71cd61 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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
width: 36,
height: 36,
@@ -173,6 +174,7 @@ function SegmentedRow({
key={o.id}
type="button"
onClick={() => onChange(o.id)}
+ className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
flex: 1,
padding: "10px 8px",
diff --git a/canvas/src/components/mobile/MobileSpawn.tsx b/canvas/src/components/mobile/MobileSpawn.tsx
index 7ee62e89d..5ff533a3a 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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
background: on
? dark
@@ -330,6 +332,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
key={t}
type="button"
onClick={() => setTier(t)}
+ className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
width: "100%",
height: 52,
diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx
index 592604a52..a36c5680c 100644
--- a/canvas/src/components/mobile/components.tsx
+++ b/canvas/src/components/mobile/components.tsx
@@ -133,6 +133,7 @@ export function TabBar({
aria-label={t.label}
onClick={() => onChange(t.id)}
onKeyDown={(e) => handleKeyDown(e, idx)}
+ className="mobile-tab-btn"
style={{
background: "none",
border: "none",
@@ -291,6 +292,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:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
display: "block",
width: "100%",
@@ -444,6 +446,7 @@ export function FilterChips({
type="button"
aria-checked={on}
onClick={() => onChange(o.id)}
+ className="focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:outline-none"
style={{
display: "inline-flex",
alignItems: "center",
diff --git a/canvas/src/components/tabs/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx
index 092a58bc9..37446d725 100644
--- a/canvas/src/components/tabs/ActivityTab.tsx
+++ b/canvas/src/components/tabs/ActivityTab.tsx
@@ -139,20 +139,20 @@ export function ActivityTab({ workspaceId }: Props) {
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
- className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
+ className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-1 ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
}`}
>
-
{f.icon} {f.label}
+
{f.icon} {f.label}
))}
setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
- className={`text-[11px] px-1.5 py-0.5 rounded ${
+ className={`text-[11px] px-1.5 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-1 ${
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid"
}`}
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
@@ -161,7 +161,7 @@ export function ActivityTab({ workspaceId }: Props) {
setTraceOpen(true)}
- className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
+ className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1"
title="View full conversation trace across all workspaces"
>
Full Trace
@@ -260,7 +260,7 @@ function ActivityRow({
)}
-
+
{statusStyle.icon}
@@ -274,7 +274,7 @@ function ActivityRow({
{formatTime(entry.created_at)}
-
+
{expanded ? "▼" : "▶"}
diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx
index 1abc1f288..d7760584b 100644
--- a/canvas/src/components/tabs/ChannelsTab.tsx
+++ b/canvas/src/components/tabs/ChannelsTab.tsx
@@ -242,7 +242,9 @@ export function ChannelsTab({ workspaceId }: Props) {
if (loading) {
return (
-
Loading channels...
+
+ Loading channels...
+
);
}
@@ -332,7 +334,7 @@ export function ChannelsTab({ workspaceId }: Props) {
))}
setShowManualInput(!showManualInput)}
- className="text-[10px] text-accent hover:underline"
+ className="text-[10px] text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{showManualInput ? "hide manual input" : "edit manually"}
@@ -410,13 +412,13 @@ export function ChannelsTab({ workspaceId }: Props) {
handleTest(ch)}
disabled={testing === ch.id}
- className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50"
+ className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{testing === ch.id ? "Sent!" : "Test"}
handleToggle(ch)}
- className={`text-[10px] px-2 py-0.5 rounded transition ${
+ className={`text-[10px] px-2 py-0.5 rounded transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
ch.enabled
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid"
@@ -426,7 +428,7 @@ export function ChannelsTab({ workspaceId }: Props) {
setPendingDelete(ch)}
- className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition"
+ className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Remove
diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx
index d6a9b85ca..24e02775a 100644
--- a/canvas/src/components/tabs/ChatTab.tsx
+++ b/canvas/src/components/tabs/ChatTab.tsx
@@ -383,7 +383,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
// ignore — user will see no change and can retry
}
}}
- className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
+ className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
>
Enable
@@ -404,7 +404,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
Retry
@@ -582,7 +582,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
Processing with {runtimeDisplayName(data.runtime)}...
{activityLog.map((line, i) => (
-
◇ {line}
+
◇ {line}
))}
)}
@@ -600,7 +600,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{!isOnline && (
setConfirmRestart(true)}
- className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
+ className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Restart
@@ -636,7 +636,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
disabled={!agentReachable || sending || uploading}
aria-label="Attach file"
title="Attach file"
- className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40"
+ className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>