Files
Atomizer/docs/plans/RALPH_LOOP_CANVAS_V2.md
Anto01 ac5e9b4054 docs: Comprehensive documentation update for Dashboard V3 and Canvas
## Documentation Updates
- DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection
- DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE
- CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features
- CLAUDE.md: Added dashboard quick reference and Canvas V3 features

## Canvas V3 Features Documented
- File Browser: Browse studies directory for model files
- Model Introspection: Auto-discover expressions, solver type, dependencies
- One-Click Add: Add expressions as design variables instantly
- Claude Bug Fixes: WebSocket reconnection, SQL errors resolved
- Health Check: /api/health endpoint for monitoring

## Backend Services
- NX introspection service with expression discovery
- File browser API with type filtering
- Claude session management improvements
- Context builder enhancements

## Frontend Components
- FileBrowser: Modal for file selection with search
- IntrospectionPanel: View discovered model information
- ExpressionSelector: Dropdown for design variable configuration
- Improved chat hooks with reconnection logic

## Plan Documents
- Added RALPH_LOOP_CANVAS_V2/V3 implementation records
- Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN
- Added investigation and sync documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:48:58 -05:00

75 KiB

Ralph Loop: Canvas Professional Upgrade V2

Purpose: Complete Canvas overhaul with Claude integration, auto-loading, and professional UI Execution: Autonomous, all phases sequential, no stopping


Launch Command

cd C:\Users\antoi\Atomizer
claude --dangerously-skip-permissions

Paste everything below the line.


You are executing a multi-phase autonomous development session to upgrade the Atomizer Canvas to production quality.

Mission Summary

Transform the Canvas from a prototype into a fully functional optimization workflow builder with:

  1. Professional Lucide icons (no emojis)
  2. Working Claude CLI integration
  3. Auto-load from optimization_config.json
  4. NX model introspection (.sim → expressions)
  5. Expression search/dropdown for design variables
  6. Responsive full-screen canvas
  7. "Process with Claude" button using Atomizer protocols
  8. Complete MCP tool implementation

Environment

Working Directory: C:/Users/antoi/Atomizer
Frontend: atomizer-dashboard/frontend/
Backend: atomizer-dashboard/backend/
MCP Server: mcp-server/atomizer-tools/
Python: C:/Users/antoi/anaconda3/envs/atomizer/python.exe
Node: Available in PATH
Git: Push to origin AND github

Execution Rules

  1. TodoWrite - Track every task, mark complete immediately
  2. Sequential phases - Complete each phase fully before next
  3. Test builds - Run npm run build after each phase
  4. No questions - Use provided code, make reasonable decisions
  5. Commit per phase - Git commit after each major phase

PHASE 1: Professional Icons (Replace All Emojis)

Replace ALL emoji icons with Lucide React icons across the Canvas.

T1.1 - Update NodePalette.tsx

File: atomizer-dashboard/frontend/src/components/canvas/palette/NodePalette.tsx

import { DragEvent } from 'react';
import { NodeType } from '../../../lib/canvas/schema';
import {
  Cube,
  Cpu,
  SlidersHorizontal,
  FlaskConical,
  Target,
  ShieldAlert,
  BrainCircuit,
  Rocket,
} from 'lucide-react';

interface PaletteItem {
  type: NodeType;
  label: string;
  icon: React.ReactNode;
  description: string;
  color: string;
}

const PALETTE_ITEMS: PaletteItem[] = [
  { type: 'model', label: 'Model', icon: <Cube size={18} />, description: 'NX model file (.prt, .sim)', color: 'text-blue-400' },
  { type: 'solver', label: 'Solver', icon: <Cpu size={18} />, description: 'Nastran solution type', color: 'text-violet-400' },
  { type: 'designVar', label: 'Design Variable', icon: <SlidersHorizontal size={18} />, description: 'Parameter to optimize', color: 'text-emerald-400' },
  { type: 'extractor', label: 'Extractor', icon: <FlaskConical size={18} />, description: 'Physics result extraction', color: 'text-cyan-400' },
  { type: 'objective', label: 'Objective', icon: <Target size={18} />, description: 'Optimization goal', color: 'text-rose-400' },
  { type: 'constraint', label: 'Constraint', icon: <ShieldAlert size={18} />, description: 'Design constraint', color: 'text-amber-400' },
  { type: 'algorithm', label: 'Algorithm', icon: <BrainCircuit size={18} />, description: 'Optimization method', color: 'text-indigo-400' },
  { type: 'surrogate', label: 'Surrogate', icon: <Rocket size={18} />, description: 'Neural acceleration', color: 'text-pink-400' },
];

export function NodePalette() {
  const onDragStart = (event: DragEvent, nodeType: NodeType) => {
    event.dataTransfer.setData('application/reactflow', nodeType);
    event.dataTransfer.effectAllowed = 'move';
  };

  return (
    <div className="w-56 bg-dark-850 border-r border-dark-700 flex flex-col">
      <div className="p-4 border-b border-dark-700">
        <h3 className="text-xs font-semibold text-dark-400 uppercase tracking-wider">
          Components
        </h3>
        <p className="text-xs text-dark-500 mt-1">
          Drag to canvas
        </p>
      </div>
      <div className="flex-1 overflow-y-auto p-3 space-y-1.5">
        {PALETTE_ITEMS.map((item) => (
          <div
            key={item.type}
            draggable
            onDragStart={(e) => onDragStart(e, item.type)}
            className="flex items-center gap-2.5 px-3 py-2.5 bg-dark-800/50 rounded-lg border border-dark-700/50
                       cursor-grab hover:border-primary-500/50 hover:bg-dark-800
                       active:cursor-grabbing transition-all group"
          >
            <div className={`${item.color} opacity-80 group-hover:opacity-100 transition-opacity`}>
              {item.icon}
            </div>
            <div className="flex-1 min-w-0">
              <div className="font-medium text-dark-200 text-sm leading-tight">{item.label}</div>
              <div className="text-[10px] text-dark-500 truncate">{item.description}</div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

T1.2 - Update BaseNode.tsx with Lucide Icons

File: atomizer-dashboard/frontend/src/components/canvas/nodes/BaseNode.tsx

import { memo, ReactNode } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import { AlertCircle } from 'lucide-react';
import { BaseNodeData } from '../../../lib/canvas/schema';

interface BaseNodeProps extends NodeProps<BaseNodeData> {
  icon: ReactNode;
  iconColor: string;
  children?: ReactNode;
  inputs?: number;
  outputs?: number;
}

function BaseNodeComponent({
  data,
  selected,
  icon,
  iconColor,
  children,
  inputs = 1,
  outputs = 1,
}: BaseNodeProps) {
  const hasErrors = data.errors && data.errors.length > 0;

  return (
    <div
      className={`
        relative px-3 py-2.5 rounded-lg border min-w-[160px] max-w-[200px]
        bg-dark-850 shadow-lg transition-all duration-150
        ${selected ? 'border-primary-400 ring-2 ring-primary-400/20' : 'border-dark-600'}
        ${!data.configured ? 'border-dashed border-dark-500' : ''}
        ${hasErrors ? 'border-red-500/70' : ''}
      `}
    >
      {inputs > 0 && (
        <Handle
          type="target"
          position={Position.Left}
          className="!w-2.5 !h-2.5 !bg-dark-500 !border-2 !border-dark-700 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
        />
      )}

      <div className="flex items-center gap-2">
        <div className={`${iconColor} flex-shrink-0`}>
          {icon}
        </div>
        <div className="flex-1 min-w-0">
          <div className="font-medium text-white text-sm truncate">{data.label}</div>
        </div>
        {!data.configured && (
          <div className="w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
        )}
      </div>

      {children && (
        <div className="mt-1.5 text-xs text-dark-400 truncate">
          {children}
        </div>
      )}

      {hasErrors && (
        <div className="mt-1.5 flex items-center gap-1 text-xs text-red-400">
          <AlertCircle size={10} />
          <span className="truncate">{data.errors![0]}</span>
        </div>
      )}

      {outputs > 0 && (
        <Handle
          type="source"
          position={Position.Right}
          className="!w-2.5 !h-2.5 !bg-dark-500 !border-2 !border-dark-700 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
        />
      )}
    </div>
  );
}

export const BaseNode = memo(BaseNodeComponent);

T1.3 - Update All Node Components

Update each node file to use Lucide icons:

ModelNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Cube } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ModelNodeData } from '../../../lib/canvas/schema';

function ModelNodeComponent(props: NodeProps<ModelNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<Cube size={16} />} iconColor="text-blue-400" inputs={0}>
      {data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected'}
    </BaseNode>
  );
}
export const ModelNode = memo(ModelNodeComponent);

SolverNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Cpu } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { SolverNodeData } from '../../../lib/canvas/schema';

function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
      {data.solverType || 'Select solution'}
    </BaseNode>
  );
}
export const SolverNode = memo(SolverNodeComponent);

DesignVarNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { SlidersHorizontal } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { DesignVarNodeData } from '../../../lib/canvas/schema';

function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400">
      {data.expressionName ? (
        <span className="font-mono">{data.expressionName}</span>
      ) : (
        'Select expression'
      )}
    </BaseNode>
  );
}
export const DesignVarNode = memo(DesignVarNodeComponent);

ExtractorNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { FlaskConical } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ExtractorNodeData } from '../../../lib/canvas/schema';

function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
      {data.extractorName || 'Select extractor'}
    </BaseNode>
  );
}
export const ExtractorNode = memo(ExtractorNodeComponent);

ObjectiveNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Target } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ObjectiveNodeData } from '../../../lib/canvas/schema';

function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
      {data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
    </BaseNode>
  );
}
export const ObjectiveNode = memo(ObjectiveNodeComponent);

ConstraintNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { ShieldAlert } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ConstraintNodeData } from '../../../lib/canvas/schema';

function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
      {data.name && data.operator && data.value !== undefined
        ? `${data.name} ${data.operator} ${data.value}`
        : 'Set constraint'}
    </BaseNode>
  );
}
export const ConstraintNode = memo(ConstraintNodeComponent);

AlgorithmNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { BrainCircuit } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { AlgorithmNodeData } from '../../../lib/canvas/schema';

function AlgorithmNodeComponent(props: NodeProps<AlgorithmNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<BrainCircuit size={16} />} iconColor="text-indigo-400">
      {data.method ? `${data.method} (${data.maxTrials || 100} trials)` : 'Select method'}
    </BaseNode>
  );
}
export const AlgorithmNode = memo(AlgorithmNodeComponent);

SurrogateNode.tsx:

import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Rocket } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { SurrogateNodeData } from '../../../lib/canvas/schema';

function SurrogateNodeComponent(props: NodeProps<SurrogateNodeData>) {
  const { data } = props;
  return (
    <BaseNode {...props} icon={<Rocket size={16} />} iconColor="text-pink-400" outputs={0}>
      {data.enabled ? (data.modelType || 'Auto') : 'Disabled'}
    </BaseNode>
  );
}
export const SurrogateNode = memo(SurrogateNodeComponent);

T1.4 - Update templates.ts with Lucide Icons

File: atomizer-dashboard/frontend/src/lib/canvas/templates.ts

Replace emoji icons with Lucide icon names (strings for serialization):

export interface CanvasTemplate {
  id: string;
  name: string;
  description: string;
  category: 'structural' | 'thermal' | 'optical' | 'general';
  icon: string; // Lucide icon name
  complexity: 'simple' | 'medium' | 'advanced';
  nodes: number;
  intent: Partial<OptimizationIntent>;
}

export const CANVAS_TEMPLATES: CanvasTemplate[] = [
  {
    id: 'mass-minimization',
    name: 'Mass Minimization',
    description: 'Single-objective mass reduction with stress constraint',
    category: 'structural',
    icon: 'Scale',
    complexity: 'simple',
    nodes: 6,
    intent: {
      // ... keep existing intent
    },
  },
  {
    id: 'multi-objective',
    name: 'Multi-Objective',
    description: 'Pareto optimization with mass and displacement',
    category: 'structural',
    icon: 'GitBranch',
    complexity: 'medium',
    nodes: 7,
    intent: {
      // ... keep existing intent
    },
  },
  {
    id: 'turbo-optimization',
    name: 'Turbo Mode',
    description: 'Neural-accelerated optimization with surrogate',
    category: 'general',
    icon: 'Zap',
    complexity: 'advanced',
    nodes: 8,
    intent: {
      // ... keep existing intent
    },
  },
  {
    id: 'mirror-zernike',
    name: 'Mirror WFE',
    description: 'Zernike wavefront error optimization for optics',
    category: 'optical',
    icon: 'CircleDot',
    complexity: 'advanced',
    nodes: 7,
    intent: {
      // ... keep existing intent
    },
  },
  {
    id: 'frequency-optimization',
    name: 'Frequency Target',
    description: 'Natural frequency optimization with modal analysis',
    category: 'structural',
    icon: 'Activity',
    complexity: 'medium',
    nodes: 6,
    intent: {
      // ... keep existing intent
    },
  },
];

PHASE 2: Responsive Full-Screen Canvas

Make the canvas properly responsive and full-screen.

T2.1 - Update AtomizerCanvas.tsx for Full Screen

File: atomizer-dashboard/frontend/src/components/canvas/AtomizerCanvas.tsx

import { useCallback, useRef, DragEvent, useState, useEffect } from 'react';
import ReactFlow, {
  Background,
  Controls,
  MiniMap,
  ReactFlowProvider,
  ReactFlowInstance,
  BackgroundVariant,
} from 'reactflow';
import 'reactflow/dist/style.css';

import { nodeTypes } from './nodes';
import { NodePalette } from './palette/NodePalette';
import { NodeConfigPanel } from './panels/NodeConfigPanel';
import { ValidationPanel } from './panels/ValidationPanel';
import { ChatPanel } from './panels/ChatPanel';
import { useCanvasStore } from '../../hooks/useCanvasStore';
import { useCanvasChat } from '../../hooks/useCanvasChat';
import { NodeType } from '../../lib/canvas/schema';
import {
  CheckCircle,
  Wand2,
  Play,
  MessageSquare,
  X,
  Loader2,
} from 'lucide-react';

