Files
OpenNoodl/dev-docs/tasks/phase-5-multi-target-deployment/05-target-system/README.md

42 KiB
Raw Blame History

Phase E: Target System Core

Overview

The Target System Core is the foundational infrastructure that enables Noodl to support multiple deployment targets. It provides the underlying systems for target selection, node compatibility validation, conditional compilation, and target-aware UX across the editor.

Timeline: 2-3 weeks
Priority: Critical Foundation - Must complete before Phases B, C, D
Prerequisites: None (runs in parallel with Phase A)

Core Concept

Noodl's visual graph is inherently target-agnostic. Nodes define what happens, not where it runs. The Target System Core provides the infrastructure to:

  1. Let users select their deployment target(s)
  2. Show which nodes work on which targets
  3. Prevent invalid deployments at build time
  4. Enable target-specific runtime injection

Target Types

enum TargetType {
  WEB = 'web',              // Default - browser deployment
  CAPACITOR = 'capacitor',  // iOS/Android via Capacitor
  ELECTRON = 'electron',    // Desktop via Electron
  EXTENSION = 'extension',  // Chrome Extension
}

interface TargetDefinition {
  type: TargetType;
  displayName: string;
  icon: string;
  description: string;
  runtimePackage: string;           // e.g., 'noodl-runtime-capacitor'
  previewSupported: boolean;
  exportFormats: ExportFormat[];
}

const TARGETS: Record<TargetType, TargetDefinition> = {
  [TargetType.WEB]: {
    type: TargetType.WEB,
    displayName: 'Web',
    icon: '🌐',
    description: 'Deploy as a web application',
    runtimePackage: 'noodl-runtime',
    previewSupported: true,
    exportFormats: ['folder', 'netlify', 'vercel', 'cloudflare'],
  },
  [TargetType.CAPACITOR]: {
    type: TargetType.CAPACITOR,
    displayName: 'Mobile',
    icon: '📱',
    description: 'iOS and Android via Capacitor',
    runtimePackage: 'noodl-runtime-capacitor',
    previewSupported: true,
    exportFormats: ['capacitor-project', 'ios-xcode', 'android-studio'],
  },
  [TargetType.ELECTRON]: {
    type: TargetType.ELECTRON,
    displayName: 'Desktop',
    icon: '🖥️',
    description: 'Windows, macOS, and Linux via Electron',
    runtimePackage: 'noodl-runtime-electron',
    previewSupported: true,
    exportFormats: ['electron-project', 'dmg', 'exe', 'deb', 'appimage'],
  },
  [TargetType.EXTENSION]: {
    type: TargetType.EXTENSION,
    displayName: 'Browser Extension',
    icon: '🧩',
    description: 'Chrome extension',
    runtimePackage: 'noodl-runtime',
    previewSupported: false, // Requires loading as unpacked
    exportFormats: ['extension-folder', 'extension-zip'],
  },
};

Project Configuration Model

Target Configuration

interface ProjectTargetConfig {
  primary: TargetType;                    // Main target (determines default palette)
  enabled: TargetType[];                  // All enabled targets
  
  // Per-target configuration
  web?: WebTargetConfig;
  capacitor?: CapacitorTargetConfig;
  electron?: ElectronTargetConfig;
  extension?: ExtensionTargetConfig;
}

interface WebTargetConfig {
  // Standard web config
}

interface CapacitorTargetConfig {
  appId: string;                          // com.company.app
  appName: string;
  version: string;
  
  ios?: {
    teamId: string;
    deploymentTarget: string;
  };
  
  android?: {
    packageName: string;
    minSdkVersion: number;
  };
  
  plugins: string[];                      // Capacitor plugins to include
  permissions: CapacitorPermission[];     // Auto-detected from nodes
}

interface ElectronTargetConfig {
  appId: string;
  appName: string;
  version: string;
  
  platforms: ('darwin' | 'win32' | 'linux')[];
  
  permissions: {
    fileSystem: boolean;
    processExecution: boolean;
    systemTray: boolean;
  };
  
