feat: add ShieldAI theme system with auto-shifting CSS and useTheme hook
This commit is contained in:
395
web/src/app.css
395
web/src/app.css
@@ -1 +1,396 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@property --color-bg {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #fafbfc;
|
||||
}
|
||||
|
||||
@property --color-bg-secondary {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #f3f4f6;
|
||||
}
|
||||
|
||||
@property --color-text-primary {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #111827;
|
||||
}
|
||||
|
||||
@property --color-text-secondary {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #6b7280;
|
||||
}
|
||||
|
||||
@property --color-border {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #e5e7eb;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #fafbfc;
|
||||
--color-bg-secondary: #f3f4f6;
|
||||
--color-bg-tertiary: #e5e7eb;
|
||||
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-brand-primary: #4f46e5;
|
||||
--color-brand-primary-light: #818cf8;
|
||||
--color-brand-primary-dark: #4338ca;
|
||||
|
||||
--color-brand-accent: #06b6d4;
|
||||
--color-brand-accent-light: #67e8f9;
|
||||
--color-brand-accent-dark: #0891b2;
|
||||
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-dark: #d1d5db;
|
||||
|
||||
--color-success: #06b6d4;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #4f46e5;
|
||||
|
||||
--color-success-bg: #ecfeff;
|
||||
--color-warning-bg: #fffbeb;
|
||||
--color-error-bg: #fef2f2;
|
||||
--color-info-bg: #eef2ff;
|
||||
|
||||
--color-amber: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
|
||||
--color-glass: rgba(255, 255, 255, 0.8);
|
||||
--color-glass-dark: rgba(17, 24, 39, 0.8);
|
||||
--gradient-card-start: #ffffff;
|
||||
--gradient-card-end: #f3f4f6;
|
||||
|
||||
--color-dot-grid: #e5e7eb;
|
||||
|
||||
--color-focus-ring: #4f46e5;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-bg: #fafbfc;
|
||||
--color-bg-secondary: #f3f4f6;
|
||||
--color-bg-tertiary: #e5e7eb;
|
||||
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-brand-primary: #4f46e5;
|
||||
--color-brand-primary-light: #818cf8;
|
||||
--color-brand-primary-dark: #4338ca;
|
||||
|
||||
--color-brand-accent: #06b6d4;
|
||||
--color-brand-accent-light: #67e8f9;
|
||||
--color-brand-accent-dark: #0891b2;
|
||||
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-dark: #d1d5db;
|
||||
|
||||
--color-success: #06b6d4;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #4f46e5;
|
||||
|
||||
--color-success-bg: #ecfeff;
|
||||
--color-warning-bg: #fffbeb;
|
||||
--color-error-bg: #fef2f2;
|
||||
--color-info-bg: #eef2ff;
|
||||
|
||||
--color-amber: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
|
||||
--color-glass: rgba(255, 255, 255, 0.8);
|
||||
--color-glass-dark: rgba(17, 24, 39, 0.8);
|
||||
--gradient-card-start: #ffffff;
|
||||
--gradient-card-end: #f3f4f6;
|
||||
--color-dot-grid: #e5e7eb;
|
||||
--color-focus-ring: #4f46e5;
|
||||
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
|
||||
--spacing-0: 0px;
|
||||
--spacing-1: 4px;
|
||||
--spacing-2: 8px;
|
||||
--spacing-3: 12px;
|
||||
--spacing-4: 16px;
|
||||
--spacing-5: 20px;
|
||||
--spacing-6: 24px;
|
||||
--spacing-7: 32px;
|
||||
--spacing-8: 40px;
|
||||
--spacing-9: 48px;
|
||||
--spacing-10: 56px;
|
||||
--spacing-11: 64px;
|
||||
--spacing-12: 72px;
|
||||
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #374151;
|
||||
--color-border-dark: #4b5563;
|
||||
|
||||
--color-success-bg: #0c4a6e;
|
||||
--color-warning-bg: #78350f;
|
||||
--color-error-bg: #7f1d1d;
|
||||
--color-info-bg: #1e1b4b;
|
||||
|
||||
--color-glass: rgba(17, 24, 39, 0.8);
|
||||
--color-glass-dark: rgba(17, 24, 39, 0.9);
|
||||
--gradient-card-start: #1f2937;
|
||||
--gradient-card-end: #0b1120;
|
||||
|
||||
--color-dot-grid: #374151;
|
||||
--color-focus-ring: #818cf8;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-bg: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #374151;
|
||||
--color-border-dark: #4b5563;
|
||||
|
||||
--color-success-bg: #0c4a6e;
|
||||
--color-warning-bg: #78350f;
|
||||
--color-error-bg: #7f1d1d;
|
||||
--color-info-bg: #1e1b4b;
|
||||
|
||||
--color-glass: rgba(17, 24, 39, 0.8);
|
||||
--color-glass-dark: rgba(17, 24, 39, 0.9);
|
||||
--gradient-card-start: #1f2937;
|
||||
--gradient-card-end: #0b1120;
|
||||
--color-dot-grid: #374151;
|
||||
--color-focus-ring: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--color-bg: #fafbfc;
|
||||
--color-bg-secondary: #f3f4f6;
|
||||
--color-bg-tertiary: #e5e7eb;
|
||||
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-dark: #d1d5db;
|
||||
|
||||
--color-success-bg: #ecfeff;
|
||||
--color-warning-bg: #fffbeb;
|
||||
--color-error-bg: #fef2f2;
|
||||
--color-info-bg: #eef2ff;
|
||||
|
||||
--color-glass: rgba(255, 255, 255, 0.8);
|
||||
--color-glass-dark: rgba(17, 24, 39, 0.8);
|
||||
--gradient-card-start: #ffffff;
|
||||
--gradient-card-end: #f3f4f6;
|
||||
|
||||
--color-dot-grid: #e5e7eb;
|
||||
--color-focus-ring: #4f46e5;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--color-bg: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #374151;
|
||||
--color-border-dark: #4b5563;
|
||||
|
||||
--color-success-bg: #0c4a6e;
|
||||
--color-warning-bg: #78350f;
|
||||
--color-error-bg: #7f1d1d;
|
||||
--color-info-bg: #1e1b4b;
|
||||
|
||||
--color-glass: rgba(17, 24, 39, 0.8);
|
||||
--color-glass-dark: rgba(17, 24, 39, 0.9);
|
||||
--gradient-card-start: #1f2937;
|
||||
--gradient-card-end: #0b1120;
|
||||
|
||||
--color-dot-grid: #374151;
|
||||
--color-focus-ring: #818cf8;
|
||||
}
|
||||
|
||||
:root {
|
||||
background-color: var(--color-bg);
|
||||
background-image: linear-gradient(90deg, transparent 1.5px),
|
||||
radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--color-dot-grid) 80%, transparent) 1px,
|
||||
transparent 3px
|
||||
);
|
||||
background-size:
|
||||
54px 100%,
|
||||
36px 36px;
|
||||
background-position:
|
||||
0 0,
|
||||
18px 0;
|
||||
background-attachment: fixed;
|
||||
transition:
|
||||
background-color 500ms ease-in-out,
|
||||
color 500ms ease-in-out;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
background-color 500ms ease-in-out,
|
||||
color 500ms ease-in-out;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-brand-primary) 0%,
|
||||
var(--color-brand-primary-dark) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-brand-accent) 0%,
|
||||
var(--color-brand-accent-dark) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-subtle {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--color-bg) 0%,
|
||||
var(--color-bg-secondary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--gradient-card-start) 0%,
|
||||
var(--gradient-card-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background: var(--color-glass-dark);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-brand-primary) 0%,
|
||||
var(--color-brand-primary-light) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.text-gradient-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-brand-accent) 0%,
|
||||
var(--color-brand-accent-light) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.shadow-glow-primary {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(79, 70, 229, 0.1),
|
||||
0 4px 12px rgba(79, 70, 229, 0.15);
|
||||
}
|
||||
|
||||
.shadow-glow-accent {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(6, 182, 212, 0.1),
|
||||
0 4px 12px rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.02'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bg-dot-grid {
|
||||
background-color: var(--color-bg);
|
||||
background-image: linear-gradient(90deg, transparent 1.5px),
|
||||
radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--color-dot-grid) 80%, transparent) 1px,
|
||||
transparent 3px
|
||||
);
|
||||
background-size:
|
||||
54px 100%,
|
||||
36px 36px;
|
||||
background-position:
|
||||
0 0,
|
||||
18px 0;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
288
web/src/lib/theme.test.ts
Normal file
288
web/src/lib/theme.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { createRoot } from "solid-js";
|
||||
import {
|
||||
useTheme,
|
||||
getSystemTheme,
|
||||
getStoredTheme,
|
||||
getResolvedTheme,
|
||||
applyThemeClass,
|
||||
updateMetaThemeColor,
|
||||
persistTheme,
|
||||
} from "./theme";
|
||||
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
function setupDOM() {
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document
|
||||
.querySelectorAll('meta[name="theme-color"]')
|
||||
.forEach((el) => el.remove());
|
||||
const ls = createLocalStorageMock();
|
||||
vi.stubGlobal("localStorage", ls);
|
||||
}
|
||||
|
||||
function createMatchMediaMock(matches: boolean) {
|
||||
return vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function runWithRoot<T>(fn: () => T): T {
|
||||
return createRoot((dispose) => {
|
||||
const result = fn();
|
||||
return result;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
describe("getSystemTheme", () => {
|
||||
it("returns 'light' when prefers-color-scheme is light", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
expect(getSystemTheme()).toBe("light");
|
||||
});
|
||||
|
||||
it("returns 'dark' when prefers-color-scheme is dark", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(true));
|
||||
expect(getSystemTheme()).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStoredTheme", () => {
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
});
|
||||
|
||||
it("returns 'system' when nothing is stored", () => {
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
});
|
||||
|
||||
it("returns 'light' when stored", () => {
|
||||
localStorage.setItem("shieldai-theme", "light");
|
||||
expect(getStoredTheme()).toBe("light");
|
||||
});
|
||||
|
||||
it("returns 'dark' when stored", () => {
|
||||
localStorage.setItem("shieldai-theme", "dark");
|
||||
expect(getStoredTheme()).toBe("dark");
|
||||
});
|
||||
|
||||
it("returns 'system' for invalid value", () => {
|
||||
localStorage.setItem("shieldai-theme", "invalid");
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResolvedTheme", () => {
|
||||
it("resolves explicit theme directly", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(true));
|
||||
expect(getResolvedTheme("light")).toBe("light");
|
||||
expect(getResolvedTheme("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it("resolves 'system' to OS preference", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
expect(getResolvedTheme("system")).toBe("light");
|
||||
});
|
||||
|
||||
it("resolves 'system' to dark with dark OS", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(true));
|
||||
expect(getResolvedTheme("system")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyThemeClass", () => {
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
});
|
||||
|
||||
it("adds 'light' class and removes 'dark'", () => {
|
||||
document.documentElement.classList.add("dark");
|
||||
applyThemeClass("light");
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds 'dark' class and removes 'light'", () => {
|
||||
document.documentElement.classList.add("light");
|
||||
applyThemeClass("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("light")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMetaThemeColor", () => {
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
});
|
||||
|
||||
it("creates a theme-color meta tag for light mode", () => {
|
||||
updateMetaThemeColor("light");
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.getAttribute("content")).toBe("#fafbfc");
|
||||
});
|
||||
|
||||
it("creates a theme-color meta tag for dark mode", () => {
|
||||
updateMetaThemeColor("dark");
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.getAttribute("content")).toBe("#111827");
|
||||
});
|
||||
|
||||
it("updates existing meta tag instead of creating new one", () => {
|
||||
updateMetaThemeColor("light");
|
||||
updateMetaThemeColor("dark");
|
||||
const metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
expect(metas.length).toBe(1);
|
||||
expect(metas[0].getAttribute("content")).toBe("#111827");
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistTheme", () => {
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
});
|
||||
|
||||
it("writes theme to localStorage", () => {
|
||||
persistTheme("dark");
|
||||
expect(localStorage.getItem("shieldai-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("writes 'system' to localStorage", () => {
|
||||
persistTheme("system");
|
||||
expect(localStorage.getItem("shieldai-theme")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTheme", () => {
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
});
|
||||
|
||||
describe("initial theme", () => {
|
||||
it("returns 'system' when no theme is stored", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
runWithRoot(() => {
|
||||
const { theme } = useTheme();
|
||||
expect(theme()).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 'light' from localStorage", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
localStorage.setItem("shieldai-theme", "light");
|
||||
runWithRoot(() => {
|
||||
const { theme } = useTheme();
|
||||
expect(theme()).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 'dark' from localStorage", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
localStorage.setItem("shieldai-theme", "dark");
|
||||
runWithRoot(() => {
|
||||
const { theme } = useTheme();
|
||||
expect(theme()).toBe("dark");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolved theme", () => {
|
||||
it("resolves 'system' to 'light' with light OS preference", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
runWithRoot(() => {
|
||||
const { resolved } = useTheme();
|
||||
expect(resolved()).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves 'system' to 'dark' with dark OS preference", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(true));
|
||||
runWithRoot(() => {
|
||||
const { resolved } = useTheme();
|
||||
expect(resolved()).toBe("dark");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTheme", () => {
|
||||
it("sets theme signal and persists to localStorage", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
runWithRoot(() => {
|
||||
const { setTheme, theme } = useTheme();
|
||||
setTheme("dark");
|
||||
expect(theme()).toBe("dark");
|
||||
expect(localStorage.getItem("shieldai-theme")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
it("persists 'system' to localStorage", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
runWithRoot(() => {
|
||||
const { setTheme } = useTheme();
|
||||
setTheme("system");
|
||||
expect(localStorage.getItem("shieldai-theme")).toBe("system");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("toggles from light to dark", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(false));
|
||||
localStorage.setItem("shieldai-theme", "light");
|
||||
runWithRoot(() => {
|
||||
const { toggle, resolved, theme } = useTheme();
|
||||
toggle();
|
||||
expect(resolved()).toBe("dark");
|
||||
expect(theme()).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles from dark to light", () => {
|
||||
vi.stubGlobal("matchMedia", createMatchMediaMock(true));
|
||||
localStorage.setItem("shieldai-theme", "dark");
|
||||
runWithRoot(() => {
|
||||
const { toggle, resolved, theme } = useTheme();
|
||||
toggle();
|
||||
expect(resolved()).toBe("light");
|
||||
expect(theme()).toBe("light");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("system theme listener", () => {
|
||||
it("registers matchMedia change listener", () => {
|
||||
const addEventListener = vi.fn();
|
||||
const removeEventListener = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
}),
|
||||
);
|
||||
runWithRoot(() => {
|
||||
useTheme();
|
||||
expect(addEventListener).toHaveBeenCalledWith(
|
||||
"change",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
94
web/src/lib/theme.ts
Normal file
94
web/src/lib/theme.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createSignal, createEffect, createRoot, onCleanup } from "solid-js";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "shieldai-theme";
|
||||
|
||||
export function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export function getStoredTheme(): Theme {
|
||||
try {
|
||||
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function getResolvedTheme(theme: Theme): ResolvedTheme {
|
||||
return theme === "system" ? getSystemTheme() : theme;
|
||||
}
|
||||
|
||||
export function applyThemeClass(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolved);
|
||||
}
|
||||
|
||||
export function updateMetaThemeColor(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const color = resolved === "dark" ? "#111827" : "#fafbfc";
|
||||
let meta = document.querySelector<HTMLMetaElement>(
|
||||
'meta[name="theme-color"]',
|
||||
);
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.name = "theme-color";
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = color;
|
||||
}
|
||||
|
||||
export function persistTheme(theme: Theme) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const initial = getStoredTheme();
|
||||
const [theme, setTheme] = createSignal<Theme>(initial);
|
||||
|
||||
const resolved = () => getResolvedTheme(theme());
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolved();
|
||||
applyThemeClass(current);
|
||||
updateMetaThemeColor(current);
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const listener = () => {
|
||||
if (theme() === "system") {
|
||||
const systemResolved = getSystemTheme();
|
||||
applyThemeClass(systemResolved);
|
||||
updateMetaThemeColor(systemResolved);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", listener);
|
||||
onCleanup(() => mediaQuery.removeEventListener("change", listener));
|
||||
}
|
||||
|
||||
const setAndPersist = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
persistTheme(newTheme);
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const next = resolved() === "dark" ? "light" : "dark";
|
||||
setAndPersist(next);
|
||||
};
|
||||
|
||||
return { theme, resolved, setTheme: setAndPersist, toggle };
|
||||
}
|
||||
Reference in New Issue
Block a user