test(theme): 颜色字面量 fail-closed 守门(css-theme-vars)+ 基线清零;AGENTS/STATE 同步语义层与浅色主题铁律
This commit is contained in:
1 parent
beaa6f1f04
commit
5bc91fd498
4 files changed
+123
-2
No files matched your search
@@ -70,7 +70,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 的做法)
|
||||
- **浅色主题可读性**:内置浅色主题 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 不渲染该层,零成本**
|
||||
|
||||
## 终端尺寸同步
|
||||
|
||||
@@ -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` fail-closed 守门 | ✅(2026-10-16,设计/计划见 `docs/superpowers/`;测试 22→25) |
|
||||
|
||||
## 锁屏实现要点(v1.0.17+,已落地)
|
||||
|
||||
|
||||
+1
-1
@@ -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 && node tests/semantic-theme.mjs && node tests/theme-contrast.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",
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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).
|
||||
'theme/chrome.ts': new Set([
|
||||
'#000000', '#ffffff',
|
||||
'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')
|
||||
Reference in new issue
Block a user