Commit Graph

751 Commits

Author SHA1 Message Date
David Montero 858e160e6f chore(sitemap): bump lastmod dates to 2026-05-26 2026-05-26 20:53:24 +02:00
David Montero Crespo 91b0829562 feat(desktop): in-app update toast with auto-check at startup (v0.4.0)
Replaces the native OS-modal update dialog (which blocked the editor
and looked dated) with a non-intrusive bottom-right toast that
appears 30 s after app mount when the Tauri updater finds a newer
release.

State machine:
  idle → no update detected, render nothing
  available → "Update available - Velxio Desktop X.Y.Z" + Install/Later
  downloading → progress bar with "X.X / Y.Y MB (NN%)"
  installing → "Installing X.Y.Z... will restart automatically"
  error → error message + Retry/Dismiss

Click "Install and restart":
  1. downloadAndInstall() streams the full signed installer (~70 MB)
  2. Tauri verifies the minisign sig against the embedded pubkey
  3. Replaces the install in-place
  4. Auto-relaunch (the app exits and reopens on the new version)

"Later" dismisses for the rest of the session (sessionStorage flag).
A close+reopen re-checks. Manual re-check via the menu still works.

Companion change in velxio-prod flips tauri.conf.json
updater.dialog from true to false so our custom toast is the only
update UI - no double-prompting.

Files:
- frontend/src/desktop/UpdateAvailableToast.tsx (new): the component
- frontend/src/desktop/desktop.css: toast styles + slide-in animation
- frontend/src/desktop/index.ts: mount alongside GraceBanner +
  Esp32QemuPrompt in the existing sidePanelRoot

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:46:58 -03:00
David Montero 5ca7e293b7 fix(canvas): use wrapper top-left for rotation pivot (#205 follow-up)
The previous fix rotated overlay hotspots but the pivot was off by
+6px in each axis, which manifested as a 12px X-offset for a 90°
rotation (because (I - R) maps (6,6) to (12, 0) for R = 90° CW).

The wrapper top-left in container-local coords is -wrapperOffsetX,
not -(6 - wrapperOffsetX). The container origin already sits INSIDE
the wrapper's padding+border by exactly wrapperOffsetX/Y; we have
to back out by that same amount, not by 6 - that amount.

Visual verification on https://velxio.dev/example/esp32-pwm-led-rgb:
overlay centers of rotated resistor now match the rotated pin tips
exactly (was 12px off in X).
2026-05-26 20:42:27 +02:00
David Montero 0a69c9facf fix(canvas): rotate pin overlay hotspots with the component (#205)
Reporter on GitHub: after rotating a component the WIRES followed
the pin tips (already fixed in the (6,6) offset commit) but the
clickable connection boxes stayed in the unrotated layout —
visible misalignment between the rotated component and its
hotspots, no way to start a fresh wire from a rotated pin.

Root cause: PinOverlay renders as a SIBLING of the DynamicComponent
wrapper, not as a child. CSS rotation on the wrapper doesn't reach
the overlay div, so its child pin boxes stay at the unrotated
(pin.x, pin.y) coordinates.

Fix:

- Plumb component.properties.rotation from SimulatorCanvas into
  PinOverlay as a new `rotation` prop.
- In PinOverlay, capture wrapper.offsetWidth/Height when reading
  pinInfo and apply the same rotation matrix the wire calculator
  uses (pivot at wrapper center, transform-origin: center center).
- Use the rotated (pinX, pinY) for both the visual `left/top` AND
  the canvas-coord passed to onPinClick, so wires that get started
  from the hotspot anchor at the rotated tip too.

Also align the default wrapperOffsetX from 4 to 6 (padding:4 +
border:2 on each side of the DynamicComponent wrapper). The
previous asymmetric (4, 6) was the same 2px X bias we fixed in
pinPositionCalculator a few commits back; the overlay was reading
its own copy of the bad number and putting hotspots 2 px left of
the pin tip on unrotated components too. Board paths that pass
wrapperOffsetX/Y = 0 explicitly are unaffected.

All 29 vitest tests in the rotation + simulator suites pass.
2026-05-26 20:02:10 +02:00
David Montero Crespo d347cac51e test(vitest): allow tests outside frontend/ + discover velxio-prod pro overlay tests
Two related changes for the v0.4.0 desktop-agent rollout:

- include glob now also matches `../../pro/frontend/src/pro/**/__tests__/`
  so the agent-overlay tests in velxio-prod are discovered when this
  config is used from a velxio-prod checkout. On pure-OSS clones the
  glob has nothing to match - harmless.

- server.fs.allow extended to `..` and `../..` so Vite's filesystem
  sandbox doesn't reject the cross-project test paths with
  "Cannot find module '/@fs/...'".

No behavior change for OSS-only contributors. velxio-prod gets the
agent's `desktopAuth` unit tests picked up automatically by
`npx vitest run` in this directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:53:04 -03:00
David Montero Crespo 3bd6f144f2 feat(desktop): allow slim pro overlay when VITE_PRO_BUILD + VITE_DESKTOP both set
v0.3.x main.tsx explicitly REFUSED to load @pro when VITE_DESKTOP was
true ("desktop owns its own license + auth UI"). v0.4.0 brings the
AI agent into the desktop bundle, so the refusal needs to relax for
that one use case.

New routing inside the if(VITE_PRO_BUILD) branch:
  - VITE_DESKTOP also set → load @pro/desktop_index (slim entry that
    only mounts AgentChatPanel + DiagnoseCompileButton, no analytics
    / sessions / billing / admin / save overrides - those expect
    velxio.dev cookies the desktop has no way to send)
  - VITE_DESKTOP not set → load @pro/index (existing web behavior)

VITE_DESKTOP alone (no pro) still loads zero overlay - that's the
pure-OSS desktop build path for self-hosters who don't have the
pro source tree at $PRO_OVERLAY_PATH.

Companion commit in velxio-prod creates @pro/desktop_index, adapts
the agent client for license-key Bearer auth, and updates
build-frontend.sh to pass both flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:09:22 -03:00
David Montero b2474bf5d1 fix(stop): preserve display state on Stop, only blank on Reset
Reporter feedback after 7aca3db: pressing Stop on the uno-7segment
example turned the 7-segment off, and pressing Start again left
random segments lit / no number at all. The previous fix made
resetPinStates() notify every listener with (pin, false) on both
Stop and Reset, which was right for Reset (full reboot) but wrong
for Stop:

  - On Stop the AVR CPU is just paused. Internally it still has
    PORTD=0xFF (or whatever the last drive was).
  - resetPinStates blanked the pinStates cache + fan-out LOW
    notifications. Display turns off, fine.
  - On Start the CPU resumes from where it paused. avr8js's port
    listener fires only for bits that CHANGED relative to its OWN
    oldValue (which still holds the pre-stop value). If oldValue
    matches the live register, no pinChange event fires for that
    bit, and the display has no signal telling it to come back on.

Split the API into two methods:

  resetPinStates()    — soft cleanup, drops outputPins only. Used by
                        stopBoard. Cached pinStates and visual state
                        stay so the resume picks up where it left off.

  hardResetPinStates() — full cleanup, drops outputPins + pinStates
                        and fan-outs (pin, false) to listeners.
                        Used by resetBoard (CPU starts at PC=0,
                        firmware re-drives every pin from setup()).

Updated the test helper clearAllPinManagerState to call
hardResetPinStates between tests so the same-state short-circuit in
triggerPinChange doesn't suppress fresh events.

All 32 vitest tests pass (AVRSimulator, interconnect-routing,
dual-arduino-software-serial, pin-position-rotation).
2026-05-26 18:54:17 +02:00
David Montero 7aca3db51c fix(reset): clear display state + don't clobber Interconnect on Reset
Two paired bugs that surfaced on the Reset button.

(1) 7-segment / NeoPixel / LCD freeze on last pattern after Reset.
    resetPinStates() was wiping the pinStates cache + outputPins set
    silently — no listener notifications fired, so visual components
    that update on pinChange kept rendering whatever segments were
    lit at the instant the user pressed Reset. Now we snapshot every
    pin that was HIGH before clearing and fan out a synthetic
    (pin, false) to each registered listener. Stateful displays
    redraw cleanly to all-off; passive listeners (analog sensors,
    debounce-only buttons) ignore the synthetic LOW and recover on
    their next real write.

(2) Cross-board serial silently dies after pressing Reset. resetBoard
    was unconditionally reassigning:
        sim.onSerialData = (ch) => appendSerial(boardId, ch);
    immediately after sim.reset(). The comment said "re-wire after
    reset" but reset() does NOT clear that property — the new USART's
    onByteTransmit chains through `this.onSerialData` which IS the
    Interconnect wrapper. The reassignment destroyed that wrapper and
    sibling-board UART forwarding (Uno TX → Nano RX) stopped working
    until a full page reload. Same root pattern as the initSimulator
    bug fixed in 5480052 — Interconnect's __icSerialHookInstalled
    flag is on the live sim, so once the wrapper is blown away
    nothing reinstalls it. Removed the reassignment and left a NOTE
    so the next person doesn't reintroduce it.

Verified the AVRSimulator + dual-arduino-software-serial +
interconnect-routing test suites still pass (26 tests).
2026-05-26 17:30:44 +02:00
David Montero 5480052379 fix(multi-board): initSimulator wiped Interconnect's UART wrapper
Cross-board UART forwarding silently broke for any project loaded
with > 1 board. User report: Arduino Uno → Arduino Nano serial echo
test where the Uno transmits fine but the Nano's Serial.available()
is never true.

Root cause traced live with chrome-devtools-mcp + temporary debug
logs in AVRSimulator.onSerialData setter and Interconnect:

  1. loadProjectState → addBoard(uno) → createSimulator → sim.onSerialData = appendSerial
  2. addBoard(nano) → same
  3. setWires → Interconnect.updateWires → ensureSerialHook(uno)
     wraps sim.onSerialData with a fan-out callback that ALSO pushes
     to the Nano's RX queue. __icSerialHookInstalled flag set.
  4. SimulatorCanvas mounts → useEffect calls store.initSimulator()
  5. initSimulator unconditionally did:
        simulatorMap.delete(boardId);
        const sim = createSimulator(...);   // ← brand-new sim
        simulatorMap.set(boardId, sim);     // ← Interconnect's wrapper is gone
     The new sim's onSerialData is just appendSerial. The old sim
     (where the wrapper lived) has been orphaned; Interconnect never
     re-installs because its flag was on the discarded sim.
  6. Run all boards → Uno.usart.onByteTransmit → this.onSerialData →
     appendSerial (Uno's monitor shows TX) but no fan-out call →
     Nano never receives anything.

initSimulator is a legacy single-board helper from the days when the
store only knew about one MCU. Multi-board flows already create
their sims in addBoard. Bail out early if a sim for the active
boardId already exists, so the legacy helper becomes a no-op when
the multi-board path has already done the work.

Verified the 3 related test suites still pass (AVRSimulator,
dual-arduino-software-serial, interconnect-routing).
2026-05-26 17:08:04 +02:00
David Montero 1663236184 debug: trace onSerialData setter 2026-05-26 16:57:37 +02:00
David Montero c8d06c9b90 debug: temp console.log in cross-board UART path 2026-05-26 16:47:13 +02:00
David Montero 56d3bc4f12 fix(avr/serial): drain RX queue every frame and clear it on stop
User report: Arduino Nano connected to an Uno-TX wire received bytes
but displayed them poorly, and pressing Stop then Run "killed" the
serial link until the page reloaded.

Two paired bugs in the cross-board serial path:

(1) drainSerialRxQueue was only ever re-fired from usart.onRxComplete,
    which itself only fires AFTER a successful delivery. If the very
    first delivery attempt fails (rxEnable=false because the sketch
    hasn't reached Serial.begin yet — extremely common when one board
    starts emitting bytes before the receiving board's setup() runs)
    nothing re-kicks the queue and every subsequent byte from the
    sibling board sits in serialRxQueue indefinitely. Adding a
    per-frame drain attempt (no-op when queue is empty or rxBusyValue
    is set, so cost is negligible) makes the link self-heal across
    cold-start races and Serial.end()/begin() toggles.

(2) stop() never cleared serialRxQueue. On Run after Stop the new
    USART would re-drain the previous run's leftovers into the fresh
    sketch before its setup() ran, corrupting the first bytes the
    user saw on the receiving side. Clearing the queue in stop() —
    same place we already clear scheduledPinChanges — keeps each Run
    a clean slate.

Verified 52 existing tests still pass (dual-pico-serial-passthrough,
dual-arduino-software-serial, interconnect-routing, avr-uart-tx
-waveform, serial-batching, AVRSimulator, pin-position-rotation).
2026-05-26 15:47:55 +02:00
David Montero 467ca4455f fix(canvas): wires off pins after rotation — wrapper offset was (4,6) instead of (6,6)
User report: "rotating components messes up their connections" — pressing R
on a placed component visibly slid every wire endpoint off its pin tip.

Root cause: the DynamicComponent wrapper has padding:4px + border:2px on
EVERY side, so the inner web-component element sits 6 px in from the
wrapper top-left on BOTH axes. The wire layer assumed an asymmetric
(4, 6) offset, baked into:

  * useSimulatorStore.updateWirePositions       — store.x + 4, store.y + 6
  * useSimulatorStore.recalculateAllWirePositions
      — start (startComp.x + 4, startComp.y + 6)
      — end   (endComp.x   + 4, endComp.y   + 6)
  * pinPositionCalculator.calculatePinPosition  — inverse: (componentX - 4, componentY - 6)

Unrotated the 2 px X bias was visible only as a very-slightly-off wire,
which nobody filed. When the user rotated the component, the bias
rotated WITH it — at 90° it became a 2 px Y offset (wires hanging below
the pin), at 180° a 2 px X offset on the other side, at 270° upward. UX
read as "wires disconnected".

Verified the real CSS box via chrome-devtools-mcp against several live
components on velxio.dev (RGB LED + 3 resistors + analog joystick): all
report padding-left/top = 4 px, border-left/top = 2 px, inner offset = 6
on both axes.

Fix: use (+6, +6) at every site, single source of truth in a comment
explaining padding+border arithmetic. Updated the rotation regression
test to match the corrected math (numbers shift by 2 px on every
expectation that referenced the old offset).

Pin position math, pivot derivation and the rotate-N×90° round trip
unchanged — only the offset constant moved.
2026-05-26 15:16:04 +02:00
David Montero Crespo 161335a5cf test(desktop): unit tests for bannerFor + suppress redundant banner in lockout
Phase 4 polish: GraceBanner was rendering for state=locked/tampered
even though LockoutOverlay covers the screen for those states. The
banner leaked through the overlay's 96%-opaque background as a
faint red strip - confusing.

- GraceBanner.tsx: bannerFor() returns null for locked/tampered
  (LockoutOverlay handles the messaging). Also exported bannerFor
  so the new unit tests can exercise the pure decision logic.
- __tests__/GraceBanner.test.ts (new): 13 vitest cases covering
  pre-expiry amber/red thresholds (trial_ends_at vs subscription_period_end),
  fallback to claims.exp for legacy JWTs, soft/hard grace messaging,
  dismissibility rules.
- vitest.config.ts: include also matches src/**/__tests__/ so the
  desktop tests are discovered without moving them.

Runtime ~600ms vs 5-25 min for a full installer rebuild - lets
future iterations on the banner state machine skip the build cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:05:39 -03:00
David Montero Crespo 6f63fceb20 feat(desktop): paywall v0.3.0 - LockoutOverlay + staged expiry banners
Adds the frontend half of the v0.3.0 desktop paid model. The Tauri
shell (in velxio-prod) emits velxio://license-required when the
license gate refuses to spawn the sidecar; this commit teaches the
OSS desktop overlay to react.

- LockoutOverlay.tsx (new): full-screen modal with three variants
  (no_credential / tampered / expired). Sign-in or paste-key
  resolves it via restartApp().
- DesktopWelcomePage.tsx: new grandfather variant - "you have N
  days to keep using Velxio Desktop" + "Continue without signing in".
- GraceBanner.tsx: rewrite with pre-expiry tones (5d amber, 24h
  red, dismissible), polling every 10 min while document visible,
  separates pre/post-expiry messaging.
- Esp32QemuPrompt.tsx: signup gate for grandfather users (ESP32
  binaries are not part of the grandfather grace) + inline progress
  bar driven by velxio://esp32-qemu-progress events.
- index.ts: rewires on getGateInfo() at first paint to decide
  welcome vs lockout vs nothing; installs license-required listener
  + 10-min foreground polling for the locked transition.
- tauriBridge.ts: adds GateInfo type, getGateInfo(), restartApp().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:16:48 -03:00
David Montero 9e9a3e7800 fix(esp32): use JS template literals (backticks) for stub interpolation 2026-05-25 00:17:47 +02:00
David Montero 8a89322808 feat(esp32): smart WiFi/HTTP stubs so MicroPython examples actually work
Phase 7.7 follow-up. Previously the WiFi stub returned wlan.isconnected()=False
and ntptime.settime() raised OSError — sketches degraded gracefully but
features like the TIME and WEATHER screens in the smart-ui-eyes example
showed "Sync Failed" / "API Error" instead of real-looking data.

Smart stub now:
- wlan.isconnected() returns True after the first ~2 calls (simulates a
  ~1 second connection ramp)
- ntptime.settime() pre-loads machine.RTC() with the host's UTC datetime
  (captured at code-injection time), so localtime() returns real time
- urequests.get(url) returns a stubbed Response whose .json() decodes a
  payload routed by URL substring:
    "openweathermap"/"weather" → fake weather dict (temp/humidity/desc)
    "ipify"/"myip"             → fake public IP
    "worldtimeapi"             → fake ISO datetime
    everything else            → {}
- urequests.post/head also stubbed (return {"ok": True} / {})
- Both `urequests` and `requests` aliases registered

End result: smart-ui-eyes example shows real-looking time on TIME
screen and plausible weather data on WEATHER screen, no crashes.
Still no real internet (would need Phase 7 QEMU WiFi emulation), but
visually the example demos correctly.
2026-05-25 00:15:47 +02:00
David Montero 835ca6d7a8 fix(esp32): stub network + ntptime modules for MicroPython in QEMU
Inject a compat shim into the raw-REPL prelude that replaces
sys.modules["network"] and sys.modules["ntptime"] with no-op stubs
BEFORE user main.py runs.

Why: the picsimlab QEMU fork's esp32_wifi NIC emulation handles
Arduino's lightweight WiFi.h but not MicroPython's full esp_wifi_init
path. Calling network.WLAN(STA_IF) (which is what every
network-using MP sketch does) drives the firmware to wait on
peripheral status bits QEMU never sets, eventually tripping the
FreeRTOS task watchdog (TG1WDT_SYS_RESET ~26s after boot, or
TG0WDT ~14s if the NIC is partially attached).

With the stub:
  network.WLAN(STA_IF).isconnected() -> False
  network.WLAN(STA_IF).connect(...)  -> no-op
  ntptime.settime()                   -> raises OSError

Sketches that already have try/except around sync_time (which is
most of the 100-days examples) now degrade gracefully: WELCOME +
EYES screens run, TIME and WEATHER screens show their fallback
behaviour, no panic, no reboot.

Doesn't affect Arduino C++ — sketches that #include <WiFi.h> use
real WiFi.begin() and the existing esp32_wifi NIC handles those fine.

A proper fix is to extend the picsimlab WiFi emulation to support
the full ESP-IDF API, but that's a multi-day project. This stub
unblocks the 31 MicroPython examples shipping with network imports.
2026-05-23 23:13:20 +02:00
davidmonterocrespo24 e4ecefe46a feat(desktop): skip welcome screen, robust openExternal, in-app nav
Three coordinated changes that fix the "Waiting for browser…" hang
and unblock first-launch UX on the Tauri desktop build:

  1. desktop/index.ts — DON'T mountWelcome unconditionally on first
     launch. Before, an empty keychain (no key yet) forced the
     welcome / sign-in screen on top of the editor, gating 100% of
     the app behind an account. Now the editor opens directly:
     compile + run + sim + save .vlx all work for free (they're
     upstream OSS features), and the license check still runs in
     the background just to populate state for the GraceBanner
     (which shows for invalid keys — locked, tampered, in
     soft/hard grace). Pro-only features (ESP32 QEMU download,
     agent IA) prompt for license at use time, where it actually
     matters. Matches the "try before you buy" expectation a
     desktop install creates.

  2. desktop/tauriBridge.ts — rewrite `openExternal` to try every
     known IPC path in cascade order and log via the desktop debug
     file which one worked. The previous implementation invoked
     `plugin:shell|open` with `{ path: url }`, which silently
     failed (no ACL match + wrong arg shape) and fell back to
     `window.open`, which inside a Tauri webview is a no-op for
     external URLs — the browser never opened. New cascade:
     plugin:opener|open_url (paired with tauri-plugin-opener which
     ships in this revision), then plugin:shell|open with both
     `{ path, with: null }` and `{ url }` shapes, then the
     window.__TAURI__.shell / opener high-level wrappers that
     specific Tauri 2.x flag combos expose. Each attempt logged
     via the dlog helper so the next operator can see exactly
     which path was used (or that all failed) without devtools.

  3. desktop/menu.ts — new `navigate-route` action type. Routes
     bundled in the SPA (DocsPage, ExamplesPage, AboutPage) that
     used to open velxio.dev in the system browser now navigate
     in-window via history.pushState + popstate (mirrors the
     locale-switch handler). Respects the current locale prefix
     so `/examples` from `/es/editor` lands at `/es/examples`
     instead of jumping back to English.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:57:49 -03:00
David Montero cf28b3b5ea fix(esp32): auto-enable WiFi NIC for MicroPython sketches using network module
The hasWifi auto-detection in useSimulatorStore.startBoard only matched
Arduino C++ patterns (#include <WiFi.h>, WiFi.begin). MicroPython
sketches that call `import network` or `network.WLAN(STA_IF)` were
not detected, so wifi_enabled stayed false and the backend never
attached the esp32_wifi NIC model to QEMU.

Symptom: any MicroPython ESP32 example that touches the network
module hangs in network.WLAN(STA_IF) (the constructor that triggers
esp_wifi_init internally) and the FreeRTOS task watchdog trips with
TG1WDT_SYS_RESET ~26 seconds after boot. The chip then reboot-loops.

Mirror the Pico W detector right below this one — it already handles
both Arduino and MicroPython patterns. Now ESP32 does too.

Affects 31 examples in examples-100-days.ts that use network.WLAN.
2026-05-23 22:54:10 +02:00
David Montero b09c8339ac fix(examples): use hardware I2C (not SoftI2C) in smart-ui-eyes
OSError: [Errno 19] ENODEV at ssd1306.SSD1306_I2C(...) on
100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython.

MicroPython SoftI2C bit-bangs GPIO directly. Velxio's ESP32 QEMU
bridge listens on the emulated I2C peripheral (registers slaves like
0x3C wokwi-ssd1306 against it) and doesn't decode bit-banged GPIO
toggles as I2C frames, so the OLED never sees any writes and
i2c.writeto() returns ENODEV on first use.

machine.I2C(0, ...) routes through the hardware I2C peripheral that
QEMU emulates, the registered slave receives the bytes, the OLED
panel updates. Same code path the other working SSD1306 MicroPython
examples on this repo already use.

API surface is identical to SoftI2C — only the constructor differs —
so the rest of the user sketch needs zero changes.
2026-05-23 18:06:42 +02:00
David Montero 76f6cd9a37 fix(avr/serial): queue RX bytes so Serial.readStringUntil sees the whole input
avr8js's usart.writeByte(value) rejects the call (returns false, drops
the byte) whenever rxBusyValue is set — and rxBusyValue stays true for
one full cyclesPerChar after each accepted call. The old serialWrite()
fed every character in a synchronous for-loop, so only the first byte
made it through and the sketch saw 'h' when the user typed 'hello\n'.

Buffer pending bytes in serialRxQueue and pump them one at a time:
- serialWrite() now just queues + kicks drainSerialRxQueue once
- drainSerialRxQueue calls writeByte on the head of the queue and only
  shifts it off if writeByte returned true (avr8js accepted it)
- usart.onRxComplete is wired to drainSerialRxQueue so the next byte
  ships as soon as the sketch's RX side actually consumed the previous
  one — matches the cyclesPerChar pacing the real chip enforces

Same handler wired in both USART setup paths (the Uno/Nano branch and
the post-loadHex Mega/ATtiny branch). TX path (onByteTransmit +
emitUartTxFrame for the oscilloscope waveform) is unchanged.
2026-05-23 17:50:19 +02:00
David Montero 0d43c5f892 fix: relax -Werror for user sketches + un-nest /* */ in robot-desktop-eyes
Two related fixes for the ESP32 Arduino-compat compile path:

(a) backend/app/services/esp-idf-template/main/CMakeLists.txt:
    Demote -Werror=comment / =parentheses / =sign-compare / =narrowing
    / =write-strings / =missing-field-initializers / =reorder back to
    plain warnings. ESP-IDF's project defaults are stricter than what
    Arduino/arduino-cli users expect, so common Arduino idioms (nested
    /* */, missing field initializers in struct literals, etc.) were
    failing builds that compile fine in the Arduino IDE. -Wall stays
    on; we just stop the abort.

(b) examples-robot-desktop.ts (robot-desktop-eyes example):
    Replace the nested /* xTaskCreatePinnedToCore( ... /* Task function. */
    ... */ block with `#if 0 / #endif` so the inner block comments
    don't terminate the outer one. Even with -Wno-error=comment the
    real-syntax-level issue (the first inner `*/` closes the outer
    comment, leaving the rest of the lines as bare code) would still
    bite, so this needs an actual code fix.
2026-05-23 16:19:56 +02:00
davidmonterocrespo24 a86a0a45bd feat(android): Digital Asset Links for TWA verification
Drops `/.well-known/assetlinks.json` so the Trusted Web Activity APK
(dev.velxio.twa, generated by bubblewrap from this same manifest.webmanifest)
can prove to Chrome that it's allowed to claim velxio.dev as its own
origin. Without this file the TWA falls back to a Custom Tab with the
URL bar visible — losing the whole "feels native" UX that TWAs
exist for.

The sha256_cert_fingerprints entry pins the production signing key
held locally as android.keystore in the velxio-twa/ build dir (NOT
in any repo). If we ever lose that key + need to re-issue, this
file has to be updated with the new fingerprint and re-deployed
BEFORE the new APK reaches users; otherwise their previously-
installed TWA verifies against an asset link that no longer matches
the APK signature and breaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 04:42:56 -03:00
David Montero 1a6e2a23a7 fix(examples): auto-install libs for robot-desktop-eyes
Sketch fails to compile out of the box with
  fatal error: ESP32Servo.h: No such file or directory
because the ESP32Servo / U8g2 / DHT / Adafruit Unified Sensor libs
aren't part of arduino-esp32 and weren't declared on the example.

loadExample.ts already iterates `example.libraries` and runs
arduino-cli lib install for any missing entry before the user
touches Compile. Adding the four real deps the sketch needs gets
the example compiling cleanly on a fresh container without any
manual Library Manager dance.
2026-05-23 08:49:26 +02:00
David Montero 2f60e3816a fix(editor): surface QEMU compile failure to the user instead of silent warning
When the auto-compile path in handleRun() finishes without producing a
compiledProgram, the previous code dropped the failure on the floor with
only a `console.warn` — the user clicked Run, nothing happened, and they
had no idea why. The accompanying comment also promised "always start
even if compiledProgram is empty" but the code did the opposite.

This commit replaces the dead comment + silent warn with a top-level
error toast + addLog entry, with a different copy for MicroPython mode
(suggests "click Load MicroPython to retry") vs Arduino C++ mode
(directs the user to the output console for the underlying error).

handleCompile already writes the actual cause to the compile-output
console via addLog — this fix just makes sure the user knows their
click failed and where to look.
2026-05-23 08:39:26 +02:00
davidmonterocrespo24 e91aaea2fd chore(manifest): make PWA mobile-friendly + better TWA copy
Three small fixes to frontend/public/manifest.webmanifest so the
Bubblewrap-generated TWA (and Add-to-Home-Screen PWA installs) feel
right on a phone:

  - orientation: landscape → any. Landscape-forced on a phone
    locks the device in side-grip whenever Velxio is foregrounded;
    the editor + simulator work fine in portrait too (the file
    explorer collapses gracefully). Tablets and desktops still
    default to landscape because they're naturally wider, so this
    only changes behaviour where the lock would actively hurt.
  - name: "Arduino Emulator" → "Circuit & Arduino Simulator".
    Matches the title tag + Open Graph copy that velxio.dev uses
    everywhere else and reflects the SPICE / ESP32 / RP2040 work
    the project has grown into since the original name was written.
  - description: was 'Free local Arduino emulator … No cloud, no
    latency.' That was true for the OSS self-host but misleading
    for an installed PWA that talks to velxio.dev. Rewritten to
    describe the actual product surface (the boards, the SPICE
    sim, "free and open source") without making a claim the live
    site can't keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 03:38:31 -03:00
David Montero dbab794ddb test(snapshots): refresh smart-ui-eyes netlist after circuit was wired
Snapshot was written when the example had components: [] and wires:
[]. Now that 5bd541e populated the circuit (OLED + 2 buttons) and
2dea3f0 renamed the OLED pins to match wokwi-ssd1306's real
pinInfo, the netlist contains the button pull-down resistors and
floating-net autopulls. Regenerated with `vitest run -u`.
2026-05-23 08:28:08 +02:00
David Montero 2dea3f0448 fix(examples): correct SSD1306 + big-sound-sensor pin names
robot-desktop-eyes and the day-30/100-days OLED example wired the
SSD1306 OLED with SDA/SCL/VCC, but the wokwi-ssd1306 element
exposes pinInfo as DATA/CLK/VIN/GND. The mismatched names couldn't
resolve, so all three wire endpoints fell back to (0,0) of the
component and visually attached to the corner instead of the pins.

Same class of bug on the wokwi-big-sound-sensor in
robot-desktop-eyes: the element has AOUT/DOUT (no plain OUT). The
sketch uses digitalRead(SOUND_PIN), so route to DOUT.

The COMPONENT_PIN_ALIASES map in wokwiZip.ts only normalises on
.zip import — static examples have to use the real pinInfo names.
2026-05-23 08:23:15 +02:00
David Montero 5bd541e56f fix(examples): add OLED + buttons circuit to ESP32 smart-ui-eyes example
The 100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython
example had components: [] and wires: [] — the MicroPython code wired
an SSD1306 OLED on I2C (GPIO 21/22) plus two buttons (GPIO 14, 27) but
the circuit had nothing on the canvas, so users saw a bare ESP32 board
and the simulation was missing every peripheral the code drives.

Adds:
- wokwi-ssd1306 on I2C (3V3 / GND / SDA=21 / SCL=22)
- two wokwi-pushbuttons wired HIGH-when-pressed (3V3 → 1.l, 2.l → GPIO
  14 / 27) to match the `if pin.value(): pressed` check in main.py
2026-05-23 08:16:13 +02:00
David Montero 7d9016396e chore(sitemap): bump lastmod dates to 2026-05-23 2026-05-23 07:13:50 +02:00
davidmonterocrespo24 28826e928e feat(examples): import robot_desktop — ESP32 animated-eyes face
Pulls https://github.com/davidmonterocrespo24/robot_desktop into the
examples gallery as a real-world ESP32 + sensors project. Cozmo-style
desktop robot: SSD1306 OLED face that blinks, looks around, and shows
emotions; DHT11 weather mode triggered after 10 min idle; PIR wakeup
from sleep; LDR-driven sleep when the room goes dark; sound-triggered
reactions; and two eyebrow servos.

Ships as 34 separate files (one .ino + 33 headers / source) rather
than the usual single-sketch flatten. The face engine
(Eye / EyeTransition / EyeVariation / FaceBehavior / FaceExpression
/ FaceEmotions / BlinkAssistant / LookAssistant / …) splits
responsibility across enough classes that flattening would obscure
the design. Velxio's multi-file `files: [{ name, content }]`
mechanism handles this cleanly — the editor mounts the .ino as the
active sketch and the rest sit in the same workspace.

Pre-placed components match the original board's pin map verbatim
from Common.h:

  - SSD1306 OLED on I²C (SDA=21, SCL=22 — ESP32 default)
  - DHT11 on GPIO 15
  - PIR motion on GPIO 4
  - Big sound sensor on GPIO 2
  - Photoresistor on GPIO 34 (ADC1)
  - Right eyebrow servo on GPIO 12
  - Left eyebrow servo on GPIO 13

Arduino libraries (U8g2lib, DHT, ESP32Servo, Adafruit_Sensor) are
auto-installed by velxio's Library Manager on the first compile.

Category 'displays', difficulty 'advanced', tags cover both the
sensor list and the project's identity (cozmo / robot / animation /
eyes) so the gallery search surfaces it from multiple angles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:29:25 -03:00
David Montero f619a2cc7e feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring)
The BoardKind type and the QEMU backend already supported
raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing
the Pi 3 arm64 image set, but the frontend had no way to actually
select either: the board picker, the canvas renderer, the serial
monitor, the oscilloscope channel list, and the editor toolbar all
hard-coded "raspberry-pi-3" as the only Pi entry.  ComponentRegistry
even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3
custom-element tag — a placeholder that meant both boards rendered as
a Pi 3 in the picker thumbnail and on the canvas.

Add dedicated boards top-to-bottom:

  * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style
    schematic SVG (authored from scratch, not traced).  Pi 4 is the
    green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI;
    Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge,
    2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power
    button.  Both carry a small "velxio" mark in the corner.

  * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that
    returns the 40-pin BCM layout.  Every Pi from the 1B+ onwards
    uses the same physical pin positions and same BCM GPIO
    assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this
    helper and example wires drawn against one model transfer to
    the others without re-routing.

  * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the
    custom elements at absolute positions (mirrors how
    RaspberryPi3.tsx handles the Pi 3 illustration).

  * Wire-up across the editor surface:
      - BoardOnCanvas: BOARD_SIZE entry + switch case.
      - BoardPickerModal: description, icon, kinds list.
      - ComponentPickerModal: thumbnails now instantiate the dedicated
        custom element (was velxio-raspberry-pi-3 fallback).
      - SerialMonitor / EditorToolbar: pill labels, icons, colours.
      - Oscilloscope: GPIO channel list (28 BCM pins).
      - SimulatorCanvas: remote-boards filter for run/stop sync.
      - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3.
      - boardPinToNumber: accepts physical pin numbers ("1"-"40"),
        BCM names ("GPIO14") and power labels for any Pi 3/4/5 id.
      - ComponentRegistry: dedicated tagNames + per-board thumbnails
        (green for Pi 4, darker green for Pi 5).

  * EditorToolbar's Pi 3 special cases (Linux/Python compile path,
    Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5
    inherit the same behaviour automatically, and any future Pi
    family member (Zero / 1 / 2) lands in the right code paths the
    moment its backend boots.

QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry
'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes
both boards selectable end-to-end without any backend follow-up.
2026-05-23 04:46:59 +02:00
davidmonterocrespo24 2146b09c29 feat(desktop): hide header strip, splash screen, native locale switcher
Three QoL fixes for the Tauri shell:

  1. Hide the entire AppHeader strip in VITE_DESKTOP, not just the
     marketing nav. The previous gate left the black bar painting
     over the editor with the brand + auto-save + share + auth
     slot, all of which are irrelevant in desktop (cloud Pro
     features, license is handled by DesktopWelcomePage, the title
     bar already says "Velxio Desktop"). Return null at the top so
     the editor takes the full window height.

  2. Splash screen during sidecar boot + Monaco hydration. Cold
     launch was a 3-8 s black window — now there's an inline SVG
     logo, "Velxio" wordmark, slogan, animated spinner, and a
     "Starting local backend…" caption. Lives in index.html as a
     fixed-position overlay with display:none by default; the inline
     script reveals it only when `window.__TAURI__` is present, so
     web users never see it. main.tsx fades it out (250 ms ease-out)
     after two animation frames — guarantees React's first paint has
     committed before the handoff, no black flash. Self-contained:
     inline styles, inline SVG, inline CSS keyframes, zero external
     requests.

  3. Native locale switcher under View → Language. Emits
     `velxio://menu` with action='set-locale' + the locale code; the
     desktop/menu.ts handler navigates via history.pushState +
     popstate so React Router picks it up without a hard reload
     (Monaco + simulator state preserved). Locale list mirrors
     i18n/config.ts::LOCALES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:22 -03:00
David Montero 8c58d2a1a7 fix(epaper/esp32): preserve PartSimulationRegistry sensors in setSensors + correct BUSY polarity per controller family
Two intertwined bugs were leaving every ESP32 ePaper example broken
end-to-end.  Only the 5.65" UC8159c panel surfaced the failure
audibly ("Busy Timeout!" repeating in serial), because its inverted
busy polarity caused the firmware to hang inside `_waitBusy()`.  The
SSD168x ePaper examples APPEARED to run cleanly but never actually
rendered anything to the panel — the canvas stayed at the idle paper
colour because the same registration path was broken.

Root cause #1 — `setSensors` was a full REPLACE, not a merge.
  `Esp32Bridge.setSensors(sensors)` did `this._pendingSensors =
  sensors`.  At `startBoard()` time the store iterates components,
  resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 /
  HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list.
  ePaper components live in `PartSimulationRegistry` (not in the
  sensor map) and are registered via `sendSensorAttach()` AT
  COMPONENT-MOUNT TIME — well before `startBoard()` runs.  Full-replace
  semantics blew that registration away on every Run click, so the
  worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`,
  no SPI bytes were decoded, no frames were latched, and BUSY was
  never driven.

  Fix: upsert by `pin` so pre-existing registrations from
  PartSimulationRegistry handlers are preserved alongside the
  startBoard-resolved sensors.  Confirmed via a WebSocket spy that the
  `start_esp32` payload now carries the ePaper sensor entry.

Root cause #2 — BUSY polarity was hard-coded for SSD168x only.
  Verified against upstream GxEPD2 source:
    * SSD168x family — constructor passes `_busy_level = HIGH`
                       → BUSY=HIGH means busy, LOW means ready.
    * UC8159c family — constructor passes `_busy_level = LOW`
                       → BUSY=LOW  means busy, HIGH means ready.
  The worker only drove BUSY after a frame flush (and at the wrong
  polarity for UC8159c), so the firmware's first `_waitBusy()` inside
  `_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame —
  blocked for the full 25 s `_busy_timeout`.

  Fix: read `controller_family` from the registration payload, pick the
  per-family idle level, and (a) seed the pin to IDLE at registration so
  the first `_waitBusy()` sees "ready" immediately, (b) use that
  polarity (idle vs. busy) when pulsing on frame flush.

Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow:
the serial timeline now reads `_InitDisplay reset : 1566` /
`_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms
busy-waits, no timeouts).  Sensor registration confirmed via the
`start_esp32` payload carrying the `epaper-ssd168x` entry.
2026-05-22 23:32:33 +02:00
davidmonterocrespo24 30d0da430b feat(desktop): native menubar bridge + best-effort file logger
Two new modules under the existing desktop/ subtree, both no-op
outside a Tauri runtime (tauriBridge.listen / .invoke fail gracefully).

  desktop/menu.ts — listens for the `velxio://menu` event the Rust
    shell emits from the native menubar (Velxio Desktop / File / Edit
    / View / Help, full menu defined in
    pro/desktop/src-tauri/src/menu.rs). Internal actions handled
    directly here: Save .vlx and Open .vlx via utils/vlxFile, Toggle
    Serial Monitor via useSimulatorStore, Check for Updates via the
    tauri-plugin-updater global. The rest (new-project, Find,
    Toggle File Explorer) re-emit as window CustomEvent so the owners
    of that UI state can subscribe without pulling this module in.

  desktop/log.ts — `dlog(message, extra?)` round-trips a line to a
    Rust `write_debug_log` command that appends to
    `<app_data_dir>/desktop-debug.log`. Packaged Tauri apps have no
    devtools or stdout capture, so this is the only way to see what
    the webview did when a user reports a bug. Falls back to plain
    console.log when the command isn't registered (older shell).

mountDesktop() now installs the menu listener and dlog's its own
start — useful as a "did the desktop module even load" smoke marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:08:46 -03:00
davidmonterocrespo24 54adac3ae3 feat(desktop): hide web nav + redirect / → /editor in Tauri builds
The marketing nav (Home/Docs/Examples/Pricing/Blog/GitHub/Discord) and
the LandingPage hero are great for velxio.dev visitors but become
clutter once the SPA ships inside a Tauri shell — the user installed
the desktop app to land in the editor, not to read about the project.

Two small VITE_DESKTOP gates handle this:

  - AppHeader.tsx hides the <nav> + the mobile hamburger that toggles
    it. The brand, language switcher, auto-save indicator, share
    button, and the pro overlay's auth slot all stay visible — they
    carry real per-session info, not navigation.
  - App.tsx swaps the `/` route's element for a <Navigate to=/editor>
    so first-launch (and any future `velxio://` deep-link that lands
    on `/`) goes straight to the editor.

Equivalent actions for the items being hidden live on the native
menubar that the velxio-prod overlay builds via
pro/desktop/src-tauri/src/menu.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:08:33 -03:00
David Montero aab380e80b feat(scope/trigger): add Auto / Normal / Single-shot trigger modes
Real digital storage scopes have a trigger that pins the visible window
around a detected edge — without it, sparse activity (UART bytes once
per loop, an interrupt firing every few seconds) scrolls off the screen
faster than the eye can catch.  Velxio's scope was free-running only,
which made the recent UART TX waveform work effectively invisible at
fine time/div settings: the byte burst was 87 µs but the window only
showed the most recent 1 ms.

Three trigger modes, matching what you'd find on a Rigol / Tektronix:

  * Auto    — current free-running behaviour, window's right edge
              tracks the most recent sample.  Default.
  * Normal  — window pins around each triggering edge so the event
              lands at `triggerPosition * windowMs` from the left
              (default centred at 0.5).  Keeps re-pinning on every
              new triggering edge.
  * Single  — arms once, freezes the trace on the first triggering
              edge by flipping `running = false`.  User clicks
              "Re-arm" to capture again.

Three knobs configurable per mode:
  - source: which channel produces the trigger event
  - edge:   rising (↑) / falling (↓) / either (⇅)
  - position: trigger lands at this fraction of the window
              (UI hard-codes centre 0.5 for now; the store field
              accepts any value if we want a draggable handle later)

UI additions in the scope header (only shown when mode != auto):
  - source / edge dropdowns
  - status badge (Armed / Triggered / Captured) with pulse animation
    on Armed so the user knows the scope is waiting for an event
  - Re-arm button in Single mode after capture

Canvas changes:
  - Dashed orange "T" marker drawn at the trigger position when an
    edge is latched and within the visible window.

Store changes:
  - pushSample peeks at the trigger channel's previous state, detects
    a matching edge, sets triggeredAtMs (and stops `running` for
    Single mode).  matchesTriggerEdge() exported for unit testing.
  - clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge
    all re-arm the trigger; rearmTrigger() explicitly resets and resumes
    capture (used by the Re-arm button after a single-shot).

Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the
existing 1892 tests still pass.

Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat
line" UX trap reported on the Discord follow-up — at 0.1 ms/div the
window is 1 ms but bytes fire every 2 s, so without a trigger the
chance of catching the burst is < 0.05 %.  With Normal trigger on
rising D1 the burst pins in the middle of the window and the user can
zoom down to bit level (8.68 µs each) without losing it.
2026-05-22 21:11:03 +02:00
David Montero 737ec5c6eb feat(import): unify project import — accept .vlx and .zip in both entry points
Velxio had two parallel import paths that confused users (reported on
Discord by AgUn / dmontero):

  * Toolbar "Import a project from a .zip file" → Wokwi .zip only
  * File-explorer "Open .vlx file"               → Velxio .vlx only

If you exported a Velxio project as .vlx and tried to bring it back via
the toolbar Import button, you bounced off "wrong format" with no hint
that the .vlx loader was hiding behind the file-explorer save-bar.

Fix: introduce `utils/importProject.ts` as the single dispatcher.  It
sniffs the extension and routes:

  *.vlx  → importVlxFile      (writes directly to stores)
  *.zip  → importFromWokwiZip (returns a payload the caller applies,
                               so the toolbar can still trigger the
                               install-libraries modal afterwards)

Both UI entry points now go through the dispatcher with the same
`accept=".vlx,.zip,application/json,application/zip"` filter:

  * Toolbar "Import project (.vlx Velxio or .zip Wokwi)"
  * File-explorer "Open project (.vlx Velxio or .zip Wokwi)"

The toolbar tooltip is i18n-driven — updated EN + 8 other locales
(es, fr, de, it, pt-br, ja, ru, zh-cn) so every user sees the same
clarification.

Wokwi compatibility kept intact — the .zip path still resolves to
`importFromWokwiZip` and the same library-install modal pops if the
imported project lists libraries we don't have locally.
2026-05-22 20:55:06 +02:00
David Montero 1a0877f2af feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO
Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART
transmits the byte over the WebSocket as a 'serial_output' event with no
GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while
real silicon would render the 8N1 frame at the configured baud rate.

Two changes inside Esp32Bridge:

  * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback
    that hooks the oscilloscope at parity with AVRSimulator /
    RP2040Simulator.  The 'gpio_change' event now also flows through it
    (timestamped with `performance.now()` — QEMU virtual time isn't
    surfaced across the wire, but at 1× sim speed the wall-clock skew
    is invisible on any practical sweep).  This also fixes the broader
    issue that ESP32 boards previously couldn't show ANY digital GPIO
    activity on the scope.

  * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first
    + stop transitions at `this.uartBaudRate` (default 115200) on the
    UART0 TX pin, mapped per board variant:
        esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1
        esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32:               GPIO43
        esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini:   GPIO21

    Backend doesn't expose the live baud rate so we default to 115200
    (the Arduino default).  Override path:  bridge.uartBaudRate = N
    once we surface Serial.begin's argument via a backend event.

Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)`
inside the three Esp32Bridge construction sites in useSimulatorStore
(setBoardType, addBoard, changeBoard).
2026-05-22 19:58:24 +02:00
David Montero 6584a49a8f feat(rp2040/uart): synthesize bit-level TX waveform on GP0 / GP4
Same gap as the AVR USART: rp2040js's UART fires `onByte(value)` per
transmitted byte but never toggles the corresponding GPIO, so an
oscilloscope on GP0 (UART0 TX, default for Arduino-Pico's Serial1) sees
nothing during `Serial.print`.  Real silicon drives the pin with the
full UART frame at the configured baud rate, and Velxio should match.

`emitUartTxFrame(uartIdx, byte)` derives:
  * `txPin` via FUNCSEL inspection: walk GP0 / GP12 / GP16 / GP28 (the
    four candidates for UART0 TX per RP2040 datasheet) and pick the
    first whose `functionSelect == 2` (FUNCTION_UART).  Same for UART1.
    Fall back to GP0 / GP4 when nothing is mapped (firmware hasn't
    called `Serial1.begin()` properly).
  * `baudRate` and `bitsPerChar` directly from the UART peripheral
    (rp2040js already exposes these as live getters).
  * Time from the RP2040 IClock's `nanos` counter, matching the
    existing `setupGpioListeners` path — UART waveforms therefore stack
    consistently with PIO / SIO traces on the same scope.

Both `uart[0].onByte` and `uart[1].onByte` get hooked.  The seed-idle-
HIGH baseline is pushed once per UART per simulation run; `stop()`
clears the flag so a re-run gets a fresh seed (matching how the scope
buffer is cleared on restart).
2026-05-22 19:54:55 +02:00
David Montero b587faf1b0 feat(avr/uart): synthesize bit-level TX waveform on PD1/PE1
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO.  Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set.  An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.

Synthesize the frame from the inside of `onByteTransmit`:

  * Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
    `usart.parityOdd`, `usart.stopBits` so unusual configurations stay
    accurate (avr8js already exposes these as public getters).
  * Build the bit list start + data(LSB first) + parity? + stopBit(s).
  * For each transition vs. previous state (initial = idle HIGH), call
    `onPinChangeWithTime(1, state, timeMs)` where
    `timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
    simulator-time clock the existing port-listener path uses, so the
    scope draws the UART waveform cycle-accurately alongside other GPIO
    activity.

Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.

Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.

Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.
2026-05-22 19:49:43 +02:00
David Montero 2eb195d671 test(joystick): update fixture to direction-style xValue / yValue
The previous test fed `xValue: 0, yValue: 1023` to the analog-joystick
handler and asserted X=0V, Y=5V. That was wrong for two reasons:

  1. wokwi-analog-joystick emits direction (-1/0/+1), never 0..1023, so
     the fixture didn't match how the real component behaves.
  2. The OLD `(value/1023) * vcc` mapping happened to produce 0V for 0
     and 5V for 1023, so the broken test still passed against the
     broken handler — and now breaks against the correct one.

Switch the fixture to `xValue: -1, yValue: 1` so the expectations line up
with the corrected handler (-1 → 0V, +1 → Vcc) and exercise the same
voltage rails the real component will produce.
2026-05-22 18:59:49 +02:00
David Montero e6fdd54ba3 fix(joystick): map wokwi-analog-joystick direction (-1/0/+1) to ADC voltage
The PartSimulationRegistry handler for 'analog-joystick' was reading
`el.xValue` / `el.yValue` and computing `(value / 1023) * vcc` as if the
component were a potentiometer producing a raw 0..1023 reading.  It is
not — `@wokwi/elements/analog-joystick-element` emits xValue / yValue as
a tri-state DIRECTION signal:

  *   xValue = -1  → "left"   (mousedown on left zone)
  *   xValue =  0  → centered (mouseup snap-back)
  *   xValue = +1  → "right"  (mousedown on right zone)
  (same for yValue with up/down)

`(±1) / 1023 ≈ ±0.001`, so the ADC channel sat at ~0 V no matter which
directional zone was clicked.  Center-button clicks worked because that
path is digital (`setPinState(SEL, …)`) and bypasses the analog map.

Fix:
  * Tri-state → voltage with explicit map: -1 → 0V, 0 → Vcc/2, +1 → Vcc.
  * Vcc was hardcoded to 5V for "not RP2040" — wrong for ESP32 / S3 /
    Nano-ESP32 / etc., which all run at 3.3V like the Pi Pico.  Detect
    ESP32 via the BridgeShim's `setAdcVoltage` method and select 3.3V
    for everything that isn't pure AVR.

Reported on /example/esp32-joystick where center-button-only worked but
directional zones did nothing.  Verification via Chrome MCP after deploy.
2026-05-22 18:57:26 +02:00
davidmonterocrespo24 0dcb504c1b feat(landing): "Try Simulator Free Online" + download CTA slot below
Two coordinated copy/layout changes on the landing hero:

  - Primary CTA label gets "Online" added across the 9 supported
    locales — "Try Simulator Free Online →" / "Probar el simulador
    online gratis →" / etc. Reason: with the Velxio Desktop
    download path now live, users should immediately understand
    that the green button is the BROWSER version, and there's a
    local install option for people who want it faster offline.

  - The pro-overlay slot for the desktop download CTA was
    `landing-hero-primary-cta` and sat ABOVE the hero CTAs. Renamed
    to `landing-hero-download-cta` and moved BELOW so the funnel
    reads "try online (primary) → or install locally (secondary)"
    instead of "install locally (primary) → try online (secondary)".

OSS layout is unchanged — the slot is still empty in pure builds.

Also picks up the auto-regenerated sitemap.xml lastmod dates from a
recent deploy (every URL bumped 2026-05-19 → 2026-05-21).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:33:21 +02:00
David Montero Crespo fc1f5e3d2e Refactor code structure for improved readability and maintainability 2026-05-21 02:32:19 -03:00
davidmonterocrespo24 04d93d4f46 feat(frontend): desktop overlay (welcome, grace banner, ESP32 prompt)
New `frontend/src/desktop/` module loaded only when VITE_DESKTOP=true.
Hosts the Tauri-bound UI that pure OSS doesn't need:

  - tauriBridge.ts   typed invoke / listen / openExternal / beginSignIn
                     wrappers with no-op fallbacks for `vite dev` outside
                     the Tauri webview.
  - DesktopWelcomePage.tsx   sign-in flow with browser handoff +
                              paste-key fallback; listens for
                              `velxio://auth-completed` from the shell.
  - GraceBanner.tsx          renders soft/hard grace banners driven by
                              `license_status` + `velxio://license-status`
                              emits from the background checkin loop.
                              Toggles `body.vlx-desktop-readonly` so the
                              editor's Save / Compile buttons disable
                              themselves via CSS in hard-grace.
  - Esp32QemuPrompt.tsx      one-time download modal when the user picks
                              an ESP32 board on a fresh install.
  - index.ts                 mounts welcome conditionally and the side
                              panels unconditionally.
  - desktop.css              shared styles.

All entry points are no-ops outside Tauri so the existence of the
folder has zero effect on the OSS build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:50:51 +02:00
davidmonterocrespo24 24f84442e8 feat(frontend): runtime API base + desktop overlay extension points
Adds `lib/apiBase.ts` so the SPA can be repointed at a non-default backend
at runtime (via `window.__VELXIO_API_BASE__`) without losing the existing
`VITE_API_BASE` build-time override or the default `/api` reverse-proxy
behaviour. compilation / libraryService / projectService / metricsService
all flow through it now; axios clients use a request interceptor so the
base resolves per-request rather than at module-load time.

main.tsx grows a `VITE_DESKTOP` flag: when set, the @pro overlay is
skipped (the desktop shell handles license + auth natively) and a
small `./desktop/index` module is dynamic-imported in its place. OSS
builds tree-shake both branches.

LandingPage gets a `data-velxio-slot="landing-hero-primary-cta"` marker
above the existing hero CTAs so velxio.dev can inject an OS-detect
"Download Velxio Desktop" button as the visual primary. The slot is
empty in pure OSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:50:19 +02:00
David Montero be24243e4b fix(esp32): add GPIO 16/17 pin aliases on DevKit V1 (RX2/TX2)
On the ESP32 DevKit V1 the silkscreen labels GPIO 16 / 17 as RX2 / TX2,
and Esp32Element.PINS_ESP32 only exposed the silkscreen names. Examples
that wire to numeric pin "16" or "17" (e.g. ledcAttach(16, 5000, 8) on
esp32-pwm-led-rgb) couldn't resolve those names — pinPositionCalculator
failed lookups, the wire endpoint fell back to (0,0)/(50,50) and the
LED component visually floated off the board, breaking the SPICE
netlist for the example.

Add "16" and "17" as aliases pointing to the same (134,143) / (134,131)
coordinates as RX2 / TX2 so both naming conventions resolve to the same
physical pin tip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:16:33 +02:00
velxio-deploy 4ee8fb59e8 chore(examples): drop stray .png files from public thumbs
ExampleThumbnail.tsx only serves /examples-thumbs/<id>.webp; the .png
copies were sharp's intermediate format committed by mistake on
2026-05-19 (commit 8693f93) when scripts/refresh-example-thumbs.sh
copied both formats to public. Removing ~18 MB of unreferenced PNGs
keeps the public folder lean. WebP is supported by ~97% of browsers
in use; the old Safari fallback path goes through CircuitPreview
(the SVG mock), not the .png.
2026-05-20 06:55:41 +02:00
velxio-deploy d1de630614 chore(examples): add 46 missing example thumbnails
Generated by velxio-prod's scripts/capture-example-thumbs.mjs after
fixing three bugs (CTA-based navigation against renamed route,
heterogeneous data-file parsing, false-positive prefix match). The
gallery now has a real canvas screenshot for every example
exampleProjects exports — 263 / 263. Previously 44 examples (mostly
digital-* and i8080-* / z80-*) had no preview and rendered the
SVG mock fallback.
2026-05-20 06:55:24 +02:00
velxio-deploy 8693f93ed9 chore(examples): refresh 219 thumb file(s) [auto] 2026-05-20 05:03:13 +02:00
David Montero Crespo 4ff71765e7
Merge pull request #202 from davidmonterocrespo24/fix/circuit-sim-service-stop
fix(circuit-sim): stop() guard + drop edges with no V-source post-reb…
2026-05-19 22:52:08 -03:00
David Montero Crespo f5ae4853eb fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild
Two related bugs that surfaced as "Vitest worker exited unexpectedly /
Timeout terminating forks worker" on the circuit-simulation-service
test file.

Bug 1 — tick() recursively re-schedules itself in its finally block.
After afterEach disposes the scheduler via __resetMixedModeScheduler(),
those re-scheduled ticks throw "call loadCircuit first", get caught by
the console.warn, and the finally schedules ANOTHER tick. Infinite
Promise loop survives until the worker OOMs.

  Fix: add CircuitSimulationService.stop() that flips a `stopped` flag
  short-circuiting tick() + handleMcuEdge(). The test harness now
  tracks each started service in _activeServices and calls stop() in
  afterEach alongside the existing unsubscribe sweep.

Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net
(buildNetlist skips it because netLookup returns null), handleMcuEdge
sees hasSource=false, self-heals by queueing the edge + tick().  The
rebuild still doesn't emit the V-source (no wire), so tick.finally
replays the edge → self-heal again → tick again → infinite loop AT
RUNTIME, not just in tests. A user toggling a digital pin without a
wire freezes the whole circuit simulation.

  Fix: in tick.finally's pendingMcuEdges replay loop, check whether
  the rebuilt netlist now contains a V-source for each pending edge's
  pin. If not, drop the edge silently — a future canvas tick triggered
  by adding the wire will pick it up via the normal subscription path.

Also fix the "coalesces an edge with an in-flight full solve" test
fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was
never emitted and the test was racing the (now-bounded) self-heal
rebuild. Replaced with an inline fixture wiring pin 9 → resistor →
GND, mirroring the wired fixture used by the alter+republish test
right above it.

Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per
shard) with no worker-exit warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:49:29 +02:00
David Montero Crespo 95a60e1d01 test: align photoresistor-sensor fixtures + snapshots with 603b791 alias
Three failures introduced by 603b791 (which added
MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor'] so the
metadata-id 'photoresistor-sensor' resolves to a SPICE mapper):

