mirror of https://github.com/Mai-with-u/MaiBot.git
feat(theme): add design token schema, palette derivation, CSS sanitizer, and storage manager
parent
0a572515ba
commit
6aa1132f4c
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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}`
|
||||
}
|
||||
Loading…
Reference in New Issue