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.9.0. 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.
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' |
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. |
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) and AppBackdrop (the atmosphere layer).
MediaCard · MediaGrid (virtualized) · MediaRow · MediaHomeRow · MediaDetail · FilterBar.
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).
LoginForm · SignupForm · SettingsForm, plus the directly-exported consumer pages
LibraryScanPage · MyServersPage · FederationPage · ManageSharesPage · AuditLogsPage.
The six built-in route pages (Browse/Detail/Player/Login/Signup/Settings) and the 16 admin pages 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 16 admin pages (Dashboard, Users, Libraries, Settings, Live TV, Cast Devices, …) are produced as lazy routes — no static JS in the entry:
import { createPhlixApp, buildAdminRoutes, adminMenu } from '@phlix/ui';
createPhlixApp({
app: 'server',
apiBase: '',
extraRoutes: buildAdminRoutes('/app'), // 16 lazy routes 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 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 typecheck # vue-tsc --noEmit
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 blockingbuild/vitestgate (PNG baselines are environment-fragile). - 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