From a7475a1e67ff0d5a46c683f3c24430cf83d25f50 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 7 May 2026 11:20:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20fix(web):=20align=20UI=20and=20c?= =?UTF-8?q?harts=20with=20theme=20tokens=20and=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- .../src/components/ai-elements/tool.tsx | 10 +- .../components/ai-elements/web-preview.tsx | 2 +- web/default/src/components/copy-button.tsx | 2 +- .../src/components/data-table/index.ts | 4 +- web/default/src/components/group-badge.tsx | 4 +- web/default/src/components/status-badge.tsx | 80 +++++++------- web/default/src/components/ui/sidebar.tsx | 2 +- web/default/src/context/theme-provider.tsx | 100 +++++++++++------- .../models/consumption-distribution-chart.tsx | 14 ++- .../components/models/model-charts.tsx | 14 ++- .../overview/overview-dashboard.tsx | 17 ++- .../components/overview/summary-cards.tsx | 2 +- .../components/overview/uptime-panel.tsx | 8 +- .../components/users/user-charts.tsx | 16 ++- .../src/features/dashboard/lib/api-info.ts | 6 +- .../src/features/dashboard/lib/charts.ts | 59 +++++++++-- web/default/src/lib/colors.ts | 80 +++++++------- web/default/src/styles/theme-presets.css | 43 ++++++++ web/default/src/styles/theme.css | 24 +++++ 19 files changed, 315 insertions(+), 172 deletions(-) diff --git a/web/default/src/components/ai-elements/tool.tsx b/web/default/src/components/ai-elements/tool.tsx index 01f20084..1ba8c4cd 100644 --- a/web/default/src/components/ai-elements/tool.tsx +++ b/web/default/src/components/ai-elements/tool.tsx @@ -57,11 +57,11 @@ const getStatusBadge = (status: ExtendedToolState) => { const icons: Record = { 'input-streaming': , 'input-available': , - 'approval-requested': , - 'approval-responded': , - 'output-available': , - 'output-error': , - 'output-denied': , + 'approval-requested': , + 'approval-responded': , + 'output-available': , + 'output-error': , + 'output-denied': , } return ( diff --git a/web/default/src/components/ai-elements/web-preview.tsx b/web/default/src/components/ai-elements/web-preview.tsx index 22f07960..3fb7504b 100644 --- a/web/default/src/components/ai-elements/web-preview.tsx +++ b/web/default/src/components/ai-elements/web-preview.tsx @@ -258,7 +258,7 @@ export const WebPreviewConsole = ({ className={cn( 'text-xs', log.level === 'error' && 'text-destructive', - log.level === 'warn' && 'text-yellow-600', + log.level === 'warn' && 'text-warning', log.level === 'log' && 'text-foreground' )} key={`${log.timestamp.getTime()}-${index}`} diff --git a/web/default/src/components/copy-button.tsx b/web/default/src/components/copy-button.tsx index bbdaba99..628634ff 100644 --- a/web/default/src/components/copy-button.tsx +++ b/web/default/src/components/copy-button.tsx @@ -50,7 +50,7 @@ export function CopyButton({ aria-label={isCopied ? copiedAriaLabel : resolvedAriaLabel} > {isCopied ? ( - + ) : ( )} diff --git a/web/default/src/components/data-table/index.ts b/web/default/src/components/data-table/index.ts index 12e86c78..5923d19e 100644 --- a/web/default/src/components/data-table/index.ts +++ b/web/default/src/components/data-table/index.ts @@ -10,7 +10,7 @@ export { MobileCardList } from './mobile-card-list' export { DataTablePage, type DataTablePageProps } from './data-table-page' export const DISABLED_ROW_DESKTOP = - 'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 dark:bg-zinc-700/55 dark:hover:bg-zinc-700/70 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1 dark:[&>td:first-child]:border-l-zinc-300/70' + 'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1' export const DISABLED_ROW_MOBILE = - 'border-l-4 border-l-muted-foreground/35 bg-muted/85 dark:border-l-zinc-300/70 dark:bg-zinc-700/55' + 'border-l-4 border-l-muted-foreground/35 bg-muted/85' diff --git a/web/default/src/components/group-badge.tsx b/web/default/src/components/group-badge.tsx index 67e6b80a..1de401fa 100644 --- a/web/default/src/components/group-badge.tsx +++ b/web/default/src/components/group-badge.tsx @@ -13,10 +13,10 @@ type GroupBadgeProps = Omit< function getGroupRatioClassName(ratio: number): string { if (ratio > 1) { - return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300' + return 'border-warning/25 bg-warning/10 text-warning' } if (ratio < 1) { - return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300' + return 'border-info/25 bg-info/10 text-info' } return 'border-border bg-muted text-muted-foreground' } diff --git a/web/default/src/components/status-badge.tsx b/web/default/src/components/status-badge.tsx index dec9f017..efdfbc37 100644 --- a/web/default/src/components/status-badge.tsx +++ b/web/default/src/components/status-badge.tsx @@ -6,51 +6,51 @@ import { cn } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' export const dotColorMap = { - success: 'bg-emerald-500', - warning: 'bg-amber-500', - danger: 'bg-rose-500', - info: 'bg-sky-500', - neutral: 'bg-slate-400', - purple: 'bg-purple-500', - amber: 'bg-amber-500', - blue: 'bg-blue-500', - cyan: 'bg-cyan-500', - green: 'bg-green-500', - grey: 'bg-gray-500', - indigo: 'bg-indigo-500', - 'light-blue': 'bg-sky-500', - 'light-green': 'bg-green-500', - lime: 'bg-lime-500', - orange: 'bg-orange-500', - pink: 'bg-pink-500', - red: 'bg-red-500', - teal: 'bg-teal-500', - violet: 'bg-violet-500', - yellow: 'bg-yellow-500', + success: 'bg-success', + warning: 'bg-warning', + danger: 'bg-destructive', + info: 'bg-info', + neutral: 'bg-neutral', + purple: 'bg-chart-4', + amber: 'bg-warning', + blue: 'bg-chart-1', + cyan: 'bg-chart-2', + green: 'bg-success', + grey: 'bg-neutral', + indigo: 'bg-chart-1', + 'light-blue': 'bg-info', + 'light-green': 'bg-success', + lime: 'bg-chart-3', + orange: 'bg-warning', + pink: 'bg-chart-5', + red: 'bg-destructive', + teal: 'bg-chart-2', + violet: 'bg-chart-4', + yellow: 'bg-warning', } as const export const textColorMap = { - success: 'text-emerald-700 dark:text-emerald-400', - warning: 'text-amber-700 dark:text-amber-400', - danger: 'text-rose-700 dark:text-rose-400', - info: 'text-sky-700 dark:text-sky-400', + success: 'text-success', + warning: 'text-warning', + danger: 'text-destructive', + info: 'text-info', neutral: 'text-muted-foreground', - purple: 'text-purple-700 dark:text-purple-400', - amber: 'text-amber-700 dark:text-amber-400', - blue: 'text-blue-700 dark:text-blue-400', - cyan: 'text-cyan-700 dark:text-cyan-400', - green: 'text-green-700 dark:text-green-400', + purple: 'text-chart-4', + amber: 'text-warning', + blue: 'text-chart-1', + cyan: 'text-chart-2', + green: 'text-success', grey: 'text-muted-foreground', - indigo: 'text-indigo-700 dark:text-indigo-400', - 'light-blue': 'text-sky-700 dark:text-sky-400', - 'light-green': 'text-green-600 dark:text-green-400', - lime: 'text-lime-700 dark:text-lime-400', - orange: 'text-orange-700 dark:text-orange-400', - pink: 'text-pink-700 dark:text-pink-400', - red: 'text-red-700 dark:text-red-400', - teal: 'text-teal-700 dark:text-teal-400', - violet: 'text-violet-700 dark:text-violet-400', - yellow: 'text-yellow-700 dark:text-yellow-400', + indigo: 'text-chart-1', + 'light-blue': 'text-info', + 'light-green': 'text-success', + lime: 'text-chart-3', + orange: 'text-warning', + pink: 'text-chart-5', + red: 'text-destructive', + teal: 'text-chart-2', + violet: 'text-chart-4', + yellow: 'text-warning', } as const export type StatusVariant = keyof typeof dotColorMap diff --git a/web/default/src/components/ui/sidebar.tsx b/web/default/src/components/ui/sidebar.tsx index 4c1c5a2d..7755443f 100644 --- a/web/default/src/components/ui/sidebar.tsx +++ b/web/default/src/components/ui/sidebar.tsx @@ -481,7 +481,7 @@ const sidebarMenuButtonVariants = cva( variant: { default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', outline: - 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]', + 'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]', }, size: { default: 'h-8 text-sm', diff --git a/web/default/src/context/theme-provider.tsx b/web/default/src/context/theme-provider.tsx index 9675b4dd..41f7be5a 100644 --- a/web/default/src/context/theme-provider.tsx +++ b/web/default/src/context/theme-provider.tsx @@ -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 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(['dark', 'light', 'system']) type ThemeProviderProps = { children: React.ReactNode @@ -32,66 +40,76 @@ const initialState: ThemeProviderState = { const ThemeContext = createContext(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( - () => (getCookie(storageKey) as Theme) || defaultTheme + const [theme, _setTheme] = useState(() => + getStoredTheme(storageKey, defaultTheme) + ) + const [resolvedTheme, setResolvedTheme] = useState(() => + 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 ( diff --git a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx index 58c2a61f..c99c901c 100644 --- a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx +++ b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx @@ -4,6 +4,7 @@ import { AreaChart, BarChart3, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { TimeGranularity } from '@/lib/time' import { VCHART_OPTION } from '@/lib/vchart' +import { useThemeCustomization } from '@/context/theme-customization-provider' import { useTheme } from '@/context/theme-provider' import { CONSUMPTION_DISTRIBUTION_CHART_OPTIONS, @@ -39,6 +40,7 @@ export function ConsumptionDistributionChart( ) { const { t } = useTranslation() const { resolvedTheme } = useTheme() + const { customization } = useThemeCustomization() const [chartType, setChartType] = useState( props.defaultChartType ?? 'bar' ) @@ -72,8 +74,14 @@ export function ConsumptionDistributionChart( }, [resolvedTheme]) const chartData = useMemo( - () => processChartData(props.loading ? [] : props.data, timeGranularity, t), - [props.data, props.loading, timeGranularity, t] + () => + processChartData( + props.loading ? [] : props.data, + timeGranularity, + t, + customization.preset + ), + [props.data, props.loading, timeGranularity, t, customization.preset] ) const spec = chartType === 'bar' ? chartData.spec_line : chartData.spec_area @@ -113,7 +121,7 @@ export function ConsumptionDistributionChart(
{themeReady && spec && ( ( props.defaultChartTab ?? 'trend' ) @@ -70,8 +72,14 @@ export function ModelCharts(props: ModelChartsProps) { }, [resolvedTheme]) const chartData = useMemo( - () => processChartData(props.loading ? [] : props.data, timeGranularity, t), - [props.data, props.loading, timeGranularity, t] + () => + processChartData( + props.loading ? [] : props.data, + timeGranularity, + t, + customization.preset + ), + [props.data, props.loading, timeGranularity, t, customization.preset] ) const spec = chartData[CHART_SPEC_KEYS[activeTab]] @@ -110,7 +118,7 @@ export function ModelCharts(props: ModelChartsProps) {
{themeReady && spec && (