feat(theme): add CSS injection pipeline, presets, rewrite ThemeProvider, FOUC prevention

This commit is contained in:
DrSmoothl
2026-02-19 17:26:53 +08:00
parent 6aa1132f4c
commit 8fb137a318
5 changed files with 284 additions and 114 deletions

View File

@@ -1,15 +1,30 @@
import { createContext } from 'react'
import type { UserThemeConfig } from './theme/tokens'
type Theme = 'dark' | 'light' | 'system'
export type ThemeProviderState = {
theme: Theme
resolvedTheme: 'dark' | 'light'
setTheme: (theme: Theme) => void
themeConfig: UserThemeConfig
updateThemeConfig: (partial: Partial<UserThemeConfig>) => void
resetTheme: () => void
}
const initialState: ThemeProviderState = {
theme: 'system',
resolvedTheme: 'light',
setTheme: () => null,
themeConfig: {
selectedPreset: 'light',
accentColor: '',
tokenOverrides: {},
customCSS: '',
},
updateThemeConfig: () => null,
resetTheme: () => null,
}
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)

View File

@@ -0,0 +1,124 @@
import type { ThemeTokens, UserThemeConfig } from './tokens'
import { generatePalette } from './palette'
import { getPresetById } from './presets'
import { sanitizeCSS } from './sanitizer'
import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens'
const CUSTOM_CSS_ID = 'maibot-custom-css'
const mergeTokens = (base: ThemeTokens, overrides: Partial<ThemeTokens>): ThemeTokens => {
return {
color: {
...base.color,
...(overrides.color ?? {}),
},
typography: {
...base.typography,
...(overrides.typography ?? {}),
},
visual: {
...base.visual,
...(overrides.visual ?? {}),
},
layout: {
...base.layout,
...(overrides.layout ?? {}),
},
animation: {
...base.animation,
...(overrides.animation ?? {}),
},
}
}
const buildTokens = (config: UserThemeConfig, isDark: boolean): ThemeTokens => {
const baseTokens = isDark ? defaultDarkTokens : defaultLightTokens
let mergedTokens = mergeTokens(baseTokens, {})
if (config.accentColor) {
const paletteTokens = generatePalette(config.accentColor, isDark)
mergedTokens = mergeTokens(mergedTokens, { color: paletteTokens })
}
if (config.selectedPreset) {
const preset = getPresetById(config.selectedPreset)
if (preset?.tokens) {
mergedTokens = mergeTokens(mergedTokens, preset.tokens)
}
}
if (config.tokenOverrides) {
mergedTokens = mergeTokens(mergedTokens, config.tokenOverrides)
}
return mergedTokens
}
export function getComputedTokens(config: UserThemeConfig, isDark: boolean): ThemeTokens {
return buildTokens(config, isDark)
}
export function injectTokensAsCSS(tokens: ThemeTokens, target: HTMLElement): void {
Object.entries(tokens.color).forEach(([key, value]) => {
target.style.setProperty(tokenToCSSVarName('color', key), String(value))
})
Object.entries(tokens.typography).forEach(([key, value]) => {
target.style.setProperty(tokenToCSSVarName('typography', key), String(value))
})
Object.entries(tokens.visual).forEach(([key, value]) => {
target.style.setProperty(tokenToCSSVarName('visual', key), String(value))
})
Object.entries(tokens.layout).forEach(([key, value]) => {
target.style.setProperty(tokenToCSSVarName('layout', key), String(value))
})
Object.entries(tokens.animation).forEach(([key, value]) => {
target.style.setProperty(tokenToCSSVarName('animation', key), String(value))
})
}
export function injectCustomCSS(css: string): void {
if (css.trim().length === 0) {
removeCustomCSS()
return
}
const existing = document.getElementById(CUSTOM_CSS_ID)
if (existing) {
existing.textContent = css
return
}
const style = document.createElement('style')
style.id = CUSTOM_CSS_ID
style.textContent = css
document.head.appendChild(style)
}
export function removeCustomCSS(): void {
const existing = document.getElementById(CUSTOM_CSS_ID)
if (existing) {
existing.remove()
}
}
export function applyThemePipeline(config: UserThemeConfig, isDark: boolean): void {
const root = document.documentElement
const tokens = buildTokens(config, isDark)
injectTokensAsCSS(tokens, root)
if (config.customCSS) {
const sanitized = sanitizeCSS(config.customCSS)
if (sanitized.css.trim().length > 0) {
injectCustomCSS(sanitized.css)
return
}
}
removeCustomCSS()
}

View File

@@ -0,0 +1,62 @@
/**
* Theme Presets 定义
* 提供内置的亮色和暗色主题预设
*/
import {
defaultDarkTokens,
defaultLightTokens,
} from './tokens'
import type { ThemePreset } from './tokens'
// ============================================================================
// Default Light Preset
// ============================================================================
export const defaultLightPreset: ThemePreset = {
id: 'light',
name: '默认亮色',
description: '默认亮色主题',
tokens: defaultLightTokens,
isDark: false,
}
// ============================================================================
// Default Dark Preset
// ============================================================================
export const defaultDarkPreset: ThemePreset = {
id: 'dark',
name: '默认暗色',
description: '默认暗色主题',
tokens: defaultDarkTokens,
isDark: true,
}
// ============================================================================
// Built-in Presets Collection
// ============================================================================
export const builtInPresets: ThemePreset[] = [
defaultLightPreset,
defaultDarkPreset,
]
// ============================================================================
// Default Preset ID
// ============================================================================
export const DEFAULT_PRESET_ID = 'light'
// ============================================================================
// Preset Utility Functions
// ============================================================================
/**
* 根据 ID 获取预设
* @param id - 预设 ID
* @returns 对应的预设,如果不存在则返回 undefined
*/
export function getPresetById(id: string): ThemePreset | undefined {
return builtInPresets.find((preset) => preset.id === id)
}