Files
Agent-System-Frontend/test/design-guard.test.ts
T
weichen 9b9f24b7be Enforce tokenized workbench visuals across themes
Why: Independent review found component-local color derivation, a private transition, and missing dark/molv coverage in the visual candidate.
Evidence: Frontend design guard passes and 19 Playwright scenarios cover light, dark, molv, and 320-1920px layouts.
Impact: Keeps the approved visual direction while moving color ownership to @ui tokens and removing private motion.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:50:17 +00:00

105 lines
5.2 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.
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/**/*.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url)) // test/
const FE_ROOT = join(HERE, '..')
const SRC = join(FE_ROOT, 'src')
const DEV_ROOT = join(FE_ROOT, '..', '..') // Dev 父仓根(DESIGN.md;单独检出本仓时不存在)
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else out.push(p)
}
return out
}
const read = (p: string) => readFileSync(p, 'utf8')
const stripCssComments = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '')
const collectHex = (t: string) => [...t.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map(m => m[0].toLowerCase())
// ---- 1. 宪法在位(组合检出时) ----
describe('DESIGN.md 宪法在位', () => {
it('DESIGN.md 存在且 AGENTS.md 带强制阅读门禁', () => {
const designPath = join(DEV_ROOT, 'DESIGN.md')
if (!existsSync(designPath)) { console.warn('(单独检出 Frontend 仓:跳过宪法检查)'); return }
expect(read(designPath)).toContain('Agent-System-Dev 设计系统')
const agents = read(join(DEV_ROOT, 'AGENTS.md'))
expect(agents).toContain('DESIGN.md')
})
})
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('宿主 CSS 无 @keyframes 或私有 transition(全部走 @ui/motion.css 的 mk-* 类)', () => {
for (const path of walk(SRC).filter(p => p.endsWith('.css'))) {
const css = stripCssComments(read(path))
expect(css, `${relative(SRC, path)} 不得定义 @keyframes`).not.toContain('@keyframes')
const transitions = css.split('\n').filter(line => /transition\s*:/.test(line) && !/transition\s*:\s*none\b/.test(line))
if (path.endsWith('styles.css')) continue // 历史 transition 由现有棘轮逐步收紧;新分层 CSS 不再扩散。
expect(transitions, `${relative(SRC, path)} 动效必须消费 mk-*`).toEqual([])
}
})
})
// ---- 3. 裸色值棘轮 ----
const HEX_BASELINE_STYLES = [
'#0e1116', '#101826', '#0b1f38', // 登录品牌面板固定深底渐变(面板自带深底,主题无关)
'#dfe4f2', // 同上面板的固定浅字
] as const
describe('裸色值棘轮', () => {
it('styles.css 不引入基线外裸色值', () => {
const found = collectHex(read(join(SRC, 'styles.css')))
const allow = new Set<string>(HEX_BASELINE_STYLES)
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('分层组件 CSS 只消费令牌,不派生私有颜色', () => {
const componentCss = walk(SRC).filter(p => p.endsWith('.css') && !p.endsWith('styles.css'))
const extra: string[] = []
for (const path of componentCss) {
const css = stripCssComments(read(path))
for (const hex of collectHex(css)) extra.push(`${relative(SRC, path)}: ${hex}`)
if (/color-mix\s*\(/.test(css)) extra.push(`${relative(SRC, path)}: color-mix`)
}
expect(extra, '分层组件 CSS 的颜色须由 @ui/tokens.css 提供').toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
for (const h of collectHex(read(p))) extra.push(`${relative(SRC, p)}: ${h}`)
}
expect(extra, '组件里出现裸色值——走 tokens 令牌').toEqual([])
})
})
// ---- 4. 行内字号棘轮(--fs-ui-* 标尺) ----
const FONTSIZE_LITERAL_BASELINE = ['40'] as const // Conversation 空态装饰字形(⌘ 展示字号,非 UI 层级)
describe('行内字号棘轮', () => {
it('组件 TSX 无基线外 fontSize 数值字面量', () => {
const allow = new Set<string>(FONTSIZE_LITERAL_BASELINE)
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const lines = read(p).split('\n')
lines.forEach((l: string, i: number) => {
const m = l.match(/fontSize:\s*['"`]?([\d.]+)/)
if (m && !allow.has(m[1])) extra.push(`${relative(SRC, p)}:${i + 1} (${m[1]})`)
})
}
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
})
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
}
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
})
})