feat(web): add theme definition system with 5 built-in themes (#493)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #493.
This commit is contained in:
2026-02-23 13:59:01 +00:00
committed by jason.woltje
parent f435d8e8c6
commit cfd1def4a9
10 changed files with 575 additions and 21 deletions

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { darkTheme } from "../dark";
import { draculaTheme } from "../dracula";
import { lightTheme } from "../light";
import { nordTheme } from "../nord";
import {
DEFAULT_THEME_ID,
getAllThemes,
getDarkThemes,
getLightThemes,
getTheme,
getThemeOrDefault,
isValidThemeId,
} from "../registry";
import { solarizedDarkTheme } from "../solarized-dark";
import type { ThemeColors, ThemeDefinition } from "../types";
import { themeToVariables } from "../types";
const ALL_THEMES = [darkTheme, lightTheme, nordTheme, draculaTheme, solarizedDarkTheme];
const REQUIRED_COLOR_KEYS: (keyof ThemeColors)[] = [
"bg-950",
"bg-900",
"bg-850",
"surface-800",
"surface-750",
"border-700",
"text-100",
"text-300",
"text-500",
"blue-500",
"blue-400",
"red-500",
"red-400",
"purple-500",
"purple-400",
"teal-500",
"teal-400",
"amber-500",
"amber-400",
"pink-500",
"emerald-500",
"orange-500",
"cyan-500",
"indigo-500",
];
describe("Theme Registry", () => {
it("getAllThemes returns all 5 built-in themes", () => {
const themes = getAllThemes();
expect(themes).toHaveLength(5);
expect(themes.map((t) => t.id)).toEqual(["dark", "light", "nord", "dracula", "solarized-dark"]);
});
it("getTheme returns correct theme by id", () => {
expect(getTheme("dark")).toBe(darkTheme);
expect(getTheme("light")).toBe(lightTheme);
expect(getTheme("nord")).toBe(nordTheme);
expect(getTheme("dracula")).toBe(draculaTheme);
expect(getTheme("solarized-dark")).toBe(solarizedDarkTheme);
});
it("getTheme returns undefined for unknown id", () => {
expect(getTheme("nonexistent")).toBeUndefined();
});
it("getThemeOrDefault falls back to dark theme", () => {
expect(getThemeOrDefault("nonexistent")).toBe(darkTheme);
expect(getThemeOrDefault("dark")).toBe(darkTheme);
});
it("getDarkThemes returns only dark themes", () => {
const dark = getDarkThemes();
expect(dark.every((t) => t.isDark)).toBe(true);
expect(dark).toHaveLength(4);
});
it("getLightThemes returns only light themes", () => {
const light = getLightThemes();
expect(light.every((t) => !t.isDark)).toBe(true);
expect(light).toHaveLength(1);
expect(light[0]?.id).toBe("light");
});
it("isValidThemeId validates correctly", () => {
expect(isValidThemeId("dark")).toBe(true);
expect(isValidThemeId("light")).toBe(true);
expect(isValidThemeId("nope")).toBe(false);
});
it("DEFAULT_THEME_ID is dark", () => {
expect(DEFAULT_THEME_ID).toBe("dark");
});
it("getAllThemes returns a copy, not the internal array", () => {
const a = getAllThemes();
const b = getAllThemes();
expect(a).not.toBe(b);
expect(a).toEqual(b);
});
});
describe("Theme Definitions", () => {
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
"%s has all required fields",
(_id, theme: ThemeDefinition) => {
expect(theme.id).toBeTruthy();
expect(theme.name).toBeTruthy();
expect(theme.description).toBeTruthy();
expect(theme.author).toBeTruthy();
expect(typeof theme.isDark).toBe("boolean");
expect(theme.colorPreview).toHaveLength(5);
}
);
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
"%s has all required color tokens",
(_id, theme: ThemeDefinition) => {
for (const key of REQUIRED_COLOR_KEYS) {
expect(theme.colors[key], `missing color: ${key}`).toBeTruthy();
expect(theme.colors[key]).toMatch(/^#[0-9a-f]{6}$/i);
}
}
);
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
"%s has valid shadow definitions",
(_id, theme: ThemeDefinition) => {
expect(theme.shadows.sm).toBeTruthy();
expect(theme.shadows.md).toBeTruthy();
expect(theme.shadows.lg).toBeTruthy();
}
);
it.each(ALL_THEMES.map((t) => [t.id, t] as const))(
"%s colorPreview values are valid hex colors",
(_id, theme: ThemeDefinition) => {
for (const color of theme.colorPreview) {
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
}
}
);
it("all theme IDs are unique", () => {
const ids = ALL_THEMES.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("themeToVariables", () => {
it("maps color tokens to --ms-* CSS variables", () => {
const vars = themeToVariables(darkTheme);
expect(vars["--ms-bg-900"]).toBe("#0f141d");
expect(vars["--ms-blue-500"]).toBe("#2f80ff");
expect(vars["--ms-text-100"]).toBe("#eef3ff");
});
it("includes shadow variables", () => {
const vars = themeToVariables(darkTheme);
expect(vars["--shadow-sm"]).toBe(darkTheme.shadows.sm);
expect(vars["--shadow-md"]).toBe(darkTheme.shadows.md);
expect(vars["--shadow-lg"]).toBe(darkTheme.shadows.lg);
});
it("generates correct number of variables (24 colors + 3 shadows)", () => {
const vars = themeToVariables(darkTheme);
expect(Object.keys(vars)).toHaveLength(27);
});
});

View File

@@ -0,0 +1,41 @@
import type { ThemeDefinition } from "./types";
export const darkTheme: ThemeDefinition = {
id: "dark",
name: "Dark",
description: "Default dark theme — deep navy with vibrant accents",
author: "Mosaic Stack",
isDark: true,
colorPreview: ["#0f141d", "#1b2331", "#eef3ff", "#2f80ff", "#8b5cf6"],
colors: {
"bg-950": "#080b12",
"bg-900": "#0f141d",
"bg-850": "#151b26",
"surface-800": "#1b2331",
"surface-750": "#232d3f",
"border-700": "#2f3b52",
"text-100": "#eef3ff",
"text-300": "#c5d0e6",
"text-500": "#8f9db7",
"blue-500": "#2f80ff",
"blue-400": "#56a0ff",
"red-500": "#e5484d",
"red-400": "#f06a6f",
"purple-500": "#8b5cf6",
"purple-400": "#a78bfa",
"teal-500": "#14b8a6",
"teal-400": "#2dd4bf",
"amber-500": "#f59e0b",
"amber-400": "#fbbf24",
"pink-500": "#ec4899",
"emerald-500": "#10b981",
"orange-500": "#f97316",
"cyan-500": "#06b6d4",
"indigo-500": "#6366f1",
},
shadows: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4)",
},
};

View File

@@ -0,0 +1,45 @@
import type { ThemeDefinition } from "./types";
/**
* Dracula theme — dark theme with vibrant neon accents.
* Based on https://draculatheme.com/
*/
export const draculaTheme: ThemeDefinition = {
id: "dracula",
name: "Dracula",
description: "Dark theme with vibrant, neon-inspired accents",
author: "Zeno Rocha",
isDark: true,
colorPreview: ["#282a36", "#44475a", "#f8f8f2", "#7b93db", "#ff79c6"],
colors: {
"bg-950": "#1e1f29",
"bg-900": "#282a36",
"bg-850": "#2d303d",
"surface-800": "#343746",
"surface-750": "#44475a",
"border-700": "#555a78",
"text-100": "#f8f8f2",
"text-300": "#d4d4cd",
"text-500": "#6272a4",
"blue-500": "#7b93db",
"blue-400": "#99aee6",
"red-500": "#ff5555",
"red-400": "#ff7777",
"purple-500": "#bd93f9",
"purple-400": "#caa9fa",
"teal-500": "#50fa7b",
"teal-400": "#69ff93",
"amber-500": "#f1fa8c",
"amber-400": "#f5fca6",
"pink-500": "#ff79c6",
"emerald-500": "#50fa7b",
"orange-500": "#ffb86c",
"cyan-500": "#8be9fd",
"indigo-500": "#8b8fe8",
},
shadows: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4)",
},
};

