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:
Schwitzz
2026-10-30 14:06:46 -05:00
parent fa22a755ee
commit 1d209fedd2
8 changed files with 1660 additions and 648 deletions
+14 -3
View File
@@ -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
View File
@@ -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;