feat(cli): 新增视觉漂移审计门禁与幂等迁移脚本

- 新增 lib/visual-rules.mjs 作为规则表单一来源:字号阶梯、时长三档、
  阴影四档、焦点环收敛、圆角档位的判定与改写集中在一处,迁移与审计共用
- 新增 lib/visual-audit.mjs + ua audit:visual:按五类统计硬编码视觉字面量,
  支持 --json 与 --baseline,默认与 .ua/visual-baseline.json 棘轮比对,
  超基线退出码 1 并按 git 历史倒推疑似引入提交
- 新增 scripts/visual-migrate.mjs:幂等迁移 ui.css(重复运行零 diff),
  另带 --seed 灌视觉欠账、--backfill-domain 回填域字段
- 实跑迁移:283 处降到 35 处(字号 27→5、时长 170→9、阴影 56→17、
  圆角 2→0、焦点环 28→4),159 行逐行等价替换,括号/分号/行数不变
- package.json 把 audit:visual 追加到 check 链末尾,并补 visual:migrate
This commit is contained in:
HIsheR
2026-10-26 16:49:40 +08:00
parent 5a1d5ba6d7
commit f125369744
10 changed files with 833 additions and 174 deletions
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
/*** 作用:皮肤包视觉字面量 → 设计令牌的幂等迁移,并灌入视觉欠账条目(零新依赖,可重复运行) */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { UI_CSS_REL, migrateSource, countRows } from '../packages/tools/cli/lib/visual-rules.mjs';
// 仓库根:本脚本固定放在 <repo>/scripts/ 下,向上取一层即可
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// 样式表绝对路径
const CSS_FILE = path.join(ROOT, UI_CSS_REL);
// 迭代待办文件绝对路径
const BACKLOG_FILE = path.join(ROOT, '.ua', 'backlog.json');
/*** 作用:把内容写盘,仅在与现有内容不同才写;返回是否真的写了(幂等的落盘口径) ***/
function writeIfChanged(file, content) {
const exists = fs.existsSync(file);
const old = exists ? fs.readFileSync(file, 'utf8') : '';
if (old === content) return false;
fs.writeFileSync(file, content, 'utf8');
return true;
}
/*** 作用:迁移 ui.css 字面量到令牌,打印每类替换处数,并在无变化时明确说「无需改动」 ***/
function migrateCss() {
const before = fs.readFileSync(CSS_FILE, 'utf8');
const { text, counts } = migrateSource(before);
const rows = countRows(counts);
const total = rows.reduce((sum, row) => sum + row.count, 0);
for (const row of rows) console.log(`${row.label}(${row.key})替换 ${row.count} 处`);
console.log(`合计 ${total} 处`);
if (!writeIfChanged(CSS_FILE, text)) {
console.log(`无需改动:${UI_CSS_REL} 已是令牌化状态`);
return 0;
}
console.log(`已写回 ${UI_CSS_REL}`);
return 0;
}
/*** 作用:按标题判重后追加视觉欠账条目(domain=visual,状态 open),id 取当前最大 g 编号往后排 ***/
function seedBacklog() {
const data = JSON.parse(fs.readFileSync(BACKLOG_FILE, 'utf8'));
const items = data.items ?? [];
const seeds = visualSeeds();
const have = new Set(items.map((item) => item.title));
let maxId = 0; // 现存最大 g 编号:动态取,避免与并发迭代实例抢号
for (const item of items) {
const m = /^g(\d+)$/.exec(item.id ?? '');
if (m) maxId = Math.max(maxId, Number(m[1]));
}
let added = 0;
for (const seed of seeds) {
if (have.has(seed.title)) continue; // 幂等:同标题已存在就不再插
maxId += 1;
items.push({ id: `g${maxId}`, ...seed, status: 'open', notes: '', domain: 'visual' });
added += 1;
}
writeIfChanged(BACKLOG_FILE, `${JSON.stringify({ ...data, items }, null, 2)}\n`);
console.log(added ? `已追加 ${added} 条视觉欠账(g${maxId - added + 1}…g${maxId})` : `无需改动:${seeds.length} 条视觉欠账均已存在`);
return 0;
}
/*** 作用:给缺 domain 字段的存量条目按标题批量推断回填(不动 id / status / 其它字段与顺序) ***/
function backfillDomain() {
const data = JSON.parse(fs.readFileSync(BACKLOG_FILE, 'utf8'));
const items = data.items ?? [];
let filled = 0;
const tally = {}; // 各域回填条数,便于核对推断比例
for (const item of items) {
if (item.domain) continue;
const domain = inferDomain(item.title ?? '');
item.domain = domain;
tally[domain] = (tally[domain] ?? 0) + 1;
filled += 1;
}
writeIfChanged(BACKLOG_FILE, `${JSON.stringify({ ...data, items }, null, 2)}\n`);
if (!filled) {
console.log('无需改动:所有条目已有 domain');
return 0;
}
const detail = Object.entries(tally).map(([k, v]) => `${k} ${v}`).join(' / ');
console.log(`已回填 ${filled} 条 domain(${detail})`);
return 0;
}
/*** 作用:按标题关键词推断域:含「新增」判 new,含工具链关键词判 tooling,其余一律 tune ***/
function inferDomain(title) {
if (title.includes('新增')) return 'new';
const tooling = ['门禁', 'CLI', 'MCP', '文档', '配方', '校验'];
if (tooling.some((kw) => title.includes(kw))) return 'tooling';
return 'tune';
}
/*** 作用:八条首批视觉欠账的定义(验收标准都写成可枚举或可机器验证的口径) ***/
function visualSeeds() {
return [
{
title: '弹层阴影语义化:剩余字面阴影归入 elevation 四档',
motivation:
'阴影已收敛出四档令牌,但仍有主题色投影(color-mix text/accent)与状态色环以字面值散落在弹层、气泡、选中态里,同名层级在不同组件用不同数值,观感不齐且改不动。',
acceptance:
'ui.css 中 box-shadow 的 px 字面值全部归入 --ua-shadow-1..4 或语义色环(无主题色投影字面值);`ua audit:visual --json` 的 shadow-literal 计数相对 .ua/visual-baseline.json 至少减少 8 处并写入新基线;在 packages/skin/ui/ua.manifest.json 的 docs.intro 写明「下拉=shadow-2 / 浮层=shadow-3 / 弹窗=shadow-4 / 通知=shadow-4」对照表;npm run check 全绿。',
},
{
title: '内距令牌化:padding / gap 字面值降到基线以下',
motivation:
'间距只有 4/8/12/16 四档令牌,实际却大量写 6px/10px 这类中间值,同一类容器内距不统一,改密度只能全仓替换。',
acceptance:
'packages/design/tokens/tokens.css 补齐内距档位(新增 6px/10px 两档或把它们归并到既有档并在注释说明),tokens.json 同步;visual-rules 增加 space-literal 类(padding/gap/margin 的 px 字面值),迁移脚本同步规则并幂等;该类计数写入新基线且比首次统计少 30 处以上;npm run check 全绿。',
},
{
title: '表单控件高度统一接入 control-h 三档',
motivation:
'控件高度令牌 --ua-control-h-sm/md/lg 已就位,但 button/textField/select/inputNumber 仍各自写死高度,同一行并排时高度不一致,也挡住「略放大提高可点面积」的落地。',
acceptance:
'ui.css 中 .ua-btn / .ua-input / .ua-select / .ua-input-number 的高度声明全部引用 var(--ua-control-h-*);visual-rules 增加 control-h-literal 类且计数为 0;apps/demo/main.ts 中存在一行并排的三档高度演示(sec 锚点内真实调用四个组件);npm run check 全绿。',
},
{
title: '暗色主题阴影与焦点环适配',
motivation:
'暗色块只覆盖颜色,弹层阴影沿用亮色主题的低透明度黑投影,暗底上几乎看不出层级;焦点环在暗色下对比也不足。',
acceptance:
'[data-theme=\'dark\'] 覆盖 --ua-shadow-1..4(更高黑度)与 --ua-focus-ring 两个槽位;ua verify 增加一条校验:暗色块缺 shadow / focus 覆盖即报错;ua tokens 输出的暗色列能看到这 6 个令牌的暗色值;npm run check 全绿。',
},
{
title: '字重与行高统一到三档令牌',
motivation:
'标题、正文、辅助文字的字重与行高全靠各组件手写,同一层级的标题在不同弹窗里字重不同,多行文本行距忽松忽紧。',
acceptance:
'visual-rules 增加 weight-literal 与 leading-literal 两类(font-weight / line-height 的字面值),迁移脚本把字面值改为 --ua-weight-* / --ua-leading-*;两类计数归零并写入新基线;标题/正文/辅助三档在注释里写明归属;npm run check 全绿。',
},
{
title: '层级令牌落地:z-index 字面值归零',
motivation:
'--ua-z-dropdown/sticky/overlay/modal/toast 五档已定义但无人使用,实际仍是各组件写 100/1000/9999,弹层互相盖住的问题只能靠试数值解决。',
acceptance:
'visual-rules 增加 z-literal 类;迁移脚本把 ui.css 中全部 z-index 字面值替换为 var(--ua-z-*);该类计数为 0 并写入新基线;ua tokens 能看到五档层级;npm run check 全绿。',
},
{
title: '边框细节统一到 subtle 与基础色',
motivation:
'分割线、输入框、卡片边框混用基础边框色与更深的黑边,1px 线在不同组件上粗细观感不一,密集表格里分割线过重。',
acceptance:
'分割线改用 --ua-border-subtle、输入框与卡片边框用 --ua-color-border,ui.css 中不再出现硬编码黑边(rgba(0,0,0,…) 作为边框值);visual-rules 增加 border-literal 类且计数为 0;表格与输入框在 demo 中肉眼无粗细跳变;npm run check 全绿。',
},
{
title: '视觉回归抽查清单:六个代表组件的可勾选验收项',
motivation:
'视觉类改动只能靠人眼抽查,每次凭记忆看一遍,漏看是常态;把六个高频组件的观感要点固化成清单,后续视觉条目可直接复用。',
acceptance:
'在 ITERATION.md 或 packages/skin/ui/ua.manifest.json 增加《视觉回归抽查清单》,button / textField / select / modal / dataTable / toast 六个组件各 ≥3 条可勾选要点(合计 ≥18 条);清单条目必须覆盖边框、阴影、过渡、焦点环、暗色五项;ua verify 校验清单存在且 18 条要点齐全;npm run check 全绿。',
},
];
}
/*** 作用:命令行入口:默认迁移样式表,--seed 灌视觉欠账,--backfill-domain 回填域字段 ***/
function main(argv) {
const flags = new Set(argv);
if (flags.has('--help') || flags.has('-h')) {
console.log('用法: node scripts/visual-migrate.mjs [--seed] [--backfill-domain]');
console.log(' 默认 把 ui.css 的视觉字面量迁移成 --ua-* 令牌(幂等,无变化不写盘)');
console.log(' --seed 按标题判重追加 8 条 domain=visual 的欠账条目(幂等)');
console.log(' --backfill-domain 给缺 domain 的存量条目按标题推断回填(幂等)');
return 0;
}
try {
if (flags.has('--seed')) seedBacklog();
if (flags.has('--backfill-domain')) backfillDomain();
if (!flags.has('--seed') && !flags.has('--backfill-domain')) migrateCss();
} catch (err) {
console.error(`visual-migrate 失败:${err.message}`);
return 1;
}
return 0;
}
process.exit(main(process.argv.slice(2)));