  windowDefaults: {
    width: number;
    height: number;
    minWidth?: number;
    minHeight?: number;
  };
}

interface ExtensionTargetConfig {
  name: string;
  version: string;
  description: string;
  
  permissions: string[];
  hostPermissions: string[];
  
  contexts: {
    popup: boolean;
    background: boolean;
    contentScript: boolean;
  };
  
  contentScriptMatches: string[];
}

Storage in Project Metadata

// In project.json
{
  "name": "My App",
  "version": "1.0.0",
  
  "targets": {
    "primary": "capacitor",
    "enabled": ["web", "capacitor"],
    
    "capacitor": {
      "appId": "com.mycompany.myapp",
      "appName": "My App",
      "version": "1.0.0"
    }
  },
  
  // ... other project config
}

Node Compatibility System

Compatibility Declaration

Every node declares its target compatibility:

interface NodeDefinition {
  name: string;
  displayName: string;
  category: string;
  
  // Target compatibility
  targetCompatibility: TargetType[] | 'all';
  
  // Per-target implementation
  implementations?: {
    [key in TargetType]?: {
      runtime: string;            // Different runtime file
      warnings?: string[];        // Limitations on this target
    };
  };
  
  // ... other node properties
}

Compatibility Patterns

Universal Nodes (work everywhere):

{
  name: 'Group',
  targetCompatibility: 'all',
  // Single implementation works on all targets
}

Platform-Specific Nodes (single target):

{
  name: 'Camera Capture',
  targetCompatibility: [TargetType.CAPACITOR],
  // Only available when Capacitor is enabled
}

Multi-Platform with Different Implementations:

{
  name: 'File Picker',
  targetCompatibility: [TargetType.WEB, TargetType.ELECTRON, TargetType.CAPACITOR],
  implementations: {
    [TargetType.WEB]: {
      runtime: 'file-picker-web.ts',
      warnings: ['Limited to browser file picker API'],
    },
    [TargetType.ELECTRON]: {
      runtime: 'file-picker-electron.ts',
      // Full native file dialog
    },
    [TargetType.CAPACITOR]: {
      runtime: 'file-picker-capacitor.ts',
      warnings: ['On iOS, requires photo library permission'],
    },
  },
}

Graceful Degradation:

{
  name: 'Haptic Feedback',
  targetCompatibility: [TargetType.CAPACITOR, TargetType.WEB],
  implementations: {
    [TargetType.CAPACITOR]: {
      runtime: 'haptic-capacitor.ts',
      // Full haptic API
    },
    [TargetType.WEB]: {
      runtime: 'haptic-web.ts',
      warnings: ['Limited to Vibration API - may not work on all devices'],
    },
  },
}

Compatibility Registry

class NodeCompatibilityRegistry {
  private static instance: NodeCompatibilityRegistry;
  private registry: Map<string, TargetType[]> = new Map();
  
  registerNode(nodeType: string, compatibility: TargetType[] | 'all'): void {
    if (compatibility === 'all') {
      this.registry.set(nodeType, Object.values(TargetType));
    } else {
      this.registry.set(nodeType, compatibility);
    }
  }
  
  isCompatible(nodeType: string, target: TargetType): boolean {
    const compatibility = this.registry.get(nodeType);
    return compatibility?.includes(target) ?? false;
  }
  
  getCompatibleTargets(nodeType: string): TargetType[] {
    return this.registry.get(nodeType) ?? [];
  }
  
  getNodesForTarget(target: TargetType): string[] {
    return Array.from(this.registry.entries())
      .filter(([_, targets]) => targets.includes(target))
      .map(([nodeType, _]) => nodeType);
  }
  
  getIncompatibleNodes(usedNodes: string[], target: TargetType): string[] {
    return usedNodes.filter(node => !this.isCompatible(node, target));
  }
}

Node Palette Filtering

Dynamic Palette Based on Target

When a user selects their primary target, the node palette updates to show compatible nodes:

