feat(confidence-engine): add dark mode
This commit is contained in:
+67
-1
@@ -2,6 +2,72 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--ce-page: #f9fafb;
|
||||
--ce-surface: #ffffff;
|
||||
--ce-surface-muted: #f9fafb;
|
||||
--ce-surface-elevated: #ffffff;
|
||||
--ce-text: #111827;
|
||||
--ce-text-muted: #6b7280;
|
||||
--ce-border: #d1d5db;
|
||||
--ce-teal: #0f766e;
|
||||
--ce-teal-surface: #f0fdfa;
|
||||
--ce-focus: #14b8a6;
|
||||
--ce-skeleton: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--ce-page: #172128;
|
||||
--ce-surface: #202c34;
|
||||
--ce-surface-muted: #1b262e;
|
||||
--ce-surface-elevated: #293740;
|
||||
--ce-text: #edf2f3;
|
||||
--ce-text-muted: #b4c0c5;
|
||||
--ce-border: #40515a;
|
||||
--ce-teal: #62d3c5;
|
||||
--ce-teal-surface: #203a3c;
|
||||
--ce-focus: #78ded2;
|
||||
--ce-skeleton: #3a4a53;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] body { background-color: var(--ce-page) !important; color: var(--ce-text) !important; }
|
||||
html[data-theme="dark"] .app-chrome { background-color: var(--ce-surface-muted); border-color: var(--ce-border); }
|
||||
html[data-theme="dark"] .theme-toggle { background-color: var(--ce-surface-elevated); border-color: var(--ce-border); color: var(--ce-text); }
|
||||
html[data-theme="dark"] .theme-toggle:hover { background-color: #33444d; }
|
||||
html[data-theme="dark"] .bg-white,
|
||||
html[data-theme="dark"] [class*="bg-white"] { background-color: var(--ce-surface) !important; }
|
||||
html[data-theme="dark"] [class*="bg-gray-50"],
|
||||
html[data-theme="dark"] [class*="bg-gray-100"] { background-color: var(--ce-surface-muted) !important; }
|
||||
html[data-theme="dark"] [class*="bg-gradient-to"] { background-image: none !important; background-color: var(--ce-surface) !important; }
|
||||
html[data-theme="dark"] [class*="bg-teal-50"] { background-color: var(--ce-teal-surface) !important; }
|
||||
html[data-theme="dark"] [class*="bg-amber-50"],
|
||||
html[data-theme="dark"] [class*="bg-orange-50"],
|
||||
html[data-theme="dark"] [class*="bg-yellow-50"] { background-color: #3a3324 !important; }
|
||||
html[data-theme="dark"] [class*="bg-green-50"] { background-color: #20392f !important; }
|
||||
html[data-theme="dark"] [class*="bg-blue-50"] { background-color: #243540 !important; }
|
||||
html[data-theme="dark"] [class*="border-gray"],
|
||||
html[data-theme="dark"] [class*="border-teal"],
|
||||
html[data-theme="dark"] [class*="border-amber"],
|
||||
html[data-theme="dark"] [class*="border-orange"],
|
||||
html[data-theme="dark"] [class*="border-green"],
|
||||
html[data-theme="dark"] [class*="border-blue"] { border-color: var(--ce-border) !important; }
|
||||
html[data-theme="dark"] :is(.text-gray-900, .text-gray-800, .text-gray-700, .text-gray-600) { color: var(--ce-text) !important; }
|
||||
html[data-theme="dark"] :is(.text-gray-500, .text-gray-400) { color: var(--ce-text-muted) !important; }
|
||||
html[data-theme="dark"] :is(.text-teal-700, .text-teal-800) { color: var(--ce-teal) !important; }
|
||||
html[data-theme="dark"] :is(.text-amber-700, .text-amber-800, .text-orange-700, .text-orange-800, .text-yellow-700, .text-yellow-800) { color: #f2c879 !important; }
|
||||
html[data-theme="dark"] :is(.text-green-700, .text-green-800) { color: #8bd8a8 !important; }
|
||||
html[data-theme="dark"] input,
|
||||
html[data-theme="dark"] textarea,
|
||||
html[data-theme="dark"] select { background-color: var(--ce-surface-elevated); color: var(--ce-text); border-color: var(--ce-border); }
|
||||
html[data-theme="dark"] input::placeholder,
|
||||
html[data-theme="dark"] textarea::placeholder { color: #94a3ab; }
|
||||
html[data-theme="dark"] details { background-color: var(--ce-surface-muted) !important; }
|
||||
html[data-theme="dark"] button:focus-visible,
|
||||
html[data-theme="dark"] a:focus-visible,
|
||||
html[data-theme="dark"] input:focus-visible,
|
||||
html[data-theme="dark"] textarea:focus-visible,
|
||||
html[data-theme="dark"] select:focus-visible { outline: 2px solid var(--ce-focus); outline-offset: 2px; }
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -41,7 +107,7 @@
|
||||
.cu-skeleton-line {
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: #e5e7eb;
|
||||
background-color: var(--ce-skeleton);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./globals.css";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
|
||||
export const metadata = {
|
||||
title: "Confidence Engine",
|
||||
@@ -9,6 +10,17 @@ export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try { const saved = localStorage.getItem('confidence-engine-theme'); const theme = saved === 'dark' || saved === 'light' ? saved : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; } catch (_) {}`,
|
||||
}}
|
||||
/>
|
||||
<header className="app-chrome border-b border-gray-200/80">
|
||||
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-6 py-3">
|
||||
<span className="text-sm font-semibold tracking-wide text-teal-700">Confidence Engine</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
saveThemePreference,
|
||||
toggleTheme,
|
||||
} from "@/lib/theme-preference.js";
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState("light");
|
||||
|
||||
useEffect(() => {
|
||||
const nextTheme = resolveTheme({
|
||||
savedTheme: readThemePreference(window.localStorage),
|
||||
systemPrefersDark: window.matchMedia?.("(prefers-color-scheme: dark)").matches,
|
||||
});
|
||||
setTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const switchTheme = () => {
|
||||
const nextTheme = toggleTheme(theme);
|
||||
setTheme(nextTheme);
|
||||
saveThemePreference(nextTheme, window.localStorage);
|
||||
applyTheme(nextTheme);
|
||||
};
|
||||
|
||||
const isDark = theme === "dark";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchTheme}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="theme-toggle rounded-lg border px-3 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<span aria-hidden="true" className="mr-1.5">{isDark ? "☀" : "☾"}</span>
|
||||
{isDark ? "Light mode" : "Dark mode"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const THEME_STORAGE_KEY = "confidence-engine-theme";
|
||||
|
||||
export function resolveTheme({ savedTheme, systemPrefersDark = false } = {}) {
|
||||
if (savedTheme === "light" || savedTheme === "dark") return savedTheme;
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function toggleTheme(theme) {
|
||||
return theme === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function readThemePreference(storage) {
|
||||
try {
|
||||
return storage?.getItem(THEME_STORAGE_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveThemePreference(theme, storage) {
|
||||
storage?.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
saveThemePreference,
|
||||
THEME_STORAGE_KEY,
|
||||
toggleTheme,
|
||||
} from "@/lib/theme-preference.js";
|
||||
|
||||
function makeStorage(initial = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("theme preference", () => {
|
||||
it("restores a saved dark preference ahead of system preference", () => {
|
||||
const storage = makeStorage({ [THEME_STORAGE_KEY]: "dark" });
|
||||
expect(resolveTheme({ savedTheme: readThemePreference(storage), systemPrefersDark: false })).toBe("dark");
|
||||
});
|
||||
|
||||
it("uses system dark preference when no explicit choice exists", () => {
|
||||
expect(resolveTheme({ savedTheme: null, systemPrefersDark: true })).toBe("dark");
|
||||
});
|
||||
|
||||
it("switches themes and persists the explicit choice", () => {
|
||||
const storage = makeStorage();
|
||||
const nextTheme = toggleTheme("light");
|
||||
saveThemePreference(nextTheme, storage);
|
||||
|
||||
expect(nextTheme).toBe("dark");
|
||||
expect(readThemePreference(storage)).toBe("dark");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user