Files
hermes-ui/tests/client/style-tokens.test.ts
T
toor 5e1003ceb7
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
replace the monochrome theme with the CYBER neon-cyan skin
The project shipped a black-and-white theme that was only half converted:
the login page looked right, the message page was halfway, and the
settings page had not been started. This finishes the move to the CYBER
reference in prototype_reference/.

Colors now come from a single token source (styles/variables.scss), where
every semantic color is declared twice: once as OKLCH for the CSS, and
once as an RGB triple so translucent variants can be built as
rgba(var(--*-rgb), alpha). Cyan is the system itself, amber means the user
has to intervene, green means the machine is working, and red only ever
means something broke. The RGB triples are derived from the OKLCH values
so the two declarations cannot drift apart.

Pages adopt the .cy-* shell classes from the reference (view, panel,
kpis, tabs, grid, table, field) instead of ad-hoc markup, which is what
makes 19 of 26 views consistent. The remaining seven are deliberately
bespoke: chat streams, the kanban board, the workflow canvas, the
terminal, and the missing-agent screen.

The chart palette and the kanban column colors move into single-source
modules so they cannot drift either. A new guard test rejects raw hex
colors, named colors, and literal RGB triples anywhere in the client
source, with an explicit exemption list for the places that genuinely
cannot read a CSS variable. The existing chat semantics spec keeps the
chat chain honest.

No behavior changes: no logic, no i18n keys, no data structures, and the
/chat-run event contract is untouched.
2026-10-20 11:17:46 +08:00

