Files
UI/scripts/visual-migrate.mjs
T
HIsheR f125369744 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
2026-10-26 16:49:40 +08:00

178 lines
10 KiB
JavaScript
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.
#!/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)));