class NodePaletteService {
  private currentTarget: TargetType;
  private enabledTargets: TargetType[];
  
  setTargets(primary: TargetType, enabled: TargetType[]): void {
    this.currentTarget = primary;
    this.enabledTargets = enabled;
    this.refreshPalette();
  }
  
  getVisibleNodes(): NodeDefinition[] {
    return allNodes.filter(node => {
      // Node is visible if compatible with ANY enabled target
      const compatibility = node.targetCompatibility === 'all' 
        ? Object.values(TargetType)
        : node.targetCompatibility;
        
      return compatibility.some(t => this.enabledTargets.includes(t));
    });
  }
  
  getNodeCategories(): Category[] {
    const visibleNodes = this.getVisibleNodes();
    
    // Group by category, add target-specific categories
    const categories = new Map<string, NodeDefinition[]>();
    
    visibleNodes.forEach(node => {
      const existing = categories.get(node.category) ?? [];
      categories.set(node.category, [...existing, node]);
    });
    
    // Add target-specific category headers
    if (this.enabledTargets.includes(TargetType.CAPACITOR)) {
      // Ensure "Mobile" category exists
    }
    if (this.enabledTargets.includes(TargetType.ELECTRON)) {
      // Ensure "Desktop" category exists
    }
    
    return Array.from(categories.entries()).map(([name, nodes]) => ({
      name,
      nodes,
    }));
  }
}

Compatibility Badges in UI

Nodes display badges indicating their target compatibility:

┌──────────────────────────────────────────────────────────────┐
│ Node Palette                                          [🔍]  │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ▼ UI Elements                                               │
│    ├── Text                                    [🌐📱🖥️🧩]   │
│    ├── Button                                  [🌐📱🖥️🧩]   │
│    ├── Image                                   [🌐📱🖥️🧩]   │
│    └── Video                                   [🌐📱🖥️]     │
│                                                              │
│  ▼ Mobile                                                    │
│    ├── Camera Capture                          [📱]          │
│    ├── Push Notifications                      [📱]          │
│    ├── Haptic Feedback                         [📱🌐*]       │
│    └── Geolocation                             [📱🌐]        │
│                                                              │
│  ▼ Desktop                                                   │
│    ├── Read File                               [🖥️]          │
│    ├── Write File                              [🖥️]          │
│    ├── Run Process                             [🖥️]          │
│    └── System Tray                             [🖥️]          │
│                                                              │
│  ▼ Extension                                                 │
│    ├── Storage Get                             [🧩]          │
│    ├── Tab Query                               [🧩]          │
│    └── Send Message                            [🧩]          │
│                                                              │
│  * = Limited functionality on this target                    │
└──────────────────────────────────────────────────────────────┘

Badge Component

interface CompatibilityBadgeProps {
  targets: TargetType[];
  warnings?: Partial<Record<TargetType, string>>;
}

function CompatibilityBadge({ targets, warnings }: CompatibilityBadgeProps) {
  return (
    <div className="compatibility-badge">
      {targets.map(target => (
        <span 
          key={target}
          className={`target-icon ${warnings?.[target] ? 'has-warning' : ''}`}
          title={warnings?.[target] || TARGETS[target].displayName}
        >
          {TARGETS[target].icon}
          {warnings?.[target] && <span className="warning-indicator">*</span>}
        </span>
      ))}
    </div>
  );
}

Build-Time Validation

Validation Service

interface ValidationResult {
  valid: boolean;
  errors: ValidationError[];
  warnings: ValidationWarning[];
}

interface ValidationError {
  type: 'incompatible_node' | 'missing_config' | 'invalid_permission';
  nodeId?: string;
  nodeName?: string;
  message: string;
  target: TargetType;
}

interface ValidationWarning {
  type: 'limited_functionality' | 'performance' | 'permission_broad';
  nodeId?: string;
  message: string;
  target: TargetType;
}

