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
39 changed files with 296 additions and 3949 deletions
-13
View File
@@ -8,13 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
- **AZ fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
- **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).
@@ -47,12 +40,6 @@ 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
- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller, enabling an honest "Now listening to: <device>" readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
- **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).
-1
View File
@@ -552,7 +552,6 @@ a local pointer + code map.
- **Storage** — `localStorage` for all user preferences
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
## Backend Conventions
+56 -116
View File
@@ -55,94 +55,59 @@ The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
resolves — themed or not. Roles:
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
The existing `--fbv-*` vars stay **internal plumbing**`theme-core.js` uses them only to
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
they are **not** part of this contract and plugins must not read them. (Implementation may seed
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
`surface · card · border · text · text-dim · accent · accent-2 · good · warn · bad`
plus two **new keystones**:
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
where noted.
- **`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`.
**Normative role tokens** (all `--fb-*`, all always present):
| Role | Token | Notes |
| --- | --- | --- |
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
| text / dim | `--fb-text` `--fb-text-dim` | |
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
| **focus (new)** | `--fb-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 (all `--fb-*`-prefixed,
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` 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:
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
`--emph-fill / --emph-border / --emph-halo / --emph-on`.
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
fill/border/halo non-`none`), so a primary action is never visually flat.
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
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.
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
even where clip-text is unsupported.
> 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
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
booleans. On the existing `window.feedBack` bus:
On the existing `window.feedBack` bus:
- `feedBack.theme.get()``{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
CSS-authored decorative motion.
- `theme:changed` event → `{ id, tokens }`.
- `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`)
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
and is valid at any time — before any theme is applied it returns the default/unthemed roles
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
first theme apply.
**Reconciliation rule (ends the two-disconnected-systems problem) — depends on whether the
plugin has its own identity:**
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
never imposes a device the active theme neutralizes.
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
cross those boundaries).
- **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)
@@ -152,47 +117,28 @@ cross those boundaries).
```css
.hero-cta {
background: var(--fb-emph-fill);
border: var(--fb-emph-border);
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
color: var(--fb-emph-on); /* never hardcoded #fff again */
border-radius: var(--fb-corner-radius);
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 {
/* Always-legible solid base; survives no-clip-text support too. */
color: rgb(var(--fb-accent));
}
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
invisible. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
.accuracy-number {
background: var(--fb-acc-text-fill);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
color: var(--accent); /* legible solid fallback FIRST */
background: var(--acc-text-fill);
background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
```
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
## 6. Accessibility (baked into the contract, not per-feature)
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
named; one central rule in the contract sheet sets it to `none` under
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
cleanup — "one rule" describes the end state, not day one.
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
Contrast is the theme's job, computed once — not re-judged 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)
@@ -211,15 +157,13 @@ stays legible when its slot resolves to `none`** · reduced-motion + focus parit
## 8. Back-compat & rollout
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
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** the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
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.
@@ -238,9 +182,5 @@ two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, an
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.
*(Resolved during review and folded into the sections above: the token namespace + value grammar
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
host-owned contract stylesheet outside Tailwind (§6).)*
- 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.30.2",
"version": "3.30.0",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+3 -94
View File
@@ -1095,16 +1095,6 @@
const CAM_FRAME_H_FAR = 1.00;
const CAM_FRAME_D_NEAR = 0.575;
const CAM_FRAME_D_FAR = 0.60;
// Fret-row fit guard. The heat-coloured fret-number row is a band drawn
// BELOW the board (at sY(lowest) - S_GAP*1.4). The lower-third framing
// anchors the board CENTRE, not that row, so a tight zoom on a centred span
// (worst mid-neck — fine pushed to either end of the neck) drops the row off
// the bottom edge. Tilt can't add vertical room there (it would only trade a
// bottom clip for a top clip), so camUpdate dollies the camera back just
// enough to bring the row back into frame — auto-sized, capped, hysteretic.
const FRET_ROW_FIT_NDC_MIN = -0.86; // keep the row anchor at/above this NDC y (>-1 = on screen)
const FRET_ROW_FIT_DEADBAND = 0.06; // headroom past the min before the dolly relaxes (anti-hunt)
const FRET_ROW_FIT_BOOST_MAX = 1.6; // cap the pull-back so the zoom can't pop (never dolly back > +60%)
// Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is
// far too coarse for picking where the camera should sit — a single
@@ -3335,42 +3325,6 @@
let _fpsEma = 0;
let _fpsDisplay = 0;
let _fpsLastSampleT = 0;
// The FPS readout is pinned top-right of the highway overlay — the same
// corner the v3 player chrome stacks its persistent "Up Next" pill and
// live-performance HUD into, on a higher layer that paints over the
// canvas. So out of the box the readout sits *behind* that chrome and
// can't be read (exactly when you've turned it on to judge perf). Rather
// than relocate it (testers look top-right), we drop it just BELOW
// whichever of that chrome is showing. Refs are resolved once and cached
// — never a per-frame querySelector (see CLAUDE.md "never run DOM queries
// on a per-frame path") — and re-resolved only when a node detaches.
let _v3HudEls = null;
// Returns the bottom edge (in overlay-canvas px, which are 1:1 CSS px on
// this overlay) of the lowest visible top-right v3 chrome element, or 0
// when none apply (classic v2 UI, or all hidden). Only called while the
// FPS readout is actually drawn, so the layout reads cost nothing in the
// common (counter-off) case.
function _v3TopRightChromeBottom() {
if (typeof document === 'undefined' || !highwayCanvas) return 0;
// Only the v3 chrome stacks persistent HUD elements over the canvas's
// top-right. Gate on the documented detector so this is a strict no-op
// in classic v2 (where 'hud-time' also exists but sits elsewhere).
if (!(window.feedBack && window.feedBack.uiVersion === 'v3')) return 0;
if (!_v3HudEls || _v3HudEls.some((el) => el && !el.isConnected)) {
_v3HudEls = ['v3-upnext', 'v3-live-performance-hud', 'hud-time']
.map((id) => document.getElementById(id));
}
const top = highwayCanvas.getBoundingClientRect().top;
let maxBottom = 0;
for (const el of _v3HudEls) {
// offsetParent === null ⇒ display:none (a `.hidden` pill/HUD) or
// not laid out — don't duck under something that isn't shown.
if (!el || el.offsetParent === null) continue;
const b = el.getBoundingClientRect().bottom - top;
if (b > maxBottom) maxBottom = b;
}
return maxBottom;
}
let _diagChord = null;
// Chord diagram render cache. Keys: static layout inputs joined as a
// string. Values: OffscreenCanvas (or <canvas>) rendered at opacity=1
@@ -4101,11 +4055,6 @@
let tgtX = xFretMid(CAM_LOCK_CENTER_FRET), curX = xFretMid(CAM_LOCK_CENTER_FRET);
let tgtDist = CAM_DIST_BASE, curDist = CAM_DIST_BASE;
// Dolly-back multiplier applied to the curDist lerp target by camUpdate's
// fret-row fit guard. 1 = no extra pull-back (the common case); rises
// toward FRET_ROW_FIT_BOOST_MAX only when a tight, centred zoom would push
// the fret-number row past the bottom edge, then relaxes back to 1.
let _fretRowFitBoost = 1;
// Last committed lowFretBonus contribution baked into tgtDist
// (see candidateDist block — bonus is applied on top of the
// hysteresis-gated base).
@@ -13921,9 +13870,7 @@
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
curX += (tgtX - curX) * lerp;
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
curDist += (tgtDist * _fretRowFitBoost - curDist) * lerp;
curDist += (tgtDist - curDist) * lerp;
const dist = curDist * aspectScale;
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
@@ -13995,38 +13942,6 @@
} else {
cam.lookAt(curX, curLookY, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
// Project the fret-number-row band (just below the lowest string, at
// the play line) with the final camera. If it sits below the safe
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
// curDist lerp target next frame) until it clears; relax lazily once
// there's comfortable headroom. Asymmetric + deadbanded so it
// converges without hunting, and capped so the zoom can't pop. It
// cooperates with the tilt loop above rather than fighting it: pulling
// back shrinks the scene, the tilt loop keeps the board centre anchored
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
// while the free-cam (Camera Director) owns the view.
if (_freeCam && _freeCam.enabled) {
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
} else {
cam.updateMatrixWorld();
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
_probe.set(curX, _rowY, 0.5 * K);
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
const _rowNdcY = _probe.y;
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
// Row below the safe line → pull back promptly, proportional to
// the deficit so it converges in a few frames without overshoot.
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
&& _fretRowFitBoost > 1) {
// Comfortable headroom → relax the dolly back toward normal, lazily.
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
}
}
}
/* ── Resize helper ───────────────────────────────────────────────── */
@@ -14283,7 +14198,7 @@
pFretColMarker = null;
_fretMarkerWaveCache.clear();
gNote = gSus = gBeat = gTapChevron = null;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; _fretRowFitBoost = 1; nStr = NSTR; _oobStringWarned = false;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; tgtLookY = curLookY = 0; nStr = NSTR; _oobStringWarned = false;
_lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
_lookaheadFretSpan = DEFAULT_LOOKAHEAD_FRET_SPAN;
_lookaheadCamPrevNow = null;
@@ -14642,13 +14557,7 @@
const _fpsBoxW = Math.ceil(_fpsMetrics.width) + _fpsPadX * 2;
const _fpsBoxH = 14 + _fpsPadY * 2;
const _fpsE = 8;
// Keep it top-right but below the v3 Up Next pill / live HUD
// (whichever is showing) so the readout is never occluded.
const _fpsBaseY = Math.round(Math.max(
_fpsE + H * 0.06,
lyricsBottom + _fpsE,
_v3TopRightChromeBottom() + _fpsE,
));
const _fpsBaseY = Math.round(Math.max(_fpsE + H * 0.06, lyricsBottom + _fpsE));
const _fpsX = W - 8 - _fpsBoxW;
const _fpsY = _fpsBaseY + cornerStack['tr'];
lyricsCtx.fillStyle = 'rgba(0,0,0,0.55)';
+9 -10
View File
@@ -51,16 +51,15 @@
sources = sources.filter((s) => s
&& !/midi/i.test(String(s.providerId || ''))
&& !/^midi-input/i.test(String(s.label || '')));
// No label de-dupe here. The audio-input capability already
// collapses exact duplicates by logicalSourceKey
// (_visibleInputSources), so nothing it returns shares a key. A
// device that enumerates under several driver types (ASIO / Windows
// Audio / DirectSound) has a DISTINCT key per type and is now
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
// a real, separately-selectable input the user must be able to see.
// The old bare-label collapse also kept whichever variant sorted
// first, which could silently drop the one that was actually
// `selected` below.
// De-dupe by display label — the desktop engine enumerates the same
// device under several driver types, so the same name can repeat.
const seen = new Set();
sources = sources.filter((s) => {
const key = String(s.label || '').toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const selected = sources.find((s) => s && s.selected) || null;
return { sources, selected };
} catch (_) { return { sources: [], selected: null }; }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.2",
"version": "1.3.1",
"bundled": true,
"private": false,
"script": "screen.js",
@@ -18,8 +18,6 @@
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_LABEL_H = 12; // px height of each drum label
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
var _TUNER_IN_TUNE_THRESHOLD = 2;
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
@@ -323,34 +321,9 @@
currentAngle += (targetAngle - currentAngle) * lf;
_setNeedle(currentAngle);
// Stop once the needle has settled on its target — a static needle
// needs no repaint. update() re-kicks the loop when a new reading
// moves the target, so this idles the always-on tuner (no signal /
// steady pitch) instead of pinning a core at 60 fps forever.
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
currentDrumY = targetDrumY; currentAngle = targetAngle;
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
_setNeedle(currentAngle);
rafId = null;
return;
}
rafId = requestAnimationFrame(_animate);
}
// Restart the loop only when there's actually something to animate toward
// (a new target). Reset lastTime so the first frame after an idle gap
// doesn't take one big easing step.
function _kick() {
if (rafId === null
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
lastTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────
@@ -387,7 +360,6 @@
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
_kick(); // animate back to rest, then the loop self-stops
return;
}
@@ -404,7 +376,6 @@
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
}
_kick(); // a new reading moved the target → run until it settles
}
function destroy() {
-13
View File
@@ -620,20 +620,8 @@
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
_applyTickStates();
// No signal and both the glow and strobe drift have fully settled →
// idle the loop. update() re-kicks it on the next note.
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
_mt3RafId = null;
_mt3LastTime = null;
return;
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
// ── MODE button ───────────────────────────────────────────────
@@ -689,7 +677,6 @@
_renderNote(' ');
_applyAccidental();
}
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
@@ -354,19 +354,10 @@
if (_smoothedCents > 0) { speed = -speed; }
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
} else if (_currentCents === 0) {
// Fully decelerated and no live signal → idle the loop instead of
// rescheduling forever. update() re-kicks it on the next note.
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
}
_rafId = requestAnimationFrame(_animateStrobe);
// ── Helper: derive octave number from frequency ───────────────
@@ -448,7 +439,6 @@
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
_currentCents = hasNote ? cents : 0;
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
-12
View File
@@ -142,20 +142,9 @@ window._tunerViz_strobe = function (container) {
strobeEl.style.opacity = '0';
}
// Idle the loop when there's no live signal — the strobe only needs to
// paint while a note is sounding. update() re-kicks it on the next note,
// so a silent tuner stops repainting instead of spinning at 60 fps.
if (!strobeActive) { rafId = null; return; }
rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (rafId === null) {
lastAnimateTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────────
@@ -199,7 +188,6 @@ window._tunerViz_strobe = function (container) {
const inTune = Math.abs(cents) < 5;
strobeEl.style.opacity = inTune ? '1' : '0.6';
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
_kick();
}
function destroy() {
@@ -122,26 +122,6 @@
plungerEl.style.left = _leftPct.toFixed(2) + '%';
plungerEl.style.top = _topPct.toFixed(2) + '%';
// No live signal and the plunger has eased back to its resting centre
// → idle the loop. update() re-kicks it on the next note.
if (_currentNote === null && !_plungerDipped
&& Math.abs(targetLeft - _leftPct) < 0.05) {
_leftPct = targetLeft;
plungerEl.style.left = _leftPct.toFixed(2) + '%';
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (_rafId !== null) return;
// Already parked at rest with no signal → nothing to animate, stay idle.
if (_currentNote === null && !_plungerDipped
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
_lastTime = null;
_rafId = requestAnimationFrame(_animate);
}
@@ -150,7 +130,6 @@
_currentNote = note;
_currentCents = note === null ? 0 : cents;
if (!_plungerDipped) { noteEl.textContent = note || ''; }
_kick(); // a new reading may move the plunger → ensure the loop runs
}
function destroy() {
+17 -796
View File
File diff suppressed because it is too large Load Diff
+2 -156
View File
@@ -5993,45 +5993,11 @@ let _pendingAutostart = false;
window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return;
_pendingAutostart = false;
if (isPlaying) return;
// Feedpak contributor credits: only real feedpak plays carry authors
// (loose/archive and minigames get []), so a non-empty list is the gate.
// Shown over the highway and dismissed the moment real playback begins
// (song:play). This fresh-load path is the only place it fires —
// arrangement switches / seeks / manual replays never arm _pendingAutostart,
// and minigames never get here. Decoupled from autoplay below so credits
// show on load even when autoplay-exit is disabled.
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
if (authors.length) {
showSongCreditsOverlay(authors);
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
}
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
// couple seconds on the freshly-loaded song, then clear them (they also
// clear early if the user manually presses Play, via _creditsHideOnPlay).
if (!_autoplayExitEnabled()) {
if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
return;
}
if (!_autoplayExitEnabled() || isPlaying) return;
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
if (_countdownBeforeSongEnabled()) {
// The count-in (~2.5s) gives the credits their on-screen dwell.
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else if (authors.length) {
// No count-in window — hold the credits a couple seconds, then start.
// _cancelCountIn() and changeArrangement() both clear _creditsTimer, so
// a teardown / arrangement switch during the hold cancels this play.
_creditsTimer = setTimeout(() => {
_creditsTimer = null;
// If playback doesn't actually start (e.g. HTML5 autoplay rejection),
// song:play never fires — clear the credits promptly rather than
// waiting for the backstop. On success the song:play listener owns it.
Promise.resolve(togglePlay())
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); })
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
}, _CREDITS_HOLD_MS);
} else {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
}
@@ -6429,11 +6395,6 @@ let _arrBusyTimeout = null;
async function changeArrangement(index) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
// incoming (still-loading) arrangement. hideSongCreditsOverlay() clears
// the timer, the song:play listener, and the overlay node.
hideSongCreditsOverlay();
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
const wasPlaying = isPlaying;
const time = _audioTime();
@@ -8714,24 +8675,12 @@ function _installSectionPracticeDismiss() {
// inside #section-practice-control so it never self-closes. Listeners added
// mid-dispatch don't fire for the opening click, so there's no immediate
// close race.
//
// The click listener uses the CAPTURE phase: the v3 player rail's icon
// buttons call e.stopPropagation() in their click handler (player-chrome.js
// wireRail), which kills bubbling before it reaches document. A bubble-phase
// outside-click dismiss would therefore never fire when the user clicks a
// rail icon (Plugins, Audio, …) to open another popover, leaving this
// popover stranded open on top of it. Capture runs before the target's
// handler, so the stopPropagation can't swallow it. This mirrors the audio
// mixer popover (audio-mixer.js), which dismisses outside-clicks the same
// way. (Esc stays bubble-phase — no rail handler stops keydown propagation,
// so it already reaches us, and capturing it would reorder it ahead of the
// player's Escape-to-exit handling.)
document.addEventListener('click', (e) => {
if (!_sectionPracticePopoverOpen()) return;
const ctrl = document.getElementById('section-practice-control');
if (ctrl && ctrl.contains(e.target)) return;
_closeSectionPracticePopover();
}, true);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) _closeSectionPracticePopover();
});
@@ -9336,27 +9285,10 @@ let _countOverlay = null;
let _countInGen = 0;
let _countInTimer = null;
let _countInRaf = 0;
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
// highway when a song is loaded, alongside the count-in. Torn down together
// with the count-in via _cancelCountIn().
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_HOLD_MS = 3000;
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
// a count-in handoff that never plays). This hard cap guarantees the credits
// never linger over the highway. Generous enough to outlast a normal count-in.
const _CREDITS_MAX_MS = 12000;
function _cancelCountIn() {
_countInGen++;
_countingIn = false;
hideCountOverlay();
// The credits overlay rides the count-in lifecycle (and its no-count-in
// hold timer), so a teardown — leaving the player, loading another song —
// must clear it too, or it lingers on the next screen.
hideSongCreditsOverlay();
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
}
@@ -9374,92 +9306,6 @@ function hideCountOverlay() {
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
}
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
const _CREDIT_ROLE_VERBS = {
charter: 'Charted by',
transcriber: 'Transcribed by',
arranger: 'Arranged by',
editor: 'Edited by',
mixer: 'Mixed by',
engineer: 'Engineered by',
proofreader: 'Proofread by',
};
function _creditLineLabel(role) {
if (!role) return '';
const key = String(role).trim().toLowerCase();
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
}
// Show the feedpak contributor credits over the highway. `authors` is the
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
// Anchored to the lower third (bottom-center) so it never collides with the
// vertically-centered count-in number, and pointer-events-none so it never
// intercepts clicks. No-op when there are no contributors to show.
function showSongCreditsOverlay(authors) {
if (!Array.isArray(authors) || authors.length === 0) return;
if (!_creditsOverlay) {
_creditsOverlay = document.createElement('div');
_creditsOverlay.className = 'song-credits-overlay';
document.body.appendChild(_creditsOverlay);
}
// Build via DOM + textContent — author names are untrusted pack data and
// must never be interpolated as HTML.
_creditsOverlay.replaceChildren();
const card = document.createElement('div');
card.className = 'song-credits-card';
const eyebrow = document.createElement('div');
eyebrow.className = 'song-credits-eyebrow';
eyebrow.textContent = 'Credits';
card.appendChild(eyebrow);
const title = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.title) || '';
if (title) {
const heading = document.createElement('div');
heading.className = 'song-credits-heading';
heading.textContent = title;
card.appendChild(heading);
}
for (const a of authors) {
if (!a || !a.name) continue;
const row = document.createElement('div');
row.className = 'song-credits-line';
const label = _creditLineLabel(a.role);
if (label) {
const lab = document.createElement('span');
lab.className = 'song-credits-role';
lab.textContent = label + ' ';
row.appendChild(lab);
}
const nm = document.createElement('span');
nm.className = 'song-credits-name';
nm.textContent = a.name;
row.appendChild(nm);
card.appendChild(row);
}
_creditsOverlay.appendChild(card);
// Arm the backstop so the overlay self-clears even if playback never starts
// / never emits song:play. song:play (or any teardown) clears it earlier.
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
}
function hideSongCreditsOverlay() {
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
if (_creditsHideOnPlay) {
window.feedBack.off('song:play', _creditsHideOnPlay);
_creditsHideOnPlay = null;
}
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
}
async function startCountIn(opts = {}) {
if (_countingIn) return;
_countingIn = true;
+1 -11
View File
@@ -1766,16 +1766,6 @@
const providerResult = _providerOutcome(raw);
openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome);
openSession.reason = providerResult.reason;
// Read-back: the provider (desktop renderer) reports which device the
// native engine ACTUALLY bound. Surface it to the in-process caller so
// the wizard can show "Now listening to: <device>" and catch a silent
// mismatch (picked BlackHole, got the internal mic). Kept OUT of the
// redacted summary/event below: that flows into diagnostics, where a raw
// device name (e.g. "Byron's AirPods") is PII — but it is fine to return
// verbatim to the trusted same-renderer caller, exactly as list-sources
// already returns the device `label` verbatim.
const boundInfo = _plainObject(providerResult.payload);
const bound = { type: _string(boundInfo.boundType, ''), name: _string(boundInfo.boundName, '') };
if (providerResult.outcome === 'handled') {
openSession.openedAt = _now();
currentSession.openInputSessions.set(key, openSession);
@@ -1783,7 +1773,7 @@
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' });
capabilities.emitEvent('audio-input', 'source-opened', summary);
_touch();
return _handled((bound.name || bound.type) ? { ...summary, bound } : summary);
return _handled(summary);
}
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason });
-7
View File
@@ -3530,13 +3530,6 @@ function createHighway() {
// matchesArrangement on this rather than the
// arrangement name.
hasNotation: Boolean(msg.has_notation),
// Feedpak contributor credits (manifest
// `authors:`, spec §5.4): [{name, role}].
// Only real feedpak plays carry these; loose/
// archive sources and synthetic highway uses
// (minigames) get []. app.js shows a credits
// overlay on song load when this is non-empty.
authors: Array.isArray(msg.authors) ? msg.authors : [],
};
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
}
-90
View File
@@ -863,93 +863,3 @@ html { scroll-behavior: smooth; }
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
border-radius: 0.25rem;
}
/* Feedpak contributor credits shown over the highway when a song loads
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
ABOVE the vertically-centered count-in number; click-through. */
.song-credits-overlay {
position: fixed;
left: 0;
right: 0;
top: 15%;
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
the count-in number (z-[100]) so the credits stay prominent through the
whole load count-in play window. */
z-index: 205;
display: flex;
justify-content: center;
pointer-events: none;
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.song-credits-card {
position: relative;
min-width: 16rem;
max-width: min(90vw, 34rem);
padding: 1.4rem 2.5rem 1.5rem;
text-align: center;
background:
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
border: 1px solid rgb(129 140 248 / 0.28);
border-radius: 1rem;
box-shadow:
0 18px 50px rgb(0 0 0 / 0.55),
0 0 0 1px rgb(0 0 0 / 0.35),
inset 0 1px 0 rgb(255 255 255 / 0.07);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Accent bar across the top edge of the card. */
.song-credits-card::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 3.25rem;
height: 3px;
border-radius: 0 0 3px 3px;
background: linear-gradient(90deg, #38bdf8, #818cf8);
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
}
.song-credits-eyebrow {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgb(165 180 252 / 0.9);
margin-bottom: 0.4rem;
}
.song-credits-heading {
font-size: 1.3rem;
font-weight: 800;
color: #f8fafc;
margin-bottom: 0.7rem;
letter-spacing: 0.01em;
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
}
.song-credits-line {
font-size: 1.1rem;
line-height: 1.55;
color: #e2e8f0;
}
.song-credits-role {
color: rgb(148 163 184 / 0.95);
font-weight: 500;
}
.song-credits-name {
font-weight: 700;
color: #ffffff;
}
@keyframes song-credits-fade-in {
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
+2 -11
View File
@@ -343,10 +343,7 @@
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
<div id="settings" class="screen">
<!-- fb-selectable: Settings is read-only content (paths, device names,
version, diagnostics, About) the user copies — opt the whole panel
back in under the v3 non-select default. See static/v3/v3.css. -->
<div class="fb-settings fb-selectable">
<div class="fb-settings">
<button onclick="showScreen('home')" class="fb-settings-back">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
</button>
@@ -828,13 +825,7 @@
<!-- Top HUD — persistent (song info, time, Up Next) -->
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
<!-- fb-selectable: now-playing song metadata (title / artist /
arrangement / tuning) is copy-worthy content, not chrome.
pointer-events-auto: the #player-hud parent is pointer-events-none
(so the HUD doesn't eat clicks meant for the highway), which would
also block the mouse from reaching this text to select it — opt
just this block back into hit-testing. -->
<div class="text-sm leading-tight fb-selectable pointer-events-auto">
<div class="text-sm leading-tight">
<div><span id="hud-artist" class="text-gray-300"></span><span id="hud-title" class="text-white font-semibold"></span></div>
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
+10 -48
View File
@@ -330,16 +330,9 @@
const editing = !!opts.editing;
document.getElementById('v3-onboarding')?.remove();
// The amp-sim opt-in step (step 5) only exists in the desktop app — the
// pure-web build has no native amp sims to monitor through, so the step
// is skipped there (calibration is the last step at index 5 on web, 6 on
// desktop). See feedBack-desktop#46.
const isDesktop = !!window.feedBackDesktop;
const lastStep = isDesktop ? 6 : 5;
const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
[1, 2, 3, 4, 5].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
const overlay = document.createElement('div');
@@ -387,20 +380,8 @@
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first).
// Hidden div is always present in the DOM; setStep only navigates to
// it on desktop. See feedBack-desktop#46.
// Step 5 — calibration offer (first-run only).
'<div id="v3-ob-step5" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">How do you want to hear yourself?</label>' +
'<p class="text-sm text-fb-textDim mb-3">fee[dB]ack can run your guitar through built-in <span class="text-fb-text">amp simulations</span> (NAM / IRs / plugins) so you hear a processed tone. If you already play through your <span class="text-fb-text">own amp or rig</span>, leave this off — youll get clean, silent monitoring and never an idle buzz.</p>' +
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
'<input type="checkbox" id="v3-ob-ampsims" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
'<span class="text-sm text-fb-text">Use in-app amp simulations' +
'<span class="block text-xs text-fb-textDim mt-1">Loads your saved tone chain for monitoring. You can change this any time in the desktop Audio settings.</span></span>' +
'</label>' +
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked if you monitor through your own gear. This is off by default.</p></div>' +
// Step 6 — calibration offer (first-run only).
'<div id="v3-ob-step6" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
@@ -460,7 +441,7 @@
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 6; i++) {
for (let i = 1; i <= 5; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
@@ -473,13 +454,12 @@
: n === 2 ? 'Point us at your songs'
: n === 3 ? 'Feats of Power (optional)'
: n === 4 ? 'Choose your instrument paths'
: n === 5 ? 'How do you want to monitor?'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 6 ? 'Play it now' : 'Next';
submit.textContent = n === 5 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and
// the calibration challenge (the last step).
skipBtn.classList.toggle('hidden', !(n === 2 || n === 6));
// the calibration challenge.
skipBtn.classList.toggle('hidden', !(n === 2 || n === 5));
refreshSubmit();
}
@@ -715,29 +695,11 @@
// New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths);
setStep(isDesktop ? 5 : 6);
setStep(5);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
if (step === 5) {
// Step 5 (desktop only) — persist the amp-sim opt-in (default OFF
// / own-rig). Best-effort: a failed write must not block onboarding;
// it's settable later from the desktop Audio settings.
submit.disabled = true;
try {
const ampEl = overlay.querySelector('#v3-ob-ampsims');
const useAmpSims = !!(ampEl && ampEl.checked);
try {
await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ use_amp_sims: useAmpSims }),
});
} catch (e) { /* best-effort — settable later */ }
setStep(6);
} finally { refreshSubmit(); }
return;
}
// Step 6 — "Play it now": leave calibration pending (it completes
// Step 5 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish({ launchingSong: !!target });
@@ -751,8 +713,8 @@
setStep(3);
return;
}
// Calibration step (last) — skip: Mastery Rank 1 immediately,
// calibration stays replayable from the Progress screen.
// Step 5 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen.
skipBtn.disabled = true;
try {
const res = await fetch('/api/progression/onboarding', {
+88 -705
View File
@@ -40,9 +40,6 @@
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other'];
const PAGE_SIZE = 24;
// Extra rows rendered above/below the viewport so a fast scroll doesn't flash
// blank before the next window render lands.
const OVERSCAN_ROWS = 2;
const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary';
@@ -54,48 +51,8 @@
artistCatalog: [], renderedHash: '',
scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(),
railLetters: null, railLettersAreSongCounts: false, railJumping: false,
// ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
// state.songs is a SPARSE array indexed by absolute library position
// (0..total-1); only the fetched pages are populated and only the visible
// window ± overscan is ever in the DOM. The sizer element gives the
// scrollbar the full-library geometry. See renderWindow / ensureWindow.
songs: [], // sparse: absoluteIndex → song row
pageCursors: {}, // pageIndex → next_cursor (keyset forward fast-path)
keysetOk: false, // did page 0 return a non-null cursor (local + keyset sort)?
pageProms: {}, // pageIndex → in-flight fetch promise (de-dupe + await)
epoch: 0, // bumped on every reset; a stale in-flight fetch checks it
geom: null, // { cols, rowH, gap } measured from the live grid
winRange: null, // { start, end } last rendered, to skip redundant renders
renderedSelectMode: null, // the selectMode the current window was rendered under
gridResizeBound: false,
};
// ── AZ jump rail ───────────────────────────────────────────────────────
// Ordered buckets shown on the rail: '#' (non-alphabetic) first, then AZ.
const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
// The rail only makes sense for the alphabetical sorts; for recent/year/
// tuning a letter jump is meaningless, so it's hidden. Returns the column
// the active sort keys on ('artist' | 'title') or null when not alphabetical.
function railSortColumn() {
if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist';
if (state.sort === 'title' || state.sort === 'title-desc') return 'title';
return null;
}
// The bucket a song falls in for the active sort: first char of the sort
// column, uppercased; anything non-AZ (digits, symbols, accents, blank)
// buckets under '#'. Mirrors the server's letter grouping in query_stats —
// which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY
// is likewise raw, so we must NOT trim here either: a leading-space title
// sorts (and buckets) under '#' on both sides, keeping the rail consistent.
function songBucket(song) {
const col = railSortColumn();
if (!col) return '';
const raw = String((col === 'title' ? song.title : song.artist) || '');
const ch = raw.charAt(0).toUpperCase();
return (ch >= 'A' && ch <= 'Z') ? ch : '#';
}
function activeFilterCount() {
const f = state.filters;
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
@@ -129,13 +86,12 @@
function _saveLibraryScrollSnapshot() {
const main = _getV3MainScroller();
// Geometry is now stable (the sizer reserves the full scroll height
// regardless of how many cards are actually in the DOM), so the scroll
// position alone is enough to restore — no page-depth bookkeeping.
const snap = {
hash: _libraryStateHash(),
scrollTop: main ? main.scrollTop : 0,
view: state.view,
page: state.page,
loadedCount: loadedCount(),
};
try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ }
}
@@ -163,14 +119,9 @@
setTimeout(apply, 0);
}
// The windowed grid keeps only a slice of cards in the DOM, so "intact" can no
// longer mean "has cards" — it means the grid + sizer chrome exist and page 0
// is loaded (state.total known, first rows present), so renderWindow() can
// repaint the right slice at any scroll position.
function _gridDomIntact() {
const grid = document.getElementById('v3-songs-grid');
const sizer = document.getElementById('v3-songs-gridsizer');
return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined;
return !!grid && loadedCount() > 0;
}
function _treeDomIntact() {
@@ -179,6 +130,32 @@
return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
}
// Resolve once no grid fetch is in flight. loadGrid early-returns while
// state.loading is set, so paging without waiting would silently skip a
// page (it bumps state.page but the fetch no-ops). Bounded so a wedged
// load can't hang the restore forever.
async function _waitForGridIdle(maxMs) {
const cap = (maxMs == null ? 8000 : maxMs);
let waited = 0;
while (state.loading && waited < cap) {
await new Promise((r) => setTimeout(r, 16));
waited += 16;
}
}
async function _ensureGridPagesThrough(targetPage) {
const goal = Math.max(0, Number(targetPage) || 0);
// The initial page-0 load (or an auto-fill) may still be settling; wait
// for the real state.total before deciding how far to page, otherwise a
// total of 0 exits the loop immediately and the depth never restores.
await _waitForGridIdle();
while (state.page < goal && loadedCount() < state.total) {
if (state.loading) { await _waitForGridIdle(); continue; }
state.page++;
await loadGrid(false);
}
}
function queryParams(extra, opts) {
const f = state.filters;
const skipArtistAlbum = opts && opts.catalog;
@@ -199,50 +176,6 @@
return p;
}
// The active filter set as a smart-collection rule object (raw query-param
// format the backend stores). Mirrors queryParams' filter fields, minus
// provider/page/size. Empty object → nothing worth saving as a collection.
function currentFilterRules() {
const f = state.filters, r = {};
if (state.q) r.q = state.q;
if (state.format) r.format = state.format;
if (state.artist) r.artist = state.artist;
if (state.album) r.album = state.album;
if (f.arr_has.length) r.arrangements_has = f.arr_has.join(',');
if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(',');
if (f.stem_has.length) r.stems_has = f.stem_has.join(',');
if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(',');
if (f.lyrics) r.has_lyrics = f.lyrics;
if (f.tunings.length) r.tunings = f.tunings.join(',');
if (state.sort && state.sort !== 'artist') r.sort = state.sort;
return r;
}
// Save the current filter set as a smart collection (a saved live query that
// shows up as a source in the picker). #636 item 2.
async function saveCurrentAsCollection() {
const rules = currentFilterRules();
if (!Object.keys(rules).length) return;
const name = ((await window.uiPrompt({
title: 'Save as collection',
label: 'A live view of the current filters, in the source picker.',
okLabel: 'Save',
placeholder: 'Collection name',
})) || '').trim();
if (!name) return;
try {
const res = await fetch('/api/collections', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, rules }),
});
if (!res.ok) return;
const col = (await res.json()).collection;
closeDrawer();
if (col && col.id != null) state.provider = 'collection:' + col.id;
await render(); // rebuilds the toolbar (provider picker now lists + selects it)
} catch (e) { /* offline / aborted — leave the drawer as-is */ }
}
function albumsForArtist(name) {
const a = (state.artistCatalog || []).find((x) => x.name === name);
return a ? (a.albums || []) : [];
@@ -299,11 +232,6 @@
if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
const folderBtn = document.getElementById('v3-songs-folder-btn');
if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
// Select button tracks state.selectMode — the screen-leave teardown clears
// select mode, so a cached-DOM re-entry must re-style the button (and the
// window re-renders without checkboxes via renderWindow's selectMode check).
const selBtn = document.getElementById('v3-songs-select');
if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : '');
updateFilterBadge();
}
@@ -395,11 +323,11 @@
if (acc == null) return '';
const pct = Math.round(acc * 100);
if (variant === 'tree') {
const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
const color = acc >= 0.9 ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
}
const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white';
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="fb-acc-badge absolute bottom-0 right-0 ' + color + '/90 ' + text + ' px-2 py-0.5 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
}
@@ -450,129 +378,6 @@
const keys = Array.from(_dirtyScores);
_dirtyScores.clear();
keys.forEach(repaintAccuracy);
// A new score shifts the repertoire meter + the keep-practicing shelf.
renderLibraryHome();
}
// ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
// Both read data we already have: state.accuracy (/api/stats/best =
// {filename: best_accuracy}) and /api/stats/recent. A song is "in your
// repertoire" at the same threshold the green accuracy badge uses (>= 0.9);
// a started song below that is "in progress". This is descriptive
// encouragement — it never gates content, decays, or nags (the goal-gradient
// / endowed-progress idea, kept healthy).
const MASTERY_ACCURACY = 0.9;
function _repertoireCounts() {
let mastered = 0, learning = 0;
for (const v of Object.values(state.accuracy || {})) {
if (typeof v !== 'number') continue;
if (v >= MASTERY_ACCURACY) mastered++; else learning++;
}
return { mastered, learning };
}
// The home block is the unfiltered "front door": shown on the grid view when
// the user isn't running a focused query (search / filter) or selecting.
// Local provider only — the meter's mastered count and the shelf both read
// local practice stats (state.accuracy / /api/stats/recent), so on a remote
// provider they'd mix local numerators with a remote song total and play
// local files while browsing a remote library. Hide it there.
function libHomeVisible() {
return state.view === 'grid' && state.provider === 'local'
&& !state.selectMode && !state.q && activeFilterCount() === 0;
}
let _homeToken = 0;
async function renderLibraryHome() {
const host = document.getElementById('v3-lib-home');
if (!host) return;
if (!libHomeVisible()) { host.classList.add('hidden'); return; }
// A newer render (view/filter/score change) supersedes this one so a
// slow response can't repaint a home the grid already moved past.
const myToken = ++_homeToken;
// Unfiltered library size for the meter denominator (the grid's
// state.total tracks the active filter; the meter is library-wide) +
// recently-played rows for the shelf, fetched together.
const [stats, recent] = await Promise.all([
jget('/api/library/stats?provider=' + enc(state.provider)),
jget('/api/stats/recent?limit=24'),
]);
if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch
if (_homeToken === myToken) host.classList.add('hidden');
return;
}
const total = (stats && (stats.total_songs ?? stats.total)) || 0;
if (total <= 0) { host.classList.add('hidden'); return; } // empty library
// Shelf = recently-played, not-yet-mastered songs, newest first. Mastery
// is per-SONG (state.accuracy = MAX best across arrangements, what the
// green badge shows) — recents are per-(song,arrangement), so dedupe by
// filename and gate on the song's best, keeping the shelf and its badges
// consistent (no green-badged "keep practicing" card, no dupes).
const acc = state.accuracy || {};
const seen = new Set();
const shelf = (Array.isArray(recent) ? recent : [])
.filter((r) => {
if (!r || seen.has(r.filename)) return false;
const best = acc[r.filename];
if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false;
seen.add(r.filename);
return true;
})
.slice(0, 8);
const { mastered, learning } = _repertoireCounts();
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
const meter =
'<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
let shelfHtml = '';
if (shelf.length) {
const cards = shelf.map((r) =>
'<button class="v3-kp-card group text-left" data-kp="' + esc(r.filename) + '" data-arr="' + esc(r.arrangement != null ? r.arrangement : '') + '" title="' + esc(r.title) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">' +
'<img src="' + esc(r.art_url) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
accuracyBadge(r.filename) +
'</div>' +
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
'</button>').join('');
shelfHtml =
'<section class="v3-kp-shelf mt-4">' +
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
'<div class="v3-kp-row">' + cards + '</div>' +
'</section>';
}
host.innerHTML = meter + shelfHtml;
host.classList.remove('hidden');
// The home block sits above the grid sizer, so its height shifts where the
// window maps in scroll space — repaint the window once it's laid out.
if (state.view === 'grid') requestWindowRender();
// Wire shelf cards → play (mirrors playCard's local path; recents are
// always local-library rows, so no provider sync is needed).
host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => {
const fn = btn.getAttribute('data-kp');
const arr = btn.getAttribute('data-arr');
if (!fn || !window.playSong) return;
_saveLibraryScrollSnapshot();
window.playSong(enc(fn), arr === '' ? undefined : Number(arr));
}));
}
// Toggle/refresh the home block on view/sort/filter/search changes.
function updateLibraryHome() {
const host = document.getElementById('v3-lib-home');
if (!host) return;
if (!libHomeVisible()) { host.classList.add('hidden'); return; }
renderLibraryHome();
}
// Source format of a song — prefer the server's `format` field, fall back
@@ -658,11 +463,8 @@
const overlay = overlayActs.length
? '<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition pointer-events-none"><div class="flex flex-wrap gap-1 justify-center max-w-[90%] pointer-events-auto">' + overlayActs.map(actBtn).join('') + '</div></div>'
: '';
// Recycled cards re-render from state, so a selected card must paint its
// ring on initial markup (toggleSelect only adds it to a live node).
const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : '';
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
return '<div class="group relative" data-fn="' + esc(key) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer" data-v3-play>' +
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
@@ -673,10 +475,7 @@
'</div></div>' +
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(song.title) + '">' + esc(song.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
// Always emit the chip row (even when empty) at a FIXED single-line
// height — uniform card height is what makes the windowed grid's
// absolute-position math exact (.v3-card-chips in v3.css).
'<div class="v3-card-chips flex gap-1 mt-1">' + arrChips + '</div>' +
(arrChips ? '<div class="flex flex-wrap gap-1 mt-1">' + arrChips + '</div>' : '') +
'</div>';
}
@@ -863,438 +662,68 @@
try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; }
}
// ── Grid (windowed / recycled — #636 item 3 stage 2) ───────────────────--
// Only the visible cards (± OVERSCAN_ROWS) live in the DOM; a sizer element
// sized to the FULL library gives the scrollbar its geometry. state.songs is
// a sparse array indexed by absolute position; ensureWindow() fetches the
// pages a window needs (keyset forward fast-path, else OFFSET random-access),
// and renderWindow() paints the slice the current scrollTop maps to.
// ── Grid (paged + infinite scroll) ─────────────────────────────────────--
async function loadGrid(reset) {
// A reset requested mid-fetch (provider/sort/filter/search change) must
// not be dropped — remember it and re-run once the in-flight load
// returns, otherwise the stale response repopulates the grid.
if (state.loading) { if (reset) state.pendingReset = true; return; }
const grid = document.getElementById('v3-songs-grid');
if (!grid) return;
// A reset wipes the grid (and any open card menu's DOM); close the menu
// first so its document-level click closer doesn't leak.
if (reset) { if (_closeCardMenu) _closeCardMenu(); state.page = 0; state.total = 0; grid.innerHTML = ''; }
state.loading = true;
const data = await jget('/api/library?' + queryParams({ page: state.page, size: PAGE_SIZE }).toString());
state.loading = false;
if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); }
if (!data) return;
state.total = data.total || 0;
(data.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; grid.insertAdjacentHTML('beforeend', songCard(s)); });
wireCards(grid);
const countEl = document.getElementById('v3-songs-count');
if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's');
const loaded = grid.querySelectorAll('[data-fn]').length;
const sentinel = document.getElementById('v3-songs-sentinel');
if (sentinel) sentinel.style.display = loaded < state.total ? 'block' : 'none';
// Auto-fill: if the grid doesn't yet overflow the scroller, keep loading
// (so a short first page still becomes scrollable without user action).
maybeFill();
}
// Live count of cards actually in the DOM — bounded under windowing, so it's
// the bounded-DOM invariant the tests assert (NOT a "loaded so far" signal).
function loadedCount() { return document.querySelectorAll('#v3-songs-grid [data-fn]').length; }
// The scroll listener lives on the SHARED #v3-main container, so guard every
// render entry point on the Songs screen actually being active — otherwise
// scrolling another screen would keep rendering into the hidden grid after
// Songs has been visited once.
// paging entry point on the Songs screen actually being active — otherwise
// scrolling another screen would keep fetching /api/library into the hidden
// grid after Songs has been visited once.
function songsActive() { const el = document.getElementById('v3-songs'); return !!el && el.classList.contains('active'); }
function _gridEl() { return document.getElementById('v3-songs-grid'); }
function _sizerEl() { return document.getElementById('v3-songs-gridsizer'); }
// Measure columns + row pitch from the LIVE grid: cols from the computed
// grid-template-columns (tracks resolve to explicit pixel sizes), rowH from a
// rendered card's box + the grid row-gap. Cards are uniform height (aspect-
// square art + truncated text + the fixed-height .v3-card-chips row), so one
// measured card sizes every row. Falls back to a coarse estimate until the
// first card exists, then re-measures.
function measureGeom() {
const grid = _gridEl();
if (!grid) return state.geom || { cols: 2, rowH: 240, gap: 16 };
const cs = getComputedStyle(grid);
const tracks = (cs.gridTemplateColumns || '').trim();
const cols = (tracks && tracks !== 'none')
? Math.max(1, tracks.split(/\s+/).length)
: (state.geom ? state.geom.cols : 2);
const gap = parseFloat(cs.rowGap) || 0;
let rowH = state.geom && state.geom.rowH;
const card = grid.querySelector('[data-fn]') || grid.querySelector('.v3-card-skel');
if (card) { const h = card.getBoundingClientRect().height; if (h > 0) rowH = h + gap; }
if (!rowH || rowH <= 0) rowH = 240 + gap; // estimate until a card is measured
state.geom = { cols, rowH, gap };
return state.geom;
function loadNext() {
if (state.loading || state.view !== 'grid' || !songsActive()) return;
if (loadedCount() < state.total) { state.page++; loadGrid(false); }
}
// The sizer's top edge measured in the scroller's content coordinate space
// (accounts for the practice-home block above it, sticky toolbar, etc.).
function _sizerTopInScroller(main, sizer) {
return sizer.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop;
function maybeFill() {
const main = document.getElementById('v3-main');
if (!main || state.view !== 'grid' || state.loading || !songsActive()) return;
// Not tall enough to scroll yet, and more remain → pull the next page.
if (main.scrollHeight <= main.clientHeight + 80 && loadedCount() < state.total) loadNext();
}
function _windowHasHoles(start, end) {
for (let i = start; i < end; i++) if (state.songs[i] === undefined) return true;
return false;
}
// A placeholder card with the SAME vertical structure (and therefore height)
// as a real card, shown only if a window's fetch hasn't landed yet. No
// [data-fn] → wireCards / repaintAccuracy skip it.
function _skeletonCard() {
return '<div class="v3-card-skel" aria-hidden="true">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card animate-pulse"></div>' +
'<div class="mt-1 text-sm text-transparent truncate">·</div>' +
'<div class="text-xs text-transparent truncate">·</div>' +
'<div class="v3-card-chips flex gap-1 mt-1"></div>' +
'</div>';
}
function _renderCardsRange(start, end) {
let html = '';
for (let i = start; i < end; i++) {
const s = state.songs[i];
html += s ? songCard(s) : _skeletonCard();
}
return html;
}
// Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset
// cursor when the previous page is already loaded (cheap forward scroll);
// otherwise OFFSET page= for random access (jumps, restore, non-keyset
// providers). Records the returned next_cursor so a later contiguous page can
// chain off it. Returns a promise that callers AWAIT (so ensureWindow never
// returns with a hole still in flight); concurrent requests for the same page
// share the one promise. An `epoch` captured at launch guards against a reset
// (provider/sort/filter change) landing mid-fetch and writing stale rows into
// the new dataset.
function _loadPage(p) {
if (p < 0 || state.songs[p * PAGE_SIZE] !== undefined) return Promise.resolve();
if (state.pageProms[p]) return state.pageProms[p];
const epoch = state.epoch;
const prom = (async () => {
const extra = { size: PAGE_SIZE };
const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null;
if (prevCursor) extra.after = prevCursor; else extra.page = p;
const data = await jget('/api/library?' + queryParams(extra).toString());
if (state.epoch !== epoch || !data) return; // reset mid-fetch → discard stale
state.total = data.total || 0;
if (typeof data.next_cursor !== 'undefined') {
state.pageCursors[p] = data.next_cursor;
if (p === 0) state.keysetOk = !!data.next_cursor;
}
const base = p * PAGE_SIZE;
(data.songs || []).forEach((s, i) => {
state.songs[base + i] = s;
state.songsById[cardKey(s)] = s;
});
})();
state.pageProms[p] = prom;
prom.finally(() => { if (state.pageProms[p] === prom) delete state.pageProms[p]; });
return prom;
}
// Ensure every absolute index in [start, end) is loaded (fetch — or await an
// in-flight fetch of — the covering pages). Pages resolve in order so the
// keyset fast-path can chain off the previous page's cursor.
async function ensureWindow(start, end) {
if (end <= start) return;
const p0 = Math.floor(start / PAGE_SIZE);
const p1 = Math.floor((end - 1) / PAGE_SIZE);
for (let p = p0; p <= p1; p++) {
if (state.songs[p * PAGE_SIZE] === undefined) await _loadPage(p);
}
}
let _winRAF = 0;
function requestWindowRender() {
if (_winRAF) return;
_winRAF = requestAnimationFrame(() => { _winRAF = 0; renderWindow(); });
}
// Paint the slice of cards the current scrollTop maps to. Sizes the sizer to
// the full library, computes the visible row range (± overscan), fetches any
// missing pages, then swaps the grid's innerHTML to just that slice. A token
// guards against an out-of-order fetch repainting a window the user scrolled
// past.
let _winToken = 0;
async function renderWindow() {
if (state.view !== 'grid' || !songsActive()) return;
const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main');
if (!grid || !sizer || !main) return;
const { cols, rowH } = measureGeom();
const total = state.total || 0;
const rows = Math.ceil(total / Math.max(1, cols));
sizer.style.height = (rows * rowH) + 'px';
if (total === 0) {
grid.innerHTML = ''; grid.style.top = '0px';
state.winRange = { start: 0, end: 0 };
return;
}
const sizerTop = _sizerTopInScroller(main, sizer);
const viewTop = Math.max(0, main.scrollTop - sizerTop);
const viewBottom = viewTop + main.clientHeight;
const firstRow = Math.max(0, Math.floor(viewTop / rowH) - OVERSCAN_ROWS);
const lastRow = Math.min(rows - 1, Math.ceil(viewBottom / rowH) + OVERSCAN_ROWS);
const start = firstRow * cols;
const end = Math.min(total, (lastRow + 1) * cols);
// Re-render when the range changed, a card is missing, OR select mode
// toggled since the window was last painted (so checkboxes/rings on cached
// cards track state — e.g. after leaving Songs in select mode and back).
const same = state.winRange && state.winRange.start === start && state.winRange.end === end
&& state.renderedSelectMode === state.selectMode;
if (same && !_windowHasHoles(start, end)) return;
const myToken = ++_winToken;
if (_windowHasHoles(start, end)) {
await ensureWindow(start, end);
if (_winToken !== myToken || state.view !== 'grid') return; // superseded
}
if (_closeCardMenu) _closeCardMenu(); // its DOM is about to be replaced
grid.style.top = (firstRow * rowH) + 'px';
grid.innerHTML = _renderCardsRange(start, end);
wireCards(grid);
state.winRange = { start, end };
state.renderedSelectMode = state.selectMode;
if (sm && typeof sm.emit === 'function') {
try { sm.emit('v3:library-window-rendered', { start, end, total }); } catch (e) { /* */ }
}
}
// Reset/initial load of the grid. Clears the sparse store, fetches page 0
// (which establishes state.total + whether the keyset fast-path is available),
// then renders the window twice — the first render lays a real card so the
// second can measure the true row height and settle the window size.
async function loadGrid(reset) {
// A reset requested mid-fetch (provider/sort/filter/search change) must
// not be dropped — remember it and re-run once the in-flight load returns.
if (state.loading) { if (reset) state.pendingReset = true; return; }
const grid = _gridEl();
if (!grid) return;
if (reset) {
if (_closeCardMenu) _closeCardMenu();
state.epoch++; // invalidate any in-flight page fetch from the old query
state.songs = [];
state.pageCursors = {};
state.pageProms = {};
state.keysetOk = false;
state.winRange = null;
state.renderedSelectMode = null;
state.geom = null;
state.total = 0;
grid.innerHTML = '';
grid.style.top = '0px';
const sizer = _sizerEl();
if (sizer) sizer.style.height = '0px';
}
state.loading = true;
await _loadPage(0);
state.loading = false;
if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); }
const countEl = document.getElementById('v3-songs-count');
if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's');
// The sentinel no longer drives loading (the sizer reserves full height);
// keep the node for coexistence but it has no visible role.
const sentinel = document.getElementById('v3-songs-sentinel');
if (sentinel) sentinel.style.display = 'none';
await renderWindow(); // first paint (rowH from estimate)
await renderWindow(); // re-measure rowH from a real card, settle the window
}
// A scroll on #v3-main re-renders the window (rAF-coalesced). No more
// near-bottom paging trigger — the visible range alone decides what's shown.
// Robust infinite scroll: a scroll listener on the real scroll container
// (#v3-main), bound once. Avoids the IntersectionObserver "already in view
// at observe-time" race that stuck the grid on page 0.
function bindScroll() {
const main = document.getElementById('v3-main');
if (!main || state.scrollBound) return;
state.scrollBound = true;
main.addEventListener('scroll', () => {
if (state.view !== 'grid') return;
requestWindowRender();
if (state.view !== 'grid' || state.loading) return;
if (main.scrollTop + main.clientHeight >= main.scrollHeight - 600) loadNext();
}, { passive: true });
}
// Re-measure + re-render when the scroller's WIDTH changes (column count and
// the aspect-square art height both track width). Height-only changes just
// need a re-render to widen/narrow the visible window.
function bindGridResize() {
if (state.gridResizeBound) return;
const main = document.getElementById('v3-main');
if (!main || typeof ResizeObserver !== 'function') return;
state.gridResizeBound = true;
let lastW = main.clientWidth;
new ResizeObserver(() => {
if (state.view !== 'grid') return;
const w = main.clientWidth;
if (w !== lastW) { lastW = w; state.geom = null; } // force re-measure
requestWindowRender();
}).observe(main);
}
// ── AZ jump rail interaction ─────────────────────────────────────────────
// With the windowed grid the rail seeks DIRECTLY: sort_letters gives the
// per-bucket song counts, so the first card of a letter is at the cumulative
// count of the buckets before it — convert that index to a scrollTop and let
// the scroll handler render+fetch the destination window (O(1), no page-
// through). The rail only offers letters the server reports present for the
// active sort+filter, so a tap always lands on a real card. (A legacy provider
// lacking sort_letters falls back to a bounded forward scan.)
function railEl() { return document.getElementById('v3-songs-azrail'); }
function railBubbleEl() { return document.getElementById('v3-songs-azbubble'); }
function railVisible() { return state.view === 'grid' && !!railSortColumn(); }
let _railToken = 0;
async function refreshRail() {
const rail = railEl();
if (!rail) return;
if (!railVisible()) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
const col = railSortColumn();
// A newer refresh (sort/filter/search/provider change) supersedes this
// one — a slow stats response must not repaint a rail the grid moved on.
const myToken = ++_railToken;
// Present letters for the active sort+filter (filter-synced; counts
// songs). `sort_letters=1` opts into the active-sort breakdown so the
// dashboard / v2 tree (which read only `letters`) skip the extra query.
const stats = await jget('/api/library/stats?' + queryParams({ sort_letters: 1 }).toString());
if (_railToken !== myToken || !railVisible()) { // changed mid-fetch
if (_railToken === myToken) rail.classList.add('hidden');
return;
}
// Prefer the active-sort breakdown. `letters` is the artist distinct-
// count, so it only matches the cards on an artist sort; a legacy/third-
// party provider that predates `sort_letters` returns none, in which
// case a title sort would advertise wrong letters — hide the rail then.
let letters = stats && stats.sort_letters;
// sort_letters counts SONGS per bucket of the active sort column — exactly
// the cumulative the windowed jump needs to seek to a row index. The
// `letters` fallback is a distinct-ARTIST count (legacy provider without
// sort_letters, artist sort only), which can't drive a precise seek — flag
// it so jumpToLetter does a bounded scan instead of trusting the math.
const songCounts = !!(stats && stats.sort_letters);
if (!letters) {
if (col === 'artist') letters = (stats && stats.letters) || {};
else { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
}
state.railLetters = letters;
state.railLettersAreSongCounts = songCounts;
// No present letters (empty or fully-filtered grid) → nothing to jump
// to; hide the rail instead of rendering a column of disabled buttons.
if (!Object.keys(letters).length) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
const desc = state.sort.endsWith('-desc');
const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS;
// Roving tabindex: only the first present letter is in the tab order;
// the rest are reached with the arrow keys (see bindRailOnce). Avoids
// dumping up to 27 tab stops into the page.
let firstPresent = true;
rail.innerHTML = order.map((L) => {
const n = letters[L] || 0;
const present = n > 0;
const tabbable = present && firstPresent;
if (tabbable) firstPresent = false;
const name = L === '#' ? 'non-alphabetical' : L;
return '<button type="button" class="v3-azrail-letter" data-letter="' + esc(L) + '"'
+ (present ? '' : ' disabled') + ' tabindex="' + (tabbable ? '0' : '-1') + '"'
+ ' aria-label="Jump to ' + esc(name) + (present ? ', ' + n + ' song' + (n === 1 ? '' : 's') : ' (none)') + '">'
+ esc(L) + '</button>';
}).join('');
rail.classList.remove('hidden');
bindRailOnce();
}
function _setRailActive(letter) {
railEl()?.querySelectorAll('.v3-azrail-letter').forEach((b) => {
b.classList.toggle('is-active', b.getAttribute('data-letter') === letter);
});
}
function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } }
function _hideBubble() { railBubbleEl()?.classList.add('hidden'); }
// The absolute index of the first card in a bucket, from the sort_letters
// song-counts: sum the counts of every bucket ordered before it. O(1) — no
// page-through. Returns null when we don't have true song-counts (the legacy
// distinct-artist fallback), so the caller can scan instead.
function _letterStartIndex(letter) {
if (!state.railLettersAreSongCounts) return null;
const letters = state.railLetters || {};
const desc = state.sort.endsWith('-desc');
const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS;
let idx = 0;
for (const b of order) { if (b === letter) return idx; idx += (letters[b] || 0); }
return idx;
}
// Fallback for providers without sort_letters: walk the sparse store forward
// (fetching pages as needed, bounded by total) until a card's bucket matches.
async function _scanForLetter(letter, token) {
const total = state.total || 0;
for (let i = 0; i < total; i++) {
if (state.songs[i] === undefined) {
await ensureWindow(i, Math.min(total, i + PAGE_SIZE));
if (_jumpToken !== token) return null;
}
const s = state.songs[i];
if (s && songBucket(s) === letter) return i;
}
return null;
}
let _jumpToken = 0;
async function jumpToLetter(letter) {
const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main');
if (!grid || !sizer || !main || state.view !== 'grid' || !letter) return;
_setRailActive(letter);
const myToken = ++_jumpToken; // a newer jump supersedes this one
const { cols, rowH } = measureGeom();
let targetIndex = _letterStartIndex(letter);
if (targetIndex == null) {
targetIndex = await _scanForLetter(letter, myToken);
if (_jumpToken !== myToken) return;
if (targetIndex == null) return; // letter not present
}
const total = state.total || 0;
if (targetIndex >= total) targetIndex = Math.max(0, total - 1);
const targetRow = Math.floor(targetIndex / Math.max(1, cols));
// Pre-fetch the destination window so cards are present when the smooth
// scroll arrives (avoids a flash of skeletons at the landing row).
await ensureWindow(targetIndex, Math.min(total, targetIndex + cols * (OVERSCAN_ROWS * 2 + 4)));
if (_jumpToken !== myToken || state.view !== 'grid') return;
const sizerTop = _sizerTopInScroller(main, sizer);
const toolbar = document.getElementById('v3-songs-toolbar');
const pad = (toolbar ? toolbar.offsetHeight : 0) + 12; // clear the sticky toolbar
const top = Math.max(0, sizerTop + targetRow * rowH - pad);
main.scrollTo({ top, behavior: 'smooth' });
requestWindowRender();
}
function bindRailOnce() {
const rail = railEl();
if (!rail || rail._bound) return;
rail._bound = true;
let dragging = false, moved = false, lastDrag = null;
const letterAtY = (y) => {
const els = rail.querySelectorAll('.v3-azrail-letter');
if (!els.length) return null;
for (const el of els) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; }
return y < els[0].getBoundingClientRect().top ? els[0] : els[els.length - 1]; // clamp past ends
};
rail.addEventListener('click', (e) => {
const btn = e.target.closest('.v3-azrail-letter');
if (!btn || btn.disabled) return;
if (moved) { moved = false; return; } // a drag already handled it
jumpToLetter(btn.getAttribute('data-letter'));
});
rail.addEventListener('pointerdown', (e) => {
const btn = e.target.closest('.v3-azrail-letter');
if (!btn) return;
dragging = true; moved = false; lastDrag = null;
try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ }
_showBubble(btn.getAttribute('data-letter'));
});
rail.addEventListener('pointermove', (e) => {
if (!dragging) return;
const el = letterAtY(e.clientY);
if (!el || el.disabled) return;
moved = true;
const L = el.getAttribute('data-letter');
_showBubble(L);
if (L !== lastDrag) { lastDrag = L; jumpToLetter(L); } // only on change
});
const end = () => { dragging = false; _hideBubble(); };
rail.addEventListener('pointerup', end);
rail.addEventListener('pointercancel', end);
rail.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const btns = [...rail.querySelectorAll('.v3-azrail-letter:not([disabled])')];
const i = btns.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
const next = btns[i + (e.key === 'ArrowDown' ? 1 : -1)];
if (next) {
btns[i].setAttribute('tabindex', '-1'); // roving tabindex follows focus
next.setAttribute('tabindex', '0');
next.focus();
jumpToLetter(next.getAttribute('data-letter'));
}
});
}
// Pin the sticky toolbar directly beneath the sticky topbar. Both live in
// the #v3-main scroller, so without an explicit offset they share top:0 and
// the toolbar covers the topbar's song search. The topbar has two responsive
@@ -1424,11 +853,6 @@
}
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
// Collections always replay against the LOCAL library, so only offer
// "save" when browsing local with a non-empty filter set.
(state.provider === 'local' && Object.keys(currentFilterRules()).length
? '<div class="pt-3 border-t border-fb-border/50"><button data-drawer-save class="w-full text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md py-2"> Save as collection</button></div>'
: '') +
'<div class="flex justify-between pt-3 border-t border-fb-border/50"><button data-drawer-clear class="text-sm text-fb-textDim hover:text-fb-text">Clear all</button>' +
'<button data-drawer-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Done</button></div></div>';
@@ -1440,7 +864,6 @@
renderDrawer();
}));
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] };
@@ -1490,24 +913,12 @@
// state.q) and needs a refresh rather than a scroll-preserving no-op.
state.renderedHash = _libraryStateHash();
updateFilterBadge();
// A sort/filter/search/view change rebuilds the grid from page 0, so any
// in-flight letter jump is now paging through a dataset that's about to
// be discarded — supersede it so it can't scroll the rebuilt grid.
_jumpToken++;
// Keep a handle on the load so callers (notably the scroll restore on
// screen re-entry) can await page-0 actually landing before paging
// deeper. The visibility/scroll resets below stay synchronous.
// Hide the SIZER (not the inner grid) for non-grid views, so its reserved
// scroll height collapses and the tree/folder content sits at the top.
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
// Refresh the AZ jump rail (shows only for the grid + alphabetical
// sorts; hides itself otherwise). Independent of the grid load.
refreshRail();
// Refresh the practice-aware home (repertoire meter + keep-practicing
// shelf); hides itself when searching/filtering/selecting or off-grid.
updateLibraryHome();
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
if (state.view === 'folder') {
_applyMainScrollTop(0);
@@ -1569,25 +980,11 @@
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
'</div></div></div>' +
// Practice-aware library home: a repertoire progress meter + a
// "Keep practicing" shelf of started-but-not-mastered songs. Shown
// only on the grid view when not searching/filtering/selecting
// (renderLibraryHome + updateLibraryHome). Empty/absent → collapses.
'<div id="v3-lib-home" class="hidden mb-5"></div>' +
// Windowed grid: the sizer reserves the full-library scroll height;
// #v3-songs-grid is absolutely positioned inside it and holds only the
// visible window's cards (.v3-grid-window in v3.css).
'<div id="v3-songs-gridsizer" class="relative">' +
'<div id="v3-songs-grid" class="v3-grid-window grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
'</div>' +
'<div id="v3-songs-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
'<div id="v3-songs-tree" class="hidden"></div>' +
'<div id="lib-folder-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' +
// AZ jump rail (grid + alphabetical sorts only; populated by
// refreshRail). The bubble shows the current letter while dragging.
'<nav id="v3-songs-azrail" class="v3-azrail hidden" aria-label="Jump to letter"></nav>' +
'<div id="v3-songs-azbubble" class="v3-azbubble hidden" aria-hidden="true"></div>' +
// Filter drawer + overlay
'<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
'<aside id="v3-songs-drawer" class="fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll"></aside>' +
@@ -1669,7 +1066,6 @@
// before it tries to page deeper.
await setView(state.view);
bindScroll();
bindGridResize();
positionToolbar();
bindToolbarReflow();
updateFilterBadge();
@@ -1694,20 +1090,18 @@
if (snap && hashMatch && domReady && chromeOk && viewOk) {
if (state.view === 'grid' && _gridDomIntact()) {
// Geometry is stable (the sizer still holds the full height from
// the prior session), so restore is just: restore scrollTop, then
// repaint the window that maps to it. No more page-through.
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', false);
if ((snap.page || 0) > state.page || (snap.loadedCount || 0) > loadedCount()) {
await _ensureGridPagesThrough(snap.page || 0);
}
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', false);
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true);
syncChromeFromState();
updateLibraryHome(); // select-mode clear on leave re-shows the home block
_applyMainScrollTop(snap.scrollTop || 0);
requestWindowRender();
_clearLibraryScrollSnapshot();
return;
}
if (state.view === 'tree' && _treeDomIntact()) {
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', true);
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', true);
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false);
syncChromeFromState();
_applyMainScrollTop(snap.scrollTop || 0);
@@ -1724,14 +1118,10 @@
// instead of silently showing the old results. Unchanged state keeps
// the scroll-preserving no-op.
if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
updateLibraryHome(); // select-mode clear on leave re-shows the home block
// Re-render in case the viewport resized while we were away (column
// count / row height may have changed) or select mode was cleared.
if (state.view === 'grid') requestWindowRender();
return;
}
@@ -1739,10 +1129,8 @@
if (snap && !hashMatch) _clearLibraryScrollSnapshot();
await render();
if (snapToRestore && snapToRestore.hash === _libraryStateHash()) {
// render() built + sized the sizer at scrollTop 0; move to the saved
// position and let the scroll handler repaint that window.
if (state.view === 'grid') await _ensureGridPagesThrough(snapToRestore.page || 0);
_applyMainScrollTop(snapToRestore.scrollTop || 0);
if (state.view === 'grid') requestWindowRender();
}
_clearLibraryScrollSnapshot();
}
@@ -1788,11 +1176,6 @@
getSort: () => state.sort,
getArtist: () => state.artist,
getAlbum: () => state.album,
// The grid is windowed: only a slice of cards is in the DOM at any time.
// A plugin that decorates cards should read THIS (not a global
// querySelectorAll that assumes every card is present) and re-run on each
// `v3:library-window-rendered` event rather than once at load.
visibleCards: () => document.querySelectorAll('#v3-songs-grid [data-fn]'),
filterParams: () => {
const f = state.filters;
const p = new URLSearchParams();
-181
View File
@@ -4,62 +4,6 @@
* `fb` palette in tailwind.config.js.
*/
/* Text-selection policy (v3)
Accidental drag/double-click selection of app chrome (sidebar, transport, the
note highway/HUD, buttons, labels) makes the UI look broken and is never
useful so default the interface to non-selectable, then opt *content* back
in. v3-only: this sheet loads only on /v3 (v2 is unchanged). The panel's
guardrails are baked in:
- NEVER a `* { user-select:none }` rule it breaks input carets / IME
composition on WebKit (bug 82692); we scope to `html` and re-enable below.
- This is cosmetic only; it protects nothing (DevTools defeats it) and must
never be used to "lock" copy-worthy text away (a11y: keep errors, IDs,
paths, versions, metadata, lyrics selectable incl. in modals/toasts). */
html { -webkit-user-select: none; user-select: none; }
/* Form fields are ALWAYS selectable/editable protects the caret + IME
(including CJK / dead-key composition). The default must never swallow typing.
`.fb-selectable *` forces descendants so a child element's own non-select
can't strand copy-worthy text inside a content island. */
input, textarea, select,
[contenteditable]:not([contenteditable="false"]),
[contenteditable]:not([contenteditable="false"]) * {
-webkit-user-select: text; user-select: text;
}
/* Plugin screens are content surfaces (editor, tabview, lyrics, theory, chord
text, ). Re-enable their mounted subtree by INHERITANCE (no `*`) so the host
policy can't silently make a plugin's copyable text un-selectable including
community / out-of-tree plugins that never adopt `.fb-selectable`. A plugin
that wants its own chrome non-selectable still wins via its own element rule
(which this inherited value doesn't override). */
.screen[id^="plugin-"] { -webkit-user-select: text; user-select: text; }
/* Core read-only content opts back in by CONTAINER (lower-drift than tagging
each value a new setting added later inherits "selectable" for free):
the Settings panel (values, paths, device names, version, diagnostics,
About) and the now-playing song metadata (both tagged `.fb-selectable`).
Plugins re-enable their own copyable regions with this same class
(documented in CLAUDE.md).
The focused, transient surfaces below ALWAYS carry copy-worthy text (errors,
IDs, file paths, device/version strings) per the a11y guardrail, so they're
blanket-opted-in by selector rather than hand-tagged they're single focused
panels, not dense card lists, so re-enabling selection there can't recreate
the across-cards marquee mess the policy prevents:
- modals / dialogs: `.feedBack-modal`, `[role="dialog"]` (confirm, edit-meta,
retune result/error, calibration, filter drawer);
- toasts: `#fb-notify-stack`, `#v3-fb-toast`;
- the library scan banner (`#scan-banner` shows the current file path).
(Dense card lists the library grid, dashboard, profile are intentionally
left non-selectable; copy their text from the now-playing HUD / Settings.) */
.fb-selectable, .fb-selectable *,
.feedBack-modal, .feedBack-modal *,
[role="dialog"], [role="dialog"] *,
#fb-notify-stack, #fb-notify-stack *,
#v3-fb-toast, #v3-fb-toast *,
#scan-banner, #scan-banner * { -webkit-user-select: text; user-select: text; }
/* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */
#tuner-toggle-btn { display: none !important; }
@@ -1173,128 +1117,3 @@ html.fb-immersive #v3-main > .screen.active {
inset: 0;
overflow: hidden;
}
/* — AZ jump rail (v3 Songs grid; static/v3/songs.js) — */
/* Fixed to the right edge next to the scroller's scrollbar; vertically
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
.v3-azrail {
position: fixed;
right: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 25;
display: flex;
flex-direction: column;
align-items: center;
max-height: 84vh;
padding: 4px 1px;
user-select: none;
-webkit-user-select: none;
touch-action: none; /* let a drag scrub the rail without scrolling the page */
}
.v3-azrail-letter {
appearance: none;
-webkit-appearance: none;
background: none;
border: 0;
color: #94a3b8; /* fb-textDim */
font-size: .62rem;
font-weight: 700;
line-height: 1.05;
padding: 1px 4px;
margin: 0;
cursor: pointer;
border-radius: 4px;
}
.v3-azrail-letter:hover:not([disabled]),
.v3-azrail-letter.is-active {
color: #0ea5e9; /* fb-primary */
}
.v3-azrail-letter:focus-visible {
outline: 2px solid #38bdf8; /* fb-primaryHi */
outline-offset: 1px;
}
.v3-azrail-letter[disabled] {
color: rgba(148, 163, 184, .28);
cursor: default;
}
/* Drag indicator bubble (Android fast-scroll pattern). */
.v3-azbubble {
position: fixed;
right: 2.6rem;
top: 50%;
transform: translateY(-50%);
z-index: 26;
width: 2.6rem;
height: 2.6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: .7rem;
background: #0ea5e9; /* fb-primary */
color: #f8fafc; /* fb-text */
font-size: 1.15rem;
font-weight: 800;
box-shadow: 0 6px 22px rgba(0, 0, 0, .45);
pointer-events: none;
}
.v3-azrail.hidden,
.v3-azbubble.hidden { display: none; }
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
Tighten it; a collapse-to-anchors pass is a follow-up. */
@media (max-height: 640px) {
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
}
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
#v3-lib-home.hidden { display: none; }
.v3-rep-meter { max-width: 30rem; }
.v3-rep-track {
height: 6px;
border-radius: 999px;
background: rgba(148, 163, 184, .22); /* fb-textDim @ low alpha */
overflow: hidden;
}
.v3-rep-fill {
height: 100%;
border-radius: 999px;
background: #0ea5e9; /* fb-primary */
transition: width .4s ease;
}
/* Horizontal, scroll-snapping shelf of fixed-width cards. */
.v3-kp-row {
display: flex;
gap: .75rem;
overflow-x: auto;
scroll-snap-type: x proximity;
padding-bottom: 6px;
-webkit-overflow-scrolling: touch;
}
.v3-kp-card {
flex: 0 0 8.5rem;
width: 8.5rem;
scroll-snap-align: start;
}
/* — Windowed (virtualized) Songs grid (#636 item 3 stage 2) — */
/* The grid is absolutely positioned inside #v3-songs-gridsizer, whose height is
set to the FULL library (ceil(total/cols)*rowH) so the scrollbar reflects the
whole library while only the visible window's cards are in the DOM. The inline
`top` (set by renderWindow) offsets the window to the first visible row. */
.v3-grid-window {
position: absolute;
left: 0;
right: 0;
top: 0;
}
/* The arrangement-chip row is rendered on EVERY card (even when empty) at a fixed
single-line height uniform card height is what makes the window's
absolute-position math exact. Extra chips are clipped rather than wrapping. */
.v3-card-chips {
height: 1.5rem;
overflow: hidden;
flex-wrap: nowrap;
}
/* Skeleton placeholder shown only if a window's fetch hasn't landed; mirrors a
real card's vertical structure so it occupies an identical row height. */
.v3-card-skel { pointer-events: none; }
@@ -1,130 +0,0 @@
import { test, expect } from '@playwright/test';
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
// stage 2). Before virtualization the grid appended every scrolled page, so for
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
// Now only the visible window (± overscan) is ever in the DOM while a sizer
// element gives the scrollbar the full-library geometry.
//
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
// deterministic in CI without a seeded 2000-row library: /api/library serves a
// synthetic page from the page/after param with total 2001, and the keyset
// cursor is mocked as the next absolute offset.
const TOTAL = 2001;
const PAGE_SIZE = 24;
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
// Same bucketing as the seed/server: index % 26 → a first letter, so the AZ
// rail has real buckets and a jump has somewhere to land.
function songAt(i: number) {
const letter = COLS[i % 26];
return {
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
title: `Song ${String(i).padStart(4, '0')}`,
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
album: `${letter} Album`,
format: 'sloppak',
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
};
}
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
function sortLetters() {
const m: Record<string, number> = {};
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
return m;
}
test.beforeEach(async ({ page }) => {
await page.route('**/api/library?**', async (route) => {
const url = new URL(route.request().url());
const after = url.searchParams.get('after');
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
const songs = [];
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
const nextOffset = offset + size;
await route.fulfill({
json: {
songs, total: TOTAL, page: Math.floor(offset / size), size,
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
},
});
});
await page.route('**/api/library/stats**', (route) => {
const url = new URL(route.request().url());
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
return route.fulfill({ json: body });
});
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } }));
await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} }));
await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] }));
});
async function openSongs(page) {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
await page.evaluate(() => {
// @ts-ignore — neutralize playback so a stray click can't navigate away.
window.playSong = () => Promise.resolve();
// @ts-ignore
window.showScreen('v3-songs');
});
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
}
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
await openSongs(page);
// The count reflects the FULL library even though only a window is rendered.
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
expect(scrollHeight).toBeGreaterThan(20000);
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
const CAP = 150;
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
for (let s = 0; s < 50; s++) {
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
await page.waitForTimeout(60);
const n = await page.locator('#v3-songs-grid [data-fn]').count();
maxNodes = Math.max(maxNodes, n);
expect(n).toBeLessThanOrEqual(CAP);
}
// Sanity: we actually rendered a window (not zero), and stayed well under the
// unbounded 2001 the old append-everything grid would have produced.
expect(maxNodes).toBeGreaterThan(0);
expect(maxNodes).toBeLessThanOrEqual(CAP);
// The count is still correct after scrolling to the end.
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
});
test('the AZ rail jumps directly to a letter without loading every page', async ({ page }) => {
await openSongs(page);
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
await page.evaluate(() => {
const b = [...document.querySelectorAll('.v3-azrail-letter')]
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
if (!b) throw new Error('no M rail letter'); b.click();
});
// After the jump+window render, an 'M' card is present near the top of the
// viewport (the jump is O(1) via sort_letters, not a full page-through).
await expect.poll(async () => page.evaluate(() => {
const main = document.getElementById('v3-main')!;
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
const r = c.getBoundingClientRect();
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
});
}), { timeout: 5000 }).toBe(true);
});
-29
View File
@@ -281,35 +281,6 @@ test('open-source and close-source record outcomes events and no live handles',
assert.equal(encoded.includes('token=abc'), false);
});
test('open-source returns the actually-bound device for read-back without leaking it into diagnostics', async () => {
const window = loadAudioSession();
const api = window.feedBack.capabilities;
const openedEvents = captureEvents(window, 'audio-input:source-opened');
await registerSource(api, {
sourceId: 'readback-source',
logicalSourceKey: 'test:readback',
operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open', payload: { boundType: 'CoreAudio', boundName: 'BlackHole 16ch', requestedName: 'BlackHole 16ch' } }),
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:readback' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
// The trusted in-process caller (input_setup's confirmation gate) gets the real bound device,
// so it can show "Now listening to: <device>" and catch a silent wrong-mic substitution.
assert.equal(open.outcome, 'handled');
assert.equal(open.payload.bound.type, 'CoreAudio');
assert.equal(open.payload.bound.name, 'BlackHole 16ch');
// ...but a raw device name is PII: it must NOT reach the emitted event or the diagnostics snapshot.
assert.equal(openedEvents.length, 1);
assert.equal('bound' in openedEvents[0], false);
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
assert.equal(encoded.includes('BlackHole'), false);
});
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
const window = loadAudioSession();
const api = window.feedBack.capabilities;
@@ -130,59 +130,6 @@ test('measure-start cache is invalidated on song change', () => {
);
});
// ── Fret-row fit guard ──────────────────────────────────────────────────────
// Keeps the heat-coloured fret-number row from clipping off the bottom edge
// when a tight, centred zoom (worst mid-neck) drops it below the lower-third
// framing. camUpdate dollies the camera back via a capped, hysteretic boost.
test('fret-row fit guard constants are defined', () => {
for (const name of [
'FRET_ROW_FIT_NDC_MIN', 'FRET_ROW_FIT_DEADBAND', 'FRET_ROW_FIT_BOOST_MAX',
]) {
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
`${name} must be declared as a fit-guard constant`);
}
});
test('the curDist lerp target applies the fit-guard dolly boost', () => {
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
assert.match(
src,
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward tgtDist * _fretRowFitBoost',
);
});
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
assert.match(
src,
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
'the guard must probe the same row band the fret-number row is drawn at',
);
// Prompt pull-back when below the min, capped at BOOST_MAX.
assert.match(
src,
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
);
// Lazy relax only once past the deadband, floored at 1.
assert.match(
src,
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
'past the deadband the boost relaxes back toward 1',
);
});
test('the fit guard yields to the free-cam (Camera Director)', () => {
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
assert.match(
src,
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
'with the free-cam enabled the guard must drop any auto dolly back to 1',
);
});
// ── Debug hook stayed removed ───────────────────────────────────────────────
test('temporary camera debug hook is not present', () => {
-43
View File
@@ -1,43 +0,0 @@
// Guards the Section Practice popover's outside-click dismiss in static/app.js
// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call
// e.stopPropagation() in their click handler (static/v3/player-chrome.js
// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks
// a different rail icon (Plugins, Audio, …) — leaving the Practice popover
// stranded open under the newly-opened one (feedBack#638). The dismiss must bind
// in the CAPTURE phase (runs before the target's stopPropagation can swallow it).
// Esc must stay bubble-phase so it doesn't reorder ahead of the player's
// Escape-to-exit handling. A revert to bubble-phase should fail here.
//
// Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
const body = m[0];
test('the outside-click dismiss binds in the CAPTURE phase', () => {
assert.match(
body,
/addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/,
'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s '
+ 'stopPropagation() cannot swallow it',
);
});
test('only the click listener is capture (Escape keydown stays bubble-phase)', () => {
// Exactly one capture binding in the installer — the click. The keydown
// (Escape) listener must NOT be capture.
const captureBinds = body.match(/,\s*true\s*\)/g) || [];
assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)');
});
test('the dismiss ignores clicks inside the control (no self-close)', () => {
assert.match(body, /section-practice-control/, 'must scope to #section-practice-control');
assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/,
'a click inside the control (incl. the pill) must not dismiss the popover');
});
-133
View File
@@ -1,133 +0,0 @@
// Verify the feedpak credits overlay helpers in app.js:
// - _creditLineLabel() role → friendly "<verb> by" label
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
// - hideSongCreditsOverlay() removes the overlay element
//
// Same isolation strategy as autoplay_exit.test.js — extract the functions
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
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 SRC = fs.readFileSync(APP_JS, 'utf8');
// Minimal fake DOM element: records className, children, and textContent.
// Setting textContent clears children (matching real DOM) so we can assert
// names were set via textContent (not innerHTML) — the XSS-safety contract.
function makeEl() {
return {
className: '',
children: [],
_text: '',
set textContent(v) { this._text = String(v); this.children = []; },
get textContent() { return this._text; },
appendChild(c) { this.children.push(c); return c; },
replaceChildren() { this.children = []; },
remove() { this.removed = true; },
};
}
function allText(node) {
let s = node._text || '';
for (const c of node.children) s += allText(c);
return s;
}
function buildSandbox(currentSong) {
const body = makeEl();
const sandbox = {
document: { body, createElement: () => makeEl() },
window: { feedBack: { currentSong, off() {} } },
setTimeout: () => 1,
clearTimeout: () => {},
};
vm.createContext(sandbox);
const preamble = `
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_MAX_MS = 12000;
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
charter: 'Charted by', transcriber: 'Transcribed by',
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
engineer: 'Engineered by', proofreader: 'Proofread by',
})};
`;
vm.runInContext(
preamble
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
+ 'globalThis._creditLineLabel = _creditLineLabel;'
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
+ 'globalThis._getOverlay = () => _creditsOverlay;',
sandbox,
);
return sandbox;
}
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
const s = buildSandbox({});
assert.equal(s._creditLineLabel('charter'), 'Charted by');
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
assert.equal(s._creditLineLabel(''), '');
});
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
const s = buildSandbox({ title: 'My Song' });
s.showSongCreditsOverlay([
{ name: 'Azure', role: 'charter' },
{ name: 'Bob Lee', role: 'editor' },
{ name: 'Solo', role: null },
]);
const overlay = s._getOverlay();
assert.ok(overlay, 'overlay created');
assert.equal(overlay.className, 'song-credits-overlay');
assert.equal(s.document.body.children.length, 1);
const text = allText(overlay);
assert.match(text, /My Song/); // heading is the song title
assert.match(text, /Charted by/);
assert.match(text, /Azure/);
assert.match(text, /Edited by/);
assert.match(text, /Bob Lee/);
assert.match(text, /Solo/); // role-less entry still shows the name
});
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
const overlay = s._getOverlay();
// The raw string survives verbatim as text — proving it was never parsed
// as HTML (no innerHTML interpolation anywhere on the path).
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
});
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([]);
assert.equal(s._getOverlay(), null);
s.showSongCreditsOverlay(undefined);
assert.equal(s._getOverlay(), null);
assert.equal(s.document.body.children.length, 0);
});
test('hideSongCreditsOverlay removes the overlay', () => {
const s = buildSandbox({ title: 'T' });
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
const overlay = s._getOverlay();
assert.ok(overlay);
s.hideSongCreditsOverlay();
assert.equal(overlay.removed, true);
assert.equal(s._getOverlay(), null);
});
-94
View File
@@ -1,94 +0,0 @@
// Pins the v3 Songs AZ jump rail wiring in static/v3/songs.js.
//
// The rail lets a user jump the library grid to artists/titles starting with a
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
// card's absolute index (cumulative of prior buckets), which converts to a
// scrollTop — no page-through. The rail only offers letters the server reports
// present for the active sort+filter (so a tap always lands on a real card). It
// is shown only for the grid view + alphabetical (artist/title) sorts.
//
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('the rail is context-gated to grid view + alphabetical sorts', () => {
// railSortColumn returns the active alpha column or null (recent/year/tuning).
assert.match(src, /function\s+railSortColumn\s*\(\)/);
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
assert.match(
src,
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
'the rail must be visible only for the grid view + an alphabetical sort',
);
});
test('cards carry a data-letter bucket and non-AZ buckets under #', () => {
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
'each card must tag its sort-letter bucket via songBucket(song)');
assert.match(
src,
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
'songBucket must bucket non-AZ first chars under "#"',
);
});
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
'refreshRail must query /api/library/stats with the active filter params');
// Opts into the active-sort breakdown so non-rail callers skip the scan.
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
'refreshRail must request the sort_letters breakdown');
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
'refreshRail must prefer the active-sort breakdown (sort_letters)');
// The legacy artist `letters` is only a valid fallback for an artist sort;
// a title sort with no sort_letters hides the rail rather than mislabel it.
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
'refreshRail must only fall back to letters for an artist sort');
// Absent letters are disabled (non-interactive), not just dimmed.
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
});
test('reload() refreshes the rail', () => {
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
});
test('the rail + drag bubble are rendered in the Songs markup', () => {
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
assert.match(src, /id="v3-songs-azbubble"/);
});
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
// The cumulative-count seek: sum the song-counts of buckets ordered before
// the target to get its first row's absolute index.
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
'jumpToLetter must derive the target index from sort_letters counts');
assert.match(
src,
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
);
// It pre-fetches the destination window so cards are ready when the scroll lands.
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
'jumpToLetter must pre-fetch the destination window before scrolling');
// The old forward-paging helper is gone (the seek is O(1)).
assert.doesNotMatch(src, /_loadNextAwait/,
'the page-through helper must be removed under the windowed grid');
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
assert.match(src, /_jumpToken\s*!==\s*myToken/);
});
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
assert.match(src, /addEventListener\('pointerdown'/);
assert.match(src, /addEventListener\('pointermove'/);
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
'arrow keys must move between present letters');
});
-34
View File
@@ -1,34 +0,0 @@
// Pins the v3 "Save as collection" wiring in static/v3/songs.js (#636 item 2).
// A smart collection is a saved live library filter, surfaced as a source in
// the provider picker; the drawer can save the current filter set as one.
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('currentFilterRules builds the raw query-param rule object', () => {
assert.match(src, /function\s+currentFilterRules/);
// Multi-value filters are CSV strings (what the backend stores / re-parses).
assert.match(src, /r\.tunings\s*=\s*f\.tunings\.join\(','\)/);
assert.match(src, /r\.arrangements_has\s*=\s*f\.arr_has\.join\(','\)/);
});
test('saving POSTs to /api/collections with name + rules', () => {
assert.match(
src,
/fetch\('\/api\/collections',[\s\S]*?JSON\.stringify\(\{\s*name,\s*rules\s*\}\)/,
'saveCurrentAsCollection must POST {name, rules} to /api/collections',
);
// After save, switch the source to the new collection and rebuild the UI.
assert.match(src, /state\.provider\s*=\s*'collection:'\s*\+\s*col\.id/);
});
test('the drawer shows a Save-as-collection action only when filters are set', () => {
assert.match(src, /Object\.keys\(currentFilterRules\(\)\)\.length[\s\S]*?data-drawer-save/);
assert.match(src, /data-drawer-save[\s\S]*?saveCurrentAsCollection/);
});
-74
View File
@@ -1,74 +0,0 @@
// Pins the practice-aware library home in static/v3/songs.js:
// - a "Repertoire" progress meter (mastered / total library songs), and
// - a "Keep practicing" shelf (recently played, not yet mastered).
// Both reuse existing data (/api/stats/best already in state.accuracy, and
// /api/stats/recent) and are shown only on the unfiltered grid front door.
//
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('repertoire uses the same mastery threshold as the green accuracy badge', () => {
assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/);
assert.match(
src,
/function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/,
'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY',
);
});
test('the home is the unfiltered grid front door, local provider only', () => {
assert.match(
src,
/function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/,
'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters',
);
});
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
assert.match(src, /\/api\/stats\/recent\?limit=/);
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
// shows), not the per-arrangement recents row, and each filename appears
// once — so no green-badged "keep practicing" card and no duplicates.
assert.match(
src,
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
);
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
});
test('the meter + shelf fetch together and a stale render is discarded', () => {
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
'the two reads must be issued together (Promise.all), not sequentially');
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
'a stale render must be superseded by a newer one via a token');
});
test('the repertoire denominator is the unfiltered library total', () => {
assert.match(src, /\/api\/library\/stats\?provider='/);
assert.match(src, /total_songs\s*\?\?\s*stats\.total/);
assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/);
});
test('the home + #v3-lib-home host are wired into render and reload', () => {
assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host');
assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/,
'reload() must refresh/toggle the home');
assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/,
'a new score must refresh the meter + shelf');
});
test('shelf cards play the song on click', () => {
assert.match(
src,
/querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/,
'a shelf card click must call window.playSong with the recents filename',
);
});
+8 -12
View File
@@ -36,15 +36,13 @@ function makeStore() {
};
}
// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed
// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just
// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets
// scrollTop and re-renders the window that maps to it).
function saveSnapshot(storage, state, scrollTop) {
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
const snap = {
hash: buildLibraryStateHash(state),
scrollTop,
view: state.view,
page,
loadedCount,
};
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
}
@@ -90,21 +88,19 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
});
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
test('snapshot stores scrollTop and page', () => {
const storage = makeStore();
saveSnapshot(storage, baseState, 1840);
saveSnapshot(storage, baseState, 1840, 3, 96);
const snap = readSnapshot(storage);
assert.strictEqual(snap.scrollTop, 1840);
assert.strictEqual(snap.view, 'grid');
assert.strictEqual(snap.page, 3);
assert.strictEqual(snap.loadedCount, 96);
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
// Page-depth bookkeeping is gone — the windowed grid restores from scrollTop.
assert.strictEqual(snap.page, undefined);
assert.strictEqual(snap.loadedCount, undefined);
});
test('stale snapshot is detected when filters change', () => {
const storage = makeStore();
saveSnapshot(storage, baseState, 500);
saveSnapshot(storage, baseState, 500, 1, 48);
const snap = readSnapshot(storage);
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
assert.notStrictEqual(snap.hash, changed);
-80
View File
@@ -1,80 +0,0 @@
// Guards the v3 text-selection policy (static/v3/v3.css + static/v3/index.html):
// the UI defaults to non-selectable so accidental chrome selection can't look
// broken, while form fields, plugin screens, and core content opt back in. A
// future global reset clobbering the rule — or the content containers losing
// their .fb-selectable opt-in — should fail here.
//
// Source-level only — same strategy as the other tests/js/ files.
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, '..', '..');
// Strip block comments so the policy's own explanatory prose (which quotes the
// `* { user-select:none }` anti-pattern as a warning) can't trip the assertions.
const css = fs.readFileSync(path.join(root, 'static', 'v3', 'v3.css'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '');
const html = fs.readFileSync(path.join(root, 'static', 'v3', 'index.html'), 'utf8');
test('v3 defaults to non-selectable on html (not a universal `*` rule)', () => {
assert.match(css, /html\s*\{[^}]*user-select:\s*none/,
'html must default user-select: none');
// The `* { user-select: none }` anti-pattern breaks input carets / IME — must not exist.
assert.doesNotMatch(css, /\*\s*\{[^}]*user-select:\s*none/,
'must NOT use a universal `*` user-select:none rule');
});
test('form fields are always re-enabled (caret / IME safe)', () => {
assert.match(
css,
/input,\s*textarea,\s*select[\s\S]*?contenteditable[\s\S]*?user-select:\s*text/,
'input/textarea/select/[contenteditable] must be re-enabled to user-select: text',
);
});
test('plugin screen subtree stays selectable by inheritance (no `*`, respects plugin opt-outs)', () => {
assert.match(
css,
/\.screen\[id\^="plugin-"\]\s*\{[^}]*user-select:\s*text/,
'plugin screens must be re-enabled so plugin content is not silently un-copyable',
);
assert.doesNotMatch(
css,
/\.screen\[id\^="plugin-"\]\s*\*/,
'the plugin carve must NOT use `*` (would override a plugin\'s own non-select chrome)',
);
});
// The rule that re-enables selection on copyable content. Find the single
// declaration block whose body sets `user-select: text`, then assert each
// required selector is one of its selectors — order/format independent.
const selectableRule = (css.match(/([^{}]*)\{[^}]*user-select:\s*text[^}]*\}/g) || [])
.join('\n');
test('core content opts back in via .fb-selectable (element + descendants)', () => {
assert.match(selectableRule, /\.fb-selectable\b/, '.fb-selectable must set user-select: text');
assert.match(selectableRule, /\.fb-selectable\s*\*/, '...and its descendants (.fb-selectable *)');
});
test('focused copyable surfaces (modals/toasts/scan banner) opt back in', () => {
// The PR\'s a11y guardrail keeps copyable text selectable "incl. in
// modals/toasts" — these carry errors / IDs / paths the user copies.
assert.match(selectableRule, /\.feedBack-modal\b/, 'modals (.feedBack-modal) must be selectable');
assert.match(selectableRule, /\[role="dialog"\]/, 'dialogs ([role="dialog"]) must be selectable');
assert.match(selectableRule, /#fb-notify-stack\b/, 'toasts (#fb-notify-stack) must be selectable');
assert.match(selectableRule, /#scan-banner\b/, 'the scan banner (#scan-banner) must be selectable');
});
// Match a class="" attribute that contains ALL given tokens in any order.
const hasClasses = (...tokens) => new RegExp(
'class="' + tokens.map((t) => '(?=[^"]*\\b' + t + '\\b)').join('') + '[^"]*"');
test('the Settings panel and now-playing metadata carry .fb-selectable', () => {
assert.match(html, hasClasses('fb-settings', 'fb-selectable'),
'the Settings panel must opt back in (paths / version / diagnostics / About)');
assert.match(html, hasClasses('fb-selectable', 'pointer-events-auto'),
'the now-playing metadata must opt back in AND re-enable pointer-events '
+ '(its #player-hud parent is pointer-events-none, which would block mouse selection)');
});
-164
View File
@@ -1,164 +0,0 @@
"""Tests for smart/dynamic collections (got-feedback/feedBack#636 item 2).
A collection is a saved set of library filter rules, surfaced as a registered
library provider so it inherits the v3 Songs UI. Storage reuses the playlists
table (a `rules` JSON blob smart collection); membership is the LIVE filter
result, not stored songs.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _put(server_mod, *, filename, title, artist, tuning_name="E Standard", tuning_sort_key=0):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title, "artist": artist, "album": "LP", "year": "", "duration": 1.0,
"tuning": tuning_name, "arrangements": [], "has_lyrics": False, "format": "archive",
"stem_count": 0, "stem_ids": [], "tuning_name": tuning_name,
"tuning_sort_key": tuning_sort_key, "tuning_offsets": "",
})
def _seed_mixed(server_mod):
_put(server_mod, filename="d1.archive", title="Drop One", artist="Anna", tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="d2.archive", title="Drop Two", artist="Bea", tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="e1.archive", title="Std One", artist="Cy", tuning_name="E Standard")
# ── CRUD ────────────────────────────────────────────────────────────────────
def test_create_list_delete_collection(client):
assert client.get("/api/collections").json() == {"collections": []}
r = client.post("/api/collections", json={"name": "Drop D stuff", "rules": {"tunings": ["Drop D"]}})
assert r.status_code == 200
col = r.json()["collection"]
assert col["name"] == "Drop D stuff"
assert col["rules"] == {"tunings": "Drop D"} # raw query-param format
cid = col["id"]
listed = client.get("/api/collections").json()["collections"]
assert [c["name"] for c in listed] == ["Drop D stuff"]
assert client.request("DELETE", f"/api/collections/{cid}").json() == {"ok": True}
assert client.get("/api/collections").json() == {"collections": []}
def test_create_requires_name_and_sanitizes_rules(client):
assert client.post("/api/collections", json={"rules": {}}).status_code == 400
# Unknown rule keys are dropped (never 500); known ones normalized to the
# raw query-param format (list→CSV, favorites→1).
col = client.post("/api/collections", json={
"name": "Mix", "rules": {"tunings": ["Drop D", "Eb Standard"], "sort": "title", "bogus": "x", "favorites": True},
}).json()["collection"]
assert col["rules"] == {"tunings": "Drop D,Eb Standard", "sort": "title", "favorites": 1}
def test_update_collection(client):
cid = client.post("/api/collections", json={"name": "A", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
r = client.put(f"/api/collections/{cid}", json={"name": "B", "rules": {"format": "sloppak"}})
assert r.status_code == 200
assert r.json()["collection"]["name"] == "B"
assert r.json()["collection"]["rules"] == {"format": "sloppak"}
assert client.put("/api/collections/99999", json={"name": "x"}).status_code == 404
# ── Provider behaviour ──────────────────────────────────────────────────────
def test_collection_registers_as_a_provider(client, server_mod):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
providers = client.get("/api/library/providers").json()["providers"]
ids = [p["id"] for p in providers]
assert f"collection:{cid}" in ids
def test_collection_provider_returns_only_matching_songs(client, server_mod):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
pid = f"collection:{cid}"
page = client.get("/api/library", params={"provider": pid}).json()
titles = sorted(s["title"] for s in page["songs"])
assert titles == ["Drop One", "Drop Two"] # E Standard song excluded
stats = client.get("/api/library/stats", params={"provider": pid}).json()
assert stats["total_songs"] == 2
def test_collection_provider_is_local_kind(client, server_mod):
# kind="local" keeps the client's play/art paths on the local branch (a
# collection's matched songs are local rows), not the remote-sync branch.
cid = client.post("/api/collections", json={"name": "C", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
prov = next(p for p in client.get("/api/library/providers").json()["providers"]
if p["id"] == f"collection:{cid}")
assert prov["kind"] == "local"
def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
# A hand-edited / imported bad rules row (int where a string is expected, a
# list for `sort`) must not crash the query — the provider re-sanitizes on
# load. Write the bad JSON straight past the API sanitizer.
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "Bad", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
# `artist: []` (list for a string field) and `sort: []` (unhashable) are
# the values that would crash `.strip()` / `sort_map.get` if they reached a
# query — they must be dropped, leaving the valid `tunings` rule intact.
server_mod.meta_db.conn.execute(
"UPDATE playlists SET rules = ? WHERE id = ?",
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
)
server_mod.meta_db.conn.commit()
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
assert r.status_code == 200 # no 500/503 from bad rules
assert sorted(s["title"] for s in r.json()["songs"]) == ["Drop One", "Drop Two"]
def test_collection_provider_survives_restart(client, server_mod, tmp_path, monkeypatch):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
server_mod.meta_db.conn.close()
# Re-import the server (same CONFIG_DIR) → boot scan must re-register it.
sys.modules.pop("server", None)
mod2 = importlib.import_module("server")
try:
ids = [p["id"] for p in mod2.library_providers.list()]
assert f"collection:{cid}" in ids
finally:
mod2.meta_db.conn.close()
# ── Isolation from manual playlists ─────────────────────────────────────────
def test_collections_excluded_from_playlists_and_are_read_only(client):
cid = client.post("/api/collections", json={"name": "Coll", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
# Not listed among manual playlists...
assert all(p["id"] != cid for p in client.get("/api/playlists").json())
# ...and manual-playlist mutations 404 on a collection id (get_playlist gate).
assert client.post(f"/api/playlists/{cid}/songs", json={"filename": "d1.archive"}).status_code == 404
assert client.get(f"/api/playlists/{cid}").status_code == 404
-159
View File
@@ -1,159 +0,0 @@
"""Tests for feedpak contributor credits on the highway.
Covers the `_sanitize_authors` helper (unit) and the `song_info` WebSocket
frame carrying the manifest `authors` list end-to-end (integration). The
frontend uses a non-empty `authors` list to gate a credits overlay shown when
a song loads, so loose/archive/synthetic plays must surface `[]`.
"""
from __future__ import annotations
import importlib
import json
import sys
import pytest
import yaml
from fastapi.testclient import TestClient
# ── _sanitize_authors unit tests ────────────────────────────────────────────
@pytest.fixture()
def server_mod(monkeypatch, tmp_path):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
(tmp_path / "dlc").mkdir()
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def test_sanitize_authors_valid(server_mod):
out = server_mod._sanitize_authors(
{
"authors": [
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
{"name": "Bob Lee", "role": "editor"},
{"name": "Solo"},
]
}
)
# name + role only; email/url dropped; missing role → None.
assert out == [
{"name": "Azure", "role": "charter"},
{"name": "Bob Lee", "role": "editor"},
{"name": "Solo", "role": None},
]
def test_sanitize_authors_skips_malformed(server_mod):
out = server_mod._sanitize_authors(
{
"authors": [
{"name": ""}, # blank name → skipped
{"name": " "}, # whitespace name → skipped
{"role": "mixer"}, # no name → skipped
"not-a-dict", # non-dict → skipped
{"name": " Kept ", "role": " arranger "}, # trimmed
]
}
)
assert out == [{"name": "Kept", "role": "arranger"}]
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
assert server_mod._sanitize_authors(manifest) == []
# ── song_info WS integration ────────────────────────────────────────────────
def _write_sloppak(dlc_root, *, authors):
pak = dlc_root / "authortest.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "lead.json").write_text(
json.dumps(
{
"notes": [],
"chords": [],
"anchors": [],
"handshapes": [],
"templates": [],
"beats": [{"time": 0.0, "measure": 1}],
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
}
)
)
manifest = {
"title": "Author Test",
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [],
}
if authors is not None:
manifest["authors"] = authors
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
@pytest.fixture()
def make_client(tmp_path, monkeypatch):
def _make():
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
return server
(tmp_path / "dlc").mkdir()
yield _make
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
def _song_info(client, path):
with client.websocket_connect(path) as ws:
for _ in range(200):
msg = ws.receive_json()
if msg.get("error"):
raise AssertionError(f"WS error frame: {msg}")
if msg.get("type") == "song_info":
return msg
if msg.get("type") == "ready":
break
raise AssertionError("no song_info frame received")
def test_song_info_carries_authors(make_client):
server = make_client()
_write_sloppak(
server._get_dlc_dir(),
authors=[{"name": "Azure", "role": "charter", "email": "a@b.c"}],
)
with TestClient(server.app) as client:
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
assert info["authors"] == [{"name": "Azure", "role": "charter"}]
def test_song_info_authors_empty_when_absent(make_client):
server = make_client()
_write_sloppak(server._get_dlc_dir(), authors=None)
with TestClient(server.app) as client:
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
assert info["authors"] == []
+2 -39
View File
@@ -398,39 +398,6 @@ def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_m
assert stats["letters"] == {"#": 1}
def test_query_stats_sort_letters_artist_counts_songs(client, server_mod):
"""The v3 jump rail's `sort_letters` counts SONGS per first-letter bucket
of the active sort column (vs `letters`, which counts distinct artists).
Two songs by the same A-artist letters {A:1}, sort_letters {A:2}."""
_put(server_mod, filename="a1.archive", title="Song One", artist="Abba")
_put(server_mod, filename="a2.archive", title="Song Two", artist="Abba")
_put(server_mod, filename="b1.archive", title="Another", artist="Beck")
_put(server_mod, filename="num.archive", title="Track", artist="2Pac")
# sort_letters=1 opts into the active-sort breakdown (the jump rail path).
stats = client.get("/api/library/stats", params={"sort": "artist", "sort_letters": 1}).json()
assert stats["letters"] == {"A": 1, "B": 1, "#": 1} # distinct artists
assert stats["sort_letters"] == {"A": 2, "B": 1, "#": 1} # songs
# Without the opt-in, the extra breakdown is not computed or returned.
plain = client.get("/api/library/stats", params={"sort": "artist"}).json()
assert "sort_letters" not in plain
assert plain["letters"] == {"A": 1, "B": 1, "#": 1}
def test_query_stats_sort_letters_follow_title_sort(client, server_mod):
"""With a title sort, the rail buckets key on the TITLE's first letter,
not the artist's, so a tap lands on a real card in the grid's order."""
_put(server_mod, filename="z1.archive", title="Apple", artist="Zztop")
_put(server_mod, filename="z2.archive", title="Banana", artist="Zztop")
stats = client.get("/api/library/stats", params={"sort": "title", "sort_letters": 1}).json()
assert stats["sort_letters"] == {"A": 1, "B": 1}
# The legacy artist breakdown is unchanged regardless of sort — both songs
# share one artist, so it stays a single distinct-artist Z bucket.
assert stats["letters"] == {"Z": 1}
def test_query_stats_ignores_null_letter_counts(server_mod):
"""Legacy/corrupt rows can surface as NULL-ish letter aggregate
rows on some SQLite builds. The stats endpoint should ignore those
@@ -462,13 +429,9 @@ def test_query_stats_ignores_null_letter_counts(server_mod):
server_mod.meta_db.conn.close()
server_mod.meta_db.conn = FakeConn()
stats = server_mod.meta_db.query_stats(want_sort_letters=True)
stats = server_mod.meta_db.query_stats()
# `sort_letters` (the v3 jump-rail breakdown) shares the GROUP BY letter
# path in this fake, so it surfaces the same single live bucket when the
# caller opts in.
assert stats == {"total_songs": 1, "total_artists": 1,
"letters": {"T": 1}, "sort_letters": {"T": 1}}
assert stats == {"total_songs": 1, "total_artists": 1, "letters": {"T": 1}}
def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
-154
View File
@@ -1,154 +0,0 @@
"""Keyset (cursor) pagination for the library grid (feedBack#636 item 3, stage 1).
Pins the data layer the virtualized grid builds on:
- every sort gets a unique `filename` tiebreak a TOTAL order (fixes the
latent OFFSET skip/dupe across equal-key rows);
- `/api/library?after=<cursor>` walks the SAME total order with a WHERE-seek,
returning exactly the OFFSET page would, with no gaps or dupes;
- bad cursors / non-keyset sorts fall back to OFFSET safely.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _seed(server_mod, n=25, *, shared_artist=False):
for i in range(n):
artist = "SameArtist" if shared_artist else f"Artist{i:02d}"
server_mod.meta_db.put(f"song{i:02d}.archive", float(i), 1, {
"title": f"Title{i:02d}", "artist": artist, "album": "LP", "year": "",
"duration": 1.0, "tuning": "E Standard", "arrangements": [], "has_lyrics": False,
"format": "archive", "stem_count": 0, "stem_ids": [], "tuning_name": "E Standard",
"tuning_sort_key": 0, "tuning_offsets": "",
})
def _walk_keyset(client, sort, size, total):
"""Page the whole library via the cursor and return the filename order."""
seen, cursor, guard = [], "", 0
while len(seen) < total and guard < total + 5:
guard += 1
params = {"sort": sort, "size": size}
if cursor:
params["after"] = cursor
body = client.get("/api/library", params=params).json()
seen.extend(s["filename"] for s in body["songs"])
cursor = body.get("next_cursor")
if not body["songs"] or not cursor:
break
return seen
def _walk_offset(client, sort, size, total):
seen, page = [], 0
while len(seen) < total:
body = client.get("/api/library", params={"sort": sort, "size": size, "page": page}).json()
if not body["songs"]:
break
seen.extend(s["filename"] for s in body["songs"])
page += 1
return seen
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "title", "title-desc", "recent"])
def test_keyset_matches_offset_exactly(client, server_mod, sort):
_seed(server_mod, 25)
offset_order = _walk_offset(client, sort, 7, 25)
keyset_order = _walk_keyset(client, sort, 7, 25)
assert keyset_order == offset_order # same order...
assert len(keyset_order) == 25
assert len(set(keyset_order)) == 25 # ...no gaps, no dupes
def test_stable_tiebreak_on_equal_keys(client, server_mod):
# 25 songs, all the SAME artist → the artist sort is decided entirely by the
# filename tiebreak. Both pagers must still cover all 25 with no dupe.
_seed(server_mod, 25, shared_artist=True)
keyset_order = _walk_keyset(client, "artist", 6, 25)
assert len(keyset_order) == 25 and len(set(keyset_order)) == 25
assert keyset_order == sorted(keyset_order) # tiebreak is filename ASC
def test_first_page_has_cursor_and_no_after_is_offset(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
assert body["next_cursor"] # cursor offered
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive"]
def test_bad_cursor_falls_back_to_first_page(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "artist", "size": 3, "after": "not-a-cursor"}).json()
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive", "song02.archive"]
def test_legacy_dir_desc_keysets_correctly(client, server_mod):
# The legacy `sort=artist&dir=desc` shape must keyset against a DESC order
# (canonicalized to artist-desc), not seek `>` against it → no gaps/dupes.
_seed(server_mod, 20)
offset_order, page = [], 0
while True:
body = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 6, "page": page}).json()
if not body["songs"]:
break
offset_order.extend(s["filename"] for s in body["songs"])
page += 1
keyset, cursor, guard = [], "", 0
while len(keyset) < 20 and guard < 25:
guard += 1
params = {"sort": "artist", "dir": "desc", "size": 6}
if cursor:
params["after"] = cursor
body = client.get("/api/library", params=params).json()
keyset.extend(s["filename"] for s in body["songs"])
cursor = body.get("next_cursor")
if not body["songs"] or not cursor:
break
assert keyset == offset_order
assert len(set(keyset)) == 20
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "recent"])
def test_keyset_handles_null_sort_keys(client, server_mod, sort):
# NULL artist/mtime (corrupt/legacy rows past put()'s '' defaults) sort
# first in ASC / last in DESC; keyset must cover them exactly like OFFSET.
_seed(server_mod, 10)
server_mod.meta_db.conn.executemany(
"INSERT INTO songs (filename, mtime, size, title, artist) VALUES (?, NULL, 1, ?, NULL)",
[("zznull1.archive", "ZZ1"), ("zznull2.archive", "ZZ2")],
)
server_mod.meta_db.conn.commit()
offset_order = _walk_offset(client, sort, 4, 12)
keyset_order = _walk_keyset(client, sort, 4, 12)
assert keyset_order == offset_order
assert len(keyset_order) == 12 and len(set(keyset_order)) == 12
def test_non_keyset_sort_offers_no_cursor(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "tuning", "size": 2}).json()
assert body["next_cursor"] is None # compound sort → OFFSET only
assert len(body["songs"]) == 2
+1 -3
View File
@@ -213,9 +213,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert stats["letters"] == {"R": 1}
assert "page" not in provider.stats_kwargs
assert "size" not in provider.stats_kwargs
# `sort` is forwarded to query_stats now (the v3 jump rail keys its
# present-letter breakdown on the active sort column); defaults to "artist".
assert provider.stats_kwargs.get("sort") == "artist"
assert "sort" not in provider.stats_kwargs
tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json()
assert tunings["tunings"][0]["name"] == "E Standard"
-311
View File
@@ -1,311 +0,0 @@
"""Tests for the library-DB + custom-art half of the settings bundle
(got-feedback/feedBack#636 item 1).
The base bundle (config + plugin files) is covered in test_settings_export.py;
this file pins the additive `core_server_files` section:
- the live library DB is exported as a CONSISTENT single-file snapshot
(SQLite online-backup), base64-encoded;
- custom playlist covers / avatar are walked into the bundle;
- on import the DB is STAGED to `web_library.db.restore` (never written
over the live, open DB) and swapped in at next startup, clearing stale
WAL sidecars; custom art is written immediately;
- the whole thing round-trips: export wipe import restart data back.
"""
import base64
import importlib
import sqlite3
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _valid_db_bytes(tmp_path, name="mk.db", marker="x"):
"""Bytes of a small, valid (quick_check-clean) SQLite database."""
p = tmp_path / name
c = sqlite3.connect(str(p))
try:
c.execute("CREATE TABLE t (x TEXT)")
c.execute("INSERT INTO t VALUES (?)", (marker,))
c.commit()
finally:
c.close()
return p.read_bytes()
def _seed_song(server_mod, filename="marker.archive", title="Marker", artist="Tester"):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title, "artist": artist, "album": "LP", "year": "",
"duration": 200.0, "tuning": "E Standard", "arrangements": [],
"has_lyrics": False, "format": "archive", "stem_count": 0,
"stem_ids": [], "tuning_name": "E Standard", "tuning_sort_key": 0,
"tuning_offsets": "",
})
# ── Export ──────────────────────────────────────────────────────────────────
def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_path):
_seed_song(server_mod, filename="snap.archive", title="SnapSong")
bundle = client.get("/api/settings/export").json()
core = bundle["core_server_files"]
assert "web_library.db" in core
entry = core["web_library.db"]
assert entry["encoding"] == "base64"
# The snapshot must be a complete, openable DB reflecting current data —
# written to its own file (no WAL sidecar needed) and queryable.
snap = tmp_path / "snapshot.db"
snap.write_bytes(base64.b64decode(entry["data"]))
conn = sqlite3.connect(str(snap))
try:
rows = conn.execute(
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
).fetchall()
finally:
conn.close()
assert rows == [("SnapSong",)]
def test_export_includes_custom_art_dirs(client, tmp_path):
(tmp_path / "playlist_covers").mkdir()
(tmp_path / "playlist_covers" / "3.png").write_bytes(b"\x89PNG-cover")
(tmp_path / "avatars").mkdir()
(tmp_path / "avatars" / "me.png").write_bytes(b"\x89PNG-avatar")
core = client.get("/api/settings/export").json()["core_server_files"]
assert core["playlist_covers/3.png"]["encoding"] == "base64"
assert base64.b64decode(core["playlist_covers/3.png"]["data"]) == b"\x89PNG-cover"
assert base64.b64decode(core["avatars/me.png"]["data"]) == b"\x89PNG-avatar"
# ── Import: DB is staged, never written over the live file ──────────────────
def test_import_stages_db_restore_without_touching_live_db(client, server_mod, tmp_path):
live = tmp_path / "web_library.db"
live_bytes_before = live.read_bytes()
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(payload).decode()},
},
})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["restart_required"] is True
assert any("restart" in w.lower() for w in body["warnings"])
assert "web_library.db" in body["applied"]["core_files"]
# Live DB untouched; the restore is staged beside it for next startup.
assert live.read_bytes() == live_bytes_before
assert (tmp_path / "web_library.db.restore").read_bytes() == payload
def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, tmp_path):
# The dangerous case: SQLite magic header but a corrupt body. It must be
# refused at import — otherwise startup would delete the live DB and then
# fail to open the bad restore.
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(corrupt).decode()},
},
})
assert r.status_code == 400
assert not (tmp_path / "web_library.db.restore").exists()
def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
# A truncated / wrong file staged as the restore would brick startup —
# reject anything lacking the SQLite magic header, before touching disk.
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(b"not a database").decode()},
},
})
assert r.status_code == 400
assert not (tmp_path / "web_library.db.restore").exists()
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"playlist_covers/7.png": {"encoding": "base64",
"data": base64.b64encode(b"cover7").decode()},
},
})
assert r.status_code == 200
assert r.json()["restart_required"] is False
assert (tmp_path / "playlist_covers" / "7.png").read_bytes() == b"cover7"
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
secret = tmp_path.parent / "escape.txt"
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"../escape.txt": {"encoding": "base64",
"data": base64.b64encode(b"pwned").decode()},
},
})
assert r.status_code == 400
assert not secret.exists()
def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_path):
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
# the rest of the bundle still applies.
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"audio_cache/x.ogg": {"encoding": "base64",
"data": base64.b64encode(b"nope").decode()},
},
})
assert r.status_code == 200
assert not (tmp_path / "audio_cache" / "x.ogg").exists()
assert any("undeclared" in w.lower() for w in r.json()["warnings"])
# ── Startup swap ────────────────────────────────────────────────────────────
def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path):
main = tmp_path / "web_library.db"
new_db = _valid_db_bytes(tmp_path, name="new.db", marker="new")
# Simulate a live DB with stale WAL sidecars + a (valid) staged restore.
main.write_bytes(b"OLD-DB")
(tmp_path / "web_library.db-wal").write_bytes(b"OLD-WAL")
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
(tmp_path / "web_library.db.restore").write_bytes(new_db)
server_mod._apply_pending_db_restore(tmp_path)
assert main.read_bytes() == new_db # swapped in
assert not (tmp_path / "web_library.db.restore").exists()
assert not (tmp_path / "web_library.db-wal").exists() # stale sidecars gone
assert not (tmp_path / "web_library.db-shm").exists()
def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_path):
# A corrupt staged restore must be thrown away WITHOUT destroying the
# live DB — never brick startup or lose data for a bad bundle.
main = tmp_path / "web_library.db"
main.write_bytes(b"LIVE-GOOD-DB")
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
server_mod._apply_pending_db_restore(tmp_path)
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
(tmp_path / "web_library.db").write_bytes(b"LIVE")
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
# ── Full round-trip ─────────────────────────────────────────────────────────
def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
_seed_song(server_mod, filename="keepme.archive", title="KeepMe")
bundle = client.get("/api/settings/export").json()
# Lose the data (a song removed from the live DB after the backup).
server_mod.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", ("keepme.archive",))
server_mod.meta_db.conn.commit()
assert server_mod.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchone()[0] == 0
# Re-import the bundle → DB staged, not yet live.
r = client.post("/api/settings/import", json=bundle)
assert r.status_code == 200 and r.json()["restart_required"] is True
# Simulate a restart: close the live conn, apply the staged restore,
# reopen — the song is back.
server_mod.meta_db.conn.close()
server_mod._apply_pending_db_restore(tmp_path)
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
try:
rows = conn.execute(
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchall()
finally:
conn.close()
assert rows == [("KeepMe",)]
assert not (tmp_path / "web_library.db.restore").exists()
# ── Failure modes ───────────────────────────────────────────────────────────
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
# A backup that silently omits the library DB is a data-loss trap — the
# export must error rather than hand back an incomplete-looking bundle.
monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None)
r = client.get("/api/settings/export")
assert r.status_code == 500
assert "library database" in r.json()["error"].lower()
def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, monkeypatch):
# If a later write in phase 2 fails, the request 500s — but a staged DB
# restore must NOT survive to swap in on the next restart.
payload = _valid_db_bytes(tmp_path, name="incoming.db")
real_write = server_mod._atomic_write_file
def boom(target, data):
if target.name == "config.json": # last write of the commit
raise OSError("disk full")
return real_write(target, data)
monkeypatch.setattr(server_mod, "_atomic_write_file", boom)
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
"data": base64.b64encode(payload).decode()},
},
})
assert r.status_code == 500
assert not (tmp_path / "web_library.db.restore").exists()
-111
View File
@@ -1,111 +0,0 @@
"""Tests for the wishlist / "wanted" list (got-feedback/feedBack#636 item 4).
A wishlist entry is a song the user does NOT own yet (the *arr Wanted/Monitored
analogue), so it lives in its own `wanted` table keyed by descriptive identity
rather than a local filename. Producers (the find_more ownership-diff, or a
manual add) POST entries; the API is idempotent on identity so a re-run of an
ownership-diff can't duplicate.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def test_add_list_remove_round_trip(client):
assert client.get("/api/wanted").json() == {"wanted": []}
r = client.post("/api/wanted", json={"artist": "Tool", "title": "Lateralus",
"source": "find_more", "source_ref": "cf:123"})
assert r.status_code == 200
row = r.json()["wanted"]
assert (row["artist"], row["title"], row["source"]) == ("Tool", "Lateralus", "find_more")
wid = row["id"]
listed = client.get("/api/wanted").json()["wanted"]
assert [w["title"] for w in listed] == ["Lateralus"]
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": True}
assert client.get("/api/wanted").json() == {"wanted": []}
# Deleting an already-gone id is a no-op, not an error.
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": False}
def test_add_is_idempotent_on_identity(client, server_mod):
payload = {"artist": "Rush", "title": "YYZ", "source": "find_more", "source_ref": "x1"}
first = client.post("/api/wanted", json=payload).json()["wanted"]
# Same identity (case-insensitive on artist/title) → no duplicate, same row.
again = client.post("/api/wanted", json={**payload, "artist": "rush", "title": "yyz"}).json()["wanted"]
assert first["id"] == again["id"]
assert server_mod.meta_db.count_wanted() == 1
# A different source_ref is a distinct entry.
client.post("/api/wanted", json={**payload, "source_ref": "x2"})
assert server_mod.meta_db.count_wanted() == 2
def test_newest_first_ordering(client, server_mod):
for t in ("First", "Second", "Third"):
server_mod.meta_db.add_wanted(artist="A", title=t, source="manual")
titles = [w["title"] for w in client.get("/api/wanted").json()["wanted"]]
assert titles == ["Third", "Second", "First"]
def test_add_requires_artist_or_title(client):
r = client.post("/api/wanted", json={"source": "manual"})
assert r.status_code == 400
r2 = client.post("/api/wanted", json={"artist": "", "title": " "})
assert r2.status_code == 400
def test_add_defaults_source_to_manual(client):
row = client.post("/api/wanted", json={"title": "Untitled"}).json()["wanted"]
assert row["source"] == "manual"
assert row["artist"] == ""
def test_non_dict_body_rejected(client):
# FastAPI's `data: dict` validation rejects a JSON array (422) before the
# handler's own defensive isinstance guard; either way it's not a 2xx.
assert client.post("/api/wanted", json=[]).status_code in (400, 422)
def test_table_creation_is_idempotent(server_mod):
# Re-running the CREATE TABLE / CREATE INDEX must not error or wipe rows —
# pin the additive + idempotent migration guarantee (constitution IV).
server_mod.meta_db.add_wanted(artist="Keep", title="Me")
server_mod.meta_db.conn.execute("""
CREATE TABLE IF NOT EXISTS wanted (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', source_ref TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', created_at TEXT
)
""")
server_mod.meta_db.conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
)
assert server_mod.meta_db.count_wanted() == 1