Every commit. No marketing spin.
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.