Changelog

All notable changes to XOSC. Newest first. Updated automatically by just package.

Unreleased

  • _nothing yet_

v0.7.19 — 2026-05-01

  • Fix: enabling input "global capture" no longer blanks the screen. useGlobalHotkeys selector returned a fresh {signature, accelerator, descriptor} object per node on every snapshot. useShallow compares array elements with Object.is — always false for new object refs — so getSnapshot churned indefinitely the moment any node had globalHotkey: true, and React 18's stale-snapshot detector aborted the renderer with #185 "Maximum update depth exceeded". The selector now returns a flat signature\taccelerator string array (primitives are shallow-stable when content matches); the wire payload is derived via useMemo. Unused descriptor field on the entry shape dropped. (src/features/inputs/useGlobalHotkeys.ts)

v0.7.18 — 2026-05-01

  • Fix: first-launch splash hang on multi-monitor / mixed-DPI Windows. BrowserWindow.ready-to-show silently never fires (or fires too late) on some Windows GPU configurations — typically tri-monitor setups with mixed DPI scaling. The splash stayed up forever, the main window stayed hidden, and the user concluded the app was frozen. The createMain() reveal logic is now an idempotent reveal(via, dwell) armed by three triggers: the original ready-to-show (1400 ms splash dwell preserved), did-finish-load (no dwell — the renderer is already loaded by the time it fires), and a 5 s safety timer (last resort). New phase=main-window-reveal via=... lands in lifecycle.log so the diagnostic confirms which trigger won. Also confirms the v0.7.7→v0.7.15 "close-then-reopen crash" was always the same bug — the user task-killed a stuck splash, which lifecycle.log recorded as reason=killed exitCode=1. With v0.7.18 the splash dismisses cleanly and close-then-reopen ends with === CLEAN-EXIT === every run. (electron/main.ts)

v0.7.17 — 2026-05-01

  • userData directory listing on every boot. Right after main-entry, lifecycle.log gets a phase=userdata-listing entry followed by every file/folder in %APPDATA%\xosc\ (relative path, size, mtime). Capped at 200 entries / 4 levels deep. Surfaces stale .tmp.<pid>.<ts> save orphans, .corrupt.<ts> quarantines, and Chromium caches (Local Storage, IndexedDB, Cookies) so the "presets stored somewhere I didn't expect" mystery becomes a one-grep job. (electron/lifecycle-log.ts, electron/main.ts)

v0.7.16 — 2026-05-01

  • v0.7.16 diagnostic battery — instrument the renderer side of the silent kill. v0.7.15 proved main-process teardown is clean (every dispose-ok, every === CLEAN-EXIT ===); the kill is renderer-side. v0.7.16 ships four pieces of instrumentation so the next reproduction lands hard evidence:
- Renderer heartbeat at 5 s. Single-line %APPDATA%\xosc\heartbeat.log overwritten each beat. After a kill, the timestamp on disk + the stored counter tell us whether the renderer was healthy up to the moment of termination (sudden external kill) or already wedged for many seconds (slow hang). (src/heartbeat.ts, electron/lifecycle-log.ts) - webContents event capture in main: every did-start-loading / did-finish-load / did-fail-load / dom-ready / unresponsive / responsive / render-process-gone is timestamped to lifecycle.log. unresponsive is also written to crash.log so it stands out — it's Chromium's "I'll TerminateProcess in 30 s if this doesn't recover" signal. Console messages at warn/error level are appended to crash.log so React #185 / xyflow loops show up post-mortem. (electron/main.ts) - Renderer phase marks for entry, react-mount-ok, config-load-start, config-load-shape (with nodeCount + per-kind counts — shape only, never contents), config-hydrated, hydrated-flag-set, config-load-error. Lifecycle log is now end-to-end: main entry → renderer mount → config load → hydrate. The last line before a kill pinpoints the bad step. (src/main.tsx, src/features/settings/useConfigSync.ts, electron/main.ts) - Top-bar ? menu adds "Open heartbeat log" so the user can grab the third diagnostic file alongside crash.log and lifecycle.log. (src/components/Topbar.tsx) No behavioural change — pure observability. Once the next kill is reproduced, the three logs together should pinpoint the cause unambiguously.

v0.7.15 — 2026-05-01

  • Lifecycle log added — silent, persistent boot/teardown timeline at %APPDATA%\XOSC\lifecycle.log. Every meaningful main-process phase (single-instance lock, when-ready, splash + main window create, each handler register, before-quit, window-all-closed, each dispose call, will-quit, quit) is timestamped to disk. Each dispose is wrapped in a try/catch that records dispose-throw with the error message, so a throw in one teardown step no longer short-circuits the rest. A === CLEAN-EXIT === footer is appended on app.on("quit"); absence between two BOOT headers proves the previous run died mid-shutdown — the diagnostic the silent close-then-reopen crash needed. Top-bar ? menu adds "Open lifecycle log". Rotates to .old at 512 KB. (electron/lifecycle-log.ts, electron/main.ts, electron/preload.ts, src/components/Topbar.tsx, src/types/api.ts)
  • DEBUG_BUILD disabled for ship — release.yml no longer sets DEBUG_BUILD: "1", so v0.7.15+ builds are normal production: minified, prod React, no auto-opening DevTools, ~3× lighter installer. The crash log + lifecycle log are now the diagnostic surface; the client doesn't see a debug surface anymore. (.github/workflows/release.yml)
  • Easy-Next simplified to clip-only — the v0.7.14 axis toggle (advance clip vs advance layer) is gone. Every press walks the chosen layer's clips left-to-right; the layer dropdown stays so the user picks WHICH layer. Two motions on one Resolume box = two ResolumeOut nodes pinned to different layers. Saved configs with advance: "layer" are normalised back to "clip" on hydration. The phantom-extra-layer bug in layer-advance is gone with the code. (src/features/inspector/ResolumeOutInspector.tsx, src/features/mapping/useMappingRouter.ts, src/store/store.ts)
  • Auto-refresh composition when Easy-Next opens — opening the Easy-Next inspector section auto-fetches Resolume's composition tree if state is idle, instead of requiring a manual click on "Refresh from Resolume" before the layer dropdown populates. Skips when already loading or in error state. (src/features/inspector/ResolumeOutInspector.tsx)

