What We Shipped

Changelog

Every commit. No marketing spin.

1–30 of 662 commits
1 / 23
The server answers a no-subscription account with BOTH tier:'free' AND
subscriptionStatus:'EXPIRED' (no subscription row → status defaults to
EXPIRED). The client checked the status first, so packaged builds discarded
the explicit tier and mapped free users to the offline hobby limits — capped
posts, no Mill, and no usage meter. Dev builds masked the bug completely:
METERED_FREE_TIER_ROLLOUT (= isDev) maps the same branch to 'free' there,
so every dev-mode E2E passed while v2.4.1–v2.4.4 releases shipped broken.

An explicit server tier:'free' now wins at both mapping sites (loginOnline +
verifySession). Pinned by auth-tier-mapping.test.js, which mocks
app.isPackaged = true — the packaged posture is the only place this class
exists; dev-posture tests structurally cannot catch it.

Release notes: v2.4.6 framed as the single-purpose hotfix it is.
Typing a name with an illegal character (e.g. "Nice 3/4 Plug.jc3") could
fragment the save path — the project save failed with a misleading transient
error, and STEP export silently mkdir'd a "Nice 3" folder and reported
success. Deletes in the picker failed silently on volumes without a Trash.

- src/shared/filename.js: THE validate/sanitize choke point (Windows-portable
  rules on every platform), with a unit matrix + a structural contract scan
  that fails the commit on any new inline sanitizer regex
- Picker: live inline validation on save-as (red reason line + disabled
  Save), rename and New Folder block invalid names; open side untouched
- Main writers guard via new path-guard.js assertWritablePath(): validated
  basename on new files, parent must exist; export:writeFile's unconditional
  recursive mkdir removed (Drafting folder export passes createDirs
  explicitly); io-error passes user-facing guard messages through verbatim
- Failed saves now surface on the persistent error indicator, not the
  auto-hiding task indicator
- ~20 scattered inline sanitizers consolidated onto sanitizeFileName; the
  three drifted Ruida 32-char copies collapsed into shared/ruida-filename.js
- Picker delete: trash failures surfaced per item with a Delete Permanently
  fallback (new fs:delete IPC, protected-roots guard); cut/paste trash result
  checked; Del and Cmd+Backspace bound
- tool-library-static.test.js: fix stale assertion (case 'tool_library'
  moved to machineStorage.js in 8b09960f)

Card cmsm4o3hh000z01qj8doflouo.
- The machine now tells the session's viewer over the relay whenever a
  session ends on the machine side (badge End button, TTL expiry) —
  {t:'session.ended', sessionId, endedBy}. The dashboard's listener existed
  but had never received a message, so ended sessions looked live.
- Popup focus treatment is per-OPENER, not per-session: only popups opened
  on the heels of synthetic remote input (markRemoteInput + 1.5s window)
  open inactive and ignore blur. Suppressing blur for ALL popups during a
  session left locally-opened dropdowns stuck on screen — the customer's
  own badge/bell popups now keep normal click-away dismissal mid-session.
- Release notes: OEM Connect section updated to the shipped behavior —
  session badge (not banner), whole-window app-only screen sharing with
  in-window menus and adaptive crispness, mutual pointer visibility, and
  the OEM-side jetcad.io support console.
The captured frame is now the WHOLE JC3 window, composited in main from app
surfaces only (never the desktop): shell strip + active tab + any open shell
popup alpha-blended at its bounds. Desktop builds show the appliance's themed
in-app menubar while a screen session runs, so File/Edit/… menus render as
popup surfaces the composite can see — native OS menus never enter any app
surface.

- Remote input routes whole-window fractions to the surface under the point:
  topmost open popup → strip → tab; keyboard follows an open menu first.
- Session-scoped popup behavior: open with showInactive() and ignore blur —
  the driving OEM's browser owns OS focus, so blur-close killed every menu on
  the OEM's own physical click. Synthetic outside-clicks dismiss instead;
  open popups are swept when the share ends.
- Window-host appmenu dropdowns left-align under their title.
- Machine-user cursor + annotate remapped for whole-window geometry.
- Session badge truly centered in the 44px strip (bordered pill reads against
  the bar, not the icon baseline).
The desktop half of OEM Connect (card cmskjv00l000n01qjj4k9e01d): an OEM
machine builder can support a customer whose machine controller is JC3.

- src/main/oem-connect/: per-machine relay agent (persistent outbound WSS),
  capabilities.js HANDLERS registry as THE consent gate (contract test
  enforces every handler declares its cap), ConsentManager (Tier-2 session
  approval, 4h TTL, dismiss = deny), append-only JSONL ledger + website audit
  mirror, PTY terminal (all platforms), remote machine-config read/patch via
  the shared applyMachineSection with snapshot-before-every-write + rollback,
  JCM pre-provision + claim-code pairing (secret only in the vault).
- Screen share: MAIN-process capturePage of the active tab → self-contained
  JPEG frames (10fps, change-detection, adaptive quality — full-native-res
  sharp still after ~400ms of stillness), remote input mapped onto the tab
  webContents, machine-user cursor broadcast, annotate/pointer overlay.
- Transparency surfaces: task-indicator flashes on every Tier-1 access,
  shell top-right session badge with dropdown + End session, consent dialog.
- Machine Config dialog: OEM Machine section (pairing, enable, support
  contact card, Request Help, activity, snapshot restore).
- machines:changed broadcast → syncAllProfileManagers() so remote edits land
  in open dialogs; postinstall chmod fix for node-pty's spawn-helper.
