Compare commits

...
12 Commits
Author SHA1 Message Date
Bill 619e4ac57f chore(release): package-lock 根版本对齐 1.0.29
Deploy website to GitHub Pages / deploy (push) Waiting to run
CI / typecheck + test + build (windows) (push) Waiting to run
2026-10-16 23:57:42 +08:00
Bill 2aeea53da1 chore(release): v1.0.29 版本号与 CHANGELOG 四语同步 2026-10-16 23:44:53 +08:00
Bill 81748e4470 fix(theme): 浅色主题标签强调色改为按 WCAG 加深(实测 4.0:1 -> 4.9:1)
浅色分支此前固定 mix(accent, #000, 0.35),默认绿 #3fb950 得 #297834:在强调色
染过的激活标签底(≈#dae8df)上只有 4.0:1,低于 AA,用户自定义的浅色强调色还会
更差。改用 darkenToRatio(accent, chromeBg, 5.5)——对 chrome 底色保证 5.5:1,
落到更深的染色底上仍有 4.9:1。深色分支(mix 白 0.18)不动。

chrome.ts 新增 chromeBg 记录当前 chrome 底色(applyTabAccent 可能在
applyChromeTheme 之前被设置变更单独调用);#181818 是 pre-JS 深色默认值,
登记进 css-theme-vars 白名单(与 global.css 的 --chrome-bg 同值)。
2026-10-16 23:44:48 +08:00
Bill 3b86d62111 fix(theme): --ot-* 变量名 camelCase/kebab-case 不匹配导致浅色分支静默失效
CSS 自定义属性名大小写敏感,而 semanticVars() 的键是 camelCase、样式表读的是
kebab-case:chrome.ts 直接写 `--ot-${key}` 时,9 个多词变量(--ot-text-muted、
--ot-text-secondary、--ot-surface-raised、--ot-border-strong、--ot-success-fg、
--ot-danger-fg、--ot-warning-fg、--ot-info-fg、--ot-scrollbar-active、
--ot-focus-ring)从未真正生效,元素一律回落到 global.css :root 的深色兜底值——
浅色主题正是靠这些变量修可读性的,等于白改(--ot-text-muted 48 处引用仍是
#8a8a8a,白底 3.25:1;--ot-danger-fg 仍是 #f85149,3.15:1)。

- 键 `muted` 改名 `textMuted`(CSS 读的是 --ot-text-muted)
- 新增 semantic.ts 的 cssVarName()/semanticCssVars(),chrome.ts 只调用它们,
  命名逻辑单一来源
- 新增 tests/ot-var-names.mjs:把「CSS 引用名 = JS 写入名 = :root 声明名」
  三集合锁在一起(值测试看不出这类问题),已用「去掉转换」验证会失败
- AGENTS/STATE 同步该铁律;测试数 22/25 的陈旧计数修正为实际的 29
2026-10-16 23:29:03 +08:00
Bill 9e424138b5 Merge remote-tracking branch 'origin/Dev_20261001' 2026-10-16 23:10:29 +08:00
Bill 5bc91fd498 test(theme): 颜色字面量 fail-closed 守门(css-theme-vars)+ 基线清零;AGENTS/STATE 同步语义层与浅色主题铁律 2026-10-16 20:34:22 +08:00
Bill beaa6f1f04 refactor(theme): SFTP/监控/设置/高亮/锁屏面板颜色语义变量化;MonitorPanel 图表色随主题明暗派生(canvas 需实色) 2026-10-16 20:29:53 +08:00
Bill 83384b7233 refactor(theme): 工作区/连接侧栏/命令面板颜色语义变量化(徽章文本按 -fg 分流) 2026-10-16 20:27:09 +08:00
Bill 606ee0fa02 refactor(theme): global/terminal 硬编码颜色收敛进 --ot-* 语义变量(含滚动条/阴影/浮层,铁律例外保留) 2026-10-16 20:25:35 +08:00
Bill 79bea68c13 fix(theme): 浅色主题 ANSI 调色板重做到 WCAG AA + MCR/bold 动态开关 + antd 算法随亮度切换;github-light 底色柔化 #f6f8fa 2026-10-16 20:23:33 +08:00
Bill 2136221f2c feat(theme): 语义变量层基座 --ot-*(深色锁现值/浅色按 WCAG 派生)+ 表测试 2026-10-16 20:21:40 +08:00
Bill 0a7bdda2d3 docs(theme): 主题系统 A+B+C 改造设计文档与实施计划(含对抗性审查记录) 2026-10-16 20:20:04 +08:00
31 changed files with 1788 additions and 241 deletions

No files matched your search

+5 -3
View File
@@ -7,10 +7,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 22 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store;真实服务器测试需 JD_* 凭据,不在此列)。后三个渲染层纯函数测试用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 29 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、reserved-accelerators、terminal-title、ipc-guard、updater-fallback、log-sanitizer、app-log、sftp-timeout、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store、write-batcher、refit-throttle、semantic-theme、theme-contrast、css-theme-vars、ot-var-names;真实服务器测试需 JD_* 凭据,不在此列)。其中渲染层纯函数测试(semantic-theme、theme-contrast、ot-var-names、write-batcher、refit-throttle、broadcast-store、terminal-cwd、terminal-links、terminal-title、hl-split-smoke、hl-rules 等)用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边
- 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 22 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 29 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
- 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码)
@@ -71,7 +71,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 主题机制
- 终端主题由 xterm 主题派生 UI 配色:`src/renderer/src/theme/chrome.ts` 的 `applyChromeTheme` 写入 `--chrome-bg/-bg-deep/-border/-hover` CSS 变量,antd token 在 `main.tsx` ThemedConfigProvider 派生
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效
- **语义变量层(2026-10 主题改造)**:`src/renderer/src/theme/semantic.ts`(**零 import 纯模块**——`tests/semantic-theme.mjs` 经 Node 原生类型剥离直连它,加 import 前先想清楚)导出 `isLightTheme`/`mix`/`luminance`/`contrast`/`darkenToRatio`/`SEMANTIC` 锚点;`chrome.ts` 的 `applyChromeTheme` 随主题派生 `--ot-*` 变量并写 `color-scheme: dark|light`(原生滚动条/表单跟随)。**深色分支锁 SEMANTIC 锚点原值(零回归),浅色分支 `darkenToRatio` 按 WCAG 派生**(正文/-fg ≥4.5:1,语义基色 ≥3:1)。CSS 一律 `var(--ot-*, 锚点兜底)`,新代码禁止裸颜色字面量——`node tests/css-theme-vars.mjs` fail-closed 扫描(剥注释/color-mix/var 兜底走受控清单 FALLBACK_OK,跳过含 `${` 的模板串行,白名单按「文件+精确值」登记在测试内);**canvas 绘制吃不到 CSS 变量**(`ctx.strokeStyle` 不解析 var()),图表类组件用 `useResolvedTheme()` + `semanticVars()` 取实色(MonitorPanel 的做法)。**变量名大小写敏感**:`semanticVars()` 的键是 camelCase 而 CSS 读的是 kebab-case,`chrome.ts` 必须经 `semanticCssVars()`(内部 `cssVarName()`)转换后再写——直接写 camelCase 名**不报错**,只是该变量永不生效、元素静默回落到 `:root` 的深色兜底值(浅色主题就这么坏过一轮:`--ot-text-muted` 等 9 个多词变量全部没生效)。`node tests/ot-var-names.mjs` 把「CSS 引用名 = JS 写入名 = global.css `:root` 声明名」三个集合锁在一起——**值测试(semantic-theme/theme-contrast)看不出这类问题**,改语义层时别只跑它们
- **浅色主题可读性**:内置浅色主题 ANSI 调色板已重做,16 色+fg 全部 ≥4.5:1(`node tests/theme-contrast.mjs` 守门,改浅色主题必须过它;改色用 `darkenToRatio` 统一加深算法,不许手挑色);`minimumContrastRatio` 按「背景图 4.5|浅色无图 4.5|深色无图 1」矩阵(构造+热更两处都要改),`drawBoldTextInBrightColors` 浅色关闭;WebGL 渲染器同样执行 MCR(addon-webgl 自带 `_getMinimumContrastColor` 对比度路径,已核实);antd 算法随 `isLightTheme` 切 `defaultAlgorithm`/`darkAlgorithm`(**antd 没有 lightAlgorithm,浅色就是默认算法**);github-light 底色已柔化 `#f6f8fa`
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效;`--tab-accent-fg` 是强调底上的文字色(明暗自适应,选中态按钮文字也用它)
- 背景图(`settings.terminal.backgroundImage`):图片层 `.term-bg-image` 在 xterm 画布之下(`terminal.css`),xterm 背景设 `#00000000` 透明。**图片模式下的三条铁律**:① `.terminal-view.has-bg-image .terminal-view-dock` 强制深色底衬 `#0d1117`,不能用 `--chrome-bg`——浅色主题下它是白色,调低透明度会把图洗白而不是压暗;② `minimumContrastRatio` 从 1 提到 4.5(WCAG AA,与 VS Code 终端默认值一致;TerminalView 构造与设置热更两处都要改;注意正确拼写是 `minimumContrastRatio`,`minContrastRatio` 会被静默忽略),xterm 的对比度计算把透明背景当黑色亮度,会把浅色主题的深色前景自动提亮到可读。无图时保持 1 不动主题配色。③ 压暗层(`settings.terminal.backgroundImageDim`,0–90,**默认 0**):`.term-bg-dim` 纯黑 scrim 在 DOM 序上位于 `.term-bg-image` 之后、xterm(z-index:1)之下(同在 z-index:0 层,靠 DOM 序压图),与透明度的区别是透明度把图与深色底衬混合、压暗在图之上叠黑(保饱和但整体变暗);**dim=0 时 TerminalView 不渲染该层,零成本**
## 终端尺寸同步
+14
View File
@@ -1,5 +1,19 @@
# OpenTerminal Changelog
## v1.0.29 - 2026-10-16
This release fixes light themes: on white themes the text inside the terminal (especially the colored output of programs) was almost unreadable, and settings dialogs and other surfaces rendered with dark-theme colors on a light background.
### Fixed: light themes were unreadable and glaring
- Reworked the terminal palettes of all three built-in light themes (GitHub Light / One Half Light / Solarized Light): all 16 ANSI colors plus the foreground now meet WCAG AA (≥ 4.5:1) against the background, so "white on white" and washed-out yellow/cyan are gone
- Light themes now enforce a minimum contrast ratio of 4.5:1 and no longer promote bold text to bright colors, so program output tuned for dark backgrounds stays readable
- GitHub Light's pure white background is softened to #f6f8fa
- Settings dialogs, dropdowns and other components now render with light-theme colors under light themes instead of dark surfaces on a light background
### Improved: UI colors derived from the active theme
- The sidebar, tab bars and the SFTP / monitor / settings panels now derive every color from the theme, so nothing is left too faint to read on light themes
- Scrollbars, shadows and accent button text follow the theme as well
## v1.0.28 - 2026-10-15
This release fixes the "downloads twice" issue during in-app updates: when updating from an older version the progress bar ran through twice, costing nearly double the traffic in the worst case.
+14
View File
@@ -1,5 +1,19 @@
# OpenTerminal 更新履歴
## v1.0.29 - 2026-10-16
今回のリリースはライトテーマの可読性を修正します。ホワイト系テーマでは、ターミナル内の文字(特に各プログラムが出力する色付きの文字)がほとんど読めず、設定ダイアログなども明るい背景にダーク向けの色が重なって表示されていました。
### 修正: ライトテーマが見にくい・まぶしい
- 内蔵の3つのライトテーマ(GitHub Light / One Half Light / Solarized Light)のターミナル配色を全面的に作り直しました。16色と前景色のすべてが背景に対して WCAG AA(≥ 4.5:1)を満たし、「白地に白文字」「淡い黄・水色で見えない」といった色は解消されます
- ライトテーマではターミナルに最低コントラスト(4.5:1)を適用し、「太字を明るい色に昇格」を無効化しました。ダーク背景向けに調整されたプログラムの出力も、自動的に暗く調整されて読めるようになります
- GitHub Light の純白の背景を #f6f8fa にやわらげました
- ライトテーマでは設定ダイアログやドロップダウンなどのコンポーネントが明るい配色で描画され、明るい画面にダークな部品が重なる問題を解消しました
### 改善: UI の配色をテーマから統一派生
- サイドバー、タブバー、SFTP・モニター・設定などのパネルの色をすべてテーマから派生させ、ライトテーマで読めないほど薄い文字が残らないようにしました
- スクロールバー、影、強調ボタンの文字色もテーマに追従します
## v1.0.28 - 2026-10-15
本リリースではアプリ内アップデートの「2 回ダウンロード」問題を修正しました。旧バージョンからの更新時にプログレスバーが 2 周し、最悪の場合ほぼ倍の通信量が発生していました。
+14
View File
@@ -1,5 +1,19 @@
# OpenTerminal 更新日志
## v1.0.29 - 2026-10-16
本次重点修复浅色主题的可读性问题:白色主题下终端里的文字(尤其是各程序输出的彩色文字)几乎看不清,设置弹窗等界面在浅色下还会出现深色组件压在浅底上、文字发灰的错乱。
### 修复:浅色主题看不清、刺眼
- 三套内置浅色主题(GitHub Light / One Half Light / Solarized Light)的终端配色全部重做:16 色加前景文字在白底上的对比度都达到 WCAG AA(≥ 4.5:1),「白字白底」「浅黄/浅青看不清」等颜色全部修正
- 浅色主题下终端开启最低对比度兜底(4.5:1)并停用「粗体跳亮色」:即使某个程序输出的是为深色背景调配的浅色文字,也会被自动压暗到可读
- GitHub Light 的纯白背景改为柔和的 #f6f8fa,长时间使用不刺眼
- 浅色主题下设置弹窗、下拉菜单等组件的背景/边框/文字改用浅色渲染,不再出现深色组件压在浅色界面上
### 改进:界面配色统一跟随主题
- 侧栏、标签栏、SFTP/监控/设置等所有面板的颜色改为由主题统一派生,浅色主题下不再有某处文字浅到看不见
- 滚动条、阴影、强调按钮文字等也随主题自适应
## v1.0.28 - 2026-10-15
本次修复应用内更新「下载两次」的问题:从旧版本更新时进度条会跑两遍,最坏情况消耗近双倍流量。
+14
View File
@@ -1,5 +1,19 @@
# OpenTerminal 更新日誌
## v1.0.29 - 2026-10-16
本次重點修復淺色主題的可讀性問題:白色主題下終端內的文字(尤其是各程式輸出的彩色文字)幾乎看不清,設定彈窗等介面在淺色下還會出現深色元件壓在淺底上、文字發灰的錯亂。
### 修復:淺色主題看不清、刺眼
- 三套內建淺色主題(GitHub Light / One Half Light / Solarized Light)的終端配色全部重做:16 色加前景文字在白底上的對比度都達到 WCAG AA(≥ 4.5:1),「白字白底」「淺黃/淺青看不清」等顏色全部修正
- 淺色主題下終端開啟最低對比度防護(4.5:1)並停用「粗體跳亮色」:即使某個程式輸出的是為深色背景調配的淺色文字,也會被自動壓暗到可讀
- GitHub Light 的純白背景改為柔和的 #f6f8fa,長時間使用不刺眼
- 淺色主題下設定彈窗、下拉選單等元件的背景/邊框/文字改用淺色渲染,不再出現深色元件壓在淺色介面上
### 改進:介面配色統一跟隨主題
- 側欄、標籤列、SFTP/監控/設定等所有面板的顏色改為由主題統一派生,淺色主題下不再有某處文字淺到看不見
- 捲軸、陰影、強調按鈕文字等也隨主題自動調整
## v1.0.28 - 2026-10-15
本次修復應用內更新「下載兩次」的問題:從舊版本更新時進度條會跑兩遍,最壞情況消耗近雙倍流量。
+1
View File
@@ -28,6 +28,7 @@
| M11 终端自定义(背景图/压暗层+对比度护栏)、高亮预设扩到 22 条、传输健壮性修复 | ✅(v1.0.14–1.0.16) |
| M12 锁屏(主窗口遮罩+scrypt 密码+Ctrl+L 立即锁屏+冷却阶梯+落盘状态) | ✅(v1.0.17,v1.0.18 修复锁屏输入回归,v1.0.20 摘除菜单加固) |
| M13 更新源改为 GitHub 优先 + Gitea 通道兜底,高亮引擎性能优化 | ✅(v1.0.19–1.0.20) |
| M14 主题系统全面化:`--ot-*` 语义变量层、浅色主题 ANSI 调色板重做到 WCAG AA、MCR/bold/antd 算法明暗自适应、`css-theme-vars` + `ot-var-names` fail-closed 守门(后者抓住 `--ot-*` 变量名 camelCase/kebab-case 不匹配——9 个多词变量在浅色下静默回落到深色兜底) | ✅(2026-10-16,设计/计划见 `docs/superpowers/`;测试 25→29) |
## 锁屏实现要点(v1.0.17+,已落地)
@@ -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 审查 + 手工验收清单移交用户)。
@@ -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 手工清单 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
"version": "1.0.28",
"version": "1.0.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "1.0.28",
"version": "1.0.29",
"license": "MIT",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.28",
"version": "1.0.29",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
@@ -13,7 +13,7 @@
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"pretest": "npm run typecheck",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs && node tests/semantic-theme.mjs && node tests/theme-contrast.mjs && node tests/css-theme-vars.mjs && node tests/ot-var-names.mjs",
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
+2 -2
View File
@@ -56,7 +56,7 @@ class ErrorBoundaryClass extends Component<ErrorBoundaryClassProps, ErrorBoundar
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: '#cccccc'
color: 'var(--ot-text, #cccccc)'
}}
>
<div>{tFor(this.props.language, 'workspace.error.title')}</div>
@@ -66,7 +66,7 @@ class ErrorBoundaryClass extends Component<ErrorBoundaryClassProps, ErrorBoundar
maxHeight: 240,
overflow: 'auto',
whiteSpace: 'pre-wrap',
color: '#ff6b6b',
color: 'var(--ot-danger-fg, #ff6b6b)',
fontSize: 12
}}
>
+8 -8
View File
@@ -50,24 +50,24 @@
flex: 1 1 auto;
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
letter-spacing: 0.6px;
}
.commands-clear-btn {
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 11px;
padding: 0 2px;
}
.commands-clear-btn:hover {
color: #cf6679 !important;
color: var(--ot-danger-fg, #cf6679) !important;
}
.commands-empty {
padding: 20px 8px;
text-align: center;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 12px;
}
@@ -110,7 +110,7 @@
.commands-row-sub {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -120,7 +120,7 @@
.commands-row-del {
flex: 0 0 auto;
display: none;
color: #9a9a9a;
color: var(--ot-text-muted, #9a9a9a);
}
.commands-row:hover .commands-row-del {
@@ -128,7 +128,7 @@
}
.commands-row-del:hover {
color: #cf6679 !important;
color: var(--ot-danger-fg, #cf6679) !important;
}
/* ---- add-library modal ---- */
@@ -141,7 +141,7 @@
/* IPC/store failure during save: shown inside the dialog (in the app's danger
colour) so a failed save is visible instead of a silent no-op. */
.commands-save-error {
color: #f85149;
color: var(--ot-danger-fg, #f85149);
font-size: 12px;
word-break: break-all;
}
+26 -26
View File
@@ -70,15 +70,15 @@
.connections-group-count {
flex: 0 0 auto;
font-size: 11px;
color: #8a8a8a;
background: rgba(255, 255, 255, 0.08);
color: var(--ot-text-muted, #8a8a8a);
background: var(--ot-hover, rgba(255, 255, 255, 0.08));
border-radius: 9px;
padding: 0 7px;
line-height: 18px;
}
.connections-collapse .ant-collapse-item {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--ot-border, rgba(255, 255, 255, 0.05));
}
.connections-collapse .ant-collapse-item:last-child {
@@ -129,7 +129,7 @@
.connections-row-sub {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -143,8 +143,8 @@
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
color: #9a9a9a;
background: rgba(255, 255, 255, 0.08);
color: var(--ot-text-muted, #9a9a9a);
background: var(--ot-hover, rgba(255, 255, 255, 0.08));
}
.connections-row-actions {
@@ -159,12 +159,12 @@
}
.connections-row-actions .ant-btn {
color: #9a9a9a;
color: var(--ot-text-muted, #9a9a9a);
}
.connections-row-actions .ant-btn-text:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
color: var(--ot-text, #ffffff);
background: var(--ot-hover, rgba(255, 255, 255, 0.1));
}
/* ---------- empty state ---------- */
@@ -172,7 +172,7 @@
.connections-empty {
padding: 28px 8px;
text-align: center;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 12px;
line-height: 20px;
}
@@ -186,14 +186,14 @@
}
.connections-form-section {
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--ot-border, rgba(255, 255, 255, 0.08));
padding-top: 8px;
}
.connections-form-section-title {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
color: var(--ot-text-secondary, rgba(255, 255, 255, 0.55));
letter-spacing: 0.5px;
margin: 10px 0 0;
}
@@ -207,12 +207,12 @@
}
.connections-switch-label {
color: rgba(255, 255, 255, 0.88);
color: var(--ot-text, rgba(255, 255, 255, 0.88));
font-size: 13px;
}
.connections-switch-hint {
color: rgba(255, 255, 255, 0.45);
color: var(--ot-text-muted, rgba(255, 255, 255, 0.45));
font-size: 12px;
margin-top: 2px;
}
@@ -245,7 +245,7 @@
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
color: #cf6679;
color: var(--ot-danger-fg, #cf6679);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -255,7 +255,7 @@
.connections-empty-create {
border: none;
background: none;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font: inherit;
cursor: pointer;
padding: 4px 8px;
@@ -263,7 +263,7 @@
}
.connections-empty-create:hover {
color: #ffffff;
color: var(--ot-text, #ffffff);
background: var(--chrome-hover);
}
@@ -292,15 +292,15 @@
flex: 1 1 auto;
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
letter-spacing: 0.6px;
}
.connections-section-count {
flex: 0 0 auto;
font-size: 11px;
color: #8a8a8a;
background: rgba(255, 255, 255, 0.08);
color: var(--ot-text-muted, #8a8a8a);
background: var(--ot-hover, rgba(255, 255, 255, 0.08));
border-radius: 9px;
padding: 0 7px;
line-height: 18px;
@@ -308,7 +308,7 @@
.connections-section-empty {
padding: 10px 8px;
color: #6a6a6a;
color: var(--ot-text-muted, #6a6a6a);
font-size: 12px;
}
@@ -332,7 +332,7 @@
}
.connections-local-list::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
background: var(--ot-scrollbar, rgba(121, 121, 121, 0.45));
border-radius: 4px;
}
@@ -370,8 +370,8 @@
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
color: #9a9a9a;
background: rgba(255, 255, 255, 0.08);
color: var(--ot-text-muted, #9a9a9a);
background: var(--ot-hover, rgba(255, 255, 255, 0.08));
}
/* recent sessions */
@@ -386,7 +386,7 @@
.connections-recent-title {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
letter-spacing: 0.6px;
}
@@ -422,7 +422,7 @@
.connections-recent-time {
flex: 0 0 auto;
font-size: 11px;
color: #6a6a6a;
color: var(--ot-text-muted, #6a6a6a);
}
/* [M5] command history/library sidebar section fills the rest of the list. */
+28 -4
View File
@@ -13,6 +13,30 @@
--chrome-tab-ring-hover: rgba(255, 255, 255, 0.2);
--chrome-tab-fg: #98a2ab;
--chrome-tab-fg-hover: #d0d0d0;
/* Semantic layer; overwritten per theme by applyChromeTheme() via
semanticVars() — these dark values are the pre-JS fallback (and the
zero-regression dark branch). */
--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;
}
html,
@@ -32,7 +56,7 @@ body,
tracks + arrow buttons that look broken on the dark surface. */
* {
scrollbar-width: thin;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
scrollbar-color: var(--ot-scrollbar, rgba(125, 145, 170, 0.5)) transparent;
}
*::-webkit-scrollbar {
@@ -45,7 +69,7 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(125, 145, 170, 0.5);
background: var(--ot-scrollbar, rgba(125, 145, 170, 0.5));
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
@@ -53,7 +77,7 @@ body,
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(150, 172, 200, 0.7);
background: var(--ot-scrollbar-active, rgba(150, 172, 200, 0.7));
border: 3px solid transparent;
background-clip: padding-box;
}
@@ -134,7 +158,7 @@ button {
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
color: #ff6b6b;
color: var(--ot-danger-fg, #ff6b6b);
}
.panel-error-retry {
+2 -2
View File
@@ -37,7 +37,7 @@
border: 1px solid var(--chrome-border, #2d2d2d);
border-radius: 10px;
background: var(--chrome-bg, #181818);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
box-shadow: 0 18px 48px var(--ot-shadow, rgba(0, 0, 0, 0.45));
text-align: center;
}
@@ -70,7 +70,7 @@
min-height: 18px;
font-size: 12px;
line-height: 18px;
color: #f85149;
color: var(--ot-danger-fg, #f85149);
}
.lock-screen-hint {
+6 -3
View File
@@ -10,6 +10,7 @@ import { ErrorBoundary } from './ErrorBoundary'
import ZmodemOffers from './workspace/ZmodemOffers'
import { useSettingsStore } from './settings/store'
import { mix } from './theme/chrome'
import { isLightTheme } from './theme/semantic'
import './global.css'
// Diagnostics: report renderer main-thread freezes after recovery. console.error
@@ -221,14 +222,16 @@ function ThemedConfigProvider({ children }: { children: ReactNode }): React.JSX.
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const colors = getThemeById(themeId, customThemes).colors
const light = isLightTheme(colors.background)
return (
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
// antd has no lightAlgorithm — light is the default algorithm.
algorithm: light ? antdTheme.defaultAlgorithm : antdTheme.darkAlgorithm,
token: {
colorBgBase: colors.background,
colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? '#1f1f1f',
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? '#252525',
colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? (light ? '#f5f5f5' : '#1f1f1f'),
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? (light ? '#f5f5f5' : '#252525'),
colorTextBase: colors.foreground,
borderRadius: 6
}
+21 -13
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { isLightTheme, semanticVars } from '@renderer/theme/semantic'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './monitor.css'
@@ -105,11 +106,13 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart
)
}
/** Red/green/yellow threshold bar color for CPU usage. */
function cpuColor(usage: number): string {
if (usage > 80) return '#f85149'
if (usage > 60) return '#e3b341'
return '#3fb950'
/** Red/green/yellow threshold bar color for CPU usage. Takes the semantic
* var map so light themes get the darkened (WCAG-compliant) variants —
* canvas fills need actual color strings, CSS vars don't resolve there. */
function cpuColor(usage: number, vars: Record<string, string>): string {
if (usage > 80) return vars.dangerFg
if (usage > 60) return vars.warningFg
return vars.successFg
}
/** Format a size in MB: show GB (1 decimal) once over 1024 MB. */
@@ -156,6 +159,11 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
/** Switching workspace mode only hides the dockview (panels stay mounted), so
* polling has to stop explicitly while the SSH workspace is not visible. */
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
const theme = useResolvedTheme()
const vars = semanticVars(theme.colors, isLightTheme(theme.colors.background))
// #rrggbb + alpha → 'rgba(r, g, b, a)' — canvas fill needs a solid color.
const withAlpha = (hex: string, a: number): string =>
`rgba(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(', ')}, ${a})`
useEffect(() => {
if (workspaceMode !== 'ssh') return
@@ -226,15 +234,15 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<section className="mm-block" aria-label="CPU">
<div className="mm-block-row">
<span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}>
<span className="mm-value" style={{ color: cpuColor(cpu.usage, vars) }}>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {tFor(language, 'ssh.monitor.cores', { n: cpu.cores })}</span>
</span>
</div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<Bar pct={cpu.usage} color={cpuColor(cpu.usage, vars)} />
<div className="mm-sub mm-load">
{tFor(language, 'ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
</div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} />
<HistoryChart data={cpuHist} lineColor={vars.successFg} fillColor={withAlpha(vars.success, 0.15)} height={48} maxY={100} />
</section>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.mem')}>
@@ -246,7 +254,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</div>
<div className="mm-mem-row">
<div className="mm-mem-bar">
<Bar pct={memPct} color="#d29922" />
<Bar pct={memPct} color={vars.warningFg} />
</div>
<span className="mm-mem-pct">{memPct.toFixed(0)}%</span>
</div>
@@ -266,7 +274,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
{d.mount}
</span>
<div className="mm-disk-bar">
<Bar pct={pct} color={cpuColor(pct)} />
<Bar pct={pct} color={cpuColor(pct, vars)} />
</div>
<span className="mm-disk-size">
{formatSize(d.usedMb)} / {formatSize(d.totalMb)}
@@ -295,8 +303,8 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</div>
</div>
<div className="mm-net-charts">
<HistoryChart data={rxHist} lineColor="#58a6ff" fillColor="rgba(88,166,255,0.15)" height={40} />
<HistoryChart data={txHist} lineColor="#f2cc60" fillColor="rgba(242,204,96,0.15)" height={40} />
<HistoryChart data={rxHist} lineColor={vars.infoFg} fillColor={withAlpha(vars.info, 0.15)} height={40} />
<HistoryChart data={txHist} lineColor={vars.warningFg} fillColor={withAlpha(vars.warning, 0.15)} height={40} />
</div>
</section>
+15 -15
View File
@@ -19,10 +19,10 @@
}
.mm-error {
border: 1px solid #f85149;
border: 1px solid var(--ot-danger, #f85149);
border-radius: 6px;
background: rgba(248, 81, 73, 0.12);
color: #f85149;
background: color-mix(in srgb, var(--ot-danger, #f85149) 12%, transparent);
color: var(--ot-danger-fg, #f85149);
padding: 5px 8px;
font-size: 12px;
}
@@ -49,7 +49,7 @@
.mm-os {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -78,7 +78,7 @@
.mm-label {
font-size: 11px;
font-weight: 500;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
text-transform: uppercase;
letter-spacing: 0.04em;
user-select: none;
@@ -86,19 +86,19 @@
.mm-value {
font-size: 13px;
color: #3fb950;
color: var(--ot-success-fg, #3fb950);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.mm-value-sub {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.mm-sub {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.mm-load {
@@ -141,7 +141,7 @@
.mm-mem-pct {
flex: 0 0 auto;
font-size: 11px;
color: #d29922;
color: var(--ot-warning-fg, #d29922);
font-variant-numeric: tabular-nums;
min-width: 32px;
text-align: right;
@@ -177,7 +177,7 @@
.mm-disk-size {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -221,7 +221,7 @@
.mm-net-unit {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.mm-arrow {
@@ -231,17 +231,17 @@
}
.mm-up {
color: #58a6ff;
color: var(--ot-info-fg, #58a6ff);
}
.mm-down {
color: #f0a55a;
color: var(--ot-warning-fg, #f0a55a);
}
/* ---------- footer ---------- */
.mm-footer {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.mm-uptime {
@@ -253,7 +253,7 @@
align-items: center;
justify-content: center;
min-height: 120px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.mm-dots {
+6 -6
View File
@@ -79,12 +79,12 @@
}
.hl-pattern-bad {
color: #f85149;
color: var(--ot-danger-fg, #f85149);
}
.hl-warn-icon {
flex: 0 0 auto;
color: #f85149;
color: var(--ot-danger-fg, #f85149);
font-size: 12px;
line-height: 1;
cursor: help;
@@ -289,17 +289,17 @@
.hl-editor-error {
font-size: 12px;
color: #f85149;
color: var(--ot-danger-fg, #f85149);
}
.hl-editor-input-bad {
border-color: #f85149;
border-color: var(--ot-danger, #f85149);
}
.hl-editor-input-bad:hover,
.hl-editor-input-bad:focus {
border-color: #f85149;
box-shadow: 0 0 0 2px rgba(248, 81, 73, 0.2);
border-color: var(--ot-danger, #f85149);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ot-danger, #f85149) 20%, transparent);
}
.hl-editor-sub-label {
+10 -10
View File
@@ -200,12 +200,12 @@
antd's own checked colour loses to our text overrides, so pin it here. */
.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled),
.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
color: #fff;
color: var(--tab-accent-fg, #fff);
}
.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover,
.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover {
color: #fff;
color: var(--tab-accent-fg, #fff);
}
/* Own positive spacing: the shared `.settings-render-hint` uses a negative
@@ -299,8 +299,8 @@
}
.theme-card-selected {
border-color: #3b8eea;
box-shadow: 0 0 0 1px #3b8eea inset;
border-color: var(--ot-focus-ring, #3b8eea);
box-shadow: 0 0 0 1px var(--ot-focus-ring, #3b8eea) inset;
}
.theme-card-bg {
@@ -333,7 +333,7 @@
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
background: rgba(59, 142, 234, 0.2);
background: var(--ot-focus-ring, rgba(59, 142, 234, 0.2));
color: color-mix(in srgb, var(--chrome-accent, #3b8eea) 55%, var(--chrome-fg, #cccccc));
}
@@ -510,11 +510,11 @@
}
.about-status.is-new {
color: #3fd06a;
color: var(--ot-success-fg, #3fd06a);
}
.about-error {
color: #f85149;
color: var(--ot-danger-fg, #f85149);
margin-top: 4px;
font-size: 12px;
word-break: break-all;
@@ -596,11 +596,11 @@
}
.settings-lock-feedback.is-ok {
color: #3fb950;
color: var(--ot-success-fg, #3fb950);
}
.settings-lock-feedback.is-error {
color: #f85149;
color: var(--ot-danger-fg, #f85149);
}
.settings-lock-note {
@@ -627,5 +627,5 @@
margin: -4px 0 10px;
padding-left: 2px;
font-size: 12px;
color: #d29922;
color: var(--ot-warning-fg, #d29922);
}
+18 -18
View File
@@ -24,7 +24,7 @@
.sftp-breadcrumb {
padding: 3px 8px;
font-size: 12px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
border-bottom: 1px solid var(--chrome-border);
overflow-x: auto;
white-space: nowrap;
@@ -152,17 +152,17 @@
}
.sftp-row-selected {
background: rgba(59, 142, 234, 0.18);
background: var(--ot-focus-ring, rgba(59, 142, 234, 0.18));
}
.sftp-icon {
display: inline-flex;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: calc(14px * var(--sftp-scale, 1));
}
.sftp-icon-dir {
color: #e3b341;
color: var(--ot-warning-fg, #e3b341);
}
.sftp-name {
@@ -175,7 +175,7 @@
.sftp-mode {
text-align: center;
color: #9a9a9a;
color: var(--ot-text-muted, #9a9a9a);
font-family: Consolas, 'Courier New', monospace;
font-size: var(--sftp-fs-meta, 12px);
white-space: nowrap;
@@ -184,7 +184,7 @@
.sftp-owner {
text-align: right;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -192,7 +192,7 @@
.sftp-size {
text-align: right;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -200,7 +200,7 @@
.sftp-time {
text-align: right;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -210,12 +210,12 @@
.sftp-error {
padding: 12px;
text-align: center;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 12px;
}
.sftp-error {
color: #f85149;
color: var(--ot-danger-fg, #f85149);
}
/* ---- permission / owner dialog ---- */
@@ -248,7 +248,7 @@
}
.sftp-perm-octal {
color: #3fb950;
color: var(--ot-success-fg, #3fb950);
}
.sftp-perm-grid {
@@ -264,7 +264,7 @@
align-items: center;
column-gap: 8px;
font-size: 12px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.sftp-perm-grid-head span {
@@ -305,7 +305,7 @@
.sftp-perm-label {
font-size: 12px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
.sftp-perm-own-field .ant-input {
@@ -314,7 +314,7 @@
.sftp-perm-hint {
font-size: 11px;
color: #6a6a6a;
color: var(--ot-text-muted, #6a6a6a);
}
/* ---- global transfer overlay ---- */
@@ -339,7 +339,7 @@
border: 1px solid var(--chrome-border);
border-radius: 6px;
padding: 8px 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
box-shadow: 0 4px 16px var(--ot-shadow, rgba(0, 0, 0, 0.45));
}
.sftp-transfer-head {
@@ -357,11 +357,11 @@
}
.sftp-transfer-done .sftp-transfer-title {
color: #3fb950;
color: var(--ot-success-fg, #3fb950);
}
.sftp-transfer-error .sftp-transfer-title {
color: #f85149;
color: var(--ot-danger-fg, #f85149);
}
.sftp-transfer-cancelled .sftp-transfer-title {
@@ -423,7 +423,7 @@
.sftp-transfer-err {
font-size: 11px;
color: #f85149;
color: var(--ot-danger-fg, #f85149);
margin-top: 3px;
word-break: break-all;
}
+17 -9
View File
@@ -9,6 +9,7 @@ import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseO
import { Checkbox, Modal } from 'antd'
import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
import { isLightTheme } from '@renderer/theme/semantic'
import type { CommandItem } from '@shared/commands'
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
import { rulesForProfile } from '@shared/highlightProfiles'
@@ -846,6 +847,8 @@ export function TerminalView({
// ---- terminal instance: built once per session ----
useLayoutEffect(() => {
const themeColors = getThemeById(themeOpts.themeId, themeOpts.customThemes).colors
const lightTheme = isLightTheme(themeColors.background)
const term = new Terminal({
fontSize: fontOpts.fontSize,
fontFamily: fontOpts.fontFamily,
@@ -857,16 +860,21 @@ export function TerminalView({
cursorBlink: fontOpts.cursorBlink,
cursorStyle: fontOpts.cursorStyle,
cursorInactiveStyle: fontOpts.cursorInactiveStyle,
theme: withChromeColors(getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme, bgOpts.image !== ''),
theme: withChromeColors(themeColors as ITheme, bgOpts.image !== ''),
allowProposedApi: true,
allowTransparency: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
drawBoldTextInBrightColors: true,
// Light themes ship WCAG-compliant palettes, but apps still emit
// dark-tuned bright/bold colors; bold must NOT jump to the bright set.
drawBoldTextInBrightColors: !lightTheme,
// In image mode the xterm background is transparent (#00000000), which
// xterm's contrast cache treats as black; a raised contrast floor lifts
// dark foreground colors (light themes) enough to stay readable over
// the wallpaper. Without an image keep 1 (off) to honor theme colors.
minimumContrastRatio: bgOpts.image ? 4.5 : 1,
// the wallpaper. Without an image light themes keep the 4.5 floor to
// rescue low-contrast app output; dark themes keep 1 (off) to honor
// theme colors. (Spelled minimumContrastRatio — minContrastRatio is
// silently ignored.)
minimumContrastRatio: bgOpts.image || lightTheme ? 4.5 : 1,
automaticFontFallback: true,
convertEol: false,
windowsPty: { backend: 'conpty', buildNumber: 0 }
@@ -1261,11 +1269,11 @@ export function TerminalView({
useEffect(() => {
const term = termRef.current
if (!term) return
term.options.theme = withChromeColors(
getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme,
bgOpts.image !== ''
)
term.options.minimumContrastRatio = bgOpts.image ? 4.5 : 1
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
}, [themeOpts, bgOpts])
// ---- renderer mode changes: keep the instance, swap renderer, refit ----
+15 -15
View File
@@ -40,7 +40,7 @@
so it reads as a soft slate bar, never a white line. */
.terminal-view .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
scrollbar-color: var(--ot-scrollbar, rgba(125, 145, 170, 0.5)) transparent;
}
.terminal-view .xterm-viewport::-webkit-scrollbar {
@@ -52,7 +52,7 @@
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(125, 145, 170, 0.5);
background: var(--ot-scrollbar, rgba(125, 145, 170, 0.5));
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
@@ -60,7 +60,7 @@
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(150, 172, 200, 0.7);
background: var(--ot-scrollbar-active, rgba(150, 172, 200, 0.7));
border: 3px solid transparent;
background-clip: padding-box;
}
@@ -147,7 +147,7 @@
border: 1px solid var(--chrome-border);
border-radius: 8px;
background: var(--chrome-bg-deep);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
box-shadow: 0 6px 20px var(--ot-shadow, rgba(0, 0, 0, 0.4));
font-size: 12px;
color: var(--chrome-fg);
user-select: none;
@@ -210,7 +210,7 @@
gap: 2px;
padding: 2px;
border-radius: 6px;
background: rgba(24, 26, 31, 0.85);
background: var(--ot-surface-raised, rgba(24, 26, 31, 0.85));
border: 1px solid var(--chrome-border);
/* Hidden until the terminal is hovered — it must not sit over the output. */
opacity: 0;
@@ -234,14 +234,14 @@
border: none;
border-radius: 4px;
background: transparent;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 12px;
cursor: pointer;
}
.term-rec-btn:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
color: var(--ot-text, #ffffff);
background: var(--ot-hover, rgba(255, 255, 255, 0.1));
}
.term-rec-btn.is-recording {
@@ -278,7 +278,7 @@
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
box-shadow: 0 8px 28px var(--ot-shadow, rgba(0, 0, 0, 0.55));
user-select: none;
box-sizing: border-box;
}
@@ -308,8 +308,8 @@
.term-suggest-item:hover,
.term-suggest-item.is-active {
background: rgba(59, 142, 234, 0.18);
color: #ffffff;
background: var(--ot-focus-ring, rgba(59, 142, 234, 0.18));
color: var(--ot-text, #ffffff);
}
.term-suggest-cmd {
@@ -327,14 +327,14 @@
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
color: #9a9a9a;
background: rgba(255, 255, 255, 0.08);
color: var(--ot-text-muted, #9a9a9a);
background: var(--ot-hover, rgba(255, 255, 255, 0.08));
}
.term-suggest-hint {
flex: 0 0 auto;
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
opacity: 0.9;
padding-top: 4px;
line-height: 13px;
@@ -416,7 +416,7 @@
border: none;
border-radius: 4px;
background: var(--chrome-accent);
color: #fff;
color: var(--tab-accent-fg, #fff);
font: inherit;
cursor: pointer;
}
+25 -39
View File
@@ -1,47 +1,17 @@
import type { ThemeColors } from '@shared/theme'
import { contrast, darkenToRatio, isLightTheme, mix, semanticCssVars } from './semantic'
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. */
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')
}
/** WCAG contrast ratio between two colors; 21 when either is not hex. */
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)
}
/** 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)}`
}
// 兼容既有导入方(main.tsx 的 mix);新代码请直接从 ./semantic 导入。
export { contrast, luminance, mix } from './semantic'
/** Whether the active terminal theme has a light background. Tab bars derived
* from it flip their overlay direction (white-on-dark vs black-on-light). */
let chromeIsLight = false
/** Last chrome background; applyTabAccent() needs it to derive a readable
* accent foreground for light themes. */
let chromeBg = '#181818'
/** Dark-chrome tab label: the background mixed toward the theme's own foreground
* (the light branch is the same mix toward black), but never below 4.5:1 against
* the background, since a theme foreground dimmed by 0.68 can land unreadable. */
@@ -65,7 +35,8 @@ export function applyChromeTheme(colors: ThemeColors): void {
}
set('--chrome-bg', colors.background)
set('--chrome-fg', colors.foreground)
chromeIsLight = (luminance(colors.background) ?? 0) > 0.5
chromeIsLight = isLightTheme(colors.background)
chromeBg = colors.background
if (chromeIsLight) {
// Light chrome: keep the bar close to the background (a big mix toward
// black turns pale themes into a muddy gray strip) and overlay tabs with
@@ -90,6 +61,15 @@ export function applyChromeTheme(colors: ThemeColors): void {
set('--chrome-tab-fg', tabLabel(colors.background, colors.foreground, 0.68))
set('--chrome-tab-fg-hover', tabLabel(colors.background, colors.foreground, 0.88))
}
// Semantic layer: --ot-* variables consumed by panels, popovers and inline
// styles. The dark branch locks the SEMANTIC anchors (zero visual change);
// the light branch darkens them until the WCAG ratio holds against this
// theme's background. `color-scheme` rides along so native scrollbars and
// form controls follow the same switch. semanticCssVars() owns the camelCase
// -> kebab-case naming (custom property names are case-sensitive, so a miss
// would leave the variable unset and silently fall back to the dark :root
// literal); tests/ot-var-names.mjs guards it.
for (const [name, value] of Object.entries(semanticCssVars(colors, chromeIsLight))) set(name, value)
applyTabAccent(tabAccent)
}
@@ -107,6 +87,12 @@ export function applyTabAccent(color: string): void {
return
}
style.setProperty('--tab-accent', color)
const fg = chromeIsLight ? mix(color, '#000000', 0.35) : mix(color, '#ffffff', 0.18)
// Light chrome: a fixed 0.35 mix toward black left the default green
// (#3fb950 -> #297834) at 4.0:1 on the accent-tinted tab, just under AA, and
// arbitrary user accents could be far worse. darkenToRatio() guarantees the
// ratio against the chrome background; the tinted surfaces it also lands on
// are slightly darker, hence the margin above 4.5. Dark chrome keeps the
// existing lighten mix (its accents sit on dark tabs).
const fg = chromeIsLight ? darkenToRatio(color, chromeBg, 5.5) : mix(color, '#ffffff', 0.18)
style.setProperty('--tab-accent-fg', fg ?? color)
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 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 (camelCase keys; chrome.ts converts them to
* kebab-case, so `textMuted` lands as `--ot-text-muted`). */
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,
textMuted: 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),
textMuted: 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'
}
}
/** Final CSS custom property name for a semanticVars() key. Custom property
* names are case-sensitive, so this kebab conversion is the only thing tying
* the camelCase keys to the --ot-* names the stylesheets read — a miss leaves
* the variable unset and the element falls back to the dark :root literal.
* Exported so chrome.ts and tests/ot-var-names.mjs share one implementation. */
export function cssVarName(key: string): string {
if (key === 'colorScheme') return 'color-scheme'
return `--ot-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
}
/** semanticVars() keyed by their final CSS custom property names. */
export function semanticCssVars(colors: ThemeLike, light: boolean): Record<string, string> {
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(semanticVars(colors, light))) out[cssVarName(key)] = value
return out
}
+22 -22
View File
@@ -79,10 +79,10 @@
display: flex;
flex-direction: column;
gap: 2px;
background: #1f1f1f;
background: var(--ot-surface-raised, #1f1f1f);
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
box-shadow: 0 8px 24px var(--ot-shadow, rgba(0, 0, 0, 0.45));
}
.workspace-flyout-item {
@@ -93,7 +93,7 @@
border: none;
border-radius: 5px;
background: transparent;
color: rgba(255, 255, 255, 0.85);
color: var(--ot-text, rgba(255, 255, 255, 0.85));
font-size: 13px;
text-align: left;
white-space: nowrap;
@@ -102,7 +102,7 @@
.workspace-flyout-item:hover {
background: var(--chrome-hover);
color: #ffffff;
color: var(--ot-text, #ffffff);
}
.workspace-flyout-divider {
@@ -177,8 +177,8 @@
--dv-tabs-and-actions-container-height: 28px;
--dv-tab-divider-color: transparent;
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
--dv-sash-color: rgba(148, 163, 184, 0.28);
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
--dv-sash-color: var(--ot-border, rgba(148, 163, 184, 0.28));
--dv-active-sash-color: var(--ot-info, rgba(59, 142, 234, 0.75));
/* Tab surfaces. Dockview paints tabs through group-scoped rules that
outspecify our .dv-tab rules, so theme the tabs via the vars those rules
consume — otherwise the abyss defaults (dark navy) leak onto light themes. */
@@ -276,13 +276,13 @@
}
.workspace-empty-title {
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 14px;
user-select: none;
}
.workspace-empty-hint {
color: #6a6a6a;
color: var(--ot-text-muted, #6a6a6a);
font-size: 12px;
user-select: none;
}
@@ -339,13 +339,13 @@
top: 4px;
right: 4px;
z-index: 4;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 13px;
}
.workspace-terminal-monitor-toggle:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
color: var(--ot-text, #ffffff);
background: var(--ot-hover, rgba(255, 255, 255, 0.1));
}
/* ---- SSH bottom file browser (M8, FileZilla-style resizable bottom panel) ----
@@ -553,13 +553,13 @@ body.ssh-resizing .ssh-bottom-divider {
.workspace-bcast-pop-empty {
padding: 10px 0;
text-align: center;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 12px;
}
.workspace-bcast-pop-hint {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
line-height: 1.4;
border-top: 1px solid var(--chrome-border);
padding-top: 6px;
@@ -658,7 +658,7 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-template-time {
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
font-size: 12px;
margin-top: 2px;
}
@@ -666,7 +666,7 @@ body.ssh-resizing .ssh-bottom-divider {
.workspace-template-empty {
padding: 24px 0;
text-align: center;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
}
/* ---- SSH connect flow modals ---- */
@@ -702,7 +702,7 @@ body.ssh-resizing .ssh-bottom-divider {
}
.hostkey-changed-title {
color: #ff4d4f;
color: var(--ot-danger-fg, #ff4d4f);
font-weight: 600;
}
@@ -722,7 +722,7 @@ body.ssh-resizing .ssh-bottom-divider {
.connections-mode-title {
font-size: 11px;
letter-spacing: 0.4px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
padding: 10px 14px 2px;
user-select: none;
}
@@ -736,7 +736,7 @@ body.ssh-resizing .ssh-bottom-divider {
}
.commands-mode-badge-text {
font-size: 11px;
color: #8a8a8a;
color: var(--ot-text-muted, #8a8a8a);
letter-spacing: 0.4px;
}
.commands-mode-badge-tag {
@@ -747,10 +747,10 @@ body.ssh-resizing .ssh-bottom-divider {
line-height: 16px;
}
.commands-mode-badge--terminal .commands-mode-badge-tag {
color: #4cc38a;
background: rgba(76, 195, 138, 0.14);
color: var(--ot-success-fg, #4cc38a);
background: color-mix(in srgb, var(--ot-success, #3fb950) 14%, transparent);
}
.commands-mode-badge--ssh .commands-mode-badge-tag {
color: #6aa9ff;
background: rgba(106, 169, 255, 0.14);
color: var(--ot-info-fg, #6aa9ff);
background: color-mix(in srgb, var(--ot-info, #58a6ff) 14%, transparent);
}
+42 -42
View File
@@ -133,21 +133,21 @@ export const BUILTIN_THEMES: TerminalTheme[] = [
cursorAccent: '#fafafa',
selectionBackground: '#d2d6db',
black: '#383a42',
red: '#e45649',
green: '#50a14f',
yellow: '#c18401',
blue: '#0184bc',
red: '#c2493e',
green: '#40813f',
yellow: '#9a6a01',
blue: '#0177a9',
magenta: '#a626a4',
cyan: '#0997b3',
white: '#fafafa',
cyan: '#07798f',
white: '#717171',
brightBlack: '#4f525e',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff'
brightRed: '#b3565e',
brightGreen: '#5b7549',
brightYellow: '#7e6a44',
brightBlue: '#3f729b',
brightMagenta: '#955aa6',
brightCyan: '#38767e',
brightWhite: '#737373'
}
},
{
@@ -184,26 +184,26 @@ export const BUILTIN_THEMES: TerminalTheme[] = [
builtin: true,
colors: {
background: '#fdf6e3',
foreground: '#657b83',
foreground: '#586e75',
cursor: '#586e75',
cursorAccent: '#fdf6e3',
selectionBackground: '#eee8d5',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#93a1a1',
brightRed: '#cb4b16',
red: '#d1302d',
green: '#647300',
yellow: '#886700',
blue: '#2076b3',
magenta: '#c8337c',
cyan: '#207972',
white: '#6b6860',
brightBlack: '#677171',
brightRed: '#c14715',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3'
brightYellow: '#5b6f76',
brightBlue: '#626f71',
brightMagenta: '#6166b0',
brightCyan: '#677171',
brightWhite: '#726f66'
}
},
{
@@ -295,27 +295,27 @@ export const BUILTIN_THEMES: TerminalTheme[] = [
name: 'GitHub Light',
builtin: true,
colors: {
background: '#ffffff',
background: '#f6f8fa',
foreground: '#4d4d4c',
cursor: '#4d4d4c',
cursorAccent: '#ffffff',
cursorAccent: '#f6f8fa',
selectionBackground: '#dcdcdc',
black: '#3e3e3e',
red: '#c91b00',
green: '#00c200',
yellow: '#c7c400',
green: '#007e00',
yellow: '#777600',
blue: '#0225c7',
magenta: '#c930c7',
cyan: '#00c5c7',
white: '#c7c7c7',
magenta: '#bf2ebd',
cyan: '#007677',
white: '#6d6d6d',
brightBlack: '#676767',
brightRed: '#ff6d67',
brightGreen: '#5ff967',
brightYellow: '#ffff6c',
brightBlue: '#6871ff',
brightMagenta: '#ff76ff',
brightCyan: '#5ffdff',
brightWhite: '#ffffff'
brightRed: '#b34c48',
brightGreen: '#307d34',
brightYellow: '#737331',
brightBlue: '#5860d9',
brightMagenta: '#a64da6',
brightCyan: '#2b7273',
brightWhite: '#666666'
}
},
{
+119
View File
@@ -0,0 +1,119 @@
/**
* Fail-closed guard: no raw color literals in renderer source outside the
* whitelist. Scans .css/.tsx/.ts under src/renderer/src. Strips comments,
* var() fallback bodies (those go through the controlled FALLBACK_OK list)
* and color-mix() bodies first; lines containing '${' are skipped wholesale
* (template-literal color BUILDERS like MonitorPanel's withAlpha construct
* rgba() from variables — they contain no literal to police).
* Run: node tests/css-theme-vars.mjs (must exit 0)
*/
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; null = whole file exempt */
const WHITELIST = {
// The layer that DEFINES the anchors and derives per-theme values.
'theme/semantic.ts': null,
// Pre-existing --chrome-* derivation anchors (mix targets / overlay bases).
// #181818 = global.css --chrome-bg, the pre-JS chromeBg default.
'theme/chrome.ts': new Set([
'#000000', '#ffffff', '#181818',
'rgba(0, 0, 0, 0.045)', 'rgba(0, 0, 0, 0.09)', 'rgba(0, 0, 0, 0.18)', 'rgba(0, 0, 0, 0.32)',
'rgba(255, 255, 255, 0.04)', 'rgba(255, 255, 255, 0.07)', 'rgba(255, 255, 255, 0.1)', 'rgba(255, 255, 255, 0.2)'
]),
// Theme data: editor seeds itself from DEFAULT_DARK; preset swatches are rules.
'theme/editor/ThemeEditor.tsx': null,
'settings/HighlightTab.tsx': null,
'terminal/highlightEngine.ts': null,
// xterm search-match ITheme data + transparent-background markers.
'terminal/TerminalView.tsx': new Set(['#3b8eea', '#2d2d2d', '#c77800', '#00000000']),
// antd token fallbacks (mix() returning null on non-hex themes).
'main.tsx': new Set(['#f5f5f5', '#1f1f1f', '#252525']),
// :root static fallback block for --ot-*/--chrome-* (pre-JS = dark defaults).
'global.css': new Set([
'#181818', '#cccccc', '#2d2d2d', '#3b8eea', '#2a2a2a', '#101418', '#98a2ab', '#d0d0d0',
'#8a8a8a', '#3fb950', '#f85149', '#d29922', '#58a6ff', '#ff6b6b',
'rgba(255, 255, 255, 0.04)', 'rgba(255, 255, 255, 0.07)', 'rgba(255, 255, 255, 0.1)',
'rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0.16)',
'rgba(204, 204, 204, 0.78)', 'rgba(204, 204, 204, 0.05)',
'rgba(24, 26, 31, 0.85)', 'rgba(125, 145, 170, 0.5)', 'rgba(150, 172, 200, 0.7)',
'rgba(0, 0, 0, 0.45)', 'rgba(59, 142, 234, 0.2)'
]),
// Background-image iron laws: dark dock backing #0d1117, dim scrim #000,
// transparent xterm bg #00000000, deliberate dark dead-process scrim.
'terminal/terminal.css': new Set(['#0d1117', '#00000000', '#000', '#fff', 'rgba(0, 0, 0, 0.55)'])
}
/** values allowed inside var(x, <fallback>) — the historical literal set. */
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',
'#e8e8e8', '#10141a', '#98a2ab', '#4ad476', '#46c96a',
'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(255, 255, 255, 0.24)', 'rgba(255, 255, 255, 0.45)',
'rgba(255, 255, 255, 0.55)', 'rgba(255, 255, 255, 0.85)', 'rgba(255, 255, 255, 0.88)',
'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.4)', 'rgba(0, 0, 0, 0.45)', 'rgba(0, 0, 0, 0.55)',
'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(90, 93, 94, 0.31)'
])
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).split(path.sep).join('/')
const wl = WHITELIST[rel]
if (wl === null) continue
const raw = stripComments(fs.readFileSync(file, 'utf8'))
const outsideVar = raw.replace(/var\([^)]*\)/g, '()').replace(/color-mix\([^)]*\)/g, '()')
const lines = outsideVar.split(/\r?\n/)
lines.forEach((line, idx) => {
if (line.includes('${')) return // template-literal color builder, not a literal
for (const m of line.matchAll(COLOR)) {
const lit = m[0].toLowerCase()
if (wl && wl.has(lit)) continue
console.error(`RAW ${rel}:${idx + 1}: ${lit}`)
violations++
}
})
for (const v of raw.matchAll(/var\(([^)]*)\)/g)) {
let fb = v[1].split(',').slice(1).join(',').trim()
if (!fb || fb.startsWith('var(')) continue
// var(a, rgba(...)) — the [^)]* capture stops at rgba's ')'; restore it
if (fb.startsWith('rgb') && !fb.endsWith(')')) fb += ')'
for (const lit of fb.match(COLOR) ?? []) {
if (!FALLBACK_OK.has(lit.toLowerCase())) {
console.error(`FALLBACK ${rel}: var(${v[1]}) -> ${lit}`)
violations++
}
}
}
}
// negative self-test: the matcher must catch literals incl. 3/4-digit hex
{
const probe = stripComments('.a{color:#fff9;border:#abcd}')
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')
+86
View File
@@ -0,0 +1,86 @@
/**
* Fail-closed guard for the --ot-* semantic layer's NAMES (not values).
*
* CSS custom properties are case-sensitive, so `setProperty('--ot-dangerFg')`
* does NOT satisfy `var(--ot-danger-fg)`: the reference just falls back to the
* dark :root literal and the light theme silently loses its fix. Value tests
* (semantic-theme / theme-contrast) cannot see that, so this test locks the
* three sets together:
* 1) every var(--ot-*) referenced in renderer source is actually emitted;
* 2) the :root static fallback block declares exactly the emitted names;
* 3) both semanticVars() branches emit the same key set (a key that only
* exists on one branch leaves the other theme with a stale :root value).
* Also self-tests the kebab converter so a no-op can't make (1) vacuous.
* Run: node tests/ot-var-names.mjs (must exit 0)
*/
import fs from 'node:fs'
import path from 'node:path'
import { cssVarName, semanticCssVars } from '../src/renderer/src/theme/semantic.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const ROOT = path.resolve('src/renderer/src')
// The naming lives in semantic.ts (cssVarName) and is the SAME function
// chrome.ts calls via semanticCssVars — so a regression there fails here.
const names = (vars) => new Set(Object.keys(vars).filter((k) => k !== 'color-scheme'))
// --- self-test: the converter must actually convert ------------------------
console.log('cssVarName:')
ok(cssVarName('textMuted') === '--ot-text-muted', 'textMuted -> --ot-text-muted')
ok(cssVarName('dangerFg') === '--ot-danger-fg', 'dangerFg -> --ot-danger-fg')
ok(cssVarName('text') === '--ot-text', 'text -> --ot-text (unchanged)')
ok(cssVarName('colorScheme') === 'color-scheme', 'colorScheme -> color-scheme')
// --- what JS emits, per branch ---------------------------------------------
const darkVars = semanticCssVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
const lightVars = semanticCssVars({ background: '#f6f8fa', foreground: '#383a42' }, true)
const darkNames = names(darkVars)
const lightNames = names(lightVars)
console.log('branches emit the same key set:')
const onlyDark = [...darkNames].filter((n) => !lightNames.has(n)).sort()
const onlyLight = [...lightNames].filter((n) => !darkNames.has(n)).sort()
ok(onlyDark.length === 0, `no dark-only keys${onlyDark.length ? ` (${onlyDark.join(', ')})` : ''}`)
ok(onlyLight.length === 0, `no light-only keys${onlyLight.length ? ` (${onlyLight.join(', ')})` : ''}`)
ok(darkVars['color-scheme'] === 'dark' && lightVars['color-scheme'] === 'light', 'color-scheme switches per branch')
// --- what the CSS references ------------------------------------------------
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 referenced = new Set()
for (const file of walk(ROOT)) {
for (const m of fs.readFileSync(file, 'utf8').matchAll(/var\((--ot-[A-Za-z0-9-]+)/g)) referenced.add(m[1])
}
console.log('every referenced var is emitted:')
const dangling = [...referenced].filter((n) => !darkNames.has(n)).sort()
ok(dangling.length === 0, dangling.length ? `dangling: ${dangling.join(', ')}` : `${referenced.size} reference(s) all emitted`)
// --- the static :root block in global.css ----------------------------------
const css = fs.readFileSync(path.join(ROOT, 'global.css'), 'utf8')
const rootBlock = /:root\s*\{([\s\S]*?)\}/.exec(css)
const declared = new Set()
if (rootBlock) for (const m of rootBlock[1].matchAll(/(--ot-[A-Za-z0-9-]+)\s*:/g)) declared.add(m[1])
console.log(':root fallback block matches the emitted set (no dark-only leftovers):')
const undeclared = [...darkNames].filter((n) => !declared.has(n)).sort()
const extra = [...declared].filter((n) => !darkNames.has(n)).sort()
ok(rootBlock !== null, ':root block found in global.css')
ok(undeclared.length === 0, undeclared.length ? `emitted but not declared: ${undeclared.join(', ')}` : 'all emitted names declared')
ok(extra.length === 0, extra.length ? `declared but never emitted: ${extra.join(', ')}` : 'no extra declared names')
if (failed > 0) {
console.error(`ot-var-names: ${failed} check(s) failed`)
process.exit(1)
}
console.log('ot-var-names: all ok')
+60
View File
@@ -0,0 +1,60 @@
/**
* 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['textMuted'] === SEMANTIC.muted, 'textMuted = 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)
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 ['textMuted', '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')
+46
View File
@@ -0,0 +1,46 @@
/**
* 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')