class TargetValidationService {
  validate(project: Project, target: TargetType): ValidationResult {
    const errors: ValidationError[] = [];
    const warnings: ValidationWarning[] = [];
    
    // 1. Check all used nodes are compatible
    const usedNodes = this.getUsedNodes(project);
    usedNodes.forEach(usage => {
      const compatibility = NodeCompatibilityRegistry.getInstance()
        .getCompatibleTargets(usage.nodeType);
      
      if (!compatibility.includes(target)) {
        errors.push({
          type: 'incompatible_node',
          nodeId: usage.nodeId,
          nodeName: usage.displayName,
          message: `"${usage.displayName}" is not available on ${TARGETS[target].displayName}`,
          target,
        });
      }
      
      // Check for limited functionality warnings
      const nodeDefinition = getNodeDefinition(usage.nodeType);
      const targetWarnings = nodeDefinition.implementations?.[target]?.warnings;
      if (targetWarnings) {
        warnings.push({
          type: 'limited_functionality',
          nodeId: usage.nodeId,
          message: targetWarnings.join('; '),
          target,
        });
      }
    });
    
    // 2. Check target-specific configuration
    const targetConfig = project.targets[target];
    if (!targetConfig && target !== TargetType.WEB) {
      errors.push({
        type: 'missing_config',
        message: `${TARGETS[target].displayName} target is not configured`,
        target,
      });
    }
    
    // 3. Validate permissions
    const requiredPermissions = this.detectRequiredPermissions(usedNodes, target);
    const configuredPermissions = targetConfig?.permissions ?? [];
    
    requiredPermissions.forEach(perm => {
      if (!configuredPermissions.includes(perm)) {
        warnings.push({
          type: 'permission_broad',
          message: `Permission "${perm}" may be required for ${TARGETS[target].displayName}`,
          target,
        });
      }
    });
    
    return {
      valid: errors.length === 0,
      errors,
      warnings,
    };
  }
  
  private getUsedNodes(project: Project): NodeUsage[] {
    // Traverse all components and collect used nodes
    const usages: NodeUsage[] = [];
    
    project.components.forEach(component => {
      component.nodes.forEach(node => {
        usages.push({
          nodeId: node.id,
          nodeType: node.type,
          displayName: node.displayName,
          componentId: component.id,
        });
      });
    });
    
    return usages;
  }
  
  private detectRequiredPermissions(
    usedNodes: NodeUsage[], 
    target: TargetType
  ): string[] {
    const permissions = new Set<string>();
    
    usedNodes.forEach(usage => {
      const nodePerms = getNodePermissions(usage.nodeType, target);
      nodePerms.forEach(p => permissions.add(p));
    });
    
    return Array.from(permissions);
  }
}

Pre-Export Validation UI

┌──────────────────────────────────────────────────────────────┐
│ Export to Mobile                                       [×]   │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ⚠️ 2 issues found                                          │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ ❌ ERRORS (1)                                          │ │
│  │                                                        │ │
│  │ • "Read File" node is not available on Mobile          │ │
│  │   Used in: SettingsScreen (line 47)                    │ │
│  │   [Go to Node] [Remove Node] [Use Alternative]         │ │
│  │                                                        │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ ⚠️ WARNINGS (1)                                        │ │
│  │                                                        │ │
│  │ • "Haptic Feedback" has limited functionality on Web   │ │
│  │   Vibration API may not work on all devices           │ │
│  │   Used in: ButtonComponent                             │ │
│  │                                                        │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
│                              [Cancel] [Export with Warnings] │
└──────────────────────────────────────────────────────────────┘

Duplicate as Target Feature

When a user wants to adapt a component for a different target, the "Duplicate as Target" feature analyzes compatibility and suggests adaptations.

Analysis Engine

interface TargetAdaptationAnalysis {
  compatible: boolean;
  adaptations: Adaptation[];
  blockers: Blocker[];
}

interface Adaptation {
  nodeId: string;
  nodeName: string;
  adaptationType: 'replace' | 'remove' | 'configure';
  
  // For 'replace'
  replacement?: {
    nodeType: string;
    displayName: string;
    reason: string;
  };
  
