Compare commits

..
Author SHA1 Message Date
ChrisBeWithYouandClaude Opus 4.8 5bfe508642 docs: add plugin-author theming migration guide + scope the reconciliation rule
- docs/plugin-theming.md — the how-to for plugin authors: the golden rule
  (reference roles + recipe slots, never hardcode a device), what the host
  provides (--fbv-* role tokens incl. on-accent/focus-ring + window.feedBack.theme),
  and two paths — Path A (no own skin → derive surfaces from host + pick devices
  from capabilities) and Path B (own deliberate skins → own surfaces, adopt only
  the per-skin device pattern), plus accessibility + the pre-merge matrix check.
  Workstream item 4 of #644.
- Refines the contract's reconciliation rule: "derive surfaces from host" applies
  to plugins WITHOUT their own identity; plugins WITH deliberate skins (note_detect)
  own their surfaces (deriving would erase metal's steel) and adopt only the
  role/recipe pattern. Records the 2026-06-29 decision to keep note_detect's skins
  self-owned + their current button text — so item 2 is the per-skin pattern + the
  verification gate, not surface-derivation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
2026-06-29 16:50:36 -05:00
ChrisBeWithYouandClaude Opus 4.8 2a15f6e757 docs: host theme contract proposal (prevent features carving into one theme)
Charrette output after a plugin UI feature (note_detect results card) was built
against only the default skin and broke on the others — the colours adapted via
tokens but the visual *devices* (glow ring, gradient) did not, because themes are
design languages, not palettes, and nothing governs whether a theme does glow.

Proposes a host theme contract: always-present semantic role tokens (incl. the
missing on-accent + focus-ring), intent-named capability recipe slots where "off"
is legal (an EMPHASIS recipe + an ACCENT-TEXT recipe), a window.feedBack.theme
read/capability API + theme:changed event, a derive-surfaces-from-host
reconciliation rule, accessibility baked in, and a skin-matrix verification gate.
All additive + feature-detected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
2026-06-29 07:53:40 -05:00
90fb2ee3bc feat(v3): content-dependent playlist covers + custom art (#626)
* 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>
2026-06-28 14:10:15 +02:00
3d97c07b2b feat(v3): add "Add to playlist" to a song's ⋮ More menu (#625)
* 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>
2026-06-28 13:58:14 +02:00
b103a722ce fix(v3): refresh Songs grid after a Settings rescan / DLC-folder change (#624)
Reported on macOS: on a fresh install, pointing at a DLC folder in Settings and
running a scan showed NO songs until an app restart. The scan itself was fine —
_background_scan re-reads config.json fresh, so it scans the new folder and
populates the library — but the v3 Songs grid never reloaded.

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

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

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


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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:42:02 +02:00
a0f5435854 perf(highway): stop the adaptive renderScale from visibly hunting up/down (#622)
Alpha testers reported the 3D-highway "quality going up and down to try to
compensate" as passages got busier (#618 charrette). That's core's
load-adaptive render scale (_adaptRenderScale, #654) ping-ponging across the
7-12ms deadband: it downscales when a busy frame blows the budget, then the
now-cheaper frame dips under the low watermark so it upscales, which blows the
budget again — a visible resolution pop on a loop.

Fix: keep downscaling prompt (protect the frame rate), but make UPSCALING lazy
and predictive:
- smaller up-step (x1.06 vs x1.1) on a longer, separate cooldown
  (_AUTO_UPSCALE_COOLDOWN_MS = 2500ms vs the 600ms general adjust cooldown),
  reset on any downscale so we never bounce straight back up;
- a predictive guard: only upscale when the projected cost AFTER the step
  (~cost * step^2, since draw cost tracks pixel count) still clears the high
  budget. The scale settles just inside the deadband instead of oscillating.

No public API change; the user-facing "Min res" floor (_autoScaleMin) is
untouched. Pairs with the in-plugin AA-under-bloom fix in feedBack#618.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:05:34 +02:00
2a43d5b494 fix(diagnostics): rebuild diagnostic sloppak so song name reads "FeedBack", not "Slopsmith" (#621)
PR #586 renamed the bundled diagnostic to
docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak but only git-mv'd
the file -- it never regenerated the zip. So the manifest INSIDE still
carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith`
(and the same heading in DIAGNOSTIC.md), which is the name testers saw in
the library/player and the onboarding calibration step. Meanwhile the build
script, server `_BUILTIN_DIAGNOSTIC_SOURCES`, README, and docs all already
say "FeedBack Diagnostic — Basic Guitar" -- only the committed binary was
stale.

Regenerate the artifact from its own generator
(docs/diagnostics/build_diagnostic_basic_guitar.py) so the committed sloppak
matches the source of truth: title/artist/heading now "FeedBack"; the chart
(5 notes / 7 chords / 5 sections), the click-track stem, and the
`diagnostic:` metadata block are unchanged. Verified the rebuilt manifest
parses, carries a real U+2014 em-dash, and contains no "Slopsmith".

No code change -- the #586 rename just needed the rebuild it skipped.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:47:12 +02:00
ee7bafbb47 fix(v3): decode stats:recorded filename so post-play score badge refreshes (#620)
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>
2026-06-28 12:39:13 +02:00
fef870047b fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:32:33 +02:00
290783b80b feat(highway_3d): hit-feedback juice + Hit-sparks toggle (#618)
* feat(highway_3d): hit-feedback juice — cinematic lighting, strike line, sparks, intensity dial

Charrette wave 1 (additive, default-tasteful, all behind settings):
- #8 Hit-feedback settings: hitFx (0..1), cinematic, verdictMarks, timingFx,
  streakFx in BG_DEFAULTS + h3dBgSet* setters + settings.html (intensity slider +
  cinematic toggle). hitFx=0 → colour verdict only.
- #2 Cinematic lighting: ambient 0.85→0.35 + stronger key light when cinematic on,
  so emissive gems have a dark surround to pop against. Live-toggleable.
- #1 Strike line: a glowing bar at the hit line (Z=0) that flashes green on a
  verified hit / red on a miss, eased from the per-frame verdict alpha.
- #3 Hit sparks: a pooled additive Points burst at the gem on a verified hit
  (deduped one burst per note), scaled by hitFx; disposed on teardown.

Staged for wave 2 (after dogfooding): bloom+ACES (#4), colorblind verdict glyphs
(#6), early/late timing tint (#5), streak heat + clean-bar (#7), gem scale-punch.

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

* feat(highway_3d): wave 2 — gem scale-punch, streak heat, colorblind verdict marks

- #3 (completion) gem scale-punch: the hit gem briefly grows (1 + 0.22·hitFx·alpha),
  biggest at the strike and easing with the verdict — the per-gem impulse.
- #7 streak heat: a renderer-side consecutive-hit counter eases a 0..1 "heat"
  (plateau at 16) that grows the spark burst + warms the strike-line idle glow;
  a miss eases it back down. Behind the Streak-feedback toggle.
- #6 colorblind verdict marks: a redundant ✓ (hit) / ✗ (miss) glyph on the verdict
  via the existing 2D label overlay, so the green/red pair isn't the only signal —
  notably also covers the provider path (where the timing labels don't show).
- settings.html: Streak-feedback + Accessible-marks toggles.

Deferred: #4 bloom+ACES (needs the Three.js postprocessing addons vendored into
core static/vendor/three/ — not present; warrants its own infra change), and #5's
timing tint (the early/late ±ms labels already render on the event path; surfacing
them on the provider path needs a notedetect verdict field — a cross-plugin item).

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

* feat(highway_3d): #4 bloom + ACES — vendored Three.js postprocessing, perf-gated

The single biggest fidelity lever from the charrette. Core had only
three.module.min.js (no postprocessing addons), so this vendors the r170
EffectComposer/RenderPass/UnrealBloomPass/OutputPass + their shader deps into
static/vendor/three/addons/, with every `from 'three'` rewritten to the SAME
vendored three (../../three.module.min.js) so the addons share the plugin's
three instance (a CDN copy would be a second, non-interoperable module).

highway_3d wiring:
- Lazy-loads the addons only when the new `bloom` setting is on (dynamic import),
  builds EffectComposer(RenderPass → UnrealBloomPass(strength .65/radius .5/
  threshold .82 — high so only emissive gems + the hit flash bloom) → OutputPass).
- Render loop uses composer.render() with ACES tone-mapping when bloom is active,
  else the unchanged direct ren.render() with NoToneMapping (bloom-off = today's look).
- Perf-gated: OFF in splitscreen; graceful fallback to direct render if the modules
  or composer fail; composer.setSize on canvas resize; disposed on teardown.
- settings.html: "Glow bloom" toggle (default on).

Verified the import chain resolves + renders via a same-origin module-load test
(EffectComposer built + a bloom frame rendered, three r170).

Charrette status: 7/8 (only #5's early/late timing tint remains — a notedetect
verdict-field change, outside the highway).

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

* feat(highway_3d): #5 early/late timing — colour the hit feedback by timing

Surfaces the detector's timing on every hit (the charrette's last item), fully
highway-side: notedetect already dispatches the judgment (timingState/timingError)
on notedetect:hit/miss, so we carry timingState onto the event mark and tint the
hit's spark burst + the ✓ verdict glyph by it — on-time green, early cyan, late
amber. Gracefully falls back to green when no timing is known (pure-provider path),
so it never invents data. Behind the new "Timing feedback" toggle (default on).

Charrette: 8/8 complete.

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

* feat(highway_3d): add a "Hit sparks" on/off toggle (note-hit particles)

The on-hit spark burst (the particle effect that fires the instant
note_detect confirms a hit) could previously only be removed by dragging
Hit-feedback intensity to 0 — which also kills the strike-line flash and
the scale-punch. Add a dedicated "Hit sparks" toggle (default on) under
3D Highway settings, in the hit-feedback group beside the intensity
slider, that gates ONLY the spark particles; the strike flash and colour
verdict are unaffected.

Wired the same way as the sibling juice toggles: a `sparks` boolean in
BG_DEFAULTS, in _BG_BOOL_KEYS, a window.h3dBgSetSparks setter, the
per-instance _sparks state + settings re-read, and a guard on the
_sparkBurst spawn. Reuses existing Tailwind utility classes, so
assets/plugin.css is unchanged; plugin.json version bumped to 3.28.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(highway_3d): act on tester charrette — strike line, fog readability, AA

Addresses the alpha-tester 3D-highway feedback thread via the design panel's
recommendations:

- Strike line (panel rec 1a): now a HIT-ONLY faint "now" line — flashes green
  on a confirmed hit, no red miss branch (misses already show at the gem: red
  wash + ✗). Moved off the bottom edge to the vertical CENTRE of the string
  field, which was the "incorrectly placed" complaint (it read as the board's
  lower border and fused with open-string gems on a miss). Added a "Strike
  line" on/off toggle (`strikeLine`, default on).

- Horizon readability (#2): the note gems + their outlines are now fog-exempt
  (`material.fog = false` on mStr/mGlow/mStrHitOutline/mHitBright/mWhiteOutline/
  mMissOutline), so upcoming notes punch through the distance fog and stay
  legible as they render in — the board, lane, sustains and scenery keep their
  atmospheric fog, so depth is preserved.

- Cinematic lighting softened: cinematic ambient 0.35 -> 0.45 so the dark stage
  doesn't crush note/fret legibility.

- Anti-aliasing under bloom (perf rec): give the bloom EffectComposer a
  multisampled (WebGL2 MSAA x4) HalfFloat render target. The default target had
  no `samples`, so bloom-on bypassed MSAA — the "too HD / jagged on Windows,
  fine on Mac" report (Mac only won via Retina supersampling). This is the
  highest-value, smallest fix for the jaggies.

plugin.json -> 3.29.0. The renderScale quality-oscillation is core
(static/highway.js) and will be a separate feedBack PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(highway_3d): remove the strike line; sparks-only hit feedback, subtler

Second tester-charrette pass. The strike line (even hit-only/centred from the
last pass) was still too distracting/confusing on a hit, so it's removed
entirely — strings + fret markers already orient the player, and the hit is
fully carried at the gem (bright outline + scale-punch + spark burst) with the
timing-coloured ✓/✗ verdict as the knowledge-of-results channel.

- Deleted the strike-line mesh, its per-frame update, the `strikeLine` setting
  (BG_DEFAULTS / _BG_BOOL_KEYS / setter / settings-load), the settings.html
  toggle, and the now-dead `_strikeLine`/`_ndHitFlash`/`_ndMissFlash` state +
  their verdict-block feeds.
- Made the spark burst subtler now that it's the sole celebration: point size
  1.7→1.0·K, opacity 0.95→0.8, burst count (7+13·hitFx)→(4+7·hitFx), radial
  speed (7+r·20)→(5+r·12)·K, life (0.40+r·0.28)→(0.30+r·0.16)s.
- Toggles for Hit sparks and the ✓/✗ verdict marks already exist in settings
  (kept).

Minimal hit-feedback set now: gem bright + subtle spark (celebration) +
timing-coloured ✓/✗ (the KR) + ambient streak heat. plugin.json -> 3.30.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(highway_3d): hydrate hit-feedback settings controls from saved state

The 7 new juice controls (Hit sparks, Cinematic, Streak, Verdict marks,
Bloom, Timing, Hit-feedback intensity) were hard-coded to their default
markup and never read back from localStorage when the settings panel
reopened — so a saved non-default (e.g. Hit sparks off) showed as the
default (checked) even though the renderer correctly honored it. The
sibling controls in the same panel were already hydrated; this restores
that pattern for the new ones.

Reads h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' coercion.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 11:53:48 +02:00
20 changed files with 1807 additions and 28 deletions
+10
View File
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` 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. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx``dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
@@ -36,6 +40,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where``query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 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 start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards 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`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** 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, so 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 that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked``_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
+186
View File
@@ -0,0 +1,186 @@
# Host Theme Contract — design proposal
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
## 1. Problem
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
gradient-filled accuracy number) was built and visually verified against **only the
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
glow-less, near-monochrome design language — the glow ring and the colour gradient
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
but the **visual devices themselves did not port**, because nothing in the system says
"this theme does / doesn't do glow rings."
### Root cause (three findings)
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
device-named slot to express a different language. The cure is to finish that move:
name slots by intent, with "off" (`none`) a legal value.
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
amber→near-white and washed out.
A process gap compounds it: **verification covered one skin**, so the regression was
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
ship their own independent skin systems too.
## 2. Current state (two disconnected systems)
| System | What it is | Limits |
| --- | --- | --- |
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults***devices are an ownerless gap.** |
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
## 3. Goals / non-goals
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
**implement** the contract.
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
## 4. The contract — three layers
### Layer 1 — Semantic colour **roles** (always present)
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
resolves — themed or not. Roles:
`surface · card · border · text · text-dim · accent · accent-2 · good · warn · bad`
plus two **new keystones**:
- **`on-accent`** — foreground legible *on* an accent fill. (Rule: any role used as a fill
behind text gets a paired `on-*`.) Fixes white-on-amber.
- **`focus-ring`** — focus indicator independent of accent, so focus stays visible when
`accent ≈ surface`.
`accent-2` is demoted to "just a second hue" — **never** an assumed gradient end.
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
A theme declares its design *language* by filling intent-named slots. A feature applies the
slot bundle **unconditionally**; it never branches on "is this theme glowy?". Atomic slots
(renames-by-intent of today's tokens): `corner-radius`, `corner-clip`, `panel-shadow`,
`text-emph-shadow`, `panel-texture`, `motion-decorative` (reduced-motion-gated).
Two **composite recipes** carry the load:
- **EMPHASIS** — how this theme makes a primary action special:
`--emph-fill / --emph-border / --emph-halo / --emph-on`.
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
None empty — each emphasises in its own language.
- **ACCENT-TEXT** — how this theme fills a big accent number: `--acc-text-fill`
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
> These generalize the interim per-skin tokens already shipped in `note_detect`
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
### Layer 3 — JS read API + reconciliation
On the existing `window.feedBack` bus:
- `feedBack.theme.get()``{ id, tokens, isThemed }`
- `feedBack.theme.capabilities()``{ glow, gradients, motion }` (the device-affordance signal)
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once)
- `theme:changed` event → `{ id, tokens, capabilities }` (emitted at theme-core's existing
apply chokepoint; analogous to `note_detect`'s `notedetect:skin`)
**Reconciliation rule (ends the two-disconnected-systems problem) — depends on whether the
plugin has its own identity:**
- **A plugin *without* its own skin** should **derive surface/text/border from host tokens**
(`background: var(--fbv-card)`, etc.) and **own only its accent + its devices**, selecting the
device via the recipe. A host theme then pulls its chrome along — one truth for surfaces — and
it never imposes a device the active theme neutralizes. This is the common case.
- **A plugin *with* deliberate skins** (e.g. `note_detect`'s neon / esports / metal, which are
full design languages) **owns its surfaces** — deriving them from the host theme would *erase*
the skin's identity (metal's brushed steel becomes a flat host colour). Such a plugin adopts the
**role + recipe *pattern*** (per-skin device tokens, `on-accent`, `focus-ring`, "off" is legal)
and verifies across **its own** skin matrix, but does not blindly inherit host surfaces.
*(Decided 2026-06-29: keep note_detect's skins self-owned + their current button text — so the
fix is the per-skin device pattern + the verification gate, not surface-derivation.)*
## 5. Consumption pattern (the rule for feature authors)
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
> in slots; the theme owns the slots.
```css
.hero-cta {
background: var(--emph-fill);
border: var(--emph-border);
box-shadow: var(--emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
color: var(--emph-on); /* never hardcoded #fff again */
border-radius: var(--corner-radius);
}
.accuracy-number {
color: var(--accent); /* legible solid fallback FIRST */
background: var(--acc-text-fill);
background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
```
## 6. Accessibility (baked into the contract, not per-feature)
- **Reduced motion:** `motion-decorative` is the *only* place decorative animation is named;
one central rule sets it to `none` under `@media (prefers-reduced-motion: reduce)`, so no
theme can forget the gate.
- **Focus parity:** exactly one contract-level `:focus-visible { outline: 2px solid var(--focus-ring) }`;
themes recolour `focus-ring` but may not author their own focus styling.
- **On-accent contrast:** `on-accent` is required and **lintable** (`contrast(on-accent, accent) ≥ 4.5:1`,
3:1 large). Contrast is the theme's job, computed once — not re-judged per feature.
## 7. Verification gate (prevent recurrence)
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
key states** (rest / hover / focus / reduced-motion).
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
a guard test so the matrix can't silently go stale.
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
## 8. Back-compat & rollout
All additive: new `--fb-*` runtime vars + a new `feedBack.theme` namespace + a new event with no
current listeners. Existing plugins (those reading `fb-*` utility classes, or shipping their own
skins) are untouched unless they opt in; two-arg `var(--fb-x, fallback)` + `window.feedBack?.theme?.get`
feature-detection means older hosts behave exactly as today.
**Workstream (sub-tasks):**
1. **Host minimal surface** — always-present default `--fb-*` tokens + `feedBack.theme.{get,capabilities,prefersReducedMotion}` + `theme:changed`. *(the smallest thing that would have prevented the incident)*
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
## 9. Cross-apply status (already done)
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
(the Layer-2 prototype) and verified across neon/esports/metal.
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
## 10. Open questions
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
slots are the right seed. When/whether to graduate.
- Where the central reduced-motion + focus rules physically live (core base layer vs a shared
plugin import) and how the host injects role tokens into plugin roots.
+95
View File
@@ -0,0 +1,95 @@
# Theming a plugin so it works in every theme
This is the how-to for plugin authors. It exists because of one recurring bug:
a plugin's UI is built and eyeballed against the **default** look, ships, and
then a user switches themes and the feature's *colours adapt but its visual
devices vanish* — a glow ring, a colour gradient — because the active theme
doesn't speak that visual language.
**The golden rule:** themes are different **design languages**, not palettes.
Reference **roles** and **recipe slots**; never hardcode a *device* (a literal
`box-shadow` glow, a literal `linear-gradient`, a hex colour). Then your feature
renders correctly in a theme nobody has invented yet — and **verify it across
the whole theme set before you merge.**
(Design + rationale: [host-theme-contract.md](host-theme-contract.md), got-feedback/feedBack#644.)
## What the host gives you
Always-present CSS role tokens on `:root` (resolve themed *or* un-themed):
`--fbv-bg / sidebar / card / cardMuted / primary / primaryHi / accent / text /
textDim / border / good / mid / low / gold`, plus **`--fbv-on-accent`** (a
foreground legible *on* an accent fill) and **`--fbv-focus-ring`**. Use as
`color: rgb(var(--fbv-text))`, `background: rgb(var(--fbv-card))`, etc.
A JS read surface on the event bus:
```js
const t = window.feedBack?.theme;
t?.get(); // { id, isThemed, tokens }
t?.capabilities(); // { glow, gradients, motion } — what devices this theme permits
t?.prefersReducedMotion(); // boolean (one central matchMedia)
window.feedBack.on('theme:changed', (e) => { /* e.detail = {id, tokens, capabilities} */ });
```
Feature-detect everything (`window.feedBack?.theme?.get`) and pass a fallback to
every `var(--fbv-x, <fallback>)` so you degrade cleanly on an older host.
## Pick your path
### Path A — you do NOT have your own skins (most plugins)
Look like the host. **Derive surfaces from host tokens** and **choose devices
from capabilities** instead of hardcoding them:
```js
const caps = window.feedBack?.theme?.capabilities?.() ?? { glow: true, motion: true };
heroEl.classList.toggle('use-glow', caps.glow && !window.feedBack.theme.prefersReducedMotion());
```
```css
.hero { background: rgb(var(--fbv-primary)); color: rgb(var(--fbv-on-accent, #fff)); }
.hero:focus-visible { outline: 2px solid rgb(var(--fbv-focus-ring)); outline-offset: 2px; }
.hero.use-glow { box-shadow: 0 0 16px -2px rgb(var(--fbv-primary)); } /* only where the theme allows it */
```
Re-read on `theme:changed` if you cache anything (e.g. a canvas palette).
### Path B — you HAVE your own deliberate skins (like the scoring UI)
Your skins are an identity (neon vs steel vs clean) — **own your surfaces**;
don't inherit host surfaces or you'll erase that identity. Adopt only the
**pattern**: make every visual *device* a **per-skin token whose "off" value is
legal**, so each skin authors its own version and a glow-less skin simply
doesn't glow. Example (the "make the hero special" device):
```css
/* base / neon */ :root { --hero-ring: .5; --hero-border: transparent; --acc-fill: linear-gradient(135deg, var(--accent), var(--accent2)); }
/* glow-less skin */ [data-skin="clean"] { --hero-ring: 0; --hero-border: var(--accent); --acc-fill: var(--accent); }
.hero { background: var(--acc-fill); border-color: var(--hero-border); }
.hero::after { opacity: var(--hero-ring); /* the glow ring; 0 = off */ }
```
Neon emphasises with the ring, the clean skin with a solid border — **neither is
empty of emphasis; each speaks its own language.** Same idea for an accent-filled
number (`--acc-fill` = a gradient in one skin, a solid in another so it doesn't
wash out to white). Add `on-accent` + `focus-ring` per skin too.
## Accessibility (part of the contract, not an afterthought)
- **Reduced motion:** gate decorative animation on `prefers-reduced-motion`
(CSS `@media`, or `theme.prefersReducedMotion()`); functional transitions are fine.
- **Focus:** always a visible `:focus-visible` outline using `focus-ring` — don't
bind focus only to `accent` (it can ≈ the surface in some themes).
- **On-accent contrast:** text on an accent fill uses `on-accent`; watch light
accents (a mid-amber needs dark text, not white).
## Before you merge — verify across the matrix
Render your UI in **every** theme/skin and look at them together. If you ship
your own skins, do this with a render-matrix in CI/local (reference: the scoring
UI's `npm run render-skins` + `theme-matrix-checklist.md`). The checklist that
catches this bug class:
- [ ] colours via tokens, no hardcoded hexes
- [ ] rendered in **all** themes/skins (not just the default)
- [ ] every new *device* is per-skin / capability-gated and **legible when its token is `none`**
- [ ] reduced-motion + visible focus in every theme
- [ ] text on accent stays legible everywhere
+29
View File
@@ -1373,6 +1373,35 @@
if (thsi) thsi.value = String(toneHudSize);
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
// Hit-feedback "juice" controls — hydrate from saved state so the
// panel reflects persistence on reopen (the renderer already reads
// these via _bgReadSetting; without this the controls always showed
// their default markup, misrepresenting a saved non-default). Reads
// h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
// hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' rules.
try {
const _bgBool = (k, def) => {
const v = localStorage.getItem('h3d_bg_' + k);
return v == null ? def : !(v === 'false' || v === '0');
};
const _setChk = (id, on) => { const el = document.getElementById(id); if (el) el.checked = on; };
_setChk('h3d-sparks', _bgBool('sparks', true));
_setChk('h3d-cinematic', _bgBool('cinematic', true));
_setChk('h3d-streakfx', _bgBool('streakFx', true));
_setChk('h3d-verdictmarks', _bgBool('verdictMarks', true));
_setChk('h3d-bloom', _bgBool('bloom', true));
_setChk('h3d-timingfx', _bgBool('timingFx', true));
const _hf = document.getElementById('h3d-hitfx');
if (_hf) {
let v = parseFloat(localStorage.getItem('h3d_bg_hitFx'));
if (!isFinite(v)) v = 0.70;
v = Math.max(0, Math.min(1, v));
_hf.value = String(v);
const _hfl = document.getElementById('h3d-hitfx-label');
if (_hfl) _hfl.textContent = v.toFixed(2);
}
} catch (_) { /* storage blocked — controls keep their default markup */ }
// (3D Highway palette picker removed — string colors are now set
// via the core "Highway String Colors" UI above, which drives both
// the 2D and 3D highways. The bg-settings 'palette' key still exists
+114 -5
View File
@@ -220,6 +220,8 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
("POST", re.compile(r"^/api/saved/toggle$")),
# Progression (spec 010) write endpoints — demo mode stays read-only.
("POST", re.compile(r"^/api/progression/paths$")),
@@ -1323,14 +1325,30 @@ class MetadataDB:
return None
def list_playlists(self) -> list[dict]:
from urllib.parse import quote
rows = self.conn.execute(
"SELECT id, name, system_key, created_at, updated_at FROM playlists "
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
).fetchall()
return [{
"id": r[0], "name": r[1], "system_key": r[2],
"created_at": r[3], "updated_at": r[4], "count": self._playlist_count(r[0]),
} for r in rows]
out = []
for r in rows:
pid = r[0]
# First few still-present songs (in order) → art URLs, for a
# content-dependent playlist cover (single art / 2x2 mosaic). The
# JOIN drops dead songs, matching get_playlist's visibility.
arts = self.conn.execute(
"SELECT ps.filename FROM playlist_songs ps "
"JOIN songs s ON s.filename = ps.filename "
"WHERE ps.playlist_id = ? ORDER BY ps.position LIMIT 4",
(pid,),
).fetchall()
out.append({
"id": pid, "name": r[1], "system_key": r[2],
"created_at": r[3], "updated_at": r[4],
"count": self._playlist_count(pid),
"art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts],
})
return out
def create_playlist(self, name: str, system_key: str | None = None) -> dict:
with self._lock:
@@ -5149,9 +5167,35 @@ def api_song_stats(filename: str):
# ── Playlists / Saved for Later / Continue-Playing (fee[dB]ack v0.3.0) ────────
def _playlist_cover_path(pid) -> Path | None:
"""Filesystem path of a playlist's optional custom cover image (PNG),
stored under CONFIG_DIR. Returns None for a non-integer id."""
try:
pid = int(pid)
except (TypeError, ValueError):
return None
return CONFIG_DIR / "playlist_covers" / f"{pid}.png"
def _playlist_cover_url(pid) -> str | None:
cover = _playlist_cover_path(pid)
if not cover or not cover.exists():
return None
try:
# Nanosecond mtime so a same-second replace/remove/re-upload still
# changes the cache-bust token (int seconds could collide → stale image).
mt = cover.stat().st_mtime_ns
except OSError:
mt = 0
return f"/api/playlists/{pid}/cover?v={mt}"
@app.get("/api/playlists")
def api_list_playlists():
return meta_db.list_playlists()
lists = meta_db.list_playlists()
for pl in lists:
pl["cover_url"] = _playlist_cover_url(pl["id"])
return lists
@app.post("/api/playlists")
@@ -5167,6 +5211,7 @@ def api_get_playlist(pid: int):
pl = meta_db.get_playlist(pid)
if pl is None:
return JSONResponse({"error": "not found"}, status_code=404)
pl["cover_url"] = _playlist_cover_url(pid)
return pl
@@ -5193,6 +5238,12 @@ def api_delete_playlist(pid: int):
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
if not meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
return JSONResponse({"error": "not found"}, status_code=404)
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
if cover and cover.exists():
try:
cover.unlink()
except OSError:
pass
return {"ok": True}
@@ -5239,6 +5290,64 @@ def api_reorder_playlist(pid: int, data: dict):
return meta_db.get_playlist(pid)
@app.post("/api/playlists/{pid}/cover")
async def api_set_playlist_cover(pid: int, data: dict):
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
Overrides the content-dependent (song-art) cover. Stored as a small PNG
thumbnail under CONFIG_DIR/playlist_covers/."""
if meta_db.get_playlist(pid) is None:
return JSONResponse({"error": "not found"}, status_code=404)
import base64
import io
b64 = data.get("image", "")
# Guard the type before the `","` membership test — a non-string image
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
# Mirrors the avatar/song-art upload guard.
if not isinstance(b64, str) or not b64:
return JSONResponse({"error": "No image data"}, status_code=400)
if "," in b64:
b64 = b64.split(",", 1)[1]
if not b64:
return JSONResponse({"error": "No image data"}, status_code=400)
try:
img_data = base64.b64decode(b64)
except Exception:
return JSONResponse({"error": "Invalid base64"}, status_code=400)
cover = _playlist_cover_path(pid)
cover.parent.mkdir(parents=True, exist_ok=True)
try:
from PIL import Image
img = Image.open(io.BytesIO(img_data)).convert("RGB")
img.thumbnail((640, 640)) # covers stay small
tmp = cover.with_suffix(".png.tmp")
img.save(str(tmp), "PNG")
tmp.replace(cover)
except Exception as e:
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
@app.get("/api/playlists/{pid}/cover")
def api_get_playlist_cover(pid: int):
cover = _playlist_cover_path(pid)
if not cover or not cover.exists():
return JSONResponse({"error": "not found"}, status_code=404)
# no-cache (revalidate) like song art, so a replaced cover is never served
# stale — pairs with the mtime-ns cache-bust token on the URL.
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
@app.delete("/api/playlists/{pid}/cover")
def api_delete_playlist_cover(pid: int):
cover = _playlist_cover_path(pid)
if cover and cover.exists():
try:
cover.unlink()
except OSError:
pass
return {"ok": True}
@app.post("/api/saved/toggle")
def api_toggle_saved(data: dict):
"""Add/remove a song on the reserved Saved-for-Later playlist."""
+411 -11
View File
@@ -460,6 +460,25 @@ function _shortcutDispatchBlocked(e) {
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
return false;
}
// Escape is the universal "back" action and must fire like Space above even
// when a transport/rail control <button> holds keyboard focus after a click
// — otherwise a focused control swallows Esc and the user can't leave the
// song until they click empty canvas (feedBack — "Escape in song not
// consistent"). It applies on the player (exit the song) AND settings
// (return to the previous screen), both of which register an Escape=Back
// shortcut. The earlier guards still win: text inputs are exempted at the
// top (Esc there clears/blurs the field), and the Section Practice popover
// already claimed Esc above. A true modal layered over the screen still
// traps Esc — the modal-overlay check keeps Esc closing the modal rather
// than ejecting past it to the screen behind.
if (e.key === 'Escape') {
const ctx = _getCurrentContext();
if ((ctx.isPlayer || ctx.isSettings) &&
!(e.target && e.target.closest &&
e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) {
return false;
}
}
return _isInsideInteractiveControl(e.target);
}
@@ -1033,6 +1052,11 @@ async function showScreen(id) {
const audio = document.getElementById('audio');
const stopTime = _audioTime();
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying;
// Snapshot where we were so leaving the player — especially by accident
// — is recoverable instead of dumping the user back at bar 1 next time.
// Must run BEFORE highway.stop()/audio unload, while getSongInfo() and
// the position (stopTime) are still live.
if (hadPlayableSong) _snapshotResumeSession(stopTime);
highway.stop();
// Cancel any queued seeks, in-flight shim closures, AND active
// count-in timers before stopping playback so none of these paths
@@ -3394,6 +3418,8 @@ async function loadSettings() {
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
const showUpNextEl = document.getElementById('setting-show-upnext');
if (showUpNextEl) showUpNextEl.checked = _showUpNextEnabled();
const confirmExitEl = document.getElementById('setting-confirm-exit');
if (confirmExitEl) confirmExitEl.checked = _exitConfirmEnabled();
// Restore master-difficulty slider from persisted value (defaults
// to 100 when the key is absent — no behaviour change for users
// who've never touched the slider).
@@ -4517,6 +4543,9 @@ async function rescanLibrary() {
_treeStats = null;
_tuningNames = null; // re-fetch on next drawer open
loadLibrary();
// Tell the v3 Songs grid the library changed so it reloads instead of
// keeping a cached (e.g. pre-DLC, empty) grid until an app restart.
if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' });
}
}, 1000);
}
@@ -4545,6 +4574,9 @@ async function fullRescanLibrary() {
_treeStats = null;
_tuningNames = null; // re-fetch on next drawer open
loadLibrary();
// Tell the v3 Songs grid the library changed so it reloads instead of
// keeping a cached (e.g. pre-DLC, empty) grid until an app restart.
if (window.feedBack) window.feedBack.emit('library:changed', { reason: 'rescan' });
}
}, 1000);
}
@@ -5971,6 +6003,201 @@ window.feedBack.on('song:ready', () => {
}
});
// ── Resume last session ────────────────────────────────────────────────────
// Leaving a song snapshots where you were — song, arrangement, position, and
// speed — so an exit (especially an accidental one, now that Escape reliably
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
// The snapshot is offered back through a non-blocking "Resume" pill; it never
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
// (This is the player-session slice; the broader nav/state-resume work — e.g.
// returning to a song after wandering into Settings → Tone Builder — is a
// separate, larger track.)
const _RESUME_KEY = 'feedBack.resumeSession';
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
let _pendingResume = null; // {position, speed}, consumed at song:ready
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
function _curPlaybackSpeed() {
try {
return window._juceMode
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
: (document.getElementById('audio')?.playbackRate || 1);
} catch (_) { return 1; }
}
// Snapshot the live session. Called from showScreen()'s teardown before
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
function _snapshotResumeSession(position) {
try {
if (!currentFilename) return;
const si = (window.highway && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const dur = Number(si.duration) || 0;
const pos = Number(position) || 0;
// Only worth resuming a song you were genuinely mid-way through — not a
// glance at the first seconds, and not one that already basically ended.
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
const snap = {
f: currentFilename,
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
? si.arrangement_index : undefined,
t: pos,
sp: _curPlaybackSpeed(),
title: si.title || '',
artist: si.artist || '',
ts: Date.now(),
};
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
// A fresh snapshot earns one offer — undo any earlier dismissal.
_resumePillDismissed = false;
} catch (_) { /* storage unavailable — resume is best-effort */ }
}
function _readResumeSession() {
try {
const raw = localStorage.getItem(_RESUME_KEY);
if (!raw) return null;
const snap = JSON.parse(raw);
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
return snap;
} catch (_) { return null; }
}
function _clearResumeSession() {
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
}
// Re-enter the snapshotted song and restore arrangement + position + speed.
async function resumeLastSession() {
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return false; }
_hideResumePill();
try {
await playSong(snap.f, snap.a, {
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
});
} catch (err) {
// A transient load/connect failure must not strand the user: keep the
// snapshot so the pill can re-offer it on the next non-player screen,
// rather than consuming the only copy before the song actually loaded.
console.warn('[app] resume failed to load; keeping snapshot:', err);
_pendingResume = null;
return false;
}
_clearResumeSession(); // consumed only after a successful load
return true;
}
window.resumeLastSession = resumeLastSession;
if (window.feedBack) window.feedBack.resumeLastSession = resumeLastSession;
// Consume a pending resume once the chart is ready: restore speed, seek to the
// saved position, then (if autoplay is on) start from there. playSong() does
// NOT arm autostart for a resume load, so the two never fight over playback.
window.feedBack.on('song:ready', () => {
const pend = _pendingResume;
if (!pend) return;
_pendingResume = null;
try {
if (pend.speed && pend.speed > 0) {
const slider = document.getElementById('speed-slider');
if (slider) slider.value = String(Math.round(pend.speed * 100));
setSpeed(pend.speed);
}
} catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err));
});
// A song that finishes on its own has nothing to resume — and we never want to
// offer "resume" for a song the user just completed.
window.feedBack.on('song:ended', _clearResumeSession);
// ── Resume pill (non-blocking "continue where you left off") ────────────────
// Self-contained, inline-styled, body-appended so it works identically in the
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
// the player screen, never blocks, and a dismiss forgets the current snapshot
// for the session.
function _hideResumePill() {
const el = document.getElementById('fb-resume-pill');
if (el) el.remove();
}
function _maybeShowResumePill() {
const active = document.querySelector('.screen.active');
if (active && active.id === 'player') { _hideResumePill(); return; }
if (_resumePillDismissed) return;
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return; }
if (document.getElementById('fb-resume-pill')) return; // already shown
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
const pill = document.createElement('div');
pill.id = 'fb-resume-pill';
pill.setAttribute('role', 'status');
pill.style.cssText = [
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
'display:flex', 'align-items:center', 'gap:10px',
'max-width:min(90vw,360px)', 'padding:10px 12px',
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
].join(';');
const text = document.createElement('div');
text.style.cssText = 'flex:1;min-width:0';
const t1 = document.createElement('div');
t1.textContent = 'Resume practice';
t1.style.cssText = 'font-weight:600;color:#fff';
const t2 = document.createElement('div');
t2.textContent = label;
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
text.appendChild(t1); text.appendChild(t2);
const resumeBtn = document.createElement('button');
resumeBtn.type = 'button';
resumeBtn.textContent = 'Resume ▸';
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
const dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.setAttribute('aria-label', 'Dismiss');
dismissBtn.textContent = '✕';
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
pill.appendChild(text);
pill.appendChild(resumeBtn);
pill.appendChild(dismissBtn);
(document.body || document.documentElement).appendChild(pill);
}
if (window.feedBack) window.feedBack._maybeShowResumePill = _maybeShowResumePill;
// Exposed for tests/debugging (mirrors window._panels / _getCurrentContext).
window._snapshotResumeSession = _snapshotResumeSession;
window._readResumeSession = _readResumeSession;
window._clearResumeSession = _clearResumeSession;
// Drive the pill off screen transitions (hide over the player, offer it
// elsewhere) plus a one-shot check on first load for a prior-session snapshot.
window.feedBack.on('screen:changed', (ev) => {
const id = (ev && ev.detail && ev.detail.id) || (ev && ev.id);
if (id === 'player') _hideResumePill();
else _maybeShowResumePill();
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded',
() => { try { _maybeShowResumePill(); } catch (_) {} }, { once: true });
} else {
try { _maybeShowResumePill(); } catch (_) {}
}
// Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The
// editor's "Loop in 3D" button stashes a pending loop + return context, then
// calls playSong(). Once the chart is ready (playSong's own clearLoop() has
@@ -6124,8 +6351,17 @@ async function playSong(filename, arrangement, options) {
currentFilename = filename;
// A fresh load arms autoplay; a pending auto-exit from the previous
// song is no longer relevant.
_pendingAutostart = true;
// song is no longer relevant. A *resume* load (options.resume) instead
// arms _pendingResume — consumed at song:ready to restore speed + seek to
// the saved position, then start — so autostart and resume don't both try
// to begin playback from different positions.
if (options && options.resume && Number(options.resume.position) > 0) {
_pendingResume = options.resume;
_pendingAutostart = false;
} else {
_pendingResume = null;
_pendingAutostart = true;
}
_clearAutoExit();
// Remember which screen the player was launched from so Esc /
// navigation back from the player (and auto-exit) returns the user
@@ -6397,6 +6633,135 @@ function closeCurrentSong() {
window.closeCurrentSong = closeCurrentSong;
if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
// of leaving immediately. Auto-exit on song-end and a results screen's own
// Close never prompt — they call closeCurrentSong() directly, which stays the
// unguarded actual-exit.
function _exitConfirmEnabled() {
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
}
// Settings checkbox setter (onchange="setConfirmExitSong(this.checked)").
window.setConfirmExitSong = function (on) {
try { localStorage.setItem('confirmExitSong', on ? '1' : '0'); } catch (_) { /* private mode */ }
const el = document.getElementById('setting-confirm-exit');
if (el && el.checked !== !!on) el.checked = !!on;
};
let _exitConfirmOpen = false; // guard against stacking confirm modals
// User-initiated request to leave the player. Honors the confirm toggle; the
// actual exit is always closeCurrentSong() (origin-aware teardown).
function requestExitSong() {
if (!_exitConfirmEnabled()) { closeCurrentSong(); return; }
if (_exitConfirmOpen) return; // already asking
_openExitConfirm();
}
window.requestExitSong = requestExitSong;
if (window.feedBack) window.feedBack.requestExitSong = requestExitSong;
// A *true* modal (role="dialog" aria-modal="true" + .feedBack-modal) so the
// Escape/Space carve-outs classify it as a focus trap — they won't fire
// player-back / play-pause while it's up. Opening it PAUSES the song so it
// isn't running (or being scored) behind the prompt; Stay resumes exactly what
// we paused. Escape matches every other modal (and the generic _confirmDialog):
// it *dismisses* the prompt → Stay → drops you back into the (resumed) song —
// so a second Escape does NOT leave. Leaving is the explicit, default-focused
// "Leave" button, so Space/Enter (or click) is the keyboard "just get me out".
function _openExitConfirm() {
_exitConfirmOpen = true;
// Freeze the song while the user decides: cancel any pending count-in (so it
// can't start playback behind the modal) and pause if we're playing. Stay
// resumes only what we paused (wasPlaying), and only if the same song is
// still live on the player — guarding a teardown/seek/end behind the prompt.
_cancelCountIn();
const _resumeGen = _audioSeekGen;
const _wasPlaying = isPlaying;
if (_wasPlaying) Promise.resolve(togglePlay()).catch(() => {});
const overlay = document.createElement('div');
overlay.id = 'fb-exit-confirm';
overlay.className = 'feedBack-modal';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Leave this song?');
overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:200', 'display:flex',
'align-items:center', 'justify-content:center',
'background:rgba(0,0,0,0.6)',
'font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
].join(';');
const card = document.createElement('div');
card.style.cssText = [
'max-width:min(92vw,360px)', 'padding:18px 18px 14px',
'background:#111827', 'color:#e5e7eb',
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:12px',
'box-shadow:0 12px 40px rgba(0,0,0,0.5)', 'text-align:left',
].join(';');
const h = document.createElement('div');
h.textContent = 'Leave this song?';
h.style.cssText = 'font-size:16px;font-weight:700;color:#fff;margin-bottom:6px';
const p = document.createElement('div');
p.textContent = 'You can pick up where you left off from the Resume pill.';
p.style.cssText = 'opacity:0.75;margin-bottom:16px';
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
const stayBtn = document.createElement('button');
stayBtn.type = 'button';
stayBtn.textContent = 'Stay';
stayBtn.style.cssText = 'padding:8px 14px;border:1px solid rgba(148,163,184,0.3);border-radius:8px;background:transparent;color:#e5e7eb;cursor:pointer';
const leaveBtn = document.createElement('button');
leaveBtn.type = 'button';
leaveBtn.textContent = 'Leave';
leaveBtn.style.cssText = 'padding:8px 14px;border:0;border-radius:8px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
let settled = false;
function close(leave) {
if (settled) return;
settled = true;
_exitConfirmOpen = false;
document.removeEventListener('keydown', onKey, true);
overlay.remove();
if (leave) { closeCurrentSong(); return; }
// Stay → resume exactly what we paused, but only if the session is still
// the same live song on the player (not torn down / ended / seeked away
// behind the modal). If the user was already paused, leave them paused.
if (_wasPlaying && !isPlaying &&
_audioSeekGen === _resumeGen &&
document.querySelector('.screen.active')?.id === 'player') {
Promise.resolve(togglePlay()).catch(() => {});
}
}
// Capture-phase so this dialog owns Escape and it can't fall through to the
// player-scope back shortcut. Escape = Stay (dismiss the prompt and resume
// the song) — consistent with every other modal, so a second Escape does
// NOT leave. Space/Enter stay on native activation of the focused button
// (Leave by default), so the keyboard "leave" is Space/Enter.
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(false); }
}
document.addEventListener('keydown', onKey, true);
leaveBtn.addEventListener('click', () => close(true));
stayBtn.addEventListener('click', () => close(false));
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(false); });
row.appendChild(stayBtn);
row.appendChild(leaveBtn);
card.appendChild(h);
card.appendChild(p);
card.appendChild(row);
overlay.appendChild(card);
(document.body || document.documentElement).appendChild(overlay);
// Trap Tab within the dialog (Stay ↔ Leave) so focus can't fall back to the
// player controls underneath while it's open.
_trapFocusInModal(overlay);
// Default focus on "Leave" so Space/Enter leaves immediately.
leaveBtn.focus();
}
window._openExitConfirm = _openExitConfirm; // exposed for tests/debugging
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
const SPEED_SNAP_THRESHOLD = 0.02;
let _speedPresetsWired = false;
@@ -9652,7 +10017,7 @@ registerShortcut({
key: 'Escape',
description: 'Back to library',
scope: 'player',
handler: () => showScreen(_playerOriginScreen || 'home')
handler: () => requestExitSong()
});
registerShortcut({
@@ -9764,9 +10129,14 @@ function openEditModal(songData, openerEl) {
<input type="text" id="edit-album" value="${_escAttr(songData.al)}"
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
</div>
<div>
<label class="text-xs text-gray-400 mb-1 block">Year</label>
<input type="text" inputmode="numeric" id="edit-year" value="${_escAttr(songData.y)}" placeholder="e.g. 2024"
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
</div>
</div>
<div class="flex gap-3 mt-5">
<button onclick="saveEditModal('${encodeURIComponent(songData.f)}')"
<button data-edit-save
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition">Save</button>
<button data-edit-close
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
@@ -9802,6 +10172,16 @@ function openEditModal(songData, openerEl) {
document.getElementById('edit-art-file').click();
});
// Save — wired in JS (not an inline onclick) so the filename never has to
// survive embedding in a single-quoted attribute string. encodeURIComponent
// does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
// the inline `saveEditModal('…')` handler and silently fail the save. The
// raw filename lives in the closure; encode it here for saveEditModal.
const saveBtn = modal.querySelector('[data-edit-save]');
if (saveBtn) {
saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
}
const deleteBtn = modal.querySelector('[data-delete-filename]');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
@@ -9810,17 +10190,34 @@ function openEditModal(songData, openerEl) {
}
// Close on backdrop click or Cancel button; restore focus to opener.
// Backdrop dismissal requires the gesture's mousedown to have STARTED on
// the backdrop — not just the click/mouseup to land there. Otherwise a
// click-drag that begins inside a field (e.g. selecting text) and is
// released past the modal edge resolves its `click` target to the backdrop
// and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
let _downOnBackdrop = false;
modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.closest('[data-edit-close]')) {
const opener = modal._opener;
modal.remove();
const focusTarget = (opener && document.body.contains(opener)) ? opener
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
if (focusTarget) focusTarget.focus({ preventScroll: true });
}
if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
const opener = modal._opener;
modal.remove();
const focusTarget = (opener && document.body.contains(opener)) ? opener
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
if (focusTarget) focusTarget.focus({ preventScroll: true });
});
}
// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
// the click target to be the backdrop element itself AND the gesture to have
// started there (downOnBackdrop) — so a click-drag begun inside a field and
// released on the backdrop does not discard the form. Pure + top-level so it's
// unit-testable in isolation.
function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
return clickTarget === modalEl && downOnBackdrop === true;
}
function previewEditArt(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
@@ -9841,6 +10238,9 @@ async function saveEditModal(encodedFilename) {
title: document.getElementById('edit-title').value.trim(),
artist: document.getElementById('edit-artist').value.trim(),
album: document.getElementById('edit-album').value.trim(),
// Year is normalised server-side (non-numeric/empty → ""), so a
// blank or cleared field round-trips safely.
year: document.getElementById('edit-year').value.trim(),
}),
});
+15 -1
View File
@@ -519,6 +519,20 @@
</label>
</div>
</div>
<!-- Ask before leaving a song -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Ask before leaving a song</div>
<div class="fb-srow-desc">Confirm before Escape (or the player&rsquo;s ✕) exits a song. Off by default &mdash; Escape leaves instantly. With it on, a confirm appears; Space, Enter, or &ldquo;Leave&rdquo; exits, while Escape dismisses it.</div>
</div>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-confirm-exit" onchange="setConfirmExitSong(this.checked)">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
</div>
</div>
@@ -999,7 +1013,7 @@
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="v3-pop-btn hidden" title="Return to the editor where you left off">↩ Editor</button>
</span>
</div>
<button onclick="showScreen('home')" class="v3-pop-close" title="Close player">✕ Close player</button>
<button onclick="requestExitSong()" class="v3-pop-close" title="Close player">✕ Close player</button>
</div>
</div>
+40 -3
View File
@@ -28,6 +28,22 @@
} catch (e) { return null; }
}
// Content-dependent playlist cover: a custom uploaded cover wins; otherwise
// the playlist's own song art — the icon when empty, one cover for a few
// songs, a 2×2 mosaic at 4+. `art_urls` / `cover_url` come from /api/playlists.
function playlistCoverHtml(p) {
const box = 'w-full aspect-square rounded-lg overflow-hidden bg-fb-bg/50 mb-3';
const img = (u, cls) => '<img src="' + esc(u) + '" alt="" class="' + cls + '" onerror="this.style.visibility=\'hidden\'">';
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
if (!arts.length) {
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
}
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
@@ -96,8 +112,7 @@
(lists.length
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
'<div class="w-full aspect-square rounded-lg bg-fb-bg/50 mb-3 flex items-center justify-center text-fb-textDim">' +
(p.system_key ? '🔖' : '🎵') + '</div>' +
playlistCoverHtml(p) +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>'
@@ -126,8 +141,11 @@
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
(isSystem ? '' :
'<div class="flex gap-2 shrink-0">' +
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button></div>') +
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden"></div>') +
'</div>' +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
@@ -147,6 +165,25 @@
await fetch('/api/playlists/' + pid, { method: 'DELETE' });
renderPlaylists();
});
// Custom cover: pick an image → upload as a data URL → the playlist card
// shows it (overriding the song-art cover). Re-render the detail so the
// Remove-cover button appears; the grid picks up the new cover on return.
const coverFile = root.querySelector('#v3-pl-cover-file');
root.querySelector('#v3-pl-cover')?.addEventListener('click', () => coverFile && coverFile.click());
coverFile?.addEventListener('change', () => {
const f = coverFile.files && coverFile.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = async (e) => {
await jsend('POST', '/api/playlists/' + pid + '/cover', { image: e.target.result });
renderPlaylistDetail(pid);
};
reader.readAsDataURL(f);
});
root.querySelector('#v3-pl-cover-rm')?.addEventListener('click', async () => {
await fetch('/api/playlists/' + pid + '/cover', { method: 'DELETE' });
renderPlaylistDetail(pid);
});
}
// ── #v3-saved ─────────────────────────────────────────────────────────--
+1 -1
View File
@@ -29,7 +29,7 @@
gameplay: {
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
local: ['lefty', 'autoplayExit', 'showUpNext', 'arrangementNamingMode', 'countdownBeforeSong'],
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
after: function () {
// Left-handed is held on the highway object, not re-derived
// from localStorage on load — flip it back to the default.
+66 -7
View File
@@ -15,6 +15,20 @@
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
// Inverse of `enc` for matching a played song's filename back to a library
// card. The `stats:recorded` event (like `song:loading`) carries the
// filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd
// (see `playCard`, and the highway WS which decodeURIComponent's it). But
// cards key on the DECODED library filename (`cardKey` → `localFilename`),
// and `/api/stats/best` is server-canonicalized to that same decoded key, so
// an encoded filename matches no card and the post-play badge repaint silently
// no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent
// for already-decoded names (no '%'); on malformed input falls back to the
// original so a real filename containing a literal '%' is never corrupted.
function decFn(fn) {
if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || '';
try { return decodeURIComponent(fn); } catch (_) { return fn; }
}
const SORTS = [
['artist', 'Artist AZ'], ['artist-desc', 'Artist ZA'],
@@ -324,6 +338,12 @@
// tracks filenames scored while the library was off-screen, applied on enter.
const _dirtyScores = new Set();
// Set when a library scan / DLC-folder change happened while this screen was
// off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM /
// snapshot would otherwise survive a sidebar return, so we force a full
// re-fetch on the next entry. (feedBack — "No DLC until restart".)
let _libraryDirty = false;
function repaintAccuracy(key) {
const apply = (el, variant) => {
if (el.getAttribute('data-fn') !== key) return;
@@ -475,6 +495,7 @@
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
const rows = [
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
{ id: '__playlist', label: 'Add to playlist' },
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
];
menu.innerHTML = rows.map((r) =>
@@ -494,6 +515,7 @@
const id = b.getAttribute('data-act');
closeMenu();
if (id === '__play') { playCard(song); return; }
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
if (reg) await reg.run(id, song, { source: 'v3-songs' });
}));
setTimeout(() => document.addEventListener('click', closer), 0);
@@ -592,26 +614,42 @@
finishBatch();
}
async function batchAddToPlaylist() {
// Prompt for a target playlist (pick a listed number, or type a new name to
// create it) and add the given song filenames to it. Shared by the
// select-mode batch bar and the per-card ⋮ menu's single-song add. Returns
// the playlist id (or null if cancelled).
async function addFilenamesToPlaylist(filenames) {
const fns = Array.from(filenames || []);
if (!fns.length) return null;
const lists = (await jget('/api/playlists')) || [];
const choices = lists.filter((p) => !p.system_key);
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join(' ');
const ans = ((await window.uiPrompt({
title: 'Add ' + state.selected.size + ' song(s) to a playlist',
title: 'Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to a playlist',
label: (labels ? labels + '' : '') + 'Type a number above, or a new playlist name:',
okLabel: 'Add',
placeholder: 'Number or new playlist name',
})) || '').trim();
if (!ans) return;
if (!ans) return null;
let pid = null;
const num = parseInt(ans, 10);
if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id;
else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; }
if (!pid) return;
for (const fn of state.selected) {
if (!pid) return null;
for (const fn of fns) {
try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
}
finishBatch();
if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } }
return pid;
}
async function batchAddToPlaylist() {
const pid = await addFilenamesToPlaylist(state.selected);
// Only tear down the multi-select when the add actually happened. A
// cancelled or failed picker returns null — preserve the selection (and
// skip the reload) so the user can retry, matching the pre-refactor
// behaviour where !ans / !pid returned early before finishBatch().
if (pid) finishBatch();
}
function finishBatch() {
@@ -1035,6 +1073,10 @@
}
async function onV3SongsScreenEnter() {
// A library scan / DLC-folder change marked the grid stale — re-fetch
// from scratch instead of restoring a cached (possibly empty, pre-DLC)
// snapshot. Must win over every fast-path below.
if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
// Pull in any scores recorded while the library was off-screen (the usual
// play→return flow) before the fast-paths below restore the cached DOM,
// so the just-played song's badge is current. The full render() path
@@ -1173,7 +1215,13 @@
// If the library is visible right now, repaint immediately; otherwise
// mark it dirty and onV3SongsScreenEnter applies it on return.
sm.on('stats:recorded', (e) => {
const fn = e && e.detail && e.detail.filename;
// Decode to the library-card key space — the event carries the
// encodeURIComponent'd filename, but cards (data-fn) and
// state.accuracy key on the decoded library filename. Without this
// the repaint below (and applyScoreRefresh's repaintAccuracy) match
// no card, so a just-earned score stays invisible until a full
// render() (restart / search / re-enter), which is this bug.
const fn = decFn(e && e.detail && e.detail.filename);
if (!fn) return;
_dirtyScores.add(fn);
// Only repaint now if the library is the active screen; otherwise
@@ -1182,5 +1230,16 @@
const active = document.querySelector('.screen.active');
if (active && active.id === 'v3-songs') applyScoreRefresh();
});
// A library scan (rescan / full rescan from Settings, or a DLC-folder
// change) can add or remove songs while this grid is cached — the
// Settings rescan only refreshed the classic library, so the v3 grid
// stayed on its pre-scan (e.g. empty, pre-DLC) state until an app
// restart. Reload now if we're showing; otherwise mark dirty so the next
// entry re-fetches instead of restoring the stale snapshot.
sm.on('library:changed', () => {
const active = document.querySelector('.screen.active');
if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); }
else _libraryDirty = true;
});
}
})();
+124
View File
@@ -0,0 +1,124 @@
import { test, expect } from '@playwright/test';
// Opt-in "Ask before leaving a song" confirm. Default OFF → Escape/✕ leave
// instantly. When ON, a true-modal confirm appears and PAUSES the song; Escape
// (like every other modal) DISMISSES it → Stay, so a second Escape returns to
// the song rather than leaving, and Space/Enter activate the default-focused
// "Leave". (The mock song has no backing audio, so the pause-on-open /
// resume-on-Stay is verified manually on web + desktop; these specs lock the
// navigation + keyboard semantics.)
const CONFIRM_KEY = 'confirmExitSong';
async function installMockSong(page) {
await page.evaluate(() => {
const messages = [
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
{ type: 'ready' },
];
class MockWebSocket {
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen = null; onmessage = null; onerror = null; onclose = null; url;
constructor(url) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen(new Event('open'));
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
}, 0);
}
send() {}
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
}
// @ts-ignore
window.WebSocket = MockWebSocket;
});
}
async function openPlayerWithMockSong(page) {
await installMockSong(page);
await page.evaluate(async () => { /* @ts-ignore */ await window.playSong('mock-song.sloppak'); });
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
}
test.describe('Exit-confirm toggle', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (a modal that intercepts
// pointer/keyboard events) so Escape reaches the player, not the overlay.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.evaluate((k) => localStorage.removeItem(k), CONFIRM_KEY);
});
test('default OFF: Escape exits the song immediately, no confirm', async ({ page }) => {
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(0);
});
test('ON: Escape opens the confirm and the song stays', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
await expect(page.locator('#player.active')).toHaveCount(1);
// "Leave" is focused so Space/Enter leaves immediately.
await expect(page.locator('#fb-exit-confirm button', { hasText: 'Leave' })).toBeFocused();
});
test('ON: a second Escape dismisses the prompt and stays in the song', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
// Escape = dismiss (Stay), matching every other modal — NOT leave.
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(1);
});
test('ON: clicking the backdrop dismisses the prompt and stays', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
// mousedown on the overlay backdrop (top-left, away from the centered card)
// is Stay — never an accidental leave.
await page.locator('#fb-exit-confirm').click({ position: { x: 5, y: 5 } });
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(1);
});
test('ON: "Stay" keeps you in the song; "Leave" exits', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await page.locator('#fb-exit-confirm button', { hasText: 'Stay' }).click();
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(1);
await page.keyboard.press('Escape');
await page.locator('#fb-exit-confirm button', { hasText: 'Leave' }).click();
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(0);
});
test('ON: Enter on the default-focused "Leave" leaves', async ({ page }) => {
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
await openPlayerWithMockSong(page);
await page.keyboard.press('Escape');
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
await expect(page.locator('#player.active')).toHaveCount(0);
});
});
+185
View File
@@ -51,6 +51,14 @@ async function openPlayerWithMockSong(page) {
test.describe('Keyboard Shortcuts', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that
// intercepts pointer/keyboard events — so the app behaves like a returning
// user, which is the state these tests assume.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
});
@@ -778,6 +786,183 @@ test('should support condition callbacks', async ({ page }) => {
expect(result.clicked).toBe(1);
});
// ── Escape = universal "Back" carve-out ──────────────────────────────────
// Escape must escape a focused non-modal control exactly like Space does,
// so a focused transport/rail button can't swallow it ("Escape in song not
// consistent"). These mirror the #593 Space tests above. Each registers an
// Escape spy in the relevant scope (which replaces the built-in handler for
// that composite key) so the assertion doesn't depend on showScreen teardown.
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
await openPlayerWithMockSong(page);
// The bug: a focused <button> is an "interactive control", so Escape was
// blocked before reaching the dispatcher and the song wouldn't exit until
// the user clicked empty canvas to blur the control.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const btn = document.createElement('button');
btn.id = '__test-rail-btn';
btn.textContent = 'Restart';
document.getElementById('player')!.appendChild(btn);
});
await page.locator('#__test-rail-btn').focus();
await expect(page.locator('#__test-rail-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Back-to-library fired despite the control button holding focus.
expect(backCount).toBe(1);
});
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
await openPlayerWithMockSong(page);
// The text-input exemption (_isTextInput) is checked before the Escape
// carve-out, so Escape in a field is the field's own concern (clear/blur),
// never a song exit.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const input = document.createElement('input');
input.type = 'text';
input.id = '__test-player-input';
document.getElementById('player')!.appendChild(input);
});
await page.locator('#__test-player-input').focus();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
expect(backCount).toBe(0);
});
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
await openPlayerWithMockSong(page);
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
// over the player is a focus trap: Escape there must NOT eject past it to
// exit the song — the modal owns Escape. The carve-out's modal-overlay
// guard keeps the player-back shortcut from firing.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const modal = document.createElement('div');
modal.id = '__test-modal';
modal.className = 'feedBack-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const btn = document.createElement('button');
btn.id = '__test-modal-btn';
btn.textContent = 'Close';
modal.appendChild(btn);
document.body.appendChild(modal);
});
await page.locator('#__test-modal-btn').focus();
await expect(page.locator('#__test-modal-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Playback is NOT exited behind the modal.
expect(backCount).toBe(0);
});
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
await openPlayerWithMockSong(page);
// The Section Practice popover claims Escape earlier in
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
// open popover suppresses the player-scope back-to-library Escape — the
// popover's own handler owns closing it. This locks that ordering guard.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
let bar = document.getElementById('section-practice-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'section-practice-bar';
document.getElementById('player')!.appendChild(bar);
}
bar.classList.add('section-practice-bar--open');
});
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Player-back did NOT fire while the popover was open.
expect(backCount).toBe(0);
});
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
// The same focus bug existed on the settings screen (the carve-out was
// player-only). The fix covers settings too: Escape returns to the
// previous screen even when a settings control holds focus.
await page.evaluate(() => {
// @ts-ignore
window.__escSettingsBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Go back from settings (test spy)',
scope: 'settings',
// @ts-ignore
handler: () => { window.__escSettingsBackCount++; },
});
// @ts-ignore
window.showScreen('settings');
const btn = document.createElement('button');
btn.id = '__test-settings-btn';
btn.textContent = 'Some setting';
document.getElementById('settings')!.appendChild(btn);
});
await page.waitForSelector('#settings.active', { timeout: 5000 });
await page.locator('#__test-settings-btn').focus();
await expect(page.locator('#__test-settings-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
expect(backCount).toBe(1);
});
test('should warn on invalid scope', async ({ page }) => {
const messages: string[] = [];
page.on('console', msg => {
+152
View File
@@ -0,0 +1,152 @@
import { test, expect } from '@playwright/test';
// Resume-last-session: leaving the player snapshots {song, arrangement,
// position, speed} so an exit is recoverable via a non-blocking "Resume" pill.
// These exercise the deterministic plumbing (snapshot guards, staleness, the
// pill, and resume consumption) without depending on real audio timing.
const RESUME_KEY = 'feedBack.resumeSession';
// Make playSong()'s WebSocket a no-network mock that emits a song_info + ready.
async function installMockSong(page) {
await page.evaluate(() => {
const messages = [
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
{ type: 'ready' },
];
class MockWebSocket {
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen = null; onmessage = null; onerror = null; onclose = null; url;
constructor(url) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen(new Event('open'));
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
}, 0);
}
send() {}
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
}
// @ts-ignore
window.WebSocket = MockWebSocket;
});
}
async function openPlayerWithMockSong(page) {
await installMockSong(page);
await page.evaluate(async () => {
// @ts-ignore
await window.playSong('mock-song.sloppak');
});
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
}
test.describe('Resume last session', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (a modal that intercepts
// pointer/keyboard events) so the player isn't covered.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.evaluate((k) => localStorage.removeItem(k), RESUME_KEY);
});
test('snapshots song + arrangement + position once you are mid-song', async ({ page }) => {
await openPlayerWithMockSong(page);
const snap = await page.evaluate(() => {
// @ts-ignore
window._snapshotResumeSession(30);
// @ts-ignore
return window._readResumeSession();
});
expect(snap).not.toBeNull();
expect(snap.f).toBe('mock-song.sloppak');
expect(snap.a).toBe(0);
expect(Math.round(snap.t)).toBe(30);
expect(snap.title).toBe('Mock Song');
});
test('does NOT snapshot a barely-started or basically-finished song', async ({ page }) => {
await openPlayerWithMockSong(page);
const result = await page.evaluate(() => {
// @ts-ignore
window._snapshotResumeSession(1); // < 3s min → ignored
// @ts-ignore
const tooEarly = window._readResumeSession();
// duration is 90; end-guard is 5s, so 88 > 85 → ignored
// @ts-ignore
window._snapshotResumeSession(88);
// @ts-ignore
const tooLate = window._readResumeSession();
return { tooEarly, tooLate };
});
expect(result.tooEarly).toBeNull();
expect(result.tooLate).toBeNull();
});
test('a stale (>24h) snapshot is ignored', async ({ page }) => {
const got = await page.evaluate((k) => {
const old = { f: 'old.sloppak', a: 0, t: 42, sp: 1, title: 'Old', ts: Date.now() - 25 * 60 * 60 * 1000 };
localStorage.setItem(k, JSON.stringify(old));
// @ts-ignore
return window._readResumeSession();
}, RESUME_KEY);
expect(got).toBeNull();
});
test('the Resume pill appears off-player and hides on the player', async ({ page }) => {
await page.evaluate((k) => {
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', artist: 'Mock Artist', ts: Date.now() };
localStorage.setItem(k, JSON.stringify(snap));
// @ts-ignore
window.feedBack._maybeShowResumePill();
}, RESUME_KEY);
await expect(page.locator('#fb-resume-pill')).toBeVisible();
await expect(page.locator('#fb-resume-pill')).toContainText('Mock Song');
// Entering the player hides it (screen:changed → _hideResumePill()).
await page.evaluate(() => { /* @ts-ignore */ window.showScreen('player'); });
await page.waitForSelector('#player.active', { timeout: 5000 });
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
});
test('dismissing the pill removes it and does not re-show it this session', async ({ page }) => {
await page.evaluate((k) => {
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
localStorage.setItem(k, JSON.stringify(snap));
// @ts-ignore
window.feedBack._maybeShowResumePill();
}, RESUME_KEY);
await expect(page.locator('#fb-resume-pill')).toBeVisible();
await page.locator('#fb-resume-pill button[aria-label="Dismiss"]').click();
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
// A re-offer attempt within the same session is suppressed.
await page.evaluate(() => { /* @ts-ignore */ window.feedBack._maybeShowResumePill(); });
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
});
test('resumeLastSession() re-enters the song and consumes the snapshot', async ({ page }) => {
await installMockSong(page);
await page.evaluate((k) => {
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
localStorage.setItem(k, JSON.stringify(snap));
}, RESUME_KEY);
await page.evaluate(async () => { /* @ts-ignore */ await window.resumeLastSession(); });
await page.waitForSelector('#player.active', { timeout: 5000 });
// The snapshot is consumed (cleared) so it isn't offered again.
const remaining = await page.evaluate((k) => localStorage.getItem(k), RESUME_KEY);
expect(remaining).toBeNull();
});
});
+111
View File
@@ -0,0 +1,111 @@
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
//
// 1. Year is editable — the modal renders an `edit-year` field and
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
// (Backend already accepts/normalizes year; only the UI omitted it.)
//
// 2. A click-drag that starts inside a field and is released on the backdrop
// must NOT dismiss the modal. _editModalShouldClose() gates backdrop
// dismissal on the mousedown having started on the backdrop too.
//
// Functions are extracted from the real shipped source and run in a vm — no
// mirror copies.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
function loadFn(signature, sandbox, exportAs) {
const fnSrc = extractFunction(readApp(), signature);
const ctx = vm.createContext(sandbox);
vm.runInContext(`${fnSrc}\nglobalThis.${exportAs} = ${exportAs};`, ctx);
return sandbox[exportAs];
}
// ── Issue: Edit Metadata does not allow changing Year ────────────────────────
test('openEditModal renders a Year field bound to songData.y', () => {
const src = extractFunction(readApp(), 'function openEditModal');
assert.match(src, /id="edit-year"/, 'modal must render an #edit-year input');
assert.match(src, /_escAttr\(songData\.y\)/, 'year input must be populated from songData.y');
});
test('Save button wires via data-edit-save, not an inline onclick that embeds the filename', () => {
// encodeURIComponent does NOT escape `'`, so embedding the filename in a
// single-quoted inline `saveEditModal('…')` handler breaks the save for a
// song whose filename contains an apostrophe (e.g. `Bob's Song.sloppak`).
// The Save button must use the data-attr + JS-listener pattern instead.
const src = extractFunction(readApp(), 'function openEditModal');
assert.doesNotMatch(src, /onclick="saveEditModal\('/, 'Save must not embed the filename in an inline onclick');
assert.match(src, /data-edit-save/, 'Save button must carry the data-edit-save hook');
assert.match(src, /querySelector\('\[data-edit-save\]'\)/, 'Save must be wired via addEventListener');
});
test('saveEditModal includes year in the metadata POST body', async () => {
const calls = [];
const values = {
'edit-title': 'My Title', 'edit-artist': 'My Artist',
'edit-album': 'My Album', 'edit-year': '1998',
'edit-art-file': null, // signals the file branch via .files below
'edit-modal': null,
};
const sandbox = {
decodeURIComponent, encodeURIComponent, JSON, Promise,
_lastLibSelected: null,
loadLibrary: () => {}, loadFavorites: () => {},
fetch: (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); },
document: {
getElementById: (id) => {
if (id === 'edit-art-file') return { files: null };
if (id === 'edit-modal') return null;
return id in values ? { value: values[id] } : null;
},
querySelector: () => null, // no active screen
body: { contains: () => false },
},
};
const saveEditModal = loadFn('async function saveEditModal', sandbox, 'saveEditModal');
await saveEditModal(encodeURIComponent('Song With Spaces.sloppak'));
const metaCall = calls.find((c) => /\/api\/song\/.+\/meta$/.test(c.url));
assert.ok(metaCall, 'expected a POST to /api/song/<filename>/meta');
const body = JSON.parse(metaCall.opts.body);
assert.equal(body.year, '1998', 'meta POST body must carry the edited year');
assert.deepEqual(
body,
{ title: 'My Title', artist: 'My Artist', album: 'My Album', year: '1998' },
'meta POST body shape',
);
});
// ── Issue: Renaming Metadata Closes Modal (click-drag release on backdrop) ────
test('_editModalShouldClose: backdrop needs mousedown to have started there', () => {
const fn = loadFn('function _editModalShouldClose', {}, '_editModalShouldClose');
const modalEl = { closest: () => null }; // the backdrop element
const innerEl = { closest: () => null }; // a field inside the modal
const cancelBtn = { closest: (s) => (s === '[data-edit-close]' ? { tag: 'button' } : null) };
// Cancel / ✕ always closes, regardless of where the mousedown began.
assert.equal(fn(cancelBtn, modalEl, false), true, 'Cancel/✕ closes');
assert.equal(fn(cancelBtn, modalEl, true), true, 'Cancel/✕ closes (down-on-backdrop irrelevant)');
// Genuine backdrop click: down AND up on the backdrop.
assert.equal(fn(modalEl, modalEl, true), true, 'backdrop down+up closes');
// The reported bug: drag began inside a field (down NOT on backdrop), click
// resolves to the backdrop on release — must NOT close.
assert.equal(fn(modalEl, modalEl, false), false, 'drag-from-field release on backdrop does NOT close');
// A click that lands on inner content never closes via the backdrop path.
assert.equal(fn(innerEl, modalEl, true), false, 'click on inner content does not close');
});
+37
View File
@@ -0,0 +1,37 @@
// Guard: a song's ⋮ "More" menu offers "Add to playlist" for a single song —
// not only the select-mode checkbox + batch-bar flow. Both paths share the
// extracted addFilenamesToPlaylist() helper. (Menu/DOM wiring isn't headlessly
// unit-testable, so these are source-level guards.)
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'), 'utf8');
test('the ⋮ card menu lists an "Add to playlist" row', () => {
assert.match(SONGS, /id:\s*'__playlist',\s*label:\s*'Add to playlist'/);
});
test('the menu row adds the single song via the shared helper', () => {
assert.match(SONGS, /id === '__playlist'[\s\S]{0,100}addFilenamesToPlaylist\(\[song\.filename\]\)/);
});
test('batch and single-song add share addFilenamesToPlaylist()', () => {
assert.match(SONGS, /async function addFilenamesToPlaylist\(filenames\)/);
assert.match(SONGS, /async function batchAddToPlaylist\(\)[\s\S]{0,120}addFilenamesToPlaylist\(state\.selected\)/);
});
test('batch only finishes (clears selection) when the add succeeded, not on cancel', () => {
// addFilenamesToPlaylist returns null on a cancelled/failed picker; the
// batch caller must capture it and gate finishBatch() on a truthy pid, so
// cancelling preserves the multi-select (regression guard for the
// extract-helper refactor — previously finishBatch ran unconditionally).
assert.match(SONGS, /const pid = await addFilenamesToPlaylist\(state\.selected\)/,
'batch must capture the returned playlist id');
assert.match(SONGS, /if \(pid\) finishBatch\(\)/,
'finishBatch must be gated on a successful add (truthy pid)');
});
+40
View File
@@ -0,0 +1,40 @@
// Regression guard for "No DLC until restart": a library scan triggered from
// Settings (rescan / full rescan, e.g. right after pointing at a DLC folder)
// reloaded only the classic library — the v3 Songs grid kept its cached
// (pre-DLC, empty) state until an app restart.
//
// The fix wires a `library:changed` event (emitted by the rescan handlers in
// app.js) to a reload in static/v3/songs.js. That's DOM/event glue, not a pure
// function, so these are source-level guards that the wiring isn't dropped; the
// end-to-end behavior is verified in-app / by a browser test.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
test('app.js emits library:changed when a Settings rescan completes', () => {
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
'a completed rescan must broadcast library:changed for the v3 grid');
});
test('songs.js handles library:changed — reload when active, else mark dirty', () => {
const m = SONGS.match(/sm\.on\(\s*['"]library:changed['"][\s\S]{0,500}?\}\);/);
assert.ok(m, 'songs.js must subscribe to library:changed');
assert.match(m[0], /reload\(\)/, 'reloads the grid when the screen is active');
assert.match(m[0], /_libraryDirty\s*=\s*true/, 'marks dirty when off-screen');
});
test('onV3SongsScreenEnter forces a reload when the library is dirty', () => {
const m = SONGS.match(/function onV3SongsScreenEnter\(\)[\s\S]{0,400}?\{/);
assert.ok(m, 'onV3SongsScreenEnter present');
// The dirty check must short-circuit to a reload before the cached-DOM
// fast-paths get a chance to restore the stale grid.
assert.match(SONGS, /if\s*\(_libraryDirty\)\s*\{[^}]*reload\(\)[^}]*return;/,
'a dirty library must force a full reload on entry, ahead of any fast-path');
});
+28
View File
@@ -0,0 +1,28 @@
// Guard for the content-dependent playlist cover (playlists.js). A custom
// uploaded cover wins; otherwise the playlist's song art decides: icon when
// empty, a single cover for a few songs, a 2×2 mosaic at 4+. (Rendering is DOM
// glue, so this is a source-level guard on the decision branches.)
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PL = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js'), 'utf8');
test('custom cover_url takes priority', () => {
assert.match(PL, /function playlistCoverHtml\(p\)/);
assert.match(PL, /if \(p\.cover_url\) return/);
});
test('empty → icon, <4 → single art, 4+ → 2×2 mosaic', () => {
assert.match(PL, /if \(!arts\.length\)[\s\S]{0,160}(🔖|🎵)/); // empty → icon
assert.match(PL, /arts\.length < 4\) return[\s\S]{0,120}arts\[0\]/); // a few → single cover
assert.match(PL, /grid-cols-2 grid-rows-2[\s\S]{0,120}slice\(0, 4\)/); // 4+ → mosaic
});
test('the card uses playlistCoverHtml (not the old static emoji box)', () => {
assert.match(PL, /playlistCoverHtml\(p\)/);
});
@@ -0,0 +1,99 @@
// Regression guard for the post-play score-badge refresh bug
// (#574 follow-up): after finishing a song, its accuracy badge on the
// Songs screen stayed stale until a full re-render (app restart / search /
// re-enter), even though stats-recorder fired `stats:recorded`.
//
// Root cause: `stats:recorded` (like `song:loading`) carries the filename
// exactly as handed to playSong — encodeURIComponent'd (see playCard) — but
// library cards key on the DECODED filename (data-fn = cardKey → localFilename)
// and /api/stats/best is server-canonicalized to that same decoded key. So the
// in-place repaint (repaintAccuracy) matched no card and silently no-oped.
//
// The fix is a `decFn` helper in static/v3/songs.js that decodes the event
// filename back into the card / state.accuracy key space before matching. This
// test extracts the REAL decFn from the shipped source (not a mirror) and proves
// the encoded event filename round-trips to the raw card key.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
// Brace-balanced extraction so nested braces / template strings survive.
function extractFunctionSource(src, name) {
const sig = `function ${name}`;
const start = src.indexOf(sig);
assert.ok(start !== -1, `function declaration '${name}' not found in songs.js`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${name}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces in function '${name}'`);
return src.slice(start, i);
}
function loadDecFn() {
const src = fs.readFileSync(SONGS_JS, 'utf8');
const fnSrc = extractFunctionSource(src, 'decFn');
const sandbox = {};
vm.createContext(sandbox);
// decodeURIComponent is an intrinsic global in the fresh context.
vm.runInContext(`${fnSrc}\nglobalThis.__decFn = decFn;`, sandbox);
return sandbox.__decFn;
}
const enc = encodeURIComponent; // exactly what playCard passes to playSong
// The on-disk library filenames from the bug report's screenshots, plus a
// subfolder path (encodeURIComponent turns '/' into %2F too).
const CARD_KEYS = [
'Black Me Out.sloppak',
'All In Now.sloppak',
'Dogstar - All In Now.feedpak',
'Subdir/Song (Live).sloppak',
];
test('decFn decodes an encoded event filename back to the raw card key', () => {
const decFn = loadDecFn();
for (const key of CARD_KEYS) {
const eventFilename = enc(key); // how stats:recorded carries it
// Precondition: the encoded form does NOT equal the card key — this is
// exactly why the un-decoded match failed and the badge stayed stale.
assert.notEqual(eventFilename, key, `expected '${key}' to encode to something different`);
// The fix: decoding lands back on the card / state.accuracy key.
assert.equal(decFn(eventFilename), key, `decFn must recover the card key for '${key}'`);
}
});
test('decFn is idempotent for already-decoded filenames (no % present)', () => {
const decFn = loadDecFn();
for (const key of CARD_KEYS) {
assert.equal(decFn(key), key, `decFn must leave the already-decoded '${key}' unchanged`);
}
});
test('decFn leaves a real literal-% filename intact rather than throwing', () => {
const decFn = loadDecFn();
// '%.sloppak' / '100%.sloppak' are malformed percent-escapes —
// decodeURIComponent would throw; decFn must fall back to the original.
for (const name of ['100%.sloppak', 'mix %.feedpak', '%zz.sloppak']) {
assert.equal(decFn(name), name, `decFn must not corrupt/throw on '${name}'`);
}
});
test('decFn coerces non-string / empty input to an empty string', () => {
const decFn = loadDecFn();
assert.equal(decFn(null), '');
assert.equal(decFn(undefined), '');
assert.equal(decFn(''), '');
});
+64
View File
@@ -106,3 +106,67 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
names = [s["filename"] for s in pl["songs"]]
assert "live.archive" in names and "ghost.archive" not in names
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
# ── Playlist covers (content-dependent art + custom upload) ──────────────────
def _png_b64():
"""A tiny base64 PNG with the data-URL prefix, like the browser sends."""
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (4, 4), (200, 30, 60)).save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def test_list_includes_song_art_urls_for_content_cover(client, server):
for fn in ("x.archive", "y.archive"):
server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "Arts"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "x.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "y.archive"})
pl = [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]
assert pl["art_urls"] == ["/api/song/x.archive/art", "/api/song/y.archive/art"]
assert pl["cover_url"] is None # no custom cover yet
def test_custom_cover_roundtrip(client):
pid = client.post("/api/playlists", json={"name": "Cover"}).json()["id"]
r = client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert r.status_code == 200 and r.json()["ok"] is True
assert r.json()["cover_url"].startswith(f"/api/playlists/{pid}/cover")
# list + detail both report it
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["cover_url"]
assert client.get(f"/api/playlists/{pid}").json()["cover_url"]
# served as a real PNG
img = client.get(f"/api/playlists/{pid}/cover")
assert img.status_code == 200 and img.headers["content-type"] == "image/png"
assert img.content[:8] == b"\x89PNG\r\n\x1a\n"
# removed
assert client.delete(f"/api/playlists/{pid}/cover").json() == {"ok": True}
assert client.get(f"/api/playlists/{pid}/cover").status_code == 404
assert client.get(f"/api/playlists/{pid}").json()["cover_url"] is None
def test_cover_rejects_non_image(client):
pid = client.post("/api/playlists", json={"name": "Bad"}).json()["id"]
assert client.post(f"/api/playlists/{pid}/cover",
json={"image": "data:text/plain;base64,bm90IGFuIGltYWdl"}).status_code == 400
assert client.post(f"/api/playlists/{pid}/cover", json={"image": ""}).status_code == 400
def test_cover_rejects_non_string_image_with_400_not_500(client):
# A non-string `image` (number / null / object) must be a clean 400, not a
# 500 from `"," in <non-str>` raising TypeError before the type check.
pid = client.post("/api/playlists", json={"name": "Typed"}).json()["id"]
for bad in (123, None, {"x": 1}, ["a"]):
assert client.post(f"/api/playlists/{pid}/cover", json={"image": bad}).status_code == 400
def test_deleting_playlist_removes_custom_cover(client, server):
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert server._playlist_cover_path(pid).exists()
client.delete(f"/api/playlists/{pid}")
assert not server._playlist_cover_path(pid).exists()