Use type recipes and Button danger variant, add danger tint, dropzone and cover-fill tokens, give SearchInput a size prop so the top bar keeps its larger search, show queue details inline on mobile, derive upload limits from constants and document the card hover style.
114 lines
13 KiB
Markdown
114 lines
13 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
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 lint` and `npm run check` must exit clean before closing a change.
|
|
- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too.
|
|
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
|
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
|
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
|
- Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-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.ts` holds 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` (merges `player.recentlyPlayed` with the server history), and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
|
|
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, `liked.svelte.ts` (the user's liked tracks), `toggle.svelte.ts` (`createToggle`), and `scroll.ts` (`restoreScroll`). Nothing in `state/` imports from `components/`.
|
|
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`, and `color.ts` (`ACCENT_HUE`). The player never imports from `theme/`: `(app)/+layout.svelte` follows `player.currentId` and calls `accent.follow`.
|
|
- `src/lib/data/` holds client side domain helpers: albums, genres, search. It never imports from `player/`.
|
|
- `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.ts` holds 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, and `navLinks.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.
|
|
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from 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).
|
|
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
|
|
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/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 value `err` in `catch (err)`.
|
|
- Play handlers: `playFrom(index)` and `playAll()`. 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` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
|
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
|
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
|
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
|
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
|
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/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, and `bg-[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`, `SegmentedControl`, `CoverForm`.
|
|
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` 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`:
|
|
|
|
```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.ts` holds the client and error handling: `apiFor`, `unwrapOrError`, `failOnError` (turns an API error into a `fail(...)`), `requireData` (also narrows the response data) and `apiErrorDetail`.
|
|
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. 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 `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
|
|
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
|
|
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
|
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
|
- Page sizes come from `$lib/config`; do not hardcode them.
|