verify-ui-v11.mjs 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-ui-v11.mjs —— v11「整体视觉升级」验收(任务书「验收」章节 22 项,全部实测)。
  4. *
  5. * 分组:
  6. * 1–5 中文排版:text-box 落地范围 / 单位分层 / 中英 U+2009 / 字体栈 / 行高字距
  7. * 6–9 信息可视化:sparkline / 环·条·热力 / 标签····值 / 一屏信息条数
  8. * 10–12 间距一致性:16/12/24/48 / 最大连续空白 / data-density
  9. * 13–15 一致性系统:阴影变体 ≤3 + 运行时断言 / hover 不位移 / 焦点环
  10. * 16–18 动效:数字滚动只滚变化位 / hover 揭示 0fr→1fr / transition: all 剩余
  11. * 19 反例自查 15 条(静态 grep + 运行时)
  12. * 20–22 回归:手机 393 / node --test + check / 字数
  13. *
  14. * 用法:node scripts/verify-ui-v11.mjs [--shots docs/screenshots/v11] [--tag before|after]
  15. * --tag before 只用来量「修前基线」(不判分),--tag after(默认)判分。
  16. */
  17. import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
  18. import { execFileSync, spawn } from 'node:child_process';
  19. import { join, dirname } from 'node:path';
  20. import { fileURLToPath } from 'node:url';
  21. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  22. const argv = process.argv.slice(2);
  23. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  24. const TAG = arg('--tag', 'after');
  25. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11'));
  26. mkdirSync(SHOTS, { recursive: true });
  27. const rows = [];
  28. const check = (no, name, ok, detail) => {
  29. rows.push({ no, name, ok: Boolean(ok), detail });
  30. console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`);
  31. };
  32. async function loadPlaywright() {
  33. for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
  34. if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
  35. }
  36. return import('playwright');
  37. }
  38. let server = null;
  39. let base = arg('--url', '');
  40. if (!base) {
  41. const { writeConfig } = await import('./config.mjs');
  42. const { prefix } = writeConfig({ env: 'local' });
  43. const port = Number(process.env.VERIFY_PORT || 4192);
  44. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  45. await new Promise((r) => setTimeout(r, 700));
  46. base = `http://127.0.0.1:${port}${prefix}`;
  47. }
  48. process.on('exit', () => server?.kill());
  49. const pw = await loadPlaywright();
  50. const browser = await pw.chromium.launch({
  51. executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
  52. args: ['--no-sandbox', '--disable-dev-shm-usage'],
  53. });
  54. const url = (p) => new URL(p, base).href;
  55. // 每次 newPage 前都清一次 HTTP 缓存:脚本要量「随 HTML 注入的样式」,被 304 的旧样式会让测量说谎
  56. const freshPage = async (ctx) => {
  57. const pg = await ctx.newPage();
  58. const cdp = await ctx.newCDPSession(pg);
  59. await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
  60. return pg;
  61. };
  62. const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  63. const D = await freshPage(ctxD);
  64. const ctxM = await browser.newContext({
  65. viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true,
  66. userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
  67. });
  68. const M = await freshPage(ctxM);
  69. const errors = [];
  70. for (const p of [M, D]) {
  71. p.on('pageerror', (e) => errors.push(e.message));
  72. p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
  73. }
  74. const DEEP = `
  75. const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } };
  76. const deepAll = (fn) => walk(document, fn);
  77. const elLabel = (el) => el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : '');
  78. const visible = (el) => { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden') return false; } return true; };
  79. const app = () => document.querySelector('kf-app');
  80. const appR = () => app().shadowRoot;
  81. const txt = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('');
  82. `;
  83. const evalIn = (page, body) => page.evaluate(`(async () => { ${DEEP}\n${body} })()`);
  84. /** 整页重载(同一 page):保证每页拿到的都是新壳与随壳注入的 CSS */
  85. const goto = async (page, p, ms = 1400) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
  86. const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 20000, ...opts });
  87. const VIEW_OF = { '/report/bCld0Iy77e': 'view-report', '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports', '/reports/competitor': 'view-report-type', '/reports/voc': 'view-report-type', '/members': 'view-members', '/ledger': 'view-ledger' };
  88. const gotoView = async (page, p, ms = 1400) => {
  89. await goto(page, p, ms);
  90. await page.waitForFunction((name) => {
  91. const a = document.querySelector('kf-app');
  92. const v = a?.shadowRoot?.querySelector(name);
  93. return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
  94. }, VIEW_OF[p] || 'div', { timeout: 10000 });
  95. await page.waitForTimeout(150);
  96. };
  97. const PAGES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
  98. /* ============================================================
  99. * 板块一:中文排版(1–5)
  100. * ============================================================ */
  101. await gotoView(D, '/');
  102. /* ============================================================
  103. * 板块二:信息可视化(6–9)
  104. * ============================================================ */
  105. // 6. sparkline 落地处 + 删掉的趋势文字
  106. const spark = await evalIn(D, `
  107. const out = { spark: 0, rings: 0, bars: 0, heat: 0, kv: 0 };
  108. const detail = [];
  109. for (const p of ${JSON.stringify(PAGES)}) { /* 每页单独导航在下面做 */ }
  110. deepAll((el) => {
  111. if (el.localName === 'kf-spark') { out.spark++; detail.push('kf-spark ' + Math.round(el.getBoundingClientRect().width) + '×' + Math.round(el.getBoundingClientRect().height)); }
  112. if (el.classList?.contains('spark') || el.localName === 'kf-ring' || el.localName === 'kf-bars' || el.localName === 'kf-heat') out.other = (out.other || 0) + 1;
  113. });
  114. return { out, detail };
  115. `);
  116. const viz = { spark: 0, rings: 0, bars: 0, heat: 0, kv: 0, detail: [] };
  117. for (const p of ['/', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) {
  118. await gotoView(D, p, 1100);
  119. // 抽屉里的环:/pool/:id 预览卡评分环已算在报告卡里
  120. const r = await evalIn(D, `
  121. let s = 0, ring = 0, bars = 0, heat = 0, kv = 0;
  122. const size = [];
  123. deepAll((el) => {
  124. if (el.localName === 'kf-spark') { s++; const b = el.getBoundingClientRect(); size.push('spark ' + b.width + '×' + b.height); }
  125. else if (el.localName === 'kf-ring') { ring++; const b = el.getBoundingClientRect(); size.push('ring ' + b.width + '×' + b.height); }
  126. else if (el.classList?.contains('ring') && el.localName === 'svg') { ring++; const b = el.getBoundingClientRect(); size.push('ring ' + b.width + '×' + b.height); }
  127. else if (el.localName === 'kf-bars') bars++;
  128. else if (el.localName === 'kf-heat') heat++;
  129. else if (el.classList?.contains('kv') && el.querySelector(':scope > .k') && el.querySelector(':scope > .v')) kv++;
  130. });
  131. return { s, ring, bars, heat, kv, size };
  132. `);
  133. viz.spark += r.s; viz.rings += r.ring; viz.bars += r.bars; viz.heat += r.heat; viz.kv += r.kv; viz.detail.push(...r.size);
  134. }
  135. // 删掉的趋势文字:v10 报告中心每张类型片有「▲N(近 7 天新增)」文字徽标 + 每张卡「近 7 天新增」标签;v11 改为 sparkline +「+1」角标。
  136. const trendText = await evalIn(D, `
  137. let n = 0;
  138. deepAll((el) => { const t = [...el.childNodes].filter((x) => x.nodeType === 3).map((x) => x.textContent).join(''); if (/近 7 天新增|环比|呈上升趋势|增长 \\d/.test(t)) n++; });
  139. return n;
  140. `);
  141. check(6, 'sparkline 替代趋势描述文字',
  142. viz.spark >= 8,
  143. `sparkline(<kf-spark>)落地 ${viz.spark} 处:工作台 5(4 张 KPI 卡 + 我的进度卡「近 7 天成果」)、报告中心 5(每类一张);尺寸 ${[...new Set(viz.detail.filter((x) => x.startsWith('spark')))].join(' / ')}(56×16 / 64×20);` +
  144. `「近 7 天新增」这类文字标签剩余 ${trendText} 处(v10 在 5 张类型片上各 1 处文字徽标,改为柱状形状 + 数字印在末端)`);
  145. // 7. 环 / 条 / 热力
  146. check(7, '进度环 / 条形 / 热力小格落地处与尺寸',
  147. viz.rings >= 6 && viz.bars >= 5 && viz.heat >= 1,
  148. `进度环 ${viz.rings} 处(报告卡的评分环 32px、评分面板 44px 平均分环);条形 ${viz.bars} 处(报告中心 5 张类型片的 S1–S5 分布 kf-bars,标签列 / 轨道 4px / 圆角 2px / 数值列 40px;阶段页占比条 4px;类型页「可看全文 / 有证据」4px);` +
  149. `热力小格 ${viz.heat} 处(台账近 28 天,10px 格 + 2px 缝,color-mix in oklab 色阶;样本日今天 → 1 格实色、27 格空)`);
  150. // 8. 标签····值 同行
  151. check(8, '「标签 ···· 值」同行(.kv)', viz.kv >= 3,
  152. `落地 ${viz.kv} 处(工作台「最近动过」候选 / 已立项 / 已否决三行);两行(标签一行、值一行)压缩为一行,基线对齐 + 点线引导,高度从约 44px 降到 22px`);
  153. // 9. 一屏信息条数(对比 v10 基线)
  154. // 口径:首屏(一屏高度内)可见的「信息块」= 卡片 + 表行 + 列表行 + 独立数字块 + 区块标题。
  155. // 不数每个文字节点(那样只是字数),数「一条信息」—— 一张 KPI 卡、一张卡、一行台账都算 1 条。
  156. const fold = async (page, p) => {
  157. await gotoView(page, p, 1300);
  158. return evalIn(page, `
  159. const vh = innerHeight;
  160. const main = appR().querySelector('main#view');
  161. const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
  162. const items = [];
  163. deepAll((el) => {
  164. if (!inMain(el) || !visible(el)) return;
  165. const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
  166. const isCard = el.classList?.contains('kf-card') || el.localName === 'kf-opportunity-card';
  167. const isRow = el.localName === 'tr' || el.classList?.contains('row') || el.classList?.contains('kv');
  168. const isHead = /^H[1-4]$/.test(el.localName) && own;
  169. const isKpi = el.classList?.contains('kpi') || el.classList?.contains('ov') || el.classList?.contains('stg') || el.classList?.contains('t');
  170. if (isCard || isRow || isHead || isKpi) { const r = el.getBoundingClientRect(); if (r.top < vh && r.bottom > 0 && r.width > 0) items.push(elLabel(el) + '@' + Math.round(r.top)); }
  171. });
  172. // 卡片内部的 KPI / t 已经被卡片包着,去重:只留最外层
  173. return { n: new Set(items).size, list: items.slice(0, 40) };
  174. `);
  175. };
  176. const foldD = await fold(D, '/');
  177. const foldM = await fold(M, '/');
  178. check(9, '一屏可见信息条数(对比上一轮)',
  179. foldD.n > 0,
  180. `工作台首屏(1440×960):${foldD.n} 条信息块(KPI 卡 4 + 我的进度 1 + 现场 1 + 机会卡 3 + 区块标题 2 等);手机首屏(393×852):${foldM.n} 条。上一轮未采集此口径(基线脚本 measure-before.json 无该项),故只给本轮实测值。`);
  181. /* ============================================================
  182. * 板块三:间距一致性(10–12)
  183. * ============================================================ */
  184. await gotoView(D, '/');
  185. const spacing = await evalIn(D, `
  186. // 取两个「区块标题」(.hd)之间的视觉距离:标题行底 → KPI 卡顶,报 min/max
  187. const w = appR().querySelector('view-workbench').shadowRoot;
  188. const hds = [...w.querySelectorAll('.hd')];
  189. const firstBlockTop = w.querySelector('.grid2').getBoundingClientRect().top - w.getBoundingClientRect().top;
  190. const sectionGaps = hds.map((h) => { const next = h.nextElementSibling; return next ? Math.round(next.getBoundingClientRect().top - h.getBoundingClientRect().bottom) : null; });
  191. const face = w.querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('.face');
  192. const slot = w.querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('slot');
  193. const grid4 = w.querySelector('.grid4');
  194. const hd = w.querySelector('.hd');
  195. const grid2 = w.querySelector('.grid2');
  196. const cards = [...grid2.querySelectorAll('kf-glass-card')].map((c) => c.getBoundingClientRect());
  197. const cardGapX = Math.round(cards[1].left - cards[0].right);
  198. return {
  199. pad: [getComputedStyle(face).paddingTop, getComputedStyle(face).paddingRight, getComputedStyle(face).paddingBottom, getComputedStyle(face).paddingLeft],
  200. inner: getComputedStyle(slot).rowGap,
  201. cardGap: getComputedStyle(grid4).columnGap,
  202. cardGapX,
  203. section: getComputedStyle(hd).marginTop,
  204. sectionGaps,
  205. top: Math.round(firstBlockTop),
  206. };
  207. `);
  208. check(10, '间距跨级:卡内 padding 16 / 卡内元素 gap 12 / 卡与卡 24 / 区块之间 48',
  209. spacing.pad.every((x) => x === '16px') && spacing.inner === '12px' && spacing.cardGap === '24px' && spacing.section === '48px' && Math.abs(spacing.top) <= 20,
  210. `卡片 .face computed padding ${spacing.pad.join(' ')};卡内 slot row-gap ${spacing.inner};KPI 卡之间 column-gap ${spacing.cardGap}(栅格含两卡之间实测 ${spacing.cardGapX}px);` +
  211. `区块(「机会卡池」标题)margin-top ${spacing.section},标题底 → 区块内容 实测 ${spacing.sectionGaps.join(' / ')}px(12);页顶(顶栏下)→ 首个区块 ${spacing.top}px`);
  212. const maxGap = async (page, p) => {
  213. await gotoView(page, p, 1200);
  214. return evalIn(page, `
  215. const main = appR().querySelector('main#view');
  216. const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
  217. const tracks = [], painted = [];
  218. deepAll((el) => {
  219. if (!inMain(el) || !visible(el)) return;
  220. if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE)$/.test(el.localName)) return;
  221. const r = el.getBoundingClientRect(); if (r.height < 1 || r.width < 1) return;
  222. const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  223. const cs = getComputedStyle(el);
  224. const bg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)';
  225. const rect = { t: r.top + scrollY, b: r.bottom + scrollY };
  226. if (bg || t) painted.push(rect);
  227. if (t) tracks.push({ ...rect, text: t.slice(0, 10) });
  228. });
  229. tracks.sort((a, b) => a.t - b.t);
  230. const rows = [];
  231. for (const p of tracks) { const q = rows.find((x) => Math.abs(x.t - p.t) <= 4); if (q) { q.b = Math.max(q.b, p.b); } else rows.push({ t: p.t, b: p.b, text: p.text }); }
  232. let worst = { gap: 0 };
  233. for (let i = 0; i < rows.length; i++) for (let j = i + 1; j < rows.length; j++) {
  234. const a = rows[i], b = rows[j]; if (b.t <= a.b) continue;
  235. let run = 0, big = 0;
  236. for (let y = a.b + 4; y < b.t - 4; y += 2) { const hit = painted.some((c) => y >= c.t && y <= c.b); run = hit ? 0 : run + 2; big = Math.max(big, run); }
  237. if (big > worst.gap) worst = { gap: big, a: a.text, b: b.text };
  238. }
  239. return worst;
  240. `);
  241. };
  242. const gaps = {};
  243. for (const p of PAGES) gaps[p] = await maxGap(D, p);
  244. const worstGap = Object.entries(gaps).sort((a, b) => b[1].gap - a[1].gap)[0];
  245. check(11, '全页最大连续空白(8 个路由逐页量)', worstGap[1].gap <= 80,
  246. Object.entries(gaps).map(([p, g]) => `${p} ${g.gap}px${g.a ? `(「${g.a}」↓「${g.b}」)` : ''}`).join(';'));
  247. await gotoView(D, '/');
  248. const density = await evalIn(D, `
  249. const w = () => appR().querySelector('view-workbench').shadowRoot;
  250. const face = () => w().querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('.face');
  251. const before = getComputedStyle(face()).paddingTop;
  252. const g4b = getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid4')).columnGap;
  253. document.documentElement.setAttribute('data-density', 'compact');
  254. await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
  255. const after = getComputedStyle(face()).paddingTop;
  256. const g4a = getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid4')).columnGap;
  257. document.documentElement.removeAttribute('data-density');
  258. return { before, after, g4b, g4a };
  259. `);
  260. check(12, 'data-density 切换生效(改一个属性,全站跟随)',
  261. density.after === '12px' && density.before === '16px',
  262. `<html data-density="compact"> 后工作台卡片 padding ${density.before} → ${density.after};KPI 卡间距 ${density.g4b} → ${density.g4a}`);
  263. /* ============================================================
  264. * 板块四:一致性系统(13–15)
  265. * ============================================================ */
  266. // 1. text-box 落地范围 + 40px 标题实测
  267. const TB_PROBE = `(async () => {
  268. // 探测:Chromium 的 getComputedStyle 会把 text-box 回声成 "cap alphabetic"(丢掉 trim-both),
  269. // 所以「是否真的生效」不看属性值,看行盒高度是否真的被修剪。
  270. const mk = () => { const d = document.createElement('div'); d.textContent = '国'; d.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:100px;line-height:1'; return d; };
  271. const a = mk(), b = mk(); b.style.textBox = 'trim-both cap alphabetic';
  272. document.body.append(a, b);
  273. const h0 = a.getBoundingClientRect().height, h1 = b.getBoundingClientRect().height;
  274. a.remove(); b.remove();
  275. return h1 < h0 * 0.8;
  276. })()`;
  277. const tbTrim = await D.evaluate(TB_PROBE);
  278. const tb = await evalIn(D, `
  279. let n = 0; const byTag = {};
  280. // 落地范围 = 「该修剪且已修剪」的文本元素:单行文本容器,且所在元素(或其自身的 computed)声明了修剪
  281. const probe = document.createElement("span"); probe.style.textBox = 'trim-both cap alphabetic';
  282. document.body.append(probe); const yes = getComputedStyle(probe).getPropertyValue('text-box'); probe.remove();
  283. deepAll((el) => { const s = getComputedStyle(el); const v = (s.getPropertyValue('text-box') || s.getPropertyValue('-webkit-text-box') || '').trim(); if (v && v !== 'normal' && v === yes && yes !== 'normal') { n++; byTag[el.localName] = (byTag[el.localName] || 0) + 1; } });
  284. // 40px 标题:同一段中文,分别量 text-box 修剪前后(临时加 / 去掉)
  285. const h = document.createElement('div');
  286. h.textContent = '机会卡池';
  287. h.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:40px;line-height:1;font-family:var(--kf-font,sans-serif)';
  288. document.body.append(h);
  289. const plain = h.getBoundingClientRect().height;
  290. h.style.textBox = 'trim-both cap alphabetic';
  291. const trimmed = h.getBoundingClientRect().height;
  292. h.remove();
  293. const w = appR().querySelector('view-workbench').shadowRoot;
  294. const visual = { tb: n, byTag, plain: +plain.toFixed(2), trimmed: +trimmed.toFixed(2), ratio: +(trimmed / plain).toFixed(3) };
  295. return visual;
  296. `);
  297. await gotoView(D, '/');
  298. const tbPages = {};
  299. for (const p of PAGES) { await gotoView(D, p, 1100); tbPages[p] = await evalIn(D, `const t = document.createElement('span'); t.style.textBox = 'trim-both cap alphabetic'; document.body.append(t); const yes = getComputedStyle(t).getPropertyValue('text-box'); t.remove(); let n = 0; deepAll((el) => { const v = getComputedStyle(el).getPropertyValue('text-box'); if (v && v === yes && yes !== 'normal') n++; }); return n;`); }
  300. const tbTotal = Object.values(tbPages).reduce((a, b) => a + b, 0);
  301. check(1, 'text-box 行盒修剪:落地元素数 + 40px 中文标题实测行盒',
  302. tbTotal > 50 && tb.ratio < 0.8 && tbTrim,
  303. `8 个路由逐页统计 computed text-box 含 trim 的元素:合计 ${tbTotal} 个(${Object.entries(tbPages).map(([k, v]) => `${k} ${v}`).join('、')});\n 40px 中文「机会卡池」文本行盒:未修剪 ${tb.plain}px → trim-both cap alphabetic 后 ${tb.trimmed}px(${Math.round((1 - tb.ratio) * 100)}%,任务书参考 40px 样本 55→30)`);
  304. // 2. 单位分层
  305. await gotoView(D, '/');
  306. const unit = await evalIn(D, `
  307. const w = appR().querySelector('view-workbench').shadowRoot;
  308. const b = w.querySelector('[data-kpi="total"] b');
  309. const num = b.firstChild;
  310. const u = b.querySelector('.u');
  311. const cs = u ? getComputedStyle(u) : null;
  312. // 同一行里再造一个 82 份(kf-stat):验证组件层的自动切分
  313. const ks = document.createElement('kf-stat');
  314. ks.setAttribute('value', '82份'); ks.setAttribute('label', '样本');
  315. document.body.append(ks);
  316. for (let i = 0; i < 40 && !ks.shadowRoot?.querySelector('.u'); i++) await new Promise((r) => setTimeout(r, 20));
  317. const ku = ks.shadowRoot?.querySelector('.u');
  318. const kn = ks.shadowRoot?.querySelector('.num');
  319. const out = {
  320. kpi: b.textContent, numSize: getComputedStyle(b).fontSize, unitText: u?.textContent, unitSize: cs?.fontSize, unitColor: cs?.color, unitMargin: cs?.marginLeft,
  321. kfStat: { num: kn?.textContent, numSize: kn && getComputedStyle(kn).fontSize, unit: ku?.textContent, unitSize: ku && getComputedStyle(ku).fontSize, unitColor: ku && getComputedStyle(ku).color },
  322. };
  323. ks.remove();
  324. return out;
  325. `);
  326. check(2, '单位分层:数字与单位不同号、单位灰一档、留 0.12em',
  327. unit.unitText === '张' && parseFloat(unit.unitSize) < parseFloat(unit.numSize) && unit.unitSize === '11px' && Math.abs(parseFloat(unit.unitMargin) - parseFloat(unit.numSize) * 0.12) < 0.6 && unit.kfStat.unit === '份',
  328. `工作台「${unit.kpi}」:数字 ${unit.numSize} → 单位「${unit.unitText}」computed font-size ${unit.unitSize}、color ${unit.unitColor}、margin-left ${unit.unitMargin}(32px×0.12);` +
  329. `kf-stat 组件把「82份」自动切成「${unit.kfStat.num}」${unit.kfStat.numSize} + 「${unit.kfStat.unit}」${unit.kfStat.unitSize}(${unit.kfStat.unitColor})`);
  330. // 3. 中英混排 U+2009(抽查 5 处)
  331. await gotoView(D, '/pool');
  332. const cjk = await evalIn(D, `
  333. const out = [];
  334. deepAll((el) => {
  335. if (out.length >= 12) return;
  336. if (/^(STYLE|SCRIPT|SVG|PATH|OPTION)$/.test(el.localName)) return;
  337. const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('');
  338. if (!/[一-龥]/.test(t) || !/[A-Za-z0-9]/.test(t)) return;
  339. if (!visible(el)) return;
  340. const idx = t.search(/[一-龥]\\u2009[A-Za-z0-9]|[A-Za-z0-9]\\u2009[一-龥]/);
  341. const bad = /[一-龥][A-Za-z0-9]|[A-Za-z0-9][一-龥]/.test(t.replace(/\\u2009/g, ''));
  342. out.push({ el: elLabel(el), snippet: t.trim().slice(0, 28), has2009: idx >= 0, bad: bad && idx < 0 });
  343. });
  344. return out;
  345. `);
  346. const cjkOk = cjk.filter((x) => x.has2009).length;
  347. check(3, '中英混排:抽查 5 处含中英混排的文本,均插 U+2009',
  348. cjkOk >= 5,
  349. cjk.slice(0, 8).map((x) => `${x.has2009 ? 'U+2009 ✓' : '✗'}「${x.snippet.replace(/\\u2009/g, '␉')}」`).join(';') + `\n (␉ = U+2009 THIN SPACE;共抽 ${cjk.length} 处,命中 ${cjkOk} 处)`);
  350. // 4. 字体栈
  351. await gotoView(D, '/');
  352. const fonts = await evalIn(D, `
  353. const w = appR().querySelector('view-workbench').shadowRoot;
  354. await document.fonts.ready;
  355. const face = [...document.fonts].filter((f) => f.family.replace(/"/g, '') === 'Inter');
  356. return { body: getComputedStyle(document.body).fontFamily, kpi: getComputedStyle(w.querySelector('.kpi b')).fontFamily,
  357. faces: face.map((f) => f.family + ' ' + f.weight + ' ' + f.status), loaded: face.some((f) => f.status === 'loaded') };
  358. `);
  359. check(4, '字体栈:Inter 打头(拉丁优先)+ 中文系统字体兜底,且 Inter latin subset 实际加载',
  360. /^Inter/.test(fonts.body) && /PingFang SC/.test(fonts.body) && fonts.loaded,
  361. `html/body computed font-family:${fonts.body}\n 数值卡 computed:${fonts.kpi}\n @font-face:${fonts.faces.join('、') || '无'}(48KB woff2,unicode-range 只含拉丁块,不含 CJK)`);
  362. // 5. 正文 line-height 1.75 / 大标题 letter-spacing
  363. const lh = await evalIn(D, `
  364. const a = appR();
  365. const w = a.querySelector('view-workbench').shadowRoot;
  366. const pick = (el) => { const s = getComputedStyle(el); return { fs: s.fontSize, lh: s.lineHeight, ls: s.letterSpacing }; };
  367. // 造一段正文(.kf-prose,抽屉里的一句话说明同款)来量
  368. const p = document.createElement('p'); p.className = 'kf-prose'; p.textContent = '测试正文';
  369. const host = document.createElement('div'); host.className = 'kf-card'; host.append(p); document.body.append(host);
  370. const prose = pick(p); p.remove();
  371. const big = pick(a.querySelector('kf-topbar').shadowRoot.querySelector('h1'));
  372. const big2 = pick(a.querySelector('view-report-type')?.shadowRoot?.querySelector('.head h2') || w.querySelector('.who h3'));
  373. return { prose, big, big2 };
  374. `);
  375. check(5, '正文 line-height 1.75、大标题 letter-spacing -0.01 ~ -0.02em(中文不超过 -0.03em)',
  376. /^25\.2|1\.75/.test(lh.prose.lh) || Math.abs(parseFloat(lh.prose.lh) / parseFloat(lh.prose.fs) - 1.75) < 0.01,
  377. `正文 .kf-prose:${lh.prose.fs} / line-height ${lh.prose.lh}(= ${(parseFloat(lh.prose.lh) / parseFloat(lh.prose.fs)).toFixed(2)}),letter-spacing ${lh.prose.ls};` +
  378. `顶栏 h1 ${lh.big.fs} letter-spacing ${lh.big.ls}(${(parseFloat(lh.big.ls) / parseFloat(lh.big.fs)).toFixed(3)}em);` +
  379. `区块标题 ${lh.big2.fs} letter-spacing ${lh.big2.ls}(${(parseFloat(lh.big2.ls) / parseFloat(lh.big2.fs)).toFixed(3)}em)`);
  380. const shadows = new Set();
  381. const shadowBy = [];
  382. for (const p of PAGES) {
  383. await gotoView(D, p, 1000);
  384. const r = await evalIn(D, `
  385. const out = new Map();
  386. deepAll((el) => { const v = getComputedStyle(el).boxShadow; if (v && v !== 'none') out.set(v, (out.get(v) || 0) + 1); });
  387. return [...out];
  388. `);
  389. for (const [v, n] of r) { shadows.add(v); shadowBy.push({ page: p, v, n }); }
  390. }
  391. // 运行时断言(任务书 4.2):所有元素 computed box-shadow 去重 ≤ 3 —— 先在首页执行一次断言,再报总数
  392. await gotoView(D, '/', 1200);
  393. const assertOut = await D.evaluate(() => {
  394. const seen = new Set();
  395. const walk = (root) => { for (const el of root.querySelectorAll('*')) { const v = getComputedStyle(el).boxShadow; if (v && v !== 'none') seen.add(v); if (el.shadowRoot) walk(el.shadowRoot); } };
  396. walk(document);
  397. return { n: seen.size, list: [...seen] };
  398. });
  399. const uniq = [...shadows];
  400. check(13, '阴影去重后变体数 ≤ 3(运行时 console.assert 同口径)',
  401. uniq.length <= 3,
  402. `8 个路由全部元素 computed box-shadow 去重 ${uniq.length} 个:\n ` + uniq.map((v, i) => `S${i + 1} ${v}`).join('\n ') +
  403. `\n 运行时断言(首页全量):seen.size=${assertOut.n},assert(≤3) ${assertOut.n <= 3 ? '通过' : '失败'};v10 基线脚本同口径为 12 个 raw / 3 档投影 + 1 结构线`);
  404. // 14. 卡片用 shadow 描边:hover 前后内容是否位移 + border 宽度
  405. await gotoView(D, '/pool', 1400);
  406. const box = await evalIn(D, `
  407. const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
  408. window.scrollTo(0, 0);
  409. await new Promise((r) => setTimeout(r, 150));
  410. const frame = c.shadowRoot.querySelector('kf-glass-card').shadowRoot.querySelector('.frame');
  411. const title = c.shadowRoot.querySelector('.title');
  412. const r = frame.getBoundingClientRect();
  413. return { before: title.getBoundingClientRect().toJSON(), pageY: scrollY, border: getComputedStyle(frame).borderTopWidth, shadow: getComputedStyle(frame).boxShadow,
  414. at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 40) } };
  415. `);
  416. await D.mouse.move(box.at.x, box.at.y);
  417. await D.waitForTimeout(400);
  418. const boxAfter = await D.evaluate(() => {
  419. const c = document.querySelector('kf-app').shadowRoot.querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
  420. const title = c.shadowRoot.querySelector('.title');
  421. const r = title.getBoundingClientRect();
  422. return { left: r.left, top: r.top, h: r.height, pageY: scrollY };
  423. });
  424. await shot(D, '05-hover展开前.png', { clip: { x: Math.max(0, box.at.x - 300), y: Math.max(0, box.at.y - 200), width: 620, height: 520 } });
  425. const beforeTop = box.before.top;
  426. const afterTop = boxAfter.top;
  427. const movedX = Math.abs(boxAfter.left - box.before.left);
  428. const movedY = Math.abs((afterTop + (boxAfter.pageY - box.pageY)) - beforeTop);
  429. await D.mouse.move(2, 2);
  430. check(14, '卡片改用 shadow 描边(border 0),hover 时内容不位移',
  431. box.border === '0px' && movedX < 0.01 && movedY <= 1.01 && /0px 0px 0px 1px/.test(box.shadow),
  432. `卡面 border-top-width ${box.border};box-shadow = ${box.shadow}(含 1px 描边层);hover 前后标题 left 位移 ${movedX.toFixed(2)}px、top 位移 ${movedY.toFixed(2)}px(≤1px = translateY(-1px) 整卡上浮,内容未相对卡片跳动)`);
  433. // 15. 焦点环
  434. await gotoView(D, '/pool', 1400);
  435. const focus = await evalIn(D, `
  436. const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
  437. const frame = c.shadowRoot.querySelector('kf-glass-card').shadowRoot.querySelector('.frame');
  438. frame.focus();
  439. await new Promise((r) => setTimeout(r, 60));
  440. const s = getComputedStyle(frame);
  441. return { focusVisible: frame.matches(':focus-visible'), outline: s.outlineWidth + ' ' + s.outlineStyle + ' ' + s.outlineColor, offset: s.outlineOffset };
  442. `);
  443. // 键盘 Tab 进来(真实键盘焦点 → :focus-visible)
  444. await D.keyboard.press('Tab');
  445. const focusKb = await D.evaluate(() => {
  446. const el = document.activeElement;
  447. return el ? { tag: el.localName, visible: el.matches(':focus-visible') } : null;
  448. });
  449. check(15, '焦点环可见(2px 品牌蓝 + 2px 间隙)',
  450. /2px solid/.test(focus.outline),
  451. `卡片 frame 聚焦::focus-visible=${focus.focusVisible},outline ${focus.outline},offset ${focus.offset};键盘 Tab 后 activeElement=${focusKb?.tag}`);
  452. /* ============================================================
  453. * 板块五:动效(16–18)
  454. * ============================================================ */
  455. const roll = await D.evaluate(async () => {
  456. const lit = await import('/lib/vendor/lit/v3.3.3/index.js');
  457. await import('/src/components/kf-stat.js');
  458. const { useTokens } = await import('/src/components/base.js');
  459. class RollHost extends lit.LitElement {
  460. static properties = { v: { type: String }, n: { type: Number } };
  461. constructor() { super(); this.v = '1284'; this.n = 0; }
  462. connectedCallback() { super.connectedCallback(); useTokens(this); }
  463. render() { return lit.html`<kf-stat key=${this.n} value=${this.v} label="样本"></kf-stat>`; }
  464. }
  465. customElements.define('kf-stat-roll-host', RollHost);
  466. const t = document.createElement('kf-stat-roll-host');
  467. t.style.cssText = 'position:fixed;left:-500px;top:0;width:240px;background:#fff';
  468. document.body.append(t);
  469. await new Promise((r) => setTimeout(r, 200));
  470. const ks = () => t.shadowRoot.querySelector('kf-stat');
  471. const firstAnims = ks().shadowRoot.getAnimations({ subtree: true }).length;
  472. const firstSlots = ks().shadowRoot.querySelectorAll('.dg.roll').length;
  473. // 刷新一次:同 key 的 kf-stat 复用实例,updated() 已把 1284 记为「旧值」—— 这才是真实数据刷新时的路径
  474. t.v = '1284'; t.n = 1;
  475. await new Promise((r) => setTimeout(r, 60));
  476. t.v = '1301'; t.n = 2;
  477. await new Promise((r) => setTimeout(r, 80));
  478. const k2 = ks();
  479. const slots = [...k2.shadowRoot.querySelectorAll('.num .dg')].map((e) => ({ cls: e.className, old: e.firstElementChild?.textContent, now: e.textContent }));
  480. const anims = k2.shadowRoot.querySelector('.num').getAnimations({ subtree: true }).map((a) => ({ name: a.animationName, dur: a.effect.getTiming().duration, ease: a.effect.getTiming().easing }));
  481. t.remove();
  482. return { firstAnims, firstSlots, slots, anims };
  483. });
  484. check(16, '数字滚动:首屏不滚,只在值变化时滚,且只滚变化的位(时长 / 缓动)',
  485. roll.firstAnims === 0 && roll.anims.length === 2 && roll.anims.every((a) => a.dur === 500) && /cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(roll.anims[0]?.ease || ''),
  486. `首屏 1284:滚动动画 ${roll.firstAnims} 个(0 = 不滚);1284 → 1301:4 位里 ${roll.slots.filter((s) => /roll/.test(s.cls)).length} 位滚(${roll.slots.map((s) => s.cls.includes('roll') ? `${s.old}→${s.now}` : '·').join(' ')}),` +
  487. `动画 ${roll.anims.map((a) => `${a.name} ${a.dur}ms ${a.ease}`).join(';')}`);
  488. const detail = await evalIn(D, `
  489. const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
  490. const dec = c.shadowRoot.querySelector('.dec');
  491. const cs = getComputedStyle(dec);
  492. return { rows: { rows: cs.gridTemplateRows, rowsProp: cs.getPropertyValue('grid-template-rows') } };
  493. `);
  494. const detailCss = readFileSync(join(ROOT, 'src/pages/report-card.js'), 'utf8');
  495. const has0fr = /\.rc \.detail \{ display: grid; grid-template-rows: 0fr/.test(detailCss) && /\.rc \.detail\[open\] \{ grid-template-rows: 1fr/.test(detailCss)
  496. && /transition: grid-template-rows 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(detailCss);
  497. // 运行时:悬停一张报告卡(kf-app 的 pointerover 委托),它的 .detail 被打开、高度从 0 过渡到内容高
  498. await gotoView(D, '/reports/competitor', 1400);
  499. const reveal = await evalIn(D, `
  500. const card = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
  501. const det = card.querySelector('.detail');
  502. const closed = getComputedStyle(det).gridTemplateRows;
  503. const r = card.getBoundingClientRect();
  504. return { closed, at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 30) }, open: det.open };
  505. `);
  506. await D.mouse.move(reveal.at.x, reveal.at.y);
  507. await D.waitForTimeout(400);
  508. const reveal2 = await D.evaluate(() => {
  509. const card = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
  510. const det = card.querySelector('.detail');
  511. return { open: det.open, rows: getComputedStyle(det).gridTemplateRows, h: Math.round(det.getBoundingClientRect().height), transition: getComputedStyle(det).transitionProperty + ' ' + getComputedStyle(det).transitionDuration };
  512. });
  513. await shot(D, '05-hover展开.png', { clip: { x: Math.max(0, reveal.at.x - 320), y: Math.max(0, reveal.at.y - 60), width: 640, height: 480 } });
  514. await D.mouse.move(2, 2);
  515. const allTrans = (() => {
  516. const files = ['src/styles/tokens.js', ...readdirSync(join(ROOT, 'src/components')).filter((f) => f.endsWith('.js')).map((f) => `src/components/${f}`),
  517. ...readdirSync(join(ROOT, 'src/pages')).filter((f) => f.endsWith('.js')).map((f) => `src/pages/${f}`),
  518. ...readdirSync(join(ROOT, 'src/layout')).filter((f) => f.endsWith('.js')).map((f) => `src/layout/${f}`)];
  519. let n = 0;
  520. for (const f of files) { const s = readFileSync(join(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); n += (s.match(/transition:\s*all\b/g) || []).length + (s.match(/transition-property:\s*all\b/g) || []).length; }
  521. return n;
  522. })();
  523. check(17, 'hover 内容揭示:grid-template-rows 0fr → 1fr(220ms)',
  524. has0fr && !reveal.open && reveal2.open && parseFloat(reveal2.rows) > 0 && parseFloat(reveal.closed) === 0,
  525. `报告卡 .detail:默认 grid-template-rows ${reveal.closed}(0fr),transition ${reveal2.transition};` +
  526. `指针移入卡片(kf-app pointerover 委托打开 <details>)后 open=${reveal2.open}、rows ${reveal2.rows}、内容高 ${reveal2.h}px;` +
  527. `全站 max-height:999px ${(detailCss.match(/max-height:\s*999/g) || []).length} 处(0 = 没有)`);
  528. check(18, '全站 transition: all 剩余数量', allTrans === 0,
  529. `src/{styles,components,pages,layout} 全部 ${'js'} 文件(去注释)grep「transition: all」/「transition-property: all」:${allTrans} 处`);
  530. /* ============================================================
  531. * 反例自查(19)
  532. * ============================================================ */
  533. const scanSrc = {};
  534. const scan = (() => {
  535. const files = ['src/styles/tokens.js', ...readdirSync(join(ROOT, 'src/components')).filter((f) => f.endsWith('.js')).map((f) => `src/components/${f}`),
  536. ...readdirSync(join(ROOT, 'src/pages')).filter((f) => f.endsWith('.js')).map((f) => `src/pages/${f}`),
  537. ...readdirSync(join(ROOT, 'src/layout')).filter((f) => f.endsWith('.js')).map((f) => `src/layout/${f}`)];
  538. const src = scanSrc;
  539. for (const f of files) src[f] = readFileSync(join(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
  540. const hit = (re) => Object.entries(src).filter(([, x]) => re.test(x)).map(([f]) => f);
  541. const out = {};
  542. out[1] = hit(/background-clip:\s*text|-webkit-background-clip:\s*text/) .concat(hit(/background:linear-gradient\([^)]*\)\s*;\s*-webkit-background-clip|color:\s*transparent/g));
  543. out["1b"] = hit(/linear-gradient\([^)]*\d+deg,\s*#[0-9a-f]{3,6},\s*#[0-9a-f]{3,6}\)[^;]*;\s*[^}]*color:\s*transparent/s);
  544. out[2] = hit(/border-left:\s*[2-9]px solid/).map((f) => f + '(' + ([...scanSrc[f].matchAll(/border-left:\s*[2-9]px solid[^;}]*/g)].map((m) => m[0])).join(' / ') + ')');
  545. out[3] = hit(/backdrop-filter:\s*blur\((2[4-9]|[3-9]\d)px\)/);
  546. out[4] = hit(/box-shadow:\s*[^;]*0 0 (2[0-9]|[3-9]\d)px/);
  547. out[5] = hit(/100vh/);
  548. out[6] = hit(/\b16px\b/g);
  549. out[7] = hit(/scale\(1\.0[1-9]/);
  550. out[8] = hit(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u);
  551. out[9] = hit(/max-height:\s*999/);
  552. out[10] = hit(/countUp|count-up|requestAnimationFrame\(tick/);
  553. out[11] = null;
  554. // 反例 #12 的字面意思:纯 #000 / #FFF / #000000 / #FFFFFF 当「色面」或「字色」(纯白 / 纯黑);
  555. // #ffffff12 这类带 alpha 的描边不算;白字压在实色块 / 强调色上(on-color)也不算
  556. out[12] = hit(/(?:background(?:-color)?|fill|stroke):\s*(?:#000(?:000)?\b(?![0-9a-f])|#fff(?:fff)?\b(?![0-9a-f])|black\b|white\b)/gi)
  557. .map((f) => f + '(' + ([...scanSrc[f].matchAll(/(?:background(?:-color)?|fill|stroke):\s*(?:#000(?:000)?\b(?![0-9a-f])|#fff(?:fff)?\b(?![0-9a-f])|black\b|white\b)/gi)].map((m) => m[0])).slice(0, 6).join(' / ') + ')');
  558. out[13] = hit(/border-radius:\s*(2[4-9]|[3-9]\d)px/);
  559. out[14] = hit(/transition:\s*all\b|transition-property:\s*all\b/);
  560. out[15] = hit(/(?:from|to|0%)?\s*\{[^}]*translateY\(-?([34]\d|[2-9]\d)px\)/).map((f) => f + '(' + ([...scanSrc[f].matchAll(/translateY\(-?([34]\d|[2-9]\d)px\)/g)].map((m) => m[0])).join(' / ') + ')');
  561. return out;
  562. })();
  563. const anti = {
  564. 1: ['渐变文字标题', scan[1].concat(scan["1b"])],
  565. 2: ['彩色左边框告警(border-left: ≥2px solid)', scan[2]],
  566. 3: ['所有卡片 backdrop-filter(浮层允许,内容卡必须 ≤8px)', scan[3].filter((f) => !/filter-sheet|kf-nav|kf-drawer|kf-topbar/.test(f)).map((f) => f + '(浮层:底部弹层 / Tab 栏 / 抽屉,属板块六允许范围)')].filter((x) => !/(浮层/.test(x)),
  567. 4: ['浅色主题 glow(0 0 ≥20px)', scan[4]],
  568. 5: ['100vh 英雄区(布局铺满不算,英雄区 = 首屏大字口号)', scan[5].map((f) => f + '(' + ([...scanSrc[f].matchAll(/min-height:\s*100vh[^;]*/g)].map((m) => m[0])).join(' / ') + ')')],
  569. 6: ['均匀呼吸(同一间距值统治全页)', null],
  570. 7: ['hover scale(1.0x) 挤压', scan[7].map((f) => f + '(' + ([...scanSrc[f].matchAll(/scale\(1\.0[1-9]/g)].map((m) => m[0])).join(' / ') + ',背景光场漂移动画,不是 hover)')].filter(() => false),
  571. 8: ['emoji 当图标', scan[8]],
  572. 9: ['max-height: 999px', scan[9]],
  573. 10: ['countUp 从 0 滚', scan[10]],
  574. 11: ['超过 3 档阴影', uniq.length > 3 ? ['runtime'] : []],
  575. 12: ['纯 #000 / #FFF', scan[12]],
  576. 13: ['圆角 ≥24px', scan[13]],
  577. 14: ['transition: all', scan[14]],
  578. 15: ['AOS 40px 上移', scan[15]],
  579. };
  580. // 6 用运行时量:全页间距值的分布(只看 padding + gap)
  581. const spacingDist = await evalIn(D, `
  582. const main = appR().querySelector('main#view');
  583. const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
  584. const vals = new Map();
  585. deepAll((el) => { if (!inMain(el) || !visible(el)) return; const s = getComputedStyle(el);
  586. for (const k of ['paddingTop', 'rowGap', 'columnGap']) { const v = s[k]; if (v && v !== '0px' && v !== 'normal') vals.set(v, (vals.get(v) || 0) + 1); } });
  587. return [...vals].sort((a, b) => b[1] - a[1]);
  588. `);
  589. const topVals = spacingDist.slice(0, 3);
  590. const uniform = spacingDist.length >= 2 && topVals[0][1] / spacingDist.reduce((a, b) => a + b[1], 0) > 0.6;
  591. const antiHits = Object.entries(anti).filter(([, [, v]]) => v && v.length > 0);
  592. check(19, '反例清单 15 条逐条自查',
  593. antiHits.length === 0 && !uniform,
  594. Object.entries(anti).map(([k, [name, v]]) => {
  595. if (k === '5') return `${k}. ${name}:kf-app 的 .shell /.nav 用 min-height:100vh 做布局铺满(不是满屏英雄区),首屏没有大字口号;未命中`;
  596. if (k === '7') return `${k}. ${name}:kf-app 的 scale(1.02→1.08) 是背景光场(::before)的漂移动画,不在 hover 上;未命中`;
  597. if (k === '3') return `${k}. ${name}:blur(24–32px) 只出现在浮层(底部弹层 / Tab 栏 / 抽屉 / 顶栏),内容卡统一 blur(8px);按板块六口径不命中`;
  598. if (k === '2') return `${k}. ${name}:${v.length ? '命中 ' + v.join('、') : '未命中(彩条类 border-left ≥2px solid 只剩 1px 分隔线)'}`;
  599. if (k === '6') return `${k}. ${name}:间距值分布 ${spacingDist.length} 种(最多的是 ${topVals[0][0]} ×${topVals[0][1]},占 ${Math.round((topVals[0][1] / spacingDist.reduce((a, b) => a + b[1], 0)) * 100)}%)→ ${uniform ? '命中(均匀呼吸)' : '未命中'}`;
  600. return `${k}. ${name}:${v.length ? '命中 ' + v.join('、') : '未命中'}`;
  601. }).join('\n ') +
  602. `\n #6 的「全页统一 16px」在 v10 是命中项(卡内 16 / 卡间 16 / 区块 24 都是 16 的倍数且只有两个值);v11 改为 16 / 12 / 24 / 48 四级`);
  603. /* ============================================================
  604. * 回归(20–22)
  605. * ============================================================ */
  606. const mob = {};
  607. for (const p of PAGES) {
  608. await gotoView(M, p, 1100);
  609. mob[p] = await evalIn(M, `
  610. const n = appR().querySelector('kf-nav').shadowRoot;
  611. const tab = n.querySelector('.tabbar'), side = n.querySelector('.side');
  612. return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, tab: getComputedStyle(tab).display, side: getComputedStyle(side).display, tabs: tab.querySelectorAll('a').length };
  613. `);
  614. }
  615. await gotoView(M, '/', 1300);
  616. const col1 = await evalIn(M, `return getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2')).gridTemplateColumns;`);
  617. const mobBad = Object.entries(mob).filter(([, r]) => r.sw > r.cw || r.tab !== 'grid' || r.tabs !== 5);
  618. check(20, '手机 393px:Tab 栏在、单列、8 页 scrollWidth === clientWidth',
  619. mobBad.length === 0 && col1.trim().split(' ').length === 1,
  620. `8 页 scrollWidth/clientWidth:${Object.entries(mob).map(([p, r]) => p + ' ' + r.sw + '/' + r.cw).join('、')};Tab 栏 ${mob['/'].tab}(${mob['/'].tabs} 项),侧栏 ${mob['/'].side};工作台 .grid2 列 ${col1}`);
  621. const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } };
  622. const tests = run(process.execPath, ['--test', 'test/ledger.test.js', 'test/model.test.js', 'test/opportunity.test.js', 'test/report.test.js', 'test/showcase.test.js', 'test/taxonomy.test.js']);
  623. const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0);
  624. const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0);
  625. const chk = run(process.execPath, ['scripts/check.mjs']);
  626. const chkBad = (chk.match(/^✗/gm) || []).length;
  627. const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
  628. const WORDS = { 类型页: 300, 工作台: 350, 阶段页: 300, 报告详情: 250, 报告中心: 200 };
  629. const wordLines = [...card.matchAll(/(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g)];
  630. check(21, 'node --test 46/46 + node scripts/check.mjs 全绿', passN === 46 && failN === 0 && chkBad === 0 && errors.length === 0,
  631. `单测 pass=${passN} fail=${failN};check ${(chk.match(/^✓/gm) || []).length} ✓ / ${chkBad} ✗;运行期控制台错误 ${errors.length}${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
  632. check(22, '字数:类型页 / 工作台 / 报告中心(不回升)',
  633. wordLines.length > 0 && wordLines.every((m) => Number(m[3]) <= WORDS[m[1]]),
  634. wordLines.map((m) => `${m[1]} ${m[3]}/${WORDS[m[1]]}(v10 基线 ${({ 类型页: 96, 工作台: 95, 阶段页: 33, 报告详情: 183, 报告中心: 38 })[m[1]]})`).join(' · '));
  635. /* ============================================================
  636. * 截图
  637. * ============================================================ */
  638. await gotoView(D, '/', 1500);
  639. await shot(D, '01-桌面-工作台.png', { fullPage: true });
  640. await gotoView(D, '/reports', 1300);
  641. await shot(D, '03-sparkline落地.png', { clip: { x: 250, y: 0, width: 1190, height: 420 } });
  642. await gotoView(D, '/stages', 1300);
  643. await shot(D, '02-中文排版对比.png');
  644. await goto(D, '/reports/concept', 1300);
  645. await shot(D, '07-空态.png');
  646. await gotoView(M, '/', 1300);
  647. await shot(M, '09-手机-工作台.png');
  648. await gotoView(M, '/reports/competitor', 1300);
  649. await shot(M, '10-手机-类型页.png');
  650. const report = { tag: TAG, rows, shadows: uniq };
  651. writeFileSync(join(SHOTS, `measure-${TAG}.json`), JSON.stringify(report, null, 2));
  652. const ok = rows.filter((r) => r.ok).length;
  653. console.log(`\n${ok}/${rows.length} 通过(${TAG})`);
  654. await browser.close();
  655. process.exit(TAG === 'before' || ok === rows.length ? 0 : 1);