/** * The app-wide colour system. Every screen reads colours from here (via useTheme) instead of hardcoding * hex — so light/dark is one switch. Tokens are SEMANTIC (what a colour is for), not literal, so the two * palettes can diverge freely. Light is the original "duck" cream/green/teal look; dark is the "fin" * palette (see the old tailwind config). The immersive full-screen media player is intentionally black in * both themes (its own `player*` tokens); the mini dock follows the theme. */ export type ThemeMode = 'light' | 'dark'; export type ThemePreference = 'light' | 'dark' | 'system'; export type Palette = { mode: ThemeMode; // Surfaces bg: string; // screen background surface: string; // cards, inputs, list-row fills surfaceAlt: string; // section headers, elevated panels overlay: string; // modal scrim // Text text: string; // primary textMuted: string; // secondary textFaint: string; // tertiary / placeholder heading: string; // section / brand headings // Accent accent: string; // primary action / active accentText: string; // text/icon on an accent fill // Brand (the Officer yellow — the same fill in both themes, so it carries its own ink: // palette.text is near-white in dark mode and unreadable on it.) brand: string; // brand-yellow fill for a primary CTA / selected pill brandText: string; // text/icon on a brand fill // Lines border: string; // input & card borders hairline: string; // 1px list separators /* Status. `success` and `warning` complete the state vocabulary the web app already uses — see the platform's `components/Data/tone.ts`, where five tones and no others is the whole point: when a view can reach for any colour, green stops meaning "finished" and starts meaning "the author liked green here". `danger` is the third of them and predates this; `neutral` is textMuted and `info` is accent. Same values as the web's tokens so a state reads identically on both, and like the web these do NOT change between light and dark — a finished thing is the same green wherever you look at it. */ success: string; // finished, healthy, passing warning: string; // in progress, needs attention, not yet failed danger: string; // destructive text/icon dangerText: string; // text on a danger fill dangerBg: string; // error box fill dangerBorder: string; // error box border // Icons icon: string; // default icon tint iconFaint: string; // disabled / placeholder icon // Media player (immersive; near-black in both themes) playerBg: string; playerText: string; playerMuted: string; playerFaint: string; playerAccent: string; playerHeart: string; // active heart dockBg: string; // mini-player dock (follows theme) }; export const lightPalette: Palette = { mode: 'light', bg: '#ede7de', surface: 'rgba(255,255,255,0.6)', surfaceAlt: '#d8d3c9', overlay: 'rgba(0,0,0,0.4)', text: '#14532D', textMuted: 'rgba(20,83,45,0.6)', textFaint: 'rgba(20,83,45,0.4)', heading: '#166534', accent: '#0E7490', accentText: '#ffffff', brand: '#F4C430', brandText: '#0E7490', border: 'rgba(20,83,45,0.2)', hairline: 'rgba(20,83,45,0.08)', // hsl(142.1 76.2% 36.3%) and hsl(43 96% 56%) — the web's --success and --warning, converted. success: '#16A34A', warning: '#FABD23', danger: '#B91C1C', dangerText: '#ffffff', dangerBg: '#fef2f2', dangerBorder: '#fca5a5', icon: '#0E7490', iconFaint: 'rgba(20,83,45,0.35)', playerBg: '#000000', playerText: '#ffffff', playerMuted: 'rgba(255,255,255,0.6)', playerFaint: 'rgba(255,255,255,0.3)', playerAccent: '#0891B2', playerHeart: '#EF4444', dockBg: 'rgba(237,231,222,0.98)', }; export const darkPalette: Palette = { mode: 'dark', bg: '#101418', /* Translucent, mirroring light mode. `#1b2027` at 60%. Light has always been rgba(255,255,255,0.6) — cards there were built to let the wallpaper through — and dark was opaque, so the same card read as a panel on one theme and a pane on the other. Over the dusk photograph (0.12 mean luminance) a dark card at 60% stays dark, so near-white body text keeps its contrast. `surfaceAlt` deliberately stays opaque: it is the "elevated panel" token, and things that float ABOVE content — dropdowns, tab bars — must not have that content reading through them. */ surface: 'rgba(27,32,39,0.6)', surfaceAlt: '#242b34', overlay: 'rgba(0,0,0,0.6)', text: '#e8eaed', /* Secondary text, not faint text. Was #9aa4b2 — a mid grey that held up against an opaque #1b2027 card and stopped holding up the moment the card became translucent over a photograph. Most of the writing on these screens is `textMuted`: subtitles, peer rows, every explanatory line under a heading. It has to be readable first and quiet second, and it was the other way round. Now the same near-white as `text`, at 75%, so the two read as a hierarchy rather than as one colour and both sit on one hue instead of drifting blue-grey apart. It carries less than it used to. Body copy — anything the reader is meant to actually read — was moved to `text`, because the honest answer to "why is this muted if it matters" is that it should not be. What is left here is labels, timestamps and row metadata, which is what secondary means. */ textMuted: 'rgba(232,234,237,0.75)', textFaint: 'rgba(232,234,237,0.55)', heading: '#e8eaed', /* Dark mode is yellow, not blue. The accent was #00a4dc — Jellyfin's blue, inherited when the player was the first thing built here and never revisited. It is the one colour on these screens that belongs to somebody else's product, sitting beside a suite whose entire identity is a yellow duck. So `accent` is now the duck yellow, and everything keyed off it moves together: buttons, icons, selected states, bullets, the emphasised word in the disclosure. Ink on yellow is `duck-forest` — #166534, taken from the platform's own tokens rather than invented here, where it is already paired with duck-yellow. Roughly 4.4:1 against #F4C430: under the 4.5 needed for body text, comfortably over the 3.0 for large bold type, which is all this is used for. Note WHICH forest. The platform lightens it to #4ade80 for dark mode, because there it sits on a dark background. Ours sits on YELLOW — a light surface even in dark mode — so it wants the light theme's dark forest. Taking the dark-mode value would be reading the token by name rather than by the job it does. `accentText` and `brandText` share it, so a yellow button and a yellow tab pill carry the same ink. They did not before. LIGHT MODE IS UNCHANGED, and that is now a decision rather than a deferral (2026-08-07): the redesign is dark-only, across every app. Light keeps the teal accent and its own surfaces. Yellow on cream never has to be solved, because light is not being restyled — and the sign-in screen, which is deliberately always-light, is unaffected by any of this. */ accent: '#F4C430', accentText: '#0891B2', brand: '#F4C430', brandText: '#0891B2', border: 'rgba(255,255,255,0.14)', hairline: 'rgba(255,255,255,0.08)', // Unchanged from light, exactly as the web keeps --success and --warning identical across themes. success: '#16A34A', warning: '#FABD23', danger: '#f87171', dangerText: '#ffffff', dangerBg: 'rgba(248,113,113,0.12)', dangerBorder: 'rgba(248,113,113,0.4)', icon: '#F4C430', iconFaint: 'rgba(232,234,237,0.35)', playerBg: '#000000', playerText: '#ffffff', playerMuted: 'rgba(255,255,255,0.6)', playerFaint: 'rgba(255,255,255,0.3)', playerAccent: '#00a4dc', playerHeart: '#EF4444', dockBg: '#1b2027', }; export const paletteFor = (mode: ThemeMode): Palette => (mode === 'dark' ? darkPalette : lightPalette);