feat(theme): add design token schema, palette derivation, CSS sanitizer, and storage manager

pull/1496/head
DrSmoothl 2026-02-19 17:16:28 +08:00
parent 0a572515ba
commit 6aa1132f4c
4 changed files with 872 additions and 0 deletions

View File

@ -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],
}
}

View File

@ -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))
}

View File

@ -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<UserThemeConfig>): 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<string, unknown>
// 必要字段存在性校验
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<any>) || {},
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')
}

View File

@ -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<ThemeTokens>
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}`
}