Files
PubScript/packages/ui/src/theme.ts
T

332 lines
9.2 KiB
TypeScript

/**
* 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<ThemeId, string> = {
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<string, string>;
}
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
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<FontSettings>): 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");
}