feat(FN-839): add nord, dracula, gruvbox, and tokyo-night color themes

- Add new theme IDs (nord, dracula, gruvbox, tokyo-night) to core types and ThemeSelector component
- Add CSS custom properties for all four themes covering background, text, accent, border, and surface colors
- Add theme validation sync, bootstrap logic, and unit tests for ThemeSelector and useTheme hook
- Update GitManagerModal to support new theme-aware styling
- Update README theme system documentation with expanded theme list
- Add changeset for published package
This commit is contained in:
gsxdsm
2026-04-04 02:44:58 -07:00
parent 2d74a26428
commit fe73a23731
8 changed files with 364 additions and 2 deletions

View File

@@ -24,6 +24,10 @@ export const COLOR_THEMES = [
"factory",
"ayu",
"one-dark",
"nord",
"dracula",
"gruvbox",
"tokyo-night",
] as const;
export type ColorTheme = (typeof COLOR_THEMES)[number];

View File

@@ -29,6 +29,10 @@ const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] =
{ value: "factory", label: "Factory", className: "theme-swatch-factory" },
{ value: "ayu", label: "Ayu", className: "theme-swatch-ayu" },
{ value: "one-dark", label: "One Dark", className: "theme-swatch-one-dark" },
{ value: "nord", label: "Nord", className: "theme-swatch-nord" },
{ value: "dracula", label: "Dracula", className: "theme-swatch-dracula" },
{ value: "gruvbox", label: "Gruvbox", className: "theme-swatch-gruvbox" },
{ value: "tokyo-night", label: "Tokyo Night", className: "theme-swatch-tokyo-night" },
];
/**

View File

@@ -89,6 +89,10 @@ describe("ThemeSelector", () => {
expect(screen.getByLabelText("Factory theme")).toBeDefined();
expect(screen.getByLabelText("Ayu theme")).toBeDefined();
expect(screen.getByLabelText("One Dark theme")).toBeDefined();
expect(screen.getByLabelText("Nord theme")).toBeDefined();
expect(screen.getByLabelText("Dracula theme")).toBeDefined();
expect(screen.getByLabelText("Gruvbox theme")).toBeDefined();
expect(screen.getByLabelText("Tokyo Night theme")).toBeDefined();
});
it("marks current color theme as active", () => {
@@ -127,6 +131,95 @@ describe("ThemeSelector", () => {
expect(onColorThemeChange).toHaveBeenCalledWith("zen");
});
it("calls onColorThemeChange when a new color theme is clicked", () => {
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Nord theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("nord");
fireEvent.click(screen.getByLabelText("Dracula theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("dracula");
fireEvent.click(screen.getByLabelText("Gruvbox theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("gruvbox");
fireEvent.click(screen.getByLabelText("Tokyo Night theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("tokyo-night");
});
it("displays Nord in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="nord"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Nord/)).toBeDefined();
});
it("displays Dracula in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="dracula"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Dracula/)).toBeDefined();
});
it("displays Gruvbox in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="gruvbox"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Gruvbox/)).toBeDefined();
});
it("displays Tokyo Night in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="tokyo-night"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Tokyo Night/)).toBeDefined();
});
it("displays light Tokyo Night in preview when light mode", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="tokyo-night"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Light \/ Tokyo Night/)).toBeDefined();
});
it("displays current theme preview", () => {
render(
<ThemeSelector

View File

@@ -177,6 +177,42 @@ describe("useTheme", () => {
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies nord theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "nord";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("nord");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies dracula theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "dracula";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("dracula");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies gruvbox theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "gruvbox";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("gruvbox");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies tokyo-night theme attributes", () => {
localStorageMock["kb-dashboard-color-theme"] = "tokyo-night";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("tokyo-night");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies factory-specific design tokens from the stylesheet", () => {
const style = document.createElement("style");
style.textContent = readFileSync("app/styles.css", "utf8");

View File

@@ -11,7 +11,7 @@
try {
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'solarized', 'factory', 'ayu', 'one-dark'];
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'solarized', 'factory', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night'];
if (!validThemes.includes(colorTheme)) {
colorTheme = 'default';
}

View File

@@ -8557,6 +8557,186 @@ html .column.drag-over * {
--shadow: var(--shadow-lg);
}
/* NORD - Arctic blue palette inspired by the Nord color scheme */
[data-color-theme="nord"] {
--bg: #2e3440;
--surface: #3b4252;
--card: #434c5e;
--card-hover: #4c566a;
--border: #4c566a;
--text: #eceff4;
--text-muted: #d8dee9;
--text-dim: #81a1c1;
--todo: #88c0d0;
--in-progress: #81a1c1;
--in-progress-rgb: 129, 161, 193;
--in-review: #a3be8c;
--triage: #ebcb8b;
--done: #7b88a1;
--color-success: #a3be8c;
--color-error: #bf616a;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow: var(--shadow-lg);
}
[data-color-theme="nord"][data-theme="light"] {
--bg: #eceff4;
--surface: #e5e9f0;
--card: #d8dee9;
--card-hover: #cdd3e0;
--border: #b0b8c9;
--text: #2e3440;
--text-muted: #4c566a;
--text-dim: #7b88a1;
--todo: #5e81ac;
--in-progress: #5e81ac;
--in-progress-rgb: 94, 129, 172;
--in-review: #7ca373;
--triage: #c98530;
--done: #4c566a;
--color-success: #7ca373;
--color-error: #b54a52;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.1);
--shadow: var(--shadow-lg);
}
/* DRACULA - Popular dark theme with deep purples and vivid accents */
[data-color-theme="dracula"] {
--bg: #282a36;
--surface: #21222c;
--card: #2c2e3a;
--card-hover: #363848;
--border: #44475a;
--text: #f8f8f2;
--text-muted: #bd93f9;
--text-dim: #6272a4;
--todo: #8be9fd;
--in-progress: #bd93f9;
--in-progress-rgb: 189, 147, 249;
--in-review: #50fa7b;
--triage: #f1fa8c;
--done: #6272a4;
--color-success: #50fa7b;
--color-error: #ff5555;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.5);
--shadow: var(--shadow-lg);
}
[data-color-theme="dracula"][data-theme="light"] {
--bg: #f8f8f2;
--surface: #efeefa;
--card: #e8e8f4;
--card-hover: #dddde8;
--border: #b0b0c0;
--text: #282a36;
--text-muted: #5a5a72;
--text-dim: #8888a0;
--todo: #0097a7;
--in-progress: #7c4dff;
--in-progress-rgb: 124, 77, 255;
--in-review: #2e8b57;
--triage: #c98600;
--done: #5a5a72;
--color-success: #2e8b57;
--color-error: #cc3333;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.1);
--shadow: var(--shadow-lg);
}
/* GRUVBOX - Warm, retro earth-tone palette */
[data-color-theme="gruvbox"] {
--bg: #282828;
--surface: #1d2021;
--card: #32302f;
--card-hover: #3c3836;
--border: #504945;
--text: #ebdbb2;
--text-muted: #bdae93;
--text-dim: #665c54;
--todo: #83a598;
--in-progress: #d3869b;
--in-progress-rgb: 211, 134, 155;
--in-review: #b8bb26;
--triage: #fabd2f;
--done: #928374;
--color-success: #b8bb26;
--color-error: #fb4934;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.5);
--shadow: var(--shadow-lg);
}
[data-color-theme="gruvbox"][data-theme="light"] {
--bg: #fbf1c7;
--surface: #f2e5bc;
--card: #ebdbb2;
--card-hover: #e0d0a0;
--border: #bdae93;
--text: #3c3836;
--text-muted: #504945;
--text-dim: #7c7066;
--todo: #076678;
--in-progress: #8f3f71;
--in-progress-rgb: 143, 63, 113;
--in-review: #427b58;
--triage: #b57614;
--done: #504945;
--color-success: #427b58;
--color-error: #9d0006;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.1);
--shadow: var(--shadow-lg);
}
/* TOKYO NIGHT - Deep blues and soft purples inspired by the VS Code theme */
[data-color-theme="tokyo-night"] {
--bg: #1a1b26;
--surface: #16161e;
--card: #24283b;
--card-hover: #2e324e;
--border: #3b4261;
--text: #c0caf5;
--text-muted: #a9b1d6;
--text-dim: #565f89;
--todo: #7aa2f7;
--in-progress: #bb9af7;
--in-progress-rgb: 187, 154, 247;
--in-review: #9ece6a;
--triage: #e0af68;
--done: #565f89;
--color-success: #9ece6a;
--color-error: #f7768e;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.5);
--shadow: var(--shadow-lg);
}
[data-color-theme="tokyo-night"][data-theme="light"] {
--bg: #e1e2e7;
--surface: #d5d6db;
--card: #e1e2e7;
--card-hover: #d0d1d8;
--border: #b4b5bc;
--text: #1a1b26;
--text-muted: #414868;
--text-dim: #68709a;
--todo: #3b5ea5;
--in-progress: #7755c0;
--in-progress-rgb: 119, 85, 192;
--in-review: #48703e;
--triage: #9a6b20;
--done: #414868;
--color-success: #48703e;
--color-error: #c53b53;
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.1);
--shadow: var(--shadow-lg);
}
/* ============================================================
THEME SELECTOR COMPONENT STYLES
============================================================ */
@@ -8741,6 +8921,26 @@ html .column.drag-over * {
--surface: #21252b;
}
.theme-swatch-nord {
--bg: #2e3440;
--surface: #3b4252;
}
.theme-swatch-dracula {
--bg: #282a36;
--surface: #21222c;
}
.theme-swatch-gruvbox {
--bg: #282828;
--surface: #1d2021;
}
.theme-swatch-tokyo-night {
--bg: #1a1b26;
--surface: #16161e;
}
/* Light mode swatch overrides */
[data-theme="light"] .theme-swatch-factory {
--bg: #f5f5f5;
@@ -8757,6 +8957,26 @@ html .column.drag-over * {
--surface: #fafafa;
}
[data-theme="light"] .theme-swatch-nord {
--bg: #eceff4;
--surface: #e5e9f0;
}
[data-theme="light"] .theme-swatch-dracula {
--bg: #f8f8f2;
--surface: #efeefa;
}
[data-theme="light"] .theme-swatch-gruvbox {
--bg: #fbf1c7;
--surface: #f2e5bc;
}
[data-theme="light"] .theme-swatch-tokyo-night {
--bg: #e1e2e7;
--surface: #d5d6db;
}
/* Reset to defaults button */
.theme-reset-btn {
display: flex;