From 35155e65975587084708fea99d25cc9c112f10ca Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 1 Aug 2026 07:01:17 +0000 Subject: [PATCH] chore: remove unused files --- .../src/components/editor/EditorToolbar.css | 434 ---- .../src/components/editor/EditorToolbar.tsx | 1864 ----------------- .../components/simulator/SimulatorCanvas.css | 585 ------ 3 files changed, 2883 deletions(-) delete mode 100644 frontend/src/components/editor/EditorToolbar.css delete mode 100644 frontend/src/components/editor/EditorToolbar.tsx delete mode 100644 frontend/src/components/simulator/SimulatorCanvas.css diff --git a/frontend/src/components/editor/EditorToolbar.css b/frontend/src/components/editor/EditorToolbar.css deleted file mode 100644 index 28e81f81..00000000 --- a/frontend/src/components/editor/EditorToolbar.css +++ /dev/null @@ -1,434 +0,0 @@ -/* ── 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 deleted file mode 100644 index ef45c420..00000000 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ /dev/null @@ -1,1864 +0,0 @@ -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 deleted file mode 100644 index ae3ca489..00000000 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ /dev/null @@ -1,585 +0,0 @@ -.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; - } -}