A saved resolution came back after a reboot and the machine still looked broken:
the interface in one corner, black filling the rest, until you opened Settings →
Display and set the same mode again. It reads exactly like "display settings
don't survive a reboot", which is why it was reported that way — but wlr-randr
confirms the output IS in the saved mode. What never moved was JetCad3.

applySavedDisplaySettings() runs AFTER the shell window exists, deliberately: a
display that will not take its mode must never stop JetCad3 launching. So the
window is created at the output's preferred mode, the output then grows under it,
and on Wayland a fullscreen toplevel that misses the resulting configure simply
keeps the size it was created at. Nothing anywhere reacted to a display change —
there was no screen listener in the main process at all.

refitKioskWindows() re-REQUESTS the fullscreen state rather than writing bounds.
The size of a fullscreen surface is the compositor's to decide, and labwc already
discards our geometry on the popup path, so dropping and re-asserting the state is
what actually makes it send a fresh configure at the new size. Chasing the settled
content size afterwards is forceActiveTabViewportResize()'s existing job — Linux
reports it asynchronously — so the refit reuses that reconciler instead of
guessing a delay.

It hangs off two triggers, and that is not belt-and-braces padding: on the devvm
the order genuinely varies between boots. One restart had the post-restore call
do the work with the debounced metrics watcher then finding the window already
correct; the next had the watcher act first. A third boot resized on its own
before either ran. A single-hook version would have passed that lucky boot and
shipped broken, which is also why the no-op branch logs — without it a silent
no-op is indistinguishable from a dead hook, and the first verification pass was
inconclusive for exactly that reason.

Verified on the devvm across two consecutive restarts, both filling the screen:

  [appliance] refitting kiosk window 1280x800 → 2048x1152
  [appliance] display change observed (bounds,workArea)
  [appliance] kiosk window already fills the display (2048x1152)

Rotation and monitor hot-plug ride the same watcher but were not exercised — the
VM has one virtual output.

The decision half is pure and tested (display-refit.js, 9 cases); only the acting
half touches Electron. An empty changedMetrics counts as "worth looking at":
Electron does not reliably populate it on Linux, and this code exists for a Linux
compositor, so the opposite default would drop the very event it was written to
catch. In practice labwc sends bounds,workArea.

Also documents the themed menu bar, which shipped in f075e7e2 with no docs at all:
why GTK's bar had to go, one template and two consumers, the role-does-nothing
trap with Help → Learn as the worked example, positional ids, and the two failure
modes already paid for. appliance-mode.md was missing from docs/README.md
entirely, not just missing that section.

Cards cmsl4nlow000t01qjndpdg8ar (fix), cmsl4o9ac000v01qj82rrnddt (docs).
The in-app Learn reference was reachable from exactly one place: behind the
avatar in the top-right corner. Nobody hunting for documentation looks there —
they open Help, which held a disabled "About" row and Check for Updates.

Help → Learn now opens it, with F1 as the accelerator. F1 was free: nothing in
hotkey-defs.js, no F1 handling in the renderer, and F12/DevTools was the only
function key on the template. Being a native menu accelerator it is global and
fires ahead of renderer hotkeys, which is what Help should do.

One item on an existing channel — no new IPC, no preload change, no renderer
change. `menu:openLearn` already ran from the profile menu, and openLearnDialog()
is a singleton, so a second invoke focuses the open dialog instead of stacking a
copy.

The appliance needed nothing written for it. createMenu() ends in
publishAppMenuBar(), which serializes this same template, so one item serves both
the native menu and the themed menubar. That is also why it is a `click` and not
a `role`: app-menu-serialize.js can only perform roles it maps onto the focused
webContents, and an unmapped one renders a normal-looking row that silently does
nothing. The reason is stated at the call site, and the test pins it — the item
must invoke with a null webContents.

Verified on the appliance itself (devvm on the plasma rig): the themed Help
dropdown lists Learn between About and Check for Updates with F1 on the right,
and F1 opens the dialog with live content over the Drafting tab.

Docs: native-learn-community.md gains an Entry points table for all three
producers; keyboard-shortcuts.md records that app-menu accelerators are invisible
to findConflicts() and names the reserved set, so a future tool default doesn't
collide with F1.

Card cmskib64t000h01qjef4fbgyg.
Correcting a wrong conclusion from 2026-08-06. The `D` directive in tmp.conf
empties the directory whenever tmpfiles runs with --remove, which
systemd-tmpfiles-setup.service does at every boot; the 30d age governs only the
separate daily cleaner.

The lesson worth keeping is the method: presence of a directory after a reboot
proves nothing, because the next process to write there recreates it. Plant a
uniquely-named probe file and look for that.
Three defects Travis found driving the new menubar on the appliance.

**Unthemed scrollbar in a long menu.** `.sp-menu` IS a registered chrome root and
style.css already lists it in all six selector groups of the app-wide
themed-scrollbar baseline — but shell-popup.html does NOT load style.css. This
renderer builds its own stylesheet and imports only theme tokens, so that
scoping could never reach it. The same declarations now live in the popup's own
sheet, scoped to .sp-menu. Bell and profile never exposed it because they are
too short to overflow; the appliance Tools menu is 50+ items and is the first
that scrolls.

**Checkbox items rendered with no state, and threw when clicked.** `type` was
never serialized and `checked` was never drawn, so "Dynamic Orbit" read as a
plain command with no way to tell on from off. Worse, Electron toggles
`menuItem.checked` and hands the handler the MenuItem — every checkbox handler
in createMenu() reads `menuItem.checked`, so calling `click()` bare threw a
TypeError and the item did nothing at all. Checkable rows now carry a tick
column (reserved whether ticked or not, so labels stay on one left edge and the
menu does not jump as items toggle), and invoke synthesizes the same MenuItem
shape Electron would, with the toggled value.