  // For 'configure'
  configChanges?: {
    property: string;
    oldValue: any;
    newValue: any;
    reason: string;
  }[];
  
  // For 'remove'
  removalReason?: string;
  affectedConnections: string[];
}

interface Blocker {
  nodeId: string;
  nodeName: string;
  reason: string;
  suggestion?: string;
}

class TargetAdaptationService {
  analyze(
    component: Component, 
    sourceTarget: TargetType, 
    destTarget: TargetType
  ): TargetAdaptationAnalysis {
    const adaptations: Adaptation[] = [];
    const blockers: Blocker[] = [];
    
    component.nodes.forEach(node => {
      const sourceCompat = NodeCompatibilityRegistry.getInstance()
        .isCompatible(node.type, sourceTarget);
      const destCompat = NodeCompatibilityRegistry.getInstance()
        .isCompatible(node.type, destTarget);
      
      if (sourceCompat && !destCompat) {
        // Node works on source but not dest - needs adaptation
        const alternative = this.findAlternative(node.type, destTarget);
        
        if (alternative) {
          adaptations.push({
            nodeId: node.id,
            nodeName: node.displayName,
            adaptationType: 'replace',
            replacement: {
              nodeType: alternative.nodeType,
              displayName: alternative.displayName,
              reason: alternative.reason,
            },
          });
        } else if (this.isOptional(node)) {
          adaptations.push({
            nodeId: node.id,
            nodeName: node.displayName,
            adaptationType: 'remove',
            removalReason: `No ${TARGETS[destTarget].displayName} equivalent`,
            affectedConnections: this.getConnections(node),
          });
        } else {
          blockers.push({
            nodeId: node.id,
            nodeName: node.displayName,
            reason: `No ${TARGETS[destTarget].displayName} equivalent`,
            suggestion: this.getSuggestion(node.type, destTarget),
          });
        }
      }
    });
    
    return {
      compatible: blockers.length === 0,
      adaptations,
      blockers,
    };
  }
  
  private findAlternative(
    nodeType: string, 
    target: TargetType
  ): { nodeType: string; displayName: string; reason: string } | null {
    // Mapping of nodes to their alternatives on different targets
    const alternatives: Record<string, Partial<Record<TargetType, string>>> = {
      'ReadFile': {
        [TargetType.CAPACITOR]: 'CapacitorFilesystem',
        [TargetType.WEB]: null, // No alternative
      },
      'NativeFilePicker': {
        [TargetType.WEB]: 'FileInput',
        [TargetType.CAPACITOR]: 'CapacitorFilePicker',
      },
      'CameraCapture': {
        [TargetType.WEB]: 'MediaDevicesCamera',
      },
      // ... more mappings
    };
    
    const alt = alternatives[nodeType]?.[target];
    if (alt) {
      const altDef = getNodeDefinition(alt);
      return {
        nodeType: alt,
        displayName: altDef.displayName,
        reason: `Equivalent functionality for ${TARGETS[target].displayName}`,
      };
    }
    
    return null;
  }
  
  apply(
    component: Component, 
    analysis: TargetAdaptationAnalysis
  ): Component {
    const adapted = cloneComponent(component);
    
    analysis.adaptations.forEach(adaptation => {
      switch (adaptation.adaptationType) {
        case 'replace':
          this.replaceNode(adapted, adaptation.nodeId, adaptation.replacement!);
          break;
        case 'remove':
          this.removeNode(adapted, adaptation.nodeId);
          break;
        case 'configure':
          this.configureNode(adapted, adaptation.nodeId, adaptation.configChanges!);
          break;
      }
    });
    
    return adapted;
  }
}

Adaptation UI