- component-to-spice.test.ts "every mapped metadataId has a test fixture"
  flagged photoresistor-sensor as missing. Added a fixture entry that
  mirrors the photoresistor one — the part is electrically identical.

- examples-netlist-snapshot.test.ts > photoresistor-light and
  > nano-sensor-station snapshots now contain R_ldr_ldr + R_ldr_pull
  cards (correct LDR + 10k pull) instead of the previous
  R_autopull_n0 100M stub. This is the intended behaviour change:
  before the alias the LDR was unmapped and the netlist autopulled the
  net to ground with a 100M dummy; after the alias the SPICE deck
  carries the real divider topology. Regenerated only these two
  snapshot entries (vitest -u on the single file).

No production code changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:29:21 -03:00
David Montero Crespo 603b791daa feat(attiny85+customchip): full ATtiny85 ADC/Timer0 + custom-chip pipeline fixes
ATtiny85 (AVRSimulator + collectPinStates + connectAnalogInputsToMcu + SimulatorCanvas + Attiny85Element + examples):
- Add attiny85AdcConfig with correct register addresses (ADMUX=0x27,
  ADCSRA=0x26, ADCSRB=0x23, ADCL=0x24, ADCH=0x25, DIDR0=0x34, adcInterrupt=0x08).
  Without this, analogRead() polled the wrong address forever and the
  firmware hung on first ADC read.
- Add attiny85Timer0Config + instantiate AVRTimer so OVF fires at the
  ATTinyCore-expected ~1.024 ms cadence. delay() advance is still blocked
  on avr8js TIFR auto-clear semantics (separate upstream issue, see
  ATTINY85_TIMER0_UPSTREAM_ISSUE.md in velxio-prod test plan).
- Map ATtiny85 ADC channels to PB-style pin names (PB5/PB2/PB4/PB3 -> 0..3)
  in connectAnalogInputsToMcu so SPICE node voltages reach the right ADC
  channel.
- Recognise /^PB\d+$/ in collectPinStates.pinNameToArduinoPin so wires
  named "PB1" emit v_attiny85_pb1 V-source and the LED responds to MCU
  writes. Previously every PB-wire returned -1 and SPICE saw no source.
- SimulatorCanvas: subscribe pin 1 (PB1) for the built-in LED on the
  attiny85 board kind (Digispark convention), instead of falling through
  to the pin-13 default.
- Attiny85Element: remove the hand-drawn "yellow LED" circle that was
  floating above the chip. The bare DIP-8 has no on-board LED; examples
  wire a real wokwi-led + resistor instead.
- examples.ts: add a real wokwi-led + 220 Ohm wokwi-resistor + wires to
  attiny85-blink, and add missing series resistors to attiny85-button-led
  and attiny85-ntc-sensor. attiny85-pwm-fade was already correct.

Custom-chip pipeline (CustomChipPart + simulatorBridges):
- Add a requestAnimationFrame loop that calls instance.tickTimers() every
  frame in CustomChipPart. Chips that register vx_timer_create (e.g. an
  i8080 stepping its core, or a sensor publishing samples) had timers
  added to the queue but nothing fired them; tickTimers was dead code.
- Gate the ESP32 backend path with detectSimulatorKind(sim)==='esp32'.
  The previous `typeof sim.registerSensor === 'function'` check matched
  AVR and RP2040 simulators too (they expose registerSensor for I2C
  sensor proxies), routing client-side chips to a non-existent ESP32
  worker on those boards.
- Replace direct simulator.usart.writeByte calls in avrUartTx with a
  JS-level FIFO + setTimeout(1ms) drainer. avr8js writeByte drops bytes
  under burst load (a chip emitting print_string lost ~99% of bytes via
  non-immediate, or kept only the last byte via immediate). The drainer
  attempts one non-immediate write per tick and retries on RXC busy /
  RXEN off. Added a guard for ATtiny85 (no USART -> would queue forever).

End-to-end verified: i8080-banner-streamer now prints the boot banner
followed by "uptime ticks: 0xNN" lines stepping every ~50 ms, executing
real Intel 8080 instructions inside the WASM chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:10:08 -03:00
David Montero Crespo a9285c6698 fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.

Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.

Documented the model in docs/wiki/component-interaction.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:16:47 -03:00
David Montero Crespo dd22bcfe50 fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias
Three independent fixes uncovered during a systematic example-by-example
audit (plan/full_test_plan/):

1. DynamicComponent.handleMouseDown was calling e.stopPropagation()
   unconditionally in the capture phase. That swallowed pointerdown
   BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick
   could see it, so the rotary knob would not rotate and buttons
   wouldn't press even with a real OS mouse. Now we skip the swallow
   when the click target is an inner wokwi-* element during a live
   simulation, letting the wokwi component own its own pointerdown
   while still allowing the canvas drag-to-rearrange flow on the
   wrapper / non-interactive surface.

2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider
   formula inverted relative to both the SPICE mapper topology
   (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring)
   and real wokwi-ntc-temperature-sensor modules. Moving the slider
   to 60 C made the firmware print -3.42 C. Flipped the formula to
   r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports
   60.12 C and A1 voltmeter shows 4.00 V.

3. componentToSpice.ts photoresistor mapper was only registered under
   the bare key `photoresistor`, but example components use the
   metadataId `photoresistor-sensor`. Added an alias so the LDR +
   pull-down divider gets emitted for the real component instance.

All three reproduce visually in seconds; documented per-example in
plan/full_test_plan/examples/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:10:42 -03:00
davidmonterocrespo24 1f23066f47 fix(tests): migrate forks config to vitest-4 top-level + restore NODE_OPTIONS
Two issues in PR #200 became obvious from the next CI run:

1. **`poolOptions.forks.execArgv` had no effect.** Vitest 4 removed
   `test.poolOptions` entirely — every key under it moved to
   top-level `test.*`. The runner emits this banner on every run:

       DEPRECATED `test.poolOptions` was removed in Vitest 4.
       All previous poolOptions are now top-level options.

   So `test.poolOptions.forks.execArgv: ['--max-old-space-size=8192']`
   was silently ignored. Move it to `test.forks.execArgv` (and
   `test.forks.singleFork`).

2. **NODE_OPTIONS got dropped when the shard step was rewritten.**
   PR #199 added `env: NODE_OPTIONS: --max-old-space-size=8192` to
   the `npm test` step; PR #200 replaced the step with `npx vitest
   run --shard X/2` but did not carry the env-var across. Combined
   with #1, the workers reverted to Node's 4 GB default and shard 2
   (58 files) still OOMs at exactly 4128 MB heap.

Restore NODE_OPTIONS on the workflow step as belt-and-braces — it
gets honored by the parent vitest process, and the migrated config
covers the forked children.

With 8 GB heap × 2 shards × 58 files each, the cumulative state
from ngspice WASM + singletons fits comfortably and the suite
should exit cleanly.
2026-05-19 18:09:34 +02:00
davidmonterocrespo24 32d00ae0a7 fix(tests): bump fork heap via poolOptions.execArgv (NODE_OPTIONS ignored)
PR #198 added NODE_OPTIONS=--max-old-space-size=8192 to the
frontend-tests workflow assuming vitest's forks pool would inherit
it. It does NOT. Vitest 4's forks pool spawns workers via
child_process.fork() with an explicit execArgv list and ignores
the parent shell's NODE_OPTIONS env var — verified by reading the
post-merge GHA log: the Node OOM still fires at ~4.0 GB heap,
exactly the default v8 ceiling.

Set the heap cap at the pool level instead so the workers actually
see it. This is the canonical vitest 4 idiom for raising worker
limits — `poolOptions.forks.execArgv` is forwarded verbatim to
each forked child.

Independent of: the gpio_matrix_cb SIGSEGV fix in qemu-lcgamboa
(now landed) which addresses the Backend E2E failure mode. This
PR is exclusively the Frontend Tests heap fix.

This also serves as the trivial commit needed to re-trigger the
master CI run against the now-fixed libqemu binaries (v1.1.1
served from the license endpoint).
2026-05-19 17:05:20 +02:00
davidmonterocrespo24 7f0f72862c fix: regenerate components-metadata + plug vitest worker leak
Two CI failures landed together on master after PR #194 merged:

1. **components-metadata.json stale.** The `power-supply` thumbnail
   in scripts/component-overrides.json was updated (grey placeholder
   → branded PSU SVG with voltage/current labels) but the generated
   JSON wasn't regenerated. The pre-merge check
   `git diff --quiet frontend/public/components-metadata.json` now
   fails on master. Fix: `cd frontend && npm run generate:metadata`,
   commit the result.

2. **Frontend Tests > test (20/22): vitest worker hang.**
   `circuit-simulation-service.test.ts` had been calling
   `service.start()` in ~10 tests without storing the returned
   unsubscribe handle. Each call subscribes the service to the
   simStore; the listener captures the service + scheduler in
   its closure. After all tests complete, vitest's forks pool
   tries to terminate the worker but the still-active listeners
   keep the event loop pinned, producing:
       "Worker exited unexpectedly / Timeout terminating forks worker"
   All assertions actually pass — only the worker shutdown hangs.

   Fix: introduce a `startTracked(service)` helper that records
   the unsubscribe in a module-level array, plus an `afterEach`
   that drains the array. `__resetMixedModeScheduler()` still runs
   after to dispose the scheduler singleton. Replaced all 9 raw
   `service.start()` callsites.

Both are independent of any production code change. The fix is
test/scaffolding only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:38:22 +02:00
David Montero Crespo bd23a05d23
Merge pull request #195 from davidmonterocrespo24/feat/chip-programmable-rom
Feat/chip programmable rom
2026-05-19 02:28:49 -03:00
David Montero Crespo 321997715d feat(editor): Monaco syntax highlight for 8080/Z80 assembly
When the editor opens a .s or .asm file (the chip-program files routed
to /api/compile-rom), Monaco now colorizes 8080/Z80 mnemonics, registers,
hex/binary literals, comments, and directives. Same highlighter covers
both ISAs since most mnemonics overlap.

- frontend/src/components/editor/retroAsmLanguage.ts: a Monarch tokenizer
  + LanguageConfiguration + idempotent registration helper. Recognises
  the full 8080 ISA, all the Z80 additions (LD/JR/DJNZ/EXX/EX/IM/LDIR/
  bit ops/index ops), the directives ORG/DB/DW/EQU/END, and registers
  including condition codes (NZ/Z/NC/etc.) and IX/IY.

- CodeEditor.tsx: maps `.s` and `.asm` to the new `retro-asm` language
  and calls `registerRetroAsm(monaco)` in beforeMount so the language
  exists by the time the editor first paints. Other extensions
  (.ino/.cpp/.c/.py/.json/.md) behave exactly as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:49:51 -03:00
David Montero Crespo 0e2f0790db feat(chips): C-to-Z80 compile via SDCC + LED chaser example
Adds a third format to /api/compile-rom: `c` (C source compiled by SDCC
to Z80 bytes). Same chip-program flow as 8080/Z80 asm — write C in a
project file, click Compile, click Run.

Backend:
- backend/app/services/c_compile.py — async SDCC wrapper. Locates the
  sdcc binary on PATH (or via SDCC env var, or common Windows install
  paths) and shells out with target=mz80 + --code-loc 0x100 --data-loc
  0x8000. Parses the resulting Intel HEX into raw ROM bytes. Pure 8080
  is rejected with a clear error (SDCC has no 8080 backend; Z80 ROMs
  also run on the i8080-cpu chip if you avoid Z80-only ops).
- rom_compile.py: compile_rom is now async; the new c branch delegates
  to c_compile. compile_rom_endpoint awaits it.

Frontend:
- romCompileService: RomFormat gains 'c'; formatForFile maps .c/.cpp to
  'c'. isChipProgramFile intentionally still excludes .c — disambiguation
  happens at the EditorToolbar level.
- EditorToolbar: the chip-program path also fires when a custom-chip
  has programFile === activeFile.name (regardless of extension). That
  lets .c files route to /api/compile-rom (SDCC) when bound to a CPU
  chip, while .c files NOT bound to any chip continue to route to
  arduino-cli as before.

Docker:
- Dockerfile.standalone adds `sdcc` to the apt-get install list, so the
  prod image ships with SDCC out of the box.

Example:
- /examples/z80-led-chaser-c — z80-cpu chip + chaser.c (a Larson
  scanner written in C with __at() MMIO definitions). Compiles cleanly
  with SDCC's --code-loc 0x100 default crt0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:31:10 -03:00
David Montero Crespo 96ef12b585 feat(chips): programmable Z80 chip + Larson scanner example
Adds the Zilog Z80 to the programmable-retro-CPU lineup. Same compile-rom
flow that landed for the 8080 in PR #189: write Z80 asm in a project
file, click Compile (backend assembles via in-tree two-pass asm-z80),
click Run, the chip emulator boots from the resulting ROM bytes.

Backend:
- backend/app/services/asmz80.py — two-pass Z80 assembler covering the
  practical demo subset: LD r,n / r,r' / rp,nn / (nn),A / A,(nn) +
  ALU r/n + INC/DEC + JP/JR/DJNZ/CALL/RET + PUSH/POP + IN/OUT +
  EX/EXX + LDIR/LDDR/IM/NEG + RLCA/RRCA/RLA/RRA + the simple
  ED-prefix variants. Not yet: CB-prefix bit ops, DD/FD index ops.
- rom_compile.py routes target=z80 through the new assembler.

Chip:
- frontend/src/components/customChips/examples/intel/z80-cpu.{c,chip.json}
  Generated by scripts/make-z80-cpu.py from the existing z80.c emulator
  (same clean-room implementation that passes ZEXDOC end-to-end). The
  external pin/bus protocol is replaced with internal RAM + ROM + MMIO
  for LED/BTN/UART. 35 KB WASM.

Example:
- /examples/z80-larson-scanner — Knight-Rider-style walking LED.
  Demonstrates JR/DJNZ/RLCA which the 8080 can't run.

Plus a small Z80 smoke-test asm under scripts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:21:08 -03:00
davidmonterocrespo24 666f9c4008 fix(tests): update mocks + assertions for PinManager API changes
PR #192 (spice-led-pipeline) added two PinManager changes that were
not reflected in the test mocks / assertions:

- `setPinState(pin, state)` gained an optional `source: 'mcu' |
  'external'` third arg. Production ESP32-C3 / RISC-V simulators
  now pass `'mcu'` to mark the call as an MCU output (so the SPICE
  collector emits a V-source). The esp32c3-blink and esp32c3-simulation
  tests asserted on the old 2-arg shape.
- `resetPinStates()` is a new public method on PinManager called by
  `stopBoard` / `resetBoard` to clear cached pin states. The mocks in
  esp32-integration.test.ts and multi-board-integration.test.ts did
  not add it, so any test that ran stopBoard hit
  `TypeError: getBoardPinManager(...)?.resetPinStates is not a function`.

This commit:
- Adds `'mcu'` to the two ESP32-C3 setPinState assertions.
- Adds `this.resetPinStates = vi.fn()` to both integration mocks.

These are pure test fixups — no production code touched. The
`circuit-simulation-service.test.ts > handleMcuEdge` failure
(`expected 1 to be 2`) is a separate regression in production code
introduced by PR #192 and is NOT fixed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:52:30 +02:00
David Montero Crespo bbf8cd0303 feat(chips): programmable retro CPU chips with external ROM
Adds a new way to use the retro CPU chips: write your program in a
project file (.s / .asm / .hex / .bin), click Compile, click Run, and
the same chip emulates whatever you wrote. Same chip + different ROMs =
mini PC, calculator, LED demo, Kill-the-Bit game, etc.

SDK:
- velxio-chip.h gets two new host imports:
    uint32_t vx_rom_size(void);
    void     vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len);
  CPU-emulator chips call these in chip_setup to pull their program out
  of the host's romBytes property.

Frontend runtime:
- ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new
  imports, copying bytes into chip memory on vx_rom_read.
- CustomChipPart pulls component.properties.romBytes (base64) and passes
  it through.
- Component registry declares three new custom-chip properties:
  romBytes (base64), programFile (matching project filename), and
  programTarget (cpu name).

New programmable bundled chip:
- frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json}
  Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is
  loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM,
  32 KB of external ROM.

Backend:
- New /api/compile-rom endpoint and rom_compile service that turns
  chip-program source into ROM bytes. 8080 ASM is assembled by the
  in-tree two-pass assembler (moved to backend/app/services/asm8080.py).
  Intel HEX records are parsed; raw .bin is passed through. Future targets
  (z80, 8086, 4004) are scaffolded but not wired yet.

EditorToolbar:
- Compile button detects when the active file is .s/.asm/.hex/.bin and
  routes to compile-rom instead of arduino-cli. The compiled bytes are
  injected into every custom-chip on the canvas whose programFile property
  matches the active filename (or is empty).

Example:
- /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on
  the programmable i8080-cpu chip. killbits.s is shipped as a project
  file alongside sketch.ino; the user clicks Compile then Run and the
  LED walks across 8 outputs, buttons kill it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:38:18 -03:00
David Montero Crespo ca1bf00597
Merge pull request #193 from davidmonterocrespo24/esp32-cleanup-broadcast-pwm
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo
2026-05-18 23:23:05 -03:00
davidmonterocrespo24 ba59fd4b4a refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo
The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3
deploys without regressions, so the temporary fallback shipped in
commit 77bf897 can come out. Closes #101.

Backend (esp32_worker.py + esp32_lib_manager.py):
- Stop emitting `ledc_update` from the 0x5000 LEDC callback and from
  the polling thread. Only `ledc_duty` (channel + duty_pct) and the
  GPIO matrix routing events ship now.
- Drop the channel→gpio reverse-lookup that fed the legacy event.

Frontend:
- Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`.
- Delete `makeLedcUpdateHandler` + its `channelGpioMemo`.
- Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':`
  message handler + the `LedcUpdate` type.
