fix(canvas): UX improvements — shared tokens, focus rings, loading spinners, a11y
- Extract STATUS_CONFIG, TIER_CONFIG, TIER_COLORS to shared design-tokens.ts (eliminates 3 duplicate definitions across WorkspaceNode, EmptyState, TemplatePalette) - Add focus-visible:ring-2 ring-blue-500 to WorkspaceNode, SidePanel tabs, EmptyState buttons, TemplatePalette buttons (keyboard navigation now visible) - Replace "Loading..." text with animated spinner SVG in EmptyState, TemplatePalette sidebar, and OrgTemplatesSection - Add disabled:cursor-not-allowed + suppress hover styling when disabled on EmptyState template buttons and TemplatePalette deploy buttons - Brighten SidePanel tab hover from bg-zinc-800/20 to bg-zinc-800/40 and text from zinc-300 to zinc-200 - Add screen reader labels to CommunicationOverlay directional arrows and status icons (sr-only text for "sent", "received", "to", status) Fixes #422, #424, #427 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bf9fb7cb51
commit
cd30430979
@ -143,14 +143,17 @@ 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} aria-hidden="true">{typeIcon}</span>
|
||||
<span className="sr-only">{c.type === "a2a_send" ? "sent" : c.type === "a2a_receive" ? "received" : "task update"}</span>
|
||||
<span className="text-zinc-300 font-medium truncate">
|
||||
{c.sourceName}
|
||||
</span>
|
||||
<span className="text-zinc-400" aria-hidden="true">→</span>
|
||||
<span className="sr-only">to</span>
|
||||
<span className="text-zinc-300 truncate">{c.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
|
||||
<span className="sr-only">{c.status}</span>
|
||||
<span className="text-zinc-400">{age}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { OrgTemplatesSection } from "./TemplatePalette";
|
||||
import { TIER_COLORS } from "@/lib/design-tokens";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
@ -15,13 +16,6 @@ interface Template {
|
||||
skill_count: number;
|
||||
}
|
||||
|
||||
const TIER_COLORS: Record<number, string> = {
|
||||
1: "text-zinc-400 border-zinc-700/60",
|
||||
2: "text-sky-400 border-sky-500/30",
|
||||
3: "text-violet-400 border-violet-500/30",
|
||||
4: "text-amber-400 border-amber-500/30",
|
||||
};
|
||||
|
||||
export function EmptyState() {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -105,7 +99,13 @@ export function EmptyState() {
|
||||
|
||||
{/* Template grid */}
|
||||
{loading ? (
|
||||
<div className="text-xs text-zinc-400 py-4">Loading templates...</div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-400 py-4">
|
||||
<svg className="w-4 h-4 motion-safe:animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Loading templates...
|
||||
</div>
|
||||
) : templates.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5 mb-4 text-left">
|
||||
{templates.map((t) => {
|
||||
@ -115,7 +115,7 @@ export function EmptyState() {
|
||||
key={t.id}
|
||||
onClick={() => deploy(t)}
|
||||
disabled={!!deploying}
|
||||
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 text-left"
|
||||
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-800/60 disabled:hover:bg-zinc-900/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
@ -144,7 +144,7 @@ export function EmptyState() {
|
||||
<button
|
||||
onClick={createBlank}
|
||||
disabled={!!deploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50"
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
{deploying === "blank" ? "Creating..." : "+ Create blank workspace"}
|
||||
</button>
|
||||
|
||||
@ -186,10 +186,10 @@ export function SidePanel() {
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabIndex={panelTab === tab.id ? 0 : -1}
|
||||
onClick={() => setPanelTab(tab.id)}
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 ${
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
|
||||
panelTab === tab.id
|
||||
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20"
|
||||
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
|
||||
|
||||
@ -5,6 +5,7 @@ import { api } from "@/lib/api";
|
||||
import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight";
|
||||
import { MissingKeysModal } from "./MissingKeysModal";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { TIER_CONFIG as TIER_LABELS_SHARED } from "@/lib/design-tokens";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
@ -90,7 +91,11 @@ export function OrgTemplatesSection() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="text-[10px] text-zinc-500">
|
||||
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-zinc-500">
|
||||
<svg className="w-3 h-3 motion-safe:animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
@ -141,12 +146,7 @@ export function OrgTemplatesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<number, { label: string; color: string }> = {
|
||||
1: { label: "T1", color: "text-zinc-400 bg-zinc-800/60" },
|
||||
2: { label: "T2", color: "text-sky-400 bg-sky-950/40" },
|
||||
3: { label: "T3", color: "text-violet-400 bg-violet-950/40" },
|
||||
4: { label: "T4", color: "text-amber-400 bg-amber-950/40" },
|
||||
};
|
||||
const TIER_LABELS = TIER_LABELS_SHARED;
|
||||
|
||||
function ImportAgentButton({ onImported }: { onImported: () => void }) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
@ -354,7 +354,11 @@ export function TemplatePalette() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-zinc-500 text-center py-8">
|
||||
<svg className="w-4 h-4 motion-safe:animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
@ -380,7 +384,7 @@ export function TemplatePalette() {
|
||||
key={t.id}
|
||||
onClick={() => handleDeploy(t)}
|
||||
disabled={isDeploying}
|
||||
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 group"
|
||||
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800/40 disabled:hover:border-zinc-700/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] font-semibold text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
|
||||
@ -5,6 +5,7 @@ import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
/** Stable selector: returns children, grandchild flag, and descendant count for a node */
|
||||
@ -27,15 +28,6 @@ function useHierarchyInfo(parentId: string) {
|
||||
return { children, hasGrandchildren, descendantCount };
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { dot: string; glow: string; label: string; bar: string }> = {
|
||||
online: { dot: "bg-emerald-400", glow: "shadow-emerald-400/50", label: "Online", bar: "from-emerald-500/20 to-transparent" },
|
||||
offline: { dot: "bg-zinc-500", glow: "", label: "Offline", bar: "from-zinc-600/10 to-transparent" },
|
||||
paused: { dot: "bg-indigo-400", glow: "", label: "Paused", bar: "from-indigo-500/10 to-transparent" },
|
||||
degraded: { dot: "bg-amber-400", glow: "shadow-amber-400/50", label: "Degraded", bar: "from-amber-500/20 to-transparent" },
|
||||
failed: { dot: "bg-red-400", glow: "shadow-red-400/50", label: "Failed", bar: "from-red-500/20 to-transparent" },
|
||||
provisioning: { dot: "bg-sky-400 motion-safe:animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" },
|
||||
};
|
||||
|
||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||
function EjectIcon() {
|
||||
return (
|
||||
@ -46,13 +38,6 @@ function EjectIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
const TIER_CONFIG: Record<number, { label: string; color: string }> = {
|
||||
1: { label: "T1", color: "text-zinc-500 bg-zinc-800/80" },
|
||||
2: { label: "T2", color: "text-sky-400 bg-sky-950/50" },
|
||||
3: { label: "T3", color: "text-violet-400 bg-violet-950/50" },
|
||||
4: { label: "T4", color: "text-amber-400 bg-amber-950/50" },
|
||||
};
|
||||
|
||||
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
|
||||
@ -123,6 +108,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
: "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
|
||||
}
|
||||
backdrop-blur-sm
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950
|
||||
`}
|
||||
>
|
||||
{/* Status gradient bar at top */}
|
||||
|
||||
22
canvas/src/lib/design-tokens.ts
Normal file
22
canvas/src/lib/design-tokens.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const STATUS_CONFIG: Record<string, { dot: string; glow: string; label: string; bar: string }> = {
|
||||
online: { dot: "bg-emerald-400", glow: "shadow-emerald-400/50", label: "Online", bar: "from-emerald-500/20 to-transparent" },
|
||||
offline: { dot: "bg-zinc-500", glow: "", label: "Offline", bar: "from-zinc-600/10 to-transparent" },
|
||||
paused: { dot: "bg-indigo-400", glow: "", label: "Paused", bar: "from-indigo-500/10 to-transparent" },
|
||||
degraded: { dot: "bg-amber-400", glow: "shadow-amber-400/50", label: "Degraded", bar: "from-amber-500/20 to-transparent" },
|
||||
failed: { dot: "bg-red-400", glow: "shadow-red-400/50", label: "Failed", bar: "from-red-500/20 to-transparent" },
|
||||
provisioning: { dot: "bg-sky-400 motion-safe:animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" },
|
||||
};
|
||||
|
||||
export const TIER_CONFIG: Record<number, { label: string; color: string }> = {
|
||||
1: { label: "T1", color: "text-zinc-500 bg-zinc-800/80" },
|
||||
2: { label: "T2", color: "text-sky-400 bg-sky-950/50" },
|
||||
3: { label: "T3", color: "text-violet-400 bg-violet-950/50" },
|
||||
4: { label: "T4", color: "text-amber-400 bg-amber-950/50" },
|
||||
};
|
||||
|
||||
export const TIER_COLORS: Record<number, string> = {
|
||||
1: "text-zinc-400 border-zinc-700/60",
|
||||
2: "text-sky-400 border-sky-500/30",
|
||||
3: "text-violet-400 border-violet-500/30",
|
||||
4: "text-amber-400 border-amber-500/30",
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user