39 KiB
主题系统全面改造(A+B+C)实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or swarm waves as mapped in §11. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 浅色主题下终端与全部 UI 文字达到 WCAG AA 可读、底色柔化、194 处硬编码颜色全量收敛进语义变量层并以 fail-closed 测试锁死。
Architecture: ThemeColors(L0)→ theme/semantic.ts(纯色数学 + 锚点色 + isLightTheme)→ chrome.ts 运行时派生 --ot-* L2 变量(深色分支锁现值)→ CSS/TSX 消费;终端画布侧由调色板重做 + 动态 minimumContrastRatio/drawBoldTextInBrightColors + antd 算法切换兜底。
Tech Stack: Electron 渲染层(React + antd v6 + xterm.js 6.1.0-beta.304 + WebGL addon)。无新依赖。
规格文档: docs/superpowers/specs/2026-10-16-theme-overhaul-design.md(含对抗性审查记录 §11)
Global Constraints(每个任务隐含遵守)
- 深色主题视觉零回归:语义色深色分支 = 现值锚点,逐值锁定(微差容忍:
#9a9a9a/#6a6a6a → #8a8a8a一档收敛视为可接受,见 T3 规则)。 - 背景图三铁律不破:
#0d1117深底衬、透明背景#00000000、图模式下 MCR=4.5 不变。 - 无新 npm 依赖;不改 IPC/settings 结构;
customThemes无迁移。 .ts直连导入的测试(semantic-theme / theme-contrast)要求被测模块零 import(Node 22.18+ 原生类型剥离;参照tests/terminal-cwd.mjs头注释)。semantic.ts因此不得 import 任何东西(类型就地声明)。- 每任务一提交,提交信息用仓库惯例(中文、
类型(范围): 描述)。 - 行号会漂移:一律按内容定位,不得盲信本文行号。
--tab-accent/--chrome-*现有机制与变量不动。- 改动若与 AGENTS.md「主题机制」「终端尺寸同步」描述冲突,以本计划为准并在 T6 同步更新 AGENTS.md。
Task 1(T1): 语义层基座 semantic.ts + chrome.ts 派生 + 表测试
Files:
- Create:
src/renderer/src/theme/semantic.ts - Create:
tests/semantic-theme.mjs - Modify:
src/renderer/src/theme/chrome.ts(整体重构:助手函数移入 semantic.ts,新增 L2 变量写入) - Modify:
package.json(test 链追加&& node tests/semantic-theme.mjs,加在node tests/refit-throttle.mjs之后)
Interfaces(后续任务依赖):
-
isLightTheme(background: string): boolean -
mix(a, b, t): string | null、luminance(color): number | null、contrast(a, b): number(chrome.ts 原有,移至 semantic.ts 并从那里导出) -
semanticVars(colors, light): Record<string, string>(chrome.ts 逐个setProperty;键不含--ot-前缀,写入时加) -
SEMANTIC(锚点色常量表,CSS 静态兜底与 TSX 内联取色共用) -
Step 1: 写失败测试
tests/semantic-theme.mjs(完整文件)
/**
* semantic.ts self-test. 该模块必须零 import(Node 原生类型剥离直连 .ts,
* 同 tests/terminal-cwd.mjs 的约束)。锁三件事:
* 1) isLightTheme 边界(>0.5 亮度为浅);
* 2) 深色分支 = SEMANTIC 锚点原值(零回归锁定);
* 3) 浅色分支全部达标:正文/fg ≥4.5,语义基色 ≥3.0。
* Run: node tests/semantic-theme.mjs (must exit 0)
*/
import { isLightTheme, semanticVars, SEMANTIC } from '../src/renderer/src/theme/semantic.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
console.log('isLightTheme:')
ok(isLightTheme('#ffffff') === true, '#ffffff is light')
ok(isLightTheme('#fafafa') === true, '#fafafa is light')
ok(isLightTheme('#fdf6e3') === true, '#fdf6e3 is light')
ok(isLightTheme('#1e1e1e') === false, '#1e1e1e is dark')
ok(isLightTheme('#282a36') === false, '#282a36 is dark')
console.log('dark branch locks anchors (zero regression):')
const dark = semanticVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
ok(dark['muted'] === SEMANTIC.muted, 'muted = anchor')
ok(dark['success'] === SEMANTIC.success, 'success = anchor')
ok(dark['danger'] === SEMANTIC.danger, 'danger = anchor')
ok(dark['warning'] === SEMANTIC.warning, 'warning = anchor')
ok(dark['info'] === SEMANTIC.info, 'info = anchor')
ok(dark['scrollbar'] === SEMANTIC.scrollbar, 'scrollbar = anchor')
ok(dark['shadow'] === SEMANTIC.shadow, 'shadow = anchor')
ok(dark['colorScheme'] === 'dark', 'colorScheme dark')
console.log('light branch meets ratios:')
for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) {
const v = semanticVars({ background: bg, foreground: '#383a42' }, true)
// 借 semantic.ts 的 mix 造不出对比度函数——这里用重新实现校验关键值
const L = (h) => {
const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
.map((s) => (s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const cr = (a, b) => {
const x = L(a), y = L(b)
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
for (const key of ['muted', 'textSecondary', 'successFg', 'dangerFg', 'warningFg', 'infoFg']) {
ok(cr(v[key], bg) >= 4.5, `${bg} ${key} ${v[key]} >= 4.5 (${cr(v[key], bg).toFixed(2)})`)
}
for (const key of ['success', 'danger', 'warning', 'info']) {
ok(cr(v[key], bg) >= 3, `${bg} ${key} ${v[key]} >= 3.0 (${cr(v[key], bg).toFixed(2)})`)
}
ok(v['colorScheme'] === 'light', `${bg} colorScheme light`)
}
if (failed > 0) {
console.error(`${failed} check(s) failed`)
process.exit(1)
}
console.log('semantic-theme: all ok')
-
Step 2: 跑测试确认失败:
node tests/semantic-theme.mjs→ 期望ERR_MODULE_NOT_FOUND(semantic.ts 不存在)。 -
Step 3: 写
src/renderer/src/theme/semantic.ts(完整文件;零 import)
/**
* Semantic color layer for app chrome. PURE module: no imports (it is loaded
* directly by tests/semantic-theme.mjs via Node's native TS type stripping),
* no DOM access. chrome.ts turns semanticVars() into --ot-* CSS variables on
* <html> whenever the terminal theme changes.
*
* Dark-branch values are LOCKED to the historical literals (zero visual
* regression); light-branch values are derived per-theme by darkening the
* same anchors until the WCAG ratio is met.
*/
interface ThemeLike {
background: string
foreground: string
}
function parseHex(color: string): [number, number, number] | null {
const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */
export function luminance(color: string): number | null {
const c = parseHex(color)
if (!c) return null
const lin = c.map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
})
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
/** t=0 → a, t=1 → b */
export function mix(a: string, b: string, t: number): string | null {
const ca = parseHex(a)
const cb = parseHex(b)
if (!ca || !cb) return null
return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}`
}
/** WCAG contrast ratio; 21 when either side is not parseable hex. */
export function contrast(a: string, b: string): number {
const la = luminance(a)
const lb = luminance(b)
if (la === null || lb === null) return 21
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
}
export function isLightTheme(background: string): boolean {
return (luminance(background) ?? 0) > 0.5
}
/** Darken `color` toward black in 0.05 steps until contrast vs `bg` >= ratio.
* Returns the input unchanged when it already passes (or never parses). */
export function darkenToRatio(color: string, bg: string, ratio: number): string {
if (contrast(color, bg) >= ratio) return color
let t = 0
let cur = color
while (t < 0.85) {
t = Math.round((t + 0.05) * 100) / 100
const next = mix(color, '#000000', t)
if (!next) return color
cur = next
if (contrast(cur, bg) >= ratio) return cur
}
return cur
}
/** Historical dark-chrome literals. The dark branch MUST keep these values. */
export const SEMANTIC = {
muted: '#8a8a8a',
success: '#3fb950',
danger: '#f85149',
warning: '#d29922',
info: '#58a6ff',
surfaceRaised: 'rgba(24, 26, 31, 0.85)',
hover: 'rgba(255, 255, 255, 0.08)',
borderStrong: 'rgba(255, 255, 255, 0.16)',
scrollbar: 'rgba(125, 145, 170, 0.5)',
scrollbarActive: 'rgba(150, 172, 200, 0.7)',
shadow: 'rgba(0, 0, 0, 0.45)',
focusRing: 'rgba(59, 142, 234, 0.2)'
} as const
/** Values for --ot-* variables (keys without the prefix). */
export function semanticVars(colors: ThemeLike, light: boolean): Record<string, string> {
const bg = colors.background
const fg = colors.foreground
if (!light) {
return {
text: fg,
textSecondary: mix(bg, fg, 0.78) ?? fg,
muted: SEMANTIC.muted,
surface: mix(bg, fg, 0.05) ?? bg,
surfaceRaised: SEMANTIC.surfaceRaised,
hover: SEMANTIC.hover,
border: mix(bg, fg, 0.16) ?? bg,
borderStrong: SEMANTIC.borderStrong,
success: SEMANTIC.success,
successFg: SEMANTIC.success,
danger: SEMANTIC.danger,
dangerFg: SEMANTIC.danger,
warning: SEMANTIC.warning,
warningFg: SEMANTIC.warning,
info: SEMANTIC.info,
infoFg: SEMANTIC.info,
scrollbar: SEMANTIC.scrollbar,
scrollbarActive: SEMANTIC.scrollbarActive,
shadow: SEMANTIC.shadow,
focusRing: SEMANTIC.focusRing,
colorScheme: 'dark'
}
}
return {
text: darkenToRatio(fg, bg, 7),
textSecondary: darkenToRatio(mix(bg, fg, 0.75) ?? fg, bg, 4.5),
muted: darkenToRatio(SEMANTIC.muted, bg, 4.5),
surface: mix(bg, fg, 0.04) ?? bg,
surfaceRaised: mix(bg, '#000000', 0.045) ?? bg,
hover: 'rgba(0, 0, 0, 0.06)',
border: mix(bg, '#000000', 0.14) ?? bg,
borderStrong: 'rgba(0, 0, 0, 0.22)',
success: darkenToRatio(SEMANTIC.success, bg, 3),
successFg: darkenToRatio(SEMANTIC.success, bg, 4.5),
danger: darkenToRatio(SEMANTIC.danger, bg, 3),
dangerFg: darkenToRatio(SEMANTIC.danger, bg, 4.5),
warning: darkenToRatio(SEMANTIC.warning, bg, 3),
warningFg: darkenToRatio(SEMANTIC.warning, bg, 4.5),
info: darkenToRatio(SEMANTIC.info, bg, 3),
infoFg: darkenToRatio(SEMANTIC.info, bg, 4.5),
scrollbar: 'rgba(0, 0, 0, 0.28)',
scrollbarActive: 'rgba(0, 0, 0, 0.45)',
shadow: 'rgba(0, 0, 0, 0.15)',
focusRing: SEMANTIC.focusRing,
colorScheme: 'light'
}
}
-
Step 4: 跑测试确认通过:
node tests/semantic-theme.mjs→semantic-theme: all ok。 -
Step 5: 重构
chrome.ts- 删除 chrome.ts 内的
parseHex/luminance/toHex/mix/contrast本地实现与chromeIsLight模块级变量,改为:import { isLightTheme, luminance, mix, semanticVars, contrast } from './semantic' export { mix, luminance, contrast } from './semantic' // 兼容既有导入方(main.tsx 等),T2 会迁走 applyChromeTheme末尾(applyTabAccent(tabAccent)之前)追加:const light = isLightTheme(colors.background) const vars = semanticVars(colors, light) for (const [key, value] of Object.entries(vars)) { if (key === 'colorScheme') style.setProperty('color-scheme', value) else set(`--ot-${key}`, value) }applyTabAccent里原chromeIsLight判断改用isLightTheme需要背景色——改为在applyChromeTheme里把light存进模块级let chromeIsLight(保留原变量名,只是赋值来源换成isLightTheme(colors.background)),applyTabAccent逻辑不变。tabLabel助手保留(仍用 contrast)。
- 删除 chrome.ts 内的
-
Step 6: 接入测试链:
package.json的"test"值末尾(node tests/refit-throttle.mjs之后)追加&& node tests/semantic-theme.mjs。 -
Step 7: 验证:
npm run typecheck通过;node tests/semantic-theme.mjs通过。 -
Step 8: 提交
git add src/renderer/src/theme/semantic.ts src/renderer/src/theme/chrome.ts tests/semantic-theme.mjs package.json
git commit -m "feat(theme): 语义变量层基座 --ot-*(深色锁现值/浅色按 WCAG 派生)+ 表测试"
Task 2(T2): M0 终端画布——调色板重做 + xterm 动态开关 + antd 算法
Files:
- Modify:
src/shared/theme.ts(三套浅色主题调色板整体替换 + github-light 底色柔化) - Modify:
src/renderer/src/terminal/TerminalView.tsx(构造与热更两处选项) - Modify:
src/renderer/src/main.tsx(算法切换 + 兜底双色) - Modify:
src/renderer/src/ErrorBoundary.tsx(2 处内联色) - Create:
tests/theme-contrast.mjs - Modify:
package.json(test 链追加&& node tests/theme-contrast.mjs)
Interfaces: 消费 T1 的 isLightTheme(@renderer/theme/semantic)。
- Step 1: 写失败测试
tests/theme-contrast.mjs(完整文件)
/**
* Builtin theme palette contrast gate. Light themes: foreground + all 16 ANSI
* colors must reach 4.5:1 against the theme background (WCAG AA body text);
* cursor must reach 3:1. Dark themes: only the foreground sanity check (>=4.5)
* — dark palettes are locked aesthetics, not regression targets here.
* Imports ../src/shared/theme.ts directly (zero-import module, Node >= 22.18).
* Run: node tests/theme-contrast.mjs (must exit 0)
*/
import { BUILTIN_THEMES } from '../src/shared/theme.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const L = (h) => {
const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
.map((s) => (s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const cr = (a, b) => {
const x = L(a), y = L(b)
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
const ANSI = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue',
'brightMagenta', 'brightCyan', 'brightWhite']
const isLight = (bg) => L(bg) > 0.5
for (const t of BUILTIN_THEMES) {
const c = t.colors
const light = isLight(c.background)
console.log(`${t.id} (${light ? 'light' : 'dark'}, bg ${c.background}):`)
ok(cr(c.foreground, c.background) >= 4.5, `foreground ${c.foreground} ${cr(c.foreground, c.background).toFixed(2)}`)
ok(cr(c.cursor, c.background) >= 3, `cursor ${c.cursor} ${cr(c.cursor, c.background).toFixed(2)}`)
if (!light) continue
for (const k of ANSI) {
ok(cr(c[k], c.background) >= 4.5, `${k} ${c[k]} ${cr(c[k], c.background).toFixed(2)}`)
}
}
if (failed > 0) {
console.error(`${failed} check(s) failed`)
process.exit(1)
}
console.log('theme-contrast: all ok')
-
Step 2: 跑测试确认失败:
node tests/theme-contrast.mjs→ 期望多条 FAIL(现三套浅色主题大量 <4.5)。 -
Step 3: 替换
src/shared/theme.ts三套浅色主题(算法生成:mix(原值,#000,t)步进 0.05 至 ≥4.5,色相保留;white/brightWhite同规则自动落到中灰)。整体替换colors对象,未列字段不变:
github-light(background 柔化 + cursorAccent 跟随):
background: '#f6f8fa', foreground: '#4d4d4c', cursor: '#4d4d4c', cursorAccent: '#f6f8fa',
selectionBackground: '#dcdcdc',
black: '#3e3e3e', red: '#c91b00', green: '#007e00', yellow: '#777600',
blue: '#0225c7', magenta: '#bf2ebd', cyan: '#007677', white: '#6d6d6d',
brightBlack: '#676767', brightRed: '#b34c48', brightGreen: '#307d34', brightYellow: '#737331',
brightBlue: '#5860d9', brightMagenta: '#a64da6', brightCyan: '#2b7273', brightWhite: '#666666'
one-half-light(background/cursor/cursorAccent/selectionBackground 不变):
black: '#383a42', red: '#c2493e', green: '#40813f', yellow: '#9a6a01',
blue: '#0177a9', magenta: '#a626a4', cyan: '#07798f', white: '#717171',
brightBlack: '#4f525e', brightRed: '#b3565e', brightGreen: '#5b7549', brightYellow: '#7e6a44',
brightBlue: '#3f729b', brightMagenta: '#955aa6', brightCyan: '#38767e', brightWhite: '#737373'
solarized-light(background/cursor/cursorAccent/selectionBackground 不变;foreground #657b83 → #586e75——原 fg 对奶油底仅 4.13:1 不过 AA,#586e75 是 Solarized base01(与 cursor 同色),对底 4.99:1):
black: '#073642', red: '#d1302d', green: '#647300', yellow: '#886700',
blue: '#2076b3', magenta: '#c8337c', cyan: '#207972', white: '#6b6860',
brightBlack: '#677171', brightRed: '#c14715', brightGreen: '#586e75', brightYellow: '#5b6f76',
brightBlue: '#626f71', brightMagenta: '#6166b0', brightCyan: '#677171', brightWhite: '#726f66'
-
Step 4: 跑测试确认通过:
node tests/theme-contrast.mjs→ all ok。 -
Step 5:
TerminalView.tsx两处动态选项 顶部导入:import { isLightTheme } from '@renderer/theme/semantic'。 构造 effect(约 :855-873,theme: withChromeColors(...)一带)改为:const themeColors = getThemeById(themeOpts.themeId, themeOpts.customThemes).colors const lightTheme = isLightTheme(themeColors.background)(
theme:行改用themeColors as ITheme)同对象内:drawBoldTextInBrightColors: !lightTheme, // 背景图模式保持 4.5(铁律);无图时浅色主题开 4.5 兜底应用输出的低对比色, // 深色维持 1(现状)。拼写必须是 minimumContrastRatio(minContrastRatio 会被静默忽略)。 minimumContrastRatio: bgOpts.image || lightTheme ? 4.5 : 1,热更 effect(约 :1261-1269)改为:
const colors = getThemeById(themeOpts.themeId, themeOpts.customThemes).colors const lightTheme = isLightTheme(colors.background) term.options.theme = withChromeColors(colors as ITheme, bgOpts.image !== '') term.options.drawBoldTextInBrightColors = !lightTheme term.options.minimumContrastRatio = bgOpts.image || lightTheme ? 4.5 : 1 -
Step 6:
main.tsxThemedConfigProvider 导入改为import { isLightTheme, mix } from '@renderer/theme/semantic'(先确认现mix的导入源并迁移;若 chrome.ts 的兼容 re-export 仍被别处使用则保留)。函数体:const colors = getThemeById(themeId, customThemes).colors const light = isLightTheme(colors.background)ConfigProvider theme 对象内:
algorithm: light ? antdTheme.defaultAlgorithm : antdTheme.darkAlgorithm, // antd 无 lightAlgorithm,浅色即默认算法 colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? (light ? '#f5f5f5' : '#1f1f1f'), colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? (light ? '#f5f5f5' : '#252525'), -
Step 7:
ErrorBoundary.tsx两处内联色:color: '#cccccc'→'var(--ot-text, #cccccc)';color: '#ff6b6b'→'var(--ot-danger-fg, #ff6b6b)'。 -
Step 8: 接链 + 验证:
package.jsontest 链末尾追加&& node tests/theme-contrast.mjs;npm run typecheck+node tests/theme-contrast.mjs+node tests/semantic-theme.mjs全过。 -
Step 9: 提交
git add src/shared/theme.ts src/renderer/src/terminal/TerminalView.tsx src/renderer/src/main.tsx src/renderer/src/ErrorBoundary.tsx tests/theme-contrast.mjs package.json
git commit -m "fix(theme): 浅色主题 ANSI 调色板重做到 WCAG AA + MCR/bold 动态开关 + antd 算法随亮度切换;github-light 底色柔化 #f6f8fa"
Task 3(T3): global.css + terminal.css 清扫
Files:
- Modify:
src/renderer/src/global.css - Modify:
src/renderer/src/terminal/terminal.css
Interfaces: 消费 T1 变量(var(--ot-*, 兜底),兜底一律用 T1 的 SEMANTIC 暗色锚点原值)。
判定表(遇具体行按此选目标变量):
| 声明 | 目标 |
|---|---|
color: 中性灰(#8a8a8a/#9a9a9a/#6a6a6a/#cccccc/#d0d0d0/rgba(255,255,255,x) 文本) |
弱化文本 --ot-text-muted;正文级 --ot-text;次要 --ot-text-secondary(按语境三选一,语境不明用 muted) |
color: 语义色 |
--ot-success-fg / --ot-danger-fg / --ot-warning-fg / --ot-info-fg |
color:#ffffff/#fff(在深色底上,底也被本表替换时) |
--ot-text |
background: 白色半透明 .04–.1 |
--ot-hover |
background: 白色半透明 .16–.2 |
--ot-surface-raised |
background: 深色实底/深色半透明面板 |
--ot-surface-raised |
border(-x/-y/-bottom/-top): 半透明白/黑 |
--ot-border;强调边框 --ot-border-strong |
box-shadow 里的 rgba(0,0,0,.4+) |
--ot-shadow |
| 滚动条色(scrollbar-color/thumb 背景) | --ot-scrollbar / active 态 --ot-scrollbar-active |
选区/焦点蓝 rgba(59,142,234,x) / #3b8eea |
--ot-focus-ring |
dockview sash --dv-sash-color |
--ot-border;--dv-active-sash-color → --ot-info |
铁律例外(terminal.css,保持硬编码): :34 的 #0d1117(.has-bg-image .terminal-view-dock 深底衬,AGENTS.md 铁律①);:390 的 #000(.term-bg-dim 背景图压暗层,铁律③);:409-410 的 rgba(0,0,0,0.55) + #fff(.term-dead-mask 死终端遮罩——有意为之的深色 scrim,浅色主题下同样成立);所有 #00000000 透明色。
-
Step 1: global.css
:root块(:3-15)现有--chrome-*静态兜底保留不动,其后追加(暗色默认, chrome.ts 启动后覆盖):--ot-text: #cccccc; --ot-text-secondary: rgba(204, 204, 204, 0.78); --ot-text-muted: #8a8a8a; --ot-surface: rgba(204, 204, 204, 0.05); --ot-surface-raised: rgba(24, 26, 31, 0.85); --ot-hover: rgba(255, 255, 255, 0.08); --ot-border: rgba(255, 255, 255, 0.16); --ot-border-strong: rgba(255, 255, 255, 0.16); --ot-success: #3fb950; --ot-success-fg: #3fb950; --ot-danger: #f85149; --ot-danger-fg: #f85149; --ot-warning: #d29922; --ot-warning-fg: #d29922; --ot-info: #58a6ff; --ot-info-fg: #58a6ff; --ot-scrollbar: rgba(125, 145, 170, 0.5); --ot-scrollbar-active: rgba(150, 172, 200, 0.7); --ot-shadow: rgba(0, 0, 0, 0.45); --ot-focus-ring: rgba(59, 142, 234, 0.2); color-scheme: dark;:35/:48滚动条 →var(--ot-scrollbar, rgba(125, 145, 170, 0.5));:56→var(--ot-scrollbar-active, rgba(150, 172, 200, 0.7));:137 #ff6b6b→var(--ot-danger-fg, #f85149)。 -
Step 2: terminal.css 逐行替换(除铁律例外外 0 硬编码残留):
:43/:55滚动条、:63active、:150 rgba(0,0,0,.4)→--ot-shadow、:213深色面板底 →--ot-surface-raised、:237 #8a8a8a→--ot-text-muted、:243 #ffffff+:244白色半透明 →--ot-text+--ot-hover、:281→--ot-shadow、:311 rgba(59,142,234,.18)→--ot-focus-ring、:312 #ffffff→--ot-text、:330 #9a9a9a→--ot-text-muted、:331→--ot-hover、:337→--ot-text-muted、:419(.term-dead-btn的#fff,底是--chrome-accent)→var(--tab-accent-fg, #fff);:409/:410见上方铁律例外保留。 注意:只改颜色声明值,不动选择器/布局/盒模型;替换后逐条目检上下文确保语义正确(尤其 :409-419 一带)。 -
Step 3: 验证:
npm run typecheck;对两文件跑残留扫描(见 §10 命令)应为 0(铁律例外除外);node tests/semantic-theme.mjs仍过。 -
Step 4: 提交
git add src/renderer/src/global.css src/renderer/src/terminal/terminal.css
git commit -m "refactor(theme): global/terminal 硬编码颜色收敛进 --ot-* 语义变量(含滚动条/阴影/浮层)"
Task 4(T4): workspace.css + connections.css + commands.css 清扫
Files:
- Modify:
src/renderer/src/workspace/workspace.css(22 处) - Modify:
src/renderer/src/connections/connections.css(26 处) - Modify:
src/renderer/src/commands/commands.css(8 处)
Interfaces: 同 T3 判定表与兜底规则。
-
Step 1: workspace.css(22 处,逐行):
:82 #1f1f1f实底 →var(--ot-surface-raised, rgba(24, 26, 31, 0.85))(若该选择器是弹层/工具条;读上下文,若是静态面板底改用--ot-surface);:85→--ot-shadow;:96 rgba(255,255,255,.85)文本 →--ot-text;:105 #ffffff→--ot-text;:180 --dv-sash-color→var(--ot-border, rgba(148, 163, 184, 0.28));:181 --dv-active-sash-color→var(--ot-info, rgba(59, 142, 234, 0.75));:279/:342/:556/:562/:661/:669/:725/:739 #8a8a8a→--ot-text-muted;:285 #6a6a6a→--ot-text-muted;:347 #ffffff→--ot-text;:348白色半透明 .1 →--ot-hover;:705 #ff4d4f→--ot-danger-fg;:750 #4cc38a→--ot-success-fg;:751 rgba(76,195,138,.14)→color-mix(in srgb, var(--ot-success, #3fb950) 14%, transparent);:754 #6aa9ff→--ot-info-fg;:755 rgba(106,169,255,.14)→color-mix(in srgb, var(--ot-info, #58a6ff) 14%, transparent)。 -
Step 2: connections.css(26 处,逐行): 所有
#8a8a8a(7 处)/#9a9a9a(3 处)/#6a6a6a(2 处) →--ot-text-muted;:74/:147/:303/:374 rgba(255,255,255,.08)背景 →--ot-hover;:81 rgba(255,255,255,.05)边框 →--ot-border;:189边框 →--ot-border;:166 #ffffff+:167白色半透明 .1 →--ot-text+--ot-hover;:266 #ffffff→--ot-text;:196 rgba(255,255,255,.55)、:210 rgba(255,255,255,.88)、:215 rgba(255,255,255,.45)——先读上下文:若文本在语义色徽章/强调底上 → 对应--ot-*-fg;普通底上 →--ot-text-secondary/--ot-text-muted;:248/:64/:131 #cf6679→--ot-danger-fg;:335 rgba(121,121,121,.45)滚动条/滑块底 →--ot-scrollbar。 -
Step 3: commands.css(8 处):
#8a8a8a(5 处) →--ot-text-muted;#9a9a9a→--ot-text-muted;#cf6679(2 处,含!important) →var(--ot-danger-fg, #cf6679) !important(保留 !important);#f85149→--ot-danger-fg。 -
Step 4: 验证:typecheck + 残留扫描 0 + 语义测试过。
-
Step 5: 提交
git add src/renderer/src/workspace/workspace.css src/renderer/src/connections/connections.css src/renderer/src/commands/commands.css
git commit -m "refactor(theme): 工作区/连接侧栏/命令面板颜色语义变量化(徽章文本按 -fg 分流)"
Task 5(T5): sftp.css + monitor.css + MonitorPanel.tsx + settings.css + highlight.css + lock.css 清扫
Files:
- Modify:
src/renderer/src/sftp/sftp.css(18 处) - Modify:
src/renderer/src/monitor/monitor.css(15 处) - Modify:
src/renderer/src/monitor/MonitorPanel.tsx(7 处内联) - Modify:
src/renderer/src/settings/settings.css(11 处) - Modify:
src/renderer/src/settings/highlight.css(6 处) - Modify:
src/renderer/src/lock/lock.css(2 处)
Interfaces: 同 T3 判定表;MonitorPanel 内联色从 @renderer/theme/semantic 导入 SEMANTIC。
sftp 铁律(AGENTS.md): 不动 grid 模板/--sftp-row-h 系变量/box-sizing: border-box/.ant-dropdown-trigger 相关选择器——只改颜色值。
-
Step 1: sftp.css(18 处):
#8a8a8a(10 处)/#9a9a9a/#6a6a6a→--ot-text-muted;#e3b341→--ot-warning-fg;#3fb950(2 处) →--ot-success-fg;#f85149(4 处) →--ot-danger-fg;:155 rgba(59,142,234,.18)→--ot-focus-ring;:342→--ot-shadow。 -
Step 2: monitor.css(15 处):
#8a8a8a(9 处) →--ot-text-muted;:22/:25 #f85149→--ot-danger-fg;:24 rgba(248,81,73,.12)→color-mix(in srgb, var(--ot-danger, #f85149) 12%, transparent);:89 #3fb950→--ot-success-fg;:144 #d29922→--ot-warning-fg;:234 #58a6ff→--ot-info-fg;:238 #f0a55a→--ot-warning-fg。 -
Step 3: MonitorPanel.tsx(7 处内联;HistoryChart 是 canvas 绘制——
ctx.strokeStyle/fillStyle赋值不解析 CSS 变量串,必须传实色):import { useResolvedTheme } from '@renderer/settings/store' import { isLightTheme, semanticVars } from '@renderer/theme/semantic'组件内:
const theme = useResolvedTheme() const vars = semanticVars(theme.colors, isLightTheme(theme.colors.background)) // #rrggbb + alpha → 'rgba(r, g, b, a)'(canvas 需要实色) const withAlpha = (hex: string, a: number) => `rgba(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(', ')}, ${a})`替换:usage 档位函数(约 :110-112)返回值 →
vars.dangerFg/vars.warningFg/vars.successFg(该函数若在组件外,移入组件或改为接收 vars 参数);:237lineColor={vars.successFg} fillColor={withAlpha(vars.success, 0.15)};:249color={vars.warningFg};:298lineColor={vars.infoFg} fillColor={withAlpha(vars.info, 0.15)};:299lineColor={vars.warningFg} fillColor={withAlpha(vars.warning, 0.15)}。 效果:图表色随主题明暗自动达标(浅色吃加深变体),文件内不再有颜色字面量、无需白名单。 -
Step 4: settings.css(11 处):
:203/:208 #fff→ 读上下文:强调底按钮上的文字 →var(--tab-accent-fg, #fff),否则--ot-text;:302/:303 #3b8eea→--ot-focus-ring(保留 inset box-shadow 结构);:336 rgba(59,142,234,.2)→--ot-focus-ring;:513 #3fd06a→--ot-success-fg;:517/:603 #f85149→--ot-danger-fg;:599 #3fb950→--ot-success-fg;:630 #d29922→--ot-warning-fg;:49注释里的#ffffff不动(T6 扫描器剥注释)。 -
Step 5: highlight.css(6 处):
#f85149(4 处 color) →--ot-danger-fg;:296/:301 border-color #f85149→--ot-danger(描边用基色);:302 rgba(248,81,73,.2)→color-mix(in srgb, var(--ot-danger, #f85149) 20%, transparent)。 -
Step 6: lock.css(2 处):
:40→--ot-shadow;:73 #f85149→--ot-danger-fg。锁屏遮罩底色如为深色实底(读 lock.css 全文确认)保持不动(安全优先:锁屏必须不透明遮罩,z-index 4000 语义不变)。 -
Step 7: 验证:typecheck + 残留扫描(白名单:MonitorPanel.tsx 若走锚点路线的两行 fillColor)+ 语义测试。
-
Step 8: 提交
git add src/renderer/src/sftp/sftp.css src/renderer/src/monitor/monitor.css src/renderer/src/monitor/MonitorPanel.tsx src/renderer/src/settings/settings.css src/renderer/src/settings/highlight.css src/renderer/src/lock/lock.css
git commit -m "refactor(theme): SFTP/监控/设置/高亮/锁屏面板颜色语义变量化;MonitorPanel 图表色接 SEMANTIC 锚点"
Task 6(T6): fail-closed 防护测试 + 基线清零 + 文档同步
Files:
- Create:
tests/css-theme-vars.mjs - Modify:
package.json(test 链追加&& node tests/css-theme-vars.mjs) - Modify:
AGENTS.md(「主题机制」章节增补) - Modify:
docs/STATE.md(追加条目)
Interfaces: 无新接口;消费 T1-T5 的最终状态。
- Step 1: 写
tests/css-theme-vars.mjs(完整文件;先跑一次看当前残留并按实况填WHITELIST,基线必须为「白名单外 0」)
/**
* Fail-closed guard: no raw color literals in renderer source outside the
* whitelist. Scans .css/.tsx/.ts under src/renderer/src. Strips comments
* first (terminal.css:16 etc. live in comments), ignores var() fallback
* bodies except for the controlled-fallback check. Run: node tests/css-theme-vars.mjs
*/
import fs from 'node:fs'
import path from 'node:path'
const ROOT = path.resolve('src/renderer/src')
/** exact relative path -> Set of allowed lowercase literals ('#fff', 'rgba(0, 0, 0, 0.45)') */
const WHITELIST = {
'theme/semantic.ts': null, // null = whole file exempt (it defines the anchors)
'theme/chrome.ts': new Set(['#000000', '#ffffff']),
'theme/editor/ThemeEditor.tsx': null, // theme data (DEFAULT_DARK seed)
'settings/HighlightTab.tsx': null, // preset rule swatches = data
'terminal/highlightEngine.ts': null, // rule engine data/docstring
'terminal/TerminalView.tsx': new Set(['#3b8eea', '#2d2d2d', '#c77800', '#00000000']),
'global.css': new Set(['#181818', '#cccccc', '#2d2d2d', '#3b8eea', '#2a2a2a', '#101418',
'#98a2ab', '#d0d0d0', 'rgba(255, 255, 255, 0.04)', 'rgba(255, 255, 255, 0.07)',
'rgba(255, 255, 255, 0.1)', 'rgba(255, 255, 255, 0.2)', '#8a8a8a', '#3fb950', '#f85149',
'#d29922', '#58a6ff', 'rgba(125, 145, 170, 0.5)', 'rgba(150, 172, 200, 0.7)',
'rgba(24, 26, 31, 0.85)', 'rgba(255, 255, 255, 0.16)', 'rgba(255, 255, 255, 0.08)',
'rgba(204, 204, 204, 0.78)', 'rgba(204, 204, 204, 0.05)', 'rgba(0, 0, 0, 0.45)',
'rgba(59, 142, 234, 0.2)']),
'terminal/terminal.css': new Set(['#0d1117', '#00000000', '#000', '#fff', 'rgba(0, 0, 0, 0.55)'])
}
const FALLBACK_OK = new Set([
'#cccccc', '#8a8a8a', '#9a9a9a', '#6a6a6a', '#d0d0d0', '#ffffff', '#fff', '#000', '#000000',
'#3fb950', '#f85149', '#cf6679', '#ff6b6b', '#ff4d4f', '#d29922', '#e3b341', '#f0a55a',
'#f2cc60', '#4cc38a', '#6aa9ff', '#3fd06a', '#58a6ff', '#3b8eea', '#1f1f1f', '#0d1117',
'#f5f5f5', '#252525', '#2d2d2d', '#181818', '#101418',
'rgba(255, 255, 255, 0.04)', 'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.07)',
'rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0.1)', 'rgba(255, 255, 255, 0.16)',
'rgba(255, 255, 255, 0.2)', 'rgba(0, 0, 0, 0.06)', 'rgba(0, 0, 0, 0.14)',
'rgba(0, 0, 0, 0.22)', 'rgba(0, 0, 0, 0.28)', 'rgba(0, 0, 0, 0.45)',
'rgba(24, 26, 31, 0.85)', 'rgba(125, 145, 170, 0.5)', 'rgba(150, 172, 200, 0.7)',
'rgba(59, 142, 234, 0.18)', 'rgba(59, 142, 234, 0.2)', 'rgba(59, 142, 234, 0.75)',
'rgba(148, 163, 184, 0.28)', 'rgba(121, 121, 121, 0.45)', 'rgba(248, 81, 73, 0.12)',
'rgba(248, 81, 73, 0.2)', 'rgba(76, 195, 138, 0.14)', 'rgba(106, 169, 255, 0.14)',
'rgba(204, 204, 204, 0.78)', 'rgba(204, 204, 204, 0.05)', 'rgba(63, 185, 80, 0.15)',
'rgba(88, 166, 255, 0.15)', 'rgba(242, 204, 96, 0.15)', 'rgba(255, 255, 255, 0.55)',
'rgba(255, 255, 255, 0.85)', 'rgba(255, 255, 255, 0.88)', 'rgba(255, 255, 255, 0.45)'
])
function walk(dir, out = []) {
for (const f of fs.readdirSync(dir)) {
const p = path.join(dir, f)
if (fs.statSync(p).isDirectory()) walk(p, out)
else if (/\.(css|tsx|ts)$/.test(f)) out.push(p)
}
return out
}
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1')
const COLOR = /#[0-9a-fA-F]{3,8}\b(?![-\w])|rgba?\([^)]*\)/g
let violations = 0
for (const file of walk(ROOT)) {
const rel = path.relative(ROOT, file).replaceAll('\\', '/')
const wl = WHITELIST[rel]
if (wl === null) continue
const raw = stripComments(fs.readFileSync(file, 'utf8'))
const outsideVar = raw.replace(/var\([^)]*\)/g, '()')
for (const m of outsideVar.matchAll(COLOR)) {
const lit = m[0].toLowerCase()
if (wl && wl.has(lit)) continue
console.error(`RAW ${rel}: ${lit}`)
violations++
}
for (const v of raw.matchAll(/var\(([^)]*)\)/g)) {
const fb = v[1].split(',').slice(1).join(',').trim()
if (!fb) continue
if (fb.startsWith('var(') || fb.startsWith('color-mix(') || fb.startsWith('in srgb')) continue
for (const m of fb.match(COLOR)) {
if (!FALLBACK_OK.has(m[0].toLowerCase())) {
console.error(`FALLBACK ${rel}: var(${v[1]}) -> ${m[0]}`)
violations++
}
}
}
}
// negative self-test: the matcher itself must catch literals and 3/4-digit hex
{
const probe = stripComments('.a{color:#fff9;border:#abcd}') // comments gone, hex visible
const hits = [...probe.matchAll(COLOR)].length
if (hits < 2) {
console.error('self-test failed: matcher did not catch probe literals')
process.exit(1)
}
}
if (violations > 0) {
console.error(`css-theme-vars: ${violations} violation(s)`)
process.exit(1)
}
console.log('css-theme-vars: all ok')
-
Step 2: 基线清零:跑
node tests/css-theme-vars.mjs,对每条 RAW/FALLBACK 输出:属于遗漏清扫 → 回对应文件改掉(不扩大白名单来逃课;若确属主题数据/铁律 → 加进 WHITELIST 精确值并在此步骤记录理由)。目标:白名单外 0 违规。 -
Step 3: 接链 + 全量验证:
package.jsontest 链末尾追加&& node tests/css-theme-vars.mjs;跑npm test全链(typecheck + 全部测试)必须全绿。 -
Step 4: AGENTS.md「主题机制」增补(追加 bullet,保留既有内容):
- 语义变量层(2026-10 主题改造):`src/renderer/src/theme/semantic.ts`(零 import 纯模块,`isLightTheme`/`mix`/`darkenToRatio`/`SEMANTIC` 锚点)→ `chrome.ts` 的 `applyChromeTheme` 随主题派生 `--ot-*` 变量并写 `color-scheme`;**深色分支锁 SEMANTIC 锚点(零回归),浅色分支 darkenToRatio 按 WCAG 派生**。CSS 一律 `var(--ot-*, 锚点兜底)`,新代码禁止再写裸颜色字面量——`node tests/css-theme-vars.mjs` fail-closed 扫描(先剥注释、忽略 var() 兜底但有受控清单,白名单按「文件+精确值」登记)
- 浅色主题可读性:内置浅色主题 ANSI 调色板已重做到 16 色+fg 全部 ≥4.5:1(`node tests/theme-contrast.mjs` 守门,改内置浅色主题必须过它);`minimumContrastRatio` 按「背景图 4.5|浅色无图 4.5|深色无图 1」矩阵(构造+热更两处),`drawBoldTextInBrightColors` 浅色关闭——WebGL 渲染器同样执行 MCR(addon-webgl 自带对比度路径,已核实);antd 算法随 `isLightTheme` 切 `defaultAlgorithm`/`darkAlgorithm`(**antd 没有 lightAlgorithm**)
- 内置主题调色板是「主题数据」不是 chrome 颜色:改浅色主题色值用统一加深算法(`darkenToRatio`),不许手挑色
-
Step 5: docs/STATE.md 追加条目(一行式,随仓库现有格式):主题系统 A+B+C 落地——语义变量层 + 浅色 AA 调色板 + 防护测试(semantic-theme/theme-contrast/css-theme-vars)。
-
Step 6: 提交
git add tests/css-theme-vars.mjs package.json AGENTS.md docs/STATE.md
git commit -m "test(theme): 颜色字面量 fail-closed 守门 + 基线清零;AGENTS/STATE 同步主题语义层"
§10 通用验证命令
# 残留扫描(T3-T5 各自跑,只看本任务文件)
node -e "const fs=require('fs');const t=fs.readFileSync(process.argv[1],'utf8').replace(/\/\*[\s\S]*?\*\//g,'').replace(/(^|[^:'\"])\/\/.*$/gm,'$1');const out=t.replace(/var\([^)]*\)/g,'()');console.log([...out.matchAll(/#[0-9a-fA-F]{3,8}\b(?![-\w])|rgba?\([^)]*\)/g)].map(m=>m[0]).join('\n')||'CLEAN')" <文件路径>
npm run typecheck # 每任务必跑
node tests/semantic-theme.mjs
node tests/theme-contrast.mjs
npm test # T6 与最终把关必跑(全链)
§11 Swarm 编排(依赖波次)
| 波次 | 任务 | 并行度 | 前置 |
|---|---|---|---|
| W1 | T1 | 1 | — |
| W2 | T2、T3 | 2 | T1 |
| W3 | T4、T5 | 2 | T1(不依赖 T2/T3) |
| W4 | T6 | 1 | T1–T5 全部 |
文件冲突核对:T2 独占 theme.ts/TerminalView.tsx/main.tsx/ErrorBoundary.tsx;T3 独占 global.css/terminal.css;T4 独占 workspace/connections/commands.css;T5 独占 sftp/monitor/settings/highlight/lock.css + MonitorPanel.tsx;package.json 仅 T1/T2/T6 顺序触碰。每任务子代理完成后必须自审(typecheck + 本任务测试 + git diff 逐块重读 + 提交),主会话在 W4 后做总体把关(npm test 全链 + 全量 diff 审查 + 手工验收清单移交用户)。