#!/usr/bin/env node /** * ds-compare.mjs — 还原保真人工目检编排(原型基准 ↕ Flutter golden 并排) * * design-distill 阶段4「保真闸」的人工目检侧:对屏按 A/B/C 三主题截原型基准图(注入 * 统一字体),再与已生成的 Flutter golden 用 montage 上下并排,产出对比图供人工逐屏目检。 * 跨渲染器(Chromium↔Skia)不跑严格 pixelmatch(那是 fidelity.mjs 的活);这里靠肉眼兜底像素级。 * * 「每一屏 + 外壳都要跟原型比」(规则 2):默认遍历 tools/screens.mjs 注册表(含 shell)。 * * 前置:先生成对应 Flutter golden: * cd client && flutter test --update-goldens test/golden/_golden_test.dart * * 用法(仓库根运行): * 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,可重生,上=原型 下=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, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { SCREENS } from './screens.mjs'; const { values, positionals } = parseArgs({ allowPositionals: true, options: { themes: { type: 'string', default: 'a,b,c' }, // 临时单屏(不在注册表时): html: { type: 'string' }, prefix: { type: 'string' }, width: { type: 'string', default: '1280' }, 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) { 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'); const shoot = join(SKILL, 'tools/shoot-prototype.mjs'); const montage = join(SKILL, 'tools/montage.mjs'); for (const t of [shoot, montage]) { if (!existsSync(t)) { console.error(`[ds-compare] 找不到 skill 工具: ${t}(设 DD_SKILL 覆盖)`); process.exit(2); } } const outDir = 'design/_compare'; mkdirSync(outDir, { recursive: true }); const themes = values.themes.split(',').map((s) => s.trim()).filter(Boolean); // 统一字体注入(与 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')); // 构造待对比清单:临时单屏 优先;否则用注册表(指定屏名或全部)。 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,人工目检)`); } }