Просмотр исходного кода

🎨 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`
t0ng7u 2 дней назад
Родитель
Сommit
a7475a1e67

+ 5 - 5
web/default/src/components/ai-elements/tool.tsx

@@ -57,11 +57,11 @@ const getStatusBadge = (status: ExtendedToolState) => {
   const icons: Record<ExtendedToolState, ReactNode> = {
     'input-streaming': <CircleIcon className='size-4' />,
     'input-available': <ClockIcon className='size-4 animate-pulse' />,
-    'approval-requested': <ClockIcon className='size-4 text-yellow-600' />,
-    'approval-responded': <CheckCircleIcon className='size-4 text-blue-600' />,
-    'output-available': <CheckCircleIcon className='size-4 text-green-600' />,
-    'output-error': <XCircleIcon className='size-4 text-red-600' />,
-    'output-denied': <XCircleIcon className='size-4 text-orange-600' />,
+    'approval-requested': <ClockIcon className='text-warning size-4' />,
+    'approval-responded': <CheckCircleIcon className='text-info size-4' />,
+    'output-available': <CheckCircleIcon className='text-success size-4' />,
+    'output-error': <XCircleIcon className='text-destructive size-4' />,
+    'output-denied': <XCircleIcon className='text-warning size-4' />,
   }
 
   return (

+ 1 - 1
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}`}

+ 1 - 1
web/default/src/components/copy-button.tsx

@@ -50,7 +50,7 @@ export function CopyButton({
       aria-label={isCopied ? copiedAriaLabel : resolvedAriaLabel}
     >
       {isCopied ? (
-        <Check className={cn('text-green-600', iconClassName)} />
+        <Check className={cn('text-success', iconClassName)} />
       ) : (
         <Copy className={cn(iconClassName)} />
       )}

+ 2 - 2
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'

+ 2 - 2
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'
 }

+ 40 - 40
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

+ 1 - 1
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',

+ 60 - 42
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<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 handleChange = () => {
-      if (theme === 'system') {
-        const systemTheme = mediaQuery.matches ? 'dark' : 'light'
-        applyTheme(systemTheme)
-      }
+    const applyTheme = () => {
+      const nextResolvedTheme = theme === 'system' ? getSystemTheme() : theme
+      root.classList.remove('light', 'dark')
+      root.classList.add(nextResolvedTheme)
+      setResolvedTheme(nextResolvedTheme)
     }
 
-    applyTheme(resolvedTheme)
+    applyTheme()
 
-    mediaQuery.addEventListener('change', handleChange)
+    mediaQuery.addEventListener('change', applyTheme)
 
-    return () => mediaQuery.removeEventListener('change', handleChange)
-  }, [theme, resolvedTheme])
+    return () => mediaQuery.removeEventListener('change', applyTheme)
+  }, [theme])
 
-  const setTheme = (theme: Theme) => {
-    setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
-    _setTheme(theme)
-  }
+  const setTheme = useCallback(
+    (theme: Theme) => {
+      setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
+      _setTheme(theme)
+    },
+    [storageKey]
+  )
 
