feat: add theme provider, toggle, and beta badge components; update global styles for dark mode support

This commit is contained in:
Xi Zhang
2026-06-01 23:24:53 +01:00
parent 198edf5857
commit ab7e98cb6f
8 changed files with 246 additions and 55 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Static "BETA" badge shown next to the title.
*
* We deliberately do NOT show a version number here. This is the WebUI client;
* it cannot reliably know which EvoScientist backend version the user actually
* has installed, so showing the latest PyPI release would falsely imply they're
* running it. "BETA" honestly signals an early-stage product without making a
* version claim — and needs no network request.
*/
export function BetaBadge() {
return (
<span className="rounded-lg border border-teal-300/50 bg-teal-100/60 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-teal-800 shadow-sm backdrop-blur-sm dark:border-teal-700/40 dark:bg-teal-900/30 dark:text-teal-300">
BETA
</span>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/providers/ThemeProvider";
/**
* Top-bar light/dark toggle. The theme defaults to "system"; clicking flips
* between explicit light and dark based on what is currently showing.
*
* Until mounted, both server and first client render show the Sun icon so they
* match (no hydration warning); the resolved icon appears right after mount.
*/
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = mounted && resolvedTheme === "dark";
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
<Moon
className="size-5"
aria-hidden="true"
/>
) : (
<Sun
className="size-5"
aria-hidden="true"
/>
)}
</Button>
);
}
+51 -49
View File
@@ -135,56 +135,58 @@
--chart-5: 27 87% 67%;
}
@media (prefers-color-scheme: dark) {
:root {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #065a8a;
--color-user-message-bg: #2d2d2d;
--color-avatar-bg: #1c3c3c;
--color-secondary: #bbc4c4;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-background: #202020;
--color-subagent-hover: #1e3f3f;
--color-surface: #2a2a2a;
--color-border: #404040;
--color-border-light: #353535;
--color-text-primary: #f3f4f6;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--color-file-button: #2a2a2a;
--color-file-button-hover: #353535;
/* Dark theme — applied via the `.dark` class on <html> (set by ThemeProvider
and the inline pre-paint script). Tailwind's darkMode: ["class", …] drives
`dark:` utilities off the same class, so CSS variables and utilities switch
together. The default theme follows the system preference. */
.dark {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #065a8a;
--color-user-message-bg: #2d2d2d;
--color-avatar-bg: #1c3c3c;
--color-secondary: #bbc4c4;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-background: #202020;
--color-subagent-hover: #1e3f3f;
--color-surface: #2a2a2a;
--color-border: #404040;
--color-border-light: #353535;
--color-text-primary: #f3f4f6;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--color-file-button: #2a2a2a;
--color-file-button-hover: #353535;
/* Tailwind/Radix UI component variables for dark mode */
--radius: 0.5rem;
--background: 0 0% 13%;
--foreground: 220 13% 95%;
--card: 0 0% 18%;
--card-foreground: 220 13% 95%;
--popover: 0 0% 18%;
--popover-foreground: 220 13% 95%;
--primary: 174 72% 56%;
--primary-foreground: 0 0% 13%;
--secondary: 0 0% 25%;
--secondary-foreground: 220 13% 95%;
--muted: 0 0% 22%;
--muted-foreground: 220 9% 70%;
--accent: 0 0% 22%;
--accent-foreground: 220 13% 95%;
--destructive: 0 63% 71%;
--destructive-foreground: 0 0% 13%;
--border: 0 0% 28%;
--input: 0 0% 28%;
--ring: 174 72% 56%;
--sidebar: 0 0% 18%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
/* Tailwind/Radix UI component variables for dark mode */
--radius: 0.5rem;
--background: 0 0% 13%;
--foreground: 220 13% 95%;
--card: 0 0% 18%;
--card-foreground: 220 13% 95%;
--popover: 0 0% 18%;
--popover-foreground: 220 13% 95%;
--primary: 174 72% 56%;
--primary-foreground: 0 0% 13%;
--secondary: 0 0% 25%;
--secondary-foreground: 220 13% 95%;
--muted: 0 0% 22%;
--muted-foreground: 220 9% 70%;
--accent: 0 0% 22%;
--accent-foreground: 220 13% 95%;
--destructive: 0 63% 71%;
--destructive-foreground: 0 0% 13%;
--border: 0 0% 28%;
--input: 0 0% 28%;
--ring: 174 72% 56%;
--sidebar: 0 0% 18%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
+21 -5
View File
@@ -1,7 +1,8 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "sonner";
import { ThemeProvider, ThemedToaster } from "@/providers/ThemeProvider";
import { THEME_STORAGE_KEY } from "@/lib/theme";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
@@ -13,10 +14,20 @@ export const metadata: Metadata = {
};
export const viewport: Viewport = {
themeColor: "#f9f9f9",
colorScheme: "light",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f9f9f9" },
{ media: "(prefers-color-scheme: dark)", color: "#212121" },
],
colorScheme: "light dark",
};
// Runs before paint so the right theme class is on <html> immediately — no flash
// of the wrong theme. Mirrors ThemeProvider's resolution (default: follow the
// system); ThemeProvider takes over once React mounts. Kept inline + minimal.
const themeScript = `(function(){try{var k=${JSON.stringify(
THEME_STORAGE_KEY
)};var t=localStorage.getItem(k)||"system";var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);var e=document.documentElement;e.classList.toggle("dark",d);e.style.colorScheme=d?"dark":"light";}catch(e){}})();`;
export default function RootLayout({
children,
}: {
@@ -31,8 +42,13 @@ export default function RootLayout({
className={inter.className}
suppressHydrationWarning
>
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<NuqsAdapter>
<ThemeProvider>
{children}
<ThemedToaster />
</ThemeProvider>
</NuqsAdapter>
</body>
</html>
);
+4
View File
@@ -18,6 +18,8 @@ import { ThreadList } from "@/app/components/ThreadList";
import { ChatProvider } from "@/providers/ChatProvider";
import { ChatInterface } from "@/app/components/ChatInterface";
import { SkillsMarketplace } from "@/app/components/SkillsMarketplace";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import { BetaBadge } from "@/app/components/BetaBadge";
interface HomePageInnerProps {
config: DeploymentConfig;
@@ -151,6 +153,7 @@ function HomePageInner({
priority
/>
<h1 className="text-xl font-semibold">EvoScientist</h1>
<BetaBadge />
</div>
<div className="flex items-center gap-0.5">
<Button
@@ -193,6 +196,7 @@ function HomePageInner({
</div>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<Button
variant="outline"
size="sm"
+23
View File
@@ -0,0 +1,23 @@
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
// Single source of truth for the storage key, imported by BOTH the server
// layout's pre-paint script and the client ThemeProvider. It must live in a
// module WITHOUT "use client": importing a value from a client module into the
// server layout turns it into a client-reference stub, which corrupts the inline
// script (it serializes as a throwing function, not the string).
export const THEME_STORAGE_KEY = "evoscientist-theme";
export const DARK_QUERY = "(prefers-color-scheme: dark)";
/** Read the persisted theme, defaulting to "system" (also on SSR / storage errors). */
export function getStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" || stored === "system"
? stored
: "system";
} catch {
return "system";
}
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { Toaster } from "sonner";
import {
DARK_QUERY,
getStoredTheme,
THEME_STORAGE_KEY,
type ResolvedTheme,
type Theme,
} from "@/lib/theme";
interface ThemeContextValue {
/** The user's selection: light, dark, or follow-system. */
theme: Theme;
/** The actually-applied theme after resolving "system". */
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function applyTheme(resolved: ResolvedTheme) {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Lazy initializer reads the stored choice on the client's first render, so the
// single effect below resolves the right theme on its first run — no
// "system → stored" double-apply flash. On the server it returns "system" (no
// localStorage); the inline pre-paint script in layout.tsx has already set the
// class, so nothing flashes regardless.
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("light");
useEffect(() => {
const mql = window.matchMedia(DARK_QUERY);
const apply = () => {
const resolved: ResolvedTheme =
theme === "system" ? (mql.matches ? "dark" : "light") : theme;
setResolvedTheme(resolved);
applyTheme(resolved);
};
apply();
// Track live OS changes only while following the system.
if (theme !== "system") return;
mql.addEventListener("change", apply);
return () => mql.removeEventListener("change", apply);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Private mode / storage disabled — fall back to in-memory only.
}
setThemeState(next);
}, []);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
/** Sonner toaster that follows the resolved theme (must render inside ThemeProvider). */
export function ThemedToaster() {
const { resolvedTheme } = useTheme();
return <Toaster theme={resolvedTheme} />;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: ["class", '[data-joy-color-scheme="dark"]'],
darkMode: "class",
theme: {
extend: {
fontSize: {