fix: comprehensive a11y and contrast QA pass across workbench

Automated QA loop (6 parallel browser agents, 2 rounds) found and fixed
15 accessibility, contrast, and responsive issues across all 8 pages:

- WCAG contrast: light-mode warning (#854d0e), error (#b91c1c), toggle
  off-state (surface-400), connection badge (fg-muted)
- ARIA: mode selector group+pressed, tab pattern ids+labelledby, nav
  and aside labels, dialog focus-return, alert roles on banners
- Responsive: library header flex-wrap, search/button aria-labels
- Focus: NavLink visible ring, dialog close button ring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-11 23:26:28 -05:00
parent 77a5fa5044
commit d097b790ff
24 changed files with 1360 additions and 17 deletions

View file

@ -0,0 +1,10 @@
---
active: true
iteration: 1
session_id:
max_iterations: 10
completion_promise: "ALL_CLEAR"
started_at: "2026-04-10T22:12:33Z"
---
Run a full QA pass on the TrustGraph Workbench at localhost:5173. Launch 6 parallel QA agents using the Agent tool with mcp__claude-in-chrome__* browser tools. Agent assignments: Agent 1: /chat + /library. Agent 2: /graph + /prompts. Agent 3: /token-cost + /knowledge-cores. Agent 4: /flows + /settings. Agent 5: sidebar, root-layout, skip-link, loading bar, disconnection banner (viewport 1440x900, test both dark+light mode). Agent 6: responsive at 768x600 across all 8 pages + keyboard navigation (Tab/Shift+Tab/Enter/Escape) on dialogs (/library upload, /flows start/stop). Each agent checks: (a) visual - page loads fully, icons visible, no overflow/clipping; (b) a11y - aria-labels, htmlFor/id label pairs, heading hierarchy, color contrast (no raw amber/yellow on dark bg), focus indicators; (c) functional - buttons respond, toggles work, dialogs open/close/trap focus, loading states display; (d) responsive - content wraps, no horizontal scrollbar, tables scroll. Each agent outputs: AGENT N REPORT - PAGE: /path - ISSUES FOUND: count - then per issue: [SEVERITY:critical|major|minor] [CATEGORY:visual|a11y|functional|responsive] file_path:line description. After all agents complete, aggregate. If total issues == 0, output <promise>ALL_CLEAR</promise>. If issues > 0, fix them by editing source files in ts/packages/workbench/src/, run 'cd /home/elpresidank/YeeBois/dev/trustgraph/ts && pnpm build' to verify, then exit so the loop re-runs.

6
.idea/GitLink.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="uk.co.ben_gibson.git.link.SettingsState">
<option name="host" value="72037fcc-cb9c-4c22-960a-ffe73fd5e229" />
</component>
</project>

7
.idea/discord.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

1210
ts/bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
Alice Johnson is a senior engineer at Acme Corporation. Acme develops CloudSync, a cloud storage platform. CloudSync uses Amazon Web Services for hosting.

View file

@ -0,0 +1 @@
Bob Chen is the CTO of Acme Corporation. Alice reports to Bob. CloudSync was launched in 2024 and competes with Dropbox.

View file

@ -0,0 +1 @@
Alice Johnson is a senior engineer at Acme Corporation. Acme develops CloudSync, a cloud storage platform. CloudSync uses Amazon Web Services for hosting.

View file

@ -0,0 +1 @@
Bob Chen is the CTO of Acme Corporation. Alice reports to Bob. CloudSync was launched in 2024 and competes with Dropbox.

View file

@ -0,0 +1 @@
The Eiffel Tower is a wrought-iron lattice tower in Paris, France

View file

@ -0,0 +1,88 @@
{
"documents": {
"d2871466-6dbd-46f3-b548-8cffc77c466f": {
"kind": "text/plain",
"title": "Test Knowledge",
"comments": "Test doc",
"user": "test",
"tags": [
"test"
],
"documentType": "source",
"id": "d2871466-6dbd-46f3-b548-8cffc77c466f",
"time": 1775544601926
},
"2b954221-169b-4297-9d0b-5733676bdd19": {
"id": "2b954221-169b-4297-9d0b-5733676bdd19",
"time": 1775545730516,
"kind": "application/pdf",
"title": "Acme Corporation Test Document",
"comments": "End-to-end pipeline test",
"user": "test",
"tags": [
"test",
"pipeline"
],
"documentType": "source"
},
"09b14acd-6274-48f3-a468-1ef78ec71147": {
"id": "09b14acd-6274-48f3-a468-1ef78ec71147",
"user": "test",
"kind": "text/plain",
"title": "Page 1",
"parentId": "2b954221-169b-4297-9d0b-5733676bdd19",
"documentType": "page",
"time": 1775545736563,
"comments": "",
"tags": []
},
"b7bc3c30-65fc-424b-bb8b-6dfeca739250": {
"id": "b7bc3c30-65fc-424b-bb8b-6dfeca739250",
"user": "test",
"kind": "text/plain",
"title": "Page 2",
"parentId": "2b954221-169b-4297-9d0b-5733676bdd19",
"documentType": "page",
"time": 1775545738656,
"comments": "",
"tags": []
},
"5c884d9d-7b96-48c2-826c-7ca21314edf8": {
"id": "5c884d9d-7b96-48c2-826c-7ca21314edf8",
"time": 1775545966287,
"kind": "application/pdf",
"title": "Acme Corporation Test Document",
"comments": "End-to-end pipeline test",
"user": "test",
"tags": [
"test",
"pipeline"
],
"documentType": "source"
},
"897f90a7-58f4-4466-933c-adf6f8d904ca": {
"id": "897f90a7-58f4-4466-933c-adf6f8d904ca",
"user": "test",
"kind": "text/plain",
"title": "Page 1",
"parentId": "5c884d9d-7b96-48c2-826c-7ca21314edf8",
"documentType": "page",
"time": 1775545972298,
"comments": "",
"tags": []
},
"35befdd5-50f2-4432-a7da-b7d95fcc6ab3": {
"id": "35befdd5-50f2-4432-a7da-b7d95fcc6ab3",
"user": "test",
"kind": "text/plain",
"title": "Page 2",
"parentId": "5c884d9d-7b96-48c2-826c-7ca21314edf8",
"documentType": "page",
"time": 1775545974301,
"comments": "",
"tags": []
}
},
"processing": {},
"collections": []
}

BIN
ts/data/test.pdf Normal file

Binary file not shown.

View file

@ -52,7 +52,7 @@ export function RootLayout() {
{/* Connection lost banner */} {/* Connection lost banner */}
{isDisconnected && ( {isDisconnected && (
<div className="flex items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning"> <div role="alert" className="flex items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning">
<WifiOff className="h-3.5 w-3.5" /> <WifiOff className="h-3.5 w-3.5" />
<span>Connection lost. Attempting to reconnect...</span> <span>Connection lost. Attempting to reconnect...</span>
</div> </div>

View file

@ -32,7 +32,7 @@ interface NavItemProps {
function NavItem({ to, icon: Icon, label }: NavItemProps) { function NavItem({ to, icon: Icon, label }: NavItemProps) {
return ( return (
<NavLink to={to} className="w-full"> <NavLink to={to} className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50">
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
@ -72,7 +72,7 @@ function ConnectionBadge() {
? "text-warning" ? "text-warning"
: isConnected : isConnected
? "text-success" ? "text-success"
: "text-fg-subtle", : "text-fg-muted",
)} )}
> >
<span <span
@ -148,7 +148,7 @@ function FlowSelectorDropdown() {
export function Sidebar() { export function Sidebar() {
return ( return (
<aside className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50"> <aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
{/* Logo area */} {/* Logo area */}
<div className="flex h-14 items-center gap-2 px-4"> <div className="flex h-14 items-center gap-2 px-4">
<TestTube2 className="h-5 w-5 text-brand-500" /> <TestTube2 className="h-5 w-5 text-brand-500" />
@ -167,7 +167,7 @@ export function Sidebar() {
<div className="mx-3 border-t border-border" /> <div className="mx-3 border-t border-border" />
{/* Navigation links */} {/* Navigation links */}
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3"> <nav aria-label="Main navigation" className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
<NavItem to="/chat" icon={MessageSquareText} label="Chat" /> <NavItem to="/chat" icon={MessageSquareText} label="Chat" />
<NavItem to="/library" icon={LibraryBig} label="Library" /> <NavItem to="/library" icon={LibraryBig} label="Library" />
<NavItem to="/graph" icon={Rotate3d} label="Graph" /> <NavItem to="/graph" icon={Rotate3d} label="Graph" />

View file

@ -35,6 +35,17 @@ export function Dialog({
const titleId = useId(); const titleId = useId();
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
// Save the element that triggered the dialog so we can restore focus on close
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement as HTMLElement | null;
} else if (triggerRef.current) {
triggerRef.current.focus();
triggerRef.current = null;
}
}, [open]);
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -132,7 +143,7 @@ export function Dialog({
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close dialog" aria-label="Close dialog"
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg" className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>

View file

@ -130,6 +130,6 @@ html.light {
/* Semantic colors stay vivid but slightly darker for contrast */ /* Semantic colors stay vivid but slightly darker for contrast */
--color-success: #16a34a; --color-success: #16a34a;
--color-warning: #ca8a04; --color-warning: #854d0e;
--color-error: #dc2626; --color-error: #b91c1c;
} }

View file

@ -251,11 +251,12 @@ export default function ChatPage() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Mode selector */} {/* Mode selector */}
<div className="flex rounded-lg border border-border bg-surface-100 p-0.5"> <div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
{MODES.map((mode) => ( {MODES.map((mode) => (
<button <button
key={mode.value} key={mode.value}
onClick={() => setChatMode(mode.value)} onClick={() => setChatMode(mode.value)}
aria-pressed={chatMode === mode.value}
className={cn( className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors", "rounded-md px-3 py-1 text-xs font-medium transition-colors",
chatMode === mode.value chatMode === mode.value

View file

@ -324,6 +324,7 @@ function FlowRow({
}} }}
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error" className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
title="Stop flow" title="Stop flow"
aria-label={`Stop flow ${flow.id}`}
> >
<Square className="h-3.5 w-3.5" /> <Square className="h-3.5 w-3.5" />
</button> </button>
@ -451,7 +452,7 @@ export default function FlowsPage() {
)} )}
{error && ( {error && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error"> <p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error} {error}
</p> </p>
)} )}

View file

@ -500,6 +500,7 @@ export default function GraphPage() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search nodes..." placeholder="Search nodes..."
aria-label="Search nodes"
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/> />
{searchTerm && ( {searchTerm && (

View file

@ -214,6 +214,7 @@ export default function KnowledgeCoresPage() {
disabled={actionInProgress === id} disabled={actionInProgress === id}
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40" className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40"
title="Load core" title="Load core"
aria-label={`Load core ${id}`}
> >
{actionInProgress === id ? ( {actionInProgress === id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@ -227,6 +228,7 @@ export default function KnowledgeCoresPage() {
disabled={actionInProgress === id} disabled={actionInProgress === id}
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40" className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40"
title="Delete core" title="Delete core"
aria-label={`Delete core ${id}`}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete Delete

View file

@ -156,6 +156,7 @@ function UploadDialog({
e.stopPropagation(); e.stopPropagation();
setFile(null); setFile(null);
}} }}
aria-label="Remove selected file"
className="ml-1 text-fg-subtle hover:text-fg" className="ml-1 text-fg-subtle hover:text-fg"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@ -345,7 +346,7 @@ export default function LibraryPage() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header */} {/* Header */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<LibraryBig className="h-6 w-6 text-brand-400" /> <LibraryBig className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Library</h1> <h1 className="text-2xl font-bold text-fg">Library</h1>

View file

@ -71,9 +71,9 @@ export default function PromptsPage() {
{/* Tabs */} {/* Tabs */}
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1"> <div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
<button <button
id="tab-templates"
role="tab" role="tab"
aria-selected={activeTab === "templates"} aria-selected={activeTab === "templates"}
aria-controls="panel-templates"
onClick={() => setActiveTab("templates")} onClick={() => setActiveTab("templates")}
className={cn( className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors", "flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
@ -86,9 +86,9 @@ export default function PromptsPage() {
Templates Templates
</button> </button>
<button <button
id="tab-system"
role="tab" role="tab"
aria-selected={activeTab === "system"} aria-selected={activeTab === "system"}
aria-controls="panel-system"
onClick={() => setActiveTab("system")} onClick={() => setActiveTab("system")}
className={cn( className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors", "flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
@ -111,7 +111,7 @@ export default function PromptsPage() {
{/* Templates tab */} {/* Templates tab */}
{activeTab === "templates" && ( {activeTab === "templates" && (
<div id="panel-templates" role="tabpanel" className="flex flex-1 flex-col gap-4 overflow-hidden"> <div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" className="flex flex-1 flex-col gap-4 overflow-hidden">
{loading && prompts.length === 0 && ( {loading && prompts.length === 0 && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" /> <Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
@ -201,7 +201,7 @@ export default function PromptsPage() {
{/* System Prompt tab */} {/* System Prompt tab */}
{activeTab === "system" && ( {activeTab === "system" && (
<div id="panel-system" role="tabpanel" className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border"> <div id="panel-system" role="tabpanel" aria-labelledby="tab-system" className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
<div className="border-b border-border bg-surface-100 px-4 py-3"> <div className="border-b border-border bg-surface-100 px-4 py-3">
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted"> <h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
System Prompt System Prompt

View file

@ -342,7 +342,7 @@ export default function SettingsPage() {
onClick={toggleTheme} onClick={toggleTheme}
className={cn( className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors", "relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isDark ? "bg-brand-600" : "bg-surface-300", isDark ? "bg-brand-600" : "bg-surface-400",
)} )}
> >
<span <span
@ -372,7 +372,7 @@ export default function SettingsPage() {
onClick={() => updateFeatureSwitches({ [key]: !enabled })} onClick={() => updateFeatureSwitches({ [key]: !enabled })}
className={cn( className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors", "relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
enabled ? "bg-brand-600" : "bg-surface-300", enabled ? "bg-brand-600" : "bg-surface-400",
)} )}
> >
<span className={cn( <span className={cn(