Merge pull request #447 from Molecule-AI/fix/canvas-dark-theme-a11y-sweep

fix(canvas): UIUX Cycle 15 dark-theme & a11y sweep (C1–C5, A1–A4, F1, M1)
This commit is contained in:
Hongming Wang 2026-04-16 05:21:10 -07:00 committed by GitHub
commit f308765529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 161 additions and 28 deletions

View File

@ -99,10 +99,10 @@ export function CommunicationOverlay() {
return (
<button
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
title="Show communications"
>
{comms.length > 0 ? `${comms.length} comms` : "Communications"}
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
);
}
@ -111,13 +111,14 @@ export function CommunicationOverlay() {
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-zinc-900/95 border border-zinc-700/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/60">
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
Communications ({comms.length})
<span aria-hidden="true"> </span>Communications ({comms.length})
</div>
<button
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-zinc-500 hover:text-zinc-300 text-xs"
>
<span aria-hidden="true"></span>
</button>
</div>
@ -141,15 +142,15 @@ export function CommunicationOverlay() {
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className={typeColor}>{typeIcon}</span>
<span className={typeColor} aria-hidden="true">{typeIcon}</span>
<span className="text-zinc-300 font-medium truncate">
{c.sourceName}
</span>
<span className="text-zinc-400"></span>
<span className="text-zinc-400" aria-hidden="true"></span>
<span className="text-zinc-300 truncate">{c.targetName}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className={statusColor}>{statusIcon}</span>
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
<span className="text-zinc-400">{age}</span>
</div>
</div>

View File

@ -138,7 +138,8 @@ export function OnboardingWizard() {
</span>
<button
onClick={dismiss}
className="text-[10px] text-zinc-600 hover:text-zinc-400 transition-colors"
aria-label="Skip onboarding guide"
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
>
Skip guide
</button>

View File

@ -112,7 +112,7 @@ export function SearchDialog() {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search workspaces..."
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus:outline-none"
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus:outline-none rounded"
/>
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
</div>

View File

@ -156,11 +156,14 @@ export function SidePanel() {
</div>
</div>
{/* Tabs */}
{/* Tabs — relative wrapper lets the fade gradient position against the scroll container */}
<div className="relative border-b border-zinc-800/40">
{/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */}
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-zinc-950 to-transparent z-10" aria-hidden="true" />
<div
role="tablist"
aria-label="Workspace panel tabs"
className="flex border-b border-zinc-800/40 overflow-x-auto bg-zinc-900/20 px-1"
className="flex overflow-x-auto bg-zinc-900/20 px-1"
onKeyDown={(e) => {
const idx = TABS.findIndex((t) => t.id === panelTab);
let next: number | null = null;
@ -194,6 +197,7 @@ export function SidePanel() {
</button>
))}
</div>
</div>
{/* Needs Restart Banner */}
{node.data.needsRestart && !node.data.currentTask && selectedNodeId && (

View File

@ -0,0 +1,117 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, cleanup, act } from "@testing-library/react";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Mocks (defined before dynamic import of component) ───────────────────────
let mockFetchSession: ReturnType<typeof vi.fn>;
let mockRedirectToLogin: ReturnType<typeof vi.fn>;
let mockGetTenantSlug: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetchSession = vi.fn();
mockRedirectToLogin = vi.fn();
mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through)
});
vi.mock("@/lib/auth", () => ({
fetchSession: (...args: unknown[]) => mockFetchSession(...args),
redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args),
}));
vi.mock("@/lib/tenant", () => ({
getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args),
}));
// Import after mocks are set up
import { AuthGate } from "../AuthGate";
// ─────────────────────────────────────────────────────────────────────────────
describe("AuthGate — loading state", () => {
it("renders a blank overlay while session fetch is in-flight (prevents flash of unauth'd content)", () => {
// getTenantSlug returns a slug so the session fetch is triggered
mockGetTenantSlug.mockReturnValue("acme");
// fetchSession never resolves — keeps the component in loading state
mockFetchSession.mockReturnValue(new Promise(() => {}));
const { container } = render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
const overlay = container.querySelector(".bg-zinc-950.fixed.inset-0");
expect(overlay).not.toBeNull();
expect(overlay?.getAttribute("aria-hidden")).toBe("true");
});
it("does not render children while in loading state", () => {
mockGetTenantSlug.mockReturnValue("acme");
mockFetchSession.mockReturnValue(new Promise(() => {}));
const { queryByTestId } = render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
expect(queryByTestId("child")).toBeNull();
});
});
describe("AuthGate — non-SaaS / pass-through mode", () => {
it("renders children immediately when there is no tenant slug", async () => {
mockGetTenantSlug.mockReturnValue(null);
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
});
expect(result!.getByTestId("child")).toBeTruthy();
});
});
describe("AuthGate — authenticated state", () => {
it("renders children after a successful session fetch", async () => {
mockGetTenantSlug.mockReturnValue("acme");
mockFetchSession.mockResolvedValue({ userId: "u1", email: "a@b.com" });
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
});
expect(result!.getByTestId("child")).toBeTruthy();
});
});
describe("AuthGate — anonymous / redirect state", () => {
it("calls redirectToLogin when session fetch returns null", async () => {
mockGetTenantSlug.mockReturnValue("acme");
mockFetchSession.mockResolvedValue(null);
await act(async () => {
render(
<AuthGate>
<div data-testid="child">Protected content</div>
</AuthGate>
);
});
expect(mockRedirectToLogin).toHaveBeenCalledWith("sign-in");
});
});

