Commit Graph
100 Commits
Author SHA1 Message Date
byrongamatosandClaude Sonnet 4.6 930b492fa5 h3d-carve-11: extract R-section (string glow) into src/string-glow.js
VERBATIM-MOVE of updateStringHighlights from screen.js into a new
createStringGlow() factory. 7 DI-rewires at function entry (aliased
locals), plus 1 plain const shorthand. 8 DI params total, 0 setter
pairs, single export { updateStringHighlights }.

screen.js changes:
- Function definition (old 6610–6649) replaced with createStringGlow({…})
  factory destructure
- mStr confirmed absent from updateStringHighlights (plan row 11 stale;
  declared surprise in contract, accepted by god)
- VENUE_GEM_EMISSIVE_MUL passed as plain const shorthand (not a getter)

Tests (highway_3d_string_glow.test.js, 11 new, all green):
- Module shape + DI rewire source-scans
- Wiring-correspondence guard (createStringGlow, empty PINNED_RENAMES)
- 3 behavioral tests: emissive/opacity writes, venue multiplier,
  null-mesh slot safety — behavioral kill: gut loop → assert RED

Suite: 1317/1319 pass. Pre-existing failures #46 (legacy analyser)
and #639 (nut-labels) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 17:51:54 +02:00
byrongamatosandClaude Sonnet 4.6 4b2e4172c5 h3d-carve-10: extract K-section (score FX) into src/score-fx.js
VERBATIM-MOVE of the score-FX block from screen.js into a new
createScoreFx() factory. 7 DI-rewires only (getNdFrameNowMs,
getCam, getProbe, getNStr, getCurX, getHighwayCanvas × closure,
sY shorthand). No logic changes.

Four regions in screen.js replaced with factory callsites:
- State block (old lines 3619–3720): createScoreFx({…}) destructure
- drawScoreFx body (old 12564–12672): tombstone comment
- Init block (old 5839–5862): fxInit() callsite
- Teardown lines (old 12796/12814–12820): fxTeardown() callsite;
  _fxOnSkin removal moved into fxTeardown(), feedBack block
  restructured to contain only non-K-section listeners.

Tests (highway_3d_score_fx.test.js, 37 new, all green):
- Gut-audit: all 4 exports + _fxHandle + _fxSpawnPop paths
- Class-killers: fxTeardown listener removal, _fxGen increment
- Verbatim-declaration: no re-entry guard in fxInit (per dispatch)
- Wiring-correspondence guard: createScoreFx({…}) naming (cut-9
  pattern, empty PINNED_RENAMES)

Pre-existing failures: #46 (legacy analyser), #628 (nut-labels) —
both pre-date this cut, confirmed against 4a45ed8 baseline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 17:19:01 +02:00
byrongamatosandClaude Sonnet 4.6 f401127c20 test(h3d-carve-9): naming-correspondence guard for createCamera wiring (Creed r1)
Adds a structural source-scan test that verifies every entry in the
createCamera({...}) argument object satisfies its naming class:

  - Shorthand entries (plain consts, sY): key===value by definition, skipped.
  - Getter arrows (getXxx: () => [_]var): var stem (stripped of optional _)
    must equal key minus 'get' prefix (getCurX → curX, getProbe → probe).
  - Setter arrows (setXxx: (v) => { [_]var = v; }): same rule for set.
  - Fn-ref renames (freeCamFor: _freeCamFor, etc.): must appear in the
    exhaustive PINNED_RENAMES map; any unregistered key:value entry fails.

Kills the entire param-swap class (not just the CAM_H_BASE/CAM_DIST_BASE
instance Creed probed). Cut-13 additions inherit the guard automatically;
new fn-ref renames only need a one-line PINNED_RENAMES entry.

Mutation proofs (all RED before revert):
  (a) CAM_H_BASE: CAM_DIST_BASE, CAM_DIST_BASE: CAM_H_BASE → not ok 14
  (b) getCurX: () => curDist (wrong var stem) → not ok 14
  (c) bogusKey: _someOtherFn (unregistered rename) → not ok 14

Gut-audit: severing the violation check (violations.push suppressed) → the
assert.deepEqual(violations, []) always passes — so the push lines are the
live part; removing any one of the three class branches lets its mutation
class through silently.

Suite: 1272/1274 pass (same 2 pre-existing failures, +1 new test).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 16:55:49 +02:00
byrongamatosandClaude Sonnet 4.6 10c7ec8a9d refactor(h3d-carve-9): extract W-section (camera lerp) → src/camera.js
effectiveVfov + camUpdate (~192 lines) extracted from the screen.js IIFE
into a createCamera() ES-module factory in plugins/highway_3d/src/camera.js.
screen.js imports and destructures the return { effectiveVfov, camUpdate }.

DI surface (48 params): 22 plain constants, 11 getters, 5 getter+setter pairs,
5 fn-refs (DI-renamed: _freeCamFor→freeCamFor, _aspectPaneKey→aspectPaneKey,
_resolveTuneFor→resolveTuneFor, _aspectRegisterPane→aspectRegisterPane).
Peer ES imports at module level: computeBPM (geometry.js), _ssActive (utils.js).
Per-call write-backs: setCurX, setCurDist, setCurLookY, setTgtLookY,
setFretRowFitBoost (all write-backs confirmed by setter class-killer tests).

Test retargeting (54 tests across 4 files):
- highway_3d_wide_fov.test.js:   6 tests → cameraSrc; 4 regexes updated for
  DI-renamed fn refs (resolveTuneFor, aspectRegisterPane, aspectPaneKey/getPaneUid)
- highway_3d_camera_framing.test.js: 5 tests → cameraSrc; getTgtDist() regex fix
- highway_3d_camera_bootstrap.test.js: extractFn retargeted to cameraSrc;
  getTgtX() ordering-check fix; 2 new setter class-killers added (setCurX,
  setFretRowFitBoost)
- highway_3d_lefty.test.js: shoulder-offset test → cameraSrc + getLeftyCached()
- highway_3d_panel_controls.test.js: createCamera stub added

Bite proofs:
- Gut effectiveVfov in camera.js → wide_fov not ok 4 (RED) ✓
- Gut camUpdate body (H_NEAR lerp) → framing not ok 3 (RED) ✓
- Gut camUpdate body (curX+=) → bootstrap not ok 11 (RED) ✓
- Sever setCurX → bootstrap not ok 12 (RED) ✓
- Sever setFretRowFitBoost → bootstrap not ok 13 (RED) ✓