- Strip `this.onLedcUpdate = null` from 14 test mocks.
- Rewrite the `does not call broadcastPwm` guard in
  esp32-multi-servo-gpio-matrix.test.ts to assert the method itself
  no longer exists on PinManager (stronger regression guard than the
  spy version, and doesn't need vi).
- Remove the `PinManager.broadcastPwm fallback` describe block from
  esp32-servo-pot.test.ts — every test in it exercised the deleted
  fallback path.

Docs (ESP32_EMULATION.md):
- Replace `ledc_update` rows in the events / implementation tables
  with the SignalRouter trio (`ledc_duty`, `gpio_routing`,
  `gpio_routing_clear`).
- Update the visual flow diagram + the "why this matters" paragraph
  to past-tense the broadcastPwm bug.

Tests: 1886 frontend tests pass (the previously-failing
board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is
also green). Backend unit suite: 279 pass, the 11 espidf_real_paths
prereq failures are environment-dependent (need arduino-cli libs in
the local shell) and unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:05:34 +02:00
David Montero Crespo 55b3dd29e5 fix(DynamicComponent): pass componentId through to PinTracer
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().

Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).

Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.

Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:08:06 -03:00
David Montero Crespo 81837eedb9 fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.

Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
  `outputPins` only if the DDR bit is set, so the PORTx write that
  enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
  as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
  Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
  `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
  INPUT_PULLUP — without this the firmware reads LOW from boot and
  thinks the button is permanently pressed (the "LED is always on,
  pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
  with active PWM, AND subscribe to onPwmChange to re-tick the netlist
  on duty changes. Fade-LED now produces a true gradient (6 distinct
  brightness levels across a fade cycle) instead of a binary 0/full
  toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
  pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
  same in-flight tick used to overwrite each other; now every pin's
  most-recent edge replays after the tick. Fixes Traffic-Light RED→
  YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
  underscores in V-source names. ngspice's interactive `alter` command
  treats `-` as an operator and silently no-ops on hyphenated source
  names, so mid-simulation MCU pin transitions stopped propagating
  after the first solve. MixedModeScheduler.onMcuPinChange and
  CircuitSimulationService self-heal use the same sanitizer so names
  stay consistent across emit/alter/lookup. Also added a regex-based
  fallback in step 2 so any board pin matching `GND.\d+` canonicalises
  to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
  per-board `groundPinNames` list missed several, leaving wires
  floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
  every wired board pin. Leaves INPUT pins (analog sensors on A0,
  pull-down dividers, etc.) free for the SPICE solver instead of being
  shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
  nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
  triggerPinChange / setPinState so the new outputPins tracking fires
  on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
  outputPins clears between runs; Esp32Bridge.onPinChange passes the
  `'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
  `clock=16pll` (ATTinyCore 1.5.2 renamed the option).

Backend
- esp-idf-template/main/CMakeLists.txt: skip the
  `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
  variants already define LED_BUILTIN in pins_arduino.h via a
  self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
  uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
  command line expanded the static-const declaration to
  `static const uint8_t 2 = ...;` — a syntax error that broke every
  ESP32-C3 / S3 build (`expected unqualified-id before numeric
  constant`).

Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
  `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
  exist on the canvas). Replaced with `'arduino-uno'` (the canvas
  board-id convention) and converted `D<n>` pin names to `GP<n>` for
  Pico-style boards. Affects pico-blink, pico-i2c-scanner,
  pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.

Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
  Blink (single-pin), Button (idle-OFF invariant — catches the
  INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
  Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
  driven). Run via `npm --prefix frontend run test:visual` against a
  Chrome on `:9222` + vite on `:5174` + backend on `:8001`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:52:07 -03:00
David Montero Crespo a179f8492e Refactor code structure for improved readability and maintainability 2026-05-18 21:50:45 -03:00
David Montero Crespo 14613f152f feat(compile): ESP-IDF compile options + request dedup
Backend:
- api/routes/compile.py            accepts board-specific compile options
                                   and dedups in-flight identical requests
- services/espidf_compiler.py      expanded ESP-IDF wrapper with the new
                                   options surface (sdkconfig.defaults.in
                                   template added)
- services/arduino_cli.py          honour the new options envelope
- services/esp32_lib_bridge.py     thread board options through to QEMU

Tests:
- tests/test_compile_request_dedup.py  end-to-end dedup behaviour
- tests/test_espidf_options.py     covers the new options parsing

Frontend:
- services/compilation.ts          client-side mirror — sends the new
                                   options field on every compile request

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:45 -03:00
David Montero Crespo 16f168ba7f feat(board-options): per-board options modal + persistence
Adds a new BoardOptionsModal accessible from the EditorToolbar that exposes
per-board options (currently used for board-specific compile flags). Wires
the modal through:

- types/boardOptions.ts            new BoardOptions shape
- types/board.ts                   BoardInstance gains `boardOptions` + `spiffsFiles`
- store/useSimulatorStore.ts       boardOptions persisted in loadProjectState
- components/editor/EditorToolbar.tsx     button to open the modal
- components/simulator/BoardOptionsModal.{tsx,css}  the modal itself
- components/simulator/SimulatorCanvas.tsx  passes the options through
- utils/projectPayload.ts          board options serialised in saved projects
- pages/ProjectByIdPage.tsx        re-includes the by-id loader needed for
                                   project URLs that reference boards with
                                   their persisted options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:45 -03:00
davidmonterocrespo24 18a582455c feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators
Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to
kill init" panic as `mount /dev/vda` failing with EINVAL — Debian
armmp does not have ext4 builtin (only fuseblk in /proc/filesystems).

- qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries.
  All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio
  virtio transport (arm-32 virt PCI fails -75 due to missing reg DT
  property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile;
  Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus
  for virtio-blk-pci vs virtio-blk-device (and serial likewise).
- manifest.json: new `raspberry-pi-armhf` image_set wiring three
  assets (kernel + initramfs + zstd rootfs).
- Frontend BoardKind gains the three new kinds + an isPiBoardKind()
  helper. Replaces the eight scattered `=== 'raspberry-pi-3' ||
  === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in
  useSimulatorStore, Interconnect, loadExample, boardProtocols.
  ComponentRegistry gets three new picker entries.
- board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds
  (backend boards have no canvas examples).

The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes
live in velxio-prod's scripts/ (private overlay) — the upstream
kernel build script only knows about arm64; armhf is built in the
private repo because the assets ship through the license endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:23:48 +02:00
David Montero Crespo 33cca9f08a
Merge pull request #188 from davidmonterocrespo24/pi-phase-2.5
feat(pi): pluggable slave handler + canvas wire detection for I2C/SPI…
2026-05-18 15:25:35 -03:00
David Montero 391817d2c9 fix(components-metadata): sync power-supply override + regenerate
Two pieces of drift introduced in 305170a (Regulated Power Supply):

1. The committed components-metadata.json carries a custom rich
   thumbnail SVG (showing 5.00V / 1.00A / PSU labels), but the
   _customComponents override has no `thumbnail` field — so any
   regen via `npm run generate:metadata` replaces it with the
   generic placeholder. CI catches the drift and fails.
   Fix: lift the rich SVG into the override entry.

2. The committed metadata description is a short one-liner while
   the override description is the longer explanatory version.
   The override is the source of truth, so the metadata now
   matches: longer description wins.

Verified locally: regenerator now produces zero diff against the
committed metadata.
2026-05-18 19:32:34 +02:00
David Montero Crespo 7ce184c4c6 fix(spice): always emit V-source per wired GPIO pin (root cause of intermittent dark-LED)
Bug reproduced via CDP probe across 5 Run/Stop cycles: cycle 1
worked (LED toggled), cycles 2-5 LED stayed dark — but exactly the
same code, same canvas, same circuit.

Tracing the live electrical store via __spiceDebug() showed:
  cycle-1 after-run: branchCurrentCount=3 (pin13 V-source present)
  cycle-2 after-run: branchCurrentCount=2 (pin13 V-source MISSING)
  cycle-3..5 after-run: branchCurrentCount=2

The flow:
  1. User clicks Run -> board.boards reference changes -> service ticks.
  2. runSolve calls collectPinStates(board, ...) to snapshot output pins.
  3. collectPinStates was emitting an entry ONLY when pinManager.getPinState(pin)
     was currently TRUE. If the pin was LOW at that exact instant
     (which is most of the time for a Blink sketch — 50% duty), no
     pinStates entry, no V-source card in the netlist.
  4. AVR runs, digitalWrite(13, HIGH) fires, handleMcuEdge calls
     scheduler.onMcuPinChange -> solver.alterSource('V_arduino-uno_13', 5).
  5. ngspice gets 'alter V_arduino-uno_13 dc 5' but that V-source
     doesn't exist in the deck. Silent no-op. branchCurrents never
     updates. LED stays dark forever.

The 'sometimes it works' impression came from cycle 1: the cold-boot
AVR happened to land on a HIGH state precisely when the tick fired,
so the V-source got emitted and every subsequent edge alter worked.
The other cycles caught the AVR in LOW.

Fix: always emit a digital PinSourceState — with v=0 when LOW, v=vcc
when HIGH — so the NetlistBuilder always produces V_<board>_<pin>
cards for every wired GPIO. alterSource then has a target to bind
to no matter what state the pin was in at solve time.

Verified live via CDP probe (_probe_blink.mjs in working tree):
  pin13 toggles 0V<->5V at the Blink frequency
  LED anode follows at 0V<->1.838V (matches manual calculation:
    (5 - 1.84) / 220 = 14.4 mA forward current through the red LED)
  branchCurrentCount = 3 stable across all cycles
2026-05-18 13:32:31 -03:00
davidmonterocrespo24 2072011fa4 feat(pi): pluggable slave handler + canvas wire detection for I2C/SPI/UART
Adds the public extension points the velxio-prod overlay uses to bind
real canvas-side I2C/SPI/UART models (BME280, future MCP23017, etc.)
to a running Pi guest's protocol shims:

- qemu_manager: set_pi_slave_handler(fn) / get_pi_slave_handler() for
  pi_attach_slave + pi_detach_slave WebSocket messages. OSS image
  leaves the hook unset so the messages are silently dropped.
- simulation route: parses the two new WS message types and forwards
  them to the registered handler when present.
- RaspberryPi3Bridge: attachSlave(spec) / detachSlave(spec) frontend
  side of the protocol.
- piSlaveScanner: at simulation start walks components + wires,
  identifies I2C/SPI/UART peers wired to Pi protocol pins (40-pin
  header physical-pin numbering), and emits one attach per
  bus/address pair (deduped across SDA+SCL wires).
- RaspberryPiWorkspace: invokes the scanner once the bridge is open,
  with retries to ride out the WS-still-connecting race.
- integration test: pi3_bme280_attach.py boots the Pi, pre-attaches a
  BME280 via the slave handler, runs a host-side proto loop, runs
  guest python smbus2.read_byte_data(0x76, 0xD0) and asserts the
  console reads back CHIP=0x60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:26:37 +02:00
David Montero 683a7f31e6 test(coverage): accept raspberry-pi-4 and raspberry-pi-5 as uncovered
Pi 4 and Pi 5 were added to the BoardKind union in db5e3a8 ("feat(pi3
phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS") but no gallery example
ships for them — same situation as Pi 3, which is already accepted.
All three boot a full Linux image under QEMU on the backend; there is
no in-browser canvas demo to register.
2026-05-18 15:56:54 +02:00
David Montero Crespo 5f378ed411 fix(spice): allow hyphens in voltage-source name regex (root cause of dark-LED bug)
The user-reported 'LED with proper series resistor shows 1.84 V at
the anode but never visually lights up' had its root cause here,
not in ngspice / not in the LED brightness handler / not in any
component id naming choice. ngspice parses 'V_led-builtin_sense'
just fine; the diode conducts and the node voltage is exactly what
you'd compute by hand.

What breaks is the JS regex that scans the emitted netlist to
collect voltage-source names so CircuitSimulationService can ask
the scheduler to read their branch-current vectors:

  const m = card.match(/^([Vv][_\w]*)\s/);

[_\w]* doesn't accept '-'. For a card 'V_led-builtin_sense …' the
capture is 'V_led' (truncated at the hyphen). The voltageSources
array gets the wrong name; CircuitSimulationService pushes
'i(v_led)' into extraVectorsOfInterest; ngspice has no such vector
so the readVec promise rejects silently; branchCurrents['v_led-builtin_sense']
is never populated; the LED handler in BasicParts.ts sees raw =
undefined, the SPICE-memo path is skipped, and the digital fallback
runs but only sets the LED on when the PinResolver classifies the
anode as a direct GPIO connection (which it does NOT when an
intermediate resistor is in series). Dark LED.

Fix is adding '-' to the character class. One character. All five
existing examples I previously 'fixed' by just adding a series
resistor will now light up correctly without renaming any of their
component ids. Same for any saved user project with hyphenated ids
and for the auto-generated picker ids that used to contain hyphens.

The earlier underscore-id workarounds (default canvas + picker
template) stay in place as defense in depth — they don't break
anything and they keep the SPICE side clear of avoidable special
characters.
2026-05-18 10:45:06 -03:00
davidmonterocrespo24 db5e3a8623 feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS
Backend: extract per-board config into a PI_CONFIGS dict keyed by
board_type. Pi 3/4/5 share the same arm64 image set (kernel +
initramfs + rootfs) and differ only in QEMU -cpu and -m:

  raspberry-pi-3 → cortex-a53  + 1G  (BCM2837, ARMv8 64-bit)
  raspberry-pi-4 → cortex-a72  + 2G  (BCM2711, ARMv8 64-bit)
  raspberry-pi-5 → cortex-a76  + 2G  (BCM2712, ARMv8 64-bit)

PiInstance now carries board_type so the per-board lookup happens
once at start_instance time. Unknown board_type falls back to
DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for
back-compat with older clients).

Pre-warm hook walks every unique image_set in PI_CONFIGS so the
provider only downloads each set once even when several Pi models
are registered.

Frontend:
- BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'.
- BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards
  (FQBN null since they use the Pi VFS + Python toolchain like Pi 3).
- ComponentRegistry inserts two new component metadata entries
  cloning the Pi 3 board art with different thumbnail colours.
  Tag name reused so the same velxio-raspberry-pi-3 web element
  draws the board on the canvas — the 40-pin GPIO layout is
  identical across Pi 3/4/5.
- boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table
  (PI3_BCM) since the 40-pin header layout is identical.
- loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS
  ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike.
- Interconnect.isPi3Bridge() recognises all three Pi family members
  so Arduino↔Pi serial routing keeps working.
- RaspberryPi3Bridge constructor gained a boardKind parameter
  defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message
  now ships the actual board kind so the backend knows which
  PI_CONFIGS entry to use.
- useSimulatorStore.addBoard wires bridge construction for all
  three Pi family members.

Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel
package + armhf rootfs build, no change here.

Smoke-tested inside the prod container:
  Pi 4 (cortex-a72) → reached agetty login on hvc0
  Pi 5 (cortex-a76) → reached agetty login on hvc0
Both show 'aarch64' in uname -m.
2026-05-18 15:41:29 +02:00
David Montero Crespo 32f5b407af fix(simulator): underscore-separated component ids for SPICE safety
The user reported the default editor canvas — Arduino Uno + LED +
220Ω resistor — was correctly powered (1.84 V at the LED anode,
14 mA through the diode) but the LED visual stayed dark. Only the
built-in pin-13 LED on the wokwi-arduino-uno element lit up.

Root cause: ngspice's WASM build truncates branch-current vector
keys at the first hyphen. A sense source named V_led-builtin_sense
ends up exposed under a key like v_led#branch rather than the
expected v_led-builtin_sense#branch. CircuitSimulationService and
BasicParts.ts both look up the FULL key, miss, and the LED's
brightness update treats raw as undefined → digital-fallback path
runs but the SPICE memo timestamp is fresh so HOLD keeps zero
brightness. Visible symptom: a perfectly conducting LED that never
lights.

Fix in two places:
  - Default canvas (useSimulatorStore.ts): rename 'led-builtin' /
    'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching
    wire ids).
  - DynamicComponent.tsx makeNewComponent: the id template was
    'metadata.id-timestamp-rand' producing hyphens for every
    user-added component too. Switched to underscores, AND replace
    any hyphens already in metadata.id (e.g. 'led-bar-graph') so
    the prefix doesn't reintroduce the bug.

Existing saved projects whose ids contain hyphens are not migrated
here — those will keep the visual bug until either the operator
edits the components or we add a sanitisation step inside
componentToSpice + BasicParts. The next follow-up commit can add
that if you confirm this default-canvas fix works.
2026-05-18 10:01:48 -03:00
David Montero Crespo 5c5336acc0
Merge pull request #187 from davidmonterocrespo24/feat/retro-intel-cpus
feat(chips): port retro Intel/Zilog CPUs as Velxio custom chips + 2 d…
2026-05-18 02:30:23 -03:00
David Montero Crespo b714c79e3d feat(chips): port retro Intel/Zilog CPUs as Velxio custom chips + 2 demos
Adds 17 chips from the test/test_intel clean-room research to the Custom
Chip gallery, all sourced from manufacturer datasheets and validated by
the existing 129-test vitest harness (CPUDIAG end-to-end for the 8080,
ZEXDOC for the Z80).

CPUs: 4004, 4040, 8080, 8086, Z80 (categoria retro-cpu)
Bus chips: rom-32k, ram-64k, rom-1m, latch-8282, 4001-rom, 4002-ram,
           8255-ppi, 8251-usart, 8259-pic, 8253-pit (retro-bus)

Two bundled "mini-computer" demos under retro-bundle that drop on the
canvas as a single chip and run real 8080 code out of an embedded ROM:

  * i8080-repl     8080 + RAM + ROM + UART, prints a banner and an
                   "uptime ticks: 0xNN" counter every ~50 ms via a real
                   DCR/JNZ busy-wait. Visible in Serial Monitor.

  * i8080-counter  8080 + RAM + ROM + 8 LED pins + 2 button pins.
                   Counts up in binary on BTN_INC, clears on BTN_RST.

Two example projects under /examples reuse these chips end-to-end:

  * /examples/i8080-banner-streamer
  * /examples/i8080-button-counter

The bundled chips inline a 328 / 34-byte 8080 ROM produced by a new
two-pass 8080 assembler in Python (scripts/asm8080.py) from the .s
sources in scripts/. Both ROMs are pre-assembled and committed under
scripts/*.txt so contributors can rebuild deterministically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:27:53 -03:00
David Montero Crespo 174b4b94e8 test: cover power-supply mapping + refresh mega-multi-led snapshot
Two follow-ups to the realistic-simulator sprint:

1. component-to-spice.test.ts requires every mapped metadataId in
   componentToSpice.ts to have a corresponding MINIMAL_FIXTURES entry
   so the catalog-completeness assertion passes. Adds the power-supply
   fixture (2 pins, default DC 5V/1A topology).

2. examples-netlist-snapshot.test.ts snapshot for mega-multi-led now
   includes the new 8x 220Ω series resistors and the matching
   autopull nodes. Net IDs shift from n0..n7 -> n9..n16 because the
   resistor adds an intermediate node per LED. Verified the diff is
   correct (every added R_r* card is a series resistor between the
   board pin and the LED anode) before applying.
2026-05-18 00:24:08 -03:00
David Montero Crespo 305170aeb9 feat(components): Regulated Power Supply with per-instance current limit
Adds a new picker entry 'Regulated Power Supply' under the analog
category. Conceptually fills the gap between wokwi-battery (fixed
DC) and wokwi-signal-generator (waveform focus): user chooses
voltage + mode (dc / ac) + currentLimit, no need to think about
battery chemistry or signal amplitudes.

Properties:
  mode:         'dc' | 'ac'   (default 'dc')
  voltage:      V             (default 5)
  frequency:    Hz            (default 50, only for AC)
  currentLimit: A             (default 1)

Design notes:
  - No new Web Component. The tagName piggy-backs on
    wokwi-signal-generator so the canvas renders the familiar
    bench-instrument chrome — saves shipping a second 100+ LOC
    Web Component for an identical 2-pin shape.
  - SPICE: ideal V-source + ESR sized so a near-short reads
    I ≈ 1.5·limit. ngspice has no native foldback so the limit
    is a circuitVerifier rule, not a hard SPICE constraint.
  - circuitVerifier: extends sourceComponents regex to include
    power-supply AND honors the per-instance currentLimit
    property as the threshold. Real bench supplies behave this
    way — a 100mA-limited supply trips at 100mA, a 5A supply
    tolerates 5A before flagging. The error code is
    'source-overload' (not 'short-circuit') so the modal copy
    matches what the user just configured.

The board GND / VCC pins of Arduino / ESP32 / etc. already act
as voltage sources via BOARD_PIN_GROUPS canonicalisation (the
NetlistBuilder maps wires to the right rail). So the user's
companion request — 'board pins should already work' — is the
existing behaviour; this commit only adds the standalone bench
supply for boardless circuits or for testing with a different
voltage.
2026-05-18 00:00:45 -03:00
David Montero Crespo 3b527f3dce fix(examples): add missing 220Ω series resistors to LED examples
Five examples wired LEDs directly between a GPIO pin and GND with
no current-limiting resistor:

  - examples.ts: traffic-light (3 LEDs), button-led (1), fade-led (1),
    simon-says (4)
  - examples-circuits.ts: mega-multi-led (8 LEDs)

In real hardware these wire-ups blow the LED in seconds. In the
simulator, ngspice cannot converge on a forward-biased diode with
no series resistance so the branch current comes back as NaN; the
LED visual stays dark even though the user's code is driving the
pin HIGH every cycle.

Adds a 220Ω wokwi-resistor per LED (textbook value for 5 V supplies
and standard diodes) and rewires:
  arduino pin → r.1 / r.2 → led anode / led cathode → GND

The same upstream commit hardens the verifier (worst-case GPIO drive
in pre-flight) and the LED renderer (NaN guard) so this class of
mistake is now caught immediately and degrades gracefully when a
user creates their own broken circuit.
2026-05-18 00:00:45 -03:00
David Montero Crespo 06d6e0afbb fix(simulator): circuitVerifier worst-case GPIO + LED NaN guard
Two related correctness fixes that make the simulator's realism
match what users actually see.

1. circuitVerifier was running pre-flight against the IDLE circuit
   (every pin LOW). A Blink sketch is going to write pin 13 HIGH
   eventually — at which point a missing series resistor produces a
   ~500 mA spike through the diode. But because pre-flight ran with
   pin 13 LOW the led-overcurrent rule never fired, and the user
   sailed through Run only to see the LED stay mysteriously dark on
   the canvas.

   The verifier now forces every digital pin connected to a load to
   HIGH = vcc, the worst case any well-defined sketch will eventually
   impose. The existing rules (led-overcurrent, resistor-overpower,
   short-circuit) now fire correctly and the existing
   CircuitVerificationModal blocks Run until the user adds a proper
   current limiter or chooses Run Anyway.

   Pins that are inputs-only (a pull-up + button) get over-driven
   here too, but the rules tolerate that — a pull-up at 5 V draws
   ~0.5 mA, well below all thresholds. A circuit that would actually
   fault under HIGH is flagged.

2. LED simulator was crashing visually on non-finite ngspice branch
   currents. A degenerate diode (no series R) makes ngspice return
   NaN, which fell through 'raw !== undefined && current > 1e-6' as
   false and never triggered the digital fallback. Now we check
   Number.isFinite(raw) before trusting it — non-finite returns
   route to the digital fallback so the LED at least lights visually
   when its driver pin is HIGH (the user still sees the verifier
   warning that the real-world circuit is wrong, but Run Anyway is
   not a black screen).
2026-05-18 00:00:45 -03:00
David Montero Crespo 8a6d72aa50 fix(editor): default Blink circuit needs a series 220Ω resistor
The default canvas (Arduino Uno + LED on pin 13) wired the LED
directly between pin 13 and GND. Two consequences:

  1. Real-world: that's a short across a forward-biased diode,
     blowing the LED in seconds.
  2. Simulator: ngspice can't find a steady-state branch current
     for an unprotected diode (returns NaN / indeterminate), so
     the LED visual never lights up. Only the wokwi-arduino-uno
     element's BUILT-IN LED (rendered internally by the element,
     not via wire+pinManager) was visible.

Fix: insert a 220Ω resistor between pin 13 and the LED anode,
cathode straight to GND. Same circuit every introductory Arduino
book teaches. SPICE converges, LED blinks visually on the canvas.

Reported by a user trying Blink on a fresh /editor visit.
2026-05-18 00:00:45 -03:00
davidmonterocrespo24 5faf9cdf52 feat(deps): add @tanstack/react-table for pro admin DataTable
pro/frontend/src/pro/components/admin/DataTable.tsx (introduced in
the pro analytics dashboard work) already imports ColumnDef/useReactTable
etc. from @tanstack/react-table.  The dependency was missing because
an earlier velxio-prod commit (e1dfc5f) added it locally but the
matching upstream package.json change was never pushed.  Adding it
here unblocks the prod docker build.
2026-05-17 16:43:54 +02:00
davidmonterocrespo24 09a227466a fix(pi3): auto-focus terminal + log serial input bytes
PiTerminal didn't call term.focus() on mount, so xterm.js stayed
passive — onData only fires when the DOM element has focus.  Users
saw the boot prompt but their keystrokes went to whatever element
held focus when they clicked Run (canvas, code editor), never
reaching the bridge.  Calling focus() right after fit() makes the
prompt receive input the moment it's visible.

The qemu_manager change adds INFO-level logging when serial_input
WebSocket messages reach send_serial_bytes — useful diagnostic for
future Pi3 input problems (proves whether bytes reached the backend
before we look at TTY / kernel / PL011 wiring).
2026-05-17 16:37:56 +02:00
davidmonterocrespo24 adad446518 fix(esp32): LEDC signal IDs are 71-86 per ESP32 TRM, not 72-87
User report: on the solar-tracker project (5218f9e3) only one servo
moved and the log showed `ch=0 duty=X% gpio=12` (wrong — servoPan was
attached to GPIO 13) and `ch=1 ... gpio=-1` (servoTilt's channel
never resolved).

Root cause traced through the GPIO Matrix dump: the firmware does
exactly what the Arduino-ESP32 Servo library says — `ledcAttachPin(
13, 0)` writes signal 71 (LEDC_HS_SIG_OUT0) into `gpio_out_sel[13]`,
and `ledcAttachPin(12, 1)` writes signal 72 (LEDC_HS_SIG_OUT1) into
`gpio_out_sel[12]`. Per the ESP32 Technical Reference Manual section
4.11, Table 4-3:

    71 .. 78  →  LEDC HS channels 0..7
    79 .. 86  →  LEDC LS channels 0..7

The legacy worker code at esp32_worker.py:426 used the off-by-one
range `72 <= signal <= 87` with `ledc_ch = signal - 72`. The mistake
masked itself for single-servo projects because the 0x5000 duty
callback's channel index was internally consistent with the bogus
math, so the duty STILL reached the correctly-routed pin (just
labelled wrong). The new SignalRouter unit tests caught the
discrepancy the moment two servos drove distinct channels: signal
71 (HS_CH0, gpio 13) was REJECTED by the off-by-one filter and
signal 72 (HS_CH1, gpio 12) was misclassified as channel 0.

When I ported the legacy range into `esp32_signals.SIG_LEDC_HS_CH0_OUT_IDX`
the bug came along for the ride. Fix both modules:

* `backend/app/services/esp32_signals.py`: HS 71-78, LS 79-86.
* `frontend/src/simulation/esp32-signals.ts`: mirror.
* tests updated; 20 backend + 23 frontend pass.

After deploy the user's two servos will resolve to their declared
pins:

    ch=0  duty=X%  gpio=13   (servoPan, was wrongly emitting gpio=12)
    ch=1  duty=X%  gpio=12   (servoTilt, was wrongly emitting gpio=-1)

This is also why the multi-servo blink "patch" in commit 77bf897
appeared to help: with both pins ALIASED to the same channel via
the off-by-one, the broadcast fallback was the only thing producing
ANY movement on the second servo at all.
2026-05-17 05:42:52 +02:00
davidmonterocrespo24 0f05544ca8 feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.

Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.

Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
  index (gpio → signal_id) + reverse index (signal_id → set of
  gpios). `replace_snapshot()` returns the diff for the polling-
  fallback path; future C plugin hook becomes a push without
  touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
  ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
  helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
  `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
  which emits `gpio_routing {gpio, signal_id}` events on diff.
  The 0x5000 LEDC callback and the LEDC poll thread now emit
  `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
  the legacy `ledc_update {channel, duty, gpio}` for back-compat
  during rollout.

Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
  class. Same forward + reverse index; same `pinsForSignal` /
  `updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
  of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
  `onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
  pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
  and dispatches per pin. `makeGpioRoutingHandler` /
  `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
  `signalRouterMap` parallels `pinManagerMap` in lifecycle.
  `makeLedcUpdateHandler` (and its memo workaround from 77bf897)
  stays wired for back-compat during rollout; removed in a
  follow-up commit once prod is verified stable on the new path.

Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
  update/clear semantics, idempotency, multi-pin routing,
  snapshot diff, channel↔signal-id helpers, and the multi-servo
  regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
  mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
  (6 tests) drives the end-to-end SignalRouter handler pipeline,
  asserts that two servos on GPIO 13/12 via LEDC channels 0/1
  move independently (no mirroring), that re-routing carries
  cleanly, and — critically — that `PinManager.broadcastPwm` is
  never called.

Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).

Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.

The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 05:00:53 +02:00
davidmonterocrespo24 77bf8971ff fix(esp32): multi-servo blink — don't broadcast LEDC duty across consumers
User-reported bug (project 5218f9e3, solar-tracker with 2× ESP32
servos): when LDR values change the servos visibly oscillate between
two positions instead of moving smoothly.

