feat: add theme provider, toggle, and beta badge components; update global styles for dark mode support
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user