diff --git a/frontend/src/components/editor/EditorToolbar.css b/frontend/src/components/editor/EditorToolbar.css new file mode 100644 index 00000000..28e81f81 --- /dev/null +++ b/frontend/src/components/editor/EditorToolbar.css @@ -0,0 +1,434 @@ +/* ── Toolbar container ────────────────────────────────────────────────────── + * Single-row layout: [left actions] [file tabs flex] [right actions] + * The center slot (file tabs) flex-grows and scrolls horizontally so the + * action icons on either side stay pinned and visible no matter how narrow + * the editor pane gets when the user drags the editor/canvas divider. + * + * Uses CSS container queries so the Libraries label collapses based on the + * toolbar's *own* width (not the viewport) — which is what changes when the + * user drags the editor/canvas divider. + */ +.editor-toolbar-wrapper { + container-type: inline-size; + container-name: editor-toolbar; +} +.editor-toolbar { + display: flex; + align-items: stretch; + justify-content: flex-start; + padding: 0 6px; + height: 38px; + background: #252526; + border-bottom: 1px solid #333; + flex-shrink: 0; + gap: 4px; + min-width: 0; +} + +/* ── Groups ─────────────────────────────────────── */ +.toolbar-group { + display: flex; + align-items: center; + gap: 1px; + flex-shrink: 0; +} + +.toolbar-group-right { + gap: 4px; +} + +/* Center slot — a flexible spacer between the left and right action groups + that keeps the right icons pinned to the far right. Normally empty. */ +.toolbar-center-slot { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: stretch; + overflow: hidden; +} + +/* ── Divider ─────────────────────────────────────── */ +.tb-divider { + width: 1px; + height: 18px; + background: #3a3a3a; + margin: 0 3px; + align-self: center; +} + +/* ── Icon buttons ───────────────────────────────── */ +.tb-btn { + /* 28px — the one control height across the whole editor strip (view-mode + segments, Libraries, language select, canvas controls all match). */ + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 4px; + cursor: pointer; + background: transparent; + color: #9d9d9d; + transition: + background 0.12s, + color 0.12s; + flex-shrink: 0; + align-self: center; +} + +.tb-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Compile — accent blue (token, not neon) */ +.tb-btn-compile { + color: var(--color-action-primary); +} +.tb-btn-compile:hover:not(:disabled) { + background: rgba(0, 113, 227, 0.14); + color: var(--color-action-primary-hover); +} +.tb-btn-compile:active:not(:disabled) { + background: rgba(0, 113, 227, 0.22); +} + +/* Run — semantic success green (token) */ +.tb-btn-run { + color: var(--color-feedback-success); +} +.tb-btn-run:hover:not(:disabled) { + background: rgba(48, 209, 88, 0.14); + color: var(--color-feedback-success); +} + +/* Stop — semantic error red (token) */ +.tb-btn-stop { + color: var(--color-feedback-error); +} +.tb-btn-stop:hover:not(:disabled) { + background: rgba(255, 69, 58, 0.14); + color: var(--color-feedback-error); +} + +/* Reset */ +.tb-btn-reset:hover:not(:disabled) { + background: #2a2d2e; + color: #ccc; +} + +/* Libraries button (always visible, with label) */ +.tb-btn-libraries { + display: flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 8px 0 7px; + border: none; + border-radius: 5px; + cursor: pointer; + background: var(--color-action-primary); + color: #fff; + font-size: 11px; + font-weight: 500; + font-family: inherit; + white-space: nowrap; + transition: + background 0.15s, + color 0.15s; + flex-shrink: 0; + align-self: center; +} +.tb-btn-libraries:hover { + background: var(--color-action-primary-hover); + color: #fff; +} +.tb-libraries-label { + line-height: 1; +} +/* Hide the label only when the toolbar is genuinely cramped — keep the + "Libraries" text visible in the default 50/50 split (~376px toolbar) so + the button reads clearly. Below 360px the icon alone keeps it reachable + without stealing horizontal space from the file tabs. The container query + reacts to the toolbar's own width, so it triggers when the user drags the + editor/canvas divider very narrow, not just on small viewports. */ +@container editor-toolbar (max-width: 360px) { + .tb-libraries-label { + display: none; + } + .tb-btn-libraries { + padding: 0 6px; + } +} +/* When even narrower, also collapse the board pill label. */ +@container editor-toolbar (max-width: 460px) { + .tb-board-pill-label { + display: none; + } + .tb-board-pill { + padding: 3px 6px; + } +} + +/* Import / Export moved to the File menu in the header (EditorMenuBar): + their inline buttons and the responsive overflow twins are gone, which + is ~70px the row no longer has to find on laptop widths. */ + +/* Legacy lib icon-only button (for overflow items) */ +.tb-btn-lib { + color: var(--color-action-primary); +} +.tb-btn-lib:hover { + background: rgba(0, 113, 227, 0.12); + color: var(--color-action-primary-hover); +} + +/* Missing library hint banner */ +.tb-lib-hint { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: rgba(255, 159, 10, 0.1); + border-bottom: 1px solid rgba(255, 159, 10, 0.25); + color: var(--color-feedback-warning); + font-size: 12px; + font-family: inherit; +} +.tb-lib-hint svg { + flex-shrink: 0; + color: var(--color-feedback-warning); +} +.tb-lib-hint-btn { + background: var(--color-action-primary); + border: none; + border-radius: 4px; + color: #fff; + font-size: 12px; + font-weight: 600; + font-family: inherit; + padding: 2px 8px; + cursor: pointer; + transition: background 0.12s; +} +.tb-lib-hint-btn:hover { + background: var(--color-action-primary-hover); + color: #fff; +} +.tb-lib-hint-close { + margin-left: auto; + background: none; + border: none; + color: #888; + font-size: 16px; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} +.tb-lib-hint-close:hover { + color: #ccc; +} + +/* Output Console toggle */ +.tb-btn-output { + color: #9d9d9d; +} +.tb-btn-output:hover { + background: rgba(255, 255, 255, 0.06); + color: #ccc; +} +.tb-btn-output-active { + color: var(--color-action-primary); + background: rgba(0, 113, 227, 0.12); +} +.tb-btn-output-active:hover { + background: rgba(0, 113, 227, 0.2); + color: var(--color-action-primary-hover); +} + +/* ── Spin animation (compiling) ─────────────────── */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.spin { + animation: spin 0.8s linear infinite; +} + +/* ── Status inline message ──────────────────────── */ +.tb-status { + display: flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + max-width: 280px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tb-status-success { + color: var(--color-feedback-success); + background: rgba(48, 209, 88, 0.1); +} + +.tb-status-error { + color: var(--color-feedback-error); + background: rgba(255, 69, 58, 0.1); +} + +.tb-status-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Compile All / Run All buttons ─────────────── */ +.tb-btn-compile-all { + color: var(--color-action-primary); +} +.tb-btn-compile-all:hover:not(:disabled) { + background: rgba(0, 113, 227, 0.14); + color: var(--color-action-primary-hover); +} + +.tb-btn-run-all { + color: var(--color-feedback-success); +} +.tb-btn-run-all:hover:not(:disabled) { + background: rgba(48, 209, 88, 0.14); + color: var(--color-feedback-success); +} + +/* ── Board context pill ─────────────────────────── */ +.tb-board-pill { + display: flex; + align-items: center; + gap: 5px; + padding: 3px 9px 3px 7px; + border-radius: 20px; + border: 1px solid; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + opacity: 0.85; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.04); + max-width: 170px; + overflow: hidden; +} + +.tb-board-pill-icon { + font-size: 9px; + flex-shrink: 0; +} + +.tb-board-pill-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.tb-board-pill-running { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-feedback-success); + flex-shrink: 0; + animation: pulse-run 1.4s ease-in-out infinite; +} + +@keyframes pulse-run { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +/* ── Overflow (…) menu ──────────────────────────── */ + + + + + + +/* ── Run split-button (multi-board: primary Run = all boards + menu) ──── */ +.tb-run-split { + position: relative; + display: inline-flex; + align-items: center; +} +.tb-btn-run-caret { + width: 16px; + color: var(--color-feedback-success); +} +.tb-btn-run-caret:hover:not(:disabled) { + background: rgba(48, 209, 88, 0.14); + color: var(--color-feedback-success); +} +.tb-run-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 200; + background: #2a2a2c; + border: 1px solid #3a3a3c; + border-radius: 10px; + padding: 6px; + min-width: 200px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + gap: 2px; +} +.tb-run-menu-item { + display: flex; + align-items: center; + width: 100%; + padding: 8px 10px; + background: transparent; + border: none; + border-radius: 7px; + color: #ccc; + font-size: 13px; + font-family: inherit; + cursor: pointer; + text-align: left; + white-space: nowrap; + transition: + background 0.12s, + color 0.12s; +} +.tb-run-menu-item:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.tb-run-menu-item:disabled { + opacity: 0.5; + cursor: default; +} + + +/* Pro pill — signals premium items in the overflow menu BEFORE the user + clicks. Used in place of a hidden gate so free users aren't surprised + by an upgrade prompt on what looked like a free button. */ + +/* ── Error detail bar (for long compiler errors) ── */ +.toolbar-error-detail { + background: #1a0000; + border-bottom: 1px solid #5a1a1a; + color: #f48fb1; + font-size: 11px; + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + padding: 4px 12px; + max-height: 80px; + overflow-y: auto; + white-space: pre-wrap; + line-height: 1.4; +} diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx new file mode 100644 index 00000000..ef45c420 --- /dev/null +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -0,0 +1,1864 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { registerEditorCommand } from '../../lib/editorCommands'; +import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; +import { useSimulatorStore, piRerunScript } from '../../store/useSimulatorStore'; +import { decideEngine } from '../../lib/instantEngine'; +import { useElectricalStore } from '../../store/useElectricalStore'; +import { type VerificationResult } from '../../simulation/verify/circuitVerifier'; +import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore'; +import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal'; +import type { BoardKind, LanguageMode } from '../../types/board'; +import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board'; +import { compileCode } from '../../services/compilation'; +import { + compileRom, + isChipProgramFile, + formatForFile, + targetForChip, +} from '../../services/romCompileService'; +import { compileChip } from '../../services/chipCompileService'; +import { clearChipDrives } from '../../simulation/customChips/chipPinDrives'; +import { requestElectricalResolve } from '../../simulation/spice/electricalResolveHook'; +import { reportRunEvent } from '../../services/metricsService'; +import { useProjectStore } from '../../store/useProjectStore'; +import { LibraryManagerModal } from '../simulator/LibraryManagerModal'; +import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal'; +import { parseCompileResult } from '../../utils/compilationLogger'; +import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger'; +import { exportToWokwiZip } from '../../utils/wokwiZip'; +import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; +import { readFirmwareFile } from '../../utils/firmwareLoader'; +import { + trackCompileCode, + trackRunSimulation, + trackStopSimulation, + trackResetSimulation, + trackOpenLibraryManager, +} from '../../utils/analytics'; +import './EditorToolbar.css'; + +/** + * Output-console group for circuit pre-flight + runtime faults. Routing these + * into the compile console (instead of an inline toolbar toast that overlapped + * the Run/Stop buttons) gives one unified, red-coloured diagnostics log — + * Proteus-style. id is matched when clearing so the findings survive an + * auto-compile triggered by the same Run. + */ +const CIRCUIT_CHECK_TARGET: CompileTarget = { + id: 'circuit-check', + label: 'Circuit check', + kind: 'board', +}; + +/** + * Clear the output drives of every custom chip on the canvas and re-solve, so + * chip-driven LEDs go dark on Stop. A chip drives its nets via its own SPICE + * voltage sources (registered in chipPinDrives); stopBoard / electrical-pause + * don't touch those, so without this the LEDs would freeze at their last frame. + */ +function clearAllChipDrives(): void { + const comps = useSimulatorStore.getState().components; + let any = false; + for (const c of comps) { + if (c.metadataId === 'custom-chip') { + clearChipDrives(c.id); + any = true; + } + } + if (any) requestElectricalResolve(); +} + +/** + * Boards whose firmware runs in a QEMU worker rather than a client-side AVR + * core. They can start without a pre-stored `compiledProgram`. Shared by + * handleRun and handleRunAll so the two paths can't drift. + */ +function isQemuBoardKind(kind: BoardKind | undefined): boolean { + if (!kind) return false; + return ( + isPiBoardKind(kind) || + kind === 'esp32' || + kind === 'esp32-s3' || + kind === 'esp32-cam' || + kind === 'esp32-c3' || + kind === 'esp32-devkit-c-v4' || + kind === 'wemos-lolin32-lite' || + kind === 'xiao-esp32-s3' || + kind === 'arduino-nano-esp32' || + kind === 'xiao-esp32-c3' || + kind === 'aitewinrobot-esp32c3-supermini' + ); +} + +interface EditorToolbarProps { + consoleOpen: boolean; + setConsoleOpen: (open: boolean | ((v: boolean) => boolean)) => void; + compileLogs: CompilationLog[]; + setCompileLogs: (logs: CompilationLog[] | ((prev: CompilationLog[]) => CompilationLog[])) => void; + /** + * Optional element rendered between the left action group and the right + * action group. Normally empty (the slot just acts as a flexible spacer + * that keeps the right action icons pinned); private overlays may inject + * deployment-specific content here without forking the toolbar. + */ + centerSlot?: React.ReactNode; + /** + * Optional extra elements rendered after the built-in right-group buttons + * (Libraries / Import-Export / Output Console). Used by private overlays + * to add deployment-specific actions without forking the toolbar. + */ + rightSlot?: React.ReactNode; +} + +const BOARD_PILL_ICON: Record = { + 'arduino-uno': '⬤', + 'arduino-nano': '▪', + 'arduino-mega': '▬', + 'raspberry-pi-pico': '◆', + 'raspberry-pi-3': '⬛', + 'raspberry-pi-4': '⬛', + 'raspberry-pi-5': '⬛', + esp32: '⬡', + 'esp32-s3': '⬡', + 'esp32-c3': '⬡', + 'stm32-bluepill': '◈', + 'stm32-blackpill': '◈', + 'stm32-bluepill-f103cb': '◈', + 'stm32-blackpill-f401': '◈', + 'stm32-f4-discovery': '◈', + 'stm32-olimex-h405': '◈', + 'stm32-netduino-plus2': '◈', + 'stm32-netduino2': '◈', +}; + +const BOARD_PILL_COLOR: Record = { + 'arduino-uno': '#4fc3f7', + 'arduino-nano': '#4fc3f7', + 'arduino-mega': '#4fc3f7', + 'raspberry-pi-pico': '#ce93d8', + 'raspberry-pi-3': '#ef9a9a', + 'raspberry-pi-4': '#ef9a9a', + 'raspberry-pi-5': '#ef9a9a', + esp32: '#a5d6a7', + 'esp32-s3': '#a5d6a7', + 'esp32-c3': '#a5d6a7', + 'stm32-bluepill': '#80cbc4', + 'stm32-blackpill': '#b0bec5', + 'stm32-bluepill-f103cb': '#80cbc4', + 'stm32-blackpill-f401': '#b0bec5', + 'stm32-f4-discovery': '#90caf9', + 'stm32-olimex-h405': '#a5d6a7', + 'stm32-netduino-plus2': '#ce93d8', + 'stm32-netduino2': '#ce93d8', +}; + +export const EditorToolbar = ({ + consoleOpen, + setConsoleOpen, + compileLogs: _compileLogs, + setCompileLogs, + centerSlot, + rightSlot, +}: EditorToolbarProps) => { + const { t } = useTranslation(); + const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore(); + const { + boards, + activeBoardId, + compileBoardProgram, + loadMicroPythonProgram, + setBoardLanguageMode, + updateBoard, + startBoard, + stopBoard, + resetBoard, + // legacy compat + startSimulation, + stopSimulation, + resetSimulation, + running, + compiledHex, + } = useSimulatorStore(); + + const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0]; + const currentProject = useProjectStore((s) => s.currentProject); + + // Board-less mode: digital / analog SPICE-only circuits. The Run / Stop + // buttons toggle the SPICE solver's `paused` flag — pausing freezes every + // LED at its current brightness so the user can inspect the state, and + // resuming flushes the most recent switch toggle through the engine. + const electricalPaused = useElectricalStore((s) => s.paused); + const setElectricalPaused = useElectricalStore((s) => s.setPaused); + const isBoardless = boards.length === 0; + const digitalRunning = isBoardless && !electricalPaused; + // Any board actually running — the correct multi-target signal for the + // Run-All / Stop buttons (the flat `running` flag only tracks the ACTIVE + // board, so it misreports a multi-board or non-active-board run). + const anyBoardRunning = boards.some((b) => b.running); + // Multi-board: the primary Run button runs ALL boards (the whole wired + // project is one system — running a subset is almost never intended), with a + // split-menu to still run just the active board. Single-board is unchanged. + const isMultiBoard = boards.length > 1; + + // A "run target" is a board OR a programmable custom-chip (a CPU that runs a + // ROM). When there is more than one target — two boards, a board + a chip, or + // several chips — the unified Compile-All / Run-All buttons appear and act on + // every target, the same way multiple Arduinos behave. Resolved as a number + // so the toolbar only re-renders when the count changes. The predicate is a + // cheap string test (no JSON.parse) since this selector runs on every store + // change, including high-frequency simulation churn. (The compile/run paths + // deliberately act on ALL custom chips, not just programmable ones.) + const targetCount = useSimulatorStore((s) => { + let chips = 0; + for (const c of s.components) { + if (c.metadataId !== 'custom-chip') continue; + const p = c.properties as Record; + if (String(p?.programFile ?? '').trim() || String(p?.chipJson ?? '').includes('"programTargets"')) + chips++; + } + return s.boards.length + chips; + }); + + // Circuit-verification modal state. When `pendingRun` is non-null we've + // already paid the cost of solving + analysing — the user can either + // bail out or proceed by running `pendingRun()`. + const [verification, setVerification] = useState(null); + const pendingRunRef = useRef<(() => void) | null>(null); + + // Helper: report a Run event to the backend for analytics. Resolves the + // FQBN from the board kind so the backend can group by family/fqbn. + const reportRun = useCallback( + (boardKind: BoardKind | undefined, engine?: 'instant' | 'linux') => { + const fqbn = boardKind ? BOARD_KIND_FQBN[boardKind] : null; + void reportRunEvent({ + project_id: currentProject?.id ?? null, + board_fqbn: fqbn ?? null, + engine: engine ?? null, + }); + }, + [currentProject], + ); + const [compiling, setCompiling] = useState(false); + // True while the pre-flight circuit verification SPICE solve is running. + // Drives the Run-button spinner so the user gets feedback during the + // (sometimes multi-second, cold-worker) solve instead of a dead button. + const [verifying, setVerifying] = useState(false); + // Synchronous re-entrancy guard: a click while a run/verify is already in + // flight is ignored, so rapid clicks can't stack multiple verifications. + const runInFlightRef = useRef(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [libManagerOpen, setLibManagerOpen] = useState(false); + const [pendingLibraries, setPendingLibraries] = useState([]); + const [installModalOpen, setInstallModalOpen] = useState(false); + const importInputRef = useRef(null); + const firmwareInputRef = useRef(null); + const toolbarRef = useRef(null); + const [missingLibHint, setMissingLibHint] = useState(false); + // Split-button menu for the multi-board Run control ("Run all" / "Run active only"). + const [runMenuOpen, setRunMenuOpen] = useState(false); + const runMenuRef = useRef(null); + + // Open the Library Manager when another component (e.g. the velxio.json entry + // in the FileExplorer) asks for it via a window event. Avoids prop-drilling + // the modal state down to the explorer. + useEffect(() => { + const open = () => setLibManagerOpen(true); + window.addEventListener('velxio-open-library-manager', open); + return () => window.removeEventListener('velxio-open-library-manager', open); + }, []); + + // Surface a runtime circuit fault (e.g. an LED that burnt out from + // overcurrent during the live SPICE solve) in the output console, in red, + // under the "Circuit check" group — same place as the pre-flight findings. + // (Previously an inline toolbar toast that overlapped the Run/Stop buttons.) + // We do NOT auto-open the console here: the continuous solver can fault on + // load, and popping the console open then would be intrusive. The pre-flight + // (on Run) opens it; this entry then lands in the already-open log. + useEffect(() => { + const onFault = (e: Event) => { + const detail = (e as CustomEvent).detail as { message?: string } | undefined; + if (!detail?.message) return; + const text = detail.message; + setCompileLogs((prev) => [ + ...prev, + { timestamp: new Date(), type: 'error', message: text, target: CIRCUIT_CHECK_TARGET }, + ]); + }; + window.addEventListener('velxio-circuit-fault', onFault); + return () => window.removeEventListener('velxio-circuit-fault', onFault); + }, [setCompileLogs]); + + + // Close the Run split-menu on outside click / Escape (mirrors the more-menu). + useEffect(() => { + if (!runMenuOpen) return; + const onClickOutside = (e: MouseEvent) => { + if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) { + setRunMenuOpen(false); + } + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') setRunMenuOpen(false); + }; + document.addEventListener('mousedown', onClickOutside); + document.addEventListener('keydown', onEsc); + return () => { + document.removeEventListener('mousedown', onClickOutside); + document.removeEventListener('keydown', onEsc); + }; + }, [runMenuOpen]); + + // Compile All / Run All — runs sequentially, logs to console (no dialog) + const [compileAllRunning, setCompileAllRunning] = useState(false); + + const addLog = useCallback( + (log: CompilationLog) => { + setCompileLogs((prev: CompilationLog[]) => [...prev, log]); + }, + [setCompileLogs], + ); + + /** + * Make every custom-chip on the canvas runnable: compile its C source to + * WASM (when it has none yet) and, for programmable CPU chips, assemble or + * compile the program file it references into ROM bytes — stashing both on + * the chip component's `properties` so the next simulation start picks them + * up. Non-fatal by design: a chip that fails to compile is logged and + * skipped so the board itself still runs. + */ + const prepareCustomChips = useCallback( + async ( + chips: { id: string; properties: Record }[], + boardFiles: { name: string; content: string }[], + ) => { + const codeChanged = useEditorStore.getState().codeChangedSinceLastCompile; + const updateComponent = useSimulatorStore.getState().updateComponent; + let failed = 0; + + for (const chip of chips) { + // Re-read the freshest properties each iteration (an earlier chip's + // update doesn't touch this one, but be defensive). + const live = useSimulatorStore.getState().components.find((c) => c.id === chip.id); + const props = { ...(live?.properties ?? chip.properties) } as Record; + const chipLabel = String(props.chipName ?? 'custom chip'); + const sourceC = String(props.sourceC ?? ''); + const chipJson = String(props.chipJson ?? '{}'); + let changed = false; + // Stamp every line for this chip with its target so the console groups + // it under its own section (alongside the boards). + const chipTarget: CompileTarget = { id: chip.id, label: chipLabel, kind: 'chip' }; + const clog = (type: CompilationLog['type'], message: string) => + addLog({ timestamp: new Date(), type, message, target: chipTarget }); + + // 1. C -> WASM. Only when missing — the chip designer fills this too. + if (!String(props.wasmBase64 ?? '') && sourceC) { + clog('info', `Compiling chip "${chipLabel}" to WASM...`); + try { + const r = await compileChip(sourceC, chipJson); + if (r.success && r.wasm_base64) { + props.wasmBase64 = r.wasm_base64; + changed = true; + clog('success', `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`); + } else { + clog( + 'error', + `Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`, + ); + failed++; + } + } catch (e) { + clog( + 'error', + `Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`, + ); + failed++; + } + } + + // 2. program file -> ROM bytes (programmable CPU chips). Recompile + // when there's no ROM yet or the user edited code since last build. + const programFile = String(props.programFile ?? '').trim(); + if (programFile && (!String(props.romBytes ?? '') || codeChanged)) { + // The program lives in the chip's OWN editor group (its collapsible + // section in the file explorer), separate from the board sketch. + // Fall back to the board files for older projects that still carried + // the program alongside sketch.ino in the board group. + const chipGroupFiles = useEditorStore + .getState() + .getGroupFiles(chipFileGroupId(chip.id)); + const file = + chipGroupFiles.find((f) => f.name === programFile) ?? + boardFiles.find((f) => f.name === programFile); + if (!file) { + clog('error', `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`); + failed++; + } else { + const target = targetForChip(chipJson); + const fmt = formatForFile(programFile); + clog( + 'info', + `Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`, + ); + try { + const rr = await compileRom(file.content, target, fmt); + if (rr.success && rr.rom_base64) { + props.romBytes = rr.rom_base64; + props.programFile = programFile; + changed = true; + clog('success', `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`); + } else { + clog( + 'error', + `ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`, + ); + failed++; + } + } catch (e) { + clog( + 'error', + `ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`, + ); + failed++; + } + } + } + + if (changed) { + updateComponent(chip.id, { properties: props } as any); + } + } + return { failed }; + }, + [addLog], + ); + + const handleCompile = async () => { + setCompiling(true); + setMessage(null); + setConsoleOpen(true); + // Wipe the previous build's output before we append anything new. + // Issue #209: lingering logs from prior compiles made it impossible + // to tell the latest errors / warnings apart from stale ones. + // Keep the "Circuit check" findings, though: a Run auto-compiles right + // after the pre-flight verification logs them, and clearing here would + // wipe a circuit warning the user just triggered. + setCompileLogs((prev) => prev.filter((l) => l.target?.id === CIRCUIT_CHECK_TARGET.id)); + trackCompileCode(); + + // ── Custom-chip preparation ───────────────────────────────────────── + // Any custom-chip on the canvas is made "live" here so a single + // Compile / Run is enough — no separate trip through the chip designer + // or a manual ROM compile. For every custom-chip we: + // 1. compile its C source to WASM (when it has none yet), and + // 2. for programmable CPU chips, assemble/compile the program file it + // points at (larson.s, chaser.c, …) into ROM bytes. + // Both artefacts are stashed on the chip component's `properties`; + // CustomChipPart reads wasmBase64 + romBytes at simulation start. + // + // The chip program files are ALSO kept out of the Arduino sketch compile + // below (see `chipProgramFiles`) — otherwise arduino-cli/avr-gcc would + // try to build e.g. chaser.c and choke on SDCC-only syntax such as + // `__at(0xC000)`, which is exactly what broke the Z80 examples. + const componentsForCompile = useSimulatorStore.getState().components; + const customChips = componentsForCompile.filter((c) => c.metadataId === 'custom-chip'); + const chipProgramFiles = new Set(); + for (const chip of customChips) { + const pf = String((chip.properties as any)?.programFile ?? '').trim(); + if (pf) chipProgramFiles.add(pf); + } + + if (customChips.length > 0) { + const boardFiles = activeBoard?.activeFileGroupId + ? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId) + : files; + await prepareCustomChips(customChips, boardFiles); + } + // ── End custom-chip preparation ───────────────────────────────────── + + const kind = activeBoard?.boardKind; + // The active board's console target, defined up front so EVERY board path + // (Pi, MicroPython, arduino-cli, errors) groups its lines under one section. + const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown'; + const boardTarget: CompileTarget | undefined = activeBoardId + ? { id: activeBoardId, label: boardLabel, kind: 'board' } + : undefined; + const blog = (type: CompilationLog['type'], message: string) => + addLog({ timestamp: new Date(), type, message, target: boardTarget }); + + // QEMU-Linux boards don't need arduino-cli compilation + if (isPiBoardKind(kind)) { + blog('info', `${boardLabel}: no compilation needed — run Python scripts directly.`); + setMessage({ type: 'success', text: 'Ready (no compilation needed)' }); + setCompiling(false); + return; + } + + // MicroPython mode — no backend compilation needed + if (activeBoard?.languageMode === 'micropython' && activeBoardId) { + blog('info', 'MicroPython: loading firmware and user files...'); + try { + const groupFiles = useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId); + const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content })); + await loadMicroPythonProgram(activeBoardId, pyFiles); + blog('success', 'MicroPython firmware loaded successfully'); + setMessage({ type: 'success', text: 'MicroPython ready' }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython'; + blog('error', errMsg); + setMessage({ type: 'error', text: errMsg }); + } finally { + setCompiling(false); + } + return; + } + + const fqbn = kind ? BOARD_KIND_FQBN[kind] : null; + + if (!fqbn) { + blog('error', `No FQBN for board kind: ${kind}`); + setMessage({ type: 'error', text: 'Unknown board' }); + setCompiling(false); + return; + } + + blog('info', `Starting compilation for ${boardLabel} (${fqbn})...`); + + try { + const groupFiles = activeBoard?.activeFileGroupId + ? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId) + : files; + const sketchFiles = (groupFiles.length > 0 ? groupFiles : files) + // Keep chip-program files (a chip's programFile, or .s/.asm/.hex/.bin) + // out of the arduino-cli build — they're compiled to ROM above, not + // Arduino sources, and avr-gcc chokes on e.g. SDCC's __at(). + .filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name)) + .map((f) => ({ + name: f.name, + content: f.content, + })); + + // Stream live cmake + ninja output into the compilation console as + // it arrives, instead of waiting for the whole build to finish. + // Each poll the backend returns the cumulative stdout buffer; we + // append only the delta since the previous call as 'info' lines. + let lastStreamedLen = 0; + const result = await compileCode( + sketchFiles, + fqbn, + currentProject?.id ?? null, + ({ stdout }) => { + if (stdout.length <= lastStreamedLen) return; + const delta = stdout.slice(lastStreamedLen); + lastStreamedLen = stdout.length; + const newLines = delta.split('\n').filter((s) => s.trim()); + if (!newLines.length) return; + const now = new Date(); + setCompileLogs((prev: CompilationLog[]) => [ + ...prev, + ...newLines.map((line) => ({ + timestamp: now, + type: 'info' as const, + message: line, + target: boardTarget, + })), + ]); + }, + // Per-board ESP32 build options + SPIFFS uploads. Undefined for AVR + // / RP2040 boards (ignored on those paths by the backend). + { + boardOptions: activeBoard?.boardOptions, + spiffsFiles: activeBoard?.spiffsFiles, + // P2.4 — THIS board's declared manifest (compile scope). Per-board so + // two boards can use different libraries without clashing. + libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null, + // Pure ESP-IDF mode (issue #139): tell the backend to compile the + // user's app_main() sources without the arduino-esp32 component. + language: activeBoard?.languageMode === 'espidf' ? 'espidf' : undefined, + }, + ); + + // After the build settles, append the structured analysis on top of + // the live stream — parseCompileResult highlights FAILED blocks and + // tags compiler errors with type='error', which the console uses for + // colour + the auto-switch-to-errors filter. + const resultLogs = parseCompileResult(result, boardLabel, boardTarget); + setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]); + + if (result.success) { + const program = result.hex_content ?? result.binary_content ?? null; + if (program && activeBoardId) { + compileBoardProgram(activeBoardId, program); + if (result.has_wifi !== undefined) { + updateBoard(activeBoardId, { hasWifi: result.has_wifi }); + } + } + setMessage({ type: 'success', text: 'Compiled successfully' }); + markCompiled(); + setMissingLibHint(false); + } else { + const errText = result.error || result.stderr || 'Compile failed'; + setMessage({ type: 'error', text: errText }); + // Issue #208: drop the previous successful program from this + // board so a subsequent Run cannot silently execute stale code + // that doesn't match the editor any more. The Run button gates + // on `!compiledProgram` and will refuse + force a re-compile. + if (activeBoardId) { + updateBoard(activeBoardId, { compiledProgram: null }); + } + // Detect missing library errors — common patterns: + // "No such file or directory" for #include, "fatal error: XXX.h" + const looksLikeMissingLib = + /No such file or directory|fatal error:.*\.h|library not found/i.test(errText); + setMissingLibHint(looksLikeMissingLib); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Compile failed'; + blog('error', errMsg); + setMessage({ type: 'error', text: errMsg }); + } finally { + setCompiling(false); + } + }; + + // Track whether we should auto-run after compilation completes + const autoRunAfterCompile = useRef(false); + + /** + * Pre-flight safety check: solves the current circuit and flags shorts, + * LED over-current and resistor over-power. Returns the result. When the + * solver fails to converge (degenerate netlist, no power source, …) we + * silently report a clean result so the user isn't blocked on circuits + * that aren't physically meaningful yet. + */ + const runVerification = useCallback( + (): Promise => verifyCircuitFromStore(), + [], + ); + + /** + * Returns true if the caller should proceed inline. All findings are written + * to the output console (red errors / orange warnings, "Circuit check" + * group). If the verifier finds errors we also stash a resume callback in + * `pendingRunRef` and pop the verification modal; the resume callback + * re-enters `handleRun` with `skipVerify = true` so we don't loop. + * Warnings-only results don't block — the console entry is enough. + */ + const checkOrBlock = useCallback( + async (resume: () => void): Promise => { + const result = await runVerification(); + if (!result) return true; + if (result.errors.length === 0 && result.warnings.length === 0) return true; + + // Write every finding to the output console under "Circuit check" — red + // for errors, orange for warnings — so there's one persistent, unified + // diagnostics log next to the compiler output (Proteus-style). Replace + // any prior circuit-check entries so repeated runs stay clean, and open + // the console so the findings are visible. + const now = new Date(); + setCompileLogs((prev) => [ + ...prev.filter((l) => l.target?.id !== CIRCUIT_CHECK_TARGET.id), + ...result.errors.map((e) => ({ + timestamp: now, + type: 'error' as const, + message: e.message, + target: CIRCUIT_CHECK_TARGET, + })), + ...result.warnings.map((w) => ({ + timestamp: now, + type: 'warning' as const, + message: w.message, + target: CIRCUIT_CHECK_TARGET, + })), + ]); + setConsoleOpen(true); + + // Warnings only — non-blocking; the console entry is enough, run continues. + if (result.errors.length === 0) return true; + + // Errors → also pop the modal so the user makes an explicit Run-anyway / + // Cancel decision; the console keeps the persistent red record. + pendingRunRef.current = resume; + setVerification(result); + return false; + }, + [runVerification, setCompileLogs, setConsoleOpen], + ); + + const handleRun = async (skipVerify = false) => { + console.log('[handleRun] click', { activeBoardId, running, codeChangedSinceLastCompile }); + + // Pre-flight: solve the circuit and check for shorts / overcurrent / + // overpower. If anything trips we hand control to the modal, which + // resumes by calling `handleRun(true)` for "Run anyway". + if (!skipVerify) { + // The verification solve can take a second or two (cold ngspice worker). + // Show the Run-button spinner and ignore re-clicks while it runs — the + // button otherwise looks idle and gets clicked repeatedly, stacking + // multiple verifications. + if (runInFlightRef.current) return; + runInFlightRef.current = true; + setVerifying(true); + let ok = false; + try { + ok = await checkOrBlock(() => handleRun(true)); + } finally { + setVerifying(false); + runInFlightRef.current = false; + } + if (!ok) return; + } + + // Board-less circuits have no MCU to start. If there are custom-chip CPUs + // on the canvas, compile them (WASM + ROM) and re-attach so they pick up + // the fresh WASM — Velxio runs custom chips with no Arduino/ESP32 board, + // as a general-purpose electronics simulator. Then resume the electrical + // solver (replays any switch toggles captured while paused). + if (isBoardless) { + const customChips = useSimulatorStore + .getState() + .components.filter((c) => c.metadataId === 'custom-chip'); + if (customChips.length > 0) { + setCompiling(true); + setConsoleOpen(true); + // Fresh chip output, but keep the circuit pre-flight findings just + // logged by checkOrBlock so they survive a "Run anyway". + setCompileLogs((prev) => prev.filter((l) => l.target?.id === CIRCUIT_CHECK_TARGET.id)); + try { + await prepareCustomChips(customChips, files); + } catch (e) { + addLog({ + timestamp: new Date(), + type: 'error', + message: e instanceof Error ? e.message : String(e), + }); + } + setCompiling(false); + // Force the chip parts to re-attach with their freshly compiled WASM. + useSimulatorStore.getState().restartParts(); + } + setElectricalPaused(false); + setMessage(null); + return; + } + + if (activeBoardId) { + const board = boards.find((b) => b.id === activeBoardId); + console.log('[handleRun] active board', { + id: board?.id, + kind: board?.boardKind, + hasCompiledProgram: !!board?.compiledProgram, + compiledProgramLen: board?.compiledProgram?.length ?? 0, + }); + + // MicroPython mode: stop any running session first, then reload firmware + start + if (board?.languageMode === 'micropython') { + trackRunSimulation(board.boardKind); + reportRun(board.boardKind); + + // Always stop the current session so the new run gets a clean QEMU boot. + // This also prevents the double start_esp32 that occurs when the bridge + // is already connected and startBoard() is called again. + if (board.running) { + stopBoard(activeBoardId); + // Give the WebSocket a moment to close cleanly before reconnecting. + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + setCompiling(true); + setMessage(null); + const mpyTarget: CompileTarget = { + id: activeBoardId, + label: boardDisplayName(board), + kind: 'board', + }; + const mlog = (type: CompilationLog['type'], message: string) => + addLog({ timestamp: new Date(), type, message, target: mpyTarget }); + mlog('info', 'MicroPython: loading firmware and user files...'); + try { + const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId); + const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content })); + await loadMicroPythonProgram(activeBoardId, pyFiles); + mlog('success', 'MicroPython firmware loaded'); + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython'; + mlog('error', errMsg); + setMessage({ type: 'error', text: errMsg }); + setCompiling(false); + return; + } + setCompiling(false); + startBoard(activeBoardId); + setMessage(null); + return; + } + + const isQemuBoard = isQemuBoardKind(board?.boardKind); + + // QEMU boards: auto-compile if no firmware available yet + if (isQemuBoard) { + console.log('[handleRun] QEMU path'); + // QEMU-Linux boards (Raspberry Pi family + overlay piFamily kinds) + // boot straight from the rootfs — there is no firmware to compile, + // and handleCompile's Pi early-return never sets compiledProgram, so + // the gate below would surface a bogus "Compilation produced no + // firmware" error. Power the board on directly (Run follows the + // standard disabled-while-running convention; RESET is the fast + // re-run-without-reboot on a booted guest). Must stay ABOVE the + // generic stop-then-boot restart below. + if (isPiBoardKind(board?.boardKind ?? '')) { + trackRunSimulation(board?.boardKind); + reportRun( + board?.boardKind, + decideEngine(activeBoardId, board?.enginePinned).engine, + ); + if (board?.running) { + // Zombie/edge case (Run is normally disabled while running): + // power-cycle for a clean boot. + stopBoard(activeBoardId); + await new Promise((resolve) => setTimeout(resolve, 300)); + } + console.log('[handleRun] → startBoard (QEMU-Linux, no firmware)', activeBoardId); + startBoard(activeBoardId); + setMessage(null); + return; + } + // Clean restart when the board is already running. Esp32Bridge.connect() + // is a no-op while the socket is non-CLOSED, so startBoard() on a live + // session does NOTHING — and if the backend QEMU session has since died + // but the frontend socket is still zombie (CONNECTING/OPEN/CLOSING), the + // user sees a dead sim that only a page reload fixes. This is the exact + // "el agente terminó, di Run y no funcionó; recargué y sí" report: the + // agent's run_simulation left the board running, so the user's Run + // no-op'd. Stop first (closes the WS), let it settle, then boot fresh — + // mirrors what the MicroPython branch above already does. + if (board?.running) { + stopBoard(activeBoardId); + await new Promise((resolve) => setTimeout(resolve, 300)); + } + if (!board?.compiledProgram || codeChangedSinceLastCompile) { + console.log('[handleRun] auto-compile + run'); + autoRunAfterCompile.current = true; + await handleCompile(); + const updatedBoard = useSimulatorStore + .getState() + .boards.find((b) => b.id === activeBoardId); + console.log('[handleRun] after compile', { + hasCompiledProgram: !!updatedBoard?.compiledProgram, + compiledProgramLen: updatedBoard?.compiledProgram?.length ?? 0, + autoRunFlag: autoRunAfterCompile.current, + }); + if (autoRunAfterCompile.current) { + autoRunAfterCompile.current = false; + if (updatedBoard?.compiledProgram) { + trackRunSimulation(updatedBoard.boardKind); + reportRun(updatedBoard.boardKind); + console.log('[handleRun] → startBoard', activeBoardId); + startBoard(activeBoardId); + setMessage(null); + } else { + // handleCompile returned without producing a firmware/program. + // Most common causes: arduino-cli unreachable, ESP-IDF compile + // error in the user's sketch, MicroPython firmware download + // failed, or the bridge rejected the load. handleCompile has + // already addLog'd the underlying error — surface a top-level + // toast too so the user knows their Run click didn't silently + // succeed. + const isMicropython = updatedBoard?.languageMode === 'micropython'; + const errText = isMicropython + ? 'MicroPython firmware did not load. Click "Load MicroPython" to retry, or check the console for the underlying error.' + : 'Compilation produced no firmware. Check the output console for the underlying error.'; + console.warn('[handleRun] compile finished but no compiledProgram — not starting'); + setMessage({ type: 'error', text: errText }); + addLog({ timestamp: new Date(), type: 'error', message: errText }); + } + } + return; + } + trackRunSimulation(board?.boardKind); + reportRun(board?.boardKind); + console.log('[handleRun] → startBoard (already compiled)', activeBoardId); + startBoard(activeBoardId); + setMessage(null); + return; + } + + // Auto-compile if no program or code changed since last compile + if (!board?.compiledProgram || codeChangedSinceLastCompile) { + autoRunAfterCompile.current = true; + await handleCompile(); + // After compile, check if it succeeded and run + const updatedBoard = useSimulatorStore + .getState() + .boards.find((b) => b.id === activeBoardId); + if (autoRunAfterCompile.current && updatedBoard?.compiledProgram) { + autoRunAfterCompile.current = false; + trackRunSimulation(updatedBoard.boardKind); + reportRun(updatedBoard.boardKind); + startBoard(activeBoardId); + setMessage(null); + } else { + autoRunAfterCompile.current = false; + } + return; + } + + trackRunSimulation(board?.boardKind); + reportRun(board?.boardKind); + startBoard(activeBoardId); + setMessage(null); + return; + } + + // Legacy fallback + if (!compiledHex || codeChangedSinceLastCompile) { + autoRunAfterCompile.current = true; + await handleCompile(); + const hex = useSimulatorStore.getState().compiledHex; + if (autoRunAfterCompile.current && hex) { + autoRunAfterCompile.current = false; + trackRunSimulation(); + reportRun(undefined); + startSimulation(); + setMessage(null); + } else { + autoRunAfterCompile.current = false; + } + } else { + trackRunSimulation(); + reportRun(undefined); + startSimulation(); + setMessage(null); + } + }; + + const handleStop = () => { + trackStopSimulation(); + if (isBoardless) { + // Freeze the chip tick (the paused flag) AND clear the chip's output + // drives so its LEDs go dark on Stop — not frozen at their last frame. + setElectricalPaused(true); + clearAllChipDrives(); + setMessage(null); + return; + } + // Stop EVERY running board — Run-All can start several, and leaving any + // running keeps chips ticking (their gate is boards.some(running)). + const runningBoards = useSimulatorStore.getState().boards.filter((b) => b.running); + if (runningBoards.length > 0) runningBoards.forEach((b) => stopBoard(b.id)); + else if (activeBoardId) stopBoard(activeBoardId); + else stopSimulation(); + // A chip wired to a board drives its LEDs via its own SPICE sources, which + // stopBoard doesn't touch — clear them so those LEDs also go dark. + clearAllChipDrives(); + setMessage(null); + }; + + const handleReset = () => { + trackResetSimulation(); + // QEMU-Linux boards: Reset = re-upload the edited files and re-run the + // script on the live guest (no ~45 s reboot). Mirrors what Reset means + // elsewhere — restart the program — while Run keeps the standard + // disabled-while-running behaviour. + if (activeBoard && isPiBoardKind(activeBoard.boardKind) && activeBoard.piBooted) { + void piRerunScript(activeBoard.id, activeBoard.boardKind); + setMessage(null); + return; + } + if (activeBoardId) resetBoard(activeBoardId); + else resetSimulation(); + setMessage(null); + }; + + /** + * Compile every board on the canvas sequentially. Progress + per-board + * results stream to the existing compilation console — no separate dialog. + * Returns the count of boards that ended up with a runnable program (so + * Run All can use it to decide whether to proceed to start them). + */ + const compileAllBoards = async (): Promise<{ ok: number; failed: number }> => { + const boardsList = useSimulatorStore.getState().boards; + // Every custom-chip is a target too — Compile-All / Run-All build chips + // (WASM + ROM) alongside boards, so the flow works for a board + chip, for + // several chips with no board, etc. + const allCustomChips = useSimulatorStore + .getState() + .components.filter((c) => c.metadataId === 'custom-chip'); + if (boardsList.length === 0 && allCustomChips.length === 0) return { ok: 0, failed: 0 }; + + setCompileAllRunning(true); + setConsoleOpen(true); + const targetSummary = [ + boardsList.length ? `${boardsList.length} board${boardsList.length === 1 ? '' : 's'}` : '', + allCustomChips.length ? `${allCustomChips.length} chip${allCustomChips.length === 1 ? '' : 's'}` : '', + ] + .filter(Boolean) + .join(' + '); + addLog({ + timestamp: new Date(), + type: 'info', + message: `Compiling all targets (${targetSummary})...`, + }); + + // Make every custom-chip live (WASM + ROM) before compiling the boards, + // mirroring the single-board Compile path, and collect their program file + // names so they stay out of the arduino-cli builds below. + const chipProgramFiles = new Set(); + for (const chip of allCustomChips) { + const pf = String((chip.properties as any)?.programFile ?? '').trim(); + if (pf) chipProgramFiles.add(pf); + } + let chipFailed = 0; + if (allCustomChips.length > 0) { + const everyFile = boardsList.flatMap((b) => + useEditorStore.getState().getGroupFiles(b.activeFileGroupId), + ); + chipFailed = (await prepareCustomChips(allCustomChips, everyFile)).failed; + } + + let ok = 0; + let boardFailed = 0; + + for (const board of boardsList) { + const label = boardDisplayName(board); + // Stamp this board's lines so the console groups them under its section. + const boardTarget: CompileTarget = { id: board.id, label, kind: 'board' }; + const blog = (type: CompilationLog['type'], message: string) => + addLog({ timestamp: new Date(), type, message, target: boardTarget }); + + if (isPiBoardKind(board.boardKind)) { + blog('info', 'skipped (no compilation needed)'); + ok++; + continue; + } + + const fqbn = BOARD_KIND_FQBN[board.boardKind]; + if (!fqbn) { + blog('error', 'no FQBN configured'); + boardFailed++; + continue; + } + + blog('info', 'compiling...'); + + try { + const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId); + const sketchFiles = groupFiles + .filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name)) + .map((f) => ({ name: f.name, content: f.content })); + + // Stream live cmake + ninja output per-board (Compile-All flow). + let lastStreamedLen = 0; + const result = await compileCode( + sketchFiles, + fqbn, + currentProject?.id ?? null, + ({ stdout }) => { + if (stdout.length <= lastStreamedLen) return; + const delta = stdout.slice(lastStreamedLen); + lastStreamedLen = stdout.length; + const newLines = delta.split('\n').filter((s) => s.trim()); + if (!newLines.length) return; + const now = new Date(); + setCompileLogs((prev: CompilationLog[]) => [ + ...prev, + // No `${label}: ` prefix — the target section header carries it. + ...newLines.map((line) => ({ + timestamp: now, + type: 'info' as const, + message: line, + target: boardTarget, + })), + ]); + }, + { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined }, + ); + + const resultLogs = parseCompileResult(result, label, boardTarget); + setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]); + + if (result.success) { + const program = result.hex_content ?? result.binary_content ?? null; + if (program) { + compileBoardProgram(board.id, program); + if (result.has_wifi !== undefined) { + updateBoard(board.id, { hasWifi: result.has_wifi }); + } + } + ok++; + } else { + boardFailed++; + } + } catch (err) { + blog('error', err instanceof Error ? err.message : String(err)); + boardFailed++; + } + } + + const failed = boardFailed + chipFailed; + const chipOk = allCustomChips.length - chipFailed; + const doneParts = []; + if (boardsList.length) + doneParts.push(`${ok} board${ok === 1 ? '' : 's'} ok${boardFailed > 0 ? `, ${boardFailed} failed` : ''}`); + if (allCustomChips.length) + doneParts.push(`${chipOk} chip${chipOk === 1 ? '' : 's'} ok${chipFailed > 0 ? `, ${chipFailed} failed` : ''}`); + addLog({ + timestamp: new Date(), + type: failed > 0 ? 'error' : 'success', + message: `Done — ${doneParts.join('; ')}`, + }); + if (failed === 0) markCompiled(); + setCompileAllRunning(false); + return { ok, failed }; + }; + + const handleCompileAll = () => { + trackCompileCode(); + void compileAllBoards(); + }; + + /** + * Run All = compile every target (boards + chips) if needed, then start every + * one: boards via startBoard, chips via restartParts (re-attach with the + * fresh WASM/ROM) + resuming the electrical solver when there's no board. + * Mirrors single Run, generalised across all targets. + */ + const handleRunAll = async (skipVerify = false) => { + const sim = useSimulatorStore.getState(); + const boardsList = sim.boards; + const chips = sim.components.filter((c) => c.metadataId === 'custom-chip'); + if (boardsList.length === 0 && chips.length === 0) return; + + // Same pre-flight safety check as handleRun — block on shorts / overcurrent + // before starting every board, with a "Run anyway" escape. + if (!skipVerify) { + const ok = await checkOrBlock(() => handleRunAll(true)); + if (!ok) return; + } + + // A chip needs compiling when it has no WASM yet, or it references a program + // file but hasn't been assembled to ROM. + const chipNeedsCompile = chips.some((c) => { + const p = c.properties as Record; + const programFile = String(p?.programFile ?? '').trim(); + return !String(p?.wasmBase64 ?? '') || (programFile && !String(p?.romBytes ?? '')); + }); + const needsCompile = + codeChangedSinceLastCompile || + chipNeedsCompile || + boardsList.some( + (b) => + !isPiBoardKind(b.boardKind) && + b.languageMode !== 'micropython' && + !b.compiledProgram, + ); + + if (needsCompile) { + const { failed } = await compileAllBoards(); + if (failed > 0) return; // a board failed — don't start anything + } + + // Start every board (compiledProgram may have changed during compile). + const refreshed = useSimulatorStore.getState().boards; + for (const board of refreshed) { + if (board.running) continue; + if (isQemuBoardKind(board.boardKind) || board.compiledProgram || board.languageMode === 'micropython') { + trackRunSimulation(board.boardKind); + reportRun(board.boardKind); + startBoard(board.id); + } + } + + // Run the chips: re-attach so they pick up the freshly compiled WASM/ROM. + // The chip tick gates on a running board, so when NO board actually started + // (board-less, or a board that compiled to nothing) resume the electrical + // solver instead, otherwise the chips would stay frozen. + if (chips.length > 0) { + useSimulatorStore.getState().restartParts(); + const anyBoardRunning = useSimulatorStore.getState().boards.some((b) => b.running); + if (!anyBoardRunning) setElectricalPaused(false); + } + }; + + const handleExport = async () => { + try { + const { + components, + wires, + boardPosition, + boardType: legacyBoardType, + } = useSimulatorStore.getState(); + const projectName = + files.find((f) => f.name.endsWith('.ino'))?.name.replace('.ino', '') || 'velxio-project'; + await exportToWokwiZip(files, components, wires, legacyBoardType, projectName, boardPosition); + } catch (err) { + setMessage({ type: 'error', text: 'Export failed.' }); + } + }; + + // Phase 3 D3.2 — Schematic screenshot. Pro-tier-gated by the backend. + // Same UX pattern as BOM export: everyone can click; 402 redirects to + // /pricing. The server-side headless chromium renders the canvas and + // returns a PNG, which we trigger a download for. + const handleExportScreenshot = async () => { + const projectId = currentProject?.id; + if (!projectId) { + setMessage({ type: 'error', text: 'Save the project before exporting an image.' }); + return; + } + setMessage({ type: 'info', text: 'Rendering screenshot — may take 5-10 seconds…' }); + try { + const resp = await fetch(`/api/pro/projects/${projectId}/screenshot.png`, { + credentials: 'include', + }); + if (resp.status === 402) { + // Fire the in-place upgrade modal instead of bouncing to /pricing — + // keeps the user in the editor with full context. The pro overlay's + // UpgradeGate listens for this event and opens UpgradePromptModal. + window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', { + detail: { componentName: 'Schematic screenshot export' }, + })); + return; + } + if (resp.status === 401) { + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`; + return; + } + if (resp.status === 422) { + setMessage({ type: 'error', text: 'Add at least one component to export an image.' }); + return; + } + if (!resp.ok) { + setMessage({ type: 'error', text: 'Screenshot export failed.' }); + return; + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const cd = resp.headers.get('Content-Disposition') || ''; + const m = /filename="?([^"]+)"?/.exec(cd); + a.download = m ? m[1] : `velxio-${projectId}.png`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + setMessage({ type: 'success', text: 'Screenshot downloaded.' }); + } catch { + setMessage({ type: 'error', text: 'Screenshot export failed.' }); + } + }; + + // Phase 3 D3.1 — BOM export. Pro-tier-gated by the backend (402 if not pro). + // We let everyone click; the 402 response feeds the upgrade prompt below + // so free/maker users hit the funnel naturally instead of an obviously- + // locked button (which they'd just dismiss). + const handleExportBom = async () => { + const projectId = currentProject?.id; + if (!projectId) { + setMessage({ type: 'error', text: 'Save the project before exporting a BOM.' }); + return; + } + try { + const resp = await fetch(`/api/pro/projects/${projectId}/bom.csv`, { + credentials: 'include', + }); + if (resp.status === 402) { + // Fire the in-place upgrade modal instead of bouncing to /pricing — + // keeps the user in the editor with full context. The pro overlay's + // UpgradeGate listens for this event and opens UpgradePromptModal. + window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', { + detail: { componentName: 'BOM export' }, + })); + return; + } + if (resp.status === 401) { + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`; + return; + } + if (!resp.ok) { + setMessage({ type: 'error', text: 'BOM export failed.' }); + return; + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + // Filename comes from Content-Disposition; pick a fallback. + const cd = resp.headers.get('Content-Disposition') || ''; + const m = /filename="?([^"]+)"?/.exec(cd); + a.download = m ? m[1] : `bom-${projectId}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch { + setMessage({ type: 'error', text: 'BOM export failed.' }); + } + }; + + const handleFirmwareUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (firmwareInputRef.current) firmwareInputRef.current.value = ''; + if (!file) return; + + setConsoleOpen(true); + addLog({ timestamp: new Date(), type: 'info', message: `Loading firmware: ${file.name}...` }); + + try { + const boardKind = activeBoard?.boardKind; + if (!boardKind) { + setMessage({ type: 'error', text: 'No board selected' }); + return; + } + + const result = await readFirmwareFile(file, boardKind); + + // Architecture mismatch warning for ELF files + if (result.elfInfo?.suggestedBoard && result.elfInfo.suggestedBoard !== boardKind) { + const detected = result.elfInfo.architectureName; + const current = activeBoard ? boardDisplayName(activeBoard) : boardKind; + addLog({ + timestamp: new Date(), + type: 'info', + message: `Note: Detected ${detected} architecture, but current board is ${current}. Loading anyway.`, + }); + } + + if (activeBoardId) { + compileBoardProgram(activeBoardId, result.program); + markCompiled(); + addLog({ timestamp: new Date(), type: 'info', message: result.message }); + setMessage({ type: 'success', text: `Firmware loaded: ${file.name}` }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Failed to load firmware'; + addLog({ timestamp: new Date(), type: 'error', message: errMsg }); + setMessage({ type: 'error', text: errMsg }); + } + }; + + const handleImportFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!importInputRef.current) return; + importInputRef.current.value = ''; + if (!file) return; + try { + const result = await importProjectFile(file); + if (result.kind === 'vlx') { + // importVlxFile already wrote into the stores. + setMessage({ type: 'success', text: `Imported ${file.name}` }); + return; + } + // .zip path: apply the parsed payload to the stores ourselves, then + // surface any missing libraries via the existing install modal. + const { loadFiles } = useEditorStore.getState(); + const { setComponents, setWires, setBoardType, setBoardPosition, stopSimulation } = + useSimulatorStore.getState(); + stopSimulation(); + if (result.boardType) setBoardType(result.boardType); + setBoardPosition(result.boardPosition); + setComponents(result.components); + setWires(result.wires); + if (result.files.length > 0) loadFiles(result.files); + setMessage({ type: 'success', text: `Imported ${file.name}` }); + if (result.libraries.length > 0) { + setPendingLibraries(result.libraries); + setInstallModalOpen(true); + } + } catch (err: any) { + setMessage({ type: 'error', text: err?.message || 'Import failed.' }); + } + }; + + // File-menu commands owned by this toolbar (the handlers close over its + // state). Registered through a latest-ref so the one-time registration + // always invokes the current render's closure, never a stale one. + const makeMenuCommands = () => ({ + import: () => importInputRef.current?.click(), + export: () => void handleExport(), + bom: () => void handleExportBom(), + screenshot: () => void handleExportScreenshot(), + firmware: () => firmwareInputRef.current?.click(), + // Pro actions fire the same window events the old "..." menu items + // fired; without the overlay they are silent no-ops, which is fine — + // OSS builds cannot have linked repos or shared projects anyway. + share: () => + window.dispatchEvent(new CustomEvent('velxio-pro-share-prompt', { + detail: { projectId: currentProject?.id ?? null }, + })), + githubSync: () => + window.dispatchEvent(new CustomEvent('velxio-pro-github-sync-prompt', { + detail: { projectId: currentProject?.id ?? null }, + })), + record: () => + window.dispatchEvent(new CustomEvent('velxio-pro-replay-record-toggle', { + detail: { projectId: currentProject?.id ?? null }, + })), + compile: () => void handleCompile(), + run: () => void handleRun(), + stop: () => handleStop(), + resetBoard: () => handleReset(), + toggleConsole: () => setConsoleOpen((v) => !v), + }); + const menuCommandsRef = useRef(makeMenuCommands()); + menuCommandsRef.current = makeMenuCommands(); + useEffect(() => { + const offs = [ + registerEditorCommand('project.import', () => menuCommandsRef.current.import()), + registerEditorCommand('project.export', () => menuCommandsRef.current.export()), + registerEditorCommand('project.exportBom', () => menuCommandsRef.current.bom()), + registerEditorCommand('project.exportScreenshot', () => menuCommandsRef.current.screenshot()), + registerEditorCommand('firmware.upload', () => menuCommandsRef.current.firmware()), + registerEditorCommand('project.share', () => menuCommandsRef.current.share()), + registerEditorCommand('project.githubSync', () => menuCommandsRef.current.githubSync()), + registerEditorCommand('sim.record', () => menuCommandsRef.current.record()), + registerEditorCommand('sim.compile', () => menuCommandsRef.current.compile()), + registerEditorCommand('sim.run', () => menuCommandsRef.current.run()), + registerEditorCommand('sim.stop', () => menuCommandsRef.current.stop()), + registerEditorCommand('sim.resetBoard', () => menuCommandsRef.current.resetBoard()), + registerEditorCommand('view.toggleConsole', () => menuCommandsRef.current.toggleConsole()), + ]; + return () => offs.forEach((off) => off()); + }, []); + + return ( + <> +
+
+ {/* Language selector — only when active board supports an + alternative to Arduino C++ (MicroPython on Pico/ESP32 boards, + pure ESP-IDF on the ESP32 family — issue #139). The board + context pill that used to live here was removed: it duplicated + the BoardSelector dropdown elsewhere in the toolbar. */} + {activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && ( + + )} + +
+ {/* Compile */} + + +
+ + {/* Run — in a multi-board project this runs ALL boards (the wired + boards are one system; running a subset is almost never + intended), with a split-menu to still run only the active board. + Single-board / board-less behaviour is unchanged. */} +
+ + {isMultiBoard && ( + + )} + {isMultiBoard && runMenuOpen && ( +
+ + +
+ )} +
+ + {/* Stop */} + + + {/* Reset — for a booted QEMU-Linux guest this re-uploads the + edited files and re-runs the script without rebooting. */} + + + {targetCount > 1 && ( + <> +
+ + {/* Compile All — boards + programmable chips */} + + + {/* Run All — only when the primary Run isn't already the + "run all boards" action (i.e. board + chip or chips-only + projects). For 2+ boards the split Run button covers it. */} + {!isMultiBoard && ( + + )} + + )} +
+ + {/* Center slot — a flexible spacer that keeps the right action group + pinned to the far right. Rendered unconditionally so the layout + holds even when no overlay supplies content here. */} +
{centerSlot}
+ +
+ {/* Hidden file input for project import. Accepts both .vlx + (Velxio native) and .zip (Wokwi bundle); the dispatcher in + utils/importProject.ts picks the right loader by extension. */} + + {/* Hidden file input for firmware upload */} + + + {/* Library Manager — always visible with label */} + + + {/* Import / Export moved to the File menu in the header — the + hidden input above stays because the File-menu command + clicks it through the editorCommands registry. */} + {/* Overflow "More" menu — collects the secondary actions + (BOM, Schematic image, Upload firmware) so the toolbar no + longer overflows on narrow widths. The two Pro items show + a small "PRO" pill in the menu so users know they're + premium BEFORE clicking, instead of being surprised by an + upgrade prompt. */} + {/* The "..." menu is gone: every item it held now lives in the + File menu (with PRO pills where they apply). */} +
+ + {/* Output Console toggle */} + + {rightSlot} +
+
+
+ + {/* Error detail bar */} + {message?.type === 'error' && message.text.length > 40 && !consoleOpen && ( +
{message.text}
+ )} + + {/* Missing library hint */} + {missingLibHint && ( +
+ + + + + + {t('editor.toolbar.libHint.message')} + + +
+ )} + + setLibManagerOpen(false)} /> + setInstallModalOpen(false)} + libraries={pendingLibraries} + /> + {verification && ( + { + pendingRunRef.current = null; + setVerification(null); + }} + onRunAnyway={() => { + const resume = pendingRunRef.current; + pendingRunRef.current = null; + setVerification(null); + resume?.(); + }} + /> + )} + + ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css new file mode 100644 index 00000000..ae3ca489 --- /dev/null +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -0,0 +1,585 @@ +.simulator-canvas-container { + display: flex; + height: 100%; + position: relative; +} + +.simulator-canvas { + flex: 1; + background-color: #1a1a1a; + color: #e0e0e0; + display: flex; + flex-direction: column; +} + +/* ── Canvas header ───────────────────────────────── */ +.canvas-header { + height: 46px; + padding: 0 10px; + background: #252526; + border-bottom: 1px solid #333; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +/* When portaled into the unified top toolbar, drop the standalone chrome + (border + background) and shrink to match the editor toolbar height so + the two halves read as a single bar. Also drop space-between so the + left/right groups sit adjacent to each other inside the slot. */ +.canvas-header--portaled { + height: 38px; + padding: 0 8px; + background: transparent; + border-bottom: none; + justify-content: flex-start; + gap: 8px; +} + +.canvas-header-left, +.canvas-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +/* ── Status LED dot ──────────────────────────────── */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + transition: + background 0.3s, + box-shadow 0.3s; +} + +.status-dot.running { + background: #4caf50; + box-shadow: 0 0 6px rgba(76, 175, 80, 0.7); + animation: pulse-green 2s ease-in-out infinite; +} + +.status-dot.stopped { + background: #555; + box-shadow: none; +} + +@keyframes pulse-green { + 0%, + 100% { + box-shadow: 0 0 4px rgba(76, 175, 80, 0.5); + } + 50% { + box-shadow: 0 0 10px rgba(76, 175, 80, 0.9); + } +} + +/* ── Board Selector ──────────────────────────────── */ +.board-selector { + padding: 4px 8px; + background: #1e2a38; + color: #c9d1d9; + border: 1px solid #3a4a5a; + border-radius: 5px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s; + height: 28px; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 6px center; + padding-right: 22px; +} + +.board-selector:hover:not(:disabled) { + border-color: var(--color-action-primary); +} + +.board-selector:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Serial Monitor button ───────────────────────── */ +.canvas-serial-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + height: 28px; + background: transparent; + border: 1px solid #3a4a5a; + border-radius: 5px; + color: #9d9d9d; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s, + color 0.15s; + white-space: nowrap; +} + +.canvas-serial-btn:hover { + border-color: var(--color-action-primary); + color: #ccc; +} + +.canvas-serial-btn-active { + background: #0e3a5a; + border-color: var(--color-action-primary); + color: var(--color-action-primary); +} + +/* ── Canvas icon-only buttons (undo/redo, …) ─────── */ +.canvas-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: 1px solid #3a4a5a; + border-radius: 5px; + color: #9d9d9d; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s, + color 0.15s, + opacity 0.15s; + flex-shrink: 0; +} + +.canvas-icon-btn:hover:not(:disabled) { + border-color: var(--color-action-primary); + color: #ccc; +} + +.canvas-icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Component count ─────────────────────────────── */ +.component-count { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #666; +} + +/* ── Add Component button ────────────────────────── */ +.add-component-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + height: 28px; + background: var(--color-action-primary); + color: #fff; + border: none; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.add-component-btn:hover:not(:disabled) { + background: var(--color-action-primary-hover); +} + +.add-component-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* ── Canvas content (viewport) ───────────────────── */ +.canvas-content { + flex: 1; + position: relative; + overflow: hidden; + background-color: #1a1a1a; + background-image: + repeating-linear-gradient( + 0deg, + transparent, + transparent 19px, + rgba(255, 255, 255, 0.025) 19px, + rgba(255, 255, 255, 0.025) 20px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 19px, + rgba(255, 255, 255, 0.025) 19px, + rgba(255, 255, 255, 0.025) 20px + ); + user-select: none; + touch-action: none; /* Disable browser scroll/zoom so JS handlers take full control */ +} + +/* ── Infinite canvas world ───────────────────────── */ +.canvas-world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + width: 4000px; + height: 3000px; +} + +/* ── Components area ─────────────────────────────── */ +.components-area { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + /* Hit-test transparent: this full-canvas div sits later in the DOM than + the boards, so with pointer events it would swallow clicks meant for + board pins / board drag overlays (their z-index is local to each + board's stacking context). Component groups re-enable their own. */ + pointer-events: none; +} + +/* ── Zoom controls ───────────────────────────────── */ +.zoom-controls { + display: flex; + align-items: center; + gap: 2px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 5px; + padding: 0 2px; + height: 28px; +} + +.zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 3px; + color: #9d9d9d; + cursor: pointer; + flex-shrink: 0; +} + +.zoom-btn:hover { + background: #3a3a3a; + color: #ccc; +} + +.zoom-level { + min-width: 42px; + text-align: center; + background: transparent; + border: none; + color: #9d9d9d; + font-size: 11px; + font-family: monospace; + cursor: pointer; + padding: 0 2px; + border-radius: 3px; +} + +.zoom-level:hover { + background: #3a3a3a; + color: #ccc; +} + +.component-label { + font-size: 11px; + background-color: #252526; + padding: 3px 8px; + border-radius: 3px; + white-space: nowrap; + margin-top: 5px; + text-align: center; + color: #aaa; +} + +/* ── Wire creation mode banner ──────────────────── */ +.wire-mode-banner { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + background: rgba(0, 113, 227, 0.92); + color: #fff; + padding: 8px 16px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + white-space: nowrap; + pointer-events: auto; +} + +.wire-mode-banner button { + background: rgba(255, 255, 255, 0.2); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 4px; + padding: 4px 14px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: background 0.15s; +} + +.wire-mode-banner button:hover { + background: rgba(255, 255, 255, 0.35); +} + +/* ── Mobile-friendly adjustments ────────────────── */ +@media (max-width: 768px) { + .canvas-header { + height: 42px; + padding: 0 6px; + } + + .canvas-header-left, + .canvas-header-right { + gap: 4px; + } + + .canvas-serial-btn { + padding: 0 6px; + font-size: 0; /* hide text, show icon only */ + } + + .canvas-serial-btn svg { + width: 18px; + height: 18px; + } + + .add-component-btn { + padding: 0 8px; + font-size: 0; /* hide text, show icon only */ + } + + .add-component-btn svg { + width: 18px; + height: 18px; + } + + .board-selector { + font-size: 11px; + max-width: 90px; + padding: 4px 20px 4px 6px; + } + + .zoom-controls { + display: none; /* Use pinch-to-zoom on mobile */ + } + + .component-count { + display: none; + } + + .wire-mode-banner { + font-size: 12px; + padding: 6px 12px; + gap: 8px; + bottom: 8px; + } +} + +/* ── Narrow shared toolbar (desktop, AI chat open) ──────────────────────── + When the canvas header is portaled into the unified top toolbar, it shares + one row with the editor actions. As that *bar* narrows (mainly when the + right-docked AI chat opens), collapse the canvas controls the same way the + mobile block does — but keyed to the bar's own width via a container query, + not the viewport, so it triggers on a wide screen with the chat open too. + Priority: labels -> icons, then drop the non-interactive count, then the + zoom buttons (mouse-wheel zoom still works). Mirrors the thresholds the + editor toolbar and view-mode toggle collapse at, so the whole bar degrades + together and nothing overlaps. */ +@container unified-toolbar (max-width: 1010px) { + .canvas-serial-btn { + padding: 0 8px; + font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */ + } + .canvas-serial-btn svg { + width: 18px; + height: 18px; + } + .add-component-btn { + padding: 0 9px; + font-size: 0; /* hide "Add" text, keep the icon */ + } + .add-component-btn svg { + width: 18px; + height: 18px; + } + .board-selector { + max-width: 120px; + text-overflow: ellipsis; + overflow: hidden; + } +} +@container unified-toolbar (max-width: 905px) { + .component-count { + display: none; + } + .board-selector { + max-width: 96px; + } +} +@container unified-toolbar (max-width: 835px) { + .zoom-controls { + display: none; /* mouse-wheel / trackpad zoom still available */ + } +} + +/* ── WiFi / BLE status badges ─────────────────────────────────────────── */ +.canvas-wifi-badge, +.canvas-ble-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border-radius: 4px; + cursor: default; + vertical-align: middle; +} + +.canvas-wifi-badge svg, +.canvas-ble-badge svg { + display: block; +} + +/* WiFi status colours */ +.canvas-wifi-initializing { + color: #f59e0b; +} +.canvas-wifi-connected { + color: #f59e0b; +} +.canvas-wifi-got_ip { + color: #22c55e; +} +.canvas-wifi-disconnected { + color: #6b7280; +} + +.canvas-wifi-clickable { + cursor: pointer !important; + transition: + transform 0.2s, + filter 0.2s; +} + +.canvas-wifi-clickable:hover { + transform: scale(1.15); + filter: drop-shadow(0 0 5px currentColor); +} + +.canvas-wifi-clickable:active { + transform: scale(0.95); +} + +/* BLE status colours */ +.canvas-ble-initialized { + color: var(--color-action-primary); +} +.canvas-ble-advertising { + color: #6366f1; +} + +/* ── Selection action bar (floating toolbar for selected wire/component/board) ─── */ +.selection-action-bar button:hover { + background: #2a2d2e !important; + border-color: #3c3c3c !important; +} +.selection-action-bar button:active { + transform: scale(0.97); +} +.selection-action-bar button:focus-visible { + outline: 2px solid var(--color-action-primary); + outline-offset: 1px; +} + +/* Hide button labels on narrow viewports — keep just the icons */ +@media (max-width: 520px) { + .selection-action-bar .selection-action-bar__label { + display: none; + } +} + +/* Runtime burnout (P4): a component destroyed mid-simulation renders charred, + with a red dashed outline + a smoke badge (the badge is a sibling of the + container, so it stays full-colour). Cleared on Reset. */ +.velxio-burnt .web-component-container { + filter: grayscale(0.85) brightness(0.4) sepia(0.5) contrast(1.15); + transition: filter 0.25s ease; +} +.velxio-burnt { + outline: 1px dashed rgba(220, 38, 38, 0.65); + outline-offset: 1px; + border-radius: 4px; +} + +/* Selected component: marching ants. + A newly added part lands in a corner of the viewport and the previous + flat dashed border was easy to miss on a busy canvas — a moving outline + is what the eye actually catches. Drawn on a pseudo-element just OUTSIDE + the body so it never covers the artwork, and pointer-events: none so it + never steals a click or a pin. + + Animating a dashed border is not possible (dash offset is not an + animatable property), hence the four gradient edges with a moving + background-position — the standard recipe. */ +.velxio-ants::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 5px; + pointer-events: none; + z-index: 6; + background-image: + linear-gradient(90deg, var(--ant-color) 50%, transparent 0), + linear-gradient(90deg, var(--ant-color) 50%, transparent 0), + linear-gradient(0deg, var(--ant-color) 50%, transparent 0), + linear-gradient(0deg, var(--ant-color) 50%, transparent 0); + background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; + background-size: 10px 2px, 10px 2px, 2px 10px, 2px 10px; + background-position: 0 0, 0 100%, 0 0, 100% 0; + animation: velxio-ants 0.55s linear infinite; +} + +.velxio-ants { + --ant-color: #2f9bff; +} + +@keyframes velxio-ants { + to { + background-position: 10px 0, -10px 100%, 0 -10px, 100% 10px; + } +} + +/* Respect the OS setting: the outline still marks the selection, it just + stops moving. */ +@media (prefers-reduced-motion: reduce) { + .velxio-ants::after { + animation: none; + } +} diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index e779351a..9a920220 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -50,7 +50,7 @@ import { seatOnDrop, snapPositionToBreadboard, } from '../../utils/breadboardSnap'; -import { snapBoardToSocket } from '../../utils/socketSnap'; +import { snapBoardToSocket, isBoardSeated } from '../../utils/socketSnap'; import { findWireNearPoint, findSegmentNearPoint, @@ -138,8 +138,7 @@ function carrySeatedBoards( for (const b of st.boards) { // Restricting the candidate list to the dragged component asks the // narrow question: is this board seated on THIS socket? - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [dragged]); - if (seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5) { + if (isBoardSeated(b.id, b.boardKind, b.x, b.y, [dragged])) { st.setBoardPosition({ x: b.x + dx, y: b.y + dy }, b.id); } } @@ -371,6 +370,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Which drag session already raised its item (drag-to-front fires once per // drag, on the first mousemove). const raisedThisDragRef = useRef(null); + /** + * The socket a dragged board was plugged into when the drag STARTED. + * Latched once per drag: "is it seated?" is only true at the seat, so + * re-asking after the first pixel of movement answers no and the stack + * would come apart one frame in. + */ + const carriedSocketRef = useRef<{ dragId: string; sockId: string } | null>(null); // Captures (x, y) of the dragged component at mousedown so a drag-end // can record the diff as a single undoable Move. Boards are intentionally // skipped — board moves don't go through component history. @@ -932,14 +938,22 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const st = useSimulatorStore.getState(); const b = st.boards.find((bb) => bb.id === boardId); // Same stack rule as the mouse path: while simulating, a seated - // board drags its socket along instead of unplugging. - const sock = - st.running && b - ? st.components.find((c) => { - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) - : undefined; + // board drags its socket along instead of unplugging. Latched at + // drag start for the same reason (see carriedSocketRef). + if (carriedSocketRef.current?.dragId !== touchId) { + // Ask the DRAGGED board whether it is running, not the store's + // top-level flag — that one mirrors the ACTIVE board only, so a + // running non-active board read as stopped and its stack came + // apart mid-simulation. + const boardRunning = !!(b && (b.running || st.running)); + const seatedOn = + boardRunning && b + ? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c])) + : undefined; + carriedSocketRef.current = { dragId: touchId, sockId: seatedOn?.id ?? '' }; + } + const sockId = carriedSocketRef.current.sockId; + const sock = sockId ? st.components.find((c) => c.id === sockId) : undefined; if (b && sock) { updateComponent(sock.id, { x: sock.x + (nb.x - b.x), @@ -1602,13 +1616,23 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // While SIMULATING, a seated stack moves as one piece: dragging the // board drags its socket along instead of unplugging it. Unplugging // is an edit-mode gesture (carrySeatedBoards is the other half). - const sock = - st.running && b - ? st.components.find((c) => { - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) - : undefined; + if (carriedSocketRef.current?.dragId !== draggedComponentId) { + // Ask the DRAGGED board whether it is running, not the store's + // top-level flag — that one mirrors the ACTIVE board only, so a + // running non-active board read as stopped and its stack came + // apart mid-simulation. + const boardRunning = !!(b && (b.running || st.running)); + const seatedOn = + boardRunning && b + ? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c])) + : undefined; + carriedSocketRef.current = { + dragId: draggedComponentId, + sockId: seatedOn?.id ?? '', + }; + } + const sockId = carriedSocketRef.current.sockId; + const sock = sockId ? st.components.find((c) => c.id === sockId) : undefined; if (b && sock) { updateComponent(sock.id, { x: sock.x + (nb.x - b.x), @@ -1940,6 +1964,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { recalculateAllWirePositions(); setDraggedComponentId(null); raisedThisDragRef.current = null; + carriedSocketRef.current = null; } }; @@ -2911,6 +2936,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { setPan({ ...panRef.current }); setDraggedComponentId(null); raisedThisDragRef.current = null; + carriedSocketRef.current = null; }} onContextMenu={(e) => { e.preventDefault(); diff --git a/frontend/src/lib/onlineOnlyBoards.ts b/frontend/src/lib/onlineOnlyBoards.ts index a99607e8..6992e07f 100644 --- a/frontend/src/lib/onlineOnlyBoards.ts +++ b/frontend/src/lib/onlineOnlyBoards.ts @@ -189,10 +189,10 @@ export const ONLINE_ONLY_COMPONENT_ADS: OnlineOnlyComponentAd[] = [ }, { id: 'grove-gesture-pag7660', - label: 'Grove Smart IR Gesture (PAG7660)', - description: 'IR gesture recognition (rotate/push/tap/...) - available in the online editor', + label: 'Grove Smart IR Gesture (PAG7661QN)', + description: 'IR camera gesture sensor on a XIAO carrier: seat a XIAO on its socket and read rotate/tap/grab/pinch/swipe over I2C - available in the online editor', category: 'input', - thumbnailSvg: '', + thumbnailSvg: '', }, { id: 'respeaker-lite', diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index b8901794..24ba269f 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -52,7 +52,7 @@ import { collectWireSegments, } from '../utils/wireAutoRoute'; import { isBreadboard } from '../utils/breadboardNets'; -import { snapBoardToSocket } from '../utils/socketSnap'; +import { isBoardSeated } from '../utils/socketSnap'; import { computeSeating } from '../utils/breadboardSnap'; import { createSerialBatcher } from './serialBatcher'; import { @@ -3169,16 +3169,9 @@ export const useSimulatorStore = create((set, get) => { // above — raising the socket alone buried its own seated board, and a // buried board cannot be grabbed to unplug it. const seatedOnIt = s.components.some((c) => c.id === id) - ? s.boards.filter((b) => { - const seat = snapBoardToSocket( - b.id, - b.boardKind, - b.x, - b.y, - s.components.filter((c) => c.id === id), - ); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) + ? s.boards.filter((b) => + isBoardSeated(b.id, b.boardKind, b.x, b.y, s.components.filter((c) => c.id === id)), + ) : []; let top = s.zTop; const zOrders = { ...s.zOrders, [id]: ++top }; diff --git a/frontend/src/utils/socketSnap.ts b/frontend/src/utils/socketSnap.ts index a2ae90f9..ab7a0f62 100644 --- a/frontend/src/utils/socketSnap.ts +++ b/frontend/src/utils/socketSnap.ts @@ -95,12 +95,24 @@ export function snapBoardToSocket( } /** - * True when the board's CURRENT position IS a socket seat (within half a - * pixel). This is the z-order question, not the drag question: a seated - * board must paint above its socket component, an unseated one must stay - * below components like every other board — the blanket zIndex bump that - * preceded this check hid a resistor behind an Arduino in every ordinary - * example. + * How far off the exact seat a board may sit and still count as plugged in. + * Not zero, and deliberately far below SOCKET_SNAP_TOLERANCE: a stack the + * magnet built lands exact, but one an EXAMPLE declares (or a project saved + * before a socket's art was nudged) can be a fraction of a pixel out. At the + * old half-pixel bar such a board looked seated on screen while every seat + * test said otherwise — so it got no electrical connection and its socket + * did not travel with it. A couple of pixels is invisible to the eye and + * still nowhere near the next hole. + */ +const SEATED_EPSILON = 2; + +/** + * True when the board's CURRENT position IS a socket seat. This is the + * "is it plugged in?" question, asked by z-order (a seated board must paint + * above its socket, an unseated one stays below components like every other + * board — a blanket zIndex bump once hid a resistor behind an Arduino), + * by the electrical hop that makes seating mean connection, and by the drag + * rules that keep a plugged stack together. */ export function isBoardSeated( boardId: string, @@ -110,5 +122,5 @@ export function isBoardSeated( components: ComponentLike[], ): boolean { const seat = snapBoardToSocket(boardId, boardKind, x, y, components); - return !!seat && Math.hypot(seat.x - x, seat.y - y) < 0.5; + return !!seat && Math.hypot(seat.x - x, seat.y - y) <= SEATED_EPSILON; }