**The Window menu was empty.** `{ role: 'minimize' }` / `{ role: 'close' }`
carry no `label` — Electron derives those for the native menu, and serializing
them produced blank BUT CLICKABLE rows. Roles now get the label Electron would
have shown, and an item that cannot be labelled is dropped rather than shipped
blank. Ids stay positional across a drop, so they still resolve to the right
action.

The menu is also omitted entirely on the appliance, which is what Travis asked
for and is right: JetCad3 IS the desktop there. Minimize would hide the only
interface on the machine with no taskbar to restore it, and Close would quit the
shop's CAM software. Neither carries an accelerator, so nothing is lost.

Verified on the appliance under labwc: Tools scrolls with a themed bar, Dynamic
Orbit shows its tick, the menubar reads File/Edit/Tools/View/Help. 20 tests on
the serializer; full suite green (523 files, 7955).

Cards cmsjq8ogy000401qjfee1cd8m, cmsfglyg8004401nynxhx2h6e (scrollbar class).
Also compacts MEMORY.md from 20.7KB to 16.6KB against its 24.4KB read limit —
the two all-closed index sections (shipped campaigns, Dynamic V3) moved into
their own files, which is what the index is for. Every link verified to resolve.
On Linux, Electron's application menu is drawn by GTK in the desktop theme: a
light grey bar over JetCad3's dark UI that no amount of our CSS can reach. It
was hidden on the kiosk in aec2b224, which fixed how it looked and left the
appliance with no visible menu at all — every accelerator still fired, but
discoverability was zero, and on a touchscreen with no keyboard it was
genuinely unreachable.

createMenu() already builds a plain Electron template — labels, accelerators,
enabled, separators, submenus, click handlers. app-menu-serialize.js reads THAT
template rather than introducing a second menu definition, so the native menu
and the themed one cannot drift. The native Menu object stays registered on the
appliance either way, because unregistering it would take all 17 accelerators
with it.

Items dispatch by template id. `role:` items are the trap this module exists
for: cut/copy/paste/selectAll/undo/redo carry no click handler — they are
implemented inside Chromium — so dispatching one by id does NOTHING unless it
is explicitly mapped onto the focused webContents. ROLE_ACTIONS does that;
window roles are reported back for the caller to perform; anything unmapped is
logged rather than silently swallowed.

The dropdown rides the view popup host from a4d8fa1d, with two differences that
a menu bar requires:

- Its overlay starts BELOW the tab strip instead of covering the window, so the
  titles stay live underneath. A menu bar switches on a single click (and on
  hover once open); a backdrop over the strip eats exactly those events and the
  second click only dismisses. Trigger popups keep the full-window backdrop,
  where a re-click SHOULD toggle off. The strip's own dismiss is handled in
  shell.js, since that is now the one place a click cannot reach the backdrop.
- Dropdowns LEFT-align to their title. computeInlinePlacement() grew an `align`
  option rather than a second copy of the maths.

The bar renders inline in the existing 44px strip rather than in a row of its
own: TAB_BAR_HEIGHT is a hardcoded constant every tab BrowserView is positioned
against, so growing the strip would silently mis-size every tab. The appliance
has no traffic lights, so the 80px reservation pays for it.

Submenus (Open Recent) drill in place with a back row rather than flying out —
easier to hit and to understand on a touchscreen.

Verified on a real installed appliance under labwc: File and Edit render with
accelerators, disabled items greyed, separators, submenu arrows; switching
File→Edit takes one click. 24 unit tests. Full suite green (523 files, 7946).

Card cmsjq8ogy000401qjfee1cd8m.
The appliance patches itself with unattended-upgrades (appliance repo). That
must never land during a cut: holding the dpkg lock, restarting sshd, or
swapping a graphics library out from under the kiosk while the torch is live is
a scrap-and-safety event.

JetCad3 is the only thing that knows a program is streaming, so it publishes the
fact as a marker file at $XDG_RUNTIME_DIR/streaming. The refusal itself lives in
the OS (jc3-unattended-guard, an ExecCondition= on apt-daily-upgrade.service and
unattended-upgrades.service) — same shape as the Appliance power menu, which
refuses rather than warns.

A FILE rather than IPC or D-Bus because the consumer is a /bin/sh guard systemd
runs with no session: `test -e` is the whole contract and cannot drift from a
protocol.

It lives in the kiosk unit's RuntimeDirectory=, so systemd deletes it if
JetCad3 exits. A crashed app therefore cannot strand a marker that blocks OS
patching forever, and absence is the correct default anyway: no JetCad3 means
nothing is streaming, because JetCad3 is what streams.

Polled, not event-driven, deliberately: the registry has no streaming-state
change event, and adding one would put this in the path of every status frame.
Seconds of latency are irrelevant to a guard whose consumer fires daily, and a
poll cannot miss a transition the way a subscription with one missed call site
can. A registry that throws leaves the marker alone rather than clearing it —
a transient failure is not evidence that nothing is running.

Inert off an appliance.

Card cmshgwgn7000p01qjbepjo4gq.
Every themed shell dropdown — bell, profile and the appliance Start panel — was a
transparent child BrowserWindow placed in SCREEN coordinates by
positionShellPopup(). A Wayland client cannot position its own top-level
surfaces, so labwc discarded the coordinates and placed all three dead-centre.
Not a panel bug: the mechanism the desktop relies on does not exist there. The
bell and profile menus had it too, unnoticed only because the appliance had never
been signed in, so neither trigger rendered.

popupHostKind() now picks the surface:

  window  macOS/Windows/X11  transparent child BrowserWindow, positioned by main
  view    appliance          transparent BrowserView over the whole window,
                             card positioned by the renderer in CSS