┌──────────────────────────────────────────────────────────────┐
│ Duplicate "FileManager" for Mobile                     [×]   │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Analysis: 3 nodes need adaptation                           │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ 🔄 REPLACEMENTS (2)                                    │ │
│  │                                                        │ │
│  │ ☑ "Read File" → "Capacitor Filesystem Read"            │ │
│  │   Equivalent functionality for Mobile                  │ │
│  │                                                        │ │
│  │ ☑ "Native File Picker" → "Capacitor File Picker"       │ │
│  │   Equivalent functionality for Mobile                  │ │
│  │                                                        │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ 🗑️ REMOVALS (1)                                        │ │
│  │                                                        │ │
│  │ ☑ "Watch Directory" - No Mobile equivalent             │ │
│  │   ⚠️ Will disconnect: onChange → UpdateList            │ │
│  │                                                        │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
│  New component name: [FileManager_Mobile________]            │
│                                                              │
│                                     [Cancel] [Create Copy]   │
└──────────────────────────────────────────────────────────────┘

Target Selection UI

Project Creation Flow

┌──────────────────────────────────────────────────────────────┐
│ Create New Project                                     [×]   │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Project Name: [My App_________________________]             │
│                                                              │
│  ── Primary Target ─────────────────────────────────────    │
│  What are you building?                                      │
│                                                              │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐            │
│  │     🌐      │ │     📱      │ │     🖥️      │            │
│  │    Web      │ │   Mobile    │ │   Desktop   │            │
│  │             │ │  (iOS/And)  │ │ (Win/Mac)   │            │
│  │  [Selected] │ │             │ │             │            │
│  └─────────────┘ └─────────────┘ └─────────────┘            │
│                                                              │
│  ┌─────────────┐                                             │
│  │     🧩      │                                             │
│  │  Extension  │                                             │
│  │  (Chrome)   │                                             │
│  └─────────────┘                                             │
│                                                              │
│   You can enable additional targets later in Settings     │
│                                                              │
│                                            [Cancel] [Create] │
└──────────────────────────────────────────────────────────────┘

Project Settings - Targets Panel

┌──────────────────────────────────────────────────────────────┐
│ Project Settings                                             │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  [General] [Targets] [Backends] [Version Control]            │
│            ────────                                          │
│                                                              │
│  ── Enabled Targets ────────────────────────────────────    │
│                                                              │
│  ☑ 🌐 Web (Primary)                              [Configure] │
│      Standard web deployment                                 │
│                                                              │
│  ☑ 📱 Mobile                                     [Configure] │
│      iOS and Android via Capacitor                          │
│      App ID: com.mycompany.myapp                            │
│                                                              │
│  ☐ 🖥️ Desktop                                    [Enable]    │
│      Windows, macOS, Linux via Electron                     │
│                                                              │
│  ☐ 🧩 Browser Extension                          [Enable]    │
│      Chrome Extension                                        │
│                                                              │
│  ── Primary Target ─────────────────────────────────────    │
│  [Web ▾]                                                     │
│  The primary target determines the default node palette     │
│                                                              │
│                                                  [Save]      │
└──────────────────────────────────────────────────────────────┘

Target Configuration Modals

Clicking "Configure" opens target-specific settings:

Capacitor Configuration:

┌──────────────────────────────────────────────────────────────┐
│ Mobile Target Settings                                 [×]   │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ── App Identity ───────────────────────────────────────    │
│  App ID: [com.mycompany.myapp_______]                        │
│  App Name: [My App__________________]                        │
│  Version: [1.0.0____]                                        │
│                                                              │
│  ── iOS Settings ───────────────────────────────────────    │
│  Team ID: [ABCD1234__]                                       │
│  Deployment Target: [14.0 ▾]                                 │
│                                                              │
│  ── Android Settings ───────────────────────────────────    │
│  Min SDK Version: [24 ▾]                                     │
│                                                              │
│  ── Detected Permissions ───────────────────────────────    │
│  Based on nodes used in your project:                        │
│  ☑ Camera (Camera Capture node)                              │
│  ☑ Location (Geolocation node)                               │
│  ☑ Push Notifications (Push node)                            │
│                                                              │
│                                          [Cancel] [Save]     │
└──────────────────────────────────────────────────────────────┘

Runtime Injection

Conditional Runtime Loading

Different targets require different runtime packages:

class RuntimeInjector {
  private target: TargetType;
  
