/** * Theme engine — CSS-variable token map. * * Ten themes ship as token objects; `applyTheme(id)` sets * `document.documentElement.dataset.theme`. Accent color, text color, and * density are runtime overrides layered on top. */ export type ThemeId = | "ink" | "parchment" | "nocturne" | "terminal" | "espresso" | "forest" | "nordic" | "paperback" | "sepia" | "synthwave"; export const THEME_IDS: ThemeId[] = [ "ink", "parchment", "nocturne", "terminal", "espresso", "forest", "nordic", "paperback", "sepia", "synthwave", ]; export const THEME_LABELS: Record = { ink: "Ink (Dark)", parchment: "Parchment (Warm Light)", nocturne: "Nocturne (Midnight Indigo)", terminal: "Terminal (Phosphor Mono)", espresso: "Espresso (Dark Roast)", forest: "Evergreen (Deep Pine)", nordic: "Nordic Frost (Slate Blue)", paperback: "Paperback (Crisp Daylight)", sepia: "Vintage Sepia (Aged Manuscript)", synthwave: "Synthwave (Neon Dusk)", }; export interface ThemeTokens { label: string; /** CSS custom properties applied to :root when this theme is active. */ css: Record; } export const THEME_TOKENS: Record = { ink: { label: "Ink (Dark)", css: { "--ps-bg": "#0e0f13", "--ps-surface": "#17181f", "--ps-surface-2": "#20222c", "--ps-border": "#2a2d3a", "--ps-text": "#e6e8ee", "--ps-text-muted": "#9aa0b0", "--ps-accent": "#7c8bf5", "--ps-accent-contrast": "#0e0f13", "--ps-danger": "#f16d7a", "--ps-success": "#5fd08a", "--ps-warning": "#f0b754", }, }, parchment: { label: "Parchment (Warm Light)", css: { "--ps-bg": "#f6f1e7", "--ps-surface": "#fffdf7", "--ps-surface-2": "#f2ead8", "--ps-border": "#e2d6bd", "--ps-text": "#2c2720", "--ps-text-muted": "#6f6656", "--ps-accent": "#b5793a", "--ps-accent-contrast": "#fffdf7", "--ps-danger": "#c0392b", "--ps-success": "#2e7d4f", "--ps-warning": "#b8860b", }, }, nocturne: { label: "Nocturne (Midnight Indigo)", css: { "--ps-bg": "#0c0b1e", "--ps-surface": "#171533", "--ps-surface-2": "#221f4a", "--ps-border": "#2e2a5e", "--ps-text": "#e8e6ff", "--ps-text-muted": "#a49fcf", "--ps-accent": "#9d7bff", "--ps-accent-contrast": "#0c0b1e", "--ps-danger": "#ff7b9c", "--ps-success": "#6ee7a8", "--ps-warning": "#ffd27b", }, }, terminal: { label: "Terminal (Phosphor Mono)", css: { "--ps-bg": "#05070a", "--ps-surface": "#0a0f14", "--ps-surface-2": "#0f1720", "--ps-border": "#16232e", "--ps-text": "#33ff9b", "--ps-text-muted": "#2c8a63", "--ps-accent": "#33ff9b", "--ps-accent-contrast": "#05070a", "--ps-danger": "#ff6b6b", "--ps-success": "#33ff9b", "--ps-warning": "#ffd93d", }, }, espresso: { label: "Espresso (Dark Roast)", css: { "--ps-bg": "#14100e", "--ps-surface": "#1e1815", "--ps-surface-2": "#2a211d", "--ps-border": "#3b2f29", "--ps-text": "#f2e8df", "--ps-text-muted": "#b39f91", "--ps-accent": "#d98a4e", "--ps-accent-contrast": "#14100e", "--ps-danger": "#e86868", "--ps-success": "#6ac48a", "--ps-warning": "#e6b450", }, }, forest: { label: "Evergreen (Deep Pine)", css: { "--ps-bg": "#0b1410", "--ps-surface": "#12201a", "--ps-surface-2": "#1a2e25", "--ps-border": "#264236", "--ps-text": "#e3f2ea", "--ps-text-muted": "#8fb09e", "--ps-accent": "#52c48c", "--ps-accent-contrast": "#0b1410", "--ps-danger": "#f07178", "--ps-success": "#52c48c", "--ps-warning": "#e5c07b", }, }, nordic: { label: "Nordic Frost (Slate Blue)", css: { "--ps-bg": "#1e222a", "--ps-surface": "#282c34", "--ps-surface-2": "#323844", "--ps-border": "#3e4656", "--ps-text": "#eceff4", "--ps-text-muted": "#9aa5b8", "--ps-accent": "#88c0d0", "--ps-accent-contrast": "#1e222a", "--ps-danger": "#bf616a", "--ps-success": "#a3be8c", "--ps-warning": "#ebcb8b", }, }, paperback: { label: "Paperback (Crisp Daylight)", css: { "--ps-bg": "#f5f7fa", "--ps-surface": "#ffffff", "--ps-surface-2": "#ebeef5", "--ps-border": "#d6dce8", "--ps-text": "#1a202c", "--ps-text-muted": "#5a6578", "--ps-accent": "#3b6ef6", "--ps-accent-contrast": "#ffffff", "--ps-danger": "#d93848", "--ps-success": "#1f8a58", "--ps-warning": "#c47f17", }, }, sepia: { label: "Vintage Sepia (Aged Manuscript)", css: { "--ps-bg": "#efe4ce", "--ps-surface": "#f7eedb", "--ps-surface-2": "#e5d6b8", "--ps-border": "#cfc0a1", "--ps-text": "#362819", "--ps-text-muted": "#6e5840", "--ps-accent": "#9c4b27", "--ps-accent-contrast": "#f7eedb", "--ps-danger": "#b3362a", "--ps-success": "#367347", "--ps-warning": "#a67314", }, }, synthwave: { label: "Synthwave (Neon Dusk)", css: { "--ps-bg": "#12091c", "--ps-surface": "#1c0f2b", "--ps-surface-2": "#28163d", "--ps-border": "#3c225c", "--ps-text": "#f8e9ff", "--ps-text-muted": "#b695cc", "--ps-accent": "#ff4fd8", "--ps-accent-contrast": "#12091c", "--ps-danger": "#ff5c77", "--ps-success": "#36f9b3", "--ps-warning": "#ffe261", }, }, }; /** The active theme id, persisted by the settings store. */ let activeTheme: ThemeId = "ink"; /** Return the token map for a theme id (throws on unknown). */ export function getThemeTokens(id: ThemeId): ThemeTokens { const t = THEME_TOKENS[id]; if (!t) { throw new Error(`Unknown theme: ${id}`); } return t; } /** * Apply a theme by setting the data attribute and writing its CSS variables to * :root. Returns the applied id. Safe to call before the DOM exists (no-op). */ export function applyTheme(id: ThemeId): ThemeId { if (!THEME_TOKENS[id]) { throw new Error(`Unknown theme: ${id}`); } activeTheme = id; const root = typeof document !== "undefined" ? document.documentElement : undefined; if (root) { root.dataset.theme = id; const { css } = THEME_TOKENS[id]!; for (const [k, v] of Object.entries(css)) { root.style.setProperty(k, v); } } return id; } /** * Apply custom user overrides for accent color and/or primary text color on top * of the active theme. Passing an empty string for `textColor` or `accentColor` * restores the active theme's default token value. */ export function applyColorOverrides(overrides: { accentColor?: string | undefined; textColor?: string | undefined; }): void { const root = typeof document !== "undefined" ? document.documentElement : undefined; if (!root) return; const themeCss = THEME_TOKENS[activeTheme]?.css ?? THEME_TOKENS.ink.css; if (overrides.accentColor !== undefined) { const acc = overrides.accentColor.trim(); if (acc) { root.style.setProperty("--ps-accent", acc); } else if (themeCss["--ps-accent"]) { root.style.setProperty("--ps-accent", themeCss["--ps-accent"]!); } } if (overrides.textColor !== undefined) { const txt = overrides.textColor.trim(); if (txt) { root.style.setProperty("--ps-text", txt); root.style.setProperty( "--ps-text-muted", `color-mix(in srgb, ${txt} 68%, var(--ps-bg))`, ); } else { if (themeCss["--ps-text"]) { root.style.setProperty("--ps-text", themeCss["--ps-text"]!); } if (themeCss["--ps-text-muted"]) { root.style.setProperty("--ps-text-muted", themeCss["--ps-text-muted"]!); } } } } /** The currently active theme id. */ export function getActiveTheme(): ThemeId { return activeTheme; } /** * Runtime font overrides for the writing surface. * These layer on top of the theme's base font families. */ export interface FontSettings { /** Body font family override (falls back to the theme's body font). */ family: string; /** Base body font size in px. */ size: number; /** Line height as a unitless multiplier. */ leading: number; /** Prose column max width (measure) in px. */ measure: number; } export const DEFAULT_FONT_SETTINGS: FontSettings = { family: "", size: 18, leading: 1.7, measure: 720, }; export function applyFontSettings(s: Partial): void { const root = typeof document !== "undefined" ? document.documentElement : undefined; if (!root) return; if (s.size != null) root.style.setProperty("--ps-font-size", `${s.size}px`); if (s.leading != null) root.style.setProperty("--ps-font-leading", String(s.leading)); if (s.measure != null) root.style.setProperty("--ps-font-measure", `${s.measure}px`); if (s.family != null && s.family !== "") root.style.setProperty("--ps-font-body", s.family); } export function applyDensity(density: "compact" | "comfortable"): void { const root = typeof document !== "undefined" ? document.documentElement : undefined; if (!root) return; root.dataset.density = density; root.style.setProperty("--ps-density", density === "compact" ? "8px" : "14px"); root.style.setProperty("--ps-topbar-h", density === "compact" ? "40px" : "48px"); }