Commit Graph
52 Commits
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 d3e6357b28 refactor(app): the host seam + carve section practice out of app.js (R3a)
static/js/host.js (99) + static/js/section-practice.js (1,214).
app.js 9,461 → 8,409.

THE FIRST SLICE OUT OF THE STRONGLY-CONNECTED CORE. What is left in app.js is not
a tree, it is a cycle: seeding a dependency closure from section-practice, from
loops, from count-in, or from the JUCE seek shim all return the SAME 178-function
set, and setLoop() and practiceSection() call each other directly. No closure-based
carve can cut it at any seed. So it is cut BY NAME, and the calls back into app.js
go through a host seam.

  61 functions + its own 24 _sectionPractice*/_sectionParents* scalars (read nowhere
  else) move out. 11 hooks come back in. 4 of those are read-only GETTERS —
  loopA/loopB/_audioSeekGen/_loopMutationGen are only ever READ here, never written,
  so app.js keeps owning them and NO state container is needed (a 977-site lift
  avoided).

app.js used to reach IN and reset the module's state by hand (clearLoop() zeroed the
selection; changeArrangement() invalidated the parent count). It cannot now — an
imported binding is read-only — so those are exported as resetSelection() and
invalidateParentCount(). Strictly better: the module owns its own invariants instead
of trusting two callers on the far side of the file to zero the right three fields.

═══ THE SILENT-NO-OP PROBLEM, SOLVED ═══
The obvious host seam is an object of no-op defaults. That is a TRAP and we walked
into it once: the plugin loader's seam defaulted populateVizPicker to `() => {}`, so
a dropped wiring line would have left the viz picker quietly not refreshing with NO
test, boot check, or bot noticing. Two layers stop it here:

  1. RUNTIME — host.js is a Proxy with NO defaults and NO stubs. Reading an unwired
     hook THROWS. An unwired hook cannot degrade into a no-op because there is
     nothing to degrade INTO. configureHost() also rejects a non-function at WIRE
     time, and refuses to run twice.

  2. STATIC — tests/js/host_contract.test.js asserts the hooks the modules USE are
     exactly the hooks app.js WIRES. This is the layer that matters: a runtime throw
     only fires if the broken path executes, and the whole danger of a seam is the
     paths that never run in a smoke test. VERIFIED TO BITE in all three drift
     directions: drop a hook from configureHost -> fails; rename host.setLoop in the
     module -> fails; wire a hook nobody uses -> fails.

Writing that guard took three tries and each failure is instructive: (a) the
configureHost regex anchored `});` at column 0, ran past the indented close, and
swallowed app.js's 66-name window contract — 77 "hooks"; (b) an import-stripping
regex with `[\s\S]*?` ate 14,000 characters INCLUDING the drift the bite test was
meant to catch — a guard with a hole is worse than no guard, because you trust it;
(c) `host.js'` in the import path backtracked from `js` to a "hook" called `j`.
The bite tests are what surfaced all three.

CODEX FOUND A REAL RACE [P2]. configureHost() was inside the async boot function,
after several awaits — but the window handlers (onPhraseNext, …) go live during
app.js's SYNCHRONOUS module evaluation. A user clicking one in that window would hit
"[host] … was read before configureHost() ran". It is now a bare top-level statement
sitting immediately before the window contract, so the seam is always wired before a
handler can be reached. Verified live: invoking a handler 1.2s in — well before the
boot awaits settle — works.

VERIFIED. A/B against origin/main in two browsers with a REAL song loaded: popover
toggle, practice-mode change, phrase-next, and clearLoop (all of which cross the seam
— setLoop/clearLoop/_audioTime/loopA/loopB) — IDENTICAL, zero page errors. Since an
unwired hook throws, a live app is itself proof the seam is wired.

Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a
resetSelection SPY (not a stub) and ASSERTS it fires — the guarantee is still tested,
just through the seam.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:50:43 +02:00
f53d566dbc refactor(app): give the <audio> element a module of its own (R3a) (#886)
static/js/audio-el.js — one exported const. app.js's diff is 5 lines.
This is a HINGE, not a carve: nothing shrinks, but almost everything left in
app.js is blocked behind it.

WHY. `audio` is `document.getElementById('audio')` with 162 references in app.js
and 173 outside any one cluster. Every remaining cluster measured — settings,
app-updates, count-in (208 fns), exit-confirm (212), library-render (220) — lists
`audio` among its inbound symbols, because they all touch playback and playback
reaches for the element directly. A module that needs it cannot import app.js to
get it (that closes a cycle and fails import-x/no-cycle), so today the only way to
carve any of them would be a host seam — the exact thing #878 had to build and
#880 had to tear out.

WHY IT'S SAFE. `audio` is a `const` and is NEVER reassigned anywhere in core, so a
read-only import binding is exactly right and no state container is needed. The
162 call sites are untouched — the binding keeps its name, it is just imported
instead of declared. (Contrast the reassigned scalars — isPlaying, _avOffsetMs —
which CANNOT be shared this way: an imported binding cannot be written to. Those
still need containers, and that is the next problem, not this one.)

TIMING. app.js is <script type="module">, so it evaluates after the HTML is parsed
and its imports evaluate just before its body — the same moment app.js used to run
this exact lookup. If the element had not been in the document, `audio` would be
null and app.js's top-level `audio.addEventListener(...)` calls would throw and
kill the module. They don't.

VERIFIED WITH REAL PLAYBACK, not a boot check. A/B against origin/main in two
browsers: app alive with zero page errors (which is itself the proof the import
resolved), #audio is an AUDIO element, togglePlay/seekBy live, and playSong() on a
real library song sets audio.src and the element reports a duration — IDENTICAL on
both sides.

pytest 2396, node 1038/1038, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:44:01 +02:00
b5dd585d25 refactor(app): carve settings backup + plugin updates out of app.js (R3a) (#885)
Two leaves, one PR. app.js 9,651 → 9,457.

static/js/settings-io.js (155) — exportSettings + importSettings, the Settings
backup bundle. Imports nothing. The two-phase rationale comment (server first and
atomic; then a best-effort localStorage merge) is the contract and moved with the
code.

plugin-updates → INTO static/js/plugin-loader.js, not a module of its own.
checkPluginUpdates + updatePlugin are plugin MANAGEMENT; they belong with the code
that loads plugins. A new file for 50 lines would have been a file for its own
sake.

All four are inline handlers on the Settings screen and already in app.js's window
contract, so app.js re-exposes the imported bindings unchanged.

VERIFIED BY DRIVING BOTH FLOWS. A/B against origin/main in two browsers:
  * checkPluginUpdates() -> hits the API and settles the button back to
    "Check for Updates" — IDENTICAL
  * exportSettings() -> POSTs /api/settings/export and writes
    "Exported feedBack-settings…" to #backup-status — IDENTICAL (fetch intercepted
    so the assertion is on the real call, not a stub)
  * all four resolve on window — IDENTICAL
  * zero console/page errors either side

Zero harnesses broke. pytest 2396, node 1038/1038, ESLint 0, tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:42:54 +02:00
d47883c5e5 refactor(app): carve the tuning-display helpers out of app.js (R3a) (#884)
static/js/tuning-display.js (228 lines) — bodies VERBATIM. app.js 9,838 → 9,650.
A LEAF: imports nothing.

Tuning NAME resolution (Drop D / Eb Standard / raw-offset fallback), bass
detection, effective string count, and the target FREQUENCIES + note names the
tuner checks against. Pure functions over a small MIDI/note-name table; the 3
_TUNING_* tables are read nowhere else and move in.

NOT A SLICE — a node-level extract. The span 2309-2535 INTERLEAVES the functions
with the `window.*` / `window.feedBack.*` assignments that publish them, and one
of those is `window.feedBack = window.feedBack || {}` — the BUS BOOTSTRAP, not
tuning code at all. Every ExpressionStatement stays exactly where it was; only the
16 functions and 3 tables move. app.js re-exposes the imported bindings from the
same lines, so the public surface and its ordering are untouched (constitution II
names window.feedBack).

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors,
              tuning-display }

HARNESSES — 4 broke, and 3 of them broke in the SAME informative way: they sliced
app.js from `function isBassArrangement(` UP TO the marker
`window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;` — an end-marker
that (correctly) stayed behind in app.js. The module is now nothing BUT the tuning
helpers, so there is no block to slice: they read it whole and strip `export ` so
the vm sandbox still evaluates it as a script.
  tuner_auto_open is SPLIT — its autoplay-gate test still reads app.js, so it keeps
  APP_JS and gains TUNING_JS. Retargeting its path wholesale (my first attempt)
  silently pointed the autoplay test at the wrong file.

VERIFIED BY DRIVING THE CONTRACT. A/B against origin/main in two browsers, through
the real window surface: displayTuningName -> "E Standard" / "Drop D" /
"Eb Standard", parseRawTuningOffsets('-2,0,0,0,0,0') -> [-2,0,0,0,0,0],
isBassArrangement, effectiveStringCount, displayTuningTargets, and
window.feedBack.displayTuningName / .songTuningContext — IDENTICAL on both, zero
console/page errors either side.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:37:44 +02:00
ebbfc8da6f refactor(app): carve the highway string-colours out of app.js (R3a) (#883)
static/js/highway-colors.js (601 lines) — bodies VERBATIM.
app.js 10,415 → 9,837. Under 10k.

DECOMPOSED, not sliced. The "settings" blob measured 47 fns / 19 inbound and was
not carvable as-is. Seeding the closure from a FUNCTION (initHighwayColors) found
only 18 fns and left 4 HWC_* constants used outside it — i.e. the seed was wrong,
not the cluster. Re-seeding from the STATE (every function touching HWC_*/`_hwc*`)
found the true cluster: 45 top-level nodes, lines 2751-3331, CONTIGUOUS, with ZERO
foreign nodes inside the span.

  INBOUND: 0.  EXPORTS: 2 (initHighwayColors, hwcInitSettingsUI).

The other 43 symbols — the HWC_* tables, the 12 presets, the theme store, the
share codec, the picker handlers, the window.feedBack.highwayColors facade — are
used nowhere else in core and stay private. No inline on*= handlers here (the
Settings buttons are wired by addEventListener inside hwcInitSettingsUI), so
nothing needed re-exposing on window. The three bus listeners register inside
initHighwayColors, which app.js calls — not at module top level — so no ordering
change.

THE no-undef GATE EARNED ITS KEEP. My closure said INBOUND=0; the module actually
uses `uiPrompt` (the "name this theme" prompt). It was missed because uiPrompt is
no longer an app.js DECLARATION — it's an IMPORT BINDING (from #882's dom.js), and
I was collecting declarations only. `no-undef` with typeof:true caught it.
  => Lesson for the next carve: seed `tops` from ImportDeclaration bindings too.
  => And it VALIDATES carving dom.js early: this module just imports uiPrompt from
     it. Had dom.js still been stranded in app.js, this carve would have needed a
     host seam.

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors }
  plugin-loader -> viz
  highway-colors -> dom
  viz, diagnostics-export, dom -> (leaves)

