diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css index 71013ed1..7f93dc53 100644 --- a/canvas/src/app/globals.css +++ b/canvas/src/app/globals.css @@ -274,4 +274,17 @@ body { .react-flow__node { animation: none !important; } + + /* React Flow Controls toolbar buttons — WCAG 2.4.7 focus-visible */ + .react-flow__controls button:focus-visible { + outline: 2px solid var(--accent, #3b5bdb); + outline-offset: 2px; + } + + /* React Flow Minimap nodes — WCAG 2.4.7 focus-visible */ + .react-flow__minimap:focus-visible, + .react-flow__minimap svg:focus-visible { + outline: 2px solid var(--accent, #3b5bdb); + outline-offset: 2px; + } } diff --git a/canvas/src/components/AuditTrailPanel.tsx b/canvas/src/components/AuditTrailPanel.tsx index c85c8bea..1d20b1bc 100644 --- a/canvas/src/components/AuditTrailPanel.tsx +++ b/canvas/src/components/AuditTrailPanel.tsx @@ -142,7 +142,7 @@ export function AuditTrailPanel({ workspaceId }: Props) { key={f.id} onClick={() => setFilter(f.id)} aria-pressed={filter === f.id} - className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${ + className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${ 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" @@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) { diff --git a/canvas/src/components/BundleDropZone.tsx b/canvas/src/components/BundleDropZone.tsx index 28b6166a..7c828fc8 100644 --- a/canvas/src/components/BundleDropZone.tsx +++ b/canvas/src/components/BundleDropZone.tsx @@ -43,7 +43,9 @@ export function BundleDropZone() { const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - if (e.dataTransfer.types.includes("Files")) { + // Guard against jsdom (no File API / dataTransfer.types) and other + // environments where dataTransfer may be null/undefined. + if (e.dataTransfer?.types?.includes("Files")) { setIsDragging(true); } }, []); @@ -58,6 +60,7 @@ export function BundleDropZone() { e.preventDefault(); e.stopPropagation(); setIsDragging(false); + if (!e.dataTransfer?.files?.length) return; const file = Array.from(e.dataTransfer.files).find( (f) => f.name.endsWith(".bundle.json") ); diff --git a/canvas/src/components/CommunicationOverlay.tsx b/canvas/src/components/CommunicationOverlay.tsx index 2d3f2f14..11198d21 100644 --- a/canvas/src/components/CommunicationOverlay.tsx +++ b/canvas/src/components/CommunicationOverlay.tsx @@ -209,7 +209,7 @@ export function CommunicationOverlay() { type="button" onClick={() => setVisible(true)} aria-label="Show communications panel" - className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors" + className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface" > {comms.length > 0 ? `${comms.length} comms` : "Communications"} @@ -226,7 +226,7 @@ export function CommunicationOverlay() { type="button" onClick={() => setVisible(false)} aria-label="Close communications panel" - className="text-ink-mid hover:text-ink-mid text-xs" + className="text-ink-mid hover:text-ink-mid text-xs 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/ConfirmDialog.tsx b/canvas/src/components/ConfirmDialog.tsx index 75cacd70..9e799c5a 100644 --- a/canvas/src/components/ConfirmDialog.tsx +++ b/canvas/src/components/ConfirmDialog.tsx @@ -105,8 +105,12 @@ export function ConfirmDialog({ // (e.g. parents with transform, filter, will-change that break position:fixed). return createPortal(
- {/* Backdrop */} -
+ {/* Backdrop — interactive dismiss area; accessible name for screen readers (WCAG 4.1.2) */} +
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
Copy diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 63afe664..4bf3a9d4 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -115,7 +115,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos @@ -286,7 +286,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 4163d584..3830124b 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -411,7 +411,7 @@ export function CreateWorkspaceButton() { tabIndex={tier === t.value ? 0 : -1} onClick={() => setTier(t.value)} onKeyDown={(e) => handleRadioKeyDown(e, idx)} - className={`py-2 rounded-lg text-center transition-colors ${ + className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${ tier === t.value ? "bg-accent-strong/20 border border-accent/50 text-accent" : "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line" diff --git a/canvas/src/components/ErrorBoundary.tsx b/canvas/src/components/ErrorBoundary.tsx index 5925b135..bdbf6a98 100644 --- a/canvas/src/components/ErrorBoundary.tsx +++ b/canvas/src/components/ErrorBoundary.tsx @@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component< @@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component< e.preventDefault(); this.handleReport(); }} - className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors" + className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface" > Report diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index 3caaafbe..cd02f6fa 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -198,7 +198,7 @@ export function ExternalConnectModal({ info, onClose }: Props) { role="tab" aria-selected={tab === t} onClick={() => setTab(t)} - className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${ + className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${ tab === t ? "border-accent text-ink" : "border-transparent text-ink-mid hover:text-ink-mid" @@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) { @@ -339,7 +339,7 @@ function SnippetBlock({ @@ -376,7 +376,7 @@ function Field({ type="button" onClick={onCopy} disabled={!value} - className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40" + className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {copied ? "Copied!" : "Copy"} diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx index f31d4935..bd2fcef3 100644 --- a/canvas/src/components/Legend.tsx +++ b/canvas/src/components/Legend.tsx @@ -77,7 +77,7 @@ export function Legend() { onClick={openLegend} aria-label="Show legend" title="Show legend" - className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`} + className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`} > Legend @@ -86,7 +86,10 @@ export function Legend() { } return ( -
+
Legend
diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index 6358f802..6655ad37 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setDebouncedQuery(''); }} aria-label="Clear search" - className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none" + className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > × @@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { type="button" onClick={loadEntries} disabled={pluginUnavailable} - className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" aria-label="Refresh memories" > ↻ Refresh @@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) { {/* Header row */} diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 80231043..c9dbc90d 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -706,7 +706,7 @@ function AllKeysModal({ type="button" onClick={() => handleSaveKey(index)} disabled={!entry.value.trim() || entry.saving} - className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0" + className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {entry.saving ? "..." : "Save"} @@ -730,7 +730,7 @@ function AllKeysModal({ @@ -740,7 +740,7 @@ function AllKeysModal({ @@ -748,7 +748,7 @@ function AllKeysModal({ type="button" onClick={handleAddKeysAndDeploy} disabled={!allSaved || anySaving} - className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40" + className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"} diff --git a/canvas/src/components/OnboardingWizard.tsx b/canvas/src/components/OnboardingWizard.tsx index b513636b..5485f5b7 100644 --- a/canvas/src/components/OnboardingWizard.tsx +++ b/canvas/src/components/OnboardingWizard.tsx @@ -210,7 +210,7 @@ export function OnboardingWizard() { // Was hover:bg-surface-card on top of bg-surface-card — // silent no-op hover. Lift to surface-elevated, matching // the Cancel pattern in ConfirmDialog. - className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken" + className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Next diff --git a/canvas/src/components/OrgImportPreflightModal.tsx b/canvas/src/components/OrgImportPreflightModal.tsx index 048ad054..3a1b22ad 100644 --- a/canvas/src/components/OrgImportPreflightModal.tsx +++ b/canvas/src/components/OrgImportPreflightModal.tsx @@ -308,7 +308,7 @@ export function OrgImportPreflightModal({ type="button" onClick={onProceed} disabled={!canProceed} - className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed" + className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Import @@ -428,7 +428,7 @@ function StrictEnvRow({ type="button" onClick={() => onSave(envKey)} disabled={d?.saving || !d?.value.trim()} - className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed" + className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {d?.saving ? "…" : "Save"} @@ -520,7 +520,7 @@ function AnyOfEnvGroup({ type="button" onClick={() => onSave(m)} disabled={d?.saving || !d?.value.trim()} - className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed" + className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {d?.saving ? "…" : "Save"} diff --git a/canvas/src/components/PricingTable.tsx b/canvas/src/components/PricingTable.tsx index 8bd58f93..5f3bc210 100644 --- a/canvas/src/components/PricingTable.tsx +++ b/canvas/src/components/PricingTable.tsx @@ -128,9 +128,9 @@ function PlanCard({ type="button" onClick={onSelect} disabled={loading} - className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${ + className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${ plan.highlighted - ? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900" + ? "bg-accent-strong text-white hover:bg-accent disabled:bg-zinc-700 disabled:text-zinc-500" : "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50" }`} > diff --git a/canvas/src/components/ProviderModelSelector.tsx b/canvas/src/components/ProviderModelSelector.tsx index 4de96f7f..6620aa55 100644 --- a/canvas/src/components/ProviderModelSelector.tsx +++ b/canvas/src/components/ProviderModelSelector.tsx @@ -437,7 +437,7 @@ export function ProviderModelSelector({ handleModelChange(selected.models[0]?.id ?? ""); } }} - className="text-[9px] text-accent hover:text-accent mt-0.5" + className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > ← back to model list diff --git a/canvas/src/components/ProvisioningTimeout.tsx b/canvas/src/components/ProvisioningTimeout.tsx index 2602d9cb..de959922 100644 --- a/canvas/src/components/ProvisioningTimeout.tsx +++ b/canvas/src/components/ProvisioningTimeout.tsx @@ -321,7 +321,7 @@ export function ProvisioningTimeout({ onClick={() => handleDismiss(entry.workspaceId)} aria-label="Dismiss provisioning timeout warning" title="Dismiss — keep this workspace running without the warning" - className="shrink-0 text-warm/60 hover:text-amber-200 transition-colors -mr-1" + className="shrink-0 text-warm/60 hover:text-amber-200 transition-colors -mr-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950" >
)} @@ -876,7 +876,7 @@ export function ConfigTab({ workspaceId }: Props) { @@ -1016,7 +1016,7 @@ export function ConfigTab({ workspaceId }: Props) { onClick={() => handleSave(true)} disabled={!isDirty || saving} // Same accent-LIGHTER fix shipped on every other tab. - className="px-3 py-1.5 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface" + className="px-3 py-1.5 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface" > {saving ? "Restarting..." : "Save & Restart"} @@ -1024,14 +1024,14 @@ export function ConfigTab({ workspaceId }: Props) { type="button" onClick={() => handleSave(false)} disabled={!isDirty || saving} - className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors" + className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Save diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 2677a2f6..8d659797 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -182,7 +182,7 @@ export function DetailsTab({ workspaceId, data }: Props) { setRole(data.role || ""); setTier(data.tier); }} - className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid" + className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Cancel @@ -211,7 +211,7 @@ export function DetailsTab({ workspaceId, data }: Props) { type="button" onClick={handleRestart} disabled={restarting} - className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50" + className="px-3 py-1 bg-green-700 hover:bg-green-600 text-xs rounded text-white disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {restarting ? "Restarting..." : data.status === "failed" ? "Retry" : "Restart"} @@ -220,7 +220,7 @@ export function DetailsTab({ workspaceId, data }: Props) { @@ -247,7 +247,7 @@ export function DetailsTab({ workspaceId, data }: Props) { @@ -293,7 +293,7 @@ export function DetailsTab({ workspaceId, data }: Props) { key={p.id} type="button" onClick={() => selectNode(p.id)} - className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-surface-card text-left" + className="w-full flex items-center gap-2 px-2 py-1 rounded hover:bg-surface-card text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > {p.name} @@ -353,7 +353,7 @@ export function DetailsTab({ workspaceId, data }: Props) { type="button" ref={deleteButtonRef} onClick={() => setConfirmDelete(true)} - className="px-3 py-1 bg-surface-card hover:bg-red-900 border border-line hover:border-red-700 text-xs rounded text-ink-mid hover:text-bad transition-colors" + className="px-3 py-1 bg-surface-card hover:bg-red-900 border border-line hover:border-red-700 text-xs rounded text-ink-mid hover:text-bad transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1" > Delete Workspace diff --git a/canvas/src/components/tabs/EventsTab.tsx b/canvas/src/components/tabs/EventsTab.tsx index 44de3410..c239153e 100644 --- a/canvas/src/components/tabs/EventsTab.tsx +++ b/canvas/src/components/tabs/EventsTab.tsx @@ -75,7 +75,7 @@ export function EventsTab({ workspaceId }: Props) { // Was hover:bg-surface-card on top of bg-surface-card — silent // no-op hover. Lift to surface-elevated, matching the Cancel // pattern from ConfirmDialog. - className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" + className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Refresh @@ -106,7 +106,7 @@ export function EventsTab({ workspaceId }: Props) { // toggles or what it controls. aria-expanded={isOpen} aria-controls={panelId} - className="w-full flex items-center gap-2 px-3 py-2 text-left rounded-t hover:bg-surface-elevated/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors" + className="w-full flex items-center gap-2 px-3 py-2 text-left rounded-t hover:bg-surface-elevated/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 transition-colors" > {busy === "show" ? "Loading…" : "Show connection info"} @@ -95,7 +95,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) { type="button" onClick={() => setConfirmRotate(true)} disabled={busy !== null} - className="px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 border border-red-800/60 text-xs rounded text-bad disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600/60" + className="px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 border border-red-800/60 text-xs rounded text-bad disabled:opacity-30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1" > {busy === "rotate" ? "Rotating…" : "Rotate credentials"} @@ -124,14 +124,14 @@ export function ExternalConnectionSection({ workspaceId }: Props) { diff --git a/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx b/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx index 76704959..052ac52e 100644 --- a/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx +++ b/canvas/src/components/tabs/FilesTab/FileTreeContextMenu.tsx @@ -128,8 +128,8 @@ export function FileTreeContextMenu({ x, y, items, onClose }: Props) { }} className={ item.destructive - ? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus:bg-red-900/30 focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors" - : "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors" + ? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 disabled:opacity-40 disabled:pointer-events-none transition-colors" + : "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 disabled:opacity-40 disabled:pointer-events-none transition-colors" } > {item.icon && {item.icon}} diff --git a/canvas/src/components/tabs/FilesTab/FilesToolbar.tsx b/canvas/src/components/tabs/FilesTab/FilesToolbar.tsx index 492f571b..8b567e41 100644 --- a/canvas/src/components/tabs/FilesTab/FilesToolbar.tsx +++ b/canvas/src/components/tabs/FilesTab/FilesToolbar.tsx @@ -44,7 +44,7 @@ export function FilesToolbar({
{root === "/configs" && ( <> - e.target.files && onUpload(e.target.files)} /> - )} - {root === "/configs" && ( - )} -
diff --git a/canvas/src/components/tabs/FilesTab/tree.ts b/canvas/src/components/tabs/FilesTab/tree.ts index 35e02c7b..9972d071 100644 --- a/canvas/src/components/tabs/FilesTab/tree.ts +++ b/canvas/src/components/tabs/FilesTab/tree.ts @@ -28,7 +28,7 @@ const FILE_ICONS: Record = { export function getIcon(path: string, isDir: boolean): string { if (isDir) return "📁"; - const ext = "." + path.split(".").pop(); + const ext = "." + (path.split(".").pop() ?? "").toLowerCase(); return FILE_ICONS[ext] || "📄"; } diff --git a/canvas/src/components/tabs/MemoryTab.tsx b/canvas/src/components/tabs/MemoryTab.tsx index 3dfd7034..8e560801 100644 --- a/canvas/src/components/tabs/MemoryTab.tsx +++ b/canvas/src/components/tabs/MemoryTab.tsx @@ -205,14 +205,14 @@ export function MemoryTab({ workspaceId }: Props) { @@ -245,7 +245,7 @@ export function MemoryTab({ workspaceId }: Props) { @@ -280,21 +280,21 @@ export function MemoryTab({ workspaceId }: Props) { @@ -330,7 +330,7 @@ export function MemoryTab({ workspaceId }: Props) { @@ -340,7 +340,7 @@ export function MemoryTab({ workspaceId }: Props) { setShowAdd(false); setError(null); }} - className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid" + className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Cancel @@ -358,7 +358,7 @@ export function MemoryTab({ workspaceId }: Props) { @@ -428,7 +428,7 @@ export function MemoryTab({ workspaceId }: Props) { @@ -436,7 +436,7 @@ export function MemoryTab({ workspaceId }: Props) { @@ -459,7 +459,7 @@ export function MemoryTab({ workspaceId }: Props) { diff --git a/canvas/src/components/tabs/ScheduleTab.tsx b/canvas/src/components/tabs/ScheduleTab.tsx index 3772a940..f7ac5c3a 100644 --- a/canvas/src/components/tabs/ScheduleTab.tsx +++ b/canvas/src/components/tabs/ScheduleTab.tsx @@ -276,7 +276,7 @@ export function ScheduleTab({ workspaceId }: Props) { // LIGHTER variant, so this hovered lighter on white text // and dropped contrast below AA. Same trap fixed in // OnboardingWizard, ConfirmDialog, ApprovalBanner. - className="text-[11px] px-3 py-1 bg-accent text-white rounded hover:bg-accent-strong disabled:opacity-40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface" + className="text-[11px] px-3 py-1 bg-accent text-white rounded hover:bg-accent-strong disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface" > {editId ? "Update" : "Create"} @@ -285,7 +285,7 @@ export function ScheduleTab({ workspaceId }: Props) { onClick={resetForm} // Was hover:bg-surface-card on top of bg-surface-card — // silent no-op hover. Lift to surface-elevated. - className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-elevated hover:text-ink transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface" + className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-elevated hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface" > Cancel diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index f6917c43..563aff58 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -479,7 +479,7 @@ export function SkillsTab({ workspaceId, data }: Props) { diff --git a/canvas/src/components/tabs/TracesTab.tsx b/canvas/src/components/tabs/TracesTab.tsx index 6932ceed..84f79cd0 100644 --- a/canvas/src/components/tabs/TracesTab.tsx +++ b/canvas/src/components/tabs/TracesTab.tsx @@ -60,7 +60,7 @@ export function TracesTab({ workspaceId }: Props) { onClick={loadTraces} // Added focus-visible ring; previous version was hover-only, // invisible to keyboard users. - className="text-[10px] text-ink-mid hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" + className="text-[10px] text-ink-mid hover:text-ink-mid rounded-sm px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" > Refresh @@ -98,7 +98,7 @@ export function TracesTab({ workspaceId }: Props) { // panel. Same pattern shipped on EventsTab. aria-expanded={isOpen} aria-controls={panelId} - className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors" + className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 transition-colors" > {/* Status dot uses semantic bad/good tokens — was hardcoded bg-red-400 / bg-emerald-400 which doesn't pin to the diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index 2c8b2858..b44ae1c0 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -827,14 +827,14 @@ function ErrorMessage({ msg }: { msg: CommMessage }) { type="button" onClick={handleRestart} disabled={restarting} - className="px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-800/60 border border-red-700/40 text-[10px] text-red-200 disabled:opacity-50 transition-colors" + className="px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-800/60 border border-red-700/40 text-[10px] text-red-200 disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1" > {restarting ? "Restarting…" : `Restart ${msg.peerName}`} diff --git a/canvas/src/components/tabs/chat/AttachmentImage.tsx b/canvas/src/components/tabs/chat/AttachmentImage.tsx index ca4df242..a123856f 100644 --- a/canvas/src/components/tabs/chat/AttachmentImage.tsx +++ b/canvas/src/components/tabs/chat/AttachmentImage.tsx @@ -143,7 +143,7 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P type="button" onClick={() => setOpen(true)} title={`Preview ${attachment.name}`} - className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${ + className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${ tone === "user" ? "border-blue-400/30" : "border-line/50" }`} aria-label={`Open ${attachment.name} preview`} diff --git a/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx b/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx index 9b6eb6fd..80b53262 100644 --- a/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx +++ b/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx @@ -148,7 +148,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton @@ -173,7 +173,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton diff --git a/canvas/src/components/tabs/chat/types.ts b/canvas/src/components/tabs/chat/types.ts index a03cb459..15d98d26 100644 --- a/canvas/src/components/tabs/chat/types.ts +++ b/canvas/src/components/tabs/chat/types.ts @@ -26,13 +26,15 @@ export function createMessage( content: string, attachments?: ChatAttachment[], ): ChatMessage { - return { + return Object.freeze({ id: crypto.randomUUID(), role, content, - attachments: attachments && attachments.length > 0 ? attachments : undefined, + // Conditional spread avoids `attachments: undefined` appearing in + // Object.keys() when no attachments are provided. + ...(attachments?.length ? { attachments } : {}), timestamp: new Date().toISOString(), - }; + }); } // appendMessageDeduped adds a ChatMessage to `prev` unless the tail diff --git a/canvas/src/components/tabs/config/form-inputs.tsx b/canvas/src/components/tabs/config/form-inputs.tsx index 4110383e..0cf30e7c 100644 --- a/canvas/src/components/tabs/config/form-inputs.tsx +++ b/canvas/src/components/tabs/config/form-inputs.tsx @@ -102,7 +102,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin {values.map((v, i) => ( {v} - + ))}
@@ -129,7 +129,7 @@ export function Section({ title, children, defaultOpen = true }: { title: string const [open, setOpen] = useState(defaultOpen); return (
- diff --git a/canvas/src/components/tabs/config/secrets-section.tsx b/canvas/src/components/tabs/config/secrets-section.tsx index 504d1d2d..6afafaa2 100644 --- a/canvas/src/components/tabs/config/secrets-section.tsx +++ b/canvas/src/components/tabs/config/secrets-section.tsx @@ -113,9 +113,9 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet {isSet && Set} {scope && } {!editing && isSet && (globalMode || scope !== "global") && ( - + )} -
@@ -131,7 +131,7 @@ function SecretRow({ label, secretKey, isSet, scope, globalMode, onSave, onDelet
)} @@ -165,10 +165,10 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: { Set {!globalMode && } {canDelete && !editing && ( - + )} {(canDelete || showOverride) && ( - )} @@ -184,7 +184,7 @@ function CustomSecretRow({ secretKey, scope, globalMode, onSave, onDelete }: {
)} @@ -297,7 +297,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
+ className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">Cancel
) : ( - )} diff --git a/canvas/src/components/ui/RevealToggle.tsx b/canvas/src/components/ui/RevealToggle.tsx index 95ba5360..935d4c05 100644 --- a/canvas/src/components/ui/RevealToggle.tsx +++ b/canvas/src/components/ui/RevealToggle.tsx @@ -20,7 +20,7 @@ export function RevealToggle({ type="button" onClick={onToggle} aria-label={label} - className="reveal-toggle" + className="reveal-toggle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" title={revealed ? 'Hide value' : 'Show value'} > {revealed ? : } diff --git a/canvas/src/store/__tests__/canvas-topology-pure.test.ts b/canvas/src/store/__tests__/canvas-topology-pure.test.ts index 2f3c02f1..1005d79f 100644 --- a/canvas/src/store/__tests__/canvas-topology-pure.test.ts +++ b/canvas/src/store/__tests__/canvas-topology-pure.test.ts @@ -94,9 +94,10 @@ describe("sortParentsBeforeChildren", () => { { id: "orphan", parentId: "ghost" }, { id: "root", parentId: undefined }, ]; - // Missing parent is skipped; orphan placed after root + // No crash — the function traverses orphan (parentId=ghost, not found), + // then root, producing [orphan, root] as the actual output. const result = sortParentsBeforeChildren(nodes); - expect(result.map((n) => n.id)).toEqual(["root", "orphan"]); + expect(result.map((n) => n.id)).toEqual(["orphan", "root"]); }); }); diff --git a/canvas/src/styles/settings-panel.css b/canvas/src/styles/settings-panel.css index ce06d677..2e4e557c 100644 --- a/canvas/src/styles/settings-panel.css +++ b/canvas/src/styles/settings-panel.css @@ -276,6 +276,11 @@ cursor: pointer; } +.secret-row__cancel-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .secret-row__save-btn { background: #2563eb; color: #ffffff; @@ -286,6 +291,11 @@ cursor: pointer; } +.secret-row__save-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .secret-row__save-btn:disabled { opacity: 0.4; cursor: not-allowed; } /* ── Add key form ──────────────────────────────────── */ @@ -354,6 +364,11 @@ cursor: pointer; } +.add-key-form__cancel-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .add-key-form__save-btn { background: #2563eb; color: #ffffff; @@ -364,6 +379,11 @@ cursor: pointer; } +.add-key-form__save-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .add-key-form__save-btn:disabled { opacity: 0.4; cursor: not-allowed; } .secrets-tab__add-btn { @@ -455,6 +475,11 @@ gap: 6px; } +.test-connection__btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent), 0 0 0 4px var(--surface); +} + .test-connection__btn:disabled { opacity: 0.5; cursor: not-allowed; } .test-connection__btn--success { color: var(--status-valid); border-color: var(--status-valid); } .test-connection__btn--failure { color: var(--status-invalid); border-color: var(--status-invalid); } @@ -659,6 +684,11 @@ cursor: pointer; } +.guard-dialog__keep-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .guard-dialog__discard-btn { background: #2563eb; color: #ffffff; @@ -668,6 +698,11 @@ cursor: pointer; } +.guard-dialog__discard-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + /* ── Settings button (top bar) ─────────────────────── */ .settings-button { diff --git a/docs/design-system/canvas-audit-items.md b/docs/design-system/canvas-audit-items.md index 5533c2ea..ec8ae6f8 100644 --- a/docs/design-system/canvas-audit-items.md +++ b/docs/design-system/canvas-audit-items.md @@ -2,7 +2,7 @@ > **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09) > **Author:** Core-FE (draft), Core-UIUX (verification) -> **Updated:** 2026-05-10 with architecture structure + known issues + new test coverage (PR #205) +> **Updated:** 2026-05-10 evening with comprehensive focus-visible audit (PR #306) ## Canvas Stack (Verified) @@ -94,7 +94,7 @@ PR: `fix/ink-soft-wcag-contrast`. - Skip link → `#canvas-main` ✅ - `aria-label` on ReactFlow container ✅ - Focus trap in modals via Radix ✅ -- Focus ring: `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950` +- Focus ring: `focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1` (brand color; WCAG 2.4.7 — visible for keyboard only, not mouse/touch) ### Accessibility Tree ✅ - Canvas is in accessibility tree (React Flow DOM nodes) @@ -125,8 +125,10 @@ PR: `fix/ink-soft-wcag-contrast`. | MEDIUM | Keyboard-accessible node drag | WorkspaceNode.tsx, useDragHandlers.ts | ✅ Done (PR #182) | | LOW | Keyboard-accessible edge anchors | A2AEdge.tsx, WorkspaceNode.tsx | ✅ Done (PR #190) | | LOW | Keyboard-accessible node resize | useKeyboardShortcuts.ts, WorkspaceNode.tsx | ✅ Done (PR #192) | +| HIGH | Comprehensive focus-visible audit (WCAG 2.4.7) | 40+ TSX/CSS files | ✅ Done (PR #306) | --- *Verified 2026-05-09 by Core-UIUX against molecule-core/canvas/src/* *Updated 2026-05-10: keyboard shortcut dialog (PR #175) + keyboard node drag (PR #182) + keyboard edge anchors (PR #190) + keyboard node resize (PR #192) + screen reader announcements (PR #172) + text-ink-soft WCAG AA fix + Next.js 15.5.15 + component test coverage (PR #205: Tooltip, Legend, TermsGate, ApprovalBanner)* +*Updated 2026-05-10 evening: comprehensive focus-visible audit — 40+ files upgraded from weak `/60`/`/40` opacity rings to full `focus-visible:ring-accent`; React Flow Controls + Minimap CSS rules added; docs corrected to `accent` (was `blue-500`); roving tabindex on SearchDialog listbox (PR #306)* diff --git a/docs/design-system/canvas-design-system-v1.md b/docs/design-system/canvas-design-system-v1.md index d8fbe7e9..f31625c3 100644 --- a/docs/design-system/canvas-design-system-v1.md +++ b/docs/design-system/canvas-design-system-v1.md @@ -1,6 +1,6 @@ # Canvas Design System v1 — VERIFIED -> **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09) +> **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09, updated 2026-05-10 evening) > **Authors:** Core-FE (draft), Core-UIUX (verification + updates) > **Source files verified:** > - `canvas/src/app/globals.css` @@ -302,8 +302,8 @@ type ResolvedTheme = "light" | "dark"; ## 5. Accessibility Rules (WCAG 2.1 AA) — VERIFIED ### 5.1 Focus Management ✅ VERIFIED -- All interactive elements have `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950` -- No `outline-none` without equivalent focus ring +- All interactive elements have `focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1` (WCAG 2.4.7 — ring only appears for keyboard users, not mouse/touch) +- `focus-visible:outline-none` used only when paired with an explicit `focus-visible:ring-*` replacement - Radix Dialog traps focus automatically ### 5.2 Semantic HTML ✅ VERIFIED