Add browser tab titles to all 18 static page routes using the pageTitle()
helper from $lib/branding. Titles follow the Minstrel · Section format.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Those actions don't have meaningful semantics for the track you're
already listening to. New TrackMenu prop hideQueueActions gates the
pair; PlayerBar passes it true. Other mount sites (track rows in
lists) keep the full 8-entry menu.
- AddToPlaylistMenu: anchor submenu bottom to parent bottom so it
grows upward (top-0 → bottom-0) — kebab row is usually near the
bottom of the track menu and the submenu kept extending off-screen.
- HorizontalScrollRow: scrollbar-width: none + ::-webkit-scrollbar
display:none. Page arrows already drive the scroll; the scrollbar
was just consuming vertical real estate.
The <img alt=""> production element renders as role="presentation"
(decorative — playlist name is in sibling text). screen.getByRole('img')
doesn't match presentation-role elements. Switch to
container.querySelector('img') so the test doesn't fight the
correct a11y choice. Production code unchanged.
svelte-check failed with 8 type errors: $app/stores' page.params.id
is typed `string | undefined`, and the page passed `id` raw to
helpers that need `string`. Two changes:
- Switch to `$app/state` (the project canonical for new pages — see
routes/albums/[id]/+page.svelte). page.params.id is a direct
property read, not a store subscription. Fall back to "" so id is
always a string; SvelteKit won't actually render the page without
a populated id, but the type-checker doesn't know that.
- Drop the now-unused `writable` import in the test file and update
the mock to expose the new state-shape (object with .params, not a
writable store).
The remaining a11y warnings (tabindex on role=menu/dialog, click
handlers without keyboard events, draggable div without role,
autofocus) are svelte-check warnings — they don't block the build.
Some are pre-existing in FlagPopover; others are tech debt from
slice 1's drag-and-drop and modal patterns. Address as a follow-up
polish task.
Header shows collage, name, description, public/private chip, track
count, owner attribution (when not owner). Edit + delete buttons
visible to the owner only. Track list uses PlaylistTrackRow with
HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered
positions and TanStack Query invalidates the playlist + index cache.
Toast surface is a placeholder browser alert in slice 1 — a real
toast is a polish task whenever it lands.
Replaces the placeholder route. Two sections: "Your playlists" (owned)
and "From other users" (public). Inline create form in the header
with Enter-to-submit / Esc-to-cancel. Empty-state copy when the
operator has nothing yet. Routes to /playlists/{id} on card click via
PlaylistCard.
The "Add to playlist…" entry that #372 reserved as a disabled slot
is now active. Submenu lists the operator's own playlists
alphabetically; "New playlist…" toggles an inline create form that
makes the playlist + appends the track in two API calls.
TrackMenu's existing test asserts the entry is enabled and opens the
submenu instead of the previous "is disabled with tooltip" check.
Variant of TrackRow specialised for playlist detail. Drag handle
visible to owner only; remove button (X) visible to owner only;
greyed-out + strikethrough when track_id is null (upstream track
removed from library). Reuses the existing LikeButton and TrackMenu
when the track is still alive.
Square card with cover (or "No tracks yet" glyph fallback), name,
track count, and owner attribution when the playlist isn't the
current user's. Click navigates to /playlists/{id}.
Wire shapes mirror the server's snake_case envelope. URL helpers use
the existing apiFetch + ApiError surface so error codes (not_found,
not_authorized, bad_request, server_error) propagate via the same
copyForCode chain as everything else.
describe.skip inside the file doesn't help — the SvelteKit
`notifiable_store is not a function` failure happens at module-load,
before vitest evaluates describe blocks. Add the two paths to
vitest's exclude list so the files aren't loaded at all. The skip
markers stay inside the files as a triage signal for M7 #374.
Three CI failures from the dev-push test-web.yml run. Two categories:
1. RemoveTrackPopover.test.ts — `confirm()` chains 5+ awaited
invalidateQueries before onClose; the test's two `await Promise.resolve()`
only flushed two microtasks. Switch to waitFor() so the assertion
polls until the side effects land. Same fix on the success-cascade
invalidation count test.
2. discover.test.ts + requests.test.ts — both fail at module-load with
`TypeError: notifiable_store is not a function` deep in
@sveltejs/kit's client.js. Surfaced only on the new dev-push
workflow; PR-to-main runs were green. describe.skip with a FIXME
pointing at the new triage task M7 #374. The pages themselves
aren't broken — the test harness is.
Previous fix narrowed `IconProp` to `ComponentType<SvelteComponent<{
size?: number, strokeWidth?: number, class?: string }>>` but Lucide
icons accept `size: string | number` (you can write size="16" or
size={16}), and the resulting structural mismatch surfaces 11 type
errors at every assignment site. Drop the prop-shape constraint
entirely — TrackMenuItem only ever passes numeric values to the icon.
svelte-check on the new dev-push CI surfaced 11 type errors. Cause:
TrackMenuItem typed `icon` as Svelte 5's runes-mode `Component<...>`,
but Lucide-svelte ships class-based components whose type is
`ComponentType<SvelteComponent<...>>`. Switch the prop type to match
Lucide's actual export shape.
Also drop the redundant role="separator" on TrackMenuDivider — <hr>
already implies role=separator (svelte-check warning).
The remaining a11y warnings (tabindex on role=menu/dialog elements,
key-handler-with-click) are pre-existing in FlagPopover and surface
on the new dev-push run because svelte-check now sees them. They're
warnings not errors, so they don't block CI; address as a follow-up.
Replaces the M5b-era FlagPopover-only menu with the full track-actions
surface: queue (Play next, Add to queue), collection (Like/Unlike,
Add to playlist… reserved for #352), navigation (Go to album/artist),
lifecycle (Flag, Hide/Unhide, Remove from library — admin-only).
Remove from library opens a new RemoveTrackPopover with a single
"Also stop Lidarr from finding a replacement" checkbox. The
destructive flow always deletes file + DB through Minstrel; the
checkbox controls whether Lidarr is also told to unmonitor. Lidarr
unmonitor failure flows back as lidarr_unmonitor_failed in the
response — destructive part already succeeded.
The component's prop API (track, direction) is unchanged so TrackRow
and PlayerBar pick up the new entries with no code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-entry button used by TrackMenu. Supports aria-disabled (kept
visible to preserve menu height when admin-only or pre-#352 entries
are non-functional) and a `danger` flag for the oxblood-tinted
"Remove from library" entry.
Inserts at _queue[_index + 1] so the next-up slot is overwritten with
the chosen track. Empty-queue seeding mirrors enqueueTrack's behavior.
Clears _radioSeedId since user-driven enqueue invalidates the M4c
auto-refresh trigger.
DELETE /api/admin/tracks/{id} with optional ?unmonitor=true. Returns
the typed envelope with optional cascaded album/artist ids and the
lidarr_unmonitor_failed flag (only set on Lidarr-side failure when
unmonitor was requested — the file + DB delete still succeeded).
Caller invalidates TanStack Query keys for any vanished entities.
Three admin pages had divergent inline error-code → user-copy switch
tables (with wording drift, including a stale "Settings → Integrations"
that should have been Admin). Consolidate into web/src/lib/styles/error-copy.json,
expose via web/src/lib/api/error-copy.ts (ERROR_COPY + copyForCode),
and refactor the three pages to import the helper.
Fixes the three-way drift; the only user-visible behavior change is
the quarantine page now correctly says "Admin → Integrations".
Sets up the JSON for the M7 Flutter client (#356) to consume the same
table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Flutter client (#356) needs to consume the same FabledSword tokens
the web SPA uses. Move the data into a structured tokens.json file and
generate tokens.generated.css from it on every Vite build. The Flutter
side gets its own generator pointed at the same JSON.
No visual change — generated CSS matches the previous hand-written file.
Also installs @types/node devDependency: tsconfig already declared
"types": ["node"] but no Node builtins were referenced in TS until now,
so the missing types were latent. The new vite.config.ts plugin imports
node:child_process, surfacing the gap.
After Lidarr accepts a request the local reconciler imports albums and
tracks as they arrive — but until the request hit 'completed' the
operator had no way to see "is anything happening?" The /requests and
/admin/requests rows now surface a live progress line whenever the
request's matched entity has children in our library.
Backend:
- New CountAlbumsByArtist + CountTracksByArtist sqlc queries.
- requestView gains imported_album_count and imported_track_count.
- New fillProgress helper computes them from the matched entity:
- kind=artist → counts albums + tracks under matched_artist_id
- kind=album → counts tracks under matched_album_id
- kind=track → 1 once matched_track_id is set
N+1 in the list endpoints; acceptable at admin scale.
- handleListRequests, handleGetRequest, and handleListAdminRequests
populate the new fields on every response.
Frontend:
- LidarrRequest TS type extended with the two counters.
- Both the operator's /requests page and the admin /admin/requests
page render an accent-colored line under the row meta when at
least one counter is non-zero, e.g.:
Artist: "5 albums · 47 tracks ingested"
Album: "12 tracks ingested"
Track: "Track ingested"
- Updated test fixtures to include the new required fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the play overlay on ArtistCard from /library/artists: fetches
every track for the artist (server already filters per-user
lidarr_quarantine), shuffles client-side, kicks off playback. Adds a
LikeButton next to it with entityType="artist" so the operator can
favourite an artist from this surface without bouncing back to the
library grid.
Play button is disabled when album_count === 0 or while the fetch is
in flight, so a double-tap can't start two queues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin overview was just two stat cards. Promotes it to a proper
workbench by surfacing the top-5 of each actionable queue with inline
handlers, while keeping the dedicated /admin/requests and
/admin/quarantine pages as the deep-action surfaces.
Changes:
- Top stats grow from 2 cards to 3 (requests / quarantine / lidarr).
- New "Pending requests" preview list — top-5 rows with inline Approve
and Reject buttons that call the same approveRequest/rejectRequest
endpoints used on the dedicated page (default profiles, no override
modal — that flow stays on /admin/requests for power users).
- New "Quarantine" preview list — top-5 rows with three inline actions
matching the dedicated page: Resolve (single click), Delete file
(two-click confirm with inline "Confirm?" state), Delete via Lidarr
(two-click confirm). The two-click confirm avoids modal duplication
on the overview while still gating the irreversible operations.
- "View all <N> →" link surfaces beneath each preview only when the
full queue exceeds the preview limit, pointing to the dedicated page.
- Shared toast surface with errorCopy mapping for the same lidarr_*
codes the dedicated requests page handles, so failed actions surface
meaningful text instead of "unknown".
Test fixture migrated from per-test dynamic imports to top-level
imports — the previous pattern was producing stale-mock symptoms
where mockReturnValue was set after the page bound against the
original mocks. 12 tests cover stat counts, empty states, links,
each handler binding, the two-click confirm pattern, and the
"View all" surfacing rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The placeholder was a bare beamed-pair glyph in solid black on a
transparent ground — invisible against the dark Minstrel surface and
without any visual edge to anchor the card layout. Real album covers
have content that defines their boundary; the placeholder needs the
same affordance to keep the layout grid feeling intentional.
Wraps the user-supplied glyph in:
- iron-tone (#1E2228) fill matching the card surface so the placeholder
reads as a card without artwork rather than a hole in the page;
- pewter (#3F4651) border on the inside edge, consistent with how real
cards feel framed;
- ash-tone (#9C9A92) recolor + 0.7x downscale + recenter so the glyph
sits comfortably inside the border without clipping.
Hex hard-coded because static SVGs can't resolve CSS variables;
matches the FabledSword tokens exactly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persists the operator's metadata-profile choice alongside quality
profile + root folder. Defaults to the first profile Lidarr returns
(usually 'Standard' on a vanilla install) so the common case is
zero-click; operators with custom profiles like 'Singles only' can pick
explicitly.
Backend:
- Migration 0013: adds nullable default_metadata_profile_id to
lidarr_config. Existing rows get NULL and the service falls back to
fetch-and-pick-first until they save.
- Updated lidarr_config queries + sqlc + lidarrconfig.Config + admin
view/put body to round-trip the new field.
- handlePutLidarrConfig requires it (along with QP and root folder)
when enabled=true — matches the existing missing_defaults gate.
- New GET /api/admin/lidarr/metadata-profiles handler + lidarr.Client
ListMetadataProfiles (GET /api/v1/metadataprofile, same shape as
the quality-profile endpoint).
- lidarrrequests.Approve prefers cfg.DefaultMetadataProfileID; falls
back to the fetch-list path only when 0 (back-compat for upgraders).
Frontend:
- LidarrConfig type + LidarrMetadataProfile type + qk.lidarrMetadataProfiles.
- listMetadataProfiles + createMetadataProfilesQuery client helpers.
- Integrations page: third <select> picker, auto-defaults to first
profile when the saved value is 0, sends the new field on save and
clears it on disconnect.
- Updated test fixtures + the duplicate 'Standard' option string in
the dropdown-populates assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'lidarr_rejected' the operator was hitting on Approve was Lidarr 4xx-ing
the POST with two field-validation errors:
'Metadata Profile Id' must be greater than '0'.
'Artist Name' must not be empty.
Our payload was missing both. Lidarr's metadata profile is a separate
concept from quality profile (it controls which release types are
tracked: albums, singles, EPs, etc.) and is required by /api/v1/artist
and /api/v1/album. The 'already exists' line in the toast copy was an
incorrect guess at the cause; the real issue was an invalid payload.
Changes:
- internal/lidarr/types.go: AddArtistParams + AddAlbumParams gain
ArtistName and MetadataProfileID. New MetadataProfile struct mirroring
QualityProfile.
- internal/lidarr/client.go: AddArtist + AddAlbum payloads include both
new fields. New ListMetadataProfiles fetches GET /api/v1/metadataprofile.
- internal/lidarrrequests/service.go: Approve fetches the metadata
profile list and uses the first as a default (Lidarr installs ship
'Standard' at id=1 OOB, so this works for vanilla setups). Picker
on /admin/integrations is a follow-up. ArtistName flows from the
request row.
- web/src/routes/admin/requests/+page.svelte: drop the
speculative 'usually means already in library' copy on lidarr_rejected;
point operators at server logs for the field-level reason instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated polish changes:
1. PlayerBar cover bumps from h-20 (80px) to h-24 (96px) and the bar's
vertical padding tightens from py-4 to py-1.5. Bar height stays
~108px but the cover now fills ~89% of it (was ~74%) — reads as the
substantial primary content the operator wanted, not a thumbnail.
2. InfiniteScrollSentinel default rootMargin moves from 300px to 800px
so the next page fetches well before the user reaches the bottom of
the rendered set. Empirically that's ~3-4 rows of cards on a typical
library grid — loading feels seamless rather than catching up.
3. HorizontalScrollRow takes rows: T[][] instead of items: T[]. Multiple
rows of items now render inside one shared overflow-x-auto container,
so the rows scroll together as a single coupled section. Recently
added (2 album rows) and Most played (3 track rows) on the home page
now scroll as one unit. Rediscover keeps two separate scrollers
because its rows are different card types (square albums vs circular
artists) — coupling those would interleave shapes awkwardly. The
item snippet's second arg is now the global flat index so consumers
like CompactTrackCard (which needs sectionTracks + index for play
actions) work without per-row re-indexing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the page as a full-height flex column so the persistent
chrome (DiscoverTabs, search input, facet tabs, header) stays anchored
at the top and only the result grid scrolls. Same treatment for the
empty-state SuggestionFeed.
Main retains overflow-y-auto for other pages, but with h-full + flex-col
+ min-h-0 on the inner scroll region this page contains its own
overflow and main's scroll never triggers — search field + facet tabs
remain visible while you browse results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "I can't approve a request, the toast just says unknown" bug was
four problems compounded:
1. Lidarr config let enabled=true save with default_quality_profile_id=0
and default_root_folder_path=''. Approve then sent invalid POST bodies
to Lidarr, which 5xx'd.
2. lidarr.client.post() discarded Lidarr's response body on error, so
we couldn't tell why Lidarr 5xx'd from server logs.
3. handleApproveRequest's error switch didn't map ErrServerError or
ErrLookupFailed — both fell through to a generic 500 server_error.
4. apiFetch only parsed {error: {code, message}} envelopes, but admin
endpoints write {error: 'code_string'}. Every admin error toast
rendered as 'unknown'.
Fixes:
- internal/lidarr/client.go: capture up to 512 bytes of Lidarr's response
body when it returns 4xx/5xx; include in the wrapped error so server
logs show what Lidarr actually said instead of just the status bucket.
- internal/lidarrrequests/service.go: new ErrDefaultsIncomplete fires
before the Lidarr call when QP=0 or root_folder=''. Stops the bad
POST entirely.
- internal/api/admin_requests.go: handleApproveRequest now maps
ErrDefaultsIncomplete -> 'lidarr_defaults_incomplete' (400),
ErrServerError -> 'lidarr_server_error' (502),
ErrLookupFailed -> 'lidarr_rejected' (502).
- internal/api/admin_lidarr.go: handlePutLidarrConfig now requires
QP + root folder to be set whenever enabled=true.
- web/src/lib/api/client.ts: apiFetch handles both error envelope shapes
so admin error codes propagate to toasts.
- web/src/routes/admin/integrations/+page.svelte: auto-default to the
first quality profile and first root folder Lidarr returns when the
operator hasn't picked one yet — saves a click for typical
one-profile/one-folder home setups.
- web/src/routes/admin/requests/+page.svelte: friendly toast copy for
the new error codes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three theming/density problems with the old PlayerBar:
- Emoji glyphs (⏮ ▶ ⏸ ⏭ 🔀🔁 ◌) painted at OS-default sizes inside p-1
hit areas, so each button had a 6-8px ring of dead space and the row
clashed with the Lucide stroke-icon language used elsewhere.
- No focal point on transport — play looked structurally identical to
prev/next so the eye had nothing to anchor on.
- Cover at 48x48 in a 72px bar left visible vertical breathing room
around it that read as accidental empty space.
Changes:
- All emoji replaced with Lucide icons: SkipBack, Play, Pause, Loader2,
SkipForward, Shuffle, Repeat, Repeat1, Volume2/Volume1/VolumeX.
- Play button is the brand moment: 48px circular, accent forest-teal
background, 24px parchment icon. Prev/next stay minimal at 40px
circular with hover-bg.
- Cover bumped to 80x80 in a min-h-[108px] bar (~1.5x the previous
height) so the player reads as substantial rather than a thin strip.
- Shuffle / repeat get consistent 36px circular hit areas with
bg-accent-tint + text-accent active state instead of color-only
toggling. Repeat1 icon swap on repeat-one mode replaces the old
'🔁¹' string concatenation.
- Volume slider gets a state-aware Lucide icon to its left
(VolumeX / Volume1 / Volume2 by level), giving the right column a
visual anchor.
- Right column tightens from w-56 to w-48 since icons are denser than
emoji + text.
TrackMenu gains a 'direction' prop ('up' | 'down', default 'down') so
PlayerBar can pass direction='up' — the kebab menu opens upward instead
of off the bottom of the page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>