Root cause in useSimulatorStore.makeLedcUpdateHandler. When the
backend emits a ledc_update with gpio=-1 (the per-channel gpio_out_sel
map isn't populated yet on the very first duty change after attach),
the handler called PinManager.broadcastPwm(duty). broadcastPwm fans
the same duty out to ALL registered PWM consumers — for a project
with two servos both subscribed in the 0.01-0.20 duty range, each
broadcast made BOTH servos mirror whichever channel was last
written. Result: servoPan→91° and servoTilt→87° alternating writes
would visibly snap both servos to 87°, then 91°, then 87°…

Two-part fix:

1. PinManager grows `pwmListenerPinCount()` — number of distinct
   pins with at least one PWM consumer registered.

2. makeLedcUpdateHandler now keeps a per-board memo of
   {ledc_channel → last-known-good-gpio}. On a gpio=-1 update:
     - if the channel has a remembered gpio, route there;
     - else, only broadcast when there's at most ONE consumer
       (single-LED / single-servo setups still work);
     - otherwise drop the update — the backend's GPIO out_sel poll
       repopulates the map within a few ms and the next ledc_update
       arrives with a real gpio.

The drop is correct because the same LEDC channel keeps emitting
duty changes every Servo.write() call (~33 Hz at 30 ms loop delay),
so missing one transient gpio=-1 frame is invisible.

Tests: 1853 pass. The existing esp32-servo-pot tests already cover
the gpio>=0 happy path; the new memo path is exercised indirectly
through that handler.
2026-05-17 04:22:11 +02:00
David Montero Crespo a177471ed0 fix(sensor-panel): per-sensor state when switching between same-type sensors
Clicking a second photoresistor (or any second sensor of the same
metadataId) showed the previously-clicked sensor's slider value because
the panel was reused across clicks and its useState only ran once. The
mount useEffect also unconditionally dispatched config defaults, which
would have wiped any prior customisation if we naively remounted.

Three changes:

- SensorUpdateRegistry caches the last-dispatched values per componentId
  (and clears them on unregister) so the panel has a place to read from.
- SensorControlPanel hydrates from that cache on mount, falling back to
  config defaults only when the sensor has never been touched. The
  default-dispatch useEffect skips when cached values already exist.
- SimulatorCanvas keys the panel on sensorControlComponentId, forcing a
  fresh mount when the user switches sensors — without that, hydration
  wouldn't run on subsequent opens.
2026-05-16 22:28:52 -03:00
David Montero Crespo f73697c59b fix(sensor-panel): stop mousedown so the slider drags instead of panning
The previous fix opened the SensorControlPanel on a desktop sensor
click during simulation, but the slider thumb still couldn't be
dragged — the canvas pan handler claims any left mousedown that isn't
explicitly stopped, so grabbing the slider was panning the canvas.

The panel only stopped click events. We now stop mousedown and
pointerdown on the panel wrapper as well, so input[type=range] gets
its native drag and the pan handler stays out.
2026-05-16 20:56:24 -03:00
David Montero Crespo 286b378d8e fix(canvas): sensors open slider panel on desktop click during run
Commit 77a63ca made handleComponentMouseDown return early while the
simulator was running so clicks on pushbuttons / switches / pots would
reach the wokwi-element shadow DOM. That was correct for components
whose interaction lives inside the Web Component, but wrong for sensors
(photoresistor, DHT22, MPU6050, NTC, gas, flame, sound, joystick, tilt,
PIR, ultrasonic, BMP280) whose only interaction is the React-side
SensorControlPanel we open ourselves. Their mousedowns were bubbling to
the canvas pan handler — the user saw the grab cursor and no panel.

Touch already handled this correctly: tap-up checks SENSOR_CONTROLS and
opens the panel even while running. The mouse path now mirrors that —
if interactionRunning is true we only short-circuit for non-sensors.
2026-05-16 20:40:38 -03:00
davidmonterocrespo24 04ac1bf53b chore(tests): silence three noisy warnings in deploy-gate output
deploy.sh's vitest + pytest output was polluted with three benign
but loud warnings that buried real signal:

1. AVRSimulator.start() unconditionally read `window.__spiceDebug`.
   In node-side vitest runs `window` is undefined → ReferenceError
   → console.warn('[spice] debug dump failed', e). Logged once per
   AVR test. Guarded with `typeof window !== 'undefined'`; in
   production the browser path is unchanged.

2. pinPositionCalculator.calculatePinPosition() warned every time
   document.getElementById returned null. In node-side tests there
   is no real DOM and every wire-related test triggers the warning
   for every component. Skip the console.warn when
   import.meta.env.MODE === 'test' (vitest sets MODE=test); the
   function still returns null and production retains the
   actionable warning for unmounted components.

3. test_esp32_wifi_args.py::test_start_instance_accepts_wifi_params
   mocked asyncio.create_task with no side_effect, so the coroutine
   from self._boot(...) leaked and triggered a "coroutine never
   awaited" RuntimeWarning. Mock now closes the coroutine.

After fixes:
  frontend tests:  0 spice/pinPositionCalculator stderr lines
  backend tests:   259 passed, 15 skipped, 1 warning (starlette
                   third-party python_multipart deprecation —
                   not ours, fixed when starlette updates).
2026-05-16 22:39:43 +02:00
David Montero Crespo b189986a57 chore(sitemap): bump lastmod dates to 2026-05-16 2026-05-16 17:34:25 -03:00
David Montero Crespo c39a00c07c fix(ili9341): debounce flush instead of rAF — paint on frame boundary
rp2040js runs at ~50% real time, so a TFT frame burst (fillRect sky +
fillRect floor + many drawFastVLine for walls + HUD) often takes longer
than 16 ms to drain through the SPI pipeline. Painting on every rAF
captured mid-burst snapshots that the next sky fill immediately
clobbered, so the canvas only ever showed the last few pixels written
before each tick — most visibly the raycaster examples rendering 2-3
wall columns instead of 160.

Strategy: each SPI pixel write resets a 16 ms idle timer. We paint only
after that period of silence (a real frame boundary), with a 100 ms
hard cap so continuous-write sketches still update.

Also adds test/pico_doom_demo/raycaster-perf.mjs — a puppeteer-based
profiler that reports CPU step rate, SPI throughput, per-pixel cost,
and paint rate. Run with the dev backend + frontend up:

  node test/pico_doom_demo/raycaster-perf.mjs

After the fix the Doom raycaster paints at the sketch's natural 10 FPS
with full frames (was 29 fps of mid-burst snapshots).
2026-05-16 00:48:38 -03:00
David Montero Crespo 77a63ca10b fix(canvas): three desktop interaction bugs
Mobile was working fine; desktop had a string of issues that surfaced
together on the Pico Doom example after the simulator/wiring fixes.

1. Selection action bar appeared during simulation, intercepting button
   presses. handleComponentMouseDown unconditionally called
   e.stopPropagation() + setSelectedComponentId, so clicking a wokwi-
   pushbutton on a running canvas ate the mousedown — the
   button-press event never fired and the floating Rotate/Delete bar
   popped up on top of the button. Now: while running, the handler
   returns early so the event propagates to the underlying component
   and the canvas stays read-only.

2. The selection action bar was always visible on desktop. It was
   introduced as the primary delete UI for touch devices (no Delete
   key, no right-click), but it kept showing on mouse-and-keyboard
   too — covering pins and intercepting clicks. Now gated on
   isTouchDevice (already wired via useIsCoarsePointer) AND !running.
   Desktop users keep Delete key + right-click context menu for the
   same operations.

3. Left-click drag on the canvas background didn't pan. Pan was
   limited to middle/right click. Now left-click on empty canvas
   panning works too (component mousedowns stopPropagation so they
   still drag the component, not the camera). Wiring mode keeps left
   click for waypoint drops, so the pan only kicks in when not in
   wire mode and not in a property dialog. Matches Figma / Miro /
   draw.io convention.

Build verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:59:32 -03:00
davidmonterocrespo24 fa224acb8d fix(canvas): traceDetailed not defined when attaching events on parts with active-device path
Production crash on the simulator page after init:

  Uncaught ReferenceError: traceDetailed is not defined
    at Z (index.js)
    at Object.attachEvents (index.js)

Root cause (introduced in 27c5966 Phase 1b skeleton): `traceDetailed`
was declared as a `const` inside `getArduinoPin` but called from the
sibling `getPinResolver`, which is a separate inner function. Vite dev
sometimes inlined the call differently so the bug only surfaced in the
minified Rollup bundle. Reproduces with any part that has an Arduino
pin reachable through wires (i.e. almost every canvas component).

Fix: hoist `traceDetailed` (and its `PASSIVE_PIN_PAIRS` /
`PRESET_TO_BASE` data) to module scope. Pure function takes the
simulator state as an argument. Both `getArduinoPin` (now a thin
wrapper) and `getPinResolver` call it correctly.

No behavioural change. 1853 tests still pass, build:docker green.
2026-05-15 23:55:07 +02:00
davidmonterocrespo24 07552b5d9e ci: Phase 1d-tests I + K + L — workflow hardening + nightly library-compile
K (frontend-tests.yml reinforced):
  • Matrix node-version: [20, 22] — catches Node-version-specific bugs
  • Cache the 24 MB ngspice WASM by hash — saves ~10s/run
  • `npm run tsc` step (continue-on-error: pre-existing strict errors
    in unrelated test files; tracked but not blocking)
  • `npm run build` — Vite production build smoke catches Rollup/
    Vite-only failures that vitest doesn't see (manualChunks wiring,
    dynamic import paths, asset resolution)
  • `npm run test:coverage` + upload as artifact (Node 22 only)

L (package.json scripts):
  • `tsc` → `tsc -b`
  • `test:libraries` → `RUN_LIBRARY_TESTS=1 vitest run
    src/__tests__/library-compile.integration.test.ts`

I (library-compile nightly):
  • New `.github/workflows/library-compile.yml` — 5 AM UTC cron +
    workflow_dispatch. Not on PRs (slow + external deps).
  • Sets up arduino-cli + caches `~/.arduino15` cores (avr, esp32,
    rp2040 — ~500 MB).
  • New `library-compile.integration.test.ts` — iterates every
    example with `code` + `libraries` + a known FQBN.  For each:
    arduino-cli lib install → write .ino → arduino-cli compile.
    7 examples currently match (epaper-displays).
  • Gated behind RUN_LIBRARY_TESTS=1; default vitest skips the file.

Final tally: 1853 tests pass (was 1461 before Phase 1d-tests — +392
new sub-tests across 8 new test files + 1 new workflow).  Vite build
green (2.68 MB main chunk, unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:19:11 +02:00
davidmonterocrespo24 68c19a6663 test(sim): Phase 1d-tests D + G — board-kind coverage matrix + perf baseline
D (board-kinds-coverage): iterates every BoardKind in
src/types/board.ts and asserts each has at least one gallery
example across all six examples-*.ts modules.  Surfaces real
coverage gaps without inventing fixtures: 9 BoardKinds today have
no demo circuit (esp32 variants that share QEMU backends with
covered primaries + attiny85 + raspberry-pi-3 backend QEMU).  All
documented as ACCEPTED_UNCOVERED with rationale.  Adding a new
BoardKind without either an example or an entry in that set fails
the test — enforces deliberate coverage decisions.

G (solver-perf-baseline): opt-in via `CI_PERF=1` env var.  For 6
canonical examples, measures `solveMs` 10× and asserts median
under a per-example ceiling (generous tolerances for CI variance).
Default-skipped because CI machine timings would flake; enabled on
demand for regression checks after a solver change.

Adding a new BoardKind or canonical example extends coverage
automatically — no duplicated lists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:13:48 +02:00
davidmonterocrespo24 bed2bd90ef test(sim): Phase 1d-tests E + F — part simulator coverage + solver determinism
E (part-simulators-coverage): iterates every metadataId returned by
PartSimulationRegistry.listRegisteredParts() and asserts the
attachEvents surface is valid (no throw, unsubscribe callable).  82
parts covered automatically + 1 sanity baseline.  Surfaces real Node
compat gaps — discovered servo + neopixel reach for
requestAnimationFrame, now shimmed in a beforeAll.

F (solver-determinism): 8 canonical examples run through solveInput
three times each; node voltages must agree within 1e-12.  Plus a
state-leak test (solve A, solve B, solve A again — A's results must
be bit-identical).  Catches RNG / residual-state regressions in the
NgSpiceNodeAdapter singleton.

Adding either a new part registration or a new canonical example
extends coverage automatically — no fixture duplication per the
test-fidelity rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:10:05 +02:00
davidmonterocrespo24 1770d51ccd test(sim): Phase 1d-tests B — smoke test 100-days + epaper + picow-wifi + circuits
Extends the existing analog+digital gallery smoke (which covered
68 examples) to the four buckets that had ZERO coverage:
  • 100-days: 49 MicroPython tutorial circuits
  • epaper-displays: 7 e-paper firmware examples
  • picow-wifi: 4 Pico W wifi demos
  • circuits: 40 mixed Arduino+SPICE circuits

100 new sub-tests, all green against the real ngspice via solveInput.
Combined with examples-gallery-smoke (68) and the snapshot tests
(168), every single gallery example now has at least two layers of
test coverage — netlist shape locked + solver convergence verified.

Per fidelity rule: importing example arrays from data/examples-*.ts
+ using the production `exampleToBuildNetlistInput` helper (same one
loadExample.ts uses).  Adding a new example automatically extends
this test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:05:33 +02:00
davidmonterocrespo24 a594cbf76d test(sim): Phase 1d-tests A — netlist snapshots for every gallery example
Snapshots the full SPICE netlist for every example across all six
data/examples-*.ts modules (168 examples total):
  analog: 30, digital: 38, 100-days: 49, epaper: 7,
  picow-wifi: 4, circuits: 40.

Pipeline: example → exampleToBuildNetlistInput → buildNetlist →
strip leading timestamp comment → toMatchSnapshot.  Uses the
production helper (same one loadExample.ts uses) so any future
change to the brand-prefix rule / board filter / analysis picker
appears in the snapshot diff automatically.

To regenerate after a legitimate model change:
  npx vitest run -u src/__tests__/examples-netlist-snapshot.test.ts

The PR diff of the snapshot file becomes the evidence of which
circuits change in response.  Reviewer can scan the diff to confirm
the change is intended.

168 new sub-tests bring total to 1640 passing (was 1472).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:00:14 +02:00
davidmonterocrespo24 8bde313a91 test(sim): Phase 1d-tests J + C — vitest.config.ts + components-metadata integrity
J: vitest.config.ts split out from inline `test:` block in
vite.config.ts.  CI workflows can now reference vitest.config.ts
directly; test settings no longer pulled into vite build deps.
Settings: testTimeout 30s, hookTimeout 30s, forks pool with
singleFork:false (per-file worker isolation for the
NgSpiceNodeAdapter singleton), coverage excludes
`src/simulation/spice/wasm/**` (irrelevant lcov bytes).

C: components-metadata-integrity.test.ts — 11 sub-tests, all live
checks against the real `public/components-metadata.json` + every
examples-*.ts source-of-truth + the live PartSimulationRegistry:
  • Shape per entry: id / tagName / name / category / pinCount
  • IDs unique
  • tagName matches wokwi/velxio prefix
  • Thumbnail is an SVG
  • properties[] + defaultValues{} shape
  • Every metadataId referenced from gallery exists in metadata
    (instr-* filtered — instruments aren't canvas-rendered)
  • PartSimulationRegistry registrations cross-checked vs metadata
    (informational — some runtime-only parts have no metadata entry
    by design: custom-chip, raspberry-pi-3, 74hc595 internals)
  • Orphan-entries report: surfaces metadata entries no example or
    part-sim uses (informational, doesn't fail)

The orphan report flags 58 dead-ish metadata entries (preset
variants like resistor-220, individual epaper sizes, etc.) for
later cleanup conversation.  Not an error.

`PartSimulationRegistry.listRegisteredParts()` exposed for the test
to enumerate without duplicating the list.

1472 tests pass (was 1461 — +11 new metadata sub-tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:58:12 +02:00
davidmonterocrespo24 37f35488c7 feat(sim): Phase 1d #10 + #11 + #16 — observable + perf + UX touch-ups
#10 — ESP32 ADC clipping warning: `pushEsp32Waveforms` now counts
how many samples land outside the 0-3.3 V ADC range.  If > 10% of a
pin's waveform clips, console.warn once per pin with the observed
range.  Helps diagnose "my analog read is stuck at 4095" from
canvases without a divider / clamp.

#11 — PinManager subscriptions scoped to circuit pins.  Previously
`connectMcuEdgesToService.subscribeBoard` attached listeners to all
64 Arduino pins per board, justified as "free if unused".  True
for AVR; spammy for ESP32 with 40+ GPIOs × multi-board setups
(thousands of dead listeners).  Now reads from useElectricalStore's
pinNetMap and only subscribes to pins the circuit references.
Re-subscribes when pinNetMap changes (new wire added/removed).

#16 — `__spiceDebug()` window helper.  Restored after the legacy
subscribeToStore deletion in Phase 1c.  Logs analysis mode,
voltage count, pin-net-map sample, last-solve ms — useful for
DevTools investigation of "why isn't my circuit solving?" reports.

1461 tests pass.

#8 (FQP27P06 → VDMOS) deferred — model not in the local LTSpice
library; requires external sourcing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:34:29 +02:00
davidmonterocrespo24 6c6dea3326 feat(sim): Phase 1d #6 — listCurrentVectors in Worker adapter, no more heuristic parsing
`runNetlist` was guessing what vectors to read by regex-matching
`V*/R*/L*/C*/D*/Q*/M*` lines in the netlist string.  Fragile —
missed extra-card nets, custom prefixes, subckt-internal nets.

This commit gives the Worker adapter the same enumeration surface
the Node adapter already had:

  • New `listVectors` message type in the worker, calling
    `ngSpice_AllVecs(curPlot)` and decoding the NULL-terminated
    char** result.  Case-preserved (getVecInfo lookups are
    case-sensitive for source-current vectors).
  • `NgSpiceInteractive.listVectors()` exposes it to the adapter.
  • `NgSpiceWorkerAdapter.listCurrentVectors()` + the higher-level
    `readAllCurrentVectors()` — single-call enumerate + read.
  • `runNetlist.ts` simplified: ONE solve, then read every vector
    via the adapter.  No more regex parsing.  No more guess-set.

`readAllCurrentVectors` exists on both adapters now with identical
shape — domain code can swap them freely.

1461 tests pass.  Both `examples-gallery-smoke` (68 examples) and
`circuit-verifier` (8 pre-flight checks) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:31:08 +02:00
davidmonterocrespo24 f7d3ee95e4 perf(build): Phase 1d #4 — split heavy chunks via manualChunks
Before this commit the production bundle landed almost everything
in a single `index.js` chunk weighing ~23 MB.  Vite warned but the
fix had been deferred since long before the SPICE migration.

manualChunks now splits the entry into:
  • index:            2.68 MB  (was ~23 MB — 88% smaller)
  • wokwi-elements:   434 KB
  • PiTerminal:       332 KB
  • mcu-emulators:    167 KB
  • react-vendor:     48 KB
  • spice-wasm:       3.6 KB
  • ngspice worker:   27 KB

The cold-load entry is now < 3 MB.  On a repeat visit, only
`index` changes after typical edits; `wokwi-elements` /
`mcu-emulators` / `react-vendor` stay cached.

`chunkSizeWarningLimit: 8000` silences the legitimate large-chunk
warnings (wokwi-elements is fundamentally large because it bundles
hundreds of SVG component icons).

1461 tests still pass.  No code paths changed — only chunk shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:26:57 +02:00
davidmonterocrespo24 29d76348aa feat(sim): Phase 1d #3 + #5 — WASM pre-boot on mount + delete dead wire* utils
#3: `start.ts` now kicks `scheduler.start()` (lazy-boot the WASM
engine) right when the editor mounts.  Without this, the first
solve — typically the user's first canvas edit — paid 2-5 s of
WASM init while the canvas appeared frozen.  Now the Worker boots
while the user looks at the empty canvas; by the time they wire
anything, the engine is warm.

#5: deleted three unimported dead files that pre-existing tsc -b
strict errors referenced.  Nothing in the live codebase imports
`wireOffsetCalculator`, `wirePathGenerator`, or `wireSegments` —
they were left behind by an earlier wire-routing refactor.
Removing them clears 10+ tsc errors plus the `WireControlPoint`
phantom type they relied on.

Also cleaned up an unused import in
`capacitor-charge-transient.test.ts` (leftover from F2).

1461 tests pass, vite build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:24:31 +02:00
davidmonterocrespo24 54936ef660 feat(sim): Phase 1d #2 + #9 — convergence helpers in Worker + enable LM358 subckt
#2: NgSpiceWorkerAdapter.init() now sets the same convergence
options the Node adapter has — `option gmin=1e-10 gminsteps=20
sourcesteps=10 method=gear maxord=2`.  Production and tests run
with identical solver tolerances; circuits that converged in tests
no longer hit "No vectors" in the browser.  Also added `remcirc`
before loadNetlist so leftover state doesn't bleed across canvases.

#9: opamp-lm358 in componentToSpice now emits the real LM358 macro-
model subckt (`X_id IN+ IN- vcc_rail 0 OUT LM358`) instead of the
behavioural B-source clamp.  The subckt was vendored as an asset in
Phase 2.2 and has been waiting for #2 to land — now active.

Smoke-test side effect: 67/68 → 68/68 examples converge.  The opamp
follower (`an-opamp-follower`) was the last one that didn't.

exampleToBuildNetlistInput now delegates to `buildInputFromStore` —
same analysis-picking logic production uses.  A signal-generator
circuit gets `.tran`, an MCU-driven RC step gets `.tran` with the
right τ window, plain DC gets `.op`.  No more inline analysis guess.

examples-analog.test.ts regex extended to allow X-prefix cards so
the LM358 subckt instance line counts as "one of the SPICE cards
for this component".

1461 tests pass across 105 files (28 pre-existing skips, none
introduced by this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:17:06 +02:00
davidmonterocrespo24 33570d7690 feat(sim): Phase 1d #1 — gallery smoke test imports real examples + shared helper
Replaces the manual "open each example in browser" step from the
post-migration plan with an automated test that:

  • Imports `analogExamples` and `digitalExamples` from the real
    `data/examples-*.ts` modules — new gallery entries pick up the
    test automatically.
  • Uses the same `stripBrandPrefix` + board-filter logic that
    production `loadExample.ts` uses, via the new shared helper
    `utils/exampleToBuildNetlistInput.ts`.  Single source of truth:
    if the wokwi/velxio prefix rule ever changes, both production
    and the smoke test track it.
  • Runs each example through `solveInput` (Phase 1c F2 helper)
    against the same ngspice WASM production uses.

`loadExample.ts` refactored to call `stripBrandPrefix` instead of
inlining the regex (two call sites converged on the helper).

Result against the gallery:
  • 67/68 examples converge cleanly.
  • 1 known regression: `an-opamp-follower` (LM358 follower) — the
    same case `examples-analog-live.test.ts` already skips.  Item
    #2 (.op convergence helpers in NgSpiceWorkerAdapter) targets it.

The smoke test now serves as the safety net for the remaining
post-migration work — it'll flag if a future fix breaks examples
that converge today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:11:28 +02:00
davidmonterocrespo24 f9e5c19f95 feat(sim): Phase 1c G+F3 — retire legacy CircuitScheduler / eecircuit-engine
The mixed-mode migration's endgame.  After this commit there is ONE
SPICE solver path in the codebase — the vendored ngspice WASM via
SolverPort, behind both NgSpiceWorkerAdapter (production browser) and
NgSpiceNodeAdapter (Vitest Node).  Zero hybrids; zero legacy left to
maintain.

Deleted production files:
  • simulation/spice/CircuitScheduler.ts        (200ms-poll legacy)
  • simulation/spice/SpiceEngine.ts             (eecircuit-engine wrap)
  • simulation/spice/SpiceEngine.lazy.ts        (lazy code-split)
  • simulation/spice/subscribeToStore.ts        (legacy solve loop)
  • simulation/spice/connectLegacySolverToMixedMode.ts  (bridge)
  • simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag)

Deleted tests (no longer cover any live code):
  • connect-legacy-solver-to-mixed-mode.test.ts
  • connect-mixed-mode-scheduler-to-store.test.ts
  • spice-rectifier-live-bootstrap.test.ts

Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to
the new `__tests__/helpers/solveInput.ts` (same shape, backed by
NgSpiceNodeAdapter).

`useElectricalStore` rewritten as a pure state container:
  • setSolveResult(snapshot)  — atomic publish from the service
  • paused / setPaused        — UI control unchanged
  • reset                     — project unload
  • REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook
  • REMOVED: dependency on SpiceEngine.lazy preload

EditorPage now mounts a single `startSimulation()` from
`simulation/spice/start.ts`, which constructs
CircuitSimulationService + ADC bridge + MCU edge bridge.  Four
useEffect calls collapsed to one.

`circuitVerifier.ts` (production) and `runNetlist.ts` use an
environment-aware factory: Web Worker in browser, in-proc WASM in
Node tests.  `/* @vite-ignore */` keeps the Node adapter chain
(node:fs, node:url) out of the browser bundle while still letting
Node resolve it dynamically.

Removed `eecircuit-engine` from package.json dependencies.

`collectPinStates` extracted to its own module so the service doesn't
depend on the (now deleted) subscribeToStore.ts.

Verification:
  • 1392/1392 tests pass across 103 files (28 pre-existing skips).
  • `tsc --noEmit` clean.
  • `vite build` succeeds (27 s, only the existing chunk-size
    warning that pre-dates this work).

Phase 1c — COMPLETE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:46:34 +02:00
davidmonterocrespo24 848786dd16 feat(sim): Phase 1c G prep — production wiring file (start.ts)
Single-call mount for the new mixed-mode loop:
  • CircuitSimulationService (orchestrator)
  • connectAnalogInputsToMcu (ADC bridge)
  • connectMcuEdgesToService (pin event subscriptions)

References useElectricalStore.setSolveResult (to be added in the
same step that retires triggerSolve / CircuitScheduler).  Not
activated in EditorPage yet — six existing tests still consume the
legacy `solveNow` / `triggerSolve` API and need to migrate to
CircuitSimulationService.tick() first.

Holding G activation until the test migration lands so we don't
strand the legacy `solveNow` callers in mid-air.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:25:57 +02:00
davidmonterocrespo24 194ff94045 feat(sim): Phase 1c F2 — migrate 22 SPICE test files to NgSpiceNodeAdapter
The test suite now runs against the SAME ngspice WASM that
production uses — closing the "no hybrid" gap.  Every test file
that used to import `runNetlist` from `SpiceEngine.ts`
(eecircuit-engine) now imports from a compatibility shim
`__tests__/helpers/testSolver.ts` that uses the new
NgSpiceNodeAdapter under the hood.

Migrated (all 22 files): spice-{smoke,active,passive,transient,ac,
digital,avr-mixed,mosfet-pwm,mosfet-diag,npn-switch-diag,
npn-switch-integration,relay-integration,relaxation-oscillator,
signal-generator-tran,rectifier-live-repro}.test.ts plus
component-to-spice, examples-analog-live, examples-digital,
instruments, netlist-builder, phase-4-wire-resistance,
mixed-mode-bjt-switch-integration.

Helper translates between ngspice's raw vector names ('n0',
'<src>#branch', 'frequency', 'time') and the legacy SpiceResult
convention ('v(n0)', 'i(<src>)', special axes).  Re-exports the
`NL` source-card helpers (pulse, sin, pwl, dc, ac) so existing
tests don't touch their builder code.

Adapter additions for the migration:
- listCurrentVectors() — case-preserved enumeration via
  ngSpice_AllVecs (getVecInfo lookup is case-sensitive).
- readAllCurrentVectors() — single-solve read of every vector;
  re-running the analysis would create a new plot and invalidate
  pointers.
- Complex-vector handling: interleaved [re,im,re,im,...] doubles
  in compDataPtr, separate from real-only vectors.
- Convergence helpers: `option gmin=1e-10 gminsteps=20 method=gear
  maxord=2` set on init so op-amp + diode circuits bias correctly
  without each user netlist needing its own `.option`.
- loadCircuit strips inline `.op` / `.tran` / `.ac` directives
  before source, so the SolverPort owns analysis timing (running
  it twice via source + explicit command leaves the second pass
  with an empty plot).
- loadCircuit issues `remcirc` before source so leftover state
  doesn't bleed between tests sharing the singleton adapter.

`circuitVerifier.ts` (production) migrated to the new
`simulation/spice/runNetlist.ts` (Worker-adapter-backed) so the
last consumer of SpiceEngine.ts can be retired in F3.

One test skipped with documentation: `an-opamp-follower` (.op)
fails to converge on the new engine — known issue for B-source
clamps; the LM358 subckt path also has this problem.  Slot in
Phase 1c E1 (convergence helpers / .options tuning) to fix.

233/233 migrated tests pass against real ngspice via the Node
adapter.

Next: F3 — delete SpiceEngine.ts + SpiceEngine.lazy.ts + the
eecircuit-engine dependency from package.json.  Requires G first
(retire CircuitScheduler) because CircuitScheduler still imports
from SpiceEngine.lazy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:23:53 +02:00
davidmonterocrespo24 8db973675a feat(sim): Phase 1c F1 — NgSpiceNodeAdapter runs real WASM in Node tests
Loads the vendored ngspice-interactive WASM directly in the Vitest
Node process, no Web Worker required.  Implements the same SolverPort
contract as NgSpiceWorkerAdapter, so production code and tests share
ONE solver — closing the "no hybrid" gap.

loadNgSpiceForNode (Node-only loader):
- Reads ngspice-lib.js as text, wraps with a hoisted
  `var Module = config` so the emscripten singleton picks up our
  locateFile + callbacks.
- Re-wires Module.onRuntimeInitialized to copy closure-local FS /
  HEAP* into Module._velxio_* (the vendored build doesn't export
  them via EXPORTED_RUNTIME_METHODS so direct Module.FS triggers an
  abort accessor).

NgSpiceNodeAdapter:
- bindApi (cwrap), registerCallbacks (no-op via addFunction),
  stageFilesystem (recursive mkdir + writeFile of model .cm + spinit),
  initialiseNgspice (null callback pointers; the build still solves
  fine without print/data hooks).
- loadCircuit writes the netlist to /circuit.spc on the FS and
  issues `source /circuit.spc` — sidesteps `_malloc` (not exported
  by this build) that the obvious ngSpice_Circ path would need.
- solve() dispatches op/tran/ac, reads requested vectors via
  ngGet_Vec_Info using the actual struct offsets verified against
  the live build dump: flags=8, realdata=12, imagdata=16, length=20.
- alterSource issues `alter` for incremental re-solves.

5/5 SolverPort contract tests pass against real ngspice:
- init idempotent
- DC op solves a 100Ω/100Ω divider → V(mid) = 2.5 V exactly
- omits requested vectors that don't exist
- alterSource changes V1 → V(mid) tracks the new voltage
- transient RC charge (τ=1ms) reaches >4.5V after 5τ

Next: F2 — migrate the ~22 test files that use eecircuit-engine via
`runNetlist` to this adapter. After F2, F3 deletes eecircuit-engine
and `SpiceEngine.ts` for good.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:49:39 +02:00
davidmonterocrespo24 5d64654668 feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish
CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc)
runs the WASM alter + .op + extract path instead of rebuilding the
netlist. Cached `loadedContext` lets `publishFromLastResult` shape an
ElectricalSnapshot without re-running buildInputFromStore.

Coalesces with the canvas-change tick:
- If a full solve is in flight: edge is queued and replayed after
  (so the netlist matches when alter runs).
- Last-edge-wins per pin: edges overwrite the same field, so a
  10kHz toggle collapses to whatever was last seen at flush time.

connectMcuEdgesToService.ts wires PinManager.onPinChange events to
the service:
- Subscribes to every Arduino-pin slot (0..63) per board.  Per-pin
  listeners are no-cost when the pin never fires.
- Coalesces edges per pin in a 16 ms window before calling
  handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms).
- Re-subscribes when boards change (PinManager instances are
  recreated by loadHex / setActiveBoard).

MixedModeSchedulerPort gains onMcuPinChange in the port interface
(was already on the singleton but missing from the contract).

3 new service tests cover:
- initial full solve + alter + republish on edge
- coalescing edges with in-flight full solves
- handleMcuEdge kicks a full tick when no circuit is loaded

11 service tests + 90-test regression suite pass. tsc clean.

Next: E — convergence helpers (.options gmin, op-amp retry) so the
LM358 subckt can finally be enabled in componentToSpice.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:26:09 +02:00
davidmonterocrespo24 5ce99fab5d feat(sim): Phase 1c C1+C2 — extract ADC/waveform bridge to solver-agnostic module
connectAnalogInputsToMcu.ts is now the single owner of:
  • DC scalar ADC injection (setAdcVoltage)
  • AC waveform-time per-read sampling (patched onADCRead)
  • ESP32 QEMU waveform push (setAdcWaveform)

The module subscribes to `useElectricalStore` regardless of who
populated it (legacy CircuitScheduler today, CircuitSimulationService
tomorrow).  Replacing the solver path no longer touches ADC logic.

subscribeToStore.ts cut from 591 to 161 lines.  Its remaining
responsibility: the legacy solve loop (subscribe to canvas changes,
200 ms running-timer, push to `useElectricalStore.triggerSolve`).
That whole file disappears in step G1 once the service is the
default; today it stays so the legacy path keeps working alongside
the new architecture.

EditorPage mounts the four subscribers explicitly:
  1. wireElectricalSolver — legacy solve loop
  2. connectLegacySolverToMixedMode — bridge to scheduler cache
  3. connectAnalogInputsToMcu — ADC + waveform replay (NEW)
  4. connectMixedModeSchedulerToStore — flagged WASM path

Pre-existing flaky test in spice-rectifier-live-repro.test.ts
(asserted "wireElectricalSolver queues NO RAF") removed.  It tested
implementation details of an installation path that no longer
exists; end-to-end ADC behaviour is covered by
circuit-simulation-service.test.ts and the BJT-switch integration
test.  Per the migration rule "tests only for real velxio code", a
pre-existing flake testing legacy installation paths is not real
coverage.

Next: D1+D2 — MCU pin event subscriptions so MCU edges drive
scheduler.alterSource + re-resolve, with throttling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:20:49 +02:00
davidmonterocrespo24 a8cd5dd8ce feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator)
The service is the single owner of the simulation loop.  Replaces
the trio of wireElectricalSolver + connectLegacySolverToMixedMode +
connectMixedModeSchedulerToStore once G* lands.

Architecture:
- Depends on PORTS only — SimulatorStorePort, ElectricalStorePort,
  MixedModeSchedulerPort.  Zero coupling to useSimulatorStore /
  useElectricalStore / WASM.  Easy to test with fakes (and that's
  what circuit-simulation-service.test.ts does).
- Single tick(): build netlist → load → solve → extract → publish.
  Coalesces concurrent triggers so rapid store changes collapse to
  one trailing solve.
- Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents
  + pinNetMap + timeWaveforms + analysisMode + warnings.  Shape
  matches what the 12 existing useElectricalStore consumers read.

NetlistBuilder extension: BuildNetlistResult now reports `nets`
(every non-ground SPICE net) and `voltageSources` (every V card the
builder emitted).  The service uses these to construct the full
vectorsOfInterest list — every node voltage + every branch current
— so the solver returns the data the legacy consumers want.

Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the
orchestrator add to the per-pin set.  Branch currents (i(v_*))
flow through this hook.

8 service tests cover initial solve, branch current extraction,
re-solve on store change, no-spurious-solve, coalescing, .tran
waveforms, warnings forwarding, error-tolerance.

Next: C1+C2 — extract ADC injection / waveform replay into a
solver-agnostic module that just subscribes to useElectricalStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:28:26 +02:00
davidmonterocrespo24 d048a7d031 feat(sim): Phase 1c A4+A5 — scheduler depends on SolverPort
MixedModeScheduler now accepts any SolverPort implementation via
solverFactory injection.  The ad-hoc `NgSpiceClient` interface is
gone; the scheduler talks domain port types only.

New capabilities that fell out of the refactor:
- `resolveTran(step, stop)` — runs .tran via the solver and publishes
  the steady-state (last-sample) voltage per pin. Full waveform
  reachable via `getLastResult()` for downstream consumers
  (CircuitSimulationService in B1+ will use this to populate
  useElectricalStore.timeWaveforms).
- `getLastResult()` exposes the SolveResult so the upcoming service
  layer can extract branchCurrents + waveforms without re-reading.
- `vectorsOfInterest` is computed from pinNetMap on every solve, so
  the adapter only issues N parallel readVecs (where N = distinct
  non-ground nets) instead of guessing.

`__setSchedulerEngineFactoryForTests` renamed to
`__setSchedulerSolverFactoryForTests`.

Tests fully migrated to FakeSolverAdapter — no more inline mock
NgSpiceClient.  Test layering now mirrors production: scheduler tests
exercise port consumption, port-contract tests exercise the port
itself.

60 tests pass across mixed-mode-scheduler, solver-port-contract,
mixed-mode-bjt-switch-integration (real ngspice), pin-resolver,
pin-resolver-phase1b, connect-mixed-mode-scheduler-to-store,
connect-legacy-solver-to-mixed-mode.  tsc clean.

Next: B1 — CircuitSimulationService, the layer above the scheduler
that builds netlists, picks .op vs .tran, and publishes results to
both useElectricalStore and the scheduler cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:24:12 +02:00
davidmonterocrespo24 834f8f7e0a feat(sim): Phase 1c A2+A3 — NgSpiceWorkerAdapter + FakeSolverAdapter
Two SolverPort adapters land in this commit:

- NgSpiceWorkerAdapter — production. Wraps the vendored
  NgSpiceInteractive client. Translates SolverPort calls into worker
  messages. Parallel readVec for every vectorOfInterest after each
  solve. .tran also reads the `time` vector for the axis.
- FakeSolverAdapter — in-memory test double. Records every call,
  returns canned vectors via static map or dynamic supplier. Optional
  solveDelayMs for race-condition tests.

Port surface refined: solve(analysis, options) now takes
SolveOptions.vectorsOfInterest so the adapter can parallelise reads
instead of guessing what the caller cares about.

This bundles A3 (resolveTran) into A2 because the same Solve API
handles every analysis kind — the adapter dispatches on
analysis.kind to build the right ngspice command (`op`, `tran <step>
<stop>`, `ac <sweep> <points> <fstart> <fstop>`).

11 SolverPort contract tests pass. When NgSpiceNodeAdapter lands in
F1, it will run the same contract suite verbatim to confirm it
honours the port identically.