View File

@@ -0,0 +1,18 @@
export type { ThemeColors, ThemeDefinition, ThemeShadows, ThemeColorKey } from "./types";
export { themeToVariables } from "./types";
export { darkTheme } from "./dark";
export { lightTheme } from "./light";
export { nordTheme } from "./nord";
export { draculaTheme } from "./dracula";
export { solarizedDarkTheme } from "./solarized-dark";
export {
getAllThemes,
getTheme,
getThemeOrDefault,
getDarkThemes,
getLightThemes,
isValidThemeId,
DEFAULT_THEME_ID,
} from "./registry";

View File

@@ -0,0 +1,41 @@
import type { ThemeDefinition } from "./types";
export const lightTheme: ThemeDefinition = {
id: "light",
name: "Light",
description: "Clean light theme — soft blues with crisp contrast",
author: "Mosaic Stack",
isDark: false,
colorPreview: ["#f0f4fc", "#dde4f2", "#0f141d", "#2f80ff", "#8b5cf6"],
colors: {
"bg-950": "#f8faff",
"bg-900": "#f0f4fc",
"bg-850": "#e8edf8",
"surface-800": "#dde4f2",
"surface-750": "#d0d9ec",
"border-700": "#b8c4de",
"text-100": "#0f141d",
"text-300": "#2f3b52",
"text-500": "#5a6a87",
"blue-500": "#2f80ff",
"blue-400": "#56a0ff",
"red-500": "#e5484d",
"red-400": "#f06a6f",
"purple-500": "#8b5cf6",
"purple-400": "#a78bfa",
"teal-500": "#14b8a6",
"teal-400": "#2dd4bf",
"amber-500": "#f59e0b",
"amber-400": "#fbbf24",
"pink-500": "#ec4899",
"emerald-500": "#10b981",
"orange-500": "#f97316",
"cyan-500": "#06b6d4",
"indigo-500": "#6366f1",
},
shadows: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08)",
},
};

