diff --git a/dashboard/src/lib/theme/palette.ts b/dashboard/src/lib/theme/palette.ts new file mode 100644 index 00000000..e55f61e0 --- /dev/null +++ b/dashboard/src/lib/theme/palette.ts @@ -0,0 +1,203 @@ +import type { ColorTokens } from './tokens' + +type HSL = { + h: number + s: number + l: number +} + +const clamp = (value: number, min: number, max: number): number => { + if (value < min) return min + if (value > max) return max + return value +} + +const roundToTenth = (value: number): number => Math.round(value * 10) / 10 + +const wrapHue = (value: number): number => ((value % 360) + 360) % 360 + +export const parseHSL = (hslStr: string): HSL => { + const cleaned = hslStr + .trim() + .replace(/^hsl\(/i, '') + .replace(/\)$/i, '') + .replace(/,/g, ' ') + const parts = cleaned.split(/\s+/).filter(Boolean) + const rawH = parts[0] ?? '0' + const rawS = parts[1] ?? '0%' + const rawL = parts[2] ?? '0%' + + const h = Number.parseFloat(rawH) + const s = Number.parseFloat(rawS.replace('%', '')) + const l = Number.parseFloat(rawL.replace('%', '')) + + return { + h: Number.isNaN(h) ? 0 : h, + s: Number.isNaN(s) ? 0 : s, + l: Number.isNaN(l) ? 0 : l, + } +} + +export const formatHSL = (h: number, s: number, l: number): string => { + const safeH = roundToTenth(wrapHue(h)) + const safeS = roundToTenth(clamp(s, 0, 100)) + const safeL = roundToTenth(clamp(l, 0, 100)) + return `${safeH} ${safeS}% ${safeL}%` +} + +export const hexToHSL = (hex: string): string => { + let cleaned = hex.trim().replace('#', '') + if (cleaned.length === 3) { + cleaned = cleaned + .split('') + .map((char) => `${char}${char}`) + .join('') + } + + if (cleaned.length !== 6) { + return formatHSL(0, 0, 0) + } + + const r = Number.parseInt(cleaned.slice(0, 2), 16) / 255 + const g = Number.parseInt(cleaned.slice(2, 4), 16) / 255 + const b = Number.parseInt(cleaned.slice(4, 6), 16) / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const delta = max - min + const l = (max + min) / 2 + + let h = 0 + let s = 0 + + if (delta !== 0) { + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min) + switch (max) { + case r: + h = (g - b) / delta + (g < b ? 6 : 0) + break + case g: + h = (b - r) / delta + 2 + break + case b: + h = (r - g) / delta + 4 + break + default: + break + } + h *= 60 + } + + return formatHSL(h, s * 100, l * 100) +} + +export const adjustLightness = (hsl: string, amount: number): string => { + const { h, s, l } = parseHSL(hsl) + return formatHSL(h, s, l + amount) +} + +export const adjustSaturation = (hsl: string, amount: number): string => { + const { h, s, l } = parseHSL(hsl) + return formatHSL(h, s + amount, l) +} + +export const rotateHue = (hsl: string, degrees: number): string => { + const { h, s, l } = parseHSL(hsl) + return formatHSL(h + degrees, s, l) +} + +const setLightness = (hsl: string, lightness: number): string => { + const { h, s } = parseHSL(hsl) + return formatHSL(h, s, lightness) +} + +const setSaturation = (hsl: string, saturation: number): string => { + const { h, l } = parseHSL(hsl) + return formatHSL(h, saturation, l) +} + +const getReadableForeground = (hsl: string): string => { + const { h, s, l } = parseHSL(hsl) + const neutralSaturation = clamp(s * 0.15, 6, 20) + return l > 60 + ? formatHSL(h, neutralSaturation, 10) + : formatHSL(h, neutralSaturation, 96) +} + +export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens => { + const accent = parseHSL(accentHSL) + const primary = formatHSL(accent.h, accent.s, accent.l) + + const background = isDark ? '222.2 84% 4.9%' : '0 0% 100%' + const foreground = isDark ? '210 40% 98%' : '222.2 84% 4.9%' + + const secondary = formatHSL( + accent.h, + clamp(accent.s * 0.35, 8, 40), + isDark ? 17.5 : 96, + ) + + const muted = formatHSL( + accent.h, + clamp(accent.s * 0.12, 2, 18), + isDark ? 17.5 : 96, + ) + + const accentVariant = formatHSL( + accent.h + 35, + clamp(accent.s * 0.6, 20, 85), + isDark ? clamp(accent.l * 0.6 + 8, 25, 60) : clamp(accent.l * 0.8 + 14, 40, 75), + ) + + const destructive = formatHSL( + 0, + clamp(accent.s, 60, 90), + isDark ? 30.6 : 60.2, + ) + + const border = formatHSL( + accent.h, + clamp(accent.s * 0.2, 5, 25), + isDark ? 17.5 : 91.4, + ) + + const mutedForeground = setSaturation( + setLightness(muted, isDark ? 65.1 : 46.9), + clamp(accent.s * 0.2, 10, 30), + ) + + const chartBase = formatHSL(accent.h, accent.s, accent.l) + const chartSteps = [0, 72, 144, 216, 288] + const charts = chartSteps.map((step) => rotateHue(chartBase, step)) + + const card = adjustLightness(background, isDark ? 2 : -1) + const popover = adjustLightness(background, isDark ? 3 : -0.5) + + return { + primary, + 'primary-foreground': getReadableForeground(primary), + 'primary-gradient': 'none', + secondary, + 'secondary-foreground': getReadableForeground(secondary), + muted, + 'muted-foreground': mutedForeground, + accent: accentVariant, + 'accent-foreground': getReadableForeground(accentVariant), + destructive, + 'destructive-foreground': getReadableForeground(destructive), + background, + foreground, + card, + 'card-foreground': foreground, + popover, + 'popover-foreground': foreground, + border, + input: border, + ring: primary, + 'chart-1': charts[0], + 'chart-2': charts[1], + 'chart-3': charts[2], + 'chart-4': charts[3], + 'chart-5': charts[4], + } +} diff --git a/dashboard/src/lib/theme/sanitizer.ts b/dashboard/src/lib/theme/sanitizer.ts new file mode 100644 index 00000000..de425b38 --- /dev/null +++ b/dashboard/src/lib/theme/sanitizer.ts @@ -0,0 +1,111 @@ +/** + * CSS 安全过滤器 - 用于过滤用户自定义 CSS 中的危险内容 + * 防范外部资源加载和 XSS 注入 + */ + +interface SanitizeResult { + css: string + warnings: string[] +} + +/** + * 过滤规则:基于正则表达式的危险模式检测 + * 与匹配的危险模式相关的警告消息 + */ +interface FilterRule { + pattern: RegExp + message: string +} + +/** + * 定义所有过滤规则 + */ +const filterRules: FilterRule[] = [ + { + pattern: /@import\s+(?:url\()?['"]?(?:https?:|\/\/)?[^)'"]+['"]?\)?[;]?/gi, + message: '移除 @import 语句(禁止加载外部资源)', + }, + { + pattern: /url\s*\(\s*(?:https?:|\/\/|data:|javascript:)[^)]*\)/gi, + message: '移除 url() 调用(禁止外部请求)', + }, + { + pattern: /javascript:/gi, + message: '移除 javascript: 协议(XSS 防护)', + }, + { + pattern: /expression\s*\(\s*[^)]*\)/gi, + message: '移除 expression() 函数(IE 遗留 XSS 向量)', + }, + { + pattern: /-moz-binding\s*:\s*[^;]+/gi, + message: '移除 -moz-binding 属性(Firefox XSS 向量)', + }, + { + pattern: /behavior\s*:\s*[^;]+/gi, + message: '移除 behavior: 属性(IE HTC)', + }, +] + +/** + * 将原始 CSS 按行分割并跟踪行号 + */ +function splitCSSByLines(css: string): string[] { + return css.split(/\r?\n/) +} + +/** + * 在 CSS 中查找模式匹配的行号 + */ +function findMatchingLineNumbers(css: string, pattern: RegExp): number[] { + const lines = splitCSSByLines(css) + const matchingLines: number[] = [] + + lines.forEach((line, index) => { + if (pattern.test(line)) { + matchingLines.push(index + 1) // 行号从 1 开始 + } + }) + + return matchingLines +} + +/** + * 过滤 CSS 中的危险内容 + * @param rawCSS 原始 CSS 字符串 + * @returns 包含过滤后的 CSS 和警告列表的对象 + */ +export function sanitizeCSS(rawCSS: string): SanitizeResult { + let sanitizedCSS = rawCSS + const warnings: string[] = [] + + // 应用所有过滤规则 + filterRules.forEach((rule) => { + const lineNumbers = findMatchingLineNumbers(sanitizedCSS, rule.pattern) + + // 对每个匹配的行生成警告 + lineNumbers.forEach((lineNum) => { + warnings.push(`Line ${lineNum}: ${rule.message}`) + }) + + // 从 CSS 中移除匹配内容 + sanitizedCSS = sanitizedCSS.replace(rule.pattern, '') + }) + + // 清理多余的空白行 + sanitizedCSS = sanitizedCSS.replace(/\n\s*\n/g, '\n').trim() + + return { + css: sanitizedCSS, + warnings, + } +} + +/** + * 快速检查 CSS 是否包含危险模式 + * @param css CSS 字符串 + * @returns 如果包含危险模式返回 true,否则返回 false + */ +export function isCSSSafe(css: string): boolean { + return !filterRules.some((rule) => rule.pattern.test(css)) +} diff --git a/dashboard/src/lib/theme/storage.ts b/dashboard/src/lib/theme/storage.ts new file mode 100644 index 00000000..63ea4d6a --- /dev/null +++ b/dashboard/src/lib/theme/storage.ts @@ -0,0 +1,205 @@ +/** + * 主题配置的 localStorage 存储管理模块 + * 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key + */ + +import type { UserThemeConfig } from './tokens' + +/** + * 主题存储 key 定义 + * 统一使用 'maibot-theme-*' 前缀,替代现有的 'ui-theme'、'maibot-ui-theme' 和 'accent-color' + */ +export const THEME_STORAGE_KEYS = { + MODE: 'maibot-theme-mode', + PRESET: 'maibot-theme-preset', + ACCENT: 'maibot-theme-accent', + OVERRIDES: 'maibot-theme-overrides', + CUSTOM_CSS: 'maibot-theme-custom-css', +} as const + +/** + * 默认主题配置 + */ +const DEFAULT_THEME_CONFIG: UserThemeConfig = { + selectedPreset: 'light', + accentColor: 'blue', + tokenOverrides: {}, + customCSS: '', +} + +/** + * 从 localStorage 加载完整主题配置 + * 缺失值使用合理默认值 + * + * @returns 加载的主题配置对象 + */ +export function loadThemeConfig(): UserThemeConfig { + const preset = localStorage.getItem(THEME_STORAGE_KEYS.PRESET) + const accent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT) + const overridesStr = localStorage.getItem(THEME_STORAGE_KEYS.OVERRIDES) + const customCSS = localStorage.getItem(THEME_STORAGE_KEYS.CUSTOM_CSS) + + // 解析 tokenOverrides JSON + let tokenOverrides = {} + if (overridesStr) { + try { + tokenOverrides = JSON.parse(overridesStr) + } catch { + // JSON 解析失败,使用空对象 + tokenOverrides = {} + } + } + + return { + selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset, + accentColor: accent || DEFAULT_THEME_CONFIG.accentColor, + tokenOverrides, + customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS, + } +} + +/** + * 保存完整主题配置到 localStorage + * + * @param config - 要保存的主题配置 + */ +export function saveThemeConfig(config: UserThemeConfig): void { + localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset) + localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor) + localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides)) + localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS) +} + +/** + * 部分更新主题配置 + * 先加载现有配置,合并部分更新,再保存 + * + * @param partial - 部分主题配置更新 + */ +export function saveThemePartial(partial: Partial): void { + const current = loadThemeConfig() + const updated: UserThemeConfig = { + ...current, + ...partial, + } + saveThemeConfig(updated) +} + +/** + * 导出主题配置为美化格式的 JSON 字符串 + * + * @returns 格式化的 JSON 字符串 + */ +export function exportThemeJSON(): string { + const config = loadThemeConfig() + return JSON.stringify(config, null, 2) +} + +/** + * 从 JSON 字符串导入主题配置 + * 包含基础的格式和字段校验 + * + * @param json - JSON 字符串 + * @returns 导入结果,包含成功状态和错误列表 + */ +export function importThemeJSON( + json: string, +): { success: boolean; errors: string[] } { + const errors: string[] = [] + + // JSON 格式校验 + let config: unknown + try { + config = JSON.parse(json) + } catch (error) { + return { + success: false, + errors: [`Invalid JSON format: ${error instanceof Error ? error.message : 'Unknown error'}`], + } + } + + // 基本对象类型校验 + if (typeof config !== 'object' || config === null) { + return { + success: false, + errors: ['Configuration must be a JSON object'], + } + } + + const configObj = config as Record + + // 必要字段存在性校验 + if (typeof configObj.selectedPreset !== 'string') { + errors.push('selectedPreset must be a string') + } + if (typeof configObj.accentColor !== 'string') { + errors.push('accentColor must be a string') + } + if (typeof configObj.customCSS !== 'string') { + errors.push('customCSS must be a string') + } + if (configObj.tokenOverrides !== undefined && typeof configObj.tokenOverrides !== 'object') { + errors.push('tokenOverrides must be an object') + } + + if (errors.length > 0) { + return { success: false, errors } + } + + // 校验通过,保存配置 + const validConfig: UserThemeConfig = { + selectedPreset: configObj.selectedPreset as string, + accentColor: configObj.accentColor as string, + tokenOverrides: (configObj.tokenOverrides as Partial) || {}, + customCSS: configObj.customCSS as string, + } + + saveThemeConfig(validConfig) + return { success: true, errors: [] } +} + +/** + * 重置主题配置为默认值 + * 删除所有 THEME_STORAGE_KEYS 对应的 localStorage 项 + */ +export function resetThemeToDefault(): void { + Object.values(THEME_STORAGE_KEYS).forEach((key) => { + localStorage.removeItem(key) + }) +} + +/** + * 迁移旧的 localStorage key 到新 key + * 处理: + * - 'ui-theme' 或 'maibot-ui-theme' → 'maibot-theme-mode' + * - 'accent-color' → 'maibot-theme-accent' + * 迁移完成后删除旧 key,避免重复迁移 + */ +export function migrateOldKeys(): void { + // 迁移主题模式 + // 优先使用 'ui-theme'(因为 ThemeProvider 默认使用它) + const uiTheme = localStorage.getItem('ui-theme') + const maiTheme = localStorage.getItem('maibot-ui-theme') + const newMode = localStorage.getItem(THEME_STORAGE_KEYS.MODE) + + if (!newMode) { + if (uiTheme) { + localStorage.setItem(THEME_STORAGE_KEYS.MODE, uiTheme) + } else if (maiTheme) { + localStorage.setItem(THEME_STORAGE_KEYS.MODE, maiTheme) + } + } + + // 迁移强调色 + const accentColor = localStorage.getItem('accent-color') + const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT) + + if (accentColor && !newAccent) { + localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, accentColor) + } + + // 删除旧 key + localStorage.removeItem('ui-theme') + localStorage.removeItem('maibot-ui-theme') + localStorage.removeItem('accent-color') +} diff --git a/dashboard/src/lib/theme/tokens.ts b/dashboard/src/lib/theme/tokens.ts new file mode 100644 index 00000000..cded85dd --- /dev/null +++ b/dashboard/src/lib/theme/tokens.ts @@ -0,0 +1,353 @@ +/** + * Design Token Schema 定义 + * 集中管理所有设计令牌(颜色、排版、间距、阴影、动画等) + */ + +// ============================================================================ +// Color Tokens 类型定义 +// ============================================================================ + +export type ColorTokens = { + primary: string + 'primary-foreground': string + 'primary-gradient': string + secondary: string + 'secondary-foreground': string + muted: string + 'muted-foreground': string + accent: string + 'accent-foreground': string + destructive: string + 'destructive-foreground': string + background: string + foreground: string + card: string + 'card-foreground': string + popover: string + 'popover-foreground': string + border: string + input: string + ring: string + 'chart-1': string + 'chart-2': string + 'chart-3': string + 'chart-4': string + 'chart-5': string +} + +// ============================================================================ +// Typography Tokens 类型定义 +// ============================================================================ + +export type TypographyTokens = { + 'font-family-base': string + 'font-family-code': string + 'font-size-xs': string + 'font-size-sm': string + 'font-size-base': string + 'font-size-lg': string + 'font-size-xl': string + 'font-size-2xl': string + 'font-weight-normal': number + 'font-weight-medium': number + 'font-weight-semibold': number + 'font-weight-bold': number + 'line-height-tight': number + 'line-height-normal': number + 'line-height-relaxed': number + 'letter-spacing-tight': string + 'letter-spacing-normal': string + 'letter-spacing-wide': string +} + +// ============================================================================ +// Visual Tokens 类型定义 +// ============================================================================ + +export type VisualTokens = { + 'radius-sm': string + 'radius-md': string + 'radius-lg': string + 'radius-xl': string + 'radius-full': string + 'shadow-sm': string + 'shadow-md': string + 'shadow-lg': string + 'shadow-xl': string + 'blur-sm': string + 'blur-md': string + 'blur-lg': string + 'opacity-disabled': number + 'opacity-hover': number + 'opacity-overlay': number +} + +// ============================================================================ +// Layout Tokens 类型定义 +// ============================================================================ + +export type LayoutTokens = { + 'space-unit': string + 'space-xs': string + 'space-sm': string + 'space-md': string + 'space-lg': string + 'space-xl': string + 'space-2xl': string + 'sidebar-width': string + 'header-height': string + 'max-content-width': string +} + +// ============================================================================ +// Animation Tokens 类型定义 +// ============================================================================ + +export type AnimationTokens = { + 'anim-duration-fast': string + 'anim-duration-normal': string + 'anim-duration-slow': string + 'anim-easing-default': string + 'anim-easing-in': string + 'anim-easing-out': string + 'anim-easing-in-out': string + 'transition-colors': string + 'transition-transform': string + 'transition-opacity': string +} + +// ============================================================================ +// Aggregated Theme Tokens +// ============================================================================ + +export type ThemeTokens = { + color: ColorTokens + typography: TypographyTokens + visual: VisualTokens + layout: LayoutTokens + animation: AnimationTokens +} + +// ============================================================================ +// Theme Preset & Config Types +// ============================================================================ + +export type ThemePreset = { + id: string + name: string + description: string + tokens: ThemeTokens + isDark: boolean +} + +export type UserThemeConfig = { + selectedPreset: string + accentColor: string + tokenOverrides: Partial + customCSS: string +} + +// ============================================================================ +// Default Light Tokens (from index.css :root) +// ============================================================================ + +export const defaultLightTokens: ThemeTokens = { + color: { + primary: '221.2 83.2% 53.3%', + 'primary-foreground': '210 40% 98%', + 'primary-gradient': 'none', + secondary: '210 40% 96.1%', + 'secondary-foreground': '222.2 47.4% 11.2%', + muted: '210 40% 96.1%', + 'muted-foreground': '215.4 16.3% 46.9%', + accent: '210 40% 96.1%', + 'accent-foreground': '222.2 47.4% 11.2%', + destructive: '0 84.2% 60.2%', + 'destructive-foreground': '210 40% 98%', + background: '0 0% 100%', + foreground: '222.2 84% 4.9%', + card: '0 0% 100%', + 'card-foreground': '222.2 84% 4.9%', + popover: '0 0% 100%', + 'popover-foreground': '222.2 84% 4.9%', + border: '214.3 31.8% 91.4%', + input: '214.3 31.8% 91.4%', + ring: '221.2 83.2% 53.3%', + 'chart-1': '221.2 83.2% 53.3%', + 'chart-2': '160 60% 45%', + 'chart-3': '30 80% 55%', + 'chart-4': '280 65% 60%', + 'chart-5': '340 75% 55%', + }, + typography: { + 'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + 'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace', + 'font-size-xs': '0.75rem', + 'font-size-sm': '0.875rem', + 'font-size-base': '1rem', + 'font-size-lg': '1.125rem', + 'font-size-xl': '1.25rem', + 'font-size-2xl': '1.5rem', + 'font-weight-normal': 400, + 'font-weight-medium': 500, + 'font-weight-semibold': 600, + 'font-weight-bold': 700, + 'line-height-tight': 1.2, + 'line-height-normal': 1.5, + 'line-height-relaxed': 1.75, + 'letter-spacing-tight': '-0.02em', + 'letter-spacing-normal': '0em', + 'letter-spacing-wide': '0.02em', + }, + visual: { + 'radius-sm': '0.25rem', + 'radius-md': '0.375rem', + 'radius-lg': '0.5rem', + 'radius-xl': '0.75rem', + 'radius-full': '9999px', + 'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + 'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + 'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1)', + 'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1)', + 'blur-sm': '4px', + 'blur-md': '12px', + 'blur-lg': '24px', + 'opacity-disabled': 0.5, + 'opacity-hover': 0.8, + 'opacity-overlay': 0.75, + }, + layout: { + 'space-unit': '0.25rem', + 'space-xs': '0.5rem', + 'space-sm': '0.75rem', + 'space-md': '1rem', + 'space-lg': '1.5rem', + 'space-xl': '2rem', + 'space-2xl': '3rem', + 'sidebar-width': '16rem', + 'header-height': '3.5rem', + 'max-content-width': '1280px', + }, + animation: { + 'anim-duration-fast': '150ms', + 'anim-duration-normal': '300ms', + 'anim-duration-slow': '500ms', + 'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)', + 'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)', + 'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)', + 'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)', + 'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)', + }, +} + +// ============================================================================ +// Default Dark Tokens (from index.css .dark) +// ============================================================================ + +export const defaultDarkTokens: ThemeTokens = { + color: { + primary: '217.2 91.2% 59.8%', + 'primary-foreground': '210 40% 98%', + 'primary-gradient': 'none', + secondary: '217.2 32.6% 17.5%', + 'secondary-foreground': '210 40% 98%', + muted: '217.2 32.6% 17.5%', + 'muted-foreground': '215 20.2% 65.1%', + accent: '217.2 32.6% 17.5%', + 'accent-foreground': '210 40% 98%', + destructive: '0 62.8% 30.6%', + 'destructive-foreground': '210 40% 98%', + background: '222.2 84% 4.9%', + foreground: '210 40% 98%', + card: '222.2 84% 4.9%', + 'card-foreground': '210 40% 98%', + popover: '222.2 84% 4.9%', + 'popover-foreground': '210 40% 98%', + border: '217.2 32.6% 17.5%', + input: '217.2 32.6% 17.5%', + ring: '224.3 76.3% 48%', + 'chart-1': '217.2 91.2% 59.8%', + 'chart-2': '160 60% 50%', + 'chart-3': '30 80% 60%', + 'chart-4': '280 65% 65%', + 'chart-5': '340 75% 60%', + }, + typography: { + 'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + 'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace', + 'font-size-xs': '0.75rem', + 'font-size-sm': '0.875rem', + 'font-size-base': '1rem', + 'font-size-lg': '1.125rem', + 'font-size-xl': '1.25rem', + 'font-size-2xl': '1.5rem', + 'font-weight-normal': 400, + 'font-weight-medium': 500, + 'font-weight-semibold': 600, + 'font-weight-bold': 700, + 'line-height-tight': 1.2, + 'line-height-normal': 1.5, + 'line-height-relaxed': 1.75, + 'letter-spacing-tight': '-0.02em', + 'letter-spacing-normal': '0em', + 'letter-spacing-wide': '0.02em', + }, + visual: { + 'radius-sm': '0.25rem', + 'radius-md': '0.375rem', + 'radius-lg': '0.5rem', + 'radius-xl': '0.75rem', + 'radius-full': '9999px', + 'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.25)', + 'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.3)', + 'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.4)', + 'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.5)', + 'blur-sm': '4px', + 'blur-md': '12px', + 'blur-lg': '24px', + 'opacity-disabled': 0.5, + 'opacity-hover': 0.8, + 'opacity-overlay': 0.75, + }, + layout: { + 'space-unit': '0.25rem', + 'space-xs': '0.5rem', + 'space-sm': '0.75rem', + 'space-md': '1rem', + 'space-lg': '1.5rem', + 'space-xl': '2rem', + 'space-2xl': '3rem', + 'sidebar-width': '16rem', + 'header-height': '3.5rem', + 'max-content-width': '1280px', + }, + animation: { + 'anim-duration-fast': '150ms', + 'anim-duration-normal': '300ms', + 'anim-duration-slow': '500ms', + 'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)', + 'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)', + 'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)', + 'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)', + 'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)', + }, +} + +// ============================================================================ +// Token Utility Functions +// ============================================================================ + +/** + * 将 Token 类别和 key 转换为 CSS 变量名 + * @example tokenToCSSVarName('color', 'primary') => '--color-primary' + */ +export function tokenToCSSVarName( + category: keyof ThemeTokens | 'color' | 'typography' | 'visual' | 'layout' | 'animation', + key: string, +): string { + return `--${category}-${key}` +}