One renderer, one payload, one action dispatch, one anchor rect; only the
positioning differs. Nothing on the view path computes a screen coordinate, which
is what makes it compositor-proof. The window host is untouched beyond routing
through popupContents/popupAlive/popupSend, so desktop behaviour is unchanged.

The view deliberately covers the WHOLE window, tab strip included. The card
overlaps the strip by design — POPUP_GAP below the trigger's bottom edge, which
is above the 44px line, with the caret 5px higher again — so a view bounded to
TAB_BAR_HEIGHT downward would clip both. Covering the strip also makes the
surface a real backdrop, so a re-click on the trigger dismisses: the toggle that
blur gives the window host for free. There is no blur on a BrowserView, so
dismissal is a pointerdown on the backdrop rather than a focus event.

The view attaches BEFORE the payload is sent: a detached BrowserView has no
compositor surface and the renderer measures its card synchronously inside
render(). The card is held hidden until placement lands, so nothing flashes.

Placement maths is extracted to shell-popup-placement.js as a pure function — 12
tests, including that a card taller than the window clips at the BOTTOM rather
than pushing its header off the top.

Verified on a real installed appliance under labwc (tools/devvm in the appliance
repo): panel and profile menu anchored and right-aligned to their triggers,
click-away, re-click toggle and Escape all dismissing, no errors in the journal.
Full suite green: 522 files, 7934 tests.

Card cmsjnhwkt000001qjn2fx6tms.
Bug card cmsgtb4l7000001qj5czscjdb items 2 and 4, both confirmed on an installed
0.3.0 appliance in QEMU rather than reasoned about.

**#2 — the menu bar.** On Linux the Electron application menu is drawn by GTK
with the desktop theme, so the kiosk showed a light File/Edit/View/Window/Help
strip above JetCad3's dark chrome. It is the one surface on that screen we do not
control, and on the appliance it is redundant anyway — the ▦ panel already covers
the system-level actions.

Hidden with setMenuBarVisibility(false), NOT Menu.setApplicationMenu(null). The
template carries 17 accelerators including Ctrl+S/O/N/P and undo/copy/paste;
dropping the menu drops those with it, which would trade a cosmetic problem for a
functional one. Auto-hide is deliberately left off so Alt cannot summon the bar
back on a kiosk.

**#4 — the terminal's missing p10k prompt.** The fix is in the appliance image
(HOME was unset, so Chromium fell back to /tmp and zsh looked for /tmp/.zshrc;
and root never inherited /etc/skel). Nothing to change here beyond correcting the
comment on SHELL, which claimed the appliance provisions p10k "into /etc/skel" —
true, and precisely why root did not have it.

Full suite green. electron-vite build clean.
0.2.0 is already published and contains cage — appliance repo af639ad bumps the
image version for the labwc build. Correcting the claim here so the Display
section does not tell a support call that a 0.2.0 appliance can change its
screen mode when it cannot.
The appliance image swapped cage → labwc (appliance repo f168d80, card
cmsdjwk3s000i01nyronc95a9), so wlr-randr finally has a compositor to talk to and
this feature stops reporting "not supported". Three things had to change for it
to actually be correct there.

1. JetCad3 asks for fullscreen itself on the appliance.

cage force-fullscreened its single client, which is why nothing here ever had
to. labwc is a general compositor and does not, so createShellWindow() now
passes fullscreen: true when the appliance gate fires. Asking for the xdg-shell
fullscreen state is the portable answer — it removes decorations and fills the
output identically under cage, labwc and the live ISO's Weston, so the kiosk
looks the same on every path. A windowed, decorated JetCad3 is now a reliable
signal that the gate did not fire.

2. Rotation carries touch with it (touch-map.js).

Rotating the picture without rotating touch is worse than not rotating: every
press lands somewhere else. It does not come for free. labwc's seat.c does

    char *output_name = touch->output_name ? touch->output_name
                                           : touch_config_output_name;
    map_input_to_output(seat, dev, output_name);   /* NULL → no mapping */

and wlroots' wlr_cursor.c only transforms events for a device that HAS a mapped
output. A generic USB touchscreen carries no udev WL_OUTPUT property, so nothing
is mapped and no transform is applied. The only lever is labwc's
<touch mapToOutput="…"/>, whose value is an output name and so cannot ship baked
into the image.

ensureTouchMappedToOutput() writes it into the marker block in the JC3-owned
/etc/jc3/labwc/rc.xml and runs `labwc -r`. It is a one-time fixup per output:
labwc re-runs the mapping from seat_output_layout_changed(), so every later
rotation follows on its own. `labwc -r` works because it signals $LABWC_PID,
which labwc exports into everything it spawns and JetCad3 inherits as a
descendant of the kiosk session. The output name is validated rather than
trusted (it is written into a config file a root compositor parses), and missing
markers mean do nothing — a malformed rc.xml costs the user their only screen.

3. The blank timeout stops being a preference nothing reads (blanking.js).

It was persisted and never enforced; the dialog has always promised it works.
labwc has no idle timer but does implement ext-idle-notify-v1, so JetCad3
supervises a swayidle child that calls wlopm.

wlopm, not `wlr-randr --output … --off`: turning an output off removes it from
the layout and re-arranges views — labwc's own shipped autostart warns about
exactly this. wlopm is wlr-output-power-management, i.e. real DPMS.

A running machine job wins for free, with no second timer to disagree with the
first: Electron's powerSaveBlocker — already refcounted around machine runs — is
zwp_idle_inhibit_manager_v1 on Wayland, and labwc feeds any inhibitor into
wlr_idle_notifier_v1_set_inhibited(). Every failure path leaves the screen ON.

