feat(frontend): add light mode with runtime theme toggle

- globals.css: split theme tokens into :root (light default) + .dark
  overrides; switch @theme inline → @theme so utilities reference
  var(--color-*) and a runtime class swap actually re-skins the UI
  (inline inlines literal values and ignores .dark overrides)
- layout.tsx: drop the beforeInteractive inline theme script (it caused
  hydration instability); theme is applied client-side only
- top-nav: apply persisted theme on mount, default light, toggle
  updates <html> class + localStorage
- glass-intense: light variant (white card on light canvas); chart grid
  line uses var(--color-border); attachment chip uses bg-glass-bg
- Verified in static export: toggle dark↔light both directions,
  chatbot panel renders clean in both themes
This commit is contained in:
asepharyana
2026-08-03 07:20:09 +07:00
parent 831254bb71
commit 9abb09dd33
5 changed files with 98 additions and 36 deletions
+88 -26
View File
@@ -4,41 +4,51 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
/* Canvas — deep navy */
--color-canvas: oklch(0.07 0.015 250);
--color-surface: oklch(0.11 0.02 245 / 0.6);
--color-surface-hover: oklch(0.15 0.02 245 / 0.7);
/*
* Theme variables — light mode lives in :root (default), dark mode overrides
* via `.dark`. Toggle in TopNav swaps the class on <html>.
*
* NOTE: this is `@theme` (NOT `@theme inline`) on purpose — inline inlines
* literal values into utilities, so a `.dark` class override of the custom
* property would NOT re-skin utilities. With plain `@theme`, utilities
* reference `var(--color-*)`, so runtime theme switching works.
*/
@theme {
/* ── Light (default) ── */
/* Canvas — soft off-white */
--color-canvas: oklch(0.97 0.005 250);
--color-surface: oklch(1 0 0 / 0.7);
--color-surface-hover: oklch(0.93 0.01 250 / 0.8);
/* Glass */
--color-glass-bg: oklch(1 0 0 / 0.04);
--color-glass-border: oklch(1 0 0 / 0.08);
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4);
--color-glass-bg: oklch(0 0 0 / 0.04);
--color-glass-border: oklch(0.2 0.02 250 / 0.12);
--glass-shadow: 0 8px 32px oklch(0.2 0.02 250 / 0.12);
/* Primary — teal-cyan */
--color-primary: oklch(0.62 0.17 215);
--color-primary-glow: oklch(0.62 0.17 215 / 0.4);
--color-primary: oklch(0.52 0.17 215);
--color-primary-glow: oklch(0.52 0.17 215 / 0.35);
--color-primary-foreground: oklch(0.98 0 0);
--color-border: oklch(1 0 0 / 0.06);
--color-border-glow: oklch(0.62 0.17 215 / 0.3);
--color-border: oklch(0.2 0.02 250 / 0.08);
--color-border-glow: oklch(0.52 0.17 215 / 0.35);
/* Accents */
--color-accent-purple: oklch(0.65 0.2 280);
--color-accent-amber: oklch(0.7 0.17 75);
--color-accent-purple: oklch(0.55 0.2 280);
--color-accent-amber: oklch(0.65 0.17 75);
--color-destructive: oklch(0.577 0.245 27.325);
--color-success: oklch(0.6 0.18 160);
--color-success: oklch(0.55 0.18 160);
/* Text */
--color-text-primary: oklch(0.93 0.01 245);
--color-text-secondary: oklch(0.55 0.02 245);
--color-text-mono: oklch(0.62 0.17 215);
--color-text-primary: oklch(0.25 0.02 250);
--color-text-secondary: oklch(0.48 0.02 250);
--color-text-mono: oklch(0.52 0.17 215);
/* Legacy overrides for shadcn compatibility */
--color-background: var(--color-canvas);
--color-foreground: var(--color-text-primary);
--color-card: var(--color-surface);
--color-card-foreground: var(--color-text-primary);
--color-muted: oklch(0.17 0.015 245);
--color-muted: oklch(0.9 0.01 250);
--color-muted-foreground: var(--color-text-secondary);
--color-accent: var(--color-primary);
--color-accent-foreground: var(--color-primary-foreground);
@@ -58,6 +68,42 @@
--font-mono: "JetBrains Mono", monospace;
}
/* ── Dark theme overrides ── */
.dark {
--color-canvas: oklch(0.07 0.015 250);
--color-surface: oklch(0.11 0.02 245 / 0.6);
--color-surface-hover: oklch(0.15 0.02 245 / 0.7);
--color-glass-bg: oklch(1 0 0 / 0.04);
--color-glass-border: oklch(1 0 0 / 0.08);
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4);
--color-primary: oklch(0.62 0.17 215);
--color-primary-glow: oklch(0.62 0.17 215 / 0.4);
--color-primary-foreground: oklch(0.98 0 0);
--color-border: oklch(1 0 0 / 0.06);
--color-border-glow: oklch(0.62 0.17 215 / 0.3);
--color-accent-purple: oklch(0.65 0.2 280);
--color-accent-amber: oklch(0.7 0.17 75);
--color-destructive: oklch(0.577 0.245 27.325);
--color-success: oklch(0.6 0.18 160);
--color-text-primary: oklch(0.93 0.01 245);
--color-text-secondary: oklch(0.55 0.02 245);
--color-text-mono: oklch(0.62 0.17 215);
--color-background: var(--color-canvas);
--color-foreground: var(--color-text-primary);
--color-card: var(--color-surface);
--color-card-foreground: var(--color-text-primary);
--color-muted: oklch(0.17 0.015 245);
--color-muted-foreground: var(--color-text-secondary);
--color-accent: var(--color-primary);
--color-accent-foreground: var(--color-primary-foreground);
--color-ring: var(--color-primary);
}
@layer utilities {
.glass {
background: var(--color-glass-bg);
@@ -70,19 +116,35 @@
background: var(--color-glass-bg);
backdrop-filter: blur(16px);
border: 1px solid var(--color-border-glow);
box-shadow: 0 8px 32px oklch(0 0 0 / 0.5), 0 0 20px var(--color-primary-glow);
box-shadow: 0 8px 32px var(--glass-shadow), 0 0 20px var(--color-primary-glow);
}
.glass-intense {
background: oklch(1 0 0 / 0.08);
background: oklch(1 0 0 / 0.75);
backdrop-filter: blur(20px);
border: 1px solid oklch(1 0 0 / 0.12);
border: 1px solid oklch(0.2 0.02 250 / 0.12);
box-shadow: 0 8px 32px oklch(0.2 0.02 250 / 0.12);
}
}
/* Dark variant of glass-intense (reads better on dark canvas) */
.dark .glass-intense {
background: oklch(1 0 0 / 0.08);
border: 1px solid oklch(1 0 0 / 0.12);
box-shadow: none;
}
@layer base {
* { @apply border-border; }
body {
@apply bg-canvas text-text-primary font-sans antialiased;
background-image:
radial-gradient(circle, oklch(0.3 0.02 250 / 0.05) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.52 0.17 215 / 0.08), transparent),
radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.55 0.2 280 / 0.05), transparent);
background-size: 24px 24px, 100% 100%, 100% 100%;
}
/* Dark body background */
.dark body {
background-image:
radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.62 0.17 215 / 0.06), transparent),
@@ -108,16 +170,16 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 0.1);
background: oklch(0.4 0.02 250 / 0.2);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 0.2);
background: oklch(0.4 0.02 250 / 0.35);
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.1); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: oklch(1 0 0 / 0.2); }
::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 250 / 0.2); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: oklch(0.4 0.02 250 / 0.35); }
}
/* ── Animations ──────────────────────────── */
+1 -7
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
@@ -27,14 +26,9 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable} dark h-full antialiased`}
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<head>
<Script id="theme-script" strategy="beforeInteractive">
{`try{const t=localStorage.getItem('theme')||'dark';document.documentElement.classList.add(t)}catch(e){}`}
</Script>
</head>
<body className="min-h-full flex flex-col">
{children}
<Toaster position="bottom-right" richColors closeButton />
@@ -74,7 +74,7 @@ export function ActivityChart({ data = [] }: ActivityChartProps) {
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="oklch(1 0 0 / 0.05)"
stroke="var(--color-border)"
vertical={false}
/>
<XAxis
@@ -10,9 +10,15 @@ export function TopNav() {
const router = useRouter();
const [theme, setTheme] = useState<"light" | "dark">("dark");
// Apply persisted theme on mount (client-side only — avoids SSR hydration
// mismatch from touching <html> during server render).
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
const initial =
stored === "light" ? "light" : stored === "dark" ? "dark" : "light";
setTheme(initial);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(initial);
}, []);
const toggleTheme = () => {
@@ -54,7 +54,7 @@ export function AttachmentsGrid({
{others.map((att) => (
<div
key={att.id}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-2 py-1 text-xs text-text-secondary"
className="flex items-center gap-1.5 rounded-md bg-glass-bg px-2 py-1 text-xs text-text-secondary"
>
<ImageIcon className="size-3 text-text-secondary/50" />
<span className="font-mono max-w-40 truncate">