Skip to content

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.

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

“Librariarr” is displayed in the sidebar using the display font:

font-family: Sora
font-weight: 600 (semibold)
letter-spacing: tight (-0.025em)

All colors use the OKLCH color model for perceptual uniformity. The application enforces dark mode only (className="dark" on <html>).

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
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

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.

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.

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)
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.

The design uses layered lightness to create depth. Each surface level is incrementally brighter:

Background 0.16 ← deepest
Sidebar 0.18
Card 0.21
Popover 0.23
Surface-2 0.245
Muted 0.25
Secondary 0.27
Surface-3 0.285 ← lightest surface

All surfaces share a blue hue angle (~270) with very low chroma (0.006–0.015), giving a cohesive navy tint rather than flat grey.

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

Three variable font families, self-hosted through the @fontsource-variable/* packages imported in src/app/layout.tsx, each serving a distinct role:

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.

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.

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

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]

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

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%);
}

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.

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%)

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); }
}

All scrollable areas use thin, translucent scrollbars:

scrollbar-width: thin;
scrollbar-color: oklch(1 0 0 / 10%) transparent;

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);

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);
}

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).

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 —
  • Buttons: active:scale-[0.98] press feedback
  • Media cards: lift hover:-translate-y-[5px] with image group-hover:scale-105
  • Nav items: hover:translate-x-px subtle rightward shift

Librariarr is installable as a PWA (manifest.ts, display: standalone, theme color #0c0d10) and the layout adapts below the md (768px) breakpoint:

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.

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))]).

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].

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-wrap with a gap, never a single unwrapped flex row.
  • Card headers stack below sm. A header holding a title, chips and action buttons is flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between, with min-w-0 on the title block and flex-wrap on its chip row.
  • Tab strips scroll inside themselves. A segmented control or tab bar carries max-w-full overflow-x-auto so 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-auto wrapper.
  • 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 “…”.

Default card styling:

bg-card rounded-xl
border border-white/6
shadow-[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.

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.

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 plain ColorChip with no classes is a neutral fact (user, player, codec, bitrate; font-mono tabular-nums for 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.

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.

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/10
transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]

Image zooms on hover: group-hover:scale-105

Border-bottom style tabs with icon support:

  • Active: border-brand-bright text-foreground with 2px bottom border
  • Inactive: border-transparent text-muted-foreground with hover fade-in
  • Width: 248px open, 72px collapsed to an icon rail
  • Active item: bg-brand-dim text-foreground, its icon text-brand-bright, and a 3px brand-bright bar glowing (shadow-[0_0_12px_var(--brand)]) on the rail’s left edge
  • Inactive item: text-muted-foreground with hover to text-foreground on bg-surface-2
  • Group labels: font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-faint
  • Gradient overlay: Top wash from brand-faint to transparent for atmospheric depth

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, children
  • confirmLabel and an optional confirmIcon; destructive for an action that removes something (a red confirm button)
  • onConfirm may 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 to false or rejects, so a failed save is not dismissed. Pass pending instead when the caller tracks the running state itself
  • confirmDisabled for a requirement in children that 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): its icon turns into a spinner of the same size while loading, 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 a size-* class (iconClassName="size-3.5"): Button makes every icon without one size-4, which overrides h-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. Use multiline when 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 own label (the OIDC discovery check reads Discovery OK: <issuer>). The badge wraps a long answer instead of running past the card.

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.success on success and toast.error on 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.

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. Use formatEpisodeTitle (for example “Breaking Bad S01E01”), or formatMediaItemTitle for 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 actionTargetTitle and actionTitleSnapshot (src/lib/lifecycle/action-target.ts). An action stored against an episode may act on the whole show or on exactly that episode.

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 reads label: n in the chip’s colour.
  • Order: counts follow QUALITY_ORDER and AUDIO_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 exported QualityBar for a bar outside MediaCard.
  • Every other info pill beside them is a ColorChip too, never a shadcn Badge (see Chips vs Badges): neutral facts (user, player, codec, container, bitrate) are a plain ColorChip (font-mono tabular-nums for figures), a stream decision follows Stream Decisions below, a server is coloured by its type (SERVER_TYPE_STYLES), and ratings are RatingChip. A resolution named outside a media item (a Seerr request’s “4K”) is a QualityChip too, 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.

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.

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 no summary — passing one is a compile error, so this cannot silently regress
  • summary may 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 pass summaryUrl={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 way
  • imageAspect="square" for music items; "poster" (default) for everything else
  • audioCodec (one track) and audioCodecCounts (an artist or album) are music-only; qualityCounts is series-only. A popover given an audioCodec shows the track’s codec chip instead of the video chips, so never pass it for a film or episode — hoverDataFromRecord strips 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, audioProfile or audioCodec
  • 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/episodeCount for grouped series, duration/resolution for individual items)

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.

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.

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.

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.


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

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’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 Badge for a status, label or tag — that is a ColorChip; a Badge is 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, ConnectionTestResult and ConfirmDialog
  • Don’t write a page <h1> by hand — use PageHeader
  • Don’t size anything in vh — use dvh
  • Don’t mark a card or row with a coloured left border, and don’t add decorative gradients