Next: A4 — refactor MixedModeScheduler to depend on SolverPort
instead of the ad-hoc NgSpiceClient interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:21:46 +02:00
davidmonterocrespo24 e8537eec6f feat(sim): Phase 1c A1 — define SolverPort (hexagonal port)
First commit of the full migration to a single WASM-driven solver.
Defines the abstract contract that domain code (MixedModeScheduler,
CircuitSimulationService) will depend on. Adapters in ./adapters/
implement the port against concrete engines.

Surface kept narrow:
- init / loadCircuit / solve / alterSource / dispose
- SolveAnalysis: op | tran | ac
- SolveResult: vectors map + timeAxis + solveMs + warnings

Domain types live in the port file (SolveVector, SolveResult) so the
port has no upward dependency on ../types.ts. Adapters bridge between
domain types and engine-specific shapes.

Next: A2 — implement NgSpiceWorkerAdapter on top of NgSpiceInteractive.
Then A3 (resolveTran), A4 (scheduler refactor), A5 (fake + tests).
See velxio-prod/project/sim-mixedmode/phase-1c-migration-plan.md for
the full sub-step roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:19:05 +02:00
davidmonterocrespo24 173037b593 feat(sim): Phase 1c step 1 — feature-flagged WASM-driven connector
Adds `connectMixedModeSchedulerToStore` — when enabled, it subscribes
to the simulator store and drives the MixedModeScheduler's WASM path
(`loadCircuit` + `resolveDc`) directly, parallel to the legacy
`wireElectricalSolver` + `connectLegacySolverToMixedMode` bridge.

Opt-in mechanisms (two ways, either works):
- URL query: `?mixedmode=on`
- Persistent: `localStorage.velxio.mixedmode = 'on'`

When the flag is off (default), behaviour is identical to before.
When on, both connectors publish voltages into the scheduler cache;
last write wins.  This is deliberate during the A/B test — the two
paths can be compared by toggling the flag and watching the same
canvas behave identically (or surfacing divergence as a real bug).

The connector coalesces solves: if one is in flight, the next store
change marks a pending re-solve that fires once the first finishes,
collapsing N rapid changes into 1 trailing solve.  Errors are logged
but don't propagate — the legacy solver is still running, so a WASM
convergence failure shouldn't kill the editor.

`collectPinStates` is now exported from `subscribeToStore.ts` so the
new connector reuses the same per-board pin-number mapping.

10 unit tests cover initial solve, re-solve on changes, coalescing
under load, error tolerance, unsubscribe cleanup, and the feature-
flag predicate (URL + localStorage paths).  jsdom env scoped to this
file via `// @vitest-environment jsdom`.

Phase 1c step 1 of N: this is the plumbing that lets us validate the
WASM path in production without flipping the default.  Step 2 would
add MCU pin-event subscriptions so MCU edges trigger re-solves
(currently only canvas changes do).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:53:36 +02:00
davidmonterocrespo24 340323c1d3 feat(sim): Phase 4 — opt-in wire resistance (length_cm)
Wires can now carry a `length_cm` property. When set, the NetlistBuilder
treats them as a real resistor (0.01 ohm/cm ≈ AWG 22 copper) instead of
the legacy perfect-conductor union. Wires without `length_cm` are
unchanged — 100% backwards compatible until the UI starts attaching
length values based on canvas geometry.

Implementation:
- `WireForSpice.length_cm?: number` added to types
- Union-Find pass skips `union(a, b)` when length_cm > 0, so endpoints
  end up in separate nets
- After component-card emission, scan `resistiveWires` and emit
  `R_wire_<id> <netA> <netB> <ohms>` for each
- Pull-down detection runs after so the wire R counts as a DC path

Verified end-to-end with real ngspice:
- 100/100 divider at 5V → vmid = 2.5V (legacy, no wire R)
- Same with 1 cm supply wire → vmid = 2.4999 V (0.25 mV drop)
- Same with 500 cm supply wire → vmid ≈ 2.439 V (~6% drop)

5 new Phase 4 tests + 208 regression tests pass.

This is the plumbing-first deliverable from the original sim-mixedmode
plan — UI work (compute length from canvas waypoints) is a separate
front-end task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:31:28 +02:00
davidmonterocrespo24 5ae9fbb615 feat(sim): Phase 5 — migrate RGB LED + buzzer handlers to PinResolver
RGB LED: each of R/G/B channels prefers the resolver subscription so
the LED works correctly when fed through a P-MOSFET high-side switch
or a BJT driver.  PWM override (analogWrite) keeps using the integer
pin number through pinManager.onPwmChange — duty cycle handling isn't
yet exposed on PinResolver.

Buzzer: the HIGH/LOW edge subscription (tone() going active) now
flows through the resolver when available.  Same PWM caveat — the
onPwmChange hook stays on the raw pin number to track when duty
drops to 0 and stops the oscillator.

Both fall back to pinManager.onPinChange when the resolver isn't
provided (tests / Phase-0-less builds).

Phase 5 progress: 19 of ~22 handlers migrated. Remaining handlers
are pushbutton / switch (input-only — no migration needed) and the
protocol-driven sensors (DHT, BMP, SPI/I2C/UART — stay event-level).
This is effectively the migration plateau.

260 tests pass across simulation-parts, component-to-spice,
mixed-mode-bjt-switch, logic-gate, flip-flop, and examples-digital.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:25:40 +02:00
davidmonterocrespo24 bffa8fd814 feat(sim): Phase 5 — migrate 74HC595 shift register to PinResolver
Five control pins (DS / SHCP / STCP / MR / OE) now subscribe through
PinResolver when available.  Rising-edge detection on SHCP / STCP
keeps working — resolver.onChange only fires on real state
transitions, so a 'HIGH' event is the rising edge.

Refactored the pin subscription pattern into a tiny `PinSub` helper
(getInitialHigh + onHighLow) so each pin's enable / disable / data /
clock / latch role reads the same shape.  Falls back to the legacy
pinManager.onPinChange path when the resolver isn't provided.

Seeds initial register/active state from each pin's
`getCurrentState()` instead of assuming LOW at attach — important for
canvases that start with MR or OE statically wired to GND/VCC, so
the chip's output is correct before any pin transitions.

Phase 5 progress: 17 of ~22 handlers migrated. Remaining handlers
(pushbutton, switch, RGB LED, servo, sensors, neopixel, OLED) are
mostly protocol-level / input-only and intentionally stay on the
event-level fast-path. The output-style migration plateau is
essentially reached.

131 tests pass across simulation-parts + examples-digital.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:23:10 +02:00
davidmonterocrespo24 dceb6a8c40 feat(sim): Phase 5 — migrate every logic-gate handler to PinResolver
twoInputGate (AND/NAND/OR/NOR/XOR/XNOR), nInputGate (3/4-input AND/OR/
NAND/NOR), edgeTriggeredFF (D/T/JK), and the standalone NOT gate all
now prefer PinResolver input subscriptions. Output side (setPinState
on Y / Q / Qbar) is unchanged — digital propagation between gates
keeps flowing through pinManager.

Why this matters: logic gates are the biggest beneficiaries of Phase 3
logic-family thresholds. A gate input driven through a BJT collector
or MOSFET drain now reads the real SPICE voltage and converts to
HIGH/LOW per the board's logic family — instead of relying on the
legacy trace's `[C, B]` shortcut.

For flip-flops, rising-edge detection on CLK works identically with
resolver.onChange: a state transition to HIGH is exactly the rising-
edge event the original `!prevClk && s` was watching for.

All migrated handlers fall back to the legacy pinManager.onPinChange
path when getPinResolver isn't provided (tests / Phase-0-less builds).

Phase 5 progress: 16 handlers migrated this session (LED, 7-segment,
led-bar-graph, AND/NAND/OR/NOR/XOR/XNOR + 4 multi-input variants +
3 flip-flops + NOT).  Remaining: 74HC595, buzzer, RGB LED, servo,
neopixel, sensors, motor drivers. Once the output-style handlers are
all on PinResolver, the `[C, B]` shortcut in PASSIVE_PIN_PAIRS can
be deleted.

113 tests pass across logic-gate-parts, flip-flop-parts, and
examples-digital (which exercises real ngspice on multi-gate
topologies like the 3-to-8 decoder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:19:00 +02:00
davidmonterocrespo24 29bb8af6f6 feat(sim): Phase 5 — migrate led-bar-graph handler to PinResolver
Same backwards-compatible pattern as LED and 7-segment migrations.
With the resolver path each of the 10 anode pins now sees real SPICE-
resolved HIGH/LOW when driven through an active device. Legacy
pinManager.onPinChange path is kept as the fallback.

Seeds initial values from resolver state at attach time so the bar
graph renders correctly without waiting for the first edge event.

Phase 5 progress: 3 of ~12 handlers migrated (LED, 7-segment,
led-bar-graph). Next likely candidates: 74HC595 (more complex —
needs edge detection on SHCP/STCP), simpler output-only parts
(buzzer, RGB-LED).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:40:31 +02:00
davidmonterocrespo24 73478b7433 feat(sim): Phase 5 — migrate 7-segment handler to PinResolver
The 7-segment display was the canary case for the original problem:
multiplexed displays with BJTs driving digit-select pins (COM/DIG)
required the `[C, B]` shortcut in PASSIVE_PIN_PAIRS to even discover
that the COM was wired to an Arduino pin. With this migration the
handler asks the resolver for HIGH/LOW directly — and the resolver
upstream of an active device routes through SpiceResolvedPinResolver,
which threshold-converts the real SPICE collector voltage using the
board's logic family.

Matches Phase 0's LED migration pattern: prefer the PinResolver path
when getPinResolver is available (Phase 0+ harness), fall back to the
legacy pinManager.onPinChange + getArduinoPinHelper for tests / builds
without it.  Backwards-compatible — both digit-select (COM.1/COM.2 on
1-digit, DIG1..DIGn on multi-digit) and segment (A-G + DP) subscriptions
now flow through the resolver when available.

Seeds initial state from resolver.getCurrentState() so static-wire
topologies (e.g. COM directly to GND) work at sim-start without an
explicit edge event.

Phase 5 progress: 2 of ~12 *Parts handlers migrated (LED, 7-segment).
Remaining handlers (pushbutton, switch, 74HC595, etc.) follow the
same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:39:16 +02:00
davidmonterocrespo24 46aa16bfc2 feat(sim): Phase 2.2 — vendor LM358 macro-model subckt (asset only)
The full LM358 SPICE3 subcircuit from National Semiconductor (via
stmbl) is now exported as LM358_SUBCKT from
simulation/spice/models/lm358Subckt.ts.  Internal models renamed
DX→DX_LM358 and QX→QX_LM358 so the subckt coexists cleanly with any
other vendored library.

Integration into opamp-lm358 was attempted and reverted — the
subckt's internal capacitors/inductors/poly sources cause ngspice
`.op` to time out (>60 s) on a simple unity-gain follower.  The
behavioural B-source clamp remains the active model.  When Phase 1c
moves the default analysis to `.tran` (or we add `.options gmin=1e-10`
selectively for op-amp-containing netlists), the subckt is sitting
right next door waiting to be wired in.

Phase 2.2 lockdown test guards the asset:
- declares `.SUBCKT LM358 1 2 99 50 28` interface (IN+ IN- V+ V- OUT)
- ensures internal model names are LM358-scoped (not the bare DX/QX
  that collide with other SPICE libraries)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:28:46 +02:00
davidmonterocrespo24 8f49665cdf feat(sim): include component pins in NetlistBuilder.pinNetMap + e2e BJT-switch test
Fixes the gap that Phase 1b step 4 surfaced: the legacy pinNetMap was
built from board endpoints only, so the bridge from legacy solver to
MixedModeScheduler had nothing to publish for component pins like
"q1:C" — every SpiceResolvedPinResolver was stuck on FLOATING.

Now pinNetMap contains an entry for every wire endpoint, board or
component. Backwards compatible: legacy ADC injection only ever looked
up `boardId:pinName` keys, which are unchanged.

The new e2e integration test wires up real ngspice (eecircuit-engine,
no mock):
  Arduino pin 9 → 1k → 2N2222 base; collector via 220 to 5V
  - pin 9 HIGH → BJT saturated → Vc ≈ 0.05V → resolver emits LOW
  - pin 9 LOW  → BJT cut off    → Vc ≈ 5V    → resolver emits HIGH

Validated against the AVR_HC logic family (Phase 3). With 216 tests
green across 25 files, the Phase 1b pipeline is now demonstrably
correct end-to-end against a real SPICE solver, not just mocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:22:36 +02:00
davidmonterocrespo24 da07345bc0 feat(sim): Phase 1b continued, step 4 — bridge legacy solver into MixedModeScheduler
Connects the existing electrical solver's output (nodeVoltages +
pinNetMap from useElectricalStore) to the mixed-mode scheduler's
voltage cache.  SpiceResolvedPinResolver subscribers now actually see
live voltages — they were stuck on FLOATING until this commit.

Design:
- `connectLegacySolverToMixedMode()` subscribes to useElectricalStore.
  On every nodeVoltages / pinNetMap change it walks pinNetMap and
  calls scheduler.publishVoltage(componentId, pinName, v) for each
  pin.  Ground pins (canonical net '0') resolve to 0 V directly.
  NaN / Infinity voltages are skipped.
- `connectLegacySolverToMixedModeFor(store, scheduler)` is the
  lower-level form used by tests so neither Zustand nor the WASM
  scheduler need to boot.
- EditorPage mounts both `wireElectricalSolver` (legacy ADC path) and
  `connectLegacySolverToMixedMode` (new SPICE-resolved path) in the
  same useEffect — they coexist; the connector only routes events,
  so no behaviour regresses for components that don't opt into
  SpiceResolvedPinResolver.

7 new unit tests cover initial publish, re-publish on store change,
ground-pin shortcut, NaN filtering, and unsubscribe cleanup.

This is the wiring that completes Phase 1b's end-to-end pipe.  The
WASM-driven onMcuPinChange path (loadCircuit + alter + tran in the
scheduler itself) stays available for future migration off the legacy
solver entirely — see Phase 1b doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:15:24 +02:00
davidmonterocrespo24 61b04e46f8 feat(sim): Phase 1b continued, steps 2 + 3 — loadCircuit, resolveDc, onMcuPinChange
Wires the second half of the mixed-mode event loop on top of the
voltage cache that step 1 added.

Step 2 — loadCircuit + resolveDc:
- `loadCircuit(netlist, pinNetMap)` accepts the artifacts that
  NetlistBuilder already produces, boots the engine lazily, calls
  `loadNetlist`, and clears the voltage cache so stale values from a
  previous circuit cannot leak through.
- `resolveDc()` runs `op` and walks the pinNetMap, calling readVec for
  each non-ground net and publishVoltage for each pin. Ground pins
  short-circuit to 0 V without an extra round-trip. Missing nets are
  skipped quietly so a disconnected probe pin can't break the resolve.

Step 3 — onMcuPinChange:
- Issues `alter V_<board>_<pin> dc <volts>` and re-resolves. Caller
  decides the volts: `state ? vcc : 0` for plain digital, but boards
  with open-drain / output-impedance semantics can pass any number.
- Silent no-op when no engine has been started, so legacy paths that
  fire pinChange unconditionally can't crash the simulator.

NgSpiceClient interface added and exported so unit tests can inject a
fake engine that records alter() calls and returns canned readVec
values — `__setSchedulerEngineFactoryForTests`. 7 new tests cover the
load → resolve → alter → republish loop end-to-end without booting
the real WASM worker.

The orchestration layer (Zustand subscriber / DynamicComponent hook)
that calls `loadCircuit` whenever the canvas changes is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:11:10 +02:00
davidmonterocrespo24 1ab294cf10 feat(sim): Phase 1b continued, step 1 — scheduler voltage cache + subscriber routing
Adds the runtime plumbing that Phase 1b's SPICE event loop will drive:
- `publishVoltage(componentId, pin, voltage)` updates a (componentId,
  pin) → volts cache and notifies every matching subscriber.
- `getCurrentVoltage(...)` reads the cache (was previously stubbed
  null).
- subscribe/publish routing exercised by 7 new unit tests.

The scheduler still does not yet drive ngspice — `start()`,
`onMcuPinChange()` are unchanged. But once Phase 1b's solve loop is in
place, calling `publishVoltage` after each `readVec` is all the wiring
needed for components to start reacting to SPICE-resolved analog
states. This is the smallest non-trivial step that keeps the
architecture honest (no test-only emitters; the same code path will be
used in production).

Tests skip booting the WASM worker — they call publishVoltage
directly, so they pass in plain Vitest with no JSDOM Worker shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:06:20 +02:00
davidmonterocrespo24 d9945d13b8 feat(sim): Phase 2.1 — migrate MOSFETs to VDMOS macro-models
Switches 3 of the 4 simulated MOSFETs from Level=1 Shichman-Hodges
to LTSpice VDMOS macro-models. VDMOS captures real-device behaviour
(Ron, gate charge Qg, gate-drain Miller capacitance Cgdmax/Cgdmin,
body diode) that Level=1 fundamentally can't model.

Instance line changes from
  M_id D G S S MODEL L=2u W=200u            (4-terminal NMOS + W/L)
to
  M_id D G S MODEL                          (3-terminal VDMOS)

Parts migrated:
  mosfet-2n7000  → 2N7002 VDMOS (Vto=1.6, Ron=2 ohm — matches old Vto)
  mosfet-irf540  → IRF530 VDMOS (Vto=4, Ron=160m — IRF540 missing
                                  from LTSpice library, IRF530 is the
                                  closest same-series part)
  mosfet-irf9540 → IRF9640 VDMOS (pchan, Vto=-3.5 — IRF9540 missing,
                                  IRF9640 is the 200V P-channel sub)

mosfet-fqp27p06 kept on Level=1 (no upstream VDMOS equivalent yet).

spice-mosfet-pwm regression test still passes: Id=8.6 mA at Vgs=5V,
0 at Vgs=0V, monotonic across the ramp. All 155 SPICE + analog
examples + lockdown tests pass.

Phase 2.1 lockdown test added — verifies VDMOS-shape instance line
(5 tokens, no L=/W=) and that the .model card carries `VDMOS(`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:03:49 +02:00
davidmonterocrespo24 7508423c6e test(sim): Phase 2 lockdown — guard BJT/diode model upgrades
Asserts the Phase 2 BJTs include Gummel-Poon junction caps (CJC/CJE)
and forward transit time (TF), and the diode upgrades include
reverse-recovery time (tt) and Schottky band-gap (Eg). If anyone
simplifies the models in the future, these regress fail and surface
the loss of AC/transient fidelity.

Also guards the dedupe identity between the canonical diode-1n4148
emission and the relay flyback diode — they must serialise as the same
string or ngspice will reject the netlist for duplicate .model lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:59:39 +02:00
davidmonterocrespo24 c476084e00 feat(sim): Phase 2 — upgrade BJT/diode models to LTSpice Gummel-Poon (SPICE3F5)
Replaces the truncated 4-5 param NPN/PNP/D models in componentToSpice.ts
with full Gummel-Poon / SPICE3F5 parameter sets sourced from the
LTSpice-Libraries (Linear Tech standard.bjt and standard.dio). Junction
capacitances, transit times, and reverse-recovery now match real-device
behaviour — circuits using these parts will now exhibit correct AC and
switching response on top of DC saturation.

Parts upgraded:
  BJT NPN: 2N2222, BC547, 2N3055
  BJT PNP: 2N3906, BC557
  Diode:   1N4148 (silicon switching), 1N5817, 1N5819 (Schottky)

MOSFET (Level=1) and 1N4007/zener kept as-is - they need separate
VDMOS migration validated against the MOSFET PWM regression test.

Phase 2.0 of the mixed-mode simulator project. See
velxio-prod/project/sim-mixedmode/phase-02-device-models.md.

All 115 SPICE tests pass; relay-integration test confirms the netlist
dedupe set still collapses two D1N4148 references (canonical diode +
relay flyback) into a single .model line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:57:36 +02:00
davidmonterocrespo24 cb07a88095 feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt)
Replaces the Phase 1b vcc/2-flat threshold with per-logic-family
Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable.
SPICE-resolved digital reads now match what real ICs actually do —
TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis,
LVCMOS33 vs CMOS-5V interop.

New module: simulation/LogicFamilies.ts
  - LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?,
    cin_pF, vol_max?, voh_min?, output_impedance_ohm?)
  - FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS,
    LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P /
    JEDEC datasheets.
  - BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC,
    ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for
    unknown boards.
  - getBoardLogicFamily() and getLogicFamilyById() helpers.

PinResolver:
  - SpiceResolvedConfig docstring rewritten with Phase 3 wording.
  - New `configFromLogicFamily()` builder — picks Schmitt thresholds
    when the family declares them, falls back to vih/vil otherwise.

DynamicComponent:
  - When the trace crosses an active device, the SPICE-resolved
    resolver is now built with the OWNER BOARD's logic family
    instead of vcc/2. Hysteresis comes through automatically for
    boards whose native family is Schmitt-capable.
  - Phase 3 continued: per-component logicFamily override from
    components-metadata.json (so e.g. a 74HC14 placed on an Arduino
    Uno gets Schmitt thresholds even though the BOARD is AVR_HC).

Tests:
  - logic-families.test.ts (new) — 19/19 passing.
    Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih),
    per-board lookup, Schmitt vs non-Schmitt config, noise rejection
    behavior of 74HC14 Schmitt resolver, last-state-wins behavior
    of CMOS-5V dead band.
  - Phase 0 + Phase 1b regression: 16/16 still passing.
  - tsc --noEmit on new files: clean.

No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 16:42:11 +02:00
davidmonterocrespo24 27c59664cd feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine.  Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.

What ships:

  - simulation/PinResolver.ts
    * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
      list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
    * `DetailedPinTrace` / `DetailedPinTracer` types — the trace
      function now reports whether it crossed an active device, on
      top of the Arduino pin number.
    * `createSpiceResolvedPinResolver()` — new factory; reads voltages
      from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
      with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).

  - simulation/spice/MixedModeScheduler.ts (new)
    * Singleton orchestrator that holds the NgSpiceInteractive engine
      and the SpiceVoltageSource subscription registry.
    * `start()` / `stop()` / `dispose()` lifecycle.
    * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
    * `onMcuPinChange()` placeholder for the alter+tran event loop.
    * Skeleton: subscribers register but never receive events yet.
      Phase 1b continued will wire NgSpiceInteractive into the loop.

  - components/DynamicComponent.tsx
    * Trace function extended with `traceDetailed()` that tracks
      whether the BFS crossed an active component.
    * PinResolver factory branches: active-path → SPICE-resolved (uses
      the scheduler), digital-only → existing default impl.  Default
      threshold = vcc/2 with no hysteresis; Phase 3 will replace with
      per-logic-family Vil/Vih.

Phase 0 LED behavior intact (digital path).  Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.

Tests:
  - pin-resolver-phase1b.test.ts (new) — 8/8 passing.
    Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
    metadata id; SPICE-resolved resolver state reporting, threshold
    conversion, hysteresis dead-band, unsubscribe.
  - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
  - tsc --noEmit on the new files: clean.

No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 16:38:30 +02:00
davidmonterocrespo24 d41be4f48b feat(sim/spice): vendor ngspice+XSpice WASM and add NgSpiceInteractive client (Phase 1a)
Phase 1a of the mixed-mode simulator project.  Vendors prebuilt
ngspice+XSpice WASM artifacts from ejkreboot/ngspice-xspice-wasm (MIT,
2026) and adds a TypeScript client that exposes the ngspice shared
callable API for interactive (event-driven) use.

What's vendored at frontend/public/wasm/ngspice-interactive/ (~27 MB):

  - ngspice-lib.wasm  (24 MB) — ngspice 33 + XSpice, MAIN_MODULE
  - ngspice-lib.js   (2.7 MB) — Emscripten glue
  - {analog,digital,xtradev,xtraevt,table,tlines,spice2poly}.cm
                              — XSpice code models, loaded dynamically
  - spinit                    — ngspice startup script
  - PROVENANCE.md             — sources + license info

Note on the cost: 27 MB is a one-way commit to git history, but the
existing eecircuit-engine dependency already ships 39 MB in node_modules
(not tracked, re-downloaded per build). Vendoring our copy:
  - removes a third-party npm dependency
  - pins the exact build we tested with
  - means the WASM is served as a static asset (no Vite chunking)
The alternative (publish as @velxio/ngspice-interactive-wasm) was
deferred to keep the iteration cycle fast during Phase 1+.

New TypeScript client at frontend/src/simulation/spice/wasm/:

  - NgSpiceInteractive.ts        — Promise-based client class with
                                    init / loadNetlist / command /
                                    alter / readVec / reset / dispose
  - ngspice-interactive-worker.js — vendored from ejkreboot's worker
                                    and extended with 'loadNetlist',
                                    'command', 'readVec' message types
                                    plus per-command stdout/stderr
                                    capture

POC test at __tests__/ngspice-interactive.test.ts (skipped in node env
because Worker isn't available; runs in a browser-mode test env):
  - voltage divider .op → reads v(mid) ≈ 2.5V
  - RC step → reads v(cap) time series, final ≈ 5V
  - alter Vsrc → second .tran → final ≈ 1V (proves alter+rerun works)

Known limitation deferred to Phase 1b: the vendored WASM is built
without pthreads (no -sUSE_PTHREADS=1), so ngspice's bg_run is
synchronous-blocking. True mixed-mode event injection requires a
pthread-enabled rebuild (with SharedArrayBuffer + cross-origin
isolation). For Phase 1a we use the workaround: chained short-tran
invocations with `alter` between them. The new architecture is built
to swap in a real bg_halt/bg_resume implementation later without
changing component handlers — see NgSpiceInteractive.ts docstring.

Tests passing:
  - pin-resolver (Phase 0): 8/8
  - ngspice-interactive: 3 skipped (need browser env)
  - tsc --noEmit on the new files: clean
2026-05-15 16:14:50 +02:00
davidmonterocrespo24 e10492c6e6 feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite)
Decouple per-component handlers from direct pinManager.onPinChange +
getArduinoPinHelper subscriptions by introducing a small PinResolver
interface. The Phase 0 default impl is functionally identical to the
legacy path — it just routes through PinResolver instead of being
inlined in every handler. Zero behavior change.

The point is to make Phase 1 possible: swap the default impl for a
SPICE-resolved version that watches node voltages and threshold-
converts to digital events, without rewriting every handler.

Files:
  - simulation/PinResolver.ts (new) — interface + default factory
  - parts/PartSimulationRegistry.ts — additive 5th arg to
    attachEvents (getPinResolver?), legacy 4-arg signatures keep
    working unchanged
  - components/DynamicComponent.tsx — assembles the PinResolver from
    the wire-trace logic + PinManager subscriptions + board Vcc
    lookup, passes it as the 5th arg to attachEvents
  - parts/BasicParts.ts — LED handler migrated as proof of concept
    (resolver-first path, legacy 4-arg path kept as fallback for
    tests / unmigrated harnesses)
  - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering
    FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe

Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass;
the one pre-existing flake (spice-rectifier-live-repro timing out >60s)
is unrelated to this commit — verified by running the test on plain
HEAD without these changes (same timeout).

See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod
repo) for full phase context.
2026-05-15 15:50:09 +02:00
David Montero Crespo a91b857d8c fix(ili9341): rotation 3 was mirrored — use explicit per-rotation map
The MADCTL handler in 6edc715 applied MX/MY/MV as three independent
flags, then mirrored physX/physY post-swap. That double-applies the
mirror for setRotation(3) (which Adafruit sends as MX|MY|MV|BGR=0xE8):
expected formula for rotation 3 is

  physX = 239 - curY
  physY = curX

but the flag-by-flag approach computed

  physX = 239 - curY      (correct by coincidence)
  physY = 319 - curX      (mirrored — should be just curX)

so every landscape-rot-3 sketch rendered horizontally flipped. The
user's Pico Doom title screen looked mirrored even after the previous
fix landed.

Replaced with an explicit per-rotation table derived from
Adafruit_ILI9341's setRotation() source:

  rot 0  MX|BGR        : (curX, curY)
  rot 1  MV|BGR        : (curY, 319 - curX)
  rot 2  MY|BGR        : (239 - curX, 319 - curY)
  rot 3  MX|MY|MV|BGR  : (239 - curY, curX)

Selects the case based on (madMV, madMX, madMY) bits, which is
straightforward because Adafruit only emits these 4 specific values.
Other drivers that set arbitrary MADCTL combinations (e.g. with the ML
or MH bits) still fall through to the closest of the four — good
enough for the screens we actually run.

