The VoCat card was a rounded rectangle - the product is a round puck
with two cat-ear bumps, a mic in each and the green LED between them
(official product photo). The P4 card was maroon; the Function-EV PCB
is black.
The merged component copied every library into ONE interleaved tree and
then exported EVERY directory that held a file. A library's private
internals therefore sat on the global -I and hijacked #includes issued by
other libraries, by the arduino-esp32 core and by ESP-IDF itself. Each
incident was patched by adding a name to _SHADOW_STD_HEADERS — chasing
symptoms, since the denylist grows with every library that ships a
generically-named internal header (three landed yesterday alone:
FastLED's platforms/stub/Arduino.h, its fl/stl/asio/http/http_parser.h,
and earlier LovyanGFX's lgfx/internal/limits.h).
Adopt arduino-cli's model instead — the resolver every other Velxio board
family already delegates to. It shares one global -I list too, but adds
exactly ONE directory per library: <lib>/src for the recursive layout,
<lib> for the flat legacy one, never a subdirectory; utility/ stays
private to its owner. arduino-esp32 does the same as an IDF component.
- Each library is copied into its own subtree of the component, so two
libraries shipping the same relative path no longer collapse into one
silently-picked copy.
- INCLUDE_DIRS is one root per library, in resolution order. Internals
stay copied and reachable through file-relative includes.
- Legacy-layout utility/ dirs go in PRIV_INCLUDE_DIRS.
- Root-level header collisions (the only shadowing still possible) are
logged the way the Arduino IDE reports them, instead of being silent.
- The transitive scan now walks only the library just copied.
One component is deliberate: a component per library would swap this
failure mode for REQUIRES-order shadowing (IDF exports component includes
as plain -I, never -isystem) plus IDF component-name collisions, and buys
no caching ccache does not already provide.
Verified against the real FastLED+U8g2 project WITH the two denylist
entries removed: green, byte-identical firmware size. The 3-library
SSD1306+GFX+BusIO corpus is green too. VELXIO_PER_LIB_ROOTS=0 restores
the legacy layout; the mode is folded into the build-variant hash so the
two can never share a configured build dir.
Validated live against the ESP32 alarm-keypad project. Three fixes over
the initial implementation:
- Active-row model: the write-pin callback only fires on OUTPUT-latch
changes (after the first pass re-driving the row LOW is a no-change
write), and dir alone misses the first pass. A matrix scan drives one
row at a time with the latch LOW, so 'the row that most recently went
active (dir=OUTPUT, or latch write LOW)' IS the scanned row.
- Idempotent install: the canvas effect re-attaches on every boards[]
mutation (each serial line); reinstalling wiped the pressed-key set
mid-scan. Same wiring now keeps the existing state dict.
- Gap-free listeners: the keypad's button-press listeners were attached
on a 300ms setTimeout per effect run, opening a listener-less window
on every re-run in which a press was lost entirely. Attach
synchronously and drop the sendSensorDetach cleanup (detach+reattach
on unrelated re-renders is exactly the wipe the idempotent install
guards against).
A 4x4 membrane keypad scan is us-timing-critical: firmware drives one
row LOW and reads the columns back within the same loop pass, far
faster than the browser<->QEMU websocket round trip, so the browser-side
matrix simulation could never register a key on ESP32 boards.
Worker: new 'matrix-keypad' sensor type (rows/cols GPIO lists, pressed
key set). _on_dir_change/_on_pin_change recompute the column levels
synchronously on the QEMU thread — the pinMode(OUTPUT) transition is
the trigger on steady-state passes, since the row's output latch stays
LOW and the digitalWrite fires no change callback. Async gpio_in writes
to keypad-owned columns are ignored so the browser's generic matrix
logic can't clobber them.
Frontend: SimulatorCanvas attaches the keypad as a matrix-keypad sensor
when every R/C pin resolves to an ESP32-family board GPIO, and streams
the pressed-key set from the element's button-press/release events.
_resolve_library_components does minutes of synchronous filesystem work
for a big library (FastLED: BFS over every header + copying thousands of
files) directly inside async compile(), starving the event loop: the
/api/compile/start response never flushed and clients timed out at 30s
while the build itself was healthy. Run it via asyncio.to_thread like
the cmake and ninja steps already do.
IDF's esp_http_server.h includes <http_parser.h> for the HTTP_GET/...
method enums. FastLED ships fl/stl/asio/http/http_parser.h and the merge
put that directory on the global -I, so the IDF include resolved to
FastLED's file and every httpd_method_t use failed with 'HTTP_GET was
not declared'. FastLED only references its copy path-qualified, so the
directory can safely stay off -I.
The arduino-mode branch used only the FIRST .ino in the request and
silently dropped every other tab, so any multi-tab sketch (the standard
Wokwi/Arduino-IDE project layout) died with 'not declared in this scope'
on globals defined in the main file. Concatenate the main sketch
(literal sketch.ino, else the first .ino) followed by the remaining
tabs alphabetically — the arduino-cli order — with #line markers so
diagnostics keep pointing at the real tab/line. Also REQUIRE
http_parser: exposing esp_http_server.h activates FastLED's fl net
server, which uses the HTTP_GET/HTTP_POST enums http_parser provides.
esp_cache.h (esp_mm) was the next one-header-at-a-time failure after
esp_http_server. Scan FastLED 3.10's esp platform tree once and add the
full candidate set (esp_mm, esp_hw_support, heap, esp_system, esp_rom,
log, app_update, bt, esp_psram, esp_pm) — every entry remains guarded on
the component existing in the IDF tree, so older IDF generations are
unaffected.
Two more merge gaps surfaced by FastLED 3.10 + U8g2 in one sketch:
- src/fl/test/ was fully dropped because 'test' matched the generic
excluded_dirs check at ANY depth, but FastLED's unity build includes
"fl/test/_build.cpp.hpp". Repo-level dirs named test/examples/docs
stay fully excluded; the same names nested inside a src-layout tree are
now header-only (fragments copied, sources still never compiled).
- FastLED's fl/net remote code includes <esp_http_server.h>, which the
Arduino IDE provides via the core's global include path. Add
esp_http_server to the merged component's guarded REQUIRES list.
FastLED's unity build (src/fl/build/extras+.cpp) includes
"extras/_build.cpp.hpp", but extras/ was in the excluded_dirs set so the
fragment was never copied and the merged-component build died with
'No such file or directory'. Treat extras/ as header-only instead: copy
.h/.hpp/.inl fragments so unity includes resolve, but keep compiling
nothing from it — extras/ traditionally holds repo-only content.
FastLED ships a host-test stub src/platforms/stub/Arduino.h. When another
merged library (e.g. U8g2) does #include <Arduino.h>, the stub resolved
first and broke the build: 'yield' was not declared in U8g2lib.cpp and
fl::string polluted String overload resolution inside the core WString.h.
Reuse the _SHADOW_STD_HEADERS mechanism so the stub's directory stays off
the global include path; FastLED reaches it via a file-relative include.
arduino-cli FQBNs may carry a menu-option suffix
(vendor:arch:board_id:Menu1=val1,...). The espidf lane previously derived
cdc_on_boot exclusively from the board id's boards.txt default, so a
generic-devkit board could never be built with Serial on the USB
Serial/JTAG CDC port.
- _fqbn_board_id_and_options(): split board id and menu options, keeping
the old last-segment behaviour for plain 3-part FQBNs.
- _arduino_board_flags(): CDCOnBoot=cdc overlays cdc_on_boot=1 on the
boards.txt base flags (mirroring <id>.menu.CDCOnBoot.cdc.build.
cdc_on_boot=1); the per-board cache stores only base flags, overrides
are applied on a copy.
- _arduino_variant(): board id extraction goes through the same helper so
a menu suffix no longer corrupts the variant lookup.
First user: the ESP32-C3-LCDkit board def pins
esp32:esp32:esp32c3:CDCOnBoot=cdc — the real kit has no UART bridge, its
only console is the USB Serial/JTAG port, and the esp32c3js engine now
models the always-attached host that HWCDC's connection gate requires
(esp32c3js v1.8.0).
A micropython example that ships only 'code:' landed its Python in
sketch.ino (the mode switch was right, the name was not - seen on the
SensAirShuttle ws2812 example). main.py / main.c / sketch.ino by mode.
The interface requires both fields but the Docker build never
type-checks the pro overlay's example data (vite build only), so a
missing field shipped and loadExample's unguarded .filter/.map threw -
the page surfaced as a sticky 404 on a registered example (four
SensAirShuttle examples on staging). Load an armless example as having
none instead; authoring still declares the fields required.
The Function-EV module always carries 32 MB AP-hex PSRAM and esp_lcd
allocates DSI framebuffers from it unconditionally - without it the
board's flagship peripheral cannot run. Explicit psram=disabled still
wins; the UI only sends the field after the user opens the
board-options modal. Validated: an optionless espidf compile now embeds
esp_psram.
The P4 gate ('until the engines validate it') can flip: esp32p4js
v0.3.3 models the AP hex PSRAM + MSPI1 command controller. The classic
SPIRAM chunk emitted S3-era symbols (SPEED_80M, MODE_QUAD/OCT) that do
not exist in the P4's Kconfig.spiram - it gets its own chunk: MODE_HEX
+ SPEED_200M (tree default, rev0-safe; 250M needs rev >= 3). Validated
on staging: an esp_lcd_ek79007 espidf build with psram=enabled boots in
the engine to streaming 1024x600 DSI frames.
The ESP32-C5 sensing devkit lands in the hosted editor (BME690 + BMM350
Bosch shuttles, 1.9" touch LCD, WiFi 6). Same contract as the other
Espressif cards: id matches the hosted BoardKind (esp-sensairshuttle,
in LAUNCH_ITEMS) so the ad vanishes where the real board registers and
stays suppressed during the embargo.
Two more Espressif boards land in the hosted editor; the OSS picker
advertises them like the ESP-EYE/LCDkit/C6 cards. Ids match the hosted
BoardKinds (esp-vocat, esp32-p4) so the ads vanish automatically where
the real boards register, and both ids are in LAUNCH_ITEMS so the
hosted build suppresses the ads during the embargo.
Also removes two accidental double commas that left holes in the ad
arrays.
Generalizes the esp_camera.h lesson into a table (esp_lcd_st77916,
esp_lcd_touch_cst816s, esp_lcd_gc9a01, esp_lcd_ek79007, esp32-camera)
and applies it in the arduino branch, the no-core pure-IDF fallback AND
the explicit language=espidf branch (issue #139) - the last one is where
the VoCat display example lives and previously died at the first
include. Validated on staging: the ST77916 colour-bars espidf example
now fetches espressif/esp_lcd_st77916 from the registry and compiles.
nginx 504s a cold ESP-IDF build long before it finishes; Starlette then
cancels the handler and the cancellation killed the build subprocess
MID-WRITE, leaving truncated .obj files in the persistent per-target
build cache - every later build of that target failed with
'ranlib: file truncated' until the cache was wiped (bitten twice on
staging the day the P4 lane landed). asyncio.shield lets the build run
to completion with a consistent cache; only the response is lost, and
the async /start + /status flow remains the right path for long builds.
_idf_target mapped only c3/c6/s3 and defaulted everything else to
'esp32', so a P4 fqbn compiled with the XTENSA toolchain and
-DARDUINO_ESP32P4_DEV. Adds p4/c5 predicates, IDF5-target membership,
0x2000 bootloader offsets, the riscv toolchain requirement, PSRAM held
off until the engines validate it, P4 CPU-freq drops (its kconfig
choices do not overlap the classic set), a rev0 sdkconfig pin for the P4
(the in-browser engine runs the only published mask ROM and v3 firmware
reads a flash descriptor rev0 never wrote), and an honest refusal for C5
arduino builds (no core exists). Validated on staging: an esp32p4
arduino sketch now produces a merged image with the riscv32 toolchain
that the esp32p4js engine accepts.
The overlay's ESP32-P4 (and the coming C5) run ONLY on their in-browser
engines: neither chip has a QEMU machine anywhere. toQemuBoardType now
throws a clear error for those families instead of booting the wrong
machine and wedging on the first register.
Overlay boards with a camera now declare builtInCamera on their def (bool,
or { maxFrameBytes } for drivers with tight frame buffers) and the canvas
CameraToggle keys off it - the hardcoded esp32-cam / xiao-esp32s3-sense
kinds keep working unchanged.
The knob kit joins the overlay board program (Espressif partner batch);
OSS builds show the shop-window card until the pro overlay registers the
real kind.
The esp32/s3/c3 MicroPython bins were v1.20.0 (April 2023) — built by
micropython.org on IDF 4.4, three IDF generations behind the compile
pipeline and the engines' own validation corpus (both IDF 5.5), and two
behind the C6 bin (already v1.28). That drift is what broke I2C on the
S3: the engines decode the IDF 5.x driver sequences, and the v1.20 image
drove them the 4.4 way.
All four families now ship v1.28.0 (2026-04-06, IDF 5.x). Remote URLs
and cache keys move together, so a browser holding the old bin in
IndexedDB misses the new key and fetches v1.28 on its own. The new
images all declare 4 MB flash — padToFlashSize's floor exactly (the old
S3 bin declared 8 MB).
Validated cold on each engine (boot -> REPL, i2c.scan finds an attached
slave, WLAN scan/connect/DHCP; S3 additionally quad-PSRAM with a
multi-MB heap) and in the app (scan [60] on esp32/s3/c3, the ssd1306
gallery example drawing). Companion engine release: esp32s3js v1.16.0
(I2C interrupts on both cores, quad-PSRAM data plane). Pin.toggle() and
three years of upstream fixes come along; the v1.20 Pin.toggle note in
the editor seed is now history only.
Residual risk, flagged not fixed: the OSS QEMU self-host path (no JS
engines) now boots v1.28 under QEMU — unvalidated here, velxio.dev does
not exercise it.
The lux slider was linear 0-1000, but an LDR's response (and light
perception) is logarithmic: in the night-light example the LED's whole
on/off behaviour lived below ~20 lux — 2% of the slider's travel — and the
remaining 98% did nothing, reading as "the sensor barely works". The
physics were right (the divider model puts the example's 500-count
threshold at ~20 lux, sensible for a night light); the CONTROL was wrong.
The photoresistor and photodiode lux sliders now run in log position
space: value = min + 10^(p·log10(span+1)) - 1, anchored exactly on min
and max (the +1 shift gives the log axis a zero). Mid-travel lands in the
tens of lux, and the night-light toggles near the middle of the track
instead of at its first pixels. Same mapping on the component's own
on-canvas slider. The model, the examples and the reported lux values are
untouched — only where a given lux sits on the track changed.
Slider controls gain an opt-in `scale: 'log'`; everything else stays
linear.
One Semaphore(2) gated every compile, so while two ESP32 builds ran (cmake +
ninja, minutes on a cold cache) an Arduino Uno blink sat 'compiling' for as
long as they took. Two lanes now: heavy (ESP-IDF, still 2 — the VPS load
constraint is real) and light (arduino-cli boards, 3), plus a console line
when a job is queued so the wait is visible instead of looking like a hung
build. The per-target lock that keeps concurrent builds out of one ESP-IDF
build dir is unchanged.
A MicroPython library in Velxio IS a .py file in the project, copied onto
the board's filesystem before main.py runs. That mechanism worked, but
getting the file meant hunting GitHub by hand — the gap issue #214 is
about. Now the Library Manager fills it, in both halves:
Backend: /api/micropython-libs proxies the official micropython-lib
package index (the same one `mip` uses on real hardware). /search filters
the index; /fetch resolves a package plus its declared deps into .py
files. The proxy is what makes this safe to expose: only the official
index is reachable (the name must exist in index.json, files come from its
content-addressed store), source only (.py, never .mpy bytecode), path-
safe, and capped at 24 files / 256 KB per install. In-process TTL cache
over the index.
Frontend: the same Library Manager modal, mode-aware. On a MicroPython
board the search hits the index and "Add to project" writes the package's
files into the board's workspace (upsert — re-adding is the upgrade path),
visible in the explorer and travelling with the project. Before typing it
shows featured picks (ssd1306, umqtt.*, urequests) and the modules already
frozen into the firmware (neopixel, dht, onewire, ds18x20, machine,
network) labeled "built into the firmware", so searching for one answers
"just import it" instead of an empty list.
The file-materialization prelude now creates package directories: umqtt
ships as umqtt/simple.py, and MicroPython's open() does not mkdir, so the
prelude died with ENOENT on any packaged module.
Also ships a gallery example ("ESP32 + MicroPython: use an external
library") that documents the file-is-the-library mechanism with the
ssd1306 driver on the wired OLED.
"Flash to real board" no longer needs a prior Compile. The menu item is
enabled for every Arduino/ESP-IDF board (MicroPython keeps its own
gating) and the Flash dialog compiles first when the board has no program
or its code changed since the last build — compiler output streams into
the dialog's console, a red build ends in an error banner there with a
"Recompile & flash" retry, and only a green build reaches the flasher.
Web Serial's port picker needs a user gesture, so the web flow asks the
overlay to grant the port at the click (new WebFlashImpl.preparePort)
before the compile that may take minutes.
Stale detection: compileBoardProgram() now records a fingerprint of the
sources + language + build options (BoardInstance.compiledSourceHash,
utils/sourceFingerprint.ts); the dialog warns and rebuilds when the live
sources differ. utils/boardCompile.ts holds the compile request builder
shared with the toolbar (same files, same options).
The whole dialog and the menu hints are translated (editor.flash.* and
editor.canvas.flashHint.* in the 9 locales); before, every string was
hard-coded English.
Entitlement seam for #207 (desktop "upload to board"): lib/proWebFlash.ts
gains installHardwareFlashGate(); OSS default allows, and when a gate
blocks, the dialog shows a paid-license panel with a "See plans" link
instead of the port picker.
Two places where the editor bar spent width on nothing.
The file explorer put its label and its buttons on one line: WORKSPACE next
to new/open/save, and every board section's name next to rename / new file /
new folder plus the status dot. Between them those set a floor under the
panel's width, and the board name — the one thing a user reads there — got
whatever was left ("M5 Cardput..." in a 150px pane). Both headers now stack:
the label owns its row, the actions sit above (board) or below (workspace),
and the status dot rides in the actions row, where it costs the name
nothing. The actions row keeps its height reserved whether or not the
buttons are showing, so hovering a section never shoves the files below it
down. Default pane width 150 -> 124px; the freed px go to the code editor.
The toolbar's center slot is a flexible spacer that pins the right icons to
the far right, and nothing renders into it (no caller passes centerSlot). So
whenever the bar had width to spare — closing the docked AI chat is exactly
that — the spacer ate it: 191px of black between Reset and Libraries on a
1600px window. Empty, it no longer grows, so the editor controls stay packed
and the surplus collects at the one boundary that already carries a divider,
in front of the canvas controls (which belong over the canvas pane).
Now that SSR bodies are actually injected, two more prerender defects
showed: every t() rendered as its key (i18next was never initialised in
entry-server — the English bundle is imported now, plus the overlay's
"pro" namespace on pro builds, with an OSS stub), and every example
detail page rendered "404 Example not found" because ExampleDetailPage
was mounted without a Route so useParams() was empty. Verified locally:
/examples/blink-led SSRs breadcrumb, h1, description and JSON-LD; the
gallery and editor summary render translated copy.
replaceRootSeo() swaps the static #root-seo fallback for the page's SSR
body by string search. The old regex required a <script> right after the
block, but Vite hoists the module script into <head> in the built
index.html, so it never matched: every prerendered page (landings, /v3,
/about, all 171 example pages) shipped the HOMEPAGE's fallback body —
same <h1>, same nav — under its own <head>. Two hundred near-duplicate
bodies, and none of the landings' actual content (h1, sections, FAQ)
ever reached the crawler.
ExamplesGallery passes a server snapshot to useSyncExternalStore so
/examples SSRs instead of falling back to meta-only.
data/starters.ts maps eight free boards to the gallery example that opens
the editor with the board and a working blink (blink-led, nano-blink,
mega-blink, esp32-blink-led, esp32s3-blink-led, c3-blink, pico-blink,
attiny85-blink). ExampleEditorPage gives those ids an action-shaped
title/description ("New ESP32 project — free online ESP32 simulator")
while the gallery keeps the sketch title; the prerender writes
dist/starters.json so the prod app shell can serve the same head in the
raw HTML. This is the Wokwi "New ESP32 Project" shape: a URL of its own,
canonical of its own, that lands inside the product with the board loaded
— a candidate for brand sitelinks with usage intent.
Superseded release pages that were taking two brand sitelink slots (5.9k
impressions, 29 clicks). They still render for old links; hreflang is
only emitted for a page that is its own canonical (prerender + useSEO).
A fresh load of /editor is 301'd to /editor/ by nginx (prerendered dir),
while client-side navigation still lands on /editor. The three exact
pathname comparisons (header active state, header share button, starter
dialog gate) now ignore the trailing slash, and the header nav links to
/editor/ so crawled internal links match the canonical with no redirect
hop.
So the prod app shell can give a route that has metadata but (yet) no
prerendered file its own head, self canonical and hreflang instead of the
homepage's.
- useSEO: for the static SEO routes (marketing pages, examples gallery,
editor, home) the canonical is now the page's OWN locale variant and the
head lists every locale variant as rel=alternate hreflang plus x-default
on the locale-less English URL. /es/ and /zh-cn/ were declaring
canonical=/ while Google indexed and ranked them on their own — an
unstable contradiction. UGC pages (projects, profiles, example detail
pages) are the same English content under every prefix and keep a single
locale-less canonical with no hreflang.
- prerender-seo.mjs emits the same hreflang set into each prerendered page
and writes dist/seo-routes.json (path/title/description) so the prod app
shell can serve the /<locale>/<route> variants (which have no static file)
with the route's own head.
- /editor gets seoMeta and a static SSR summary component, so it is
prerendered: nginx now serves /editor/ as a real page and 301s /editor to
it. Both forms answered 200 with the homepage head before, and Google
indexed /editor and /editor/ as two pages.
- 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.