View File

@@ -0,0 +1,45 @@
import type { ThemeDefinition } from "./types";
/**
* Nord theme — Arctic, north-bluish palette.
* Based on https://www.nordtheme.com/
*/
export const nordTheme: ThemeDefinition = {
id: "nord",
name: "Nord",
description: "Arctic, north-bluish color palette inspired by the beauty of the arctic",
author: "Arctic Ice Studio",
isDark: true,
colorPreview: ["#2e3440", "#3b4252", "#eceff4", "#5e81ac", "#b48ead"],
colors: {
"bg-950": "#242933",
"bg-900": "#2e3440",
"bg-850": "#333a47",
"surface-800": "#3b4252",
"surface-750": "#434c5e",
"border-700": "#4c566a",
"text-100": "#eceff4",
"text-300": "#d8dee9",
"text-500": "#7b88a1",
"blue-500": "#5e81ac",
"blue-400": "#81a1c1",
"red-500": "#bf616a",
"red-400": "#d08787",
"purple-500": "#b48ead",
"purple-400": "#c4a5bf",
"teal-500": "#8fbcbb",
"teal-400": "#88c0d0",
"amber-500": "#ebcb8b",
"amber-400": "#f0d8a8",
"pink-500": "#c97fba",
"emerald-500": "#a3be8c",
"orange-500": "#d08770",
"cyan-500": "#88c0d0",
"indigo-500": "#7b88a1",
},
shadows: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.25)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.25)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.35)",
},
};

View File

@@ -0,0 +1,50 @@
import { darkTheme } from "./dark";
import { draculaTheme } from "./dracula";
import { lightTheme } from "./light";
import { nordTheme } from "./nord";
import { solarizedDarkTheme } from "./solarized-dark";
import type { ThemeDefinition } from "./types";
/** All built-in themes, ordered for display */
const builtInThemes: ThemeDefinition[] = [
darkTheme,
lightTheme,
nordTheme,
draculaTheme,
solarizedDarkTheme,
];
const themeMap = new Map<string, ThemeDefinition>(builtInThemes.map((t) => [t.id, t]));
/** Default theme when no preference is set */
export const DEFAULT_THEME_ID = "dark";
/** Get all registered themes */
export function getAllThemes(): ThemeDefinition[] {
return [...builtInThemes];
}
/** Get a theme by ID, or undefined if not found */
export function getTheme(id: string): ThemeDefinition | undefined {
return themeMap.get(id);
}
/** Get a theme by ID, falling back to the default dark theme */
export function getThemeOrDefault(id: string): ThemeDefinition {
return themeMap.get(id) ?? darkTheme;
}
/** Get only dark themes */
export function getDarkThemes(): ThemeDefinition[] {
return builtInThemes.filter((t) => t.isDark);
}
/** Get only light themes */
export function getLightThemes(): ThemeDefinition[] {
return builtInThemes.filter((t) => !t.isDark);
}
/** Check if a theme ID is valid */
export function isValidThemeId(id: string): boolean {
return themeMap.has(id);
}

View File

@@ -0,0 +1,45 @@
import type { ThemeDefinition } from "./types";
/**
* Solarized Dark theme — precision colors for machines and people.
* Based on https://ethanschoonover.com/solarized/
*/
export const solarizedDarkTheme: ThemeDefinition = {
id: "solarized-dark",
name: "Solarized Dark",
description: "Precision color palette with selective contrast relationships",
author: "Ethan Schoonover",
isDark: true,
colorPreview: ["#002b36", "#073642", "#fdf6e3", "#268bd2", "#6c71c4"],
colors: {
"bg-950": "#001e26",
"bg-900": "#002b36",
"bg-850": "#04313d",
"surface-800": "#073642",
"surface-750": "#174452",
"border-700": "#2a5565",
"text-100": "#fdf6e3",
"text-300": "#93a1a1",
"text-500": "#657b83",
"blue-500": "#268bd2",
"blue-400": "#4ba2de",
"red-500": "#dc322f",
"red-400": "#e35855",
"purple-500": "#6c71c4",
"purple-400": "#8b8fd3",
"teal-500": "#2aa198",
"teal-400": "#47b5ad",
"amber-500": "#b58900",
"amber-400": "#cba020",
"pink-500": "#d33682",
"emerald-500": "#859900",
"orange-500": "#cb4b16",
"cyan-500": "#36bcb3",
"indigo-500": "#4b66c4",
},
shadows: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.35)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.45), 0 2px 4px -2px rgb(0 0 0 / 0.35)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.55), 0 4px 6px -4px rgb(0 0 0 / 0.45)",
},
};

