diff --git a/docs/superpowers/plans/2026-10-16-theme-overhaul.md b/docs/superpowers/plans/2026-10-16-theme-overhaul.md new file mode 100644 index 0000000..1a237e4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-theme-overhaul.md @@ -0,0 +1,732 @@ +# 主题系统全面改造(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`(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 + * 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 { + 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 审查 + 手工验收清单移交用户)。 diff --git a/docs/superpowers/specs/2026-10-16-theme-overhaul-design.md b/docs/superpowers/specs/2026-10-16-theme-overhaul-design.md new file mode 100644 index 0000000..991f0d6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-theme-overhaul-design.md @@ -0,0 +1,255 @@ +# 主题系统全面改造(A+B+C)设计文档 + +- 日期:2026-10-16 +- 状态:待交叉审核(未实现) +- 范围:浅色主题可读性(A)+ 底色柔化(B)+ 全量 CSS/TSX 颜色变量化(C) +- 关联:`docs/ROADMAP.md`、AGENTS.md「主题机制」章节(M4 需同步更新) + +## 1. 背景与问题 + +用户主诉:白色主题下整体刺眼,终端内大量文字看不清(截图为 GitHub Light 下运行 Claude Code 的实际效果)。 + +### 1.1 根因清单 + +| # | 根因 | 位置 | 影响 | +|---|------|------|------| +| R1 | 内置浅色主题 ANSI 调色板是暗底色直接搬来的 | `src/shared/theme.ts` | 终端内 16 色大半不可读 | +| R2 | `drawBoldTextInBrightColors: true` 写死 | `TerminalView.tsx:864` | 粗体强制跳到 bright 变体,放大 R1 | +| R3 | `minimumContrastRatio` 仅背景图模式开 4.5,平时恒 1 | `TerminalView.tsx:869,1268` | xterm 渲染时对比度兜底完全关闭 | +| R4 | antd 写死 `darkAlgorithm` | `main.tsx:227` | 浅色下弹层/边框/hover 按暗色派生 | +| R5 | CSS/TSX 194 处真硬编码颜色(另有 122 处 var() 兜底值) | 见 §3 清单 | 白底白字、灰字对比不足、深色面板突兀 | +| R6 | GitHub Light 纯 `#ffffff` 铺满终端+chrome+antd 表面 | `theme.ts:298` | 刺眼 | +| R7(外部) | Claude Code 等 TUI 经 ConPTY 拿不到真实底色,按暗色出图 | 应用侧,不可控 | 只能靠 R3 的渲染时兜底 | + +### 1.2 关键实测数据(WCAG 对比度) + +GitHub Light(bg `#ffffff`)16 色中 **10 个 < 3:1**:brightWhite 1.00(白字白底)、brightYellow 1.06、brightCyan 1.24、brightGreen 1.38、white 1.69、yellow 1.85、brightMagenta 2.26、green 2.41、brightRed 2.75、brightBlue 3.89。仅 fg/black/blue/red/brightBlack 达到 4.5。 + +One Half Light(bg `#fafafa`)ANSI 15 色中 **7 个 < 3:1**(white 1.00、brightWhite 1.04、brightYellow 1.65、brightGreen 1.93、brightBlue 2.26、brightCyan 2.27、brightMagenta 2.82),**13 个 < 4.5:1**(另含 red/green/yellow/cyan/brightRed 3.0–3.5);仅 magenta、brightBlack 达标(fg/black 同为 `#383a42` 达标)。 + +Solarized Light 未逐色测,其 bright 系列为 Solarized 体系的去饱和色,预计大部分达标;`white #eee8d5` vs bg `#fdf6e3` 明显不足,需纳入统一修复。 + +## 2. 目标与非目标 + +**目标** +1. 三套内置浅色主题下,终端与应用全部文字达到 WCAG AA(正文 ≥4.5:1,大字/图形 ≥3:1),以脚本验证。 +2. 浅色主题不再刺眼(柔化底色,见 §5.3)。 +3. 深色主题视觉零回归(语义色深色分支逐值对齐现状)。 +4. 建立语义 token 层 + fail-closed 防护测试,杜绝硬编码颜色再次积累。 +5. 主题切换时全 UI(终端、chrome、antd 弹层)一致跟随。 + +**非目标** +- 不新增主题/柔化强度设置项(YAGNI)。 +- 不改关键词高亮引擎的着色算法(`highlightThemeColors` 跟随主题机制已存在,默认关,维持现状;预设颜色本身在浅色下的对比度由 R3 兜底,不单独重调)。 +- 不动 `node_modules` 内 xterm.css。 +- 不新增内置主题。 + +## 3. 现状盘点 + +### 3.1 硬编码分布(raw = 规则内真硬编码;fallback = `var(x, 兜底)` 内的值) + +| 文件 | raw | fallback | 备注 | +|------|----:|----:|------| +| connections.css | 26 | 1 | 最大真硬编码户 | +| workspace.css | 22 | 42 | fallback 多为 `--chrome-*` 旧兜底 | +| terminal.css | 20 | 0 | 含白底隐形文字(:243/:312 `#ffffff` on 白)与深色工具条 | +| sftp.css | 18 | 17 | | +| global.css | 16 | 0 | | +| theme/chrome.ts | 16 | 0 | 混色锚点 `#000`/`#fff`,属算法参数,入白名单 | +| monitor.css | 15 | 12 | | +| settings.css | 11 | 25 | | +| MonitorPanel.tsx | 10 | 0 | 内联语义色 | +| TerminalView.tsx | 10 | 0 | 搜索高亮色 + 背景图深底衬 `#0d1117`(AGENTS.md 铁律,保留) | +| HighlightTab.tsx | 9 | 0 | 预设规则色板,属数据非 chrome,入白名单 | +| commands.css | 8 | 0 | | +| highlight.css | 6 | 18 | | +| ErrorBoundary.tsx | 2 | 0 | | +| lock.css | 2 | 7 | | +| main.tsx | 2 | 0 | antd token 的 mix() 失败兜底(暗色偏置,需顺手修正) | +| highlightEngine.ts | 1 | 0 | 规则默认色,属数据,入白名单 | +| **合计** | **194** | **122** | | + +### 3.2 颜色族归类(按出现频次) + +| 族 | 代表值 | 处数级 | 去向 | +|----|--------|--------|------| +| 中性灰文本 | `#cccccc` `#8a8a8a` `#9a9a9a` `#6a6a6a` `#d0d0d0` | ~120(含 fallback) | 文本层级变量 | +| GitHub 暗色语法色 | `#3fb950` `#f85149` `#d29922` `#58a6ff` `#e3b341` `#f0a55a` `#3fd06a` | ~70 | 语义色变量 | +| 白色半透明叠加 | `rgba(255,255,255,.04–.2)` | ~20 | hover/overlay 变量 | +| 深色面板 | `rgba(24,26,31,.85)` `#101418` `#181818` | 少量 | surface 变量 | +| 滚动条 | `rgba(125,145,170,.5)` | 4 | scrollbar 变量 | +| 阴影 | `rgba(0,0,0,.4–.55)` | 少量 | shadow 变量 | + +### 3.3 失败模式 + +1. **白底白字**:`terminal.css:243,312` 的 `color:#ffffff` + 半透明白底、`#8a8a8a` 级灰字在浅色下 < 3.5:1。 +2. **暗底语法色硬上浅底**:语义色全部取自 GitHub 暗色系(`#3fb950` 对白底 2.5:1)。 +3. **深色构件不随主题**:搜索面板/工具条深色底在浅色窗口里成黑块(观感问题,可接受项,但颜色值需收敛进变量才可调)。 + +## 4. 总体架构 + +``` +ThemeColors (L0 主题原始色, theme.ts) + └─ chrome.ts (L1 派生: --chrome-* 现有 8 个 + 新增 L2 语义变量 --ot-*) + ├─ global.css / 各 *.css 消费语义变量(本改造主体) + ├─ main.tsx ThemedConfigProvider(按亮度切 antd algorithm) + └─ semantic.ts(TS 常量,供 TSX 内联样式与 chrome.ts 共用单一来源) +``` + +- 亮度判定抽为共享纯函数 `isLightTheme(colors)`(chrome.ts 内已有 `chromeIsLight` 私有逻辑,抽出导出,配表驱动测试)。 +- 语义变量一律带 fallback 写法(`var(--ot-text-muted, #8a8a8a)`),fallback 值收敛到受控常量清单(见 §7)。 + +## 5. 设计细节 + +### 5.1 L2 语义变量(新增,`--ot-` 前缀) + +| 变量 | 用途 | 深色分支(=现值,零回归) | 浅色分支 | +|------|------|--------------------------|----------| +| `--ot-text` | 正文 | 主题 foreground | 混黑加深至 ≥7:1 | +| `--ot-text-secondary` | 次要文本 | fg 78% 透明(现 `color-mix` 值) | 同式,校验 ≥4.5:1 | +| `--ot-text-muted` | 弱化文本(`#8a8a8a` 族) | `#8a8a8a` | `mix(bg,#000,0.55)` 级,≥4.5:1 | +| `--ot-surface` | 面板底 | 现 `color-mix(fg 4–6%, bg)` | 同式反向 | +| `--ot-surface-raised` | 浮层/工具条底 | `rgba(24,26,31,.85)` 等 | `rgba(255,255,255,.9)` 级 | +| `--ot-hover` | 控件/行 hover 叠加 | `rgba(255,255,255,.04–.1)` | `rgba(0,0,0,.04–.1)` | +| `--ot-border` / `--ot-border-strong` | 边框 | 现 `--chrome-border` 族 | 同式 | +| `--ot-success`(+`-fg`) | 成功/运行态(`#3fb950` 族) | `#3fb950`(fg 亮化) | 加深变体 ≥3:1(fg ≥4.5:1) | +| `--ot-danger`(+`-fg`) | 危险(`#f85149` 族) | 同上策略 | 同上 | +| `--ot-warning`(+`-fg`) | 警告(`#d29922`/`#e3b341`) | 同上策略 | 同上 | +| `--ot-info`(+`-fg`) | 信息/链接(`#58a6ff`/`#3b8eea`) | 同上策略 | 同上 | +| `--ot-scrollbar` / `--ot-scrollbar-active` | 滚动条 | 现 `rgba(125,145,170,*)` | `rgba(0,0,0,.25/.4)` 级 | +| `--ot-shadow` | 浮层阴影 | `rgba(0,0,0,.4–.55)` | `rgba(0,0,0,.12–.2)` | +| `--ot-focus-ring` | 焦点环 | 现 `rgba(59,142,234,.18)` | 同值(蓝环两底均可读) | + +规则: +- 语义色**不是**深浅共用一个值:深色分支锁定现值(零回归),浅色分支用 `mix(现值, #000, t)` 自动加深并经对比度脚本校验,t 在 `semantic.ts` 里按色相手调一次后固化。 +- `--tab-accent` 机制不动(已有 `-fg` 明暗适配)。 +- `:root` 同步写 `color-scheme: dark | light`(chrome.ts 按分支写入):原生滚动条、表单控件、`::selection` 等跟随明暗,避免变量化了的部分被原生控件的不跟随出卖。 + +### 5.2 A:终端画布可读性(M0) + +1. **浅色调色板重做**(`theme.ts` 三套): + - 规则:16 色逐个过对比度脚本,<4.5:1 的用 `mix(原值, #000, t)` 加深至达标(保留色相,t 固化进表);`white`/`brightWhite` 在浅色主题一律重映射为中灰(提案:github-light brightWhite → `#57606f`,其余主题按各自 fg 深度取灰),**不允许任何颜色与背景对比 < 4.5:1**。 + - 达标用新增 `tests/theme-contrast.mjs` 固化(见 §7),调色板具体数值在实现期以脚本为准微调,本设计只锁规则与阈值。 +2. **`minimumContrastRatio`(MCR)动态化**——按「背景图 × 主题亮度」矩阵取值: + + | 场景 | 现值 | 新值 | + |------|------|------| + | 背景图模式(任意主题) | 4.5 | **4.5(不变,铁律)** | + | 无图 · 浅色主题 | 1 | **4.5** | + | 无图 · 深色主题 | 1 | **1(不变)** | + + 构造(`TerminalView.tsx:869`)与热更(`:1268`)两处同步改;拼写用 `minimumContrastRatio`(AGENTS.md 已记 `minContrastRatio` 静默失效的坑)。此项是对 R7(应用输出 arbitrary truecolor 低对比色)的唯一渲染时兜底,浅色必须开。已验证(2026-10-16,node_modules 实查):WebGL 渲染器自带完整对比度执行路径(`addon-webgl.mjs` 的 `_getMinimumContrastColor` → `ensureContrastRatio`,含 dim 的 halfContrastCache),且 `drawBoldTextInBrightColors`/`minimumContrastRatio` 变更会随 options 同步进 webgl 配置;`rendererMode` 默认 `auto`(webgl 优先、dom 回退),两条路径均执行 MCR,无死角。 +3. **`drawBoldTextInBrightColors`**:浅色 false、深色 true(维持现状),两处同改。 +4. **antd algorithm**(`main.tsx`):按 `isLightTheme` 切 `defaultAlgorithm`/`darkAlgorithm`——**antd(含 v6)没有 `lightAlgorithm`,浅色就是 `defaultAlgorithm`**(已在 node_modules/antd/es/theme/index.js 核实导出仅有 default/dark/compact 三种);`colorBgContainer`/`colorBgElevated` 的 mix() 失败兜底改为按亮度取 `#f5f5f5`/`#1f1f1f` 两套。 + +### 5.3 B:底色柔化(M0) + +- GitHub Light `background #ffffff` → `#f6f8fa`(GitHub 官方 light canvas 色),`cursorAccent` 同步;其余 15 色按 §5.2.1 重算。 +- One Half Light `#fafafa`、Solarized Light `#fdf6e3` 本身带色调,**不动**(避免拍脑袋改两套成熟主题的观感)。 +- 不加「柔化强度」设置。 + +### 5.4 C:清扫批次(M1–M3,每批独立提交、独立可回滚) + +映射规则(机械执行): + +| 现值模式 | 替换为 | +|----------|--------| +| `#8a8a8a`/`#9a9a9a`/`#6a6a6a` 文本 | `var(--ot-text-muted, …)` | +| `#cccccc`/`#d0d0d0` 文本 | `var(--ot-text, …)` 或 `-secondary`(按语境) | +| `color-mix(fg x%, …)` 文本 | `var(--ot-text-secondary/muted)` | +| `#3fb950` 族 | `var(--ot-success)` / `-fg` | +| `#f85149` 族 | `var(--ot-danger)` / `-fg` | +| `#d29922`/`#e3b341`/`#f0a55a` | `var(--ot-warning)` / `-fg` | +| `#58a6ff`/`#3b8eea`/`#3fd06a`(链接) | `var(--ot-info)` / `-fg` | +| `rgba(255,255,255,.04–.1)` 控件 hover | `var(--ot-hover)` | +| `rgba(255,255,255,.16–.2)` / 成块白色半透明面板 | `var(--ot-surface-raised)` / `var(--ot-border-strong)`(按语境:描边用后者) | +| `rgba(125,145,170,*)` | `var(--ot-scrollbar*)` | +| `rgba(0,0,0,.4+)` 阴影 | `var(--ot-shadow)` | + +批次: +- **M1 基座**:`semantic.ts` + chrome.ts 写 L2 变量 + `isLightTheme` 导出 + global.css + terminal.css + TerminalView.tsx 内嵌色 + ErrorBoundary.tsx + main.tsx 兜底。 +- **M2 主工作区**:workspace.css + connections.css + commands.css。 +- **M3 功能面板**:sftp.css + monitor.css + MonitorPanel.tsx + settings.css + highlight.css + lock.css。 + +白名单(防护测试豁免,§7):**semantic.ts 自身**(它是语义色的定义处,扫描它等于扫描标尺);**theme.ts**(内置调色板是主题数据);**global.css 的 `:root` 静态兜底块**(chrome.ts 变量缺失时的暗色默认值);chrome.ts 混色锚点(`#000000`/`#ffffff`,属算法参数);TerminalView 的 xterm ITheme 搜索色(主题数据)、背景图深底衬 `#0d1117` 与 `#00000000` 透明色(铁律);HighlightTab.tsx/highlightEngine.ts 的规则色(数据)。扫描器必须**先剥离注释再匹配**(terminal.css:16、highlightEngine.ts:17 的命中都在注释里),且正则覆盖 3/4 位 hex(`#fff`)。 + +### 5.5 兼容性 + +- 用户自建 `customThemes` 不受影响(L2 从 ThemeColors 派生,无需迁移)。 +- ThemeEditor 只能编辑/新建 customThemes(已核实 `ThemeEditor.tsx:70-74`),改内置主题颜色无兼容负担;想要旧色的用户可在编辑器里以旧值建副本。 +- `getThemeById` fallback(DEFAULT_DARK)不变。 +- 纯渲染层改动,无 IPC/settings 结构变更;`settings.terminal.themeId` 语义不变。 + +## 6. 测试与验收 + +### 6.1 新增测试(fail-closed,沿用仓库表驱动风格) + +1. `tests/theme-contrast.mjs`:直接 `import '../src/shared/theme.ts'`(Node 22.18+ 原生类型剥离,同 terminal-cwd 等三测试的既有做法)。对每套内置主题:计算全部 16 色对 background 的对比度,**浅色主题(isLight=true)每色 ≥4.5,深色主题仅断言 foreground ≥4.5**(不约束暗色系既定美学)。另覆盖 `isLightTheme` 判定表。 +2. `tests/css-theme-vars.mjs`:扫描 `src/renderer/src` 全部 `.css/.tsx/.ts`,正则提取色字面量,白名单外一律 exit 1;`var()` 内 fallback 必须命中受控常量清单。**接入 `package.json` `test` 脚本链**(纯文件扫描,无需 esbuild bundle)。 + +### 6.2 既有回归 + +`npm test`(pretest typecheck + 22 个离线测试)全绿。 + +### 6.3 手工验收清单(每批 M1–M3 + M0 各跑一次) + +三套浅色主题 × {终端(含 Claude Code 实跑)、设置弹层/下拉/Tooltip、SFTP 面板、监控面板、锁屏(含 `.lock-screen-boot` 启动闪层)、命令面板、连接侧栏、zmodem 传输弹层};深色主题抽查同清单确认零观感变化;背景图模式抽查(深底衬铁律不破);运行中明暗主题热切换各一次(chrome/antd/xterm 三路同步)。 + +### 6.4 性能 + +`minimumContrastRatio=4.5` 生产先例即背景图模式(已在线上跑);实现时用诊断日志对比开关前后的渲染层 stall 事件与高频输出场景,数据记入 PR 描述。 + +## 7. 防护测试细则 + +- 扫描正则:`#[0-9a-fA-F]{3,8}\b` 与 `rgba?\(`;`var(...)` 内命中归 fallback 规则,其余归 raw 规则。 +- 白名单按「文件 + 值」二元组维护在测试内(表驱动用例风格)。 +- 受控 fallback 清单:`semantic.ts` 导出的常量表,测试同时校验「CSS 里出现的 fallback ⊆ 清单」与「清单无死值」双向一致。 +- 里程碑 M4 收尾时把扫描基线清零:除白名单外 raw 计数必须为 0,此后新增硬编码直接红。 + +## 8. 里程碑与提交切分 + +| 里程碑 | 内容 | 提交 | 验收 | +|--------|------|------|------| +| M0 | §5.2 + §5.3(调色板、两开关、antd 算法、柔化)+ theme-contrast 测试 | 1 个 | 主诉立解;深色零回归 | +| M1 | 语义层基座 + global/terminal + TSX 内嵌 | 1 个 | 手工清单浅色全过 | +| M2 | workspace/connections/commands | 1 个 | 同上 | +| M3 | sftp/monitor/settings/highlight/lock | 1 个 | 同上 | +| M4 | css-theme-vars 测试 + 基线清零 + AGENTS.md「主题机制」重写 + docs/STATE.md 更新 | 1 个 | npm test 全绿含新测试 | + +每个里程碑独立可回滚;M0 可单独先行发版验证效果。 + +## 9. 风险与缓解 + +| 风险 | 缓解 | +|------|------| +| WebGL 渲染器不执行 MCR | **已排除**(2026-10-16 实查 addon-webgl 源码,见 §5.2.2) | +| MCR=4.5 渲染性能 | 背景图模式生产先例;诊断日志实测;不行则浅色降 3.5 并记录取舍(4.5 仍为目标值) | +| 深色回归 | 语义色深色分支锁现值 + 每批手工清单深色抽查 + diff 审查 | +| 加深算法破坏色相辨识(如 red 混成棕) | t 按色相手调固化,不许纯算法一路推到底;脚本只做下限校验 | +| 用户依赖旧浅色配色 | 主题编辑器可建副本还原;CHANGELOG 注明 | +| 大批量 CSS 改动引入选择器/特异性事故 | 只改颜色声明值,不动布局/盒模型属性;AGENTS.md 已记 sftp grid 教训 | + +## 10. 待拍板问题(交叉审核请逐条确认) + +1. 语义变量命名空间 `--ot-*`(提案)还是并入现有 `--chrome-*`? +2. GitHub Light 柔化目标 `#f6f8fa` 是否接受? +3. 深色主题是否也统一开 MCR 4.5(提案:维持 1,最小改动)? +4. `var()` fallback 是否按 §7 收敛为受控清单(提案:是,M4 落地)? +5. One Half Light / Solarized Light 底色维持不动(提案)? +6. M0 是否单独先行发版,还是 M0–M4 一起发? + +## 11. 对抗性审查记录(2026-10-16) + +审查执行:独立审查子代理(环境故障未产出有效报告)+ 主会话逐项自行验证。以下发现已全部落入正文: + +| # | 发现 | 级别 | 处置 | +|---|------|------|------| +| 1 | antd 无 `lightAlgorithm`,浅色应为 `defaultAlgorithm` | Important(真实错误) | §5.2.4 已改 | +| 2 | §5.2.2「深色 1(维持现状)」与背景图模式现状(深色+图=4.5)表述冲突 | Important | §5.2.2 改为显式矩阵 | +| 3 | WebGL 渲染器是否执行 MCR 未验证(承重假设) | Critical(假设) | **已实查源码排除**:addon-webgl 有完整对比度路径,§5.2.2 记录证据 | +| 4 | 两 xterm 选项运行时热设未验证 | Important(假设) | 已核实:options 同步进 webgl 配置;生产代码本就在热设 MCR | +| 5 | 防护测试扫描器会误伤 semantic.ts/theme.ts/global.css 兜底块自身 | Important | §5.4 白名单已显式枚举 | +| 6 | 扫描器须剥注释、覆盖 3/4 位 hex | Minor(实现细节) | §5.4 已写明 | +| 7 | One Half Light「9 个 <3:1」计数错误(实为 7 个) | Minor(数据错误) | §1.2 已改 | +| 8 | 原生控件(滚动条等)不随明暗 | Minor(遗漏) | §5.1 增 `color-scheme` | +| 9 | 锁屏启动闪层、zmodem 等面板浅色表现未入验收清单 | Minor | 已入 §6.3 手工清单 |