- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
album and playlist. The playlist page gains Play and Shuffle; it had
neither. The cover uses the shared Cover component, so the playlist
header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
The artist page's round icon button said Play but shuffled; Play now
keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
converter, in place of its own copy. Its copy skipped only removed
tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
not a colour in the theme, so it never rendered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 8789 warned on both:
- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
`#test-utils/*` subpath import, matching #lib, and its 43 import sites
move with it.
- Vite's coming native config loader needs the extension on
vitest.config.ts's import of vite.config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:
- svelte.config.js is gone. Its options move into sveltekit() in
vite.config.ts, exported as kitOptions so vitest.config.ts runs the
same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
^8.0.12, svelte-check ^4.7.5.
By hand, from the codemod's list of non-automated tasks:
- goto's replaceState option is now replace; keepFocus becomes
reset: false. For the search typeahead, reset: false also stops the
scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
base/assets, which kit 3 removed, and mock refreshAll in place of
invalidateAll.
- The other flagged files only read page.url or goto internal routes,
so they needed no change.
TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.
With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI caught a real bug, not just a brittle test. The page rendered an
attempt four hours away as "in 3h", because timeUntil floored the way
relativeTime does.
Flooring an elapsed time is honest: "3h ago" means at least three hours
have passed. Flooring a countdown is not -- 3h59m away became "in 3h",
so the operator comes back an hour early and finds nothing has happened.
It rounds now, with the boundary cases pinned: sub-minute is "any
moment", 59.6m is "in 1h", 24h is "in 1d".
That divergence is now the fourth documented difference between the two
formatters, all deliberate, all in snippet #2699 with a test asserting
they disagree so nobody unifies them later.
The test assertions were also genuinely wrong: they read raw textContent
from a template that wraps mid-sentence, so "last 2d ago" arrived as
"last\n 2d ago". Added a whitespace-normalising helper —
asserting on raw textContent makes a test fail when the markup reflows,
which says nothing about the behaviour.
Completes milestone #290. The sweeper has been running and the settings
have been editable, but the list itself said nothing about either, so
the only way to tell "not tried yet" from "asked twice and nothing came
back" was to go and read the Requests queue.
Each folder now carries its album's attempt record: how many times, when
last, when next -- or that it gave up, with the reassurance that a file
coming back and going missing later starts the process over. Null when
nothing has been attempted, which is the common case for a folder that
just went missing and would be noise on every row.
next_attempt_at is computed, not stored. The schedule is a function of
the attempt count and the current settings, so persisting it would go
stale the moment an operator edited the backoff -- and the card lets
them do exactly that.
Needed a forward-looking formatter. relativeTime deliberately collapses
a future timestamp to "just now" (pinned by its own test) because that
is the right answer for a clock-skewed past event; it is the wrong one
for a scheduled future attempt, which would have rendered "next just
now". timeUntil is its companion rather than a sign-aware rewrite: the
two read differently in the same sentence -- "last tried 3d ago, next in
4h" -- and a test asserts they disagree about the future on purpose, so
nobody later "fixes" the divergence.
The state lookup is one batched query for the whole page and best-effort:
this is context on a list whose real job is showing what is missing, so
a failure leaves the groups bare rather than failing the page. The
settings service is read with a nil guard falling back to the shipped
defaults, since contexts that wire routing without services exist and a
backoff projection is not worth a nil-pointer panic (rule #48).
Admin quarantine, admin playback-errors and library/hidden each carried
a byte-identical private copy of the same coarse "3d ago / 5h ago /
12m ago / just now" formatter. Writing the missing-files surface would
have made it four, so extract it instead.
They are one concept, not three that happen to look alike: each shows
the age of something an operator is deciding about, and they have to
agree -- a row reading "2d ago" on one screen and "2 days" on another
makes the reader wonder whether the two mean different things.
Three near neighbours are deliberately NOT folded in, because they are
different intents rather than drifted copies:
- HistoryRow shows a weekday and clock time under a week ("Tue 21:40"):
for listening history, WHEN you played something beats how long ago.
- ActiveSessions.when() writes prose ("1 hour ago", "yesterday") and
falls back to a locale date past 30 days -- a security surface where
the longer form reads better.
- PlaylistCard.refreshedLabel() is day-boundary aware and prefixed
("Refreshed today"), and already carries a comment saying it is
deliberately not the m/h-ago style.
Merging any of those would mean forcing one caller's wording onto
another, which is the wrong-abstraction failure, so they stay put.
Tests pin the boundaries the copies never covered: each unit step, that
only the largest whole unit is reported (25h is "1d ago", never
"1d 1h ago"), and that a future timestamp from a skewed client clock
degrades to "just now" instead of rendering a negative age.
Replaces inline TrackRef literal redefinitions with calls to the
test-utils helper. Tests that asserted on default field values
(e.g. track titles, artist names rendered in the DOM) keep explicit
overrides; tests that only need a stub for shape now use makeTrack()
with no overrides.
PlaylistCard.test.ts, PlaylistTrackRow.test.ts, and playlist.test.ts
SKIPPED — they use PlaylistTrack (with track_id/added_at), not TrackRef.
ArtistCard.svelte and +page.svelte route files (matched by initial grep)
SKIPPED — live code, not test files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The synchronous pre-timeout in the implementation was a band-aid for a
missing flushSync() in the third test. $effect callbacks are scheduled
as microtasks and don't run until flushed — the test set source=true
before the initial effect had a chance to register the setTimeout, so
advanceTimersByTime fired nothing.
Proper fix: call flushSync() after $effect.root so the initial effect
runs synchronously, then advance fake timers. Removed the duplicate
setTimeout branch from useDelayed — one code path, no race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Boolean mirror that flips true only after the source stays true for
delayMs. Used to hide the skeleton on sub-100ms cache hits so
back-navigation feels instant.