>;
+}
+
+function ModelNodeConfig({ spec }: SpecConfigProps) {
+ const [showIntrospection, setShowIntrospection] = useState(false);
+
+ return (
+ <>
+
+
+
+
Read-only. Set in study configuration.
+
+
+
+
+
+
+
+ {spec.model.sim?.path && (
+
+ )}
+
+ {showIntrospection && spec.model.sim?.path && (
+
+ setShowIntrospection(false)}
+ />
+
+ )}
+ >
+ );
+}
+
+function SolverNodeConfig({ spec }: SpecConfigProps) {
+ return (
+
+
+
+
Detected from model file.
+
+ );
+}
+
+function AlgorithmNodeConfig({ spec }: SpecConfigProps) {
+ const algo = spec.optimization.algorithm;
+
+ return (
+ <>
+
+
+
+
Edit in optimization settings.
+
+
+
+
+
+
+ >
+ );
+}
+
+function SurrogateNodeConfig({ spec }: SpecConfigProps) {
+ const surrogate = spec.optimization.surrogate;
+
+ return (
+ <>
+
+
+
+
+
+ {surrogate?.enabled && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ Edit in optimization settings.
+ >
+ );
+}
+
+// ============================================================================
+// Editable node configs
+// ============================================================================
+
+interface DesignVarNodeConfigProps {
+ node: DesignVariable;
+ onChange: (field: string, value: unknown) => void;
+}
+
+function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) {
+ return (
+ <>
+
+
+ onChange('name', e.target.value)}
+ className={inputClass}
+ />
+
+
+
+
+ onChange('expression_name', e.target.value)}
+ placeholder="NX expression name"
+ className={`${inputClass} font-mono text-sm`}
+ />
+
+
+
+
+ {node.baseline !== undefined && (
+
+
+ onChange('baseline', parseFloat(e.target.value))}
+ className={inputClass}
+ />
+
+ )}
+
+
+
+ onChange('units', e.target.value)}
+ placeholder="mm"
+ className={inputClass}
+ />
+
+
+
+ onChange('enabled', e.target.checked)}
+ className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500"
+ />
+
+
+ >
+ );
+}
+
+interface ExtractorNodeConfigProps {
+ node: Extractor;
+ onChange: (field: string, value: unknown) => void;
+}
+
+function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
+ const extractorOptions = [
+ { id: 'E1', name: 'Displacement', type: 'displacement' },
+ { id: 'E2', name: 'Frequency', type: 'frequency' },
+ { id: 'E3', name: 'Solid Stress', type: 'solid_stress' },
+ { id: 'E4', name: 'BDF Mass', type: 'mass_bdf' },
+ { id: 'E5', name: 'CAD Mass', type: 'mass_expression' },
+ { id: 'E8', name: 'Zernike (OP2)', type: 'zernike_op2' },
+ { id: 'E9', name: 'Zernike (CSV)', type: 'zernike_csv' },
+ { id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' },
+ ];
+
+ return (
+ <>
+
+
+ onChange('name', e.target.value)}
+ className={inputClass}
+ />
+
+
+
+
+
+
+
+ {node.type === 'custom_function' && node.function && (
+
+
+
+
Edit custom code in dedicated editor.
+
+ )}
+
+
+
+
o.name).join(', ') || ''}
+ readOnly
+ placeholder="value, unit"
+ className={`${inputClass} bg-dark-900 cursor-not-allowed`}
+ />
+
Outputs are defined by extractor type.
+
+ >
+ );
+}
+
+interface ObjectiveNodeConfigProps {
+ node: Objective;
+ onChange: (field: string, value: unknown) => void;
+}
+
+function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
+ return (
+ <>
+
+
+ onChange('name', e.target.value)}
+ className={inputClass}
+ />
+
+
+
+
+
+
+
+
+
+ onChange('weight', parseFloat(e.target.value))}
+ className={inputClass}
+ />
+
+
+ {node.target !== undefined && (
+
+
+ onChange('target', parseFloat(e.target.value))}
+ className={inputClass}
+ />
+
+ )}
+ >
+ );
+}
+
+interface ConstraintNodeConfigProps {
+ node: Constraint;
+ onChange: (field: string, value: unknown) => void;
+}
+
+function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
+ return (
+ <>
+
+
+ onChange('name', e.target.value)}
+ className={inputClass}
+ />
+
+
+
+
+
+
+
+
+
+ onChange('threshold', parseFloat(e.target.value))}
+ className={inputClass}
+ />
+
+
+
+ >
+ );
+}
+
+export default NodeConfigPanelV2;
diff --git a/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts b/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts
index b182e92d..23acc648 100644
--- a/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts
+++ b/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts
@@ -1,3 +1,19 @@
+/**
+ * @deprecated This store is deprecated as of January 2026.
+ * Use useSpecStore instead, which works with AtomizerSpec v2.0.
+ *
+ * Migration guide:
+ * - Import useSpecStore from '../hooks/useSpecStore' instead
+ * - Use spec.design_variables, spec.extractors, etc. instead of nodes/edges
+ * - Use addNode(), updateNode(), removeNode() instead of canvas mutations
+ * - Spec changes sync automatically via WebSocket
+ *
+ * This store is kept for emergency fallback only with AtomizerCanvas.
+ *
+ * @see useSpecStore for the new state management
+ * @see AtomizerSpec v2.0 documentation
+ */
+
import { create } from 'zustand';
import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, Connection, NodeChange, EdgeChange } from 'reactflow';
import { CanvasNodeData, NodeType } from '../lib/canvas/schema';
diff --git a/atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts b/atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts
new file mode 100644
index 00000000..4e379a3f
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts
@@ -0,0 +1,209 @@
+/**
+ * useSpecStore Unit Tests
+ *
+ * Tests for the AtomizerSpec v2.0 state management store.
+ */
+
+///
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { useSpecStore } from './useSpecStore';
+import { createMockSpec, mockFetch } from '../test/utils';
+
+// Type for global context
+declare const global: typeof globalThis;
+
+describe('useSpecStore', () => {
+ beforeEach(() => {
+ // Reset the store state before each test
+ useSpecStore.setState({
+ spec: null,
+ studyId: null,
+ hash: null,
+ isLoading: false,
+ error: null,
+ validation: null,
+ selectedNodeId: null,
+ selectedEdgeId: null,
+ isDirty: false,
+ pendingChanges: [],
+ });
+ });
+
+ describe('initial state', () => {
+ it('should have null spec initially', () => {
+ const { spec } = useSpecStore.getState();
+ expect(spec).toBeNull();
+ });
+
+ it('should not be loading initially', () => {
+ const { isLoading } = useSpecStore.getState();
+ expect(isLoading).toBe(false);
+ });
+
+ it('should have no selected node initially', () => {
+ const { selectedNodeId } = useSpecStore.getState();
+ expect(selectedNodeId).toBeNull();
+ });
+ });
+
+ describe('selection', () => {
+ it('should select a node', () => {
+ const { selectNode } = useSpecStore.getState();
+ selectNode('dv_001');
+
+ const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
+ expect(selectedNodeId).toBe('dv_001');
+ expect(selectedEdgeId).toBeNull();
+ });
+
+ it('should select an edge', () => {
+ const { selectEdge } = useSpecStore.getState();
+ selectEdge('edge_1');
+
+ const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
+ expect(selectedEdgeId).toBe('edge_1');
+ expect(selectedNodeId).toBeNull();
+ });
+
+ it('should clear selection', () => {
+ const { selectNode, clearSelection } = useSpecStore.getState();
+ selectNode('dv_001');
+ clearSelection();
+
+ const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
+ expect(selectedNodeId).toBeNull();
+ expect(selectedEdgeId).toBeNull();
+ });
+
+ it('should clear edge when selecting node', () => {
+ const { selectEdge, selectNode } = useSpecStore.getState();
+ selectEdge('edge_1');
+ selectNode('dv_001');
+
+ const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
+ expect(selectedNodeId).toBe('dv_001');
+ expect(selectedEdgeId).toBeNull();
+ });
+ });
+
+ describe('setSpecFromWebSocket', () => {
+ it('should set spec directly', () => {
+ const mockSpec = createMockSpec({ meta: { study_name: 'ws_test' } });
+ const { setSpecFromWebSocket } = useSpecStore.getState();
+
+ setSpecFromWebSocket(mockSpec, 'test_study');
+
+ const { spec, studyId, isLoading, error } = useSpecStore.getState();
+ expect(spec?.meta.study_name).toBe('ws_test');
+ expect(studyId).toBe('test_study');
+ expect(isLoading).toBe(false);
+ expect(error).toBeNull();
+ });
+ });
+
+ describe('loadSpec', () => {
+ it('should set loading state', async () => {
+ mockFetch({
+ 'spec': createMockSpec(),
+ 'hash': { hash: 'abc123' },
+ });
+
+ const { loadSpec } = useSpecStore.getState();
+ const loadPromise = loadSpec('test_study');
+
+ // Should be loading immediately
+ expect(useSpecStore.getState().isLoading).toBe(true);
+
+ await loadPromise;
+
+ // Should no longer be loading
+ expect(useSpecStore.getState().isLoading).toBe(false);
+ });
+
+ it('should handle errors', async () => {
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
+
+ const { loadSpec } = useSpecStore.getState();
+
+ await loadSpec('test_study');
+
+ const { error, isLoading } = useSpecStore.getState();
+ expect(error).toContain('error');
+ expect(isLoading).toBe(false);
+ });
+ });
+
+ describe('getNodeById', () => {
+ beforeEach(() => {
+ const mockSpec = createMockSpec({
+ design_variables: [
+ { id: 'dv_001', name: 'thickness', expression_name: 't', type: 'continuous', bounds: { min: 1, max: 10 } },
+ { id: 'dv_002', name: 'width', expression_name: 'w', type: 'continuous', bounds: { min: 5, max: 20 } },
+ ],
+ extractors: [
+ { id: 'ext_001', name: 'displacement', type: 'displacement', outputs: ['d'] },
+ ],
+ objectives: [
+ { id: 'obj_001', name: 'mass', type: 'minimize', weight: 1.0 },
+ ],
+ });
+
+ useSpecStore.setState({ spec: mockSpec });
+ });
+
+ it('should find design variable by id', () => {
+ const { getNodeById } = useSpecStore.getState();
+ const node = getNodeById('dv_001');
+
+ expect(node).not.toBeNull();
+ expect((node as any).name).toBe('thickness');
+ });
+
+ it('should find extractor by id', () => {
+ const { getNodeById } = useSpecStore.getState();
+ const node = getNodeById('ext_001');
+
+ expect(node).not.toBeNull();
+ expect((node as any).name).toBe('displacement');
+ });
+
+ it('should find objective by id', () => {
+ const { getNodeById } = useSpecStore.getState();
+ const node = getNodeById('obj_001');
+
+ expect(node).not.toBeNull();
+ expect((node as any).name).toBe('mass');
+ });
+
+ it('should return null for unknown id', () => {
+ const { getNodeById } = useSpecStore.getState();
+ const node = getNodeById('unknown_999');
+
+ expect(node).toBeNull();
+ });
+ });
+
+ describe('clearSpec', () => {
+ it('should reset all state', () => {
+ // Set up some state
+ useSpecStore.setState({
+ spec: createMockSpec(),
+ studyId: 'test',
+ hash: 'abc',
+ selectedNodeId: 'dv_001',
+ isDirty: true,
+ });
+
+ const { clearSpec } = useSpecStore.getState();
+ clearSpec();
+
+ const state = useSpecStore.getState();
+ expect(state.spec).toBeNull();
+ expect(state.studyId).toBeNull();
+ expect(state.hash).toBeNull();
+ expect(state.selectedNodeId).toBeNull();
+ expect(state.isDirty).toBe(false);
+ });
+ });
+});
diff --git a/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts b/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts
new file mode 100644
index 00000000..1d150db8
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts
@@ -0,0 +1,742 @@
+/**
+ * useSpecStore - Zustand store for AtomizerSpec v2.0
+ *
+ * Central state management for the unified configuration system.
+ * All spec modifications flow through this store and sync with backend.
+ *
+ * Features:
+ * - Load spec from backend API
+ * - Optimistic updates with rollback on error
+ * - Patch operations via JSONPath
+ * - Node CRUD operations
+ * - Hash-based conflict detection
+ */
+
+import { create } from 'zustand';
+import { devtools, subscribeWithSelector } from 'zustand/middleware';
+import {
+ AtomizerSpec,
+ DesignVariable,
+ Extractor,
+ Objective,
+ Constraint,
+ CanvasPosition,
+ SpecValidationReport,
+ SpecModification,
+} from '../types/atomizer-spec';
+
+// API base URL
+const API_BASE = '/api';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface SpecStoreState {
+ // Spec data
+ spec: AtomizerSpec | null;
+ studyId: string | null;
+ hash: string | null;
+
+ // Loading state
+ isLoading: boolean;
+ error: string | null;
+
+ // Validation
+ validation: SpecValidationReport | null;
+
+ // Selection state (for canvas)
+ selectedNodeId: string | null;
+ selectedEdgeId: string | null;
+
+ // Dirty tracking
+ isDirty: boolean;
+ pendingChanges: SpecModification[];
+}
+
+interface SpecStoreActions {
+ // Loading
+ loadSpec: (studyId: string) => Promise;
+ reloadSpec: () => Promise;
+ clearSpec: () => void;
+
+ // WebSocket integration - set spec directly without API call
+ setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
+
+ // Full spec operations
+ saveSpec: (spec: AtomizerSpec) => Promise;
+ replaceSpec: (spec: AtomizerSpec) => Promise;
+
+ // Patch operations
+ patchSpec: (path: string, value: unknown) => Promise;
+ patchSpecOptimistic: (path: string, value: unknown) => void;
+
+ // Node operations
+ addNode: (
+ type: 'designVar' | 'extractor' | 'objective' | 'constraint',
+ data: Record
+ ) => Promise;
+ updateNode: (nodeId: string, updates: Record) => Promise;
+ removeNode: (nodeId: string) => Promise;
+ updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise;
+
+ // Edge operations
+ addEdge: (source: string, target: string) => Promise;
+ removeEdge: (source: string, target: string) => Promise;
+
+ // Custom function
+ addCustomFunction: (
+ name: string,
+ code: string,
+ outputs: string[],
+ description?: string
+ ) => Promise;
+
+ // Validation
+ validateSpec: () => Promise;
+
+ // Selection
+ selectNode: (nodeId: string | null) => void;
+ selectEdge: (edgeId: string | null) => void;
+ clearSelection: () => void;
+
+ // Utility
+ getNodeById: (nodeId: string) => DesignVariable | Extractor | Objective | Constraint | null;
+ setError: (error: string | null) => void;
+}
+
+type SpecStore = SpecStoreState & SpecStoreActions;
+
+// ============================================================================
+// API Functions
+// ============================================================================
+
+async function fetchSpec(studyId: string): Promise<{ spec: AtomizerSpec; hash: string }> {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec`);
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Failed to load spec' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ const spec = await response.json();
+
+ // Get hash
+ const hashResponse = await fetch(`${API_BASE}/studies/${studyId}/spec/hash`);
+ const { hash } = await hashResponse.json();
+
+ return { spec, hash };
+}
+
+async function patchSpecApi(
+ studyId: string,
+ path: string,
+ value: unknown
+): Promise<{ hash: string; modified: string }> {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path, value, modified_by: 'canvas' }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Patch failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ return response.json();
+}
+
+async function addNodeApi(
+ studyId: string,
+ type: string,
+ data: Record
+): Promise<{ node_id: string }> {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type, data, modified_by: 'canvas' }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Add node failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ return response.json();
+}
+
+async function updateNodeApi(
+ studyId: string,
+ nodeId: string,
+ updates: Record
+): Promise {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ updates, modified_by: 'canvas' }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Update node failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+}
+
+async function deleteNodeApi(studyId: string, nodeId: string): Promise {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}?modified_by=canvas`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Delete node failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+}
+
+async function addEdgeApi(studyId: string, source: string, target: string): Promise {
+ const response = await fetch(
+ `${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
+ { method: 'POST' }
+ );
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Add edge failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+}
+
+async function removeEdgeApi(studyId: string, source: string, target: string): Promise {
+ const response = await fetch(
+ `${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
+ { method: 'DELETE' }
+ );
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Remove edge failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+}
+
+async function addCustomFunctionApi(
+ studyId: string,
+ name: string,
+ code: string,
+ outputs: string[],
+ description?: string
+): Promise<{ node_id: string }> {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec/custom-functions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, code, outputs, description, modified_by: 'claude' }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Add custom function failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ return response.json();
+}
+
+async function validateSpecApi(studyId: string): Promise {
+ const response = await fetch(`${API_BASE}/studies/${studyId}/spec/validate`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Validation failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ return response.json();
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function applyPatchLocally(spec: AtomizerSpec, path: string, value: unknown): AtomizerSpec {
+ // Deep clone spec
+ const newSpec = JSON.parse(JSON.stringify(spec)) as AtomizerSpec;
+
+ // Parse path and apply value
+ const parts = path.split(/\.|\[|\]/).filter(Boolean);
+
+ let current: Record = newSpec as unknown as Record;
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i];
+ const index = parseInt(part, 10);
+ if (!isNaN(index)) {
+ current = (current as unknown as unknown[])[index] as Record;
+ } else {
+ current = current[part] as Record;
+ }
+ }
+
+ const finalKey = parts[parts.length - 1];
+ const index = parseInt(finalKey, 10);
+ if (!isNaN(index)) {
+ (current as unknown as unknown[])[index] = value;
+ } else {
+ current[finalKey] = value;
+ }
+
+ return newSpec;
+}
+
+function findNodeById(
+ spec: AtomizerSpec,
+ nodeId: string
+): DesignVariable | Extractor | Objective | Constraint | null {
+ // Check design variables
+ const dv = spec.design_variables.find((d) => d.id === nodeId);
+ if (dv) return dv;
+
+ // Check extractors
+ const ext = spec.extractors.find((e) => e.id === nodeId);
+ if (ext) return ext;
+
+ // Check objectives
+ const obj = spec.objectives.find((o) => o.id === nodeId);
+ if (obj) return obj;
+
+ // Check constraints
+ const con = spec.constraints?.find((c) => c.id === nodeId);
+ if (con) return con;
+
+ return null;
+}
+
+// ============================================================================
+// Store
+// ============================================================================
+
+export const useSpecStore = create()(
+ devtools(
+ subscribeWithSelector((set, get) => ({
+ // Initial state
+ spec: null,
+ studyId: null,
+ hash: null,
+ isLoading: false,
+ error: null,
+ validation: null,
+ selectedNodeId: null,
+ selectedEdgeId: null,
+ isDirty: false,
+ pendingChanges: [],
+
+ // =====================================================================
+ // Loading Actions
+ // =====================================================================
+
+ loadSpec: async (studyId: string) => {
+ set({ isLoading: true, error: null, studyId });
+
+ try {
+ const { spec, hash } = await fetchSpec(studyId);
+ set({
+ spec,
+ hash,
+ isLoading: false,
+ isDirty: false,
+ pendingChanges: [],
+ });
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Failed to load spec',
+ });
+ }
+ },
+
+ reloadSpec: async () => {
+ const { studyId } = get();
+ if (!studyId) return;
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const { spec, hash } = await fetchSpec(studyId);
+ set({
+ spec,
+ hash,
+ isLoading: false,
+ isDirty: false,
+ pendingChanges: [],
+ });
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Failed to reload spec',
+ });
+ }
+ },
+
+ clearSpec: () => {
+ set({
+ spec: null,
+ studyId: null,
+ hash: null,
+ isLoading: false,
+ error: null,
+ validation: null,
+ selectedNodeId: null,
+ selectedEdgeId: null,
+ isDirty: false,
+ pendingChanges: [],
+ });
+ },
+
+ // Set spec directly from WebSocket (no API call)
+ setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => {
+ const currentStudyId = studyId || get().studyId;
+ console.log('[useSpecStore] Setting spec from WebSocket:', spec.meta?.study_name);
+ set({
+ spec,
+ studyId: currentStudyId,
+ isLoading: false,
+ isDirty: false,
+ error: null,
+ });
+ },
+
+ // =====================================================================
+ // Full Spec Operations
+ // =====================================================================
+
+ saveSpec: async (spec: AtomizerSpec) => {
+ const { studyId, hash } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const response = await fetch(
+ `${API_BASE}/studies/${studyId}/spec?modified_by=canvas${hash ? `&expected_hash=${hash}` : ''}`,
+ {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(spec),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Save failed' }));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ const result = await response.json();
+ set({
+ spec,
+ hash: result.hash,
+ isLoading: false,
+ isDirty: false,
+ pendingChanges: [],
+ });
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Save failed',
+ });
+ throw error;
+ }
+ },
+
+ replaceSpec: async (spec: AtomizerSpec) => {
+ await get().saveSpec(spec);
+ },
+
+ // =====================================================================
+ // Patch Operations
+ // =====================================================================
+
+ patchSpec: async (path: string, value: unknown) => {
+ const { studyId, spec } = get();
+ if (!studyId || !spec) throw new Error('No study loaded');
+
+ // Optimistic update
+ const oldSpec = spec;
+ const newSpec = applyPatchLocally(spec, path, value);
+ set({ spec: newSpec, isDirty: true });
+
+ try {
+ const result = await patchSpecApi(studyId, path, value);
+ set({ hash: result.hash, isDirty: false });
+ } catch (error) {
+ // Rollback on error
+ set({ spec: oldSpec, isDirty: false });
+ const message = error instanceof Error ? error.message : 'Patch failed';
+ set({ error: message });
+ throw error;
+ }
+ },
+
+ patchSpecOptimistic: (path: string, value: unknown) => {
+ const { spec, studyId } = get();
+ if (!spec) return;
+
+ // Apply locally immediately
+ const newSpec = applyPatchLocally(spec, path, value);
+ set({
+ spec: newSpec,
+ isDirty: true,
+ pendingChanges: [...get().pendingChanges, { operation: 'set', path, value }],
+ });
+
+ // Sync with backend (fire and forget, but handle errors)
+ if (studyId) {
+ patchSpecApi(studyId, path, value)
+ .then((result) => {
+ set({ hash: result.hash });
+ // Remove from pending
+ set({
+ pendingChanges: get().pendingChanges.filter(
+ (c) => !(c.path === path && c.value === value)
+ ),
+ });
+ })
+ .catch((error) => {
+ console.error('Patch sync failed:', error);
+ set({ error: error.message });
+ });
+ }
+ },
+
+ // =====================================================================
+ // Node Operations
+ // =====================================================================
+
+ addNode: async (type, data) => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const result = await addNodeApi(studyId, type, data);
+
+ // Reload spec to get new state
+ await get().reloadSpec();
+
+ return result.node_id;
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Add node failed',
+ });
+ throw error;
+ }
+ },
+
+ updateNode: async (nodeId, updates) => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ try {
+ await updateNodeApi(studyId, nodeId, updates);
+ await get().reloadSpec();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Update failed';
+ set({ error: message });
+ throw error;
+ }
+ },
+
+ removeNode: async (nodeId) => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ set({ isLoading: true, error: null });
+
+ try {
+ await deleteNodeApi(studyId, nodeId);
+ await get().reloadSpec();
+
+ // Clear selection if deleted node was selected
+ if (get().selectedNodeId === nodeId) {
+ set({ selectedNodeId: null });
+ }
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Delete failed',
+ });
+ throw error;
+ }
+ },
+
+ updateNodePosition: async (nodeId, position) => {
+ const { studyId, spec } = get();
+ if (!studyId || !spec) return;
+
+ // Find the node type and index
+ let path: string | null = null;
+
+ const dvIndex = spec.design_variables.findIndex((d) => d.id === nodeId);
+ if (dvIndex >= 0) {
+ path = `design_variables[${dvIndex}].canvas_position`;
+ }
+
+ if (!path) {
+ const extIndex = spec.extractors.findIndex((e) => e.id === nodeId);
+ if (extIndex >= 0) {
+ path = `extractors[${extIndex}].canvas_position`;
+ }
+ }
+
+ if (!path) {
+ const objIndex = spec.objectives.findIndex((o) => o.id === nodeId);
+ if (objIndex >= 0) {
+ path = `objectives[${objIndex}].canvas_position`;
+ }
+ }
+
+ if (!path && spec.constraints) {
+ const conIndex = spec.constraints.findIndex((c) => c.id === nodeId);
+ if (conIndex >= 0) {
+ path = `constraints[${conIndex}].canvas_position`;
+ }
+ }
+
+ if (path) {
+ // Use optimistic update for smooth dragging
+ get().patchSpecOptimistic(path, position);
+ }
+ },
+
+ // =====================================================================
+ // Edge Operations
+ // =====================================================================
+
+ addEdge: async (source, target) => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ try {
+ await addEdgeApi(studyId, source, target);
+ await get().reloadSpec();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Add edge failed';
+ set({ error: message });
+ throw error;
+ }
+ },
+
+ removeEdge: async (source, target) => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ try {
+ await removeEdgeApi(studyId, source, target);
+ await get().reloadSpec();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Remove edge failed';
+ set({ error: message });
+ throw error;
+ }
+ },
+
+ // =====================================================================
+ // Custom Function
+ // =====================================================================
+
+ addCustomFunction: async (name, code, outputs, description) => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const result = await addCustomFunctionApi(studyId, name, code, outputs, description);
+ await get().reloadSpec();
+ return result.node_id;
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Add custom function failed',
+ });
+ throw error;
+ }
+ },
+
+ // =====================================================================
+ // Validation
+ // =====================================================================
+
+ validateSpec: async () => {
+ const { studyId } = get();
+ if (!studyId) throw new Error('No study loaded');
+
+ try {
+ const validation = await validateSpecApi(studyId);
+ set({ validation });
+ return validation;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Validation failed';
+ set({ error: message });
+ throw error;
+ }
+ },
+
+ // =====================================================================
+ // Selection
+ // =====================================================================
+
+ selectNode: (nodeId) => {
+ set({ selectedNodeId: nodeId, selectedEdgeId: null });
+ },
+
+ selectEdge: (edgeId) => {
+ set({ selectedEdgeId: edgeId, selectedNodeId: null });
+ },
+
+ clearSelection: () => {
+ set({ selectedNodeId: null, selectedEdgeId: null });
+ },
+
+ // =====================================================================
+ // Utility
+ // =====================================================================
+
+ getNodeById: (nodeId) => {
+ const { spec } = get();
+ if (!spec) return null;
+ return findNodeById(spec, nodeId);
+ },
+
+ setError: (error) => {
+ set({ error });
+ },
+ })),
+ { name: 'spec-store' }
+ )
+);
+
+// ============================================================================
+// Selector Hooks
+// ============================================================================
+
+export const useSpec = () => useSpecStore((state) => state.spec);
+export const useSpecLoading = () => useSpecStore((state) => state.isLoading);
+export const useSpecError = () => useSpecStore((state) => state.error);
+export const useSpecValidation = () => useSpecStore((state) => state.validation);
+export const useSelectedNodeId = () => useSpecStore((state) => state.selectedNodeId);
+export const useSelectedEdgeId = () => useSpecStore((state) => state.selectedEdgeId);
+export const useSpecHash = () => useSpecStore((state) => state.hash);
+export const useSpecIsDirty = () => useSpecStore((state) => state.isDirty);
+
+// Computed selectors
+export const useDesignVariables = () =>
+ useSpecStore((state) => state.spec?.design_variables ?? []);
+export const useExtractors = () => useSpecStore((state) => state.spec?.extractors ?? []);
+export const useObjectives = () => useSpecStore((state) => state.spec?.objectives ?? []);
+export const useConstraints = () => useSpecStore((state) => state.spec?.constraints ?? []);
+export const useCanvasEdges = () => useSpecStore((state) => state.spec?.canvas?.edges ?? []);
+
+export const useSelectedNode = () =>
+ useSpecStore((state) => {
+ if (!state.spec || !state.selectedNodeId) return null;
+ return findNodeById(state.spec, state.selectedNodeId);
+ });
diff --git a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx
index 289f099f..a10d1e5e 100644
--- a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx
+++ b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx
@@ -1,42 +1,236 @@
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight } from 'lucide-react';
+import { useState, useEffect, useCallback } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal } from 'lucide-react';
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
+import { SpecRenderer } from '../components/canvas/SpecRenderer';
+import { NodePalette } from '../components/canvas/palette/NodePalette';
+import { FileStructurePanel } from '../components/canvas/panels/FileStructurePanel';
import { TemplateSelector } from '../components/canvas/panels/TemplateSelector';
import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
+import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel';
+import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
+import { ChatPanel } from '../components/canvas/panels/ChatPanel';
import { useCanvasStore } from '../hooks/useCanvasStore';
+import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
import { useStudy } from '../context/StudyContext';
+import { useChat } from '../hooks/useChat';
import { CanvasTemplate } from '../lib/canvas/templates';
export function CanvasView() {
const [showTemplates, setShowTemplates] = useState(false);
const [showImporter, setShowImporter] = useState(false);
+ const [showChat, setShowChat] = useState(true);
+ const [chatPowerMode, setChatPowerMode] = useState(false);
const [notification, setNotification] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [paletteCollapsed, setPaletteCollapsed] = useState(false);
+ const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
- const { nodes, edges, clear } = useCanvasStore();
- const { selectedStudy } = useStudy();
+ // Spec mode is the default (AtomizerSpec v2.0)
+ // Legacy mode can be enabled via:
+ // 1. VITE_USE_LEGACY_CANVAS=true environment variable
+ // 2. ?mode=legacy query param (for emergency fallback)
+ const legacyEnvEnabled = import.meta.env.VITE_USE_LEGACY_CANVAS === 'true';
+ const legacyQueryParam = searchParams.get('mode') === 'legacy';
+ const useSpecMode = !legacyEnvEnabled && !legacyQueryParam;
+
+ // Get study ID from URL params (supports nested paths like M1_Mirror/study_name)
+ const { '*': urlStudyId } = useParams<{ '*': string }>();
+
+ // Legacy canvas store (for backwards compatibility)
+ const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore();
+
+ // New spec store (AtomizerSpec v2.0)
+ const spec = useSpec();
+ const specLoading = useSpecLoading();
+ const specIsDirty = useSpecIsDirty();
+ const selectedNodeId = useSelectedNodeId();
+ const { loadSpec, saveSpec, reloadSpec } = useSpecStore();
+
+ const { setSelectedStudy, studies } = useStudy();
+ const { clearSpec, setSpecFromWebSocket } = useSpecStore();
+
+ // Active study ID comes ONLY from URL - don't auto-load from context
+ // This ensures /canvas shows empty canvas, /canvas/{id} shows the study
+ const activeStudyId = urlStudyId;
+
+ // Chat hook for assistant panel
+ const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({
+ studyId: activeStudyId,
+ mode: chatPowerMode ? 'power' : 'user',
+ useWebSocket: true,
+ onCanvasModification: chatPowerMode ? (modification) => {
+ // Handle canvas modifications from Claude in power mode (legacy)
+ console.log('Canvas modification from Claude:', modification);
+ showNotification(`Claude: ${modification.action} ${modification.nodeType || modification.nodeId || ''}`);
+ // The actual modification is handled by the MCP tools on the backend
+ // which update atomizer_spec.json, then the canvas reloads via WebSocket
+ reloadSpec();
+ } : undefined,
+ onSpecUpdated: useSpecMode ? (newSpec) => {
+ // Direct spec update from Claude via WebSocket - no HTTP reload needed
+ console.log('Spec updated from Claude via WebSocket:', newSpec.meta?.study_name);
+ setSpecFromWebSocket(newSpec, activeStudyId);
+ showNotification('Canvas synced with Claude');
+ } : undefined,
+ });
+
+ // Load or clear spec based on URL study ID
+ useEffect(() => {
+ if (urlStudyId) {
+ if (useSpecMode) {
+ // Try to load spec first, fall back to legacy config
+ loadSpec(urlStudyId).catch(() => {
+ // If spec doesn't exist, try legacy config
+ loadStudyConfig(urlStudyId);
+ });
+ } else {
+ loadStudyConfig(urlStudyId);
+ }
+ } else {
+ // No study ID in URL - clear spec for empty canvas (new study creation)
+ if (useSpecMode) {
+ clearSpec();
+ } else {
+ clear();
+ }
+ }
+ }, [urlStudyId, useSpecMode]);
+
+ // Notify Claude when user edits the spec (bi-directional sync)
+ // This sends the updated spec to Claude so it knows what the user changed
+ useEffect(() => {
+ if (useSpecMode && spec && specIsDirty && chatPowerMode) {
+ // User made changes - notify Claude via WebSocket
+ notifyCanvasEdit(spec);
+ }
+ }, [spec, specIsDirty, useSpecMode, chatPowerMode, notifyCanvasEdit]);
+
+ // Track unsaved changes (legacy mode only)
+ useEffect(() => {
+ if (!useSpecMode && activeStudyId && nodes.length > 0) {
+ setHasUnsavedChanges(true);
+ }
+ }, [nodes, edges, useSpecMode]);
+
+ const loadStudyConfig = async (studyId: string) => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`);
+ if (!response.ok) {
+ throw new Error(`Failed to load study: ${response.status}`);
+ }
+ const data = await response.json();
+ loadFromConfig(data.config);
+ setHasUnsavedChanges(false);
+
+ // Also select the study in context
+ const study = studies.find(s => s.id === studyId);
+ if (study) {
+ setSelectedStudy(study);
+ }
+
+ showNotification(`Loaded: ${studyId}`);
+ } catch (error) {
+ console.error('Failed to load study config:', error);
+ showNotification('Failed to load study config');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const saveToConfig = async () => {
+ if (!activeStudyId) {
+ showNotification('No study selected to save to');
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ if (useSpecMode && spec) {
+ // Save spec using new API
+ await saveSpec(spec);
+ showNotification('Saved to atomizer_spec.json');
+ } else {
+ // Legacy save
+ const intent = toIntent();
+
+ const response = await fetch(`/api/optimization/studies/${encodeURIComponent(activeStudyId)}/config`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ intent }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Failed to save');
+ }
+
+ setHasUnsavedChanges(false);
+ showNotification('Saved to optimization_config.json');
+ }
+ } catch (error) {
+ console.error('Failed to save:', error);
+ showNotification('Failed to save: ' + (error instanceof Error ? error.message : 'Unknown error'));
+ } finally {
+ setIsSaving(false);
+ }
+ };
const handleTemplateSelect = (template: CanvasTemplate) => {
+ setHasUnsavedChanges(true);
showNotification(`Loaded template: ${template.name}`);
};
const handleImport = (source: string) => {
+ setHasUnsavedChanges(true);
showNotification(`Imported from ${source}`);
};
const handleClear = () => {
+ if (useSpecMode) {
+ // In spec mode, clearing is not typically needed since changes sync automatically
+ showNotification('Use Reload to reset to saved state');
+ return;
+ }
+
if (nodes.length === 0 || window.confirm('Clear all nodes from the canvas?')) {
clear();
+ setHasUnsavedChanges(true);
showNotification('Canvas cleared');
}
};
+ const handleReload = () => {
+ if (activeStudyId) {
+ const hasChanges = useSpecMode ? specIsDirty : hasUnsavedChanges;
+ if (hasChanges && !window.confirm('Reload will discard unsaved changes. Continue?')) {
+ return;
+ }
+
+ if (useSpecMode) {
+ reloadSpec();
+ showNotification('Reloaded from atomizer_spec.json');
+ } else {
+ loadStudyConfig(activeStudyId);
+ }
+ }
+ };
+
const showNotification = (message: string) => {
setNotification(message);
setTimeout(() => setNotification(null), 3000);
};
+ // Navigate to canvas with study ID
+ const navigateToStudy = useCallback((studyId: string) => {
+ navigate(`/canvas/${studyId}`);
+ }, [navigate]);
+
return (
{/* Minimal Header */}
@@ -55,24 +249,75 @@ export function CanvasView() {
Canvas Builder
- {selectedStudy && (
+ {activeStudyId && (
<>
- {selectedStudy.name || selectedStudy.id}
+ {activeStudyId}
+ {hasUnsavedChanges && (
+ •
+ )}
>
)}
{/* Stats */}
-
- {nodes.length} node{nodes.length !== 1 ? 's' : ''} • {edges.length} edge{edges.length !== 1 ? 's' : ''}
-
+ {useSpecMode && spec ? (
+
+ {spec.design_variables.length} vars • {spec.extractors.length} ext • {spec.objectives.length} obj
+
+ ) : (
+
+ {nodes.length} node{nodes.length !== 1 ? 's' : ''} • {edges.length} edge{edges.length !== 1 ? 's' : ''}
+
+ )}
+
+ {/* Mode indicator */}
+ {useSpecMode && (
+
+
+ v2.0
+
+ )}
+
+ {(isLoading || specLoading) && (
+
+ )}
{/* Action Buttons */}
+ {/* Save Button - only show when there's a study and changes */}
+ {activeStudyId && (
+
+ )}
+
+ {/* Reload Button */}
+ {activeStudyId && (
+
+ )}
+
+
+ {/* Divider */}
+
+
+ {/* Chat Toggle */}
+
{/* Main Canvas */}
-
-
+
+ {/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
+ {useSpecMode && (
+
+ {/* Tab buttons (only show when expanded) */}
+ {!paletteCollapsed && (
+
+
+
+
+ )}
+
+ {/* Tab content */}
+
+ {leftSidebarTab === 'components' || paletteCollapsed ? (
+ setPaletteCollapsed(!paletteCollapsed)}
+ showToggle={true}
+ />
+ ) : (
+ {
+ // TODO: Update model path in spec
+ showNotification(`Selected: ${path.split(/[/\\]/).pop()}`);
+ }}
+ />
+ )}
+
+
+ )}
+
+ {/* Canvas area - must have explicit height for ReactFlow */}
+
+ {useSpecMode ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */}
+ {selectedNodeId && !showChat && (
+ useSpecMode ? (
+ useSpecStore.getState().clearSelection()} />
+ ) : (
+
+
+
+ )
+ )}
+
+ {/* Chat/Assistant Panel */}
+ {showChat && (
+
+ {/* Chat Header */}
+
+
+
+ Assistant
+ {isConnected && (
+
+ )}
+
+
+ {/* Power Mode Toggle */}
+
+
+
+
+ {/* Chat Content */}
+
+
+ )}
{/* Template Selector Modal */}
diff --git a/atomizer-dashboard/frontend/src/test/setup.ts b/atomizer-dashboard/frontend/src/test/setup.ts
new file mode 100644
index 00000000..ccd72fcf
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/test/setup.ts
@@ -0,0 +1,137 @@
+/**
+ * Vitest Test Setup
+ *
+ * This file runs before each test file to set up the testing environment.
+ */
+
+///
+
+import '@testing-library/jest-dom';
+import { vi, beforeAll, afterAll, afterEach } from 'vitest';
+
+// Type for global context
+declare const global: typeof globalThis;
+
+// ============================================================================
+// Mock Browser APIs
+// ============================================================================
+
+// Mock ResizeObserver (used by ReactFlow)
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
+// Mock IntersectionObserver
+global.IntersectionObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
+// Mock matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+// Mock scrollTo
+Element.prototype.scrollTo = vi.fn();
+window.scrollTo = vi.fn();
+
+// Mock fetch for API calls
+global.fetch = vi.fn();
+
+// ============================================================================
+// Mock localStorage
+// ============================================================================
+
+const localStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+ length: 0,
+ key: vi.fn(),
+};
+Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+
+// ============================================================================
+// Mock WebSocket
+// ============================================================================
+
+class MockWebSocket {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSING = 2;
+ static readonly CLOSED = 3;
+
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSING = 2;
+ readonly CLOSED = 3;
+
+ url: string;
+ readyState: number = MockWebSocket.CONNECTING;
+ onopen: ((event: Event) => void) | null = null;
+ onclose: ((event: CloseEvent) => void) | null = null;
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: ((event: Event) => void) | null = null;
+
+ constructor(url: string) {
+ this.url = url;
+ // Simulate connection after a tick
+ setTimeout(() => {
+ this.readyState = MockWebSocket.OPEN;
+ this.onopen?.(new Event('open'));
+ }, 0);
+ }
+
+ send = vi.fn();
+ close = vi.fn(() => {
+ this.readyState = MockWebSocket.CLOSED;
+ this.onclose?.(new CloseEvent('close'));
+ });
+}
+
+global.WebSocket = MockWebSocket as any;
+
+// ============================================================================
+// Console Suppression (optional)
+// ============================================================================
+
+// Suppress console.error for expected test warnings
+const originalError = console.error;
+beforeAll(() => {
+ console.error = (...args: any[]) => {
+ // Suppress React act() warnings
+ if (typeof args[0] === 'string' && args[0].includes('Warning: An update to')) {
+ return;
+ }
+ originalError.call(console, ...args);
+ };
+});
+
+afterAll(() => {
+ console.error = originalError;
+});
+
+// ============================================================================
+// Cleanup
+// ============================================================================
+
+afterEach(() => {
+ vi.clearAllMocks();
+ localStorageMock.getItem.mockReset();
+ localStorageMock.setItem.mockReset();
+});
diff --git a/atomizer-dashboard/frontend/src/test/utils.tsx b/atomizer-dashboard/frontend/src/test/utils.tsx
new file mode 100644
index 00000000..3092a5b1
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/test/utils.tsx
@@ -0,0 +1,142 @@
+/**
+ * Test Utilities
+ *
+ * Provides custom render function with all necessary providers.
+ */
+
+///
+
+import { ReactElement, ReactNode } from 'react';
+import { render, RenderOptions } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import { StudyProvider } from '../context/StudyContext';
+
+// Type for global context
+declare const global: typeof globalThis;
+
+/**
+ * All providers needed for testing components
+ */
+function AllProviders({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Custom render function that wraps component with all providers
+ */
+const customRender = (
+ ui: ReactElement,
+ options?: Omit
+) => render(ui, { wrapper: AllProviders, ...options });
+
+// Re-export everything from RTL
+export * from '@testing-library/react';
+export { userEvent } from '@testing-library/user-event';
+
+// Override render with our custom one
+export { customRender as render };
+
+/**
+ * Create a mock AtomizerSpec for testing
+ */
+export function createMockSpec(overrides: Partial = {}): any {
+ return {
+ meta: {
+ version: '2.0',
+ study_name: 'test_study',
+ created_by: 'test',
+ created_at: new Date().toISOString(),
+ ...overrides.meta,
+ },
+ model: {
+ sim: {
+ path: 'model.sim',
+ solver: 'nastran',
+ solution_type: 'SOL101',
+ },
+ ...overrides.model,
+ },
+ design_variables: overrides.design_variables ?? [
+ {
+ id: 'dv_001',
+ name: 'thickness',
+ expression_name: 'wall_thickness',
+ type: 'continuous',
+ bounds: { min: 1, max: 10 },
+ baseline: 5,
+ enabled: true,
+ },
+ ],
+ extractors: overrides.extractors ?? [
+ {
+ id: 'ext_001',
+ name: 'displacement',
+ type: 'displacement',
+ outputs: ['max_disp'],
+ enabled: true,
+ },
+ ],
+ objectives: overrides.objectives ?? [
+ {
+ id: 'obj_001',
+ name: 'minimize_mass',
+ type: 'minimize',
+ source: { extractor_id: 'ext_001', output: 'max_disp' },
+ weight: 1.0,
+ enabled: true,
+ },
+ ],
+ constraints: overrides.constraints ?? [],
+ optimization: {
+ algorithm: { type: 'TPE' },
+ budget: { max_trials: 100 },
+ ...overrides.optimization,
+ },
+ canvas: {
+ edges: [],
+ layout_version: '2.0',
+ ...overrides.canvas,
+ },
+ };
+}
+
+/**
+ * Create a mock API response
+ */
+export function mockFetch(responses: Record) {
+ return (global.fetch as any).mockImplementation((url: string, options?: RequestInit) => {
+ const method = options?.method || 'GET';
+ const key = `${method} ${url}`;
+
+ // Find matching response
+ for (const [pattern, response] of Object.entries(responses)) {
+ if (key.includes(pattern) || url.includes(pattern)) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(response),
+ text: () => Promise.resolve(JSON.stringify(response)),
+ });
+ }
+ }
+
+ // Default 404
+ return Promise.resolve({
+ ok: false,
+ status: 404,
+ json: () => Promise.resolve({ detail: 'Not found' }),
+ });
+ });
+}
+
+/**
+ * Wait for async state updates
+ */
+export async function waitForStateUpdate() {
+ await new Promise(resolve => setTimeout(resolve, 0));
+}
diff --git a/atomizer-dashboard/frontend/vitest.config.ts b/atomizer-dashboard/frontend/vitest.config.ts
new file mode 100644
index 00000000..5e2a1a44
--- /dev/null
+++ b/atomizer-dashboard/frontend/vitest.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ exclude: ['node_modules', 'dist', 'tests/e2e'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'html', 'lcov'],
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'src/test/**',
+ 'src/**/*.d.ts',
+ 'src/vite-env.d.ts',
+ 'src/main.tsx',
+ ],
+ },
+ // Mock CSS imports
+ css: false,
+ },
+ resolve: {
+ alias: {
+ '@': '/src',
+ },
+ },
+});