-  const resetTheme = () => {
+  const resetTheme = useCallback(() => {
     removeCookie(storageKey)
-    _setTheme(DEFAULT_THEME)
-  }
-
-  const contextValue = {
-    defaultTheme,
-    resolvedTheme,
-    resetTheme,
-    theme,
-    setTheme,
-  }
+    _setTheme(defaultTheme)
+  }, [defaultTheme, storageKey])
+
+  const contextValue = useMemo(
+    () => ({
+      defaultTheme,
+      resolvedTheme,
+      resetTheme,
+      theme,
+      setTheme,
+    }),
+    [defaultTheme, resolvedTheme, resetTheme, theme, setTheme]
+  )
 
   return (
     <ThemeContext value={contextValue} {...props}>

+ 11 - 3
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<ConsumptionDistributionChartType>(
     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(
       <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
         {themeReady && spec && (
           <VChart
-            key={`${chartType}-${resolvedTheme}`}
+            key={`${chartType}-${resolvedTheme}-${customization.preset}`}
             spec={{
               ...spec,
               theme: resolvedTheme === 'dark' ? 'dark' : 'light',

+ 11 - 3
web/default/src/features/dashboard/components/models/model-charts.tsx

@@ -4,6 +4,7 @@ import { PieChart as PieChartIcon } 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 {
   DEFAULT_TIME_GRANULARITY,
@@ -37,6 +38,7 @@ interface ModelChartsProps {
 export function ModelCharts(props: ModelChartsProps) {
   const { t } = useTranslation()
   const { resolvedTheme } = useTheme()
+  const { customization } = useThemeCustomization()
   const [activeTab, setActiveTab] = useState<ModelAnalyticsChartTab>(
     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) {
       <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
         {themeReady && spec && (
           <VChart
-            key={`${activeTab}-${resolvedTheme}`}
+            key={`${activeTab}-${resolvedTheme}-${customization.preset}`}
             spec={{
               ...spec,
               theme: resolvedTheme === 'dark' ? 'dark' : 'light',

+ 6 - 11
web/default/src/features/dashboard/components/overview/overview-dashboard.tsx

@@ -210,13 +210,11 @@ function StartStepItem(props: {
       <span
         className={cn(
           'bg-background relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border shadow-xs',
-          props.step.completed && 'border-emerald-500/30 bg-emerald-500/10'
+          props.step.completed && 'border-success/30 bg-success/10'
         )}
       >
         <StatusIcon
-          className={
-            props.step.completed ? 'size-4 text-emerald-600' : 'size-4'
-          }
+          className={props.step.completed ? 'text-success size-4' : 'size-4'}
           aria-hidden='true'
         />
       </span>
@@ -316,9 +314,9 @@ function RequestPreview(props: {
 
       <div className='bg-foreground/[0.035] my-3 rounded-xl p-3 font-mono text-xs'>
         <div className='mb-2 flex items-center gap-1.5'>
-          <span className='size-2 rounded-full bg-red-400' />
-          <span className='size-2 rounded-full bg-amber-400' />
-          <span className='size-2 rounded-full bg-emerald-400' />
+          <span className='bg-destructive size-2 rounded-full' />
+          <span className='bg-warning size-2 rounded-full' />
+          <span className='bg-success size-2 rounded-full' />
         </div>
         <div className='flex flex-col gap-1 overflow-hidden'>
           {previewLines.map((line, index) => (
@@ -650,10 +648,7 @@ export function OverviewDashboard() {
               <div className='relative flex flex-wrap items-center justify-between gap-3'>
                 <div className='flex min-w-0 items-center gap-3'>
                   <span className='bg-background/70 flex size-9 shrink-0 items-center justify-center rounded-xl border shadow-xs'>
-                    <Check
-                      className='size-4 text-emerald-600'
-                      aria-hidden='true'
-                    />
+                    <Check className='text-success size-4' aria-hidden='true' />
                   </span>
                   <div className='min-w-0'>
                     <div className='flex items-center gap-2'>

+ 1 - 1
web/default/src/features/dashboard/components/overview/summary-cards.tsx

@@ -188,7 +188,7 @@ export function SummaryCards() {
           </StaggerContainer>
         </div>
 
-        <div className='flex flex-col justify-between gap-5 border-t bg-amber-50/80 p-4 sm:p-5 xl:border-t-0 xl:border-l dark:bg-amber-950/20'>
+        <div className='bg-warning/10 flex flex-col justify-between gap-5 border-t p-4 sm:p-5 xl:border-t-0 xl:border-l'>
           <div className='flex flex-col gap-2'>
             <div className='text-muted-foreground text-sm'>
               {t('Credit remaining')}

+ 4 - 4
web/default/src/features/dashboard/components/overview/uptime-panel.tsx

@@ -12,10 +12,10 @@ import type {
 import { PanelWrapper } from '../ui/panel-wrapper'
 
 const STATUS_COLOR_MAP: Record<number, string> = {
-  1: 'bg-emerald-500',
-  0: 'bg-red-500',
-  2: 'bg-amber-500',
-  3: 'bg-blue-500',
+  1: 'bg-success',
+  0: 'bg-destructive',
+  2: 'bg-warning',
+  3: 'bg-info',
 }
 const DEFAULT_STATUS_COLOR = 'bg-muted-foreground/40'
 

+ 13 - 3
web/default/src/features/dashboard/components/users/user-charts.tsx

@@ -5,6 +5,7 @@ import { Users, Loader2 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { getRollingDateRange, 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 { Skeleton } from '@/components/ui/skeleton'
 import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
@@ -46,6 +47,7 @@ const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50]
 export function UserCharts() {
   const { t } = useTranslation()
   const { resolvedTheme } = useTheme()
+  const { customization } = useThemeCustomization()
   const [themeReady, setThemeReady] = useState(false)
   const themeManagerRef = useRef<
     (typeof import('@visactor/vchart'))['ThemeManager'] | null
@@ -117,9 +119,17 @@ export function UserCharts() {
         isLoading ? [] : (userData ?? []),
         timeGranularity,
         t,
-        topUserLimit
+        topUserLimit,
+        customization.preset
       ),
-    [userData, isLoading, timeGranularity, t, topUserLimit]
+    [
+      userData,
+      isLoading,
+      timeGranularity,
+      t,
+      topUserLimit,
+      customization.preset,
+    ]
   )
 
   return (
@@ -207,7 +217,7 @@ export function UserCharts() {
                   themeReady &&
                   spec && (
                     <VChart
-                      key={`user-${chart.value}-${topUserLimit}-${resolvedTheme}`}
+                      key={`user-${chart.value}-${topUserLimit}-${resolvedTheme}-${customization.preset}`}
                       spec={{
                         ...spec,
                         theme: resolvedTheme === 'dark' ? 'dark' : 'light',

+ 3 - 3
web/default/src/features/dashboard/lib/api-info.ts

@@ -5,12 +5,12 @@ import type { PingStatus } from '@/features/dashboard/types'
  */
 export function getLatencyColorClass(latency: number): string {
   if (latency < 200) {
-    return 'text-green-600 dark:text-green-400'
+    return 'text-success'
   }
   if (latency < 500) {
-    return 'text-yellow-600 dark:text-yellow-400'
+    return 'text-warning'
   }
-  return 'text-red-600 dark:text-red-400'
+  return 'text-destructive'
 }
 
 /**

+ 51 - 8
web/default/src/features/dashboard/lib/charts.ts

@@ -20,7 +20,37 @@ type TooltipLineItem = {
   shapeSize?: number
 }
 
-function getVChartDefaultColors(domainLength: number) {
+const THEME_CHART_COLOR_VARIABLES = [
+  '--chart-1',
+  '--chart-2',
+  '--chart-3',
+  '--chart-4',
+  '--chart-5',
+] as const
+
+function getThemeChartColors(themeKey?: string): string[] {
+  if (typeof document === 'undefined') return []
+  void themeKey
+
+  const bodyStyle = window.getComputedStyle(document.body)
+  const rootStyle = window.getComputedStyle(document.documentElement)
+
+  return THEME_CHART_COLOR_VARIABLES.map((name) => {
+    return (
+      bodyStyle.getPropertyValue(name) || rootStyle.getPropertyValue(name)
+    ).trim()
+  }).filter(Boolean)
+}
+
+function getVChartDefaultColors(domainLength: number, themeKey?: string) {
+  const themeColors = getThemeChartColors(themeKey)
+  if (themeColors.length > 0) {
+    return Array.from(
+      { length: Math.max(domainLength, themeColors.length) },
+      (_, index) => themeColors[index % themeColors.length]
+    )
+  }
+
   const scheme =
     vchartDefaultDataScheme.find(
       (item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
@@ -49,7 +79,8 @@ function renderQuotaCompat(rawQuota: number, digits = 4): string {
 export function processChartData(
   data: QuotaDataItem[],
   timeGranularity: TimeGranularity = 'day',
-  t?: TFunction
+  t?: TFunction,
+  themeKey?: string
 ): ProcessedChartData {
   const tt: TFunction = t ?? ((x) => x)
   const otherLabel = tt('Other')
@@ -240,7 +271,10 @@ export function processChartData(
   const sortedTimes = Array.from(timeModelMap.keys()).sort()
   const sortedModels = [...allModels].sort()
   const modelColorDomain = Array.from(new Set([...sortedModels, otherLabel]))
-  const modelColorRange = getVChartDefaultColors(modelColorDomain.length)
+  const modelColorRange = getVChartDefaultColors(
+    modelColorDomain.length,
+    themeKey
+  )
   const otherColor = modelColorRange[modelColorDomain.indexOf(otherLabel)]
   const otherTooltipColor =
     typeof otherColor === 'string' ? otherColor : '#FF8A00'
@@ -665,7 +699,7 @@ export function processChartData(
   }
 }
 
-const USER_COLORS = [
+const USER_COLOR_FALLBACKS = [
   '#5B8FF9',
   '#5AD8A6',
   '#F6BD16',
@@ -682,11 +716,20 @@ export function processUserChartData(
   data: QuotaDataItem[],
   timeGranularity: TimeGranularity = 'day',
   t?: TFunction,
-  limit = 10
+  limit = 10,
+  themeKey?: string
 ): ProcessedUserChartData {
   const tt: TFunction = t ?? ((x) => x)
   const { config } = getCurrencyDisplay()
   const quotaPerUnit = config.quotaPerUnit
+  const themeUserColors = getThemeChartColors(themeKey)
+  const userColorRange =
+    themeUserColors.length > 0
+      ? Array.from(
+          { length: Math.max(limit, themeUserColors.length) },
+          (_, index) => themeUserColors[index % themeUserColors.length]
+        )
+      : USER_COLOR_FALLBACKS
 
   const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
 
@@ -704,7 +747,7 @@ export function processUserChartData(
         subtext: tt('No data available'),
       },
       legends: { visible: false },
-      color: { type: 'ordinal', range: USER_COLORS },
+      color: { type: 'ordinal', range: userColorRange },
       background: { fill: 'transparent' },
     },
     spec_user_trend: {
@@ -719,7 +762,7 @@ export function processUserChartData(
         subtext: tt('No data available'),
       },
       legends: { visible: true, selectMode: 'single' },
-      color: { type: 'ordinal', range: USER_COLORS },
+      color: { type: 'ordinal', range: userColorRange },
       point: { visible: false },
       background: { fill: 'transparent' },
     },
@@ -749,7 +792,7 @@ export function processUserChartData(
 
   const userColorMap = topUsers.reduce<Record<string, string>>(
     (acc, user, i) => {
-      acc[user] = USER_COLORS[i % USER_COLORS.length]
+      acc[user] = userColorRange[i % userColorRange.length]
       return acc
     },
     {}

+ 37 - 43
web/default/src/lib/colors.ts

@@ -17,47 +17,41 @@ export type SemanticColor =
   | 'grey'
 
 export const colorToBgClass: Record<SemanticColor, string> = {
-  blue: 'bg-blue-500',
-  green: 'bg-green-500',
-  cyan: 'bg-cyan-500',
-  purple: 'bg-purple-500',
-  pink: 'bg-pink-500',
-  red: 'bg-red-500',
-  orange: 'bg-orange-500',
-  amber: 'bg-amber-500',
-  yellow: 'bg-yellow-500',
-  lime: 'bg-lime-500',
-  'light-green': 'bg-green-400',
-  teal: 'bg-teal-500',
-  'light-blue': 'bg-sky-500',
-  indigo: 'bg-indigo-500',
-  violet: 'bg-violet-500',
-  grey: 'bg-gray-500',
+  blue: 'bg-chart-1',
+  green: 'bg-success',
+  cyan: 'bg-chart-2',
+  purple: 'bg-chart-4',
+  pink: 'bg-chart-5',
+  red: 'bg-destructive',
+  orange: 'bg-warning',
+  amber: 'bg-warning',
+  yellow: 'bg-warning',
+  lime: 'bg-chart-3',
+  'light-green': 'bg-success',
+  teal: 'bg-chart-2',
+  'light-blue': 'bg-info',
+  indigo: 'bg-chart-1',
+  violet: 'bg-chart-4',
+  grey: 'bg-neutral',
 }
 
 export const avatarColorMap: Record<SemanticColor, string> = {
-  blue: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
-  green: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400',
-  cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-400',
-  purple:
-    'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400',
-  pink: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-400',
-  red: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
-  orange:
-    'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-400',
-  amber: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
-  yellow:
-    'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400',
-  lime: 'bg-lime-100 text-lime-700 dark:bg-lime-500/20 dark:text-lime-400',
-  'light-green':
-    'bg-green-50 text-green-600 dark:bg-green-400/20 dark:text-green-300',
-  teal: 'bg-teal-100 text-teal-700 dark:bg-teal-500/20 dark:text-teal-400',
-  'light-blue': 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400',
-  indigo:
-    'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-400',
-  violet:
-    'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400',
-  grey: 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-400',
+  blue: 'bg-chart-1/10 text-chart-1',
+  green: 'bg-success/10 text-success',
+  cyan: 'bg-chart-2/10 text-chart-2',
+  purple: 'bg-chart-4/10 text-chart-4',
+  pink: 'bg-chart-5/10 text-chart-5',
+  red: 'bg-destructive/10 text-destructive',
+  orange: 'bg-warning/10 text-warning',
+  amber: 'bg-warning/10 text-warning',
+  yellow: 'bg-warning/10 text-warning',
+  lime: 'bg-chart-3/10 text-chart-3',
+  'light-green': 'bg-success/10 text-success',
+  teal: 'bg-chart-2/10 text-chart-2',
+  'light-blue': 'bg-info/10 text-info',
+  indigo: 'bg-chart-1/10 text-chart-1',
+  violet: 'bg-chart-4/10 text-chart-4',
+  grey: 'bg-muted text-muted-foreground',
 }
 
 export function getAvatarColorClass(name: string): string {
@@ -111,11 +105,11 @@ export type AnnouncementType =
  * Announcement status color mapping
  */
 export const ANNOUNCEMENT_TYPE_COLORS: Record<AnnouncementType, string> = {
-  default: 'bg-gray-500',
-  ongoing: 'bg-blue-500',
-  success: 'bg-green-500',
-  warning: 'bg-orange-500',
-  error: 'bg-red-500',
+  default: 'bg-neutral',
+  ongoing: 'bg-info',
+  success: 'bg-success',
+  warning: 'bg-warning',
+  error: 'bg-destructive',
 }
 
 /**

+ 43 - 0
web/default/src/styles/theme-presets.css

@@ -273,6 +273,49 @@
   --sidebar-ring: oklch(0.6359 0.1699 307.95);
 }
 
+/* ── Semantic surface bridge ──────────────────────────────────────────── */
+/* Color presets should tint the surfaces most components actually use, not
+ * only primary buttons. These derived tokens keep the app theme-aware without
+ * duplicating per-component dark-mode overrides. */
+[data-theme-preset]:not([data-theme-preset='default']) {
+  --card: color-mix(in oklch, var(--primary) 3%, var(--background));
+  --popover: color-mix(in oklch, var(--primary) 5%, var(--background));
+  --muted: color-mix(in oklch, var(--primary) 7%, var(--background));
+  --muted-foreground: color-mix(
+    in oklch,
+    var(--foreground) 68%,
+    var(--primary)
+  );
+  --accent: color-mix(in oklch, var(--primary) 14%, var(--background));
+  --accent-foreground: var(--foreground);
+  --border: color-mix(in oklch, var(--primary) 20%, var(--background));
+  --input: color-mix(in oklch, var(--primary) 22%, var(--background));
+  --sidebar: color-mix(in oklch, var(--primary) 4%, var(--background));
+  --sidebar-accent: color-mix(in oklch, var(--primary) 14%, var(--background));
+  --sidebar-accent-foreground: var(--foreground);
+  --sidebar-border: color-mix(in oklch, var(--primary) 18%, var(--background));
+  --success: var(--chart-2);
+  --warning: var(--chart-4);
+  --info: var(--chart-1);
+  --neutral: var(--muted-foreground);
+}
+.dark [data-theme-preset]:not([data-theme-preset='default']) {
+  --card: color-mix(in oklch, var(--primary) 8%, var(--background));
+  --popover: color-mix(in oklch, var(--primary) 12%, var(--background));
+  --muted: color-mix(in oklch, var(--primary) 12%, var(--background));
+  --muted-foreground: color-mix(
+    in oklch,
+    var(--foreground) 74%,
+    var(--primary)
+  );
+  --accent: color-mix(in oklch, var(--primary) 18%, var(--background));
+  --border: color-mix(in oklch, var(--primary) 24%, var(--background));
+  --input: color-mix(in oklch, var(--primary) 28%, var(--background));
+  --sidebar: color-mix(in oklch, var(--primary) 5%, var(--background));
+  --sidebar-accent: color-mix(in oklch, var(--primary) 18%, var(--background));
+  --sidebar-border: color-mix(in oklch, var(--primary) 22%, var(--background));
+}
+
 /* ── Border radius ────────────────────────────────────────────────────── */
 [data-theme-radius='none'] {
   --radius: 0rem;

+ 24 - 0
web/default/src/styles/theme.css

@@ -25,6 +25,14 @@
   --color-accent-foreground: var(--accent-foreground);
   --color-destructive: var(--destructive);
   --color-destructive-foreground: var(--destructive-foreground);
+  --color-success: var(--success);
+  --color-success-foreground: var(--success-foreground);
+  --color-warning: var(--warning);
+  --color-warning-foreground: var(--warning-foreground);
+  --color-info: var(--info);
+  --color-info-foreground: var(--info-foreground);
+  --color-neutral: var(--neutral);
+  --color-neutral-foreground: var(--neutral-foreground);
   --color-border: var(--border);
   --color-input: var(--input);
   --color-ring: var(--ring);
@@ -70,6 +78,14 @@
   --accent-foreground: oklch(0.145 0 0);
   --destructive: oklch(0.577 0.245 27.325);
   --destructive-foreground: oklch(0.985 0 0);
+  --success: oklch(0.596 0.145 163.225);
+  --success-foreground: oklch(0.985 0 0);
+  --warning: oklch(0.681 0.162 75.834);
+  --warning-foreground: oklch(0.145 0 0);
+  --info: oklch(0.588 0.158 241.966);
+  --info-foreground: oklch(0.985 0 0);
+  --neutral: oklch(0.708 0 0);
+  --neutral-foreground: oklch(0.145 0 0);
   --border: oklch(0.93 0 0);
   --input: oklch(0.93 0 0);
   --ring: oklch(0.708 0 0);
@@ -108,6 +124,14 @@
   --accent-foreground: oklch(0.985 0 0);
   --destructive: oklch(0.704 0.191 22.216);
   --destructive-foreground: oklch(0.985 0 0);
+  --success: oklch(0.696 0.17 162.48);
+  --success-foreground: oklch(0.145 0 0);
+  --warning: oklch(0.769 0.188 70.08);
+  --warning-foreground: oklch(0.145 0 0);
+  --info: oklch(0.68 0.17 237.323);
+  --info-foreground: oklch(0.145 0 0);
+  --neutral: oklch(0.76 0 0);
+  --neutral-foreground: oklch(0.145 0 0);
   --border: oklch(1 0 0 / 9%);
   --input: oklch(1 0 0 / 16%);
   --ring: oklch(0.68 0 0);