Parser fixes, from output captured verbatim off wlr-randr 0.3.0 (what noble
ships, so what the appliance runs — it predates --json, meaning the text
fallback is the live path, not the JSON one):

  * a mode line with no refresh clause ("1280x720 px (current)") was dropped
    entirely, which read as "this output has no modes at all" — the one shape
    that makes the picker look broken rather than limited
  * such a mode now gets a bare "1280x720" id, because --mode 1280x720@0.000Hz
    is a mode wlr-randr can never find

Full suite green: 7921 passed, 4 skipped. electron-vite build clean.

Cards cmsdjx83r000l01nyshekpsb1 (D), cmsdjs7k0000501ny3zl9hjv5 (epic).
Second Mac failure on v2.4.4, same class as the first: after @napi-rs was
excluded, makeUniversalApp died on the NEXT identical single-arch binary —
node-pty/prebuilds/darwin-arm64/pty.node. The x64ArchFiles rule named exactly
one module ("**/node-hid/prebuilds/**"), so every new dependency that ships
prebuilds re-breaks the Mac build the day it lands. node-hid hit this wall
once before and was allowlisted by name; node-pty just proved the pattern.

x64ArchFiles is now "**/prebuilds/**". A prebuilds dir is static per-platform
binaries by construction — identical in both slices, with the runtime picking
its own arch subdir — so declaring the class is the honest statement, and the
next native dep with prebuilds ships without editing this line.

Verified by running the REAL universal merge locally (electron-builder --mac
--dir --universal): both slices packaged and merged clean, which is the
exhaustive check — makeUniversalApp walks every file in both slices, so a
passing merge means no further file of this class exists. Output app confirmed:
main binary lipo'd x86_64+arm64, node-pty darwin prebuilds shipped, @napi-rs
absent.
The v2.4.4 Mac job failed in the universal merge:

  Detected file ".../@napi-rs/canvas-darwin-arm64/skia.darwin-arm64.node" that's the
  same in both x64 and arm64 builds and not covered by the x64ArchFiles rule

`@napi-rs/canvas` is pdfjs-dist's NODE canvas backend, pulled in transitively when
the eCAD datasheet viewer added pdfjs-dist (8096c679). The renderer imports
`pdfjs-dist/build/pdf.min.mjs` — the browser build, bundled by Vite into out/renderer
— so nothing in the app ever loads the native module. `files` took
`node_modules/**/*` wholesale, so 26 MB of arm64-only skia binary rode along, and on
an arm64 runner the same file landed in BOTH arch slices, which @electron/universal
refuses.

Excluded rather than added to x64ArchFiles: declaring an arm64 binary as an x64 file
would be a lie that happens to work, and the module is dead weight either way.

Why v2.4.3 was fine and this is not: pdfjs-dist arrived with the eCAD epic, which only
reached main today. Windows and Linux both passed — only Mac merges architectures, so
no test and no other platform can see this class of failure.
Dev version must read as the NEXT release, so package.json goes to 2.4.5 with a
matching rolling notes file (patch rolls 0-9 before the minor bumps).
eCAD had never been announced in ANY release notes despite shipping — the section
opened on a grid-density refinement, as though the workspace already existed.

- Intro now leads with two new workspaces, and frames eCAD as the first release of
  a bigger build: schematic capture and simulation are live, PCB layout and routing
  are next, and the browser tree already shows where they go.
- New section preamble spells out live / coming next / also on the list, plus where
  to find it. Tier claim checked against canAccessEcad: Drafting and up, and on a
  free online account where it is METERED — so it says it draws on free-tier hours
  rather than implying it is unlimited.
- Four foundation sections that were missing entirely. The 13 existing subsections
  all covered refinements; nothing covered the library, the part picker, the rules
  check or the BOM, which are the features someone opening eCAD meets first.
- Conway Welding's mandala panel drove three separate fixes across two workspaces
  (region detection hours → ~1s, Design trim 5.7s → 8.7ms, Join for ring
  fragments). That was told as three unrelated entries; it now reads as one story
  in the intro, with each detail section pointing back at the file, and credited to
  them by name — the same form used for Scott Pridham in v2.3.8.

Two claims were wrong on the first pass and are corrected here: DNP parts still
appear on the BOM flagged (only excludeFromBom drops a line), and the library has
no 74-series logic, so the parts list now names what actually ships.
Plus the durable lesson in memory: a decision about a part must read the part as
DRAWN, never the kerf-offset geometry, and the test for it is a kerf SWEEP.
A 360-part job grid-packed nothing at 14 gauge and packed correctly at 3/16".
Same parts, same sheet — only the material changed.

The grid pre-pass decided eligibility from the KERF-OFFSET ring, so material →
cut chart → kerf → a different polygon → a different density → the other side of
the 0.85 floor. Measured live on the real job, density per family:

  Front & Rear Plate   raw 0.840 | k0.042 0.856 | k0.067 0.863   crosses the floor
  Ratchet              raw 0.295 | k0.042 0.681 | k0.067 0.340   0.39 swing
  Spool Gear           raw 0.463 | k0.042 0.580 | k0.067 0.199   0.38 swing

Not a small perturbation, and not a regression — this file is byte-identical to
v2.4.3, so the same job behaved the same way on the last release. Travis just
hadn't nested that material before.