  constructor(target: TargetType) {
    this.target = target;
  }
  
  getRuntime(): RuntimeBundle {
    switch (this.target) {
      case TargetType.WEB:
        return {
          core: 'noodl-runtime',
          nodes: 'noodl-nodes-web',
        };
        
      case TargetType.CAPACITOR:
        return {
          core: 'noodl-runtime',
          nodes: 'noodl-nodes-capacitor',
          bridge: '@capacitor/core',
          plugins: this.getCapacitorPlugins(),
        };
        
      case TargetType.ELECTRON:
        return {
          core: 'noodl-runtime',
          nodes: 'noodl-nodes-electron',
          preload: 'electron-preload',
        };
        
      case TargetType.EXTENSION:
        return {
          core: 'noodl-runtime',
          nodes: 'noodl-nodes-extension',
        };
    }
  }
  
  private getCapacitorPlugins(): string[] {
    // Based on nodes used in project
    const usedNodes = ProjectModel.getInstance().getUsedNodeTypes();
    const plugins: string[] = [];
    
    if (usedNodes.includes('CameraCapture')) {
      plugins.push('@capacitor/camera');
    }
    if (usedNodes.includes('Geolocation')) {
      plugins.push('@capacitor/geolocation');
    }
    if (usedNodes.includes('PushNotifications')) {
      plugins.push('@capacitor/push-notifications');
    }
    // ... more mappings
    
    return plugins;
  }
  
  inject(bundle: RuntimeBundle): string {
    // Generate import statements and initialization code
    let code = `
      import { Noodl } from '${bundle.core}';
      import * as nodes from '${bundle.nodes}';
    `;
    
    if (bundle.bridge) {
      code += `import { Capacitor } from '${bundle.bridge}';\n`;
    }
    
    if (bundle.plugins) {
      bundle.plugins.forEach(plugin => {
        const name = this.getPluginName(plugin);
        code += `import { ${name} } from '${plugin}';\n`;
      });
    }
    
    code += `
      Noodl.registerNodes(nodes);
      ${this.target !== TargetType.WEB ? `Noodl.setTarget('${this.target}');` : ''}
    `;
    
    return code;
  }
}

Preview Mode Runtime Selection

class PreviewService {
  private target: TargetType;
  
  setPreviewTarget(target: TargetType): void {
    this.target = target;
    this.reloadPreview();
  }
  
  getPreviewFrame(): string {
    switch (this.target) {
      case TargetType.WEB:
        return 'standard-preview-frame';
        
      case TargetType.CAPACITOR:
        // Mobile dimensions, inject Capacitor bridge
        return 'capacitor-preview-frame';
        
      case TargetType.ELECTRON:
        // Enable IPC bridge to main process
        return 'electron-preview-frame';
        
      case TargetType.EXTENSION:
        // Popup dimensions, inject mock chrome API
        return 'extension-preview-frame';
    }
  }
}

Preview Toolbar Target Selector

┌──────────────────────────────────────────────────────────────┐
│ Preview                           [Web ▾] [Device ▾] [↻]    │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │                                                         ││
│  │              Preview Content                            ││
│  │                                                         ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
└──────────────────────────────────────────────────────────────┘

Target Dropdown:
┌───────────────┐
│ 🌐 Web        │ ← Current
├───────────────┤
│ 📱 Mobile     │
│ 🖥️ Desktop    │
│ 🧩 Extension  │
└───────────────┘

Implementation Phases

Phase 1: Target Configuration Model (3-4 days)

Goal: Implement project-level target configuration

Tasks:

  1. Define TypeScript interfaces for all target configs
  2. Add targets field to project.json schema
  3. Create TargetConfigService for CRUD operations
  4. Migrate existing projects (add default web target)

Files to Create:

packages/noodl-editor/src/editor/src/models/
├── TargetConfig.ts
├── TargetType.ts
└── targets/
    ├── WebTargetConfig.ts
    ├── CapacitorTargetConfig.ts
    ├── ElectronTargetConfig.ts
    └── ExtensionTargetConfig.ts

