Files
OpenTerminal/docs/superpowers/plans/2026-10-16-theme-overhaul.md
T

733 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 主题系统全面改造(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`**(完整文件)
```js
/**
* 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**)
```ts
/**
* 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` 模块级变量,改为:
```ts
import { isLightTheme, luminance, mix, semanticVars, contrast } from './semantic'
export { mix, luminance, contrast } from './semantic' // 兼容既有导入方(main.tsx 等),T2 会迁走
```
- `applyChromeTheme` 末尾(`applyTabAccent(tabAccent)` 之前)追加:
```ts
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)。
- [ ] **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: 提交**
```bash
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`**(完整文件)
```js
/**
* 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(...)` 一带)改为:
```ts
const themeColors = getThemeById(themeOpts.themeId, themeOpts.customThemes).colors
const lightTheme = isLightTheme(themeColors.background)
```
(`theme:` 行改用 `themeColors as ITheme`)同对象内:
```ts
drawBoldTextInBrightColors: !lightTheme,
// 背景图模式保持 4.5(铁律);无图时浅色主题开 4.5 兜底应用输出的低对比色,
// 深色维持 1(现状)。拼写必须是 minimumContrastRatio(minContrastRatio 会被静默忽略)。
minimumContrastRatio: bgOpts.image || lightTheme ? 4.5 : 1,
```
热更 effect(约 :1261-1269)改为:
```ts
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.tsx` ThemedConfigProvider**
导入改为 `import { isLightTheme, mix } from '@renderer/theme/semantic'`(先确认现 `mix` 的导入源并迁移;若 chrome.ts 的兼容 re-export 仍被别处使用则保留)。函数体:
```tsx
const colors = getThemeById(themeId, customThemes).colors
const light = isLightTheme(colors.background)
```
ConfigProvider theme 对象内:
```tsx
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.json` test 链末尾追加 ` && node tests/theme-contrast.mjs`;`npm run typecheck` + `node tests/theme-contrast.mjs` + `node tests/semantic-theme.mjs` 全过。
- [ ] **Step 9: 提交**
```bash
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 启动后覆盖):
```css
--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` 滚动条、`:63` active、`: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: 提交**
```bash
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: 提交**
```bash
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 变量串,必须传实色):
```tsx
import { useResolvedTheme } from '@renderer/settings/store'
import { isLightTheme, semanticVars } from '@renderer/theme/semantic'
```
组件内:
```tsx
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 参数**);`:237` `lineColor={vars.successFg} fillColor={withAlpha(vars.success, 0.15)}`;`:249` `color={vars.warningFg}`;`:298` `lineColor={vars.infoFg} fillColor={withAlpha(vars.info, 0.15)}`;`:299` `lineColor={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: 提交**
```bash
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」)
```js
/**
* 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.json` test 链末尾追加 `&& node tests/css-theme-vars.mjs`;跑 **`npm test` 全链**(typecheck + 全部测试)必须全绿。
- [ ] **Step 4: AGENTS.md「主题机制」增补**(追加 bullet,保留既有内容):
```markdown
- 语义变量层(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: 提交**
```bash
git add tests/css-theme-vars.mjs package.json AGENTS.md docs/STATE.md
git commit -m "test(theme): 颜色字面量 fail-closed 守门 + 基线清零;AGENTS/STATE 同步主题语义层"
```
---
## §10 通用验证命令
```bash
# 残留扫描(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 审查 + 手工验收清单移交用户)。