Style & Branding Guide
Librariarr uses a modern cinematic aesthetic — a layered dark interface with navy-tinted surfaces, atmospheric depth, and editorial typography. The design draws from premium streaming services while maintaining a functional media-management tool identity.
Brand Identity
Section titled “Brand Identity”The logo is an SVG side view of a movie reel with a circular arrow (representing lifecycle/recycling) enclosing book spines (representing a media library). Primary brand colors within the logo:
| Element | Color | Hex |
|---|---|---|
| Circular arc / arrow | Plex blue | #00a4dc |
| Spine 1 (gold) | Plex accent | #e5a00d |
| Spine 2 (blue) | Primary blue | #00a4dc |
| Spine 3 (green) | Success green | #52b54b |
| Spine 4 (orange) | Warm orange | #e4881c |
| Spine 5 (purple) | Purple accent | #9b59b6 |
| Background fill | Deep navy | #0f1923 |
Application Name
Section titled “Application Name”“Librariarr” is displayed in the sidebar using the display font:
font-family: Sorafont-weight: 600 (semibold)letter-spacing: tight (-0.025em)Color System
Section titled “Color System”All colors use the OKLCH color model for perceptual uniformity. The application enforces dark mode only (className="dark" on <html>).
Core Palette
Section titled “Core Palette”| Token | OKLCH Value | Role |
|---|---|---|
--background |
oklch(0.16 0.006 270) |
Page background — deep navy-grey |
--foreground |
oklch(0.95 0.008 80) |
Primary text — warm off-white |
--card |
oklch(0.21 0.008 270) |
Card/panel surfaces — lifted from background |
--popover |
oklch(0.23 0.01 270) |
Popover/dropdown surfaces |
--primary |
oklch(0.62 0.2 264) (var(--brand)) |
Primary actions — the brand blue |
--primary-foreground |
oklch(0.98 0.005 270) (var(--on-brand)) |
Text on primary — near-white |
--secondary |
oklch(0.27 0.01 270) |
Secondary surfaces |
--muted |
oklch(0.25 0.01 265) |
Muted backgrounds |
--muted-foreground |
oklch(0.65 0.015 260) |
Secondary/helper text |
--accent |
oklch(0.27 0.015 270) |
Accent surfaces |
--destructive |
oklch(0.704 0.191 22.216) |
Error/danger actions |
--border |
oklch(1 0.01 270 / 12%) |
Borders — translucent white |
--input |
oklch(1 0.01 270 / 15%) |
Input field borders |
--ring |
oklch(0.50 0.015 260) |
Focus ring color |
Surfaces, Text and Brand
Section titled “Surfaces, Text and Brand”| Token | OKLCH Value | Role |
|---|---|---|
--surface-0 |
var(--background) |
The page canvas |
--surface-1 |
var(--card) |
Cards and panels |
--surface-2 |
oklch(0.245 0.01 270) |
Hover wells, icon wells, the kbd hint |
--surface-3 |
oklch(0.285 0.012 270) |
Hover on top of surface-2 |
--faint-foreground (text-faint) |
oklch(0.52 0.012 262) |
Tertiary text: eyebrows, nav group labels, timestamps — small mono labels only |
--border-strong |
oklch(1 0.01 270 / 18%) |
Hover border on tiles and outlined surfaces |
--brand |
oklch(0.62 0.2 264) |
The brand blue: primary fills, active rows, chart-1 |
--brand-bright |
oklch(0.7 0.2 264) |
Brand as text or a mark: active nav icon and tab underline, the “arr” of the wordmark, hover titles |
--brand-dim / --brand-faint |
brand at 15% / 8% | Active sidebar row, count pills / the sidebar’s top wash |
--on-brand |
oklch(0.98 0.005 270) |
Text and icons on a brand fill |
Status Colors
Section titled “Status Colors”Four hues, each with a -dim fill at 14%. A status chip is the hue as text on its -dim fill with a 25% border (border-green/25 bg-green-dim text-green); a status always carries its word, never colour alone.
| Token | OKLCH Value | Means |
|---|---|---|
--green / --green-dim |
oklch(0.74 0.16 158) |
Success, active, connected, completed, Direct Play |
--amber / --amber-dim |
oklch(0.8 0.15 78) |
Pending, queued, warning, Transcode |
--red / --red-dim |
oklch(0.7 0.19 22) |
Failed, expired, disconnected |
--sky / --sky-dim |
oklch(0.72 0.13 235) |
Informational, running, syncing, update available, Direct Stream |
A neutral label is bg-muted/40 text-muted-foreground. Never write a raw Tailwind palette colour (text-green-500, bg-blue-500/10, text-yellow-500, text-slate-300…) for a status: those drift from the tokens and from each other.
Identity Colors
Section titled “Identity Colors”What a thing is, never how it is doing — so they never borrow the status hues, with one deliberate exception: the media type chips keep their original colours, Movie in the sky blue and Music in the green (the word on the chip tells a type from a status). The same in both themes unless they alias a status token.
| Token | Value | Used for |
|---|---|---|
--excluded |
oklch(75% 0.183 55.934) |
An item protected by a lifecycle exception: the shield marker, the Exclude button, the corner badge on a card (its icon in text-background) |
--app-sonarr |
oklch(74.6% 0.16 232.661) |
Sonarr |
--app-radarr |
oklch(82.8% 0.189 84.429) |
Radarr |
--app-lidarr |
oklch(76.5% 0.177 163.223) |
Lidarr |
--app-seerr |
oklch(70.2% 0.183 293.541) |
Seerr |
--app-tracearr |
oklch(78.9% 0.154 211.53) |
Tracearr |
--media-movie |
var(--sky) |
Movie type chip (blue) |
--media-series |
oklch(71.4% 0.203 305.504) |
Series type chip (purple) |
--media-music |
var(--green) |
Music type chip (green) |
The three media types keep their original colours — blue, purple and green — pinned by tests/unit/theme/media-type-colors.test.ts.
Read an integration’s classes from INTEGRATION_APP_STYLES (src/lib/theme/integration-colors.ts), a media type’s from MEDIA_TYPE_BADGE_COLORS (src/lib/theme/media-type-colors.ts), and a server type’s from SERVER_TYPE_STYLES (src/lib/server-styles.ts) — Plex is orange everywhere, the login button and onboarding included.
Sidebar Palette
Section titled “Sidebar Palette”| Token | OKLCH Value |
|---|---|
--sidebar |
oklch(0.18 0.008 270) |
--sidebar-foreground |
oklch(0.95 0.008 80) |
--sidebar-primary |
oklch(0.55 0.26 264) |
--sidebar-accent |
oklch(0.25 0.012 270) |
Chart Colors
Section titled “Chart Colors”| Token | Value | Approximate hue |
|---|---|---|
--chart-1 |
oklch(0.55 0.26 264) |
Blue (follows the accent preset) |
--chart-2 |
oklch(0.72 0.19 162) |
Green |
--chart-3 |
oklch(0.78 0.20 70) |
Amber |
--chart-4 |
oklch(0.65 0.28 304) |
Violet |
--chart-5 |
oklch(0.67 0.26 16) |
Rose |
--chart-6 … --chart-15 |
#a855f7 #ef4444 #06b6d4 #ec4899 #f97316 #14b8a6 #6366f1 #84cc16 #0ea5e9 #d946ef |
Purple, red, cyan, pink, orange, teal, indigo, lime, sky, fuchsia |
--chart-other |
#6e7383 |
“Other” buckets and hidden series |
Charts take their colours from these tokens only: AUTO_HEX / OTHER_HEX (src/components/dashboard/chart-palette.ts) are var(--chart-N) strings, which work in an SVG fill/stroke, an inline style and color-mix(). Axis ticks are var(--muted-foreground), grid lines var(--border), tooltips var(--popover); a heatmap blends its two ends with color-mix(in oklab, …). Never write a hex colour into a chart.
Surface Elevation
Section titled “Surface Elevation”The design uses layered lightness to create depth. Each surface level is incrementally brighter:
Background 0.16 ← deepestSidebar 0.18Card 0.21Popover 0.23Surface-2 0.245Muted 0.25Secondary 0.27Surface-3 0.285 ← lightest surfaceAll surfaces share a blue hue angle (~270) with very low chroma (0.006–0.015), giving a cohesive navy tint rather than flat grey.
Accent Color Presets
Section titled “Accent Color Presets”Users can choose from 6 accent colors in Settings. Each preset but Default overrides --primary, --primary-foreground, --ring, --sidebar-primary, --sidebar-primary-foreground, --chart-1, --brand, --brand-bright, --brand-dim and --brand-faint (Default removes every override):
| Preset | Primary OKLCH | Swatch |
|---|---|---|
| Default | oklch(0.62 0.2 264) |
#457cfd — the stylesheet’s --brand; choosing it removes every override |
| Violet | oklch(0.65 0.28 304) |
#8b5cf6 |
| Green | oklch(0.72 0.19 162) |
#22c55e |
| Orange | oklch(0.78 0.20 70) |
#f59e0b |
| Rose | oklch(0.67 0.26 16) |
#f43f5e |
| Teal | oklch(0.65 0.14 185) |
#14b8a6 |
Typography
Section titled “Typography”Three variable font families, self-hosted through the @fontsource-variable/* packages imported in src/app/layout.tsx, each serving a distinct role:
Sora (Display)
Section titled “Sora (Display)”Usage: Page headings, sidebar brand name, section titles.
Weights: 400, 500, 600, 700
CSS variable: --font-display
Tailwind class: font-display
Sora is a geometric sans-serif with clean letterforms and wide weight range. Applied to all <h1> elements with font-display tracking-tight.
Plus Jakarta Sans (Body)
Section titled “Plus Jakarta Sans (Body)”Usage: All body text, navigation items, labels, inputs, buttons.
Weights: 300, 400, 500, 600, 700
CSS variable: --font-sans
Tailwind class: font-sans (default — applied on <body> in globals.css)
A geometric humanist sans-serif — warm, readable at small sizes, and well-suited for UI text.
JetBrains Mono (Monospace)
Section titled “JetBrains Mono (Monospace)”Usage: “Mono for data” — the eyebrow label above a figure, sidebar group labels, counts and ranges, timestamps, log lines, bitrates and codecs in chips, keyboard hints, code. Set tabular-nums on figures.
Weights: 400, 500
CSS variable: --font-mono
Tailwind class: font-mono
Type Scale
Section titled “Type Scale”| Context | Classes |
|---|---|
| Page title | text-2xl sm:text-3xl font-bold font-display tracking-tight |
| Card title | text-lg font-semibold |
Section heading (<h2>) |
text-xl font-bold font-display — every section heading, settings tab titles included |
| Eyebrow | .eyebrow — JetBrains Mono 10.5px, 0.18em tracking, uppercase, text-faint. Every small uppercase label; never a sans uppercase tracking-wider imitation |
| Body text | text-sm (14px) — default |
| Helper/metadata | text-xs text-muted-foreground |
| Chip and badge text | text-xs font-medium |
| Micro text | text-[10px] or text-[11px] |
Border Radius
Section titled “Border Radius”Base radius: 0.625rem (10px). Derived values:
| Token | Value | Usage |
|---|---|---|
rounded |
4px | ColorChip, RatingChip, checkboxes |
radius-sm |
6px | Small elements |
radius-md |
8px | Buttons, inputs, tab triggers, error boxes |
radius-lg |
10px | Dialogs, the Tabs track, inner card elements |
radius-xl |
14px | Cards, tiles and media cards (outer) — hand-built surfaces too |
radius-full |
9999px | Badges (counts), switches, avatars, status dots |
Effects & Atmosphere
Section titled “Effects & Atmosphere”Glass Morphism
Section titled “Glass Morphism”Applied to filter bars, detail panels, login card, and mobile header:
.glass { backdrop-filter: blur(20px); background: oklch(0.21 0.008 270 / 70%); border: 1px solid oklch(1 0 0 / 5%);}Film Grain / Noise Overlay
Section titled “Film Grain / Noise Overlay”A subtle SVG noise texture is overlaid on the entire page at 2.5% opacity using mix-blend-mode: overlay. This adds organic texture that prevents the dark surfaces from feeling digitally flat.
Atmospheric Gradient
Section titled “Atmospheric Gradient”The main content area has a radial gradient at the top providing subtle ambient light (the .canvas-atmosphere utility):
radial-gradient(ellipse at 50% 0%, oklch(0.22 0.02 270) 0%, transparent 60%)Logo Glow
Section titled “Logo Glow”On the login page, the logo has a pulsing glow animation:
@keyframes logo-glow { 0%, 100% { box-shadow: 0 0 20px oklch(0.50 0.15 270 / 0.15); } 50% { box-shadow: 0 0 40px oklch(0.50 0.15 270 / 0.3); }}Custom Scrollbar
Section titled “Custom Scrollbar”All scrollable areas use thin, translucent scrollbars:
scrollbar-width: thin;scrollbar-color: oklch(1 0 0 / 10%) transparent;Focus Ring
Section titled “Focus Ring”Focus-visible states use a glow effect instead of a hard outline:
box-shadow: 0 0 0 2px var(--ring), 0 0 12px oklch(0.50 0.15 270 / 0.2);Selection
Section titled “Selection”Text selection uses the primary accent color at 30% opacity:
::selection { background: oklch(0.50 0.15 270 / 30%); color: oklch(0.95 0.008 80);}Animation
Section titled “Animation”Entry Animations
Section titled “Entry Animations”Page elements use a staggered fade-in-up animation on load:
@keyframes fade-in-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); }}Apply animate-fade-in-up for single elements, or stagger-children on a parent to auto-stagger up to 12 children (50ms intervals).
Transitions
Section titled “Transitions”| Context | Duration | Easing |
|---|---|---|
| Hover colour changes, nav rows | 150ms | default |
| Media card lift and artwork zoom | 300ms | cubic-bezier(0.22, 1, 0.36, 1) |
| Button press | 200ms | default |
| Sidebar collapse | 300ms | ease-in-out |
| Collapsible expand | 200ms | ease-out |
| Tab changes | instant | — |
Micro-Interactions
Section titled “Micro-Interactions”- Buttons:
active:scale-[0.98]press feedback - Media cards: lift
hover:-translate-y-[5px]with imagegroup-hover:scale-105 - Nav items:
hover:translate-x-pxsubtle rightward shift
Mobile Web App
Section titled “Mobile Web App”Librariarr is installable as a PWA (manifest.ts, display: standalone, theme color #0c0d10) and the layout adapts below the md (768px) breakpoint:
Navigation
Section titled “Navigation”Below md, the shell renders a glass header whose hamburger button opens the full navigation drawer (a Sheet containing the sidebar). There is no bottom tab bar — all destinations live in the drawer.
Safe Areas
Section titled “Safe Areas”The viewport uses viewport-fit=cover; utilities pad content clear of notches and the home indicator (env() resolves to 0 in regular browser tabs):
| Class | Padding |
|---|---|
.pt-safe |
env(safe-area-inset-top) — mobile header, drawer, full-screen item panel |
.pb-safe |
env(safe-area-inset-bottom) — main scroll container, drawer, filter sheet, session sheet |
.px-safe |
env(safe-area-inset-left/right) — the app frame and the full-screen item panel, for the notch in landscape |
.pl-safe |
env(safe-area-inset-left) — the navigation drawer |
These are plain (unlayered) classes, so they override a padding utility on the same element; where an element also needs its own padding, add the inset to it instead (pb-[calc(1rem+env(safe-area-inset-bottom))]).
Viewport Heights
Section titled “Viewport Heights”Size frames and caps with dvh, never vh: a phone browser’s 100vh is its height with the toolbars hidden, so a frame sized h-screen or min-h-screen runs behind the toolbars while they show, and a dialog or sheet capped at 85vh can be taller than the screen. Use h-dvh, min-h-dvh, h-[55dvh] and max-h-[85dvh]. tests/unit/app/viewport-units.test.ts refuses h-screen, min-h-screen and any h-[…vh], min-h-[…vh] or max-h-[…vh].
Phone Widths
Section titled “Phone Widths”Every page must fit a 320px screen without scrolling sideways. The shell’s <main> clips horizontal overflow, so anything too wide is either cut off at the screen edge or, inside a page’s own scroll box, makes the whole page slide left and right. e2e/mobile-layout.spec.ts checks every page at 375px and 320px.
- Toolbars wrap. A row of buttons uses
flex flex-wrapwith a gap, never a single unwrappedflexrow. - Card headers stack below
sm. A header holding a title, chips and action buttons isflex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between, withmin-w-0on the title block andflex-wrapon its chip row. - Tab strips scroll inside themselves. A segmented control or tab bar carries
max-w-full overflow-x-autoso it scrolls within its own box rather than widening the page. - Tables scroll sideways, the page does not. Wide tables sit in their own
overflow-x-autowrapper. - Truncation is for secondary text. A button’s label or a figure that matters wraps (
line-clamp-2) on a phone rather than ending in “…”.
Component Patterns
Section titled “Component Patterns”Default card styling:
bg-card rounded-xlborder border-white/6shadow-[0_1px_3px_0_rgb(0_0_0/0.1),inset_0_1px_0_oklch(1_0_0/4%)]Cards use an inset top highlight (inset_0_1px_0) to simulate a subtle light edge, adding perceived depth. A surface built by hand instead of with <Card> (bg-card border …) uses the same rounded-xl corner, never rounded-lg. A card or row is never marked with a coloured left border; tint it (bg-green-dim) or ring it (ring-1 ring-green/40) instead, and never add a decorative gradient — the only gradients are the atmosphere, the auth backdrop, the sidebar wash and the scrims that keep text legible over artwork.
Buttons
Section titled “Buttons”Six variants: default, destructive, outline, secondary, ghost, link
Eight sizes: default (h-9), xs (h-6), sm (h-8), lg (h-10), icon (9), icon-xs (6), icon-sm (8), icon-lg (10)
All buttons include active:scale-[0.98] and transition-all duration-200.
Chips vs Badges
Section titled “Chips vs Badges”Two shapes, never interchangeable:
ColorChip(src/components/color-chip.tsx) — the rounded-rect chip (rounded px-1.5 py-0.5 text-xs font-medium leading-none border). Use it for every value, status, label, tag or criterion shown about something: quality chips (QualityChip), ratings (RatingChip), server types, media types, stream decisions, action and request statuses, Arr tags, matched criteria, API-key scopes, release markers, schedule types, a “N matches” figure in a card’s meta row, and removable tokens in a picker (a server, a tag, an excluded user — keep the remove button inside the chip). Colour it with the dim tokens:border-green/25 bg-green-dim text-green(success, active, managed),border-amber/25 bg-amber-dim text-amber(pending, warning, transcode),border-red/25 bg-red-dim text-red(failed, expired, destructive),border-sky/25 bg-sky-dim text-sky(informational),bg-muted/40 text-muted-foreground(neutral). A plainColorChipwith no classes is a neutral fact (user, player, codec, bitrate;font-mono tabular-numsfor figures).Badge(shadcn,rounded-full) — only for a bare count attached to a control or a heading: the active-filter count on the Filters button, the log-level count on its dropdown, the session count beside “Active Sessions”, the play count beside the History title, a group’s count on a collapsible header. Also the selection summary inside a filter trigger.
ColorChip does not forward a ref, so under a Radix TooltipTrigger asChild wrap it in a <span className="inline-flex">. Give a chip a title when its text abbreviates something.
Badges
Section titled “Badges”Rounded-full pills with backdrop-blur-sm for use over images. Variants: default, secondary, destructive, outline, ghost, link. See Chips vs Badges above for when a Badge is the right shape.
Media Cards
Section titled “Media Cards”Poster-forward cards with cinematic hover:
hover:-translate-y-[5px]hover:shadow-[0_18px_40px_-12px_oklch(0_0_0/0.65)]hover:ring-1 hover:ring-white/10transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]Image zooms on hover: group-hover:scale-105
Tab Navigation
Section titled “Tab Navigation”Border-bottom style tabs with icon support:
- Active:
border-brand-bright text-foregroundwith 2px bottom border - Inactive:
border-transparent text-muted-foregroundwith hover fade-in
Sidebar Navigation
Section titled “Sidebar Navigation”- Width: 248px open, 72px collapsed to an icon rail
- Active item:
bg-brand-dim text-foreground, its icontext-brand-bright, and a 3pxbrand-brightbar glowing (shadow-[0_0_12px_var(--brand)]) on the rail’s left edge - Inactive item:
text-muted-foregroundwith hover totext-foregroundonbg-surface-2 - Group labels:
font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-faint - Gradient overlay: Top wash from
brand-faintto transparent for atmospheric depth
Dialogs
Section titled “Dialogs”Dialogs use backdrop-blur-sm on the overlay and border-white/6 on the content panel.
An “Are you sure?” prompt is a ConfirmDialog (src/components/confirm-dialog.tsx), never an AlertDialog written out by hand:
title,description(text or inline content) and, for block content such as a checkbox or a list,childrenconfirmLabeland an optionalconfirmIcon;destructivefor an action that removes something (a red confirm button)onConfirmmay return a promise: the dialog stays open with the confirm button spinning until it settles, closes when it resolves, and stays open when it resolves tofalseor rejects, so a failed save is not dismissed. Passpendinginstead when the caller tracks the running state itselfconfirmDisabledfor a requirement inchildrenthat is not met yet
Forms: Buttons, Errors and Connection Tests
Section titled “Forms: Buttons, Errors and Connection Tests”- Button labels are Title Case (“New Rule Set”, “Reset to Defaults”, “Test Connection”, “Clear All”); descriptions, toasts and dialog bodies are sentence case.
- A button that starts an action is a
LoadingButton(src/components/loading-button.tsx): itsiconturns into a spinner of the same size whileloading, and the button is disabled meanwhile, so the label never moves. Don’t write{busy ? <Loader2 … /> : <Icon … />}by hand. Size an icon smaller than 16px with asize-*class (iconClassName="size-3.5"):Buttonmakes every icon without onesize-4, which overridesh-3.5 w-3.5. - A failure message inside a form or a card is an
InlineError(src/components/inline-error.tsx): the red box with an alert icon. Usemultilinewhen the message can wrap, so the icon stays on the first line. A failure that is not tied to a form is a toast. - A connection test’s answer is a
ConnectionTestResult(src/components/connection-test-result.tsx):variant="banner"under a server form (“Connection successful” / “Connection failed — reason”),variant="badge"beside an integration’s Test button, naming what answered (“Connected to Radarr v5.2”). A test that is not a connection passes its ownlabel(the OIDC discovery check readsDiscovery OK: <issuer>). The badge wraps a long answer instead of running past the card.
Toasts
Section titled “Toasts”Transient feedback uses sonner via the global <Toaster> mounted in src/app/layout.tsx (position="bottom-right", richColors, closeButton, duration={4000}, and a mobileOffset that keeps toasts clear of the phone safe area). Import toast from sonner in any client component.
When to toast:
- Every user-triggered mutation (save, create, update, delete, toggle, test, run, sync) gets feedback:
toast.successon success andtoast.erroron failure. Don’t leave a mutation silent. - Background jobs that change state surface their outcome — e.g. library sync start/complete/failure (
SyncIndicator). - Keep the title short and imperative (“Server added”, “Sync failed”); put the detail (item name, error message) in
description.
When not to toast:
- Incidental auto-saves with their own immediate visual feedback (accent color, chip colors, dashboard drag-rearrange) only toast on error — a success toast on every drag/pick is noise.
- Navigation that immediately replaces the page (e.g. logout) — the toast would unmount before it’s read.
Toasts auto-dismiss after 4s and are dismissable via the close button; never set a long/Infinity duration for routine feedback.
Naming TV Items
Section titled “Naming TV Items”An episode row’s own title is the episode’s name (“Pilot”). The show is its parentTitle. Name TV items through src/lib/media/display-title.ts:
- Anything that stands for a show, such as a series row, a series lifecycle match or action, or a group of excepted episodes, is named by the show. Use
seriesTitleOf. Never use the title of the representative episode it is stored against. - Anything that stands for one episode is named
<Show> SxxExx. UseformatEpisodeTitle(for example “Breaking Bad S01E01”), orformatMediaItemTitlefor a row of any type. This covers log lines, toasts, dialogs, hover-popover titles and card headings. - The episode’s own title is fine where the show is already on screen, such as the episode list of a season page, or as secondary detail beside the show and code.
- For lifecycle actions use
actionTargetTitleandactionTitleSnapshot(src/lib/lifecycle/action-target.ts). An action stored against an episode may act on the whole show or on exactly that episode.
Quality Chips
Section titled “Quality Chips”The coloured resolution, dynamic-range, audio-profile and audio-codec chips — and the quality bar under a card’s artwork, drawn from the same values — follow one set of rules, held in src/lib/media/quality-chips.ts and drawn by QualityChip / QualityChips (src/components/quality-chips.tsx). Never build a chip or a bar segment by hand.
- What an item shows: a film or episode its resolution, dynamic range and audio profile; a track its audio codec; a show or season its resolution counts; an artist or album its codec counts (
qualityChips(source, "video" | "music")). The same set everywhere: card bars, table cells, hover popovers, detail-page heroes and the side panel. - Missing values: no chip. A table cell shows a plain
-instead — never a grey “-”, “Unknown” or “SDR” chip. - Labels: a resolution shows its standard label (
4K,1080P…) and a value that maps to none its own spelling; SDR is shown like any other dynamic range; an audio codec is upper-cased; a count readslabel: nin the chip’s colour. - Order: counts follow
QUALITY_ORDERandAUDIO_CODEC_ORDER, then the rest by name, with empty buckets dropped. - Card bars:
qualitySegments(chips, getHex, show)— one segment per chip, weighted by its count, filtered by the card-display toggles. SDR draws no segment: it is the baseline every file has, so the bar under the artwork marks only what sets an item apart (the SDR chip still shows in tables, popovers and detail pages). Use the exportedQualityBarfor a bar outsideMediaCard. - Every other info pill beside them is a
ColorChiptoo, never a shadcnBadge(see Chips vs Badges): neutral facts (user, player, codec, container, bitrate) are a plainColorChip(font-mono tabular-numsfor figures), a stream decision follows Stream Decisions below, a server is coloured by its type (SERVER_TYPE_STYLES), and ratings areRatingChip. A resolution named outside a media item (a Seerr request’s “4K”) is aQualityChiptoo, so it takes the user’s resolution colour. - Per-stream values (a stream’s
videoRangeType, an audio stream’s profile) are the server’s own spellings; colour them by the range or profile they name (detectStreamDynamicRange,detectStreamAudioProfile), with the raw value as the chip’s title.
Stream Decisions
Section titled “Stream Decisions”Whether a play was transcoded is decided in one place, streamDecision in src/lib/media/stream-decision.ts, for a live session in the Stream Manager, a row on the History page and a play in a detail page’s play list alike, so a play keeps its label after it ends:
| Decision | When | Chip (decisionChipClass) |
|---|---|---|
| Transcode | Any re-encode, video or audio — an audio-only transcode included | bg-amber-dim text-amber border-amber/25 |
| Direct Stream | A copy decision: a remux, the container changes and nothing is re-encoded |
bg-sky-dim text-sky border-sky/25 |
| Direct Play | Everything else | bg-green-dim text-green border-green/25 |
streamDecision returns null when nothing is known (every row from a server’s native history); show no chip then. Never decide on isTranscode alone, and never colour a Direct Stream green.
Hover Popovers
Section titled “Hover Popovers”Every MediaHoverPopover must pass the same universal set of fields regardless of page, view type (table or card), or data source. All fields are optional — sections hide gracefully when data is absent. The component renders only what is non-null, so passing all fields is always safe.
Universal field set:
title, year, summary, contentRating, rating, audienceRating, ratingImage, audienceRatingImage, duration, resolution, dynamicRange, audioProfile, audioCodec, fileSize, genres, studio, playCount, lastPlayedAt, addedAt, servers, seasonCount, episodeCount, albumCount, trackCount, qualityCounts, audioCodecCounts
For grouped data sources (series shows, seasons, music artists, albums), metadata fields (summary, genres, studio, contentRating, rating, etc.) are aggregated from a representative child item via first-non-null picking in the API.
Build the data, don’t write it. src/lib/media/hover-data.ts has one builder per kind of row — hoverDataFromListItem (a film, an episode, a track, or a Query row that may be grouped), hoverDataFromSeriesGroup, hoverDataFromSeason, hoverDataFromArtistGroup, hoverDataFromAlbum and hoverDataFromRecord (a full /api/media/[id] record). Pass the builder’s result as data; a hand-written data={{ … }} object is how a grouped music row once came to say “episodes”.
Rules:
- Table and card views within the same page must always pass identical fields — no exceptions
- Pages fed by the flat list endpoints type their rows as
MediaListItem, which has nosummary— passing one is a compile error, so this cannot silently regress summarymay be passed or fetched: the large list endpoints (movies, all-episodes, all-tracks) leave it out of their payloads because it is a paragraph of prose per row that only this popover renders. Those pages passsummaryUrl={hoverSummaryUrl(item.id)}instead and the popover fetches the summary for the one hovered item when it opens. The URL is the item’s own record, the one the detail panel and page read, so a click that follows the hover reuses that request instead of making another. Every other field still comes from the list, so the popover paints immediately either wayimageAspect="square"for music items;"poster"(default) for everything elseaudioCodec(one track) andaudioCodecCounts(an artist or album) are music-only;qualityCountsis series-only. A popover given anaudioCodecshows the track’s codec chip instead of the video chips, so never pass it for a film or episode —hoverDataFromRecordstrips it from a raw/api/media/[id]record that is not a track- A grouped row (a show, artist or album standing for its members) passes its counts, never its representative episode’s or track’s own
resolution,dynamicRange,audioProfileoraudioCodec - When an item can be either individual or grouped (e.g. query results), conditionally pass the appropriate fields based on whether it’s grouped (e.g.
seasonCount/episodeCountfor grouped series,duration/resolutionfor individual items)
Page Layout Patterns
Section titled “Page Layout Patterns”Authenticated Pages
Section titled “Authenticated Pages”All authenticated pages share this structure:
Sidebar (collapsible 72px/248px) | Main content (flex-1, overflow-y-auto)The main content area includes the atmospheric radial gradient and a 1px border-l border-white/3 separator.
Page Headers
Section titled “Page Headers”Every page’s heading is a PageHeader (src/components/page-header.tsx):
<PageHeader title="Lifecycle Rules" description="Define rules to automatically manage your media library." actions={<Button>New Rule Set</Button>}/>It renders the <h1> in the required style, text-2xl sm:text-3xl font-bold font-display tracking-tight, with the description under it in text-muted-foreground. icon goes before the title, meta (a count, a status chip) beside it, wrapping under it on a narrow screen. actions sit to the right of the heading and drop below it on a phone, so a row of buttons never pushes the page sideways at 320px; actionsInline keeps a single small control (a library page’s Sync button) beside the heading on a phone too.
Lifecycle Pages
Section titled “Lifecycle Pages”All lifecycle pages (Rules, Matches, Pending Actions, Exceptions) include a TabNav for filtering by media type (Movies / Series / Music), plus an “All” tab on Matches and Pending Actions.
Login / Onboarding
Section titled “Login / Onboarding”Full-screen cinematic gradient background (the .auth-backdrop utility — a blue bloom at the horizon, oklch(0.55 0.2 264 / 0.2), over a navy center falloff) with glass-morphism card. Frames here use min-h-dvh.
Status Colors
Section titled “Status Colors”Log Levels
Section titled “Log Levels”Log levels are coloured mono text and an icon, not chips.
| Level | Text class |
|---|---|
| DEBUG | text-faint |
| INFO | text-sky |
| WARN | text-amber |
| ERROR | text-red |
Action Statuses
Section titled “Action Statuses”Lifecycle action statuses are ColorChips.
| Status | Chip classes |
|---|---|
| Pending | bg-amber/20 text-amber border-amber/30 |
| Completed | bg-green/20 text-green border-green/30 |
| Failed | bg-red/20 text-red border-red/30 |
Don’ts
Section titled “Don’ts”- Don’t use flat grey — all dark surfaces should have the navy tint (hue ~270)
- Don’t use HSL — the entire color system is OKLCH for perceptual consistency
- Don’t use generic fonts — never fall back to Inter, Roboto, Arial, or system fonts
- Don’t use a
Badgefor a status, label or tag — that is aColorChip; aBadgeis only a count on a control or heading - Don’t edit shadcn/ui primitives in
src/components/ui/unless adding project-wide enhancements - Don’t use light mode — the application enforces dark mode only
- Don’t add emojis or text glyphs as icons (
✓,⚠,👤) — use Lucide icons for all iconography - Don’t write raw Tailwind palette colours (
text-green-500,bg-amber-500/10,text-blue-400) — use the status tokens or an identity token - Don’t hand-write a spinner button, a red error box, a connection-test line or an “Are you sure?” dialog — use
LoadingButton,InlineError,ConnectionTestResultandConfirmDialog - Don’t write a page
<h1>by hand — usePageHeader - Don’t size anything in
vh— usedvh - Don’t mark a card or row with a coloured left border, and don’t add decorative gradients