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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user