## 发布说明 **v1.13.0 Theme V3 主题系统版本** ### 关联 Issue - 关联 #99(「能不能直接在网页加个密码?」):本版本实现其中**主题苹果风**部分——新增 Apple 内置主题(macOS 风格浅色)并落地 Theme V3 主题系统;**网页访问密码**部分未包含在本版本,issue 保持开启、留待后续版本。 ### 版本亮点 - **内置主题 5 → 7 款**:新增 CRT Amber(CRT 磷光深色:径向暗角背景 + 扫描线/闪烁/辉光/噪点全开 + 方形 mono 造型)与 Glass(蓝紫浅色玻璃态:mesh 粉彩漂移背景 + 强表面模糊 + 1.4× 圆角 + 分段控件);Glacier 替换为 Apple(macOS 风格浅色,完整 ANSI 16 色无障碍变体) - **Theme V3 声明式令牌系统**(schemaVersion 3):`background`(solid/linear/radial/mesh 四类型、停靠点白名单、读性遮罩下限 0.25/0.35、drift 动画)、`effects`(scanline/flicker/glow/noise 强度钳制 0-1、data-fx-* 门控、Cyberpunk 默认视觉不变)、`typography`(字号/圆角缩放)、`appearance.blur` 独立表面模糊档位 - **零 DOM 操作、前后端同源校验**:背景/效果全部 CSS 变量 + 属性驱动;V2 主题数据自动升级、glacier baseTheme 优雅降级;导入/导出双向兼容 - **在线主题编辑器支持 V3**:背景层/效果/版式三组面板 + blur 档位,导出 schemaVersion 3 - **全量文档对齐**:README/tests README/AGENTS/CHANGELOG 与当前实现同步 ### 提交列表 | 提交 | 说明 | | ---- | ---- | | 268a892 | feat: 内置主题 Glacier 替换为 Apple 浅色主题 | | 86e3ddf | feat: Theme V3 主题系统落地——背景层、效果注册表与版式缩放 | | 9092e2a | release: 发布 v1.13.0(版本号 + CHANGELOG) | | c098a7c | docs: 全量文档与当前实现对齐(测试清单 + 措辞修正) | ### 验证结果 - ✅ typecheck(worker + frontend + tests 三工程) - ✅ vitest 54 文件 / 649 用例 - ✅ build:frontend 可复现内联构建(html.ts 再生成) - ✅ 主题 e2e 6/6(含 CRT/Glass 切换、drift 属性、强模糊与 body::before 背景计算值断言) --------- Co-authored-by: TanXin <tanxin@example.com>
749 lines
23 KiB
TypeScript
749 lines
23 KiB
TypeScript
import type { ITheme } from '@xterm/xterm';
|
|
import {
|
|
type BuiltInThemeName,
|
|
type ColorScheme,
|
|
type NormalizedThemeData,
|
|
normalizeThemeData,
|
|
type ThemeBackground,
|
|
type ThemeEffects,
|
|
type ThemeTypography,
|
|
type ThemeAppearance,
|
|
type ThemeBlur,
|
|
type ThemeComponentStyles,
|
|
type ThemeDensity,
|
|
type ThemeFont,
|
|
type ThemeMotion,
|
|
type ThemeShadow,
|
|
type ThemeShape,
|
|
type UIStylePresetName,
|
|
} from '../../src/theme-schema';
|
|
|
|
export {
|
|
type BuiltInThemeName,
|
|
type ColorScheme,
|
|
type ThemeBackground,
|
|
type ThemeEffects,
|
|
type ThemeTypography,
|
|
THEME_MAX_BYTES,
|
|
THEME_SCHEMA_VERSION,
|
|
type ThemeAppearance,
|
|
type ThemeBlur,
|
|
type ThemeComponentStyles,
|
|
type ThemeDensity,
|
|
type ThemeFont,
|
|
type ThemeMotion,
|
|
type ThemeShadow,
|
|
type ThemeShape,
|
|
type UIStylePresetName,
|
|
} from '../../src/theme-schema';
|
|
|
|
export interface ResolvedThemeAppearance {
|
|
style: UIStylePresetName;
|
|
shape: ThemeShape;
|
|
density: ThemeDensity;
|
|
font: ThemeFont;
|
|
shadow: ThemeShadow;
|
|
motion: ThemeMotion;
|
|
blur: ThemeBlur;
|
|
components: ThemeComponentStyles;
|
|
}
|
|
|
|
export type ImportedThemeData = Omit<
|
|
NormalizedThemeData,
|
|
'schemaVersion' | 'colorScheme' | 'terminal'
|
|
> & {
|
|
schemaVersion?: number;
|
|
colorScheme?: ColorScheme;
|
|
terminal?: ITheme;
|
|
};
|
|
|
|
export const UI_STYLE_PRESETS: Record<UIStylePresetName, ResolvedThemeAppearance> = {
|
|
standard: {
|
|
style: 'standard',
|
|
shape: 'rounded',
|
|
density: 'comfortable',
|
|
font: 'system',
|
|
shadow: 'subtle',
|
|
motion: 'reduced',
|
|
blur: 'subtle',
|
|
components: {
|
|
button: 'solid',
|
|
input: 'boxed',
|
|
card: 'outlined',
|
|
tabs: 'underline',
|
|
},
|
|
},
|
|
cyberpunk: {
|
|
style: 'cyberpunk',
|
|
shape: 'square',
|
|
density: 'compact',
|
|
font: 'mono',
|
|
shadow: 'none',
|
|
motion: 'full',
|
|
blur: 'none',
|
|
components: {
|
|
button: 'outline',
|
|
input: 'underline',
|
|
card: 'outlined',
|
|
tabs: 'underline',
|
|
},
|
|
},
|
|
soft: {
|
|
style: 'soft',
|
|
shape: 'soft',
|
|
density: 'comfortable',
|
|
font: 'system',
|
|
shadow: 'elevated',
|
|
motion: 'reduced',
|
|
blur: 'strong',
|
|
components: {
|
|
button: 'soft',
|
|
input: 'boxed',
|
|
card: 'elevated',
|
|
tabs: 'segmented',
|
|
},
|
|
},
|
|
dense: {
|
|
style: 'dense',
|
|
shape: 'rounded',
|
|
density: 'compact',
|
|
font: 'mono',
|
|
shadow: 'none',
|
|
motion: 'reduced',
|
|
blur: 'none',
|
|
components: {
|
|
button: 'outline',
|
|
input: 'boxed',
|
|
card: 'flat',
|
|
tabs: 'segmented',
|
|
},
|
|
},
|
|
};
|
|
|
|
export const THEMES = {
|
|
'standard-dark': {
|
|
background: '#0b0e14',
|
|
foreground: '#d6deeb',
|
|
cursor: '#58a6ff',
|
|
cursorAccent: '#0b0e14',
|
|
selectionBackground: '#264f78',
|
|
selectionInactiveBackground: '#1f3a56',
|
|
black: '#0d1117',
|
|
red: '#ff7b72',
|
|
green: '#3fb950',
|
|
yellow: '#d29922',
|
|
blue: '#58a6ff',
|
|
magenta: '#bc8cff',
|
|
cyan: '#39c5cf',
|
|
white: '#b1bac4',
|
|
brightBlack: '#6e7681',
|
|
brightRed: '#ffa198',
|
|
brightGreen: '#56d364',
|
|
brightYellow: '#e3b341',
|
|
brightBlue: '#79c0ff',
|
|
brightMagenta: '#d2a8ff',
|
|
brightCyan: '#56d4dd',
|
|
brightWhite: '#f0f6fc',
|
|
},
|
|
'standard-light': {
|
|
background: '#ffffff',
|
|
foreground: '#24292f',
|
|
cursor: '#0969da',
|
|
cursorAccent: '#ffffff',
|
|
selectionBackground: '#b6d7ff',
|
|
selectionForeground: '#1f2328',
|
|
selectionInactiveBackground: '#dbeafe',
|
|
black: '#24292f',
|
|
red: '#cf222e',
|
|
green: '#1a7f37',
|
|
yellow: '#9a6700',
|
|
blue: '#0969da',
|
|
magenta: '#8250df',
|
|
cyan: '#1b7c83',
|
|
white: '#6e7781',
|
|
brightBlack: '#57606a',
|
|
brightRed: '#a40e26',
|
|
brightGreen: '#116329',
|
|
brightYellow: '#7d4e00',
|
|
brightBlue: '#0550ae',
|
|
brightMagenta: '#6639ba',
|
|
brightCyan: '#0a6c74',
|
|
brightWhite: '#1f2328',
|
|
},
|
|
cyberpunk: {
|
|
background: '#0a0a0a',
|
|
foreground: '#4af626',
|
|
cursor: '#14d1ff',
|
|
cursorAccent: '#0a0a0a',
|
|
selectionBackground: '#273747',
|
|
},
|
|
apple: {
|
|
background: '#ffffff',
|
|
foreground: '#1d1d1f',
|
|
cursor: '#0066da',
|
|
cursorAccent: '#ffffff',
|
|
selectionBackground: '#b3d7ff',
|
|
selectionForeground: '#1d1d1f',
|
|
selectionInactiveBackground: '#dde8f7',
|
|
black: '#1d1d1f',
|
|
red: '#d70015',
|
|
green: '#248a3d',
|
|
yellow: '#b25000',
|
|
blue: '#0040dd',
|
|
magenta: '#8944ab',
|
|
cyan: '#0b7285',
|
|
white: '#8e8e93',
|
|
brightBlack: '#6e6e73',
|
|
brightRed: '#ff3b30',
|
|
brightGreen: '#34c759',
|
|
brightYellow: '#c93400',
|
|
brightBlue: '#0071e3',
|
|
brightMagenta: '#af52de',
|
|
brightCyan: '#30b0c7',
|
|
brightWhite: '#1d1d1f',
|
|
},
|
|
gruvbox: {
|
|
background: '#282828',
|
|
foreground: '#ebdbb2',
|
|
cursor: '#d3869b',
|
|
cursorAccent: '#282828',
|
|
selectionBackground: '#504945',
|
|
},
|
|
crt: {
|
|
background: '#0f0a00',
|
|
foreground: '#ffb000',
|
|
cursor: '#ffd257',
|
|
cursorAccent: '#0f0a00',
|
|
selectionBackground: '#3d2e00',
|
|
selectionForeground: '#ffb000',
|
|
selectionInactiveBackground: '#2a2000',
|
|
black: '#241a00',
|
|
red: '#ff6b4a',
|
|
green: '#d4a017',
|
|
yellow: '#ffb000',
|
|
blue: '#f0c040',
|
|
magenta: '#e08e39',
|
|
cyan: '#c8b04a',
|
|
white: '#d9c08a',
|
|
brightBlack: '#8a7440',
|
|
brightRed: '#ffa080',
|
|
brightGreen: '#ffd257',
|
|
brightYellow: '#ffe08a',
|
|
brightBlue: '#ffe8b0',
|
|
brightMagenta: '#ffc27a',
|
|
brightCyan: '#e8d89a',
|
|
brightWhite: '#fff3c4',
|
|
},
|
|
glass: {
|
|
background: '#ffffff',
|
|
foreground: '#1a2332',
|
|
cursor: '#235cd8',
|
|
cursorAccent: '#ffffff',
|
|
selectionBackground: '#c4d8f7',
|
|
selectionForeground: '#1a2332',
|
|
selectionInactiveBackground: '#dde8f7',
|
|
black: '#1a2332',
|
|
red: '#c03528',
|
|
green: '#1e7d3c',
|
|
yellow: '#8a5a00',
|
|
blue: '#235cd8',
|
|
magenta: '#7c4dcc',
|
|
cyan: '#0e6e86',
|
|
white: '#56637a',
|
|
brightBlack: '#56637a',
|
|
brightRed: '#d9483b',
|
|
brightGreen: '#2a944f',
|
|
brightYellow: '#a86e10',
|
|
brightBlue: '#3870e8',
|
|
brightMagenta: '#8f5ce6',
|
|
brightCyan: '#2a8ba3',
|
|
brightWhite: '#0e1626',
|
|
},
|
|
} satisfies Record<string, ITheme>;
|
|
|
|
export const UI_THEMES: Record<BuiltInThemeName, Record<string, string>> = {
|
|
'standard-dark': {
|
|
'--bg': '#0f1115',
|
|
'--bg-surface': '#161a22',
|
|
'--bg-elevated': '#1c222d',
|
|
'--bg-terminal': '#0b0e14',
|
|
'--text': '#e6edf3',
|
|
'--text-muted': '#9ba7b4',
|
|
'--text-dim': '#7d8590',
|
|
'--accent': '#58a6ff',
|
|
'--accent-secondary': '#79c0ff',
|
|
'--accent-secondary-light': '#a5d6ff',
|
|
'--border': '#30363d',
|
|
'--border-strong': '#484f58',
|
|
'--error': '#ff7b72',
|
|
'--error-bg': '#3d1418',
|
|
'--on-accent': '#07111f',
|
|
'--surface-dot': '#30363d',
|
|
'--scrollbar-track': 'rgba(22, 26, 34, 0.7)',
|
|
'--scrollbar-thumb': 'rgba(155, 167, 180, 0.35)',
|
|
'--scrollbar-thumb-hover': 'rgba(155, 167, 180, 0.55)',
|
|
'--scanline-tint': 'transparent',
|
|
'--accent-glow': 'rgba(88, 166, 255, 0.08)',
|
|
'--accent-bg': 'rgba(88, 166, 255, 0.1)',
|
|
'--modal-overlay': 'rgba(0, 0, 0, 0.72)',
|
|
'--on-surface': '#e6edf3',
|
|
'--on-surface-variant': '#9ba7b4',
|
|
'--agent-user-color': '#58a6ff',
|
|
'--agent-agent-color': '#79c0ff',
|
|
},
|
|
'standard-light': {
|
|
'--bg': '#f6f8fa',
|
|
'--bg-surface': '#ffffff',
|
|
'--bg-elevated': '#eef2f6',
|
|
'--bg-terminal': '#ffffff',
|
|
'--text': '#1f2328',
|
|
'--text-muted': '#57606a',
|
|
'--text-dim': '#656d76',
|
|
'--accent': '#0969da',
|
|
'--accent-secondary': '#0550ae',
|
|
'--accent-secondary-light': '#0550ae',
|
|
'--border': '#d0d7de',
|
|
'--border-strong': '#afb8c1',
|
|
'--error': '#cf222e',
|
|
'--error-bg': '#ffebe9',
|
|
'--on-accent': '#ffffff',
|
|
'--surface-dot': '#eaeef2',
|
|
'--scrollbar-track': 'rgba(208, 215, 222, 0.45)',
|
|
'--scrollbar-thumb': 'rgba(87, 96, 106, 0.35)',
|
|
'--scrollbar-thumb-hover': 'rgba(87, 96, 106, 0.55)',
|
|
'--scanline-tint': 'transparent',
|
|
'--accent-glow': 'rgba(9, 105, 218, 0.08)',
|
|
'--accent-bg': 'rgba(9, 105, 218, 0.1)',
|
|
'--modal-overlay': 'rgba(31, 35, 40, 0.48)',
|
|
'--on-surface': '#1f2328',
|
|
'--on-surface-variant': '#57606a',
|
|
'--agent-user-color': '#0969da',
|
|
'--agent-agent-color': '#8250df',
|
|
},
|
|
cyberpunk: {
|
|
'--bg': '#0a0a0a',
|
|
'--bg-surface': '#121212',
|
|
'--bg-elevated': '#131313',
|
|
'--bg-terminal': '#0e0e0e',
|
|
'--text': '#4af626',
|
|
'--text-muted': '#bbccb0',
|
|
'--text-dim': '#3c4b36',
|
|
'--accent': '#4af626',
|
|
'--accent-secondary': '#14d1ff',
|
|
'--accent-secondary-light': '#b7eaff',
|
|
'--border': '#1f1f1f',
|
|
'--border-strong': '#3c4b36',
|
|
'--error': '#ffb4ab',
|
|
'--error-bg': '#93000a',
|
|
'--on-accent': '#022100',
|
|
'--surface-dot': '#353534',
|
|
'--scrollbar-track': 'rgba(28, 27, 27, 0.5)',
|
|
'--scrollbar-thumb': 'rgba(60, 75, 54, 0.8)',
|
|
'--scrollbar-thumb-hover': 'rgba(134, 149, 125, 0.8)',
|
|
'--scanline-tint': 'rgba(74, 246, 38, 0.02)',
|
|
'--accent-glow': 'rgba(74, 246, 38, 0.08)',
|
|
'--accent-bg': 'rgba(74, 246, 38, 0.1)',
|
|
'--modal-overlay': 'rgba(0, 0, 0, 0.8)',
|
|
'--on-surface': '#e5e2e1',
|
|
'--on-surface-variant': '#bbccb0',
|
|
'--agent-user-color': '#4af626',
|
|
'--agent-agent-color': '#14d1ff',
|
|
},
|
|
apple: {
|
|
'--bg': '#f5f5f7',
|
|
'--bg-surface': '#ffffff',
|
|
'--bg-elevated': '#ffffff',
|
|
'--bg-terminal': '#ffffff',
|
|
'--text': '#1d1d1f',
|
|
'--text-muted': '#636368',
|
|
'--text-dim': '#6e6e73',
|
|
'--accent': '#0066da',
|
|
'--accent-secondary': '#0052b4',
|
|
'--accent-secondary-light': '#cce4ff',
|
|
'--border': '#d2d2d7',
|
|
'--border-strong': '#aeaeb2',
|
|
'--error': '#d70015',
|
|
'--error-bg': '#ffebe9',
|
|
'--on-accent': '#ffffff',
|
|
'--surface-dot': '#e8e8ed',
|
|
'--scrollbar-track': 'rgba(210, 210, 215, 0.45)',
|
|
'--scrollbar-thumb': 'rgba(99, 99, 104, 0.35)',
|
|
'--scrollbar-thumb-hover': 'rgba(99, 99, 104, 0.55)',
|
|
'--scanline-tint': 'transparent',
|
|
'--accent-glow': 'rgba(0, 102, 218, 0.08)',
|
|
'--accent-bg': 'rgba(0, 102, 218, 0.1)',
|
|
'--modal-overlay': 'rgba(0, 0, 0, 0.32)',
|
|
'--on-surface': '#1d1d1f',
|
|
'--on-surface-variant': '#636368',
|
|
'--agent-user-color': '#0066da',
|
|
'--agent-agent-color': '#5856d6',
|
|
},
|
|
gruvbox: {
|
|
'--bg': '#282828',
|
|
'--bg-surface': '#303030',
|
|
'--bg-elevated': '#282828',
|
|
'--bg-terminal': '#1d2021',
|
|
'--text': '#ebdbb2',
|
|
'--text-muted': '#a89984',
|
|
'--text-dim': '#665c54',
|
|
'--accent': '#b8bb26',
|
|
'--accent-secondary': '#83a598',
|
|
'--accent-secondary-light': '#8ec07c',
|
|
'--border': '#3c3836',
|
|
'--border-strong': '#665c54',
|
|
'--error': '#fb4934',
|
|
'--error-bg': '#3d0000',
|
|
'--on-accent': '#282828',
|
|
'--surface-dot': '#3c3836',
|
|
'--scrollbar-track': 'rgba(40, 40, 40, 0.5)',
|
|
'--scrollbar-thumb': 'rgba(168, 153, 132, 0.3)',
|
|
'--scrollbar-thumb-hover': 'rgba(168, 153, 132, 0.5)',
|
|
'--scanline-tint': 'rgba(184, 187, 38, 0.02)',
|
|
'--accent-glow': 'rgba(184, 187, 38, 0.08)',
|
|
'--accent-bg': 'rgba(184, 187, 38, 0.1)',
|
|
'--modal-overlay': 'rgba(0, 0, 0, 0.75)',
|
|
'--on-surface': '#ebdbb2',
|
|
'--on-surface-variant': '#a89984',
|
|
'--agent-user-color': '#b8bb26',
|
|
'--agent-agent-color': '#83a598',
|
|
},
|
|
crt: {
|
|
'--bg': '#0f0a00',
|
|
'--bg-surface': '#171000',
|
|
'--bg-elevated': '#1d1600',
|
|
'--bg-terminal': '#0f0a00',
|
|
'--text': '#ffb000',
|
|
'--text-muted': '#bfa06a',
|
|
'--text-dim': '#937c46',
|
|
'--accent': '#ffb000',
|
|
'--accent-secondary': '#ffd257',
|
|
'--accent-secondary-light': '#ffe08a',
|
|
'--border': '#3d2e00',
|
|
'--border-strong': '#5c470a',
|
|
'--error': '#ff6b4a',
|
|
'--error-bg': '#3d1200',
|
|
'--on-accent': '#0f0a00',
|
|
'--surface-dot': '#3d2e00',
|
|
'--scrollbar-track': 'rgba(23, 16, 0, 0.5)',
|
|
'--scrollbar-thumb': 'rgba(255, 176, 0, 0.25)',
|
|
'--scrollbar-thumb-hover': 'rgba(255, 176, 0, 0.45)',
|
|
'--scanline-tint': 'rgba(255, 176, 0, 0.03)',
|
|
'--accent-glow': 'rgba(255, 176, 0, 0.12)',
|
|
'--accent-bg': 'rgba(255, 176, 0, 0.12)',
|
|
'--modal-overlay': 'rgba(0, 0, 0, 0.8)',
|
|
'--on-surface': '#fff3c4',
|
|
'--on-surface-variant': '#bfa06a',
|
|
'--agent-user-color': '#ffb000',
|
|
'--agent-agent-color': '#ffd257',
|
|
},
|
|
glass: {
|
|
'--bg': '#e8edf5',
|
|
'--bg-surface': '#f4f7fb',
|
|
'--bg-elevated': '#ffffff',
|
|
'--bg-terminal': '#ffffff',
|
|
'--text': '#1a2332',
|
|
'--text-muted': '#4d5a70',
|
|
'--text-dim': '#56637a',
|
|
'--accent': '#235cd8',
|
|
'--accent-secondary': '#3870e8',
|
|
'--accent-secondary-light': '#c4d8f7',
|
|
'--border': '#c9d4e3',
|
|
'--border-strong': '#a9b8cc',
|
|
'--error': '#c03528',
|
|
'--error-bg': '#f9e3e0',
|
|
'--on-accent': '#ffffff',
|
|
'--surface-dot': '#d5deea',
|
|
'--scrollbar-track': 'rgba(201, 212, 227, 0.45)',
|
|
'--scrollbar-thumb': 'rgba(77, 90, 112, 0.3)',
|
|
'--scrollbar-thumb-hover': 'rgba(77, 90, 112, 0.5)',
|
|
'--scanline-tint': 'transparent',
|
|
'--accent-glow': 'rgba(35, 92, 216, 0.1)',
|
|
'--accent-bg': 'rgba(35, 92, 216, 0.08)',
|
|
'--modal-overlay': 'rgba(26, 35, 50, 0.35)',
|
|
'--on-surface': '#1a2332',
|
|
'--on-surface-variant': '#4d5a70',
|
|
'--agent-user-color': '#235cd8',
|
|
'--agent-agent-color': '#7c4dcc',
|
|
},
|
|
};
|
|
|
|
export const BUILT_IN_APPEARANCE: Record<BuiltInThemeName, ThemeAppearance> = {
|
|
'standard-dark': { style: 'standard' },
|
|
'standard-light': { style: 'standard' },
|
|
cyberpunk: { style: 'cyberpunk' },
|
|
apple: { style: 'soft' },
|
|
gruvbox: { style: 'dense' },
|
|
crt: { style: 'cyberpunk' },
|
|
glass: { style: 'soft', blur: 'strong' },
|
|
};
|
|
|
|
const COLOR_SCHEMES: Record<BuiltInThemeName, ColorScheme> = {
|
|
'standard-dark': 'dark',
|
|
'standard-light': 'light',
|
|
cyberpunk: 'dark',
|
|
apple: 'light',
|
|
gruvbox: 'dark',
|
|
crt: 'dark',
|
|
glass: 'light',
|
|
};
|
|
|
|
/** V3 内置主题的背景层配置(未列出的主题保持纯色背景) */
|
|
export const BUILT_IN_BACKGROUND: Partial<Record<BuiltInThemeName, ThemeBackground>> = {
|
|
cyberpunk: {
|
|
type: 'linear',
|
|
stops: ['#0a0a0a', '#11170c'],
|
|
angle: 165,
|
|
scrim: 0.3,
|
|
animation: 'none',
|
|
},
|
|
crt: {
|
|
type: 'radial',
|
|
stops: ['#261b00', '#0f0a00'],
|
|
angle: 160,
|
|
scrim: 0.3,
|
|
animation: 'none',
|
|
},
|
|
glass: {
|
|
type: 'mesh',
|
|
stops: ['#d3e3f8', '#e8edf5', '#ece0f6'],
|
|
angle: 135,
|
|
scrim: 0.35,
|
|
animation: 'drift',
|
|
},
|
|
};
|
|
|
|
/** V3 内置主题的效果配置(键存在且 >0 即启用) */
|
|
export const BUILT_IN_EFFECTS: Partial<Record<BuiltInThemeName, ThemeEffects>> = {
|
|
cyberpunk: { scanline: 1, flicker: 1 },
|
|
crt: { scanline: 1, flicker: 0.6, glow: 0.5 },
|
|
glass: { glow: 0.4 },
|
|
};
|
|
|
|
/** V3 内置主题的版式缩放(缺省 1/1) */
|
|
export const BUILT_IN_TYPOGRAPHY: Partial<Record<BuiltInThemeName, ThemeTypography>> = {
|
|
crt: { fontScale: 1, radiusScale: 0.75 },
|
|
glass: { fontScale: 1.05, radiusScale: 1.4 },
|
|
};
|
|
|
|
let activeTerminalTheme: ITheme = THEMES.cyberpunk;
|
|
let activeColorScheme: ColorScheme = 'dark';
|
|
let activeAppearance: ResolvedThemeAppearance = UI_STYLE_PRESETS.cyberpunk;
|
|
const terminalThemeListeners = new Set<(theme: ITheme) => void>();
|
|
const colorSchemeListeners = new Set<(colorScheme: ColorScheme) => void>();
|
|
|
|
export function isBuiltInTheme(value: string | null): value is BuiltInThemeName {
|
|
return !!value && Object.hasOwn(THEMES, value);
|
|
}
|
|
|
|
export function applyBuiltInTheme(themeName: BuiltInThemeName): void {
|
|
applyTheme(
|
|
UI_THEMES[themeName],
|
|
THEMES[themeName],
|
|
COLOR_SCHEMES[themeName],
|
|
themeName,
|
|
resolveThemeAppearance(BUILT_IN_APPEARANCE[themeName]),
|
|
BUILT_IN_BACKGROUND[themeName],
|
|
BUILT_IN_EFFECTS[themeName],
|
|
BUILT_IN_TYPOGRAPHY[themeName]
|
|
);
|
|
}
|
|
|
|
export function applyImportedTheme(data: ImportedThemeData): void {
|
|
const normalized = normalizeImportedTheme(data);
|
|
if (!normalized) return;
|
|
const colorScheme = normalized.colorScheme ?? 'dark';
|
|
const fallbackName =
|
|
normalized.baseTheme || (colorScheme === 'light' ? 'standard-light' : 'cyberpunk');
|
|
const ui = { ...UI_THEMES[fallbackName], ...normalized.ui };
|
|
const terminal = { ...THEMES[fallbackName], ...normalized.terminal };
|
|
const fallbackAppearance = resolveThemeAppearance(BUILT_IN_APPEARANCE[fallbackName]);
|
|
const appearance = resolveThemeAppearance(normalized.appearance, fallbackAppearance);
|
|
const background = normalized.background ?? BUILT_IN_BACKGROUND[fallbackName];
|
|
const effects = { ...BUILT_IN_EFFECTS[fallbackName], ...normalized.effects };
|
|
const typography = normalized.typography ?? BUILT_IN_TYPOGRAPHY[fallbackName];
|
|
applyTheme(ui, terminal, colorScheme, 'custom', appearance, background, effects, typography);
|
|
}
|
|
|
|
export function normalizeImportedTheme(data: unknown): ImportedThemeData | null {
|
|
return normalizeThemeData(data) as ImportedThemeData | null;
|
|
}
|
|
|
|
export function getActiveTerminalTheme(): ITheme {
|
|
return activeTerminalTheme;
|
|
}
|
|
|
|
export function getActiveColorScheme(): ColorScheme {
|
|
return activeColorScheme;
|
|
}
|
|
|
|
export function getActiveThemeAppearance(): ResolvedThemeAppearance {
|
|
return activeAppearance;
|
|
}
|
|
|
|
export function onTerminalThemeChange(listener: (theme: ITheme) => void): () => void {
|
|
terminalThemeListeners.add(listener);
|
|
listener(activeTerminalTheme);
|
|
return () => terminalThemeListeners.delete(listener);
|
|
}
|
|
|
|
export function onColorSchemeChange(listener: (colorScheme: ColorScheme) => void): () => void {
|
|
colorSchemeListeners.add(listener);
|
|
listener(activeColorScheme);
|
|
return () => colorSchemeListeners.delete(listener);
|
|
}
|
|
|
|
function applyTheme(
|
|
ui: Record<string, string>,
|
|
terminal: ITheme,
|
|
colorScheme: ColorScheme,
|
|
themeName: BuiltInThemeName | 'custom',
|
|
appearance: ResolvedThemeAppearance,
|
|
background?: ThemeBackground,
|
|
effects?: ThemeEffects,
|
|
typography?: ThemeTypography
|
|
): void {
|
|
activeTerminalTheme = terminal;
|
|
activeColorScheme = colorScheme;
|
|
activeAppearance = appearance;
|
|
|
|
if (typeof document !== 'undefined') {
|
|
const root = document.documentElement;
|
|
Object.entries(ui).forEach(([property, value]) => {
|
|
root.style.setProperty(property, value);
|
|
});
|
|
root.dataset.theme = themeName;
|
|
root.dataset.colorScheme = colorScheme;
|
|
root.dataset.uiStyle = appearance.style;
|
|
root.dataset.uiShape = appearance.shape;
|
|
root.dataset.uiDensity = appearance.density;
|
|
root.dataset.uiFont = appearance.font;
|
|
root.dataset.uiShadow = appearance.shadow;
|
|
root.dataset.uiMotion = appearance.motion;
|
|
root.dataset.uiBlur = appearance.blur;
|
|
root.dataset.componentButton = appearance.components.button;
|
|
root.dataset.componentInput = appearance.components.input;
|
|
root.dataset.componentCard = appearance.components.card;
|
|
root.dataset.componentTabs = appearance.components.tabs;
|
|
root.style.colorScheme = colorScheme;
|
|
root.classList.toggle('dark', colorScheme === 'dark');
|
|
applyThemeV3Layers(root, background, effects, typography, colorScheme);
|
|
}
|
|
|
|
terminalThemeListeners.forEach((listener) => {
|
|
listener(terminal);
|
|
});
|
|
colorSchemeListeners.forEach((listener) => {
|
|
listener(colorScheme);
|
|
});
|
|
}
|
|
|
|
/** V3 增量层:背景/效果/版式缩放全部走变量与 data 属性,不碰 DOM 结构 */
|
|
function applyThemeV3Layers(
|
|
root: HTMLElement,
|
|
background: ThemeBackground | undefined,
|
|
effects: ThemeEffects | undefined,
|
|
typography: ThemeTypography | undefined,
|
|
colorScheme: ColorScheme
|
|
): void {
|
|
root.style.setProperty('--app-bg-stack', resolveBackgroundStack(background, colorScheme));
|
|
root.dataset.bgAnimation =
|
|
background && background.animation === 'drift' && background.type !== 'solid'
|
|
? 'drift'
|
|
: 'none';
|
|
|
|
root.dataset.fxScanline = effects?.scanline ? 'on' : 'off';
|
|
root.dataset.fxFlicker = effects?.flicker ? 'on' : 'off';
|
|
root.dataset.fxGlow = effects?.glow ? 'on' : 'off';
|
|
root.dataset.fxNoise = effects?.noise ? 'on' : 'off';
|
|
root.style.setProperty('--fx-scanline-opacity', String(effects?.scanline ?? 1));
|
|
// 闪烁频率随强度在 0.32s–0.12s 之间收紧
|
|
root.style.setProperty('--fx-flicker-speed', `${0.32 - 0.2 * (effects?.flicker ?? 1)}s`);
|
|
root.style.setProperty('--fx-glow-strength', String(effects?.glow ?? 0.6));
|
|
root.style.setProperty('--fx-noise-opacity', String(effects?.noise ?? 0.05));
|
|
|
|
root.style.setProperty('--radius-scale', String(typography?.radiusScale ?? 1));
|
|
root.style.setProperty('--type-scale', String(typography?.fontScale ?? 1));
|
|
}
|
|
|
|
/** 合成含读性遮罩的完整背景栈;无渐变时为 none,body 的 --bg 纯色兜底 */
|
|
function resolveBackgroundStack(
|
|
background: ThemeBackground | undefined,
|
|
colorScheme: ColorScheme
|
|
): string {
|
|
if (!background || background.type === 'solid' || background.stops.length < 2) return 'none';
|
|
const channel = colorScheme === 'dark' ? '0, 0, 0' : '255, 255, 255';
|
|
const scrim = `linear-gradient(rgba(${channel}, ${background.scrim}), rgba(${channel}, ${background.scrim}))`;
|
|
return `${scrim}, ${resolveBackgroundCss(background)}`;
|
|
}
|
|
|
|
/** 由 schema 背景配置合成 CSS 渐变串(停靠点均已过白名单校验,拼接安全) */
|
|
export function resolveBackgroundCss(background: ThemeBackground | undefined): string {
|
|
if (!background || background.type === 'solid' || background.stops.length < 2) {
|
|
return 'var(--bg)';
|
|
}
|
|
switch (background.type) {
|
|
case 'linear':
|
|
return `linear-gradient(${background.angle}deg, ${background.stops.join(', ')})`;
|
|
case 'radial':
|
|
return `radial-gradient(ellipse at 50% 25%, ${background.stops.join(', ')})`;
|
|
case 'mesh': {
|
|
const [first, second, third] = background.stops;
|
|
return [
|
|
`radial-gradient(at 18% 22%, ${first} 0px, transparent 55%)`,
|
|
`radial-gradient(at 82% 28%, ${second ?? first} 0px, transparent 50%)`,
|
|
`radial-gradient(at 50% 88%, ${third ?? second ?? first} 0px, transparent 60%)`,
|
|
].join(', ');
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveThemeAppearance(
|
|
appearance?: ThemeAppearance,
|
|
fallback: ResolvedThemeAppearance = UI_STYLE_PRESETS.cyberpunk
|
|
): ResolvedThemeAppearance {
|
|
const requestedStyle = isUIStylePresetName(appearance?.style) ? appearance.style : fallback.style;
|
|
const preset = UI_STYLE_PRESETS[requestedStyle];
|
|
|
|
return {
|
|
style: requestedStyle,
|
|
shape: isOneOf(appearance?.shape, ['square', 'rounded', 'soft'])
|
|
? appearance.shape
|
|
: preset.shape,
|
|
density: isOneOf(appearance?.density, ['compact', 'comfortable', 'spacious'])
|
|
? appearance.density
|
|
: preset.density,
|
|
font: isOneOf(appearance?.font, ['mono', 'system']) ? appearance.font : preset.font,
|
|
shadow: isOneOf(appearance?.shadow, ['none', 'subtle', 'elevated'])
|
|
? appearance.shadow
|
|
: preset.shadow,
|
|
motion: isOneOf(appearance?.motion, ['none', 'reduced', 'full'])
|
|
? appearance.motion
|
|
: preset.motion,
|
|
blur: isOneOf(appearance?.blur, ['none', 'subtle', 'strong'])
|
|
? appearance.blur
|
|
: preset.blur,
|
|
components: {
|
|
button: isOneOf(appearance?.components?.button, ['outline', 'solid', 'soft'])
|
|
? appearance.components.button
|
|
: preset.components.button,
|
|
input: isOneOf(appearance?.components?.input, ['underline', 'boxed'])
|
|
? appearance.components.input
|
|
: preset.components.input,
|
|
card: isOneOf(appearance?.components?.card, ['outlined', 'flat', 'elevated'])
|
|
? appearance.components.card
|
|
: preset.components.card,
|
|
tabs: isOneOf(appearance?.components?.tabs, ['underline', 'segmented'])
|
|
? appearance.components.tabs
|
|
: preset.components.tabs,
|
|
},
|
|
};
|
|
}
|
|
|
|
function isUIStylePresetName(value: unknown): value is UIStylePresetName {
|
|
return typeof value === 'string' && Object.hasOwn(UI_STYLE_PRESETS, value);
|
|
}
|
|
|
|
function isOneOf<T extends string>(value: unknown, values: readonly T[]): value is T {
|
|
return typeof value === 'string' && values.includes(value as T);
|
|
}
|