feat: add tabbed Settings, 10 themes with custom accent/text color pickers, main project directory selector, and export filename/directory controls
This commit is contained in:
@@ -2,9 +2,20 @@ import { describe, it, expect } from "vitest";
|
||||
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
|
||||
|
||||
describe("theme token map", () => {
|
||||
it("defines exactly four themes", () => {
|
||||
expect(THEME_IDS).toEqual(["ink", "parchment", "nocturne", "terminal"]);
|
||||
expect(Object.keys(THEME_TOKENS)).toHaveLength(4);
|
||||
it("defines ten themes including the core four", () => {
|
||||
expect(THEME_IDS).toEqual([
|
||||
"ink",
|
||||
"parchment",
|
||||
"nocturne",
|
||||
"terminal",
|
||||
"espresso",
|
||||
"forest",
|
||||
"nordic",
|
||||
"paperback",
|
||||
"sepia",
|
||||
"synthwave",
|
||||
]);
|
||||
expect(Object.keys(THEME_TOKENS)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("labels every theme", () => {
|
||||
|
||||
+179
-28
@@ -1,20 +1,47 @@
|
||||
/**
|
||||
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
|
||||
* Theme engine — CSS-variable token map.
|
||||
*
|
||||
* Four themes ship as token objects; `applyTheme(id)` sets
|
||||
* `document.documentElement.dataset.theme`. Accent color and density are
|
||||
* runtime overrides layered on top.
|
||||
* 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";
|
||||
export type ThemeId =
|
||||
| "ink"
|
||||
| "parchment"
|
||||
| "nocturne"
|
||||
| "terminal"
|
||||
| "espresso"
|
||||
| "forest"
|
||||
| "nordic"
|
||||
| "paperback"
|
||||
| "sepia"
|
||||
| "synthwave";
|
||||
|
||||
export const THEME_IDS: ThemeId[] = ["ink", "parchment", "nocturne", "terminal"];
|
||||
export const THEME_IDS: ThemeId[] = [
|
||||
"ink",
|
||||
"parchment",
|
||||
"nocturne",
|
||||
"terminal",
|
||||
"espresso",
|
||||
"forest",
|
||||
"nordic",
|
||||
"paperback",
|
||||
"sepia",
|
||||
"synthwave",
|
||||
];
|
||||
|
||||
export const THEME_LABELS: Record<ThemeId, string> = {
|
||||
ink: "Ink",
|
||||
parchment: "Parchment",
|
||||
nocturne: "Nocturne",
|
||||
terminal: "Terminal",
|
||||
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 {
|
||||
@@ -23,13 +50,9 @@ export interface ThemeTokens {
|
||||
css: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four themes. Ink is the default (dark). Parchment is warm light,
|
||||
* Nocturne is deep indigo, Terminal is monochrome monospace.
|
||||
*/
|
||||
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
ink: {
|
||||
label: "Ink",
|
||||
label: "Ink (Dark)",
|
||||
css: {
|
||||
"--ps-bg": "#0e0f13",
|
||||
"--ps-surface": "#17181f",
|
||||
@@ -45,7 +68,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
},
|
||||
},
|
||||
parchment: {
|
||||
label: "Parchment",
|
||||
label: "Parchment (Warm Light)",
|
||||
css: {
|
||||
"--ps-bg": "#f6f1e7",
|
||||
"--ps-surface": "#fffdf7",
|
||||
@@ -61,7 +84,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
},
|
||||
},
|
||||
nocturne: {
|
||||
label: "Nocturne",
|
||||
label: "Nocturne (Midnight Indigo)",
|
||||
css: {
|
||||
"--ps-bg": "#0c0b1e",
|
||||
"--ps-surface": "#171533",
|
||||
@@ -77,7 +100,7 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
label: "Terminal",
|
||||
label: "Terminal (Phosphor Mono)",
|
||||
css: {
|
||||
"--ps-bg": "#05070a",
|
||||
"--ps-surface": "#0a0f14",
|
||||
@@ -92,6 +115,102 @@ export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
"--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. */
|
||||
@@ -126,13 +245,54 @@ export function applyTheme(id: ThemeId): ThemeId {
|
||||
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. See PLAN.md §4.1/§4.7.
|
||||
* Runtime font overrides for the writing surface.
|
||||
* These layer on top of the theme's base font families.
|
||||
*/
|
||||
export interface FontSettings {
|
||||
@@ -153,11 +313,6 @@ export const DEFAULT_FONT_SETTINGS: FontSettings = {
|
||||
measure: 720,
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply font settings by writing CSS variables to :root. The prose column
|
||||
* (`.ps-content`) reads these; an empty `family` leaves the theme's body font
|
||||
* in place. Safe to call before the DOM exists (no-op).
|
||||
*/
|
||||
export function applyFontSettings(s: Partial<FontSettings>): void {
|
||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||
if (!root) return;
|
||||
@@ -167,10 +322,6 @@ export function applyFontSettings(s: Partial<FontSettings>): void {
|
||||
if (s.family != null && s.family !== "") root.style.setProperty("--ps-font-body", s.family);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply UI density ("compact" | "comfortable") by setting `data-density` on
|
||||
* `:root` and updating `--ps-density` and `--ps-topbar-h`.
|
||||
*/
|
||||
export function applyDensity(density: "compact" | "comfortable"): void {
|
||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||
if (!root) return;
|
||||
|
||||
Reference in New Issue
Block a user