Suite: 1271/1273 pass; 2 pre-existing failures unchanged from cut-8 baseline
(#46 analyser fallback, #591 nut-labels — both in flight before cut 9).

plugin.json: 3.44.0 → 3.45.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 16:37:20 +02:00
byrongamatosandClaude Sonnet 4.6 4a45ed8782 test(fx): source-scan BG_DEFAULTS.nutColor fixture (Creed r2)
test 11 now extracts the real nutColor from screen.js via regex and
pins it against a literal ('#f5f3f0').  Three-layer guard:
  1. guard: BG_DEFAULTS literal is present in screen.js
  2. guard: nutColor key is extractable
  3. literal-pin: extracted value == '#f5f3f0' → fail loudly if
     production drifts (verified: mutating screen.js to '#deadf5'
     makes the test fail on the pin assertion; revert confirmed clean)
Fixture and fallback assertion both use PROD_NUT_COLOR (the extracted
value), so they track production automatically.

panel_controls stub updated: added _applyBloom to the createFx stub
(Toby r1 added _applyBloom to the return set; stub was one short).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 16:07:37 +02:00
byrongamatosandClaude Sonnet 4.6 1f3bde73d1 test(h3d-carve-8): add discriminating tests for _applyCinematic and _h3dHexOrDefault
Creed gap fix: two functions had no test that went RED when gutted.

_applyCinematic: tests both paths (cinematic=true and false) against
  fake ambLight/dirLight objects with intensity fields. Asserts all 4
  expected intensity values. Mutation `return` at entry → RED (4 fail).

_h3dHexOrDefault: tests valid hex parse ('#a1b2c3' → 0xa1b2c3), no-#
  fallback (regex requires #), gibberish fallback, and explicit defHex
  override. Mutation (return default always) → RED (valid-hex assertion fails).

Suite: 1269/1271 (same 2 pre-existing failures unchanged).

Note: from cut 9 onward, self-audit step added before reporting — every
moved function names which test kills it; no uncovered function ships.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:58:33 +02:00
byrongamatosandClaude Sonnet 4.6 d060c49238 test(h3d-carve-8): fix Toby r1 findings — _applyBloom extract + test 7 vacuity
F1 (MED — declared gap confirmed and widened):
  Extract _bloomEnsure's .then() body into named _applyBloom([EC, RP, UB, OP])
  at factory scope. _applyBloom added to return set and screen.js destructure.
  Test 9 calls _applyBloom directly with mock module objects (no import() needed).
  All 4 write-back mutations now RED:
    sever setBloomPass(bp)    → RED
    sever setBloomW(w)        → RED
    sever setBloomH(h)        → RED
    sever setComposer(comp)   → RED

F2 (MED — vacuous OR clause in test 7):
  Old:
       The  arm is always true (no living sparks) → NO-OP passes.
  Fix: replaced with independent AND assertions on BOTH needsUpdate flags.
  NO-OP mutation (return; at top of _sparkUpdate) now goes RED.

DI beyond-subst count +5: _applyBloom reads T/ren/scene/cam/highwayCanvas
from live-accessors (instead of inheriting _bloomEnsure's locals).
fx.js return set: 6 → 7 symbols (added _applyBloom).
Suite: 1267/1269 (2 pre-existing failures unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:51:42 +02:00
byrongamatosandClaude Sonnet 4.6 9b98d78cbe refactor(h3d-carve-8): extract Q-helpers (lighting/FX) → src/fx.js
Six functions extracted from Q-section of screen.js into a new
src/fx.js ES module behind a createFx({...}) factory:
  _h3dHexOrDefault, _applyCinematic, _timingHex,
  _sparkBurst, _sparkUpdate, _bloomEnsure

buildBoard (330 lines) deferred to plan §3 row 16 (P-section cut):
its write-back surface spans 9 factory-scope vars owned by P/U/Y —
premature extraction would require ~50 DI params. See plans/highway3d-carve.md.

DI surface (26 beyond-subst rewires):
  BG_DEFAULTS, K          — plain IIFE-scope constants
  getT                    — live-accessor (Three.js, lazy)
  getAmbLight/getDirLight/getCinematic/getTimingFx — live-accessors
  getSparkPts/.../getSparkN — live-accessors (per-call, not init-cached)
  setSparkPts/setSparkPos/setSparkVel/setSparkCol/setSparkLife — setters
  getComposer/setComposer — getter+setter (_bloomEnsure lazy reassigns)
  getBloomLoad/setBloomLoad/getBloomPass/setBloomPass — getter+setter pairs
  getBloomW/setBloomW/getBloomH/setBloomH — getter+setter pairs
  getRen/getScene/getCam/getHighwayCanvas — live-accessors (null pre-init)
  canvasSize              — factory function ref (stable)

Class-killers (all mutations verified RED before commit):
  - _timingHex EARLY branch removed → RED
  - _sparkBurst stale init-time sparkPos cache → RED (live-accessor required)
  - setBloomLoad silenced → RED (synchronous write-back path)
  - setComposer getComposer init-cached → RED (live getComposer() required)
  GAP declared: setComposer(comp) inside .then() (async, no import() mock)

Tests: 1266/1268 (2 pre-existing failures unrelated to this cut)
plugin.json: 3.43.0 → 3.44.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:37:46 +02:00
byrongamatosandClaude Sonnet 4.6 4b87db0c17 test(h3d-carve-7): add discriminating render-path tests for drawToneHud and drawSectionHud
Creed gap: both functions only tested with empty/null state — a return 0 at
the body entry point kept the suite green (Creed's exact injection at
overlay.js:631 for drawToneHud; equivalent non-empty branch gut for drawSectionHud).

Fix: two new recording-ctx tests (tests 9–10) that drive real rendering:
  • drawToneHud: toneBase='Clean', next change 'Lead' at t=10, currentTime=5
    → assert boxH>0, fillText contains 'Clean' and 'Lead', ctx.fill() called
  • drawSectionHud: sections=[Intro@0, Verse@10], currentTime=5
    → assert boxH>0, fillText contains 'Intro' and 'Verse', ctx.fill() called

Named mutations verified RED:
  Mut 1: return 0 at drawToneHud body start (overlay.js:631) → test 10 RED
  Mut 2: return 0 after drawSectionHud empty-guard → test 11 RED

Shipped code: zero changes. Test files only.
Suite: 262/262 on 0a91f4f (base) → 264/264 (+2 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:14:53 +02:00
byrongamatosandClaude Sonnet 4.6 0a91f4f1b1 refactor(h3d-carve-7): extract O-section (lyrics + HUD overlay) → src/overlay.js
Move longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
drawSectionHud, drawToneHud, drawLyrics out of screen.js IIFE into
plugins/highway_3d/src/overlay.js as export function createOverlay({ diagRenderCache }).

Surprises vs approved contract:
  • longestConsecutiveRun (lines 4338–4352) co-moved — called only by drawChordDiagram
  • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX DELETED from screen.js (lines 573–575);
    only users were inside the O-section; moved not copied to avoid drift
  • _DIAG_CACHE_MAX DELETED from screen.js factory scope (line 3256); moved not copied

Beyond-subst rewires (2):
  1. Factory wrapper createOverlay({ diagRenderCache })
  2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached

Base run stated: 253/253 on a993d2b
Post-cut: 262/262 (+9 tests — 8 overlay class-killers + 1 panel-controls stub)

Named mutations verified RED:
  • createOverlay({ diagRenderCache: new Map() }) → test 6 RED (ref-severance)
  • void _DIAG_CACHE_MAX in screen.js         → test 4 RED (bare private)

plugin.json: 3.42.0 → 3.43.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 14:53:03 +02:00
byrongamatosandClaude Sonnet 4.6 a993d2b291 test(h3d-carve-6): strengthen stale-private guard to catch full class (Toby r1)
Replace hardcoded ['_pmXSpriteMat','_fhXSpriteMat'] list in test 4 with
a full factory-depth-1 scan of materials.js (4-space indent const/let
declarations NOT in the return set). This catches TXT_STYLES and any
future factory-private additions automatically.

Mutation-verified: void TXT_STYLES injected into screen.js → test 4 RED;
reverted → 16/16 GREEN. Full h3d suite: 253/253, 0 fail.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 12:40:34 +02:00
byrongamatosandClaude Sonnet 4.6 e6b2f86068 refactor(h3d-carve-6): move N-section material builders to src/materials.js
Extract TXT_STYLES + 13 material builder functions (txtMat, pinchHarmonicMat,
naturalHarmonicMat, palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat, _meshMatForGhostFretDigit,
_spriteMat2MeshMat) and pool() from screen.js N-section into:
  plugins/highway_3d/src/materials.js  (createMaterialBuilders factory)

screen.js drops ~600 lines (15302→14705).

Surprises vs plan §4:
  • DI is 4 params { getT, getTxtCache, techMatCache, techMeshMatClones } not 1
  • _syncOpenStringPitchLabels cluster excluded (20+ factory-scope deps)
  • _techMatCache stays in screen.js factory scope for teardown .values()/.clear()

Beyond-subst (4):
  1. Factory wrapper createMaterialBuilders({...})
  2. T → const T = getT() inside each function body (live accessor)
  3. txtCache[k] → const cache = getTxtCache(); cache[k]
  4. _techMatCache/_techMeshMatClones → DI param names techMatCache/techMeshMatClones

Tests: 16 new class-killer tests in highway_3d_materials.test.js;
pool warm tests retargeted to src/materials.js; panel_controls stub added.
Full suite: 1246/1248 pass (2 pre-existing: network + nut-labels).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 10:24:40 +02:00
byrongamatosandClaude Sonnet 4.6 733d772154 test(h3d-carve-5): pin literal tables — _PC_C, _PC_PILL, _PC_LABELS, _PC_USES (test 15)
Creed finding: DOM/style literals in the moved factory were never asserted;
a single-value change (e.g. idle '#181830'→'#181831') shipped silently.

Add test 15: table-driven source scan pinning all 6 _PC_C color entries,
4 _PC_PILL CSS fragments, all 8 _PC_LABELS display strings, and all 9
_PC_USES intensity/reactive boolean pairs.

Mutation-verified RED:
  idle '#181830'→'#181831'       → not ok 15, fail 1 (Creed's mutant)
  particles intensity true→false → not ok 15, fail 1

Suite: 236/236 → 237/237.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:55:36 +02:00
byrongamatosandClaude Sonnet 4.6 90283f9d3a test(h3d-carve-5): fix test 14 defined-set to full identifier scan
Toby r2 finding: the declaration-only regex only captured the first
variable from multi-var let statements. bg-control.js lines 81-88 have
a single let with 8 state symbols (_pcEl, _pcSel, _pcReactive, _pcIntensity,
_pcIntensityWrap, _pcReason, _pcRetry, _pcRetryTimer); 7 of 8 were
invisible to the guard, so a bare _pcEl ref in screen.js escaped all tests.

Fix: replace the declaration-pattern matchAll with a full identifier
scan (/\b(_pc\w+)\b/g). Every _pc* word in bg-control.js lands in
`defined`; the privateSymbols filter (excluding destructured public API)
is unchanged.

Mutation-verified RED:
  _pcEl.remove() injected in screen.js → not ok 14, fail 1 (Toby's mutant)
  _pcSync() injected in screen.js      → not ok 14, fail 1 (Mut B, still works)
Suite: 236/236.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:49:23 +02:00
byrongamatosandClaude Sonnet 4.6 97799fff37 test(h3d-carve-5): fix test 13 kill + add stale-private guard (test 14)
Toby r1 finding: test 13's body-ref filter let the stated mutation escape
('add _pcNewFn to return only → RED' was actually GREEN), and the cut-4
stale-private-reference class (_pcSync bare ref in screen.js) was also
unguarded.

Fix test 13: drop the && screen.js body-ref condition from the leaked
filter — pure 'returned ⊆ destructure' check. Mutation-verified RED:
add _pcNewFn to return but not destructure → leaked = ['_pcNewFn'] → fail 1.

Add test 14: extract every _pc* symbol defined in bg-control.js (function/
let/const declarations), filter to private (not in the destructure), assert
none appears bare in screen.js IIFE body after stripping imports, block
comments, line comments, and the destructure statement. Mutation-verified RED:
inject _pcSync() in screen.js body → stale = ['_pcSync'] → fail 1.

Suite: 235/235 (c4eebe1 base) → 236/236 tip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:43:30 +02:00
byrongamatosandClaude Sonnet 4.6 c4eebe1c9f refactor(h3d-carve-5): move H-section player-chrome bg-control to src/bg-control.js
Extracted _pc* subsystem (420 lines) from screen.js IIFE into
src/bg-control.js using a factory DI pattern (createBgControl({...})).

Exports: createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe,
         _bgUnsubscribe, getVenueSceneOverride })
         → { _pcAcquire, _pcRelease }
screen.js: const { _pcAcquire, _pcRelease } = createBgControl({...})

Beyond-subst (2):
1. Factory wrapper (IIFE-scope closure → DI params) — factory export pattern
   required because DI values are IIFE-scope, not ES module imports.
2. _venueSceneOverride → getVenueSceneOverride() (live accessor, 1 call site
   in _pcSync — mutable let at screen.js:1523 must be read per-call).

DI values all defined before the createBgControl call (screen.js):
- BG_STYLE_IDS: line 1435 | _bgReadGlobal: 1825
- _bgSubscribe/_bgUnsubscribe: 1917-18 | _venueSceneOverride: 1523
First _pcAcquire caller: init() in createFactory() (~line 14850 post-cut).
Construction order correct: createBgControl call before createFactory.

Surprise declared to god before commit (outbox/h3d-cut5-surprise.json):
No bc-panel.js dependency — §8's anticipation was wrong. The
_bcCreateController call at what was ~line 8082 is in _bcSyncMode
(P-section / factory scope), not the H-section. bg-control.js has ZERO
dependency on bc-panel.js.

Tests:
- tests/js/highway_3d_bg_control.test.js (new, 13 class-killers):
  stranded-caller (test 13, factory-adapted from bc-panel test 12),
  construction-order (tests 11-12), DI completeness (test 2),
  live-accessor enforcement (test 3), lifecycle (tests 4-7).
- plugins/highway_3d/tests/background_control.test.js: retargeted from
  screen.js slice → bg-control.js factory eval; all 20 existing behaviour
  tests preserved (load() uses vm.createContext + augmented return getters).
- tests/js/highway_3d_panel_controls.test.js: createBgControl stub added.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
  base 222/222 (f69c544) → tip 235/235 (+13: 13 new class-killers)
plugin.json: 3.40.0 → 3.41.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:25:44 +02:00
byrongamatosandClaude Sonnet 4.6 f69c544eea fix(h3d-carve-4): export _bcLoadSettings + _bcFfIdx; add caller-coverage test (Creed HIGH)
screen.js H/P-section render path at lines 15396-15402 calls _bcLoadSettings()
and _bcFfIdx() (3×) — both moved to bc-panel.js in cut 4 but omitted from the
export list.  Browser: first render() after bcCtrl creation → ReferenceError;
seek/loop fast-forward index also dead.  Suite was green because no test
executed the butterchurn render path.

Fix:
- export _bcLoadSettings and _bcFfIdx from bc-panel.js
- add both to the tagged import in screen.js

Class-killer (test 12 — generic, not instance-specific):
  Extracts all exports from bc-panel.js, all imports in screen.js's
  bc-panel.js import clause, then asserts no exported symbol appears as a
  bare reference in the screen.js IIFE body without being imported.
  Generic: adding a new export + new caller without updating the import → RED.

Own grep (audit):
  grep (non-comment lines, all private _bc* names from bc-panel.js):
    _bcLoadSettings  1 hit  (line 15396)
    _bcFfIdx         3 hits (lines 15400-15402)
    all others: 0 hits
  Only the two symbols Creed found.

Mutation-verify:
  remove _bcLoadSettings from screen.js import →
    node --test tests/js/highway_3d_bc_panel.test.js
    tests 12   pass 11   fail 1   (test 12 RED)  ✓
  restore →
    node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
    tests 222   pass 222   fail 0               ✓ GREEN

Plan §8 amended: exports 2→4 with dated note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:03:10 +02:00
byrongamatosandClaude Sonnet 4.6 81b366746e test(h3d-carve-4): fix vacuous module-scope check in bc_panel.test.js (Toby r1)
Test 2 used s.indexOf which matched the JSDoc comment at offset 133, not
the real assignment at line 183 — so it passed even with the assignment
indented inside a function.

Fix: s.search(/^window\.h3dBcApplySettings\s*=/m) anchored to line-start.
An indented assignment (inside a function) does not match /^window\./m and
returns -1.  Also removed the erroneous firstFnIdx comparison: the assignment
is legitimately after _bcIsDesktop in the file and still at module scope.

Mutation-verify:
  indent assignment → node --test tests/js/highway_3d_bc_panel.test.js
    not ok 2  (fail 1/11)  ✓ RED
  restore     → node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
    tests 221   pass 221   fail 0  ✓ GREEN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:55:06 +02:00
byrongamatosandClaude Sonnet 4.6 4e060cbf7b refactor(h3d-carve-4): extract Butterchurn panel to src/bc-panel.js
Move all _bc* constants, mutable state, and functions (~670 lines) from
screen.js B-section (lines 27-695) to src/bc-panel.js.

Public API: _bcCreateController, _bcIsDesktop (2 named exports).
window.h3dBcApplySettings assigned at bc-panel.js module scope (1
beyond-subst; body verbatim — scope moves from IIFE to ES module top-level).
THREE_URL / THREE_CDN dead-code tombstoned (unused since three-loader.js).
Vendor files untouched (R2); asset URL constants verbatim (R3).

Delta note: original survey estimated ~1,333 lines for B-section. Actual is
~670 because (a) prior cuts 1b/2/3 moved material that was interleaved in the
B-section range, and (b) the original survey section boundaries were wrong —
the B-section ends where the H-section factory begins, not at line 695 of the
pre-cut file.

Completeness grep (post-cut):
  grep -nP '^\s*(function|const|let|var)\s+_bc' screen.js
  → 2 hits: _bcActive and _bcSyncMode at line 8081-8082 (factory-scope
    H/P-section helpers that orchestrate the imported controller; not B-section
    symbols — confirmed F-section in the survey table).
  B-section _bc* definitions in IIFE: 0.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
  tests 221   pass 221   fail 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:43:00 +02:00
byrongamatosandClaude Sonnet 4.6 6050b6262b fix(h3d): thread maxStrings param through resolveStringCount (Toby r1)
Toby r1 on c7f7c88: MAX_RENDER_STRINGS=6 hardcoded in utils.js is a
stale compile-time copy — if S_COL grows to 7 entries, resolveStringCount
silently clamps a 7-string chart to 6 and no test fails.

Fix: delegator-param pattern (mirrors fretX / geoFretX):
  - Remove const MAX_RENDER_STRINGS from utils.js
  - resolveStringCount(bundle, maxStrings) — param replaces the copy
  - _openStringPitchLabelsForTuning(bundle, songInfo, n, maxStrings) — same
  - screen.js import aliases (_resolveStringCountBase, _openStringPitchLabelsForTuningBase)
  - 1-line delegators in IIFE supply MAX_RENDER_STRINGS (= S_COL.length); zero call sites change
  - NSTR=6 kept (it is a fixed semantic fact about standard guitar, not a palette ceiling)

New test: 'maxStrings param is authoritative, not a hardcoded 6'
  — resolveStringCount({stringCount:7}, 7)=7; re-hardcode mutation → RED.
Wiring tests: delegator lines asserting _resolveStringCountBase/
_openStringPitchLabelsForTuningBase each receive MAX_RENDER_STRINGS.

Mutation-verified: re-hardcode 6 → 1 RED; original → 210/210 GREEN.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
207→210/210 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:02:00 +02:00
byrongamatosandClaude Sonnet 4.6 c7f7c88c62 refactor(h3d): extract color/tuning/splitscreen utils to src/utils.js (h3d-carve-3)
Moves 12 pure-function / compile-time-constant exports from the screen.js
IIFE into a new src/utils.js ES module:

  Color utils: _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt
  String-count: resolveStringCount
  Tuning/pitch: _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8,
                _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning
  Splitscreen:  _ssActive, _ssIsCanvasFocused

Free-identifier audit: all clean. resolveStringCount uses MAX_RENDER_STRINGS
and NSTR — copied as compile-time constants (both = 6) matching IIFE values.
_ssActive/_ssIsCanvasFocused read window.feedBackSplitscreen live per call.

Survey discrepancy declared: the 12 functions span two non-contiguous regions
in current screen.js (lines 741–882 and 1490–1503) rather than the plan's
original 1703–2193 range; function list from the plan is exact.

New test file highway_3d_utils.test.js: 19 class-killer tests.
Mutation-verified (3-char shorthand removal → RED, original → GREEN).
Panel-controls sandbox: 16 stubs added for the new imports.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
188→207/207 pass.

Plugin: 3.38.0 → 3.39.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:52:41 +02:00
byrongamatosandClaude Sonnet 4.6 f75e91088f test(h3d): catch const-shadow mutation in T=mod assertion (Toby r1)
Toby r1 finding on 8ea123d: the count-based check /T\s*=\s*mod\s*;/g
matches even when the .then bodies declare 'const T = mod' — a local
shadow that leaves the module-level live-binding T permanently null.
Fix: add doesNotMatch(/(?:const|let|var)\s+T\s*=\s*mod/) to reject
any declaration form.

Mutation-verified: 7/8 RED under const-shadow, 188/188 GREEN on original.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:42:18 +02:00
byrongamatosandClaude Sonnet 4.6 8ea123deaf refactor(h3d-carve-2): extract Three.js loader to src/three-loader.js
Move the D-section (~21 lines) from screen.js factory scope to its own module.

Exports:
- loadThree() — memoised import() with local-vendor→CDN fallback; same body verbatim
- T (live let-binding) — updated to the Three.js namespace on first resolution

screen.js gains: import { loadThree, T } from './src/three-loader.js'
screen.js loses: let T = null; let threeLoadPromise = null; function loadThree()
T and loadThree remain accessible to the IIFE via module-scope closure. The live
let-binding means the IIFE reads the populated T after loadThree() resolves
without any call-site changes.

Panel-controls vm test: strip regex extended to consume all consecutive import
lines; T: null + loadThree stubs added to sandbox context.

Class-killer tests (8 new — highway_3d_three_loader.test.js):
- loadThree exported (mutation: rename/remove → import fails)
- T exported as mutable let (mutation: const → T=mod throws TypeError)
- T=mod in both .then handlers (mutation: remove both → T stays null)
- memoisation guard !threeLoadPromise (mutation: remove → race + duplicate loads)
- CDN fallback .catch chain (mutation: remove → deploy failures unrecoverable)
- threeLoadPromise reset on failure (mutation: remove → no retry possible)
- screen.js imports loadThree+T (wiring confirmed)
- IIFE no longer declares local T or threeLoadPromise (shadow defeated)

All class-killer mutations confirmed distinguishable before commit.

Base run (5e401af): 180/180 pass.
Post-cut run: 188/188 pass.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:35:27 +02:00
byrongamatosandClaude Sonnet 4.6 5e401afe87 refactor(h3d-carve-1b): extract render-order, note-key, camera-bootstrap, fretMid to src/geometry.js
Move 9 symbols verbatim from screen.js factory scope to geometry.js:
- RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO,
  RENDER_ORDER_FAR_CLAMP — compile-time constants, now exported from geometry.js
- renderOrderForLayerAtZ — pure fn; reads K + RENDER_ORDER_* from geometry scope
- _noteKey, lowerBoundT — hot-path helpers; no external deps
- hwyFirstRelevantFrettedTime — camera bootstrap scan; no external deps
- fretMid → geoFretMid(f, uniform) — same fretX delegator pattern as Cut 1;
  screen.js keeps: const fretMid = f => geoFretMid(f, _h3dFretUniform); (1 beyond-subst)

screen.js import line updated to import all 9 new exports; tombstones replace
each original definition.

Source-scan retargets:
- highway_3d_render_order.test.js: layers()/zZeroRenderOrder() now read
  GEOMETRY_JS; the 5 renderOrderForLayerAtZ-internals asserts in
  chordFrameRenderOrder test retargeted to geo() (call-site assert stays on src()).
- highway_3d_camera_bootstrap.test.js: extractFn now reads geoSrc (GEOMETRY_JS);
  sourceBetween wiring tests remain on SCREEN_JS.
- highway_3d_panel_controls.test.js: sandbox stubs extended with 9 new names.

Class-killer tests added to highway_3d_geometry.test.js (8 new tests, 180 total):
- RENDER_ORDER_LAYER_STACK length + first/last entries
- RENDER_ORDER_LAYER_INDEX spot-checks (CHORD_FILL=0, NOTE_CORE=10)
- renderOrderForLayerAtZ far-clamp (worldZ=-5 gives 50, not 33 without max)
- renderOrderForLayerAtZ unknown-layer throws
- _noteKey |0 truncation (1.5,3)=150003 not float-derived 150008
- lowerBoundT strict lower-bound (3 in [{t:1},{t:3},{t:5}] gives 1, not 2)
- hwyFirstRelevantFrettedTime smoke (empty → null)
- geoFretMid sentinel (f=0 gives -2K≈-0.015, not 0) + ratio invariant

Mutation analysis confirmed all 8 tests fail under their named mutation before
committing (per Toby r1 lesson).

Base run (a1f7ad5): 172/172 pass.
Post-cut run: 180/180 pass.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:19:58 +02:00
byrongamatosandClaude Sonnet 4.6 a1f7ad5e68 test(h3d-carve-1): add class-killer coverage for camBaseDistU, camLowFretPullbackU, _makeGaussTex
Toby r1 finding: three exports in src/geometry.js had no class-killer tests.
Named mutations that were undetected before this commit:
  - camBaseDistU: Math.max(span,4)→span leaves camBaseDistU(0)=65 vs 77
  - camLowFretPullbackU: drop Max(0,..) leaves (10)=-20 vs 0
  - _makeGaussTex: default sigma=0 leaves centre-pixel alpha=0 vs 255

Each test is written to call without the value that makes the mutation
transparent (sigma passed without default; span=0 for the floor case;
fret=10 for the clamp case). Mutation verification run before commit:
all three mutations confirmed caught, all three pass on real code.

Suite (cmd): node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Base (84d3376): 169/169
Tip:          172/172  (+3 discriminating tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:05:24 +02:00
byrongamatosandClaude Sonnet 4.6 84d33769b8 refactor(h3d-carve-1): extract pure geometry helpers to src/geometry.js
Moves 7 exports from screen.js to a new ES module src/geometry.js:
  geoFretX (was fretX, now 2-arg), dZ, slideTrailEnd, camBaseDistU,
  camLowFretPullbackU, computeBPM, _makeGaussTex.

Internal helpers _fretXLog / _fretXUniStep / _fretXUni are module-private
inside geometry.js. Module-level constants (SCALE, K, FRET_SCALE, NFRETS,
FRET_SPACING_*, TS) are duplicated at geometry.js module scope — values
are compile-time and never vary at runtime.

screen.js keeps a 1-arg delegator (1 beyond-subst):
  const fretX = f => geoFretX(f, _h3dFretUniform);
No call site changes; fretMid / fretColumnWorldW / slideOffsetWorldX /
_recomputeFretSpacingDerived stay in screen.js and use the delegator.

Tests:
- highway_3d_geometry.test.js: class-killer via import() with 9
  known-answer assertions (geoFretX uniform/log invariants, dZ linearity,
  slideTrailEnd, computeBPM BPM estimate).
- highway_3d_fret_spacing: test updated to match delegator pattern + adds
  GEOMETRY_JS read to verify geoFretX export.
- highway_3d_sustain_bloom: retargeted _makeGaussTex check to GEOMETRY_JS
  (definition moved); call-site check stays on SCREEN_JS.
- highway_3d_panel_controls: vm loader strips ES import line and injects
  geometry stubs; all factory-statics tests unaffected.

Suite: 169/169 pass
  node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Version bump: 3.35.0 -> 3.36.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 06:50:52 +02:00
byrongamatosandClaude Sonnet 4.6 d5f622f31b refactor(h3d-carve-0): add scriptType:module to plugin.json
Zero logic changes. screen.js content byte-identical — an IIFE is valid
inside a <script type="module">. The loader consumes scriptType at
plugin-loader.js:666 (if plugin.script_type === "module" -> script.type =
"module"), fed from plugins/__init__.py:1429 (manifest.get("scriptType")).

Isolates the R7 module-flip risk as its own verifiable cut before any
function extraction begins.

Version bump: 3.34.1 → 3.35.0
Suite: 160/160 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 06:35:26 +02:00
byrongamatosandClaude Fable 5 7bce521a5b Merge fix/scan-prune-guard: refuse mass prune on automatic scans
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
2026-09-04 14:35:47 +02:00
byrongamatosandClaude Sonnet 4.6 cafb1ee790 fix(scan): mass-prune guard v2 — partial degraded listing refused on auto scan
The v1 zero-listing guard (f6e9727) was bypassed by a partial degraded
mount: if even one song was visible, current_files was non-empty and
delete_missing ran freely, pruning every invisible DB row (Creed r1 HIGH).

Guard contract (god ruling):
- Auto scans (startup, periodic, /api/rescan): refuse when
  would_remove >= max(_PRUNE_MAX_ABS=1, _PRUNE_MAX_FRAC=0.5 * existing).
  Zero-listing also refused (would_remove == existing).
  Scan sets stage='error', leaves DB intact.
- /api/rescan/full: allow_mass_prune=True → guard logs a warning and
  proceeds; delete_missing runs (user-authorised explicit intent).

Changes:
- lib/scan.py: _PRUNE_MAX_ABS/FRAC constants; combined guard (zero +
  partial) before delete_missing; allow_mass_prune param on
  background_scan(); _scan_mass_prune_next global threaded through
  kick_scan() and _scan_runner()
- server.py: trigger_full_rescan calls kick_scan(allow_mass_prune=True)
- tests/test_scan_prune_guard.py: Case B (Creed partial, RED f6e9727→
  GREEN here) + Case C (full-rescan bypass)

Gates: pytest 2816/2816, JS 1155/1155

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-04 13:45:04 +02:00
byrongamatosandClaude Sonnet 4.6 f6e9727b04 fix(scan): refuse to prune when listing returns 0 songs and DB is non-empty
When background_scan() discovers zero songs in the DLC directory but the
songs table is non-empty, skip delete_missing and return with stage='error'.

An empty listing on a non-empty library almost certainly means the DLC mount
was temporarily inaccessible (FUSE remount, NTFS dirty-flag RO fallback, brief
unmount mid-scan) rather than every song being genuinely deleted.
delete_missing({}) on a non-empty DB deleted ALL rows — the #P1-libpurge
incident that wiped 50,943 songs was caused by exactly this path.

The guard fires only when current_files is empty AND the DB has at least one
row, so a genuinely empty new library is unaffected.

Test: test_scan_prune_guard.py::test_empty_listing_refuses_prune_when_db_nonempty
Failing input: dlc dir with no feedpak/sloppak/wem + 1 DB row.
Before: delete_missing({}) fires, row gone.
After:  stage='error', row survives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-04 13:18:46 +02:00
byrongamatosandClaude Fable 5 b739e44e3d Merge feat/career-gig-tuning: gig tuning preference + tuner interstitials
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
2026-09-03 18:49:02 +02:00
byrongamatosandClaude Sonnet 4.6 2ae46fe754 test(career): F2b — drive real closeBook() in in-flight-discard test (Creed closer)
The previous test for closeBook() invalidating in-flight requests bumped
_ppBookGen directly via setBookGen instead of calling the production closeBook().
Deleting ++_ppBookGen from closeBook() left the test green — decoration.

Fix: expose closeBook via the __careerPassportTest seam and call it directly
in the test. Mutation proof (run before commit):
  - delete ++_ppBookGen from closeBook() → 1 fail (test RED)  ✓
  - restore → 14/14 pass (GREEN)  ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:34:02 +02:00
byrongamatosandClaude Sonnet 4.6 1033de87ce fix(career): gen check after error-branch json + closeBook invalidates in-flight (Creed F1b/F2b)
F1b (MEDIUM): the error path (non-ok response) lacked a gen check after
res.json() completed. A stale 404's json() could finish after a newer booking
had incremented _ppBookGen, and the error handler would still revert the newer
pref to 'any'. Fix: `if (gen !== _ppBookGen) return;` immediately after the
error-branch `await res.json().catch(...)`. Test: json() side-effect bumps
_ppBookGen (simulating a new booking racing in), verifies pref stays 'standard'.

F2b (LOW): closeBook() dismissed the poster but left _ppBookGen unchanged,
so a still-pending successful bookGig response could land after dismissal and
reopen the overlay / repopulate _ppGigProposal. Fix: `++_ppBookGen` in
closeBook(). Test: pending request fires, gen bumped (simulating closeBook),
response resolves, asserts _ppGigProposal stays null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:27:12 +02:00
byrongamatosandClaude Sonnet 4.6 c264a66e5d fix(career): bookGig generation guard + 404-only pref revert (Creed F1/F2)
F1 (MEDIUM): bookGig had no request-generation guard. Rapid pref changes could
let a stale response overwrite _ppGigProposal → user sees the wrong song set.
Fix: _ppBookGen counter incremented on each request; response discarded unless
gen === _ppBookGen at both the res.ok check and after json(). Stale-response
driver test fails without the guard.

F2 (LOW): every non-ok response reverted _ppGigTuningPref to 'any' and
persisted it. A transient 500 would silently blow away the user's pref.
Fix: pref reverted only on 404 (no-match case). Other errors notify but keep
the pref. 500 driver test asserts pref stays 'drop' — fails without the fix.
404 driver still asserts revert to 'any'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:13:53 +02:00
byrongamatosandClaude Sonnet 4.6 c46b6484bf fix(career): specific-tuning interstitial guard + 404-revert re-render (Toby F1/F2/F3)
F1 (HIGH): onGigSongLoading used `pref !== 'specific'` but production pref
is always 'specific:<name>' not bare 'specific' — guard was always true,
interstitial fired even when every gig song shared one tuning.
Fix: !pref.startsWith('specific:')

F2 (MEDIUM): JS test for specific-exemption used bare 'specific' (impossible
in production), giving false confidence. Updated to 'specific:E Standard',
which is the real production shape and correctly exercises the fixed guard.

F3 (MEDIUM): On 404-revert (_ppGigTuningPref → 'any'), poster was not
re-rendered so the stale pill from the previous successful booking stayed
highlighted while internal pref was already 'any'.
Fix: re-render overlay with gigPosterHTML(_ppGigProposal) before returning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:02:57 +02:00
byrongamatosandClaude Sonnet 4.6 2a455702b8 feat(career): tuning preference filter + interstitial for gigs
Users can now pick a tuning preference before booking a gig:
- Any (default), Standard only, Drop only, or a specific tuning
- Backend filters the song pool (stubs + filler) by that preference
- Empty-filter case returns a 404 with a descriptive message; frontend
  reverts the pref to 'any' and shows a notification
- Interstitial pause before first song and on tuning changes (all prefs
  except 'specific') via window.feedBack.holdAutoplay(); opens the tuner
  panel in auto mode while the user retunes
- 'Specific' gigs skip interstitials (every song already shares one tuning)
- Graceful degradation: no holdAutoplay → interstitial silently skipped

New backend:
- _tuning_ok_fn helper for standard/drop/specific classification
- _fill_genre_songs accepts optional tuning_ok filter
- propose_gig batch-fetches tuning_name for played stubs, applies filter
- GET /gigs/tunings endpoint for the specific-tuning picker

Tests:
- tests/test_career_gig_tuning.py — 17 Python tests (classification, filter)
- tests/js/career_gig_tuning.test.js — 9 JS tests (interstitial logic)
- tests/plugins/career/conftest.py — songs table schema gets tuning_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 17:52:30 +02:00
byrongamatosandClaude Fable 5 a57b62378c Merge feat/vocal-calibration-wizard: Vocals path + first-run vocal calibration handoff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
2026-09-03 15:42:27 +02:00
byrongamatosandClaude Sonnet 4.6 d2847ab153 fix(vocal-cal): cancel fallback timer on Skip via _activeCleanup (Creed finding)
Root cause: clicking Calibrate in the vocalCalibration-absent path queued
an 1800ms setTimeout but never stored the timer id. If the user clicked
Skip before the timer fired, advance('vocals', false) resolved the wizard;
the stale timer then fired advance('vocals', true), mutating the completed
array after resolution and (with a multi-instrument queue) double-
incrementing idx so the next instrument was dropped from both lists.

Fix: capture the setTimeout return value as _timerId and assign:
  _activeCleanup = () => clearTimeout(_timerId)
advance() already drains _activeCleanup on every exit path (Skip,
Calibrate, and any future button), so no further call sites needed.
The _advancing flag + calBtn.disabled remain as the double-click guard;
this fix covers the orthogonal Skip-before-timer race.

Test 3c (new): Calibrate then Skip before timer — asserts clearTimeout
called, vocals in skipped only (not completed), stale timer no-op.
Fails without the fix. Harness updated to expose clearTimeout to vm ctx.

Gates: JS 1141/1141, pytest 60/60.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 14:37:34 +02:00
byrongamatosandClaude Sonnet 4.6 47e85d61c7 fix(vocal-cal): address Toby review F1/F2/F3
F1 (HIGH): add double-click guard to fallback path in renderAudioPanel.
  - Declare `_advancing` flag before click handler; fallback else branch
    returns early if already advancing, then sets flag + calBtn.disabled.
  - Prevents two setTimeout advances from queuing when the button is
    clicked twice before the 1800ms timer fires, which would silently
    drop subsequent instruments from both completed and skipped.
  - Test 4 (new): double-click with two instruments queued, asserts only
    one timer is scheduled -- fails without the guard.

F2 (MEDIUM): add 'vocals' to _inputSetupRelaunch fallback list.
  - Was ['guitar','bass','keys','drums']; now includes 'vocals' so the
    Settings re-calibration wizard runs the vocals panel even when
    /api/progression fails to respond.
  - Test 6 (new): reads fallback literal from source, asserts 'vocals'
    present -- fails without the fix.

F3 (LOW): key button label and notice off vocalCalibration presence for
  vocals, not hasDetector (noteDetect).
  - New `hasVocalCal` and `canCalibrate` vars; vocals shows 'Calibrate'
    iff vocalCalibration facade present, 'Continue' otherwise.
  - notLoadedNotice selects the correct per-instrument message.
  - Test 7 (new): facade present + noteDetect absent => label 'Calibrate'.

Also: fix curly-quote string delimiters introduced by editor autocorrect
in prior commit -- replaced with straight ASCII quotes; restored original
curly apostrophes in prose content (isn’t, it’s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 14:11:22 +02:00
byrongamatosandClaude Sonnet 4.6 95f4bb80e1 feat(vocal-cal): Vocals path + input_setup vocal-calibration handoff
1. Add data/progression/paths/vocals.json (5 levels, instrument:vocals
   challenges) so the Vocals tile appears on wizard step 4.

2. Add vocals to INSTRUMENTS in plugins/input_setup/screen.js (mode:audio)
   so the wizard renders a Vocals panel when that path is selected.

3. In renderAudioPanel, branch vocals away from noteDetect.launchCalibration
   to window.feedBack.vocalCalibration.launch({requester,onDone,onCancel}).
   Guard: facade absent (vocal-highway plugin disabled) → shows a notice and
   auto-advances via setTimeout; never hangs or throws.

4. Update test_bundled_content_loads_clean to expect the 'vocals' path id.

5. Add tests/js/vocal_calibration_handoff.test.js — 4 tests covering:
   - vocals.json shape (id/icon/5 levels, namespaced challenge ids)
   - INSTRUMENTS includes vocals
   - fallback when facade absent (no hang/throw, auto-advance)
   - facade.launch called with correct args, onDone resolves wizard

Facade contract: window.feedBack.vocalCalibration frozen {version:1,
launch({requester,onDone,onCancel})} — built by Dwight (vocal-highway).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 13:57:19 +02:00
e0270e5c30 fix(song): make bass detection instrument-type-aware, not name-only (#1019)
ship-ci / ci (push) Waiting to run
Editor now authors an arrangement's instrument as first-class data (a manifest
'type' field). Core dropped it: the sloppak loader never read 'type', and
'is this a bass?' was defined three different ways across call sites (name-only
in note_pitch_midi and the highway scale-degree path; path_bass+name in bass
selection; name-only in arrangement_string_count). So an authored type=bass
chart not named 'bass' got 6-string lane counts and guitar open-string MIDI.

- Add optional Arrangement.type; sloppak load_song lifts the manifest type onto it
- Add arrangement_is_bass(arr) = type=='bass' OR path_bass OR 'bass' in name
  (None/whitespace safe), and route string count, note_pitch_midi, the highway
  scale-degree base, and bass-player selection through it
- Back-compat: no bass signal -> unchanged 6-string / guitar behavior

Companion to editor #335 (first-class instrument type). Scale degrees are
display-only and never feed a grader.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:30:54 +02:00
3717e4338d fix(plugins): restore window.esc for out-of-tree plugins (#986)
ship-ci / ci (push) Waiting to run
app.js exported `esc` as an implicit global back when it was a classic
script. a9fce29 made it an ES module and 14b4058 carved `esc` into
js/dom.js; the window re-export list was rebuilt without it.

Out-of-tree plugins load screen.js as a classic script and call `esc()`
bare, so nothing in-tree catches the break: no-undef, a call-graph scan
and a grep all pass while the plugin throws in the field. The MIDI
plugin builds its device list with esc() inside the same try block that
catches requestMIDIAccess() failures, so the ReferenceError surfaced to
testers as "MIDI Access denied esc is not defined" — access had actually
been granted.

Pin the whole plugin-facing global surface by name, mirroring
tests/test_plugin_context_contract.py. Verified both ways against a
running app: without the fix the spec fails with "missing or not
functions: esc".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:06:52 +02:00
0b4b174d33 perf(scan): skip full library re-stat when the tree is unchanged (#979)
ship-ci / ci (push) Waiting to run
* perf(scan): skip full library re-stat when the tree is unchanged

Startup scans globbed the whole DLC tree twice (*.feedpak, *.wem) and
stat()'d every file to detect changes — ~100k filesystem round trips on
a 50k-song library, and painful on a slow NTFS-3G FUSE mount (the "big
drive churns on every launch" report).

Adds/removes/renames of songs all bump the mtime of the containing
directory (verified on the target mount), so after a full pass we persist
{reldir: mtime_ns} for every library dir (scan_dir_signature.json, keyed
by DLC path). The next scan re-stats only those dirs — a handful vs 100k
ops — and skips the entire listing/stat pass when none changed.

Blind spot: a pack rewritten in place under the same name bumps the file
mtime but not its dir's. Rare for a song library, and the manual Refresh
(/api/rescan + /api/rescan/full) now passes force=True to always do the
full pass. force threads through kick_scan -> _scan_runner and coalesces
like the rescan-pending flag.

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

* fix(scan): track directory-form songs' own dir in the signature

CodeRabbit: _library_dirs recorded only each song's parent. For a
directory-form song (loose-song folder or directory sloppak bundle),
adding/removing/replacing a file INSIDE the folder bumps that folder's
own mtime, not its parent's — so the fast path would skip a rescan it
should run. Record the song's own dir when f.is_dir(). File-form
sloppaks (a single .feedpak zip) aren't dirs, so the flat file library
is unaffected.

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-15 21:23:10 +02:00
Byron GamatosandGitHub 2f2a095e4c fix(venue): fly in once per set, not before every song (#978)
ship-ci / ci (push) Waiting to run
Tester, mid-gig: "the second song in the gig started when the first one ended.
But it showed the flyover intro again."

The flyover is arriving at the venue, and you arrive once. #968 stopped it
replaying on an arrangement SWITCH (same filename), but a gig's song 2 is a
genuinely different file, so it took the full-teardown path and played the
arrival flyover again — the camera flew in from the back of the room before
every track of the set.

The play queue now answers isContinuation(): false for the first song of a set
(or a standalone play — an arrival), true for song 2..N. onSongLoaded carries
the room over to the new song's loop on a continuation, and only a real arrival
plays the intro.

Verified on the built AppImage: isContinuation goes false (song 1) -> true
(song 2) across an advance, and song 2 no longer flies in.

Also confirmed NOT a bug, same session: "didn't show the author for the second
song." The credits card shows on a queue advance whenever the song carries
authors — reproduced with a song that has them as the advanced-to track. The
tester's song 2 simply had no `authors:` metadata (most auto-converted feedpaks
don't). No code change.

Tests: isContinuation across start/advance/clear, and that onSongLoaded gates the
flyover on the continuation check. Both fail on pre-fix source. JS 1214/1214.
2026-07-15 12:39:25 +02:00
Byron GamatosandGitHub e14ef64224 fix(playback): the song queue must survive a playSong wrapper that drops options (#977)
ship-ci / ci (push) Waiting to run
Tester: "Passports does not advance in the song queue."

The play queue tells playSong "don't clear the queue I'm driving" by passing
options.fromQueue. But window.playSong is wrapped by a CHAIN of plugins —
nam_tone, midi_amp, fretboard, invert_highway, tabview — and each wrapper
forwards only (filename, arrangement), silently dropping the options object. So
fromQueue never reached playSong: it cleared the queue the instant its first
song started, and a gig/album/playlist never advanced.

Reproduced on the real build via a queue.start + a hooked clear(): the queue
went inactive with 0 remaining immediately after start, and the clear stack ran
through nam_tone -> midi_amp -> invert_highway -> fretboard -> session.js.

Fixing six plugin wrappers is whack-a-mole and the next plugin re-breaks it.
Fix it at the source instead: the queue raises an out-of-band flag
(_consumeInternalPlay, one-shot) beside the wrapper chain, not through it, and
playSong's clear-guard honours it. options.fromQueue stays as the in-band path.
The flag is consumed on read so a later MANUAL play still abandons the queue.

Verified on the real build: the gig queue stays active after start and advances
on song:ended (Iron Maiden -> Blind Guardian), and a manual play still clears.

Tests drive the real clear-guard against the queue for: a dropped-options
wrapper (the bug), the one-shot manual-play-still-clears invariant, and the
in-band fromQueue path on its own. All 3 fail on the pre-fix source. JS 1211/1211.
2026-07-15 10:50:59 +02:00
Byron GamatosandGitHub 365cec1d29 fix(career): gig song selection — full-genre pool, working re-roll, and the venue pack loads (#976)
* fix(career): a gig's song pool is the whole genre, and re-roll varies it

Two tester reports, one root: the gig song pool was built from only two sets —
songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL
(`filename NOT IN song_stats`).

A song played on a DIFFERENT instrument's arrangement is in neither: it has a
stats row (so the "unplayed" filler skipped it), and its played bucket is that
other instrument's, not this passport's. It could never be gigged.

- "Metalcore says 137 songs only shows 1 in the gig list" — a library of
  metalcore all played on another instrument. Reproduced: a guitar passport with
  137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw
  was whatever handful happened to be on-instrument or truly unplayed.

- "Passport re-roll does not change songs" — a set drawn from that filler was the
  library's first N in table ORDER, every call. Re-roll re-proposes, so it
  returned the identical set. Reproduced: 3 proposals, byte-identical.

_unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of
the genre the set hasn't already picked (a stats row on some other instrument has
no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll
actually re-rolls.

Both reproduced against the real propose logic before the fix and pinned as
regression tests (both fail on the pre-fix routes.py). Full career suite green.

* fix(career): load the gig's venue pack when the gig starts

Tester: "Venue doesn't load when starting song from passport. Loads standard
particles."

crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage
pack — is reached ONLY through pushCrowdManifest, and pushCrowdManifest is
called ONLY from refresh(), the career tab's own reload. A gig navigates AWAY
from the career tab to the player, so refresh() never runs during it. startGig
set the venue override and nulled _appliedManifestVenue but never re-pushed, so
the venue visualization turned on (3D highway) while its pack never loaded — the
song played over the bare highway backdrop, or over whatever venue a previous
refresh() had left applied.

startGig now pushes the crowd manifest for the gig venue right after setting the
override, using the career state the booking screen already fetched.

This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other
caller and startGig is not it), but it is fixed by static analysis — I could not
reproduce the user-visible symptom locally because this instance happened to have
a manifest already applied from a prior refresh. On-device confirmation on a real
passport gig is still owed.

Guard test: startGig must push the manifest after setting the override (fails on
the pre-fix source). Career suite green.
2026-07-15 10:50:55 +02:00
Byron GamatosandGitHub 1702afa379 feat(career): extract the whole setlist before the gig starts (no more waiting between songs) (#971)
ship-ci / ci (push) Waiting to run
* feat(career): extract the whole setlist before the gig starts

A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.

A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.

Best-effort by design, at every level:
  - a corrupt pak in the set does not sink the prepare (it is reported in
    `failed`; the play itself surfaces the error exactly as it does outside a
    gig — slow beats blocked)
  - a host without the library resolvers degrades to a no-op rather than 500
  - a failed request just falls through to the old lazy extraction

Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.

Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.

NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.

* fix(career): bound the prepare request; validate the setlist (PR #971 review)

Both CodeRabbit findings were right.

1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.

   `await fetch(...)` only rejects on a network ERROR. A server that accepts the
   connection and then never answers hangs indefinitely — and the gig would never
   start. That makes this optimisation the exact thing the PR promises it can
   never be: the reason you cannot play.

   The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
   because unpacking a setlist is real work — but a CEILING, not a wait). Past it
   we start the gig and let the first play extract lazily, as it always did. The
   Play button is restored in a `finally`, so a timeout cannot strand the poster
   on "Preparing set…" with Play disabled — which would have been the same bug
   wearing a different hat.

2. THE `songs` BODY WAS UNVALIDATED.

   A str is iterable: "abc" would have prepared three one-character "songs". And
   the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
   Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.

Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.

* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap

CodeRabbit again, and the first one is a real hole I put there.

1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
   with NO containment guard — so `../../x` walks straight out of the library, and
   my new endpoint handed it attacker-supplied filenames. Every filename now goes
   through _resolve_dlc_path first, the same check every other filename-bound
   handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
   a Windows drive path are all refused, and nothing outside the library is
   unpacked.

2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
   library — where the endpoint exits before extraction — so it passed whether or
   not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
   endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
   fail when the cap is removed.

   Same class of mistake as the notedetect gigBlock: a test that passes for the
   wrong reason. Worth saying out loud since it is twice in one day.

3. E702 — semicolon-joined statements in the new tests, split.

51 career tests; full suite green.
2026-07-15 00:36:20 +02:00
Byron GamatosandGitHub 917d81c2d2 fix(highway): a SUPERSEDED renderer init is not a FAILED one (#970)
Starting a gig dropped the player onto the fallback 2D highway with no venue.

startGig() calls setViz('venue'), which installs the 3D renderer — whose init is
async — and then immediately starts its play queue. playSong() re-initialises
that same renderer a tick later. A renderer mints a fresh readyPromise per
init() and rejects the previous one with "superseded"; highway.js only checked
that the RENDERER OBJECT was unchanged, which it is. So it treated a healthy,
re-initialising renderer as a failed one, tore it down, and reverted to 2D:

    renderer async init failure: Error: superseded
    viz picker: reverted to default renderer (async-init-failure)

The guard now also checks the PROMISE identity: a rejection from an init cycle
the renderer has already moved on from is ignored. The renderer-identity guard
stays (a rejection for a renderer since REPLACED is also not ours), and a
genuine failure of the CURRENT cycle still reverts — both init() call sites go
through _setRenderer, which re-wires the handler every time, so the new cycle is
always watched.

Reproduced and fixed against the real build:

    before:  vizSelection=default  viz-picker=default  venue=inactive  viz:reverted
    after:   vizSelection=venue    viz-picker=venue    venue=ACTIVE    (no revert)

Also widens the paused-frame throttle's opt-out. The throttle fires whenever the
CHART CLOCK is stalled — not only on a pause, but through a count-in and the
credits/author overlay too. Its opt-out only asked "is a crowd video rolling",
but the venue scene animates on a clock of its own with no pack at all (backdrop
breathe, parallax, haze drift, warmth pulse — Math.sin(t) in the draw loop), so
that motion was still being throttled. It now claims frames for both sources; a
plain 3D highway with no venue reads motion mode 'off' and keeps the #654 GPU
saving.

HONEST CAVEAT on that second part: I could not get the throttle to fire in a
reproduction. A control run on the shipped code showed 100 draws/sec while
paused, not the ~10/sec a firing throttle would give — so the change is
defensible on its own terms (a stalled clock is genuinely not a static picture)
but it does NOT have a demonstrated symptom behind it. The viz fix above does.

Tests: the superseded guard, and that the throttle opt-out covers both motion
sources. All fail against the pre-fix source. eslint 0 errors; JS 1207/1207.
2026-07-15 00:36:16 +02:00
Byron GamatosandGitHub 939c98214b feat(song-info): publish the playable stem list so stems can preload (fixes the 698ms freeze) (#972)
* feat(song-info): publish the playable stem list, so stems can preload

The stems plugin could only learn its stem list from the highway's WS `ready`,
which arrives once the highway is already up. So it fetched, decoded, and then
handed every stem's PCM to its audio worklet — copying the WHOLE SONG — with the
player already on screen.

For a 4-minute 6-stem pack that is over half a GIGABYTE of memcpy, in one frame,
on the main thread. Measured on a real load: a 698 ms frame, right as the
song-credits card appeared, with the venue video visibly stopping. That is the
"the video pauses when the author appears" report.

GET /api/song/{f}?stems=1 now returns the same list — [{id, url, default}] plus
full_mix_url — so the plugin can start the whole load at `song:loading`, before
the highway (and the venue) is drawn, where a stalled frame costs nothing.
Nothing about the work changes; only WHEN.

Opt-in via the query param so the library's own metadata calls — the hot path —
pay nothing. Deliberately NOT stored in the metadata cache: that is a
fixed-column table, and widening it would mean a schema migration plus a stale
row for every song already scanned, to cache something that is a plain manifest
read on an already-unpacked pack.

The safety property: REST and the WS must publish the SAME list. If they
disagreed the plugin would preload a graph and then throw it away and rebuild —
strictly worse than not preloading. So both now resolve `default` through one
shared helper (stem_default_on, extracted from load_song), and a test rebuilds
the WS's payload from load_song and requires the REST helper to produce the
identical list, rather than pinning either against a snapshot.

Also pinned: the mixdown is lifted OUT of the stem list (spec 5.3 — `full` is
not a layer; listing it beside the instruments would play the whole song on top
of the stems) while staying reachable as full_mix_url, a single-`full` pack keeps
it as its only playable stem, and an unreadable pack yields an empty list rather
than failing the request. Full suite 2608 passed.

Consumed by feedBack-plugin-stems (preloadSong).

* fix(song-info): call load_song for the stem payload — do not reimplement it

CodeRabbit caught a real bug, and it would have hit most real libraries.

load_song() falls back to the DEPRECATED `original_audio:` key when a pack has no
reserved `full` stem — which is every pack written before feedpak 1.15.0. My
payload rebuilt the full-mix rule from extract_meta and returned None for those:
REST would say "no full mix" while the WS said there was one.

Worse than a wrong field: the plugin would preload a graph WITHOUT the pristine
mix and — because the stem signature still matched — never rebuild. Unity
playback would silently downgrade to the lossy stem recombination.

That is exactly the drift this PR claims to prevent, and my test had a hole: I
only covered packs that carry a `full` stem.

So stop reimplementing. The payload now calls load_song, whose LoadedSloppak
already carries the partitioned stems and the resolved full mix, and builds the
URLs exactly as ws_highway does. Drift is now impossible by construction rather
than by agreement. extract_meta is reverted to its original shape (it never
needed to change), and the shared stem_default_on helper stays as the one place
`default: off` is resolved.

Tests rewritten to compare against load_song — the WS's own function — for a
reserved-`full` pack, a LEGACY original_audio pack (the case that was broken), and
a single-`full` pack. Also documents the `?stems=1` contract in CHANGELOG.md.
Full suite green.
2026-07-15 00:36:13 +02:00
Byron GamatosandGitHub 4e0e3c5417 fix(venue/highway): flyover replay on arrangement switch, venue on Virtuoso, and the paused throttle starving the venue (#968)
* fix(venue): don't replay the flyover on an arrangement switch; keep the venue off other screens

Two bugs from a live career session.

1. CHANGING ARRANGEMENT REPLAYED THE ARRIVAL FLYOVER.

   changeArrangement() reloads the song through the normal load path, so
   highway.js re-emits `song:loaded` — same filename, new arrangement. The venue
   could not tell that from a fresh arrival, so it reset the machine and flew the
   camera in from the back of the room again, mid-set, every time the player
   switched lead -> rhythm. The player is already on stage.

   onSongLoaded now compares the filename. A repeat of the song already on stage
   keeps the video pipeline running and only re-syncs the mood: the performance
   restarts, so the loop follows the reset machine with a quiet crossfade, never
   the intro. A genuinely different song still gets the full teardown + flyover.

2. THE VENUE SHOWED UP ON THE VIRTUOSO HIGHWAY.

   The venue was gated purely on `isVenueViz()` — the selected visualization,
   which is a GLOBAL preference and says nothing about what is on screen.
   Virtuoso borrows the same highway_3d renderer for its practice charts, so with
   Venue selected it inherited the backdrop: the crowd and the stage behind a
   chromatic exercise.

   Selecting Venue is a preference for the PLAYER; it is not a licence to paint
   the venue over whatever else happens to be using the renderer. The venue is now
   gated on viz AND screen (`shouldBeActive`), and follows `screen:changed` — it
   tears down on leaving the player and rebuilds on return. Nothing else changes:
   stop() already unbinds the videos from the renderer, so deactivating is enough
   to clear the backdrop.

Tests: both decisions exposed as pure predicates and pinned — arrangement switch
vs new song (including the first load, and a malformed payload that must not
suppress the flyover forever), and the venue's screen scope. The existing syncViz
test encoded the OLD contract (activate regardless of screen), so it now states
the new one and additionally asserts the venue does NOT activate on virtuoso.

Includes a guard test: with Venue selected AND on the player, the venue IS
active — without it, every "not active" assertion could pass vacuously.

All 8 new/updated assertions fail against the pre-fix source. eslint clean;
JS 1199/1199; pytest 2597 passed.

* fix(highway): the paused-frame throttle was throttling the whole venue

Pausing the song dropped the venue, the crowd and the stage to ~10 fps —
"everything around the highway drops fps by a lot".

draw() caps paused frames to one per _PAUSED_FRAME_INTERVAL_MS (100ms), on an
assumption stated plainly in highway-constants.js: a heavy WebGL renderer "does
a full render every frame even while paused. That is pure waste." That was true
when a paused chart was a still picture.

The venue broke the assumption. Its video backdrop keeps playing and its crowd
reacts on a clock of their own, and BOTH are drawn into the same canvas as the
notes — so a throttle aimed at static notes throttled the entire room. The
scene only got a texture upload 10 times a second while the transport sat
paused.

Renderers can now declare that their picture is not static while the chart
clock is stopped: an optional needsContinuousFrames(). The throttle is skipped
only when it returns exactly true, and the probe fails closed — a renderer that
doesn't implement it, or one that throws, keeps the throttle unchanged. So the
GPU saving that motivated #654 survives everywhere it was actually valid.

highway_3d implements it and claims continuous frames ONLY while a crowd video
is genuinely rolling (bound, unpaused, not ended, readyState >= 2). With no
venue pack — the common case — the paused scene really is static, so it keeps
the throttle and the GPU still idles.

Tests extend tests/js/highway_pause_throttle.test.js, which guards this code
path source-level (the draw loop owns the rAF + WebGL lifecycle and is
deliberately not reproduced in a vm — see the file header). The new guards pin
that the capability GATES the early return rather than merely being called near
it, that the probe fails closed on absent/non-function/throwing/truthy-but-not-
true, and that the 3D renderer keys off the real video elements and can still
return false. All 3 fail against the pre-fix source.

eslint 0 errors; JS 1202/1202; pytest 2597 passed.
2026-07-14 22:11:52 +02:00
Byron GamatosandGitHub 8ef97708ef perf(folder_library): render only the songs on screen — 1.3M DOM nodes -> ~30 (#965) (#967)
* perf(folder_library): render only the songs on screen (#965)

A song list rendered EVERY song it held. On a flat 50,944-song library that is
one <div> with 50,938 children and ~1,300,000 DOM nodes — ~4.2 GB of renderer
RSS, for a screen the user may not even be looking at (it was built while the
visible screen was v3-home).

It is not just this plugin's problem. A million-node document poisons unrelated
code: any `document.querySelector` that MISSES has to walk the whole tree before
returning null. That is exactly how song_preview's per-frame menu check ended up
consuming ~50% of the renderer and dropping the app to 2.7 fps
(feedBack-plugin-song-preview#7 fixes the per-frame walk; this fixes the tree it
was walking).

So render only what is on screen. Rows are uniform height (grid cards uniform
size), so the window is pure arithmetic — no per-row observers. Off-window songs
are represented by padding ON THE LIST rather than spacer elements: a spacer div
would become a grid ITEM in grid view and shift the columns, whereas padding
behaves identically in both layouts. Lists at or below VIRTUAL_MIN (200) render
in full exactly as before, so normal folders are untouched.

Two ordering fixes this forced, both real bugs waiting to happen:
  - Both expand handlers populated the list BEFORE showing it. A windowed list
    measures a real row and the scroller viewport, and both are zero under
    display:none. Show first, then populate.
  - _render() now tears down the previous render's scroll listeners. Without it
    they survive against detached nodes and leak on every re-render.

Verified in real Chromium over CDP with 50,000 rows — the DOM glue, not just the
maths:

    at top          rendered= 25 rows   scrollHeight=2,200,000px   [0..24]
    scroll   500k   rendered= 31 rows   scrollHeight=2,200,000px   [11357..11387]
    scroll 1,100k   rendered= 31 rows   scrollHeight=2,200,000px   [24994..25024]
    scroll to end   rendered= 25 rows   scrollHeight=2,200,000px   [49975..49999]

25-31 rows in the DOM instead of 50,000; scroll height exact and constant (the
scrollbar stays honest); the last row lands on song 49,999.

Tests: _visibleWindow is pure and exposed via __test — top/middle/bottom/past-
the-end windows, the grid row-packing case, the padding-plus-rendered-equals-
total invariant that keeps the list from changing height as you scroll, and the
degenerate zero-height case (a list still display:none) falling back to
render-everything rather than to an empty list. eslint clean; full JS suite
1186/1186.

* fix(folder_library): re-window on resize and on show/hide (PR #967 review)

CodeRabbit caught two real bugs in the first pass. Both are mine.

1. GRID RESIZE. perRow and rows were captured once when the list was filled, but
   paint() also runs on resize — and resizing changes the grid's column count.
   The window maths then sliced against the OLD column count: wrong songs on
   screen, and padding sized for a row count the layout no longer had (so the
   scrollbar lied). metrics() now recomputes perRow/itemH/rows together on every
   paint, so the geometry can never disagree with itself.

2. STALE WINDOWS ON SHOW/HIDE. paint() only ran on scroll and resize. Expanding
   or collapsing any section moves every list below it, and a windowed list's
   contents are a function of its POSITION — so those lists kept the window from
   their old position and showed blank padding where songs should be until the
   user happened to scroll. Both toggles now call _repaintVirtualLists().
   Re-opening an already-populated section had the same flaw.

   Collapsed lists also kept doing layout work on every scroll tick. paint() now
   bails early when the list is display:none or detached, and forgets its last
   window so re-showing repaints from scratch instead of short-circuiting on a
   stale memo.

Tests: grid re-window on a column-count change, the padding+rendered=rows
invariant at two different perRow values, and a test that PINS THE FAILURE MODE —
a mismatched perRow/rows pair must not silently look correct. 12/12.
Re-validated the DOM glue in real Chromium with 50k rows (25-31 rows rendered,
scroll height exact). eslint clean; JS 1189/1189; pytest 2597 passed.

CHANGELOG entry added (also flagged).
2026-07-14 21:37:22 +02:00
Byron GamatosandGitHub e729c44d5b perf(paths): resolve the library root once, not on every path check (#966)
`Path.resolve()` is a filesystem call — it lstats every component of the path.
`_resolve_dlc_path` and `safe_join` both re-resolved their ROOT on every single
call, and those run once per song, per art fetch, per scanned row.

Found while profiling a 2-fps report: on a real 50,944-song library the server
was issuing ~23,500 stat/lstat calls per second, re-walking the same three
parent directories over and over, and burning ~50% of a core doing it. It is
worst exactly where big libraries live — the library was on an NTFS-3G (FUSE)
mount, where every stat is a userspace round trip through mount.ntfs-3g (itself
visible in top). The cost was the constant re-resolution, not the work.

A root is fixed for the life of the process, so resolve it once
(safepath.resolved_root, lru_cache). Measured, 5,000 lookups against a real
library path:

    before:  15,264 stat syscalls   (54.2 ms)
    after:       277 stat syscalls   ( 0.8 ms)     55x fewer

Containment is unchanged, which is the part that matters:
  - safe_join still resolves the CANDIDATE on every call — following its
    symlinks IS the zip-slip / traversal defence, so it is never cached. Only
    the server-owned root is.
  - _resolve_dlc_path keeps its lexical containment check (deliberately does not
    follow symlinks, so junction-mounted libraries keep working).

Tradeoff, documented on resolved_root: if a root's symlink is re-pointed at a
NEW target while the server runs, the old target stays in effect until restart.
Fine for a library path fixed at startup; the cache is keyed on the Path, so
switching library dir is a different key.

Tests: root resolved once across 500 lookups (the regression), a different root
is a different entry, and the containment contract re-pinned — traversal,
Windows drive-absolute, backslash, NUL, empty, and a symlink escaping the root
must still be refused. Full suite green.
2026-07-14 19:48:39 +02:00
2991612531 feat(career): bundle the AXA club venue pack (career stage 2) (#963)
ship-ci / ci (push) Waiting to run
The Velvet Room (50 stars) now ships in every build like the bar and
arena: 4 reactive crowd loops, 2 stingers, and a balcony flyover intro
rendered from the AXA Music Stage scene (110 spectators, state-scaled
stage washes over the venue's own neon). Audio files are dive-bar
placeholders until club-scale recordings land.

The installed/delete test now asserts bundled-fallback semantics:
with every venue bundled, deleting a downloaded pack reveals the
bundled copy instead of uninstalling.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:54:08 +02:00
af611770aa test(career): assert exact arena manifest mappings (#962)
CodeRabbit follow-up on #961: presence checks alone would pass with
swapped loop filenames; assert the full loops/stingers/sfx objects.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:07:24 +02:00
ea9da0acde feat(career): bundle the arena venue pack (career stage 3) (#961)
Feedback Arena (150 stars) now ships in every build like the bar:
4 reactive crowd loops, 2 stingers, and a flyover intro rendered
from the UE5 arena scene (200 spectators + 396-body intro fill,
state-reactive rig lighting). Served by the existing bundled-pack
fallback; venues.json unchanged. Audio files are dive-bar
placeholders until arena-scale recordings land.

Largest file is 89MB — future re-renders must stay under GitHub's
100MB hard limit.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:02:04 +02:00
be473dc7af Career v3: Gold tier — verified improv upgrades an earned badge (#960)
* feat(career): Gold tier — a family-style goldImprov artifact upgrades an earned badge

The drill-state relay's goldImprov map (virtuoso gold_improv mints,
gained-only merged like drill nodes) turns an earned badge gold when the
passport's genre — or its genre family — has a verified improv artifact.
Gold never substitutes for the badge bar: gold-without-bronze stays
in_progress.

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

* feat(career): Gold tier frontend — relay, ceremony, slam, gold ink everywhere

The drill-state relay now carries virtuoso's goldImprov map; a badge that
comes back gold gets its own ceremony + notification (tier-suffixed seen
ids — the bronze moment stays seen under its legacy id, a gold slam marks
both), a gold stamp slam in the book, gold ink on the shelf-cover mini
stamp, and the real gold foil chip. The bronze page's dashed 'Gold rung
coming' preview becomes a live invitation to jam the style in Virtuoso.

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

* fix(career): gold review fixes — family-space style matching, intake guards, rail counter

The review's showstopper: virtuoso mints goldImprov under raw
STYLE_PALETTES ids ('punk', 'djent', 'disco'), which are mostly NOT
family keys — the tier check now matches in family space (artifact style
and passport genre bucket through the same _genre_family keyword match),
so a 'punk' gold reaches a 'punk rock' passport. Also: non-dict
goldImprov 400s loudly instead of silently dropping; evidence-free
artifacts (no verifier) never mint; goldImprov gets the same pre-merge
size bound byNode has (junk under the cap could otherwise persist
forever and wedge every later relay at the post-merge check); the
instrument-rail badge counter counts gold (earning gold no longer made a
badge vanish from the rail); first-artifact-wins is now asserted against
the persisted snapshot instead of vacuously.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:26:36 +02:00
dd1927e27b feat(career): gigs frontend — poster, runner strip, summary, encore (#956)
ship-ci / ci (push) Waiting to run
* feat(career): gigs frontend — poster, runner strip, summary, encore

Career v3, WS3 (frontend half), rebuilt cleanly on merged main (v3-a/b/c
in) after git interleaved the structurally-similar canvas functions:

- Book a gig from any opened passport: /gigs/propose renders as a GIG
  POSTER (venue presents GENRE NIGHT, numbered bill) with re-roll and
  Save/Copy poster (natively-drawn canvas via blob-io, audible failure
  paths, slash-safe filenames).
- Play the gig: venue override + Venue viz handoff, then
  playQueue.start(..., {source:'gig'}) — the queue's auto-advance runs
  the set; zero new playback machinery.
- Floating gig strip (body-level, pointer-events none, z 35 per the
  chrome invariant) tracks set position and names what's next.
- Completion = song:ended with an empty queue → POST /gigs → summary
  poster overlay with per-song accuracies; encore fires the crowd
  celebrate + confetti (reduced-motion: neither). song:stop with a dead
  queue = abandoned (no log); end-of-song teardown (queue still active)
  must NOT abandon.
- Gigs played render as dated rows in the passport book.

vm tests: runner advance/abandon semantics via the queue-state seam.

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

* fix(career): stage restore survives a setViz throw; size-register row

CodeRabbit on #956: a setViz failure nulled the restore snapshot AFTER
the overrides were written, permanently borrowing the stage — snapshot
now captured before any write, write failures keep it intact. Also
registers career screen.js in docs/size-exemptions.md 'Planned, not
exempt' (1,516 lines; max-lines WARNS non-blocking — the split plan
needs Byron's sign-off).

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

* docs: register career screen.js in the size register (planned, not exempt)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:54:07 +02:00
7c897e9f2b feat(career): gigs backend — propose a setlist, log the completed set (#954)
* feat(career): gigs backend — propose a setlist, log the completed set

Career v3, WS3 (backend half). A gig is career's verb:

- POST /gigs/propose {instrument, genre, size}: setlist from the
  passport's own stubs — qualifying songs (per the genre's badge bar,
  family-aware) shuffled for a free re-roll, topped with the
  highest-accuracy near-bar songs as stakes, and filled from UNPLAYED
  genre songs when the passport is young (the first gig is how stubs
  start). Names the highest venue the current stars can book.
- POST /gigs: logs a COMPLETED set only (abandoned sets never log — no
  fail state). Per-song accuracy = MAX(last_accuracy) from song_stats,
  freshly written by the set's own plays; encore = avg ≥ the data-driven
  bar (passports.json gig.encore_accuracy, 0.75). Appends to the career
  state file (same atomic _save_json pattern).
- Passports view: per-passport gigs (newest first, capped 20) and
  per-instrument gig_count — the profile wall's gig line lights up.

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

* fix(career): gig backfill offsets by qualifying taken, not picks length

CodeRabbit on #954: after the stakes loop appends near-bar songs,
qualifying[len(picks):] overshoots and skips eligible qualifying songs
— a stocked passport could still get a short set.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:26:23 +02:00
6272af8d33 feat(career): profile passport wall, home career card, shareable PNG card (#955)
* feat(career): profile passport wall, home career card, shareable PNG card

Career v3, WS2. The identity artifact leaves the plugin tab:

- Profile: #v3-profile-passports-mount (core, one div) filled by career
  on v3:profile-rendered — per-instrument shelves of earned covers,
  hours, gig count, open-career link. Absent-not-empty.
- Home: the plugin-count stat tile becomes #v3-dash-career-slot with the
  old stat as fallback content; career replaces it with a trading-card
  tile (leather + foil shine, badge count, hours, closest-stamp ask) on
  the existing v3:dashboard-rendered event.
- Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom
  duplicated verbatim in settings-io/diagnostics-export — both
  refactored; copyImageBlob wraps ClipboardItem, returns false to signal
  the download fallback). Earned passports get Save/Copy card: a
  natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line);
  copy falls back to download with a notice when the clipboard refuses.
- Mount-point convention documented in docs/plugin-v3-ui.md.

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

* fix(career): external surfaces stay absent until a passport exists

CodeRabbit on #955: a bare commitment produced a zero-passport wall and
replaced the dashboard fallback. Docs also now say mounts may hold
fallback content and plugins REPLACE, never append.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:25:39 +02:00
831117fb96 feat(career): practice invitations — closest stamps + bring-these-up (#953)
* feat(career): practice invitations — closest stamps + bring-these-up

Career v3, WS1. The passport now points at the practice that pays:

- Stubs carry next_star_at (the same primitive _stars() uses) and each
  passport exposes `nearest`: the top 3 non-qualifying songs by distance
  to their next star, in the worklist order.
- "Closest stamps" strip above the shelf: the in-progress graded
  passports nearest to minting, each row naming the ask — N more songs
  (with the nearest title + best %) or the blocking Virtuoso drill.
  Rows open the passport.
- "Bring these up" list on the stubs page of in-progress passports;
  earned pages stay memorabilia (no homework on a won badge).
- Invitation-voiced throughout: no meters, no completion pressure.

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

* docs(test): comment says qualifying-bar ranking, matching the assertion

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:10:55 +02:00
6cc0312661 feat(career): genre families — sub-genres inherit the family drill (#951)
The enrichment fallback made the passport rack real (hundreds of MB
sub-genres) but only the five exact umbrella keys carried Virtuoso
drills. Genres now resolve to a family by keyword substring (MB's
vocabulary is open — 'metalcore' must hit metal without an alias),
first-match-wins in list order ('blues rock' → blues), and inherit the
family's requirement from the same genres map. Exact entries still win;
per-instrument scoping unchanged; unmatched genres stay songs-only.

Data: families for metal (incl. djent/grindcore/thrash/doom), blues,
jazz (bebop/swing/bossa), funk (disco), rock (punk/grunge/shoegaze).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:05:05 +02:00
329cc86315 fix(sloppak): the full mix is a stem — drop the invented original_audio key (#946)
Nightly / build-docker (push) Has been cancelled
ship-ci / ci (push) Waiting to run
* fix(sloppak): the full mix is a stem — drop the invented `original_audio` key (#933)

Core read, served, and depended on `original_audio:` — a top-level manifest key
this repo invented in #583 that the feedpak spec never defined. The format
already had a home for the pre-separation mixdown: it is a stem. feedpak 1.15.0
(feedpak-spec#53) RESERVES the id `full` for it, so read it from there.

The key existed to work around a bug in our own reader. The packer's comment
said so plainly: "we must NOT list the full mix as a playable stem — the player
sums every entry in `stems` and does not gate playback on `default`, so a listed
full mix plays on top of the stems". Faced with a reader that would double the
song, the packer put the mixdown outside `stems` and invented a key to point at
it. The fix belongs in the reader, and that is what this is.

load_song() now partitions the stem list: `full` comes out as
LoadedSloppak.full_mix, the instruments stay in .stems. Nothing that sums stems
or draws one fader per stem can see the mixdown, so retaining it is safe — which
is what lets the packer put it where the format says it goes.

- ws_highway: `song_info` gains full_mix_url / has_full_mix. The old
  original_audio_url / has_original_audio remain as deprecated aliases for one
  release so an older stems plugin keeps working (#945).
- `stems` on the wire, and stem_ids / stem_count in the library index, are now
  INSTRUMENT stems only — a separated pack that retains its mixdown no longer
  advertises a bogus "full" chip or an inflated stem count.
- enrichment: fingerprint against the mixdown wherever it lives. This widens
  coverage — _song_audio_file() previously returned None for any pack without
  the invented key, so fingerprinting silently did nothing for nearly every pack.
- sloppak: `original_audio:` is still READ as a deprecated fallback, because
  every pack in the wild carries it and would otherwise lose its pristine mix.
  tools/migrate_full_mix_stem.py rewrites those packs into the spec shape
  (original/full.ogg -> stems/full.ogg, add the `full` stem at default:off, drop
  the key); the fallback and the aliases die with #945.

The spec gate keeps the debt honest: the grandfather entry now tracks #945, and
the gate fails if it goes stale.

Verified: spec gate OK (4/4, incl. ingesting the spec's new example pack that
retains `full`); 2493 python tests, 995 js tests; migrator round-tripped over
real packs from the library and the results pass the spec's reference validator.

* fix(migrate): discover directory-form packs instead of silently skipping them

iter_packs() searched only files, so a directory-form pack (`song.sloppak/`, the
authoring shape) was walked INTO and never yielded — silently missed by a run
that's meant to be exhaustive. Discover suffix-named directories too (yielded
whole, not descended into), and route packs through migrate_pack/verify_pack.

Directory packs are REPORTED as `dir-form-unsupported`, not rewritten in place:
a single-file pack is replaced atomically (a fully-built temp archive swapped in
with one os.replace), but a populated directory can't be swapped that way, so an
interrupted in-place rewrite could leave an authoring pack half-migrated. The
status is a problem status, so it counts against the run's exit code and shows
in the summary — the operator re-packs or migrates it as a `.feedpak` instead of
it vanishing from the report. Addresses a CodeRabbit review finding.

Signed-off-by: Kris Anderson <topkoa@gmail.com>

* fix(migrate): verify requires an explicit `off` on a retained full mix

verify_zip accepted any non-truthy `default` on a multi-stem `full` (missing,
empty, boolean, `false`/`no`/`0`, malformed) as "ok". But core defaults an ABSENT
`default` to True — ON (lib/sloppak.py: `s.get("default", True)`) — and treats an
empty/unrecognized string as ON too, so a migrated-shape pack whose `full` stem
has a missing or blank default beside instrument stems would actually play the
mixdown on open and double the song. verify was certifying that as safe.

Require an explicit normalized `off` beside instrument stems: `on`-ish values are
reported `full-stem-default-on` (actively plays), everything that is not a
normalized `off` is reported `full-stem-default-not-off`. The migrator already
writes the literal `off`, so its own output is unaffected; this also certifies
the pack is in the tool's canonical, most-portable shape. The len>1 gate is kept,
so a sole `full` stem (which IS the audio) is not policed.

Adds parametrized coverage for missing / empty / boolean / off-ish / malformed
defaults, and a sole-full-stem case. Addresses a CodeRabbit review finding.

Signed-off-by: Kris Anderson <topkoa@gmail.com>

---------

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Kris Anderson <topkoa@gmail.com>
2026-07-13 12:22:42 -04:00
Byron GamatosandGitHub d876ded00f fix(sloppak): bound the unpack cache; add read_member_bytes() so callers stop unpacking whole songs (#950)
* fix(sloppak): bound the unpack cache, and add a way to read a song without unpacking it

A tester's sloppak_cache reached 60 GB from an 1800-song library — his entire
library, unpacked, none of it played. Stems are already-compressed audio, so an
unpacked pack is ~1.1x its zip: the cache is a second, DECOMPRESSED copy of every
song it touches. It had no size cap, no LRU, and no cleanup of any kind — not even
when the song itself was deleted.

Two halves:

1. resolve_source_dir() now evicts least-recently-used songs to stay under a cap
   (FEEDBACK_SLOPPAK_CACHE_MAX_MB, default 4 GB ≈ 130 songs of recency; 0 disables).
   The sweep runs on unpack — the only moment the cache grows — so it can't drift.
   An evicted song is dropped from _source_cache too: get_cached_source_dir() is
   the only thing media.py consults before falling back, so a stale path there
   would 404 every stem for the rest of the process instead of re-unpacking.
   get_cached_source_dir() now also verifies the dir still exists, which makes
   "just delete sloppak_cache/ to reclaim disk" safe advice.

2. read_member_bytes() reads ONE file out of a pack without unpacking it — the
   same trick read_cover_bytes() uses so the library grid doesn't explode every
   pack to show a cover. Unpacking a whole song to read a few KB of JSON is ~45x
   write amplification; doing it in a loop over the library is what produced the
   60 GB. rig_builder's library-wide tone batch is the caller that did exactly
   that (fixed separately); this gives it, and everyone else, the right primitive.

Eviction is concurrency-safe: unpacks run 2-at-a-time, so a dir being written is
marked in-flight and the sweep skips it — checked and rmtree'd under one hold of
the guard, and the marker is released even if the unpack raises (a leaked marker
would make that dir permanently un-evictable).

read_member_bytes normalizes both the requested path AND the archive's stored
member names through safe_join, taking the last match — so './arrangements/x.json',
backslash members from Windows tooling, and duplicate members that normalize to
the same path all read back exactly as unpack-then-read did. Zip-slip is rejected
before anything is opened.

Tests: tests/test_sloppak_unpack_cache.py. All bite-tested (reverted each fix,
watched it fail) — including one that was passing vacuously: a freshly-unpacked
dir is the most-recently-used, so the LRU never reaches it and the in-flight race
test proved nothing until the packs were sized to force the sweep that far.

* test: split semicolon-joined statements (E702)

CodeRabbit on #950. Style only; no behaviour change.
2026-07-13 17:13:08 +02:00
18d77d2d41 feat(library): effective genre falls back to MusicBrainz enrichment (#949)
* feat(library): effective genre falls back to MusicBrainz enrichment

Converted packs rarely carry a genres manifest key — on Byron's real
library 1188/1190 songs had no genre, starving the genre facet and the
career passport rack (2 usable genres) while song_enrichment already
held MB genres for 636 matched songs.

The effective-genre expression now resolves: per-song override → pack
genre → json_extract(enrichment.genres, '$[0]') for MATCHED rows only
(review/failed candidates could carry the wrong recording's genres).
Same fast-path gating as before: the plain indexed column is used
unless overrides or enrichment genres actually exist; stand-in DBs
without the table degrade via the OperationalError guard. Career
passports pick this up automatically through _effective_genre_expr().

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

* docs: changelog names manual rows in the enrichment fallback

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:53:06 +02:00
5921157f35 test(stats): prove seconds-only recency on a fresh row (#948)
CodeRabbit on #947: the prior lastPlayPosition POST already stamped
last_played_at, so the assertion passed even if the seconds-only path
left it unchanged — a guard that cannot fail. Assert on a fresh row.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:36:05 +02:00
3e57ba0345 fix(career): hours polish — recency stamp + non-2xx POST is a failure (#947)
CodeRabbit follow-up on #942:

- add_play_seconds() now stamps last_played_at (like touch_position):
  an unscored play that ran to the natural end WAS played — recent /
  Continue ordering must see it. Resume position stays untouched.
- stats-recorder post() treats non-2xx as failure: a 4xx/5xx JSON error
  body parsed as an object read as success, silently dropping the
  accrued seconds instead of re-queuing them.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:31:12 +02:00
b85496fe58 feat(career): passport visuals pack — tilt, emerging ink, gold foil (#944)
* feat(career): passport visuals pack — tilt, emerging ink, gold foil

Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps:

- Trading-card tilt on EARNED artifacts only (shelf covers + the badge
  page stamp): pointer-tracked perspective rotateX/Y with a glint sweep
  following the pointer. The cover's jitter rotation moves into a CSS
  var so the tilt transform composes with it; the blanket cover-hover
  translate is scoped :not(.pp-tilt) so it can't fight the tilt.
  Hover-capable pointers only; off under prefers-reduced-motion.
- Emerging-stamp ink: the ghost stamp fills by qualifying/required via
  a conic-gradient — the stamp visibly carves in, no numbers added.
- Gold foil preview: the "coming" note gains a dashed foil chip with a
  periodic shimmer — honest, never earnable-looking.

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

* fix(career): tilt rAF race + freshly-slammed stamp becomes a card

CodeRabbit on #944: a queued tilt frame closed over the departed card
and re-applied vars after pointerleave; and the just-slammed stamp
never regained pp-tilt until the next open.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:22:41 +02:00
4027c31a61 feat(career): curated genre drills — per-instrument, achievably cleared (#943)
Career v2, WS3. Bronze in blues/rock/metal/funk/jazz now also asks for
the genre's signature Virtuoso drill, data-driven in passports.json:
blues_shuffle, rock_power_backbeat, melodic_metal_gallop,
sixteenth_pocket, vl_shells — with career-side display labels the
passport page renders instead of raw node ids.

- virtuoso_nodes becomes {instrument: [node_ids]} so a keys passport
  never demands a guitar drill; a flat list keeps meaning guitar
  (virtuoso's content is guitar-first).
- _node_cleared also accepts keysCleared (a top-tier clean pass in one
  key — virtuoso's FIRST gained-only artifact). The depth rungs
  additionally require a maxed speed tier, too high a bar for Bronze.
- Genres without a curated entry stay songs-only.

Note: pre-release behavior change — v1 passports aren't in any shipped
build, so no earned badge can demote in the wild.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:14:39 +02:00
0fc6a4beed feat(career): hours-per-genre odometer — honest wall-clock play time (#942)
Career v2, WS2. Nothing measured play time before (the achievements
plugin's final-position shortcut double-counts loops and mis-reads
seeks). Now:

- stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔
  pause/stop/ended spans (single spans clamp at 2h against suspend
  inflation) and piggybacks them as `seconds` on the POSTs it already
  sends; failed POSTs restore the accumulator; a session reset flushes
  first so time can't re-attribute to the next song/arrangement.
- POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the
  scored and position branches, plus a new seconds-only branch for
  unscored plays that ran to the natural end — banks time WITHOUT
  touching the resume position (song:ended must not overwrite Continue)
  and still counts as playing today for the streak.
- song_stats gains additive idempotent `seconds_total`; record_session/
  touch_position accrue, new add_play_seconds() for the seconds-only
  path; the legacy-encoding stats merge sums seconds across duplicates.
- Passports surface it: "14.2 h in Blues" under the badge stamp and on
  the shelf cover sub-line — a true fact that only grows, never a
  target or a meter (Stage 5 post-cap, per the career design).

Tests: seconds accrual/validation/seconds-only branch (stats API),
per-instrument-and-genre summing (career), fmtHours formatting (vm).
Full suites: pytest 2480, JS 1165.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:12:23 +02:00
d26347981c feat(career): badge ceremony — the crowd erupts, the stamp drops (#941)
Earning a genre badge now stages the full moment (career v2, WS1):

- venue-crowd.js gains a public celebrate(): machine.force('ecstatic')
  commits instantly (bypassing STABLE_MS/DWELL_MS; the stamped
  lastSwitchAt makes the dwell window HOLD the forced state before the
  real perf machine reasserts) + a cheer stinger via the same
  _lastStingerAt=-Infinity bypass the end-of-song reaction uses. If a
  stinger/intro owns the idle layer, the ecstatic loop is queued via
  _pendingLoop exactly like onPerformanceState. No-op without a
  manifest/active venue.
- career detectNewBadges() calls badgeCeremony(): crowd first, then a
  body-appended full-screen overlay 300ms later (it cannot live in
  #pp-overlay — #plugin-career is display:none during playback): dimmed
  backdrop, the bronze stamp slamming in with a shine sweep, a 42-piece
  canvas confetti burst, click-or-4s dismiss.
- prefers-reduced-motion: chime + fbNotify only, no overlay.

Tests: machine.force commit/dwell-hold/bogus-state, celebrate export +
no-manifest no-op, celebrate-called-once-per-badge, crowd-absent and
crowd-throwing degradation.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:07:28 +02:00
45caa86ab8 Bar pack v4 refresh + crowd sound reactions (#940)
* chore(career): refresh bar pack to v4 + restore crowd-SFX setting

Pack v4: per-character desynced animation starts, flyover intro,
per-venue reaction sounds (sfx-up/sfx-down in manifest).
Settings: re-add the crowd sound reactions toggle that was dropped
when settings.html became the passports data panel.

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

* feat(v3): crowd mood-change sound reactions (cheer up / boo down)

Port the venue-crowd SFX runtime that the settings toggle and the
pack's sfx-up/sfx-down files were built for: on a committed mood
transition, play the venue's own cheer (up) or boo (down) one-shot,
gated by the feedBack-venue-crowd-sfx setting (default off).

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

* chore: rebuild tailwind.min.css for settings toggle classes

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:29:34 +02:00
Byron GamatosandGitHub 8f1906a0c1 Merge pull request #926 from got-feedBack/feat/tuning-midis-followups
tuningMidis follow-ups: NaN/Inf guard in freqs_to_midis + v3 badge adopts exact midis
2026-07-13 14:20:58 +02:00
Byron GamatosandGitHub 8b6829a946 Merge pull request #938 from gionnibgud/feat/v3-start-fullscreen
feat(v3): add desktop-only "Start in fullscreen" system option
2026-07-13 14:17:05 +02:00
Byron GamatosandGitHub ba796b0f27 Merge pull request #934 from got-feedBack/chore/feedpak-spec-gate
ci: gate core against the feedpak spec
2026-07-13 13:31:18 +02:00
byrongamatos ddc06ff1e7 Merge branch 'main' into chore/feedpak-spec-gate
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-13 13:27:25 +02:00
byrongamatos ac5c5ad20d ci: cover gap-fill manifest key scans
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-13 13:25:44 +02:00
99b974a5a1 fix(career): passport review polish — a11y semantics + seen-state guard (#937)
CodeRabbit follow-up on #936 (the one Major — overlay outside the click
root — was verified false: the host mounts every screen.html root inside
#plugin-career, ✕-close confirmed working live):

- Tabs: aria-selected/aria-controls + role=tabpanel/aria-labelledby.
- Book overlay: role=dialog + aria-modal + aria-label; focus moves to
  the close button on open and returns to the opener on close.
- seenBadges(): guard non-object JSON so a corrupt stored value cannot
  throw on every passport refresh (covered by a new corruption test).
- Fresh-session suppression test (badge seen → no re-notification).
- Stylelint declaration-empty-line-before nit in .pp-stamp.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:14:24 +02:00
7ffa6e2c51 feat(career): passport UI — the book, the stamp, the rack (#936)
The Passports tab beside Venues renders the badge journey physically:

- Per-instrument passport book: embossed CSS-leather cover, 3D page-turn
  spread (badge page left, ticket stubs right), Escape/backdrop close.
- Wax-seal commitment ceremony (Stage 0) — pressing the seal commits the
  instrument; opening a first passport runs the ceremony implicitly.
- Rubber-stamp badge slam: earned badges chime + notify immediately, the
  slam (with ink bleed, page shake, deterministic sin-hash jitter) plays
  when the passport is next opened, then the badge is marked seen.
- Ticket-stub repertoire: qualifying songs as collected stubs.
- Brochure rack: unopened genres as "Explore next" invitations — no
  greyed slots, no completion meters (the anti-list as layout).
- Drill relay: on virtuoso:progress bus events the career screen posts
  the full virtuoso.progress localStorage snapshot to the drill-state
  intake (debounced; one-time bootstrap when the server has none).
- Four synthesized sfx (stamp/seal/page/chime, 13 KB total) as plugin
  assets; prefers-reduced-motion disables the theatrics.

Pure logic (ppKey, ppJitter, badge diff/seen) is covered by a bare-vm
node --test suite via a window.__careerPassportTest seam.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:56:32 +02:00
3832a5762b feat(career): passport backend — genre badges computed from stars (#935)
The badge-journey layer on top of career stars (Christian's career-mode
v2 design, composed with the shipped venue system). Badges are computed
on read from song_stats × the library's effective genre — never stored:
Bronze = N genre songs at min_stars (data-driven in passports.json,
default 5 songs at 2★) plus any configured virtuoso drill nodes.

New endpoints under /api/plugins/career/:
- GET  /passports        passport walls per instrument: badges, ticket
                         stubs (qualifying songs), library genres, drills
- POST /passports/commit instrument commitment (idempotent wax seal)
- POST /passports/open   open a genre passport (implies commitment)
- POST /drill-state      intake for the relayed virtuoso.progress
                         snapshot (career's frontend listens on the bus)

Instrument attribution reuses progression.instrument_for_arrangement via
the song_stats arrangement index; the genre column goes through the
host's override-aware effective-genre SQL. Non-graded instruments (bass,
drums) render shown-not-judged — repertoire, never a false badge denial.

Persisted state (commitments, opened passports, drill snapshot) lives
under CONFIG_DIR/career/ and rides the settings export bundle via
settings.server_files; a minimal settings.html documents it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:47:59 +02:00
Byron GamatosandGitHub e2215df753 feat(career): bundle dive bar venue pack (#927) 2026-07-12 22:58:04 +02:00
ea0ca94742 feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) (#907)
* feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2)

Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★),
cumulative stars unlock bar → club → arena (data-driven venues.json).
Venue packs (UE-rendered crowd loops) download on demand to
CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 +
zip-slip validation, served via FileResponse. Career screen (promoted
sidebar entry) shows progress and pushes the active venue's manifest
into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when
either side is absent. Pack URLs land in venues.json in PR3.

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

* fix(career): keep manifest cleanup path alive on delete; badge only for installed venues

Codex preflight: nulling _appliedManifestVenue on delete skipped
pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on
a deleted pack; and the 'playing here' badge showed for an override venue
whose pack was removed.

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

* fix(career): generation-guard in-flight manifest fetches

Codex preflight: a manifest fetch resolving after a newer refresh (pack
deleted, venue switched) could re-apply a stale pack over the user's
newer selection — fetches now carry a generation token and bail when
superseded.

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

* fix(career): exclude orphaned song_stats from star totals

Codex preflight: scans hide rather than delete stats of removed songs, so
stars now apply the same existing-song filter other stats surfaces use
(filename IN (SELECT filename FROM songs)).

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

* feat(career): 50/150 star thresholds + star collection overview

Byron's progression tuning: club at 50★, arena at 150★. /state now
returns star_detail rows (title/artist joined from the library, stars,
best accuracy, next-star threshold) sorted closest-to-next-star first,
and the career screen renders a collection panel: tier summary plus a
per-song list with a 'N% to next star' practice hint.

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

* feat(career): venue select/unselect UX, intro manifest support, fullmatch guards

- 'Play here' now also defaults the visualization to Venue (remembering
  the prior viz); active venues show 'Leave venue' which restores it and
  sets the '__none__' override so no installed venue silently reapplies.
- Pack manifests may ship an intro block (flyover video + ambience mp3);
  files validate like loops/stingers, .mp3 added to the serving whitelist.
- Codex preflight: whitelist regexes use fullmatch (trailing-newline names
  could validate but 500 on serving).

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

* fix(career): let pushCrowdManifest clear the manifest on Leave venue

Codex preflight: nulling _appliedManifestVenue before refresh skipped the
setManifest(null) cleanup branch, leaving the crowd playing.

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

* fix(career): refresh tailwind output

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:39:19 +02:00
e779c72396 feat(venue): reactive crowd video layer behind the 3D highway (career mode 1/3) (#905)
* feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1)

Two crossfading video backdrop planes in the highway_3d venue background
style, driven by a new venue-crowd.js state machine that maps
v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic)
with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers
on streak milestones and end-of-song accuracy. Inert without a venue pack
manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the
static bg plate behaves exactly as before.

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

* fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions

Codex preflight P2s: (1) videos created before highway_3d registered its
globals never reached the backdrop planes — binding is now idempotent and
retried from start/perf-event/re-activation paths; (2) a crowd-state
switch committing while a stinger played was dropped because the machine
had already advanced — it is now deferred and played when the stinger ends.

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

* fix(venue-crowd): per-video load tokens + unbind renderer on stop

Codex preflight round 2: (1) the global load token let a stinger cancel a
committed loop load on the other layer — tokens are now per-element, and a
stinger preempting an in-flight loop on its own layer requeues that loop
for when the stinger ends; (2) setManifest(null)/deactivate left the last
crowd frame bound and visible over the static plate — stop() now unbinds
both layers from the renderer and zeroes the mix.

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

* fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events

Codex preflight round 3: (1) a failed/timed-out stinger left a deferred
loop switch queued forever; the failure path now flushes it. (2)
stats:recorded only carries {filename, arrangement}, so the end-of-song
reaction now uses the accuracyPct from the song's last
v3:live-performance-state event (a real percentage) instead of a field
that never existed.

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

* fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap

Codex preflight round 4: (1) idleLayer() still points at the fading-in
layer during a crossfade, so a stinger firing mid-fade overwrote the new
loop with nothing requeued — the fading loop is now tracked and requeued
like an in-flight load; (2) swapping venue packs while active now goes
through stop() so _stopGen invalidates the old manifest's in-flight loads.

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

* fix(venue-crowd): generation-gate stinger handlers; recrop on video size change

Codex preflight round 5: (1) an ended/timeout handler orphaned by stop()
could fire into a later stinger's lifecycle on the reused element — handlers
now detach unconditionally and carry a generation token; (2) the renderer
only re-applied cover-crop on camera aspect changes, so a src swap with a
different intrinsic size kept stale repeat/offset — it now recrops when
videoWidth/Height change.

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

* fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer

Codex preflight round 6: the loop crossfade's completion callback could
still run between a stinger's start and its canplaythrough, promoting the
stinger's layer to active and pausing the real loop — it now bails when
the fading loop was preempted.

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

* fix(venue-crowd): keep rear video layer opaque during crossfades

Two half-transparent layers let the static bg plate bleed through (~25%
at mid-fade) — visible as a flash of the old still image on every state
transition. The crossfade is now always the front layer fading over an
opaque rear layer.

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

* fix(venue-crowd): reset active layer with mix on stop

Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a
restart flashed layer 0's stale frame until the new loop loaded.

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

* fix(venue-crowd): reset crowd mood to neutral on song load

Codex preflight: a song ending in ecstatic/bored left the next song's
crowd stuck in that mood until the hysteresis window passed.

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

* fix(venue-crowd): cancel in-flight fade when a stinger preempts it

Codex preflight: the orphaned ramp kept pushing the mix toward the layer
whose src the stinger had just replaced.

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

* fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value

Codex preflight: Number(null) is 0, so idle HUD resets overwrote
_lastAccuracyPct before stats:recorded consumed it, suppressing the
end-of-song stinger.

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

* fix(venue-crowd): abort stale stinger state on song load

Codex preflight: a stinger straddling a song change could fade back into
the previous song's layer or flush its pending loop.

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

* fix(venue-crowd): always detach load listeners, gate only the callback

Codex preflight: superseded loads left canplaythrough/error listeners
attached to the persistent video elements — unbounded growth over a
session.

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

* feat(venue-crowd): flyover intro with crowd-ambience ducking

On song:loaded, an optional pack intro plays once: a camera flyover video
(idle layer, one-shot) with bar-crowd ambience audio that ducks out on
song:play, near the flyover's landing, or at handoff — whichever first.
Machine commits and stingers defer during the intro; stop()/song-change
abort it. Packs without an intro behave as before.

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

* fix(venue-crowd): fall back to the loop when the intro fails to load

Codex preflight: a failed/timed-out intro left the song with no crowd
loop at all.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:32:04 +02:00
8d3db5f42c fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924) (#925)
ship-ci / ci (push) Waiting to run
* fix(nav): the library sometimes showed the legacy screen — map 'home' inside showScreen

Testers: "randomly, when moving to the library from another menu option, the library shows the
old interface — never when a song ends."

━━━ WHAT WAS ACTUALLY HAPPENING ━━━

#home is the PRE-V3 library screen. The v3 shell replaced it with #v3-songs, and the mapping DID
exist — but only inside WRAPPERS on window.showScreen, and only for callers that go through
`window`. THREE independent parties monkey-patch it, each capturing whatever happens to be there
at the time:

    app.js publishes the raw function
      -> shell.js wraps it, adding the home -> v3-songs mapping
      -> the stems plugin wraps it AGAIN (src/main.js:1029), capturing the current value

Plugins load ASYNCHRONOUSLY. The chain links up in whatever order the race settles, and any
capture taken before shell.js installs — or any re-assignment after it — silently drops the
mapping. Hence "randomly".

AND THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. Reproduced
in a browser: the unwrapped function with 'home' lands on the dead legacy screen EVERY time.

"Never when a song ends" is the tell, and it is what identified the mechanism: closeCurrentSong
resolves its target through _resolvePlayerOrigin(), which ALREADY applies this mapping. That one
path was fine — which is exactly why the bug looked random rather than total.

PRE-EXISTING, not a regression from the module carve: the onclick="showScreen('home')" links and
the wrapper-only mapping both date to 2026-06-22.

━━━ THE FIX ━━━

The guard lives inside showScreen now: ONE place, in the function every caller routes through,
instead of a chain of monkey-patches that must each remember. Wrapper order stops mattering, and
the module-internal callers are covered for the first time.

Verified in a browser: the raw, unwrapped showScreen('home') — which reproduced as #home — now
lands on #v3-songs, and cannot be undone by any wrapper order.

━━━ AND A [P1] I INTRODUCED, WHICH CODEX CAUGHT ━━━

My first cut mapped BOTH 'home' and 'v3-home', copied straight from _resolvePlayerOrigin.

That is correct THERE and wrong HERE. _resolvePlayerOrigin computes where to RETURN TO after a
song, and landing on the Songs list from the dashboard is the right behaviour. But #v3-home is
the v3 DASHBOARD — a real screen that the shell's Home nav, the onboarding tour and the dashboard
re-render listener all target. Redirecting it would have made Home unreachable.

A LEGACY ALIAS IS NOT THE SAME THING AS A RETURN TARGET. Only 'home' is mapped now, and a test
pins that: re-adding 'v3-home' to the guard fails it.

4 tests, bite-tested both ways.

node 1049, pytest 2425, ESLint 0, Codex 0.

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

* fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924)

window.showScreen was wrapped by THREE independent parties, each capturing whatever happened to be
there at the time:

    app.js publishes the raw function
      -> static/v3/shell.js wrapped it (to call syncActive, and to map home -> v3-songs)
      -> the stems plugin wrapped it AGAIN (to tear down on leaving the player)

Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled. A capture
taken before shell.js installed silently dropped the mapping it carried — and the library opened on
the dead legacy #home screen. Testers saw that as "randomly, the library shows the old interface"
(#923).

#923 fixed the symptom by moving the mapping inside showScreen. This removes the CAUSE: neither
wrapper ever needed to be one.

━━━ TWO EVENTS, AND THE DISTINCTION IS THE WHOLE POINT ━━━

    screen:changing  emitted BEFORE anything happens. "I am leaving `from`." Teardown/cancel here.
    screen:changed   emitted after the DOM and data settle. "I am on `id`." Now carries `from`.

screen:changing is new, and it exists because Codex caught me collapsing the two. The stems plugin
tore down its audio graph BEFORE showScreen did anything; screen:changed fires at the very END,
after core awaits library and provider loads — so moving the plugin onto it would have delayed
teardown behind a slow fetch, or skipped it entirely if that fetch threw, and stems would keep
playing on a non-player screen. A test pins the ordering: screen:changing must precede the first
await.

shell.js is a plain screen:changed listener now, like app.js, audio-mixer.js and tour-engine.js
already were. window.showScreen is an unwrapped function again, and tests/js/
no_showscreen_monkeypatch.test.js fails CI if anything in static/ ever assigns to it again — so the
hazard is structurally impossible rather than merely avoided.

━━━ AND A FALLBACK THAT COULD NEVER FIRE ━━━

My retry-if-the-bus-is-late path listened for `slopsmith:capabilities:ready`. Core dispatches
`feedBack:capabilities:ready` (capabilities.js:1536) — the slopsmith: name is the PRE-DMCA event
and nothing has emitted it since the rename. Codex caught it. A guard that cannot fire is worse
than no guard: it reads as protection and is decoration.

(The same dead-event bug turned out to be sitting in THREE of the stems plugin's fallbacks, where
it has silently disabled its lifecycle wiring whenever the bus was late. Fixed in
feedback-plugin-stems#38.)

VERIFIED. A/B against origin/main: the nav highlight and topbar title follow IDENTICALLY with
shell.js as a listener; screen:changing -> screen:changed fire in order with the right {id, from};
window.showScreen is unwrapped; and showScreen('home') still lands on v3-songs.

node 1053, pytest 2425, ESLint 0, Codex 0.

Closes #924

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-12 16:06:28 +02:00
f27d4f623c fix(nav): the library sometimes showed the legacy screen — map 'home' inside showScreen (#923)
Testers: "randomly, when moving to the library from another menu option, the library shows the
old interface — never when a song ends."

━━━ WHAT WAS ACTUALLY HAPPENING ━━━

#home is the PRE-V3 library screen. The v3 shell replaced it with #v3-songs, and the mapping DID
exist — but only inside WRAPPERS on window.showScreen, and only for callers that go through
`window`. THREE independent parties monkey-patch it, each capturing whatever happens to be there
at the time:

    app.js publishes the raw function
      -> shell.js wraps it, adding the home -> v3-songs mapping
      -> the stems plugin wraps it AGAIN (src/main.js:1029), capturing the current value

Plugins load ASYNCHRONOUSLY. The chain links up in whatever order the race settles, and any
capture taken before shell.js installs — or any re-assignment after it — silently drops the
mapping. Hence "randomly".

AND THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. Reproduced
in a browser: the unwrapped function with 'home' lands on the dead legacy screen EVERY time.

"Never when a song ends" is the tell, and it is what identified the mechanism: closeCurrentSong
resolves its target through _resolvePlayerOrigin(), which ALREADY applies this mapping. That one
path was fine — which is exactly why the bug looked random rather than total.

PRE-EXISTING, not a regression from the module carve: the onclick="showScreen('home')" links and
the wrapper-only mapping both date to 2026-06-22.

━━━ THE FIX ━━━

The guard lives inside showScreen now: ONE place, in the function every caller routes through,
instead of a chain of monkey-patches that must each remember. Wrapper order stops mattering, and
the module-internal callers are covered for the first time.

Verified in a browser: the raw, unwrapped showScreen('home') — which reproduced as #home — now
lands on #v3-songs, and cannot be undone by any wrapper order.

━━━ AND A [P1] I INTRODUCED, WHICH CODEX CAUGHT ━━━

My first cut mapped BOTH 'home' and 'v3-home', copied straight from _resolvePlayerOrigin.

That is correct THERE and wrong HERE. _resolvePlayerOrigin computes where to RETURN TO after a
song, and landing on the Songs list from the dashboard is the right behaviour. But #v3-home is
the v3 DASHBOARD — a real screen that the shell's Home nav, the onboarding tour and the dashboard
re-render listener all target. Redirecting it would have made Home unreachable.

A LEGACY ALIAS IS NOT THE SAME THING AS A RETURN TARGET. Only 'home' is mapped now, and a test
pins that: re-adding 'v3-home' to the guard fails it.

4 tests, bite-tested both ways.

node 1049, pytest 2425, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:05:50 +02:00
57e7db5c2a refactor(app): carve the keyboard-shortcuts subsystem into static/js/shortcuts.js (R3d) (#922)
19 declarations + 23 TOP-LEVEL STATEMENTS. 922 lines. app.js 3,243 -> 2,325 (-28%).

The panel registry, both global keydown dispatchers, the library arrow-nav, and the whole
plugin-facing shortcut API.

━━━ MOST OF THIS SUBSYSTEM WAS NOT DECLARATIONS ━━━

A declaration-seeded dependency closure reports this cluster as 10 names, 246 lines.
It is 42 statements and 922.

window.registerShortcut, createShortcutPanel, getAllShortcuts, unregisterShortcut,
clearWindowShortcuts, the panel registry, and BOTH global keydown dispatchers are bare TOP-LEVEL
STATEMENTS at app.js's top level. A call-graph scan sees NONE of them.

That blind spot has now cost three times:
  * it nearly shipped a dead library A-Z rail (#896) — 43 of library.js's exports were
    referenced only from app.js's window contract;
  * it threw "Assignment to constant variable" in the session carve (#921), where the autoplay
    gate's top-level statements wrote state that had just become a read-only import;
  * and here it under-reported the slice by 3x.

The extractor takes them by construction now — any top-level statement that TOUCHES a moved
binding comes along — and the SEED is closed to a FIXED POINT, because those statements have
their own dependencies (_modifiersMatch, _isShortcutActive, _handleLibArrowNav, _gridColumns…)
that the declaration closure never walked. Seed -> pull the statements -> the statements need
more names -> re-seed. Iterate until it stops growing.

━━━ syncLibrarySong GOES ACROSS THE SEAM, NOT THROUGH AN IMPORT ━━━

The library arrow-nav calls it on Enter. It cannot be imported: syncLibrarySong reaches
showScreen/playSong, and a module importing app.js closes a cycle. It is the ONE name here that
had to stay behind, so it comes across the host seam — which is exactly what the seam is for.
host.js throws loudly if the wiring is ever dropped, and tests/js/host_contract.test.js fails in
CI if the hook drifts.

VERIFIED. A/B against origin/main in two browsers, IDENTICAL, zero page errors — and driven for
real, not merely present: the plugin API (register / unregister / getAll / panels), THE GLOBAL
KEYDOWN DISPATCHER actually firing a registered shortcut, that same shortcut correctly SUPPRESSED
while typing in a text input, and `?` opening the help modal.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:05:25 +02:00
545e569ad6 refactor(app): carve the song session out of app.js — playSong, showScreen, closeCurrentSong (R3d) (#921)
36 declarations + the 4 autoplay/auto-exit gate statements. 359 lines.
app.js 3,772 -> 3,242. Bodies VERBATIM.

━━━ THIS WAS "THE UNCUTTABLE HEART", AND IT IS 359 LINES ━━━

At the start of this epic, seeding a dependency closure from count-in, from loops, from
section-practice, or from the JUCE seek shim all returned the SAME 178-function, 3,360-line set.
playSong and showScreen called each other; everything called them; nothing could be cut anywhere.
The conclusion — correct at the time — was that NO closure-based carve could touch it at any
seed, and the answer was a host seam.

That was true THEN. Every slice taken out since (transport, loops, count-in, section-practice,
the library, the edit modal, settings) removed edges, and the strongly-connected component
DISSOLVED. This closure is 36 declarations with an interface width of FOUR.

The lesson is not that the seam was wrong — the seam is what MADE this possible, by letting the
carves proceed against a cyclic core instead of stalling on it. The lesson is to RE-MEASURE. An
SCC is a fact about a graph at a moment, not a property of the code.

━━━ THE BUG NO SCAN COULD SEE, AND THE A/B DID ━━━

First cut passed every gate — no-undef clean, no-cycle clean, 1045/1045, pytest green — and
THREW IN THE BROWSER: "Assignment to constant variable."

window.feedBack.holdAutoplay / holdAutoExit and their two event handlers are TOP-LEVEL
STATEMENTS, not declarations. They WRITE this cluster's state (_autoplayHeld, _autoExitTimer, …),
and an imported binding is READ-ONLY — so left behind in app.js, every one threw the instant the
module existed.

A dependency scan that walks DECLARATIONS cannot see them. Mine didn't. This is the same blind
spot that nearly shipped a dead library A-Z rail (#896): app.js keeps its public API in top-level
statements, and a call-graph is blind to every one of them.

The extractor now finds them by construction — any top-level statement that WRITES a moved
binding comes with the carve — and the gate statements live beside the machinery they drive,
which is where they belonged anyway.

━━━ ZERO OUTSIDE WRITES, BY MOVING THE BOUNDARY RATHER THAN BUILDING MACHINERY ━━━

The autoplay scalars and the wake-lock state were written from outside the cluster, which would
have forced a setter or a state container. But the writers — _releaseAutoplay, _acquireWakeLock —
plainly belong here. Pulling them in left ZERO outside writes, so every export is a plain import.
Same move as settings (#920): measure the writers before you reach for a container.

VERIFIED. A/B against origin/main in two browsers, IDENTICAL, zero page errors — including the
autoplay gate driven end to end: a plugin HOLDS autoplay, the song loads but does not start, the
RELEASE fires it, and a stale release is a no-op. That is the exact machinery that was throwing.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:22:19 +02:00
84fe29688c refactor(app): carve settings into static/js/settings.js (R3d) (#920)
22 declarations, 446 lines. app.js 4,218 -> 3,772. Bodies VERBATIM.

Settings load/save, the AV-offset nudge, the default-arrangement pin, the instrument pathway,
and the app-update channel.

━━━ INTERFACE WIDTH 1, AND IT GOT THERE BY DRAWING THE BOUNDARY IN THE RIGHT PLACE ━━━

app.js calls loadSettings() and nothing else.

The first cut was NOT clean: _defaultArrangement was written from OUTSIDE the cluster, and an
imported binding is READ-ONLY, so that one write would have forced a setter or a state
container — as it did for the player (player-state.js) and the library (library-state.js).

But the writers were saveSettings and pinCurrentArrangementDefault, which ARE settings
functions. Widening the slice to include them left ZERO outside writes. Every export is now a
plain read-only import and no container is needed.

Worth naming, because I reached for a container twice before: the fix for "this binding is
written from outside" is sometimes a container, and sometimes it just means the boundary is in
the wrong place. Measure the writers before you build machinery.

━━━ handleSliderInput STAYS A HOST HOOK, DELIBERATELY ━━━

It lives in settings now (it is a settings control), but player-controls.js must NOT import it:
this module already imports player-controls (_applyMastery, _autoplayExitEnabled, …), so a
direct back-import would close a cycle. player-controls keeps reading it through the host seam,
and app.js — the root, which imports both — wires it. That is exactly what the seam is for, and
the contract test proves the wiring survived.

VERIFIED. A/B against origin/main in two browsers: the window contract, the settings screen
rendering, the AV-offset and default-arrangement controls present, and a real `input` event
dispatched on a slider — which is the path that goes through the host seam. IDENTICAL, zero
page errors.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:08:45 +02:00
69aac32278 refactor(app): carve the edit-song modal into static/js/edit-modal.js (R3d) (#919)
4 functions, 234 lines. app.js 4,452 -> 4,218. Bodies VERBATIM.

INTERFACE WIDTH ZERO — nothing in app.js calls into this cluster. app.js needs only the names
on the window contract, so the markup's onclick= handlers resolve. That is what makes it the
cleanest slice left.

AND IT ONLY BECAME CLEAN BECAUSE THE LIBRARY CAME OUT FIRST (#896). Every dependency the modal
has is a module now: it reads six bindings out of ./library.js (loadLibrary, loadFavorites,
loadTreeView, _removeLibCardsForFilename, libView, _lastLibSelected) plus dom.js and the L
container. Before that carve, extracting this would have dragged the whole library with it.

Checked, and it matters: the modal never WRITES any of those six. An imported binding is
READ-ONLY, so a single write would have forced a setter or a state container. Every use is a
read, so plain imports suffice.

Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.

VERIFIED. A/B against origin/main in two browsers: the window contract, the modal actually
OPENING off a real library row, its title and year fields rendering, and the data-edit-save
wiring (rather than an inline onclick embedding the filename — the fix this cluster's harness
exists to guard). IDENTICAL, no new page errors.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:43:57 +02:00
0a6e0309e5 fix(tailwind): stop the dev server rewriting a tracked file (#911) (#918)
The runtime stylesheet moves to CONFIG_DIR. static/tailwind.min.css is never written again.

━━━ TWO DIFFERENT THINGS WERE SHARING ONE PATH ━━━

    static/tailwind.min.css   a BUILD ARTEFACT. Committed, image-baked, generated by scanning
                              the in-tree plugins only. CI's tailwind-fresh check verifies it.
    the RUNTIME sheet         PER-INSTALL STATE. Additionally scans whatever the user installed
                              into FEEDBACK_PLUGINS_DIR, so it differs machine to machine.

Writing the second over the first meant that MERELY RUNNING THE DEV SERVER from a git checkout
silently modified a tracked file. `git add -A` then swept a 100KB reshuffle of minified CSS
into the commit and ci/tailwind-fresh went red with a diff that explains nothing — on a PR
whose real change touched no Tailwind classes at all. It also wrote app state into the app
directory, which is read-only in some deploys.

A new route serves the runtime sheet when there is one and falls back to the committed one
otherwise. It is registered BEFORE the /static mount, which would otherwise swallow the path.

━━━ A PERSISTED SHEET MUST NOT OUTLIVE ITS REASON (Codex [P2] x2) ━━━

1. THE USER REMOVES THEIR PLUGINS. Startup only rebuilds when user plugins exist, so nothing
   would ever overwrite the stale sheet — and it still carries classes for plugins that are
   gone. With no user plugins the COMMITTED sheet is complete by definition. Guarded.

2. THE APP IS UPGRADED, and my first guard for this was WRONG. I compared mtimes. Codex: that
   is not a freshness signal across install methods — archives and container images routinely
   PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER timestamp than a
   runtime sheet a user built days ago. The mtime check then calls the stale one FRESH and it
   masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is present to
   trigger a rebuild.

   Freshness is decided by CONTENT now. Each runtime build stamps a sidecar with the sha256 of
   the committed sheet it was made from. Core ships new CSS -> that file changes -> the hash
   changes -> the runtime sheet is correctly judged stale. Timestamps only gesture at the
   question that hashing answers.

Falling back to the committed sheet is always safe: at worst it lacks a just-installed plugin's
classes for the seconds until the async rebuild lands.

VERIFIED END TO END. Ran the real dev server with 3 plugins installed: it rebuilt Tailwind over
them (123,291 bytes), wrote the sheet + sidecar to CONFIG_DIR, still served /static/
tailwind.min.css at 200 — and `git diff` on the tracked file came back CLEAN.

8 tests. Bite-tested: reverting to the shared path fails 3, dropping the staleness guards fails
2 more.

pytest 2425, pyflakes 0, Codex 0.

Closes #911

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:25:16 +02:00
36cf77dc44 refactor(highway): carve the 2D drawing layer into highway-draw.js (R3c) (#917)
18 functions, 1,245 lines. highway.js 3,972 -> 2,727 (-31%). The biggest R3c slice: notes,
sustains, chords, strum groups, unison bends and lyrics — everything the default renderer
paints each frame.

━━━ MUTABILITY, NOT LOCATION, DECIDES WHERE A THING BELONGS ━━━

Three per-instance caches came out with this slice, and they are why it needed care:

    _frameMismatchWarned   a warn-once Set of chord ids     (feedBack#88)
    _chordRenderInfo       a WeakMap of chord -> chain info
    _lyricMeasureCache     Map<fontSize, Map<text, width>>

All three are MUTATED. Left at module scope they would be SHARED ACROSS PANELS — one
highway's lyric widths and chord chains stomping another's, silently, with nothing throwing.
createHighway() is a factory (the constitution publishes window.createHighway so a plugin can
build a second highway), so they are lifted onto hwState, which is exactly what hwState is for.

The shimmer LUT went the OTHER way — to MODULE scope in highway-geometry.js. It is a
deterministic xorshift table, byte-for-byte identical for every instance, so sharing it is not
merely safe but BETTER: built once for the page rather than once per panel.

Same slice, opposite directions, decided entirely by whether the thing mutates.

━━━ MY SCRIPT WAS WRONG TWICE. THE GATES CAUGHT BOTH. ━━━

1. HAND-LISTED THE MOVE SET. I listed 10 functions and missed six that drawChords needs
   (_ensureChordRenderCache, bsearchChords, getChordTemplateInfo, _computeChordBox,
   _updateFretLinePreview, _drawFretLineChordPreview). The no-undef gate named every one. The
   set is now DERIVED from the dependency closure — 18, not 10.

2. JUDGED PURITY TOO EARLY, and this one is subtle. I classified _computeChordBox as pure
   because its ORIGINAL body never mentions hwState. Then the call-site rewriter injected
   `fretX(hwState, …)` INTO it — fretX takes hwState now (#916) — leaving a function that
   references an hwState it was never given. Purity has to be judged from the body AS IT WILL
   BE, so the classifier iterates to a fixed point: a function needs hwState if it mentions it,
   OR calls anything that now takes it. That moved _computeChordBox to the stateful side.

VERIFIED. A/B against origin/main: IDENTICAL, zero page errors. The PLUGIN BUNDLE contract is
byte-identical (b.fretX arity 3, b.getNoteState arity 2, both stable references, both correct
under the old calling convention). PERF GATE PASSES AT 1.92ms against its 12ms budget — and
this is the slice that could really have cost something: the ENTIRE per-frame drawing path is
now cross-module. It costs nothing measurable.

TESTS. highway_teaching_marks follows strumGroupBuckets to the new module. The two source-shape
harnesses now read highway.js AND every static/js/highway-*.js, rather than being re-pinned at
whichever file currently holds a function — re-pinning breaks again next time, and a shape
assertion that silently stops finding its target is indistinguishable from one that passes.

node 1045, pytest 2416, ESLint 0, no-undef 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:00:29 +02:00
12eb73aee9 refactor(highway): carve the STATEFUL primitives, threading hwState explicitly (R3c) (#916)
fretX, fillTextReadable, _noteState, _paintGemGlow -> static/js/highway-state-primitives.js.
50 call sites rewritten. highway.js 4,105 -> 3,965.

The first slice that changes signatures. Each of these four gains hwState as an explicit
FIRST PARAMETER.

━━━ hwState IS A PARAMETER, NOT AN IMPORT ━━━

createHighway() is a FACTORY. The constitution publishes window.createHighway so a plugin can
build a SECOND highway for its own panel, and highway.js says so itself. Import hwState as a
module singleton and two panels silently share one clock, one render scale, one string
palette — each driving the other. Nothing throws. The picture is just wrong, in a way no test
would catch.

(The exact opposite of the app.js carve, where player-state.js and library-state.js ARE
module singletons — correctly, because there is exactly one app. Same epic, same language,
opposite answer, decided entirely by whether the thing is a factory.)

━━━ THE PLUGIN BUNDLE NEARLY BROKE, SILENTLY ━━━

The renderer bundle hands two of these STRAIGHT TO PLUGINS:

    b.fretX = fretX;
    b.getNoteState = _noteState;   // stable reference

highway_3d calls both EVERY FRAME, with the old arity. Handing out the new 3-arg versions
would have passed `note` where hwState belongs — no throw, no error, just wrong geometry and
wrong judgment state INSIDE A PLUGIN, which no core test would ever see. Green CI, broken 3D
highway.

So hwState is bound ONCE per instance, in the factory, and the bundle hands out those views.
A per-frame arrow would have fixed the arity and reintroduced exactly the per-frame allocation
the bundle's stable-reference contract (feedBack#254) exists to prevent. b.project needs none
of this — project() is pure and its arity never changed.

VERIFIED IN A BROWSER, against the real bundle, on both builds:

    fretX arity                      3    3     (NOT 4 — the bound view preserves it)
    getNoteState arity               2    2
    fretX(5,1,800) in 0..800      True True
    getNoteState null w/o provider True True
    getNoteState honours provider  True True
    fretX is a stable reference    True True

IDENTICAL. Without the bound views fretX would have reported arity 4 and computed garbage.

Also caught on the way: my generated module imported STRING_BRIGHT_FALLBACK, a name
highway-constants.js does not export. ESLint does not flag that — but importing a name a
module does not export is a runtime SyntaxError that kills the WHOLE module. These four need
no constants at all; the import is gone.

TESTS. highway_note_state pins the signature AND the stable-reference contract — it caught the
bundle break. Retargeted at the module and the new arity; both contracts still asserted, and
the "no fresh arrow per frame" rule is now asserted explicitly rather than implied by
`getNoteState: _noteState`.

PERF GATE PASSES AT 1.94ms against its 12ms budget — fretX and _noteState are now CROSS-MODULE
calls, per note, per frame. It costs nothing measurable.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:42:38 +02:00
1a386c272d refactor(highway): carve the PURE geometry primitives into highway-geometry.js (R3c) (#915)
6 functions, 53 lines. highway.js 4,158 -> 4,105. NOT ONE CALL SITE CHANGES.

project, roundRect, bnvNormalizedPoints, teachingFingerLabel, teachingDegreeLabel,
chordHarmonyLabels — the shared primitives every drawing function leans on.

━━━ PURITY IS THE WHOLE POINT OF THIS SLICE ━━━

Every one of these is a pure function of its arguments. None touches hwState. None closes over
the canvas context — roundRect() already took `ctx` explicitly, and the rest need nothing but
numbers. project() reads only the module-level constants from #914.

That matters because createHighway() is a FACTORY: a plugin can build a second highway for its
own panel, so anything holding per-instance state must be PASSED hwState rather than importing
it, or two panels silently share one clock and palette. These six hold no state at all, so
they move VERBATIM — the module boundary is invisible to every caller.

The asserts are mechanical and in the extractor: it REFUSES to move a function whose body
mentions hwState, or that references `ctx` without taking it as a parameter. Purity is
checked, not assumed.

━━━ WHAT IS DELIBERATELY LEFT BEHIND ━━━

The four primitives that DO need hwState — fretX, fillTextReadable, _noteState, _paintGemGlow
— stay in the factory for now. They need an explicit hwState parameter threaded through 53
call sites, which is a real behavioural change and belongs in its own commit rather than
smuggled in beside a provably-identical move. Separating the provable from the risky is the
whole discipline of this epic.

TESTS. Three harnesses brace-match these functions out of the source and run them in a
sandbox; they now read static/js/highway-geometry.js. `export function x` still contains
`function x`, so the extractor needed no change — only the path.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. PERF GATE PASSES AT
1.91ms against its 12ms budget — and this is the one that could plausibly have cost something:
project() runs for every visible note on every frame and is now a CROSS-MODULE call. It costs
nothing measurable. That is the answer #910 was built to give.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:31:56 +02:00
8e89b39ad3 refactor(highway): carve the constants into static/js/highway-constants.js (R3c) (#914)
29 constants, 190 lines. highway.js 4,267 -> 4,158. The first real slice, and the one that
every later one imports.

━━━ WHY ONLY THE CONSTANTS MAY LIVE AT MODULE SCOPE ━━━

createHighway() is a FACTORY, not a singleton. The constitution publishes
window.createHighway precisely so a plugin can build a SECOND highway for its own panel, and
highway.js already says so at the top of the closure:

    // R3c: per-instance mutable state in one object, so extracted renderer/ws
    // modules can close over it as a factory arg without cross-panel sharing.

So hwState — all 79 mutable properties — must NEVER become a module-level singleton: two
highways would silently share it, and one panel would drive the other's clock, scale and
colour tables. Extracted functions will take it as an ARGUMENT.

That is the OPPOSITE of the app.js carve, where a single state container (player-state.js,
library-state.js) was exactly right, because there is exactly one app. Same epic, same
language, opposite answer — because one is a singleton and the other is a factory.

These 29 are pure literals: numbers, strings and colour tables, never reassigned, never
mutated. Sharing them across instances is not merely safe, it is what you want — one copy of
the shimmer LUT bounds and the string palettes rather than one per panel. Anything with a
runtime dependency (document, window, performance, localStorage) stays in the factory;
checked, and none of these has one.

ESLint now knows static/highway.js is a module. It could not have known before this commit:
the flip (#913) changed the SCRIPT TAG, but the file had no import/export yet, so it still
parsed as a script and lint stayed green. The first `import` is what makes the config wrong.

TESTS. Four source-shape harnesses asserted `const _AUTO_SCALE_MIN = …` etc. lived in
highway.js. They now read highway.js AND every static/js/highway-*.js — deliberately, rather
than being re-pinned at whichever file currently holds a constant. Re-pinning just breaks
again on the next carve, and a source-shape assertion that silently stops finding its target
is indistinguishable from one that passes. Bite-tested: renaming two constants away fails
them.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. AND THE PERF GATE
PASSES AT 1.97ms against its 12ms budget — which is the point of having built it (#910)
first: these constants moved from closure scope to module scope, and V8 does not treat those
identically. It does here. Now I know rather than hope.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:24:40 +02:00
c6963fdf30 refactor(highway): flip highway.js to an ES module (R3c) (#913)
Two lines. index.html: defer -> type="module". highway.js: one explicit assignment.
highway.js can now `import`, which is the whole point — the carve can begin.

━━━ THE ONE THING THE FLIP ACTUALLY BREAKS: window.createHighway ━━━

A top-level `function createHighway()` in a CLASSIC script IMPLICITLY becomes
window.createHighway. In a module it does not — module declarations are module-scoped, and
the name vanishes from the global object the instant the tag grows type="module".

The constitution names window.createHighway as PUBLIC EXTENSION CONTRACT (alongside
window.playSong / showScreen / feedBack). NOTHING IN-TREE CALLS IT. That is exactly why this
would have shipped: the only consumers are third-party plugins rendering their own highway
panel, and I cannot grep those. Green CI, green tests, and a broken plugin API.

Verified by removing the assignment and reloading:

    flip WITHOUT an explicit assignment:  window.createHighway === undefined   <-- gone
    flip WITH it:                         window.createHighway === function

So it is assigned explicitly now — same object, same behaviour, no longer an accident of how
the file happens to be loaded.

━━━ AND A CORRECTION TO #912 ━━━

#912 (merged) rewrote 73 bare `highway.x` -> `window.highway.x` on the stated grounds that
the flip would turn every one of them into a ReferenceError. HAVING NOW ACTUALLY FLIPPED IT,
THAT WAS WRONG. highway.js already did `window.highway = highway`, which puts the name on the
GLOBAL OBJECT — and bare-identifier resolution falls back to the global object whether or not
a lexical global binding exists. Measured on both builds: bare `highway` resolves either way.

#912 is defensible as hygiene and it does not hurt, but it was not a precondition and it fixed
no latent bug. A correction is posted on the PR so its commit message does not mislead. The
real hazard was the factory, not the instance — same class of breakage, wrong name.

ORDERING is unchanged: classic-defer and non-async type="module" share ONE post-parse
execution queue, in document order, so highway.js keeps its position at index.html:1244.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors — window.highway,
window.createHighway, the full API surface, a real song playing, the chart clock advancing,
and the seek->setTime sync. THE PERF GATE PASSES at 1.85ms against its 12ms budget (module
evaluation costs nothing at render time), which is exactly what #910 was built to tell me.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:07:28 +02:00
d9fa6d3f55 refactor(highway): make the highway global explicit before the module flip (R3c) (#912)
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files.
Provably a NO-OP today. It is the precondition for flipping highway.js to a module.

━━━ WHY THIS HAS TO LAND FIRST ━━━

highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore
creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND
to every ES module. 73 call sites quietly rely on that.

The moment highway.js becomes a module, that binding is gone. `const` in a module is
module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the
flip is impossible until they say what they mean.

`window.highway = highway` is already set, to the same object, on the same line. So this is
an identity rewrite — verified in the browser below.

━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━

1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`.
   Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted
   `const window.highway = window.highway`. Excluded.

2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read
   `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`.
   The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching
   them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE
   and the code behind it silently never runs. transport.js's was the seek->setTime sync:
   the chart clock would have quietly desynced after every seek, with nothing failing.
   All six now test window.highway.

3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114
   and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep
   searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch
   swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0.

Regex missed a shadow, a half-conversion, and two bare reads. The final check is an
AST pass that resolves scopes and reports every `highway` identifier not bound locally.
It now reports ZERO.

VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors:
window.highway is the same object as the bare global, the whole API surface resolves, a real
song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes,
which is the exact guard I nearly broke in (2).

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:56:07 +02:00
23ecddc721 test(highway): the R3c perf gate — measure the render loop before carving it (R3c) (#910)
highway.getPerf() (additive) + tests/browser/highway-perf-baseline.spec.ts.
No behaviour change. This lands BEFORE highway.js is touched, because a perf-gated refactor
without a perf gate is just a refactor.

━━━ FRAME RATE IS THE WRONG THING TO MEASURE ━━━

The highway AUTO-SCALES. When the smoothed draw cost passes _DRAW_BUDGET_HI_MS (12ms) it
LOWERS THE RENDER RESOLUTION to protect the frame rate (#654). Exactly right for players —
and it means a real perf regression does NOT show up as dropped frames. It shows up as a
BLURRIER PICTURE at a perfectly healthy 60fps.

Benchmark fps and you measure the feedback loop, not the renderer, and conclude nothing
changed while the image quietly degrades.

So the gate pins the scale (setRenderScale(1) + setMinRenderScale(1), which clamps autoScale
to [1,1]) and measures drawMs — the renderer's own cost. None of that was reachable before:
neither drawMs nor the effective scale escaped the closure. Hence getPerf().

The threshold is the app's OWN: _DRAW_BUDGET_HI_MS is the cost at which the highway itself
starts sacrificing resolution in production. Exceeding it is not an arbitrary benchmark line
— it is the renderer failing its own budget. Current cost ~2.2ms, so ~5x headroom: far more
than headless-CI variance, far less than any regression worth shipping.

━━━ I WROTE THIS GATE WRONG THREE TIMES. EACH TIME IT PASSED. ━━━

1. VACUOUS ASSERTION. First cut asserted "the auto-scaler wasn't forced to intervene", i.e.
   effectiveScale == 1. I injected a 10x regression (drawMs 2.4 -> 22.4ms, nearly DOUBLE the
   budget) and it PASSED. Of course it did: setMinRenderScale(1) sets the scaler's FLOOR to
   1, so effectiveScale CANNOT drop below it. The very pinning that stops the scaler hiding
   a regression also stops it ever reporting one. A guard that cannot fail.

2. MEASURING AN IDLE RENDERER (Codex [P2]). playSong() takes ~3-4s to actually start — it is
   fetching and decoding stems. My "if not playing after 2s, togglePlay()" fired BEFORE
   autoplay, started playback, and then the app's own autoplay toggled it straight back to
   PAUSED. The renderer idled through the entire measurement. Now it WAITS for playback
   rather than racing it, and asserts the chart clock advanced DURING the sampling window —
   not merely at some point beforehand, which the first fix would have accepted.

3. UNENCODED FILENAME (Codex [P2]). playSong() decodes its argument before building the
   /ws/highway path, so every real caller passes encodeURIComponent(filename)
   (app.js:2879, 4137). Raw, a name containing # ? % or / yields an invalid WebSocket URL,
   the song never loads — and on those libraries the gate would have silently measured an
   idle renderer instead of failing.

Every one of those bugs made the gate PASS. That is the whole hazard of a perf test: it
fails safe in the wrong direction.

BITE-TESTED, and this is the only reason I trust it: a 10x regression injected into the draw
path FAILS the gate under live playback (22.0ms vs the 12ms budget) and the clean build
passes at ~2.1ms with the chart clock advancing 5.6s across the sample.

node 1045, pytest 2412, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:36:11 +02:00
79825af28e fix(demo): the janitor re-entry guard actually works now (#902) (#909)
The guard in startup_events() read:

    if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
            and not _DEMO_JANITOR_STARTED:

`and` binds tighter than `or`, so that is `A or (B and C)`. The not-already-started half
never ran when the env var was truthy — the only case that reaches it at all. A second
startup started a SECOND janitor thread, overwrote the handle, and shutdown then joined
only the last: the first leaked and kept firing registered hooks hourly, forever.

The guard now lives INSIDE start_janitor(). A caller cannot get operator precedence wrong
if there is nothing left for it to get wrong.

━━━ THREE WAYS TO WRITE THIS GUARD WRONG. I HIT ALL THREE. ━━━

1. NO GUARD — the original bug. Double-start, orphaned thread.

2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). Codex [P2]. stop_janitor()
   DELIBERATELY leaves that flag True when a hook outruns its join timeout, so that a later
   startup cannot spawn a janitor beside a live one. But the hook usually finishes a moment
   later: the thread exits and the flag is stale. A flag-keyed guard then refuses to start a
   replacement for the rest of the process — demo cleanup silently dead. (The original bug
   accidentally MASKED this by always starting.)

3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). Codex [P2], second pass. A
   timed-out stop leaves the old thread ALIVE BUT DOOMED — its stop event is set and it
   exits as soon as its current hook returns. Treating that as a running janitor skips the
   replacement, and we are back at (2) a second later.

So: a janitor counts as running only if its thread is alive AND it has not been told to stop.

━━━ AND EACH JANITOR NOW OWNS ITS STOP EVENT ━━━

start_janitor() used to `_DEMO_JANITOR_STOP.clear()` a single SHARED Event. Start a
replacement while a doomed thread is still finishing a hook and that clear RESURRECTS it: it
loops back to wait(), sees the flag cleared, and carries on. Two janitors — the exact bug we
started from. A fresh Event per janitor makes it impossible; the old thread waits on its own
event, which stays set, so it can only exit.

Env semantics UNCHANGED, verified across every value ("", "1", "0", "true", "false", "off"):
the old expression and demo_mode_enabled() agree on all of them. The only behavioural change
is the idempotency fix.

FOUR tests, and each of the three wrong guards fails a different subset:

    no guard              -> 2 fail   (double start; orphaned thread)
    guard on the flag     -> 2 fail   (never restarts after a timed-out stop)
    liveness alone        -> 1 fail   (no replacement for a doomed janitor)
    liveness + not-stopping -> all pass

pytest 2416, pyflakes 0, Codex 0.

Closes #902

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:30:53 +02:00
f5d448af5c refactor(server): carve demo mode into lib/demo_mode.py (R3b) (#903)
lib/demo_mode.py (342). server.py 1,870 -> 1,649.

The read-only request guard (its 96-entry blocked-route table + the middleware) and the
hourly session janitor (registry, hook runner, thread). Bodies VERBATIM.

THE MIDDLEWARE NEEDS `app`, SO THE MODULE TAKES IT. _demo_mode_guard is an
@app.middleware("http") and cannot exist without an app object. Rather than have a module
under lib/ reach for a global, it exposes install(app) and server.py — which owns the app —
hands it over. The janitor is symmetrical: start_janitor() / stop_janitor(), called from
server.py's startup and shutdown hooks, where the process lifecycle actually lives.

register_demo_janitor_hook IS PART OF THE PLUGIN CONTRACT. It is a key in plugin_context,
so plugins hold it as a LIVE REFERENCE from setup(). server.py imports this exact object
and puts it in the dict unchanged — identity preserved, and
tests/test_plugin_context_contract.py (#898, merged) fails if that ever stops being true.
This is the first carve that guard has actually protected.

━━━ stop_janitor()'s ORDER IS LOAD-BEARING ━━━

The obvious way to write it — clear the "started" flag, then join — is WRONG, and I wrote
it that way first. server.py's original deliberately returns EARLY, leaving
_DEMO_JANITOR_STARTED True and the thread handle intact, when the thread outlives the join:

    # Leave _DEMO_JANITOR_STARTED True so a new janitor is not
    # spawned by a subsequent startup while the old one is alive.

Clearing the flag first quietly reintroduces exactly the double-janitor leak the flag
exists to prevent. Preserved byte-for-byte, and the reason is now written down at the
function rather than only at its single call site.

━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━

    if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
            and not _DEMO_JANITOR_STARTED:

`and` binds tighter than `or`, so this is `A or (B and C)` — the not-already-started
re-entry guard is DEAD whenever the env var is truthy, which is the only case that runs.
A second startup leaks a janitor thread (the handle is overwritten, so shutdown joins only
the last). Verified. Preserved exactly and filed as issue #902: a carve whose whole value
is being provably behaviour-neutral is not the place to change behaviour.

pyflakes caught three more missing imports on the way in (uuid, warnings x2). Five carves,
ten missing imports, every one a NameError on a live path.

pytest 2399, pyflakes 0, Codex 0.

Refs #48

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:32:46 +02:00
8f014e6a30 refactor(server): carve the library scanner into lib/scan.py (R3b) (#901)
lib/scan.py (326). server.py 2,098 -> 1,870.

The background scan, its spawn ProcessPoolExecutor, and the kick/runner plumbing that
serialises passes. Bodies VERBATIM except the seam reads.

Everything shared is read LATE off appstate — the same contract every module in
lib/routers/ uses, and it is not cosmetic: tests monkeypatch CONFIG_DIR and swap meta_db,
so a value captured at import time pins the wrong one for the life of the process.

    CONFIG_DIR        -> appstate.config_dir
    meta_db           -> appstate.meta_db
    _default_settings -> appstate.default_settings()
    _stat_for_cache   -> appstate.stat_for_cache()

━━━ THE SCAN STATUS IS REBOUND, NOT MUTATED ━━━

_background_scan does `global _scan_status; _scan_status = {**INIT, ...}` at every stage
transition. It REPLACES the dict; it never updates it in place. So nothing may hold that
dict by value — a reference captured once goes permanently stale at the first stage change
and would report "listing" forever while the scan ran to completion.

Hence `scan.status()`, a getter, and hence appstate publishes scan_status as a CALLABLE.
appstate.py already said so in a comment; this is the code that makes it true. (Same for
the plugin_context entry, which was already `lambda: dict(_scan_status)` — late-bound, so
it survives the move unchanged. The contract test from #898 covers it.)

━━━ appstate.server_root: A TRAP CLOSED PERMANENTLY ━━━

_background_scan seeds the builtin content, which needs the directory holding server.py.
`Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere under
lib/ — it yields lib/, which holds no docs/ or data/ — and it fails by finding NOTHING
rather than by raising, so the seeds would just quietly never run.

lib/builtin_content.py (#900) closed that by taking the root as a parameter. This adds the
other half: server.py publishes it ONCE as appstate.server_root, so no module under lib/
ever has a reason to derive it. Documented at the slot.

pyflakes caught two more missing imports on the way in (loosefolder_mod, enrichment) —
each a NameError on a live scan path, and the suite would have handed them over one failure
at a time. It stays part of every server.py slice.

TESTS. The two scan fixtures (test_settings_api::scan_module,
test_feedpak_extension::scan_server) patched server._make_scan_executor to swap the spawn
pool for an in-process ThreadPool; they now patch it on lib/scan.py. Worth noting WHY that
still works: the fixtures re-import `server` per test, but `scan` stays cached in
sys.modules — and it picks up the fresh CONFIG_DIR anyway, because the appstate reads are
late-bound. The seam is doing exactly the job it was built for.

━━━ TEST ISOLATION: A REGRESSION THE CARVE ITSELF CREATED (Codex [P2]) ━━━

background_scan() deliberately NEVER sets running=False — ownership of that flag lives in
_scan_runner, so a kick_scan() racing the terminal write cannot observe a stale False and
start a second runner. Correct in production.

But the scan fixtures call background_scan() DIRECTLY, skipping the runner. That was
harmless while the state lived on `server`, which the fixtures RE-IMPORT per test. It is
NOT harmless now: `scan` stays cached in sys.modules across sys.modules.pop("server"), so
the status dict OUTLIVES the test. One direct call leaves the shared scanner marked
"running" forever, and every later scan or rescan returns "already in progress" and quietly
does nothing.

Verified: after a direct call, kick_scan() returns False and starts no scan at all.

The suite passed anyway, on ordering luck — which is exactly how this class of bug ships.
tests/conftest.py::reset_scan_state now snapshots and restores lib/scan.py's module state
around the two fixtures that drive it directly.

pytest 2398, pyflakes 0, Codex 0.

Refs #48

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:26:02 +02:00
6b8f79dd9a fix(server): a raising tuning provider no longer takes down get_merged() for everyone (#899) (#904)
One word. server.py's TuningProviderRegistry.get_merged():

    except Exception:
-       logger.exception("tuning provider %r raised during get_merged()", provider_id)
+       log.exception("tuning provider %r raised during get_merged()", provider_id)

There is no `logger` in server.py — the module logger is `log`. So the handler written to
swallow-and-report a bad provider instead raised NameError from inside the except, and that
NameError propagated out of get_merged().

The effect was the exact OPPOSITE of what the handler is for: one misbehaving plugin took
the whole merged-tunings call down for every other provider, AND the traceback named the
wrong problem ("name 'logger' is not defined" rather than the provider that actually blew
up). Doubly silent: nothing was ever logged either, because the logging call was the thing
that crashed.

Found by pyflakes while carving server.py (R3b). It survived because NOTHING exercised the
failure path — no test ever had a provider raise. That is the whole reason this class of
bug is invisible: it lives only on error paths, so the suite is green and the feature is
broken exactly when it matters.

tests/test_tuning_provider_isolation.py is that path:
  * a raising provider must not lose the HEALTHY providers' tunings, nor the defaults
  * and the failure must actually be LOGGED — swallowing is only acceptable if it reports

Bite-tested: restoring `logger` fails both.

(The log assertion attaches caplog's handler to the feedBack logger directly. It sets
propagate=False, so pytest's root capture sees nothing from it — test_plugins.py has a
capture_logger() for this, but it is not importable here: pyproject pins pythonpath to
[".", "lib"], so `tests` is not a package. Three lines beat churning 21 call sites in an
unrelated file to convert that helper into a fixture.)

pytest 2410, pyflakes 0 undefined names in server.py, Codex 0.

Closes #899

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:25:08 +02:00