diff --git a/tools/ds-compare.mjs b/tools/ds-compare.mjs index 1426274..2b196b1 100644 --- a/tools/ds-compare.mjs +++ b/tools/ds-compare.mjs @@ -1,43 +1,50 @@ #!/usr/bin/env node /** - * ds-compare.mjs — 还原保真目检编排(原型基准 ↔ Flutter golden 并排) + * ds-compare.mjs — 还原保真人工目检编排(原型基准 ↕ Flutter golden 并排) * - * design-distill 阶段4「保真闸」的项目侧一键流程:对某屏,按 A/B/C 三主题 - * 截原型基准图,再与已生成的 Flutter golden 用 montage 并排,产出对比图供人工目检。 - * 跨渲染器(Chromium↔Skia)不跑严格 pixelmatch;回归靠 Flutter golden 自比(flutter test)。 + * design-distill 阶段4「保真闸」的人工目检侧:对屏按 A/B/C 三主题截原型基准图(注入 + * 统一字体),再与已生成的 Flutter golden 用 montage 上下并排,产出对比图供人工逐屏目检。 + * 跨渲染器(Chromium↔Skia)不跑严格 pixelmatch(那是 fidelity.mjs 的活);这里靠肉眼兜底像素级。 * - * 前置:先生成 Flutter golden: + * 「每一屏 + 外壳都要跟原型比」(规则 2):默认遍历 tools/screens.mjs 注册表(含 shell)。 + * + * 前置:先生成对应 Flutter golden: * cd client && flutter test --update-goldens test/golden/_golden_test.dart * - * 用法(从仓库根运行): - * node tools/ds-compare.mjs --html .superpowers/prototype/screens/inventory.html \ - * --prefix inventory_list [--themes a,b,c] [--width 1280] [--height 860] [--wait-for .app] + * 用法(仓库根运行): + * node tools/ds-compare.mjs # 注册表所有屏(含外壳) × 三主题 + * node tools/ds-compare.mjs inventory shell # 指定屏 + * node tools/ds-compare.mjs --themes a # 指定主题 + * node tools/ds-compare.mjs --html --prefix

[--width --height --wait-for] # 临时单屏(不在册时) * - * 输出:design/_compare/_.png(gitignore,可重生)。 - * 依赖:design-distill skill(shoot-prototype.mjs / montage.mjs),默认 ~/.claude/skills/design-distill,可用 DD_SKILL 覆盖。 + * 输出:design/_compare/_.png(gitignore,可重生,上=原型 下=Flutter)。 + * 依赖:design-distill skill(shoot-prototype.mjs / montage.mjs),默认 ~/.claude/skills/design-distill,DD_SKILL 可覆盖。 */ import { parseArgs } from 'node:util'; import { execFileSync } from 'node:child_process'; -import { mkdirSync, existsSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { mkdirSync, existsSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { SCREENS } from './screens.mjs'; -const { values } = parseArgs({ +const { values, positionals } = parseArgs({ + allowPositionals: true, options: { + themes: { type: 'string', default: 'a,b,c' }, + // 临时单屏(不在注册表时): html: { type: 'string' }, prefix: { type: 'string' }, - themes: { type: 'string', default: 'a,b,c' }, width: { type: 'string', default: '1280' }, - height: { type: 'string', default: '860' }, + height: { type: 'string', default: '900' }, 'wait-for': { type: 'string', default: '.app' }, 'goldens-dir': { type: 'string', default: 'client/test/golden/goldens' }, help: { type: 'boolean', default: false }, }, }); -if (values.help || !values.html || !values.prefix) { - console.log(`用法: node tools/ds-compare.mjs --html <原型.html> --prefix [--themes a,b,c --width 1280 --height 860 --wait-for .app]`); - process.exit(values.help ? 0 : 1); +if (values.help) { + console.log(`用法: node tools/ds-compare.mjs [screen...] [--themes a,b,c]\n 临时单屏: --html --prefix