packages/noodl-editor/src/editor/src/services/
└── TargetConfigService.ts

Verification:

  • Target config saved/loaded correctly
  • Default web target for new projects
  • Migration works for existing projects

Phase 2: Node Compatibility System (4-5 days)

Goal: Implement node compatibility declaration and registry

Tasks:

  1. Add targetCompatibility to NodeDefinition
  2. Create NodeCompatibilityRegistry
  3. Add compatibility to all existing nodes (mark as 'all' for universal)
  4. Create compatibility badge component

Files to Create:

packages/noodl-editor/src/editor/src/models/
└── NodeCompatibility.ts

packages/noodl-editor/src/editor/src/services/
└── NodeCompatibilityRegistry.ts

packages/noodl-editor/src/editor/src/components/
└── CompatibilityBadge/
    ├── CompatibilityBadge.tsx
    └── CompatibilityBadge.module.css

Verification:

  • All nodes have compatibility declared
  • Registry correctly filters nodes by target
  • Badges render correctly in palette

Phase 3: Build-Time Validation (3-4 days)

Goal: Prevent deploying incompatible nodes

Tasks:

  1. Create TargetValidationService
  2. Integrate validation into export flow
  3. Create validation error UI
  4. Add "Go to Node" action for errors

Files to Create:

packages/noodl-editor/src/editor/src/services/
└── TargetValidationService.ts

packages/noodl-editor/src/editor/src/views/
└── ValidationErrorsPanel/
    ├── ValidationErrorsPanel.tsx
    └── ValidationErrorsPanel.module.css

Verification:

  • Incompatible nodes detected correctly
  • Export blocked with clear error message
  • "Go to Node" navigates to problem node

Phase 4: Target Selection UI (3-4 days)

Goal: User-facing target management

Tasks:

  1. Update project creation dialog with target selection
  2. Create Targets panel in Project Settings
  3. Create target configuration modals
  4. Add preview target selector

Files to Create:

packages/noodl-editor/src/editor/src/views/
├── CreateProjectDialog/
│   └── TargetSelector.tsx
├── ProjectSettings/
│   └── TargetsPanel/
│       ├── TargetsPanel.tsx
│       └── TargetConfigModal.tsx
└── PreviewToolbar/
    └── TargetSelector.tsx

Verification:

  • New projects prompt for target
  • Targets panel shows enabled targets
  • Configuration modals save correctly
  • Preview switches targets

Phase 5: Duplicate as Target (2-3 days)

Goal: Intelligent component adaptation

Tasks:

  1. Create TargetAdaptationService
  2. Define node alternative mappings
  3. Create adaptation preview UI
  4. Implement apply logic

Files to Create:

packages/noodl-editor/src/editor/src/services/
└── TargetAdaptationService.ts

packages/noodl-editor/src/editor/src/views/
└── DuplicateAsTargetDialog/
    ├── DuplicateAsTargetDialog.tsx
    └── AdaptationPreview.tsx

Verification:

  • Analysis correctly identifies incompatibilities
  • Alternatives suggested where available
  • Apply creates working adapted component

Dependencies

External Packages

None for core target system - it's purely editor infrastructure.

Internal Dependencies

  • ProjectModel (for project configuration)
  • NodeLibrary (for node definitions)
  • Compilation service (for validation integration)
  • Export services (for build-time checks)

Risk Assessment

Risk Likelihood Impact Mitigation
Missing node compatibility High Medium Default to 'all', refine over time
Complex migration Medium High Thorough testing on real projects
UI complexity Medium Medium Start with minimal UI, iterate
Alternative mappings incomplete High Low Document as "best effort"

Success Metrics

Phase Complete:

  • All nodes have compatibility declared
  • Target selection UI functional
  • Build-time validation prevents bad deploys
  • Duplicate as Target works for common cases

Integration Ready:

  • Phases B, C, D can build on this foundation
  • Preview mode switches targets correctly
  • Export respects target configuration