Replaces the hand-rolled inline-data-URL placeholder with a static SVG
asset at /placeholders/album-fallback.svg. SvelteKit's adapter-static
publishes everything in web/static/ at the URL root and the Go binary
embeds the build output, so this resolves identically in dev and prod.
Operator can swap the artwork by replacing the file — no code changes.
Test updated to use toContain rather than strict equality, since jsdom
resolves relative URLs against the test origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Disc3 fallback was rendered at fixed 32px regardless of card size,
so on a 144px (or larger) circular ArtistCard it floated in mostly-empty
space. Now sized via h-4/5 w-4/5 so it tracks the parent — ~80%
fill, much more recognisable as a record/disc. Stroke-width drops from
1.5 to 1 so the heavier line work doesn't overwhelm at the larger size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous placeholder was a single off-tilt eighth-note silhouette in
muted pewter on slate — readable but plain. Replaces it with two filled
note heads connected by a slanted beam (bolder, more recognisable as
'this is a music placeholder'). Keeps it muted enough to read as
placeholder rather than real content, in line with the FabledSword
understated palette.
Same inline SVG data URL pattern (no extra HTTP round-trip), same
100x100 viewBox so all existing call sites are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The username was rendered as plain text with no visual cue that it
opened a dropdown. Adds a 16px Lucide ChevronDown to the right of the
name (rotates 180° when open) plus the canonical aria-haspopup='menu'
and aria-expanded={menuOpen} attributes so screen readers announce the
button as a menu opener and reflect open/closed state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The username dropdown's container had onclick={(e) => e.stopPropagation()}
to keep the window-level close-on-outside-click handler from firing on
inside clicks. But that also blocked SvelteKit's document-level link-click
interceptor, so clicking Settings/Admin inside the menu fell through to
the browser's native navigation — a full-page reload.
Symptom: clicking Admin redirected to /. On the hard reload, the admin
guard (/admin/+layout.ts) ran before bootstrap() had settled the user
store; user.value was still null, so the guard threw redirect(302, '/').
By the time you saw / render, bootstrap had finished and the dropdown
showed Admin again, primed to repeat the cycle.
Replaces the stopPropagation-based outside-click protection with a
target-containment check in the window handler: close only when the click
landed outside both the menu button and the menu container. Drops the
redundant stopPropagation on the menu button too (no longer needed since
the window handler now ignores in-menu clicks).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Shell's username dropdown had no z-index, so the layout's later
grid siblings (left nav, main content) painted over it. Visible
symptom: clicking Admin actually clicked the Home link underneath,
producing a quick flash followed by a redirect to /. Adding z-50 to
the popup container makes clicks land where they look like they should.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the explicit 'Load more' button on both library list pages with
an IntersectionObserver-based sentinel that triggers fetchNextPage
automatically as the operator scrolls near the bottom (300px rootMargin).
New InfiniteScrollSentinel component:
- Stays disabled while a fetch is in flight (prevents repeat firing on
tall pages or fast scrolls).
- Tears down its observer when hasNextPage flips false.
- Defensive against environments without IntersectionObserver (jsdom);
tests provide a synchronous mock.
Also fixes a pre-existing test setup gap on the albums page test:
AlbumCard renders LikeButton which calls useQueryClient(), and the
page.test.ts wasn't wrapped in a QueryClientProvider — added the same
useQueryClient mock the AlbumCard.test.ts already uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The main nav was carrying surfaces that didn't belong on it:
- Search led to an empty page when clicked (you only ever want to land
there from the global SearchInput typing into /search?q=...). Drop it.
- Requests is downstream of Discover (you discover, then you request).
Lift both onto a shared horizontal tab strip; keep their URLs so
bookmarks survive. New DiscoverTabs component used by both pages.
- Settings + Admin are operator chrome, not primary surfaces. Move them
into the username dropdown alongside Log out, with Admin gated on
is_admin. Users see Settings + Log out; admins see Settings + Admin
+ Log out.
Final main nav: Home / Artists / Albums / Liked / Discover / Playlists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /admin layout had three nested concentric shells: the global Shell
header + main nav, then a per-admin header banner with a Sword icon, then
a left-aligned admin sub-nav (AdminSidebar) — two competing left-edge
navigation columns plus a redundant 'Admin' label that the URL and active
nav already conveyed.
Replaces the side sub-nav with a horizontal tab strip across the top of
the admin content (matches the discover-page tab pattern), drops the
duplicate header banner, and removes the disabled Users/Library
placeholder items so the tab strip only shows surfaces that actually
ship today. The component is renamed AdminSidebar -> AdminTabs to match
its new shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hidden tracks (M5b quarantine) is a low-frequency surface — it doesn't
warrant a permanent slot in the main nav. Moves the link under a new
'Library' section card on /settings alongside ListenBrainz config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures HorizontalScrollRow so paired arrows sit in a flex header
beside an optional section title (instead of absolute-positioned at the
row's left/right edges). Adds margin-right: -1rem to the row so the
scroller's items extend to the viewport's right edge, escaping Shell's
p-4 padding. The header bar keeps its right gutter so titles align with
the rest of the page content.
Home page passes 'title' to the first row of each section; subsequent
rows in multi-row sections render arrow-only headers.
Add Artists and Albums nav entries, rename root label from Library to
Home. Migrate all three ArtistRow call sites (search, search/artists,
library/liked) to ArtistCard with grid wrapper; delete ArtistRow
component and its test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements Task 18: AlphabeticalGrid + ArtistCard infinite-scroll page
backed by createArtistsQuery('alpha'), with error, empty, and loading states.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Small 140px clickable card showing album cover, title, and artist name.
Clicking calls playQueue(sectionTracks, index) to play through the full
section list. Includes Vitest tests for click and render behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reusable horizontally-scrolling row with left/right arrow buttons,
scroll-boundary disabling, snap scrolling, and hover-reveal arrows.
Used by M6a home page (Task 17).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds sort_name/cover_url to ArtistRef, sort_title to AlbumRef (matching
backend Task 5 wire shape), HomePayload type, and qk.home/albumsAlpha/
artistTracks query keys. Updates existing test fixtures to satisfy the
new required fields.
Verified clean via 'npm run check' (0 errors, only pre-existing warnings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pending/Approved/Completed/Rejected tabs (active count only). Override modal
collapsed by default; reject modal with notes textarea. Lidarr-unreachable
toast surfaces sync-Approve failures. Cancel buttons in both modals are
Pewter ghost; Reject/Confirm reject use Bronze (the design system reserves
Oxblood for irreversible actions like Disconnect).
- Trim whitespace before checking the typed-confirm value (paste-with-spaces
was silently keeping the button disabled).
- Wrap onConfirmDisconnect in try/catch and surface disconnect_failed inline
in the modal — operators on flaky networks now see why nothing happened.
- Invalidate quality-profile + root-folder caches alongside config on both
save and disconnect, so a base_url change refetches the lists immediately.
Connection management for Lidarr — base URL, API key, default quality
profile, default root folder. Empty api_key on save preserves the
masked-saved key per backend semantics. Disconnect requires typed
"DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides
section for future integrations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the :global scoped-style workaround in AdminSidebar with a proper
accent.tint color tier in tailwind.config.js. Tailwind generates bg-accent-tint
deterministically; the global class no longer leaks from a single component.
Future consumers (e.g. /admin/requests tab counts) get the same utility.
Hard route gate in admin/+layout.ts redirects non-admins to / before
the layout (or any child) renders. AdminSidebar reads the active route
from $app/state and applies a 12% accent-tinted bg + 2px forest-teal
left strip on the active item. Overview landing shows pending-request
count and Lidarr connected/unset, each linking to its sub-page. Shell
nav exposes the Admin link only to is_admin users.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Album- and track-kind requests can have null album_title/track_title (the
type allows it). Previously aria-label rendered 'Cancel request for —' which
is meaningless to a screen reader. Fall back to 'this album by <artist>' /
'this track by <artist>' so the cancel/listen buttons stay disambiguable.
Renders the caller's Lidarr requests as rows with kind pill,
StatusPill, and per-status actions: Cancel on pending (which
calls cancelRequest then invalidates qk.myRequests()), Listen
link on completed (deepest match wins: track > album > artist),
admin notes on rejected. Empty state uses the voice-rule
"Nothing requested yet." copy. Shell nav gains /requests
between /discover and /playlists, visible to all authed users
since the view is per-user.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Local-input + 250ms debounce drives Lidarr search; tab switch refetches
with new kind. Track-kind Request opens a confirm modal explaining the
album that will be added; Confirm fires createRequest, Cancel is a no-op.
Successful requests flip the card to 'requested' optimistically.
Shell nav now exposes /discover between Search and Playlists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Single-prop component mapping LidarrRequestStatus to voice-rule labels and
semantic tones (warning/info/success/error). Used by /requests and /admin/requests
in subsequent tasks.
aria-labels now include the card title on all three button variants so
SR users navigating a grid can tell which card a button belongs to:
- "Request <title>" / "<title> is already in library" / "<title>
already requested"
The Kept pill gets role="status" so SR users hear the badge when it
appears (without aria-live's announce-on-mount noise).
The reserved-slot CSS (.badge-row { min-height: 22px } and
.actions { margin-top: auto }) was already in the scoped <style>
block; we drop the duplicate inline style="" attributes that existed
purely to satisfy jsdom's getComputedStyle. Tried stylesheet
introspection (document.styleSheets) as a replacement assertion, but
vitest's @testing-library/svelte renderer doesn't inject the scoped
<style> tag into jsdom (styleSheets.length === 0), so the two CSS
assertions are dropped with an explanatory comment. The layout
discipline is enforced visually at the consumer page rather than as
a "CSS exists in CSS" unit test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed
requestable / kept / requested cards from Lidarr search. Without
layout discipline the cards in a row land their titles at different
Y coordinates whenever the badge presence varies, which reads as
visual noise. DiscoverResultCard enforces:
- Flex column with `margin-top: auto` on `.actions`, so the action
button is anchored to the bottom of the card body regardless of
title/subtitle wrap differences.
- `.badge-row` always rendered with `min-height: 22px`, so the
title baseline holds even when no Kept pill is present.
Both load-bearing CSS values use inline style attributes — jsdom's
getComputedStyle does not resolve scoped <style> blocks, so the tests
verify positioning via inline styles directly. The remaining decorative
CSS (kept-pill background = accent at 15%, flex-direction, gap) lives
in a scoped <style> block.
State -> action mapping (sentence case per FabledSword voice):
requestable -> bg-action-primary "Request" with Plus icon
kept -> disabled ghost "In library" + Kept pill
requested -> disabled ghost "Requested"
Cover art falls back to a Lucide glyph (Disc3 / Album / Music2) when
imageUrl is absent. Adds lucide-svelte@^1.0.1 dependency.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- qk.adminRequests defaults to status='all' when no filter is set; the unfiltered
endpoint returns every status, so caching it as 'pending' was a latent mismatch.
- 60s staleTime on lidarrConfig + myRequests — both are session-stable enough
that refetching on every consumer-page mount produced needless flicker.
T13 of the M5a Lidarr plan. Three new client modules wrap api.get/post/put/del
helpers and the existing TanStack Query qk namespace:
lidarr.ts - searchLidarr() + createLidarrSearchQuery()
requests.ts - createRequest, listMyRequests, getRequest, cancelRequest;
createMyRequestsQuery(); cancel goes through apiFetch
directly because the backend returns the cancelled row body
(api.del's return type is fixed to null).
admin.ts - getLidarrConfig, putLidarrConfig, testLidarrConnection,
listQualityProfiles, listRootFolders, listAdminRequests,
approveRequest, rejectRequest; query factories for each
read; quality profiles + root folders take an enabled prop
so the call site decides when Lidarr is configured.
Shared LidarrRequestStatus / LidarrRequestKind enums and request/config/
search-result shapes added to types.ts. Per-module helpers (CreateRequestParams)
stay in their module files. testLidarrConnection returns a discriminated union
({ok:true,version} | {ok:false,error}) and never throws on ok:false so the
SPA can render either branch.
qk extended with lidarrSearch, myRequests, lidarrConfig,
lidarrQualityProfiles, lidarrRootFolders, adminRequests.
Tests mirror likes.test.ts (vi.mock('./client')) and cover URL construction,
query-param encoding, body shapes, the not-ok testLidarrConnection branch,
and qk additions. 32 new tests, 217 total passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes duplication between fabledsword-tokens.css and tailwind.config.js;
font stacks now live in one place so per-app overrides reach Tailwind utilities.
Add the canonical FS token palette (surfaces, text, action, semantic,
accent, radii, fonts) as CSS custom properties under web/src/lib/styles/
fabledsword-tokens.css, with a [data-fs-app="minstrel"] hook reserved
for future per-app accent overrides. Wire the tokens through Tailwind by
aliasing semantic colour utilities (bg-background, bg-surface,
bg-surface-hover, text-text-primary/secondary/muted, border-border,
bg-action-primary/secondary/destructive, accent, warning/error/info) to
the new variables, plus rounded-sm..xl and font-display/sans/mono. Load
Fraunces, Inter, and JetBrains Mono (400/500 only) via Google Fonts and
default the body to Inter. Existing components inherit the new palette
without per-page changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds _radioSeedId state and a module-level $effect.root that triggers a
/api/radio?seed_track=…&exclude=… fetch when queue consumption reaches
80%, appending new tracks without resetting the radio seed. Clears seed
on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five
new vitest cases cover the trigger threshold, append logic, in-flight
guard, and manual-enqueue reset path.
Adds api.put helper, ListenBrainz API module with query/mutation
helpers, a /settings route with token + enable/disable UI, and 5
tests. Adds Settings to the Shell nav.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>