- trackClickCTA sent the originating page as the event parameter `source`,
a reserved traffic-source name in GA4: every landing CTA click rewrote the
session's acquisition to landing / arduino-simulator / rpi-simulator with
medium (not set), which put 16% of sessions in the Unassigned channel.
Renamed to cta_source (register it as a custom dimension in GA4 if the
click_cta report should keep breaking down by page).
- robots.txt is one group with no blank lines (some parsers end a group at
a blank line, orphaning the Disallow rules); /login, /register and /admin
are no longer disallowed — they carry noindex, and a page must be
crawlable for that tag to take effect. /project/ and /api/ stay out.
- The static sitemap no longer stamps every URL with the build date as
<lastmod>; an all-identical, ever-changing lastmod is discarded as
unreliable, so omitting it is the honest option.
- The GA4 tag loads only on velxio.dev / www.velxio.dev / tauri.localhost:
self-hosted copies of the app (there are >180 hostnames reporting into
the property) no longer pollute velxio.dev's analytics.
The editor seeded a freshly placed board from its FAMILY: the Arduino
LED_BUILTIN blink, the Pico Pin(25) MicroPython blink, or the Raspberry
Pi RPi.GPIO script. None of those run on a board that has no such LED,
no RPi.GPIO and its own vendor library, so the first Run failed on a
board the user had not touched yet.
ProBoardDef gains defaultFiles (per language mode, plus 'python' for the
QEMU-Linux guests) and defaultLibraries; addBoard and setBoardLanguageMode
prefer the board's own seed over the family default, and the seeded
libraries land in the board's compile manifest.
Also fix the MicroPython family fallback: it recognised an ESP32 by
'esp32' appearing in the group id, so an overlay ESP32 whose kind carries
no such substring (m5stack-core, cardputer-adv) was seeded the Pico's
Pin(25) blink - a dead pin on both. It now consults the board's declared
esp32Family too.
Follow-up to 534b2ff. Dropping the strip to its own bar was now measured,
but two things still produced a wrapped bar on the XIAO example at 1440
with the chat docked:
- The first collapse tier (Serial / Camera / Mic / Scope / Add / Libraries
labels -> icons) was still a fixed 1010px container query, so on the
1060px own-bar the labels stayed and the bar wrapped in two. That tier is
now the unified-toolbar--compact class, set by the same measurement:
labels on the brand row if they fit, icons on the brand row if those fit,
else the own bar — labelled if the full bar holds it, icons otherwise.
The finer tiers (board pill 905, count/zoom 835, view toggle 760/1140)
stay as container queries.
- Each candidate layout is measured IN that layout: the zones carry their
own container queries (the view-mode toggle hides on a narrow row and
reappears on the wide bar), so a width read on the brand row said the
bar would fit when it would not.
With the AI chat docked at 1440px, a board with many canvas controls (XIAO:
display + camera + mic + scope) wrapped the strip INSIDE the header — the
canvas controls orphaned on a second line next to a black hole — because
the drop-to-own-bar fallback fired on media-query thresholds (1040 / 1060 /
1400px by chat state) that were only right for one board's strip width.
AppHeader now measures: natural single-row width of the strip (each zone at
its content width) against the room left next to the brand + menus, and
sets app-header--strip-below when it does not fit. Always measured in the
inline layout (class removed, measured, restored before paint) so the
below-state's margins and uncollapsed labels never bias the decision, and
there is nothing to oscillate. Re-measured via ResizeObserver on the strip
host, the brand block and the zones (plus a MutationObserver for the
portaled canvas controls), deferred a frame so a toggle never re-enters
delivery. The three media-query blocks collapse into one class rule; a
1000px window without the chat now keeps one row when the collapsed strip
fits, instead of being forced to two.
The explorer rows carried a 26px indent plus 22px file icons, 5px vertical
padding and 12px names in a 165px pane: a lot of margin for a sidebar
whose only job is listing a handful of files, and every px of it was taken
from the code editor. Now: root files indent 16px (12px per nested level),
file icons render at 16px like the folder icon, rows are 3px/6px with an
11px name, the board header actions are 18px, and the header buttons 20px
with a title that truncates before the actions do. The pane defaults to
150px (min 100) and the account footer keeps the first name visible at that
width.
Boards were placed 420 world-px right of the ACTIVE board plus 60/30 px per
board already on the canvas: a fixed world offset that ignored pan/zoom (the
new board could land off-screen) and kept marching further out with every
board added, even after the earlier ones had been moved away or removed.
Components had already been fixed to start at the visible top-left and take
the first free slot (utils/dropSlot); boards now go through the same rule,
and both count each other as parked items so they cascade over the same
slots.
The header rendered a "Saved" / "Unsaved changes" text pill next to the
menus whenever a project was loaded. It ate width in the one row that has
to hold the whole editor toolbar, and it duplicated a signal the Save
button in the workspace panel can carry by colour alone.
The pill is gone. FileExplorer takes the AutoSaveState and colours its Save
button: green saved, orange unsaved changes, pulsing while saving, red on
failure; grey (unchanged) with no project loaded. The tooltip carries what
the pill used to say plus the last-save time. Nine locales get the four new
strings under editor.fileExplorer with English fallbacks.
AppHeader no longer takes autoSave; EditorPage passes it to FileExplorer
instead. No pro overlay code referenced the pill or the prop.
captureDisplay.test.ts runs under @vitest-environment jsdom and says in its own
header that "jsdom + the `canvas` package give a real 2D context here" — but
canvas was never a direct devDependency. jsdom lists it as optional, so npm
skipped it, getContext('2d') returned null, and five tests died on
`ctx.fillStyle` with a TypeError. That aborted the pre-deploy test gate.
Declaring it directly makes the install deterministic; the lockfile already
carried the entry. All six tests pass with it present.
On the landing's dark board tiles they rendered as a white slab around the
board. Keyed the near-white out to transparent and cropped to the board:
Pi 4 goes 1024x1024 -> 922x599. Pi 5 kept its framing (it had no margin) and
just gained the alpha.
The supported-hardware section listed 19 boards across 5 architectures. It now
covers STM32 (Cortex-M3/M4), Raspberry Pi 4 and 5, and the partner boards from
Pimoroni, M5Stack, Seeed Studio and DFRobot — 35 in total across 6. Updated in
all nine locales so no language advertises the old count.
Two things went wrong when the window was small and the AI chat docked:
1. Libraries / Output painted over the board selector. The editor zone
has min-width: max-content, but its child .editor-toolbar-wrapper is
an inline-size query container, and size containment makes it
contribute zero intrinsic width — so the floor resolved to nothing,
flexbox squeezed the zone under its buttons and the bar never got to
wrap. Containment is now off for the in-bar mount (the wrapper keeps
it for its standalone mobile mount); the two label collapses that
depended on it are re-keyed to the unified-toolbar container at the
same thresholds the canvas controls use (1010 / 905px), so the row
degrades in step and, when it truly cannot fit, wraps instead.
2. The explorer / Code / Both / Circuit segmented toggle now hides once
the shared bar is under 760px (its inline display moved to CSS so a
container query can do that). Every switch it had is a checkmarked
row in the View menu: File Explorer, and Code / Both / Circuit as a
radio group under a View mode label. explorerOpen moves from
EditorPage state into useEditorStore so the menu can read it.
Every Pico W WiFi example in the gallery hangs at `while not
wlan.isconnected()`, with `wlan.status()` stuck at 1 (CONNECTING) and
`wlan.active()` reading True.
The CYW43439 sits on GP23/24/25/29, inside the module — there is no canvas net
for those pins. attachPioPeripheral() drives WL_HOST_WAKE (GPIO24) with
setInputValue() straight from the chip's hostWakeLevel(). When RP2040 gained
spice-driven digital inputs, the GPIO listener began seeding a pull level onto
every input pin, GPIO24 included: the CYW43 driver configures that pad as an
input, the seed landed on top of host-wake, the host stopped polling the chip,
and no association event was ever delivered.
The fault is invisible from the CYW43 side — its own harness drives a raw
rp2040js with its own FIFO/PIO plumbing and passes — which is why this looked
like a WiFi bug rather than a GPIO one.
Diagnosed by patching the sketch in the editor to print wlan.status() each
iteration: "WAIT 1 status 1 active True" repeating forever, i.e. the join was
accepted and the link never came up.
cardputer-adv-m5-button, m5stack-core-hello and c6-oled-i2c were the first
slug of their capture run and got the day's announcement overlay in the shot.
The capture script (velxio-prod) now pre-marks the day's post as shown and
strips any announcement overlay before the screenshot.
The New Project dialog now lists an M5Stack section — M5 Cardputer ADV first,
then M5Stack Core — between ESP32 and STM32. Both kinds are overlay-registered,
so the section only renders when the overlay mounted (an OSS build shows no
empty block). Selecting a card loads the board's on-LCD hello example
(cardputer-adv-hello / m5stack-core-m5-helloworld), which also provides the
card thumbnail.
The sections builder is extracted as buildStarterSections(defs) and covered
by a unit test that pins the display order.
It printed "Bluetooth Control Ready" and then did nothing, no matter what
you sent — David typed `on` and `off` into the Serial Monitor and neither
reached it.
It reads UART(0), and on this port UART0 IS the REPL's channel: the code
runs through the raw REPL, so bytes typed into the monitor are consumed
there and uart.read() never sees them. `UART(0, 9600)` also retunes the
very port the monitor is talking to at 115200.
Upstream drives this from a phone through an HC-05, where "Bluetooth" is
only the transport and the ESP32 just sees serial. Velxio has no HC-05 on
the canvas, so the Serial Monitor is that link — read sys.stdin through a
select.poll (100 ms timeout, so the loop yields instead of spinning).
Verified in the app: `on` -> "Received: on / LED ON" with the LED lit,
`off` -> "Received: off / LED OFF" with it dark.
analogRead printed a flat 1021-1023 on every loop — the collector pinned at
Vcc. Measured in the running app:
base 0.481 V emitter 0.001 V collector 4.994 V
0.481 V is below the ~0.65 V a 2N2222 needs, so the transistor never
conducted and the "amplifier" was an open switch.
The bias divider was fine on paper (47k/10k -> 0.88 V). What broke it was
the 10k "coupling resistor" from the signal generator, whose own comment
claimed it let the generator "wiggle the biased base without DC-shifting
it". It does the opposite: a generator with 0 V offset is ground at DC, so
that resistor sits in parallel with Rb2 —
10k || 10k = 5k -> Vb = 5 * 5/(47+5) = 0.481 V
which is exactly the measured value. Replace it with a 1 uF coupling
capacitor (159 ohm at the generator's 1 kHz: passes the signal, blocks the
DC that was wrecking the bias), and take Rb2 to 15k so the collector sits
near mid-rail — the operating point the sketch's own comment promises and
the only one with room to swing both ways.
The netlist snapshot could never have caught this: the text was exactly
what the author intended. So the new test asserts the PHYSICS — bias in the
active region, collector off both rails, and a collector that actually
swings — resolving node names through the builder's pin map rather than
hardcoding `n2`, and adding the 5 V rail the board supplies in the app.
`_prepare_persistent_project_dir` keys the persistent build dir on
(idf_target, variant), but the lock was keyed on the FQBN. Several FQBNs
resolve to one variant — arduino-esp32's boards.txt gives both `esp32` and
`esp32cam` build.variant=esp32 — so those two were free to build in the SAME
directory at the same time.
Measured against staging, compiling both concurrently:
esp32 285504 bytes sha f65ca909… contains its own marker
esp32cam 285504 bytes sha f65ca909… contains the OTHER sketch's marker
Byte-identical firmware, and the esp32cam caller got the esp32 sketch's
binary. In the product that is two gallery tabs open at once: the first tab
prints the second example's serial log and does nothing itself. It made
esp32-pwm-led-rgb look broken (dark LED, every SPICE node 0 V) when the
example is fine — it was running someone else's program.
Key the lock on the same identity as the directory. Boards with genuinely
separate build dirs still compile in parallel up to the semaphore cap; an
unresolvable FQBN falls back to the default pair, which is the safe
direction (serialising more only costs throughput).
Also in this commit: battery-voltage-monitor ships the 9 V battery its own
description promises. The divider's top end was wired to nothing, so A0 read
0 V and the sketch printed "Battery: 0.0 V / WARNING: Battery low!" forever.
A pro test lives at ../../pro/frontend/src/pro/..., outside this package,
so Node resolution walks up from THERE and never reaches this project's
node_modules — importing 'esp32js' dies with "Cannot find package". The
config already carries the other wiring overlay tests need (the include
glob, the fs.allow, @pro); this is the missing piece for one that drives
a real engine.
Guarded by existsSync per package: a pure-OSS clone has none of them
installed and no test imports them (the pro glob matches nothing there),
so each missing entry is simply left unaliased instead of pointing at a
path that doesn't exist.
The night-light LDR shipped a 0.001 ohm dark resistance for months and
nothing complained: the netlist built, ngspice converged, the part
rendered, and the example just quietly read 4095 forever. A wrong value
is invisible, so check them all.
Parses every resistor / pot / photoresistor / capacitor / inductor value
in the gallery with the real parser and asserts it lands in a physically
sane range. The failure message names both SPICE suffix traps, because
they are the ones that bite: M is milli (mega is "Meg"), and F is femto
and is deliberately not stripped, so "10uF" would parse as 10 fF rather
than the 10 uF intended.
Verified the check actually bites: putting "1M" back makes it fail with
`dark = "1M" -> 0.001 — LDR dark resistance (ohm) out of range`. A second
case asserts the audit visits a meaningful number of components, so the
test can't silently degrade into a no-op.
Swept the rest of the catalogue while here (all example files, the pro
overlay, and the 49 100-days diagrams): no other value hits either trap.
100d-auto-night-light shipped `dark: "1M"` meaning 1 megaohm. The value
parser follows the SPICE convention where M is MILLI and mega is `Meg`,
so the dark resistance became 0.001 ohm. With the divider's top leg a
short, AO sat at VCC: `ldr.read()` returned 4095 on every sample, the
sketch's `light < 500` branch never ran, and the lamp LED never lit.
Dragging the lux control changed nothing either, because every lux value
still divides a short (measured n1 = 3.299999996 V, which is exactly what
100k / (100k + 0.00011) predicts).
The netlist snapshot had frozen the bug in place:
- R_night-ldr_ldr vcc_rail n1 0.00011111111111111112
+ R_night-ldr_ldr vcc_rail n1 111111.11111111111
Diagram now says "1Meg" (regenerated from test/test_100_days), and the
mapper clamps a sub-1k dark resistance to the documented 1Meg default
with a console warning naming the milli/mega trap — a photoresistor that
reads as a short is always this mistake, and it fails in the most
confusing way possible: the part looks alive, it just never moves.
A sketch that drives several pins between yields lost every pin but the
first. The 4-bit binary counter example (the most-viewed one in the
gallery) blinked bit 0 and left bits 1-3 dark while the firmware counted
0-15 correctly on serial.
Sequence: pin 2's edge arrives first, finds no V-source (the pin was not
in outputPins when the netlist was built), self-heals via handleMcuEdge
and rebuilds. The pin 3/4/5 edges of that same iteration land while the
rebuild solve is in flight, so they queue in pendingMcuEdges. The drain
then looked them up in the loaded context, found no V_uno_3/4/5, and
dropped them. Those pins never got a source, so their LEDs kept the
build-time current (~0) forever — and since the LED part prefers SPICE
current over the digital fallback, they stayed dark for good.
The drop was deliberate: it stops the self-heal -> tick -> self-heal loop
for a pin wired to nothing. Replace the blanket drop with a per-pin
guard: heal a pin at most once per netlist, then drop as before. The
guard re-arms whenever a rebuild changes the V-source list, so a pin that
becomes drivable later still heals.
Verified in the browser on binary-counter-leds; two regression tests
cover both sides (sibling pin heals, unwired pin does not loop).
The RGB colour mixer wired the LED's three legs straight to GPIO25/26/27 —
examples-led-resistor.test.ts fails the build over exactly that, and it is
right to: a bare LED on a pin is a short through the die. Each leg now has
its own 220R.
The 100-days netlist snapshots were recorded when 36 of those examples had
no canvas at all, so every one of them snapshotted as an empty circuit.
Re-recorded now that they have real parts.
The 100-days series is imported from a repo that ships circuit photos, not
machine-readable diagrams, so every example whose canvas nobody had drawn by
hand loaded as a bare board: 36 of 49 had components: [] / wires: []. They
compiled and ran and showed nothing — the LDR night light printed 0 forever
because GPIO34 was floating, and there was no LED on GPIO2 to see either way.
Canvases for 33 of them (LDR, pots, MQ gas sensors, HC-SR04, PIR, 4x4 keypad,
joystick, servos, relays with a lamp on the contact, A4988 + stepper, 7-segment,
10-segment bar graph, RGB, DHT22, indicator LEDs), each wired to the pins its
own sketch uses. LED chains are active HIGH (GPIO -> 220R -> anode, cathode ->
GND); the Smart Home canvas had all four wired the other way round, so led.on()
turned them off.
AQI gains the three analog sensors its code reads (GPIO34/33/35) plus the dust
sensor's LED — without them every reading was 0 and the AQI sat at "0.0 Good".
Board-level repairs in the sources (each commented in place):
- Pin("LED") is a Pico idiom; on ESP32 it is not a pin at all, and on Pico W
it drives the CYW43 LED, which no board element renders. Now real GPIOs
with a real LED on the canvas.
- Battery monitor read ADC(0) (10-bit, single-channel) on a 12-bit per-pin ADC.
- Irrigation used GPIO3/GPIO1 — UART0 on this board, so it killed the console.
- Joystick+servo used another board's ADC0/servo pins.
- Temperature LED indicator shipped the DC-motor sketch by mistake upstream;
replaced with the listing from the project's own README (DHT22 + 3 LEDs).
- GPIO6 (internal flash) and GPIO0 (strap) are not on the DevKit header, so
those wires anchored at the board corner.
Left without a canvas on purpose: the EEPROM and hotspot demos are serial-only,
and the NTP clock needs a MAX7219 element Velxio does not have yet.
A wire endpoint naming a pin the board element does not expose silently
resolved to nothing and rendered anchored at the board corner. A full
audit (all gallery examples + every saved project on velxio.dev) found 15
examples and 28 user projects doing it — the same electrical pin spelled
differently per surface: GND.1 on the classic ESP32 (element exposes
GND/GND2), 3.3V vs 3V3, GND2 vs GND.2, D13 vs 13 vs GP13, A0-A2 vs
GP26-28 on the Pico, 36/39 vs VP/VN, bare 5V vs VBUS/VIN.
calculatePinPosition now tries those equivalences AFTER the exact match
and the two existing fallbacks miss (so no current resolution changes),
healing old saves, .vlx imports and agent-authored wires without a data
migration. The Pico element gains A0/A1/A2 alias pads on the ADC pins
(RX2/16 dual-entry precedent) and a GP25 pad at the onboard LED silk so
pico-blink-style wires have an anchor. The two examples that were
outright wrong (microsd-card-esp32 GND.1, pico-serial-echo TX) now use
the element's real pin names.
Netlist snapshots (166) untouched; verifier + builder suites green.
DEV-guarded (import.meta.env.DEV): the platform-bugs Playwright harness
drives store paths (property updates, group switches) the way the agent
tools do, which raw DOM access cannot reach. Production bundles never
ship it.
deleteFileGroup left editorStore.activeGroupId dangling at the removed
group: Monaco (and agent writes) kept editing a group compile never reads,
so a stale same-named sketch.ino shipped instead of the code on screen
(the 'leftover template Blink built for 35 minutes' failure). The pointer
now re-targets a surviving group, and compile re-points the editor when
the two pointers diverge (with a visible warning log).
compiledProgram is no longer serialised into boards_json: the firmware
blob was 99.99% of a typical ESP32 save (a bare board already ~1.04 MB),
pushing every real project against the request-size ceiling, and a
restored binary is stale the moment the code differs. Loaders still
accept it from old rows.
Three stacked defects made ANY non-knob property change invisible to a
running sim (values restored from saved projects, the agent's
set_component_property, the property dialog):
1. DynamicComponent assigned element properties without firing the events
part simulators bind to — dispatch input/change + dispatchSensorUpdate
on real value changes (applied-value guard breaks the echo loop).
2. Parts received the shared legacy AVR simulator regardless of board —
route attachEvents to the simulator of the board the part is wired to
(getBoardSimulator), so direct analog injection works on non-AVR boards.
3. The SPICE->ADC push stomped un-modeled parts: a net with no SPICE
element gets an auto pull-down, solves to 0 V, and overwrote the
part's own injected voltage every solve (the dead analog-joystick).
Only sourced nets are pushed now.
Verified end-to-end: pot curve exact to the LSB (value 100/300/600 ->
ADC 400/1201/2401 on GPIO35), joystick idle at centre 2047, full-scale
4095 after a store write.
Board supply pins split into the main logic rail plus aux rails by real
voltage (ESP32/XIAO VIN,5V -> aux_rail_5 at 5 V; 5V-Arduino 3.3V pin ->
aux_rail_3v3; Pico VBUS/VSYS -> 5 V). An external 5 V supply wired to VIN
used to read 3.3 V (ideal vcc_rail source clamped the whole net — customer
isolated it with a voltmeter). Aux rails join positiveSourceNets and the
relay nominal-voltage check; the voltmeter and hover overlay read them.
Also: ratings entry for the 4-pin ssd1306-i2c-4pin (supply pin VCC) and a
VCC alias on the 8-pin entry, so its power check works at all.
The unified-toolbar responsiveness work gave .unified-toolbar
container-type: inline-size, which makes it the containing block for
position:fixed descendants. The three modals rendered inside the toolbar
(library manager, install-libraries, circuit verification) shrank to the
toolbar's 89px box: pinned to the top, header and close button clipped
offscreen. createPortal(document.body) makes them immune to ancestor
containment. The library manager also gets a fixed tall height
(min(86vh, 1000px)) instead of a bouncy 80vh cap.
The tracked sitemap.xml committed in 3ff984a1 was a stale working-tree
regen from BEFORE df19778a retired the in-app /docs routes; it carried 15
/docs URLs that now 301 to the docs portal (which ships its own
sitemap-index, declared in robots.txt since 720a5a1). Harmless in prod —
build:docker regenerates the file at image build — but noisy in the repo
and wrong as a source of truth. 193 URLs (22 routes + 171 examples).
These binaries are loaded at runtime from /firmware/ (cyw43 installer,
RP2350 simulator, C6 bridge) and were living only in the prod working
tree — a fresh checkout would 404 them. Sitemap regenerated 2026-08-13.
velxio.dev/docs/ ships its own Astro sitemap-index (2142 URLs) but nothing
referenced it — the main sitemap dropped the retired in-app /docs entries
on purpose (df19778a) and robots.txt only listed the app + projects
sitemaps, so crawlers had no discovery path to the new portal.
The ESP32 examples now default to the in-browser JS engines, whose lwIP
serves the sketch's HTTP server from the simulation tab itself. Opening
the IoT gateway in a new tab backgrounds that tab, the browser throttles
its timers, and the in-chip server can't answer before the proxy's
timeout — the exact failure the Pico W iframe path already solved.
- WifiStatus grows an inBrowser flag (set by the overlay's JS bridges);
the serial-monitor link and the WiFi badge now open the in-app iframe
for any board that carries it, not just pi-pico-w.
- /api/gateway polls ~1s before answering 404: the net bridge dials on
'got_ip', which races the sketch printing its URL, so a fast click
used to land before the bridge registered.
Live E2E on the in-browser ESP32 engine surfaced two freezes:
- i2c.scan() stalls on the engine's I2C controller (first probed address
has no device and the transaction never completes). The bmp180 shim now
probes 0x76/0x77 directly via readfrom_mem.
- BlynkLib's blocking socket.connect() froze the demo when the second
outbound connection stalled. The vendored BlynkLib now sets a 10s
timeout before connect, and main.py catches OSError and falls back to
the offline no-op Blynk (probe-then-reconnect removed — one bounded
connection instead of two).
2c4c93a2 gave 13 of the repaired 100-days examples real canvases, so
their netlists are no longer empty. Regenerated with vitest -u; every
change is the expected kind (components and rails appearing, plus the
micropython-watch button rewiring).
- 10 Pico W projects now emit boardType pi-pico-w so the RPI_PICO_W
firmware (network + CYW43) boots; the plain-pico build has no network
module and every Wi-Fi example died on import.
- Entry points: projects whose alphabetically-first file was a library
(BlynkLib.py) or a host script (analyze.py) now ship a real main.py.
- All Blynk projects probe blynk.cloud with a 5s timeout and fall back
to an offline no-op object — a plain connect() blocks forever in the
emulated network and froze the demos.
- DHT11 -> DHT22 across six projects (the emulator speaks DHT22) and
the OLED projects use hardware I2C instead of bit-banged SoftI2C.
- 11 more examples get a wired canvas (DHT22, SSD1306, LCD1602 I2C,
membrane keypad, joystick, heart-beat sensor, LEDs): 13/49 total.
- IoT Atmospheric Monitoring (ESP32+Blynk): ship the missing bmp180.py
driver (BMP180 API over the emulated BMP280), switch SoftI2C -> hardware
I2C(0) so the emulator decodes the bus, survive an unreachable Blynk
Cloud, and add the BMP280 sensor + wiring to the canvas.
- AQI ESP: ship the missing ssd1306.py driver, DHT11 -> DHT22 (the
emulated waveform), local AQI fallback when the Flask server is absent,
and add the OLED + DHT22 canvas with wiring.
- All 49 descriptions now credit Kritish Mohapatra's upstream repo
(github.com/kritishmohapatra/100_Days_100_IoT_Projects).
Generated by test_100_days/_emit_examples_data.py in velxio-prod (which
now supports per-project velxio_diagram.json canvases).
The drag handler computed the split percentage against
getBoundingClientRect().width (border box), but the panels' percentage
widths resolve against the content box. Overlays that reserve space by
padding .app-container (docked side panels) made the handle jump left on
click and track offset from the cursor. Measure against clientWidth minus
padding, with the origin shifted by border + left padding.
The documentation moved to the Starlight portal served statically at
/docs/ (nginx shadows the old SPA routes and 301s the legacy slugs).
The header Documentation link becomes a full-page <a> so it reaches
nginx instead of client-routing into the removed pages.