forked from molecule-ai/molecule-core
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:
commit
f308765529
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
117
canvas/src/components/__tests__/AuthGate.test.tsx
Normal file
117
canvas/src/components/__tests__/AuthGate.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user