Every commit. No marketing spin.
# Conflicts: # .claude/memory/MEMORY.md # .claude/memory/project_run_history_fragment_bug.md # release-notes/v2.4.9.md
Every touchoff program was recorded as a ~10 s pre-first-pierce fragment: the tracker finalized on the first Idle+drained status tick, which is true in every NGC-runner segment gap — i.e. during every touchoff probe. Proven from the rig's serial transcripts, runs.db rows and decoded series (22 runs, all pierces=0). - runSettled() (run-metrics-tracker.js) = Idle/Alarm + !streaming + !runActive; _tickRunTracking uses it — one row per job, through probes, pierces, M0. - Outcomes are real: Stop/alarm/error latches win; mid-run disconnect finalizes (errored, or the Stop latch) instead of stranding a stale tracker; a run() rejected before streaming discards immediately — no phantom rows. - status.doCmd: GrblController tracks outgoing M62-65 P<n> at the _rawWriteLine choke point (applyUserOutputCmd, pure), clears on reset, stamps into every status. arcOn()/_torchOn() read it between reported UIO:DO and the sensor fallbacks — pierce counting works on firmware with no :report pins. - hadArcSensor is earned (a sensor must assert once); a commanded source already on at the seed sample is a stale latch and is ignored until it first drops. - cut_distance_in (schema v3, in-place idempotent migration): XY travel while the arc is on — distance_in stays odometer-style total travel. Wired through aggregates, day buckets, the History dialog and the PDF report; OEM Connect stats.snapshot carries it automatically. Tests: full sweep 9029 passed; new coverage includes a lifecycle regression modeled on the real 2026-08-15 serial timeline, doCmd source/precedence, stale-latch, earned-sensor, cut-distance and the v2→v3 migration.
Updating a part from a changed Drafting sketch / Design dropped sheetId on the whole family, turning it loose so the staging reflow re-gridded it off-sheet (rotation zeroed) — locked, already-cut parts included — and the update also rewrote locked parts' geometry, destroying the record of what was physically cut. The bug existed verbatim in all four cutting workspaces. The semantics now live in ONE shared module, common/cam/part-update.js: preservePartPlacement (update preserves sheetId + _nested, not just transform), applyFreshPartToFamily (skips every locked member; a locked master's unlocked clones still refresh in place), refreshPartStaleFlags (per-part staleness; locked parts get an informational _stalePinned instead of _staleRef), isReflowableLoosePart (staging reflow can never move a locked part; _gridLayoutParts skips them too). Browser panels show a dimmed non-clickable badge on locked+stale parts and count only unlocked families. Guarded by common/cam/part-update-contract.test.js (four-workspace source scan) + part-update.test.js; new CLAUDE.md choke-point row and docs/Plasma/part-source-links.md.
GcodePilot run-state predicate fix (card cmsv000c3005l01makv41l12h): the background machine HUD, eight further guards, and the runtime-hook rule surface all asked 'is a program running?' via status.streaming — the serial streamer's byte-level flag, which the NgcRunner parks at every touchoff and for the whole of an M0 pause. Introduces manager.isRunActive() -> status.runActive as the single gap-free answer, with a scanning contract test.
Follow-up to 9cbe5397, at Travis's direction.
The hook rule engine had the same read as the HUD. docs/GcodePilot/runtime-rules.md
has always documented the scope as "streaming (a program is running)", but
scopeActive() gated on status.streaming — the byte-level streamer flag the
NgcRunner parks at every segment boundary. So a `streaming`-scoped rule went
INERT during every touchoff and for the whole of an M0/M1 pause: precisely the
windows an arc-loss, limit or breakaway rule most needs to be live. This makes
the implementation match its own documented contract.
- scopeActive('streaming') → status.runActive.
- map._streaming (the #<_streaming> condition param) → status.runActive.
Both move together deliberately: if only one changed, `scope:'streaming'` and
`scope:'always'` + a `#<_streaming>` condition would disagree on the same tick
and the rule language would be incoherent. A test asserts they agree on all
four combinations.
The persisted scope id stays 'streaming' — it is stored in every .jcm hook and
renaming it would invalidate saved rules. Instead the UI now reads accurately:
new SCOPE_LABELS ('Always' / 'While a program is running' / 'While cutting')
drives the Scope combo, and the condition-builder field label becomes
"Program running". Ids remain the storage format, labels the display.
buildHookContext now documents that it requires a manager-stamped payload. Both
callers already qualify: the broadcast tick (_statusPayload) and the
program-complete tick (mgr.getStatus()).
Tests: the streaming scope had NO coverage before. Added gate + param agreement
tests in rule-context, and an end-to-end runtime-hooks test proving a
`streaming`-scoped limit rule fires in the segment gap. Reverting rule-context
fails 4 of them. The run-active contract test now also pins both rule-surface
reads. Full sweep 1833 passed / 3 skipped; build clean.One shared wheel→rotation stepper (common/wheel-rotation.js) replaces the six copy-pasted scroll-px×0.12 mappings: 100px detents, velocity ramp 1°/5°/15°, Shift+Alt fine mode, crossing-stop snapping on every 45° via the generalized bridgeSteppedRotationDegrees, and a sticky snap detent that absorbs macOS momentum after landing. Single parts snap their absolute (mirror-aware) angle; multi-selections snap the gesture delta; < / > hotkeys stop on crossed 45s; typed dialog/HUD angles stay exact. HUD highlights the angle field on-grid. New wheel-rotation-contract.test.js scan + 27 unit tests; CLAUDE.md choke-point row and docs/Plasma/README.md updated; v2.4.9 release notes section added.
The background machine HUD blinked on and off once per pierce on a plasma job, and vanished entirely at an M0/M1 pause. `status.streaming` is the serial streamer's own "lines in flight" flag, not "a program is running". The NgcRunner executes any reactive program (G38.x / o<sub> call / M66 / (MSG) / M0 — i.e. every plasma program with a touchoff or fire-torch sub) as MANY short streams: it drops to false the moment each segment drains, and that gap is exactly when the probe runs, because probe() refuses to run while the streamer is active. The main process already had a gap-free notion (manager.isBusy), but it was only stamped into the connectionState payload, which is emitted on connect/disconnect only — so nothing gap-free ever reached the renderer's store. - manager.isRunActive() = runner.running || _streaming, connection-scoped, and deliberately WITHOUT state === 'Run' so a manual MDI move never raises a job card. isBusy() (the run-guard) keeps it — a safety gate stays conservative. - Stamped onto every status tick and onto getStatus() via _statusPayload(), so the one-shot IPC prime matches the stream. Additive; streaming is untouched. - MachineStore gains isRunActive(); getRunning()/getBusy() read runActive, and it is cleared on complete and on disconnect so a card can't be stranded up. Eight further guards were reading streaming as "is a job running" and silently opened mid-run, several with real consequences: a second Play could restart the program, jump-in could start a second one, Plate Squaring could start, the controller reset/reboot menu lit up, the Serial Monitor unlocked its MDI input, output badges became clickable, deactivate() tore the DRO down for the rest of the run, and Post to Machine loaded OVER the running program. All now ask this._runActive() / store.isRunActive(). Five genuine reads remain and are allowlisted with reasons: settle-after-Stop detection (x3), arc-active "cutting", and the _runSawActive accumulator. Tests: 28 new across manager.run-active, machine-store and a scanning run-active contract test; reverting getRunning() to streaming fails 6 of them. Docs: GcodePilot README gains a three-predicate table; CLAUDE.md gains the choke-point row. Not changed, deliberately: the runtime-hooks scope 'streaming' gate and the #<_streaming> NGC global (rule-context.js) have the same read, but they are customer-facing rule semantics on machines in the field — Travis's call.
Found on the table: a "Gusset" whose first contour segment measured 0.049921" under the default 0.050" over-cut left 0.000079" of remainder that fell on the following ARC. extractOvercutPath split it, and the post formatted both endpoints to the same 4 decimals: 27 G1 X6.9445 Y0.2421 28 G2 I0.0201 J0.0202 <- no axis words FluidNC rejects that with error:35 and ABORTS the running program. A post that restates X/Y unconditionally instead emits the same block with coordinates, which RS274 reads as a full 360 degree circle — so the non-modal dialects were silently worse. The I/J words are byte-identical to the arc above because a split arc keeps its centre; that is the tell. Not an oxyfuel regression, though the timing suggested one: the repro was run against the pre-oxyfuel post engine (0c5426f5) and emits the identical block. overcut.js is unchanged since 50d486ab (Jun 25) and the feedAt work added a fast path that is line-for-line the old code. Latent for seven weeks; this part is simply the first to land inside the rounding. Fixed at both ends, because a producer is not the only way an arc reaches a post: - overcut.js: MIN_OVERCUT_TAIL_IN (1e-4") replaces the 1e-9 break, on the consume branch and the split branch both. The over-cut now stops on the last vertex it can reach. Shared by Plasma, Laser and Router. - _arcWouldReadAsFullCircle moves from RouterPost up to PostProcessor and becomes the guard of record for EVERY dialect. z is optional (a 2-axis post passes none; a 3-axis post still passes one, which is what keeps full-turn helixes posting), and the current position resolves from either bookkeeping style — the modal xOut/yOut Variable or the _lastX/_lastY that mach3/mach4/promach/linuxcnc-plasma/flcnc keep instead. The formatter resolves xFmt -> xOut.format_ -> xyzFmt -> a Format built from properties.decimals, so flcnc's hand-rolled _num() is covered too. - All 12 bundled .jcpst posts that write an arc now open onArc with the guard. Tests: post-arc-axis-words-contract.test.js (new) source-scans every .jcpst for the guard call before its writeBlock, then posts the real Gusset geometry end-to-end through each and asserts no axis-word-less motion block and no arc at all for the degenerate tail — plus a 0.0012" counter-case so it cannot decay into a blanket small-arc filter. Its regexes match the packed block style too (G2X1Y2), which would otherwise exempt FangLing from every assertion. Confirmed it fails without the fix: 20 failures across all 12 dialects, split exactly along the two symptom families. overcut.test.js +3, post-degenerate-arc.test.js +5. Docs: CLAUDE.md choke-point table, docs/Plasma/toolpath-generator.md, release notes v2.4.9. Verified by Travis on the plasma rig (Xmotion 4x8 HD / FluidNC).
Three defects from Travis's first test of the merged work.
1. RENAME NEVER FIRED. The tab click handler called renderTabs(), which rebuilds
the whole strip — so a double-click's FIRST click destroyed the very element
its dblclick listener was attached to, and the event could never arrive. The
handlers are now DELEGATED to the strip (which survives re-renders), and the
click handler no longer re-renders when the tab is already active. Added the
right-click menu Travis expected: Rename / Reset name / Split / Close, via
showContextMenu() per the choke-point rule.
2. SIZE AND POSITION NEVER PERSISTED, for two independent silent overrides:
- `.makeResizable()` restored the saved size and then `.setSize(900, 560)` —
next in the builder chain — stamped the default straight over it. setSize
is now a DEFAULT pre-show (it only fills what a restore left null) while
staying an override on a live dialog. It also records `_declaredSize` so a
reset has a real default to return to.
- A modal re-centered on every open, discarding the position the drag handler
had just saved. Centering now applies only when there is no restored
position — it is the INITIAL placement, not a permanent one.
`resetDialogPositions()` (View → Reset) consequently stops skipping modals:
a persisted modal can now be somewhere the user wants undone. It clears both
storage keys, un-maximizes, restores the declared size, and re-centers modals
/ re-tiles the rest.
3. TERMINAL SETTINGS LEFT THE APP SETTINGS DIALOG. A shell path, scrollback
depth and cursor style are power-user knobs for a tool most users never open;
they read as noise in Settings → Display. They now live behind a gear in the
terminal's own toolbar (`terminal-settings.js`). The VALUES still live in
`settings.terminal` — main reads `shell` at spawn time — only the UI moved.
Tests: dialog-persist.test.js pins both overrides (restored size survives
setSize; declared size is remembered; a modal keeps a restored position) plus
the collapse round-trip. Full suite 8883 passed / 0 failed; build clean.
Docs + release notes + website Learn updated for the settings move and the
right-click menu.Dialog chrome polish (card cmstpedou004501ma3jqb38tx) + terminal polish (card cmstpdki6004301mambmkc3rn).
Three defects Travis found driving the viewer in Fullscreen, all in the same
piece of chrome.
The video transport drew over still images. Not a logic bug — _syncChrome() was
setting `transport.hidden = true` correctly the whole time. `.image-viewer-
transport { display: flex }` is a CLASS selector, and a class outranks the UA
stylesheet's `[hidden] { display: none }`, so the element stayed visible and
`.hidden = true` was a silent no-op. The preview pane already guarded this
(`.file-picker-preview[hidden]`); the transport did not.
There were three stacked bars. Transport and trim were separate floating
containers above the toolbar. They are now `display: contents` GROUPS inside
the single `.image-viewer-toolbar`, so their buttons sit directly in its flex
row and hide with the group when the current item is a still. Travis: "I only
ever want one single toolbar, never the two stacked."
The toolbar grew a horizontal scrollbar. `left: 50%` with no `right` leaves an
available width of only 50vw, and a shrink-to-fit box can never exceed it — so
`max-width: 94vw` was dead code and the bar started scrolling at HALF the
window, with `overflow-x: auto` turning a broken width into a scrollbar. (It
was also a raw scroll container outside a chrome root, which the scrollbar rule
forbids independently.) `width: max-content` is an explicit width and escapes
that trap; `max-width: calc(100vw - 32px)` then clamps to the real viewport.
That clamped width is definite, which is what finally lets flex-shrink work, so
`.image-viewer-scrub` absorbs the squeeze while every other control stays
`flex: 0 0 auto`. On a narrow window the volume slider drops at 1200px and the
speed label at 1000px — mute outlives the slider, the clock outlives the speed.
Guards added to media-viewer-contract.test.js, each verified to fail when its
fix is removed rather than passing vacuously:
- nothing but the stage and the toolbar may be appended to the overlay
- no second absolutely-positioned bottom-offset bar in the CSS
- the toolbar rule carries no overflow: auto|scroll
- a `left: 50%` toolbar must declare width: max-content, with the 50vw trap
spelled out in the failure message
- a toggled group that sets `display` from a class must declare its own
`[hidden] { display: none }`
582 test files / 8834 tests green; production build green.## Dialog chrome (card cmstpedou004501ma3jqb38tx)
The resize bug was NOT hit-testing. All 8 handles receive their mousedown
correctly — verified with a standalone Electron repro that elementFromPoint's
each edge of a real .dialog-window, modal and non-modal (8/8 in both). The MATHS
threw the drag away:
`_addResizeHandles` clamped against `#viewport-wrap` unconditionally, but a
MODAL dialog is centered on the WINDOW, and `_clampToViewport()` had always
known that and returned early for modals. The two frames disagree by hundreds of
pixels — the sidebar, the toolbar, the console — so the Terminal (900x560,
centered) started life with its bottom already past the bound. `newT + newH >
maxBottom` was true before the drag began, height pinned to `maxBottom - newT`,
and every south drag was a no-op. North had 70px of room and east 330, which is
exactly why "only the top right lets me resize it".
- `_viewportRect()` is now the single source of truth for drag, resize and
clamp — the three inlined copies of the `#viewport-wrap` lookup are gone, and
it returns the window for a modal.
- `resolveResize()` extracted as a pure function (like `clampToRect`) so the
maths is testable at all; a regression test reproduces the zero-growth drag.
- A bound may PREVENT growth but may never SHRINK mid-drag. A dialog restored
from a saved size larger than the current viewport used to snap smaller the
instant a handle was grabbed — indistinguishable from a broken resize.
- `dialog-icons.js`: inline SVG on one 24-grid replaces the text glyphs
(`−`/`+`, `□`/`❒`, `×`) and the lone Font Awesome dock icon. Maximize/restore
are genuine opposites so the button reports state. Uniform 22px hit targets.
- Double-click titlebar maximizes; collapsed state persists beside the size key;
resize edges highlight on hover and are wider (6px/12px); handles hide when
maximized or collapsed.
- New `dialog.onResize()` — content that measures itself (xterm, Monaco, split
panes) now re-fits DURING the drag and on maximize, not one gesture behind.
## Terminal (card cmstpdki6004301mambmkc3rn)
- **`src/main/pty-host.js` is now the ONE node-pty load and shell chooser.**
oem-connect/terminal.js was a literal copy of appliance/terminal.js's loader
and ladder ("modeled on" it, per its own header) and they had already drifted;
`resolveShell()` made it three. Each caller keeps its own gate (kiosk-chrome
vs OEM consent) and bookkeeping (OEM's vid routing + transcript tap).
`pty-host-contract.test.js` fails the commit on a second require or ladder.
- **Sessions outlive the dialog.** The PTYs never needed changing — main keys
them to the renderer's webContents. The RENDERER was throwing them away
(`closeAll()` on close + a module-global counter), which is why every reopen
killed the shell and renamed it Shell 1 → 2 → 3. Tabs/panes/xterm instances
move to `terminal-session.js` at module scope; the dialog re-parents them.
Reattach path adopts orphaned PTYs after a renderer reload and SAYS the
scrollback is gone rather than showing a blank.
- Tab titles from OSC 7 (cwd) / OSC 0|2 (title), parsed in main off the RAW
stream before xterm consumes it. Double-click renames and pins. The counter
resets when the last tab closes.
- Find (Cmd/Ctrl+F, Escape closes the BAR not the dialog), clickable links
through openWebViewDialog(), and one split per tab starting in the focused
pane's cwd.
- `settings.terminal` (shell/fontSize/scrollback/cursorStyle/cursorBlink) with
a mandatory normalizer — loadConfig shallow-merges. Live-applies except the
shell, which is a spawn argument. A-/A+ finally persist.
Docs: new docs/terminal.md + README entry; two CLAUDE.md choke-point rows.
Release notes + website Learn (app/terminal.md) written.
Full suite 8770 passed / 0 failed; production build clean.The file browser stopped being a list of names. Double-click a screenshot, a recording, a machine manual or a .nc program and it opens in-app. On the appliance — and now in Fullscreen — this is the ONLY way: there is no Preview, no VLC, no Evince, no pager to drop out to. Children A (cmstnert2003n01mayfcb0ri2), B (cmstng365003p01mavjrjo8zz), C (cmstngx6z003r01masd5o2016), D (cmstnhmbe003t01mat748swyt). ONE viewer for images AND video. image-viewer.js grew a <video> stage beside its <img> stage — one overlay, one gallery list, one toolbar whose controls change with the medium. A separate video dialog was the obvious first design and it is wrong: the folder this exists for is Captures, which interleaves stills and recordings, so two dialogs would break the ←/→ arrows at the most common navigation in the one folder that matters. openImageViewer stays as a thin alias, so the community attachment call sites are untouched. ←/→ therefore stay GALLERY navigation even on video; seeking is Shift+←/→ and frame stepping is , / . — the frame step uses the rate MEASURED from consecutive rVFC mediaTimes, not a guess (our recordings are 10 fps). jc3media:// instead of widening the CSP. media-src had no blob: and no file:, and neither should be added: a blob URL reads the whole file into renderer memory, which is fine for a 10-minute capture and an OOM the first time someone opens a 4 GB movie off a USB stick — an ordinary thing to do on an appliance. The new privileged scheme streams from disk with HTTP Range, so <video> seeks properly. The URL carries an opaque main-minted token, never a path, so a renderer cannot reach a file the user has not opened. registerMediaSchemePrivileges() runs at MODULE SCOPE: Chromium locks the scheme registry at ready, and a late call is silently ignored, leaving video unable to seek. Trim to a clip re-encodes rather than stream-copying. A stream copy needs a demuxer to get EncodedVideoChunks out of the MP4; mp4-muxer only writes and there is no ffmpeg, so that meant adding mp4box.js for one feature. Instead the span plays through the <video> already on screen and each presented frame is encoded with its real mediaTime — source cadence preserved exactly. The honest cost is that an export takes about as long as the clip. The codec ladder was EXTRACTED from capture-overlay/encoder.js into utils/video-encode.js and is shared, not forked. Captures are archived automatically. They used to exist only in a temp file that the next capture of the same kind overwrote — miss the toast and the recording was gone, which defeats the point of a feature built for "record it, then post it". They now land in Pictures/JetCad3 Captures (or ~/Captures on the appliance) with collision-safe names, and a Captures row appears in the picker sidebar once the folder exists. Archiving is additive: the temp file is still the source for Copy/Attach/Save-a-copy, so a failure costs the archive copy and never the capture, and it reports on the error indicator. Fixes a live bug: mode:'browse' onActivate never checked isBrowse, so a file double-click fell through to acceptCurrent → _commit → dlg.hide(). Double- clicking anything in the appliance's Files window simply closed it and discarded the path, since its caller only has a .finally(). The header comment at picker-dialog.js:200 asserted the opposite of what the code did. Also: - file-kinds.js is THE ext→kind classifier; open-entry.js is THE router. .svg previews as a picture but activates as geometry — the one deliberate seam, documented in the module header. - Preview pane: collapsible, persisted, 120ms debounce, token-cancelled renders so arrow-scrubbing can't paint a stale file. - Thumbnails: staleness is STRUCTURAL (mtime+size live in the cache filename), IntersectionObserver at threshold 0 + rootMargin (never a fraction — that is a fraction of the ELEMENT and a tall tile never fires), queue reset before every non-virtualized grid rebuild. - pdf.js keeps ONE worker setup: pdf-dialog.js exports loadPdfDocument(), used by the pane and the thumbnailer. - openTextEditor gains readOnly + a padlock unlock, largeFileOptimizations, and log/csv/ini/cfg/conf in EXT_LANG. - Delete delegates to the picker's own onTrash, keeping its no-Trash fallback. - shell:open-paths reuses main's OS-open queue, so .jc3/.jshape/.dxf/.svg route exactly as a Finder double-click does. - media-viewer-contract.test.js guards the single viewer, the no-blob:/file: rule, the CSP shape, and the before-ready registration. 582 test files / 8824 tests green; production build green.
Two defects found in Travis's first live test of View → Fullscreen, both proven before fixing: 1. setFullScreen(true) was a SILENT no-op on macOS. The shell window constructor passed `fullscreen: false` explicitly (`fullscreen: kiosk` on a desktop), and Electron treats an explicit false as "this window is not fullscreenable" — proven with a two-window repro on Electron 28 (isFullScreenable() false vs true purely on the key's presence). This predates the feature: it is why the green traffic light has only ever ZOOMED JetCad3 on a Mac. The key is now spread in only when true. 2. Every desktop terminal open was refused, surfacing as the misleading "terminal component is not available in this build" dialog while node-pty sat loadable on disk (OEM Connect loads the same module in dev regularly). The gate called isKioskChrome() with NO window id — which deliberately answers only the app-wide question and never leaks "some other window is fullscreen" — from a handler that has a sender. registerApplianceTerminalIpc now takes windowIdForSender and asks about the WINDOW that is asking; a test pins the sender→window plumbing (the prior tests each passed around the hole: one pinned id-less-is-false as correct, the other mocked the predicate). Release notes: green-button fix noted under the Fullscreen section. Verified live by Travis on macOS: fullscreen enters/leaves from the menu, F11 and the green button; ▦ → Terminal opens a real zsh with the p10k prompt.
View → Fullscreen / F11 runs the appliance kiosk chrome on a desktop OS (card cmstefy59003a01mawffsbr1f).
F11 / View → Fullscreen fills the screen AND turns on the JC3 Appliance's chrome:
the themed in-app menubar, the strip clock + calendar, in-window dropdowns, the ▦
Start panel and the on-screen keyboard. The state persists in
`settings.display.fullscreen`, so the menu item IS the setting.
Appliance Mode is two halves and only one travels. The chrome half is portable;
the ▦ panel's Network / Display / Web / Settings tiles and the Shut Down /
Restart rows are `nmcli`/`wlr-randr`/`poweroff` running as root behind
`assertAppliance()`, and they stay there — a desktop panel carries Files ·
Terminal, then Exit Fullscreen · Quit, and `shell-menu-items.test.js` fails if a
privileged actionId ever reaches it.
- `src/main/kiosk-chrome.js` (new) — `isKioskChrome(windowId)` = appliance ||
window-is-fullscreen || OEM screen session. A UI predicate kept deliberately
separate from the appliance SECURITY gate. Per window, because fullscreen is:
`popupHostKind()`, `publishAppMenuBar()` and the OEM flag were all written when
the answer could not differ between shells, and the menubar in particular
computed one `show` and broadcast the same payload to every one of them.
- The WINDOW is the source of truth, never the menu item — macOS's green button,
Ctrl+Cmd+F and a Wayland compositor all change fullscreen without going
through it, so everything hangs off enter/leave-full-screen.
- Fullscreen forces the `view` popup host. On macOS a native-fullscreen window
owns its own Space and a child BrowserWindow placed in screen coords is not
reliably composited onto it — the same class of problem labwc caused, reached
from the other direction, and the same fix.
- OEM screen share needed NO porting to work fullscreen: `getCaptureTargets()`
was always host-agnostic, `sendRemoteInput()` hit-tests purely off its bounds,
and both `viaRemote` adjustments are window-host-only by construction (the
blur suppression lives inside `popupWin.on('blur')`; the showInactive branch
sits after an early return for the view host).
- The terminal is the ONE backend that changes gates — `isKioskChrome()` instead
of `assertAppliance()`. On the appliance the PTY is still root; on a desktop it
spawns as the logged-in user, which is the shell they already had. Still
evaluated in main from window state, never from a renderer claim.
`resolveShell()` keeps the appliance pinned to its provisioned zsh and honors
$SHELL on desktop; Windows gets no `-l` (PowerShell reads it as a script path).
- Files needed no backend work: its `window.api.appliance.*` calls already
carried `.catch(() => [])`, so the refused invokes degrade to no volumes /
shares / network browser.
- `header.showSystem` suppresses the network + image/uptime/disk readouts on the
desktop panel, which would otherwise advertise "Not connected · Image unknown"
— the honest rendering of fields we deliberately never probe, and
indistinguishable from a bug.
- The clock and ▦ widget were one-shot at load and returned early off-appliance;
both now re-decide on a `shell:kiosk-chrome` push, with listeners bound once.
- `settings:save` no longer lets a renderer write `display.fullscreen`: the
Settings dialog stages `display` at open time, so an F11 while it is open (the
accelerator is global) would have saved a stale value and lost the preference.
- OSK: `has-physical-keyboard` stays appliance-gated deliberately — off-appliance
it is refused, the `.catch(() => true)` reports a keyboard, and Auto therefore
resolves to hidden, which is the right desktop default. The mode chooser is
shared with the Appliance tab and surfaces under Settings → Display.
Tests: kiosk-chrome (13), desktop panel + privileged-action guard, resolveShell
across four platform/appliance combinations, the terminal gate, the
display-settings normalizer incl. the shallow-merge case, and View → Fullscreen
surviving menu serialization as a working F11 checkbox. Full suite 8694 green;
production build clean.
Docs: appliance-mode.md gains "Kiosk chrome off the appliance"; display-settings
and keyboard-shortcuts updated; release notes v2.4.9; website Learn gains
app/fullscreen.md (check-learn-sync OK).Mill was the only 3D-orbit workspace without a Fit. It creates a ViewCube but
registered no tools on it, so the strip was just rotate/rotate/home.
The reusable half — "reframe from where you are already looking" — was private
to Drafting/main.js, so adding Mill's meant either copying ~80 lines of camera
math or extracting it. Extracted:
common/camera-fit.js
fitCameraToBox(camera, controls, box, { padding })
expandBoxByPoint(box, point) NaN-safe accumulation
worldBoxOfObject(root, { skip }) visibility-honouring scene bounds
Drafting now delegates (three private helpers deleted, behaviour unchanged).
Mill adds the button, `Mill.Fit` = shift-f, and MillSceneSync.getFitBox(setupId):
frames the active setup, falling back to every visible setup, counting bodies +
stock + visible backplot + the sim's stock, and skipping the decorations that
scale to constant SCREEN size (triad, snap dots) — framing those would make Fit
chase its own tail.
The perspective branch lifted from Drafting was wrong and the new test caught it:
it solved the distance from half-extents at the box's CENTRE plane, so a deep
box's near face overflowed the frustum (27% past the edge on a 2x3x2 box). Now
solved per corner against that corner's own depth. Both workspaces are
orthographic, so no in-app behaviour changes.
camera-fit-contract.test.js scans Drafting + Mill for a second implementation:
no computed `.zoom =` (restoring a saved zoom stays legal), any `_fitView()` must
import the shared module, and the retired helper names may not return. Verified
against a deliberate violation, not just a green run. The 2D top-down fits
(Design, eCAD schematic, CAM "Fit Sheet", GcodePilot) are a different operation —
they move the camera — and are deliberately out of scope.UI scale is a page zoom applied to TAB views only, so the recording region outline drawn in the tab renderer landed at uiScale× the window coordinates (visible INSIDE the video at 75% on the rig). Outline coords now divide by webFrame.getZoomFactor(), the line is a CSS outline with outline-offset (renders outside the box at any zoom/box-sizing), and click-ripple coordinates apply the inverse correction.
Annotations are live objects now: click-select with grips, drag to move, reshape arrow endpoints / box corners, double-click text to re-edit, Delete removes, swatches recolor the selection, snapshot undo covers it all. capture:save-last routes cloud-save:// URIs through the shared uploadCloudSaveFromBuffer (OneDrive saves failed on assertWritablePath). New utils/reveal-file.js replaces five hardcoded 'Reveal in Finder' sites — 'Show in Files' on the appliance opens the built-in Files browser at the folder (openApplianceFiles gains startDir), and the JetFlight/GcodePilot menu rows that called a nonexistent window.api.textFile.reveal actually work now. The capture overlay initializes the app's themed .jc-tooltip system instead of native title bubbles.
# Conflicts: # release-notes/v2.4.9.md
Cmd/Ctrl+Shift+X freezes the window under an Xnip-style marquee overlay — copy to clipboard, save via the themed picker, annotate (arrow/box/text), or attach straight to a Community post. Cmd/Ctrl+Shift+R records the region (or full window) to MP4 at 10 fps with a synthetic cursor + click ripples, stop button with elapsed time in the tab strip. Everything is capturePage composites via the new shared src/main/capture/composite.js (one impl with OEM Connect); the overlay BrowserView doubles as the WebCodecs + mp4-muxer encoder host.
Stock-mode dynamic facing is an air pick by construction, but the face op called the singular buildClearingRequest with stockOutline:null — the whole boundary read as material (buried mid-plate helix entry) and a phantom part fence hugged the rim (walk shattered into retract/re-plunge fragments). Route it through buildClearingRequests with the real stock outline: calibrated steering ring, straight plunge in open air off the rim, one continuous walk, chunk loop mirroring dynamic2d. The engine refuses an empty part mesh, so a keep-out-free air pick now gets a distant annulus surrogate 8 tool radii out. The face schema also never carried the smooth → arc fit → simplify pipeline fields dynamic2d has, so the in-engine arc fitter got the raw walker path and fit almost nothing (6 arcs vs 11k lines). Same six fields, same defaults, same clamps; the Advanced dialog section is extracted to op-sections.js and shared by 2D Dynamic and Face (replacing Face's dead smoothing_in knob).
Chart sets persist per machine and are generated only when absent, so machines configured before the fire-cycle correction kept old-schema rows forever — the rig showed blank Preheat/Pierce Height on every chart, and re-picking the torch does not regenerate. Rows missing preheat_height_in (the schema marker) now repair on first read: factory rows take the full cycle quintet from the current field-measured factory data; custom rows keep their meaning (old pierce_height_in was the preheat standoff, so it moves there) and get the default pierce height. Idempotent.
# Conflicts: # release-notes/v2.4.9.md
The first production test (Scott Pridham / ProMach) showed the shipped cycle didn't match how a flame torch works. Corrected sequence: soak at preheat height → raise to pierce height → cutting oxygen on → fed descent to cut height taking pierce_delay seconds (F = (pierce_h − cut_h)/pierce_delay × 60, pre-computed — no G93). - victor.js: Scott's measured speeds/soaks/delays grafted onto the 12 Victor rows; per-row preheat_height_in + pierce_height_in (now the REAL pierce standoff — the old build read pierce_height_in as the preheat height); Victor tips/pressures/kerf kept - resolveOxyfuelCycle: five values, semantic fix, preview + time estimate + quote calculator now include the preheat soak (55–200 s — it dominates) - OxyfuelPost + fluidnc-oxyfuel.jcpst: new cycle; 5-arg o<fire_oxy_torch> contract - fire_oxy_torch.ngc rewritten: computes the descent feed in-sub, fixed 20 ipm plunge tunable deleted; optional cutting-oxygen relay ([JC:] toggle default OFF + P index, M62 motion-synced), dry-run skips dwells and relay; oxy_torch_off.ngc closes with M65 - resolveEffectiveCutChart allowlist passes preheat_height_in (the creep-eating trap) - Cut Charts editor: both heights editable, pierce delay labeled as descent time - docs/Oxyfuel/fire-cycle.md + release-notes v2.4.9 Oxyfuel section - tests: 36-test post suite (arg order, relay gated + dry-run guarded, descent formula), cycle/victor/registry suites pin the semantic fix and Scott's field values
The DRO→path lock resolved self-coincident geometry (a flat full circle posted as two half-arcs, lead-in on the seam) by smallest projection distance — decided by float noise at the seam, and deterministically wrong when a sample landed a hair before it (projectOnto's sweep- direction normalization projects that onto the far arc's tail at dist 0). One bad sample locked a half-revolution ahead and swept the whole ring. _lockTarget now prefers the candidate on the daemon's reported line outright, else the EARLIEST in-tolerance candidate unless a later one is closer by real geometry (LOCK_TOL/4) — locking a frame behind self- corrects, locking ahead is irreversible phantom removal. Seam tests verified failing on the pre-fix code; walker fine-chord tracking intact.
Stock-outline facing clips every raster pass to the engagement region (stock ⊕ tool radius) and extends it into open air, so all turnarounds — first/last rows included — are tangent semicircles off the material at the link rate. New High feed in air toggle + rate re-stamp without regeneration. Entry/exit plunge corners and one-way retract links get the dynamic kernel's liftBlendRadius quarter-round. Also fixed in the same campaign: - unpackMoves no longer drops arc center/clockwise when the optional radius is absent; the post degrades a center-less arc to a G1 chord instead of inventing a (0,0) I/J center (card cmsta90qx002a01maqwlwbatv) - relative cylinder stock end offsets labeled Top (Z+)/Bottom (Z−) (card cmsta10go002601ma1dw6acss) - Face warns when every pass sits at/above the stock top — nothing to face (card cmsta179f002801mam5u77jiv)
The boot overlay (and splash window) rendered Dark Navy for seconds on appliance hardware even when the installer had handed over a light theme. The preload received the correct light payload and injected it, but the data-theme stamp found documentElement === null (Wayland + software GL timing — never happens on desktop) and deferred to DOMContentLoaded, which waits on the 31 MB bundle. Meanwhile index.html's stylesheet link loaded _tokens.css's base :root Dark Navy defaults, which outrank the injected sheet at equal specificity — dark overlay until applyTheme ran. - theme-boot.js: stamp <html> the instant it is parsed (MutationObserver on document), before the stylesheet link can load; DOMContentLoaded kept as a late repair. Desktop timing unchanged. - splash.js: splashHtmlPath() uses app.getAppPath() instead of a hardcoded 'app.asar' segment — the appliance ships both packaged layouts (release keeps the asar, dev/devvm unpack to resources/app), and the hardcoded path loaded a blank splash on the unpacked one. Same fix family as defaultThemesDir() (a375a1ca). - boot-surface-contract.test.js: new guard — building a packaged path from a literal 'app.asar' in splash.js/theme-tokens.js fails the commit (this defect has now been fixed twice). Verified in the dev live VM: probes during the visible boot overlay read data-theme=light-paper / --color-bg-primary #f4f6fb where they read null / #1a1a2e before; every captured frame of the Try handoff is light.
All 8 Mill operations now draw their clearance/feed/top/bottom planes in the viewport while the Heights tab is active — translucent colored sheets sized to the stock footprint, posed in the setup's WCS, tracking every keystroke. Mesh construction is extracted to the one shared builder (common/cam/height-planes-viz.js); Router consumes it too, unchanged. d.tabs gains per-tab onShow/onHide; the Heights section gets per-plane color-chip headers matching the viewport colors. Card: cmss9o8as001g01pag10zggi1. Opens v2.4.9 release notes.
One machine profile can now carry additional processes (an oxyfuel torch beside the plasma torch, a bolt-on router spindle) instead of a duplicate profile that doubled configuration, spawned a second Motion workspace fighting for the same controller, and broke Post to Machine from the Oxyfuel workspace. - The PRIMARY process IS the legacy machine-level storage; the new `processes` table (schema v5) holds only additional processes — no migration, old and new .jcm files interoperate in both directions - src/shared/machine-processes.js: PROCESS_HOSTS matrix (plasma/oxyfuel/router, symmetric; mill/lathe/laser dedicated), PROCESS_SCOPED_KEYS, GcodePilot gate - machines:list filters on "serves this process"; process-scoped MachineProfileManager projects its copy to BE the process and writes back exactly as scoped (machine.type is never rewritten) - Machine Configuration: Processes editor in General + per-process tree groups; a single-process machine's dialog is unchanged; process post stored under the single `postConfig` spelling (legacy flat pair folded in and stripped) - Posts gain headerToolSelect/headerToolNumber (default off, output byte-identical): each process's post emits a bare T word in the header before any motion; the GcodePilot tool table applies the head's X/Y offset from the T word alone, so there is no M6 option fix(oxyfuel): creep lead-in obeys the cut chart, and only the cut chart - resolveEffectiveCutChart passed chart rows through an allowlist that dropped preheat_time_s / creep_speed_ipm / creep_distance_in — typed chart values never reached the generator while hidden OXYFUEL_CYCLE_DEFAULTS creep constants (5 ipm / 0.25") cut every job; the post's creep properties were shadowed dead the whole time. Creep is now CHART-ONLY: blank cells mean no creep, the hidden defaults and dead post properties are deleted - A feed change mid-path now splits the segment it lands in (lines interpolate, arcs keep centre/radius) instead of rounding up to the segment end — a 0.25" creep used to drag an entire 4" side down to 5 ipm because the modal F word never restated 71 new tests (process model contract, storage round-trips, projection write-back, header tool select, creep chart passthrough + mid-segment split).