function CanvasFlow() {
  const reactFlowWrapper = useRef<HTMLDivElement>(null);
  const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
  const [showChat, setShowChat] = useState(false);
  const [showValidation, setShowValidation] = useState(false);
  const [isProcessing, setIsProcessing] = useState(false);

  const {
    nodes,
    edges,
    selectedNode,
    onNodesChange,
    onEdgesChange,
    onConnect,
    addNode,
    selectNode,
    validation,
    validate,
    toIntent,
    clear,
  } = useCanvasStore();

  const { processWithClaude, isConnected, isThinking } = useCanvasChat();

  const onDragOver = useCallback((event: DragEvent) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
  }, []);

  const onDrop = useCallback(
    (event: DragEvent) => {
      event.preventDefault();
      const type = event.dataTransfer.getData('application/reactflow') as NodeType;
      if (!type || !reactFlowInstance.current || !reactFlowWrapper.current) return;

      const bounds = reactFlowWrapper.current.getBoundingClientRect();
      const position = reactFlowInstance.current.screenToFlowPosition({
        x: event.clientX - bounds.left,
        y: event.clientY - bounds.top,
      });
      addNode(type, position);
    },
    [addNode]
  );

  const onNodeClick = useCallback(
    (_: React.MouseEvent, node: { id: string }) => {
      selectNode(node.id);
      setShowChat(false);
    },
    [selectNode]
  );

  const onPaneClick = useCallback(() => {
    selectNode(null);
  }, [selectNode]);

  const handleValidate = useCallback(() => {
    const result = validate();
    setShowValidation(true);
    setTimeout(() => setShowValidation(false), 5000);
    return result;
  }, [validate]);

  const handleProcess = useCallback(async () => {
    const result = validate();
    if (!result.valid) {
      setShowValidation(true);
      return;
    }
    setIsProcessing(true);
    setShowChat(true);
    try {
      const intent = toIntent();
      await processWithClaude(intent);
    } finally {
      setIsProcessing(false);
    }
  }, [validate, toIntent, processWithClaude]);

  return (
    <div className="flex h-full w-full bg-dark-900">
      {/* Left: Node Palette */}
      <NodePalette />

      {/* Center: Canvas - Takes remaining space */}
      <div className="flex-1 flex flex-col min-w-0">
        {/* Canvas Area */}
        <div className="flex-1 relative" ref={reactFlowWrapper}>
          <ReactFlow
            nodes={nodes}
            edges={edges}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={onConnect}
            onInit={(instance) => { reactFlowInstance.current = instance; }}
            onDragOver={onDragOver}
            onDrop={onDrop}
            onNodeClick={onNodeClick}
            onPaneClick={onPaneClick}
            nodeTypes={nodeTypes}
            fitView
            fitViewOptions={{ padding: 0.2 }}
            minZoom={0.1}
            maxZoom={2}
            className="bg-dark-900"
            proOptions={{ hideAttribution: true }}
          >
            <Background
              variant={BackgroundVariant.Dots}
              gap={20}
              size={1}
              color="#1e293b"
            />
            <Controls
              className="!bg-dark-800 !border-dark-700 !rounded-lg !shadow-lg
                         [&>button]:!bg-dark-700 [&>button]:!border-dark-600
                         [&>button]:!text-dark-300 [&>button:hover]:!bg-dark-600
                         [&>button:hover]:!text-white"
              showInteractive={false}
            />
            <MiniMap
              className="!bg-dark-800/90 !border-dark-700 !rounded-lg"
              nodeColor={(n) => {
                if (n.selected) return '#00d4e6';
                return '#334155';
              }}
              maskColor="rgba(5, 10, 18, 0.85)"
              pannable
              zoomable
            />
          </ReactFlow>

          {/* Floating Action Buttons */}
          <div className="absolute bottom-4 right-4 flex items-center gap-2">
            <button
              onClick={() => setShowChat(!showChat)}
              className={`p-2.5 rounded-lg transition-all ${
                showChat
                  ? 'bg-primary-500 text-white'
                  : 'bg-dark-800 text-dark-300 hover:bg-dark-700 hover:text-white'
              } border border-dark-600`}
              title="Toggle Chat"
            >
              <MessageSquare size={18} />
            </button>
            <div className="w-px h-8 bg-dark-700" />
            <button
              onClick={handleValidate}
              className="flex items-center gap-2 px-3 py-2 bg-dark-800 text-dark-200
                         rounded-lg hover:bg-dark-700 hover:text-white transition-all
                         border border-dark-600"
            >
              <CheckCircle size={16} />
              <span className="text-sm font-medium">Validate</span>
            </button>
            <button
              onClick={handleProcess}
              disabled={isProcessing || nodes.length === 0}
              className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all
                         border font-medium text-sm ${
                isProcessing
                  ? 'bg-primary-600 text-white border-primary-500'
                  : nodes.length === 0
                  ? 'bg-dark-800 text-dark-500 border-dark-700 cursor-not-allowed'
                  : 'bg-primary-500 text-white border-primary-400 hover:bg-primary-600'
              }`}
            >
              {isProcessing ? (
                <>
                  <Loader2 size={16} className="animate-spin" />
                  <span>Processing...</span>
                </>
              ) : (
                <>
                  <Wand2 size={16} />
                  <span>Process with Claude</span>
                </>
              )}
            </button>
          </div>

          {/* Validation Toast */}
          {showValidation && (
            <div className="absolute top-4 left-1/2 -translate-x-1/2 z-50">
              <ValidationPanel
                validation={validation}
                onClose={() => setShowValidation(false)}
              />
            </div>
          )}
        </div>
      </div>

      {/* Right: Config Panel or Chat */}
      {(selectedNode || showChat) && (
        <div className="w-80 border-l border-dark-700 bg-dark-850 flex flex-col">
          {selectedNode && !showChat ? (
            <NodeConfigPanel nodeId={selectedNode} />
          ) : (
            <ChatPanel onClose={() => setShowChat(false)} />
          )}
        </div>
      )}
    </div>
  );
}

export function AtomizerCanvas() {
  return (
    <ReactFlowProvider>
      <CanvasFlow />
    </ReactFlowProvider>
  );
}

T2.2 - Update CanvasView.tsx for Full Screen Layout

File: atomizer-dashboard/frontend/src/pages/CanvasView.tsx

import { useState } from 'react';
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
import { TemplateSelector } from '../components/canvas/panels/TemplateSelector';
import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
import { useCanvasStore } from '../hooks/useCanvasStore';
import {
  LayoutTemplate,
  FileInput,
  Trash2,
  FolderOpen,
} from 'lucide-react';

export function CanvasView() {
  const [showTemplates, setShowTemplates] = useState(false);
  const [showImporter, setShowImporter] = useState(false);
  const { clear, nodes } = useCanvasStore();

  return (
    <div className="h-screen flex flex-col bg-dark-900">
      {/* Compact Header */}
      <header className="flex-shrink-0 h-12 bg-dark-850 border-b border-dark-700 px-4 flex items-center justify-between">
        <div className="flex items-center gap-3">
          <h1 className="text-sm font-semibold text-white">Canvas Builder</h1>
          <span className="text-xs text-dark-500">
            {nodes.length} node{nodes.length !== 1 ? 's' : ''}
          </span>
        </div>
        <div className="flex items-center gap-1.5">
          <button
            onClick={() => setShowTemplates(true)}
            className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium
                       text-dark-300 hover:text-white hover:bg-dark-700 rounded transition-colors"
          >
            <LayoutTemplate size={14} />
            Templates
          </button>
          <button
            onClick={() => setShowImporter(true)}
            className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium
                       text-dark-300 hover:text-white hover:bg-dark-700 rounded transition-colors"
          >
            <FileInput size={14} />
            Import
          </button>
          <div className="w-px h-4 bg-dark-700 mx-1" />
          <button
            onClick={clear}
            disabled={nodes.length === 0}
            className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium
                       text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded
                       transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
          >
            <Trash2 size={14} />
            Clear
          </button>
        </div>
      </header>

      {/* Canvas - Takes all remaining space */}
      <main className="flex-1 min-h-0">
        <AtomizerCanvas />
      </main>

      {/* Modals */}
      {showTemplates && (
        <TemplateSelector onClose={() => setShowTemplates(false)} />
      )}
      {showImporter && (
        <ConfigImporter onClose={() => setShowImporter(false)} />
      )}
    </div>
  );
}

PHASE 3: Auto-Load from Optimization Config

When a study is loaded, auto-populate the canvas from its optimization_config.json.

T3.1 - Add Backend Endpoint for Study Config

File: atomizer-dashboard/backend/api/routes/studies.py

Add this endpoint (find the router and add):

@router.get("/{study_path:path}/config")
async def get_study_config(study_path: str):
    """Load optimization_config.json from a study directory."""
    import json
    from pathlib import Path

    # Resolve study path
    studies_root = Path(__file__).parent.parent.parent.parent.parent / "studies"
    config_path = studies_root / study_path / "optimization_config.json"

    if not config_path.exists():
        # Try with 1_config subdirectory
        config_path = studies_root / study_path / "1_config" / "optimization_config.json"

    if not config_path.exists():
        raise HTTPException(status_code=404, detail=f"Config not found for study: {study_path}")

    try:
        with open(config_path, 'r') as f:
            config = json.load(f)
        return config
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")

T3.2 - Add Backend Endpoint to List Studies

@router.get("/")
async def list_studies():
    """List all available studies with their status."""
    from pathlib import Path
    import json

    studies_root = Path(__file__).parent.parent.parent.parent.parent / "studies"
    studies = []

    for category in studies_root.iterdir():
        if not category.is_dir() or category.name.startswith('.'):
            continue
        for study_dir in category.iterdir():
            if not study_dir.is_dir():
                continue

            # Find config
            config_path = study_dir / "optimization_config.json"
            if not config_path.exists():
                config_path = study_dir / "1_config" / "optimization_config.json"

            # Find database
            db_path = study_dir / "3_results" / "study.db"
            if not db_path.exists():
                db_path = study_dir / "2_results" / "study.db"

            trial_count = 0
            if db_path.exists():
                try:
                    import sqlite3
                    conn = sqlite3.connect(str(db_path))
                    cursor = conn.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
                    trial_count = cursor.fetchone()[0]
                    conn.close()
                except:
                    pass

            studies.append({
                "path": f"{category.name}/{study_dir.name}",
                "name": study_dir.name,
                "category": category.name,
                "has_config": config_path.exists(),
                "trial_count": trial_count,
            })

    return {"studies": studies}

T3.3 - Update useCanvasStore to Load from Config

File: atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts

Add/update the loadFromConfig function:

loadFromConfig: (config: OptimizationConfig) => {
  const nodes: Node<CanvasNodeData>[] = [];
  const edges: Edge[] = [];
  let nodeId = 0;
  const getId = () => `node_${++nodeId}`;

  // Layout positions
  const colX = [50, 250, 450, 650, 850];
  let row = 0;
  const getY = () => 50 + (row++) * 120;

  // Model node
  if (config.nx_model?.prt_path || config.nx_model?.sim_path) {
    const modelId = getId();
    nodes.push({
      id: modelId,
      type: 'model',
      position: { x: colX[0], y: 100 },
      data: {
        type: 'model',
        label: 'Model',
        configured: true,
        filePath: config.nx_model.sim_path || config.nx_model.prt_path,
        fileType: config.nx_model.sim_path ? 'sim' : 'prt',
      },
    });
  }

  // Solver node
  if (config.solver) {
    const solverId = getId();
    nodes.push({
      id: solverId,
      type: 'solver',
      position: { x: colX[1], y: 100 },
      data: {
        type: 'solver',
        label: 'Solver',
        configured: true,
        solverType: `SOL${config.solver.solution_type}`,
      },
    });
  }

  // Design variables
  row = 0;
  config.design_variables?.forEach((dv, i) => {
    const dvId = getId();
    nodes.push({
      id: dvId,
      type: 'designVar',
      position: { x: colX[0], y: 250 + i * 100 },
      data: {
        type: 'designVar',
        label: dv.name,
        configured: true,
        expressionName: dv.nx_expression || dv.name,
        minValue: dv.lower_bound,
        maxValue: dv.upper_bound,
        unit: dv.unit,
      },
    });
  });

  // Extractors from objectives
  const extractorIds: Record<string, string> = {};
  config.objectives?.forEach((obj, i) => {
    if (!extractorIds[obj.extractor_id]) {
      const extId = getId();
      extractorIds[obj.extractor_id] = extId;
      nodes.push({
        id: extId,
        type: 'extractor',
        position: { x: colX[2], y: 100 + i * 100 },
        data: {
          type: 'extractor',
          label: obj.extractor_id,
          configured: true,
          extractorId: obj.extractor_id,
          extractorName: obj.name,
        },
      });
    }
  });

  // Objectives
  config.objectives?.forEach((obj, i) => {
    const objId = getId();
    nodes.push({
      id: objId,
      type: 'objective',
      position: { x: colX[3], y: 100 + i * 100 },
      data: {
        type: 'objective',
        label: obj.name,
        configured: true,
        name: obj.name,
        direction: obj.direction,
        weight: obj.weight,
      },
    });

    // Connect extractor to objective
    if (extractorIds[obj.extractor_id]) {
      edges.push({
        id: `e_${extractorIds[obj.extractor_id]}_${objId}`,
        source: extractorIds[obj.extractor_id],
        target: objId,
      });
    }
  });

  // Constraints
  config.constraints?.forEach((con, i) => {
    const conId = getId();
    nodes.push({
      id: conId,
      type: 'constraint',
      position: { x: colX[3], y: 300 + i * 100 },
      data: {
        type: 'constraint',
        label: con.name,
        configured: true,
        name: con.name,
        operator: con.type === 'upper' ? '<=' : '>=',
        value: con.upper_bound ?? con.lower_bound,
      },
    });
  });

  // Algorithm
  if (config.optimization) {
    const algoId = getId();
    nodes.push({
      id: algoId,
      type: 'algorithm',
      position: { x: colX[4], y: 150 },
      data: {
        type: 'algorithm',
        label: 'Algorithm',
        configured: true,
        method: config.optimization.sampler || 'TPE',
        maxTrials: config.optimization.n_trials || 100,
      },
    });
  }

  // Surrogate
  if (config.surrogate?.enabled) {
    const surId = getId();
    nodes.push({
      id: surId,
      type: 'surrogate',
      position: { x: colX[4], y: 300 },
      data: {
        type: 'surrogate',
        label: 'Surrogate',
        configured: true,
        enabled: true,
        modelType: config.surrogate.type || 'MLP',
        minTrials: config.surrogate.min_trials || 20,
      },
    });
  }

  set({
    nodes,
    edges,
    selectedNode: null,
    validation: { valid: false, errors: [], warnings: [] },
  });
  nodeIdCounter = nodeId;
},

T3.4 - Update ConfigImporter with Study Browser

File: atomizer-dashboard/frontend/src/components/canvas/panels/ConfigImporter.tsx

import { useState, useEffect } from 'react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import {
  X,
  Upload,
  FileCode,
  FolderOpen,
  Search,
  Check,
  Loader2,
  AlertCircle,
} from 'lucide-react';

interface Study {
  path: string;
  name: string;
  category: string;
  has_config: boolean;
  trial_count: number;
}

interface ConfigImporterProps {
  onClose: () => void;
}

export function ConfigImporter({ onClose }: ConfigImporterProps) {
  const [activeTab, setActiveTab] = useState<'file' | 'paste' | 'study'>('study');
  const [jsonInput, setJsonInput] = useState('');
  const [studies, setStudies] = useState<Study[]>([]);
  const [searchQuery, setSearchQuery] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [selectedStudy, setSelectedStudy] = useState<string | null>(null);

  const { loadFromConfig } = useCanvasStore();

  // Load studies on mount
  useEffect(() => {
    loadStudies();
  }, []);

  const loadStudies = async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/studies/');
      if (!res.ok) throw new Error('Failed to load studies');
      const data = await res.json();
      setStudies(data.studies || []);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load studies');
    } finally {
      setLoading(false);
    }
  };

  const loadStudyConfig = async (studyPath: string) => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(`/api/studies/${encodeURIComponent(studyPath)}/config`);
      if (!res.ok) throw new Error('Config not found');
      const config = await res.json();
      loadFromConfig(config);
      onClose();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load config');
    } finally {
      setLoading(false);
    }
  };

  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    const reader = new FileReader();
    reader.onload = (event) => {
      try {
        const config = JSON.parse(event.target?.result as string);
        loadFromConfig(config);
        onClose();
      } catch {
        setError('Invalid JSON file');
      }
    };
    reader.readAsText(file);
  };

  const handlePasteImport = () => {
    try {
      const config = JSON.parse(jsonInput);
      loadFromConfig(config);
      onClose();
    } catch {
      setError('Invalid JSON');
    }
  };

  const filteredStudies = studies.filter(s =>
    s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
    s.category.toLowerCase().includes(searchQuery.toLowerCase())
  );

  const tabs = [
    { id: 'study', label: 'Load Study', icon: FolderOpen },
    { id: 'file', label: 'Upload File', icon: Upload },
    { id: 'paste', label: 'Paste JSON', icon: FileCode },
  ] as const;

  return (
    <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
      <div className="bg-dark-850 border border-dark-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
        {/* Header */}
        <div className="flex items-center justify-between px-5 py-4 border-b border-dark-700">
          <h2 className="text-lg font-semibold text-white">Import Configuration</h2>
          <button onClick={onClose} className="text-dark-400 hover:text-white transition-colors">
            <X size={20} />
          </button>
        </div>

        {/* Tabs */}
        <div className="flex border-b border-dark-700">
          {tabs.map((tab) => (
            <button
              key={tab.id}
              onClick={() => { setActiveTab(tab.id); setError(null); }}
              className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
                activeTab === tab.id
                  ? 'text-primary-400 border-primary-400'
                  : 'text-dark-400 border-transparent hover:text-white'
              }`}
            >
              <tab.icon size={16} />
              {tab.label}
            </button>
          ))}
        </div>

        {/* Content */}
        <div className="flex-1 overflow-y-auto p-5">
          {error && (
            <div className="mb-4 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
              <AlertCircle size={16} />
              {error}
            </div>
          )}

          {activeTab === 'study' && (
            <div className="space-y-4">
              {/* Search */}
              <div className="relative">
                <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500" />
                <input
                  type="text"
                  value={searchQuery}
                  onChange={(e) => setSearchQuery(e.target.value)}
                  placeholder="Search studies..."
                  className="w-full pl-9 pr-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
                           text-white placeholder-dark-500 text-sm
                           focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
                />
              </div>

              {/* Study List */}
              <div className="space-y-1.5 max-h-[400px] overflow-y-auto">
                {loading ? (
                  <div className="flex items-center justify-center py-8 text-dark-400">
                    <Loader2 size={20} className="animate-spin mr-2" />
                    Loading studies...
                  </div>
                ) : filteredStudies.length === 0 ? (
                  <div className="text-center py-8 text-dark-500">
                    No studies found
                  </div>
                ) : (
                  filteredStudies.map((study) => (
                    <button
                      key={study.path}
                      onClick={() => setSelectedStudy(study.path)}
                      disabled={!study.has_config}
                      className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
                        selectedStudy === study.path
                          ? 'bg-primary-500/20 border border-primary-500/50'
                          : study.has_config
                          ? 'bg-dark-800/50 border border-dark-700/50 hover:bg-dark-800 hover:border-dark-600'
                          : 'bg-dark-800/30 border border-dark-700/30 opacity-50 cursor-not-allowed'
                      }`}
                    >
                      <FolderOpen size={16} className={selectedStudy === study.path ? 'text-primary-400' : 'text-dark-400'} />
                      <div className="flex-1 min-w-0">
                        <div className="text-sm font-medium text-white truncate">{study.name}</div>
                        <div className="text-xs text-dark-500">{study.category}  {study.trial_count} trials</div>
                      </div>
                      {selectedStudy === study.path && <Check size={16} className="text-primary-400" />}
                    </button>
                  ))
                )}
              </div>
            </div>
          )}

          {activeTab === 'file' && (
            <div className="flex flex-col items-center justify-center py-12">
              <label className="flex flex-col items-center gap-3 px-8 py-6 border-2 border-dashed border-dark-600 rounded-xl cursor-pointer hover:border-primary-500/50 transition-colors">
                <Upload size={32} className="text-dark-400" />
                <div className="text-sm text-dark-300">Click to upload optimization_config.json</div>
                <input type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
              </label>
            </div>
          )}

          {activeTab === 'paste' && (
            <div className="space-y-4">
              <textarea
                value={jsonInput}
                onChange={(e) => setJsonInput(e.target.value)}
                placeholder="Paste optimization_config.json content here..."
                className="w-full h-64 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
                         text-white font-mono text-sm placeholder-dark-500
                         focus:border-primary-500 focus:ring-1 focus:ring-primary-500 resize-none"
              />
            </div>
          )}
        </div>

        {/* Footer */}
        <div className="flex justify-end gap-2 px-5 py-4 border-t border-dark-700">
          <button
            onClick={onClose}
            className="px-4 py-2 text-sm font-medium text-dark-300 hover:text-white transition-colors"
          >
            Cancel
          </button>
          {activeTab === 'study' && (
            <button
              onClick={() => selectedStudy && loadStudyConfig(selectedStudy)}
              disabled={!selectedStudy || loading}
              className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg
                       text-sm font-medium hover:bg-primary-600 transition-colors
                       disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
              Load Study
            </button>
          )}
          {activeTab === 'paste' && (
            <button
              onClick={handlePasteImport}
              disabled={!jsonInput.trim()}
              className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg
                       text-sm font-medium hover:bg-primary-600 transition-colors
                       disabled:opacity-50 disabled:cursor-not-allowed"
            >
              <Check size={16} />
              Import
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

PHASE 4: NX Model Introspection

Add ability to introspect .sim files to extract expressions and model info.

T4.1 - Add Backend NX Introspection Endpoint

File: atomizer-dashboard/backend/api/routes/nx.py (create new file)

"""NX Model Introspection Endpoints."""
from fastapi import APIRouter, HTTPException
from pathlib import Path
import subprocess
import json
import os

router = APIRouter(prefix="/api/nx", tags=["nx"])

PYTHON_PATH = r"C:\Users\antoi\anaconda3\envs\atomizer\python.exe"
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent

@router.post("/introspect")
async def introspect_model(file_path: str):
    """
    Introspect an NX model file to extract expressions and configuration.
    Works with .prt, .sim, .fem files.
    """
    # Resolve path
    path = Path(file_path)
    if not path.is_absolute():
        path = ATOMIZER_ROOT / file_path

    if not path.exists():
        raise HTTPException(status_code=404, detail=f"File not found: {file_path}")

    # Run introspection script
    script = ATOMIZER_ROOT / "optimization_engine" / "nx" / "introspect_model.py"

    if not script.exists():
        # Return mock data if script doesn't exist yet
        return {
            "file_path": str(path),
            "file_type": path.suffix.lower().replace('.', ''),
            "expressions": [],
            "solver_type": None,
            "linked_files": [],
            "error": "Introspection script not found - returning empty data"
        }

    try:
        result = subprocess.run(
            [PYTHON_PATH, str(script), str(path)],
            capture_output=True,
            text=True,
            timeout=60,
            cwd=str(ATOMIZER_ROOT)
        )

        if result.returncode != 0:
            raise HTTPException(status_code=500, detail=f"Introspection failed: {result.stderr}")

        return json.loads(result.stdout)
    except subprocess.TimeoutExpired:
        raise HTTPException(status_code=504, detail="Introspection timed out")
    except json.JSONDecodeError:
        raise HTTPException(status_code=500, detail="Invalid introspection output")


@router.get("/expressions")
async def get_expressions(file_path: str):
    """Get just the expressions from an NX model."""
    result = await introspect_model(file_path)
    return {
        "file_path": file_path,
        "expressions": result.get("expressions", [])
    }

T4.2 - Add Route to Main App

File: atomizer-dashboard/backend/main.py

Add import and include router:

from api.routes import nx
app.include_router(nx.router)

T4.3 - Create Introspection Script

File: optimization_engine/nx/introspect_model.py

#!/usr/bin/env python
"""
NX Model Introspection Script.
Extracts expressions, solver info, and linked files from NX models.

Usage: python introspect_model.py <path_to_model>
"""
import sys
import json
from pathlib import Path

def introspect_prt(path: Path) -> dict:
    """Introspect a .prt file for expressions."""
    # Try to use NXOpen if available
    try:
        import NXOpen
        # ... NXOpen introspection code
    except ImportError:
        pass

    # Fallback: Parse any associated expression files
    expressions = []

    # Look for expression export files
    exp_file = path.with_suffix('.exp')
    if exp_file.exists():
        with open(exp_file, 'r') as f:
            for line in f:
                if '=' in line and not line.strip().startswith('//'):
                    name = line.split('=')[0].strip()
                    expressions.append({
                        "name": name,
                        "type": "unknown",
                        "value": None,
                        "unit": None
                    })

    return {
        "file_path": str(path),
        "file_type": "prt",
        "expressions": expressions,
        "solver_type": None,
        "linked_files": []
    }


def introspect_sim(path: Path) -> dict:
    """Introspect a .sim file for solver and linked files."""
    result = {
        "file_path": str(path),
        "file_type": "sim",
        "expressions": [],
        "solver_type": None,
        "linked_files": []
    }

    # Find linked .fem file
    fem_path = path.with_suffix('.fem')
    if not fem_path.exists():
        # Try common naming patterns
        for suffix in ['_fem1.fem', '_fem.fem']:
            candidate = path.parent / (path.stem.replace('_sim1', '').replace('_sim', '') + suffix)
            if candidate.exists():
                fem_path = candidate
                break

    if fem_path.exists():
        result["linked_files"].append({
            "type": "fem",
            "path": str(fem_path)
        })

    # Find linked .prt file
    prt_path = path.parent / (path.stem.replace('_sim1', '').replace('_sim', '') + '.prt')
    if prt_path.exists():
        result["linked_files"].append({
            "type": "prt",
            "path": str(prt_path)
        })
        # Get expressions from prt
        prt_result = introspect_prt(prt_path)
        result["expressions"] = prt_result["expressions"]

    # Try to detect solver type from sim file
    try:
        with open(path, 'rb') as f:
            content = f.read(10000).decode('utf-8', errors='ignore')
            if 'SOL 101' in content or 'SESTATIC' in content:
                result["solver_type"] = "SOL101"
            elif 'SOL 103' in content or 'SEMODES' in content:
                result["solver_type"] = "SOL103"
            elif 'SOL 105' in content:
                result["solver_type"] = "SOL105"
            elif 'SOL 106' in content:
                result["solver_type"] = "SOL106"
            elif 'SOL 111' in content:
                result["solver_type"] = "SOL111"
            elif 'SOL 112' in content:
                result["solver_type"] = "SOL112"
    except:
        pass

    return result


def introspect_fem(path: Path) -> dict:
    """Introspect a .fem file."""
    result = {
        "file_path": str(path),
        "file_type": "fem",
        "expressions": [],
        "solver_type": None,
        "linked_files": []
    }

    # Find linked files
    prt_path = path.parent / (path.stem.replace('_fem1', '').replace('_fem', '') + '.prt')
    if prt_path.exists():
        result["linked_files"].append({"type": "prt", "path": str(prt_path)})
        prt_result = introspect_prt(prt_path)
        result["expressions"] = prt_result["expressions"]

    return result


def main():
    if len(sys.argv) < 2:
        print(json.dumps({"error": "No file path provided"}))
        sys.exit(1)

    path = Path(sys.argv[1])

    if not path.exists():
        print(json.dumps({"error": f"File not found: {path}"}))
        sys.exit(1)

    suffix = path.suffix.lower()

    if suffix == '.prt':
        result = introspect_prt(path)
    elif suffix == '.sim':
        result = introspect_sim(path)
    elif suffix == '.fem':
        result = introspect_fem(path)
    else:
        result = {
            "file_path": str(path),
            "file_type": suffix.replace('.', ''),
            "expressions": [],
            "solver_type": None,
            "linked_files": [],
            "error": f"Unsupported file type: {suffix}"
        }

    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()

PHASE 5: Expression Search/Dropdown in NodeConfigPanel

Add expression dropdown with search for design variables.

File: atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx

Add this component and update the design variable section:

import { useState, useEffect, useMemo } from 'react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import { CanvasNodeData } from '../../../lib/canvas/schema';
import {
  Trash2,
  Check,
  Search,
  Loader2,
  ChevronDown,
  RefreshCw,
  X,
} from 'lucide-react';

// Expression search dropdown component
function ExpressionSelector({
  value,
  onChange,
  modelPath,
}: {
  value: string;
  onChange: (expr: string) => void;
  modelPath?: string;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const [search, setSearch] = useState('');
  const [expressions, setExpressions] = useState<Array<{ name: string; value?: number; unit?: string }>>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadExpressions = async () => {
    if (!modelPath) {
      setError('No model selected');
      return;
    }
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(`/api/nx/expressions?file_path=${encodeURIComponent(modelPath)}`);
      if (!res.ok) throw new Error('Failed to load expressions');
      const data = await res.json();
      setExpressions(data.expressions || []);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load');
      // Add some mock expressions for testing
      setExpressions([
        { name: 'thickness', value: 10, unit: 'mm' },
        { name: 'width', value: 50, unit: 'mm' },
        { name: 'height', value: 100, unit: 'mm' },
        { name: 'radius', value: 25, unit: 'mm' },
        { name: 'angle', value: 45, unit: 'deg' },
      ]);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    if (isOpen && expressions.length === 0 && !loading) {
      loadExpressions();
    }
  }, [isOpen]);

  const filtered = useMemo(() => {
    if (!search) return expressions;
    const q = search.toLowerCase();
    return expressions.filter(e => e.name.toLowerCase().includes(q));
  }, [expressions, search]);

  return (
    <div className="relative">
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
                   text-left text-sm text-white hover:border-dark-500 transition-colors"
      >
        <span className={value ? 'font-mono' : 'text-dark-500'}>
          {value || 'Select expression...'}
        </span>
        <ChevronDown size={16} className={`text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
      </button>

      {isOpen && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 overflow-hidden">
          {/* Search */}
          <div className="p-2 border-b border-dark-700">
            <div className="relative">
              <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-dark-500" />
              <input
                type="text"
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                placeholder="Search expressions..."
                className="w-full pl-8 pr-8 py-1.5 bg-dark-700 border border-dark-600 rounded text-sm text-white placeholder-dark-500 focus:border-primary-500"
                autoFocus
              />
              <button
                onClick={loadExpressions}
                className="absolute right-2 top-1/2 -translate-y-1/2 text-dark-400 hover:text-white"
                title="Refresh"
              >
                <RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
              </button>
            </div>
          </div>

          {/* List */}
          <div className="max-h-48 overflow-y-auto">
            {loading ? (
              <div className="flex items-center justify-center py-4 text-dark-400">
                <Loader2 size={16} className="animate-spin mr-2" />
                Loading...
              </div>
            ) : filtered.length === 0 ? (
              <div className="py-4 text-center text-dark-500 text-sm">
                {expressions.length === 0 ? 'No expressions found' : 'No matches'}
              </div>
            ) : (
              filtered.map((expr) => (
                <button
                  key={expr.name}
                  type="button"
                  onClick={() => {
                    onChange(expr.name);
                    setIsOpen(false);
                    setSearch('');
                  }}
                  className={`w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-dark-700 transition-colors ${
                    value === expr.name ? 'bg-primary-500/20 text-primary-400' : 'text-white'
                  }`}
                >
                  <span className="font-mono">{expr.name}</span>
                  {expr.value !== undefined && (
                    <span className="text-dark-500 text-xs">
                      {expr.value} {expr.unit || ''}
                    </span>
                  )}
                </button>
              ))
            )}
          </div>

          {/* Manual input option */}
          <div className="p-2 border-t border-dark-700">
            <button
              type="button"
              onClick={() => {
                const name = prompt('Enter expression name:');
                if (name) {
                  onChange(name);
                  setIsOpen(false);
                }
              }}
              className="w-full text-xs text-dark-400 hover:text-white transition-colors"
            >
              + Enter manually
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

interface NodeConfigPanelProps {
  nodeId: string;
}

export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
  const { nodes, updateNodeData, deleteSelected } = useCanvasStore();
  const node = nodes.find((n) => n.id === nodeId);

  if (!node) return null;

  const { data } = node;

  // Find model node to get file path for expression lookup
  const modelNode = nodes.find(n => n.data.type === 'model');
  const modelPath = modelNode?.data.type === 'model' ? (modelNode.data as any).filePath : undefined;

  const handleChange = (field: string, value: unknown) => {
    updateNodeData(nodeId, { [field]: value, configured: true });
  };

  const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-white text-sm placeholder-dark-500 focus:border-primary-500 focus:ring-1 focus:ring-primary-500 transition-colors";
  const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-white text-sm focus:border-primary-500 transition-colors";
  const labelClass = "block text-xs font-medium text-dark-400 mb-1.5";

  return (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="flex-shrink-0 px-4 py-3 border-b border-dark-700">
        <div className="flex items-center justify-between">
          <div>
            <h3 className="font-medium text-white text-sm">Configure Node</h3>
            <p className="text-xs text-dark-500 capitalize">{data.type}</p>
          </div>
          <button
            onClick={deleteSelected}
            className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
            title="Delete node"
          >
            <Trash2 size={16} />
          </button>
        </div>
      </div>

      {/* Content */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {/* Label - common to all */}
        <div>
          <label className={labelClass}>Label</label>
          <input
            type="text"
            value={data.label}
            onChange={(e) => handleChange('label', e.target.value)}
            className={inputClass}
          />
        </div>

        {/* Design Variable - with Expression Selector */}
        {data.type === 'designVar' && (
          <>
            <div>
              <label className={labelClass}>Expression Name</label>
              <ExpressionSelector
                value={(data as any).expressionName || ''}
                onChange={(expr) => handleChange('expressionName', expr)}
                modelPath={modelPath}
              />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div>
                <label className={labelClass}>Min Value</label>
                <input
                  type="number"
                  value={(data as any).minValue ?? ''}
                  onChange={(e) => handleChange('minValue', parseFloat(e.target.value) || 0)}
                  className={inputClass}
                  placeholder="0"
                />
              </div>
              <div>
                <label className={labelClass}>Max Value</label>
                <input
                  type="number"
                  value={(data as any).maxValue ?? ''}
                  onChange={(e) => handleChange('maxValue', parseFloat(e.target.value) || 100)}
                  className={inputClass}
                  placeholder="100"
                />
              </div>
            </div>
            <div>
              <label className={labelClass}>Unit</label>
              <input
                type="text"
                value={(data as any).unit || ''}
                onChange={(e) => handleChange('unit', e.target.value)}
                className={inputClass}
                placeholder="mm"
              />
            </div>
          </>
        )}

        {/* Add other node type configurations following the same pattern */}
        {/* ... (include all other node types from earlier) ... */}
      </div>

      {/* Footer status */}
      <div className="flex-shrink-0 px-4 py-3 border-t border-dark-700">
        <div className="flex items-center gap-2">
          <div className={`w-2 h-2 rounded-full ${data.configured ? 'bg-green-400' : 'bg-amber-400'}`} />
          <span className={`text-xs ${data.configured ? 'text-green-400' : 'text-amber-400'}`}>
            {data.configured ? 'Configured' : 'Needs configuration'}
          </span>
        </div>
      </div>
    </div>
  );
}

PHASE 6: Claude Chat Integration & Process Button

Fix the Claude CLI integration and add the "Process with Claude" functionality.

T6.1 - Update useCanvasChat Hook

File: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts

import { useCallback, useState } from 'react';
import { useChat } from './useChat';
import { OptimizationIntent } from '../lib/canvas/intent';

export function useCanvasChat() {
  const { sendMessage, messages, isConnected, isThinking, clearMessages } = useChat();
  const [lastIntent, setLastIntent] = useState<OptimizationIntent | null>(null);

  const processWithClaude = useCallback(async (intent: OptimizationIntent) => {
    setLastIntent(intent);

    const message = `I have designed an optimization workflow using the Canvas Builder. Please analyze this configuration and help me create the optimization study.

## Optimization Intent

\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`

## Instructions

1. **Validate** the configuration for completeness and correctness
2. **Recommend** any improvements based on Atomizer protocols (SYS_12 for extractors, SYS_15 for method selection)
3. **Create** the study if the configuration is valid, or explain what needs to be fixed

Use your knowledge of:
- Atomizer extractors (E1-E10)
- Optimization methods (TPE, CMA-ES, NSGA-II, GP-BO)
- Best practices from LAC (Learning Atomizer Core)

Please proceed with the analysis.`;

    await sendMessage(message);
  }, [sendMessage]);

  const validateWithClaude = useCallback(async (intent: OptimizationIntent) => {
    const message = `Please validate this Canvas optimization intent WITHOUT creating a study:

\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`

Check for:
1. Missing required components
2. Invalid extractor selections
3. Algorithm appropriateness for the problem type
4. Constraint feasibility

Provide specific recommendations.`;

    await sendMessage(message);
  }, [sendMessage]);

  const analyzeWithClaude = useCallback(async (intent: OptimizationIntent) => {
    const message = `Analyze this optimization setup and provide expert recommendations:

\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`

Consider:
1. Is ${intent.optimization?.method || 'the selected method'} appropriate for ${intent.objectives?.length || 0} objective(s)?
2. Are the design variable ranges reasonable?
3. Would a neural surrogate help with this problem?
4. What trial count would you recommend?

Use Atomizer protocols SYS_15 (Method Selector) and SYS_14 (Neural Acceleration) for guidance.`;

    await sendMessage(message);
  }, [sendMessage]);

  return {
    processWithClaude,
    validateWithClaude,
    analyzeWithClaude,
    messages,
    isConnected,
    isThinking,
    clearMessages,
    lastIntent,
  };
}

T6.2 - Update ChatPanel for Canvas Context

File: atomizer-dashboard/frontend/src/components/canvas/panels/ChatPanel.tsx

import { useRef, useEffect } from 'react';
import { useCanvasChat } from '../../../hooks/useCanvasChat';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import { ChatMessage } from '../../chat/ChatMessage';
import { ThinkingIndicator } from '../../chat/ThinkingIndicator';
import {
  X,
  Send,
  Sparkles,
  Wifi,
  WifiOff,
  Trash2,
} from 'lucide-react';

interface ChatPanelProps {
  onClose: () => void;
}

export function ChatPanel({ onClose }: ChatPanelProps) {
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const { messages, isConnected, isThinking, clearMessages } = useCanvasChat();
  const { nodes, toIntent, validation } = useCanvasStore();

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  const handleSend = async () => {
    const input = inputRef.current;
    if (!input?.value.trim()) return;

    // TODO: Send message
    input.value = '';
  };

  const nodeCount = nodes.length;
  const isValid = validation.valid;

  return (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="flex-shrink-0 px-4 py-3 border-b border-dark-700">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-2">
            <Sparkles size={16} className="text-primary-400" />
            <span className="font-medium text-white text-sm">Claude Assistant</span>
          </div>
          <div className="flex items-center gap-1">
            <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
            <button
              onClick={clearMessages}
              className="p-1.5 text-dark-400 hover:text-white transition-colors"
              title="Clear chat"
            >
              <Trash2 size={14} />
            </button>
            <button
              onClick={onClose}
              className="p-1.5 text-dark-400 hover:text-white transition-colors"
            >
              <X size={16} />
            </button>
          </div>
        </div>

        {/* Canvas status */}
        <div className="mt-2 flex items-center gap-2 text-xs">
          <span className="text-dark-500">{nodeCount} nodes</span>
          <span className="text-dark-700"></span>
          <span className={isValid ? 'text-green-400' : 'text-amber-400'}>
            {isValid ? 'Valid' : 'Needs validation'}
          </span>
        </div>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 ? (
          <div className="flex flex-col items-center justify-center h-full text-center px-4">
            <Sparkles size={32} className="text-dark-600 mb-3" />
            <p className="text-dark-400 text-sm mb-1">Canvas Assistant</p>
            <p className="text-dark-500 text-xs">
              Click "Process with Claude" to analyze your workflow and create a study.
            </p>
          </div>
        ) : (
          messages.map((msg, i) => (
            <ChatMessage key={i} message={msg} />
          ))
        )}
        {isThinking && <ThinkingIndicator />}
        <div ref={messagesEndRef} />
      </div>

      {/* Input */}
      <div className="flex-shrink-0 p-3 border-t border-dark-700">
        <div className="flex items-center gap-2">
          <input
            ref={inputRef}
            type="text"
            placeholder="Ask about your optimization..."
            className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
                     text-sm text-white placeholder-dark-500
                     focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
            onKeyDown={(e) => e.key === 'Enter' && handleSend()}
          />
          <button
            onClick={handleSend}
            className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
          >
            <Send size={16} />
          </button>
        </div>
      </div>
    </div>
  );
}

PHASE 7: MCP Canvas Tools

Add the missing MCP tools for canvas intent handling.

T7.1 - Update canvas.ts MCP Tools

File: mcp-server/atomizer-tools/src/tools/canvas.ts

Verify and update the canvas tools to properly handle intent validation and execution:

import { AtomizerTool } from '../index.js';
import { spawn } from 'child_process';
import { ATOMIZER_ROOT, PYTHON_PATH } from '../utils/paths.js';
import path from 'path';

// Helper to run Python scripts
async function runPython(script: string, args: Record<string, unknown>): Promise<string> {
  return new Promise((resolve, reject) => {
    const proc = spawn(PYTHON_PATH, [
      path.join(ATOMIZER_ROOT, script),
      JSON.stringify(args)
    ], {
      cwd: ATOMIZER_ROOT,
      env: { ...process.env, ATOMIZER_ROOT }
    });

    let stdout = '';
    let stderr = '';

    proc.stdout.on('data', (data) => { stdout += data; });
    proc.stderr.on('data', (data) => { stderr += data; });

    proc.on('close', (code) => {
      if (code === 0) {
        resolve(stdout);
      } else {
        reject(new Error(stderr || `Process exited with code ${code}`));
      }
    });
  });
}

export const canvasTools: AtomizerTool[] = [
  {
    definition: {
      name: 'validate_canvas_intent',
      description: 'Validate an optimization intent from the Canvas Builder. Checks for completeness, valid extractors, appropriate algorithms, and constraint feasibility.',
      inputSchema: {
        type: 'object',
        properties: {
          intent: {
            type: 'object',
            description: 'The OptimizationIntent JSON from the canvas',
          },
        },
        required: ['intent'],
      },
    },
    handler: async (args) => {
      const intent = args.intent as Record<string, unknown>;
      const errors: string[] = [];
      const warnings: string[] = [];
      const recommendations: string[] = [];

      // Check model
      if (!intent.model || !(intent.model as any).path) {
        errors.push('Missing model file - add a Model node with file path');
      }

      // Check solver
      if (!intent.solver || !(intent.solver as any).type) {
        errors.push('Missing solver type - add a Solver node');
      }

      // Check design variables
      const dvs = (intent.design_variables as any[]) || [];
      if (dvs.length === 0) {
        errors.push('No design variables - add at least one Design Variable node');
      } else {
        dvs.forEach((dv, i) => {
          if (!dv.name) errors.push(`Design variable ${i + 1}: missing name`);
          if (dv.min === undefined || dv.max === undefined) {
            warnings.push(`Design variable "${dv.name}": missing bounds`);
          } else if (dv.min >= dv.max) {
            errors.push(`Design variable "${dv.name}": min must be less than max`);
          }
        });
      }

      // Check objectives
      const objectives = (intent.objectives as any[]) || [];
      if (objectives.length === 0) {
        errors.push('No objectives - add at least one Objective node');
      }

      // Check algorithm
      const opt = intent.optimization as any;
      if (!opt?.method) {
        warnings.push('No algorithm specified - will use default TPE');
      } else if (objectives.length > 1 && opt.method !== 'NSGA-II') {
        recommendations.push(`Multi-objective detected: consider using NSGA-II instead of ${opt.method}`);
      }

      // Check extractors
      const extractors = (intent.extractors as any[]) || [];
      const validExtractors = ['E1', 'E2', 'E3', 'E4', 'E5', 'E8', 'E9', 'E10'];
      extractors.forEach((ext) => {
        if (!validExtractors.includes(ext.id)) {
          warnings.push(`Unknown extractor: ${ext.id}`);
        }
      });

      const valid = errors.length === 0;

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            valid,
            errors,
            warnings,
            recommendations,
            summary: valid
              ? `Intent is valid: ${dvs.length} variables, ${objectives.length} objectives, ${opt?.method || 'TPE'} method`
              : `Intent has ${errors.length} error(s) that must be fixed`,
          }, null, 2),
        }],
      };
    },
  },
  {
    definition: {
      name: 'execute_canvas_intent',
      description: 'Create an optimization study from a Canvas intent. Generates optimization_config.json and study structure.',
      inputSchema: {
        type: 'object',
        properties: {
          intent: {
            type: 'object',
            description: 'The validated OptimizationIntent JSON',
          },
          study_name: {
            type: 'string',
            description: 'Name for the study (snake_case)',
          },
          auto_run: {
            type: 'boolean',
            description: 'Whether to start optimization immediately',
          },
        },
        required: ['intent', 'study_name'],
      },
    },
    handler: async (args) => {
      const { intent, study_name, auto_run } = args as {
        intent: Record<string, unknown>;
        study_name: string;
        auto_run?: boolean;
      };

      try {
        // Call Python study creator
        const result = await runPython(
          'optimization_engine/study/creator.py',
          { intent, study_name, auto_run: auto_run || false }
        );

        return {
          content: [{
            type: 'text',
            text: result,
          }],
        };
      } catch (error) {
        return {
          content: [{
            type: 'text',
            text: `Failed to create study: ${error instanceof Error ? error.message : 'Unknown error'}`,
          }],
          isError: true,
        };
      }
    },
  },
  {
    definition: {
      name: 'interpret_canvas_intent',
      description: 'Analyze a Canvas intent and provide optimization recommendations without creating anything.',
      inputSchema: {
        type: 'object',
        properties: {
          intent: {
            type: 'object',
            description: 'The OptimizationIntent JSON to analyze',
          },
        },
        required: ['intent'],
      },
    },
    handler: async (args) => {
      const intent = args.intent as Record<string, unknown>;
      const objectives = (intent.objectives as any[]) || [];
      const dvs = (intent.design_variables as any[]) || [];
      const opt = intent.optimization as any;

      const analysis = {
        problem_type: objectives.length > 1 ? 'multi-objective' : 'single-objective',
        complexity: dvs.length <= 3 ? 'low' : dvs.length <= 10 ? 'medium' : 'high',
        recommended_method: objectives.length > 1 ? 'NSGA-II' : dvs.length > 10 ? 'CMA-ES' : 'TPE',
        recommended_trials: Math.max(50, dvs.length * 20),
        surrogate_recommended: dvs.length >= 5 || (opt?.max_trials || 100) > 100,
        notes: [] as string[],
      };

      if (opt?.method && opt.method !== analysis.recommended_method) {
        analysis.notes.push(
          `Current method ${opt.method} may not be optimal. Consider ${analysis.recommended_method}.`
        );
      }

      if (analysis.surrogate_recommended && !intent.surrogate) {
        analysis.notes.push(
          'Neural surrogate recommended for this problem complexity. Add a Surrogate node.'
        );
      }

      return {
        content: [{
          type: 'text',
          text: JSON.stringify(analysis, null, 2),
        }],
      };
    },
  },
];

PHASE 8: Final Integration & Testing

T8.1 - Build and Test

# Build MCP server
cd mcp-server/atomizer-tools
npm run build

# Build frontend
cd ../../atomizer-dashboard/frontend
npm run build

# Start dev server
npm run dev

T8.2 - Commit All Changes

git add .
git commit -m "feat: Canvas Professional Upgrade V2

Phase 1: Professional Lucide Icons
- Replace all emoji icons with Lucide React
- Update NodePalette, BaseNode, all node components
- Update templates with icon names

Phase 2: Responsive Full-Screen Canvas
- Canvas adapts to window size
- Compact header, full canvas area
- Floating action buttons

Phase 3: Auto-Load from Config
- Backend endpoint for study configs
- Study browser with search
- loadFromConfig in canvas store

Phase 4: NX Model Introspection
- Backend introspection endpoint
- Extract expressions from .prt/.sim/.fem
- Detect solver type from .sim

Phase 5: Expression Search/Dropdown
- ExpressionSelector component
- Search and filter expressions
- Manual entry fallback

Phase 6: Claude Chat Integration
- useCanvasChat hook with Claude prompts
- Process with Claude button
- Context-aware chat panel

Phase 7: MCP Canvas Tools
- validate_canvas_intent
- execute_canvas_intent
- interpret_canvas_intent

Phase 8: Testing & Polish
- Full build verification
- Dark theme consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

git push origin main && git push github main

ACCEPTANCE CRITERIA

Before marking complete, verify:

  1. npm run build passes in frontend
  2. npm run build passes in MCP server
  3. Canvas displays with Lucide icons (no emojis)
  4. Canvas fills available screen space
  5. Nodes can be dragged from palette
  6. Clicking a node opens config panel
  7. Config panel has dark theme styling
  8. Expression dropdown appears for design variables
  9. Import button shows study browser
  10. "Process with Claude" button triggers chat
  11. Templates modal opens and shows templates
  12. All text is readable (white on dark)

BEGIN EXECUTION

Execute all phases sequentially. Use TodoWrite to track progress. Complete each phase fully before moving to the next. Do not stop between phases.

GO.