VERIFIED BY DRIVING THE FACADE. A/B against origin/main in two browsers:
window.feedBack.highwayColors installed, identical method surface, 12 presets,
identical default slot colours, and a share-code encode→decode round-trip
returning #112233 — IDENTICAL on both, zero console/page errors either side.

Harnesses: highway_colors_facade + highway_string_colors retargeted (both
brace-extract blocks out of the source by signature).

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:22:14 +02:00
14b4058bc6 refactor(app): carve the DOM/modal primitives out of app.js (R3a) (#882)
static/js/dom.js (203 lines) — esc, _escAttr, _isElementVisible, _trapFocusInModal,
_confirmDialog, uiPrompt. Bodies VERBATIM. app.js 10,593 → 10,414.

A GATHER, not a slice — the six lived in six different places (108, 635, 659,
2617, 2623, 8892). They belong together because they are the BOTTOM of the UI
stack: `esc` alone has 25 call sites and `_escAttr` 23, and every later carve that
renders HTML will need them.

That is the actual point of doing this one now. Give them a home and the next
carve imports them; leave them in app.js and the next carve that renders HTML has
to invent a host seam to reach back into app.js — exactly the trap the
plugin-loader carve had to work around until the viz layer became a module. This
is the cheapest possible way to stop that recurring.

  app.js -> { plugin-loader, viz, diagnostics-export, dom }
  plugin-loader -> viz
  viz, diagnostics-export, dom -> (nothing)

Zero imports. Six exports (every one is used outside the cluster).

VERIFIED BY DRIVING THE MODALS, not just booting — they are interactive, so a
green suite says little. A/B against origin/main in two browsers:
  * window.uiPrompt / _confirmDialog / _trapFocusInModal all resolve
  * uiPrompt() mounts its modal, accepts typed input, and resolves with the typed
    value ('typed') — IDENTICAL on both
  * _confirmDialog() mounts and resolves true on confirm — IDENTICAL
  * zero console/page errors either side

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:59:44 +02:00
bfb31a8b89 refactor(app): carve the diagnostics-bundle export out of app.js (R3a) (#881)
static/js/diagnostics-export.js (280 lines) — bodies VERBATIM.
app.js 10,858 → 10,592.

Chosen BY MEASUREMENT, not by eye. Ran the transitive closure over four candidate
clusters and took the one with the smallest interface:

  diagnostics       7 fns   235 lines  span 4110-4378  imports 1  exports 2
  shortcuts-modal   5 fns   235 lines  span  104-9897  imports 4  exports 5
  settings+updates 47 fns  1012 lines  span 1409-7636  imports 19 exports 30
  library-render  220 fns  4064 lines  span   20-10536 imports 126 exports 117

diagnostics is contiguous and nearly closed; its one inbound symbol
(_DIAG_FILE_LABELS) lives inside the region and is read only by _renderDiagPreview,
so it moves in and the module ends up a LEAF — imports nothing.

  app.js -> { plugin-loader, viz, diagnostics-export }
  plugin-loader -> viz
  viz, diagnostics-export -> (nothing)

Exports exactly 2: previewDiagnostics + exportDiagnostics, both already in app.js's
window contract (they're inline handlers in the Settings screen) — so app.js keeps
re-exposing them, now as imported bindings. The preview renderer, the file-label
table, and the byte/HTML formatters are used NOWHERE else in core and stay private.

VERIFIED BY DRIVING IT, not just booting. Zero harnesses broke — because the
diagnostics export flow had NO source-level test at all, which is exactly why a
green suite proves nothing here. So the flow was exercised for real: A/B against
origin/main in two browsers, window.previewDiagnostics() invoked, the preview
container rendered identical content on both, both entry points resolve on window,
zero console/page errors either side.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

NOTE for the next carve: library-render is NOT a cluster — 220 functions and 126
inbound symbols is most of app.js entangled together. It cannot be carved as a
unit; it needs decomposing from the inside first.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:52:47 +02:00
a222b45c02 refactor(app): carve the viz layer out of app.js — and delete the loader seam (R3a) (#880)
static/js/viz.js (770 lines) — the viz picker, renderer selection, Auto-match,
the WebGL2 probe, the 3D-promotion nag, the notation hints. Bodies VERBATIM.
app.js 11,603 → 10,857.

THE SEAM IS GONE. #878's plugin-loader needed configurePluginLoader({
populateVizPicker }) purely because _populateVizPicker lived in app.js and
importing app.js would have closed a cycle. viz.js is a LEAF — it imports NOTHING
— so plugin-loader now imports _populateVizPicker straight from it. The _host
object, the configure function, its loud-default guard, and the wiring line in
app.js are all deleted. The second carve simplifies the first.

  app.js -> { plugin-loader, viz }
  plugin-loader -> viz
  viz -> (nothing)

NOT A PURE MOVE — one listener block had to be SPLIT. app.js had a single
top-level `if (window.feedBack) { … }` registering four handlers, and only two
were viz. song:loaded / arrangement:changed / song:ready (the mastery slider)
stay in app.js and now call the imported _autoMatchViz / _maybeShowNotationViewHint.
The viz:reverted handler MOVES, because it REASSIGNS _cancelPendingAutoLabel and
an imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
the listener stayed behind while the state moved.

ORDER CHECKED, NOT ASSUMED: viz.js's song:ready listener now registers BEFORE
app.js's own (imports evaluate first). Safe — _pendingPromotionNag is only ever
set inside _populateVizPicker, which runs at boot/plugin-refresh, never from
inside the other song:ready handler, so the two are independent.

VERIFIED — the listeners are the risk here, so they were DRIVEN, not just booted.
A/B against origin/main in two browsers:
  * viz picker: 6 options (auto|default|venue|drum_highway_3d|keys_highway_3d|
    highway_3d), selected highway_3d, Auto label — IDENTICAL. This alone proves
    plugin-loader's direct import of viz.js works.
  * emit('viz:reverted') -> picker resets to default, localStorage resets to
    default, the warning logs — IDENTICAL. The MOVED listener fires.
  * emit('song:ready') -> mastery slider enables, no throw — IDENTICAL. The SPLIT
    listener still does both halves.
  * plugin screens, module injections, 37 capability participants — IDENTICAL.
  * zero console/page errors on both.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean. no-cycle re-bitten on
the 3-module graph (viz -> plugin-loader fails).

Codex preflight raised a [P2] claiming viz.js's top-level bus guards would be
false because "app.js only creates the event bus later" — FALSE POSITIVE. app.js
does not create the bus; capabilities.js does, from its own <script type="module">
at index.html:122, and module scripts execute in document order, so the bus exists
long before app.js's import graph evaluates. Instrumented the setter: by viz.js's
turn `window.feedBack.on` is already a function, and the viz:reverted listener is
provably attached (firing it resets the picker). The ordering is also enforced by
test_app_shell_loads_capability_registry_before_app_runtime.

Harnesses: 5 tests retargeted to viz.js across legacy_shim_hits, venue_scene_3d,
venue_viz (each SPLIT — their non-viz tests still read app.js).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:41:52 +02:00
5b904706d0 feat(audio): loopback feeder mode + static no-cache — all app audio under exclusive/ASIO (#877)
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(diag): --debug ASIO routing diagnostics in static bundle

Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug);
inert in the Docker sphere and normal desktop runs.

- [asio-diag] getCurrentDevice= full device object on outputType change
  (catches ASIO drivers reporting a non-'ASIO' type name)
- [asio-diag] renderer-bus: full feeder decision vector, change-gated
  (running/exclusive/stems/juceMode/elementSong/want/mode)
- [asio-diag] setSink: every sink flip with ctx state + rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close loopback capture context on teardown (release tap worklet)

The loopback context was reused across engages (_lbCtx || new), but teardown
only stopped the stream + deactivated the tap — never closing the context or
detaching the worklet node. Each exclusive<->shared switch orphaned a live
tap worklet on the long-lived context. Use a fresh context per session and
close it on disengage. Adds a test asserting the context is closed on teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(diag): install-time + uncaught-error diagnostics for the reroute chain

2026-07-11 tester log showed the routing watcher and renderer-bus feeder
never installed (zero [feedpak-route]/[renderer-bus] lines) plus an
uncaught SyntaxError with no source location — nothing in the log said
why. New:

- global error/unhandledrejection tap logging message + filename:line:col
  (error events carry the location even for parse errors in other scripts)
- explicit install / NOT-installed lines for watcher and feeder (incl.
  loopback capability probe)
- DOMException detail (name/message/stack head) in the feeder retry warn
  — the console-message forward stringified it to [object DOMException]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(static): force conditional revalidation on /static (Cache-Control: no-cache)

Without Cache-Control Chromium's heuristic freshness (10% of file age)
serves /static/app.js from disk cache for hours-to-days without
revalidating. Desktop consequence: a new build's window ran the previous
build's app.js — the 2026-07-11 ASIO investigation traced 'routing
watcher never installed' + a stems module-plugin SyntaxError to exactly
this (stale loader predating scriptType support). no-cache keeps caching
but revalidates via ETag — unchanged files still cost only a 304.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(diag): gate install-time + uncaught-error [asio-diag] lines on --debug

The error tap and install lines from the previous diag commit were
unconditional. Now: error/rejection taps check _asioDiagEnabled() at
event time; install lines log deferred once the async debugEnabled()
resolves true. The NOT-installed anomaly lines stay bridge-gated
(window.feedBackDesktop present) instead — a broken bridge can't deliver
the debug flag, they fire at most once, and only in the broken state
they exist to witness. Docker sphere: fully silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-11 18:22:25 +02:00
38772f604a refactor(app): carve the plugin loader out of app.js into static/js/ (R3a) (#878)
The first carve, and deliberately the riskiest: app.js IS the plugin loader (the
R0 host rails), so it goes first while the module graph is still one edge deep.

static/js/plugin-loader.js (829 lines) — bodies VERBATIM. app.js 12,217 → 11,439.
Core's first `static/js/` module, exactly as constitution II anticipates.

CLOSURE (measured with acorn, not regex — brace-matching stripped source drifted):
the block at app.js:11246-12031 is contiguous and self-contained. It needs only
TWO things from the rest of app.js, and exports only TWO:
  exports: loadPlugins (the window contract), bootstrapPluginsAndUi (boot)
  inbound: window.showScreen — already the public host contract (constitution II),
           so it is called through `window`, not re-coupled as an import
           _populateVizPicker — injected via configurePluginLoader()

WHY A SEAM, NOT AN IMPORT. plugin-loader must not import app.js: app.js imports
it, so that would close a cycle. I checked whether _populateVizPicker could just
move into the module instead (which would delete the seam entirely) — it drags 9
further symbols (_canRun3D, _autoMatchViz, _showPromotionNag, …), i.e. a whole
viz cluster. That is its own carve, so the seam stays.

THE SEAM'S DEFAULT IS LOUD, ON PURPOSE. A no-op stub is the classic silent
failure for this pattern (see the editor's setHostHooks trap, hit twice): drop the
wiring call and the loader keeps working while the viz picker quietly stops
refreshing — no test, no boot check says a word. The default now console.errors,
so the smoke harness catches it. VERIFIED BY BITE TEST: removing
configurePluginLoader() from app.js surfaces
"[plugin-loader] host seam not configured" at boot. The seam IS exercised on the
plugin-startup path, so an unwired hook cannot pass silently.

no-cycle is now LIVE on core's own graph for the first time. eslint.config.js
gains `static/app.js` + `static/js/**` to the module block — app.js now `import`s,
so parsing it as a script would be a syntax error. VERIFIED BY BITE TEST: making
plugin-loader import app.js back fails with "Dependency cycle detected".

HARNESSES (the R3a note said budget one conversion per carve — it was five):
retargeted capability_inspector_nav, plugin_hydration_wipe,
plugin_loader_script_type, plugin_style_injection, legacy_shim_hits (SPLIT — one
test needs the loader, one still needs app.js) + test_plugin_runtime_idempotence.
legacy_shim_hits was missed by a symbol-name grep because it greps for a code
STRING; only the failing run found it. test_capability_events' NEGATIVE asserts
now span app.js + the loader — carving code out of app.js would otherwise make
them vacuous instead of failing.

VERIFIED: A/B against origin/main in two browsers — mounted plugin screens, 14
loaded plugin scripts, the 3 module plugins injected as <script type="module">,
37 capability participants, 14 shims, window.loadPlugins: IDENTICAL, zero
console/page errors on both. /static/js/plugin-loader.js serves 200; R0 rails
intact (src/main.js 200, conditional GET 304, script_type passthrough).
pytest 2396, node 1032/1032, ESLint 0, Codex 0.

Codex preflight caught a REAL [P1] first pass: static/js/plugin-loader.js was
untracked, so a checkout would have served an app.js importing a nonexistent
module — a failed static import kills the whole module and every window handler
with it. Now tracked.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:18:00 +02:00
ff7e855e35 refactor(app): make app.js's window contract explicit — 66 names (R3a) (#874)
app.js is a classic script, so each of its 385 top-level `function foo()` decls
is implicitly a property of `window`. As an ES module it will not be — module
scope is not global scope — and every name reached from outside this file would
silently vanish. This adds the explicit `window.*` assignments BEFORE the flip.

Provably a NO-OP: all 66 are top-level function declarations, so while app.js is
still a classic script `Object.assign(window, {...})` only re-assigns what
`window` already has. That is what makes it safe to land on its own, ahead of
the flip that needs it.

The consumers are wider than the inline handlers in index.html:
  - inline on*= handlers in static/v3/index.html
  - on*= handlers app.js BUILDS inside template literals (goFavPage,
    updatePlugin, hideScanBanner, ...) — they resolve against window at CLICK
    time, but live in a JS string, so scanning the HTML alone never finds them
  - static/v3/*.js (showScreen alone has 17 consumers), capabilities
  - feedback-desktop and the external plugin repos — easy to miss, they live in
    other repos and no core test covers them
  - capabilities/visualization.js reads window.setViz behind a `typeof` guard,
    so losing it DEGRADES IN SILENCE rather than throwing

Constitution II names window.playSong / window.showScreen / window.feedBack as
the public extension contract, so this is an obligation, not a convenience.

FOUR names are invisible to every static tool. app.js:2156-2157 picks the
handler NAME at runtime —
    const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
— and interpolates it into `onclick="${letterFn}('A')"`. The names exist only
inside string literals, so ESLint, no-undef, and any grep for `onclick="fn` all
miss them. They are the library A-Z rail and its pagination: drop one and those
buttons throw at click time and nowhere else.

New tests/js/window_contract.test.js scrapes the HTML's handlers AND app.js's
template-literal handlers, and pins the 4 runtime-composed names by hand.
Verified to BITE: dropping showScreen, goTreePage, or setViz each fails it with
the right message.

On-device: 28 A-Z rail buttons render with their real onclick sources
(filterTreeLetter('A'), ...) and 8/8 execute with no ReferenceError; all 66
names resolve on window in the browser.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:09:18 +02:00
4b4c156fce refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (R3a) (#872)
Puts every external `<script>` in the v3 shell into the deferred queue, and
keeps each script's boot() firing at DOMContentLoaded exactly as it does today.
Behaviourally a no-op; it is what makes the ES-module flips safe.

WHY. `type="module"` defers execution to after HTML parse. Classic-`defer` and
module scripts share ONE "execute after parsing" list and run in DOCUMENT ORDER,
but a plain classic script runs DURING parse — ahead of all of them. So the
moment capabilities.js becomes a module while app.js is still plain, app.js runs
FIRST, and its 11 top-level `window.feedBack.on(...)` calls (app.js:6245-6722)
hit a bare `{}` — `_ensureFeedBackEventBus()` (capabilities.js:33), which
attaches .on/.emit/.off, would not have run yet. TypeError, app.js dies
mid-parse. Deferring everything now keeps document order == execution order
through the rest of the migration.

THE CATCH (Codex preflight caught this — a real ordering change). 22 scripts
guard their boot with `if (document.readyState === 'loading')`. A deferred
script runs at readyState 'interactive', so that test is FALSE and the else-branch
fires boot() immediately, at the script's position in document order — instead of
at DOMContentLoaded, after every script has evaluated.

That matters far more than one call site: a scan of the shell's scripts found
**43 forward references** where a script's boot() reads a global that a LATER
script defines (shell.js -> profile.js's window.v3Onboarding, songs.js ->
settings.js's window._confirmDialog, badges.js -> songs.js's
window.displayTuningName, ...). Every one of them resolves today only because
all boots happen at DOMContentLoaded. So the guards now treat 'interactive' as
not-ready (`!== 'complete'`), restoring that exactly.

Codex's specific finding (first-run onboarding silently skipped) did NOT
reproduce — shell.js's boot() awaits /api/profile, and that yield lets the
remaining deferred scripts run first. But the race it described is real, the
guard is silent when it fails (`&& window.v3Onboarding`), and the other 42
forward refs have no such await protecting them. Fixed at the root rather than
at the one site.

VERIFIED. A/B against origin/main on a fresh profile, 13 probes (onboarding
overlay, v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen,
bus, capabilities.version, createHighway, plugin scripts, mounted screens):
IDENTICAL, zero console/page errors on both. pytest 2396, node 1028/1028,
ESLint 0 errors, Codex 0.

New guard: test_every_external_script_defers_so_document_order_is_execution_order
fails if any external tag is plain classic — verified to fail on a single
reverted tag, so it actually bites.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:57:04 +02:00
9d0bf95716 refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a) (#871)
* refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a)

Deletes `static/index.html`, the `/v2` route, and the `FEEDBACK_UI` v2/legacy
opt-out. `/` and `/v3` both serve `static/v3/index.html`, which has been the
default since 0.3.0.

This is step 0 of the core-frontend ES-module migration (R3a). Both shells load
the same `static/app.js`, so every later step of that migration — exposing the
window contract, the `defer` ordering fix, the `type="module"` flips — would
otherwise have to be made and verified twice. Removing the fallback now halves
that surface before any of it is touched.

Incidentally fixes a latent bug in `index()`: its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value — so `FEEDBACK_UI=v3`
actually served the **v2** shell.

- `static/tailwind.min.css` regenerated: the content globs scanned the deleted
  file, so v2-only utility classes are now purged (CI's tailwind-fresh job
  rebuilds and diffs it).
- Constitution amended to 1.3.0 — Principle II's frontend file list now names
  `static/v3/index.html`.
- Tests: 4 suites read the v2 shell (3 via a constructed `path.join` that a
  literal grep misses). Their v2 halves are paired duplicates of v3 tests that
  stay, so they are dropped; `alpha_warning_banner` and the capability-registry
  script-order test retarget to `static/v3/index.html`.

BREAKING CHANGE: `FEEDBACK_UI=v2` / `=legacy` and the `/v2` route are gone.
Unset the variable and use `/`. No chart, settings, or plugin data changes, and
no plugin API changes — v3 reuses the same engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop the stale '/ is v2' plugin-verification guidance (CodeRabbit)

The v3-only rewrite updated the intro paragraphs but left three lines that
still instructed plugin authors to verify in 'both / (v2) and /v3' — now the
same shell. Historical 'in v2 it was X' contrasts are kept: they still orient
authors whose plugins also ship to users on older cores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:33:03 +02:00
Byron GamatosandGitHub f00ba2217d Revert "feat(audio): loopback feeder mode — all app audio under exclusive/ASIO (#865)" (#867)
This reverts commit 9a58a55fe8.
2026-07-11 14:56:35 +02:00
9a58a55fe8 feat(audio): loopback feeder mode — all app audio under exclusive/ASIO (#865)
* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close loopback capture context on teardown (release tap worklet)

The loopback context was reused across engages (_lbCtx || new), but teardown
only stopped the stream + deactivated the tap — never closing the context or
detaching the worklet node. Each exclusive<->shared switch orphaned a live
tap worklet on the long-lived context. Use a fresh context per session and
close it on disengage. Adds a test asserting the context is closed on teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-11 14:54:40 +02:00
76159c16cd feat(diag): --debug ASIO routing diagnostics in static bundle (#852)
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(diag): --debug ASIO routing diagnostics in static bundle

Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug);
inert in the Docker sphere and normal desktop runs.

- [asio-diag] getCurrentDevice= full device object on outputType change
  (catches ASIO drivers reporting a non-'ASIO' type name)
- [asio-diag] renderer-bus: full feeder decision vector, change-gated
  (running/exclusive/stems/juceMode/elementSong/want/mode)
- [asio-diag] setSink: every sink flip with ctx state + rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:42:41 +02:00
1c1a0e0268 feat(audio): renderer-bus feeder — song audio into engine output under exclusive mode (Phase 2) (#828)
ship-ci / ci (push) Waiting to run
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:51:09 +02:00
1b3178037b feat(audio): route feedpak full-mix natively under exclusive output (#824)
Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:00 +02:00
Byron GamatosandGitHub 950e348357 R0: module-migration rails (src/ serving, live-edit cache, scriptType loading, governance) (#812)
ship-ci / ci (push) Has been cancelled
Host enablement for the plugin ES-module migration: sandboxed /api/plugins/{id}/src/ serving, no-cache+weak-ETag/304 live-edit caching on src/+screen.js+assets, scriptType:module loader injection + scriptType/minHost manifest passthrough; constitution v1.2.0 + module playbook + signed size-exemptions register + maintainer/CI-only ESLint gate; rerunnable perf-baseline harness. Reviewed by Codex (local), Copilot, and CodeRabbit.
2026-07-08 10:14:40 +02:00
a18a818e8b fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B (#811)
ship-ci / ci (push) Waiting to run
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): note loop-api bridge throttle fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:00:58 +02:00
fadaa154e9 feat(library): sort and badge by personal difficulty rating (#810)
* feat(library): sort and badge by personal difficulty rating

Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(library): escape difficulty badge, wire tree view, add changelog+tests

- Wrap song.user_difficulty in esc() at both badge call sites
  (static/app.js ~2082 and ~2283) for XSS-consistency with the
  sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
  by /api/library/artists) never batch-attached user_difficulty the
  way query_page does for the grid, so the tree-view difficulty badge
  added in 75673c3 was unreachable dead code (song.user_difficulty was
  always undefined there). Now attaches it via the existing
  user_meta_map() helper, same pattern as query_page.
- Add an [Unreleased] CHANGELOG.md entry for the difficulty sort +
  badge feature, matching the repo's existing entry format.
- Add tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom
  asserting unrated songs sort to the bottom in both sort=difficulty
  and sort=difficulty-desc directions, and
  ::test_tree_view_songs_carry_user_difficulty covering the
  query_artists fix above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): chunk user_meta_map + rebuild stale tailwind css

Address review-bot findings on the difficulty sort/badge:
- user_meta_map now chunks filenames into 400-row batches (like
  overrides_map) before the IN (...) query. query_artists (tree view)
  passes every song across up to 50 artists, which could push the
  placeholder count past SQLite's older variable limit; query_page's
  small pages are unaffected. (CodeRabbit: Stability & Availability)
- Rebuild static/tailwind.min.css: the ◆N difficulty badge introduced
  bg-blue-900/30 + text-blue-300, which were never compiled into the
  committed stylesheet, failing the tailwind-fresh CI gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:20 +02:00
021ee55f2a Allow .feedpak files in library when uploading (#770)
Signed-off-by: Rob Sassack <rsassack25@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 10:05:27 +02:00
ChrisBeWithYouandGitHub 4f6dc233f1 feat(player): seed editor region handoff state (#762)
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-05 23:37:39 +02:00
de002cdc24 feat(highway): add "Colorblind (deuteranope)" string-color preset (#788)
Adds a one-click preset to the shared "Highway String Colors" picker,
next to the existing Okabe-Ito "Colorblind-friendly" preset. Contributed
by a deuteranopic player who found the Okabe-Ito set still hard to
separate: it retunes the six main strings and keeps that set's 7/8-string
colors. Applies to both the 2D and 3D highways via the shared picker,
which writes the slot->hex map the renderers already consume.

Additive frontend-only change to HWC_PRESETS in static/app.js; the picker
UI and both highways pick it up automatically (the preset list is
generated from HWC_PRESETS and applied by id). All 20 highway
string-color JS tests pass; app.js syntax-checks clean.


Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:13:13 +02:00
a86abadb14 settings: add host instrument profiles (#753)
* settings: add host instrument profiles

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* settings: add instrument pathway selection

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5

Five regressions from the instrument-profiles rework:

1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST
   froze default profiles into config.json (broke
   test_empty_post_preserves_all_existing_keys). Gate on the save touching
   instrument settings; GET already virtualizes profiles.
2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a
   no-op. reset_settings now resets pathway inside the persisted profiles too.
3. Per-profile tuning validation rejected provider/custom tunings (tuner
   plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to
   every built-in table while still rejecting a built-in misapplied to the
   wrong key.
4. First-migration overwrote an explicit active_instrument_profile with the
   legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use
   setdefault so an explicit request wins.
5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a
   4-string tuning). Updated to the valid 'Drop A'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch

Two partial-update follow-ups:
- save_settings normalized a POSTed instrument_profiles by FILLING every omitted
  profile with defaults and replacing wholesale, so a one-profile update reset
  the others. Validate each PROVIDED profile individually and merge the partial
  over the persisted set inside the lock — /api/settings is partial-merge.
- the string-count picker posted only string_count, so the backend silently
  reset a now-invalid tuning to Standard while the UI kept the old value
  (settings/tuner desync). Clamp + post the valid tuning too, mirroring the
  instrument-switch path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:16:42 +02:00
68e29a8b6e fix(plugins): don't treat transient absence from /api/plugins as uninstall (#741)
* fix(plugins): don't treat transient absence from /api/plugins as uninstall

The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update idempotence contract to the absence-is-not-uninstall invariant

The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:30 +02:00
d2b2a7e9f7 fix(tests): re-green the JS suite — 18 stale source-shape tests + 1 real seek-reason violation (#740)
main's JS suite has been red since the recent v3-library and player
refactors landed. 17 of 18 failures were test harnesses/regexes that
went stale behind real, intentional code changes; one was a genuine
contract violation in the code.

Code fix:
- session-resume seek passed 'resume' as its _audioSeek reason; the
  documented contract (enforced by song_seek.test.js) requires
  multi-word kebab-case. Renamed to 'session-resume' — no consumer
  string-matches specific reasons, so this is rename-safe.

Test updates (each pins the CURRENT contract):
- highway_colors_facade: inject HWC_PRESETS + applyHighwayStringPreset
  (new preset feature); lock presets/applyPreset into the surface test
- loop_api: stub _updateEditRegionBtn (new edit-region UI hook)
- song_close: sandbox gets window.feedBack.playQueue; assert a real
  close abandons the queue (the new queue-aware behavior)
- v3_keep_practicing: the shelf moved from client-side /api/stats/recent
  dedupe+gating to the server-side practice-suggestions recommender —
  tests now pin that (fetch, arrangement-aware card click, Promise.all)
- v3_songs_tuning: card row variable renamed song → shown (grouped cards)
- live_guitar_tone_source: accept literal ’ where &rsquo; drifted in copy
- legacy_shim_hits: normalize CRLF before fixed-width region() slicing
  (Windows-only failure; char windows shrank by one char per line)

Suite: 987/987 locally (Windows), previously 968/987 (and 18 red on CI).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:35:07 +02:00
OmikronApexandClaude Fable 5 425f72b33f feat(v3): playlist shuffle toggle
Crossing-arrows toggle next to Play all / Play album on the playlist
detail page. When on, playQueue.start Fisher-Yates-shuffles the queue
once at start — on a copy, so the stored playlist order is untouched —
swapping per-slot album arrangements in lockstep so each slot keeps its
pinned arrangement (#685 contract preserved). Prev-less queue semantics
are unchanged: auto-advance simply walks the shuffled order.

Preference is global, persisted as localStorage v3PlaylistShuffle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:07:47 +02:00
28b0319e27 play-queue: peekNext() — expose the following track for queue-aware UIs (#719)
A results screen that offers "Up next: <song> — starting in 10s" needs to
know WHAT follows without reaching into queue internals. peekNext() returns
{filename, index, total} for the next track (null when nothing follows),
pure — peeking never plays or mutates.

First consumer: the note_detect results card's queue-advance strip (the
"Playlist Play All has no way to progress" tester issue).


Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:50:56 +02:00
OmikronApexandClaude Fable 5 59aa70ce5a perf: remove throttled-trace residuals — program churn, per-frame rect, HUD clock
A 4x-CPU-throttled retrace (the honest weak-hardware proxy) surfaced
three residual per-frame costs; stack attribution pinned each:

- getParameters/getProgramCacheKey (~4% of main thread): every pooled
  label sprite map swap set material.needsUpdate, bumping
  material.version and forcing full program re-resolution next render.
  Swapping between two non-null cached textures never changes the
  compiled program (USE_MAP define unchanged) — new _setLabelMap()
  helper only flags needsUpdate on a null<->texture transition, used at
  all 7 swap sites.
- getBoundingClientRect (~1.2%): the 3D highway's per-frame canvas-size
  self-check forced a layout read every frame. The CSS-box drift read
  now runs every 10th frame (or when the wrap isn't pinned); the
  backing-store comparison stays per-frame with cheap property reads
  and forces an immediate box read + applySize when it fires.
- set textContent: the core 60 Hz HUD clock rewrote hud-time (and
  getElementById'd it) every tick for a display that changes 1/s — now
  write-on-change with a cached element ref.

(The remaining textContent writer in the trace is notedetect's
badges.js — external repo, to be filed there.)

tests/js: resize-reframe shape test updated for the hoisted _bsChanged
gate, incl. an assertion that the throttle can never delay the
backing-store path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:40:52 +02:00
41cdfd576a feat(v3): play a playlist as a queue with auto-advance (#685)
Playing a playlist previously played one song and returned to the menu -- a
play-queue was never implemented. Add window.feedBack.playQueue (start / advance
/ hasNext / clear) and a "Play all" button on the playlist detail.

Advancing rides the same exit choke point as auto-exit and a results-card close:
song-end paths call window.closeCurrentSong() (the auto-exit grace timer and a
results screen's release()), so wrapping it plays the next track instead of
returning to the menu -- advancing AFTER the user dismisses a score card, not
through it. A user-initiated exit (Escape / the close button) uses the bareword
closeCurrentSong(), left untouched, so leaving the player still leaves and
abandons the queue. playSong gains a fromQueue guard (a manual play abandons a
stale queue) and closeCurrentSong clears the queue on a real close. Binds via
song:ended / the choke point, not the <audio> element, so it advances on the
desktop (JUCE) route too. The no-queue path is unchanged.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:55 -05:00
115c96a3f0 feat(tuner): gate playback until you've tuned — hold autoplay + no-trap Skip/Back/Esc (working-tuning PR 4) (#666)
* feat(tuner): gate playback until you've tuned — hold autoplay + no-trap Skip/Back/Esc (working-tuning PR 4)

When the opt-in auto-open fires because a song needs a different tuning, playback
now WAITS behind the tuner instead of starting underneath it — the "tune before
you play" model. Built on a new generic core hook window.feedBack.holdAutoplay()
(mirrors holdAutoExit): the tuner claims the hold synchronously on song:loading
(beating the song:ready autostart) and releases it — or a 12s fail-open backstop
does — so a wedged plugin can never strand a song. Generation-guarded; manual
Play always wins.

No one-way trap:
- Skip = "I've tuned" -> plays and records the song's tuning as the instrument's
  current working tuning (the explicit write-point PR 3 left as 'assumed').
- Back to library / Esc -> leave the song, record nothing (reuses requestExitSong;
  Esc is the existing player shortcut).
- The in-panel x is dropped for an auto-open — Skip/Back/Esc are the dismiss
  surface. This also keeps the write honest: Skip is the only on-player dismiss
  that records, so leaving never falsely records a tuning.

Stacked on #660 (working-tuning PR 3). Core app.js gains only the generic hook
(a test asserts it never references the tuner's internals); shell-agnostic.

Needs a desktop smoke-test that the tuner mic doesn't contend with note_detect's
scoring input under ASIO/exclusive mode (per the design charrette).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(tuner): backstop can't cut off tuning + gate race/token hardening (PR #666 review)

Review fixes for the autoplay gate:

- The 12s fail-open backstop could start playback UNDER a legitimately-open tuner
  (a slow / mic-verify retune > 12s). holdAutoplay()'s release now carries a
  .settle() that cancels the backstop; the tuner calls it once the tuner is
  confirmed open (_gateClaimed), so the hold becomes deliberate and only a
  dismiss / song switch releases it. (Fail-open still covers "claimed but wedged
  before deciding".)
- The async song:ready handler could release a NEWER song's gate after its await
  (global _gateClaimed, no guard). It now snapshots _autoOpenGeneration and bails
  if a newer song took over.
- holdAutoplay guarded by song generation, not per-hold — a stale release from an
  earlier hold could clear a later one. Each hold now mints a unique token that
  release()/settle() must match.

Tests: source-level assertions for the token, settle(), the settle-on-open call,
and the song:ready gen-guard. 45 tuner+speed tests green. Codex-reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 11:18:29 +02:00
199550e5fb fix(v3): dismiss Section Practice popover when another player popover opens (#638)
* fix(v3): dismiss Section Practice popover when another player popover opens

The Section Practice popover (Songs > Song > Practice pill) stayed open
when the user then clicked a v3 player-rail icon (Plugins, Audio, …),
leaving two popovers stacked on top of each other. Reported on 0.3.0
(macOS) and still reproducing in the 2026-06-28 build.

Root cause: the popover's outside-click dismiss was bound in the
bubbling phase, but the v3 rail's icon buttons call e.stopPropagation()
in their click handler (player-chrome.js wireRail), which kills bubbling
before the click reaches document. So the dismiss listener never fired
for a rail-icon click and the popover was orphaned open.

Fix: bind the outside-click dismiss in the capture phase, which runs
before the target's handler so stopPropagation() can't swallow it. This
mirrors the audio mixer popover (audio-mixer.js), which already
dismisses outside-clicks via capture-phase listeners for exactly this
reason. Esc handling stays in the bubble phase (no rail handler stops
keydown propagation, and capturing it would reorder it ahead of the
player's Escape-to-exit handling).

Shared app.js code, so v2 is covered too; v2 has no stopPropagation rail,
so its outside-click dismiss behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* chore(#638): add CHANGELOG entry + capture-phase regression test

Review follow-ups for the Section Practice popover dismiss fix:
- CHANGELOG [Unreleased] → Fixed entry (repo workflow requires one).
- tests/js/section_practice_dismiss.test.js pins the fix: the outside-click
  dismiss binds in the CAPTURE phase (so a rail icon's stopPropagation can't
  swallow it), exactly one capture binding (Escape keydown stays bubble-phase),
  and the #section-practice-control containment guard (no self-close). A revert
  to bubble-phase fails the test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 15:00:28 +02:00
Byron GamatosandGitHub 5a0b62599d feat(highway): show feedpak author/editor credits on song load (#629)
Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start.

Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end.
2026-06-28 22:08:44 +02:00
b103a722ce fix(v3): refresh Songs grid after a Settings rescan / DLC-folder change (#624)
Reported on macOS: on a fresh install, pointing at a DLC folder in Settings and
running a scan showed NO songs until an app restart. The scan itself was fine —
_background_scan re-reads config.json fresh, so it scans the new folder and
populates the library — but the v3 Songs grid never reloaded.

The Settings Rescan / Full Rescan handlers only refreshed the classic (v2)
library via loadLibrary(); the v3 grid (static/v3/songs.js) had no listener for
a scan it didn't initiate (only its own upload path self-refreshes via
watchUploadScan). So its cached, pre-DLC (empty) DOM/snapshot survived a sidebar
return until a full reload (restart).

Fix: the rescan handlers now emit `library:changed` (static/app.js). The v3 grid
listens and reloads if it's the active screen, else sets `_libraryDirty` so the
next onV3SongsScreenEnter does a full re-fetch — a short-circuit placed ahead of
every cached-DOM fast-path so it can't restore the stale grid.

Tests: tests/js/v3_library_refresh.test.js guards the emit + the reload/dirty
wiring (DOM/event glue isn't headlessly unit-testable; end-to-end wants an
in-app run of the reporter's flow: set DLC in Settings → scan → Songs populate
without restart).


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:52:48 +02:00
d841813e0b fix(library): Edit Metadata modal — editable Year + don't close on drag-release outside (#623)
* fix(library): Edit Metadata modal — editable Year + no close on drag-release

Two fixes to the Songs -> Edit Metadata modal (openEditModal/saveEditModal in
static/app.js), both reported on macOS for 0.3.0.

1) Year is now editable. A year can be set when authoring a pak but the modal
   had no Year field, so it could never be changed. The backend
   (POST /api/song/<f>/meta) already accepts + normalizes `year` and writes it
   into the file via songmeta (survives a rescan) -- only the UI omitted it.
   Add a Year input (populated from the song's current year) and include
   `year` in the save POST body. Both the v3 card menu and the legacy edit
   button already pass the year through, so both surfaces get the field.

2) The modal no longer closes when a click-drag is released on the backdrop.
   Selecting text inside a field and releasing the mouse past the modal edge
   dismissed the form without warning (the `click` event's target resolves to
   the backdrop, the common ancestor) -- discarding the edit. Backdrop
   dismissal now also requires the mousedown to have STARTED on the backdrop,
   tracked per-modal and decided by a new pure helper
   _editModalShouldClose(clickTarget, modalEl, downOnBackdrop). Cancel / X
   still close on a normal click.

Tests: tests/js/edit_metadata_modal.test.js extracts the real functions from
app.js and asserts (a) openEditModal renders #edit-year, (b) saveEditModal's
meta POST body carries `year`, and (c) the backdrop-close decision table
(Cancel always closes; backdrop needs down+up on the backdrop; a drag from a
field released on the backdrop does NOT close).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(library): wire Edit Metadata Save via listener, not an inline onclick

encodeURIComponent does not escape "'", so embedding the filename in the
single-quoted inline onclick="saveEditModal('…')" handler produced a
malformed handler for any song whose filename contains an apostrophe
(e.g. Bob's Song.sloppak) — clicking Save threw a syntax error and the
edit silently failed. Replace the inline onclick with a data-edit-save
hook wired in JS from the closure filename (mirrors the existing Delete
button pattern), so the filename never has to survive attribute-string
embedding. Pre-existing bug surfaced during review of this modal.

Adds a regression assertion (no inline saveEditModal onclick; Save wired
via data-edit-save).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:42:02 +02:00
fef870047b fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit

Escape didn't always leave a song: clicking a transport control (play/FF/RW/
restart) left that <button> focused, and _shortcutDispatchBlocked() bails the
shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the
player-scope Escape=Back shortcut never fired until the user clicked empty
canvas to blur the control. Space already had a player-screen carve-out (#593);
Escape did not. That asymmetry was the bug.

Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked
to Escape, on the player AND settings screens (both register Escape=Back;
settings had the identical latent bug). The earlier guards still win: text
inputs are exempted first, the Section Practice popover already claims Escape,
and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it.
Plugins' player-scope Escape shortcuts are fixed identically.

Phase 2 — resume: leaving the player snapshots {song, arrangement, position,
speed} to localStorage; a non-blocking "Resume practice" pill offers it back on
the next non-player screen / next launch. playSong() gains a {resume} option
that restores speed + seeks to the saved position on song:ready instead of the
normal autostart. Conservative (ignores <3s / near-end), cleared on natural
song-end and once consumed, expires after 24h.

Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A
true-modal confirm with monotonic Escape (the second Escape leaves) and
Space/Enter = Leave. The player Escape shortcut and the v3 close button route
through window.requestExitSong(); auto-exit on song-end and a results screen's
own Close stay unguarded.

Design rationale: a multi-seat design charrette (engagement, learning-design,
operability, codebase-reality) — leaving a song should be reliable and
recoverable, not gated; the confirm is opt-in only.

Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* test(browser): suppress first-run onboarding in keyboard/resume/exit specs

The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts
pointer/keyboard events; on a fresh profile it covers the player and breaks any
test that presses Escape or clicks. Stub GET /api/profile to an onboarded
profile in each beforeEach so the app behaves like a returning user (the state
these tests assume).

Also tighten the Section Practice Escape test to assert the guarantee the fix
actually provides — Escape does not exit the song while the popover is open (the
line-447 guard wins over the carve-out) — rather than asserting the popover's
own close handler fires, which isn't wired for a synthetic bar.

Verified locally against a worktree server (Chromium): all 16 new specs pass
(5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay

Refinements from tester feedback on the exit-confirm (default stays OFF):

- Escape on the open prompt now = Stay (dismiss + return to the song), matching
  every other modal and the generic _confirmDialog (Esc=cancel). A second
  Escape therefore returns to the song instead of leaving it. Leaving stays the
  explicit, default-focused "Leave" button, so Space/Enter/click = "just get me
  out" (the OP's "Space always hits leave").
- Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5
  + _juceMode) so it isn't running/being scored behind the modal; Stay resumes
  exactly what we paused. Guards: cancel any count-in on open; resume only if we
  paused (wasPlaying), only if still the same live song on the player
  (_audioSeekGen unchanged), and never auto-resume a song the user had paused.
- Trap Tab inside the dialog; backdrop click was already Stay.

Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test
becomes "second Escape stays", plus a backdrop-click-stays test. The audio
pause/resume itself is verified manually on web + desktop (the mock song has no
backing track); these specs lock the navigation + keyboard semantics.

NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE
transport (known play/pause-desync path) — smoke-test on the desktop build
before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): accurate exit-confirm copy + keep resume snapshot on failed load

Two review follow-ups on the Escape/resume/confirm work:

- Settings copy said "a second Escape (or Space/Enter) still leaves",
  but Escape dismisses the confirm (Stay) like every other modal — only
  Space/Enter/Leave exit. Corrected the Gameplay-tab description so it
  matches the implementation (and the committed exit-confirm specs).

- resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so
  a transient load/connect failure permanently lost the Resume pill with
  no retry. Clear only after the load resolves; on failure keep the
  snapshot (and drop the pending in-memory resume) so the pill re-offers
  it on the next non-player screen.

All 16 Escape/resume/exit-confirm Playwright specs still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:32:33 +02:00
3b2d83d406 feat(folder_library): Folder Library core plugin (#610)
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk
folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter,
live search), wired into the classic v2 toolbar and the v3 Songs page.

Includes the screen.js IIFE dedup (unified surface factory) and review fixes:
path-traversal guard on /song/move, folder-delete data-loss fix, plural
/api/plugins/<id> namespace, loose-folder song recognition, error-text escaping,
v3 setLibView null-guard, and tests.

Co-authored-by: Kyle <kyle.j.t@live.co.uk>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:03:54 +02:00
d1f7f12293 fix(v3): add "Show 'Up Next'" toggle so the player pill can be turned off (#612)
The v0.3.0 player chrome's persistent upcoming-section pill (#v3-upnext,
drawn by static/v3/player-chrome.js's updateUpNext) shipped with no off
switch: it always showed during playback whenever a section was upcoming,
overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up
Next' section card" checkbox (a different, in-canvas widget demoted to
default-off precisely because this pill is the canonical readout). Users
reading the pill as that same setting saw "disabled in settings but still
there."

Add a real core toggle, following the autoplayExit idiom:
- static/app.js: client-only `showUpNext` localStorage pref (absence =
  enabled), _showUpNextEnabled()/setShowUpNext(), loadSettings()
  hydration, and a read-only window.feedBack.showUpNext getter. Disabling
  mid-playback hides the pill immediately.
- static/v3/index.html: a "Show 'Up Next'" switch in the Gameplay tab.
- static/v3/player-chrome.js: gate updateUpNext() on the pref.
- static/v3/settings.js: add showUpNext to RESET_MAP.gameplay.local.

Default ON, so behaviour is unchanged for existing users. v3-only (the
pill is v3 core chrome); no Tailwind rebuild.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:16:31 +02:00
6dbcc5861b fix(player): keep play/pause button in sync when a JUCE reroute aborts autoplay's play() (#611)
On the first song after a fresh load on desktop, the audio engine is often
still starting when the song loads, so the song begins on the HTML5 <audio>
element and the engine-reroute watcher then migrates it to the JUCE backing
transport. The reroute's first step is a deliberate audio.pause(), which
rejects autoplay's in-flight togglePlay() audio.play() with an AbortError —
even though playback continues on JUCE.

togglePlay()'s catch then reset isPlaying=false and the button to "Play"
while the song kept playing: the button showed Play during playback, so it
took two clicks to actually pause (one to resync the flag, one to pause).
The reroute already guards the <audio> 'play'/'pause' DOM listeners with
window._juceRerouteInProgress; this extends the same guard to togglePlay()'s
catch and the count-in catch, so a play() rejection caused by the reroute's
own pause doesn't clobber the button. A genuine failure (outside a reroute)
still resets correctly.

Adds a regression test that drives togglePlay() through a reroute-aborted
play() and asserts the button stays Pause; it fails without the guard.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:16:28 +02:00
97dae88860 feat(highway_3d): colour theming — string presets + Background/Highway scene themes (#596)
* feat(highway_3d): add one-click string-color presets

Adds 12 named string-color presets (Warm→Cool, Vivid, Colorblind-friendly,
Neon, Accessible, Warm Ember, Tape Deck, CRT Green/Amber, Pitch Ramp, Sunrise)
selectable from the 3D Highway settings panel.

Extends the existing core HWC (highway-color) subsystem in static/app.js with
HWC_PRESETS + applyHighwayStringPreset(), exposed on the existing facade as
window.feedBack.highwayColors.{presets, applyPreset}. The plugin settings page
renders the preset buttons from that core list and refreshes the per-string
pickers on apply. Purely additive — stock behavior is unchanged.

Scope: core static/app.js (the shared HWC facade both highways consume) plus the
highway_3d plugin's settings.html / screen.js / CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): address review of colour-theming PR

- Rebuild assets/plugin.css so the new `flex-wrap` (preset row) and
  `text-[10px]` (theme-dropdown helper) Tailwind classes are actually
  compiled, and bump plugin.json 3.26.0 -> 3.27.0 so the <link>'s ?v=
  cache-buster fetches the fresh CSS (per the plugin's build rule).
- Replace the mirror-at-every-read hwTheme migration with a one-time
  backfill (persist hwTheme := bgTheme on first load, no emit). The two
  scene-color axes are now genuinely independent: changing the Background
  dropdown no longer silently retints the Highway surface/lane, and the
  rendered highway can't disagree with the Highway dropdown value.
- Collapse the duplicated theme id-set in settings.html (two identical
  <option> lists + VALID_BG_THEMES) into a single SCENE_THEMES source the
  dropdowns and validator are generated from; sync points 4 -> 2.
- Update CLAUDE.md to document the backfill + reduced sync contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-26 11:05:31 +02:00
b70fde9b02 fix(player): new song no longer seeks to previous song's stop position (#595)
audio.currentTime does not reset synchronously when audio.src is cleared
— it only resets when audio.load() is called (later, in highway.js).
The jump-fix guard (setInterval ~line 8979) held lastAudioTime at the
old position and, once the new song started playing from t=0, saw a 30s+
jump and sought the new song to the previous position. If the new song
was shorter, song:ended fired immediately, showing the score screen.

Reset lastAudioTime = 0 in playSong() so the guard has no stale anchor.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:05:52 +02:00
82db8e56b1 chore(hotkeys): remove sloppak-convert library hotkey (#594)
* chore(hotkeys): remove sloppak-convert library hotkey

Removes the 'c' keyboard shortcut for converting library entries to
.sloppak. The shortcut was defined in two places:

- The no-op registerShortcut() entry that only existed to show in the
  ? help panel (the Sloppak Converter plugin handles conversion and
  can register its own shortcut via window.registerShortcut).
- The c dispatch in the library-entry keydown handler
  ({ c: 'button.sloppak-convert-btn', ... }) that triggered the
  plugin button.

* test+docs: update tests & CHANGELOG for removed `c` convert hotkey

The previous commit removed the `c` library hotkey but left three
assertions in tests/browser/keyboard-shortcuts.spec.ts that require it,
which fail deterministically (the two registry tests read window._panels
directly, independent of environment):
- should list all registered shortcuts (required {key:'c',scope:'library'})
- should have correct shortcut scopes (expected library::c)
- should show library shortcuts in help modal (Convert library entry / c)

Drop those assertions and record the removal under CHANGELOG
[Unreleased] -> Removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:24:31 +02:00
64801d5735 fix(player): Space bar play/pause when focus is on sidebar or rail buttons (#593)
* fix(player): Space bar play/pause when focus is on sidebar or rail buttons

When any <button> in the player rail (viz, audio, mixer, etc.), a sidebar
nav link, or a popover control has keyboard focus, pressing Space was
blocked by _shortcutDispatchBlocked → _isInsideInteractiveControl, which
returns true for BUTTON elements. The Space shortcut never reached the
shortcut dispatcher and togglePlay() was never called.

The fix extends the same carve-out pattern already used for the section
practice bar: when the player screen is active, Space is always dispatched
through the shortcut system. The shortcut handler's preventDefault() stops
the focused element from also activating, so this is not a double-trigger.

* test(player): cover Space play/pause carve-out + add CHANGELOG entry

Adds two Playwright regression tests for #593 in
tests/browser/keyboard-shortcuts.spec.ts:
- Space toggles play/pause when a player rail <button> has focus, and
  the focused button does NOT also activate (dispatcher preventDefault).
  Fails on base (Space blocked, played=0), passes with the carve-out.
- Space in a player-screen text input still types a space and never
  reaches play/pause (locks the _isTextInput exemption ordering).

Also records the fix under CHANGELOG [Unreleased] -> Fixed, per the
project workflow that every PR updates the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(player): don't override Space inside modal dialogs over the player

The player-screen Space carve-out keyed off the active *screen*, so it
also hijacked Space inside a true modal dialog layered over the player
(e.g. the keyboard-shortcuts help modal, edit modal): Space toggled
playback behind the modal and preventDefault blocked the modal's focused
control (Close) from activating — contradicting aria-modal semantics.

Narrow the carve-out to skip focus inside a modal
(role="dialog" aria-modal="true" or .feedBack-modal). Non-modal player
popovers/toasts (loop A/B, arrangement pin, role=dialog aria-modal=false)
are not dialogs and stay covered, so the original fix is unchanged for
the cases it targeted. Adds a Playwright regression test (Space inside a
modal reaches the modal's button, not play/pause) and updates the
CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:12:55 +02:00
287c23a532 feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)
Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.

- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
  after song-directory / before paths — publishes only display name + earned
  Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
  settings.category): the same toggle + a "Remove me from the wall" button
  (POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
  _default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
  mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
  serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
  unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
  opted-in AND profile identity (reused player_hash); competency never
  enqueues (integration law).

Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:00:30 +02:00
3b485fe62b feat(v3): tabbed, card-row settings page + per-plugin settings category (#584)
Replace the single long scrolling v3 settings screen with a horizontal tab
bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins /
System) over card rows (icon + title + description, control on the right) with
a per-category Reset.

- static/v3/index.html: tab bar + card-row markup (ids keep hydrating through
  the unchanged app.js loadSettings()/persistSetting() path).
- static/v3/settings.js (new): tab switching + active-tab persistence
  (localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds
  reference from window.getAllShortcuts().
- static/v3/v3.css: plain CSS, no Tailwind rebuild.
- Per-plugin settings tab: new optional settings.category in plugin.json →
  plugins/__init__.py surfaces settings_category; app.js mounts each plugin
  <details> into #plugin-settings-<category> (fallback: Plugins tab).
  highway_3d ships category: "graphics".
- New gameplay settings: countdown_before_song (wired end-to-end, default off);
  miss_penalty + fail_behavior (persist-only stubs); "Note highway speed"
  surfaces existing master_difficulty.
- New POST /api/settings/reset clears whitelisted keys back to defaults.

Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest,
tests/browser/settings-tabbed.spec.ts. 179 passed locally.

Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main
(slopsmith→feedBack rename applied; settings-screen markup conflict resolved
in favour of the new tabbed layout — all prior setting ids preserved).

Closes #579

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:06:46 +02:00
7399a2ac63 Edit region: Loop-in-3D round-trip between player and Song Editor (#575)
* Add "Edit region" + Loop-in-3D handoff between player and Song Editor

Wires the player half of the Editor ⇄ 3D Highway region round-trip
(editor half is in feedback-plugin-editor).

Highway → Editor:
- New "✎ Edit region" button in the loop controls (v2 and v3) opens the
  Song Editor scrolled to the active A–B loop — or, when none is set, the
  section under the playhead (or a short window around it).
- A "↩ Editor" button appears after a Loop-in-3D handoff to return to the
  exact edit position you came from.
- Both are hidden unless the editor plugin is loaded (typeof
  window.editSong) and gated by _updateEditRegionBtn.

Editor → Highway:
- A one-shot song:ready listener consumes window._pendingHighwayLoop set
  by the editor's "Loop in 3D" button — after playSong()'s own clearLoop()
  has run — arming setLoop(a,b) over the region and auto-starting playback.
  Filename-guarded so a cancelled handoff can't arm a stale loop on an
  unrelated song.

Reuses the existing A/B loop API; no new looping engine. Buttons added to
both static/index.html (v2) and static/v3/index.html (separate file —
v2 markup doesn't carry over), using already-scanned Tailwind classes.

New globals editRegionInEditor / returnToEditorFromHighway; helpers
_resolveEditRegion / _updateEditRegionBtn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(loop-in-3d): use canonical window.feedBack namespace (post-#537)

The new song:ready loop-applier landed on the legacy window.slopsmith
alias because the branch predated the slopsmith->feedBack rename (#537).
Normalize it to window.feedBack like the rest of core; the alias would
have worked but leaves the lone slopsmith reference in the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-23 11:38:51 +02:00
af2949677a rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00
32127bc70b feat: save as .feedpak; discover and load both .feedpak and .sloppak (#553)
* feat: save songs as .feedpak; discover and load both .feedpak and .sloppak

The open song format was renamed sloppak -> feedpak (public spec lives in
the feedback-feedpak-spec repo), but the server still wrote and recognized
only `.sloppak`. The two are byte-identical on disk.

Read both suffixes everywhere songs are discovered, uploaded, and loaded;
writing the new `.feedpak` suffix is handled in the editor plugin repo. Keep
the internal `format` tag `sloppak` so existing feature gates (stems, drums,
keys) are untouched, matching the "internal rename not landed yet" stance.

- lib/sloppak.py: add FEEDPAK_EXT / SLOPPAK_EXT / SONG_EXTS; is_sloppak()
  now matches either suffix (covers all 7 callers).
- server.py: union scan glob over SONG_EXTS; widen loose-folder exclusion,
  settings DLC count, upload gate (_ALLOWED_SONG_EXTS) and zip-magic check;
  refresh user-facing messages to .feedpak.
- static: library format filter relabeled Sloppak -> Feedpak (value stays
  sloppak, matches both); badge text SLOPPAK -> FEEDPAK in v2 + v3;
  filename-suffix detection and upload drag-drop filter accept both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* test: cover .feedpak/.sloppak dual-suffix support

Add tests/test_feedpak_extension.py pinning the four paths PR #553
widened so a refactor can't drop .sloppak back-compat or stop
accepting .feedpak:

- is_sloppak / SONG_EXTS suffix detection (file + dir form, case-insensitive)
- _background_scan discovery glob unions over both suffixes
- POST /api/songs/upload accepts both, rejects wrong suffix + non-zip
- save_settings DLC count includes both suffixes

19 tests, all passing; reuses the existing scan_module / TestClient /
isolate_logging fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-22 20:47:56 +02:00
4dc5936712 feat(player): global autoplay & auto-exit option (songs + lessons) (#558)
* fix(v3): pedal click opens the plugin's screen, not its settings

The v3 Pedalboard's settingsTarget() resolved settings-first, so a
plugin that ships both a screen and a settings panel (notably the
bundled Audio Engine) could only ever reach its settings from the
pedalboard — its actual page was unreachable.

Flip to screen-first (stompbox metaphor: step on the pedal, see the
pedal), falling back to settings when there is no screen. Keep a
settings fallback in openPluginSettings() when a declared screen
isn't mounted yet (installing/failed) so settings-bearing plugins are
never stranded on a toast. Drive the pedal aria-label off the same
target so it never promises the wrong surface. Update the unit test
contract to screen > settings > none.

Fixes #555

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(player): global autoplay & auto-exit option (songs + lessons)

Single Settings toggle (autoplayExit, default ON) that auto-starts a song
once it's ready and returns to the launching menu when it ends. Auto-exit
defers while a results/score overlay is on top (heuristic + holdAutoExit()
contract) so a scoring plugin's screen drives the exit. Player origin is now
context-aware (lessons return to the lessons screen via setReturnScreen()),
fixing lesson completion bouncing to the library.

Core-only; songs and lessons share the playSong -> highway path. Adds a
read-only window.slopsmith.autoplayExit getter + holdAutoExit()/setReturnScreen()
for plugins. Unit tests for the pure helpers (_autoplayExitEnabled,
_resolvePlayerOrigin, _resultsOverlayVisible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:04:03 +02:00