13 KiB
Musify: web-player
Musify's public web app (web-player/ folder). Design and UI recipes live in this file and in src/routes/layout.css.
Stack
SvelteKit 2 (adapter-node), Svelte 5 runes, Tailwind 4, TypeScript strict, Zitadel OIDC auth (openid-client + jose).
There's no svelte.config.js (config is inline in vite.config.ts) and no tailwind.config.js (config lives in src/routes/layout.css).
Commands
npm run dev # dev server (port 5173)
npm run build
npm run check # svelte-check, must exit 0 errors before closing a change
npm run format # prettier --write .
npm run test # vitest run (tests live next to the file they test)
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
Rules
- No comments in code unless essential.
npm run lintandnpm run checkmust exit clean before closing a change.- Imports: always
$lib/..., never../(ESLint enforces it). Non-null assertions are forbidden too. - Always Svelte 5 runes:
$props()withinterface Props,$state,$derived,$effect. Noexport letor$:.{#each}always keyed. - Server only code goes under
$lib/server/, and it's never imported from client code. - SSR data comes through
loadin+page.server.ts/+layout.server.tsand lands in thedataprop. - Links to
+server.tsendpoints (/auth/login,/auth/logout) carrydata-sveltekit-reload. - Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to
$lib/components/ui/.
Folder structure
src/lib/types.tsholds the domain types (Track,Album,Playlist,Mix,ListeningStats,Paged<T>). Pages and the player only ever see these, never raw API DTOs.src/lib/player/is the player:player.svelte.ts(state and orchestration),queue.ts(pure queue operations),stream.ts,mediaSession.ts,progressClock.ts,listenTracker.ts,recentlyPlayed.ts(mergesplayer.recentlyPlayedwith the server history), andactions.ts(playAllOrToggle,playShuffled,isQueueCurrent).src/lib/state/holds reactive rune state:panels.svelte.ts(queue panel, create playlist modal),history.svelte.ts(previous page forBackLink),menu.svelte.ts(createMenu<T>()),pagedList.svelte.ts(createPagedList),dialog.svelte.ts,liked.svelte.ts(the user's liked tracks),toggle.svelte.ts(createToggle), andscroll.ts(restoreScroll). Nothing instate/imports fromcomponents/.src/lib/theme/holds the theme:accent.svelte.ts(accent hue per track and the hue fade),mode.svelte.ts,palette.ts,tokens.ts, andcolor.ts(ACCENT_HUE). The player never imports fromtheme/:(app)/+layout.sveltefollowsplayer.currentIdand callsaccent.follow.src/lib/data/holds client side domain helpers: albums, genres, search. It never imports fromplayer/.src/lib/utils/holds generic utilities:format.ts,collections.ts,hrefs.ts(search, genre and cover URLs),menuPosition.ts,storage.ts,transitions.ts.src/lib/actions/holds Svelte actions:clickOutside,onEscape,pressable.src/lib/validation.tsholds the form limits (LIMITS) shared by client forms and server parsers.src/lib/server/is server only code (see below).src/lib/components/shell/holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, andnavLinks.ts.src/lib/components/player/holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.src/lib/components/music/holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu, GenrePicker, TrackStatusBadge. Thelibrary/subfolder holds the manage-music sections (tracks, albums editor, upload queue, coauthors dialog, invitations inbox).src/lib/components/ui/holds the design system, which is presentation only. ESLint forbids importing$lib/player/*,$lib/data/*,$lib/server/*,components/music,components/shellandcomponents/playerfrom it; it can use$lib/state/*,$lib/utils/*,$lib/actions/*,$lib/theme/*and$lib/types. It is split into five categories:primitives/for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).overlay/for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).media/for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).forms/for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone, TagInput).layout/for page level scaffolding (Page, PageHeader, SectionHeading).
- The root
+layout.svelteonly holds the globals (fonts, theme,DialogHost). Everything the user sees after signing in lives undersrc/routes/(app)/, including Home ((app)/+page.svelte);(app)/+layout.server.tsguards the group and loads the shell data, and(app)/+error.svelterenders inside the shell. Unknown URLs hit the catch-all(app)/[...rest]/(it throws a 404) so they also get the shell (unknown/api/...URLs hitapi/[...rest]/, a JSON 404); the root+error.svelteonly covers errors outside the group or in the group layout load. Auth routes live undersrc/routes/auth/:+page.svelteis the login screen, andlogin/,logout/,callback/are the OIDC endpoints. The public user profile route issrc/routes/(app)/user/[id]/, and its followers/following lists share one route,user/[id]/[list=followList]/(matcher insrc/params/followList.ts).
Naming conventions and glossary
- Variant maps are module level constants in upper case (
SIZE,VARIANT). - Errors: destructure as
const { data, error } = ...and name the caught valueerrincatch (err). - Play handlers:
playFrom(index)andplayAll(). Form actions:create,edit,delete,addTrack,removeTrack. - UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through
fmtDurationLong.
Styles
The design system lives in src/routes/layout.css (@theme tokens plus @utility recipes) and in src/lib/theme/ (theme.css for the static neutrals, tokens.ts for the dynamic tokens that depend on the user's hue). scripts/check-tokens.mjs (run as npm run lint:tokens, part of lint) fails on a stock radius, an h-N w-N pair, fractional quarter spacing, a literal hex/rgba(), or an arbitrary -[…] value outside its whitelist, so check that script before adding one.
- Radii: always
rounded-tag/thumb/control/art/art-lg/panel/panel-lg(orrounded-fullfor pills and circles). Neverrounded-sm/md/lg/xl/2xl/3xlor a barerounded. - Square sizes: use
size-*instead of repeatingh-N w-N(icons:size-icon-xs/sm/md/lg/xl/2xl/3xl; covers:size-cover-xsthroughsize-cover-hero). - Typography: use the recipes
text-display-1/2/3/4,text-eyebrow,text-body, with tracking throughtracking-display/tracking-eyebrowrather than a loosetracking-[…]. - Spacing: stick to the Tailwind scale in
.5steps and avoid quarters like.25/.75, unless the semantic token calls for it (for examplemb-4.5inSectionHeading). - Colors: always use
--mf-*/text-*/bg-*/border-*fromtheme.css/tokens.ts. Never a literal hex orrgba()inside a.sveltefile; those literals only belong intheme.css/tokens.tsas the source of truth. - Inline
style="…"and arbitrary-[…]values: only the ones whitelisted inscripts/check-tokens.mjs(the stagger variable--i, tile hue, menu position, progress width,text-[clamp(…)],leading-[…]on login's display type,grid-cols-[auto_1fr],max-h-[85dvh],my-/py-[Ndvh]for vertical centering,transition-[filter]on genre tiles, andbg-[image:var(--mf-*)]). Everything else should become a token. - Reach for these base components before creating a new one:
Artwork,Avatar,ListRow,MediaIdentity,TrackList,MediaCard,PageHeader,ContextMenu,ConfirmDialog,PlayButton,Slider,Logo,Chip,ChipGroup,SegmentedControl,Tabs,SearchInput,StatusBadge,SelectionBar,DataGrid,FileDropzone,TagInput,CoverForm. - Icon stroke: one global rule (
svg.lucide { stroke-width: 1.5 }inlayout.css) sets the stroke for every icon. Never passstrokeWidthto an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwindstroke-*utility.
App shell
The shell in src/routes/(app)/+layout.svelte is a fixed h-dvh frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (app-scroll) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over app-backdrop. Because the window never scrolls, scroll reset/restore on navigation is handled by restoreScroll() in $lib/state/scroll.ts; don't use window.scrollTo/scrollY.
Motion
Every animation follows one scale of three durations and one easing curve, all defined in theme.css. Fast (--mf-motion-fast, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain transition or transition-colors already uses it because it is the Tailwind default, so never write duration-150. Base (--mf-motion-base, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, animate-pop, animate-fade and the segmented control thumb. Slow (--mf-motion-slow, 300ms) is for content entering the page and larger movement: animate-enter. Media cards have no cover zoom: hovering one only fades in its background (hover:bg-hover) and reveals the play button.
Entrances and movement always use --mf-ease (ease-snappy in Tailwind, expoOut in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses transition:fade={{ duration: 200 }} and the panel uses a local pop transition (opacity + scale + translateY, expoOut, 200ms) mirroring animate-pop's keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use --i with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in the root +layout.svelte (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. prefers-reduced-motion disables everything globally in layout.css.
Auth
hooks.server.ts validates the encrypted session cookie (mf_session), refreshes the access_token, and fills locals.user and locals.accessToken. To protect a route, check locals.user in its load and redirect to /auth?returnTo=…. When talking to the backend, send locals.accessToken as a Bearer token to API_BASE_URL.
Environment variables live in .env (see .env.example): ZITADEL_*, AUTH_REDIRECT_URI, AUTH_POST_LOGOUT_URI, SESSION_SECRET, API_BASE_URL.
Backend API
Typed client via openapi-fetch over the Musify.Api OpenAPI spec. Inside a server load or action use the client in $lib/server/api.ts:
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session
const { data, error } = await api.GET('/tracks');
It returns { data, error } (ErrorOr style) and never throws except on network errors. The types live in src/lib/api/schema.d.ts and get regenerated with npm run gen:api (this needs the dev API running at API_BASE_URL, which only exposes /openapi/v1.json in Development).
Server helpers:
api.tsholds the client and error handling:apiFor,unwrapOrError,failOnError(turns an API error into afail(...)),requireData(also narrows the response data) andapiErrorDetail.guards.tsholdsrequireUser,optionalUserandloginRedirect. Keep them in pages and actions even though the(app)layout also redirects: page loads run in parallel with the layout load, and form actions skip it.mappers.ts(toTrack,toAlbum,toPlaylist,toMix,toPage,toCount,toListeningStats, ...) turn DTOs into the domain types. Every load and/apiendpoint goes through them, soNumber()conversions on DTOs never reach routes or the client.actions/holdsauthedAction(handler)(returns 401 without a token, reads the form and hands{ api, form, params, locals, url }to the handler) plus the sharedfollowandplaylistactions.forms/holdsfields.ts(formString,formFile) and the form parsers (parseAlbumForm,parsePlaylistForm); they validate withLIMITSfrom$lib/validation.uploadOptionalCoverhandles the optional cover upload.coauthors.tsholds the coauthor helpers: invite, remove, accept or reject with per status error messages, the pending invitations list and count, and the user search behind/api/users/search.genres.tscaches the genre list;playbackConfig.tscaches the anonymous listening setting.- Page sizes come from
$lib/config; do not hardcode them.