Commit Graph
169 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 4.7 7e8d19606a refactor(web): sweep test files to use empty likes/quarantine mocks (#375)
Replaces duplicated inline vi.mock('$lib/api/likes', ...) blocks with
the emptyLikesMock() helper, and (where previously left in working
tree by a parallel quarantine sweep) rolls in the matching
emptyQuarantineMock() switchovers in the same files.

Together with commit dd67f28 — which already swept the four search/*
test files for likes — this completes the 18 likes-mock conversions
called out in #375.

LikeButton.test.ts kept inline — it uses a state-driven mock to
assert the toggle behavior.

SKIPPED:
- liked.test.ts: re-exports createLikedTracksInfiniteQuery and the
  album/artist variants that emptyLikesMock() doesn't provide.
- playlist.test.ts: hand-rolled { subscribe, data } stub instead of
  readable(...) — different store contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:41:15 -04:00
bvandeusenandClaude Opus 4.7 dd67f28745 refactor(web): sweep 8 test files to use pageUrlModule() (#375)
Centralizes the inline { page: { get url() { return state.pageUrl } } }
mock shape via test-utils/mocks/appState.ts. The vi.hoisted state
declaration remains per-file (vitest mock-hoisting requires module-
level declaration). Adds a $test-utils alias to svelte.config.js so
the helper can be imported without ../../../ chains.

SKIPPED (mock exposes more than url):
- Shell.test.ts, MobileNavDrawer.test.ts, admin.test.ts: static
  page: { url: ... } shape, no hoisted state to centralize.
- album.test.ts, artist.test.ts: page exposes both params and url.
- playlist.test.ts, reset-password.test.ts: page exposes params only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:39:25 -04:00
bvandeusenandClaude Opus 4.7 0eaaf66748 refactor(web): playlistTrackToRef helper to dedupe TrackRef
reconstruction (#375)

PlaylistTrackRow.svelte and PlaylistCard.toTrackRefs both rebuilt
TrackRef from PlaylistTrack with the same field-by-field literal.
Server adding a new TrackRef field would have needed both sites
updated; now one helper owns the mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:37:11 -04:00
bvandeusenandClaude Opus 4.7 084c202654 fix(web/test): MobileNavDrawer inert + integrations Save mocks Test first
MobileNavDrawer: Svelte 5 + jsdom binds `inert` as a property, not always
an attribute. Mirror the QueueDrawer.test.ts pattern that accepts either.

Integrations Save tests: Per commit bca8622 (Save runs Test first), the
two existing Save tests need to mock testLidarrConnection before clicking
Save — otherwise the Test step returns undefined, fails the ok check,
and putLidarrConfig is never reached. The newer 'Lidarr first-time
setup' suite already does this; just bringing the older two tests in
line. Also wrapped the assertions in waitFor since the put-config call
now resolves async after the test promise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:04 -04:00
bvandeusenandClaude Opus 4.7 3ffd9beca0 fix(web): preserve per-row aria-labels through RowActionsMenu + test fixes
CI vitest run on 3f8a2c5 surfaced 17 failures across 5 test files; this
commit addresses 15 that are this batch's responsibility.

1. RowActionsMenu now accepts ariaLabel on RowAction. Defaults to label.
   Admin pages (requests/quarantine/users) pass per-row aria-labels
   matching the pre-batch buttons ("Approve Geogaddi", "Resolve Roygbiv",
   "Make alice admin", etc.) so screen readers + tests find them.

2. PlayerBar.test.ts — anchored regex /^(play|pause)$/i so the new
   "Player options" overflow ⋮ doesn't also match /play|pause/i.

3. MobileNavDrawer.test.ts — added vi.mock for $app/state, $app/navigation,
   and $lib/auth/store.svelte (mirrors Shell.test.ts pattern). Without
   these, SvelteKit's notifiable_store helper isn't bootstrapped in
   vitest and the suite fails to load.

The 2 remaining vitest failures are in /admin/integrations Save flow
(putLidarrConfig spy not called). Untouched by this batch and the
recent "Save runs Test first" refactor (bca8622) appears related —
flagging for operator verification, not chasing as a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:16:48 -04:00
bvandeusenandClaude Opus 4.7 3f8a2c511a fix(web): MobileNavDrawer focus uses querySelector, not conditional bind:this
bind:this requires an Identifier or MemberExpression — a ternary
like {i === 0 ? firstNavLink : undefined} is invalid. Replaced
with a single bind on the <aside> root, then querySelector('nav a')
to locate the first nav link at focus time. Same behaviour, valid
Svelte.

Caught by CI svelte-check on 268e12a (failed before the @const
fix landed in 1536860):
  src/lib/components/MobileNavDrawer.svelte:91:13
  https://svelte.dev/e/bind_invalid_expression

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:26:44 -04:00
bvandeusenandClaude Opus 4.7 268e12a5eb feat(web): Modal overlay gets p-4 so max-w-md modals fit on phone
Without padding, max-w-md (448px) modals touch the viewport edges
on a 375px screen. p-4 on the overlay clamps them inside the safe
area. Covers all routes that use the shared <Modal> component
(discover track-confirm, quarantine typed-DELETE, users
password-reset, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:21:52 -04:00
bvandeusenandClaude Opus 4.7 16e0e98943 feat(web): mobile mechanical sweep — pointer-aware arrows, h3 dividers, modal safe-area
- HorizontalScrollRow arrows bump 32px → 40px on coarse pointers
  (touch screens) for finger-friendly hits; mouse pointers unchanged.
- AlphabeticalGrid divider letter is now <h3> so screen readers
  announce it as a section heading. Margin reset to preserve layout.
- Modal overlay gets p-4 so max-w-md modals stay inside the safe
  area at 375px viewport. Covers all routes that use the shared
  <Modal> component (discover track-confirm, quarantine
  typed-DELETE, users password-reset, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:21:38 -04:00
bvandeusenandClaude Opus 4.7 e0cf527304 feat(web): RowActionsMenu generic primary + ⋮ for admin tables
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:19:09 -04:00
bvandeusenandClaude Opus 4.7 ed6d0936ca feat(web): PlayerBar reflows to two-row layout below md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:18:32 -04:00
bvandeusenandClaude Opus 4.7 d5983433c5 feat(web): PlayerOverflowMenu — shuffle/repeat/queue/volume in a menu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:18:10 -04:00
bvandeusenandClaude Opus 4.7 df4cb434b2 feat(web): hamburger trigger + mobile drawer mount in Shell
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:17:35 -04:00
bvandeusenandClaude Opus 4.7 857a8621ec feat(web): MobileNavDrawer off-canvas nav for <md viewport
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:17:17 -04:00
bvandeusen 3e263657c6 refactor(web): hoist .play-overlay CSS to app.css; remove triplicated <style> blocks (C2) 2026-05-08 11:28:58 -04:00
bvandeusen efef9934d4 refactor(web): migrate 4 open-coded cover URL sites to coverUrl() helper (C1 small-win) 2026-05-08 11:26:50 -04:00
bvandeusen 5a174c9ef0 refactor(web): converge PlaylistTrackRow drag-reorder onto @neodrag/svelte (B4) 2026-05-08 11:22:23 -04:00
bvandeusen 3e34f1f7b3 refactor(web/test): default svelte-query mock in vitest.setup; remove from 34 files (B3) 2026-05-08 11:08:41 -04:00
bvandeusen 617477b702 refactor(web): pushToast store + ToastHost; 8 sites migrated (W3) 2026-05-08 05:45:34 -04:00
bvandeusen 970752a153 refactor(web): Modal component; admin + discover modals migrated (W2) 2026-05-08 05:37:58 -04:00
bvandeusen 9c91a342e2 refactor(web/api): errCode + errMessage helpers; 41 sites migrated (W1) 2026-05-07 22:17:45 -04:00
bvandeusen 081b8e62d5 fix(web): a11y + reactivity warnings (paying down accumulated warnings)
Cleans up the 8 svelte-check warnings I deferred in prior CI runs.
Two distinct categories — handling them differently:

REAL REACTIVITY BUGS — fixed:
- AlbumMenu.svelte / ArtistMenu.svelte: cachedTracks initialized
  from the `tracks` prop only at component creation. When the menu
  is reused across rows (operator opens menu on one album, then
  another), the cache from the first open serves stale tracks.
  Fix: initialize cachedTracks to null; ensureTracks() reads the
  current `tracks` prop on each call before falling through to
  the network fetch.

INTENTIONAL PATTERNS LINT FLAGGED — suppressed with explanatory
comments:
- AlbumMenu / ArtistMenu / PlaylistCard menu containers: the
  onclick={(e) => e.stopPropagation()} is a guard against the
  window-level "any click closes the menu" listener — not
  user-facing interaction. A paired keyboard handler is
  intentionally absent (Escape-to-close lives on svelte:window
  onkeydown). Suppressed a11y_click_events_have_key_events with
  explanatory comments.
- CompactTrackCard overlay div: catches clicks on its inner
  LikeButton + queue button so they don't bubble to the wrapping
  play-card button. Inner buttons carry their own keyboard
  handling. Suppressed both a11y_click_events_have_key_events and
  a11y_no_static_element_interactions.
- theme.svelte.ts module-scope `_resolved = $state(resolve(_theme))`:
  module-init reads _theme to seed _resolved; subsequent updates
  go through setTheme() and the matchMedia listener. $derived
  doesn't apply at module scope. Suppressed state_referenced_locally
  with an explanatory comment.

Promised in the prior fix-forward: "I'll fix bugs when I see them
or open a follow-up task with a specific name, not park them in an
umbrella." Following through.
2026-05-07 18:38:44 -04:00
bvandeusenandClaude Sonnet 4.6 97d4dce7ee feat(web/m7-user-mgmt): /admin/users page + AdminTabs entry
New admin route /admin/users with two sections:

- Accounts — table of all users (username, optional display name,
  admin badge, created_at). Per-row "Make admin" / "Remove admin"
  button calls PUT /api/admin/users/{id}/admin. Last-admin guard
  surfaces as a toast ("Can't remove the last admin — promote
  someone else first").

- Invites — list of active invites with Copy + Revoke per row.
  "Generate invite" button creates a 24h token. Operator shares
  the token with the invitee, who enters it on /register.

AdminTabs gains a "Users" tab (5 tabs total). New typed client
functions in admin.ts (listUsers, updateUserAdmin, listInvites,
createInvite, deleteInvite) plus query factory functions
createAdminUsersQuery / createAdminInvitesQuery and query keys
qk.adminUsers / qk.adminInvites.

Tests cover: list rendering, admin badge, promote/demote actions,
last-admin guard toast, invite generation, revoke flow, empty states.
AdminTabs.test.ts updated to assert 5 tabs and Users active state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:06:08 -04:00
bvandeusenandClaude Sonnet 4.6 e48d84cde1 feat(web/playlists): For-You tile play button refreshes and plays
Click on the For-You tile's play button now triggers a synchronous
refresh, then enqueues + auto-plays the freshly-built playlist.
Two-click-target tile pattern: tile BODY click still navigates to
the playlist detail page (existing behavior); the play button is
the new generate-and-play action.

Behavior is gated on playlist.system_variant === 'for_you' — every
other playlist (user, Discover, Songs-like-X) keeps the existing
"fetch detail, enqueue, play" flow. The atomic-replace BuildSystem-
Playlists creates a new playlist_id, so the play handler uses the
refresh response's id for the follow-up getPlaylist call rather
than the stale tile prop's id.

Empty-library response (playlist_id: null) is a no-op — clicking
play in a degenerate-empty library doesn't error, it just doesn't
start playback. Caches are invalidated post-refresh so the home
view re-fetches and the tile re-renders with the new id, avoiding
the corner case of a click-then-tile-body 404.

Tests cover the refresh-and-play happy path, the empty-library
no-op, and that non-For-You playlists keep the existing flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:40:23 -04:00
bvandeusen b20738f925 feat(web/playlists): Discover refresh button (detail + home kebab)
Operator can refresh their Discover playlist without waiting for
the next daily cron tick. Two surfaces, both gated on
system_variant === 'discover':

- Detail page header: "Refresh" button next to the cover art / meta.
- Home Playlists row tile kebab: "Refresh Discover" menu item.

Both call POST /api/playlists/system/discover/refresh, show a
confirmation toast, and invalidate the playlist + playlists queries
so the new content lands without a manual page reload.

Extends Playlist.system_variant union with 'discover'. Adds
refreshDiscover() typed client. Tests cover client behaviour
(success + empty-library) plus conditional rendering on both
surfaces.
2026-05-07 08:44:33 -04:00
bvandeusen d57004b085 feat(web/admin): sticky tabs at top of admin scroll container
The admin tab strip (Overview / Integrations / Requests / Quarantine)
scrolled out of view as the operator paged through long sections like
Library Scan, Cover Art, etc. Pin it to the top of the <main>
scroll container with sticky positioning, an opaque background to
hide content scrolling behind it, and z-10 to layer above section
content.
2026-05-07 06:53:06 -04:00
bvandeusen 3b2d1009cd feat(web/m7-scan-progress): StageBadge + 4-card layout updates
Each of the 4 stage cards in the admin Library Scan section gains a
StageBadge in its header. The badge derives state via stageState()
from the existing ScanStatus payload — no new query, no schema
change. Active stage shows spinner + "Processing" in moss green;
done shows check + "Done" in moss green; pending and skipped show
muted text.

Card body now shows either the (possibly partial) numbers OR a
"Waiting for previous stage…" placeholder when the badge is
pending. The previous bottom-of-card "Pending…" / "Skipped."
fallback text disappears (the badge replaces it).

StageBadge.test.ts covers the four state renderings.
admin.test.ts adds one new case asserting the Processing badge
shows when scan.library is populated and in_flight: true. Existing
admin.test.ts assertions still pass (the default mock data has no
stage tallies + in_flight: false → all stages skipped, no Loader/
Check icons rendered).
2026-05-06 21:19:53 -04:00
bvandeusen 835580ef1e fix(web/m7-380): convert PlaylistTrack to TrackRef in PlaylistCard play handler 2026-05-04 19:46:43 -04:00
bvandeusen b290b4ff0b feat(web/m7-380): animate pending placeholder + add play overlay to PlaylistCard 2026-05-04 19:42:30 -04:00
bvandeusen 26f4c7a79f fix(web/m7-352): use typographic apostrophe in failed-variant copy 2026-05-04 19:16:25 -04:00
bvandeusen b8bb1c1fa7 feat(web/m7-352): PlaylistPlaceholderCard with 4 state variants 2026-05-04 18:31:42 -04:00
bvandeusen 56f0998f84 feat(web/m7-352): Playlist type gains kind/system_variant/seed_artist_id fields 2026-05-04 18:14:46 -04:00
bvandeusen db361c8305 feat(web/m7-377): CompactTrackCard gains like + queue + kebab overlays 2026-05-04 17:49:19 -04:00
bvandeusenandClaude Opus 4.7 0dbe326d8b fix(server,web): forward-fix CI lint + vitest failures
- gofmt -s on system.go, system_cron.go, api.go
- rename unused r → _ in 3 fetcher_test.go HTTP handlers
- TrackRow +queue test uses /add .* to queue/i (track-aware aria-label from #377)
- /library/artists page test mocks likes + tanstack-query (ArtistCard now embeds LikeButton)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:39:06 -04:00
bvandeusen 93f54e00a4 fix(server,web/m7-353): forward-fix CI — Mount call + AlbumRef test fixtures 2026-05-04 17:31:27 -04:00
bvandeusen 8fff7f62d6 refactor(web/m7-377): TrackRow +queue uses Lucide Plus icon + scoped aria-label 2026-05-04 13:08:26 -04:00
bvandeusen d35ed5945a feat(web/m7-377): AlbumCard gains bottom-right kebab 2026-05-04 13:07:06 -04:00
bvandeusen 4e42fc8280 feat(web/m7-377): ArtistCard top-right cluster + bottom-right kebab 2026-05-04 13:05:11 -04:00
bvandeusenandClaude Sonnet 4.6 43a3dc47a8 feat(web/m7-377): AlbumMenu component + listAlbumTracks helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:59:38 -04:00
bvandeusenandClaude Sonnet 4.6 ce01b3d992 feat(web/m7-377): ArtistMenu component with like/queue/playlist actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:53:41 -04:00
bvandeusen 72496852a5 feat(web/m7-377): LikeButton gains size prop (sm/md/lg) 2026-05-04 11:50:31 -04:00
bvandeusen 9eeb79e99f fix(web/m7-377): update AddToPlaylistMenu tests for tracks-array prop 2026-05-04 11:48:55 -04:00
bvandeusen 7ddf77ace5 refactor(web/m7-377): AddToPlaylistMenu accepts tracks: TrackRef[] 2026-05-04 11:47:53 -04:00
bvandeusen c04b288737 feat(web/m7-365): add History entry to Library nav 2026-05-04 06:59:05 -04:00
bvandeusen da39ff847b feat(web/m7-365): HistoryRow component with click-to-play 2026-05-04 06:19:59 -04:00
bvandeusen d202319c87 fix(web/m7-364): vitest failures — exact-match query, inert prop, -0, user guard
CI test-web was blocking on:

- QueueDrawer.test close-button query: getByLabelText(/close queue/i)
  matched BOTH the backdrop button (aria-label="Close queue backdrop")
  and the close button (aria-label="Close queue"). Switched to exact
  string match.

- QueueDrawer.test inert assertion: Svelte 5 + jsdom uses property
  binding for `inert`, not attribute, so hasAttribute('inert') returns
  false. Check the DOM property (with attribute fallback for
  robustness) instead.

- queue-row-math.ts -0 normalization: Math.round(-0.5) returns -0,
  and Object.is(-0, 0) is false — vitest's .toBe(0) fails. Added
  `|| 0` to normalize -0 to +0 at the function boundary so consumers
  never see -0.

- player/store.svelte.ts user import guard: persistence $effect.root
  reads `user.value?.id` at module-init, but in test files where
  auth/store.svelte.ts is imported transitively (auth/store.test,
  playlists/playlists.test), the player module loads via auth's
  import chain before auth's `user` binding is assigned. `user?.value`
  guards against that init-order race. The proper fix is to invert
  the auth↔player import dep (filed as I2 follow-up under #375).
2026-05-03 22:37:00 -04:00
bvandeusen d43c6b31f4 chore(web/lint): clear svelte-check warnings + fix QueueDrawer test query
CI svelte-check was blocking on:

- 1 ERROR I introduced in the previous cleanup: QueueDrawer.test.ts
  passed { hidden: true } to getByLabelText, but that option only
  exists on getByRole. Fixed by switching to a direct
  document.querySelector for the aria-hidden assertion.

- 13 WARNINGS pre-existing in the codebase from M7 #352/#372/#349
  era, never surfaced because earlier CI runs failed before reaching
  type-check. Now that CI gets that far, they accumulate. Cleared:

  - FlagPopover, RemoveTrackPopover, AddToPlaylistMenu, TrackMenu:
    interactive role divs gain tabindex="-1" + onkeydown that stops
    propagation and closes on Escape (functional, not just warning-
    suppression).

  - FlagPopover state-from-props: $state(untrack(() => prop ?? default))
    for explicit initial-snapshot semantics; const isUpdate switched
    to $derived so it reacts to prop changes.

  - PlaylistTrackRow drag-div: role="listitem" added.

  - playlists/+page.svelte: autofocus replaced with bind:this + $effect.
2026-05-03 22:26:09 -04:00
bvandeusen 22e3f67411 feat(web/m7-364): drawer focus management + grip Enter/Space handling 2026-05-03 22:23:20 -04:00
bvandeusenandClaude Sonnet 4.6 d83b3eab25 fix(web/m7-364): TrackRef field shape + seek-on-restore + drawer inert
CI svelte-check failed on 5 type errors caused by the slice using
duration_ms/album_name fields that don't exist on TrackRef (the real
shape uses duration_sec + album_id/album_title). Fixed across
QueueDrawer, QueueDrawer.test, QueueTrackRow.test, persisted.test,
and the inline `state.current = null` in PlayerBar.test (TrackRef |
undefined, not | null).

Final whole-slice review concerns also rolled in:

- C1 (Critical): persisted position was restored to UI but never
  seeked into the audio element — first timeupdate snapped _position
  back to 0. Added a one-shot _pendingRestorePosition module ref +
  consumePendingRestorePosition() export; layout's loadedmetadata
  handler now seeks once on restore.

- I1 (Important): throttled-write effect now wraps _queue / _index
  reads in untrack so the dependency tracking matches the design
  intent. Position remains the only tracked dep.

- I4 (Important): drawer gets inert={!queueDrawerOpen} so its inner
  buttons drop out of tab order when closed (aria-hidden alone
  doesn't do that). Removed redundant role="complementary" from
  <aside> (implied by the element). New test asserts inert binding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 22:14:59 -04:00
bvandeusen 82846c9fcd fix(web/m7-364): align queue toggle styling with sibling toggles
- Queue toggle active class: bg-accent-tint text-accent (matches shuffle/repeat)
- ListMusic icon size: 20 → 18 (matches siblings)
- Right-side container width: w-48 → w-60 (accommodates 4 buttons + volume)
2026-05-03 21:52:39 -04:00
bvandeusenandClaude Sonnet 4.6 36805914d8 feat(web/m7-364): PlayerBar queue toggle + Up next line
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:50:14 -04:00