Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
619e4ac57f | ||
|
|
2aeea53da1 | ||
|
|
81748e4470 | ||
|
|
3b86d62111 | ||
|
|
9e424138b5 | ||
|
|
5bc91fd498 | ||
|
|
beaa6f1f04 | ||
|
|
83384b7233 | ||
|
|
606ee0fa02 | ||
|
|
79bea68c13 | ||
|
|
2136221f2c | ||
|
|
0a7bdda2d3 | ||
|
|
2b658520a4 |
No files matched your search
@@ -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,打进去的是旧代码)
|
||||
@@ -59,6 +59,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- **调度器是注入的**:`applyUpdateSchedule(system)` 由 `index.ts` 经 `settingsStore.setUpdateScheduleApplier` 注入——`updater.ts` 已 import `settingsStore`,反向 import 成环。applier 幂等(先清旧 interval 再建)且每次 `mutateSettings` 都会重跑;启动检查用模块级 `startupArmed` 保证**每进程只武装一次**,否则改一次设置就多排一次启动检查
|
||||
- **自动下载**(`settings.system.autoDownloadUpdate`,默认关):`update-available` 时若开关开且无在途下载 → `handleDownload('auto')`(`UpdateState.auto` 标记)。`autoUpdater.autoDownload` **保持 false**——单一事实源在 settings,不交给 electron-updater。失败落 `status:'error'` 且**不重试**,等下一个周期
|
||||
- **安装永远交给用户**:绝不因下载完成自动退出;`autoInstallOnAppQuit = true` 只覆盖「用户自己退出时装上」
|
||||
- **差量下载已禁用**(`autoUpdater.disableDifferentialDownload = true`,configureAutoUpdater 里):electron-updater 每次 `downloadUpdate()` 先试差量(拉新旧 blockmap + 多段 Range)失败再回退全量——用户看到进度条跑两遍、最坏近乎双倍流量(v1.0.x 用户报的「下载两次」)。而本项目三个前提让它注定失败:GitHub release 资产落 Azure Blob,多段 Range 返回 `501 Unsupported client range`(已实测);国内 Gitea 通道被 `pruneChannel` 删得只剩最新版 exe/blockmap,老客户端取老 blockmap 得 404;NSIS 是 LZMA 固实压缩,版本间小改动让后续压缩数据整体漂移,差量区间≈整个文件。所以直接全量是唯一正确行为
|
||||
- **同版本抑制**(`actedRelease` 单槽记录,`actedFor(version)` 版本不同即整体替换):`announced`(发现气泡)、`fetchHandled`(已发起下载)、`downloaded`(包已落盘)、`downloadedAnnounced`(已下载气泡)——否则长开机的应用每 4 小时重复弹气泡/重复走一遍 `downloadUpdate`。`runCheck('manual')` **无条件清空**记录(手动是明确意图,必须如实反映);`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled`
|
||||
- `downloaded` 与 `fetchHandled` **必须分开**:前者只在 `update-downloaded` 置位,用它判断「已完成」;拿 `fetchHandled` 判断会把下载中的版本误跳过。`update-available` 里若该版本 `downloaded` 为真,要 `setState` 一个等价的 `downloaded` 状态(**不能**直接 return 不写——`performCheck` 已把状态置成 `checking`,早退会让「关于」页永远转圈),这样按钮不会从「重启安装」退回「下载」
|
||||
- **通知**:`tray.ts` 的 `notifyUpdate(title, content)` 弹 Windows 托盘气泡;非 win32 / tray 为空 / **有可见窗口**(用户在看「关于」页)都不弹。自动下载开启时不弹「发现新版本」(有意义的是「已下载」那条),手动下载完成也不弹
|
||||
@@ -70,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 不渲染该层,零成本**
|
||||
|
||||
## 终端尺寸同步
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# 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.
|
||||
|
||||
### Fixed: update downloads twice
|
||||
- The root cause: the updater tried a "differential download" (fetching only the changed chunks) on every download and fell back to a full re-download when it failed — and neither of our update sources can serve differential downloads, so every update ran as a differential attempt followed by a full package
|
||||
- Differential download is now disabled: **every update is exactly one full download**, and the progress bar runs through once
|
||||
- Note: users still on an older version will see the double download one last time while updating TO this version (the fix has to reach them first); once this version is installed, every future update downloads once
|
||||
|
||||
## v1.0.27 - 2026-10-15
|
||||
|
||||
This release adds close confirmation for terminal tabs: an accidental close no longer kills a terminal or SSH session you are working in — especially the frequent case of pressing Backspace right after clicking a tab.
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# 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 周し、最悪の場合ほぼ倍の通信量が発生していました。
|
||||
|
||||
### 修正:アップデートが 2 回ダウンロードされる
|
||||
- 根本原因は、アップデーターが毎回「差分ダウンロード」(変更されたチャンクのみ取得)を試み、失敗するとパッケージ全体を再ダウンロードすることでした——本プロジェクトの 2 つの更新ソースはいずれも差分ダウンロードの条件を満たさないため、毎回「差分試行 + 全パッケージ」の 2 段階になっていました
|
||||
- 差分ダウンロードを無効化し、**毎回ちょうど 1 回のフルダウンロード**になり、プログレスバーは 1 周だけです
|
||||
- 注意:旧バージョンのユーザーが本バージョンへ更新する際は、最後にもう一度だけ 2 回ダウンロードが発生します(修正コードが先に手元へ届く必要があるため)。本バージョン導入後の更新はすべて 1 回のダウンロードになります
|
||||
|
||||
## v1.0.27 - 2026-10-15
|
||||
|
||||
本リリースではタブを閉じる前の確認機能を追加しました。うっかり閉じて作業中のターミナルや SSH セッションを終了させてしまう——特にタブをクリックした直後に Backspace を押してタブごと閉じてしまう操作——を防ぎます。
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# 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
|
||||
|
||||
本次修复应用内更新「下载两次」的问题:从旧版本更新时进度条会跑两遍,最坏情况消耗近双倍流量。
|
||||
|
||||
### 修复:更新下载两次
|
||||
- 根因是更新组件每次下载都先尝试「差量下载」(只取变化的数据块),失败后回退整包重下——而本项目的两个更新源都不具备差量条件,导致每次更新都是"差量尝试 + 整包"两段式
|
||||
- 现已禁用差量下载,**每次更新恰好一次整包下载**,进度条只走一遍
|
||||
- 注意:还在旧版本上的用户,更新到本版本时会经历**最后一次**下载两次(修复代码要先到达用户手里);装上本版本后,之后的每次更新都是单次下载
|
||||
|
||||
## v1.0.27 - 2026-10-15
|
||||
|
||||
本次新增标签页关闭二次确认:误触关闭不再直接杀掉正在工作的终端或 SSH 会话,尤其是点击标签后顺手按 Backspace 把整个 tab 关掉的高频误操作。
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# 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
|
||||
|
||||
本次修復應用內更新「下載兩次」的問題:從舊版本更新時進度條會跑兩遍,最壞情況消耗近雙倍流量。
|
||||
|
||||
### 修復:更新下載兩次
|
||||
- 根因是更新元件每次下載都先嘗試「差量下載」(只取變化的資料塊),失敗後回退整包重下——而本專案的兩個更新來源都不具備差量條件,導致每次更新都是「差量嘗試 + 整包」兩段式
|
||||
- 現已停用差量下載,**每次更新恰好一次整包下載**,進度條只走一遍
|
||||
- 注意:還在舊版本上的使用者,更新到此版本時會經歷**最後一次**下載兩次(修復程式要先到達使用者手裡);裝上此版本後,之後的每次更新都是單次下載
|
||||
|
||||
## v1.0.27 - 2026-10-15
|
||||
|
||||
本次新增分頁關閉二次確認:誤觸關閉不再直接殺掉正在工作的終端機或 SSH 連線,尤其是點擊分頁後順手按 Backspace 把整個分頁關掉的高頻誤操作。
|
||||
|
||||
@@ -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 手工清单 |
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.29",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.27",
|
||||
"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"
|
||||
|
||||
@@ -499,6 +499,21 @@ export function configureAutoUpdater(): void {
|
||||
// ever installs on its own — autoInstallOnAppQuit only covers quitting.
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
// Differential download is off because it cannot succeed against either of
|
||||
// our feeds, and every failed attempt costs the user a second full transfer:
|
||||
// electron-updater's differential path fetches the old and new blockmaps and
|
||||
// then multi-range-requests the changed chunks, falling back to a plain whole-
|
||||
// file download when any of that fails. GitHub serves the release assets from
|
||||
// Azure Blob, which answers multi-range requests with `501 Unsupported client
|
||||
// range` (measured), and the Gitea channel keeps only the newest exe + blockmap
|
||||
// (`pruneChannel` deletes the previous release's), so an older client's
|
||||
// blockmap 404s. Even where both were available the NSIS payload is LZMA solid
|
||||
// compression: a small version-to-version change shifts everything after it,
|
||||
// making the differential range nearly the whole file. The user then watches
|
||||
// the progress bar run twice — worst case for roughly double the traffic — so
|
||||
// going straight to the full download is the only behavior that is actually
|
||||
// correct here.
|
||||
autoUpdater.disableDifferentialDownload = true
|
||||
|
||||
// The startup check and the periodic one are armed by applyUpdateSchedule
|
||||
// (wired in index.ts through settingsStore's injected applier) instead of
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -439,6 +439,13 @@ console.log('updater events drive the state the renderer reads')
|
||||
ok(live.logger === console, 'the updater logs through console')
|
||||
ok(live.autoDownload === false, 'auto-download is left to the user (the UI offers the button)')
|
||||
ok(live.autoInstallOnAppQuit === true, 'a downloaded update installs on app quit')
|
||||
// Differential download is disabled: it cannot work against either feed (GitHub
|
||||
// assets reject multi-range requests, the Gitea channel keeps no old blockmap),
|
||||
// and each failure costs a second full transfer.
|
||||
ok(
|
||||
live.disableDifferentialDownload === true,
|
||||
'differential download is off (a failed attempt would download the package twice)'
|
||||
)
|
||||
|
||||
const before = await call(Ipc.UPDATE_STATE_GET)
|
||||
ok(before !== undefined && typeof before.currentVersion === 'string', 'UPDATE_STATE_GET returns the current state')
|
||||
|
||||
+40
-52
@@ -155,6 +155,16 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': '近期更新',
|
||||
|
||||
'changelog.1.0.28.li.1': '修复应用内更新会下载两次的问题:禁用注定失败的差量下载,每次更新恰好一次整包下载,进度条只走一遍',
|
||||
|
||||
'changelog.1.0.27.li.1': '新增关闭标签二次确认:终端 / SSH 两个独立开关(默认关闭),设置 → 渲染与行为',
|
||||
'changelog.1.0.27.li.2': '覆盖标签 × 按钮、右键「关闭 / 关闭其他 / 关闭右侧」与键盘 Delete / Backspace 误关——点击标签后按 Backspace 不再直接关闭',
|
||||
'changelog.1.0.27.li.3': '批量关闭整批只确认一次;开关保存后立即生效,无需重启',
|
||||
|
||||
'changelog.1.0.26.li.1': '修复拖动缩放窗口或终端高频输出时界面冻结数秒(网格重排节流 + 输出批量写入)',
|
||||
'changelog.1.0.26.li.2': '新增落盘诊断日志:默认开启,按天一个文件保留 7 天,设置 → 系统可关闭',
|
||||
'changelog.1.0.26.li.3': '反馈卡顿问题时附上当天的日志文件即可精确定位',
|
||||
|
||||
'changelog.1.0.25.li.1': '定时检查更新(从不 / 4 / 12 / 24 小时,默认 4)',
|
||||
'changelog.1.0.25.li.2': '自动下载更新开关(后台静默下载,安装时机仍由用户决定,绝不自动退出)',
|
||||
'changelog.1.0.25.li.3': '下载完成 / 发现新版本时窗口最小化到托盘则弹提示',
|
||||
@@ -165,19 +175,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.24.li.3': '会话日志退出不再丢尾部',
|
||||
'changelog.1.0.24.li.4': 'SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试',
|
||||
|
||||
'changelog.1.0.23.li.1': 'SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序',
|
||||
'changelog.1.0.23.li.2': '字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置',
|
||||
'changelog.1.0.23.li.3': '用户 / 组列显示真实名称,不再只有数字 UID / GID',
|
||||
'changelog.1.0.23.li.4': '列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题',
|
||||
|
||||
'changelog.1.0.22.li.1': '修复中文输入法候选窗不跟随光标(v1.0.21 回归)',
|
||||
'changelog.1.0.22.li.2': '不再提供 MSI 安装包,统一使用 NSIS exe',
|
||||
'changelog.1.0.22.li.3': '打包流程新增依赖一致性校验,防止旧依赖混入安装包',
|
||||
|
||||
'changelog.1.0.21.li.1': '背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆',
|
||||
'changelog.1.0.21.li.2': '性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界',
|
||||
'changelog.1.0.21.li.3': '安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固',
|
||||
|
||||
'cl.morePre': '完整日志见仓库',
|
||||
'cl.morePost': '。',
|
||||
|
||||
@@ -332,6 +329,16 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': '近期更新',
|
||||
|
||||
'changelog.1.0.28.li.1': '修復應用內更新會下載兩次的問題:停用注定失敗的差量下載,每次更新恰好一次整包下載,進度條只走一遍',
|
||||
|
||||
'changelog.1.0.27.li.1': '新增關閉分頁二次確認:終端機 / SSH 兩個獨立開關(預設關閉),設定 → 渲染與行為',
|
||||
'changelog.1.0.27.li.2': '涵蓋分頁 × 按鈕、右鍵「關閉 / 關閉其他 / 關閉右側」與鍵盤 Delete / Backspace 誤關——點擊分頁後按 Backspace 不再直接關閉',
|
||||
'changelog.1.0.27.li.3': '批量關閉整批只確認一次;開關儲存後立即生效,無需重啟',
|
||||
|
||||
'changelog.1.0.26.li.1': '修復拖動縮放視窗或終端機高頻輸出時介面凍結數秒(網格重排節流 + 輸出批量寫入)',
|
||||
'changelog.1.0.26.li.2': '新增落盤診斷日誌:預設開啟,按天一個檔案保留 7 天,設定 → 系統可關閉',
|
||||
'changelog.1.0.26.li.3': '回報卡頓問題時附上當天的日誌檔案即可精確定位',
|
||||
|
||||
'changelog.1.0.25.li.1': '定時檢查更新(從不 / 4 / 12 / 24 小時,預設 4)',
|
||||
'changelog.1.0.25.li.2': '自動下載更新開關(背景靜默下載,安裝時機仍由使用者決定,絕不自動結束)',
|
||||
'changelog.1.0.25.li.3': '下載完成 / 發現新版本時,視窗若最小化到系統匣會彈出提示',
|
||||
@@ -342,19 +349,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.24.li.3': '工作階段日誌結束時不再遺失尾段',
|
||||
'changelog.1.0.24.li.4': 'SFTP 刪除不再誤報失敗;ZMODEM 接收大檔案的記憶體用量可控;新增 3 組渲染層測試',
|
||||
|
||||
'changelog.1.0.23.li.1': 'SFTP 檔案面板改為真正的表格:六欄對齊、表頭固定、點擊表頭排序',
|
||||
'changelog.1.0.23.li.2': '字型可縮放 0.8–1.8 倍(A- / A+ 或 Ctrl+滾輪),行高連動並記憶設定',
|
||||
'changelog.1.0.23.li.3': '使用者 / 群組欄顯示真實名稱,不再只有數字 UID / GID',
|
||||
'changelog.1.0.23.li.4': '清單空白處按右鍵即可喚出檔案選單;修復六欄先前未真正依格線對齊的問題',
|
||||
|
||||
'changelog.1.0.22.li.1': '修復中文輸入法候選窗不跟隨游標(v1.0.21 回歸)',
|
||||
'changelog.1.0.22.li.2': '不再提供 MSI 安裝程式,統一使用 NSIS exe',
|
||||
'changelog.1.0.22.li.3': '打包流程新增相依性一致性校驗,防止舊相依性混入安裝程式',
|
||||
|
||||
'changelog.1.0.21.li.1': '背景圖片體驗:任何主題下調低不透明度都是壓暗、文字自動提亮至 WCAG AA、新增背景壓暗滑桿',
|
||||
'changelog.1.0.21.li.2': '效能:設定熱更新不再觸發全部窗格重排,SFTP 大目錄虛擬捲動,面板獨立錯誤邊界',
|
||||
'changelog.1.0.21.li.3': '安裝程式精簡約 90MB;SFTP/ZMODEM 本機路徑准入與多處安全加固',
|
||||
|
||||
'cl.morePre': '完整日誌請見倉庫',
|
||||
'cl.morePost': '。',
|
||||
|
||||
@@ -509,6 +503,16 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': 'Recent updates',
|
||||
|
||||
'changelog.1.0.28.li.1': 'Fixed in-app updates downloading twice: differential download (which could never succeed) is disabled, so every update is exactly one full download with a single progress pass',
|
||||
|
||||
'changelog.1.0.27.li.1': 'Added close-confirmation for tabs: separate switches for terminal and SSH tabs (off by default), under Settings → Rendering & Behavior',
|
||||
'changelog.1.0.27.li.2': 'Covers the tab × button, the context menu\'s Close / Close others / Close right, and accidental Delete / Backspace — pressing Backspace after clicking a tab no longer closes it',
|
||||
'changelog.1.0.27.li.3': 'Batch closes ask once for the whole batch; switches take effect immediately, no restart needed',
|
||||
|
||||
'changelog.1.0.26.li.1': 'Fixed the UI freezing for seconds while resizing the window or under high-frequency terminal output (reflow throttling + batched writes)',
|
||||
'changelog.1.0.26.li.2': 'Added on-disk diagnostic logs: on by default, one file per day kept for 7 days, can be disabled in Settings → System',
|
||||
'changelog.1.0.26.li.3': 'Attach the day\'s log file when reporting freezes for precise diagnosis',
|
||||
|
||||
'changelog.1.0.25.li.1': 'Scheduled update checks (never / 4 / 12 / 24 hours, default 4)',
|
||||
'changelog.1.0.25.li.2': 'Auto-download toggle: silent background download, install timing still up to you, never quits on its own',
|
||||
'changelog.1.0.25.li.3': 'Tray notification when a new version is found or a download finishes while the window is minimized',
|
||||
@@ -519,19 +523,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.24.li.3': 'Session logs no longer lose their tail when the app exits',
|
||||
'changelog.1.0.24.li.4': 'SFTP delete no longer reports false failures; ZMODEM downloads of large files keep memory bounded; 3 new renderer test suites',
|
||||
|
||||
'changelog.1.0.23.li.1': 'SFTP file panel is now a real table: six aligned columns, sticky header, click-to-sort',
|
||||
'changelog.1.0.23.li.2': 'Font scaling from 0.8x to 1.8x (A- / A+ or Ctrl+wheel); row height follows and the setting is remembered',
|
||||
'changelog.1.0.23.li.3': 'User / group columns show real names instead of bare UID / GID numbers',
|
||||
'changelog.1.0.23.li.4': 'Right-click empty list space for the file menu; fixed columns that were not actually aligned to the grid',
|
||||
|
||||
'changelog.1.0.22.li.1': 'Fixed the IME candidate window not following the cursor (regression in v1.0.21)',
|
||||
'changelog.1.0.22.li.2': 'MSI installer dropped; NSIS exe is now the only package',
|
||||
'changelog.1.0.22.li.3': 'Build pipeline now verifies dependency consistency to keep stale dependencies out of the installer',
|
||||
|
||||
'changelog.1.0.21.li.1': 'Background images: lowering opacity now always dims the image in any theme, text is auto-brightened to WCAG AA, and a new dim slider was added',
|
||||
'changelog.1.0.21.li.2': 'Performance: settings hot-updates no longer reflow every pane, SFTP virtual scrolling for large directories, per-pane error boundaries',
|
||||
'changelog.1.0.21.li.3': 'Installer slimmed by about 90MB; local path admission for SFTP / ZMODEM plus several security hardening fixes',
|
||||
|
||||
'cl.morePre': 'Full changelog in',
|
||||
'cl.morePost': '.',
|
||||
|
||||
@@ -686,6 +677,16 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': '最近の更新',
|
||||
|
||||
'changelog.1.0.28.li.1': 'アプリ内アップデートが 2 回ダウンロードされる問題を修正:成功しない差分ダウンロードを無効化し、毎回ちょうど 1 回のフルダウンロードに',
|
||||
|
||||
'changelog.1.0.27.li.1': 'タブを閉じる前の確認機能を追加:ターミナル / SSH それぞれ個別のスイッチ(既定はオフ)、設定 → 描画と動作',
|
||||
'changelog.1.0.27.li.2': 'タブの × ボタン、右クリックメニューの「閉じる / ほかを閉じる / 右側を閉じる」、および Delete / Backspace による誤クローズに対応——タブクリック後の Backspace で閉じなくなります',
|
||||
'changelog.1.0.27.li.3': 'バッチクローズは全体で 1 回だけ確認;スイッチは保存後すぐに反映され、再起動不要',
|
||||
|
||||
'changelog.1.0.26.li.1': 'ウィンドウのリサイズや高頻度ターミナル出力時に UI が数秒固まる問題を修正(リフローのスロットリング + 書き込みバッチ化)',
|
||||
'changelog.1.0.26.li.2': '診断ログを追加:既定で有効、日ごとに 1 ファイルを 7 日間保持、設定 → システムで無効化可能',
|
||||
'changelog.1.0.26.li.3': '固まりの報告時に当日のログファイルを添付すれば正確に特定できます',
|
||||
|
||||
'changelog.1.0.25.li.1': '定期的な更新チェック(しない / 4 / 12 / 24 時間、既定は 4 時間)',
|
||||
'changelog.1.0.25.li.2': '自動ダウンロードのオン/オフ(バックグラウンドで静かにダウンロード。インストールのタイミングは従来どおりユーザーが決定し、自動で終了することはありません)',
|
||||
'changelog.1.0.25.li.3': '新バージョンの検出時やダウンロード完了時に、ウィンドウがトレイに最小化されていれば通知を表示',
|
||||
@@ -696,19 +697,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.24.li.3': '終了時にセッションログの末尾が欠けなくなりました',
|
||||
'changelog.1.0.24.li.4': 'SFTP の削除が誤って失敗扱いにならなくなりました。ZMODEM の大容量受信もメモリ使用量を抑制。レンダラー層のテストを 3 組追加',
|
||||
|
||||
'changelog.1.0.23.li.1': 'SFTP ファイルパネルを本格的なテーブルに変更:6 列の整列、ヘッダーの固定表示、ヘッダークリックで並べ替え',
|
||||
'changelog.1.0.23.li.2': '文字サイズを 0.8〜1.8 倍に変更可能(A- / A+ または Ctrl+ホイール)。行の高さも連動し、設定は記憶されます',
|
||||
'changelog.1.0.23.li.3': 'ユーザー / グループ列に実際の名前を表示。数字だけの UID / GID ではなくなりました',
|
||||
'changelog.1.0.23.li.4': '一覧の空白部分を右クリックでファイルメニューを表示。6 列が実際にはグリッドに揃っていなかった問題を修正',
|
||||
|
||||
'changelog.1.0.22.li.1': 'IME の変換候補ウィンドウがカーソルに追従しない問題を修正(v1.0.21 のリグレッション)',
|
||||
'changelog.1.0.22.li.2': 'MSI インストーラーの提供を終了し、NSIS exe に統一',
|
||||
'changelog.1.0.22.li.3': 'ビルド手順に依存関係の整合性チェックを追加し、古い依存関係が混入しないようにしました',
|
||||
|
||||
'changelog.1.0.21.li.1': '背景画像の扱いを改善。どのテーマでも不透明度を下げると画像が暗くなり、文字は WCAG AA まで自動的に明るく調整。減光スライダーも追加',
|
||||
'changelog.1.0.21.li.2': 'パフォーマンス:設定のホットアップデートで全ペインが再レイアウトされなくなり、SFTP の大規模ディレクトリは仮想スクロール、ペインごとにエラーバウンダリを追加',
|
||||
'changelog.1.0.21.li.3': 'インストーラーを約 90MB 削減。SFTP / ZMODEM のローカルパス検証と複数のセキュリティ強化',
|
||||
|
||||
'cl.morePre': '完全な変更履歴は',
|
||||
'cl.morePost': ' をご覧ください。',
|
||||
|
||||
|
||||
+27
-30
@@ -118,14 +118,14 @@
|
||||
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.25/OpenTerminal-1.0.25-setup.exe" data-i18n="hero.download">
|
||||
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.28/OpenTerminal-1.0.28-setup.exe" data-i18n="hero.download">
|
||||
下载 Windows 安装包
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener" data-i18n="hero.source">
|
||||
查看源码
|
||||
</a>
|
||||
</div>
|
||||
<p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.25</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p>
|
||||
<p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.28</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p>
|
||||
</div>
|
||||
<figure class="hero-shot">
|
||||
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" data-i18n-attr="alt:hero.shot.alt" />
|
||||
@@ -133,7 +133,7 @@
|
||||
</figure>
|
||||
</div>
|
||||
<div class="container spec-strip">
|
||||
<span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.25</strong></span>
|
||||
<span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.28</strong></span>
|
||||
<span data-i18n="spec.os">Windows 10 / 11 · x64</span>
|
||||
<span data-i18n="spec.license">MIT 开源协议</span>
|
||||
<span data-i18n="spec.update">exe 支持应用内自动更新</span>
|
||||
@@ -341,11 +341,11 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mono">OpenTerminal-1.0.25-setup.exe</td>
|
||||
<td class="mono">OpenTerminal-1.0.28-setup.exe</td>
|
||||
<td data-i18n="dl.table.exe">NSIS 安装包,支持应用内自动更新(推荐)</td>
|
||||
<td class="mono">109 MB</td>
|
||||
<td>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.25/OpenTerminal-1.0.25-setup.exe" data-i18n="dl.table.download">下载</a>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.28/OpenTerminal-1.0.28-setup.exe" data-i18n="dl.table.download">下载</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -375,6 +375,28 @@
|
||||
<p class="section-eyebrow" data-i18n="cl.eyebrow">Changelog</p>
|
||||
<h2 data-i18n="cl.title">近期更新</h2>
|
||||
<div class="changelog-list">
|
||||
<article>
|
||||
<h3><span class="mono">1.0.28</span><span class="rel-date">2026-10-16</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.28.li.1">修复应用内更新会下载两次的问题:禁用注定失败的差量下载,每次更新恰好一次整包下载,进度条只走一遍</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.27</span><span class="rel-date">2026-10-15</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.27.li.1">新增关闭标签二次确认:终端 / SSH 两个独立开关(默认关闭),设置 → 渲染与行为</li>
|
||||
<li data-i18n="changelog.1.0.27.li.2">覆盖标签 × 按钮、右键「关闭 / 关闭其他 / 关闭右侧」与键盘 Delete / Backspace 误关——点击标签后按 Backspace 不再直接关闭</li>
|
||||
<li data-i18n="changelog.1.0.27.li.3">批量关闭整批只确认一次;开关保存后立即生效,无需重启</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.26</span><span class="rel-date">2026-10-11</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.26.li.1">修复拖动缩放窗口或终端高频输出时界面冻结数秒(网格重排节流 + 输出批量写入)</li>
|
||||
<li data-i18n="changelog.1.0.26.li.2">新增落盘诊断日志:默认开启,按天一个文件保留 7 天,设置 → 系统可关闭</li>
|
||||
<li data-i18n="changelog.1.0.26.li.3">反馈卡顿问题时附上当天的日志文件即可精确定位</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.25</span><span class="rel-date">2026-10-10</span></h3>
|
||||
<ul>
|
||||
@@ -393,31 +415,6 @@
|
||||
<li data-i18n="changelog.1.0.24.li.4">SFTP 删除不再误报失败;ZMODEM 接收大文件内存可控;新增 3 组渲染层测试</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.23</span><span class="rel-date">2026-10-09</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.23.li.1">SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序</li>
|
||||
<li data-i18n="changelog.1.0.23.li.2">字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置</li>
|
||||
<li data-i18n="changelog.1.0.23.li.3">用户 / 组列显示真实名称,不再只有数字 UID / GID</li>
|
||||
<li data-i18n="changelog.1.0.23.li.4">列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.22</span><span class="rel-date">2026-10-08</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.22.li.1">修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
|
||||
<li data-i18n="changelog.1.0.22.li.2">不再提供 MSI 安装包,统一使用 NSIS exe</li>
|
||||
<li data-i18n="changelog.1.0.22.li.3">打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.21</span><span class="rel-date">2026-10-07</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.21.li.1">背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
|
||||
<li data-i18n="changelog.1.0.21.li.2">性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界</li>
|
||||
<li data-i18n="changelog.1.0.21.li.3">安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<p class="changelog-more">
|
||||
<span data-i18n="cl.morePre">完整日志见仓库</span>
|
||||
|
||||
Reference in new issue
Block a user