setAnswer(grid)}
+ disabled={done || phase === 'wrong-shake'}
+ />
+ )}
{/* Wrong attempt feedback inline */}
{phase === 'wrong-shake' && (
diff --git a/src/components/workbench/modules/code-editor/CodeEditorWorkbench.tsx b/src/components/workbench/modules/code-editor/CodeEditorWorkbench.tsx
new file mode 100644
index 0000000..8c3ae65
--- /dev/null
+++ b/src/components/workbench/modules/code-editor/CodeEditorWorkbench.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import { useState, useCallback, useRef } from 'react';
+import Editor from '@monaco-editor/react';
+import { CodeEditorContent } from '@/types/challenge';
+import { Play, CheckCircle2, XCircle, Maximize2, Minimize2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { createPortal } from 'react-dom';
+
+interface CodeEditorWorkbenchProps {
+ content: CodeEditorContent;
+ onCodeChange: (code: string) => void;
+ disabled?: boolean;
+}
+
+const LANG_MAP: Record = {
+ asm: 'mips',
+ c: 'c',
+ verilog: 'systemverilog',
+ html: 'html',
+ javascript: 'javascript',
+};
+
+// Simple JS evaluator for test cases
+function runCode(code: string, input: string, language: string): { output: string; error?: string } {
+ if (language === 'javascript') {
+ try {
+ const fn = new Function('input', `${code}\n`);
+ const result = fn(input);
+ return { output: String(result ?? '') };
+ } catch (e: unknown) {
+ return { output: '', error: String(e) };
+ }
+ }
+ // For non-JS languages, just return the code itself for verification
+ return { output: code.trim() };
+}
+
+export function CodeEditorWorkbench({ content, onCodeChange, disabled }: CodeEditorWorkbenchProps) {
+ const [code, setCode] = useState(content.starterCode);
+ const [testResults, setTestResults] = useState>([]);
+ const [fullscreen, setFullscreen] = useState(false);
+ const editorRef = useRef(null);
+
+ const handleEditorChange = useCallback((value: string | undefined) => {
+ const v = value ?? '';
+ setCode(v);
+ onCodeChange(v);
+ }, [onCodeChange]);
+
+ const handleRun = useCallback(() => {
+ const results = content.testCases.map((tc) => {
+ const { output, error } = runCode(code, tc.input, content.language);
+ const passed = output.trim() === tc.expectedOutput.trim();
+ return { passed, actual: output.trim(), error };
+ });
+ setTestResults(results);
+ }, [code, content]);
+
+ const allPassed = testResults.length > 0 && testResults.every((r) => r.passed);
+
+ const editorComponent = (fs: boolean) => (
+
+ { editorRef.current = editor; }}
+ theme="vs-dark"
+ options={{
+ minimap: { enabled: false },
+ fontSize: 14,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ readOnly: disabled,
+ automaticLayout: true,
+ tabSize: 2,
+ wordWrap: 'on',
+ }}
+ />
+
+ );
+
+ const testPanel = (
+
+
+
+ {allPassed &&
Todos los tests pasan}
+
+
+
+
+ {content.testCases.map((tc, i) => {
+ const result = testResults[i];
+ return (
+
+
+ {result && (result.passed
+ ?
+ :
+ )}
+ {tc.label || `Test ${i + 1}`}
+
+ {tc.input &&
Input: {tc.input}
}
+
Esperado: {tc.expectedOutput}
+ {result &&
Tu resultado: {result.actual || (vacío)}
}
+ {result?.error &&
{result.error}
}
+
+ );
+ })}
+
+ );
+
+ // Fullscreen portal
+ const fullscreenOverlay = fullscreen && createPortal(
+ { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}>
+
+
{editorComponent(true)}
+
+ {testPanel}
+
+
+
,
+ document.body
+ );
+
+ return (
+
+
+ {editorComponent(false)}
+
+ {testPanel}
+ {fullscreenOverlay}
+
+ );
+}
diff --git a/src/components/workbench/modules/pixel-editor/PixelEditor.tsx b/src/components/workbench/modules/pixel-editor/PixelEditor.tsx
new file mode 100644
index 0000000..d01f980
--- /dev/null
+++ b/src/components/workbench/modules/pixel-editor/PixelEditor.tsx
@@ -0,0 +1,228 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { PixelEditorContent } from '@/types/challenge';
+import { Maximize2, Minimize2, Eraser, Trash2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { createPortal } from 'react-dom';
+
+interface PixelEditorProps {
+ content: PixelEditorContent;
+ onGridChange: (grid: string) => void;
+ disabled?: boolean;
+}
+
+export function PixelEditor({ content, onGridChange, disabled }: PixelEditorProps) {
+ const { width, height, palette } = content;
+ const [grid, setGrid] = useState(() =>
+ Array.from({ length: height }, () => Array(width).fill(0))
+ );
+ const [selectedColor, setSelectedColor] = useState(1);
+ const [isErasing, setIsErasing] = useState(false);
+ const [isDrawing, setIsDrawing] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+ const canvasRef = useRef(null);
+
+ // Sync state
+ useEffect(() => {
+ onGridChange(JSON.stringify(grid));
+ }, [grid, onGridChange]);
+
+ // Draw grid on canvas
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const cellW = canvas.width / width;
+ const cellH = canvas.height / height;
+
+ // Background
+ ctx.fillStyle = '#0a0a0a';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Cells
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const colorIdx = grid[y][x];
+ ctx.fillStyle = palette[colorIdx] || '#000';
+ ctx.fillRect(x * cellW, y * cellH, cellW, cellH);
+ }
+ }
+
+ // Grid lines
+ ctx.strokeStyle = '#333';
+ ctx.lineWidth = 0.5;
+ for (let x = 0; x <= width; x++) {
+ ctx.beginPath();
+ ctx.moveTo(x * cellW, 0);
+ ctx.lineTo(x * cellW, canvas.height);
+ ctx.stroke();
+ }
+ for (let y = 0; y <= height; y++) {
+ ctx.beginPath();
+ ctx.moveTo(0, y * cellH);
+ ctx.lineTo(canvas.width, y * cellH);
+ ctx.stroke();
+ }
+
+ // Target overlay if in match mode
+ if (content.mode === 'match' && content.targetImage) {
+ // Draw small target preview in corner
+ const previewSize = Math.min(canvas.width, canvas.height) * 0.25;
+ const px = canvas.width - previewSize - 4;
+ const py = 4;
+ const pcW = previewSize / width;
+ const pcH = previewSize / height;
+ ctx.globalAlpha = 0.8;
+ ctx.fillStyle = '#000';
+ ctx.fillRect(px - 2, py - 2, previewSize + 4, previewSize + 4);
+ for (let y2 = 0; y2 < height; y2++) {
+ for (let x2 = 0; x2 < width; x2++) {
+ const ci = content.targetImage[y2]?.[x2] ?? 0;
+ ctx.fillStyle = palette[ci] || '#000';
+ ctx.fillRect(px + x2 * pcW, py + y2 * pcH, pcW, pcH);
+ }
+ }
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(px - 2, py - 2, previewSize + 4, previewSize + 4);
+ ctx.fillStyle = '#fff';
+ ctx.font = '10px monospace';
+ ctx.fillText('Objetivo', px, py + previewSize + 14);
+ }
+ }, [grid, width, height, palette, content]);
+
+ const getCell = useCallback((e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return null;
+ const rect = canvas.getBoundingClientRect();
+ const x = Math.floor(((e.clientX - rect.left) / rect.width) * width);
+ const y = Math.floor(((e.clientY - rect.top) / rect.height) * height);
+ if (x < 0 || x >= width || y < 0 || y >= height) return null;
+ return { x, y };
+ }, [width, height]);
+
+ const paint = useCallback((x: number, y: number) => {
+ if (disabled) return;
+ setGrid((prev) => {
+ const newGrid = prev.map((row) => [...row]);
+ newGrid[y][x] = isErasing ? 0 : selectedColor;
+ return newGrid;
+ });
+ }, [disabled, isErasing, selectedColor]);
+
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ const cell = getCell(e);
+ if (cell) { paint(cell.x, cell.y); setIsDrawing(true); }
+ }, [getCell, paint]);
+
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
+ if (!isDrawing) return;
+ const cell = getCell(e);
+ if (cell) paint(cell.x, cell.y);
+ }, [isDrawing, getCell, paint]);
+
+ const handleMouseUp = useCallback(() => setIsDrawing(false), []);
+
+ const clearGrid = useCallback(() => {
+ setGrid(Array.from({ length: height }, () => Array(width).fill(0)));
+ }, [width, height]);
+
+ // Match percentage
+ const matchPct = content.mode === 'match' && content.targetImage
+ ? (() => {
+ let total = 0;
+ let matches = 0;
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ total++;
+ if (grid[y][x] === (content.targetImage[y]?.[x] ?? 0)) matches++;
+ }
+ }
+ return Math.round((matches / total) * 100);
+ })()
+ : null;
+
+ const canvasSize = fullscreen ? Math.min(600, width * 40) : Math.min(400, width * 30);
+
+ const editorUI = (fs: boolean) => (
+
+ {/* Canvas */}
+
+
+ {matchPct !== null && (
+
+
+ Coincidencia: {matchPct}%
+
+
+ )}
+
+
+ {/* Tools */}
+
+ {/* Palette */}
+
+ {fs &&
Paleta
}
+ {palette.map((color, i) => (
+
+
+
+
+
+
+
+
+
+ );
+
+ const fullscreenOverlay = fullscreen && createPortal(
+ { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}>
+
+ {editorUI(true)}
+
+
,
+ document.body
+ );
+
+ return (
+ <>
+ {editorUI(false)}
+ {fullscreenOverlay}
+ >
+ );
+}
diff --git a/src/components/workbench/modules/signal-playground/SignalPlayground.tsx b/src/components/workbench/modules/signal-playground/SignalPlayground.tsx
new file mode 100644
index 0000000..d812cdc
--- /dev/null
+++ b/src/components/workbench/modules/signal-playground/SignalPlayground.tsx
@@ -0,0 +1,297 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { SignalPlaygroundContent } from '@/types/challenge';
+import { Play, Square, Maximize2, Minimize2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { createPortal } from 'react-dom';
+
+interface SignalPlaygroundProps {
+ content: SignalPlaygroundContent;
+ onStateChange: (state: string) => void;
+ disabled?: boolean;
+}
+
+type WaveType = 'sine' | 'square' | 'sawtooth' | 'triangle';
+const WAVE_TYPES: WaveType[] = ['sine', 'square', 'sawtooth', 'triangle'];
+const WAVE_LABELS: Record = {
+ sine: 'Sinusoidal',
+ square: 'Cuadrada',
+ sawtooth: 'Sierra',
+ triangle: 'Triangular',
+};
+
+export function SignalPlayground({ content, onStateChange, disabled }: SignalPlaygroundProps) {
+ const [frequency, setFrequency] = useState(content.targetFrequency ?? 440);
+ const [waveform, setWaveform] = useState(content.targetWaveform ?? 'sine');
+ const [volume, setVolume] = useState(0.3);
+ const [playing, setPlaying] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+ const [filterFreq, setFilterFreq] = useState(1000);
+ const [filterType, setFilterType] = useState('lowpass');
+ const canvasRef = useRef(null);
+ const audioCtxRef = useRef(null);
+ const oscRef = useRef(null);
+ const gainRef = useRef(null);
+ const filterRef = useRef(null);
+ const analyserRef = useRef(null);
+ const animRef = useRef(0);
+
+ const hasFilter = content.mode === 'filter' || content.mode === 'synth';
+
+ // Sync state for verification
+ useEffect(() => {
+ onStateChange(JSON.stringify({ frequency, waveform, volume, filterFreq, filterType }));
+ }, [frequency, waveform, volume, filterFreq, filterType, onStateChange]);
+
+ const startAudio = useCallback(() => {
+ if (audioCtxRef.current) return;
+ const ctx = new AudioContext();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ const analyser = ctx.createAnalyser();
+ analyser.fftSize = 2048;
+
+ osc.type = waveform;
+ osc.frequency.value = frequency;
+ gain.gain.value = volume;
+
+ if (hasFilter) {
+ const filter = ctx.createBiquadFilter();
+ filter.type = filterType;
+ filter.frequency.value = filterFreq;
+ filter.Q.value = 1;
+ osc.connect(filter);
+ filter.connect(gain);
+ filterRef.current = filter;
+ } else {
+ osc.connect(gain);
+ }
+
+ gain.connect(analyser);
+ analyser.connect(ctx.destination);
+ osc.start();
+
+ audioCtxRef.current = ctx;
+ oscRef.current = osc;
+ gainRef.current = gain;
+ analyserRef.current = analyser;
+ setPlaying(true);
+ }, [waveform, frequency, volume, hasFilter, filterFreq, filterType]);
+
+ const stopAudio = useCallback(() => {
+ oscRef.current?.stop();
+ audioCtxRef.current?.close();
+ audioCtxRef.current = null;
+ oscRef.current = null;
+ gainRef.current = null;
+ filterRef.current = null;
+ analyserRef.current = null;
+ setPlaying(false);
+ }, []);
+
+ // Update live parameters
+ useEffect(() => {
+ if (oscRef.current) {
+ oscRef.current.frequency.value = frequency;
+ oscRef.current.type = waveform;
+ }
+ if (gainRef.current) gainRef.current.gain.value = volume;
+ if (filterRef.current) {
+ filterRef.current.frequency.value = filterFreq;
+ filterRef.current.type = filterType;
+ }
+ }, [frequency, waveform, volume, filterFreq, filterType]);
+
+ // Waveform visualization
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const draw = () => {
+ const w = canvas.width;
+ const h = canvas.height;
+ ctx.fillStyle = '#0a0a0a';
+ ctx.fillRect(0, 0, w, h);
+
+ // Grid
+ ctx.strokeStyle = '#222';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
+ ctx.stroke();
+
+ if (analyserRef.current) {
+ // Live waveform from analyser
+ const bufferLength = analyserRef.current.fftSize;
+ const dataArray = new Float32Array(bufferLength);
+ analyserRef.current.getFloatTimeDomainData(dataArray);
+
+ ctx.strokeStyle = '#6366f1';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ const sliceWidth = w / bufferLength;
+ let x = 0;
+ for (let i = 0; i < bufferLength; i++) {
+ const y = (1 - dataArray[i]) * h / 2;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ x += sliceWidth;
+ }
+ ctx.stroke();
+ } else {
+ // Static preview
+ ctx.strokeStyle = '#6366f1';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ for (let x = 0; x < w; x++) {
+ const t = (x / w) * 4 * Math.PI;
+ let y = 0;
+ switch (waveform) {
+ case 'sine': y = Math.sin(t); break;
+ case 'square': y = Math.sin(t) >= 0 ? 1 : -1; break;
+ case 'sawtooth': y = 2 * ((t / (2 * Math.PI)) % 1) - 1; break;
+ case 'triangle': y = 2 * Math.abs(2 * ((t / (2 * Math.PI)) % 1) - 1) - 1; break;
+ }
+ const py = (1 - y * 0.8) * h / 2;
+ if (x === 0) ctx.moveTo(x, py);
+ else ctx.lineTo(x, py);
+ }
+ ctx.stroke();
+ }
+
+ animRef.current = requestAnimationFrame(draw);
+ };
+
+ draw();
+ return () => cancelAnimationFrame(animRef.current);
+ }, [waveform, playing]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => { stopAudio(); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const controls = (fs: boolean) => (
+
+
+
+
+ {playing ? '♪ Reproduciendo' : '○ Silencio'}
+
+ {!fs && (
+
+ )}
+ {fs && (
+
+ )}
+
+
+ {/* Waveform selector */}
+
+
+
+ {WAVE_TYPES.map((w) => (
+
+ ))}
+
+
+
+ {/* Frequency */}
+
+
+ setFrequency(Number(e.target.value))}
+ className="w-full accent-primary" />
+
+
+ {/* Volume */}
+
+
+ setVolume(Number(e.target.value))}
+ className="w-full accent-primary" />
+
+
+ {/* Filter controls */}
+ {hasFilter && (
+ <>
+
+
+
+ {(['lowpass', 'highpass', 'bandpass'] as BiquadFilterType[]).map((ft) => (
+
+ ))}
+
+
+
+
+ setFilterFreq(Number(e.target.value))}
+ className="w-full accent-primary" />
+
+ >
+ )}
+
+
{content.instructions}
+
+ );
+
+ const canvas = (fs: boolean) => (
+
+ );
+
+ const fullscreenOverlay = fullscreen && createPortal(
+ { if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false); } }}>
+
+ {controls(true)}
+
+
+ {canvas(true)}
+
+
,
+ document.body
+ );
+
+ return (
+
+ {canvas(false)}
+ {controls(false)}
+ {fullscreenOverlay}
+
+ );
+}
diff --git a/src/components/workbench/modules/synth/Knob.tsx b/src/components/workbench/modules/synth/Knob.tsx
new file mode 100644
index 0000000..728e206
--- /dev/null
+++ b/src/components/workbench/modules/synth/Knob.tsx
@@ -0,0 +1,146 @@
+'use client';
+
+import { useRef, useCallback, useState } from 'react';
+
+const SIZE = 32;
+const RADIUS = 12;
+const START_ANGLE = 225;
+const END_ANGLE = -45;
+const RANGE = 270;
+
+function polarToCart(cx: number, cy: number, r: number, deg: number) {
+ const rad = (deg - 90) * Math.PI / 180;
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
+}
+
+function describeArc(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
+ const start = polarToCart(cx, cy, r, endDeg);
+ const end = polarToCart(cx, cy, r, startDeg);
+ const large = endDeg - startDeg <= 180 ? '0' : '1';
+ return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
+}
+
+interface KnobProps {
+ value: number;
+ min: number;
+ max: number;
+ onChange: (v: number) => void;
+ color?: string;
+ modulated?: boolean;
+ liveValue?: number;
+}
+
+export function Knob({ value, min, max, onChange, color = 'var(--accent, #6366f1)', modulated = false, liveValue }: KnobProps) {
+ const ref = useRef(null);
+ const dragRef = useRef<{ startY: number; startValue: number } | null>(null);
+ const inputRef = useRef(null);
+ const [editing, setEditing] = useState(false);
+ const [editText, setEditText] = useState('');
+
+ const displayNum = liveValue !== undefined ? liveValue : value;
+ const clampedDisplay = Math.max(min, Math.min(max, displayNum));
+ const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
+ const angleDeg = START_ANGLE - norm * RANGE;
+
+ const cx = SIZE / 2, cy = SIZE / 2;
+ const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
+ const fillAngle = START_ANGLE - norm * RANGE;
+ const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
+ const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
+
+ const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
+ const baseAngle = START_ANGLE - baseNorm * RANGE;
+ const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
+
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
+ if (editing) return;
+ e.preventDefault(); e.stopPropagation();
+ dragRef.current = { startY: e.clientY, startValue: value };
+ const handleMove = (me: PointerEvent) => {
+ if (!dragRef.current) return;
+ const dy = dragRef.current.startY - me.clientY;
+ const sensitivity = (max - min) / 200;
+ let newVal = dragRef.current.startValue + dy * sensitivity;
+ newVal = Math.max(min, Math.min(max, newVal));
+ if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
+ newVal = Math.round(newVal);
+ }
+ onChange(newVal);
+ };
+ const handleUp = () => {
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', handleUp);
+ dragRef.current = null;
+ };
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', handleUp);
+ }, [value, min, max, onChange, editing]);
+
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ if (editing) return;
+ e.preventDefault(); e.stopPropagation();
+ const step = (max - min) / 100;
+ const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
+ onChange(newVal);
+ }, [value, min, max, onChange, editing]);
+
+ const handleDoubleClick = useCallback((e: React.MouseEvent) => {
+ e.preventDefault(); e.stopPropagation();
+ setEditText(String(value));
+ setEditing(true);
+ setTimeout(() => inputRef.current?.focus(), 0);
+ }, [value]);
+
+ const commitEdit = useCallback(() => {
+ const parsed = parseFloat(editText);
+ if (!isNaN(parsed)) {
+ onChange(Math.max(min, Math.min(max, parsed)));
+ }
+ setEditing(false);
+ }, [editText, min, max, onChange]);
+
+ if (editing) {
+ return (
+ e.stopPropagation()} style={{ width: SIZE, height: SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
+ setEditText(e.target.value)}
+ onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }}
+ onBlur={commitEdit}
+ onPointerDown={(e) => e.stopPropagation()}
+ style={{
+ width: 48, height: 22, background: 'oklch(0.145 0 0)', border: '1px solid var(--accent, #6366f1)',
+ borderRadius: 3, color: 'var(--accent, #6366f1)', fontSize: 10, textAlign: 'center',
+ fontFamily: "'JetBrains Mono', monospace", outline: 'none',
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/workbench/modules/synth/ModularSynth.tsx b/src/components/workbench/modules/synth/ModularSynth.tsx
new file mode 100644
index 0000000..9d4a6fc
--- /dev/null
+++ b/src/components/workbench/modules/synth/ModularSynth.tsx
@@ -0,0 +1,507 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { MODULE_REGISTRY, getModuleDef, PORT_COLORS } from './moduleRegistry';
+import { SynthModule, SynthConnection } from './synthTypes';
+import { AudioNodeWrapper, createAudioNode, updateAudioParam, connectAudio, disconnectAudio, startAudioContext } from './audioEngine';
+import { SynthModuleNode } from './SynthModuleNode';
+import { Maximize2, Minimize2 } from 'lucide-react';
+
+let nextModId = Date.now();
+
+interface WiringState {
+ moduleId: string;
+ port: string;
+ direction: 'input' | 'output';
+ x: number;
+ y: number;
+}
+
+// Colors matching app's dark theme (oklch-based shadcn)
+const COLORS = {
+ bg: 'oklch(0.145 0 0)', // --background
+ panel: 'oklch(0.178 0 0)', // slightly lighter than bg
+ surface: 'oklch(0.205 0 0)', // --card
+ surface2: 'oklch(0.235 0 0)', // --card lighter
+ border: 'oklch(1 0 0 / 10%)', // --border
+ text: 'oklch(0.985 0 0)', // --foreground
+ text2: 'oklch(0.556 0 0)', // --muted-foreground
+ accent: '#6366f1', // primary indigo (matches app primary)
+ green: '#22c55e', // matches app green
+};
+
+export function ModularSynth() {
+ const [modules, setModules] = useState([]);
+ const [connections, setConnections] = useState([]);
+ const [audioStarted, setAudioStarted] = useState(false);
+ const [playing, setPlaying] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+ const [wiring, setWiring] = useState(null);
+ const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
+ const [dragging, setDragging] = useState<{ id: string; ox: number; oy: number } | null>(null);
+
+ // Camera
+ const [camX, setCamX] = useState(0);
+ const [camY, setCamY] = useState(0);
+ const [zoom, setZoom] = useState(1);
+ const [panning, setPanning] = useState(false);
+ const panStart = useRef({ x: 0, y: 0, camX: 0, camY: 0 });
+
+ // Audio node map
+ const audioNodes = useRef