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.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CHART_CATEGORICAL,
|
||||
CHART_MEMORY,
|
||||
CHART_OTHER,
|
||||
CHART_SERIES,
|
||||
readChartColor,
|
||||
} from '../../packages/client/src/styles/chart-palette'
|
||||
|
||||
const readClientFile = (path: string) => readFileSync(`packages/client/src/${path}`, 'utf8')
|
||||
|
||||
function scssVar(source: string, name: string): string {
|
||||
const match = source.match(new RegExp(`\\$${name}:\\s*(#[0-9a-fA-F]{6});`))
|
||||
if (!match) throw new Error(`missing SCSS variable $${name}`)
|
||||
return match[1].toLowerCase()
|
||||
}
|
||||
|
||||
describe('chart palette single source', () => {
|
||||
const scss = readClientFile('styles/chart-colors.scss')
|
||||
|
||||
it('keeps the TS series colors in sync with SCSS', () => {
|
||||
expect(CHART_SERIES.input).toBe(scssVar(scss, 'chart-input'))
|
||||
expect(CHART_SERIES.output).toBe(scssVar(scss, 'chart-output'))
|
||||
expect(CHART_SERIES.cache).toBe(scssVar(scss, 'chart-cache'))
|
||||
})
|
||||
|
||||
it('keeps all 12 categorical colors in sync with SCSS', () => {
|
||||
CHART_CATEGORICAL.forEach((color, index) => {
|
||||
expect(color.toLowerCase()).toBe(scssVar(scss, `chart-cat-${index + 1}`))
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the neutral colors in sync with SCSS', () => {
|
||||
expect(CHART_OTHER.toLowerCase()).toBe(scssVar(scss, 'chart-other'))
|
||||
expect(CHART_MEMORY.toLowerCase()).toBe(scssVar(scss, 'chart-memory'))
|
||||
})
|
||||
|
||||
it('resolves categorical colors by index with wraparound', () => {
|
||||
expect(readChartColor(0)).toBe(CHART_CATEGORICAL[0])
|
||||
expect(readChartColor(CHART_CATEGORICAL.length)).toBe(CHART_CATEGORICAL[0])
|
||||
expect(readChartColor(Number.NaN)).toBe(CHART_OTHER)
|
||||
})
|
||||
|
||||
it('keeps palette definitions out of consumer files', () => {
|
||||
// 色板只允许出现在 chart-colors.scss / chart-palette.ts,消费方一律引用。
|
||||
for (const path of [
|
||||
'stores/hermes/usage.ts',
|
||||
'views/hermes/SkillsUsageView.vue',
|
||||
'views/hermes/JourneyView.vue',
|
||||
]) {
|
||||
const hexes = readClientFile(path).match(/#[0-9a-fA-F]{6}\b/g) ?? []
|
||||
expect(hexes, `${path} 不应再定义图表色`).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// 测试夹具:验证 <script setup> 的 let 绑定在模板内联语句里能否写回。
|
||||
// 与 McpManagerView.vue 的 `_autoRetryCount` 同一形态。
|
||||
let counter = 0
|
||||
const observed: number[] = []
|
||||
|
||||
function bump() {
|
||||
counter++
|
||||
observed.push(counter)
|
||||
}
|
||||
|
||||
function read() {
|
||||
observed.push(counter)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="bump" @click="bump()">bump</button>
|
||||
<!-- 先把 counter 推到非 0,再内联复位后读取 —— 这样「复位是否生效」才有区分度 -->
|
||||
<button class="reset-read" @click="counter = 0; read()">reset+read</button>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readClientFile = (path: string) => readFileSync(`packages/client/src/${path}`, 'utf8')
|
||||
|
||||
// 从 `computed(` 的左括号开始做括号配平,精确切出这个表达式的范围。
|
||||
// 用空行切分不行:箭头函数写成单行后,紧邻的下一个顶层声明只隔一个空行。
|
||||
function balancedBlock(source: string, header: string): string {
|
||||
const start = source.indexOf(header)
|
||||
expect(start, `${header} not found`).toBeGreaterThan(-1)
|
||||
|
||||
let depth = 0
|
||||
for (let i = start + header.length - 1; i < source.length; i += 1) {
|
||||
if (source[i] === '(') depth += 1
|
||||
if (source[i] === ')') {
|
||||
depth -= 1
|
||||
if (depth === 0) return source.slice(start, i + 1)
|
||||
}
|
||||
}
|
||||
return source.slice(start)
|
||||
}
|
||||
|
||||
// known-issues #1: `providerOptions` 的 computed getter 内部给 customProvider
|
||||
// 赋值。用户手动挑好的 provider 会被下一次 getter 重算无声改回去,表现为
|
||||
// 「自己跳」。修法是把同步移进 watch,让 getter 恢复成纯读。
|
||||
describe('ModelSelector provider selection', () => {
|
||||
it('keeps the providerOptions getter free of side effects', () => {
|
||||
const source = readClientFile('components/layout/ModelSelector.vue')
|
||||
const block = balancedBlock(source, 'const providerOptions = computed(')
|
||||
|
||||
expect(block).not.toContain('customProvider.value =')
|
||||
})
|
||||
|
||||
it('syncs customProvider from the store through a watch', () => {
|
||||
const source = readClientFile('components/layout/ModelSelector.vue')
|
||||
const block = balancedBlock(source, 'watch(')
|
||||
|
||||
// 同步点必须挂在 selectedProvider 上,而不是挂在 providerOptions 上 ——
|
||||
// 后者等于把同一个副作用换个地方写一遍。
|
||||
expect(block.slice(0, 200)).toContain('appStore.selectedProvider')
|
||||
expect(block).toContain('customProvider.value =')
|
||||
})
|
||||
|
||||
it('still initialises the provider when the picker opens', () => {
|
||||
// watch 是懒的:不会在初始渲染时触发。openModal 的这一次赋值是首帧
|
||||
// 唯一来源,不能跟着一起去掉。
|
||||
const source = readClientFile('components/layout/ModelSelector.vue')
|
||||
|
||||
expect(source).toMatch(/function openModal\(\)[\s\S]*?customProvider\.value = appStore\.selectedProvider/)
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ import ProfileCard from '@/components/hermes/profiles/ProfileCard.vue'
|
||||
|
||||
function actionButton(wrapper: any, text: string) {
|
||||
const button = wrapper
|
||||
.findAll('.card-actions button')
|
||||
.findAll('.cy-panel-actions button')
|
||||
.find((node: any) => node.text().trim() === text)
|
||||
if (!button) throw new Error(`no card action labelled "${text}"`)
|
||||
return button
|
||||
|
||||
@@ -337,9 +337,9 @@ describe('ProviderCard built-in destructive action labels', () => {
|
||||
}))
|
||||
|
||||
expect(wrapper.findAll('.model-row')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.model-tag-button')).toHaveLength(20)
|
||||
expect(wrapper.findAll('.model-tag-button')[0].text()).toContain('model-099')
|
||||
expect(wrapper.findAll('.model-tag-button')[0].text()).toContain('Default')
|
||||
expect(wrapper.findAll('.model-tag')).toHaveLength(20)
|
||||
expect(wrapper.findAll('.model-tag')[0].text()).toContain('model-099')
|
||||
expect(wrapper.findAll('.model-tag')[0].text()).toContain('Default')
|
||||
expect(wrapper.text()).toContain('+80 more')
|
||||
expect(wrapper.findAll('select')).toHaveLength(1)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === 'Set default')).toBe(false)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Fixture from './fixtures/script-setup-let-assignment.vue'
|
||||
|
||||
/**
|
||||
* 验证 `<script setup>` 里的 `let` 绑定在模板内联语句里到底能不能写。
|
||||
*
|
||||
* known-issues #3 认为「编译只对 ref 做解包,let 绑定在模板内联语句里不可写,
|
||||
* 这个重置很可能不生效」。这个结论需要运行时证据,不能只靠读编译产物 ——
|
||||
* compileScript 会为 `setup-let` 绑定生成一对 getter/setter 放进 __returned__,
|
||||
* 模板里的 `$setup.x = v` 走 setter,会写回闭包变量。
|
||||
*
|
||||
* 判据必须有区分度:先把 counter 推到非 0,再内联复位后读取。
|
||||
* 若复位不生效,读到的是 1;复位生效则读到 0。
|
||||
*/
|
||||
describe('script setup let binding assignment', () => {
|
||||
it('resets the counter before the follow-up call reads it', async () => {
|
||||
const wrapper = mount(Fixture)
|
||||
|
||||
await wrapper.find('.bump').trigger('click')
|
||||
expect((wrapper.vm as any).observed).toEqual([1])
|
||||
|
||||
await wrapper.find('.reset-read').trigger('click')
|
||||
|
||||
// 内联赋值先把 counter 置 0,read() 再读到它 —— 读到 0 说明赋值确实生效
|
||||
expect((wrapper.vm as any).observed).toEqual([1, 0])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,18 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readClientFile = (path: string) => readFileSync(`packages/client/src/${path}`, 'utf8')
|
||||
|
||||
const 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$/.test(name)) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('client style system', () => {
|
||||
it('keeps shared page headers on one layout baseline', () => {
|
||||
const globalStyles = readClientFile('styles/global.scss')
|
||||
@@ -96,4 +106,54 @@ describe('client style system', () => {
|
||||
expect(globalStyles).toContain('.arco-input')
|
||||
expect(globalStyles).not.toMatch(/\.n-input/)
|
||||
})
|
||||
|
||||
it('defines the whole CYBER skeleton class inventory', () => {
|
||||
// 原型里的每个外壳类都必须在 global.scss 有对应实现,否则页面会退回裸布局。
|
||||
const globalStyles = readClientFile('styles/global.scss')
|
||||
const required = [
|
||||
'cy-view', 'cy-panel', 'cy-panel-h', 'cy-panel-b', 'cy-kpis', 'cy-kpi',
|
||||
'cy-kv', 'cy-grid2', 'cy-grid4', 'cy-field', 'cy-tag', 'cy-code',
|
||||
'cy-tabs', 'cy-tab', 'cy-stack', 'cy-sec-t', 'cy-slist-t',
|
||||
'cy-tbl-wrap', 'cy-tbl', 'cy-led', 'cy-meter',
|
||||
]
|
||||
|
||||
const missing = required.filter(cls => !globalStyles.includes(`.${cls} `))
|
||||
expect(missing, `global.scss 缺少骨架类:${missing.join(', ')}`).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the CYBER skeleton coverage above the agreed floor', () => {
|
||||
// 覆盖率 = 用到 cy-* 骨架类的视图数 / 全部视图数。
|
||||
// 不用骨架类的页面是有意为之(聊天流、看板、画布等专用布局),
|
||||
// 所以卡一个下限而不是要求 100%,防止新页面裸奔。
|
||||
const viewsDir = 'packages/client/src/views/hermes'
|
||||
const views = walk(viewsDir)
|
||||
.map(p => p.replace(/\\/g, '/'))
|
||||
.filter(p => p.endsWith('.vue'))
|
||||
|
||||
const withSkeleton = views.filter(p =>
|
||||
/class="[^"]*\bcy-(view|panel|kpis|kpi|kv|tabs|tab|grid2|grid4|sec-t|slist-t|tbl|tbl-wrap|meter|led|tag|code|field|stack)\b/.test(
|
||||
readFileSync(p, 'utf8'),
|
||||
),
|
||||
)
|
||||
|
||||
// 这些页面是专用布局,不用通用骨架:聊天流 / 看板 / 画布 / 终端 / 缺失态。
|
||||
// 它们的视觉由各自的组件与令牌负责,不该被塞进 panel 结构。
|
||||
const intentional = [
|
||||
'ChatView.vue', 'GlobalAgentView.vue', 'GroupChatView.vue',
|
||||
'HermesMissingView.vue', 'KanbanView.vue', 'TerminalView.vue', 'WorkflowView.vue',
|
||||
]
|
||||
|
||||
const uncovered = views
|
||||
.filter(p => !withSkeleton.includes(p))
|
||||
.map(p => p.split('/').pop()!)
|
||||
.filter(name => !intentional.includes(name))
|
||||
|
||||
expect(uncovered, `这些页面没有 CYBER 骨架类,也不是有意豁免:${uncovered.join(', ')}`).toEqual([])
|
||||
|
||||
const ratio = withSkeleton.length / views.length
|
||||
expect(
|
||||
ratio,
|
||||
`骨架类覆盖率 ${(ratio * 100).toFixed(0)}% 低于下限 60%(${withSkeleton.length}/${views.length})。`,
|
||||
).toBeGreaterThanOrEqual(0.6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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」是历史说明,不是样式;
|
||||
* - `▾` 是 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 实体:▾ 是 ▽ 不是颜色
|
||||
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};`)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user