docs(theme): 主题系统 A+B+C 改造设计文档与实施计划(含对抗性审查记录)
This commit is contained in:
1 parent
bca7033689
commit
0a7bdda2d3
2 files changed
+987
No files matched your search
@@ -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<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 审查 + 手工验收清单移交用户)。
|
||||
Reference in new issue
Block a user