[--width --height --wait-for]\n已注册屏: ${Object.keys(SCREENS).join(', ')}`); + process.exit(0); } const SKILL = process.env.DD_SKILL || join(homedir(), '.claude/skills/design-distill'); @@ -51,20 +58,44 @@ const outDir = 'design/_compare'; mkdirSync(outDir, { recursive: true }); const themes = values.themes.split(',').map((s) => s.trim()).filter(Boolean); -for (const theme of themes) { - const base = join(outDir, `${values.prefix}_proto_${theme}.png`); - const golden = resolve(values['goldens-dir'], `${values.prefix}_${theme}.png`); - const out = join(outDir, `${values.prefix}_${theme}.png`); +// 统一字体注入(与 fidelity.mjs 一致):原型 --font/--font-mono → NotoSansSC,与 golden 同字体。 +const injectCss = join(tmpdir(), 'jiu-dscompare-font.css'); +writeFileSync(injectCss, [ + `@font-face{font-family:'NotoSansSC';src:url('/client/assets/fonts/NotoSansSC.ttf');font-weight:100 900;font-display:block;}`, + `:root{--font:'NotoSansSC',sans-serif!important;--font-mono:'NotoSansSC',monospace!important;}`, + `*{font-family:'NotoSansSC',sans-serif!important;}`, +].join('\n')); - execFileSync('node', [shoot, values.html, base, - '--width', values.width, '--height', values.height, - '--theme', theme, '--lang', 'zh', '--wait-for', values['wait-for']], - { stdio: 'inherit' }); - - if (!existsSync(golden)) { - console.error(`[ds-compare] ⚠ 缺 Flutter golden: ${golden} —— 先跑 flutter test --update-goldens。跳过 ${theme} 的并排。`); - continue; +// 构造待对比清单:临时单屏 优先;否则用注册表(指定屏名或全部)。 +let jobs; +if (values.html && values.prefix) { + jobs = [{ html: values.html, prefix: values.prefix, width: +values.width, height: +values.height, dpr: 2, waitFor: values['wait-for'] }]; +} else { + const names = positionals.length ? positionals : Object.keys(SCREENS); + jobs = []; + for (const n of names) { + if (!SCREENS[n]) { console.error(`[ds-compare] 未注册屏: ${n}(有: ${Object.keys(SCREENS).join(', ')})`); process.exit(1); } + jobs.push(SCREENS[n]); + } +} + +for (const s of jobs) { + for (const theme of themes) { + const base = join(outDir, `${s.prefix}_proto_${theme}.png`); + const golden = resolve(values['goldens-dir'], `${s.prefix}_${theme}.png`); + const out = join(outDir, `${s.prefix}_${theme}.png`); + + execFileSync('node', [shoot, s.html, base, + '--width', String(s.width), '--height', String(s.height), '--dpr', String(s.dpr || 2), + '--theme', theme, '--lang', 'zh', '--wait-for', s.waitFor, + '--inject-css', injectCss, '--wait', '1200'], + { stdio: 'inherit' }); + + if (!existsSync(golden)) { + console.error(`[ds-compare] ⚠ 缺 Flutter golden: ${golden} —— 先跑 flutter test --update-goldens。跳过 ${s.prefix}·${theme} 并排。`); + continue; + } + execFileSync('node', [montage, out, base, golden, '--dir', 'v'], { stdio: 'inherit' }); + console.log(`[ds-compare] ✅ ${s.prefix}·${theme}: ${out}(上=原型 下=Flutter,人工目检)`); } - execFileSync('node', [montage, out, base, golden, '--dir', 'v'], { stdio: 'inherit' }); - console.log(`[ds-compare] ✅ ${theme}: ${out}(上=原型 下=Flutter,人工目检)`); } diff --git a/tools/fidelity.mjs b/tools/fidelity.mjs index 56cc7bd..0a19368 100644 --- a/tools/fidelity.mjs +++ b/tools/fidelity.mjs @@ -32,21 +32,7 @@ import { execFileSync } from 'node:child_process'; import { mkdirSync, existsSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; - -// ── 逐屏注册表 ──────────────────────────────────────────────────────────────── -// width/height = golden 的「逻辑」尺寸(golden 物理像素 = 逻辑 × dpr);两边须一致。 -// prefix = golden 文件前缀 client/test/golden/goldens/_.png。 -// threshold = 该屏校准阈值(差异像素比例上限);首版人工目检「像」后把当时 diff% + 余量记此。 -// 注:屏随 Phase 2 逐个照原型重建后再把其 prefix 入册并校准阈值。 -const SCREENS = { - inventory: { - html: '.superpowers/prototype/screens/inventory.html', - prefix: 'inventory_list', - width: 1280, height: 900, dpr: 2, - waitFor: '.app', - threshold: 0.20, // 旧屏未重建,暂宽松;Phase 2 重建后收紧 - }, -}; +import { SCREENS } from './screens.mjs'; // 屏注册表单一真源(与 ds-compare 共用) const { values, positionals } = parseArgs({ allowPositionals: true, diff --git a/tools/screens.mjs b/tools/screens.mjs new file mode 100644 index 0000000..1ae67f5 --- /dev/null +++ b/tools/screens.mjs @@ -0,0 +1,31 @@ +// tools/screens.mjs — 还原验收「屏注册表」单一真源 +// +// fidelity.mjs(自动 diff 闸)与 ds-compare.mjs(人工 montage 目检)共用此表, +// 保证「每一屏 + 外壳都跟原型比」(规则 2)。屏随 Phase 1/2 照原型重建后逐个入册。 +// +// 字段: +// html 原型 html(仓库根相对路径) +// prefix Flutter golden 文件前缀 → client/test/golden/goldens/_.png +// width/height golden「逻辑」尺寸(golden 物理像素 = 逻辑 × dpr);两边须一致 +// dpr devicePixelRatio(默认 2) +// waitFor 原型截图前等待的选择器 +// threshold fidelity 该屏校准阈值(差异像素比例上限);人工目检「像」后定 +// shell true=外壳(顶栏/侧栏/状态栏整框),与普通屏一同对比 +export const SCREENS = { + // 外壳:顶栏(两级品牌+主题器+通知) + 侧栏(分组) + 状态栏。Phase 1 重建后校准阈值。 + shell: { + html: '.superpowers/prototype/screens/inventory.html', // 外壳含在每个全框屏里,借库存页取整框 + prefix: 'app_shell', + width: 1280, height: 900, dpr: 2, + waitFor: '.app', + threshold: 0.20, + shell: true, + }, + inventory: { + html: '.superpowers/prototype/screens/inventory.html', + prefix: 'inventory_list', + width: 1280, height: 900, dpr: 2, + waitFor: '.app', + threshold: 0.20, // 旧屏未重建,暂宽松;Phase 2 重建后收紧 + }, +};