The shared Vue 3 design system + application shell for Phlix.
Both Phlix products — phlix-server (the media server) and phlix-hub (the federation hub) —
mount this one package to render their entire /app/* single-page experience. Build a feature here
once and it ships to both.
@phlix/ui is a self-contained kit: a 3-theme "Nocturne" cinema-after-dark token system, ~20
accessible primitives, a media-browsing + video-player surface, auth/settings forms, a ⌘K command
palette, four Pinia stores, and a createPhlixApp() factory that wires it all into a ready-to-mount
Vue app.
- 🎨 Three built-in themes (
nocturnedark ·daylightlight ·midnightOLED) with live user controls for accent color, density, card size, and motion. - 🧩 ~20 token-driven, accessible primitives + higher-level media/player/auth surfaces.
- 🎬 Full HTML5 video player — rich scrubber, keyboard map, captions, ambient glow, theater/PiP, mini-player, resume + up-next.
- ⌘ Fuzzy command palette (⌘K / Ctrl-K), extensible by the host app.
- ⚡ Code-split + lazy-routed — the entry bundle is ~57 kB; routes, the player, and the palette load on demand.
- ♿ WCAG 2.1 AA — axe-clean across every surface × theme, full keyboard support, honored reduced-motion, an i18n-readiness seam.
- 🔤 Self-hosted variable fonts, no CDN — Fraunces / Hanken Grotesk / JetBrains Mono.
Status:
v0.98.34. Pre-1.0, so minor releases may include breaking changes. SeeCHANGELOG.mdfor the full history.
- Install
- Quick start
- Configuration —
PhlixAppConfig - Theming & user preferences
- Stores & composables
- Component catalog
- Command palette (⌘K)
- Admin surface
- CSS & font delivery
- Development
- Build output
- Compatibility & notes
@phlix/ui is distributed as a git-tagged dependency (not published to npm). Pin an exact tag:
npm installYou must provide compatible copies of Vue, Pinia, and Vue Router:
| Peer | Range |
|---|---|
vue |
^3.5.0 |
pinia |
^3.0.0 |
vue-router |
^5.0.0 |
Node ≥ 18 is required to build.
createPhlixApp(config) returns a fully-wired Vue app (Pinia + Vue Router + theme bootstrap +
provides). Import the two stylesheets, then mount:
import { createPhlixApp } from '@phlix/ui';
import '@phlix/ui/style.css'; // design tokens + component styles (required)
import '@phlix/ui/fonts.css'; // self-hosted @font-face declarations (recommended)
createPhlixApp({
app: 'server', // 'server' | 'hub'
apiBase: 'https://phlix.example.com', // base URL for /api/v1 calls
}).mount('#app');That single call mounts the whole experience under /app (Vue Router history base, configurable):
Browse (/app), a per-library page (/app/library/:id), media detail (/app/media/:id), player
(/app/player/:id), login/signup (/app/login, /app/signup), and settings (/app/settings). The
built-in route pages, the player surface, and the command palette are lazy chunks — they load only
when first reached.
Browse is organized per library. The Browse home renders a "Continue Watching" rail, any configured
homeRows, then one rail per library ("Movies", "TV", "Anime", …) read from GET /api/v1/libraries.
Each rail's "See all" opens that library's dedicated /app/library/:id page (the full filterable grid).
Set libraryLinks: true on a MenuItem to also surface one nav link per library. A library whose
type is music never renders that generic grid: createPhlixApp's router guard
(musicLibraryRedirect, exported for tests) redirects /app/library/:id → /app/music inside
beforeEach, and falls through to the generic grid when the type can't be resolved or the host's
route table has no music route.
CSS is not auto-injected. The two
import '...css'lines above are required — withoutstyle.cssnothing is styled; withoutfonts.cssthe type falls back to metric-matched system faces (no layout shift, but not the intended typography). See CSS & font delivery.
createPhlixApp(config?: Partial<PhlixAppConfig>). Every field is optional except the two you almost
always want (app, apiBase):
| Field | Type | Purpose |
|---|---|---|
app |
'server' | 'hub' |
Which Phlix product is mounting the package. |
apiBase |
string |
Base URL for all /api/v1 calls (provided to the stores via inject('apiBase')). |
imageOrigin? |
string |
Cross-origin host serving posters/artwork (a CDN/proxy). Warmed early via usePreconnect; falls back to the apiBase host when omitted. |
routerBase? |
string |
Vue Router history base. Default '/app'. |
menu? |
MenuItem[] |
Navigation entries rendered in the app shell. |
extraRoutes? |
RouteRecordRaw[] |
Extra routes merged after the built-ins (e.g. spread buildAdminRoutes() here). |
features? |
Record<string, boolean> |
Arbitrary feature-flag map. |
commands? |
Command[] |
App-injected ⌘K command-palette entries (registered alongside the built-ins). |
defaultTheme? |
'nocturne' | 'daylight' | 'midnight' |
Initial theme for a first-time visitor. A stored user choice always wins. |
branding? |
BrandingConfig |
Per-app brand: wordmark?, logoSrc?, logoAlt?, tagline?. |
homeRows? |
HomeRow[] |
Browse home-row shelves: { id, title, query? } (query is a partial LibraryQueryParams, incl. libraryId). Rendered in addition to the automatic per-library rails. |
messages? |
PhlixMessagesConfig |
Deep-partial override of user-facing English strings (the i18n-readiness seam). |
import { createPhlixApp, buildAdminRoutes, adminMenu } from '@phlix/ui';
createPhlixApp({
app: 'server',
apiBase: '',
branding: { wordmark: 'Phlix', tagline: 'Your cinema, after dark' },
defaultTheme: 'nocturne',
homeRows: [
{ id: 'continue', title: 'Continue Watching' },
{ id: 'recent', title: 'Recently Added', query: { sort: 'added', order: 'desc' } },
],
commands: [
{ id: 'rescan', title: 'Rescan library', run: () => rescan() },
],
extraRoutes: buildAdminRoutes('/app'),
menu: adminMenu('/app'),
}).mount('#app');The "Nocturne" system ships three themes, applied via a data-theme attribute on <html>:
| Theme | Scheme | Feel |
|---|---|---|
nocturne |
dark | Default — deep-brown cinema dark with projector-amber accent. |
daylight |
light | Warm parchment light theme. |
midnight |
dark | Pure-black OLED variant (--bg: #000). |
usePreferencesStore holds the user's appearance + playback preferences, persisted to
localStorage (phlix.prefs). useTheme() reflects them onto <html> reactively
(data-theme, data-density, data-reduced-motion, and inline --accent* variables). The
AppearanceSettings panel (mounted by the built-in Settings page) exposes them all:
| Preference | Default | Notes |
|---|---|---|
theme |
'nocturne' |
one of the three above |
accent |
null |
a hex string overrides projector-amber via deriveAccentVars() |
density |
'comfortable' |
or 'compact' (smaller controls) |
cardSize |
180 |
poster width in px (drives grid auto-fit) |
gridDensity |
'comfy' |
'cozy' | 'comfy' | 'dense' |
viewMode |
'grid' |
ViewMode — 'grid' | 'list' | 'backdrop' today ('table' reserved); set by the FilterBar toggle, read by LibraryPage |
reducedMotion |
'auto' |
'auto' | 'on' | 'off' (auto honors the OS setting) |
atmosphere |
true |
film-grain/vignette + ambient player glow |
autoplay |
true |
up-next auto-advance |
defaultVolume / defaultQuality / defaultSubtitleLang |
1 / 'auto' / — |
player seeds |
captionStyle |
size md, white, no bg, drop-shadow |
caption rendering |
filterPresets |
[] |
saved Browse filter presets |
To prevent a flash of the wrong theme, createPhlixApp calls applyStoredThemeEarly() synchronously
before mount. If you mount PhlixApp.vue yourself, call it first.
import { usePreferencesStore } from '@phlix/ui';
const prefs = usePreferencesStore();
prefs.theme = 'daylight';
prefs.accent = '#7c5cff'; // live re-themes via deriveAccentVars()
prefs.reset(); // back to DEFAULT_PREFERENCESFour Pinia stores plus auth/toast carry all app state. Import the composables directly; they expect to
run inside an app created by createPhlixApp (which provides apiBase).
| Store | Responsibility | Key methods |
|---|---|---|
usePreferencesStore |
Appearance + playback prefs (persisted) | saveFilterPreset, removeFilterPreset, reset, snapshot |
useMediaStore |
Library browsing — query-keyed cache (60 s TTL), dedupe, debounced search, URL sync | fetchMedia, scheduleFetch, loadMore, prefetch, toQuery, applyQuery, setSearch, setGenres, setSort |
usePlayerStore |
Singleton playback shared across routes (so the mini-player survives navigation), resume map, queue, Media Session | setCurrent, updateProgress, play/pause, next, setQueue, showMiniPlayer, resumePositionFor, bindMediaSession |
useCommandStore |
⌘K registry + palette state, persisted recents | register (→ disposer), togglePalette, runId |
useAuthStore |
Auth state over ApiClient + LocalStorageTokenStore |
login, signup, fetchUser, logout |
useToastStore |
Transient notifications (rendered by <ToastHost>) |
success, error, warning, info, dismiss |
| Composable | Purpose |
|---|---|
useTheme() |
Reflect prefs onto <html> live (call once near the root). |
applyStoredThemeEarly(defaultTheme?) |
Synchronous pre-mount theme bootstrap (no-flash). |
useMessages() |
Returns { t } — t('group.key', params?) resolves the i18n catalog + overrides. |
usePreconnect(input, opts?) |
Inject <link rel=preconnect/dns-prefetch> for a cross-origin asset host. SSR-safe, self-cleaning. |
usePrefetch() |
{ prefetch(to) } — warm a lazy route's chunk on hover/focus without navigating. |
useCommandPaletteHotkey() |
Owns the global ⌘K/Ctrl-K listener (lives outside the lazy palette chunk). |
useOnline() |
Reactive, SSR-safe navigator.onLine. |
bindMediaStoreToRouter(router, apiBase) |
Two-way sync of useMediaStore filters ↔ URL query (returns teardown). |
useFocusTrap(container, active, opts?) |
Focus-trap + scroll-lock + Escape for overlays (powers Modal/Sheet). |
deriveAccentVars(hex) |
Pure: a hex → the full --accent* custom-property ramp. |
Page Visibility API for Polling:
Polling timers (e.g., for live scan status) should pause when the tab is hidden to avoid unnecessary server load and battery drain. Use the Page Visibility API (document.visibilityState / visibilitychange event):
function handleVisibilityChange(): void {
if (document.hidden) {
pauseAllPolling();
} else {
resumeAllPolling();
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
});See LibrariesPage.vue (scan status polling), LogsPage.vue (auto-refresh), DashboardPage.vue (now-playing refresh), and MetricsPage.vue (snapshot/connections/history polling) for examples.
Scroll Handler Throttling:
Scroll events can fire hundreds of times per second. Use timestamp-based throttling rather than requestAnimationFrame alone, because Firefox aggressively throttles rAF during scrolling which causes visual "freezing":
let lastScrollMeasureTime = 0;
const SCROLL_MEASURE_THROTTLE_MS = 16; // ~60fps
function throttledMeasure(): void {
const now = performance.now();
if (now - lastScrollMeasureTime < SCROLL_MEASURE_THROTTLE_MS) return;
lastScrollMeasureTime = now;
measure();
}
window.addEventListener('scroll', throttledMeasure, { passive: true });For rAF-coalesced measurements (used in MediaGrid.vue for layout calculations after scroll settles):
let frame = 0;
function scheduleMeasure(): void {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
}The two patterns are complementary: timestamp throttling limits how often the measurement runs, while rAF coalescing ensures layout recalculations sync with the browser's paint cycle.
Stale-While-Revalidate Item Cache:
The media-detail and player pages share a module-level singleton item cache (useMediaItemCache, 60 s TTL) so navigating browse → detail → player → back reuses the already-fetched item and renders instantly, refreshing in the background when the entry is stale (and serving the stale copy if the refresh fails). Keep such a cache in its own module — a const declared at the top of <script setup> is recompiled into setup() and recreated on every mount, so it would cache nothing across navigations.
Debounced Preference Persistence:
Deep-watched preference writes to localStorage are debounced (250 ms trailing edge) so rapid slider/toggle changes don't thrash storage; a pagehide listener flushes immediately so no write is lost on tab close. usePreferencesStore clears both on onScopeDispose, so a disposed store can never write again (this is what keeps a suite that swaps its pinia between tests isolated).
Everything below is a named export from the package root (import { Button } from '@phlix/ui').
Button · IconButton · Badge · Slider · Switch · Chip · Select · Combobox · Modal ·
Sheet · Tooltip · ToastHost · Skeleton · Spinner · EmptyState · Tabs · Kbd ·
Reveal · PageTransition — all token-driven, theme-aware, keyboard-accessible. Plus Icon
(Lucide via unplugin-icons; type-checked IconName, with every registered name in
src/components/icon-registry.ts — the single source of truth Icon.test.ts iterates) and
AppBackdrop (the atmosphere layer).
MediaCard · MediaGrid (virtualized) · MediaRow · MediaHomeRow · MediaDetail · FilterBar.
MediaGrid also takes a #card slot plus columns / rowHeight props (they drive both the inline
grid-template-columns and the windowing math), so a host can swap in a per-viewMode renderer —
src/components/MediaListRow.vue for 'list' and src/components/MediaBackdropRow.vue (a wide
hero strip per item) for 'backdrop'. Those renderers are internals of
src/pages/LibraryPage.vue and are deliberately not re-exported.
MusicArtistsPage · MusicArtistPage · MusicAlbumPage · MusicTracksPage · MusicPlayerPage are
exported page components — mount them yourself under /app/music/* via extraRoutes (that is what
phlix-server/web-ui does). The drill-down page behind the built-in /app/music route
(MusicLibraryPage: artists → albums → tracks) is a lazy chunk mounted by createPhlixApp and, like
the other built-in route pages, is not re-exported. It is also where a type: 'music' library is
redirected from /app/library/:id.
Every music listing is offset-paged, and paging is the only way to see the whole library. The
endpoints serve bounded pages — ?limit= is clamped server-side to MUSIC_PAGE_SIZE (100,
exported so a consumer can size its own pager identically) — and return the true row total. So each
listing renders one page plus a MusicPager, and its count line ("2,197 artists") comes from total,
never from the rows on screen. Asking for a bigger limit is not the lever: an album list row embeds
its tracks and the server mints one signed stream URL per embedded track, so offset is the cheap
axis.
ApiClient.listArtists() / listAlbums() / listTracks() take { limit, offset } (listAlbums also
takes { artist }) and return the page envelopes MusicArtistsResult / MusicAlbumsResult /
MusicTracksResult — the rows plus the total and the limit/offset the server actually applied,
plus an artist echo of the applied filter on albums. Those three envelopes and the MusicPageParams
argument type are exported from the package root; the row types inside them are not, so name a row
structurally (MusicAlbumsResult['albums'][number]) rather than importing it. The artist drill-down
filters server-side (?artist=, exact/case-insensitive/trimmed); do not filter an album page in the
browser, because /albums is ordered globally by artist then title, so page 1 spans only a handful of
artists. getAlbum(title, artist?) takes the artist for the same reason — album titles are shared
between artists, so title alone resolves to the server's first match. An album row's embedded tracks
may be a prefix: check that row's own tracksTruncated flag and fetch getAlbum() for the whole list.
This is a signature change from the pre-paging helpers (which returned bare arrays) — see
CHANGELOG.md.
src/components/MusicPager.vue is the shared pager for all of those listings. Props: offset /
limit / total (required), plus label (names the <nav> landmark — "Artists pagination"),
controls (the id of the grid its page-jump <select> drives, for aria-controls) and disabled
(set while a page is in flight). It emits a single go(offset) and owns no offset itself. It renders
first / prev / a Page N of M · Showing A–B of TOTAL live readout / a page-jump <select> / next /
last, hides itself entirely when there is only one page, and drops the jump <select> above 1,000
pages rather than mint that many <option> nodes from a server-supplied total. Like the MediaGrid
view-mode renderers it is an internal of these pages and deliberately not re-exported.
Player · MiniPlayer · Scrubber · VolumeControl · SpeedMenu · QualityMenu ·
CaptionsMenu · CaptionOverlay · AmbientCanvas · ResumePrompt · UpNext · TranscodeNotice ·
ShortcutsHelp. Pure helpers live alongside (playback.ts, captions.ts, ambient.ts,
shortcuts.ts, format-time.ts, quality.ts). Direct play vs. server transcode is decided in
playback.ts: videoCodecFromStreams() reads the source's video codec off the detail response's
streams[], videoCodecPolicy() classifies it against a decodable allowlist
(direct / probe / transcode), and needsTranscodeWithCapabilities() combines that verdict with
the real container MIME (containerMimeForExtension()) and the primary audio-track probe.
LoginForm · SignupForm, plus the directly-exported consumer pages
LibraryScanPage · MyServersPage · FederationPage · ManageSharesPage · AuditLogsPage.
The six built-in route pages (Browse/Detail/Player/Login/Signup/Settings) and every admin page are intentionally not re-exported — they are lazy chunks mounted by
createPhlixApp/buildAdminRoutes. Compose your own pages from the building blocks above, or let the factory mount the built-ins.
The palette is a lazy chunk; the hotkey is always live. Built-in commands (navigation, theme switching, etc.) are registered automatically; add your own via config or at runtime:
import { useCommandStore } from '@phlix/ui';
const commands = useCommandStore();
const dispose = commands.register({
id: 'scan-all',
title: 'Scan all libraries',
run: () => scanAll(),
});
// dispose() to unregisterfuzzyScore / matchCommand are exported for custom command UIs. Recents persist to
localStorage (phlix.cmd.recents, capped at 8).
The server admin pages — Dashboard, Server Traffic, Users, Logs, Webhooks, Services, Integrations,
Backup, Cast Devices, DLNA Server, Remote Access, Live TV / DVR, Collections, Watch History,
SyncPlay, Libraries, Duplicates, Plugins, Transcoding, Settings — are produced as lazy routes, with
no static JS in the entry. defaultAdminPages in src/app/admin.ts is the source of truth for that
list and its sidebar order; buildHubAdminRoutes mounts the smaller hub set instead.
import { createPhlixApp, buildAdminRoutes, adminMenu } from '@phlix/ui';
createPhlixApp({
app: 'server',
apiBase: '',
extraRoutes: buildAdminRoutes('/app'), // one lazy route per admin page, under /app/admin/*
menu: adminMenu('/app'), // matching nav entries
}).mount('#app');Admin API clients (AdminUsersApi, AdminLiveTvApi, …) and their types are exported for direct use.
The hub-only per-user relay controls live on AdminUsersApi.getBandwidth() / setThrottle() /
setQuota() (with DEFAULT_THROTTLE_BPS / THROTTLE_BPS_OPTIONS / THROTTLE_BPS_LEVELS).
The package publishes only dist/, and CSS is not auto-injected — import it explicitly:
import '@phlix/ui/style.css'; // tokens + component styles + reset (required)
import '@phlix/ui/fonts.css'; // @font-face declarations (recommended)@phlix/ui/style.css— all design tokens (colors, type scale, spacing, radius, shadow, motion, density) plus every component's styles. Does not embed font bytes.@phlix/ui/fonts.css— the@font-facerules only. The three.woff2files sit next to it indist/fonts/(relativeurl()), so serve them co-located. The faces include metric-matched system fallbacks for zero-CLS swapping. Fonts are self-hosted (no CDN). Kept separate so Vite's lib build doesn't base64-inline the woff2 into the main CSS.
npm install
npm run dev # Vite dev server (open src/dev/gallery.html for the primitive Gallery)
npm run build # vue-tsc typecheck + vite lib build + d.ts emit + copy fonts
npm run test # vitest (watch)
npm run test:run # vitest run (CI)
npm run test:run -- --coverage # same suite + coverage/lcov.info (what CI uploads)
npm run typecheck # vue-tsc --noEmit
npm run lint # eslint (part of the CI gate)
npm run test:visual # Playwright visual-regression suite (on-demand; not in the default gate)
npm run test:a11y # Playwright + axe — 0 WCAG 2.0/2.1 A+AA violations across surfaces × themessrc/dev/Gallery.vue— a dev-only showcase of every primitive × every theme, with a theme switcher and density toggle. The source of truth for visual QA.- Visual + a11y harnesses (
src/dev/visual/*) mount the real surfaces (Browse, Detail, Player, Auth, Settings, shell) with deterministic offline data for Playwright. These suites are on-demand — they're not part of the blockingtypecheck/lint/build/vitestgate that.github/workflows/ui-ci.ymlruns on every push tomasterand every PR (itsvisualjob staysworkflow_dispatch-only until the-linuxbaselines are regenerated on the runner, because PNG baselines are environment-fragile). One surface,quality-menu, mountsQualityMenuon its own (rather than inside thePlayerchrome) with a deterministic offline ladder and the listbox forced open — the control only renders with ≥2 real hls.js levels, which the offlinePlayerharness's direct-play sample can never produce, so this is the only way to capture (and axe-scan) its expanded state. - Toolchain: Vite 8 · Vitest 4 · TypeScript 6 · vue-tsc 3 · Vue 3.5 · Pinia 3 · Vue Router 5.
npm run build emits to dist/ (committed in this repo):
| Output | Notes |
|---|---|
dist/phlix-ui.js |
ESM entry (~57 kB; routes/player/palette split into lazy chunks). |
dist/phlix-ui.umd.cjs |
UMD/CJS build for require(). |
dist/style.css |
Tokens + component styles. |
dist/fonts/ |
fonts.css + three variable .woff2 files. |
dist/index.d.ts + per-module .d.ts |
Type declarations. |
dist/<Chunk>-<hash>.js |
On-demand chunks (each route page, the player surface, the palette). |
- Pre-1.0 semver —
0.xminor bumps may include breaking changes. Pin an exact tag; read theCHANGELOGbefore upgrading. - Icons, never emoji — all iconography is Lucide SVG via the type-checked
Iconcomponent. - SSR-safe composables —
usePreconnect/useOnline/useMessages/useThemeguardwindow/documentaccess. - Stores need the provided
apiBase— theyinject('apiBase'), so run them inside acreatePhlixApptree (or provideapiBaseyourself). - One package, two consumers —
phlix-server/web-uiandphlix-hub/web-uiboth pin the same git tag and commit a rebuiltpublic/assets/app/**bundle. They always track the same MAJOR.
MIT © Phlix