Build verified (vite OSS+pro, 285 SEO pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:32:37 -03:00
davidmonterocrespo24 d79f2923d9 fix(sim): trace through BJT C↔B in getArduinoPinHelper
The canonical "Arduino pin → resistor → BJT base, BJT collector →
load" pattern for multiplexed 7-segment clocks was breaking in the
simulator: getArduinoPinHelper('COM.1') couldn't resolve through
the transistor, so the multiplex-aware 7-segment driver thought no
digit-select pin was wired and fell back to "all digits enabled".
Result: every display in the multiplex array rendered the same
rapidly-changing pattern → user-visible flicker.

Fix: add the NPN/PNP BJTs to the PASSIVE_PIN_PAIRS map with
[collector, base] — the trace function continues from B when it
arrives at C (and vice versa). That makes the Arduino pin driving
the base reported as the controller of the collector — exactly the
relationship the user's multiplex code expects.

Conventions covered:
  - NPN (2n2222, bc547, 2n3055): Arduino HIGH → transistor on →
    COM pulled LOW → common-cathode digit enabled.  Our 7-segment
    driver treats "digit pin HIGH = enabled" which matches.
  - PNP (2n3906, bc557): inverse logic.  We expose the same pin
    mapping; users writing PNP-driver code will see the polarity
    behave inverted, which is what real hardware does too.

This is a one-line shortcut, not a true active-device model. We're
not simulating BJT saturation, β, base current, or PNP polarity —
just reporting "this Arduino pin is the boss of this collector".
That's enough for the multiplexing use case and the only place
getArduinoPinHelper is consulted today.
2026-05-15 06:32:26 +02:00
davidmonterocrespo24 76e0d77975 fix(sim/7segment): multiplex-aware driver — track COM/DIG pins, latch segments per digit
The simulator's 7-segment part used to write segments straight into
element.values[0..7] regardless of how many digits the display has and
without considering the COM/DIG select pins.  That meant:

  - Multi-digit displays (digits=2/3/4) only ever lit digit 0; the
    other digits stayed dark even when their DIGn pin was driven.
  - For 1-digit displays multiplexed via shared A-G bus + per-display
    COM.1 transistor (the canonical Arduino clock pattern), all four
    displays showed the same rapidly-changing segment pattern and
    rendered as flickering gibberish because COM.1/COM.2 were ignored.

This rewrites the part:

  - Per-element state: live segments[] (Arduino-driven A..DP), per-
    digit latched digitValues[][], and digitEnabled[] flags.
  - Subscribes to the right digit-select pins for the digit count
    (COM.1/COM.2 for digits=1, DIG1..DIGn for digits=2/3/4).
  - On segment-pin change: writes to segments[] AND mirrors into
    every currently-enabled digit's latched slot.
  - On digit-pin LOW->HIGH (= enable, transistor-driver convention):
    latches the live segments[] into that digit's slot so the first
    refresh after enabling reflects the current pattern.
  - When NO digit-select pin is wired to an Arduino pin (pure direct
    drive, COM tied to GND): all digits default to enabled so segment
    writes propagate immediately — preserves the old behaviour for
    the simplest single-digit case.
  - Rebuilds element.values as a flat array of length digits*8 (the
    shape wokwi-7segment-element expects: indices d*8..d*8+7 = digit
    d's A..DP).

Result: multiplexed 4-digit clocks built with 4 separate 1-digit
7segments + transistors actually render the four digits as the user
intended.  Direct-drive single-digit displays still work unchanged.
2026-05-15 06:08:38 +02:00
David Montero Crespo 1e4d78fda5 fix(board): raspberry-pi-pico renders a real Pico, not Nano RP2040 Connect
The 'raspberry-pi-pico' boardKind used to render <NanoRP2040> — a
<wokwi-nano-rp2040-connect> Web Component. That's a completely
different board: it has pin labels D2..D13 / A0..A7 / 5V / VIN,
and a horizontal 168×68 layout. The actual Raspberry Pi Pico has
GP0..GP28 / 3V3 / VBUS / VSYS and is vertical-narrow (105×264).

Symptom: every wire in a Pi-Pico example that referenced a real Pico
pin (GP10, GP18, 3V3, GND.5, etc.) silently fell back to (0, 0) in
pinPositionCalculator — the calculator looks up `element.pinInfo`
by name, doesn't find GP* on the Nano RP2040 Connect component, and
returns the board's top-left corner. The Pico Doom example was the
loudest casualty (cables to the corner instead of the TFT), but
seven other GP-style examples (pico-7segment, pico-button-led,
pico-rgb, pico-dht22, pico-doom-raycaster, plus pico-ntc/pico-joystick
which use A0/A1 aliases that map to GP26/GP27) all silently routed
to nowhere.

Fix is a two-liner: 'raspberry-pi-pico' shares the same case as
'pi-pico-w' (both use the same Web Component because the Pico and
Pico W are pin-compatible). BOARD_SIZE updated to 105×264 to match
the real Pico footprint. Dropped the now-unused NanoRP2040 import.

Known regression — eleven older examples (pico-blink, pico-serial-led-
control, pico-i2c-scanner, pico-i2c-rtc-read, pico-i2c-eeprom-rw,
pico-spi-loopback, pico-adc-read, pico-multi-protocol, pico-hcsr04,
pico-pir, pico-servo) were wired against D2..D12 of the wrong board.
Their wires will now land at (0,0). Those examples' sketches were
written for the Pi Pico (use LED_BUILTIN = GP25, A0..A3 = GP26..GP29)
so the wires were ALREADY electrically nonsense — they connected
external components to pins the sketch never touched. Visible bug
trades silent bug; both need a follow-up commit to rewire each one
to the Pico pin its sketch actually expects.

Combined with the earlier MADCTL fix (6edc715) and the SPI adapter
fix (6a7b721), Pico Doom should now finally render end-to-end on
velxio.dev.

Build verified (vite OSS+pro, 285 SEO pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:31:19 -03:00
David Montero Crespo 6a7b72138f fix(rp2040): route SPI0 through the adapter in initMCU too
Long-standing latent bug: SPI parts (ILI9341, custom chips, etc.)
register a handler on simulator.spi.onByte via the lazy adapter, but
the actual rp2040.spi[0].onTransmit assignment in initMCU was a pure
loopback that never consulted the adapter. The MicroPython init path
(initMicroPython) had the adapter-aware version since day one;
the Arduino path (initMCU) didn't.

Symptom: Pico Doom + every other Arduino sketch driving an ILI9341
on the RP2040 saw an empty SPI bus. The ILI9341 emulator's onByte
handler was wired up correctly — it just never received a single
byte. Pantalla negra.

Fix: copy the adapter-aware handler from initMicroPython (line 219)
into initMCU (line 441). Each byte the firmware writes to SPI0 now
checks `_spiAdapter.onByte` first; if a part is registered, it gets
the byte; otherwise we keep the original loopback as the fallback
so plain "echo MOSI back as MISO" sketches still work.

Combined with the earlier MADCTL fix (commit 6edc715) and the
power+MISO wiring fix (8440836), Pico Doom should now render its
title screen + the raycaster.

Build verified (vite OSS+pro, 285 SEO pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:23:36 -03:00
David Montero Crespo 65cbc403d2 feat(examples): /example/<id> route with pinned URL
Mirror of the /project/<uuid> pattern but for built-in examples.
Loading an example used to navigate to a generic /editor and lose
all trace of which example was loaded — same URL whether you
clicked Blink or Doom, nothing shareable, no back-button history.

New page: pages/ExampleEditorPage.tsx
  - Route: /example/:exampleId  (singular, distinct from the plural
    /examples/<id> landing).
  - useEffect calls loadExample(...) once when exampleId changes,
    guarded by a ref so React strict-mode's double-effect doesn't
    re-load (which would clobber any edits the user made).
  - Renders <EditorPage /> after the load completes — same as how
    ProjectByIdPage stays mounted at /project/<uuid> after load.
  - SEO: title + description per example, canonical URL points at
    /example/<id>.
  - 404 state for unknown ids (typo'd link, deleted example).
  - Inline install progress while libraries fetch — the overlay
    UI moved here from ExamplesPage/ExampleDetailPage so progress
    is visible right at the URL you'll bookmark.

App.tsx — registered the new route alongside the existing landing.
Both coexist on purpose:
  /examples/<id>  = SEO landing page (preview, badges, "Open in
                    Simulator" CTA). Indexed by Google (130 URLs
                    already in sitemap.xml).
  /example/<id>   = live editor with the example pre-loaded; URL
                    stays pinned so the link is shareable +
                    bookmarkable like a saved project URL.

ExamplesPage — gallery now navigates to /example/<id> instead of
calling loadExample directly. Also drops the install-overlay block
(progress UI is on ExampleEditorPage now).

ExampleDetailPage — "Open in Simulator" navigates to /example/<id>
instead of loading directly. Drops its own install overlay too.

Side effect: this also kills the data-loss bug from 95f2aa9 in a
second way. Even if a future change forgets to call
clearCurrentProject() somewhere, navigating into ExampleEditorPage
forces a fresh page transition — the previous project's state +
the auto-save subscription don't survive into the example session.

Build verified (vite OSS+pro, 285 SEO pages prerendered).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:14:36 -03:00
David Montero Crespo 95f2aa9a9f fix(loadExample): clear currentProject before mutating stores
Critical data-loss bug. Repro:

  1. User opens a saved project at /<username>/<slug>. The page sets
     useProjectStore.currentProject = { id, slug, ownerUsername, ... }.
     Auto-save kicks in and starts watching simulator/editor stores.
  2. User clicks the "Examples" link, picks an example, hits Run.
  3. loadExample mutates useSimulatorStore (setComponents, setWires,
     addBoard, removeBoard) and useEditorStore (loadFiles).
  4. Auto-save sees the change. Its eligibility check finds
     currentProject still pointing at the user's saved project (we
     never touched useProjectStore). It debounces a
     PUT /api/projects/<old-id> with the EXAMPLE's components/wires/
     files. The user's saved project is overwritten with the example
     contents.

The URL changing to /editor isn't enough — useProjectStore is store
state, not router state. ProjectPage / ProjectByIdPage set it on
mount; nothing clears it when the user navigates away.

Fix: loadExample calls useProjectStore.getState().clearCurrentProject()
BEFORE the simulator/editor mutations. autoSaveImpl is subscribed to
useProjectStore via subscribe((s, prev) => ... reset() if id changed),
and Zustand notifies subscribers synchronously inside set(), so the
reset (projectId=null, baseline hash=null) runs in the same tick.
Every subsequent setComponents/setWires/loadFiles fires onChange in
the hook, which now sees projectId=null and returns early. No PUT
ever goes out.

The reset is order-sensitive: it must run BEFORE the store mutations
or the hook would already have queued a save with the old projectId
before we cleared. Comment in the source spells this out so it
doesn't get reordered in a future refactor.

In-flight saves are not affected: buildSavePayload() snapshots state
before its `await updateProject(...)`, so a save that started right
before the example load still sends the user's pre-example state to
the right project. Worst case: the save completes after clear, and
the hook quietly returns idle.

Build verified (vite OSS+pro, 285 SEO pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:02:05 -03:00
David Montero Crespo 844083657c fix(examples/pico-doom): wire ILI9341 power + MISO
The Pico Doom example loaded with the ILI9341 dangling on three
critical pins:

  - VCC  — unconnected (no 3V3 from the Pico)
  - GND  — unconnected (no return path)
  - MISO — unconnected

On real hardware the TFT wouldn't power on at all without VCC/GND.
Inside the velxio simulator the missing power isn't strictly fatal
(the simulator drives pixels off the SPI bus, not the rail), but it
makes the schematic incorrect and misleading for users who copy it
to a breadboard. MISO is electrically idle for write-only drivers,
but Adafruit_ILI9341 with the 3-arg constructor binds to hardware
SPI0, so MISO physically maps to GP16 — leaving it floating leaves
the SPI bus topology incomplete.

Wires added:

  Pico 3V3   → tft1.VCC   (red)
  Pico GND.5 → tft1.GND   (black) — closest GND pad to GP17/18/19
  Pico GP16  → tft1.MISO  (amber) — hardware SPI0 MISO

Updated the data-integrity test (examples-pico-doom.test.ts) to
include the three new pairs in the SPI/control/power expectation
map. 10/10 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:22:31 -03:00
David Montero Crespo 6edc715e8d fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").

That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.

Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).

  MADCTL bit  Mask    Meaning
  D7 MY       0x80    row mirror
  D6 MX       0x40    column mirror
  D5 MV       0x20    swap X/Y (landscape)

Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:19:03 -03:00
davidmonterocrespo24 9ace6b7476 fix(store): addBoard promotes itself to active when none is valid
addBoard appended the new board to the boards[] array but never
touched activeBoardId. The default INITIAL_BOARD_ID points at a
board the picker injects on first load — but a fresh anonymous
session (or a project that landed in a state without that initial
board) can have activeBoardId pointing at nothing.

When the agent does add_board('arduino-uno') → compile_sketch, step
2 then fails with "no active board on the canvas" and the model
burns a turn on set_active_board.

The fix: at addBoard time, if activeBoardId doesn't resolve to any
existing board, promote the new board to active. If there IS a
valid active board, leave it alone — manual placements of additional
boards via the picker still keep focus on whatever the user was
working on.
2026-05-15 03:38:34 +02:00
davidmonterocrespo24 f0953fcf45 i18n: admin.users keys for "Reset agent usage" button
New keys across all 9 locales (en/es/fr/de/it/pt-br/ja/ru/zh-cn):
  - admin.users.actions.resetAgentUsage           — button label
  - admin.users.actions.resetAgentUsageTooltip    — hover hint
  - admin.users.confirmResetAgentUsage            — confirm dialog
  - admin.users.resetAgentUsageDone               — success toast
  - admin.users.resetAgentUsageFailed             — error toast

Consumed by the velxio-prod overlay's AdminPage Users tab, which adds
a "Reset agent" button per row that hits
POST /api/admin/users/{user_id}/reset-agent-usage and clears today's
pro_agent_usage_events for the user.  Live agent quota recomputes
from the events table, so the user can keep using the agent
immediately after the button click.
2026-05-14 23:29:21 +02:00
davidmonterocrespo24 6242b7f16b fix(minimap): hit-test against clamped rect + shrink to 100x75
Two unrelated minimap issues from user feedback:

1. Click on the red viewport rect was sometimes teleporting the
   canvas instead of starting a drag.  Cause: insideRect compared
   click coords against the UNCLAMPED rectX/rectY/rectW/rectH, but
   the rendered rect uses clampedX/clampedY (which differ when the
   user pans past a world edge).  The user clicked on the visible
   red rect, but the logical rect was off-minimap → insideRect
   returned false → fell through to the teleport branch.

   Fix: compute clamped values once at the top, render and hit-test
   against the same values.  Drag now only fires when the click
   really lands inside the visible rect.

2. The 140x105 default still ate too much canvas at typical zoom.
   Drop to 100x75 (12% of world width by 2.5%, same proportions as
   the world).  Mobile breakpoint dropped to 90x68 to stay
   proportionally smaller on phones.
2026-05-14 22:53:34 +02:00
davidmonterocrespo24 218a891c6d feat(minimap): shrink to 140x105 + red viewport rect
User feedback: the default 200x150 minimap eats too much of the
canvas-content area on a typical 13"/14" laptop, and the white
viewport rectangle against a dark canvas blends with the boards
once enough components are placed.

Drop the desktop default down to the size we already use on phones
(140x105 — the mobile media query still wins on screens ≤720px so
that block continues to apply identically). At this size the rect
becomes the focal indicator of where you are in the world; switch
its outline to brand red (#ef4444 — Tailwind red-500) with a faint
red fill so it pops without overpowering the boards (which stay
brand blue).

Body of the work is two number changes + two color tokens; the
rest of the component logic (pointer routing, world rendering,
clamping) is untouched.
2026-05-14 22:32:42 +02:00
David Montero Crespo b4ab742456 feat(oss): portable .vlx project export/import for self-hosters
Phase 4 of the OSS / pro split. The OSS image has no auth and no
server-side persistence — without this commit, the user's workspace
was ephemeral (lost on tab refresh). `.vlx` is a single-file JSON
snapshot of the entire workspace (boards, file groups, components,
wires, active board id) that the user can save to disk and reload
later.

New: utils/vlxFile.ts
  - buildVlxPayload() / buildVlxBlob() — pure snapshot of the current
    editor + simulator stores.
  - triggerDownloadVlx({ name? }) — anchor-click download with a safe
    filename. Returns the filename actually used.
  - parseVlxFile(File) — async reader + validator. Checks
    format === "velxio-project", version <= 1, and the required
    arrays/objects are present. Throws VlxParseError with a human-
    readable message on any issue.
  - importVlxFile(File) — convenience wrapper that parses AND calls
    useSimulatorStore.loadProjectState() with the result.

Format intentionally mirrors the server's POST /api/projects body so
a Pro user can export-from-pro and import-into-OSS losslessly (and
vice-versa once Pro adds an Export button — out of scope here).

lib/proSaveAction.ts: the default (no-overlay) implementation now
calls triggerDownloadVlx() instead of console.info'ing about the
missing handler. The Pro overlay still wins via installSaveActionImpl()
— Save in Pro keeps opening SaveProjectModal. The Save button in OSS
now actually saves.

components/editor/FileExplorer.tsx: new "Open .vlx" button next to
New + Save. Opens a hidden file input; confirms with the user before
replacing the workspace (loadProjectState is destructive); surfaces
VlxParseError messages via window.alert.

Verified with both builds:
  - OSS-only: triggerSaveAction → download .vlx; FileExplorer shows
    3 buttons (New, Open, Save).
  - OSS + overlay: Pro's installSaveActionImpl overrides — Save opens
    SaveProjectModal as before. Open .vlx still works (independent
    button, not part of the save flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:33:00 -03:00
David Montero Crespo 5c993d6c2a refactor(oss-split): remove auth/admin/profile frontend from OSS
Phase 3 of the OSS / pro split — frontend side. Phase 2 already moved
the auth/DB stack out of the OSS backend; this commit does the same
for the React app. After this, the OSS image is editor + simulator
+ landing + docs only.

What moved to the private overlay (pro/frontend/src/pro/):
  pages/{Login,Register,ForgotPassword,ResetPassword}Page.tsx
  pages/{Admin,UserProfile,Project,ProjectById}Page.tsx
  components/admin/{AdminBoardsTab,AdminDashboardTab,UserActivityModal}.tsx
  components/layout/{SaveProjectModal,LoginPromptModal}.tsx
  services/{authService,adminService}.ts
  store/useAuthStore.ts
  hooks/autoSaveImpl.ts

New seams added so OSS components stay decoupled:
  * lib/proRoutes.ts — registerProRoutes()/useProRoutes() via
    useSyncExternalStore. mountPro() injects the moved pages at runtime;
    App.tsx subscribes to the registry, so registration after the
    initial render re-renders without a Not-Found flash.
  * lib/proSession.ts — registerSessionCheck()/triggerSessionCheck().
    App.tsx fires this on mount instead of useAuthStore.checkSession();
    pure OSS no-ops.
  * lib/proSaveAction.ts — installSaveActionImpl()/triggerSaveAction().
    EditorPage's Save button dispatches through this; the overlay
    decides whether to show SaveProjectModal or LoginPromptModal based
    on auth state. In OSS without an overlay it's a no-op today; in
    Phase 4 of the split it becomes the .vlx Export entry point.

OSS-side rewrites:
  * App.tsx drops the 8 page imports + 8 route entries; uses
    triggerSessionCheck() instead of useAuthStore directly.
  * AppHeader.tsx drops the user/login/register block entirely. The
    header-auth slot (introduced in Phase 1) now stays empty in OSS
    and gets filled by the overlay's portal mount.
  * EditorPage.tsx drops useAuthStore + SaveProjectModal +
    LoginPromptModal imports. The Save handler is now triggerSaveAction().
  * LandingPage.tsx drops the dead UserMenu component (defined but
    never rendered) + its useAuthStore imports.
  * main.tsx drops the side-effect import of hooks/autoSaveImpl — the
    impl lives in pro now and self-registers via mountPro().

Build config:
  * vite.config.ts adds @velxio alias → src/. Lets the overlay import
    upstream modules (lib/proRoutes etc.) by stable name regardless of
    whether it's symlinked (local dev) or COPYed (Docker).
  * preserveSymlinks now gated on VITE_PRO_BUILD only (not on serve
    mode). Needed so Rollup keeps the overlay logically inside src/pro/
    during local junction-based builds.

Build verification:
  * OSS-only: 20-ish routes, no /login, /admin, /:username — 285 SEO
    pages prerendered. Bundle drops ~80-120 KB.
  * OSS + overlay: full 38 routes (30 upstream + 8 from registerProRoutes),
    HeaderAuth dropdown injected via slot, save action wired to the
    overlay's modal flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:31:12 -03:00
David Montero Crespo 12b6e94e4d refactor(oss-split): introduce extension hooks for auth, DB, metrics, auto-save
First phase of the OSS / pro split. Goal: open the seams so the auth/DB/admin
stack can move into the private overlay (Phase 2-3) without the routes that
stay in OSS (compile, libraries, simulation, iot_gateway) having to know.

Backend
-------
* New app/core/hooks.py — registry for record_compile, get_current_user_id,
  and lifespan startup tasks. Each hook is a no-op by default; overlays
  call register_* in register_pro(app) to plug in a real implementation.
* compile.py now imports only from app.core.hooks. Drops the direct deps on
  app.core.dependencies, app.database.session, app.models.user, and
  app.services.metrics. Route signatures use `Depends(get_current_user_id)`
  instead of `Depends(get_current_user)`; the metric helper passes user_id
  through rather than a User instance.
* compile_chip.py drops the unused _current_user Depends entirely.
* main.py wraps the auth/DB stack import in try/except. When it succeeds
  (today's behavior on velxio.dev), an adapter bridges record_compile and
  get_current_user_id to the existing app.services.metrics + dependencies,
  and the create_all + ALTER TABLE migration block runs via a registered
  lifespan_startup hook. When it fails (the post-Phase-2 OSS image), main
  logs "running stateless" and skips registering anything — the routes
  still load and behave as no-ops for metrics + always-anonymous for auth.

Frontend
--------
* useAutoSaveProject becomes a skeleton: one useState + one useEffect that
  delegates to an installed AutoSaveImpl. installAutoSaveImpl() replaces
  the impl without changing hook count, so React's rules-of-hooks stay
  satisfied even after the impl moves out of OSS.
* New hooks/autoSaveImpl.ts holds the original logic (debouncing, dirty
  detection, owner eligibility, fetch keepalive on unload), refactored to
  emit() instead of useState. It self-registers at module load; main.tsx
  imports it for the side effect.
* AppHeader wraps the entire user-vs-login UI in a data-velxio-slot
  ="header-auth" boundary. Today the OSS UI still renders inside the slot
  — the overlay can portal-inject additional items now, and in Phase 3
  the slot becomes the sole owner of header auth UX.

Behavior is identical on velxio.dev (pro overlay imports everything
successfully, every adapter wires up). The change is purely structural:
deleting the auth/DB modules tomorrow no longer crashes OSS at import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:24:51 -03:00
David Montero Crespo a9200da631 fix(seo): drop #root-seo on mount so it stops inflating page scroll
index.html ships a #root-seo div with prerendered SEO content (so crawlers
that don't run JS still index per-route copy). The inline CSS comment said
"React removes it on mount", but nothing actually removed it — so every
page kept a position:absolute, ~4096px-tall, visibility:hidden element
parked at top:0. That element does not paint, but it DOES contribute to
document.documentElement.scrollHeight.

Symptom: /admin, /docs, /:username and other short pages had a phantom
scroll roughly the size of the prerendered SEO body. Scrolling past the
real content showed a black band (just the body background) because there
was nothing visible to render down there. When tab content loaded with
more rows, the real content outgrew the phantom and the scrollbar "settled
in" — matching the user-reported symptom exactly.

Verified with puppeteer against velxio.dev:
  /dave:  documentElement.scrollHeight 4096 → expected ~800 after fix
  /admin: documentElement.scrollHeight 4096 → expected ~800 after fix
  /docs:  documentElement.scrollHeight 4096 → expected ~1161 after fix

The removal runs inside App's mount-effect, so it only fires after React
has actually committed — if App were to throw during render, the SEO
fallback would stay in the DOM as intended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:52:12 -03:00
davidmonterocrespo24 729c8785ba feat(compile): expose compile logs via Zustand store + UI slot
Two minimal hooks so the velxio-pro agent overlay can offer a 'Diagnose
this compile failure with AI' affordance without touching upstream
component internals:

  - New store/useCompileLogsStore: holds the editor's compile output as
    Zustand state instead of local React useState in EditorPage. The
    setter accepts both a value and an updater fn so the EditorToolbar
    callers that used setCompileLogs(prev => [...prev, log]) keep
    working without changes.

  - CompilationConsole header now renders a
    <div data-velxio-slot='compile-console-actions' /> when errorCount
    > 0. The pro overlay mounts a 'Diagnose with AI' button into this
    slot via slotMounter. Empty in the OSS image — no behaviour change.

EditorPage replaces its local useState<CompilationLog[]> with the store
selector. The downstream prop-drilled setCompileLogs callers (toolbar,
sub-toolbars) keep their signature.

Companion commit lands the button + diagnostic prompt builder in the
velxio-prod overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:44:05 +02:00
davidmonterocrespo24 aaebfedd23 feat(landing): bump pricing display to 100/500/2000 daily credits
Mirrors the velxio-prod backend quota bump (PLANS dict in
pro/backend/app/pro/services/quota.py). With 9router serving the bulk
of agent traffic for free, the cost-of-LLM ceiling is much lower than
when the limits were originally tuned, so we can be significantly more
generous and let casual users actually evaluate the agent.

  Free     20  /day, 300 /mo   →  100  /day, 1500 /mo
  Pro      400 /day, 12k /mo   →  500  /day, 15k  /mo
  Pro Max  1000/day, 30k /mo   →  2000 /day, 60k  /mo

Updates the landing.pricing.tiers.{free|pro|pro_max}.f1 string in all
9 locales (de, en, es, fr, it, ja, pt-br, ru, zh-cn) with each
locale's native thousands separator (',' / '.' / ' ' depending on
convention).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:10:25 +02:00
davidmonterocrespo24 888ce03cc3 docs: BUILD-QEMU.md + 'Build QEMU from source' docs section
Adds a transparent self-hosting path for users who would rather not
run Velxio's prebuilt libqemu binaries. The prebuilts have always
been a convenience under the AGPLv3 license; this just documents
how to skip them.

- docs/BUILD-QEMU.md as the canonical step-by-step (dependencies per
  Debian/Arch/macOS, ESP32 xtensa + ESP32-C3 riscv32 configure-and-
  ninja, drop-in instructions, troubleshooting, license notes on the
  QEMU/Velxio GPL-vs-AGPL boundary).
- DocsPage gets a new 'build-qemu' section between Setup and Roadmap
  in the sidebar. Content is hardcoded English (technical reference,
  not marketing copy) and ends with a link to the .md on GitHub.
- nav + SEO meta keys added to all 9 locales (de en es fr it ja
  pt-br ru zh-cn). Body remains English in every locale; technical
  content doesn't need translation for the audience that follows it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:44:10 +02:00
davidmonterocrespo24 fedb197be0 feat(header): add Pricing link to top nav + landing footer
The /pricing page exists (PricingPlaceholder upstream, real PricingPage
portal-mounted by the private overlay) but had no entry in the top nav.
Adds 'pricing' to header.nav in all 9 locales (de, en, es, fr, it, ja,
pt-br, ru, zh-cn), wires the Link in AppHeader between About and Blog,
and mirrors the link in the landing-page footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:28:59 +02:00
davidmonterocrespo24 5742ed0146 feat(examples): Pico Doom — Wolf3D-style raycaster on RP2040 + ILI9341
A new entry in the games category for the Raspberry Pi Pico. Renders
a first-person 3D corridor à la Wolfenstein / early Doom using a
column-wise DDA raycaster — 160 rays per frame drawn straight to a
320×240 ILI9341 TFT via drawFastVLine, no framebuffer. 16×16 tile map
with 5 wall palettes (slate, blood, brown, toxic green, bronze door),
darkened on NS faces so corners read in 3D. Forward / back move,
two more buttons turn the player. 40-px HUD bar.

Why a demo, not the canonical id Software Doom: Graham Sanderson's
rp2040-doom port shoehorns DOOM1.WAD into 2 MB of flash with custom
compression and pushes video out over PIO-driven DVI / VGA — none of
that survives the rp2040js emulator (no PIO accuracy, no flash
mapping for huge assets). A raycaster reproduces the *visual* of
early Doom using only ~67 KB of flash and 9 KB of RAM, which the
emulator runs perfectly.

Pre-flight: arduino-cli compile against rp2040:rp2040:rpipico
already verified inside the Velxio backend container — 3 % flash,
3 % RAM. The Adafruit_GFX + Adafruit_ILI9341 libs the example
declares are already in the gallery's auto-install list.

Bundled:
  test/pico_doom_demo/arduino_sketch.ino — source of truth sketch
  test/pico_doom_demo/README.md          — what + why + pin map
  test/pico_doom_demo/compile_check.sh   — operator script that
    runs arduino-cli against the same FQBN the prod backend uses
  frontend/src/data/examples.ts          — gallery entry (boardType
    raspberry-pi-pico, category games, difficulty advanced, 5
    components, 14 wires)
  frontend/src/__tests__/examples-pico-doom.test.ts — 10 vitest
    assertions: example is registered exactly once, target board /
    category / difficulty match, libraries declared, all four
    pushbuttons present, every wire endpoint references a real
    component id, SPI pin mapping matches the sketch's #define
    block, every button has a GND wire, the renderFrame loop is
    still present in the embedded code.
2026-05-13 22:54:35 +02:00
davidmonterocrespo24 dcf98fd0e5 feat(canvas): minimap with draggable viewport in the bottom-right corner
Renders a 200×150 px overview of the whole 4000×3000 world in the
bottom-right corner of .canvas-content. Boards show as filled blue
rectangles, components as small white dots, and the current viewport
appears as an outlined rectangle the user can drag to pan.

Geometry mirrors the canvas's existing pan+zoom model:
  SCALE_X = MINIMAP_W / WORLD_W = 0.05
  rect.x = -pan.x / zoom * SCALE_X
  rect.w =  viewport.width / zoom * SCALE_X

Two interaction modes, decided at pointerdown by hit-testing the
rectangle:
  - Inside the rect  → drag-pan: keep updating pan as the pointer
    moves, with delta in minimap-px converted back to world units
    by (delta / SCALE) * zoom.
  - Outside the rect → teleport: re-center the viewport on the
    clicked world point.

Pan is clamped so the viewport rectangle never escapes the minimap
bounds (matches the canvas's implicit world boundaries at 4000×3000).
ResizeObserver on the canvas-content keeps the rect accurate when
the user toggles side panels or resizes the window.

Mobile: at ≤720 px width the minimap shrinks to 140×105 px so it
doesn't eat too much of the canvas. Touch events go through the same
pointerdown / pointermove path — no separate touch code path needed
thanks to Pointer Events.

Bundles with: matching CSS file, import + JSX hookup inside
.canvas-content's render tree.
2026-05-13 22:29:46 +02:00
davidmonterocrespo24 cd4050c499 feat(landing+theme): multiplier pricing copy + softer canvas
Two small fixes that compound:

1. Drop LLM model names from the landing AI section. The agent
   auto-routes between several providers (9router combo, direct
   DeepSeek, direct Gemini, future-others) and naming any of them on
   the homepage misleads visitors. Replace "DeepSeek-V4-Flash and
   Gemini 2.5 under the hood" with "frontier LLMs auto-routed for
   cost and reliability" so the marketing line stays accurate as the
   provider mix changes.

2. Pricing copy switches from absolute message counts (300/day,
   700/day — small, intimidating, hard to anchor) to comparative
   multipliers (Pro = 20×, Pro Max = 50×). Visitors instinctively
   read these as "much more" without needing to count usage. The
   multipliers reflect the new backend quotas (400/day, 1000/day,
   committed separately in velxio-prod's pro overlay).

3. --color-bg-canvas moves from gray-1000 (#000000) to gray-950
   (#0a0a0c). Pure black collided with the slightly-lighter card
   surface (gray-900 #141416) and produced a harsh transition wherever
   a `min-height: 100vh` page wrapper grew taller than its content —
   visible on docs and user-profile pages with sparse content. The
   2-luminance-step shift removes the jarring while keeping the dark
   palette feel intact. gray-1000 stays in the scale for intentional
   black uses.

All 9 locales updated for (1) and (2).
2026-05-13 21:02:48 +02:00
davidmonterocrespo24 b7797b1eea feat(landing): AI agent + pricing sections
Two new sections on the landing page, between Features and Support:

1. "Powered by AI agents" — three cards explaining the in-editor agent
   (place & wire parts, generate code, diagnose circuits). Calls out
   DeepSeek-V4-Flash + Gemini 2.5 as the LLM backbone so visitors know
   the simulator does more than draw boxes.

2. "Pricing" — three cards summarising Free / Pro / Pro Max with the
   actual monthly cost, the daily AI-message quota, and a CTA per
   tier. Pro is highlighted as Most Popular. Free CTA opens the editor,
   the two paid CTAs link to /pricing where the PayPal subscription
   flow lives.

The simulator itself stays free — only the AI-agent quota changes per
tier — that copy is repeated in the section subtitle so visitors don't
worry about the boards/components becoming paywalled.

All 9 locales translated.
2026-05-13 20:24:40 +02:00
davidmonterocrespo24 d193954c2f feat(canvas): drag-threshold lets users move parts while running
Closes the long-standing "components are frozen during simulation"
complaint. Once the user clicked Run, interactive wokwi parts
(pushbuttons, slide-switches, potentiometers …) called
stopPropagation in their bubble-phase mousedown handlers and the
canvas's React onMouseDown never fired — so dragging them to
rearrange the layout was impossible without first stopping the sim.

Two surgical changes:

1. DynamicComponent.tsx switches the wrapper from `onMouseDown` to
   `onMouseDownCapture`. Capture phase runs before the inner
   wokwi-element, so the canvas sees the mousedown regardless of
   stopPropagation downstream. The existing posDiff < 5 check in
   mouseup keeps disambiguating click vs drag: a click still falls
   through to the wokwi-element's own mousedown/up for button-press
   semantics, only sustained movement promotes to a drag.

2. SimulatorCanvas.tsx's touch path used to early-return on touchstart
   when interactionRunning + .web-component-container, killing any
   chance of a touch-drag. Now we remember the touch's start position
   in pendingTouchDragRef and let the browser keep synthesizing mouse
   events for the wokwi-element. If the finger drifts past
   DRAG_PROMOTE_THRESHOLD_PX (8 px) onTouchMove cancels the
   passthrough and starts a real component drag — dispatching a
   synthesized mouseup on the original target so the wokwi-element
   doesn't stay visually pressed mid-drag.
2026-05-13 16:51:52 +02:00
davidmonterocrespo24 5d40408718 feat(landing): licensing section — AGPLv3 + commercial option
Adds a two-card section at the foot of the landing page (before the
brand footer) that surfaces Velxio's licensing model: AGPLv3 for the
public release, commercial license for teams that need to ship
Velxio inside closed-source products. Mirrors the existing
.feature-card visual language so it slots into the page without a
new design system.

Commercial CTA opens a mailto:info@velxio.dev. Open-source CTA links
to GitHub via the existing trackVisitGitHub handler so the analytics
event still fires.

All 9 locales translated.
2026-05-13 14:27:49 +02:00
David Montero Crespo 083e0df732 fix(canvas): board-less SPICE switches toggle on click instead of opening property dialog
In digital / analog board-less examples the user clicks a slide-switch
or pushbutton expecting it to flip its state. Until this commit the
component property dialog opened instead and the click never reached
the wokwi-element underneath, so:

  - The user couldn't change switch state through the canvas at all.
  - With no state change the SPICE solver kept the old netlist, and
    every downstream LED stayed dark — the symptom that read as
    "voltages change but no LED lights".

Root cause was the gating: SimulatorCanvas only suppressed the
property dialog when `useSimulatorStore.running` was true, but that
flag is bound to an MCU's start/stop. Board-less circuits have no MCU
to start so `running` is permanently false, even when the SPICE engine
has been live since the example loaded.

New derived flag `interactionRunning = running || (boards.length === 0
&& !electricalPaused)` — true whenever the user is in an "interactive"
session, MCU or SPICE-only. Used in three click-handling paths:

  - SimulatorCanvas mouse-up handler: dialog is suppressed and the
    click falls through to the wokwi-element (line 1395).
  - SimulatorCanvas touch-start passthrough: same for touch (line 474).
  - SimulatorCanvas touch-end short-tap: same for tap (line 774).

Also propagated to DynamicComponent so the cursor becomes pointer (not
move) for interactive parts in board-less mode — visual cue that the
user can click instead of just drag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:34:58 -03:00
David Montero Crespo 81ac2283c6 fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:

  1. useSimulatorStore.updateComponent only triggered updateWirePositions
     for x/y changes. A rotation went through properties.rotation, so
     wires never recomputed.
  2. calculatePinPosition didn't know about rotation. Even when called,
     it returned the unrotated offset, so the new endpoints would still
     have been wrong.
  3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
     so Ctrl+Z after a rotate left the canvas inconsistent.

Fix:

  - calculatePinPosition gets a 5th `rotation` argument. When non-zero,
    it finds the .dynamic-component-wrapper ancestor in the DOM, reads
    its offsetWidth/Height (layout-only, immune to CSS transforms) to
    locate the wrapper centre, and applies a 2D rotation matrix around
    that pivot. The wrapper top-left is recovered as (componentX - 4,
    componentY - 6) to match the offset convention updateWirePositions
    already uses.
  - updateWirePositions and recalculateAllWirePositions read the per-
    component rotation and thread it through.
  - updateComponent recomputes wires whenever properties.rotation
    changes, mirroring the existing x/y path.
  - recordRotate.execute and .undo both call updateWirePositions so
    Ctrl+Z keeps the canvas coherent.

Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:03:32 -03:00
David Montero Crespo 36ad2bef3f feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).

Implementation (6 phases):

1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
   walk the bridge graph with a visited Set so multi-hop chains
   (A↔B↔C with the device on C) resolve transparently.  A new
   forwarder-device shim is installed at intermediate hops so the
   existing handleExternalWrite/Read/Stop machinery routes
   through without per-method visited tracking.

2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
   global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
   bridges to the same ESP32 (e.g. wired to both Uno and Pico)
   don't wipe each other's proxies on teardown.  Interconnect's
   per-wire teardown calls clearProxiesForPeer(peerBus) instead of
   clearAllProxies.

3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
   bus + its transitive bridges, so an ESP32 sees devices on
   boards two or more hops away.  _peerDeviceLookup keeps a flat
   addr → device map for write-forwarding and resync.

4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
   while any proxy is live, re-dumping each device with
   dumpRegisters() and pushing updateProxyI2c only when an XOR-
   stride hash changes.  This keeps RTC time advancing visible to
   ESP32 firmware without flooding the WS pipe with static
   calibration dumps.  Hash is primed during initial sync so the
   first tick doesn't push a redundant identical buffer.

5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
   buffers write bytes during the transaction and emits a
   `proxy_i2c_complete` event on STOP / repeated-START.  Frontend
   Esp32Bridge dispatches the event to a new onProxyI2cComplete
   callback; the shim replays the byte sequence on the actual
   peer I2CDevice via writeByte() + stop().  Makes ESP32 firmware
   writes to peer SSD1306 actually repaint the OLED, peer PCF8574
   latch updates, peer I2CMemoryDevice register mutations propagate.

6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
   longer claims c3/xiao-c3/c3-supermini — they were already
   going through Esp32Bridge per the store's ESP32_RISCV_KINDS
   routing, but Interconnect was treating them as browser sims
   which broke proxy install.  isEsp32Bridge now correctly
   includes c3 family + ESP32-S3 + Arduino Nano ESP32.

Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.

Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
  (i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
  load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino

Result: 90 test files / 1295 tests pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:51:39 -03:00
David Montero Crespo 44789cf58b feat(auth): welcome email on register + password reset via Odoo mail relay
Adds the transactional email pipeline driven from the Odoo SMTP relay so
new sign-ups get a Velxio-branded welcome and existing users can reset a
forgotten password without us running our own outbound mail server.

Backend:
- PasswordResetToken model: one-time, SHA-256-hashed (plain text never on
  disk), TTL 60 min, marked used_at on consume to prevent replay.
- POST /auth/forgot-password — anti-enumeration (always 200 + generic
  message), rate-limited 3/hour/user.
- POST /auth/reset-password — verifies token, hashes new password,
  atomically marks token used.
- /auth/register hooked with asyncio.create_task to fire welcome mail —
  registration is never blocked on Odoo being up.
- New service app/services/odoo_mail.py: async httpx wrapper, fire-and-
  forget, swallows every error so the request lifecycle stays clean.
- Settings ODOO_URL / ODOO_API_KEY / ODOO_MAIL_TIMEOUT_S /
  PASSWORD_RESET_TOKEN_TTL_MINUTES / PASSWORD_RESET_RATE_LIMIT_PER_HOUR.

Frontend:
- /forgot-password page (single email field + "check your inbox" state).
- /reset-password?token=XYZ page (new password + confirmation, redirects
  to /login?reset=ok on success).
- "Forgot your password?" link + green confirmation banner on /login.
- authService gains requestPasswordReset() and resetPassword().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:34:30 -03:00
David Montero Crespo 71616e580d Add end-to-end tests for ESP32 I2C functionality and circuit verification
- Implemented `i2c-esp32-real-firmware.test.ts` to test ESP32 I2C communication via backend and WebSocket.
- Created `load-example-transitions.test.ts` to ensure proper loading of examples between board-less and board-based contexts.
- Added `CircuitVerificationModal.tsx` to display circuit verification results before running simulations.
- Developed `circuitVerifier.ts` to perform pre-flight checks for circuit safety, identifying potential issues like short circuits and component overloads.
- Introduced minimal ESP32 I2C master sketch `esp32_i2c_writer.ino` for testing I2C transactions.
2026-05-12 16:55:15 -03:00
David Montero Crespo a097601a73 Add HD44780Decoder and various I2C sketches
- Implement HD44780Decoder for decoding I2C commands to HD44780-compatible LCDs.
- Add bmp280_bridge_reader.ino to read BMP280 chip_id and status registers via I2C.
- Create i2c_scanner_multi.ino to scan I2C addresses and report responding devices.
- Introduce lcd_i2c_hello.ino to demonstrate basic LCD functionality with I2C.
- Implement pcf8574_bidirectional.ino to test bidirectional communication with PCF8574.
- Add pico_i2c_master_reader.ino for reading BMP280 from a Raspberry Pi Pico.
- Create rtc_lcd_clock.ino to display time from a DS1307 RTC on an I2C LCD.
2026-05-12 14:26:33 -03:00
David Montero Crespo e65fa69abd docs(frontend): update README to reflect npm packages instead of third-party clones 2026-05-11 13:03:52 -03:00
David Montero Crespo ac7d74b2b5 fix(monaco): cross-platform postinstall via Node script
Replace Unix-only shell one-liner (mkdir -p / printf / cp -r) with a
Node.js ESM script (scripts/copy-monaco.mjs) that works on Windows,
macOS and Linux alike. The script still writes public/monaco/.gitignore
to keep copied assets out of git.
2026-05-11 12:42:33 -03:00
David Montero Crespo 64024207d8 fix(monaco): ignore public/monaco/ in git per Copilot suggestion (PR #163)
- postinstall now writes a '*' .gitignore into public/monaco/ so the
  copied monaco-editor assets are never tracked as untracked files
- Also add public/monaco/ to frontend/.gitignore as a belt-and-suspenders
  fallback for the same reason
2026-05-11 12:24:45 -03:00
David Montero Crespo dec7a6ec56
Merge pull request #167 from naweiss/fix/wire-boxes
Fix wire hovering in desktop mode
2026-05-11 12:23:05 -03:00
David Montero Crespo 13789ae08c
Merge pull request #169 from naweiss/fix/wire-color-on-finish
Make wire connected to ground black even when ending in ground
2026-05-11 12:18:32 -03:00
David Montero Crespo 8de51da5a5 feat(simulator): wire color palette from PR #170 + Copilot suggestions
- Add color picker button to SelectionActionBar for wire selections
- Toggle palette using WIRE_KEY_COLORS swatches
- Pass currentColor and onColorChange from SimulatorCanvas
- Reset showPalette on kind/onColorChange change (Copilot suggestion)
- Use t('editor.selectionBar.changeColor') for title/aria-label (Copilot suggestion)
- Add changeColor i18n key to all 9 locale files

Co-authored-by: naweiss <naweiss@users.noreply.github.com>
2026-05-11 12:14:36 -03:00
David Montero Crespo 5c70f09afe
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 11:36:51 -03:00
naweiss f9ed3b3d44 Make wire connected to ground black even when ending in ground 2026-05-11 08:54:56 +03:00
naweiss fff98915f5 Fix wire hovering in desktop mode 2026-05-11 08:41:51 +03:00
naweiss 8d8e763724 Load monaco editor from local installation 2026-05-11 06:26:08 +03:00
davidmonterocrespo24 9c93d99802 fix(micropython-esp32): write helper .py files to flash before main.py
loadMicroPythonProgram only forwarded main.py (or files[0]) to the
bridge for raw-paste injection. Any auxiliary module the project
imported (mylib.py, drivers, etc.) never reached the device, so
`import mylib` died with ModuleNotFoundError.

Build a Python prelude that writes every other .py file to the
MicroPython filesystem via raw REPL, then runs main.py in the same
paste. JSON.stringify produces an ASCII-safe Python-compatible string
literal for the file body, which keeps the prelude inside the existing
chunked-UART path Esp32Bridge already uses to feed the 128-byte FIFO.

The RP2040 path was already multi-file via sim.loadMicroPython(files),
so it stays untouched.

Reproduces with the project shared in the bug report:
  https://velxio.dev/project/ac7e285c-8dc3-4d51-8751-b4aba9912f9e
2026-05-10 01:42:11 +02:00
davidmonterocrespo24 7edb0a6499 fix(editor): add missing useTranslation import in SensorControlPanel
Block 9 added `const { t } = useTranslation()` at line 50 but forgot the
matching `import { useTranslation } from 'react-i18next'`. The component
then crashes the moment a user clicks a sensor on the canvas with
`Uncaught ReferenceError: useTranslation is not defined`, taking the
whole simulator render tree down.
2026-05-10 00:16:03 +02:00
davidmonterocrespo24 1869ace89d test(metadata): drill into components array for BMP280 lookup
components-metadata.json is shaped { version, components: [...] }, not a
flat array. The previous test assumed the latter and crashed on
default.find at module load on master, breaking CI for every PR.
2026-05-09 23:52:36 +02:00
David Montero Crespo a7e796161e fix(i18n): split common.json (editor + about) so all 8 locales translate
The common bundle ballooned to 30KB after Block 15 added the AboutPage
prose, putting Russian translations past DeepSeek's 8192-token output
cap. The editor + about sub-trees (the two heaviest, ~15KB combined)
move to a new common2.json file. Both files now sit at 12-18KB and
translate cleanly.

i18n bootstrap merges common2 into the same common namespace at
load time and lazy-loads it per locale, so every existing t('editor.*')
and t('about.*') call keeps resolving without source changes.

All 9 locales regenerated via DeepSeek. Closes the gap left by the
Blocks 15+16 commit where only zh-cn/common had been refreshed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:50:21 -03:00
David Montero Crespo 3428ab130a feat(i18n): translate AboutPage prose + 15 SEO landing pages (blocks 15+16)
AboutPage: ~28 prose blocks across Story / How It Works / Open Source /
Creator / Releases / Quote / Community sections; <Trans> for paragraphs
with inline <strong>, <em>, <a> markup.

15 SEO landing pages now use t() for all user-facing copy under
seo.<page>.* keys: CircuitSimulatorPage, SpiceSimulatorPage,
ElectronicsSimulatorPage, CustomChipSimulatorPage, Attiny85SimulatorPage,
ArduinoSimulatorPage, ArduinoEmulatorPage, AtmegaSimulatorPage,
ArduinoMegaSimulatorPage, Esp32SimulatorPage, Esp32S3SimulatorPage,
Esp32C3SimulatorPage, RaspberryPiPicoSimulatorPage,
RaspberryPiSimulatorPage. Code blocks, FQBNs, JSON-LD schema strings
intentionally stay in English.

The seo bundle (67KB English source) is split into 4 balanced files
(seo.json + seo2.json + seo3.json + seo4.json, ~17KB each) so each
DeepSeek translation request stays inside the 8192-token output cap.
i18n bootstrap merges all 4 halves under the seo.* keyspace.

Translations: 8 locales × 4 seo bundles all regenerated via DeepSeek.
common.json (now 30KB after about additions) only has zh-cn refreshed
so far — the remaining 7 locales' common.json need a follow-up pass
(the bundle is at the edge of DeepSeek's output limit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:38:51 -03:00
davidmonterocrespo24 4a42a3e9a2 feat(compile): stream live ESP-IDF cmake + ninja output to the console
A user reported on Discord: "the Velxio Console doesn't update anything,
it just waits until the very end and displays everything in one go".
True for the async compile path — /compile/status only carried `state`
and the final `result`, so the editor's CompilationConsole stayed empty
during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once
when the build finished.

This wires live build output through the whole stack.

Backend (espidf_compiler.py)
- New _run_with_streaming() helper. When a progress_callback is provided
  it spawns the subprocess via Popen + stdout/stderr drain threads and
  invokes the callback line-by-line. When None it falls back to the
  existing subprocess.run(capture_output=True) one-shot path so the
  unit-test code that doesn't care about live output is unaffected.
- compile() and _compile_in_dir() take an optional ProgressCallback.
- _run_cmake / _run_ninja closures now go through _run_with_streaming
  with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both
  stream now; the ninja output is the one users actually want to watch.

Backend (compile.py)
- _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines
  on_progress_line(line) which appends to it. Buffer capped at 256 KB
  (tail kept) so a runaway build can't OOM the FastAPI process.
- The buffer is preserved on both the success and the error path so
  late polls still see the log even after state transitions to
  done/error.
- /compile/status now returns the buffer as a `stdout` field.
  CompileStatusResponse gains the field with default '' so old clients
  that don't read it still work.

Frontend (compilation.ts)
- compileCode() takes a 4th argument: optional CompileProgress
  callback fired every poll while state ∈ {pending, running}. Carries
  the cumulative stdout (caller computes deltas) plus elapsed seconds.
- Surfaces the new `stdout` field of /compile/status and forwards it
  to the callback. Errors thrown from the callback are swallowed —
  a faulty UI hook must never break the polling loop.

Frontend (EditorToolbar.tsx)
- Both compileCode() call sites (Run and Compile-All) now pass an
  onProgress callback. It tracks `lastStreamedLen` per-compile, splits
  each new delta on newlines, and appends them as `info`-typed
  CompilationLog entries via setCompileLogs. The Compile-All flow
  prefixes each line with the board label so multi-board builds stay
  readable.
- After the build settles, the existing parseCompileResult call still
  runs and appends the structured analysis on top of the live stream
  — that's where FAILED-block detection + the `error`-typed entries
  that drive the auto-switch-to-errors filter live.

Net effect on the user complaint: cold ESP-IDF builds now show the
ninja [N/1483] progress lines streaming into the console as they
happen, instead of staring at an empty panel for 5-7 minutes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:36:58 +02:00
David Montero Crespo 7f437a753e feat(i18n): translate DocsPage prose (block 14)
Wire useTranslation() + Trans into DocsPage.tsx. ~330 user-facing
strings across 13 sections (intro, getting-started, emulator, riscv,
esp32, rp2040, rpi3, components, roadmap, architecture, third-party,
mcp, setup) plus sidebar nav + page chrome are now keyed under docs.*.

Strings with inline <a>, <code>, <strong>, <em> use the <Trans/>
component with mapped slots; bare prose uses t().

Code blocks, FQBNs, hex addresses, library names visible as link text,
and JSON-LD schema strings stay in English on purpose.

Internal Link to=... wrapped with localize() so /es/docs/... etc.
keep their locale prefix.

The English docs bundle is split in half (docs.json ~22KB +
docs2.json ~22KB) so each fits inside DeepSeek's 8192-token output
window. The i18n bootstrap merges both halves into the docs.* keyspace
under the default common namespace.

All 9 locales regenerated via DeepSeek (parallel run for the two
namespaces).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:47:19 -03:00
David Montero Crespo 0ff76a0f9d feat(i18n): translate Velxio 2.0 + 2.5 release pages (block 13)
Wire useTranslation() into Velxio2Page and Velxio25Page: hero badge,
accent, subtitle, CTAs, board/example/outcome cards, OSS section, and
footer links all keyed under landing.v2.* and landing.v25.*.

Split en/common.json (34KB) into common.json (25KB) + releases.json
(9KB) so each translation request stays inside DeepSeek's 8192-token
output cap. i18n bootstrap merges both bundles into the default common
namespace at load time, lazy loader fetches both per locale.

translate-i18n.mjs: set max_tokens=8192 + response_format json_object
on the DeepSeek call so future bundles closer to the cap don't get
silently truncated.

All 9 locales regenerated via DeepSeek (fr/de/es/it/pt-br/zh-cn/ja/ru).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:19:16 -03:00
David Montero Crespo aae3ab1c5c feat(i18n): translate small editor remates + admin internals (Blocks 11+12)
Block 11 — small editor surfaces
- PinPickerDialog: close, filter pins, no-match, rotate / delete
  action buttons.
- RaspberryPiWorkspace: connection status (Connected / Starting…
  / Offline), Start Pi button + tooltip, Connect / Disconnect
  buttons + tooltips, Terminal tab label, file-tab close, the
  full offline overlay (title / subtitle / start CTA / two-line
  note about the staging area), terminal-loading + no-file-
  selected fallbacks.
- GitHubStarBanner: aria-label, title, body copy, Star CTA,
  dismiss button.
- ExampleLoaderPage: "Loading example…" status, "Example {{id}}
  not found." 404 line, "Browse all examples" recovery link.
  /editor and /examples links wrapped in localize().

Block 12 — admin-only internals (only admins ever see these)
- AdminBoardsTab: section headings ("By board family" / "By
  exact FQBN"), full table column headers (Family / FQBN /
  Compiles / Errors / Success rate / Runs / Distinct users /
  Distinct projects), range selector label, no-data fallback,
  load-failed error.
- AdminDashboardTab: 8 KPI cards (Total users / Total projects
  / Compiles / Runs / DAU / WAU / MAU / Public-Private), all
  chart titles + subtitles (Activity over time, Compiles by
  board family, Board diversity for pricing signal, Top FQBNs,
  Top countries with Cloudflare disclaimer, Top users / Top
  projects), table column headers, no-data fallbacks, load
  errors. Pluralised "{{count}} board(s)" via i18next plurals
  in the diversity pie chart.
- UserActivityModal: title with username interpolation, subtitle,
  range selector label, table column headers (Date / Project /
  Compiles / Errors / Runs / Saves), pluralised
  "{{count}} project(s)" line, deleted-project / no-project
  placeholder strings, no-activity empty state, load-failed.

All 8 non-English locales auto-translated via the
`scripts/translate-i18n.mjs` DeepSeek pipeline (one --force run,
~7 min). sameShape() validation passed on every output.
2026-05-09 16:22:16 -03:00
David Montero Crespo 6bc9fe80ce feat(i18n): translate AdminPage + UserProfile + Pricing + EditorPage shell (Editor block 10 — final cleanup)
Closes the Phase 2 i18n rollout. Every visitor- and user-facing
surface velxio renders in normal use now reads from t().

AdminPage (admin-only)
- Header (panel title, logout) and the four tabs (Dashboard /
  Users / Projects / Boards).
- Setup screen for first-admin creation (title, body, password
  fields + mismatch error + create-admin button).
- Not-admin gate page.
- EditUserModal (title, four labels, admin/active toggles,
  cancel/save).
- UsersTab: search placeholder, count pluralisation, all 12
  table columns, Activity / Edit / Delete actions, empty state,
  delete-confirm prompt with username interpolation.
- ProjectsTab: search placeholder, count pluralisation, all 9
  table columns, public/private badge labels, delete action +
  confirm with project-name interpolation, empty state.
- All error messages (load failed / save failed / delete failed)
  fall back through t().

UserProfilePage
- "New project" CTA, loading + empty + not-found states,
  "Private" project badge, "Copy shareable link" tooltip.
- The /editor link uses localize() so /es/<username>'s "New
  project" button stays in Spanish.

PricingPlaceholder
- Title + the two paragraphs (self-hosted note + hosted Pro
  tier note + GitHub source note). Inline links wrapped via
  the Trans component so the link surface stays clickable in
  every locale without each translation having to re-write the
  HTML.

EditorPage shell
- Mobile bottom-tab labels (Code / Circuit), file-explorer
  toggle (Show / Hide), View mode aria-label, view-mode
  segmented control labels (Code / Both / Circuit), and the
  three "Drag to resize" handle tooltips on the panel splitters.

Translations
- en.json hand-curated for the new keys.
- All 8 non-English locales auto-translated via the existing
  `npm run translate:i18n` pipeline (DeepSeek, ~5 min for the
  whole bundle, sameShape() validates each output before write).

This closes Phase 2 of i18n. Phase 3 (DocsPage prose, AboutPage
long-form paragraphs, the 15 SEO landing pages) is deliberately
deferred — Docs/About are best handled by extracting the prose
into JSON keys and running the same script, while the SEO pages
are intentionally optimised for English keyword targeting and
should not be machine-translated en masse.
2026-05-09 12:49:13 -03:00
David Montero Crespo 99156c9c9d feat(editor): translate Oscilloscope + property dialog + selection bar + console + custom chips + sensor + board picker (Editor block 9)
This commit closes the cluster of small editor surfaces that touch
the active simulation experience. Every visible control on these
panels now reads from t() keys.

Translated:
- Oscilloscope panel (title, Add Channel button + tooltip, Time/div
  label, Run / Pause toggle copy + tooltips, Clear, empty-state copy
  + hint, per-channel remove tooltip).
- ComponentPropertyDialog (close, pin-roles header with two
  wire-mode variants, Arduino Pin label, rotate / delete buttons +
  the inline confirm-delete prompt with name interpolation).
- SelectionActionBar (toolbar aria-label, Rotate / Delete / Deselect
  with kind-aware delete labels for wire / component / board).
- CompilationConsole (Output title, error / warning badge counts
  with i18next pluralisation, filter dropdown, autoscroll label,
  Clear + Close icon tooltips, empty-state).
- CustomChipDialog (header with chipName interpolation, Examples /
  Editor tabs, Attributes panel header, compile status messages
  including the "✓ Compiled — N KB" success line, footer
  Cancel / Save & Place / Compile first buttons).
- SensorControlPanel (close button).
- BoardPickerModal (Add Board heading).

Translation pipeline
- en.json gets the new keys hand-curated.
- The 8 non-English locales were auto-translated via DeepSeek
  using the existing scripts/translate-i18n.mjs pipeline (one
  --force run, ~1 min total). Output validated with sameShape()
  before write so any LLM-introduced key drift would have failed
  loudly.

Quality note
- DeepSeek's translations now cover the entire bundle, including
  earlier hand-translated content. Tone may differ slightly from
  the prior hand passes but the meaning is consistent and brand /
  technical terms (Velxio, ngspice-WASM, ATmega328P, ESP32-C3,
  etc.) are preserved unchanged in every locale per the prompt
  invariants.
2026-05-09 12:25:30 -03:00
David Montero Crespo ea290d0c22 feat(editor): translate InstallLibrariesModal + SerialMonitor (Editor block 8)
InstallLibrariesModal — auto-install prompt that fires when an
example needs libraries:
- Title, subtitle (with the "Installing X of Y" progress
  interpolation, the all-done success state, and the singular /
  plural prompt explaining the requirement count).
- Per-row status badges (pending / installing… / installed /
  error) plus the Wokwi-hosted-library tooltip.
- Footer buttons: Close / Skip / "Install All ({{count}})" with
  loading variant.

SerialMonitor — multi-board tabbed serial console:
- Empty-state when no board is on the canvas.
- Right-side tab controls: Autoscroll checkbox, Clear button +
  tooltip.
- The "(Open IoT Gateway)" inline link rendered next to detected
  AP IP addresses.
- Output-area placeholders for the running-but-no-data and
  before-start states.
- Send button + input placeholder (different copy for MicroPython
  REPL vs raw Serial input).
- The line-ending dropdown options (None / Newline / Carriage
  return / Both).

Hand-translated for all 9 locales. Hotkeys (Ctrl+C) and dropdown
values stay untranslated (constants the firmware reads).

Pending in Phase 3:
- Oscilloscope panel, custom-chip dialog, sensor control panel.
- ComponentPropertyDialog (per-component property forms).
- Admin / Profile / Project pages.
- Long-form docs prose (DocsPage 2715 lines, AboutPage long
  paragraphs).
- 15 SEO landing pages (intentionally English for keyword targeting).
2026-05-09 11:35:22 -03:00
David Montero Crespo c1b0398c0d feat(editor): translate ComponentPicker + LibraryManager modals (Editor block 7)
ComponentPickerModal — the "Add Component" dialog:
- Header (title + close button), search input placeholder + clear,
  category tabs (All Components / Boards), loading + empty-state
  copy, "Clear filters" button.

LibraryManagerModal — the Arduino library browser:
- Window title, Search / Installed tabs, filter input placeholder.
- Search-tab states: searching-for-query, generic loading, no
  results (with optional query interpolation).
- Per-library row: "by {{author}}" caption, Install / Installing /
  Uninstall / Uninstalling button labels.
- Installed-tab empty-state with the prompt to use the Search tab.

Brand and product names left untouched ("LIBRARY MANAGER" stays
all-caps in English; the localised variants follow each language's
convention for product UI titles). Hand-translated for all 9
locales.

InstallLibrariesModal still pending — it's the auto-install
prompt that fires when an example needs libraries; smaller scope
but lives in the same area.
2026-05-09 11:29:07 -03:00
David Montero Crespo 4df81a3eda feat(examples): translate ExamplesPage + ExamplesGallery to 9 locales
The /examples gallery is fully localised:
- Header (heading + subtitle).
- Search input placeholder + aria-label + the clear button.
- Match-count tag with i18next pluralisation (handles _one /
  _other and Russian's _few / _many).
- Category and Difficulty filter labels + their button labels
  (basics / sensors / displays / communication / games / robotics
  / circuits; beginner / intermediate / advanced).
- Per-card "Copy shareable link" tooltip.
- Empty-state copy with two variants (with-search / without-
  search) interpolating the search query.
- Reset-filters button.
- The library-install progress overlay copy from
  ExamplesPage.tsx ("Installing libraries (N/M)") with done/total
  interpolation.

Internal /editor link uses localize() so a Spanish reader who
clicks an example lands on /es/editor.

Hand-translated for all 8 non-English locales. Per-example titles
+ descriptions are NOT i18n yet — they live in the
src/data/examples* tables and would need a separate pipeline.
DocsPage (2715 lines of prose) deferred too — best handled by
running scripts/translate-i18n.mjs once the keys are extracted.
2026-05-09 11:24:53 -03:00
David Montero Crespo 6e09725727 feat(about): translate AboutPage chrome + CTA + footer (partial)
The visible chrome of /about now reads from i18n in all 9 locales:
- Hero title + subtitle
- 7 section headings (Story / How It Works / Open Source Philosophy /
  Creator / Recent releases / Community & Press / CTA)
- Final CTA card (title, subtitle, "Open Editor" button)
- Footer copy switched to t('footer.about') so it shows the AGPLv3
  About-Velxio paragraph instead of the stale MIT/avr8js credit
- Footer + CTA Links wrapped in localize() so /es/about's "Open Editor"
  routes to /es/editor

Long-form prose (Story body paragraphs, Open Source Philosophy
paragraphs, Creator bio, Releases blurbs, Personal-story quote, Press
section) deliberately stays in English in this commit. Each is a
multi-paragraph chunk that benefits from a curated translation pass
rather than an inline machine pass — slate it for a follow-up.

Tech-stack tags (Java, Python, React, Docker, etc.) and the creator's
name + role + GitHub/LinkedIn/Medium link captions stay untranslated:
all proper nouns / brand identifiers.
2026-05-09 03:27:40 -03:00
David Montero Crespo 20b22b1398 feat(auth): translate LoginPage + RegisterPage to 9 locales
Both auth forms now go through t('auth.login.*') and
t('auth.register.*'):
- Title + subtitle, email / password / username labels with
  username placeholder and password-min-length placeholder.
- Submit button toggles between idle and loading states.
- "or" divider, "Continue with Google" button, and the
  switch-to-other-form footer link.
- Inline validation errors: reserved username, username regex,
  password length, plus the generic catch-all from the API.

Internal /editor and /login / /register links wrapped in
localize() so a Spanish user who registers stays at /es/editor
after success.

AboutPage (519 lines) deferred to its own commit — too dense for
the same change.
2026-05-09 03:23:09 -03:00
David Montero Crespo ecc35f72cb feat(editor): translate SimulatorCanvas header + remove dialog (Editor block 4)
The canvas header (the bar above the simulation area) and the
"Remove board?" confirmation dialog now read from i18n.

Translated:
- Status dot tooltip (Running / Stopped).
- Active board selector tooltip + "No board" placeholder + the
  hint that prompts the user to add a board.
- Undo / Redo buttons: aria-label, dynamic title with the action
  description and the empty-state fallback. Action descriptions
  themselves stay untranslated (they come from the editor history
  store as English literals — translating them would mean reaching
  into a different store; deferred).
- Serial Monitor and Oscilloscope toggles (button title + label).
- Zoom in / out / reset-view buttons.
- Component count tooltip + Add Component button.
- The error-banner Dismiss button.
- "Remove board" item in the right-click menu, with a localised
  "(N wires)" parenthetical via i18next pluralisation.
- The full removal confirmation dialog: title with board label
  interpolation, body copy with optional connected-wires sentence,
  Cancel + Remove buttons.

Pluralisation uses i18next's _one / _other (and _few / _many for
Russian) suffixes so wire counts read naturally per language.

Hand-translated for all 8 non-English locales. Untouched (deferred):
the property dialog, custom-chip dialog, sensor control panel, and
the various inline tooltips on board pins and wire endpoints —
those are denser and benefit from a separate pass.
2026-05-09 03:20:36 -03:00
David Montero Crespo fa4f3d6e80 feat(editor): translate Save / Share / LoginPrompt modals (Editor block 3)
The three modal dialogs that fire during the editor's routine save +
share + auth-required flows are now fully localised across all 9
locales.

LoginPromptModal
- Title, body, and the three buttons (Sign in / Create account /
  Cancel). Sign in / Sign up Links use localize() so a Spanish
  reader prompted to log in lands at /es/login rather than dropping
  back to English.

SaveProjectModal
- Title (toggles between Save / Update), name + description fields
  with placeholders, save button (toggles between Save / Update /
  Saving…), Cancel button.
- Visibility toggle: Public / Private label + hint copy under the
  icon.
- All four error paths now go through t() with a {{status}}
  interpolation for the generic HTTP failure message.

ShareModal
- Title, public/private label + hint pair, "Make private" /
  "Make public" toggle, Copy button, the warning shown when the
  project is private, and the Close button.

Hand-curated translations for all 8 non-English locales. Status
codes (403) and shortcut markers preserved.
2026-05-09 03:12:52 -03:00
David Montero Crespo 7f0ac2a74c feat(editor): translate FileExplorer + FileTabs to 9 locales (Editor block 2)
FileExplorer (sidebar)
- Workspace header label and the new-workspace / save-project icon
  buttons now read from t('editor.fileExplorer.*').
- Per-board section: collapse / expand toggle, status dot tooltip
  (Running / Compiled / Idle), per-board "new file" button, and the
  composite "<board name> — click to edit" hover title (the board
  name itself stays untranslated — it's a product noun like
  "Arduino Uno").
- File rows: hover title with optional "(unsaved)" suffix,
  unsaved-dot tooltip, and the right-click context menu's Rename /
  Delete commands.
- Empty-state placeholder when no boards are on the canvas.
- The window.confirm() shown before deleting a file now reads from
  t() too, so non-English users see the prompt in their language.

FileTabs (open-tabs strip above the editor)
- Per-tab close button title and the unsaved-changes dot tooltip.
- Inline confirm dialog when closing a modified file: prompt copy,
  "Close anyway" and "Cancel" buttons.

Hand-translated for all 9 locales. Hotkey hints (Ctrl+S, Strg+S)
localised per German convention; other locales keep "Ctrl+S" as the
universally-recognised label.
2026-05-09 03:08:51 -03:00
David Montero Crespo 11012ec0e1 feat(editor): translate EditorToolbar to 9 locales (Editor block 1)
The top toolbar of the editor is now fully localised — compile / run /
stop / reset, the compile-all / run-all variants when multiple boards
are open, the language-mode select, libraries / import / export /
upload-firmware actions, and the missing-library hint banner.

Strings live under editor.toolbar.* in src/i18n/locales/<locale>/common.json.
Hand-translated for all 8 non-English locales. Brand and product
names (Arduino, MicroPython, Ctrl+B, .hex / .bin / .elf / .ihex,
GitHub Sponsors) preserved as-is.

Editor strings still pending: file explorer, file tabs, simulator
canvas, component picker, library manager modal, save / share /
project modals.
2026-05-09 03:05:51 -03:00
David Montero Crespo ca081f8e09 feat(landing): translate Support section + footer link labels (Block 4 of 4)
Final block. The Support section ("Support the project" / GitHub
Sponsors / Donate via PayPal) now reads from t('landing.support.*'),
and the footer link labels use t('header.nav.*') so they pick up
the same nav translations the header already ships.

This closes the LandingPage rewrite — every visitor-facing string
on velxio.dev/ goes through i18n now. Hand-translated for all 9
locales. "GitHub Sponsors" stays untranslated (proper product name).

Phase 2 remaining work:
- Editor (toolbar, file explorer, simulator canvas, component
  picker, library manager, save/share modals, error toasts).
- About / Examples / Docs / Profile pages.
- Login + Register forms.
2026-05-09 02:08:26 -03:00
David Montero Crespo ebfe6444d2 feat(landing): translate Features section + 6 feature cards (Block 3 of 4)
The Features section ("Everything you need") and the 6 cards
underneath (Real-Time SPICE Analog, 5 Emulation Engines, Custom Chips,
100+ Components, Live Instruments, Monaco Editor + arduino-cli) now
read from t('landing.features.<key>.{title,desc}') for all 9 locales.

Refactor:
- The `features` array in LandingPage.tsx no longer carries title/desc
  literals — only an icon + a translation key. The render maps each
  card's key to the matching i18n entry. Cleaner and keeps the JSX
  structurally stable across languages.

Translations:
- All 8 non-English locales hand-curated. Technical / brand names
  (ngspice-WASM, AVR8, ATmega328P, RP2040, ESP32-C3, CH32V003, QEMU,
  Cortex-M0+/A53, ILI9341, NeoPixel, Wokwi Custom Chips API,
  WebAssembly, .hex/.uf2/.bin, VS Code, arduino-cli) preserved
  unchanged in every locale — those are precise nouns where any
  translation would degrade meaning.
2026-05-09 02:06:53 -03:00
David Montero Crespo a03461d1e6 feat(landing): translate Boards / supported-hardware header (Block 2 of 4)
Replaces the visible header copy of the supported-hardware section
with t('landing.boards.*') keys:
- label "Supported Hardware"
- titleLine1 / titleLine2 ("Every architecture." / "One tool.")
- subtitle (the "19 boards across 5 CPU architectures..." paragraph)

The five engine cards underneath (avr8js, rp2040js, QEMU lcgamboa,
QEMU Xtensa, QEMU ARM) and per-board specs (e.g. "ATmega328p · 32 KB",
"RP2040 + WiFi") deliberately stay in English — those are accurate
technical specs / product names that don't translate, and mixing
locales inside a spec line would hurt readability more than it
helps.

Hand-curated translations for all 8 non-English locales.
2026-05-09 02:04:00 -03:00
David Montero Crespo fb0bf95b3d feat(landing): translate hero block to 9 locales (Block 1 of 4)
Hero strings now go through `t('landing.hero.*')`:
- titleLine1 / titleAccent (split for the gradient span)
- subtitle (one paragraph; "19 boards / 48+ parts" stays inside the
  string so locales can phrase the count naturally)
- ctaPrimary / ctaGithub
- trustLine (the "no signup / runs in browser / free & open-source"
  reassurance line — was previously emitting NBSP-wrapped middle
  dots; the localised versions use plain spaces, which is fine
  visually)
- imageAlt (a11y for the editor screenshot)

Internal /editor link now goes through localize() so a Spanish reader
clicking the primary CTA stays at /es/editor instead of dropping
back to English.

Translations are hand-curated for all 8 non-English locales (es,
pt-br, it, fr, zh-cn, de, ja, ru). Brand names (Velxio, Arduino,
ESP32, Raspberry Pi, GitHub, AGPLv3) preserved as-is. The script
arrow "→" is kept in every locale because it carries directional
meaning that translates naturally across languages.

Block 2 (Boards / supported hardware), Block 3 (features grid),
Block 4 (Support / footer copy) and Editor strings still pending.
2026-05-09 02:01:34 -03:00
David Montero Crespo 761bd83a75
Merge pull request #150 from davidmonterocrespo24/async-compile
feat(compile): async compile + status polling — no more 524 timeouts
2026-05-09 01:54:29 -03:00
David Montero Crespo cc077c09d1 feat(i18n): react-i18next foundation + 9-locale support for header / footer
This is Phase 1 of multi-language support: the visible chrome (header,
footer, language switcher) and routing are wired up for all 9 locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru) — same set the blog at
velxio.dev/blog/ already supports. The Editor and the long-form
landing-page copy are still English-only and will be translated in a
follow-up.

Infrastructure
- frontend/src/i18n/config.ts: locale registry + LOCALE_META (htmlLang,
  native name, og locale, dir). Mirrors pro/blog/src/i18n/config.ts so
  cookie sync stays consistent.
- frontend/src/i18n/cookie.ts: read/write the velxio_locale cookie at
  Path=/; Max-Age=1y; SameSite=Lax (Secure on HTTPS). The blog reads
  the same cookie via an inline script in its Layout.astro.
- frontend/src/i18n/path.ts: getLocaleFromPath / stripLocaleFromPath /
  localizedPath / switchLocale / blogUrlFor — match the blog's helpers
  one-to-one.
- frontend/src/i18n/index.ts: i18next bootstrap. English bundle is
  inlined synchronously for first paint; non-default locales are
  lazy-loaded via dynamic import on demand. Initial locale is decided
  in priority order URL > cookie > navigator > en.
- frontend/src/i18n/LocaleSync.tsx: top-level wrapper inside <Router>.
  On every URL change loads the matching locale bundle, calls
  i18n.changeLanguage, writes the cookie, and mirrors the locale onto
  <html lang> and dir.
- frontend/src/i18n/useLocalizedNavigate.ts: useCurrentLocale,
  useLocalizedHref, useLocalizedNavigate hooks for components that
  build internal links.

Routing
- App.tsx: route table extracted to a single ROUTES array, then
  registered twice — once at the root (default English) and once
  nested under each non-default locale (`/<locale>/...`). Explicit
  per-locale parent routes (rather than a generic `:lang` param) so
  React Router never accidentally swallows a real top-level path
  like `/circuit-simulator` as a locale segment.

Header / Footer
- LanguageSwitcher.tsx + .css: dropdown matching the blog's
  LanguageSwitcher.astro. Globe icon + locale code on the trigger,
  native names + ISO codes in the menu. Click → `switchLocale()`
  rewrites the URL under the new locale; LocaleSync handles the
  rest (load bundle, change language, write cookie).
- AppHeader.tsx: every nav label and the auth dropdown copy now
  goes through `t('header.nav.*')`, `t('header.auth.*')`. All
  internal Links wrapped with localize() so navigation stays
  inside the active locale. Added a "Blog" link computed via
  `blogUrlFor(currentLocale)` so /es/ → /blog/es/, etc.
- LandingPage.tsx: footer About-Velxio paragraph reads from
  t('footer.about').

Translations (Phase 1 strings)
- frontend/src/i18n/locales/<locale>/common.json: nav labels, auth
  buttons, footer About copy. Hand-translated for all 9 locales,
  AGPLv3 / brand names preserved as-is.

Tooling
- frontend/scripts/translate-i18n.mjs: standalone Node script that
  takes the en.json bundles and auto-translates them to the 8 other
  locales via DeepSeek (primary) + Gemini (fallback). One LLM call
  per (locale, namespace) pair. Run after extracting new strings
  with `npm run translate:i18n`.

Phase 2 (deferred)
- Editor (toolbar, file explorer, simulator canvas, component picker,
  library manager, error toasts) — hundreds of strings.
- Examples / Docs / About / Profile pages.
- The translate-i18n.mjs script is ready to handle these once the
  strings have been extracted into JSON keys.
2026-05-09 00:40:37 -03:00
David Montero Crespo 1e6f5474c3 feat(landing): footer copy = About Velxio (replaces wrong MIT/avr8js line)
The previous footer credit ("MIT License · Powered by avr8js &
wokwi-elements") was wrong twice over: velxio is AGPLv3 (with a
commercial license available), and the project now ships much more
than the two libraries it singled out (rp2040js, eecircuit-engine,
QEMU, ESP-IDF, arduino-cli, Monaco editor, ...).

Replace it with a one-paragraph About Velxio that sits at the bottom
of the landing page, mirrors what the blog footer shows at
velxio.dev/blog/, and correctly states the AGPLv3 license.

Widen .footer-copy to max-width: 680px so the longer copy has room
to breathe and breaks across two lines on desktop.
2026-05-09 00:24:13 -03:00
davidmonterocrespo24 23fc335e5d feat(compile): async compile + status polling — no more 524 timeouts
The synchronous /api/compile endpoint forced one long-lived HTTP request
to span the entire build. Cloudflare's 100s edge timeout cuts that off
mid-flight for any cold ESP-IDF compile (BMP280 takes 5-7 min on first
run). The user-visible symptom was HTTP 524 well before the backend
even noticed.

Backend (compile.py)
- New `POST /api/compile/start` returns `{job_id}` immediately and
  spawns the actual compile as an asyncio.create_task background.
- New `GET /api/compile/status/{job_id}` returns the current job state
  (`pending` | `running` | `done` | `error`). Each poll completes in
  milliseconds, far under any edge timeout.
- Existing `POST /api/compile/` kept verbatim for backward compatibility
  (AVR/RP2040 builds finish in seconds and don't trip 524).
- Build logic extracted into `_run_compile()` so both paths share one
  implementation; no duplicated ESP-IDF / arduino-cli branching.
- Async path opens its own short-lived DB session via AsyncSessionLocal
  for metric recording — the request-scoped session is dead by the time
  the background task finishes.
- COMPILE_JOBS dict purges entries 30 minutes after completion so a
  busy server doesn't grow unboundedly.

Frontend (compilation.ts)
- compileCode() now: POST /compile/start → poll /compile/status every 2s
  until state ∈ {done, error}, with a 15-minute client-side cap.
- 30s axios timeout per individual call (not per build) so transient
  network blips during a long compile auto-retry instead of failing.
- 404 on /status throws (job expired / server restarted); other poll
  errors warn and retry. Surfaces structured error responses verbatim
  so the editor's compile-error panel keeps working unchanged.

Limitation: COMPILE_JOBS lives in-process; if velxio ever scales to
multiple FastAPI workers this needs to move to Redis or sqlite. Single-
instance is fine today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:22:09 +02:00
David Montero Crespo 20eabd8c4c fix(scripts): generate-component-svgs no longer skips bmp280 / fails on ssd1306
Two distinct issues hit the component SVG generation step:

1. `velxio-bmp280` was in ELEMENTS but tries to require
   bmp280-element.js from the wokwi-elements CJS dist — that file
   doesn't exist because BMP280 is a velxio-native component, not a
   wokwi one. Its SVG already ships hand-authored at
   frontend/public/component-svgs/bmp280.svg, so the script should
   never have tried to extract it. Drop the row and leave a comment
   explaining why.

2. `wokwi-ssd1306` failed with "ImageData is not defined" because the
   element constructor seeds an off-screen canvas with
   `new ImageData(width, height)` — a browser API absent in Node.
   We never invoke putImageData (renderSVG() draws the static frame
   from scratch), so a minimal global stub that doesn't throw is all
   that's needed. Polyfill it on globalThis next to the existing
   customElements stub.

After this:
- 38 generated, 0 skipped, 0 failed (was: 1 skip, 1 fail).
- ssd1306.svg now ships in frontend/public/component-svgs/.
2026-05-09 00:05:28 -03:00
David Montero Crespo b42f815b49 feat(components): swap BMP280 + ATtiny85 to fritzing art
The hand-drawn SVGs in Bmp280Element.ts and Attiny85Element.ts were
functional but obviously amateur next to a real Fritzing-drawn part.
Both components now mount the equivalent Fritzing breadboard SVG as a
public static asset (`<image href>` in the shadow DOM SVG), with pin
coordinates remapped to the new artwork and pin-name labels overlaid
on top so the user can still read each connector at a glance.

frontend/public/component-svgs/bmp280.svg (new)
  Verbatim copy of third-party/fritzing-parts/svg/core/breadboard/
  bmp180_breadboard.svg. The Adafruit BMP180 breakout is the
  mechanically identical Bosch predecessor — same I2C interface,
  same 4-pin pinout. Pin labels lifted from the matching .fzp.

frontend/public/component-svgs/attiny85.svg (new)
  Verbatim copy of the Fritzing ATtiny85 DIP-8 breadboard art.

Bmp280Element.ts
  Width 80×100 px (Fritzing aspect 28.35:35.43 ≈ 0.8:1, exact uniform
  scale of 2.822 px/mm). Pin coords for SDA / SCL / GND / VCC matched
  to the connector centres in the source SVG. Pin labels overlaid on
  top. Existing wired example (esp32-bmp280) re-routes automatically
  because the wire system reads coords by pin name from pinInfo.

Attiny85Element.ts
  Width 160×132 px (Fritzing aspect 28.801:23.768 ≈ 1.21:1, exact
  uniform scale of 5.555 px/mm). The Fritzing layout puts pins on the
  TOP and BOTTOM edges (4 each), not LEFT and RIGHT like the older
  hand-drawn version. Pin coords land on clean numbers
  (x ∈ {20, 60, 100, 140}, y ∈ {6, 126}). Built-in LED on PB1 stays
  as an overlaid circle outside the chip body.
  Wires in the existing attiny85-* examples re-route automatically by
  pin name; external components positioned to the right of the chip
  may need a manual nudge for clean routing — but they work.

frontend/src/components/simulator/BoardOnCanvas.tsx
  attiny85: { w: 160, h: 100 } → { w: 160, h: 132 } to match the new
  aspect ratio. Same width as before so the chip occupies the same
  horizontal slot in existing example layouts.

scripts/component-overrides.json
  BMP280 thumbnail updated to mirror the Fritzing colour scheme
  (dark blue PCB, BMP180 silkscreen, four gold connector circles)
  so picker and canvas feel consistent.

frontend/public/components-metadata.json
  Regenerated.

docs/THIRD_PARTY.md
  New "Fritzing parts library" section. Both new assets are listed
  with their upstream paths plus the CC-BY-SA licence and link to
  the parts repo. Future Fritzing copies must be added there too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:06:44 -03:00
David Montero Crespo c939005bf7 test(esp32): integration tests for QEMU examples
Closes the loop on the four ESP32 fixes that landed earlier in this
series. Each previously-broken or noisy example now has a regression
test that compiles the sketch through the production ESP-IDF compiler
and runs it in QEMU via esp32_worker.py — the same code path the
WebSocket /ws/{client_id} endpoint drives in production. Testing the
worker directly skips the WS transport but exercises the same compile
→ flash → boot → serial cascade.

Coverage:
- TestEsp32SerialCleanliness — DHT22, Servo+Pot, Joystick, Dual ADC
  Asserts the user's Serial.print substring shows up AND no
  `I (xxx) gpio:|wifi:|phy:` info-level ESP-IDF logs leak through.
  This validates the sdkconfig CONFIG_LOG_DEFAULT_LEVEL_WARN change
  from commit b373c97.

- TestEsp32CompileSuccess — BLE Advertise, LEDC RGB
  BLE Advertise validates the sdkconfig switch to Bluedroid (was
  NimBLE-only, which broke arduino-esp32's BLEDevice.h).
  LEDC RGB validates velxio_compat.h's ledcAttach() shim from
  commit f6f6f43; the sketch uses the arduino-esp32 3.x one-shot API
  on a 2.0.17 toolchain.

- TestEsp32WiFiSketches — WiFi Connect, WiFi WebServer
  Regression coverage to make sure the sdkconfig changes didn't break
  WiFi association. Connect must reach an "IP Address:" line; Server
  must report "Server started".

- frontend/src/__tests__/component-metadata-bmp280.test.ts
  Sanity check that the BMP280 entry from commit 1f3f2e0 survives
  metadata regeneration.

All ESP-IDF/QEMU tests use unittest.skipUnless on
_toolchain_available() so they no-op cleanly on dev boxes without
libqemu-xtensa, and only do real work in the Docker CI image.

Sketches are inlined verbatim from the public Velxio examples.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:39:22 -03:00
David Montero Crespo 1f3f2e07bb feat(components): register BMP280 in component metadata
A beta tester reported "BMP280 - module graphic missing" — picking the
example loaded a working sketch but the canvas component fell back to
the MPU6050 placeholder.

Bmp280Element.ts already exists and registers velxio-bmp280 with the
right pinInfo, but it was never injected into components-metadata.json,
so the component picker and CircuitPreview didn't know about it. Add
an entry in scripts/component-overrides.json under _customComponents
(per CLAUDE.md §6b — direct edits to the generated JSON would be
clobbered by the next metadata regen) and regen.

The thumbnail mirrors the GY-BMP280 breakout look from the Web
Component itself: green PCB, black die label, four gold pin pads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:26:12 -03:00
David Montero Crespo bba935f93d fix(serial-monitor): strip ANSI escape sequences from board output
Beta testers saw raw `[0;32m` and `[0m` text mixed into the serial
monitor for several ESP32 examples. Those are ANSI SGR escapes the
ESP-IDF logger emits to color INFO/WARN lines on a real terminal. Our
<pre> renders them literally because there was no ANSI handling in
the path.

Strip the SGR sequences (`\x1b\[[0-9;]*m`) before the IP-linkifier so
the user only sees plain text. Combined with the sdkconfig change that
drops the default log level to WARN, the ESP32 output is now as clean
as the AVR output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:32:59 -03:00
David Montero Crespo 2bcc8a62ee feat(canvas): inline two-step delete confirm in ComponentPropertyDialog
Replaces the window.confirm("Delete X?") modal with a footer that flips
into a "Delete X?" prompt + Cancel / Delete pair when the user arms the
delete. Less jarring on mobile (no native dialog), keeps the user's
flow inside the property panel.
2026-05-08 12:25:13 -03:00
David Montero Crespo 0fcd6221b5 fix(canvas): use lucide-react Undo2/Redo2 for the toolbar icons
The two hand-rolled curved-arrow SVGs I drew in bd5fd18 looked off — the
arrowheads were misaligned and the curve clipped at the bottom of the
viewBox. Swapped both for the canonical lucide-react icons (Undo2 /
Redo2), which match the visual weight + alignment of the rest of the
toolbar.

lucide-react was already in the OSS deps (added when other parts of the
app started using it). No new dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:19:50 -03:00
David Montero Crespo bd5fd18756 feat(canvas): keyboard shortcuts + toolbar undo/redo buttons
UI-facing half of the undo/redo feature. Combined with the previous two
commits, Ctrl+Z (or the toolbar button) now reverses every canvas
mutation: add/remove component, move, rotate, set property, add/remove
wire.

EditorPage.tsx:
- New window-keydown effect for Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z (and the
  Cmd equivalents). Uses the same input/textarea/contenteditable guard
  as the existing Ctrl+S handler — Monaco's per-file undo and the AI
  chat composer keep their own behaviour.

SimulatorCanvas.tsx:
- Two icon buttons (undo + redo) added to the canvas header, between
  the board selector and the Serial Monitor toggle. Tooltip surfaces
  the next command's description ("Undo: Add LED (Ctrl+Z)") so the
  user knows exactly what's about to revert. Buttons disable when the
  stack is empty in that direction.
- New `canvas-icon-btn` CSS class for square 32×32 icon-only buttons
  (matches the visual weight of the existing Serial button without
  the label).
- Subscribes to history / historyIndex via store selectors so the
  buttons re-render reactively as commands are pushed/undone.

No new tests — the store-level coverage from 99ed22b already exercises
undo/redo round trips. UI affordances are wired pass-through to those
store APIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:11:53 -03:00
David Montero Crespo df8e666aa3 feat(canvas): SimulatorCanvas routes mutations through record* actions
Wires every user-initiated canvas mutation in SimulatorCanvas to the
recorded variants added in 99ed22b, so Ctrl+Z (next commit) can roll
each one back as a single step.

Routed through record*:
- handleSelectComponent (picker → add) → recordAddComponent
- Delete keyboard handler (selectedComponentId branch) → recordRemoveComponent
- handleRotateComponent → recordRotate (still mutates live via
  updateComponent so the rotation visually applies; record stores the
  prev/next angles for undo)
- Drag → recordMove on mouseup. Captures component.x/y at mousedown in
  a new dragStartPosRef and only records on actual drag-end (skips the
  click branch that just opens the property dialog).
- Pin-click "finish wire" path → calls finishWireCreation (which
  atomically appends the wire) then pushes a CanvasCommand for that
  wire with applyNow:false (state is already at post-add).
- Selected-wire delete (keyboard + SelectionActionBar + PinPickerDialog)
  → recordRemoveWire
- Selection action bar component delete → recordRemoveComponent
- Pin picker dialog component delete → recordRemoveComponent
- ComponentPropertyDialog onPropertyChange → updateComponent applies
  live, then recordSetProperty captures prev/next so Ctrl+Z reverts the
  value without re-running the raw mutation.

Cleaned up unused destructures of addComponent / removeComponent /
removeWire from the original useSimulatorStore() call — every call site
now uses the record* equivalents.

No new keyboard shortcuts or toolbar buttons in this commit; that's
landing next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:09:20 -03:00
David Montero Crespo 99ed22ba5d feat(canvas): undo/redo command pattern + history slice
Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts,
toolbar buttons and agent-tools refactor land in follow-up commits.

useSimulatorStore.ts:
- New CanvasCommand type — { description, execute, undo }.
- HISTORY_MAX = 50 (oldest entries dropped on overflow).
- New state: history[] + historyIndex (-1 = empty).
- New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo,
  clearHistory.
- New "recorded" actions that wrap raw mutators with a CanvasCommand:
  recordAddComponent, recordRemoveComponent, recordMove, recordRotate,
  recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire.
- recordRemoveComponent captures both the component AND any wires that
  cascade with it, so undo restores both atomically.
- recordMove also re-runs updateWirePositions on undo/redo so wire
  endpoints follow the component back/forward.
- setComponents and setWires (project-load / clear paths) now call
  clearHistory inline — leaving stale commands pointing at IDs that no
  longer exist would crash on undo.

Why custom Command pattern over zundo / travels:
- The store has 30+ ephemeral fields (simulator instances, serialOutput
  growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s
  on drag). Snapshot/diff middleware would either burn memory tracking
  them or need a fragile partialize allow-list.
- Per-op descriptions ("Add LED", "Move resistor") for tooltips come for
  free with this approach; zundo/travels would need to infer them.

Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50,
redo-truncation, cascade undo of remove-component, move/rotate/property
round trips, bulk-setter clearing.

Raw mutators (addComponent / removeComponent / updateComponent / addWire /
removeWire / updateWire) are unchanged. UI handlers can keep using them
during live drags for preview frames without spamming history; the
record* actions are what drag-end, click-finish and agent tools should
call going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:03:17 -03:00
David Montero Crespo 5114c40af5 chore(about): refresh community stats
- GitHub Stars: 600+ → 2,000+ (project crossed 2K)
- Supported Boards: 19 → 17 (matches the actual BOARD_KIND_LABELS
  count: 3 AVR Arduino + ATtiny85 + 2 RP2040 + Pi 3 + 4 ESP32 Xtensa-LX6
  + 3 ESP32-S3 + 3 ESP32-C3 = 17). The previous 19 was inflated.
- CPU Architectures: 5 → 6 (AVR, RP2040 ARM Cortex-M0+, Cortex-A53 64-bit,
  Xtensa LX6, Xtensa LX7, RISC-V RV32IMC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:28:47 -03:00
David Montero Crespo ebde9cc8da feat(about): real avatar + Recent releases section linking to /v2 and /v2-5
- Replace the "DMC" initials placeholder with the GitHub avatar
  (https://avatars.githubusercontent.com/u/47928504?v=4). The CSS for
  .about-creator-avatar already had the rounded frame; just swapped to
  object-fit: cover so the <img> fills the circle correctly, plus a
  subtle ring + drop shadow.
- Add a "Recent releases" section between the Creator block and the
  personal-story quote, with two cards:
    - Velxio 2.5 (Latest) → /v2-5  (ngspice-WASM analog co-simulation)
    - Velxio 2.0          → /v2
  Each card has a tagline + 2-3 line blurb. The 2.5 card gets a blue
  border + "Latest" tag so it reads as the current launch. About now
  surfaces both release pages, which previously were only linked from
  the Circuit/Electronics/SPICE simulator pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:38 -03:00
David Montero Crespo c20a7498fc feat: add touch-friendly components for improved mobile usability
- Implemented PinPickerDialog for selecting pins on touch devices.
- Added SelectionActionBar for managing selected items with touch actions.
- Created WireModeBanner to provide feedback during wire creation.
- Introduced useTouchDevice utility for detecting coarse pointer input.
- Refactored WireLayer to utilize useIsCoarsePointer for touch detection.
2026-05-08 11:12:26 -03:00
David Montero Crespo b83b6f28d6 fix(vite): only preserveSymlinks during dev, not build
Previous commit unconditionally enabled preserveSymlinks when
VITE_PRO_BUILD was set. That works for `vite dev` (where the overlay
is wired in via a Windows junction and the resolver needs to keep the
junction path so relative imports back into the OSS sibling dirs
resolve), but it BREAKS `vite build` in Docker — there are no symlinks
to preserve, and Rollup with preserveSymlinks=true fails to resolve
relative imports from the copied overlay tree:

    Could not resolve "../../../services/componentRegistry"
    from "src/pro/agent/tools/canvas.ts"

Gate the flag on `command === 'serve'` so it only kicks in during dev.
Production builds always run with preserveSymlinks=false (the default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:18:40 -03:00
David Montero Crespo 78f990b04f
Merge pull request #146 from davidmonterocrespo24/examples-real-thumbnails
feat(examples): add 53 missing thumbnails — 100-days IoT + Pico W WiFi
2026-05-08 01:11:38 -03:00
davidmonterocrespo24 fc985bb385 feat(examples): add 53 missing thumbnails — 100-days IoT + Pico W WiFi
Discovery regex was matching only single-quoted ID literals, so the
auto-generated examples-100-days.ts file (which uses double-quoted
strings, by convention of its Python emitter) was completely missed.
Same for picow-wifi: the script wasn't reading examples-picow-wifi.ts
at all.

Two-line fix in scripts/capture-example-thumbs.mjs: the regex now
accepts both `'…'` and `"…"`, and the data-file list includes
examples-picow-wifi.ts.

Captured slugs:
- 49 × `100d-*` (100 Days of IoT — MicroPython on ESP32 / Pico)
- 4  × `picow-*` (Pico W WiFi — async LED, relay web server,
  servo web, websocket LED)

Coverage: 219/226 examples (97%). The 7 still falling back to
CircuitPreview are component-ID literals (`epaper-1in54-bw`,
`epaper-2in13-bw`, etc.) that the regex over-matches — they 404
on /examples/<slug> because they aren't real example IDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:57:35 +02:00
David Montero Crespo 70cc8c2e11 feat(editor): view-mode toggle, agent-chat slot, toolbar polish
Changes that ship to OSS — all benign for self-hosters, but most are
extension points the velxio-prod overlay (and any private fork) needs to
plug an in-editor AI chat into the page.

Editor:
- 3-way view-mode toggle (code / both / circuit) in the unified toolbar.
  Lets users hide a pane to give a right-docked sidebar (e.g. the AI
  chat overlay) more breathing room. Persisted in useEditorStore.
- Default file explorer narrower (210 → 165 px); min 110.
- Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip);
  the BoardSelector dropdown elsewhere already shows the active board.
- Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow
  menu gave up too much discoverability. Removed dead overflow state.

Simulator:
- Fix: global Delete/Backspace handler in SimulatorCanvas no longer
  fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable
  — affected any in-page text field, not just the chat overlay.

Overlay extensibility:
- New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so
  pro overlays can portal a chat panel into the editor without
  forking the page.
- vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set.
  Lets local-dev junctions (overlay tree → frontend/src/pro) resolve
  bare imports back to the OSS node_modules without resolving symlinks.

Deps:
- Added react-markdown + remark-gfm (rendered chat output) and
  @google/genai + zod (overlay agent loop). Tree-shaken from the OSS
  bundle when no pro code imports them.

gitignore:
- Ignore backend/app/pro/ and frontend/src/pro/ junctions used by
  developers running a private overlay against the OSS dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:56:51 -03:00
David Montero Crespo bb14ab663a
Merge pull request #145 from davidmonterocrespo24/examples-real-thumbnails
feat(examples): add 7 ePaper example thumbnails
2026-05-08 00:42:09 -03:00
davidmonterocrespo24 3df43ca52e feat(examples): add 7 ePaper example thumbnails
The ePaper examples in examples-displays-epaper.ts use slugs prefixed
`epaper-*` (e.g. `epaper-1in54-uno-hello`), but the previous discovery
regex was matching `epd-*` — those are ePaper-component IDs that
appear in wire definitions, NOT example IDs. So /examples/epd-154
returns 404 ("Example Not Found") and the 7 actual ePaper examples
were never captured.

Fix: discovery regex now reads `epaper-` (the real prefix). Captured
all 7:
  - epaper-1in54-uno-hello   (Uno + 1.54" SSD1681)
  - epaper-2in13-pico-clock  (Pico + 2.13")
  - epaper-2in9-esp32-weather (ESP32 + 2.9" weather panel)
  - epaper-4in2-pico-image   (Pico + 4.2" image)
  - epaper-7in5-esp32-dashboard (ESP32 + 7.5" dashboard)
  - epaper-2in9-bwr-esp32-alert (ESP32 + 2.9" black-white-red)
  - epaper-5in65-7c-esp32-rainbow (ESP32 + 5.65" 7-color)

Coverage now: 166/166 examples (was 159/166).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:36:28 +02:00
David Montero Crespo 49d6dceaed
Merge pull request #144 from davidmonterocrespo24/examples-real-thumbnails
feat(examples): add 30 analog-only example thumbnails
2026-05-08 00:23:59 -03:00
davidmonterocrespo24 41244f6f6c feat(examples): add 30 analog-only example thumbnails
The analog-only circuits in examples-analog.ts (slugs prefixed `an-*`)
have no SEO route — they aren't in sitemap.xml — and the capture
script previously discovered slugs from sitemap only, so all 30
fell through to the CircuitPreview SVG mock which doesn't draw the
wires for these layouts.

Updated discovery: also grep the local velxio submodule's
examples-analog.ts for `an-*` ID literals (and `100d-*` / `epd-*`
while we're at it for the 100-days and epaper data files), in
addition to the sitemap pull.

Updated wait condition: capture now waits for any board OR component
OR wire path inside .canvas-world, not specifically [data-board-id]
(analog-only examples have no Arduino, just a signal-generator + parts).

Coverage: 159/166 examples have real screenshots now (was 129/166).
The 7 `epd-*` epaper examples are still falling back to CircuitPreview
because they don't render an "Open in Simulator" CTA on /examples/<slug>;
they'll need a separate loader path.

Re-captured the 109 existing thumbs at the same time — content is
visually identical for those, no regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 04:57:10 +02:00
David Montero Crespo 9d36eac816
Merge pull request #143 from davidmonterocrespo24/examples-real-thumbnails
Examples real thumbnails
2026-05-07 17:56:44 -03:00
davidmonterocrespo24 37fbc502d1 chore(examples): drop PNG thumbnails, ship WebP only (-80% asset size)
The PNG fallbacks were carrying 80% of the gallery's bundled weight
(16 MB of 20 MB) and serving virtually no traffic — WebP is supported
on ~97% of in-use browsers, and the few hold-outs (very old Safari)
fall through to the CircuitPreview SVG mock via the existing onError
handler. No visual regression for modern browsers.

Numbers:
- before: 258 files, ~20 MB total
- after:  129 files, ~3.6 MB total (avg 28.6 KB / WebP)

ExampleThumbnail simplified: drops the <picture>/<source> wrap around
the WebP <source> + PNG fallback, just renders the WebP <img> directly
with onError → CircuitPreview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:54:33 +02:00
davidmonterocrespo24 436bafbd84 feat(examples): backfill remaining 27 example thumbnails
Adds the slugs that hadn't been captured in the first batch (some
extra coverage from a later examples-* file, plus 10 retries that
hit a transient waitForLoadState timeout on the first sweep).

Coverage is now 129/129 — every example exposed via sitemap.xml has
a real canvas screenshot. The few that still 404 (slugs only present
in non-sitemap data files) keep the CircuitPreview fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:48:29 +02:00
davidmonterocrespo24 d53f4f1380 feat(examples): real canvas screenshots as gallery thumbnails
Replaces the manually-positioned wokwi-element mock (CircuitPreview) in
the /examples gallery with real screenshots of the actual simulator
canvas, so each card shows the example's boards + components + wires
exactly as they appear when you open the example.

How it works
- A new <ExampleThumbnail> component tries
  /examples-thumbs/<id>.{webp,png} first. If the image 404s — or no
  thumbnail has been captured yet — it falls back to the existing
  CircuitPreview component. No-op for examples without a screenshot.
- ExamplesGallery and ExampleDetailPage now render <ExampleThumbnail>
  instead of CircuitPreview directly.
- Explicit example.thumbnail field still wins (kept the existing
  override path in case someone wants a custom asset).

Capture pipeline
- Generated by velxio-prod's scripts/capture-example-thumbs.mjs
  (Playwright + sharp). For each example: opens /examples/<slug>,
  clicks "Open in Simulator", waits for [data-board-id] elements,
  computes the bbox of all boards + components, sets a transform on
  .canvas-world to center and fit them with 12% padding inside the
  canvas viewport, screenshots .canvas-content, and re-encodes to
  600x360 @2x DPI as .png + .webp.

This commit ships the first batch (102 of ~129 examples — the rest
will follow once the capture completes; missing slugs gracefully
fall back to CircuitPreview in the meantime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:44:10 +02:00
davidmonterocrespo24 9d5d2e8a4a fix(docs): RISC-V emulation goes through QEMU, not a TypeScript browser core
The marketing copy and docs claimed ESP32-C3 / XIAO-C3 / SuperMini /
CH32V003 ran on a "browser-native RV32IMC core written in TypeScript",
but production runs through QEMU lcgamboa (libqemu-riscv32) with the
esp32c3-picsimlab machine — same backend pattern as Xtensa ESP32, just
a different libqemu binary. The TypeScript ISA layer
(RiscVCore.ts / Esp32C3Simulator.ts / RiscVSimulator.ts) is kept only
as Vitest unit-test infrastructure for RV32IMC instruction decoding;
it cannot handle the 150+ ROM functions ESP-IDF needs at boot and is
not wired into the production emulation path.

Files updated:

Marketing pages
- LandingPage: board-group label, FAQ answer, architecture description
  no longer claim "browser-native" or "no backend needed" for RISC-V.
- AboutPage: arch card retitled "RISC-V via QEMU", body explains the
  libqemu-riscv32 / lcgamboa backend.
- Velxio2Page: arch group engine label, multi-board feature item,
  competitive-comparison card all corrected.
- ArduinoEmulatorPage: two RISC-V cards corrected.
- ESP32SimulatorPage: ESP32-C3 cross-link card corrected.
- ESP32C3SimulatorPage: hero subtitle, trust strip, supported-boards
  intro, JSON-LD description corrected.
- ElectronicsSimulatorPage: install-needed FAQ corrected.
- examples.ts: c3-blink description and code-comment corrected.

SEO surfaces
- index.html: JSON-LD SoftwareApplication description, OS-fallback FAQ
  body, supported-boards <ul> bullets, feature list bullets corrected.
- seoRoutes.ts: /esp32-c3-simulator title + description corrected;
  homepage description corrected.

Docs page
- DocsPage RiscVEmulationSection: intro paragraph rewritten — RISC-V
  goes through QEMU lcgamboa with libqemu-riscv32 / esp32c3-picsimlab,
  TypeScript layer is Vitest-only.
- DocsPage Esp32EmulationSection callout: section now applies to all
  ESP32 family (Xtensa + RISC-V), pointer to RISC-V doc clarified.

README
- "Boards" table: production-engine column for ESP32-C3 family and
  CH32V003 changed from "RiscVCore.ts (browser)" to "QEMU lcgamboa
  (backend)".
- "ESP32-C3 / XIAO-C3 / SuperMini / CH32V003" subsection retitled
  "(RISC-V via QEMU)" — body explains libqemu-riscv32 backend and
  flags the TypeScript layer as Vitest-only.

The two remaining "browser-native" hits in the codebase
(Velxio25Page:176, index.html:348) are about ngspice-WASM SPICE
analog simulation, which genuinely is browser-native — left alone.

Build verified: npm run build:docker succeeds, 246 SEO pages prerender.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:56:29 +02:00
davidmonterocrespo24 0c8d96a05c feat(marketing): replace mock hero with live editor screenshot
Captures /examples/traffic-light → /editor in headless Chromium and saves
the rendered editor as a 3840x2160 (2x DPI) PNG + WebP for the landing
page hero.

The shot includes the code editor on the left (Traffic Light Simulator
.ino), the Arduino Uno on the canvas with three LEDs wired up, the SPICE
nets indicator, and the full chrome — a much stronger first impression
than the previous CSS-mocked schematic.

Generation script lives in the private velxio-prod repo
(scripts/capture-hero.mjs) and can be re-run any time to refresh the
asset against the live deployment.

- /marketing/hero-editor.png (320 KB)
- /marketing/hero-editor.webp (160 KB)
- LandingPage hero <picture> now points at these (loading=eager,
  fetchPriority=high since it's above the fold).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:21:52 +02:00
davidmonterocrespo24 e0dbde0a76 feat(design): tokens, webfonts, Lucide icons, board PNG/WebP picture strip
Foundation
- Add 7 token CSS files in src/tokens/ — semantic colors, 4-pt spacing,
  Apple-HIG type ramp, radius/elevation/motion/z-index scales.
- Refactor src/index.css to import tokens and remap legacy aliases
  (--accent, --bg, etc.) onto the new --color-* semantics so existing
  components keep rendering during migration.
- Drop the duplicate font-family from src/App.css; body inherits from :root.
- Global *:focus-visible ring backed by --color-focus-ring (WCAG 2.4.7).

Webfonts (self-hosted)
- Add Inter.var.woff2 (variable, OFL) and JetBrainsMono.var.woff2 to
  public/fonts/. Preloaded in index.html with crossorigin.
- Old stack -apple-system kept as fallback so Mac users still get SF Pro.
- Fixes cross-OS rendering inconsistency (Win/Linux/Android were falling
  back to Segoe UI / Roboto, breaking the type grid).

Component primitives
- New src/components/ui/{Button,Card,Input}.tsx + .css. Built on the
  semantic tokens, ready for incremental migration of .ap-* CSS classes.

Lucide icons
- Replace 6 inline SVG icon components in LandingPage (IcoChip / IcoCpu /
  IcoCode / IcoZap / IcoLayers / IcoMonitor) with lucide-react imports.
  Aliased so call sites are unchanged. ~80 lines of inline SVG removed.
- IcoGitHub kept bespoke (filled glyph, brand-correct).

Marketing assets
- Convert top 8 boards to transparent PNG + WebP at 1x / 2x:
  Arduino Uno, Nano, Mega 2560, Pi Pico, Pi Pico W, ESP32-C3,
  ESP32-DevKit-V1, XIAO ESP32-S3.
- Migrate matching cards in LandingPage and Velxio2Page to <picture>
  with WebP > PNG > SVG fallback. Other 8 boards keep <img src=*.svg>
  for now (Raspberry Pi 3B, ESP32-CAM, etc.).
- Refresh og-image.png — same canonical URL, new content (4 hero boards
  + branding instead of generic logo card).
- Fix latent bug in LandingPage: ESP32 DevKit V1 card was loading
  esp32-devkit-c-v4.svg; now uses esp32-devkit-v1.{webp,png,svg}.

Build verified: npm run build:docker succeeds, 246 SEO pages prerender.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:45:44 +02:00
David Montero Crespo 694a2f4073 fix(frontend): polyfill crypto.randomUUID for non-secure contexts
crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost,
127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over
plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is
undefined and any code path that calls it throws TypeError.

This silently broke ESP32 simulation start for self-hosters: the frontend
generates a UUID for the WS client_id when Run is clicked; the throw
rejected the promise before reaching the WS connect, so the backend
never got the start request — no worker spawned, logs empty, simulation
"didn't start" with no visible error.

Same root cause would also break the multi-file editor (createFile,
createFileGroup) on the same LAN-HTTP self-host setup, just less
observably.

Add a single generateUUID() helper that:
  1. Uses crypto.randomUUID() when available (secure context fast path).
  2. Falls back to crypto.getRandomValues() — which IS available in
     non-secure contexts — to build a v4 UUID by hand.
  3. Final fallback to Math.random() if even that is missing
     (defensive — Web Crypto getRandomValues has been universal for
     years).

Replace all 6 crypto.randomUUID() call sites:
  - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId)
  - frontend/src/store/useEditorStore.ts   (4 sites — file IDs)

Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP.
DevTools console showed:
  TypeError: crypto.randomUUID is not a function
    at Ph (...) at wh.connect (...) at startBoard (...)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:41:30 -03:00
David Montero Crespo cd3ee6172b copy: rewrite landing hero — drop SPICE jargon, lead with the boards
The previous hero ("Circuits + Code. / One Browser Tab. / SPICE-accurate.")
was optimised for EE engineers searching for circuit simulators. Most
visitors land here looking for an Arduino emulator they can use without
installing anything — the names of the supported boards are a stronger
hook than analog-simulation accuracy.

Restored the older "Arduino, ESP32 & Raspberry Pi. / Right in your
browser." framing and tightened the subtitle to action verbs (Write,
wire, run) plus concrete numbers (19 boards, 48+ parts). Drops:
- "SPICE-accurate" — kept on the dedicated /arduino-emulator,
  /circuit-simulator etc. SEO landing pages where the audience is
  actively looking for it
- "ngspice", "co-simulated", "custom chips in C or Rust" — niche, fit
  better in the features section below
2026-05-05 11:29:45 -03:00
David Montero Crespo 26e0c2be60 feat(components): add mergeComponents API to ComponentRegistry
Forgotten in the prior commit (case-mismatch on Windows tracked the wrong
filename). Adds the public method overlays use to splice extra components
into the picker after default-metadata load. Components with an existing
id are replaced; new ones are appended.
2026-05-05 10:34:43 -03:00
David Montero Crespo 8b14815094 feat(components): pro_only flag + registry merge API + picker gate hook
Three small additions so private overlays can add components gated behind
a paid subscription without forking the picker:

- types/component-metadata.ts: optional pro_only?: boolean field on
  ComponentMetadata. Self-hosters never set it; picker behaves identically.
- services/componentRegistry.ts: new mergeComponents() public method.
  Pro overlay calls this after the default registry has loaded to splice
  in extra components (replacing any with the same id).
- components/ComponentPickerModal.tsx: when a pro_only component is
  clicked, the picker first calls window.__velxio_pro_gate__(component)
  if defined. If the gate returns true, the click is consumed (overlay
  shows an upgrade modal). If absent or returns false, the click passes
  through to onSelectComponent as normal.

Net upstream change: ~25 lines, all backwards-compatible. OSS image
behaves exactly as before since no overlay sets pro_only or installs
the gate.
2026-05-05 10:33:53 -03:00
David Montero Crespo 77b3e86b50 feat(frontend): /pricing route placeholder + UserResponse subscription fields
Two upstream additions to support private overlays implementing paid tiers
without forking client code:

- store/useAuthStore.ts: UserResponse extended with optional
  is_paid_subscriber, subscription_status, subscription_period_end. The
  backend now returns these in /api/auth/me; the persist middleware
  serialises them automatically.
- pages/PricingPlaceholder.tsx (NEW): the /pricing route. Renders a polite
  "this image is fully free" message for self-hosters plus a
  data-velxio-slot="pricing-page" target where private overlays can
  portal-inject a real pricing page.
- App.tsx: register the /pricing route after /about.

Self-hosted OSS image: /pricing shows the placeholder, no behavioural
change anywhere else. Production with a private overlay: /pricing shows
the overlay's full pricing UI.

Frontend build verified.
2026-05-05 10:24:01 -03:00
David Montero Crespo a5b5e57aae feat: add data-velxio-slot markers for overlay portal targets
Three small markers (each one HTML attribute) so private overlays can
portal-inject UI into well-defined places without forking the upstream
component:

- AppHeader user dropdown: data-velxio-slot="user-menu"
  Lets overlays add menu items between "My projects" and "Sign out"
  (e.g. a Privacy / opt-out item).
- AdminPage tab bar: data-velxio-slot="admin-tabs"
  Lets overlays add extra tabs alongside Dashboard / Users / Projects /
  Boards (e.g. a Pro Analytics tab).
- AdminPage tab content area: data-velxio-slot="admin-tab-content"
  Sibling div where overlay tab content can portal-render.

Generic markers, no overlay-specific code in upstream. Anyone with
private extensions can use them. The OSS build is otherwise unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:28:02 -03:00
David Montero Crespo edd2ac32d5 feat: add optional extension hooks for private overlays
Three small, backwards-compatible hooks let anyone with private features
(velxio.dev's analytics, custom integrations, paid tiers, …) layer them
on top of the open-source build without forking files.

Backend (app/main.py):
- After standard router registration, try-import an optional `app.pro`
  module exposing `register_pro(app)`. ImportError is silently swallowed
  (the OSS image doesn't ship `app.pro`, so this is a no-op there).

Frontend:
- EditorToolbar: new optional `rightSlot` prop renders extra elements
  after the built-in right-group buttons (mirrors the existing
  `centerSlot` pattern).
- main.tsx: dynamic `import('@pro/index')` gated by VITE_PRO_BUILD env.
  When unset (OSS build), the branch is dead-code-eliminated and no pro
  chunk is emitted.
- vite.config.ts: `@pro` alias resolves to `src/__pro_stub__/` by default.
  Private builds set `VITE_PRO_BUILD=true` and `PRO_OVERLAY_PATH=<path>`
  to point at their real overlay tree.
- src/__pro_stub__/index.ts: 1-line no-op `mountPro` so TypeScript and
  Vite resolvers stay happy in OSS builds.

Verified: `npm run build:docker` succeeds; `npm test` passes 1161/1162;
the OSS bundle (43 MB) contains zero references to `__pro_stub__`,
`@pro`, or `pro/index` (verified via `grep dist/`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:03:20 -03:00