🎨 fix(web): align UI and charts with theme tokens and presets

Improve theme switching fidelity (including system preference), extend design tokens so color presets tint real surfaces—not only primary/chrome—and refactor shared badges, tables, and dashboard visuals to semantic colors. Wire VChart series colors to `--chart-*` with safe fallbacks.

**Changes**

- **Theme runtime** (`theme-provider.tsx`): Validate stored theme cookie; keep `resolvedTheme` in sync with DOM + `(prefers-color-scheme)`; `resetTheme` respects `defaultTheme`; memoized context value.
- **Tokens** (`theme.css`): Add `--success|warning|info|neutral` (+ foregrounds) and map them under `@theme inline` for Tailwind utilities.
- **Presets** (`theme-presets.css`): For non-`default` presets, derive `card`, `popover`, `muted`, `accent`, `border`, `input`, and sidebar tokens from `--primary`/`--background`; map semantic status colors to preset chart variables.
- **Components**: `status-badge`, `colors` (avatars, announcements), `copy-button`, `group-badge`, `data-table` row styles, `sidebar` outline shadow (fix `var(--sidebar-border)` usage), ai-elements tool/web-preview status colors.
- **Dashboard**: Latency/API helpers and overview fragments use semantic tokens; `charts.ts` reads `--chart-1`…`--chart-5` from computed styles with fallbacks; `processChartData` / `processUserChartData` accept optional `themeKey` for preset churn; chart components pass `customization.preset` and bump `VChart` keys.

**Verification**

- `bun run typecheck`
This commit is contained in:
t0ng7u
2026-05-07 11:20:43 +08:00
parent 415d21d071
commit a7475a1e67
19 changed files with 315 additions and 172 deletions
+59 -41
View File
@@ -1,4 +1,11 @@
import { createContext, useContext, useEffect, useState, useMemo } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
type Theme = 'dark' | 'light' | 'system'
@@ -7,6 +14,7 @@ type ResolvedTheme = Exclude<Theme, 'system'>
const DEFAULT_THEME = 'system'
const THEME_COOKIE_NAME = 'vite-ui-theme'
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
const THEMES = new Set<Theme>(['dark', 'light', 'system'])
type ThemeProviderProps = {
children: React.ReactNode
@@ -32,66 +40,76 @@ const initialState: ThemeProviderState = {
const ThemeContext = createContext<ThemeProviderState>(initialState)
function getSystemTheme(): ResolvedTheme {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
function resolveTheme(theme: Theme): ResolvedTheme {
return theme === 'system' ? getSystemTheme() : theme
}
function getStoredTheme(storageKey: string, fallback: Theme): Theme {
const storedTheme = getCookie(storageKey) as Theme | undefined
return storedTheme && THEMES.has(storedTheme) ? storedTheme : fallback
}
export function ThemeProvider({
children,
defaultTheme = DEFAULT_THEME,
storageKey = THEME_COOKIE_NAME,
...props
}: ThemeProviderProps) {
const [theme, _setTheme] = useState<Theme>(
() => (getCookie(storageKey) as Theme) || defaultTheme
const [theme, _setTheme] = useState<Theme>(() =>
getStoredTheme(storageKey, defaultTheme)
)
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
resolveTheme(getStoredTheme(storageKey, defaultTheme))
)
// Optimized: Memoize the resolved theme calculation to prevent unnecessary re-computations
const resolvedTheme = useMemo((): ResolvedTheme => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
return theme as ResolvedTheme
}, [theme])
useEffect(() => {
const root = window.document.documentElement
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const applyTheme = (currentResolvedTheme: ResolvedTheme) => {
root.classList.remove('light', 'dark') // Remove existing theme classes
root.classList.add(currentResolvedTheme) // Add the new theme class
const applyTheme = () => {
const nextResolvedTheme = theme === 'system' ? getSystemTheme() : theme
root.classList.remove('light', 'dark')
root.classList.add(nextResolvedTheme)
setResolvedTheme(nextResolvedTheme)
}
const handleChange = () => {
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light'
applyTheme(systemTheme)
}
}
applyTheme()
applyTheme(resolvedTheme)
mediaQuery.addEventListener('change', applyTheme)
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', applyTheme)
}, [theme])
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme, resolvedTheme])
const setTheme = useCallback(
(theme: Theme) => {
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
_setTheme(theme)
},
[storageKey]
)
const setTheme = (theme: Theme) => {
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
_setTheme(theme)
}
const resetTheme = () => {
const resetTheme = useCallback(() => {
removeCookie(storageKey)
_setTheme(DEFAULT_THEME)
}
_setTheme(defaultTheme)
}, [defaultTheme, storageKey])
const contextValue = {
defaultTheme,
resolvedTheme,
resetTheme,
theme,
setTheme,
}
const contextValue = useMemo(
() => ({
defaultTheme,
resolvedTheme,
resetTheme,
theme,
setTheme,
}),
[defaultTheme, resolvedTheme, resetTheme, theme, setTheme]
)
return (
<ThemeContext value={contextValue} {...props}>