The v3 Songs grid appended every scrolled page and never released nodes,
so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a
2000-song library). Replace it with a windowed/recycled render: only the
visible window (± overscan) is in the DOM while a #v3-songs-gridsizer
element sized to ceil(total/cols)*rowH gives the scrollbar full-library
geometry; #v3-songs-grid is absolutely positioned to the first visible row.
- state.songs is a sparse, absolutely-indexed store filled a page at a time
by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll
(O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage
shares an in-flight promise per page and an epoch guard discards a stale
fetch that lands after a reset.
- A–Z rail seeks directly via sort_letters cumulative counts (O(1), no
page-through); bounded scan fallback for legacy providers without it.
- Snapshot/restore is now scrollTop-based (geometry is stable). Select mode,
accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence
survive cards recycling; renderWindow re-renders when select mode toggles.
- Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered
event instead of assuming all cards are present (highway-stutter lesson).
Verified in a browser against a seeded 2001-song library: DOM bounded to
~60 nodes while the count reads "2001 songs", rail jump lands on the target
row, selection survives recycling, scroll-restore exact. Codex-reviewed
(3 findings fixed: stale-fetch epoch guard, await-in-flight page promise,
select-mode resync on cached re-entry).
Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the
bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and
v3_songs_scroll.test.js updated to the new wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements feedBack#636 item 2 (P1) — saved library filters that stay live,
the homelab primitive FeedBack was missing (Plex smart collections / Navidrome
.nsp / *arr custom filters).
A collection is a saved /api/library query surfaced as a registered library
provider, so it appears in the v3 source picker and inherits the whole Songs
UI (paging, stats, A–Z rail, art) with no new screen.
- Storage reuses the playlist subsystem: a `playlists.rules` JSON column
(additive, idempotent migration). A row with rules != NULL is a smart
collection; list_playlists + get_playlist filter `rules IS NULL`, so
collections are excluded from the manual-playlist list and read-only to
every playlist mutation that gates on get_playlist.
- SmartCollectionProvider (kind="local" — matched songs are local rows, so the
client's play/art paths stay on the local branch) delegates query_page/
query_stats/query_artists to the local DB with the stored rules applied;
tuning_names/get_art delegate straight through. Registered via a boot scan +
on create/update (replace=True) / delete.
- Rules mirror the raw /api/library query params; `_sanitize_collection_rules`
drops unknown keys and is applied at API ingress AND on provider load, so a
hand-edited / imported bad value can't crash a query.
- API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as
collection" action in the v3 filter drawer (local provider + active filters
only) that names the current filter set and switches to it.
Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to
local provider, re-sanitize persisted rules).
Tests: tests/test_collections_api.py (CRUD, provider filtering, restart
re-registration, kind=local, corrupt-rule tolerance, playlist isolation),
tests/js/v3_collections.test.js.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf
The Songs page opened cold into a flat sorted grid. This adds a practice-aware
front door on the unfiltered grid, built entirely from data already on hand
(no new endpoints, no new stored state):
- Repertoire meter — "Repertoire: N of M songs · K in progress" + a bar,
counting songs at/above the same mastery threshold the green accuracy badge
uses (>= 0.9 best accuracy) over the unfiltered library total. Reads
state.accuracy (/api/stats/best, already loaded for the card badges) and the
unfiltered /api/library/stats total.
- "Keep practicing" shelf — a horizontal row of recently-played, not-yet-
mastered songs (newest first, click to play). Reads /api/stats/recent.
Both show ONLY on the grid view when not searching/filtering/selecting (the
front-door context), refresh after a song is scored (applyScoreRefresh), and
collapse on an empty library. Soft-gamification only: descriptive encouragement
(goal-gradient / endowed-progress), never content-gating, decay, or nagging —
the practice-accuracy "continue" rail a media server can't do.
Frontend-only: static/v3/songs.js (renderLibraryHome / _repertoireCounts /
libHomeVisible, wired through reload() + applyScoreRefresh), static/v3/v3.css.
Came out of the library design charrette (UX + gamification lenses' top pick).
Stacked on the A–Z rail branch (feat/v3-library-az-rail) since both touch
static/v3/songs.js; merge that PR first (or retarget).
Tests: tests/js/v3_keep_practicing.test.js (threshold, front-door gating,
shelf filter, denominator, render/reload/score-refresh wiring, click-to-play).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(v3 library): correct practice-aware home for review P1/P2/P3
Addresses the PR #635 review findings (manual + Codex):
P1 correctness
- Gate the Repertoire meter + "Keep practicing" shelf to the LOCAL
provider (libHomeVisible). They read local practice stats
(state.accuracy / /api/stats/recent); on a remote provider they mixed a
local mastered count with a remote song total (e.g. "85 of 80") and the
shelf played local files while browsing a remote library.
- Shelf now gates on the per-SONG best (state.accuracy[filename] = MAX
across arrangements, what the green badge shows) and dedupes by filename,
instead of the per-arrangement recents row — so a "keep practicing" card
can no longer show a green "mastered" badge, and a song can't appear twice.
P2 robustness
- renderLibraryHome fetches /api/library/stats + /api/stats/recent together
(Promise.all) and a _homeToken generation guard discards a stale render
so a slow response can't repaint a home the grid already moved past.
P3 polish
- accuracyBadge references MASTERY_ACCURACY instead of a bare 0.9, so the
badge and the meter/shelf can't drift from "the same mastery threshold".
Tests updated (v3_keep_practicing.test.js): provider gating, per-song
deduped shelf, Promise.all + token.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* feat(v3 library): A–Z fast-scroll jump rail on the Songs grid
Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the
right edge next to the scrollbar so you can jump the library to a starting
letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between
letters. The classic (v2) tree already had letter selection; this brings the
new v3 grid to parity (it was the gap behind the "alphabetical scroll
selection next to the scrollbar" idea).
It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only
offers letters present in the current sort AND filter set, so a tap always
lands on a real card (absent letters are dimmed + non-interactive). The grid
is forward-only, server-paged infinite scroll with no virtualization, so a
jump pages through to the target card then scrolls to it; a token guards
overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized
window is the scaling follow-up for very large libraries.
Backend: /api/library/stats gains an optional `sort` param and an additive
`sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist
or title), filter-synced — so the rail's present-letters match the grid's real
order. The legacy `letters` (distinct-artist) field is unchanged, so the
dashboard + classic tree are unaffected. `sort` is dropped for providers whose
query_stats predates it (existing kwarg-filter), so third-party library
providers keep working (rail simply falls back / hides).
Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag +
keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble).
Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs-
distinct-artist counting), tests/test_library_providers.py (sort forwarded),
tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(v3 library): harden A–Z jump rail (review P2/P3)
Addresses the PR #634 review findings (manual + Codex):
P2 correctness
- refreshRail prefers the active-sort `sort_letters`; falls back to the
artist-based `letters` only on an artist sort, and hides the rail on a
title sort when a legacy provider returns none (was mislabeling letters).
- reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a
grid that's being rebuilt from page 0.
- songBucket no longer trims, matching the server SQL + grid ORDER BY raw
first-char bucketing (a leading-space title now buckets under '#' on both
sides).
P3 polish
- Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a
magic 4000, keeping large libraries reachable while still bounded.
- Roving tabindex: only the first present letter is tabbable; arrow keys
move it. Removes up to 27 page tab stops.
- `sort_letters` is computed only when the caller opts in
(want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree
skip the extra GROUP BY. Added sort + want_sort_letters to the optional
provider-kwargs so non-introspectable legacy providers drop them.
- _railToken supersedes stale refreshRail responses; hide the rail when no
letters are present instead of rendering disabled buttons.
Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Second half of feedBack-desktop#46. The desktop app monitors through an
in-app amp-sim/tone chain that, once loaded, auto-restores every launch —
an idle high-gain amp on the input is a constant distorted buzz, and the
dry-only monitor mute can't silence it. This adds the "own-rig first"
opt-in so players using their own external amp/rig never get a processed
monitor in the first place.
Core changes:
- New `use_amp_sims` setting (default OFF / own-rig first): GET default,
POST boolean validation, and resettable key — mirroring achievements_enabled.
- Onboarding wizard: a DESKTOP-ONLY step ("How do you want to hear
yourself?") between instrument paths and the calibration challenge. The
web build has no native amp sims, so the step is skipped there (5 steps
on web, 6 on desktop) — gated on window.feedBackDesktop, dot count and
setStep bounds are derived from it. Ticking "Use in-app amp simulations"
POSTs use_amp_sims; default unticked.
The desktop renderer consumes this setting to gate its saved-tone-chain
restore (feedback-desktop PR, paired).
Verified by booting core locally and walking the wizard with Playwright:
web shows 5 dots/no amp step, desktop shows 6 dots, the amp step is
reachable, calibration stays the final "Play it now" step, ticking the box
persists use_amp_sims=true, and there are no page errors. Server-side
GET default / POST validation / reset confirmed via curl.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(v3): content-dependent playlist covers + custom art upload
Playlist cards were a tiny 🎵 emoji on an empty square. Now the cover reflects
the playlist's contents, and you can override it with a custom image.
Cover (in priority order):
- custom uploaded cover, else
- empty playlist -> the icon
- a few songs -> the first song's album art
- 4+ songs -> a 2x2 album-art mosaic
Backend (server.py):
- MetadataDB.list_playlists() returns each playlist's first few still-present
songs' art URLs (`art_urls`) for the content cover.
- GET /api/playlists and GET /api/playlists/{id} add `cover_url` when a custom
cover exists.
- POST/GET/DELETE /api/playlists/{id}/cover — store a small PNG thumbnail under
CONFIG_DIR/playlist_covers/ (PIL-converted, mirroring song-art upload); the
cover is deleted with the playlist. Cover mutators added to _MUTATING_ROUTES.
Frontend (static/v3/playlists.js): playlistCoverHtml(p) renders the rules above;
the playlist detail view gets "Cover" (pick an image) + "Remove cover".
Tests: tests/test_playlists_api.py (art_urls + cover roundtrip / reject-non-image
/ delete-removes-cover — 11 pass) and tests/js/v3_playlist_cover.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(playlists): 400 (not 500) on non-string cover image + bust same-second cover cache
Two review follow-ups on the playlist-cover endpoints:
- POST /cover did `if "," in b64` before any type check, so a non-string
image (e.g. {"image": 123} / null) raised TypeError -> 500. Guard with
isinstance (mirrors the avatar/song-art upload) for a clean 400. +regression
test covering number/null/object/list.
- The cover URL busted only on int(st_mtime) (1s granularity) and GET /cover
sent no cache headers, so a same-second replace/remove/re-upload could serve
a stale image. Use st_mtime_ns in the cache-bust token and add the shared
no-cache header (_ART_CACHE_HEADERS), matching song art.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* feat(v3): add "Add to playlist" to a song's ⋮ More menu
You could only add a song to a playlist via select-mode (checkbox → batch bar).
Add an "Add to playlist" row to each song card's ⋮ overflow menu that targets
that one song, reusing the same picker (pick a listed number or type a new name
to create the playlist).
The select-mode batch flow and the single-song menu now share one extracted
`addFilenamesToPlaylist(filenames)` helper; the menu is `openCardMenu`, shared by
grid cards and tree rows, so both views get it. Tests:
tests/js/v3_add_to_playlist_menu.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(v3): don't clear the batch selection when the playlist picker is cancelled
The extract-helper refactor made batchAddToPlaylist() call finishBatch()
unconditionally, so cancelling (or a failed create) cleared the multi-select
and reloaded the grid — a regression from the original early-return-on-cancel
behaviour. addFilenamesToPlaylist() already returns null on cancel/failure;
gate finishBatch() on a truthy playlist id so the selection is preserved for
a retry. Adds a regression assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Reported on macOS: on a fresh install, pointing at a DLC folder in Settings and
running a scan showed NO songs until an app restart. The scan itself was fine —
_background_scan re-reads config.json fresh, so it scans the new folder and
populates the library — but the v3 Songs grid never reloaded.
The Settings Rescan / Full Rescan handlers only refreshed the classic (v2)
library via loadLibrary(); the v3 grid (static/v3/songs.js) had no listener for
a scan it didn't initiate (only its own upload path self-refreshes via
watchUploadScan). So its cached, pre-DLC (empty) DOM/snapshot survived a sidebar
return until a full reload (restart).
Fix: the rescan handlers now emit `library:changed` (static/app.js). The v3 grid
listens and reloads if it's the active screen, else sets `_libraryDirty` so the
next onV3SongsScreenEnter does a full re-fetch — a short-circuit placed ahead of
every cached-DOM fast-path so it can't restore the stale grid.
Tests: tests/js/v3_library_refresh.test.js guards the emit + the reload/dirty
wiring (DOM/event glue isn't headlessly unit-testable; end-to-end wants an
in-app run of the reporter's flow: set DLC in Settings → scan → Songs populate
without restart).
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
PR #574 added a `stats:recorded` -> in-place accuracy-badge repaint so a
just-earned score shows without restarting the app. But the repaint never
matched a card, so the badge stayed stale until a full render() (app
restart / search / re-enter the screen) -- exactly the "only updates after
a restart" report.
Root cause is a filename key-space mismatch. The event (like song:loading)
carries the filename `encodeURIComponent`'d, because that is what playCard
hands to playSong (the highway WS decodeURIComponent's it). Library cards,
though, key on the DECODED localFilename (data-fn), and /api/stats/best is
server-canonicalized to that same decoded key (server.py
_canonical_song_filename). So repaintAccuracy's `data-fn !== key` check
rejected every card and `state.accuracy[encoded]` was undefined.
Decode the event filename back into the card / state.accuracy key space via
a small `decFn` helper before marking dirty and repainting, fixing both the
immediate repaint and the onV3SongsScreenEnter deferred path. decFn is
idempotent for already-decoded names and falls back to the original on
malformed input, so a real filename containing a literal '%' is never
corrupted.
Tests: tests/js/v3_songs_score_badge_refresh.test.js extracts the real
decFn from the shipped source and proves the encoded event filename
round-trips to the raw card key (incl. spaces and subfolder '/').
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit
Escape didn't always leave a song: clicking a transport control (play/FF/RW/
restart) left that <button> focused, and _shortcutDispatchBlocked() bails the
shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the
player-scope Escape=Back shortcut never fired until the user clicked empty
canvas to blur the control. Space already had a player-screen carve-out (#593);
Escape did not. That asymmetry was the bug.
Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked
to Escape, on the player AND settings screens (both register Escape=Back;
settings had the identical latent bug). The earlier guards still win: text
inputs are exempted first, the Section Practice popover already claims Escape,
and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it.
Plugins' player-scope Escape shortcuts are fixed identically.
Phase 2 — resume: leaving the player snapshots {song, arrangement, position,
speed} to localStorage; a non-blocking "Resume practice" pill offers it back on
the next non-player screen / next launch. playSong() gains a {resume} option
that restores speed + seeks to the saved position on song:ready instead of the
normal autostart. Conservative (ignores <3s / near-end), cleared on natural
song-end and once consumed, expires after 24h.
Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A
true-modal confirm with monotonic Escape (the second Escape leaves) and
Space/Enter = Leave. The player Escape shortcut and the v3 close button route
through window.requestExitSong(); auto-exit on song-end and a results screen's
own Close stay unguarded.
Design rationale: a multi-seat design charrette (engagement, learning-design,
operability, codebase-reality) — leaving a song should be reliable and
recoverable, not gated; the confirm is opt-in only.
Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* test(browser): suppress first-run onboarding in keyboard/resume/exit specs
The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts
pointer/keyboard events; on a fresh profile it covers the player and breaks any
test that presses Escape or clicks. Stub GET /api/profile to an onboarded
profile in each beforeEach so the app behaves like a returning user (the state
these tests assume).
Also tighten the Section Practice Escape test to assert the guarantee the fix
actually provides — Escape does not exit the song while the popover is open (the
line-447 guard wins over the carve-out) — rather than asserting the popover's
own close handler fires, which isn't wired for a synthetic bar.
Verified locally against a worktree server (Chromium): all 16 new specs pass
(5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay
Refinements from tester feedback on the exit-confirm (default stays OFF):
- Escape on the open prompt now = Stay (dismiss + return to the song), matching
every other modal and the generic _confirmDialog (Esc=cancel). A second
Escape therefore returns to the song instead of leaving it. Leaving stays the
explicit, default-focused "Leave" button, so Space/Enter/click = "just get me
out" (the OP's "Space always hits leave").
- Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5
+ _juceMode) so it isn't running/being scored behind the modal; Stay resumes
exactly what we paused. Guards: cancel any count-in on open; resume only if we
paused (wasPlaying), only if still the same live song on the player
(_audioSeekGen unchanged), and never auto-resume a song the user had paused.
- Trap Tab inside the dialog; backdrop click was already Stay.
Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test
becomes "second Escape stays", plus a backdrop-click-stays test. The audio
pause/resume itself is verified manually on web + desktop (the mock song has no
backing track); these specs lock the navigation + keyboard semantics.
NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE
transport (known play/pause-desync path) — smoke-test on the desktop build
before merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(player): accurate exit-confirm copy + keep resume snapshot on failed load
Two review follow-ups on the Escape/resume/confirm work:
- Settings copy said "a second Escape (or Space/Enter) still leaves",
but Escape dismisses the confirm (Stay) like every other modal — only
Space/Enter/Leave exit. Corrected the Gameplay-tab description so it
matches the implementation (and the committed exit-confirm specs).
- resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so
a transient load/connect failure permanently lost the Resume pill with
no retry. Clear only after the load resolves; on failure keep the
snapshot (and drop the pending in-memory resume) so the pill re-offers
it on the next non-player screen.
All 16 Escape/resume/exit-confirm Playwright specs still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
The section_map plugin pins a ~20px clickable bar (#section-map, z-index:5)
to the top of #player. The v3 left-rail hover-catcher (.v3-railzone::before)
is full-height at z-index:30 with pointer-events:auto, so its top-left
corner swallowed every click on the section map's first section — the
left-most section was never clickable on the v3 desktop (macOS/Windows) UI.
Drop the catcher below the 20px bar when the section map is present,
mirroring the existing #section-map ~ #player-hud special-case in
static/style.css. The rail still reveals from anywhere below the bar.
Adds a Playwright regression test (hit-test of the top-left corner) with a
negative control that re-raises the catcher to reproduce the bug.
Fixes#616
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
window.prompt() is a silent no-op in the Electron desktop shell, so the
Playlists "New Playlist" and "Rename" buttons and the library's bulk
"add selected songs to a playlist" action did nothing. Route all three
through the existing window.uiPrompt() modal (resolves to the string, or
null on cancel; the handlers were already async). window.confirm() works
in Electron and is left as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop Audio Engine plugin (input device selection, VST hosting, pitch
detection, and the new config Reset/repair UI) was reachable only via the
generic Plugins gallery — per-plugin manifest nav entries aren't surfaced in
the v3 sidebar unless the plugin is promoted. Add it to PROMOTED_PLUGINS
anchored after Settings, plus the matching NAV registry entry so the slot
resolves its label/screen. Desktop-only by construction: the slot is filled
only when /api/plugins reports audio_engine installed, so the web app shows
no dead entry.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk
folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter,
live search), wired into the classic v2 toolbar and the v3 Songs page.
Includes the screen.js IIFE dedup (unified surface factory) and review fixes:
path-traversal guard on /song/move, folder-delete data-loss fix, plural
/api/plugins/<id> namespace, loose-folder song recognition, error-text escaping,
v3 setLibView null-guard, and tests.
Co-authored-by: Kyle <kyle.j.t@live.co.uk>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v0.3.0 player chrome's persistent upcoming-section pill (#v3-upnext,
drawn by static/v3/player-chrome.js's updateUpNext) shipped with no off
switch: it always showed during playback whenever a section was upcoming,
overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up
Next' section card" checkbox (a different, in-canvas widget demoted to
default-off precisely because this pill is the canonical readout). Users
reading the pill as that same setting saw "disabled in settings but still
there."
Add a real core toggle, following the autoplayExit idiom:
- static/app.js: client-only `showUpNext` localStorage pref (absence =
enabled), _showUpNextEnabled()/setShowUpNext(), loadSettings()
hydration, and a read-only window.feedBack.showUpNext getter. Disabling
mid-playback hides the pill immediately.
- static/v3/index.html: a "Show 'Up Next'" switch in the Gameplay tab.
- static/v3/player-chrome.js: gate updateUpNext() on the pref.
- static/v3/settings.js: add showUpNext to RESET_MAP.gameplay.local.
Default ON, so behaviour is unchanged for existing users. v3-only (the
pill is v3 core chrome); no Tailwind rebuild.
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The v3 live performance HUD (the visible top-right score tracker) keeps
its own hits/misses/streak counters from note:hit / note:miss events and
only reset them on song load / stop / ended — not on a seek. So pressing
Restart (or scrubbing back), which only repositions the playhead and
emits song:seek, left the tracker showing the stale cumulative score
(tester report).
Mirror the notedetect HUD fix: keep a per-note {t,hit} ledger (note:hit/
note:miss carry the judgment incl. noteTime) and, on a BACKWARD song:seek,
rebuild the tally to reflect only the notes up to the new playhead
(Restart -> "Waiting for notes" / 0). Forward seeks keep earlier notes;
loop-wrap (drill mode) is skipped so a practiced A-B loop still
accumulates, matching the notedetect HUD.
Tests: +3 in tests/js/live_performance_hud.test.js (backward rebuild,
restart-to-0, forward no-op, loop-wrap ignored). Existing 10 still pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a
scrolling content page below the v3 topbar — embedded in the shell they get
cut off at the bottom with excess padding up top.
Add an opt-in top-level `"fullscreen": true` plugin.json field, surfaced as the
`fullscreen` boolean on /api/plugins (mirrors the settings_category plumbing in
plugins/__init__.py). When a fullscreen plugin's screen is active, static/v3/
shell.js toggles `html.fb-immersive` from syncActive() so it tracks every
navigation incl. deep-link; static/v3/v3.css then hides the topbar, collapses
the sidebar to a functional icon rail (kept reachable — Escape is bound only on
player/settings scopes, so a fully hidden sidebar would trap the user), and
lets the active plugin screen fill #v3-main. Mirrors the existing
ss-follower-pre chrome-hide pattern. Additive + opt-in: plugins without the
flag are unaffected.
Test: tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest
Claude-Session: https://claude.ai/code/session_01BmWopMsRjdZyD6RwmZAQBv
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* Fix list/tree view: select mode, parts visibility, song actions
Bring the v3 list/tree view to parity with the grid card:
- Select mode now renders a per-row checkbox + selected-ring, preserves
expanded artist groups across re-render, and a capture-phase guard
makes a row/chip click select the song instead of starting playback.
- Always-on favourite / save-for-later / overflow-menu cluster on each
row, same actions as the grid card.
Rebuild static/tailwind.min.css so the new utilities are compiled in -
notably .sm:flex behind the arrangement chips' "hidden sm:flex" wrapper.
Without it the chips (and #582's badges) render display:none on the
Docker build, which serves the committed CSS; the desktop build looked
fine only because it rebuilds Tailwind from source at bundle time.
Signed-off-by: Sin <deathlysin@outlook.com>
* fix(v3): regenerate tailwind.min.css from source + add tree select tests + CHANGELOG
The committed tailwind.min.css was over-built: 135,578 bytes / 1,428
selectors, with 294 selectors (accent-amber-400, bg-cyan-500,
animate-spin, after:bg-gray-400, …) used in zero core source files —
bloat from a local build scanning outside the repo's content globs. It
would fail CI's rebuild-and-diff and violates the byte-stable rule in
scripts/build-tailwind.sh.
Regenerate via `scripts/build-tailwind.sh` (pinned tailwindcss@3.4.19):
111,491 bytes / 1,134 selectors, byte-identical to a clean rebuild,
still containing the .sm\:flex fix plus every new tree class
(ring-fb-primary, accent-fb-primary, pointer-events-none, …). Docker
chips now render and CI stays green.
Add tests/browser/v3-tree-select.spec.ts:
- select mode keeps expanded artist groups open across the tree
re-render (fails without loadTree's openArtists capture/restore)
- clicking a row in select mode selects instead of playing
Record the fix under CHANGELOG [Unreleased] -> Fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Sin <deathlysin@outlook.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.
- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
after song-directory / before paths — publishes only display name + earned
Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
settings.category): the same toggle + a "Remove me from the wall" button
(POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
_default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
opted-in AND profile identity (reused player_hash); competency never
enqueues (integration law).
Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Achievements & Feats of Power local engine, fully offline.
Core (static/v3/profile.js): the Profile screen becomes tabbed exactly
like v3 Settings (.fb-tabbar/.fb-tab/.fb-tabpanel, active tab persisted in
localStorage 'v3-profile-tab'). A Profile (main) tab carries the existing
cards + a Feats trophy-shelf mount (#v3-profile-feats-slot, earned-only),
and an Achievements tab carries a plugin mount
(#v3-profile-achievements-mount) + empty-state note. A new
`v3:profile-rendered` event fires after every render so the plugin
re-injects (mirrors v3:settings-rendered).
New bundled plugin (plugins/achievements/): SQLite engine
(unlocks/counters/comp_ledger/sync_queue) with pure threshold/criterion
math in the testable sibling engine.py (P-V); routes activity/
report-unlock/report-criterion/catalog/earned/feats/remove-me. Feats read
activity counters only (batched song:ended POST; notes only when notedetect
present — graceful degradation); competency Achievements evaluate from
progression events only — the integration law, never crossed. Catalogue is
always shown (locked=greyed), grouped by the real progression paths
(Global/Guitar/Bass/Drums/Keys, auto-extending) with per-category earned
badges. Versioned window.feedBack.achievements registration API with the
__feedBackAchievementsPending load-order queue + achievements:ready event.
Verified natively (uvicorn) end-to-end + Playwright (tabbar, earned-only
Feats shelf, greyed catalogue, registration API, zero console errors);
24 plugin tests pass incl. the integration-law assertion.
Opt-in/privacy/data-min gate (PR2) and the hosted wall (PR3) follow.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Preserve expanded artist groups across re-renders (was collapsing
all groups whenever select mode toggled)
- Add select checkbox + ring highlight to tree rows, matching grid
- Add capture-phase select guard on tree clicks so rows/chips toggle
selection instead of falling through to play
- Always show favorite/save-for-later/overflow-menu buttons on tree
rows instead of hover-only (matches grid card behaviour)
- Always show arrangement chips on tree rows (no longer hidden below
the sm breakpoint)
Signed-off-by: Sin <deathlysin@outlook.com>
Replace the single long scrolling v3 settings screen with a horizontal tab
bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins /
System) over card rows (icon + title + description, control on the right) with
a per-category Reset.
- static/v3/index.html: tab bar + card-row markup (ids keep hydrating through
the unchanged app.js loadSettings()/persistSetting() path).
- static/v3/settings.js (new): tab switching + active-tab persistence
(localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds
reference from window.getAllShortcuts().
- static/v3/v3.css: plain CSS, no Tailwind rebuild.
- Per-plugin settings tab: new optional settings.category in plugin.json →
plugins/__init__.py surfaces settings_category; app.js mounts each plugin
<details> into #plugin-settings-<category> (fallback: Plugins tab).
highway_3d ships category: "graphics".
- New gameplay settings: countdown_before_song (wired end-to-end, default off);
miss_penalty + fail_behavior (persist-only stubs); "Note highway speed"
surfaces existing master_difficulty.
- New POST /api/settings/reset clears whitelisted keys back to defaults.
Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest,
tests/browser/settings-tabbed.spec.ts. 179 passed locally.
Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main
(slopsmith→feedBack rename applied; settings-screen markup conflict resolved
in favour of the new tabbed layout — all prior setting ids preserved).
Closes#579
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v3 library tree rows showed no arrangement badges, unlike the grid/card
view. Render the same clickable chips in tree rows so both views match, and
clicking a specific arrangement opens THAT arrangement in the highway.
Extract the grid's chip markup into a shared arrChipsHtml(song) (one
<button data-arr="<index>"> per arrangement, capped at 4) and use it in both
songCard and the tree row. No new wiring needed: wireCards() already binds
[data-arr] → playCard(song, index) → playSong(filename, index) for any
[data-fn] scope, and the arrangement index is preserved through
/api/library/artists. Chips are hidden on the narrowest viewports
(hidden sm:flex) so they don't crowd the dense single-line tree row.
Closes#581
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tester: "at the tune step, pressing the Tuner button starts a second wizard at
the input-select step."
Root cause is stacked full-screen overlays. During onboarding the input-setup
flow runs as #input-setup-overlay (z-210) on top of the onboarding modal
#v3-onboarding (z-200), and note_detect's Calibration Wizard (z-300) launches on
top of that. When the player opens the Tuner, that wizard minimizes itself to
transparent + pointer-events:none so the Tuner (z-1000) is usable — but the
input-setup overlay underneath, still showing its "select your input" card, then
shows through behind the floating tuner and reads as a second wizard.
Two targeted hides so only the active surface is visible:
- input_setup: hide #input-setup-overlay while launchCalibration runs; restore on
its onDone/onCancel (one always fires on close), so the calibration wizard /
tuner own the screen.
- onboarding runInputSetup: hide #v3-onboarding for the whole input-setup phase
(its own overlay replaces it visually); restore in finally before advancing to
the calibration-challenge step.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add "Edit region" + Loop-in-3D handoff between player and Song Editor
Wires the player half of the Editor ⇄ 3D Highway region round-trip
(editor half is in feedback-plugin-editor).
Highway → Editor:
- New "✎ Edit region" button in the loop controls (v2 and v3) opens the
Song Editor scrolled to the active A–B loop — or, when none is set, the
section under the playhead (or a short window around it).
- A "↩ Editor" button appears after a Loop-in-3D handoff to return to the
exact edit position you came from.
- Both are hidden unless the editor plugin is loaded (typeof
window.editSong) and gated by _updateEditRegionBtn.
Editor → Highway:
- A one-shot song:ready listener consumes window._pendingHighwayLoop set
by the editor's "Loop in 3D" button — after playSong()'s own clearLoop()
has run — arming setLoop(a,b) over the region and auto-starting playback.
Filename-guarded so a cancelled handoff can't arm a stale loop on an
unrelated song.
Reuses the existing A/B loop API; no new looping engine. Buttons added to
both static/index.html (v2) and static/v3/index.html (separate file —
v2 markup doesn't carry over), using already-scanned Tailwind classes.
New globals editRegionInEditor / returnToEditorFromHighway; helpers
_resolveEditRegion / _updateEditRegionBtn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* fix(loop-in-3d): use canonical window.feedBack namespace (post-#537)
The new song:ready loop-applier landed on the legacy window.slopsmith
alias because the branch predated the slopsmith->feedBack rename (#537).
Normalize it to window.feedBack like the rest of core; the alias would
have worked but leaves the lone slopsmith reference in the file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
* Update GitHub repo references from feedback* to feedBack*
* rename: slopsmith -> feedBack, byron -> got-feedBack
Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias
Refs: #rename-slopsmith
* rename: complete regen against current main + fix backward-compat alias
Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).
Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
resolution, and move the bus aliases to AFTER the _feedBackExisting merge
block so they reference the fully-assembled object (also fixes the
loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
source labels.
Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* rename: implement advertised backward-compat + prune dead community plugins
Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.
Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
(_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
`FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
`FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).
Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
and clear the legacy key on write — so a user's update-channel preference
survives the rename instead of resetting to "stable".
Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).
Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v3 library loaded the best-accuracy map (/api/stats/best) once into
state.accuracy at render time and only refreshed it on a full re-render.
The play->return flow takes the screen-entry fast-path that restores the
cached grid DOM without re-fetching, so a just-earned score stayed
invisible on the card until the next app restart re-ran render().
stats-recorder now emits a `stats:recorded` event (filename/arrangement)
once the scored POST /api/stats resolves on the server -- the correct
moment, since song:stop fires before the POST completes. songs.js
listens: if the library is the active screen it re-fetches
/api/stats/best and patches the affected card/row badge in place;
otherwise it marks the filename dirty and onV3SongsScreenEnter applies
it on return. A failed fetch keeps the entry dirty so a later trigger
retries instead of silently dropping the update.
Badge markup is factored into a shared accuracyBadge(filename, variant)
(grid pill + tree-row percentage, both tagged .fb-acc-badge) so the
in-place repaintAccuracy can find and replace them without a full list
re-render, preserving scroll and pagination. The old empty song:stop
"refresh lazily next render" placeholder is replaced.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(v3): promote Virtuoso to the first-class sidebar slot (was slopscale)
The bundled practice plugin was rebranded/re-homed from the SlopScale fork
(id: slopscale) to feedback-plugin-virtuoso (id: virtuoso); the desktop
bundle swap is feedBack-desktop#31. shell.js still promoted `slopscale`,
whose id no longer ships, so renderPromotedNav() (gated on the plugin
appearing in /api/plugins) would find no match: the dedicated sidebar slot
goes dark and Virtuoso drops to the generic Plugins gallery.
Swap the NAV entry + PROMOTED_PLUGINS slot slopscale -> virtuoso
(screen: plugin-virtuoso, label "Virtuoso - Practice", same FeedBarcade
anchor + target icon) so the practice plugin keeps its first-class entry.
Same pattern as the editor promotion (#546). Must land with the bundle swap
or the practice plugin regresses in the UI.
Signed-off-by: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(v3): clear dead slopscale id from Plugins gallery + refresh docs
Review follow-up (topkoa) — same dead-id bug class on a second surface:
- static/v3/plugins-page.js: drop the now-dead `slopscale: 'game'` from the
CURATED category map and add `virtuoso: 'practice'`. The Virtuoso manifest
sets `category: "practice"` (authoritative in categoryOf), so it already
lands on the practice board; the curated entry is a defensive fallback so a
manifest without `category` wouldn't drop to 'other'.
- README.md: SlopScale row -> Virtuoso (new repo URL + description + clone).
- docs/plugin-capability-inventory.md: slopscale row -> virtuoso (Active).
No behavior change beyond gallery categorization for the dead id.
Signed-off-by: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Signed-off-by: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com>
Co-authored-by: ChrisBeWithYou <16130099+ChrisBeWithYou@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat: save songs as .feedpak; discover and load both .feedpak and .sloppak
The open song format was renamed sloppak -> feedpak (public spec lives in
the feedback-feedpak-spec repo), but the server still wrote and recognized
only `.sloppak`. The two are byte-identical on disk.
Read both suffixes everywhere songs are discovered, uploaded, and loaded;
writing the new `.feedpak` suffix is handled in the editor plugin repo. Keep
the internal `format` tag `sloppak` so existing feature gates (stems, drums,
keys) are untouched, matching the "internal rename not landed yet" stance.
- lib/sloppak.py: add FEEDPAK_EXT / SLOPPAK_EXT / SONG_EXTS; is_sloppak()
now matches either suffix (covers all 7 callers).
- server.py: union scan glob over SONG_EXTS; widen loose-folder exclusion,
settings DLC count, upload gate (_ALLOWED_SONG_EXTS) and zip-magic check;
refresh user-facing messages to .feedpak.
- static: library format filter relabeled Sloppak -> Feedpak (value stays
sloppak, matches both); badge text SLOPPAK -> FEEDPAK in v2 + v3;
filename-suffix detection and upload drag-drop filter accept both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* test: cover .feedpak/.sloppak dual-suffix support
Add tests/test_feedpak_extension.py pinning the four paths PR #553
widened so a refactor can't drop .sloppak back-compat or stop
accepting .feedpak:
- is_sloppak / SONG_EXTS suffix detection (file + dir form, case-insensitive)
- _background_scan discovery glob unions over both suffixes
- POST /api/songs/upload accepts both, rejects wrong suffix + non-zip
- save_settings DLC count includes both suffixes
19 tests, all passing; reuses the existing scan_module / TestClient /
isolate_logging fixtures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Equipping a cosmetic theme recolored text, fb-* utility surfaces, and
body, but the left sidebar's navy radial wash stayed on its default — so
the interface read as "only the fonts change, not the backgrounds".
Cause: #v3-sidebar is painted with a hardcoded radial-gradient in v3.css
and carries no fb-* utility class, so theme-core's per-utility override
loop never reaches it (#1e293b == default card, #0f172a == default bg).
Extend cssFor() — which already special-cases body — to re-point the
sidebar gradient at the theme, gated by html[data-fb-theme] so the
default (no-theme) look is untouched. Only background-image is overridden,
preserving v3.css's background-attachment:fixed.
Verified in Chromium against the real tailwind.min.css + v3.css +
theme-core.js: default = navy gradient (unthemed), apply() recolors the
sidebar to the theme's card->bg stops, apply(null) reverts to navy.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Codex review of #526/#528:
- midi-input discover(): one provider's enumerate() rejection no longer aborts
the whole discovery — other providers (e.g. a native/desktop MIDI provider)
are still queried; denial is only reported when NO provider enumerates.
- Home tour now waits for a 'v3:dashboard-rendered' event (dashboard.js emits
it after the #v3-home innerHTML swap) before attaching Shepherd, instead of a
single animation frame that could latch onto pre-render nodes the async
dashboard render then replaces.
- "Play it now" onboarding now arms the tour (armPendingFirstRun) to run the
first time the user returns to v3-home, instead of silently never showing it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The topbar search (#v3-search) rendered on every screen and, on the library
screen, was hidden behind the filter toolbar while scrolling (both were
sticky top-0 z-20 in the #v3-main scroller).
- shell.js: wrap the search in #v3-search-wrap (hidden by default) and toggle
it in syncActive() so it only shows on #v3-songs; bump the topbar to z-30 so
it always sits above the toolbar.
- songs.js: drop the toolbar's top-0 and pin it beneath the topbar by measuring
the topbar height (positionToolbar). A ResizeObserver on #v3-topbar keeps the
offset correct as the topbar height changes (viewport width, search show/hide)
and fixes the initial position regardless of render()/syncActive() ordering.
Fixes#559
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(v3): pedal click opens the plugin's screen, not its settings
The v3 Pedalboard's settingsTarget() resolved settings-first, so a
plugin that ships both a screen and a settings panel (notably the
bundled Audio Engine) could only ever reach its settings from the
pedalboard — its actual page was unreachable.
Flip to screen-first (stompbox metaphor: step on the pedal, see the
pedal), falling back to settings when there is no screen. Keep a
settings fallback in openPluginSettings() when a declared screen
isn't mounted yet (installing/failed) so settings-bearing plugins are
never stranded on a toast. Drive the pedal aria-label off the same
target so it never promises the wrong surface. Update the unit test
contract to screen > settings > none.
Fixes#555
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): global autoplay & auto-exit option (songs + lessons)
Single Settings toggle (autoplayExit, default ON) that auto-starts a song
once it's ready and returns to the launching menu when it ends. Auto-exit
defers while a results/score overlay is on top (heuristic + holdAutoExit()
contract) so a scoring plugin's screen drives the exit. Player origin is now
context-aware (lessons return to the lessons screen via setReturnScreen()),
fixing lesson completion bouncing to the library.
Core-only; songs and lessons share the playSong -> highway path. Adds a
read-only window.slopsmith.autoplayExit getter + holdAutoExit()/setReturnScreen()
for plugins. Unit tests for the pure helpers (_autoplayExitEnabled,
_resolvePlayerOrigin, _resultsOverlayVisible).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v3 Pedalboard's settingsTarget() resolved settings-first, so a
plugin that ships both a screen and a settings panel (notably the
bundled Audio Engine) could only ever reach its settings from the
pedalboard — its actual page was unreachable.
Flip to screen-first (stompbox metaphor: step on the pedal, see the
pedal), falling back to settings when there is no screen. Keep a
settings fallback in openPluginSettings() when a declared screen
isn't mounted yet (installing/failed) so settings-bearing plugins are
never stranded on a toast. Drive the pedal aria-label off the same
target so it never promises the wrong surface. Update the unit test
contract to screen > settings > none.
Fixes#555
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(progression): fancy notifications for quest/path progress + completion (#551)
Surface achievement feedback as in-app toasts when the player advances or
finishes a daily/weekly quest, and when they progress or level up an
instrument path.
- progression-core.js: _diff() now emits two partial-advance events —
quest-progressed (a still-incomplete quest whose count rose) and
path-progressed (a challenge toward the next level completed without a
level-up). Both are guarded so the increment that COMPLETES a quest /
the level-up itself stays a single quest-completed / path-level-up event
(no double toast). Period rollovers and brand-new quest ids emit nothing.
New events added to the capability owner's declared events list.
- notifications.js (new): reusable window.fbNotify toast surface (stacked,
animated, auto-dismiss; animation + accent via inline styles so no new
Tailwind utilities) + progression wiring — subtle toasts for advances,
celebratory toasts for quest completion, path level-up, and rank-up.
- index.html: load notifications.js after progression-core.
- tests: progression_progress_events (diff emission + guards) and
progression_notifications (toast rendering + wiring) — 11 cases.
No backend change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(progression): unwrap CustomEvent .detail in notification handlers
Codex P2: window.slopsmith.on delivers a CustomEvent (bus.on →
addEventListener), so the progression payload is e.detail — not the raw
argument. All five notifications.js handlers read the arg directly, so in
the browser every field was undefined (e.g. rank-changed never toasted).
Unwrap e.detail in each handler, matching every other sm.on consumer.
The test harness masked this by invoking handlers with raw payloads; it now
wraps them as {detail: payload} like the real bus, so the unwrap is actually
exercised (the tests fail without the fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The profile card's "Your best scores" panel was a hardcoded placeholder
(`#v3-profile-bests` was never filled), so it always read "Play a song to
start tracking..." regardless of how many songs had been scored. The
backend already records best_score/best_accuracy per song; only this
panel was left unwired.
- server.py: add MetadataDB.top_stats(limit) (per-song aggregate, best
score first, scored songs only, dead songs skipped) + /api/stats/top
route that enriches rows with title/artist/art, mirroring
/api/stats/recent. Declared before the /api/stats/{filename} catch-all.
- static/v3/profile.js: renderBests() fetches /api/stats/top and fills the
panel (rank, title/artist, best accuracy %, score; click to play),
keeping the placeholder only when nothing's been scored.
- tests: cover ordering, per-song aggregation, limit, and
resume-only/dead-song exclusion.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Arrangement Editor plugin was only reachable via the generic Plugins
gallery. Give it a dedicated sidebar entry (Library group, below Songs)
through the existing PROMOTED_PLUGINS mechanism in shell.js — a NAV entry,
a promoted slot anchored after "songs", and an edit icon.
renderPromotedNav already gates each promoted slot on the plugin being
present in /api/plugins, so the entry shows only when the editor is
installed. The displayed label comes from the plugin manifest's nav.label.
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Both the v3 index.html and live-guitar-tone-source.js had an extra 'd'
in the brand name ('feed[dB]ack' instead of 'fee[dB]ack') in the guitar
tone source selector labels and help text.
Rename the user-visible label of the HOME-group nav entry (and the matching
"Open Shop →" button on the progress page) from "Shop" to "Unlockables".
Internal identifiers (nav key 'shop', screen id v3-shop, window.v3Shop) are
left unchanged so wiring/state are unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the per-plugin sidebar list down to the single "Plugins" entry
(the gallery is the one entry point for general plugins) and give two
bundled plugins their own first-class sidebar slots instead:
- SlopScale (manifest label "SlopScale - Practice"), directly under FeedBarcade
- Rig Builder, directly after the Library group
Both are driven by a PROMOTED_PLUGINS table: each slot is anchored after
a nav key and filled by renderPromotedNav() only when the plugin is
present in /api/plugins, so an absent bundle shows nothing rather than a
dead entry that bounces to the Plugins screen. The visible label uses the
plugin's own manifest nav.label (escaped), falling back to the static NAV
label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* sloppak: read cover without unpacking + serialize/cap zip unpacks
Album art for a zip-form sloppak was served by resolve_source_dir(), which
unpacks the ENTIRE archive (stems included, ~30 MB) to disk just to read
cover.jpg. On the library grid that meant a full extraction per card on scroll.
- read_cover_bytes(): opens only the cover member from the zip (or reads the
file for dir-form), with zip-slip guarding. ~4 ms vs a full unpack.
- resolve_source_dir(): per-file lock + bounded global semaphore so concurrent
callers don't rmtree + re-extract the same dest at once (a race), and a burst
can't saturate disk/CPU. 8 concurrent calls now dedupe to 1 unpack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* server: serve sloppak art via read_cover_bytes + cache album-art responses
- get_song_art() sloppak branch now reads the cover directly (no full unpack),
off-thread via asyncio.to_thread.
- All art responses carry Cache-Control: public, max-age=86400. URLs are already
cache-busted with ?v=<mtime>, so the browser stops re-fetching every cover on
scroll-back; day bound self-heals any URL missing ?v.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* v3 library: lazy-load + async-decode card cover images
The grid (24 cards/page) and artist-row thumbnails emitted plain <img> with no
loading hint, so a whole page of covers fetched + decoded at once on each
scroll batch. Add loading="lazy" decoding="async" to defer off-screen fetches
and keep image decode off the main thread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* address Codex review: zip cover normalization + correct art revalidation
Findings from the preflight Codex passes:
- sloppak.read_cover_bytes (zip form) read the raw manifest cover string via
zf.read(), so a non-canonical name like './cover.jpg' or 'art/../cover.jpg'
404'd. Normalize via safe_join → relative member; reject escape and the
degenerate root-collapse case ('.', 'subdir/..') like _unpack_zip does.
- Album-art caching is correctness-first: Cache-Control: no-cache plus a strong
validator, with real conditional handling (Starlette FileResponse emits an
ETag but doesn't evaluate If-None-Match). All three art paths route through
_art_conditional/_file_art_response → bodyless 304 on a matching validator.
A long immutable max-age was rejected because the frontend ?v=<mtime> buster
is only second-resolution and would pin a same-second rewrite.
- The sloppak cover is validated by CONTENT (sha1 of the bytes), not a stat:
a dir-form sloppak edited in place changes the cover file's mtime but not the
directory's, so a dir-stat ETag could emit a stale 304. Content hashing is
correct for both dir- and zip-form. get_song_art gained an optional request
(internal get_art caller passes none — safe).
Adds tests/test_sloppak_cover_art.py pinning read_cover_bytes (canonical,
non-canonical, degenerate/escape, dir/zip, webp) and the endpoint's 304 contract
incl. the dir-form in-place-edit no-stale-304 regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The highway showed two overlapping note-detection scoreboards at once: the
core v3 live-performance HUD (#v3-live-performance-hud) and the note_detect
plugin's own HUD (.nd-hud). Both auto-render off the same note:hit/note:miss
events and neither suppressed the other.
Add a Settings → Visualization "Scoreboard" selector (Streak / Detailed /
Off, default Streak) backed by a single source of truth on
<html data-scoreboard>. CSS shows exactly one:
core (default) → core HUD; hide .nd-hud
detailed → .nd-hud; hide the core HUD
off → hide both
CSS-based suppression keys the default off ":not(detailed):not(off)", so the
correct HUD is right even before the pref script runs (no flash) and it
robustly hides any .nd-hud regardless of how many note_detect instances load.
Detection itself is untouched — only the duplicate scoreboard panel is hidden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(tour): align tour engine + Shepherd bubbles to the v3 fb-* palette
The tour/help engine shipped its own indigo/blue dark palette (#181830 / #4080e0)
that predates the v3 fee[dB]ack tokens, and the spotlight bubbles themselves used
the vendored Shepherd LIGHT default (white card, black text) — both clashed with
the navy/sky v3 UI behind them.
- Recolor the "?" menu button, popover and first-visit toast to the fb-* tokens
(card #1e293b, primary #0ea5e9, border #334155, text #f8fafc/#94a3b8, gold
#e8c040 unchanged).
- Add a dark .shepherd-* override block (loads after the vendored shepherd.css,
which is left pristine for upgrades): dark bubble + arrow, fb-primary Next/Done
button, slate secondary button, fb text scale, and bump the modal dim to 0.6 to
match the onboarding overlay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tour-engine): let client/core tours register into the consolidated menu
The tour engine only listed server-discovered plugins (those with a tour.json,
populated from /api/plugins) in the "?" menu, and always prompted unseen relevant
tours via the toast + button pulse. Generalize register() so a core/client-owned
tour can participate:
- `name` registers the tour into the menu catalog (_tourPlugins) so it shows in
the "?" menu even without a server plugin; never clobbers a real plugin entry.
- `autoPrompt:false` opts the tour OUT of the unseen toast + pulse (for tours
driven programmatically by their owner), while still listing + running on
demand. _unseenRelevant honours it.
Both options are additive and default to the prior behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(v3): add stable tour anchors to home cards + instrument badge
Give the first-run home tour stable spotlight targets: #v3-hero on the hero
panel and data-tour="continue" on the three continue/pick/browse card variants
(dashboard.js), and #v3-instrument-wrap on the topbar instrument selector
(badges.js, mirroring the existing #v3-tuner-wrap). The other targets (audio
routing, tuner, profile, sidebar nav) already had stable ids.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(onboarding): first-run home tour (spotlight coach marks)
After a genuine onboarding completion, dim the home page and spotlight one card
at a time with an explanatory bubble + Next, reusing the shared tour engine
(Shepherd). 7 stops: Hero/Start Playing → Continue/Pick → Instrument selector →
Tuner → Audio Routing → Profile → Sidebar nav. Auto-runs once; replayable
forever from the "?" tour menu as "Welcome tour".
- New static/v3/onboarding-tour.js: registers the spotlight tour (screens:
['v3-home'], name "Welcome tour", autoPrompt:false) and exposes startFirstRun(),
gated on the engine's seen/dismissed state so it never repeats; loaded after
tour-engine.js + dashboard.js.
- profile.js finish(): trigger startFirstRun() only on a real onboarding
completion (!editing) — a later profile edit must not relaunch it.
Verified headlessly (native core + Playwright): all 7 anchors resolve, the
spotlight advances one bubble at a time in the v3 dark theme, completion marks
seen, startFirstRun is once-only, and the "?" menu lists "Welcome tour".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(capabilities): clear the handler timeout timer once the race settles
Codex round-6: _withTimeout raced the handler promise against a bare setTimeout
but never cleared it, so a handler that resolves first leaves the timer alive
until it fires. Harmless at 250ms, but the new 15s MIDI permission-command
overrides (discover/open-source) kept the event loop alive ~15s after every
successful call (and the test process hung that long) and could accumulate
delayed callbacks across repeated scans. Capture the timer and clearTimeout it in
a .finally on the race. (Domain/capabilities tests now finish in ~0.1s, not 15s.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): give the built-in Web-MIDI provider a distinct participant id
Codex round-7: the built-in Web-MIDI provider registered with participantId
'core.midi-input' — the same id as the domain owner. unregisterProvider()
unregisters the provider's participant, so a provider swap/hot-reload would tear
down the domain OWNER too, leaving midi-input with no owner for later commands.
Register the provider as 'core.midi-input.web-midi'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(onboarding): don't start the home tour when launching the diagnostic
Codex round-7: on the final onboarding step, "Play it now" calls finish() (which
started the home tour) and THEN playSong(target). startFirstRun() navigated to
v3-home and scheduled the tour, then playSong switched to the player — so the
tour spotlighted hidden home elements / stole focus from the diagnostic. Gate the
tour on a launchingSong flag (passed by the "Play it now" path); the Skip path
stays on home, so the tour still runs there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(capabilities): add core-owned midi-input control-plane domain (#873, #880)
The MIDI analog of audio-input: a core-owned provider-coordinator over MIDI
device discovery, selection, and shared open/close sessions. Separate from
audio-input (whose source/open contract is audio-frame-centric) and not owned
by any feature plugin, so the device-access boundary outlives the input-setup
wizard. `discover` is the Web-MIDI permission boundary; selection persists by
redaction-safe logicalSourceKey; diagnostics redact device labels and never
carry raw MIDI messages.
- static/capabilities/midi-input.js + load-order wiring in both shells
- spec 012 + capability-domains/safety-matrix entries; midi-control narrowed
to mappings-only (split)
- 9 domain tests against the real runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(input_setup): bundled plugin owning input-calibration + Web-MIDI provider (#872)
Bundled core plugin that supplies the Web-MIDI source provider to the core
midi-input domain, owns the input-calibration workflow domain (run/status/
inspect), and renders the per-instrument wizard (guitar/bass -> audio-input +
note_detect; keys/drums -> midi-input live note/pad test). Idempotent
hydration; redaction-safe. .gitignore allowlists the in-tree plugin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(onboarding): input-device setup step between paths and calibration (#874)
After instrument-path selection and before the note-detect calibration
challenge, dispatch input-calibration `run` (fire-and-launch) and await the
`calibration-done` event. Fail-soft: a non-handled outcome (plugin/runtime
absent) advances immediately so onboarding can never be stranded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(midi-input): ship a built-in Web-MIDI provider in the core domain
Move the Web-MIDI source provider out of input_setup and into the core
midi-input domain so every consumer (piano, drums, input_setup) gets MIDI
devices from the domain without depending on any one plugin being loaded.
input_setup is now a pure midi-input requester (manifest role updated).
Prepares piano/drums full consumption (#876/#877). +1 domain test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(input_setup): Settings panel to re-run input setup (#878)
Adds a settings.html with a "Set up input devices" button (window
._inputSetupRelaunch) that re-runs the wizard for the player's selected
instrument paths (from /api/progression; falls back to all instruments). Makes
the calibration wizard re-launchable outside first-run onboarding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(midi-control): formalize the midi-input/midi-control split (#882)
Narrow the reserved midi-control domain to mappings ONLY (CC/pitchbend/note →
action routing), consuming the delivered midi-input domain for device access.
Adds spec 013 defining the contract + intended consumers (feedback-plugin-midi,
drums learn-mode), updates the safety-matrix row, and cross-references it from
capability-domains. Per governance, midi-control stays RESERVED (no runtime
domain) until a concrete mapping consumer + tests exist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(onboarding): wait for input_setup before the calibration step (#874)
The input-setup wizard is a mandatory onboarding step, but plugins load
asynchronously — in the desktop app (40+ plugins) the user can reach path
selection and click Next before input_setup has registered its
input-calibration owner. The dispatch then got a no-owner outcome and
onboarding fell through to the calibration challenge, silently skipping the
wizard. Now wait (bounded, 8s) for the plugin's public global before
dispatching; fall through only if it never appears. Race-verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(onboarding): add Song directory step after name+avatar (#874)
New first-run step (now step 2 of 4: name+avatar → song directory → paths →
calibration challenge) where the player sets their songs folder, fixing the
"folder not configured" error on a fresh install. Saves to settings (dlc_dir)
and kicks a library scan; persists to config.json so it survives restart. A
native folder picker is offered on desktop (window.slopsmithDesktop
.pickDirectory); web users type/paste the path. "Skip for now" leaves it
unconfigured (settable later in Settings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(input_setup): filter MIDI entries out of the guitar audio-input picker (#876)
Other plugins export pseudonymized MIDI sources ('midi-input-N') into the
audio-input domain; they aren't audio inputs and the cryptic labels confused
the guitar/bass device dropdown. Filter them out so only real audio inputs show.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(input_setup): de-dupe audio input picker entries (#876)
The desktop audio engine enumerates the same device under multiple driver
types, so the guitar audio-input dropdown showed repeated entries. De-dupe by
display label (paired with the desktop fix that surfaces real device names).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): drop vanished devices on re-discovery; reset setup confirm on switch
Codex preflight findings:
- midi-input domain `_discover()` only upserted enumerated sources, so an
unplugged device (statechange re-discovery) lingered in list-sources and later
open/select hit stale state. Reconcile each provider's sources against the
fresh enumeration (close any live session, keep the selectedKey preference).
- input_setup MIDI panel left "Continue" enabled (and the instrument marked
done) after switching the device selection following a prior hit. Reset the
waiting state + disable Continue on every selection change, and discard a
stale open if the selection changed mid-await. +1 reconciliation test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): coalesce concurrent opens; commit shown audio source pre-calibration
Codex re-review (round 2):
- midi-input domain: two concurrent open-source calls for the same source both
passed the `sessions.get` guard and each called provider.open(), which for the
built-in Web-MIDI provider overwrites the shared input.onmidimessage handler
and orphans the earlier session — leaving the device silent. Coalesce in-flight
opens onto one provider session (await the pending open, adopt its session;
re-check after open and release a redundant handle if another open won). +test.
- input_setup: the guitar/bass audio <select> shows its first option by default
but fires no `change`, so on a first run with nothing selected, audio-input was
never told before launchCalibration(). Commit the shown option on render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(midi-input): longer timeout for MIDI permission commands; stale-open guard in wizard
Codex re-review (round 3):
- The advertised command surface ran `discover`/`open-source` through the 250 ms
default handler timeout, but those front a real Web-MIDI permission prompt /
device open that commonly takes longer, so dispatch returned `failed` while the
operation was still completing. Add per-(capability,command) timeout overrides
(15 s for those two), folding the existing audio-mix special-case into the same
table so both the command() and dispatch() paths honor it.
- input_setup MIDI panel: openSelected() compared the mutable shared `activeKey`
after its awaits, so a device switch mid-open could bind the old device's
listener / close the wrong session. Capture the requested key in a local and
use a generation guard to discard a superseded open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(onboarding): detect 200-with-error song-dir saves; close MIDI session on skip
Codex re-review (round 4):
- /api/settings reports an invalid folder as a 200 response with an `error` body
(a bare dict return, not a non-2xx status), so saveSongDir's res.ok-only check
treated the failure as success and advanced onboarding without saving. Parse
the body and throw on `error` too.
- input_setup: the opened MIDI test session was only closed on the Continue
button, so using the generic "Skip for now" after scanning leaked the listener
and kept the Web-MIDI input live. Run teardown on every panel exit via a
per-panel cleanup hook invoked by advance().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(input_setup): don't hard-code Web MIDI in the device wizard
Codex re-review (round 5): the MIDI panel gated availability on
navigator.requestMIDIAccess and filtered sources to providerId === 'web-midi',
which defeats the midi-input domain's provider-coordinator abstraction — a
native/desktop MIDI adapter registered with the domain would be reported
unavailable and hidden from the picker. Gate availability on the domain
(window.slopsmith.midiInput) and show every source it surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first-run calibration prompt (profile onboarding step 3) and the
Progress-screen calibration card both told the user to play the
"Slopsmith Diagnostic". Update the visible copy to "fee[dB]ack
Diagnostic". Text-only; the diagnostic is matched functionally by the
is_diagnostic flag + filename, not by this label, so no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>