v0.7.14 — 2026-05-01 (diagnostic build, persistent crash log + Easy-Next redesigned around Resolume's grid)

Added — persistent crash log so we can finally see what's actually failing

  • %APPDATA%\XOSC\crash.log captures every uncaught exception, unhandled rejection, render-process-gone event, and renderer-side error/unhandledrejection. Wired before any other module load in both main and renderer (electron/crash-log.ts setup runs at the top of electron/main.ts; src/crash-handler.ts is the very first import in src/main.tsx). Synchronous appendFileSync so entries land even when the process is dying. The last six releases all rolled back blind because we had no diagnostic surface for "won't launch on Windows" failures — DevTools doesn't open if the renderer crashes before mount, and the app shows no on-screen feedback. This file is now the authoritative source.
  • "Open crash log" + "Show crash log folder" in the topbar ? menu. One click puts the file in front of the user. (src/components/Topbar.tsx)
  • window.xosc.app.reportCrash / openCrashLog / showCrashLogFolder IPC wired through preload + types. Renderer's window.error and unhandledrejection listeners forward to main; React mount throws are caught explicitly in src/main.tsx and forwarded too. Buffered when the preload bridge isn't ready yet, flushed on next animation frame.

Changed — Easy-Next redesigned around Resolume's actual grid model

User insight: Resolume's coordinate system is ALWAYS (layer × column). There's no useful "advance column" without picking a layer first — server-side column-fan-out connects every layer's clip in that column simultaneously, which is rarely what you want. The actually-useful primitive is "I picked a layer, the button walks that layer's clips left to right". The complementary primitive is "stay on the same column, advance the layer dropdown" — useful for comparing the same beat across layers. Plus: Resolume renders layer N on top of layer N-1, so the array-order layer dropdown was visually inverted from what the user sees on stage.

  • Mode selector (auto / columns / layer-clips) deleted. Always layer-clips. Saved configs from v0.7.7-v0.7.13 with mode: "auto" or "columns" are normalised to "layer-clips" during hydration. (src/store/store.ts, src/features/inspector/ResolumeOutInspector.tsx)
  • New "Axis" toggle: clip-advance vs layer-advance. Clip-advance (default) walks columns within the chosen layer — same press, same layer, different clip. Layer-advance walks the layer dropdown while keeping the column position fixed — same press, next layer down/up, same beat. Both wrap. Implemented in the router as a new advance field on ResolumeEasyNextSpec; the fire path branches on the axis and patches either currentIndex (clip mode) or layerIndex (layer mode). (src/store/store.ts, src/features/mapping/useMappingRouter.ts, src/features/inspector/ResolumeOutInspector.tsx)
  • Layer dropdown reversed. Resolume renders layer N on top of layer N-1, so array index 0 is the bottom of the visible stack. The dropdown now shows the visually top-most layer at the top of the list. Values stay 1-based to match Resolume's REST URLs. (src/features/inspector/ResolumeOutInspector.tsx)
  • Layer-advance auto-skips empty layers. When the next layer in the cycle has no populated clips at all (or zero after skipEmpty), the router skips it and tries the one after — caps at one full trip around so a totally-empty composition can't loop forever. (src/features/mapping/useMappingRouter.ts)

Notes for the next session

  • v0.7.13 still crashed on close-then-reopen with a Resolume Out node configured, even after four-layer defence on webContents.send. The crash log will surface the actual root cause on the next reproduction. Until that file shows real data, do not ship more behavioural changes — the fixes have to be evidence-driven now.
  • Still on DEBUG_BUILD=1. Production cutover happens once the crash log shows a clean close-then-reopen cycle with a Resolume Out node.

v0.7.13 — 2026-05-01 (diagnostic build, Easy-Next regression revert + close-crash defence-in-depth)

Fixed (THIS is the "press goes down the wire but Resolume doesn't advance" regression I introduced in v0.7.11)

  • Easy-Next router reverted to "next to fire" semantics. v0.7.11 changed currentIndex to mean "currently playing" and made the router fire (connectedIdx + 1) % total. That works only if Resolume's connected.value parses correctly. On the user's Resolume build it doesn't (clip likely in Previewing state, or shape mismatch), so connectedIdx === -1, the router fell back to currentIndex for fire BUT then set currentIndex = fireIdx (not + 1). Result: every press fired the same slot. Resolume kept connecting the same clip. Visually dead. Restored v0.7.10's algorithm: always fire currentIndex, always advance to (fireIdx + 1) % total after a successful send. The press now produces a different connect every time regardless of connected parsing. (src/features/mapping/useMappingRouter.ts)
  • Inspector + node body now display nextIdx (always known) as the primary indicator. The dashed-border "next press fires" row is the source of truth. Resolume's connected state is shown as a secondary (now: name) annotation when detectable, never as the primary highlight — guessing at the playhead when connected was unreadable was the v0.7.11 bug. (src/features/inspector/ResolumeOutInspector.tsx, src/features/mapping/ResolumeOutNode.tsx)
  • "Copy composition JSON" button added to the Easy-Next inspector. The user couldn't paste from DevTools (the composition is a few hundred KB) and a console fetch() from the renderer hits CORS against Resolume's webserver. The button serializes the in-memory composition cache and writes it to the system clipboard via navigator.clipboard, giving a one-click way to capture diagnostic data when something looks wrong. Removed once Easy-Next is stable. (src/features/inspector/ResolumeOutInspector.tsx)

Fixed (close-crash defence in depth — v0.7.12 fixed two sites; this catches the rest of the class)

  • Logger.push now wraps each listener in try/catch. v0.7.12 fixed two webContents.send call sites (main.ts:194, serial-bridge.ts:54) that were re-throwing on shutdown. But the general problem is that Logger.push.listeners.forEach(fn) had no per-listener insulation: a single listener throwing took out the rest of the dispose chain because the throw bubbled through log.emit → bridge close handler → disposeResolumeBridgesapp.on("window-all-closed"). Per-listener try/catch means a future listener regression can't ever soft-brick shutdown again. (electron/logger.ts)
  • logger.beginShutdown() flag suppresses listener notifications after app.before-quit. Earliest signal that everything is tearing down. The logger still records entries in its in-memory buffer (useful if next-launch logs surface them), it just stops fanning them out to listeners that may have stale window references. (electron/logger.ts, electron/main.ts)
  • resolume-bridge.ts pushStatus now checks wc.isDestroyed() and try/catches the send. v0.7.12 audited this site and thought it was already guarded — it checks win.isDestroyed() but not win.webContents.isDestroyed(). Same race as the two sites fixed in v0.7.12. The above three changes mean each of the four "WS close fires during dispose" code paths is now triple-protected (per-listener try/catch in Logger + shuttingDown flag + per-call site isDestroyed() checks + try/catch on send). (electron/resolume-bridge.ts)

Notes

  • The user reported v0.7.12 still crashed on close (silently — no more error modal because the per-listener throw in v0.7.12's main.ts logger fix was working, but a different listener was throwing instead). v0.7.13's per-listener try/catch in Logger itself catches every shutdown listener regardless of which file it lives in.
  • Still on diagnostic harness — production cutover happens once you confirm v0.7.13 (a) closes cleanly with a Resolume Out node, (b) advances Resolume on each press of an Easy-Next-enabled output, AND (c) the next launch boots without needing to delete %APPDATA%\XOSC.

v0.7.12 — 2026-05-01 (diagnostic build, shutdown-crash root cause)

Fixed (THIS is the v0.7.7+ "won't open / crashes on close" root cause)

  • Main process no longer throws "Object has been destroyed" during shutdown. The user's stack trace finally pinned it: when the window closes with a live Resolume WebSocket, disposeResolumeBridges() runs in the window-all-closed handler, the WS close event fires, the bridge calls pushStatuslog.emit, the logger fans out to its onChange subscribers, and one of those subscribers (electron/main.ts:194) called mainWin?.webContents.send("log:stream", …). The ?. only guarded against null — mainWin itself was still a valid reference, but its webContents had already been torn down by Electron, so the send threw. Electron then surfaced the throw as the "A JavaScript error occurred in the main process" modal. Same race in electron/serial-bridge.ts:54 (parser data event firing after the window starts tearing down). Both sites now check win.isDestroyed() AND win.webContents.isDestroyed(), plus a try/catch swallows any remaining race, because log streaming + serial data are best-effort and re-throwing during graceful shutdown helps no one. (electron/main.ts, electron/serial-bridge.ts)
  • The "saved-config soft-brick" was a downstream symptom of the same crash. When the renderer threw the "Object has been destroyed" exception during shutdown, the useConfigSync debounced save was sometimes mid-flight. With v0.7.11's atomic-rename writeSnapshot this is now harmless even if it does happen, but with v0.7.10's direct fs.writeFile the file landed truncated, then the next launch hit JSON.parse → uncaught exception → frozen splash. v0.7.11 already shipped the atomic write + corrupt-file quarantine + try/catch hydration; v0.7.12 takes out the upstream cause. Both fixes stand: v0.7.11's hardening means a config-write crash from any future cause still recovers cleanly, and v0.7.12's IPC guard means the crash that triggered this whole investigation can't happen anymore.

Notes

  • The bug class — webContents.send without isDestroyed check — predates v0.7.6/v0.7.7. The reason it surfaced now: v0.7.7 added a readCachedComposition codepath that opens / refreshes Resolume WS connections more aggressively, and the user added a Resolume Out node to test Easy-Next, which is what made the WS close path hot during shutdown. v0.7.5 and earlier rarely triggered it because most users never had a connected WS at close time. Audited all other webContents.send sites (dmx/engine.ts, osc-listener.ts, dmx/artnetIn.ts, peer-discovery.ts, global-hotkeys.ts, resolume-bridge.ts) — they already guard with isDestroyed() correctly.
  • Still on the diagnostic harness — production-mode build is one revert away once you confirm v0.7.12 launches AND closes cleanly with a Resolume Out node configured.

v0.7.11 — 2026-05-01 (diagnostic build, Easy-Next correctness fixes + soft-brick recovery)

Fixed (soft-brick recovery — root cause of v0.7.10 "won't reopen after JS error on close")

  • xosc.config.json is now written atomically. writeSnapshot previously called fs.writeFile directly, so a renderer JS error mid-shutdown could leave a truncated JSON file on disk; the next launch's JSON.parse then threw and useConfigSync could not recover, soft-bricking the app until the user manually deleted %APPDATA%\XOSC. Switched to write-to-tmp + atomic-rename: a crash mid-write leaves either the previous good file or an orphaned .tmp.<pid>.<ts>, never a half-written real path. (electron/config-store.ts)
  • Corrupt config files self-quarantine on read instead of crashing the app. If readSnapshot hits an invalid-JSON or invalid-shape error, the bad file is renamed with a .corrupt.<ts> suffix and the app boots with the same empty snapshot it would on first launch — saved graph is preserved on disk for manual recovery, but the user gets a working app immediately. (electron/config-store.ts)
  • hydrateFromSnapshot now wrapped in try/catch with per-node sanitisation. Any unexpected throw inside hydration logs to console and falls through to defaults instead of bubbling out and stranding the renderer. Easy-Next blocks on saved nodes are normalised: invalid mode strings collapse to "auto", NaN/negative/Infinity currentIndex collapses to 0, sub-1 layerIndex is dropped. (src/store/store.ts)

Fixed

  • Easy-Next playlist highlight now tracks Resolume's actual playhead, not the local pointer. Previously the highlighted item was always one ahead of what Resolume was playing because currentIndex stored "next to fire" but the UI displayed it as "now playing". Each PlaylistItem now carries a connected boolean derived from Resolume's per-clip connected.value (mapped from both numeric and string-enum forms — Empty/Disconnected/Previewing/Connected/Connected & previewing0..4, threshold >= 3); the inspector + node body highlight whatever Resolume reports as connected. Manual triggers in Resolume now sync the UI without requiring a button press in XOSC. (src/features/inspector/resolumePlaylist.ts, src/features/inspector/ResolumeOutInspector.tsx, src/features/mapping/ResolumeOutNode.tsx)
  • *Easy-Next router fires the item after whatever Resolume is currently playing, not the next item after our stored pointer. resolveEasyNextFire reads connected across the resolved playlist; if any item is connected, the press fires (connectedIdx + 1) % total. Falls back to currentIndex only when nothing is connected (Resolume just opened, blackout, etc.) so first-press behaviour stays predictable. The persisted currentIndex semantics changed from "next to fire" to "currently playing" — the index advances after a successful fire instead of pointing at the upcoming target. (src/features/mapping/useMappingRouter.ts)
  • Column mode now shows names + thumbnails. Resolume's REST API has no native column thumbnail endpoint — column tiles carry only name + connected status. Built a representative-clip lookup: for each column slot, walk every layer's clip at that index and synthesize a thumbnailPath against the first populated clip found (/api/v1/composition/layers/{repLayerIndex}/clips/{slot}/thumbnail). The same clip's name fills in when the column itself has no user-set name (Resolume defaults like Column 1 are unhelpful). Resolume's # placeholder in column names (their own rest-example convention for "insert column number here") is now substituted with the 1-based slot. Both the inspector preview list and the node-body thumbnail work for columns now. (src/features/inspector/resolumePlaylist.ts)
  • Inspector counter relabeled "now playing" with both the connected item's name and the next-up item's name shown after an arrow. The "Will fire on next press →" path stays in sync with the router so the displayed REST URL is the one that actually fires. PreviewRow gets a new dashed-border state to mark the next-to-fire row distinctly from the connected (highlighted) row. (src/features/inspector/ResolumeOutInspector.tsx, src/features/inspector/resolume-easy-next.css)

Still on the diagnostic harness

  • This release keeps DEBUG_BUILD=1 (development React, no minify, DevTools auto-opens) because the underlying production-build hang from v0.7.7/v0.7.8 hasn't been root-caused yet — only the symptom was masked by dev React. The Easy-Next fixes shipped above are renderer-side and orthogonal to whatever's looping in production. Next release will turn DEBUG_BUILD off once the loop is identified from this build's DevTools console (look for "Maximum update depth exceeded" or "The result of getSnapshot should be cached" warnings while interacting with the gloss slider, theme switcher, or any inspector that uses useSyncExternalStore-backed selectors).

v0.7.10 — 2026-05-01 (diagnostic build)

Diagnostic harness re-armed (re-applies v0.7.6 + v0.7.7 on top of the v0.7.5 known-good base)

  • This is not a normal release — it is a one-shot debug installer to find the boot hang. Source matches v0.7.7 (which means it carries both the v0.7.6 title-bar / native-menu / gloss work and the v0.7.7 Easy-Next + help-modal feature). Build is non-minified, ships the development React bundle, and auto-opens DevTools on launch (the v0.6.5 harness, gated by DEBUG_BUILD=1 in electron.vite.config.ts and now set in the release workflow). When this version stalls on the splash, DevTools opens with a real React error name + component stack instead of a blank screen, identifying the offending hook/selector or main-process IPC. Once we know which change in v0.7.6 / v0.7.7 broke boot, the next release reverts to production mode and re-applies just the fixed pieces. Installer is ~3× heavier than a production build; this tag exists for one screenshot and gets superseded by v0.7.11 once the cause is known.

v0.7.7 — 2026-04-30

Added

  • Resolume Out → "Easy Next Video" playlist mode. New optional easyNext block on the Resolume-out node spec: when enabled, every press walks through a detected list of clips (or columns) and wraps 1 → 2 → 3 → 1 regardless of the user's Resolume layout. Three modes — auto (decides between columns and a single layer based on the live composition: a single populated layer wins, ≥ 2 layers sharing populated slots flips to column-scenes), columns, and layer-clips (with a layer dropdown). Discovery is the existing REST/WS bridge; XOSC fetches /api/v1/composition, classifies each clip slot as populated/empty (name + video.source + transport.duration heuristics), and resolves the playlist on every press so adding/removing clips in Resolume is reflected without restarting. Index is persisted on the node so the playlist survives reloads. The mapping router intercepts the fire before the configured action path, sends a literal trigger at /composition/columns/N/connect or /composition/layers/L/clips/N/connect, and falls through to the existing path if the composition cache isn't loaded yet (with a one-shot composition.refresh() so the next press has data). Continuous values (faders/axes) and release edges still hit the configured parameter, so an Easy-Next node remains backwards compatible if anyone wires a CC to it. (src/store/store.ts, src/features/inspector/resolumePlaylist.ts, src/features/mapping/useMappingRouter.ts, src/features/inputs/useResolumeComposition.ts)
  • Inspector "Easy Next Video" panel — collapsible section in the Resolume-out inspector with: enable toggle, mode dropdown, layer dropdown when relevant, "skip empty slots" toggle, recommended-mode hint when auto is picked, current-index counter (2/3 next up) with a Reset button, full thumbnail-preview list (click any item to jump the index there), the exact /composition/... path that will fire next, and a "Refresh from Resolume" button. Picking a layer or flipping skipEmpty resets the index so the user can't land out of bounds. (src/features/inspector/ResolumeOutInspector.tsx, src/features/inspector/resolume-easy-next.css)
  • Resolume API help modal. ? button next to the Resolume Out inspector header (and a "How do I enable this?" button next to Reconnect) opens a themable, icon-decorated walkthrough for enabling Resolume's Webserver / REST API: open Preferences (with Ctrl , / ⌘ , keyboard hint), pick the Webserver tab, flip "Enable Webserver & REST API" on, verify in a browser at http://host:port/api/v1/composition, point the XOSC node at the same host:port. Footer has a one-click "Resolume docs" button that goes through the existing app:open-external IPC (allow-listed http(s)://). All surfaces use theme tokens — works in every shipped theme. (src/features/inspector/ResolumeHelpModal.tsx, src/features/inspector/resolume-help-modal.css)
  • Resolume-out node body shows Easy-Next state. When Easy-Next is on, the static parameter pill is replaced by an EASY NEXT • idx/total pill plus a thumbnail of the clip about to fire (when the resolved playlist is layer-based — column mode skips the thumbnail because Resolume's thumbnail endpoint is per-clip, and arbitrarily picking one layer's clip to represent the column would be misleading). (src/features/mapping/ResolumeOutNode.tsx)

v0.7.6 — 2026-04-30

Changed

  • Pads view now hides the right-side inspector strip too. The inspector strip lives in the graph view (it's where you configure node properties) and was redundant in the pad view, where pads have their own per-pad config in the pad editor modal. Single-column grid in data-view="pads" for both closed and open inspector states.
  • Native title-bar + File/Edit/View menu replaced by themable chrome. The Windows title bar (where min/max/close lives) and the native menu strip ignored every theme — the user reported them as untheme-able white bands across the top of the window. Switched the main BrowserWindow to titleBarStyle: "hidden" with titleBarOverlay so we keep the standard Windows window-control buttons but their background + symbol colour come from the theme; ThemeProvider repaints them via a new app:set-title-bar-colors IPC on every theme change. Dropped Menu.setApplicationMenu(null) so the native File/Edit/View/Window/Help row goes away. The Help submenu's contents (XOSC website / Documentation / Changelog / Report a bug on GitHub) are now a small dropdown under a ? icon in the topbar action cluster, going through a new app:open-external IPC that allow-lists http(s):// only. Edit-menu accelerators (Cmd/Ctrl-C / V / X / Z / Y) still work via Chromium defaults.
  • Gloss intensity slider now actually does something. --gloss was set on :root by ThemeProvider but no CSS rule consumed it — the slider in Settings did nothing. Wired it into three surfaces: the topbar, every .gnode, and modals with the .glossy class. Each gets an inset top-edge highlight + a faint top-to-bottom sheen scaled by var(--gloss, 0). At 0 the surfaces are flat (matches the previous look); at 1 there's a subtle bevel; at 1.5 (the slider's max) it's a clearly glassy chrome.

v0.7.5 — 2026-04-30

Added

  • Minimap now draws connection wires between nodes. Each edge renders as a thin line in the minimap, with a userSpaceOnUse linear gradient that fades from the source node's colour to the target's — same colour language as the full canvas. Wires sit under the node dots and are drawn with pointerEvents="none" so they can't swallow the click-to-pan gesture.
  • Minimap dots are half-size. The rect for each node now renders at 50 % of the measured node size, centered on the node, so a freshly added node looks like a comfortable dot instead of dominating the panel. The bounding-box / click-to-pan / wire-endpoint maths all stay in canvas-space — only the visual rect is scaled.

Fixed (memory leaks — long-session footprint)

  • Serial-out ports no longer leak when nodes are deleted. useSerialOutputs previously called serial.open for every desired path but never serial.close for paths that dropped out of the desired set; over an editing session this stranded SerialPort instances + their data listeners in the main process forever. Track opened paths in a ref, diff against desired, close the difference, and close everything on hook unmount. (src/features/inputs/useSerialOutputs.ts)
  • MIDI onmidimessage handlers are detached on unmount. useGlobalCaptureSink only cleared access.onstatechange on cleanup; every wired input kept its handler closure live, pinning the previous React tree across renderer reloads. Track wired inputs in a Set and null onmidimessage for each on cleanup. (src/features/inputs/useGlobalCaptureSink.ts)
  • Resolume thumbnail cache is now LRU-capped at 64 entries. Each thumbnail is a 50–200 KB base64 data URL; browsing a large composition over a long session previously grew the cache without bound. Evict the oldest entries that nobody is currently subscribed to whenever we exceed the cap. (src/features/inputs/useResolumeComposition.ts)
  • Resolume reconnect no longer leaves zombie WebSockets. The close handler nulled c.ws but didn't remove the open/message/close/error listeners from the closing socket; under flaky connectivity each reconnect stacked one orphaned ws kept alive by its still-attached node-ws listeners. Added ws.removeAllListeners() in the close handler. (electron/resolume-bridge.ts)
  • Throttle-state maps now sweep idle entries. Both electron/resolume-bridge.ts and electron/osc-engine.ts keyed throttle state on (host, port, parameter|address); entries lived forever even after the user stopped touching that fader. A 60 s sweep drops idle entries with no pending send.
  • DMX errorLogGate now sweeps entries older than 10 s when the gate exceeds 64 keys. A flapping network producing many unique error strings (EHOSTUNREACH 10.0.0.7, …0.8, …) used to grow this map without bound. (electron/dmx/engine.ts)

Fixed (security)

  • config:import / config:export IPC handlers now require a path returned from the file picker. Previously the renderer could pass any string; main called fs.readFile / fs.writeFile directly, giving a compromised renderer (XSS, malicious config telling the user to import) a primitive to read ~/.ssh/id_rsa or overwrite the app's own bundle. The picker handlers add the chosen path to a one-shot allow-set; import/export consume one and remove it. Audit-confirmed clean for: dangerouslySetInnerHTML/eval (none), shell.openExternal (only hard-coded literal URLs), child_process.exec (none), CSP (strict). (electron/config-store.ts)

v0.7.4 — 2026-04-30

Changed

  • Minimap dots resized + drag-to-pan + scroll-to-zoom restored. Bumped bounding-box padding from 10 % to 40 % (min 200 units) and dropped the 40-unit minimum-size clamp so rects shrink proportionally with the node count. Stroke widths are now derived from vbWidth / MINIMAP_W so they're a constant ~1.5 px regardless of zoom. Added onPointerDown / Move / Up translating SVG-local coords → viewBox → canvas → main-viewport translate, plus onWheel for zoom. Node rects + viewport indicator both have pointerEvents="none" so every event hits the SVG.

v0.7.3 — 2026-04-30

Fixed

  • Minimap now actually shows nodes. Three previous attempts (v0.6.7, v0.6.8, v0.7.2) all kept using xyflow's <MiniMap>, just tweaking node colour / stroke width / passing a custom nodeComponent. The user re-confirmed v0.7.2 still shipped an empty minimap, so the diagnosis was wrong. Reading @xyflow/react's source revealed that NodeComponentWrapperInner returns null when nodeHasDimensions(node) is false — i.e. before the ResizeObserver has measured the node. Our toRfNodes() doesn't set width / height / initialWidth, so on first mount and on every node-list rebuild the wrapper short-circuited and our custom node component was never invoked. Replaced <MiniMap> with a custom Panel-based component that subscribes to xyflow's internal store via useStore(selector, equalityFn) and renders plain <rect> elements in an SVG. Every node renders unconditionally, with measured dimensions when available and a 240×80 fallback when not. Inline style.fill (highest CSS specificity) carries the per-node colour; nothing in app.css or the xyflow stylesheet can override it.

v0.7.2 — 2026-04-29

Fixed

  • PadEditorModal crashed with React #310 ("rendered more hooks than during the previous render") on the pencil-edit click. Two useMemo calls sat after the if (!draft) return null early return, so the hook count shrank from 4 to 2 between mount (pad set) and re-mount (pad null). Moved both above the early return.

Changed

  • Left rail now expanded by default with text labels. v0.7.0's icon-only 44 px rail was unreadable for first-time users — there was no way to tell which icon was which without hovering. Now defaults to a 96 px column with text labels under each icon; a collapse chevron at the top drops it back to the original 44 px icon-only mode. State persists in ui.leftRailCollapsed.
  • Right inspector hidden by default; auto-opens on selection. Previously the inspector ate 360 px of canvas width even when the user wasn't editing anything. Selecting a node or edge auto-opens it; a chevron in the corner closes it again. When closed, a 28 px handle on the right edge re-opens without losing canvas space. State persists in ui.inspectorOpen.
  • DMX-out continuous role can now be cleared. Added a "None — discrete only" sentinel value to DmxContinuousRole; buildDmxValues short-circuits when role === "none" so the static-value snapshot ships untouched and continuous inputs are ignored.

v0.7.1 — 2026-04-29

Added

  • DMX-out nodes can now trigger a pad/sequence instead of writing a static color. Each DMX-out gets a new mode field on its spec ("color" | "pad", defaulting to "color"). Switch a node into "pad" mode from the inspector and pick one of the saved pads — when an upstream input fires, the router calls into the existing pad engine (togglePad / restartPad / startSequence + stopBySequenceId) instead of building a FixtureValues payload, so a single keystroke can fan out into a whole choreographed sequence rather than one-channel-per-input wiring. Trigger mode keeps its meaning in pad mode: pulse restarts on press, toggle flips active/inactive, hold plays only while held. The node body shows a PAD tag and the active pad name; the inspector hides the universe / fixture / static-value sections in pad mode and shows a "Toggle pad now" test button instead of the channel-snapshot one. Existing graphs hydrate unchanged because mode is optional and missing reads as "color".

v0.7.0 — 2026-04-29

Changed

  • Topbar "Add X" buttons collapsed into a vertical left rail. The graph view used to carry ten Add Input/OSC Out/Resolume Out/Resolume In/OSC In/Transform/Monitor/Serial Out/DMX Out/DMX In buttons across the top, which had become the dominant horizontal element and forced the clock/import/debug/settings cluster to the right edge. They now live in a 44px vertical rail on the left side of app-main, mirroring how DAWs and node editors (Blender, TouchDesigner) organise add-node palettes. Input is pinned at the top with a separator beneath it as the primary action; the rest are icon-only with title tooltips. The pad view collapses the rail column entirely, so it only shows alongside the graph.

v0.6.8 — 2026-04-29

Fixed

  • Settings modal content overflowed the viewport with no way to scroll. .xmodal had overflow: hidden and no max-height, so as soon as the inner panel grew taller than the viewport the bottom (DMX transmitters list, etc.) was clipped and unreachable. Cap the modal at calc(100vh - 32px), lay it out as a flex column, give .xmodal-body overflow-y: auto and min-height: 0, and pin header/footer with flex-shrink: 0 so they stay visible while the body scrolls.
  • Minimap dots vanished entirely after v0.6.7. The nodeStrokeWidth: 2 → 8 + nodeBorderRadius: 0 → 48 bump on <MiniMap> ended up rendering nodes as visually-empty blobs. Walked back to nodeStrokeWidth={3} and nodeBorderRadius={2} — same conservative shape as v0.6.6 with a slightly thicker stroke. The fallback color in the node-color/stroke callbacks was also var(--accent), which doesn't resolve in SVG fill/stroke since presentational SVG attributes are evaluated outside the CSS custom-property resolution; switched the fallback to a concrete hex (#8b5cf6) so any node that somehow lacks a stored color still paints.

v0.6.7 — 2026-04-29

Fixed

  • Adding a Resolume In node crashed the renderer (React #185). useResolumeThumbnail paired useSyncExternalStore with a getSnapshot that returned a freshly-allocated { fetchedAt: 0, url: "", state: "idle" } object on every call when the cache had no entry yet (or when the supplied path was null — which is the default case for a freshly-added Resolume In node, because its starting parameter is /composition/layers/1/video/opacity, not a clip path, so thumbnailPathForClip returns null). React 18 compares snapshots by reference; a new object every call meant the snapshot was always "different" and the renderer entered an unbounded re-render loop the moment the node mounted. Worse, the loop was sticky across relaunches because the broken node was now in the saved graph: every subsequent app start re-mounted it and re-crashed before the user could remove it. Same family of bug as the v0.6.6 useGlobalHotkeys fix and the v0.6.2 EMPTY_PADS fix — third instance of "snapshot reference must be stable" biting in the same release line. Fix: hoist a single shared idleThumbEntry constant at module scope (mirroring how useResolumeComposition already handles its idleEntry) so the empty-cache return is reference-stable.
  • Zoom controls were white on every dark theme. The --xy-controls-button-background-color-default we set in src/styles/app.css (line 164, mapped to var(--surface)) was being clobbered by xyflow's own .react-flow { ... --xy-controls-button-background-color-default: #fefefe; } rule because the bundler emitted xyflow's stylesheet after app.css. xyflow's dark-mode swap selector (.react-flow.dark) never matches because we theme via CSS custom properties on :root and a data-mode attribute, not via that class. Fix: hoist import "@xyflow/react/dist/style.css" in src/main.tsx to before the import App line, so app.css now lands later in the bundle and our overrides actually win cascade order.

Changed

  • Minimap nodes are now visible at every zoom level. Increased nodeStrokeWidth from 2 to 8 and nodeBorderRadius from 0 to 48 on the <MiniMap>. With sparse nodes on a large canvas the previous square 2px outlines vanished into the surface tone; now each node renders as a chunky bright dot that's legible without scrubbing the pane to find content.

v0.6.6 — 2026-04-29

Fixed

  • Actually fixed the launch crash (round 2). The v0.6.5 diagnostic build (development React, no minify) gave us the readable error: Warning: The result of getSnapshot should be cached to avoid an infinite loop followed by Maximum update depth exceeded from React's forceStoreRerender. The culprit: useGlobalHotkeys (originally added in v0.5.2) used useStore with a selector that built a fresh Entry[] array on every selector call: const entries = useStore((s) => { const out = []; for (...) out.push(...); return out; }). Zustand v5 with React 18's useSyncExternalStore compares snapshots by reference; a new array every call meant the snapshot was always "different" → React's loop detector fired on launch. Wrapping the selector with useShallow from zustand/react/shallow makes the comparison element-wise, so an empty out on a fresh-install graph compares equal across renders and the loop never starts. v0.5.x apparently tolerated the latency (subscription churn was harmless if hydration happened to settle quickly); v0.6.x's heavier initial state changes consistently tripped React's nested-update threshold.
  • Production build restored. v0.6.5 was a diagnostic build (~3× installer size) shipping development React + DevTools auto-open. v0.6.6 is back to a normal production build now that we have the fix. Sourcemaps stay on for next-time crash debugging.

v0.6.5 — 2026-04-29 — DEBUG BUILD

This release is intentionally a diagnostic build, not a normal release. v0.6.4 fixed the xyflow snapGrid / inline-callback loop that was visible in the previous traces; another React #185 was reported afterward but with no userspace frames in the trace, only react-dom internals — so it's likely the React useSyncExternalStore "snapshot changed" detection path triggered by another non-stable selector. v0.6.5 ships the development React bundle (readable error messages with component names), turns off renderer minification, and auto-opens DevTools on launch. The installer is roughly 3× the size of v0.6.4. Once we have the actual error message + component path, v0.6.6 will revert to a production build.

Added — diagnostic instrumentation

  • Development React bundle swapped in via define: { "process.env.NODE_ENV": "development" }. Errors will surface as Maximum update depth exceeded. The component <Name> calls setState (...) instead of Minified React error #185; visit https://reactjs.org/docs/error-decoder.html?invariant=185.
  • Renderer minification disabled. Stack frames now name actual files + functions.
  • DevTools auto-opens on the right side of the main window every launch in this build, baked in at compile time via Vite's define: { __XOSC_DEBUG_BUILD__: true } for the main bundle.

Fixed — additional unstable selectors

  • useStore((s) => s.settings.dmx?.userProfiles ?? []) in DmxOutNode, DmxOutInspector, DmxSettingsPanel was returning a fresh empty array on every selector call when no user profiles existed (the default), causing the same useSyncExternalStore snapshot-changed loop that v0.6.2 fixed for pads / clocks. New EMPTY_USER_PROFILES singleton; same pattern as EMPTY_PADS / EMPTY_CLOCKS / EMPTY_TRANSMITTERS.

v0.6.4 — 2026-04-29

Fixed

  • Actually fixed the launch crash. Reading sourcemaps for the consistent index.js:320:23 stack frame revealed the line was inside @xyflow/react/dist/esm/index.js — the StoreUpdater block that syncs <ReactFlow> props into xyflow's internal Zustand store. xyflow tracks ~50 props (the full list includes snapGrid, onEdgesDelete, onConnect, etc.); when any tracked prop's reference differs from the previous render, the StoreUpdater calls store.setState({field: newValue}). Our <ReactFlow> was passing snapGrid={[gridSize, gridSize]} — a brand-new array on every render — and inline closures for onEdgeClick, onPaneClick, onEdgesDelete. Each render produced 4 new references, fired 4 internal setState calls, cascaded through xyflow's many subscribers, and cycled into React's max-update-depth abort. The whole renderer tree unmounted. Three previous patches that "fixed the launch crash" all touched files outside this loop, which is why the same index.js:320:23 stack persisted across v0.6.0v0.6.1v0.6.2v0.6.3 with identical behaviour.
  • Stable references for every tracked ReactFlow prop. onEdgeClick / onPaneClick / onEdgesDelete now use useCallback. snapGrid uses useMemo keyed on gridSize. deleteKeyCode, defaultViewport, proOptions, and the MiniMap nodeColor / nodeStrokeColor callbacks are now module-level constants. The launch race no longer exists.

v0.6.3 — 2026-04-29

Added — diagnostic build (the launch crash is still happening)

  • setState loop detector wired into the Zustand store. v0.6.0 / 0.6.1 / 0.6.2 each thought they fixed the React #185 launch crash and each was wrong; the minified production stack consistently points at index.js:320:23 calling Zustand setState during commit, but with all our code's selectors now stable-ref it's clear the culprit lives elsewhere (most likely vendor — @xyflow/react or another internal store). v0.6.3 wraps every set() in the renderer-side store with a counter that resets each ~16 ms frame; once a frame logs 80 mutations, it captures the first 5 stacks and throws an explicit error with all five included. The new <ErrorBoundary> then renders that error visibly. Net effect: the next launch crash will name the action that's looping rather than failing through React's minified decoder.

v0.6.2 — 2026-04-29

Fixed

  • v0.6.1's launch crash didn't actually fix v0.6.0's launch crash. With sourcemaps enabled in v0.6.1, the renderer console finally pointed at the real culprit: the defensive ?? [] selector pattern I added in v0.6.1 returned a fresh empty array on every Zustand selector call, breaking Object.is equality and causing every component reading settings.dmx. to re-render on every store change. Combined with the ClockBar BPM writeback effect, that produced the React #185 "max update depth exceeded" loop.
  • Stable singleton fallbacks. New EMPTY_PADS / EMPTY_CLOCKS / EMPTY_TRANSMITTERS exported from the store. Selectors fall back to these instead of [], so the result is reference-stable when the underlying slice is missing — Zustand bails on Object.is and skips the re-render.
  • Removed ClockBar's BPM writeback effect. It tried to keep the persisted BPM in sync with the runtime tap-tempo state via a useEffect that ran on every clocks change. Even with the v0.6.1 mismatch guard it remained the most plausible loop trigger. Tap onClick already writes BPM directly through setClockBpm; future external clock sources (MIDI Clock, OSC /clock/bpm) will use the same explicit-write path. Net behaviour: identical for tap-tempo users; one fewer render-loop trap door.

v0.6.1 — 2026-04-29

Fixed

  • Renderer infinite-update loop on launch. The Topbar ClockBar widget had a feedback chain (setClocks → emit → subscriber → force re-render → effect re-runs) that, under certain hydration timings, hit React's "max update depth" guard and unmounted the entire React tree — leaving the user staring at the gradient background with no visible UI. The chain has been rewritten to skip emits when nothing changed, separate read-only display refreshes from store-write paths, and avoid setState inside the listeners-iteration scope.
  • Defensive hydration of settings.dmx. Older configs (v0.5.x had no dmx block at all; early v0.6 builds had only {transmitters, userProfiles}) now backfill cleanly with current defaults via a new ensureDmx() helper instead of producing a partial shape that crashed the first selector reading dmx.pads / dmx.clocks. All renderer selectors that read settings.dmx. are now nullish-safe.
  • Visible crash screen. New <ErrorBoundary> wraps the app — uncaught render errors now show the message + stack + a hint about renaming the config file, instead of failing silently to a blank background. The crash also pushes a system-source log entry so the debug log captures it.

Added

  • Help menu. Custom application menu replaces Electron's default. Help → XOSC website / Documentation / Changelog open the live site; Report a bug on GitHub opens the issues tracker. Default View / Edit / Window roles preserved.
  • Renderer sourcemaps. electron-vite.config.ts enables sourcemap: true for the renderer build. Adds ~2 MB to the installer in exchange for crash stacks pointing at real file/line locations instead of minified bundle offsets.

v0.6.0 — 2026-04-29

Added — DMX support (Track A: hardware backends + graph integration)

  • DMX engine in main process. New electron/dmx/ module owns one universe buffer per logical universe id and one transmitter per (kind, port). One setInterval per transmitter at the protocol's native tick rate (Art-Net / sACN: 23 ms; Open DMX: 33 ms; Slow-break: 50 ms; Enttec Pro: 100 ms). Per-tick pull from universeBus.snapshot(universeId) so writes always land on the next frame.
  • Five hardware backends. Single Transmitter interface, one file per protocol, all ported from TagTable's production-tested implementations:
- Art-Net — UDP unicast on port 6454, Op-Dmx 0x5000 + sequence number byte rolling 1..255. - sACN / E1.31 — UDP multicast at 239.255.<universe-high>.<universe-low>:5568, full ACN packet (root + framing + DMP layers), CID generated once per process, source name "XOSC". - Open DMX — FTDI USB serial, 250 kbaud 8N2, BREAK via port.set({ brk: true }) held ~1 ms, then 513-byte data frame. - Enttec USB DMX Pro — framed protocol at 57600 baud 8N1: [0x7E][0x06][len-LSB][len-MSB][0x00 + 512 B][0xE7]. - Slow-break — CH340 / PL2303 adapters that lack a real BREAK line: switch to 90 kbaud, write 0x00 (10-bit frame stays low ~111 µs > the 88 µs DMX BREAK requirement), switch back to 250 kbaud and write the data frame.
  • dmx-out node kind. New sink that resolves a fixture profile + start channel + per-role values into a list of channel writes and ships them to the universe bus. Pulse / toggle / hold trigger modes; continuous inputs override the configured continuousRole with their normalized 0..1 value (scaled to byte 0..255). Topbar DMX Out button. Node body shows live colour swatch reflecting the most recent write, plus the existing sink-rate gauge.
  • dmx-in node kind. New source that listens to inbound Art-Net frames on (bindHost, port) (UDP socket refcounted across nodes that share an endpoint, same shape as osc-in). Fires onto the input bus when a watched channel's value changes by at least the configured threshold (drops jitter). Supports a contiguous range up to 16 channels — each slot fires its own event with a per-channel signature. Topbar DMX In button.
  • Fixture profile library. Eight built-ins covering the common cases: Dimmer (1ch), RGB / RGBW / RGBA / RGBAW Par, RGB+Strobe, RGB+Dimmer+Strobe, Moving Head 12ch (pan-coarse + pan-fine + tilt-coarse + tilt-fine + speed + dimmer + strobe + R/G/B/W + function). Schema in src/features/dmx/fixtures/types.ts; user profiles persist under settings.dmx.userProfiles (modal editor parked for v0.7 polish — JSON edits via config import/export work today).
  • DMX settings panel. Lives under Settings → DMX transmitters. Add / remove / enable transmitters, pick serial port (auto-discovered via dmx:list-serial-ports), set Art-Net target IP + UDP port + universe / sACN universe / serial path. Per-row diagnostics: FPS, drop rate, frames sent, error count, last-error message, health badge (good / warning / critical / no_output). Test pattern button cycles R/G/B/W/off on channels 1-3 of the picked universe.
  • Logger source "dmx". Open / close / send-error / drop-rate-warn entries route through pushLog and surface in the debug panel under a new dmx chip. Errors rate-limited per (transmitter id, message) into 200 ms windows so a dead network doesn't flood the buffer.
  • Cycle detection over DMX. A dmx-out writing universe U channel C virtually feeds any dmx-in reading the same channel — cycleDetect synthesises the virtual edge so the wire renders amber, the same way Resolume parameter-equality loops are flagged today.

Added — DMX support (Track B: sequences + pads + mixer)

  • Pad launcher (Sequences view). New top-level view alongside the node graph (toggle in the topbar between Graph and Pads). A configurable grid of pads, each owning a sequence (cue list), color, hotkey, mixer slot/priority/mix-mode, and optional shared-clock binding. Eight starter sequences seed on first run via Load defaults (Warm Wash, Color Cycle, Slow Fade Blue, Strobe Test, Police, Gentle Lounge, Bass Hit, Blackout) with QWERTYUI hotkeys. Click a pad to toggle, Shift+click to restart, Spacebar to blackout (matches TagTable).
  • Sequence engine. Renderer-side rAF master tick advances every active layer's playhead, interpolates per-role fades, and hands composed values off to the mixer once per frame. Looping sequences wrap their cue index on each cycle. Layers can run concurrently for the same or different sequences.
  • Mixer with slot exclusivity + mix modes. Replaces Track A's last-write-wins placeholder. Per channel: candidates within a slot resolve to the highest-priority layer; across slots, layers compose by max (default — brightest wins, ideal for additive RGB lighting), add (sum, clamped 255), or replace (last write wins). Graph dmx-out writes always overlay on top so a manual fire wins over an ambient pad. Composed 512-byte frames push to main via dmx:set-frame.
  • Cue-list editor. Edit a pad's name, color, hotkey, target fixture (universe + profile + start channel), playback (duration + loop + clock), mixer hints (slot + priority + mix mode), and cue list (per-row time, fade, all role values) inside one modal. Per-cue emit field lets a cue fire a synthetic input event into the bus when it lands — wire a cue's signature to a Resolume out node and the bass-drop fires both the chase and the OSC trigger at the right beat.
  • Effect generator. Optional cue filler in the editor: pulse / chase (R-G-B) / color cycle (even hues) / fade rainbow, parameterised by step count and fade smoothness. Replaces the cue list with the generated pattern.
  • Shared clocks + tap tempo. New Clock bar in the topbar shows each clock's BPM with a tap button + reset. Layers bound to a clock derive their playhead from the clock's origin so sequences started 100 ms apart still stay in phase. Tap-tempo computes BPM from a rolling 4-tap average; values outside [30, 300] are rejected. The default main clock seeds with no BPM until you tap.
  • Pad ↔ input-bus integration. Pad hotkeys route through the existing keyboard input layer (pad:<padId>:fire signatures), so any input source — keyboard, MIDI, OSC, gamepad, Resolume parameter via threshold transform — can fire a pad. Conversely, a pad firing emits its pad:<id>:fire event so a graph node can wire to "Warm Wash starts" and trigger Resolume in lockstep. Cue emit.signature does the same per-cue.
  • Per-pad live state. Active pads show a thin progress bar fed from the engine's playhead; a glow + accent border indicates the pad whose layer is the live one in its slot.

Notes

  • Beat-locked sequence playback (sequences re-derive duration from the clock's BPM) is parked for v0.6.1 polish — tap tempo gives you a number you display + visualise; cue timings stay in seconds.
  • External clock sync (MIDI Clock 24 PPQN, OSC /clock/bpm) is also v0.6.1.

v0.5.4 — 2026-04-29

Added

  • Serial output node. New sink kind that writes to a COM port, completing the Resolume → Arduino LED feedback round-trip. Pick a port (auto-discovered from serialport.list()), baud rate, and a payload template with {v} (0..1 float, 3 decimals), {V} (0..255 byte), and {s} (state) placeholders. Line ending is configurable (LF / CRLF / CR / none). Discrete inputs honour the trigger mode (pulse / toggle / hold); continuous inputs stream their normalized value through the placeholders. Sharing the port with a serial-input node is supported — the bridge opens it once and routes both directions. New Serial Out topbar button + inspector with live test send.
  • Wires breathe on traffic. Every edge dispatch fires a 280ms halo pulse along the wire — a single press shows a single bright pulse; a continuous axis at 60 Hz keeps the wire continuously lit. Pulse colour is the source node's colour. Idle wires render unchanged. New edgeFireBus keeps the signal scoped so off-canvas edges don't pay the cost.
  • Serial input parser controls. The serial bind UI now exposes a Raw bytes toggle (chunks become triggers without line-splitting) and a Delimiter field for line mode (paste \n, \r\n, \r, or \t — XOSC decodes the escape when opening the port). Previously parser was API-only.

Added

  • Custom theme editor. Settings now ships an in-app editor below the built-in theme grid: pick six seed colors (background, surface, accent, accent-2, text, border) with a clickable swatch (OS color picker) and a hex field. Surface 2/3, border-strong, text-dim/mute, and accent glows are derived automatically via chroma-js so the result stays internally consistent. Light/dark mode auto-detects from the background luminance. Every change live-applies; Reset clears the custom theme and returns to whichever built-in is active. Seeds persist with customTheme in xosc.config.json so the editor reopens preloaded.
  • mDNS auto-discovery for hosts. Resolume Out / Resolume In / OSC Out inspectors get a host picker dropdown of LAN-discovered peers — no more hunting for IP addresses. The main process runs bonjour-service browsers for _resolume._tcp, _osc._udp, and _companion._tcp; each picker is filtered by service kind so the Resolume inspector doesn't show Companion controllers. Selecting a peer fills both host and port. mDNS is broadcast-only on 224.0.0.251:5353 — XOSC does not advertise itself. On a hardened venue with multicast blocked, the picker stays empty and manual entry still works as before.

v0.5.2 — 2026-04-29

Added

  • Cycle-warning UI. Edges that participate in a logical feedback loop now render with an amber stroke and a ⟳ loop pill at the midpoint. Detection includes static graph cycles AND virtual loops that close through Resolume parameter equality (a resolume-out writing parameter P is treated as feeding any resolume-in reading P on the same host:port). Non-blocking — the depth cap and echo guard still terminate the loop at runtime; this is a static design-time hint so you can spot the wiring at a glance.
  • Per-sink event-rate gauge. OSC-out and Resolume-out nodes show a tiny events/sec pill while traffic is live. The pill turns amber above 60 events/s, red above 200 — useful for spotting an over-wired continuous source from across the canvas. Side-channel ring buffer (out of Zustand) so 60 Hz axis traffic doesn't trigger React re-renders across the app.
  • Resolume swagger autocomplete. The Resolume parameter picker now fetches /api/v1/docs/swagger.json alongside the composition tree and unions paths in. Effect / dynamic params that don't appear in the composition (because they haven't been added yet) are still pickable as long as Resolume's swagger lists them.
  • Global keyboard hotkeys. New per-input toggle in the keyboard input inspector. When on, XOSC registers an Electron globalShortcut for that key so it's captured even when the window isn't focused. globalShortcut fires once per press; XOSC synthesizes a release ~120 ms later so toggle / hold downstream stays predictable. Modifier combinations and most non-symbol keys are supported; the failure-to-register reason is logged.

v0.5.1 — 2026-04-29

Added

  • Per-edge throttle override. MappingEdge.throttleMs? overrides the engine's default 25 ms continuous-burst throttle on a per-wire basis. Edge inspector exposes a Toggle + Slider — useful when one source feeds many sinks and only some need calming.
  • Save debug log to file. DebugPanel gets a Save button alongside Copy / Clear. Writes the filtered buffer as JSONL via a new log:save IPC + dialog.showSaveDialog. Default filename is xosc-log-<timestamp>.jsonl.
  • OSC Monitor node. New sink-only node kind that visualises traffic on its left handle: live-scaling sparkline of the last 120 numeric values + the last 6 raw timestamped samples. Doesn't fire any side effects; great for stage debugging without opening the debug panel. Topbar Monitor button.

Fixed

  • Toggle state survives rebind. When a sink's incoming edges or triggerMode change, its per-sink toggleState entry is now cleared so the next press starts from a known state instead of mid-flip. Stale entries for deleted sinks are also dropped.

v0.5.0 — 2026-04-29

Added

  • Loop protection: dispatch lineage + depth cap. InputFiredEvent now carries an optional causedBy: { nodeId, depth }. The mapping router drops any event whose depth > 8 and emits a warn-level log entry. Foundation for safe multi-hop chains.
  • Resolume echo-suppression on resolume-in. When a resolume-out writes parameter P, the dispatch tracker remembers the write for ~200 ms. The next parameter_update for P is dropped (default) — kills the resolume-in → resolume-out → Resolume → resolume-in loop without throttling user-driven motion. New per-node "Echo guard" toggle in the resolume-in inspector lets the round-trip through when you actually want it; depth cap still terminates at 8 hops.
  • Inbound OSC listener (osc-in node). New source node kind that opens a UDP listener on a configurable (bindHost, port) and emits matching packets onto the input bus, ready to be wired to any sink. Address patterns support (any non-slash chars) and ? (one char). Single listener per (bindHost, port), refcounted across nodes — many nodes can listen on different addresses on the same port. Default bind is 127.0.0.1 (loopback only); 0.0.0.0 is opt-in with a clear "LAN visible" warning in the inspector. Topbar button + node body shows live-hit indicator and last numeric value.
  • Math / logic transform nodes. New mid-graph node kind that's both a sink and a source. Initial set: clamp, scale (range remap), invert, curve (gamma), hold-timer (separate tap / hold outputs), double-tap, threshold (hysteresis with high / low outputs), latch (toggle on press), edge (rise / fall / both). Stateful ops keep state per (transformNodeId, sourceId) and reset on app reload. Multi-output ops expose multiple right-side handles; edges remember sourceHandle / targetHandle.
  • Dispatch mode per edge. Edges now carry an optional dispatch: { mode, order, delayMs?, requirePrevOk? }. Modes: parallel (default; current behaviour), sequential (await each previous edge before firing), after-prev (sequential + skip on previous error). Edges from the same source group sort by order ascending. New EdgeInspector exposes the controls; click an edge to select it. ColorMixEdge renders dashed strokes + an order/mode badge for non-default edges.

Changed

  • Mapping router rewrite. Multi-hop traversal: walks source → [transform …] → sink with MAX_DISPATCH_DEPTH = 8 bounding cycles. Threads dispatch depth through to sinks; resolume-out set actions record their write into the lineage tracker so a Resolume rebroadcast can be attributed back to the originating sink. Per-edge dispatch mode honored.
  • Edges now carry optional sourceHandle / targetHandle (transform multi-output) and dispatch (per-edge ordering); paste preserves both.

v0.4.0 — 2026-04-29

v0.4.0 — 2026-04-29

Added

  • Resolume composition picker. Resolume Out and Resolume In nodes now ship a live picker that fetches GET /api/v1/composition, walks the tree (layers / clips / columns / decks / video / audio / effects / params), and surfaces every leaf as a searchable dropdown — paths, value types, ranges, and current values. The picker on Resolume Out narrows to connect/trigger entries when Action = trigger, and to value-bearing parameters otherwise. Manual path entry still works for by-id paths or anything we don't recognise.
  • Live clip thumbnails. When the selected parameter is under a clip, XOSC pulls GET /api/v1/composition/layers/{L}/clips/{C}/thumbnail and shows the preview inside the picker row, in the inspector, and on the node body. The on-node preview polls at ~1.5 s while the WS bridge is open, stops when it disconnects.
  • Canvas keyboard shortcuts. Delete / Backspace removes selected nodes (and any wires touching them). Ctrl/Cmd+C/X/V copies, cuts, and pastes selected nodes. Paste generates fresh IDs and offsets each successive paste by 24 px so they fan out instead of stacking. Wires travel with the paste only when both endpoints are part of the selection. Shift+click extends the canvas selection.
  • Theme picker grid. Settings now shows all 10 built-in themes as a 2-column grid of preview cards (bg + accent + accent-2 + surface + border swatch), instead of the old single-line dropdown.
  • All-users installer option. The NSIS installer now offers per-user vs all-users with a UAC prompt (allowElevation: true), so the app can land in Program Files for shared workstations.
  • New IPC: resolume:fetch-json and resolume:fetch-thumbnail in the main process. The renderer never touches HTTP directly — main proxies it from a tiny node:http helper that times out at 4s.

Changed

  • App / installer icon regenerated from src/assets/xosc-mark.webp (skull-pinup mark) instead of the placeholder XOSC-text-on-purple-radial — build/icon.{png,ico,svg} and build/icon-256.png rebuilt as a multi-size .ico (16/32/48/64/128/256). New scripts/build-icons.sh regenerates them deterministically.
  • Topbar buttons rebalanced: + Input is now the primary button (it's the first action a new user takes); OSC Out, Resolume Out, and Resolume In are subtle.
  • Topbar logo light-mode override fixed — the selector previously keyed off data-theme (never set); now correctly uses data-mode="light" for Bone and Newsprint themes.
  • MiniMap now sets bgColor="var(--surface)" and uses thicker node strokes so layouts read clearly at small zoom on dark themes.
  • Resolume docs updated to reflect what XOSC actually ships now (vs the old "v0.2 will add" copy). New pages: Resolume — parameter picker & live previews and Keyboard shortcuts.

v0.3.0 — 2026-04-29

Added

  • Resolume input (subscribe) node. New resolume-in graph node subscribes to a Resolume parameter via the WS bridge and acts as an input source. Wire it into an OSC out or Resolume out to mirror or rebroadcast a parameter — the same mapping logic that handles keyboard / MIDI / gamepad applies. Live status pill on the node, inline display of the most recent value, Reconnect button in the inspector. New "Resolume In" button on the topbar.
  • Per-input range / curve mapping. Continuous inputs (MIDI CC, pitch bend, gamepad axis, Resolume feedback) gained a value-transform editor in the inspector: invert, min / max range, curve (linear / log / expo / expo² presets + free slider 0.1..4), center deadzone, low / high deadzone. Identity transforms aren't persisted, so saved configs stay clean.
  • Source / sink helpers in store. isSourceNode / isSinkNode / applyTransform / resolumeInSignature exported so feature code can route uniformly across input + resolume-in sources.
  • Mapping router refactored to per-edge dispatch. Each (source, sink) edge now applies that source's transform independently — multiple inputs matching the same physical event can each fire downstream sinks with their own remapped value.

Fixed

  • CI: typecheck-web added @types/node to web/package.json so astro check resolves node:fs/promises / node:path / process.cwd() references in build-time loaders. Was passing locally because of a hoisted dep at the repo root; failed in CI because the verify job only installs web/'s deps.
  • CI: deploy-site / release workflows no longer fail at workflow-validation time when SITE_DEPLOY_SSH_KEY isn't configured. The optional rsync / scp upload is gated behind a step output instead of ${{ env. }} in if.

v0.2.0 — 2026-04-29

Added

  • OSC throttle for continuous bursts. electron/osc-engine.ts coalesces continuous values per host:port:address (leading + trailing flush, default 25 ms). Discrete pulses still pass through synchronously. OscSendRequest gained continuous?: boolean + throttleMs?: number.
  • Continuous values from MIDI CC, pitch bend, and gamepad axes. Descriptors carry continuous: true; inputBus events carry a normalized value: number ∈ [0,1]. Gamepad axis polling joined the existing button polling loop.
  • Mapping router routes continuous values to the first numeric arg of every downstream sink (f arg gets the float verbatim; i arg gets round(v127)).
  • Resolume modifier-prefix preset in OutputInspector: prepends "a" / "+" / "-" / "" / "?" as the first string arg for Resolume-style relative parameter sets.
  • Resolume WebSocket output node. New resolume-out graph node speaks the Resolume WS API (action: trigger, set, subscribe, unsubscribe). One socket per host:port, exponential-backoff reconnect (cap 30 s), bounded send queue while connecting, per-parameter throttling for set, live status pill on the node, and a Reconnect button in the inspector.
  • Topbar "Resolume" button spawns the new node at viewport center.
  • Real logo + favicon set. Brand SVG retired in favor of the actual XOSC mark; filter: invert(1) for dark themes; light themes (bone, newsprint) keep it black. Site header, footer, splash, Electron Topbar, and full favicon/manifest set all share one webp.

Changed

  • CSP locked down for offline / air-gapped venues. No remote font/connect/object/frame loads in index.html or splash.html. Renderer never makes outbound HTTP at runtime — only local WS to the Resolume host the user picks (default 127.0.0.1).
  • External navigation blocked. setWindowOpenHandler denies popups; will-navigate rejects any non-file/non-dev URL.
  • Font fallback chain prefers Windows 11 system fonts (Segoe UI Variable Display, Cascadia Mono) so the UI renders correctly on a venue laptop without the design fonts installed.
  • Debug log polish. Continuous OSC + WS sends log at debug level (info filter shows discrete fires only). Resolume bridge logs every send / receive / queue / state-change main-side; renderer pipes resolume:onMessage + resolume:onStatus into the same buffer. DebugPanel gained source filter, level filter, free-text search across data payloads, tail toggle, and per-row inline data summary.

Fixed

  • Gamepad axis prevAxis bug — first deliberate move post-bind now reliably trips the delta gate.
  • Hydration robustnesshydrateFromSnapshot filters unknown node kinds and drops dangling edges whose endpoints don't exist (forward-compat for older saves).

Audit

  • Confirmed no autoUpdater, Sentry, Bugsnag, or crashReporter calls anywhere. electron-builder --publish never keeps the build pipeline offline-only.

v0.1.0 — 2026-04-26

  • Initial XOSC release. Electron app + Astro site + CI.