View File

@@ -0,0 +1,99 @@
/**
* Mosaic Theme System — Type Definitions
*
* Each theme provides a complete set of CSS variable overrides.
* The token names map to `--ms-{key}` CSS variables in globals.css.
*/
export interface ThemeColors {
/** Deepest background (e.g. behind modals) */
"bg-950": string;
/** Main page background */
"bg-900": string;
/** Elevated background (sidebar, panels) */
"bg-850": string;
/** Card/panel surface */
"surface-800": string;
/** Hover/secondary surface */
"surface-750": string;
/** Border color */
"border-700": string;
/** Primary text */
"text-100": string;
/** Secondary text */
"text-300": string;
/** Muted/tertiary text */
"text-500": string;
/** Primary accent */
"blue-500": string;
/** Primary accent lighter */
"blue-400": string;
/** Danger/error */
"red-500": string;
/** Danger lighter */
"red-400": string;
/** Purple accent */
"purple-500": string;
/** Purple lighter */
"purple-400": string;
/** Success/teal */
"teal-500": string;
/** Success lighter */
"teal-400": string;
/** Warning/amber */
"amber-500": string;
/** Warning lighter */
"amber-400": string;
/** Pink accent */
"pink-500": string;
/** Emerald accent */
"emerald-500": string;
/** Orange accent */
"orange-500": string;
/** Cyan accent */
"cyan-500": string;
/** Indigo accent */
"indigo-500": string;
}
export interface ThemeShadows {
sm: string;
md: string;
lg: string;
}
export interface ThemeDefinition {
/** Unique identifier (used in localStorage + UserPreference) */
id: string;
/** Display name */
name: string;
/** Short description */
description: string;
/** Theme author/credit */
author: string;
/** Whether this is a dark-mode theme */
isDark: boolean;
/** Five representative colors for preview swatches [bg, surface, text, primary, accent] */
colorPreview: [string, string, string, string, string];
/** Color token overrides (maps to --ms-{key} CSS variables) */
colors: ThemeColors;
/** Shadow overrides */
shadows: ThemeShadows;
}
/** The color token keys that map to --ms-{key} CSS variables */
export type ThemeColorKey = keyof ThemeColors;
/** All CSS variable names a theme can set (--ms-{colorKey} + --shadow-{sm|md|lg}) */
export function themeToVariables(theme: ThemeDefinition): Record<string, string> {
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(theme.colors) as [string, string][]) {
vars[`--ms-${key}`] = value;
}
vars["--shadow-sm"] = theme.shadows.sm;
vars["--shadow-md"] = theme.shadows.md;
vars["--shadow-lg"] = theme.shadows.lg;
return vars;
}

View File

@@ -3,9 +3,9 @@
> Single-writer: orchestrator only. Workers read but never modify. > Single-writer: orchestrator only. Workers read but never modify.
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes | | id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | ------ | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ----- | | ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | ---------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
| TW-PLAN-001 | in-progress | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | | 15K | — | | | TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
| TW-THM-001 | not-started | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | TBD | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | | — | 30K | — | | | TW-THM-001 | in-progress | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | — | 30K || |
| TW-THM-002 | not-started | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | TBD | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | — | — | 25K | — | | | TW-THM-002 | not-started | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | TBD | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | — | — | 25K | — | |
| TW-THM-003 | not-started | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | TBD | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | — | — | 25K | — | | | TW-THM-003 | not-started | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | TBD | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | — | — | 25K | — | |
| TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | | | TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | |
@@ -26,9 +26,9 @@
| Metric | Value | | Metric | Value |
| ------------- | ----------------- | | ------------- | ----------------- |
| Total tasks | 16 | | Total tasks | 16 |
| Completed | 0 | | Completed | 1 (PLAN-001) |
| In Progress | 1 (PLAN-001) | | In Progress | 1 (THM-001) |
| Remaining | 15 | | Remaining | 14 |
| PRs merged | — | | PRs merged | — |
| Issues closed | — | | Issues closed | — |
| Milestone | MS18-ThemeWidgets | | Milestone | MS18-ThemeWidgets |