Files
UI/packages/tools/cli/lib/visual-rules.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

312 lines
13 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.
/*** @ui-arch 视觉规则表:硬编码视觉字面量 → 设计令牌的唯一映射来源
*
* 为什么单独成模块:迁移脚本(scripts/visual-migrate.mjs)与审计门禁
* (packages/tools/cli/lib/visual-audit.mjs)必须用同一张表,否则「迁移完还报漂移」
* 或「门禁放过迁移漏掉的写法」这类派生语义不一致必然出现(CODING.md §5)。
* 改规则只改这里;新增一类硬编码 = 加一条 KIND_META + 一个 detect 分支 + migrateDeclaration 分支。
*/
/*** 皮肤包样式表相对仓库根的路径(迁移与审计共同的目标文件) ***/
export const UI_CSS_REL = 'packages/skin/ui/ui.css';
/*** 作用:五类硬编码的键名、中文名与判定口径,审计输出与报告都按这个顺序 ***/
export const KIND_META = [
{
key: 'font-size-literal',
label: '字号字面值',
hint: 'font-size 写死 px/em,应改用 --ua-font-size-*',
},
{
key: 'duration-literal',
label: '动效时长字面值',
hint: 'transition/animation 写死时长,应改用 --ua-duration-*',
},
{
key: 'shadow-literal',
label: '阴影字面值',
hint: 'box-shadow 里出现 px 长度,应整条改用 --ua-shadow-1..4',
},
{
key: 'radius-literal',
label: '圆角字面值',
hint: 'border-radius 写死 px,应改用 --ua-radius-*',
},
{
key: 'focus-ring-variant',
label: '焦点环变体',
hint: '自绘 0 0 0 Npx 色环,应改用 --ua-focus-ring / --ua-focus-ring-inset',
},
];
/*** 作用:需要扫描的 CSS 属性白名单(其余属性本轮不动;用白名单顺带避开伪类选择器误判) ***/
const SCAN_PROPS = new Set([
'font-size',
'transition',
'animation',
'transition-duration',
'animation-duration',
'box-shadow',
'border-radius',
'outline',
]);
/*** 作用:字号字面值 → 令牌(10/11 归并到 xs,避免 11px 单独成档) ***/
const FONT_SIZE_TOKENS = new Map([
['10px', 'var(--ua-font-size-xs)'],
['11px', 'var(--ua-font-size-xs)'],
['12px', 'var(--ua-font-size-sm)'],
['13px', 'var(--ua-font-size-md)'],
['14px', 'var(--ua-font-size-lg)'],
['16px', 'var(--ua-font-size-xl)'],
['18px', 'var(--ua-font-size-2xl)'],
]);
/*** 作用:圆角字面值 → 令牌(1px 细线、50% 圆形、999px 胶囊是语义形状,不属漂移,保留) ***/
const RADIUS_TOKENS = new Map([
['4px', 'var(--ua-radius-sm)'],
['10px', 'var(--ua-radius-md)'],
]);
/*** 作用:圆角里视为语义化、不参与漂移统计的取值 ***/
const RADIUS_LEGIT = new Set(['0', '1px', '50%', '999px', 'inherit']);
/*** 作用:纯黑投影的(位移:模糊:透明度)→ elevation 档位;表外的主题色投影留待后续按语义人工归并 ***/
const ELEVATION_BY_SHAPE = new Map([
['8:24:0.12', '2'],
['8:24:0.14', '2'],
['10:28:0.16', '3'],
['12:28:0.18', '4'],
['16:32:0.24', '4'],
['10:40:0.2', '4'],
['0:24:0.18', '2'],
['0:40:0.22', '4'],
]);
/*** 作用:动效时长三档的秒数上限(fast 取 0.15s,因为既有 111 处都是 0.15s,把离群值收进来不改变观感) ***/
const DURATION_STEPS = [
{ max: 0.17, token: 'var(--ua-duration-fast)' },
{ max: 0.23, token: 'var(--ua-duration-base)' },
{ max: 0.2501, token: 'var(--ua-duration-slow)' },
];
/*** 作用:压缩空白,便于用稳定字符串比对(不改语义) ***/
export function squash(value) {
return String(value).replace(/\s+/g, ' ').trim();
}
/*** 作用:把整段 CSS 的注释替换成等长空格,保持偏移与行号可对齐(迁移时按偏移回写原文) ***/
export function blankComments(src) {
let out = '';
let i = 0;
while (i < src.length) {
if (src[i] === '/' && src[i + 1] === '*') {
const close = src.indexOf('*/', i + 2);
const stop = close === -1 ? src.length : close + 2; // 未闭合时一路吞到文件尾
for (let k = i; k < stop; k++) out += src[k] === '\n' ? '\n' : ' ';
i = stop;
} else {
out += src[i];
i++;
}
}
return out;
}
/*** 作用:逐条产出「属性 + 值」及其在原文中的偏移;只认白名单属性且必须以分号收尾(避开伪类选择器) ***/
export function eachDeclaration(code) {
const re = /(transition-duration|animation-duration|transition|animation|font-size|box-shadow|border-radius|outline)\s*:\s*([^;{}]*);/gi;
const list = [];
for (const m of code.matchAll(re)) {
const prop = m[1].toLowerCase();
if (!SCAN_PROPS.has(prop)) continue; // 白名单外的属性不进入迁移与统计
const valueEnd = m.index + m[0].length - 1; // 指向结尾分号
list.push({ prop, value: m[2], valueStart: valueEnd - m[2].length, valueEnd });
}
return list;
}
/*** 作用:拆出尾部的 !important,迁移只看核心值、尾部原样保留 ***/
function splitImportant(raw) {
const m = String(raw).match(/^(.*?)(\s*!important\s*)$/i);
return m ? { core: m[1], tail: m[2] } : { core: raw, tail: '' };
}
/*** 作用:按顶层逗号切分(跳过括号内的逗号,cubic-bezier 的入参不会被切开) ***/
function splitTopLevel(value) {
const parts = [];
let depth = 0;
let start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i));
start = i + 1;
}
}
parts.push(value.slice(start));
return parts;
}
/*** 作用:单个时长字面值 → 档位令牌;超出三档上限的返回 null(如 0.3s / 1.6s 的加载动画节奏,保留原值) ***/
function durationToken(text) {
const m = String(text).match(/^(\d*\.?\d+)(ms|s)$/i);
if (!m) return null;
const sec = m[2].toLowerCase() === 'ms' ? Number(m[1]) / 1000 : Number(m[1]);
for (const step of DURATION_STEPS) if (sec <= step.max) return step.token;
return null;
}
/*** 作用:判断一段动效值里是否已写了缓动(含令牌形式;写了就保留,不覆盖作者意图) ***/
function hasTiming(value) {
if (/var\(--ua-ease/.test(value)) return true;
if (/(cubic-bezier|steps|linear)\(/.test(value)) return true; // 函数式缓动:参数里的逗号不是分段符
return /(^|[\s,])(step-start|step-end|ease-in-out|ease-in|ease-out|ease|linear)(?=$|[\s,])/.test(value);
}
/*** 作用:迁移单个动效声明值(逐段替换时长,缺缓动时补 --ua-ease);返回 null 表示无需改动 ***/
function migrateMotion(prop, core) {
const shorthand = prop === 'transition' || prop === 'animation'; // 只有简写能补缓动,-duration 属性补了是非法的
const parts = splitTopLevel(core);
let hits = 0;
const next = parts.map((segment) => {
let replaced = 0;
let out = segment.replace(/(\d*\.?\d+)(ms|s)\b/gi, (token) => {
const mapped = durationToken(token);
if (!mapped) return token; // 表外时长保留,交给后续视觉条目单独评估
replaced++;
hits++;
return mapped;
});
if (shorthand && replaced && !hasTiming(out)) out = `${out} var(--ua-ease)`; // 只补缺失的缓动,写过的保
return out;
});
if (!hits) return null;
return { kind: 'duration-literal', value: next.join(',') };
}
/*** 作用:迁移单条 box-shadow:焦点环收敛成两个令牌,纯黑投影按 位移/模糊/透明度 归入 elevation 四档 ***/
function migrateShadow(core) {
const value = squash(core);
// 焦点环:2/3px 的强调色描边环;5px 4% 这类是呼吸光晕,动效依赖两个不同状态,不并入令牌
const ring = value.match(/^(inset )?0 0 0 ([23])px color-mix\(in srgb, var\(--ua-color-accent\) (\d+)%, transparent\)$/);
if (ring && Number(ring[3]) >= 10) {
return { kind: 'focus-ring-variant', value: ring[1] ? 'var(--ua-focus-ring-inset)' : 'var(--ua-focus-ring)' };
}
const offset = value.match(/^0 (\d+)px (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 带位移的投影
const glow = value.match(/^0 0 (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 无位移的发光投影
const hit = offset
? ELEVATION_BY_SHAPE.get(`${offset[1]}:${offset[2]}:${offset[3]}`)
: glow
? ELEVATION_BY_SHAPE.get(`0:${glow[1]}:${glow[2]}`)
: undefined;
if (!hit) return null; // 主题色投影 / 状态色环 / 遮罩色块保留,由视觉条目逐条人工归并
return { kind: 'shadow-literal', value: `var(--ua-shadow-${hit})` };
}
/*** 作用:判定单条声明是否需要迁移,返回 { kind, value } 或 null(幂等:已是令牌就返回 null) ***/
export function migrateDeclaration(prop, rawValue) {
const { core, tail } = splitImportant(rawValue);
let result = null;
if (prop === 'font-size') {
const token = FONT_SIZE_TOKENS.get(squash(core));
if (token) result = { kind: 'font-size-literal', value: token };
} else if (prop === 'border-radius') {
const token = RADIUS_TOKENS.get(squash(core));
if (token) result = { kind: 'radius-literal', value: token };
} else if (prop === 'transition' || prop === 'animation' || prop === 'transition-duration' || prop === 'animation-duration') {
result = migrateMotion(prop, core);
} else if (prop === 'box-shadow') {
result = migrateShadow(core);
}
if (!result) return null;
return { kind: result.kind, value: result.value + tail };
}
/*** 作用:统计单条声明在一个类别下的命中数(0 表示该类别下不算漂移);口径与迁移规则严格对应 ***/
function detect(prop, rawValue, key) {
const { core } = splitImportant(rawValue);
const value = squash(core);
if (key === 'font-size-literal') {
if (prop !== 'font-size') return 0;
if (/var\(/.test(value)) return 0; // 已走令牌(含 calc(var(...) * 系数))不算漂移
return /\d+(px|em|rem)\b/.test(value) ? 1 : 0;
}
if (key === 'duration-literal') {
if (!['transition', 'animation', 'transition-duration', 'animation-duration'].includes(prop)) return 0;
const stripped = value.replace(/var\(--ua-duration-[\w-]+\)/g, ' '); // 已是令牌的时长先剔除
return (stripped.match(/\b\d*\.?\d+(ms|s)\b/gi) ?? []).length;
}
if (key === 'shadow-literal') {
if (prop !== 'box-shadow') return 0;
if (value === 'none') return 0;
if (/^0 0 0 (1[0-9]{2,}|[2-9][0-9]{2,})px /.test(value)) return 0; // 巨型扩散是遮罩色块(0 0 0 9999px),不是投影
return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0;
}
if (key === 'radius-literal') {
if (prop !== 'border-radius') return 0;
if (/^var\(/.test(value)) return 0;
if (RADIUS_LEGIT.has(value)) return 0;
return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0;
}
if (key === 'focus-ring-variant') {
// 口径:只统计 2-8px 的 box-shadow 自绘色环。outline 焦点描边是仓库既有的正统兜底写法
// (:focus-visible 全局规则),算进去会让新组件照抄现有写法就被门禁拦住;
// 1px 内框是边框语义(backlog 里另有边框条目),与页面底色断开的环是几何技巧,都不入此类别。
if (prop !== 'box-shadow') return 0;
if (value === 'var(--ua-focus-ring)' || value === 'var(--ua-focus-ring-inset)') return 0; // 已统一,不算变体
if (!/^(inset )?0 0 0 [2-8]px \S/.test(value)) return 0;
return /var\(--ua-color-(accent|warning|danger)/.test(value) ? 1 : 0;
}
return 0;
}
/*** 作用:扫描整段 CSS,返回五类硬编码的计数与命中行号(审计命令的唯一统计口径) ***/
export function scanSource(src) {
const code = blankComments(src);
const lineAt = (offset) => {
let line = 1;
for (let i = 0; i < offset && i < code.length; i++) if (code[i] === '\n') line++;
return line;
};
const counts = {};
const hits = {};
for (const meta of KIND_META) {
counts[meta.key] = 0;
hits[meta.key] = [];
}
for (const decl of eachDeclaration(code)) {
for (const meta of KIND_META) {
const n = detect(decl.prop, decl.value, meta.key);
if (!n) continue;
counts[meta.key] += n;
hits[meta.key].push({ line: lineAt(decl.valueStart), prop: decl.prop, value: squash(decl.value) });
}
}
return { counts, hits };
}
/*** 作用:迁移整段 CSS,返回改写后的文本与每类替换处数(幂等:无字面量时 text 与入参完全一致) ***/
export function migrateSource(src) {
const code = blankComments(src);
const counts = {};
for (const meta of KIND_META) counts[meta.key] = 0;
let out = '';
let cursor = 0;
for (const decl of eachDeclaration(code)) {
const replaced = migrateDeclaration(decl.prop, src.slice(decl.valueStart, decl.valueEnd));
if (!replaced) continue;
out += src.slice(cursor, decl.valueStart) + replaced.value;
cursor = decl.valueEnd;
counts[replaced.kind] += 1;
}
out += src.slice(cursor);
return { text: out, counts };
}
/*** 作用:把五类计数拍平成「类别: 数量」数组,供审计报告与基线文件共用同一顺序 ***/
export function countRows(counts) {
return KIND_META.map((meta) => ({ ...meta, count: counts[meta.key] ?? 0 }));
}