fix(theme): --ot-* 变量名 camelCase/kebab-case 不匹配导致浅色分支静默失效
CSS 自定义属性名大小写敏感,而 semanticVars() 的键是 camelCase、样式表读的是
kebab-case:chrome.ts 直接写 `--ot-${key}` 时,9 个多词变量(--ot-text-muted、
--ot-text-secondary、--ot-surface-raised、--ot-border-strong、--ot-success-fg、
--ot-danger-fg、--ot-warning-fg、--ot-info-fg、--ot-scrollbar-active、
--ot-focus-ring)从未真正生效,元素一律回落到 global.css :root 的深色兜底值——
浅色主题正是靠这些变量修可读性的,等于白改(--ot-text-muted 48 处引用仍是
#8a8a8a,白底 3.25:1;--ot-danger-fg 仍是 #f85149,3.15:1)。
- 键 `muted` 改名 `textMuted`(CSS 读的是 --ot-text-muted)
- 新增 semantic.ts 的 cssVarName()/semanticCssVars(),chrome.ts 只调用它们,
命名逻辑单一来源
- 新增 tests/ot-var-names.mjs:把「CSS 引用名 = JS 写入名 = :root 声明名」
三集合锁在一起(值测试看不出这类问题),已用「去掉转换」验证会失败
- AGENTS/STATE 同步该铁律;测试数 22/25 的陈旧计数修正为实际的 29
This commit is contained in:
1 parent
9e424138b5
commit
3b86d62111
7 files changed
+120
-17
No files matched your search
@@ -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')
|
||||
@@ -23,7 +23,7 @@ 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['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')
|
||||
@@ -44,7 +44,7 @@ for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) {
|
||||
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']) {
|
||||
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']) {
|
||||
|
||||
Reference in new issue
Block a user