Eligibility now reads the part as DRAWN. AutoNester passes `designRing` alongside
the offset rings, and the pre-pass decides strategy and density from it. The cell
RECIPE stays on the offset ring — that geometry is what has to clear its
neighbours, and gridFill derives real pitch from it — so none of the mate tuning
moves. Grouping was already meant to be design-based (the original card's Fix
#4); it turns out `raw` was the un-reconstructed OFFSET ring, so it never was.

Floor 0.85 → 0.83: flat plates sit on that line and tile well. Front & Rear Plate
is 0.840 by design and had been flagged a near-miss twice; at 0.85 it grids on no
material at all. Circles (0.785) and gears (0.46–0.71) stay below it.

Second bug, found while testing the first and fixed with it: `2.5 × kerf` can
exceed a GENUINE feature. This plate's notches are 0.25" deep, so at kerf 0.14 the
threshold is 0.35", the real notch edges are dropped, their neighbours meet far off
the part, and a 5" plate reconstructs 47" wide. That was survivable while only
classification read the ring — garbage just meant "passthrough" — but the cell is
measured from it too, so a 47" cell fit no sheet and the group silently placed
nothing. Reconstruction now rejects itself when it grows the bounding box past
1.25×. Checked on EXTENT, not area: the garbage is a long thin sliver whose area
stays inside any sane area bound.

Three new regression tests, each confirmed to FAIL on the pre-fix code: strategy
and grid COUNT identical across a 10-point kerf sweep, and the reconstruction
extent guard. Two more assert what must NOT change — a circle still never grids,
and cell pitch still grows with kerf, so clearance stays material-dependent as it
should. Fixtures use the real Front & Rear Plate polyline off the live document.

Verified live on the 360-part job: all ten families classify identically across
kerf 0.02–0.09. Full suite 7888 passed; production build clean.
Also records two traps found on the way: npm run build always dirties
jetengine.wasm (not byte-reproducible), and the scrollbar contract never scans
new stylesheets.
Brings the branch forward 73 commits. One conflict, and it was a union rather
than a choice: both sides added keys to the frozen TASK_KEYS registry in
file-picker/last-path.js (eCAD's four library/BOM/print keys, appliance's
browse key), so both sets are kept.

Everything else auto-merged, including the four shared files both sides touched
(main.js, preload/index.js, common/dialog.js, settings-dialog.js).

Full suite 7881 passed / 4 skipped / 0 failed; production build clean.
The parallel Design session left a note to 'build it or revert' the website's
sixteen-paper-size claim. It was this branch's work landing alongside it, so the
claim is now true — corrected rather than left as a trap for the next agent.
Two sessions built the same thing on the same day. main's 23f727ad extracted the
print core into `common/print/` for Design; feature/ecad had grown paper sizes, a
zone frame and its own preview overlay on the old `common/print-page.js`, which
that commit deleted. Resolved by keeping main's structure and folding this
branch's additions into it, so the repo has ONE print core with three consumers
(Drafting, Design, eCAD) rather than two with a merge scar between them.

Deleted `common/print-page.js`, `print-page.test.js` and `print-overlay.js`.
`PrintPaperOverlay` was a duplicate of main's `PrintFrame` — same accessor-callback
design, same method names — so it went, and the eCAD Print tool drives PrintFrame.

Folded into common/print/:
- paper-sizes.js  +8 large sheets (Tabloid, A3, ANSI C/D, both orientations),
                  ZONE_BAND_IN and sheetInsetIn() — the datum everything that must
                  stay clear of the frame now measures from.
- sheet-frame.js  NEW: zone reference marks, SVG + CSS preview, mirroring
                  title-block.js's two-renderer contract.
- title-block.js  honours the zone inset in both renderers; the table face shrinks
                  once rows get tighter than it (a schematic carries more rows than
                  three tolerances and they overlapped into a grey band).
- print-document.js  accepts an array of pages. A multi-sheet board must print as
                  one document, not one dialog per sheet — and this unblocks the
                  Design tiling that card deferred.
- print-prefs.js  takes a NAMESPACE. Drafting and Design keep sharing the historic
                  keys; eCAD opts out, because a schematic wants landscape and a
                  drawing does not, and one shared key means every workspace switch
                  silently re-papers the other. Each namespace declares the scale
                  modes it accepts, so a mode one saves is never restored by another
                  that cannot honour it.

Two bugs the merge itself would have shipped:
- eCAD held its 2D view lock in `controls.disableRotate`, which is the TRANSIENT
  flag PrintFrame's camera lock clears on exit — so opening Print and closing it
  again would have permanently unlocked the schematic into 3D orbit. It uses
  `setNavigation({ lock2D: true })` now, as Design does; arcball-controls.js
  documents exactly this trap.
- Both sides independently added a workspace-level print entry point under
  different names. Converged on one `startPrint()`, so the File → Print gate is a
  capability check rather than a growing list of workspace names.

Also: eCAD's four createDialog bodies now declare themselves in the scrollbar
contract's reviewed allowlist (they predate that contract, which main added), and
JetEngine/dist/jetengine.wasm is REBUILT from both sides' C++ — taking either
side's binary would have silently dropped the other's kernel.

Full suite 7713 passed / 4 skipped / 0 failed; production build clean.
Printing a schematic was one button and a guess: hardcoded landscape Letter,
shrink-to-fit, no preview, no persistence, and a `paperKey` parameter no caller
could reach. It is a modal tool now — the sheet appears on the canvas at real
size with everything outside it dimmed, and you frame the drawing on it.

- Three scale modes: fit-sheet (default, per-page, never magnified), true 1:1,
  and manual. The last two are camera-framed and anchor on the PAPER centre,
  not the drawing area's — centring them in the drawing area shifts the print
  half a title block off the frame the user set, silently.
- Title-block metadata arrives as model v7: board.meta shared by every sheet,
  sch.meta overriding the ones that belong to one sheet, blank meaning inherit.
  The date is stored and editable, so a reprint does not re-date an issued
  drawing. Migration seeds every field blank rather than stamping upgrade day.
- Zone reference marks, counts derived from the paper rather than fixed.
- Eight large paper sizes (Tabloid, A3, ANSI C/D) reach Sketch's print too.
- Refdes and value visibility follow the View HUD: what you hid does not print.
- Power symbols now draw from render.js's own helpers. The hand-rolled copy
  ignored ps.rotation entirely, so a rotated rail printed pointing the wrong
  way while looking correct on screen, and sheetBounds framed rails by their
  origin, cropping a ground stack at the sheet edge.
- print-page.js had no tests at all; it does now.
Conway Welding, forum #46 comment 177: the Design Trim tool made the app
unusable on a 1,294-shape / 75,594-vertex design. Activating it cost 5,715 ms
per pointer move, and events queued behind each scan.

Root cause: `_findTrimTargetAt` rebuilt the entire world-space cutter list
INSIDE its per-shape loop — O(shapes² × verts), ~98M vertex clones per move.
Building that list once costs 1.5 ms, so the cost was pure redundancy. Nothing
in the workspace cached world geometry, nothing pruned by bbox, and nothing
coalesced pointer events.

Measured on a Conway-scale soup (1,300 visible shapes / 75k verts):

  trim query, cold     5,715 ms → 8.7 ms      (~660x)
  trim query, warm     5,715 ms → ~0.2 ms
  hover hit-test        18.3 ms → ~0.15 ms
  drag pointermove      ~75 ms  → matrix patch, O(selection)
  worker round-trip        n/a  → 3.2 ms warm (render thread pays a postMessage)

Design geometry cache (design-geometry-cache.js)
  Per-shape world polygons + per-polygon bboxes, keyed on the VALUES
  (polygons ref, tx, ty, layerId) rather than the shape id — every model write
  replaces `polygons` wholesale or moves the transform, so a stale entry is
  structurally impossible. Entries are read-only; mutation paths keep using
  `designPolygonsWorld()` clones. Hover, trim, bridge contours, marquee and
  bbox reads all go through it.

Trim rework (design-trim.js)
  Split into a cursor-independent `computeTrimIntersections` (the expensive
  half, memoized until the design changes) and a cheap cursor-dependent
  `findDesignTrimTargetFromPre`. `findTrimTargetInEntries` is the shared scan —
  bbox-prunes candidate targets by cursor and cutters per target polygon.
  Pruning is exact: a cutter outside the target's bbox cannot contribute an
  intersection. `findDesignTrimTarget` is untouched and is now the FROZEN
  parity oracle the tests check the production path against.

Geometry worker (design-geometry-worker.js + -client.js)
  Persistent worker owns trim planning and bridge preview evaluation. Snapshot
  sync is identity-diffed and transferable (Float64Array coords + Uint32Array
  meta). Queries are latest-wins; stale replies are dropped by sequence and
  tool-active checks. A worker failure marks it dead for the session and the
  synchronous (index-bounded) paths take over — never retried inline. Clicks
  always resolve synchronously so they act on what the user sees.

Matrix-patched drags
  Design is polygon-only, so move/rotate/scale/skew are affine maps of the
  gesture-start state. The model is no longer mutated per pointer move: the
  gesture becomes one THREE matrix per selected root and bakes ONCE on
  pointer-up through the verbatim snapshot appliers. `model-utils.test.js`
  pins each preview affine to its world-poly bake helper so they cannot drift.
  Rotate keeps redrawing the overlay through `_rotateOverlayState` (matrixing
  it too would double-transform); skew excludes canvases because its bake never
  touched them.

Also: rAF latest-wins coalescing for all cursor-follow work; hover highlighting
patches only the two affected shapes; scoped `_rebuildShapes`/`_rebuildCanvases`
replace full scene rebuilds during gestures.

Two user-visible behaviour fixes fall out of the drag rework: Escape mid-drag
now cancels the gesture cleanly (it previously stranded half-applied geometry
with no undo entry), and click-and-drag in one motion drags the shape you
pressed on rather than the prior selection.

Verified live by Travis on the customer's file. 6,371 tests + production build
green. New guards: trim parity vs the frozen oracle over a seeded polygon soup,
memo-reuse, pack/unpack round-trip, and a 10x-Conway scale tripwire (10k shapes
/ 750k verts). The new worker is registered in the worker/DOM contract test.

Docs: docs/Design/performance.md (indexed in docs/Design/README.md).
Five Web Workers died in dev with "Uncaught ReferenceError: document is not
defined" before processing a single message: the Plasma/Laser/Oxyfuel/Router
import workers and Router/toolpath-worker.js. No DXF, SVG, Import-From-Design
or SheetCAM job import worked in any CAM workspace, and Router toolpath
generation was dead too (unreported).

Trigger: commit 0933da9b (saved logins), 3h25m after the v2.4.3 tag, added
`import { attachPasswordReveal } from '.../password-field.js'` to
common/dialog.js — and password-field.js imports password-field.css. That made
it the first STATICALLY reachable stylesheet in a chain the CAM workers had
carried harmlessly for months (import-worker → machine-profile-manager →
file-picker/index → picker-dialog → dialog). In the Vite dev server a CSS
import compiles to updateStyle() in @vite/client, whose body is
document.createElement("style") — evaluated at module load.

Static vs dynamic is the whole story: picker-dialog reaches settings-dialog and
its 13 theme CSS files via `await import(...)`, which never evaluates at worker
startup, which is why that path was always reachable and never threw.

Production builds were never affected — rollup tree-shakes the unused chain and
extracts worker CSS to a sibling asset — so this was invisible to release QA
while making dev unusable.

Fix — split the pure part out, re-export from the DOM-y module so renderer call
sites are unchanged:

  common/machine-simplify-tolerance.js  <- common/machine-profile-manager.js
  Design/design-cross-import-core.js    <- Design/design-cross-import.js
  common/cam/sheet-origin.js            <- {Plasma,Laser,Oxyfuel,Router}/scene.js
  utils/error-indicator-view.js         <- utils/error-indicator.js (registry stays)

getSheetOriginOffset was byte-identical in all four sheet CAM workspaces; the
move also removes that 4x duplication. Part managers and sheetcam-import now
import bump-stop/part-transform.js directly instead of the barrel, which was
dragging localStorage in for one pure helper. localStorage reads in
design-cross-import-core and Router/operation-params go through
`globalThis.localStorage ?? null` — a bare read is a ReferenceError in a worker,
not undefined.

Guard: workspaces/worker-dom-contract.test.js walks every worker entry's static
import graph (es-module-lexer, d === -1) and fails on any reachable .css or
DOM-only global. Negative-tested by re-introducing the original bug. A runtime
import test CANNOT catch this class — vitest runs environment:'node' but stubs
CSS imports to empty modules, so importing the broken worker entry passes.

Verified: full suite 445 files / 6355 tests green; production build clean; all
five workers spawn cleanly in the live dev app.
Design could be pulled from by Drafting and every CAM workspace, but it could
not pull from Drafting, could not print, and its import/export lived only in the
File menu. Adds an Import / Export section to the Design browser at parity with
Drafting -> Sketches, and rolls in the scrollbar-theming bug
(card cmsfglyg8004401nynxhx2h6e), whose two reported dialogs are the exact
dialogs this work extracts.

Shared sketch-import chooser (new common/sketch-import-dialog.js)
  The component-tree picker existed 4x, ~220 near-identical lines each in
  Plasma/Laser/Router/Oxyfuel, differing only in whether the engrave sub-rows
  render. Design needed a fifth. Extracted to one function behind `qty` and
  `buildLayerRecord` options; every CAM `_executeSketchImport` is untouched, so
  the extraction is strictly the picker UI. Net -728 lines across the four.

Design <- Drafting import (new design-sketch-import.js)
  Geometry runs through the SAME designEntitiesToPolylines() a DXF does —
  sketches emit exactly its entity vocabulary — so arcs tessellate there and no
  bulge reaches a Design shape. Construction geometry imports onto its own
  `<Sketch> - Construction` layer; hidden sketch layers import and create hidden
  Design layers; canvases import at the same place and size (sketch canvases are
  corner-anchored, Design canvases centre-anchored); points and dimensions do
  not. Layers arrive prefixed (`Base - Cut`) via prefixedDesignImportLayerName.
  Geometry lands at TRUE coordinates — same-origin sketches overlap, which is
  the only behaviour that preserves relative position between aligned sketches.

  Adds `layerAware` to designEntitiesToPolylines: read ent.layer on every entity
  type, not just DXF polylines. Off by default, so DXF/SVG are byte-identical.
  Under DXF only LWPOLYLINE carries its layer today, so a file of bare
  LINE/ARC/CIRCLE entities still collapses to one layer — turning it on there is
  a real improvement but repartitions existing files, so it wants its own pass.

Shared print core (new common/print/)
  Paper sizes, the 1:1 camera lock, the paper-outline overlay, the title block
  and the print iframe move out of Sketch/print.js. Behaviour-neutral: the
  generated title block was diffed against the pre-refactor implementation
  across 6 paper/unit/viewBox cases and matches to 1e-9 (only float association
  order differs). localStorage keys are unchanged.

Design print (new tools/print/main.js + design-print-svg.js)
  A real Tool, so the camera lock and overlay get proper lifecycle; filtered out
  of the toolbar — entry points are the browser row and File -> Print. Paints
  canvas images, then mesh fills, then outlines in layer colour. Only closed
  contours fill, evenodd so inner contours punch holes. Title block carries
  DESIGN / DATE / SCALE / SHEET. Single page, as in Sketch.

Send To… (common/send-to-cam.js, moved from Drafting/)
  Now shared by both push sources; gains Oxyfuel as a target, which also
  benefits Drafting. Each CAM workspace gains importDesignFromDesign(), a thin
  public wrapper over its existing _executeDesignImport.

Scrollbar theming, structurally (closes cmsfglyg8004401nynxhx2h6e)
  The opt-in class allowlist named 8 selectors against scroll containers in 34
  files, so nearly every container in the renderer was unthemed. Worse, both
  reported dialogs were classless inline-styled overlays — no allowlist entry
  could ever have reached them, which is why earlier rounds did not hold.

  Replaced with an app-wide baseline SCOPED TO CHROME ROOTS (#sidebar,
  .dialog-window, .jc-modal, menus, indicators, .fwflash-dialog); the viewport
  and HUDs keep macOS overlay scrollbars. This is not the unscoped
  ::-webkit-scrollbar that regressed before. Adds makeScrollable() as the one
  choke point, createModal()/.jc-modal for the hand-rolled overlays, and two
  guards: a static contract test (unscoped-rule ban, makeScrollable-or-reviewed
  allowlist, CSS<->auditor list sync) and a dev-only runtime auditor that warns
  when a bar is actually visible outside a chrome root — the half a static scan
  cannot do. All three guards were verified to fail on the real bug.

  The sweep found a third live instance: .fwflash-upd-notes in the firmware
  flash dialog scrolled with no theming at all.

Tests: 5 new suites; full suite 6330 passing; production build clean.
Docs: theming.md (Scrollbars rewritten), Design/README.md, CLAUDE.md choke-point
rows for makeScrollable / createModal / the sketch chooser / the print core,
release notes v2.4.4.
1–30 of 662 commits
1 / 23