diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 47d901dae4..4dca2a2475 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -1333,6 +1333,84 @@ display: flex; align-items: center; justify-content: space-between; + gap: var(--space-sm); +} + +.wf-column-add, +.wf-column-move, +.wf-column-remove { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast); +} + +.wf-column-add { + flex: 0 0 auto; + padding: var(--space-xs) var(--space-sm); + font-size: 0.72rem; + font-weight: 600; + color: var(--accent); +} + +.wf-column-move, +.wf-column-remove { + width: 1.75rem; + min-width: 1.75rem; + height: 1.75rem; + padding: 0; +} + +.wf-column-add:hover:not(:disabled), +.wf-column-move:hover:not(:disabled) { + border-color: var(--accent); + background: var(--surface-hover); + color: var(--accent); +} + +.wf-column-remove:hover:not(:disabled) { + border-color: var(--ws-error); + background: color-mix(in srgb, var(--ws-error) 12%, transparent); + color: var(--ws-error); +} + +.wf-column-add:focus-visible, +.wf-column-move:focus-visible, +.wf-column-remove:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.wf-column-add:disabled, +.wf-column-move:disabled, +.wf-column-remove:disabled { + cursor: not-allowed; + border-color: var(--border); + background: var(--bg-tertiary); + color: var(--text-tertiary); +} + +.wf-column-panel-empty { + font-size: 0.75rem; + color: var(--text-muted); + margin: 0; +} + +.wf-column-panel-errors { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; + padding: var(--space-sm); + border: 1px solid var(--ws-error); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--ws-error) 10%, transparent); } .wf-column-list { @@ -1382,6 +1460,15 @@ margin: 0; } +.wf-column-traits, +.wf-column-agent { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-top: var(--space-xs); + border-top: 1px dashed var(--border); +} + .wf-column-trait-options { display: flex; flex-wrap: wrap; @@ -1396,12 +1483,113 @@ color: var(--text-muted); } -.wf-column-traits-label { +.wf-column-traits-label, +.wf-column-agent-label { font-size: 0.65rem; + font-weight: 600; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--text-tertiary); } +.wf-column-agent-select { + width: 100%; + min-width: 0; + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 0.72rem; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); +} + +.wf-column-agent-select:hover:not(:disabled) { + border-color: var(--accent); + background: var(--surface-hover); +} + +.wf-column-agent-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.wf-column-agent-select:disabled { + background: var(--bg-tertiary); + color: var(--text-tertiary); + cursor: not-allowed; +} + +.wf-column-agent-badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + padding: 1px var(--space-xs); + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 0.65rem; + font-weight: 600; +} + +.wf-column-agent-badge--stale { + border-color: color-mix(in srgb, var(--ws-warning) 55%, var(--border)); + background: color-mix(in srgb, var(--ws-warning) 12%, transparent); + color: var(--ws-warning); +} + +.wf-column-agent-error, +.wf-column-agent-stale { + display: flex; + align-items: center; + gap: var(--space-xs); + margin: 0; + font-size: 0.68rem; +} + +.wf-column-agent-error { + color: var(--ws-error); +} + +.wf-column-agent-stale { + color: var(--ws-warning); +} + +.wf-column-agent-mode { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.wf-column-agent-mode-option { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 1px var(--space-xs); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 0.68rem; +} + +.wf-column-agent-mode-option:has(input:checked) { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--text); +} + +.wf-column-agent-mode-option:has(input:disabled) { + background: var(--bg-tertiary); + color: var(--text-tertiary); + cursor: not-allowed; +} + /* ── U10/R11: Design-with-AI affordances ─────────────────────────────────── */ /* Create-dialog disclosure (above the template picker). */ @@ -1682,6 +1870,8 @@ .wf-field input, .wf-field textarea, .wf-field select, + .wf-column-name, + .wf-column-agent-select, .wf-templates-filter, .wf-ai-prompt { min-height: var(--wf-editor-touch-target); diff --git a/packages/dashboard/app/components/__tests__/WorkflowColumnPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowColumnPanel.test.tsx new file mode 100644 index 0000000000..a60531736a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkflowColumnPanel.test.tsx @@ -0,0 +1,142 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Agent, TraitCatalogEntry } from "../../api"; +import { fetchAgents, fetchTraits } from "../../api"; +import { WorkflowColumnPanel } from "../WorkflowColumnPanel"; + +vi.mock("../../api", () => ({ + fetchAgents: vi.fn(), + fetchTraits: vi.fn(), +})); + +const traitCatalog: TraitCatalogEntry[] = [ + { id: "intake", name: "Intake", builtin: true, flags: { intake: true } }, + { id: "complete", name: "Complete", builtin: true, flags: { complete: true } }, +]; + +const agents = [ + { id: "agent-1", name: "Column Bot" }, +] as Agent[]; + +function renderPanel({ + columns = [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }], agent: { agentId: "agent-1", mode: "defer" as const } }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + readOnly = false, +}: { + columns?: ComponentProps["columns"]; + readOnly?: boolean; +} = {}) { + return render( + , + ); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function selectorMatches(selectorList: string, selector: string): boolean { + return selectorList + .split(",") + .map((part) => part.trim()) + .some((part) => part === selector || part.startsWith(`${selector}:`)); +} + +function themedRuleBlocks(css: string, selector: string): string[] { + return [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter((match) => selectorMatches(match[1], selector)) + .map((match) => match[2]); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("WorkflowColumnPanel", () => { + beforeEach(() => { + vi.mocked(fetchTraits).mockResolvedValue(traitCatalog); + vi.mocked(fetchAgents).mockResolvedValue(agents); + }); + + it("renders populated column controls with the themed column-panel classes", async () => { + const { container } = renderPanel(); + + expect(screen.getByRole("button", { name: /Add column/i })).toHaveClass("wf-column-add"); + const triageRow = screen.getByTestId("wf-column-triage"); + const moveButtons = within(triageRow).getAllByRole("button", { name: /Move column (up|down)/i }); + expect(moveButtons).toHaveLength(2); + expect(moveButtons[0]).toHaveClass("wf-column-move"); + expect(within(triageRow).getByRole("button", { name: /Remove column/i })).toHaveClass("wf-column-remove"); + expect(screen.getByTestId("wf-column-agent-select-triage")).toHaveClass("wf-column-agent-select"); + expect(screen.getByTestId("wf-column-agent-badge-triage")).toHaveClass("wf-column-agent-badge"); + expect(container.querySelector(".wf-column-traits")).toBeTruthy(); + expect(container.querySelector(".wf-column-agent-mode-option")).toBeTruthy(); + + await waitFor(() => expect(fetchTraits).toHaveBeenCalled()); + await waitFor(() => expect(fetchAgents).toHaveBeenCalled()); + }); + + it("renders the themed empty-state class when no columns exist", () => { + renderPanel({ columns: [] }); + + expect(screen.getByText(/No columns yet/i)).toHaveClass("wf-column-panel-empty"); + }); + + it("keeps read-only controls visible with their themed classes and disabled state", () => { + renderPanel({ readOnly: true }); + + expect(screen.getByRole("button", { name: /Add column/i })).toHaveClass("wf-column-add"); + expect(screen.getByRole("button", { name: /Add column/i })).toBeDisabled(); + expect(screen.getAllByRole("button", { name: /Move column (up|down)/i })[0]).toHaveClass("wf-column-move"); + expect(screen.getAllByRole("button", { name: /Move column (up|down)/i })[0]).toBeDisabled(); + expect(screen.getAllByRole("button", { name: /Remove column/i })[0]).toHaveClass("wf-column-remove"); + expect(screen.getAllByRole("button", { name: /Remove column/i })[0]).toBeDisabled(); + expect(screen.getByTestId("wf-column-agent-select-triage")).toHaveClass("wf-column-agent-select"); + expect(screen.getByTestId("wf-column-agent-select-triage")).toBeDisabled(); + }); + + it("defines tokenized CSS rules for every column-panel selector themed by FN-6400", () => { + const css = readFileSync(resolve(__dirname, "../WorkflowNodeEditor.css"), "utf8"); + const selectors = [ + ".wf-column-add", + ".wf-column-move", + ".wf-column-remove", + ".wf-column-panel-empty", + ".wf-column-panel-errors", + ".wf-column-traits", + ".wf-column-agent", + ".wf-column-agent-label", + ".wf-column-agent-select", + ".wf-column-agent-badge", + ".wf-column-agent-badge--stale", + ".wf-column-agent-error", + ".wf-column-agent-stale", + ".wf-column-agent-mode", + ".wf-column-agent-mode-option", + ]; + + for (const selector of selectors) { + const blocks = themedRuleBlocks(css, selector); + expect(blocks.length, `${selector} should have a CSS rule`).toBeGreaterThan(0); + expect(blocks.some((block) => block.includes("var(--")), `${selector} should use design tokens`).toBe(true); + for (const block of blocks) { + expect(block, `${selector} should not use raw hex or rgba()`).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba\(/); + } + } + + expect(css.match(new RegExp(escapeRegExp(".wf-column-agent-select"), "g"))?.length ?? 0).toBeGreaterThan(0); + }); +});