iter(g281): 抽出差异纯逻辑并加皮肤冒烟

This commit is contained in:
HIsheR
2026-10-26 23:07:49 +08:00
parent 65c8faaa66
commit 82e69df6df
5 changed files with 102 additions and 62 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
- `apps/docs`:文档站,纯产物(ua docs 生成) - `apps/docs`:文档站,纯产物(ua docs 生成)
**门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast + audit:motion,提交前必过。其中 `smoke` 是两段内核断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 31 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写);改内核语义前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。 **门禁一条龙**:`npm run check` = build + demo + style + typecheck + smoke + index + verify + lint-iterations + audit:visual + audit:contrast + audit:motion,提交前必过。其中 `smoke` 是三段断言:`@ui-arch/frame` 14 条(信号读写与同值短路、effect 注销与条件分支重追踪、computed 链式派生、多订阅者扇出)+ `@ui-arch/table-core` 31 条(排序 / 筛选 / 分页 / 选择的派生管线 + setRows 换源与失效行剔除 + 越界页码回写)+ `@ui-arch/ui` 8 条(皮肤层纯逻辑:行级差异的剪前后缀 / LCS 对齐 / 忽略空白 / 超限退化,跑的是 dist/diffKit.js);改内核语义或纯逻辑前先跑它,别靠组件 demo 肉眼验。`ua audit:visual` 统计皮肤包里十四类硬编码视觉字面量(字号 / 动效时长 / 阴影 / 圆角 / 焦点环 / 内距 / 控件高度 / 字重 / 行高 / 层级 / 边框色 / 混色浓度 / transform 位移 / 焦点描边)并与 `.ua/visual-baseline.json` 做棘轮:任一类超过基线即退出码 1 并指出疑似引入的提交;迁移脚本 `npm run visual:migrate` 可把字面量幂等改写成令牌。`ua audit:contrast` 走另一条线:把令牌里 18 组「前景色 × 底色」配对(正文/次级/辅助文字 × 页面底/表面/凹陷底、强调色与其反白、语义三色、禁用态豁免档、焦点描边 × 页面底/表面)按 WCAG 公式算成对比度(支持 `color-mix` 与 `var()` 求值,半透明底先压到页面底色上),亮 / 暗 × 常规 / 高对比四套主题各算一遍,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)即退出码 1;`--json` 出逐对明细,`--list` 只列配对表。改任一颜色令牌后它会立刻告诉你「改完谁读不清了」,不必等人工目测暗色。`ua audit:motion` 守的是动效面,两类必查:① 带 `infinite` 的动画是否在 `prefers-reduced-motion: reduce` 块里被 `animation: none` 关掉——循环动画是前庭敏感用户的硬伤,漏一条整页就在抖(它抓出过转圈与不确定态进度两处漏网);② 悬停 / 按压的位移(`translate`)是否在减少动效下归零——位移最容易诱发前庭不适,且只提供手感、不表达状态(一条 `ua-` 类通配的 `transform: none !important` 兜住全站 14 处,新增组件不用各自补)。`ua verify` 校验清单 ↔ 文档 ↔ 演示一致(docs.components 的 id 必须在 api.exports 有对应条目、props 必须带 n/t/d、每个文档字段名必须在组件实现源码里真实出现(改名后忘改文档会直接报错)、反向也校验 `src/types.ts` 里 `<组件>Props`(可用清单的 `propsType` 覆盖接口名,`false` 表示无入参)每个字段都在文档 props 表登记(新加字段忘了写文档会报错)、demo 锚点必须在 `apps/demo/main.ts` 中存在,且该组件必须被 demo 真实调用 `id(` / `id.`),反向也查孤儿演示(demo 里用 section() 定义的每个区块都必须被某个组件的 demo 字段认领,加了区块忘登记会直接报错),检查皮肤包样式表无硬编码颜色(`#hex` 一律禁止,必须走 `--ua-*` 令牌)与令牌引用有效(`var(--ua-*)` 必须是 tokens.css 已定义或组件自设的变量)、核对每个 `--ua-color-*` 都在 `[data-theme='dark']` 里有覆盖值且暗色块没有凭空多出的变量名(新增颜色令牌漏写暗色值直接报错)、核对 `tokens.json` 的 `color` 键与 tokens.css 的 `--ua-color-*` 同名同值(名字按 camelCase → kebab 折算,亮色值与暗色值都比,双向查漏——只改一处时文档色板会凭空多/少一个令牌,过去只能靠人眼发现)、核对 `@keyframes` 没有「帧体完全相同但名字不同」的复制粘贴(同一段动效只该有一份声明,改名复制会让方向 / 时长各写各的,翻转态就出现过「朝反方向入场」),核对 README 的 `<!-- ua:components -->` 区块与「分组:」行都覆盖清单组件集合(漏写/多写/重复都报错),并交叉校验 backlog 的 done 条目与 `.ua/iterations.log` 的 done 留痕一一对应(另外查 backlog id 不重复、g 编号严格递增、同一 id 只有一条 done 留痕,且留痕 ts 带 +08:00、时间线不回退、不落在未来——都是过去手工插条目/手写时间戳踩过的坑);配方还要求编号连续、每条都有代码块与要点,且代码块里至少认出一个已注册组件 id(拼错组件名时 `ua recipes` 会把它列成空组件表)。
`ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast / layout / jsonViewer / diffView / codeBlock / signaturePad / heatmap / sparkline 十三段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥39 条(下限由段数 × 3 推导,加抽查位不必手改)且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。 `ua verify` 另外校验 `ITERATION.md` 的《视觉回归抽查清单》:button / textField / select / modal / dataTable / toast / layout / jsonViewer / diffView / codeBlock / signaturePad / heatmap / sparkline 十三段各 ≥3 条可勾选要点、每条维度标签必须落在边框 / 阴影 / 过渡 / 焦点环 / 暗色五类内、合计 ≥39 条(下限由段数 × 3 推导,加抽查位不必手改)且五维度全覆盖——视觉类条目收尾前照单过一遍,改清单时别把条目删漏。
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json", "build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json",
"typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json", "typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json",
"smoke": "node packages/core/frame/scripts/smoke.mjs && node packages/core/table-core/scripts/smoke.mjs", "smoke": "node packages/core/frame/scripts/smoke.mjs && node packages/core/table-core/scripts/smoke.mjs && node packages/skin/ui/scripts/smoke.mjs",
"demo": "node apps/demo/build.mjs", "demo": "node apps/demo/build.mjs",
"style": "node packages/tools/cli/bin/ua.js style", "style": "node packages/tools/cli/bin/ua.js style",
"index": "node packages/tools/cli/bin/ua.js index", "index": "node packages/tools/cli/bin/ua.js index",
+40
View File
@@ -0,0 +1,40 @@
/*** 皮肤包冒烟:只测纯逻辑(今日覆盖行级差异对齐),DOM 渲染靠演示页人工过 ***/
import { alignMiddle, diffLines, normalizeLine, toRows, DP_LIMIT } from '../dist/diffKit.js';
let failed = 0;
/*** 断言:打印一条结果,失败计数 +1 ***/
const ok = (label, cond) => {
if (cond) { console.log(`ok - ${label}`); return; }
console.error(`FAIL - ${label}`);
failed += 1;
};
// 1) 完全相同:全是 same,没有增删
const same = diffLines('a\nb\nc', 'a\nb\nc', false);
ok('相同文本全是 same', same.length === 3 && same.every((o) => o.type === 'same'));
// 2) 纯新增一行
const added = diffLines('a\nc', 'a\nb\nc', false);
ok('中间插入一行 → 恰好 1 条 add', added.filter((o) => o.type === 'add').length === 1 && added.filter((o) => o.type === 'del').length === 0);
// 3) 纯删除一行
const removed = diffLines('a\nb\nc', 'a\nc', false);
ok('中间删除一行 → 恰好 1 条 del', removed.filter((o) => o.type === 'del').length === 1 && removed.filter((o) => o.type === 'add').length === 0);
// 4) 尾部换行不产生假差异
ok('尾部换行不算差异', diffLines('a\nb\n', 'a\nb', false).every((o) => o.type === 'same'));
// 5) 忽略行内空白:只差缩进时无增删,且显示保留原文
const ws = diffLines('if (a) {\n return 1;\n}', 'if (a) {\n return 1;\n}', true);
ok('忽略空白后缩进差不计入差异', ws.every((o) => o.type === 'same') && ws[1].text.includes(' '));
// 6) 不忽略空白时同一对文本必须报差异(对照组:证明开关真的起作用)
ok('不忽略空白时同一对文本报增删', diffLines('if (a) {\n return 1;\n}', 'if (a) {\n return 1;\n}', false).some((o) => o.type !== 'same'));
// 7) 行内多空格归一
ok('行内连续空白压成一个空格', normalizeLine(' a b\t c ') === 'a b c');
// 8) 超限退化:仍保证「旧行全 del + 新行全 add」,不丢内容
const bigA = Array.from({ length: 700 }, (_, i) => `a${i}`);
const bigB = Array.from({ length: 700 }, (_, i) => `b${i}`);
const big = alignMiddle(toRows(bigA.join('\n'), false), toRows(bigB.join('\n'), false));
ok(`超限(>${DP_LIMIT})退化为整段替换`, big.filter((o) => o.type === 'del').length === 700 && big.filter((o) => o.type === 'add').length === 700);
if (failed) {
console.error(`皮肤冒烟失败 ${failed} 条`);
process.exit(1);
}
console.log('SKIN SMOKE OK');
+58
View File
@@ -0,0 +1,58 @@
/*** 行级差异纯逻辑:切行 / 归一键 / 中间段 LCS 对齐;不碰 DOM,可被冒烟脚本直接跑 ***/
/*** 一条差异:same 原样、add 只在右边、del 只在左边 ***/
export type DiffOp = { type: 'same' | 'add' | 'del'; text: string };
/*** 比较用的一行:text 是显示原文,key 是比对键(忽略空白模式下把行内空白压成一个空格) ***/
export type DiffRow = { text: string; key: string };
// LCS 计算上限:中间段行数乘积超过这个值就退化为「整段替换」,避免大文件把主线程卡住
export const DP_LIMIT = 400_000;
/*** 作用:比对键归一——行内连续空白压成一个空格并去首尾空白;显示仍用原文,缩进不丢 ***/
export function normalizeLine(line: string): string {
return line.replace(/\s+/g, ' ').trim();
}
/*** 作用:切行并算比对键;末尾换行不产生一行空内容(否则每个文件都多一条假差异) ***/
export function toRows(text: string, ignore: boolean): DiffRow[] {
const lines = text.split(/\r?\n/);
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); // 尾部换行忽略
return lines.map((line) => ({ text: line, key: ignore ? normalizeLine(line) : line }));
}
/*** 作用:中间段做 LCS 对齐,输出 del / add / same 序列;超限或一侧为空时退化为整段替换 ***/
export function alignMiddle(a: DiffRow[], b: DiffRow[]): DiffOp[] {
if (!a.length || !b.length) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))];
if (a.length * b.length > DP_LIMIT) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))]; // 超限:语义仍正确,只是不再逐行对齐
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0)); // dp[i][j]:a 前 i 行与 b 前 j 行的 LCS 长度
for (let i = a.length - 1; i >= 0; i -= 1) {
for (let j = b.length - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].key === b[j].key ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i].key === b[j].key) { ops.push({ type: 'same', text: a[i].text }); i += 1; j += 1; continue; }
if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i].text }); i += 1; continue; }
ops.push({ type: 'add', text: b[j].text }); j += 1;
}
for (; i < a.length; i += 1) ops.push({ type: 'del', text: a[i].text });
for (; j < b.length; j += 1) ops.push({ type: 'add', text: b[j].text });
return ops;
}
/*** 作用:逐行差异——剪公共前后缀后只对中间段做对齐,日常改动只占几行 ***/
export function diffLines(before: string, after: string, ignore: boolean): DiffOp[] {
const a = toRows(before, ignore);
const b = toRows(after, ignore);
let head = 0;
while (head < a.length && head < b.length && a[head].key === b[head].key) head += 1; // 公共前缀
let tail = 0;
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].key === b[b.length - 1 - tail].key) tail += 1; // 公共后缀
return [
...a.slice(0, head).map((r) => ({ type: 'same' as const, text: r.text })),
...alignMiddle(a.slice(head, a.length - tail), b.slice(head, b.length - tail)),
...a.slice(a.length - tail).map((r) => ({ type: 'same' as const, text: r.text })),
];
}
+2 -60
View File
@@ -1,69 +1,11 @@
import { h } from '@ui-arch/frame'; import { h } from '@ui-arch/frame';
import { diffLines } from './diffKit.js';
import type { DiffViewProps } from './types.js'; import type { DiffViewProps } from './types.js';
// 一条差异:same 原样、add 只在右边、del 只在左边
type DiffOp = { type: 'same' | 'add' | 'del'; text: string };
// 比较用的一行:text 是显示原文,key 是比对键(忽略空白模式下把行内空白压成一个空格再 trim)
type Row = { text: string; key: string };
// LCS 计算上限:中间段行数乘积超过这个值就退化为「整段替换」,避免大文件把主线程卡住
const DP_LIMIT = 400_000;
/*** 比对键归一:行内连续空白压成一个空格并去掉首尾空白;显示仍用原文,缩进不丢 ***/
function normalize(line: string): string {
return line.replace(/\s+/g, ' ').trim();
}
/*** 切行:末尾换行不产生一行空内容(否则每个文件都会多出一条假的空行差异) ***/
function toRows(text: string, ignore: boolean): Row[] {
const lines = text.split(/\r?\n/);
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); // 尾部换行忽略
return lines.map((line) => ({ text: line, key: ignore ? normalize(line) : line }));
}
/*** 中间段做 LCS 对齐:先剪掉公共前后缀,剩下的才真算,日常改动只占几行 ***/
function alignMiddle(a: Row[], b: Row[]): DiffOp[] {
if (!a.length || !b.length) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))];
if (a.length * b.length > DP_LIMIT) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))]; // 超限:整段替换,语义仍正确
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0)); // dp[i][j]:a 前 i 行与 b 前 j 行的 LCS 长度
for (let i = a.length - 1; i >= 0; i -= 1) {
for (let j = b.length - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].key === b[j].key ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i].key === b[j].key) { ops.push({ type: 'same', text: a[i].text }); i += 1; j += 1; continue; }
if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i].text }); i += 1; continue; }
ops.push({ type: 'add', text: b[j].text }); j += 1;
}
for (; i < a.length; i += 1) ops.push({ type: 'del', text: a[i].text });
for (; j < b.length; j += 1) ops.push({ type: 'add', text: b[j].text });
return ops;
}
/*** 逐行差异:剪公共前后缀后对齐中间段,输出完整 same / add / del 序列 ***/
function diffLines(before: string, after: string, ignore: boolean): DiffOp[] {
const a = toRows(before, ignore);
const b = toRows(after, ignore);
let head = 0;
while (head < a.length && head < b.length && a[head].key === b[head].key) head += 1; // 公共前缀
let tail = 0;
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].key === b[b.length - 1 - tail].key) tail += 1; // 公共后缀
const midA = a.slice(head, a.length - tail);
const midB = b.slice(head, b.length - tail);
return [
...a.slice(0, head).map((r) => ({ type: 'same' as const, text: r.text })),
...alignMiddle(midA, midB),
...a.slice(a.length - tail).map((r) => ({ type: 'same' as const, text: r.text })),
];
}
/*** 文本差异对比:行级对齐 + 增删着色 + 双行号列,超限时退化为整段替换(只影响精度不影响语义) ***/ /*** 文本差异对比:行级对齐 + 增删着色 + 双行号列,超限时退化为整段替换(只影响精度不影响语义) ***/
export function diffView(props: DiffViewProps): HTMLElement { export function diffView(props: DiffViewProps): HTMLElement {
const ignore = props.ignoreWhitespace === true; // 只在显式开启时归一行内空白 const ignore = props.ignoreWhitespace === true; // 只在显式开启时归一行内空白
const ops = diffLines(props.before, props.after, ignore); const ops = diffLines(props.before, props.after, ignore); // 纯逻辑在 diffKit:这里只负责把它画出来
const added = ops.filter((o) => o.type === 'add').length; // 新增行数:统计条与读屏共用 const added = ops.filter((o) => o.type === 'add').length; // 新增行数:统计条与读屏共用
const removed = ops.filter((o) => o.type === 'del').length; // 删除行数 const removed = ops.filter((o) => o.type === 'del').length; // 删除行数
let oldNo = 0; let oldNo = 0;