332 lines
9.2 KiB
TypeScript
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");
|
|
}
|