197 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
/**
* CYBER 主题的裸色值护栏(docs/cyber-theme-plan.md §P4)。
*
* 全项目只允许一处写死颜色:图表色板(docs/ui-baseline.md §7)。
* 其余任何地方出现裸 hex / 具名颜色 / 字面量 rgba(0,0,0,...) 都算回退到旧主题,
* 一律拦在测试里,而不是等到肉眼看出色差。
*
* 豁免名单按「为什么能写死」逐条列在 EXEMPT,新增豁免必须同时补理由。
*/
const SRC_ROOT = 'packages/client/src'
/** 豁免:路径 → 理由。路径用正则匹配,新增必须写明为什么不能走令牌。 */
const EXEMPT: Array<{ pattern: RegExp; reason: string }> = [
// docs/ui-baseline.md §7 唯一授权的硬编码处
{ pattern: /styles[/\\]chart-colors\.scss$/, reason: '图表色板单一来源(§7 豁免)' },
{ pattern: /styles[/\\]chart-palette\.ts$/, reason: '图表色板单一来源(§7 豁免)' },
// xterm 的 theme 是 JS 对象,必须给字面量;令牌值已按 OKLCH 同源反算并逐行注释
{ pattern: /utils[/\\]hermes[/\\]terminal-theme\.ts$/, reason: 'xterm 主题预设需字面量,值与令牌同源' },
// Arco ConfigProvider 吃的是十六进制字符串,拿不到 CSS 变量
{ pattern: /styles[/\\]arco-theme\.ts$/, reason: 'Arco ConfigProvider 只接受十六进制' },
// 红线:vendor 只做装饰位面,pointer-events: none,不承载信息
{ pattern: /vendor[/\\]/, reason: 'arkscore vendor 红线:装饰位面,只读不改' },
// 语言徽标是品牌识别色(JS 黄 / Python 蓝 / HTML 橙…),换色会认不出语言
{ pattern: /chat[/\\]MessageItem\.vue$/, reason: '语言徽标为品牌识别色' },
]
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/
const NAMED_COLOR_RE =
/(?:^|[\s;{(,])(?:background(?:-color)?|color|border(?:-color)?|fill|stroke|outline|box-shadow|text-decoration-color|accent-color)\s*:\s*(?:!important\s*)?(white|black|red|blue|green|yellow|orange|purple|(?:dark|light)?(?:gray|grey)|pink|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver|gold|brown|coral|salmon|khaki|violet|indigo|crimson|tomato|wheat|tan|beige|ivory|linen|plum|orchid|skyblue|steelblue|slateblue|royalblue|dodgerblue|seagreen|forestgreen|darkgreen|darkblue|darkred|darkorange|darkkhaki|lightblue|lightgreen|lightyellow|lightpink|whitesmoke|ghostwhite|floralwhite|antiquewhite|blanchedalmond|blueviolet|mediumblue|mediumseagreen|mediumslateblue|mediumvioletred|palegreen|paleturquoise|peachpuff|powderblue|rosybrown|sandybrown|thistle|turquoise|azure|ivory|khaki|lavender|linen|mistyrose|moccasin|navajowhite|oldlace|papayawhip|peru|pink|plum|powderblue|red|salmon|sandybrown|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen)\s*[;}!]/i
const LITERAL_BLACK_RGBA_RE = /rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,/i
/**
* 扫描前先剥掉注释与 HTML 实体:
* - 注释里写「之前是 #b42318」是历史说明,不是样式;
* - `&#9662;` 是 HTML 实体(▽),不是颜色。
* 只做这两类预处理,不做其它"聪明"过滤,否则护栏会漏掉真问题。
*
* 逐行处理并保留行号,报错时能直接定位到行。
*/
function stripNonCode(source: string): string {
let inBlockComment = false
return source
.split(/\r?\n/)
.map(raw => {
let line = raw
// 块注释可能跨行:先判断这一行是否在注释里
if (inBlockComment) {
const end = line.indexOf('*/')
if (end === -1) return ' '.repeat(line.length)
line = ' '.repeat(end + 2) + line.slice(end + 2)
}
const blockStart = line.indexOf('/*')
if (blockStart !== -1) {
const end = line.indexOf('*/', blockStart + 2)
if (end === -1) {
line = line.slice(0, blockStart)
inBlockComment = true
} else {
line = line.slice(0, blockStart) + ' '.repeat(end - blockStart + 2) + line.slice(end + 2)
}
}
// 行注释:// 到行尾。但 http:// 这类 URL 不算,:// 前面不是行首空白时保留。
const lineComment = line.search(/(?:^|\s)\/\//)
if (lineComment !== -1) {
const keep = lineComment + (line[lineComment] === ' ' ? 1 : 0)
line = line.slice(0, keep)
}
// HTML 实体:&#9662; 是 ▽ 不是颜色
line = line.replace(/&#[0-9a-fA-F]+;/g, ' ')
return line
})
.join('\n')
}
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name)
if (statSync(full).isDirectory()) {
walk(full, out)
} else if (/\.(vue|scss|css|ts)$/.test(name)) {
out.push(full)
}
}
return out
}
const isExempt = (path: string) => EXEMPT.some(({ pattern }) => pattern.test(path))
describe('CYBER 裸色值护栏', () => {
const files = walk(SRC_ROOT)
.map(p => p.replace(/\\/g, '/'))
.filter(p => !isExempt(p))
/** 找出所有命中正则的行,统一格式化成「路径 + 行号 + 内容」。 */
const scan = (re: RegExp) =>
files.flatMap(path => {
const source = stripNonCode(readFileSync(path, 'utf8'))
const hits = source
.split('\n')
.map((line, i) => ({ line: line.trim(), no: i + 1 }))
.filter(({ line }) => re.test(line))
return hits.length ? [`${path}\n ${hits.map(h => `${h.no}: ${h.line}`).join('\n ')}`] : []
})
it('扫得到源文件(护栏本身没失效)', () => {
expect(files.length).toBeGreaterThan(100)
})
it('除图表色板外没有裸 hex', () => {
expect(scan(HEX_RE), '裸 hex 必须换成令牌(variables.scss)').toEqual([])
})
it('没有具名颜色关键字', () => {
expect(scan(NAMED_COLOR_RE), '具名颜色必须换成令牌').toEqual([])
})
it('没有字面量 rgba(0,0,0) 阴影', () => {
expect(scan(LITERAL_BLACK_RGBA_RE), '黑色阴影要走 --shadow-* 令牌').toEqual([])
})
it('半透明一律用 RGB 三元组,不写死数值', () => {
// rgba(15, 23, 42, ...) 这类:数字三元组是旧主题残留,必须换成
// rgba(var(--*-rgb), α)。豁免名单里的文件已在上面几条单独覆盖。
const literalRgba = /rgba?\(\s*\d{1,3}\s*,/
expect(scan(literalRgba), 'rgba 数字三元组要换成 var(--*-rgb) 三元组').toEqual([])
})
it('护栏本身能抓到真问题(自检)', () => {
// 剥注释这一步最容易悄悄失效:改坏了护栏还会一直"通过"。
// 这里用已知样本验证检测逻辑本身。
const dirty = [
'color: #ff0000;', // 裸 hex
'background: rgba(1, 2, 3, 0.5);', // 字面量三元组
'box-shadow: rgba(0, 0, 0, 0.4);', // 字面量黑影
'color: red;', // 具名颜色
'background: #0f766e;', // 裸 hex
].join('\n')
const cleaned = stripNonCode(dirty)
expect(HEX_RE.test(cleaned)).toBe(true)
expect(NAMED_COLOR_RE.test(cleaned)).toBe(true)
expect(LITERAL_BLACK_RGBA_RE.test(cleaned)).toBe(true)
expect(/rgba?\(\s*\d{1,3}\s*,/.test(cleaned)).toBe(true)
})
it('剥注释不会把真问题也剥掉', () => {
const dirty = [
'// 注释里的 #ff0000 是历史说明',
'/* 块注释里的 #00ff00 也是 */',
'color: #0000ff;',
'background: rgba(9, 9, 9, 0.3);',
].join('\n')
const cleaned = stripNonCode(dirty)
expect(cleaned).not.toContain('#ff0000')
expect(cleaned).not.toContain('#00ff00')
expect(cleaned).toContain('#0000ff')
expect(cleaned).toContain('rgba(9, 9, 9, 0.3)')
})
it('语言徽标品牌色被锁死(豁免也要有断言)', () => {
// MessageItem.vue 整体豁免(语言徽标是品牌识别色),但豁免范围不能悄悄扩大:
// 这里把徽标色板逐个锁住,改色必须同时改这里并说明理由。
const source = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
const expected: Record<string, string> = {
script: '#f7df1e', // JS
dynamic: '#3776ab', // Python
jvm: '#f0642f', // Kotlin
systems: '#5e63b6', // C
markup: '#e34f26', // HTML
style: '#8b5cf6', // CSS
data: '#64748b', // SQL
doc: '#0f766e', // YAML
shell: '#111827', // Shell
default: '#6b7280', // 其它
}
for (const [lang, color] of Object.entries(expected)) {
expect(
source,
`语言徽标 ${lang} 的品牌色必须是 ${color}(语言识别色,不参与主题令牌)`,
).toContain(`background: ${color};`)
}
})
})