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:
@@ -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)));
|
||||
Reference in New Issue
Block a user