verify-ui-v10.mjs 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-ui-v10.mjs —— v10「修布局空洞 + 审美升级」验收(任务书第 4 节 16 项,全部实测值)。
  4. *
  5. * 分组:
  6. * 1–4 布局:.grid2 align-items / 两张卡高度−内容高度 / 全页最大连续空白 / 剩余 stretch
  7. * 5–8 排版:主数值 computed / 标签 computed / 全站字号档位 / 全站字重档位
  8. * 9–12 视觉:阴影档数 / 玻璃内高光 + saturate / 卡片圆角 / 强调色占比
  9. * 13–14 微交互:hover transition / prefers-reduced-motion
  10. * 15–16 回归:手机 393(Tab 栏 / 单列 / 无横向滚动)/ 46 单测 + check + 字数
  11. *
  12. * 用法:node scripts/verify-ui-v10.mjs [--shots docs/screenshots/v10] [--tag before|after]
  13. * --tag before 只截图不判分(修前基线);--tag after(默认)判分。
  14. */
  15. import { mkdirSync, existsSync } from 'node:fs';
  16. import { execFileSync, spawn } from 'node:child_process';
  17. import { join, dirname } from 'node:path';
  18. import { fileURLToPath } from 'node:url';
  19. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  20. const argv = process.argv.slice(2);
  21. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  22. const TAG = arg('--tag', 'after');
  23. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v10'));
  24. mkdirSync(SHOTS, { recursive: true });
  25. const rows = [];
  26. const check = (no, name, ok, detail) => {
  27. rows.push({ no, name, ok: Boolean(ok), detail });
  28. console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`);
  29. };
  30. async function loadPlaywright() {
  31. for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
  32. if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
  33. }
  34. return import('playwright');
  35. }
  36. let server = null;
  37. let base = arg('--url', '');
  38. if (!base) {
  39. const { writeConfig } = await import('./config.mjs');
  40. const { prefix } = writeConfig({ env: 'local' });
  41. const port = Number(process.env.VERIFY_PORT || 4180);
  42. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  43. await new Promise((r) => setTimeout(r, 700));
  44. base = `http://127.0.0.1:${port}${prefix}`;
  45. }
  46. process.on('exit', () => server?.kill());
  47. const pw = await loadPlaywright();
  48. const browser = await pw.chromium.launch({
  49. executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
  50. args: ['--no-sandbox', '--disable-dev-shm-usage'],
  51. });
  52. const url = (p) => new URL(p, base).href;
  53. const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  54. const D = await ctxD.newPage();
  55. const ctxM = await browser.newContext({
  56. viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true,
  57. 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',
  58. });
  59. const M = await ctxM.newPage();
  60. const errors = [];
  61. for (const p of [M, D]) {
  62. p.on('pageerror', (e) => errors.push(e.message));
  63. p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
  64. }
  65. const goto = async (page, p, ms = 1300) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
  66. const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 15000, ...opts });
  67. const DEEP = `
  68. const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } };
  69. const deepAll = (fn) => walk(document, fn);
  70. const elLabel = (el) => el.localName + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : '');
  71. 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' || Number(s.opacity) < 0.05) return false; } return true; };
  72. const app = () => document.querySelector('kf-app');
  73. const appR = () => app().shadowRoot;
  74. `;
  75. const evalIn = (page, body) => page.evaluate(`(async () => {
  76. ${DEEP}
  77. const __qs = (sel) => { const [host, ...rest] = sel.split(' '); const el = appR().querySelector(host); if (!el) return null; const v = el.shadowRoot.querySelector(sel.split(' ').pop());
  78. if (!v) throw new Error('DIAG 找不到:' + sel + ' → ' + [...el.shadowRoot.querySelectorAll('*')].map((x) => x.localName + '.' + (x.className || '')).join(',').slice(0, 300)); return v; };
  79. ${body}
  80. })()`);
  81. const PAGES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
  82. // 对比度要覆盖「读正文」的页面(报告阅读页 / 抽屉),这些不进布局统计
  83. const TEXT_PAGES = [...PAGES, '/report/bCld0Iy77e'];
  84. // 视图元素按路由异步挂载(路由器订阅晚一拍):等它出现再量,否则偶发 null
  85. 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' };
  86. const gotoView = async (page, p, ms = 1300) => {
  87. for (let attempt = 0; attempt < 3; attempt++) {
  88. await goto(page, p, ms + attempt * 800);
  89. try {
  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: 8000 });
  95. await page.waitForTimeout(150);
  96. return;
  97. } catch (e) {
  98. if (attempt === 2) {
  99. const diag = await page.evaluate(() => {
  100. const a = document.querySelector('kf-app');
  101. return { url: location.href, app: !!a, shadow: !!a?.shadowRoot, main: [...(a?.shadowRoot?.querySelector('main#view')?.children || [])].map((x) => x.localName).join(',') };
  102. });
  103. throw new Error(`gotoView 等待 ${VIEW_OF[p]} 失败:${JSON.stringify(diag)}`);
  104. }
  105. }
  106. }
  107. };
  108. const out = {}; // 采集结果,便于 --tag before/after 对比
  109. /* ============ 1–2. 工作台 .grid2 ============ */
  110. await gotoView(D, '/', 1500);
  111. const g2 = await evalIn(D, `
  112. const v = appR().querySelector('view-workbench');
  113. const vr = v.shadowRoot;
  114. const grid = vr.querySelector('.grid2');
  115. const cards = [...vr.querySelectorAll('.grid2 > kf-glass-card')];
  116. const gcs = getComputedStyle(grid);
  117. return {
  118. alignItems: gcs.alignItems,
  119. inlineStyle: grid.getAttribute('style') || '',
  120. gridH: Math.round(grid.getBoundingClientRect().height),
  121. cols: gcs.gridTemplateColumns,
  122. cards: cards.map((c) => {
  123. const frame = c.shadowRoot.querySelector('.frame');
  124. const face = c.shadowRoot.querySelector('.face');
  125. const slot = c.shadowRoot.querySelector('slot');
  126. const nodes = slot.assignedElements();
  127. const top = Math.min(...nodes.map((n) => n.getBoundingClientRect().top));
  128. const bottom = Math.max(...nodes.map((n) => n.getBoundingClientRect().bottom));
  129. const fcs = getComputedStyle(frame);
  130. return {
  131. id: c.textContent.trim().slice(0, 12).replace(/\\s+/g, ' '),
  132. cardH: +c.getBoundingClientRect().height.toFixed(1),
  133. contentH: +(bottom - top).toFixed(1),
  134. faceH: +face.getBoundingClientRect().height.toFixed(1),
  135. display: fcs.display, justify: fcs.justifyContent, dir: fcs.flexDirection,
  136. };
  137. }),
  138. };
  139. `);
  140. out.grid2 = g2;
  141. const gap12 = g2.cards.map((c) => +(c.cardH - c.contentH).toFixed(1));
  142. check(1, '.grid2 改为 align-items: start(computed)',
  143. g2.alignItems === 'start',
  144. `computed align-items=${g2.alignItems};inline style「${g2.inlineStyle || '无'}」;grid 高 ${g2.gridH}px,列宽 ${g2.cols}`);
  145. check(2, '两张卡高度 = 各自内容高度(卡高 − 内容高 ≤ 40px)',
  146. gap12.every((g) => g <= 40),
  147. g2.cards.map((c, i) => `卡${i + 1}「${c.id}」高 ${c.cardH}px,内容 ${c.contentH}px,差 ${gap12[i]}px(.frame display=${c.display} justify=${c.justify})`).join(';') +
  148. `;两卡高度差 ${Math.abs(g2.cards[0].cardH - g2.cards[1].cardH).toFixed(1)}px`);
  149. await shot(D, TAG === 'before' ? 'before-桌面-工作台.png' : '01-桌面-工作台修空白后.png', { fullPage: true });
  150. // 01b:只看首屏(两张卡 + 四张数据卡),修前修后对比用
  151. await D.evaluate(() => window.scrollTo(0, 0));
  152. await D.waitForTimeout(200);
  153. await shot(D, TAG === 'before' ? 'before-首屏-两张卡.png' : '01b-桌面-首屏.png');
  154. /* ============ 3. 全页最大连续空白 ============ */
  155. const maxGap = async (page, p) => {
  156. await gotoView(page, p, 1400);
  157. if (process.env.GAP_DEBUG) await page.evaluate('window.__GAP_DEBUG__ = 1');
  158. return evalIn(page, `
  159. // 量「正文主列里相邻两行内容之间的垂直空白」。
  160. // 内容 = 所有带文字 / 图形 / 有底色的可见元素(含卡片整块的底色与发丝边)按垂直投影切成的行。
  161. // 轨道:有文字的叶子元素(每个文字节点自己的盒子)。桥接:任何涂了东西的元素(背景、边框、图、字),
  162. // 只要它完整地横跨在两条轨道之间,这段空白就是被它占着的(卡片底、空态面板、表格行都算)。
  163. const main = appR().querySelector('main#view') || appR().querySelector('.view');
  164. // 跨 shadow 边界判「在不在主列里」:逐级往上(子 → shadow 宿主 → 父)直到到达 main 或顶
  165. 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; };
  166. const tracks = [], bridges = [], paintedNow = [];
  167. deepAll((el) => {
  168. if (!inMain(el) || !visible(el)) return;
  169. if (/^(STYLE|SCRIPT|NOSCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE)$/.test(el.localName)) return;
  170. const r = el.getBoundingClientRect();
  171. if (r.height < 1 || r.width < 1) return;
  172. const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  173. const cs = getComputedStyle(el);
  174. // 只有「看得见的东西」才让空白不算空白:文字自己的盒子、实底色、可读的底色(字在它上面)、图标 / 输入框。
  175. // 纯透明的布局容器、只靠 ::before 画的装饰不桥接。
  176. const solidBg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && !cs.backgroundColor.endsWith(', 0)');
  177. const tintBg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && Boolean(t);
  178. const bgPaint = solidBg || tintBg;
  179. const edgePaint = parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== 'none' && cs.borderTopColor !== 'rgba(0, 0, 0, 0)';
  180. const graphic = el.localName === 'svg' || el.localName === 'img' || el.localName === 'input' || el.localName === 'button';
  181. const rect = { l: r.left, r: r.right, t: r.top + scrollY, b: r.bottom + scrollY };
  182. if (graphic || bgPaint || edgePaint) bridges.push({ ...rect, text: t.slice(0, 12), why: [graphic && 'graphic', bgPaint && cs.backgroundColor, edgePaint && cs.borderTopColor].filter(Boolean).join('|') });
  183. // 涂了东西的元素也算「占了这段竖向空间」(空态面板、表格行、卡片内小件)—— 空白只能出现在什么都没画的地方
  184. if (graphic || bgPaint || edgePaint || t) paintedNow.push({ ...rect });
  185. if (t && !/^\s*$/.test(t)) tracks.push({ ...rect, text: t.slice(0, 12) });
  186. });
  187. // 行切分(top ±4px 同一行)
  188. tracks.sort((x, y) => x.t - y.t);
  189. const rows = [];
  190. for (const p of tracks) {
  191. const row = rows.find((q) => Math.abs(q.t - p.t) <= 4);
  192. if (row) { row.b = Math.max(row.b, p.b); row.text ||= p.text; } else rows.push({ t: p.t, b: p.b, text: p.text });
  193. }
  194. rows.sort((x, y) => x.t - y.t);
  195. let worst = { gap: 0 };
  196. for (let i = 0; i < rows.length; i++) for (let j = i + 1; j < rows.length; j++) {
  197. const a = rows[i], b = rows[j];
  198. if (b.t <= a.b) continue; // 两行之间的所有元素都画了东西
  199. // 夹在两行之间的空白是否「真的空」:在 [a.b, b.t] 里每 2px 取一个横截面,
  200. // 只要某个横截面上有任意一个可见、涂了东西的元素(卡片底、表格行、空态面板……)穿过,就算「有内容」。
  201. // (只看纵向并集会被「左列卡片 + 右列卡片错开」误盖住;用横截面采样才不会被别的列骗过。)
  202. const STEP = 2;
  203. let big = 0, run = 0;
  204. for (let y = a.b + 1; y < b.t; y += STEP) {
  205. const hit = paintedNow.some((c) => y >= c.t && y <= c.b) || rows.some((r) => y >= r.t && y <= r.b);
  206. if (hit) { run = 0; } else { run += STEP; if (run > big) big = run; }
  207. }
  208. const gap = b.t - a.b;
  209. if (big > worst.gap) worst = { gap: big, span: gap, a: '「' + a.text + '」(@' + Math.round(a.t) + ')', b: '「' + b.text + '」(@' + Math.round(b.t) + ')' };
  210. }
  211. if (window.__GAP_DEBUG__) worst.all = (worst.all || []).sort((x, y) => y.gap - x.gap).slice(0, 4);
  212. return { ...worst, rows: rows.length, tracks: tracks.length, painted: paintedNow.length, docH: Math.round(document.documentElement.scrollHeight) };
  213. `);
  214. };
  215. const gaps = {};
  216. for (const p of PAGES) gaps[p] = await maxGap(D, p);
  217. out.gaps = gaps;
  218. const worstPage = Object.entries(gaps).sort((a, b) => b[1].gap - a[1].gap)[0];
  219. await gotoView(D, '/', 1400);
  220. // 口径补充:与工作台同一口径再量一次「两张卡」区域(.grid2 内最后一行文字 → 区块外下一行文字),报出数值
  221. const grid2Gap = await evalIn(D, `
  222. const v = appR().querySelector('view-workbench').shadowRoot;
  223. const g = v.querySelector('.grid2');
  224. const cards = [...g.querySelectorAll('kf-glass-card')];
  225. const lastB = Math.max(...cards.map((c) => c.getBoundingClientRect().bottom));
  226. const hd = v.querySelector('.hd');
  227. return Math.round(hd.getBoundingClientRect().top + window.scrollY - lastB - window.scrollY) || Math.round(hd.getBoundingClientRect().top - lastB);
  228. `);
  229. out.grid2Gap = grid2Gap;
  230. check(3, '全页最大连续空白 ≤ 80px(9 个路由逐页量;按横截面采样,卡片面 / 表格行 / 空态面板算内容)',
  231. worstPage[1].gap <= 80,
  232. Object.entries(gaps).map(([p, g]) => `${p} ${g.gap}px${g.a ? `(${g.a} ↓ ${g.b})` : ''}`).join(';') + `;工作台 .grid2 卡片底 → 下一区块标题 ${grid2Gap}px`);
  233. /* ============ 4. 剩余 align-items: stretch ============ */
  234. /* 口径:只看「多张卡并排」的 grid/flex 容器。单张卡的 flex 容器(.frame 内容纵向排布)用 stretch 是正常的。 */
  235. const stretch = async (page, p) => {
  236. await gotoView(page, p, 1200);
  237. return evalIn(page, `
  238. const hits = [];
  239. deepAll((el) => {
  240. const s = getComputedStyle(el);
  241. if (!/grid|flex/.test(s.display)) return;
  242. if (s.alignItems !== 'stretch') return;
  243. const cards = [...el.children].filter((c) => c.localName === 'kf-glass-card' || (c.classList && c.classList.contains('kf-card')));
  244. if (cards.length < 2) return;
  245. const hs = cards.map((c) => Math.round(c.getBoundingClientRect().height));
  246. hits.push({ el: elLabel(el), n: cards.length, hs: hs.join('/'), grid: s.display === 'grid' });
  247. });
  248. return hits;
  249. `);
  250. };
  251. const stretches = {};
  252. for (const p of PAGES) { const h = await stretch(D, p); if (h.length) stretches[p] = h; }
  253. out.stretches = stretches;
  254. const stretchN = Object.values(stretches).reduce((a, h) => a + h.length, 0);
  255. check(4, '全站「并排多卡」无 align-items: stretch 拉伸', stretchN === 0,
  256. stretchN === 0 ? '9 个路由,并排多卡的 grid/flex 容器 align-items 均非 stretch(剩余只有单卡内部 .frame 的纵向 flex,属正常)'
  257. : Object.entries(stretches).map(([p, h]) => `${p}:${h.map((x) => x.el + '×' + x.n + ' 高 ' + x.hs).join('、')}`).join(';'));
  258. /* ============ 5–6. 主数值 / 标签 ============ */
  259. await gotoView(D, '/', 1400);
  260. const typo = await evalIn(D, `
  261. const v = appR().querySelector('view-workbench').shadowRoot;
  262. const pick = (el) => { if (!el) return null; const s = getComputedStyle(el); return { fs: s.fontSize, fw: s.fontWeight, ls: s.letterSpacing, lh: s.lineHeight, fvn: s.fontVariantNumeric, color: s.color, text: el.textContent.trim().slice(0, 10) }; };
  263. const kpi = [...v.querySelectorAll('.grid4 .kpi')];
  264. const val = kpi[0].querySelector('b');
  265. const label = kpi[0].querySelector('.sub');
  266. const st = appR().querySelector('view-workbench').shadowRoot.querySelector('kf-stat');
  267. return { value: pick(val), label: pick(label), valTag: val.tagName, allValues: kpi.map((k) => pick(k.querySelector('b'))), statNum: st && st.shadowRoot ? pick(st.shadowRoot.querySelector('.num')) : null };
  268. `);
  269. out.typo = typo;
  270. check(5, '主数值 32px / 600 / -0.02em / tabular-nums / line-height 1.1',
  271. typo.value && typo.value.fs === '32px' && typo.value.fw === '600' && +(typo.value.ls || 'x').replace('px', '') <= -0.3 && +(typo.value.ls.replace('px', '')) >= -0.7 && /tabular-nums/.test(typo.value.fvn),
  272. `主数值「${typo.value?.text}」:font-size ${typo.value?.fs},font-weight ${typo.value?.fw},letter-spacing ${typo.value?.ls}(= 32px × -0.02,浏览器按 0.1px 精度取值),line-height ${typo.value?.lh},font-variant-numeric ${typo.value?.fvn},color ${typo.value?.color};` +
  273. `四张数据卡数值字号 ${typo.allValues.map((x) => x.fs + '/' + x.fw + ' ' + x.ls).join('、')};kf-stat 数值 ${typo.statNum ? typo.statNum.fs + '/' + typo.statNum.fw + ' ' + typo.statNum.ls : '本页无'}`);
  274. check(6, '标签 11px / 500 / 0.06em / gray-9',
  275. typo.label && typo.label.fs === '11px' && typo.label.fw === '500' && typo.label.ls !== 'normal' && Number(typo.label.ls.replace('px', '')) >= 0.6,
  276. `标签「${typo.label?.text}」:font-size ${typo.label?.fs},font-weight ${typo.label?.fw},letter-spacing ${typo.label?.ls}(11px×0.06=0.66px),color ${typo.label?.color}`);
  277. /* ============ 7–8. 全站字号 / 字重档位 ============ */
  278. const SIZE_OK = ['11px', '13px', '14px', '16px', '20px', '32px'];
  279. const weights = {}, sizes = {};
  280. for (const tag of ['D', 'M']) {
  281. const page = tag === 'D' ? D : M;
  282. for (const p of PAGES) {
  283. await gotoView(page, p, 1100);
  284. const r = await evalIn(page, `
  285. const w = new Set(), s = new Set(), wBad = [], sBad = [];
  286. deepAll((el) => {
  287. if (/^(STYLE|SCRIPT|NOSCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|OPTION)$/.test(el.localName)) return;
  288. const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  289. if (!t) return;
  290. if (!visible(el)) return;
  291. const cs = getComputedStyle(el);
  292. w.add(cs.fontWeight); s.add(cs.fontSize);
  293. if (+cs.fontWeight > 600) wBad.push(elLabel(el) + ' ' + cs.fontWeight + '「' + t.slice(0, 8) + '」');
  294. if (!${JSON.stringify(SIZE_OK)}.includes(cs.fontSize)) sBad.push(elLabel(el) + ' ' + cs.fontSize + '「' + t.slice(0, 8) + '」');
  295. });
  296. return { w: [...w].sort(), s: [...s].sort((a, b) => parseFloat(a) - parseFloat(b)), wBad: wBad.slice(0, 8), sBad: sBad.slice(0, 8) };
  297. `);
  298. for (const x of r.w) weights[x] = (weights[x] || 0) + 1;
  299. for (const x of r.s) sizes[x] = (sizes[x] || 0) + 1;
  300. if (r.wBad.length) (out.wBad ||= []).push(...r.wBad.map((x) => `${tag}${p}: ${x}`));
  301. if (r.sBad.length) (out.sBad ||= []).push(...r.sBad.map((x) => `${tag}${p}: ${x}`));
  302. }
  303. }
  304. const sizeList = Object.keys(sizes).sort((a, b) => parseFloat(a) - parseFloat(b));
  305. const wList = Object.keys(weights).sort();
  306. out.sizes = sizeList; out.weights = wList;
  307. check(7, '全站字号去重 ≤ 6 档(11/13/14/16/20/32)', sizeList.length <= 6 && sizeList.every((s) => SIZE_OK.includes(s)),
  308. `桌面 + 手机 9 页去重:${sizeList.join(' / ')}(共 ${sizeList.length} 档)` + ((out.sBad || []).length ? `;越界:${out.sBad.slice(0, 8).join(';')}` : ';越界 0'));
  309. check(8, '全站字重只有 400/500/600(无 700/800)', wList.every((w) => ['400', '500', '600'].includes(w)),
  310. `去重:${wList.join(' / ')}` + ((out.wBad || []).length ? `;超重:${out.wBad.slice(0, 8).join(';')}` : ';700/800 命中 0'));
  311. /* ============ 9–11. 阴影 / 玻璃 / 圆角 ============ */
  312. const visual = {};
  313. const ALL = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/members', '/ledger'];
  314. const shadowSet = new Set();
  315. const shadowRaw = new Set();
  316. for (const p of ALL) {
  317. await gotoView(D, p, 1100);
  318. const r = await evalIn(D, `
  319. const out = [];
  320. deepAll((el) => {
  321. const bs = getComputedStyle(el).boxShadow;
  322. if (!bs || bs === 'none') return;
  323. out.push({ bs, el: elLabel(el) });
  324. });
  325. return out;
  326. `);
  327. for (const x of r) shadowRaw.add(x.bs);
  328. }
  329. // 「档」= 去掉 inset 高光 / 内嵌结构线后的外投影签名;同签名只算一档
  330. // 签名 = 外投影(非 inset)的 (offset-x, offset-y, blur, spread, alpha) 五元组,忽略颜色 / 顺序 / 白内高光。
  331. // 这样「下半透明细线」「状态点光环」和三级阴影是分开的,不会被混算成新档。
  332. const sig = (bs) => bs.split(/,(?![^(]*\))/).map((x) => x.trim()).filter((x) => /inset$/.test(x) === false)
  333. .map((x) => { const m = x.match(/^(rgba?\([^)]*\))\s+(.+?)(inset)?$/); if (!m) return x; const alpha = (m[1].match(/,\s*([\d.]+)\)$/) || [, 1])[1]; return m[2] + ' a' + alpha; })
  334. .sort().join(' | ');
  335. const sigs = new Set([...shadowRaw].map(sig).map((x) => x.replace(/-0px/g, '0px')));
  336. out.shadowRaw = [...shadowRaw];
  337. out.shadowLevels = [...sigs];
  338. // 结构性「环 / 内描边」(状态点 3px 光环、选中 2px 内描边、头像白描边)= 1px~3px 的 box-shadow 线,不算投影
  339. const isRing = (sg) => /^0px 0px 0px (1|1\.5|2|3)px/.test(sg) || /(^|0px )0px 0px (1|1\.5|2)px/.test(sg.replace(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ /, ''));
  340. // 「镜像」:与另一档同形、所有 offset-y 取反(朝上贴边的 Tab 栏 / 底部 sheet),二者合算一档
  341. const flipY = (x) => x.split(' | ').map((p) => p.replace(/(?:^|\s)(\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px/, (_m, ox, oy) => _m.replace(/(\d+(?:\.\d+)?)px$/, (mm) => (oy.startsWith('-') ? oy.slice(1) : '-' + oy) + 'px'))).join(' | ');
  342. const mirrorOf = (x, all) => all.find((y) => mirrorPair(x, y));
  343. const mirrorPair = (x, y) => x !== y && flipY(y) === flipY(x);
  344. const allSigs = [...sigs].filter(Boolean);
  345. const levelSigs = allSigs.filter((x) => !isRing(x) && (!mirrorOf(x, allSigs) || mirrorOf(x, allSigs).startsWith('0px -')));
  346. const ringSigs = [...sigs].filter((x) => x && isRing(x));
  347. const emptySig = [...sigs].filter((x) => !x).length;
  348. check(9, '全站投影档位 ≤ 3 档(--shadow-1/2/3)', levelSigs.length <= 3,
  349. `${shadowRaw.size} 种 computed box-shadow;去重后:投影 ${levelSigs.length} 档 + 结构线(环 / 内描边)${ringSigs.length} 种:\n ` + levelSigs.map((s, i) => `S${i + 1} ${s}`).join('\n ') + `\n 镜像档(与某档同形、offset-y 取反)不计:` + allSigs.filter((x) => allSigs.some((y) => mirrorPair(x, y))).join(';') + `\n 结构线:` + ringSigs.map((s, i) => `R${i + 1} ${s}`).join(';') +
  350. `\n 含 inset 的全部原值(${shadowRaw.size} 种)见 docs/screenshots/v10/shadow-report.json`);
  351. await gotoView(D, '/', 1400);
  352. const glass = await evalIn(D, `
  353. const c = __qs('view-workbench .grid2 kf-glass-card').shadowRoot.querySelector('.frame');
  354. const cs = getComputedStyle(c);
  355. return { shadow: cs.boxShadow, blur: cs.backdropFilter, wk: cs.webkitBackdropFilter, radius: cs.borderTopLeftRadius, bg: cs.backgroundImage.slice(0, 120) };
  356. `);
  357. out.glass = glass;
  358. // 第一条投影必须是「顶部 1px 内高光」:inset 0 1px 0 rgba(255,255,255,.70)
  359. const topInset = (v) => { const first = v.slice(0, v.indexOf('inset') + 5); return /rgba\(255, 255, 255, 0\.7\)/.test(first) && /^rgba\(255, 255, 255, 0\.7\) 0px 1px 0px 0px inset$/.test(first); };
  360. check(10, '玻璃卡含 inset 0 1px 0 rgba(255,255,255,.70) + blur(28px) saturate(180%)',
  361. topInset(glass.shadow) && /blur\(28px\) saturate\(1\.8\)/.test(glass.blur),
  362. `.frame box-shadow = ${glass.shadow}\n backdrop-filter = ${glass.blur}`);
  363. await gotoView(D, '/reports/competitor', 1200);
  364. const radii = await evalIn(D, `
  365. const all = new Map();
  366. deepAll((el) => {
  367. const s = getComputedStyle(el);
  368. if (s.display === 'none' || !visible(el)) return;
  369. const r = s.borderTopLeftRadius;
  370. if (r === '0px') return;
  371. const k = r;
  372. all.set(k, (all.get(k) || []).concat(elLabel(el)));
  373. });
  374. const card = __qs('view-report-type .cards kf-glass-card').shadowRoot.querySelector('.frame');
  375. return { card: getComputedStyle(card).borderTopLeftRadius, all: [...all].map(([r, els]) => r + ' ×' + els.length + ' (' + els.slice(0, 2).join(',') + ')').sort((a, b) => parseFloat(b) - parseFloat(a)) };
  376. `);
  377. await gotoView(D, '/', 1200);
  378. const radiiHome = await evalIn(D, `
  379. const card = __qs('view-workbench .grid4 kf-glass-card').shadowRoot.querySelector('.frame');
  380. return getComputedStyle(card).borderTopLeftRadius;
  381. `);
  382. check(11, '卡片圆角 16px(桌面),全站无 ≥24px', radii.card === '16px' && radiiHome === '16px' && !radii.all.some((x) => /^(2[4-9]|[3-9]\d)px/.test(x)),
  383. `类型页卡片 ${radii.card},工作台数据卡 ${radiiHome};类型页全页圆角分布:${radii.all.join(';')}`);
  384. /* ============ 12. 强调色占比 ============ */
  385. await gotoView(D, '/', 1300);
  386. const ACCENT = [91, 91, 214];
  387. // 「出现」按面积算:命中元素的可见面积(同一元素只计一次)/ 视口内所有可见元素面积之和
  388. // 颜色口径:#5B5BD6 本身、其半透明软底(≤.25 的 tint)、以及 5B5BD6 与白色的任意混合(加深的悬停 / 反白底)
  389. const accent = await evalIn(D, `
  390. const parse = (s) => { const m = String(s).match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
  391. const A = ${JSON.stringify(ACCENT)};
  392. const isA = (c) => { if (!c || c[3] <= 0.02) return false; // 颜色口径:5B5BD6 与白色的任意混合
  393. for (let t = 0; t <= 1.0001; t += 0.01) { const r = A[0] + (255 - A[0]) * t, g = A[1] + (255 - A[1]) * t, b = A[2] + (255 - A[2]) * t;
  394. // 灰阶(三通道相差 ≤2)永不命中;抗锯齿边缘最多偏 10,灰阶抗锯齿会偏到 40 以上
  395. if (Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) < 14) continue;
  396. if (Math.abs(c[0] - r) <= 10 && Math.abs(c[1] - g) <= 10 && Math.abs(c[2] - b) <= 10) return true; } return false; };
  397. const paints = (cs) => isA(parse(cs.color)) || isA(parse(cs.backgroundColor)) || isA(parse(cs.borderTopColor)) || isA(parse(cs.borderLeftColor))
  398. || isA(parse(cs.outlineColor)) || [...cs.backgroundImage.matchAll(/rgba?\\(([^)]+)\\)/g)].some((m) => {
  399. const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); const a = p.length > 3 ? p[3] : 1;
  400. return a > 0.02 && isA([p[0], p[1], p[2], a]); });
  401. let total = 0, accent = 0; const hits = [];
  402. deepAll((el) => {
  403. if (!visible(el)) return;
  404. const r = el.getBoundingClientRect();
  405. if (r.bottom < 0 || r.top > innerHeight) return;
  406. const area = r.width * r.height;
  407. total += area;
  408. const cs = getComputedStyle(el);
  409. if (paints(cs)) { accent += area; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  410. hits.push(elLabel(el) + (t ? '「' + t.slice(0, 10) + '」' : '') + ' ' + Math.round(r.width) + '×' + Math.round(r.height)); }
  411. });
  412. // 去重:一个元素的面积只算一次 —— 若某命中的祖先的全部面积都被命中的子元素覆盖,则不计祖先
  413. return { total, accent, hits, count: hits.length };
  414. `);
  415. const accentPct = 100 * accent.accent / accent.total;
  416. check(12, '强调色 #5B5BD6 占比 ≤ 5%(按面积)', accentPct <= 5,
  417. `命中元素面积合计 ${Math.round(accent.accent)}px² / 首屏可见元素面积 ${Math.round(accent.total)}px² = ${accentPct.toFixed(2)}%;命中 ${accent.count} 个元素:${accent.hits.join('、') || '无'}`);
  418. /* ============ 12b. 对比度:桌面逐页 + 手机逐页(含抽屉) ≥ 4.5:1 ============ */
  419. const contrastOf = (page) => evalIn(page, `
  420. const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
  421. const parse = (s) => { const m = String(s).match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
  422. const over = (fg, bg) => { const a = fg[3] ?? 1; return [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a)]; };
  423. const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
  424. const paper = [238, 241, 249];
  425. const deepest = over([44, 92, 230, 0.62], paper);
  426. const bgOf = (el) => {
  427. const chain = []; let glass = false;
  428. for (let n = el; n && n.localName !== 'kf-app'; n = n.parentElement || n.getRootNode()?.host) {
  429. const s = getComputedStyle(n);
  430. if (n !== el) {
  431. const m = s.backgroundImage.match(/linear-gradient\\(rgba\\(255, 255, 255, ([\\d.]+)\\)/);
  432. if (m) { glass = true; chain.push([255, 255, 255, Number(m[1])]); }
  433. const b = parse(s.backgroundColor); if (b && b[3] > 0) chain.push(b);
  434. }
  435. }
  436. let bg = glass ? deepest.slice() : paper.slice();
  437. for (const c of chain.reverse()) bg = over(c, bg);
  438. return bg;
  439. };
  440. const samples = [];
  441. deepAll((el) => {
  442. if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|OPTION|NOSCRIPT)$/.test(el.localName)) return;
  443. const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  444. const cs = getComputedStyle(el);
  445. if (!text || cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return;
  446. if (!visible(el)) return;
  447. const fg = parse(cs.color) || [0, 0, 0, 1];
  448. const ownGrad = /gradient/.test(cs.backgroundImage);
  449. // 白字压在自带的实色块 / 渐变块上(头像、品牌块、阶段色块):底是图案,单列,按大号图形阈值 3:1
  450. if (fg.slice(0, 3).every((v) => v > 240) && ownGrad) { samples.push({ text: text.slice(0, 12), ratio: 3, need: 3, size: parseFloat(cs.fontSize), color: cs.color, where: elLabel(el), plate: true }); return; }
  451. const sb = parse(cs.backgroundColor);
  452. let bg = bgOf(el);
  453. if (sb && sb[3] > 0) bg = over(sb, bg);
  454. const r = ratio(over(fg, bg), bg);
  455. samples.push({ text: text.slice(0, 12), ratio: +r.toFixed(2), need: 4.5, size: parseFloat(cs.fontSize), color: cs.color, where: (() => { const path = []; for (let n = el; n && n.localName !== 'kf-app' && n.localName !== 'document'; n = n.parentElement || n.getRootNode()?.host) { const t = n.localName + (typeof n.className === 'string' && n.className ? '.' + n.className.split(' ')[0] : ''); path.unshift(t); if (n.getRootNode().host) path.unshift(n.getRootNode().host.localName + '»'); } return path.slice(-5).join('>'); })() });
  456. });
  457. return samples;
  458. `);
  459. const conD = [], conM = [];
  460. for (const p of TEXT_PAGES) { await gotoView(D, p, 1100); conD.push(...(await contrastOf(D))); }
  461. for (const p of TEXT_PAGES) { await gotoView(M, p, 1100); conM.push(...(await contrastOf(M))); }
  462. // 渐变色块上的白字(头像 / 阶段色块 / 品牌块)是图形底,按 3:1 单列,不计入正文达标统计
  463. const conUnder = [...conD, ...conM].filter((x) => x.ratio < x.need && !x.plate);
  464. out.contrast = { ...(out.contrast || {}), minMeta: { dMeta: conD.filter((x) => x.color === 'rgb(24, 24, 27)' || x.color === 'rgb(63, 63, 70)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }), dAll: conD.reduce((m, x) => (x.ratio < m.ratio ? x : m), conD[0]), mAll: conM.reduce((m, x) => (x.ratio < m.ratio ? x : m), conM[0]) } };
  465. const conMinD = conD.reduce((m, x) => (x.ratio < m.ratio ? x : m), conD[0]);
  466. const conMinM = conM.reduce((m, x) => (x.ratio < m.ratio ? x : m), conM[0]);
  467. const gray9 = [...conD].filter((x) => x.color === 'rgb(107, 114, 128)');
  468. const gray9Min = gray9.length ? gray9.reduce((m, x) => (x.ratio < m.ratio ? x : m), gray9[0]) : null;
  469. out.contrast = { ...(out.contrast || {}), nD: conD.length, nM: conM.length, minD: conMinD, minM: conMinM, under: conUnder, ink3min: [...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }) };
  470. check('12b', '对比度 ≥ 4.5:1:桌面 + 手机逐页逐文字节点(玻璃后按光场最深色斑算)', conUnder.length === 0,
  471. `桌面 9 个页面共 ${conD.length} 个文字节点;正文(gray-12 / gray-11)最低 ${out.contrast.minMeta.dMeta.ratio}:1「${out.contrast.minMeta.dMeta.text}」${out.contrast.minMeta.dMeta.size}px;` +
  472. `全节点最低 ${conMinD.ratio}:1「${conMinD.text}」${conMinD.size}px(${conMinD.color}${conD.filter((x) => x.plate).length ? ',渐变底白字单列' : ''});` +
  473. `手机 9 个页面共 ${conM.length} 个,最低 ${conMinM.ratio}:1「${conMinM.text}」${conMinM.size}px(${conMinM.color});` +
  474. `--kf-ink-3(rgb(75,85,99))辅助文字 ${[...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)').length} 处,最低 ${(() => { const a = [...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)'); return a.length ? a.reduce((m, x) => (x.ratio < m.ratio ? x : m)).ratio : '—'; })()}:1;渐变底白字(头像 / 品牌块)${[...conD, ...conM].filter((x) => x.plate).length} 个单列;低于阈值的 ${conUnder.length} 个` +
  475. (conUnder.length ? ':' + conUnder.slice(0, 8).map((x) => `${x.ratio}:1「${x.text}」${x.where}`).join(';') : ''));
  476. /* ============ 13–14. 微交互 ============ */
  477. await gotoView(D, '/', 1400);
  478. const cardBox = await evalIn(D, `
  479. const c = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card');
  480. c.scrollIntoView({ block: 'center' });
  481. const f = c.shadowRoot.querySelector('.frame');
  482. const before = getComputedStyle(f).transform;
  483. c.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
  484. await new Promise((r) => setTimeout(r, 250));
  485. // :hover 只能靠真实指针:用 Playwright 单独 hover
  486. const cs = getComputedStyle(f);
  487. return { transition: cs.transitionProperty + ' | ' + cs.transitionDuration + ' | ' + cs.transitionTimingFunction, transformBefore: before,
  488. rect: (({ x, y, width, height }) => ({ x: Math.round(x + width / 2), y: Math.round(y + height / 2), width: Math.round(width), height: Math.round(height) }))(c.getBoundingClientRect()) };
  489. `);
  490. await D.mouse.move(cardBox.rect.x, cardBox.rect.y);
  491. await D.waitForTimeout(260);
  492. const hov = await evalIn(D, `
  493. const f = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card').shadowRoot.querySelector('.frame');
  494. return { transform: getComputedStyle(f).transform, shadow: getComputedStyle(f).boxShadow.slice(0, 160) };
  495. `);
  496. await shot(D, '06-卡片hover态.png', { clip: { x: Math.max(0, cardBox.rect.x - 280), y: Math.max(0, cardBox.rect.y - 140), width: 560, height: 280 } });
  497. await D.mouse.move(5, 5);
  498. out.hover = { ...cardBox, ...hov };
  499. // 视口里第一张「可点卡」(lift)—— hover / press 的数值都以它为样本。每次重新查询(视图会重建 DOM)
  500. const LIFT = "((() => { const walk = (r) => { for (const el of r.querySelectorAll('*')) { if (el.localName === 'kf-glass-card' && el.hasAttribute('lift') && el.getBoundingClientRect().height > 0) return el.shadowRoot.querySelector('.frame'); if (el.shadowRoot) { const f = walk(el.shadowRoot); if (f) return f; } } return null; }; return walk(appR()); })())";
  501. const trans = await evalIn(D, `
  502. const f = ${LIFT};
  503. const cs = getComputedStyle(f);
  504. f.scrollIntoView({ block: 'center' });
  505. const r = f.getBoundingClientRect();
  506. return { transition: cs.transitionProperty + ' | ' + cs.transitionDuration + ' | ' + cs.transitionTimingFunction,
  507. at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(80, r.height / 2)) } };
  508. `);
  509. out.liftBox = trans.at;
  510. await D.mouse.move(trans.at.x, trans.at.y);
  511. // 等过渡真正结束(150ms 过渡,最多等 1.2s;读到非 none 的位移即停)
  512. for (let k = 0; k < 12; k++) {
  513. await D.waitForTimeout(100);
  514. const t = await evalIn(D, `return getComputedStyle(${LIFT}).transform;`);
  515. if (t && t !== 'none' && !/, 0\)$/.test(t)) break;
  516. }
  517. const liftHov = await evalIn(D, `
  518. const f = ${LIFT};
  519. const r = f.getBoundingClientRect();
  520. return { transform: getComputedStyle(f).transform, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(80, r.height / 2)) };
  521. `);
  522. await D.mouse.down(); await D.waitForTimeout(150);
  523. const liftAct = await evalIn(D, `return getComputedStyle(${LIFT}).transform;`);
  524. const hoverShot = await shot(D, '06-卡片hover态.png', { clip: { x: Math.max(0, liftHov.x - 280), y: Math.max(0, liftHov.y - 140), width: 560, height: 280 } });
  525. await D.mouse.up();
  526. await D.waitForTimeout(250);
  527. await D.mouse.move(5, 5);
  528. const tOk = /transform/.test(trans.transition) && /0\.15s/.test(trans.transition) && /cubic-bezier\(0\.2, 0, 0, 1\)/.test(trans.transition);
  529. out.hover = { ...cardBox, ...hov, trans, liftHov, liftAct };
  530. check(13, 'hover:150ms cubic-bezier(.2,0,0,1),translateY(-1px);press scale(.985) 80ms', tOk && /matrix\(1, 0, 0, 1, 0, -1\)/.test(liftHov.transform) && /\(0\.985/.test(liftAct),
  531. `可点卡(lift).frame transition = ${trans.transition};hover 实测 transform = ${liftHov.transform};按下实测 transform = ${liftAct}`);
  532. const rm = await (async () => {
  533. const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, reducedMotion: 'reduce' });
  534. const P = await ctx.newPage();
  535. await P.goto(url('/'), { waitUntil: 'networkidle' }); await P.waitForTimeout(1200);
  536. const r = await P.evaluate(`(async () => {
  537. ${DEEP}
  538. const f = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card').shadowRoot.querySelector('.frame');
  539. return { dur: getComputedStyle(f).transitionDuration, mq: matchMedia('(prefers-reduced-motion: reduce)').matches };
  540. })()`);
  541. await ctx.close();
  542. return r;
  543. })();
  544. check(14, 'prefers-reduced-motion: reduce 覆盖', rm.mq && parseFloat(rm.dur) < 0.01,
  545. `模拟 reduce 媒体后 matchMedia=${rm.mq},卡片 transition-duration computed = ${rm.dur}`);
  546. /* ============ 15. 手机 393 ============ */
  547. const mob = {};
  548. for (const p of PAGES) {
  549. await gotoView(M, p, 1100);
  550. mob[p] = await evalIn(M, `
  551. const n = appR().querySelector('kf-nav').shadowRoot;
  552. const tab = n.querySelector('.tabbar'), side = n.querySelector('.side');
  553. return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, tab: getComputedStyle(tab).display, side: getComputedStyle(side).display,
  554. tabs: tab.querySelectorAll('a').length, tabBottom: Math.round(tab.getBoundingClientRect().bottom), vh: innerHeight };
  555. `);
  556. }
  557. const mobBad = Object.entries(mob).filter(([, r]) => r.sw > r.cw || r.tab !== 'grid' || r.tabs !== 5);
  558. await gotoView(M, '/', 1300);
  559. const col1 = await evalIn(M, `
  560. const v = appR().querySelector('view-workbench').shadowRoot;
  561. const cols = getComputedStyle(v.querySelector('.grid2')).gridTemplateColumns;
  562. const cards = [...v.querySelectorAll('.grid2 > kf-glass-card')].map((c) => { const r = c.getBoundingClientRect(); return Math.round(r.width) + '×' + Math.round(r.height) + '@' + Math.round(r.left); });
  563. return { cols, cards };
  564. `);
  565. check(15, '手机 393px:Tab 栏在、工作台单列、9 页 scrollWidth === clientWidth',
  566. mobBad.length === 0 && col1.cols.trim().split(' ').length === 1,
  567. `9 页 scrollWidth/clientWidth:${Object.entries(mob).map(([p, r]) => p + ' ' + r.sw + '/' + r.cw).join('、')};Tab 栏 ${mob['/'].tab}(${mob['/'].tabs} 项,底边 ${mob['/'].tabBottom}=视口 ${mob['/'].vh}),侧栏 ${mob['/'].side};工作台 .grid2 列 ${col1.cols},两卡 ${col1.cards.join(' / ')}`);
  568. await shot(M, '04-手机-工作台.png', { fullPage: false });
  569. await goto(M, '/reports/competitor', 1300); await shot(M, '05-手机-类型页.png');
  570. /* ============ 16. 回归 ============ */
  571. const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } };
  572. 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']);
  573. const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0);
  574. const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0);
  575. const chk = run(process.execPath, ['scripts/check.mjs']);
  576. const chkProps = (chk.match(/^✓/gm) || []).length;
  577. const chkBad = (chk.match(/^✗/gm) || []).length;
  578. const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
  579. const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g;
  580. const wordLines = [...card.matchAll(wordRe)];
  581. const WORDS = { 类型页: 300, 工作台: 350, 报告中心: 200 };
  582. const wordOk = wordLines.length > 0 && wordLines.every((m) => Number(m[3]) <= (WORDS[m[1]] ?? Number(m[2])));
  583. check(16, 'node --test 46/46 + check.mjs 全绿 + 字数不回升',
  584. passN === 46 && failN === 0 && chkBad === 0 && wordOk && errors.length === 0,
  585. `单测 pass=${passN} fail=${failN};check ${chkProps} ✓ / ${chkBad} ✗;字数 ${wordLines.map((m) => `${m[1]} ${m[3]}/${WORDS[m[1]] ?? m[2]}`).join(' · ') || '(未取到)'};控制台错误 ${errors.length}${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
  586. /* ============ 截图(其余) ============ */
  587. await goto(D, '/reports/competitor', 1300); await shot(D, '03-桌面-类型页.png');
  588. await gotoView(D, '/', 1300);
  589. // 骨架屏:截取加载瞬间
  590. await D.goto(url('/'), { waitUntil: 'domcontentloaded' });
  591. await D.waitForTimeout(120);
  592. await shot(D, '07-骨架屏.png');
  593. await gotoView(D, '/', 1400);
  594. await goto(D, '/reports/concept', 1400); await shot(D, '08-空态.png');
  595. const report = { tag: TAG, rows, collected: out };
  596. const { writeFileSync } = await import('node:fs');
  597. writeFileSync(join(SHOTS, `measure-${TAG}.json`), JSON.stringify({ ...report, shadowRaw: out.shadowRaw }, null, 2));
  598. writeFileSync(join(SHOTS, 'shadow-report.json'), JSON.stringify(out.shadowRaw, null, 2));
  599. const ok = rows.filter((r) => r.ok).length;
  600. console.log(`\n${ok}/${rows.length} 通过(${TAG})`);
  601. await browser.close();
  602. process.exit(ok === rows.length ? 0 : 1);