FN-6400: theme workflow column controls

Theme the workflow editor column panel with tokenized controls and coverage.

- Add tokenized button, empty-state, trait, agent selector, badge, and mode styles for workflow columns.
- Preserve disabled, hover, focus, stale, and mobile touch target states across column controls.
- Add WorkflowColumnPanel tests for themed classes, read-only states, empty states, and CSS token usage.

Files changed:
 .../app/components/WorkflowNodeEditor.css          | 192 ++++++++++++++++++++-
 .../__tests__/WorkflowColumnPanel.test.tsx         | 142 +++++++++++++++
 2 files changed, 333 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-6400

Fusion-Task-Lineage: 278f0db8-ef41-4278-8db6-8b17e27d5d71
This commit is contained in:
gsxdsm
2026-06-13 15:36:46 -07:00
parent 80fbcdd5a3
commit fcb7444e0e
2 changed files with 333 additions and 1 deletions

View File

@@ -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);

View File

@@ -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<typeof WorkflowColumnPanel>["columns"];
readOnly?: boolean;
} = {}) {
return render(
<WorkflowColumnPanel
columns={columns}
onChange={vi.fn()}
violations={[]}
readOnly={readOnly}
addToast={vi.fn()}
columnAgentsEnabled
/>,
);
}
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);
});
});