View File

@ -98,11 +98,22 @@ export function ChatTab({ workspaceId, data }: Props) {
return (
<div className="flex flex-col h-full">
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
<div role="tablist" className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
<div
role="tablist"
className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0"
onKeyDown={(e) => {
const tabs: ChatSubTab[] = ["my-chat", "agent-comms"];
const idx = tabs.indexOf(subTab);
if (e.key === "ArrowRight") { e.preventDefault(); setSubTab(tabs[(idx + 1) % tabs.length]); }
else if (e.key === "ArrowLeft") { e.preventDefault(); setSubTab(tabs[(idx - 1 + tabs.length) % tabs.length]); }
}}
>
<button
id="chat-tab-my-chat"
role="tab"
aria-selected={subTab === "my-chat"}
aria-controls="chat-panel-my-chat"
tabIndex={subTab === "my-chat" ? 0 : -1}
onClick={() => setSubTab("my-chat")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
subTab === "my-chat"
@ -113,9 +124,11 @@ export function ChatTab({ workspaceId, data }: Props) {
My Chat
</button>
<button
id="chat-tab-agent-comms"
role="tab"
aria-selected={subTab === "agent-comms"}
aria-controls="chat-panel-agent-comms"
tabIndex={subTab === "agent-comms" ? 0 : -1}
onClick={() => setSubTab("agent-comms")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
subTab === "agent-comms"
@ -126,17 +139,14 @@ export function ChatTab({ workspaceId, data }: Props) {
Agent Comms
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{subTab === "my-chat" ? (
<div id="chat-panel-my-chat" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
<MyChatPanel workspaceId={workspaceId} data={data} />
</div>
) : (
<div id="chat-panel-agent-comms" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
<AgentCommsPanel workspaceId={workspaceId} />
</div>
)}
{/* Content both panels are always in the DOM so aria-controls targets exist.
The inactive panel is hidden via the HTML `hidden` attribute (removed from
display and accessibility tree, but present in the DOM for WCAG 4.1.2). */}
<div id="chat-panel-my-chat" role="tabpanel" aria-labelledby="chat-tab-my-chat" hidden={subTab !== "my-chat"} className="flex-1 overflow-hidden flex flex-col">
<MyChatPanel workspaceId={workspaceId} data={data} />
</div>
<div id="chat-panel-agent-comms" role="tabpanel" aria-labelledby="chat-tab-agent-comms" hidden={subTab !== "agent-comms"} className="flex-1 overflow-hidden flex flex-col">
<AgentCommsPanel workspaceId={workspaceId} />
</div>
</div>
);
@ -387,7 +397,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div className="mt-1.5 text-[9px] text-zinc-500 space-y-0.5">
<div className="text-zinc-400">Processing with {runtimeDisplayName(data.runtime)}...</div>
{activityLog.map((line, i) => (
<div key={i} className="pl-2 border-l border-zinc-700"> {line}</div>
<div key={line + i} className="pl-2 border-l border-zinc-700"> {line}</div>
))}
</div>
)}

View File

@ -323,8 +323,8 @@
border-radius: 6px;
font-size: 14px;
font-family: inherit;
background: #18181b;
color: #d4d4d8;
background: rgb(39 39 42);
color: rgb(212 212 216);
}
.add-key-form__select:focus,
@ -398,8 +398,8 @@
border-radius: 6px;
font-size: 14px;
font-family: monospace;
background: #18181b;
color: #d4d4d8;
background: rgb(39 39 42);
color: rgb(212 212 216);
}
.key-value-field input:focus {