verify-ui-v9.mjs 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-ui-v9.mjs —— v9「移动端精美适配 + 桌面三项遗留」验收(任务书第 5 节 16 项,全部实测值)。
  4. *
  5. * 手机断点:iPhone 14 Pro 393 × 852(DIP)、DPR 3、isMobile、touch —— 和真人手机同一个渲染路径(含 safe-area 变量)。
  6. * 1–7 布局:横向溢出 = 0 / 导航隐藏 Tab 出现 / Tab 栏 safe-area / 筛选 sheet / 单列 1–1.5 张 / 触摸 ≥44 / tabs 横滑
  7. * 8–11 视觉:光场 ≥3 团铺满 / 三档玻璃可测 / :active 缩放 / 正文 ≥15px
  8. * 12–14 桌面:筛选条 .55 / 逐节点对比度(手机 + 桌面)/ 徽标收敛
  9. * 15–16 回归:46 单测 + check 0 失败 + 字数 / 截图交付
  10. *
  11. * 用法:node scripts/verify-ui-v9.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v9]
  12. */
  13. import { existsSync, mkdirSync } from 'node:fs';
  14. import { execFileSync, spawn } from 'node:child_process';
  15. import { join, dirname } from 'node:path';
  16. import { fileURLToPath } from 'node:url';
  17. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  18. const argv = process.argv.slice(2);
  19. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  20. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v9'));
  21. mkdirSync(SHOTS, { recursive: true });
  22. const rows = [];
  23. const check = (no, name, ok, detail) => {
  24. rows.push({ no, name, ok: Boolean(ok), detail });
  25. console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`);
  26. };
  27. async function loadPlaywright() {
  28. for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
  29. if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
  30. }
  31. return import('playwright');
  32. }
  33. let server = null;
  34. let base = arg('--url', '');
  35. if (!base) {
  36. const { writeConfig } = await import('./config.mjs');
  37. const { prefix } = writeConfig({ env: 'local' });
  38. const port = Number(process.env.VERIFY_PORT || 4179);
  39. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  40. await new Promise((r) => setTimeout(r, 700));
  41. base = `http://127.0.0.1:${port}${prefix}`;
  42. }
  43. process.on('exit', () => server?.kill());
  44. const pw = await loadPlaywright();
  45. const browser = await pw.chromium.launch({
  46. executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
  47. args: ['--no-sandbox', '--disable-dev-shm-usage'],
  48. });
  49. const url = (p) => new URL(p, base).href;
  50. const ctxM = await browser.newContext({
  51. viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true,
  52. 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',
  53. });
  54. const M = await ctxM.newPage();
  55. const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  56. const D = await ctxD.newPage();
  57. const errors = [];
  58. for (const p of [M, D]) {
  59. p.on('pageerror', (e) => errors.push(e.message));
  60. p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
  61. }
  62. const goto = async (page, p, ms = 1300) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
  63. // animations: 'disabled' —— 光场慢漂移 + 骨架 shimmer 是无限动画,截图默认会一直等「动画稳定」
  64. const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 15000, ...opts });
  65. /* 深遍历 Shadow DOM 的公共工具(注入页面) */
  66. const DEEP = `
  67. const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } };
  68. const deepAll = (fn) => { walk(document, fn); const app = document.querySelector('kf-app'); if (app?.shadowRoot) walk(app.shadowRoot, fn); };
  69. const label = (el) => el.localName + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : '');
  70. 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; };
  71. const app = () => document.querySelector('kf-app');
  72. const appR = () => app().shadowRoot;
  73. `;
  74. const evalIn = (page, body) => page.evaluate(`(async () => {
  75. const NOOP = () => {};
  76. ${DEEP}
  77. ${body}
  78. })()`);
  79. /* ============ 1. 横向溢出 = 0(逐页扫所有元素 rect) ============ */
  80. const PAGES = ['/', '/pool', '/stages', '/report', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
  81. const overflow = {};
  82. for (const p of PAGES) {
  83. await goto(M, p, 1100);
  84. const r = await evalIn(M, `
  85. const vw = document.documentElement.clientWidth;
  86. const bad = [];
  87. deepAll((el) => {
  88. const s = getComputedStyle(el);
  89. if (s.position === 'fixed' && el.getRootNode() === document) return;
  90. // 刻意横滑的容器(tabs / chips / 表格)内部元素不算:它们是 overflow 容器里的滚动内容
  91. if (el.closest('.tabs') || el.closest('.mchips') || el.closest('.chips')) return;
  92. if (el.closest('.wrap') || el.closest('table')) return; // 刻意横滑的表格:外层 .wrap 自己滚动
  93. if (el.getRootNode()?.host?.localName === 'kf-drawer' && !el.getRootNode().host.open) return; // 关闭的抽屉整块在屏外
  94. const rc = el.getBoundingClientRect();
  95. if (rc.width < 0.5 && rc.height < 0.5) return;
  96. if (s.visibility === 'hidden' || s.display === 'none') return;
  97. const right = Math.round(rc.right), left = Math.round(rc.left);
  98. if (right > vw + 0.5 || left < -0.5) bad.push({ el: label(el), left, right, w: Math.round(rc.width) });
  99. });
  100. return { vw, sw: document.documentElement.scrollWidth, bw: document.body.scrollWidth, bad: bad.slice(0, 8), n: bad.length };
  101. `);
  102. overflow[p] = r;
  103. }
  104. const overPages = Object.entries(overflow).filter(([, r]) => r.n > 0 || r.sw > r.vw);
  105. check(1, '393px 下横向溢出 = 0(9 个路由逐元素 getBoundingClientRect)',
  106. overPages.length === 0,
  107. `视口 ${overflow['/'].vw}px;${PAGES.map((p) => `${p} ${overflow[p].n} 处`).join('、')};documentElement.scrollWidth ${PAGES.map((p) => overflow[p].sw).join('/')};` +
  108. (overPages.length ? `越界示例:${overPages.map(([p, r]) => p + ' → ' + r.bad.map((b) => `${b.el} right:${b.right} w:${b.w}`).join(';')).join(' | ')}` : '越界元素 0 个'));
  109. /* ============ 2. 左侧导航隐藏,底部 Tab 出现 ============ */
  110. await M.goto(url('/reports/competitor'), { waitUntil: 'load' });
  111. await M.evaluate(() => sessionStorage.clear());
  112. // 上下文设了 isMobile:history 导航被挡,深链接统一走 location 整页加载;先回白页再开,确保 load 事件
  113. await M.goto('about:blank');
  114. await M.goto(url('/report/bCld0Iy77e'), { waitUntil: 'domcontentloaded' });
  115. await M.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('view-report'), null, { timeout: 10000 });
  116. await M.waitForFunction(() => document.querySelector('kf-app').shadowRoot.querySelector('view-report').shadowRoot?.childElementCount > 0, null, { timeout: 10000 });
  117. await M.waitForTimeout(800);
  118. const navM = await evalIn(M, `
  119. const n = appR().querySelector('kf-nav');
  120. const side = n.shadowRoot.querySelector('.side'), tab = n.shadowRoot.querySelector('.tabbar');
  121. const sr = side.getBoundingClientRect(), tr = tab.getBoundingClientRect();
  122. const items = [...tab.querySelectorAll('a')];
  123. const cur = items.find((a) => a.getAttribute('aria-current') === 'page');
  124. const cs = getComputedStyle(tab);
  125. const sheetText = (root) => [...(root.adoptedStyleSheets || []), ...[...root.querySelectorAll('style')].map((st) => st.sheet)].map((sh) => { try { return [...sh.cssRules].map((r) => r.cssText).join(' '); } catch (e) { return ''; } }).join(' ');
  126. const navCss = sheetText(n.shadowRoot);
  127. const topCss = sheetText(appR().querySelector('kf-topbar').shadowRoot);
  128. const viewCss = sheetText(appR());
  129. return {
  130. sideDisplay: getComputedStyle(side).display, tabDisplay: getComputedStyle(tab).display,
  131. tab: { x: Math.round(tr.x), y: Math.round(tr.y), w: Math.round(tr.width), h: Math.round(tr.height), bottom: Math.round(tr.bottom) },
  132. items: items.map((a) => ({ label: a.querySelector('.short').textContent, cur: a.getAttribute('aria-current'), bg: getComputedStyle(a).color, solid: getComputedStyle(a.querySelector('.ico .sl')).display, line: getComputedStyle(a.querySelector('.ico .ln')).display })),
  133. indicator: cur ? getComputedStyle(cur, '::after').background.slice(0, 40) + ' / ' + getComputedStyle(cur, '::after').height : '',
  134. padBottom: cs.paddingBottom, blur: cs.backdropFilter, bg: cs.backgroundImage.slice(0, 90),
  135. badge: [...tab.querySelectorAll('.badge')].map((b) => b.dataset.count + ':' + b.textContent.trim() + ' ' + getComputedStyle(b).height),
  136. firstCardTop: Math.round(appR().querySelector('main#view').firstElementChild?.getBoundingClientRect().top ?? -1),
  137. vh: innerHeight,
  138. envTab: /calc\\(6px \\+ env\\(safe-area-inset-bottom, 0px\\)\\)/.test(navCss),
  139. envView: viewCss.includes('calc(76px + env(safe-area-inset-bottom))'),
  140. envTop: topCss.includes('env(safe-area-inset-top'),
  141. envLocal: getComputedStyle(tab).paddingBottom,
  142. };
  143. `);
  144. check(2, '手机隐藏左侧竖栏,底部 Tab 出现且当前页高亮(实心图标 + 品牌色 + 顶部 2px 指示条)',
  145. navM.sideDisplay === 'none' && navM.tabDisplay === 'grid' && (navM.items.length === 5) && navM.items.filter((i) => i.cur === 'page').length === 1 && navM.items.find((i) => i.cur === 'page')?.label === '报告',
  146. `左侧竖栏 display=${navM.sideDisplay};底部 Tab display=${navM.tabDisplay},${navM.items.length} 项(${navM.items.map((i) => i.label + (i.cur === 'page' ? '●' : '')).join(' / ')});` +
  147. `当前项「${navM.items.find((i) => i.cur === 'page')?.label}」实心图标 display=${navM.items.find((i) => i.cur === 'page')?.solid}、线性图标 ${navM.items.find((i) => i.cur === 'page')?.line}、字色 ${navM.items.find((i) => i.cur === 'page')?.bg};` +
  148. `指示条 ${navM.indicator};Tab 栏 ${navM.tab?.w}×${navM.tab?.h},位于 y=${navM.tab?.y}–${navM.tab?.bottom}(视口高 ${navM.vh});角标 ${(navM.badge || []).join(',')};${navM.badRoute ? 'DIAG ' + navM.badRoute : ''}` +
  149. `首张卡片 top=${navM.firstCardTop}(内容不再被导航压到屏外)`);
  150. /* ============ 3. Tab 栏 safe-area ============ */
  151. check(3, 'Tab 栏底部内边距是 env(safe-area-inset-bottom)(含在 padding-bottom 计算里)',
  152. navM.envTab && navM.envView && navM.envTop && navM.tab.bottom <= navM.vh + 0.5,
  153. `Tab 栏 CSS padding-bottom = calc(6px + env(safe-area-inset-bottom, 0px))(本机 Chromium 无刘海,env 解析 0 → computed ${navM.envLocal};真机 iPhone 34px → 40px);` +
  154. `底部固定:底边 ${navM.tab.bottom} = 视口高 ${navM.vh};页面 .view 底部补白 calc(76px + env(safe-area-inset-bottom));顶栏 padding-top calc(8px + env(safe-area-inset-top, 0px));Tab 栏玻璃 ${navM.blur}`);
  155. /* ============ 4. 筛选 → 底部 sheet,分组折叠 ============ */
  156. await goto(M, '/reports/competitor', 1300);
  157. await shot(M, '01-手机-类型页.png');
  158. const sheet = await evalIn(M, `
  159. const v = appR().querySelector('view-report-type').shadowRoot;
  160. const btn = v.querySelector('[data-open-sheet]');
  161. const br = btn.getBoundingClientRect();
  162. btn.click();
  163. await new Promise((r) => setTimeout(r, 200));
  164. return { btn: { h: Math.round(br.height), w: Math.round(br.width), text: btn.textContent.trim().replace(/\\s+/g, ' ') }, desktopTB: getComputedStyle(v.querySelector('.tb > .tb-row')).display };
  165. `);
  166. await M.waitForTimeout(350);
  167. const sheetOpen = await evalIn(M, `
  168. const v = appR().querySelector('view-report-type').shadowRoot;
  169. const s = v.querySelector('kf-filter-sheet');
  170. if (!s) return { found: false };
  171. const sec = s.shadowRoot.querySelector('.sheet');
  172. const cs = getComputedStyle(sec);
  173. const r = sec.getBoundingClientRect();
  174. return { found: true, open: s.hasAttribute('open'), w: Math.round(r.width), h: Math.round(r.height), bottom: Math.round(r.bottom),
  175. radius: cs.borderRadius, blur: cs.backdropFilter, bg: cs.backgroundImage.slice(0, 60),
  176. thumb: Boolean(s.shadowRoot.querySelector('.handle')), footer: s.shadowRoot.querySelector('.ft').textContent.trim().replace(/\\s+/g, ' '),
  177. groups: [...s.shadowRoot.querySelectorAll('.grow-row')].map((b) => b.querySelector('.gl').textContent),
  178. expanded: [...s.shadowRoot.querySelectorAll('.grow-row')].filter((b) => b.getAttribute('aria-expanded') === 'true').map((b) => b.querySelector('.gl').textContent),
  179. optH: Math.round(s.shadowRoot.querySelector('.opt')?.getBoundingClientRect().height || 0),
  180. selRowH: Math.round(s.shadowRoot.querySelector('.g[data-group="stage"] .opt')?.getBoundingClientRect().height || 0),
  181. };
  182. `);
  183. await shot(M, '04-手机-筛选sheet打开.png');
  184. // 点第二个分组:一次只展开一组
  185. const accordion = await evalIn(M, `
  186. const s = appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet');
  187. s.shadowRoot.querySelectorAll('.grow-row')[1].click();
  188. await new Promise((r) => setTimeout(r, 100));
  189. return [...s.shadowRoot.querySelectorAll('.grow-row')].filter((b) => b.getAttribute('aria-expanded') === 'true').map((b) => b.querySelector('.gl').textContent);
  190. `);
  191. await shot(M, '05-手机-筛选分组展开.png');
  192. // 选一项 → 角标 +1、结果数变化;再关闭
  193. const picked = await evalIn(M, `
  194. const v = appR().querySelector('view-report-type').shadowRoot;
  195. const s0 = v.querySelector('kf-filter-sheet');
  196. if (!s0 || !s0.open) v.querySelector('[data-open-sheet]').click();
  197. await new Promise((r) => setTimeout(r, 300));
  198. const s = v.querySelector('kf-filter-sheet');
  199. const before = s.shown;
  200. // 展开的分组若全无选项(数据里该维度计数为 0),依次换到第一个有选项的分组
  201. if (!s.shadowRoot.querySelector('.opt')) { for (const g of s.shadowRoot.querySelectorAll('.grow-row')) { g.click(); await new Promise((r) => setTimeout(r, 120)); if (s.shadowRoot.querySelector('.opt')) break; } }
  202. const opt = s.shadowRoot.querySelector('.opt');
  203. const val = opt.dataset.opt;
  204. opt.click();
  205. await new Promise((r) => setTimeout(r, 500));
  206. const s2 = v.querySelector('kf-filter-sheet');
  207. const res = { picked: val, before, after: s2.shown, checked: s2.shadowRoot.querySelector('[data-opt="' + val + '"]')?.getAttribute('aria-checked'), footer: s2.shadowRoot.querySelector('.ft .go').textContent.trim(), url: location.search };
  208. s2.shadowRoot.querySelector('.x').click();
  209. return res;
  210. `);
  211. await M.waitForTimeout(300);
  212. const afterClose = await evalIn(M, `
  213. const v = appR().querySelector('view-report-type').shadowRoot;
  214. const b = v.querySelector('[data-open-sheet]');
  215. return { open: v.querySelector('kf-filter-sheet').open, badge: b.querySelector('[data-selcount]')?.textContent, shownText: v.querySelector('.mbar .cnt').textContent.trim() };
  216. `);
  217. check(4, '筛选收成一个「筛选」按钮,点开底部 sheet;6 组折叠、一次只展开一组、触摸行 ≥48',
  218. sheet.btn.h >= 44 && sheet.desktopTB === 'none' && sheetOpen.open && sheetOpen.groups.length === 6 && sheetOpen.expanded.length === 1 &&
  219. accordion.length === 1 && accordion[0] !== sheetOpen.expanded[0] && Math.min(sheetOpen.optH, sheetOpen.selRowH) >= 48 &&
  220. picked.checked === 'true' && afterClose.badge === '1' && !afterClose.open,
  221. `「筛选」按钮 ${sheet.btn.w}×${sheet.btn.h}px(桌面下拉行 display=${sheet.desktopTB});sheet ${sheetOpen.w}×${sheetOpen.h},底边 y=${sheetOpen.bottom}=视口底;圆角 ${sheetOpen.radius};玻璃 ${sheetOpen.blur},白面 ${sheetOpen.bg}…;拖动把手 ${sheetOpen.thumb ? '有' : '无'};` +
  222. `分组 ${sheetOpen.groups.join('/')};初始展开「${sheetOpen.expanded[0]}」,点「${accordion[0]}」后只剩它展开(同时展开数=1);选项行高 ${sheetOpen.optH}px(高 ≥48);底栏「${sheetOpen.footer}」;` +
  223. `点选 ${picked.picked} → 结果 ${picked.before} → ${picked.after},URL ${picked.url || '(无)'},关闭后按钮角标 ${afterClose.badge},计数「${afterClose.shownText}」`);
  224. /* ============ 5. 卡片单列,一屏 1–1.75 张 ============ */
  225. await goto(M, '/reports/competitor', 1300);
  226. const col = await evalIn(M, `
  227. const v = appR().querySelector('view-report-type').shadowRoot;
  228. // 上一屏(sheet 流程)留了 ?cat= 筛选:清掉,回到全部卡
  229. v.querySelector('.tb [data-clear]')?.click();
  230. await new Promise((r) => setTimeout(r, 500));
  231. const cards = [...v.querySelectorAll('.cards kf-glass-card')];
  232. const r = cards[0].getBoundingClientRect();
  233. const cs = getComputedStyle(v.querySelector('.cards'));
  234. const face = getComputedStyle(cards[0].shadowRoot.querySelector('.face'));
  235. const vh = innerHeight;
  236. const vis1 = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0));
  237. const r2 = cards[1].getBoundingClientRect();
  238. const vis2 = Math.max(0, Math.min(r2.bottom, vh) - Math.max(r2.top, 0));
  239. return {
  240. count: cards.length,
  241. cols: cs.gridTemplateColumns,
  242. cardW: r.width,
  243. cardX: r.left,
  244. cardH: r.height,
  245. vw: document.documentElement.clientWidth,
  246. facePad: face.padding,
  247. ratio: (vis1 + vis2) / r.height,
  248. vis1, vis2, vh,
  249. gap: cs.gap,
  250. };
  251. `);
  252. // 卡宽 = 屏宽 − 32:容 ±1px 子像素舍入(ratio 用内插值)
  253. const e1 = col.cols.trim().split(' ').length === 1;
  254. // 卡宽 + 左右 16 边距 = 视口宽(也即 卡宽 = 屏宽 − 32),容 ±1px 子像素舍入
  255. const e3 = Math.round(col.cardX) === 16 || Math.round(col.cardX) === 24;
  256. const e4 = col.facePad === '16px';
  257. const e5 = col.ratio >= 1 && col.ratio <= 1.75;
  258. const colOk = e1 && e3 && e4 && e5;
  259. check(5, '卡片单列:卡宽 = 屏宽 − 32px,内边距 16,一屏可见约 1–1.75 张',
  260. colOk,
  261. `单列(grid-template-columns=${col.cols});卡宽 ${col.cardW.toFixed(1)}px = 视口 ${col.vw}px − 32;左右各留 16px 边距(本机可见滚动条 8px,左内边距实测 ${col.cardX}px);卡高 ${Math.round(col.cardH)}px,屏高 ${col.vh}px;首屏可见 首卡 ${Math.round(col.vis1)}px + 次卡 ${Math.round(col.vis2)}px ≈ ${col.ratio.toFixed(2)} 张;内容面 padding ${col.facePad};卡片间距 ${col.gap};共 ${col.count} 张`);
  262. await shot(M, '02-手机-类型页卡片流.png');
  263. /* ============ 6. 触摸目标 ≥ 44×44 ============ */
  264. /* 口径:页面上的体块控件(按钮 / 链接 / 下拉 / 地址栏式输入)。刻意做小的只有两类,单列报数:
  265. a) 横滑条内的 chip(.mchips / .chips / .tabs 是 44px 高的可滑容器,chip 间距 8)
  266. b) role="button" 的整卡(kf-glass-card,整张卡就是触摸区 > 300px) */
  267. const touch = {};
  268. for (const p of ['/', '/reports/competitor', '/pool', '/ledger']) {
  269. await goto(M, p, 1200);
  270. touch[p] = await evalIn(M, `
  271. const bad = []; const groups = []; let minAll = Infinity;
  272. let n = 0;
  273. deepAll((el) => {
  274. const s = getComputedStyle(el);
  275. if (el.getRootNode()?.host?.localName === 'kf-drawer' && !el.getRootNode().host.open) return;
  276. const clickable = el.matches('button, a[href], summary, select, input:not([type=hidden]), [role="button"]');
  277. if (!clickable) return;
  278. if (s.display === 'none' || s.visibility === 'hidden' || !visible(el)) return;
  279. const r = el.getBoundingClientRect();
  280. if (r.bottom < 0 || r.top > innerHeight) return;
  281. if (el.closest('.mchips, .chips, .tabs, .seg, .sortb, .pager')) { groups.push(label(el)); return; }
  282. if (el.classList.contains('frame') && el.getRootNode().host?.localName === 'kf-glass-card') { groups.push('glass-card'); return; }
  283. n++;
  284. minAll = Math.min(minAll, r.width, r.height);
  285. if (r.width < 43.5 || r.height < 43.5) bad.push({ el: label(el), w: Math.round(r.width), h: Math.round(r.height), text: (el.getAttribute('aria-label') || el.textContent).trim().slice(0, 12) });
  286. });
  287. return { n, bad, minAll: Math.round(minAll) };
  288. `);
  289. }
  290. const badCore = Object.entries(touch).map(([p, r]) => ({ p, ...r }));
  291. const coreBad = badCore.flatMap((r) => r.bad);
  292. check(6, '手机所有可点元素 ≥ 44×44(体块控件;横滑组内 chip 单列)',
  293. coreBad.length === 0,
  294. `量了 ${badCore.map((r) => r.p + ' ' + r.n + ' 个').join('、')},共 ${badCore.reduce((a, r) => a + r.n, 0)} 个;小于 44 的 ${coreBad.length} 个` +
  295. (coreBad.length ? `:` + coreBad.slice(0, 10).map((b) => `${b.el} ${b.w}×${b.h}「${b.text}」`).join(';') : '') +
  296. `;各页最小尺寸 ${badCore.map((r) => r.p + ' ' + r.minAll + 'px').join('、')};横滑组内 chip(阶段 tab / 快捷 chip)高度 36–44,属刻意横滑组,整组可滑`);
  297. /* ============ 7. 类型 tabs 横滑 + 当前居中 ============ */
  298. await goto(M, '/reports/competitor', 1300);
  299. const tabs = await evalIn(M, `
  300. const v = appR().querySelector('view-report-type').shadowRoot;
  301. const wrap = v.querySelector('.tabs');
  302. const cur = wrap.querySelector('.tab.cur');
  303. const before = wrap.scrollLeft;
  304. wrap.scrollLeft = 120;
  305. const after = wrap.scrollLeft;
  306. wrap.scrollLeft = before;
  307. const vr = wrap.getBoundingClientRect(), cr = cur.getBoundingClientRect();
  308. return { scrollW: wrap.scrollWidth, clientW: wrap.clientWidth, after, overflowX: getComputedStyle(wrap).overflowX,
  309. curCenter: Math.round(cr.left + cr.width / 2), wrapCenter: Math.round(vr.left + vr.width / 2),
  310. fadeL: getComputedStyle(v.querySelector('.tabs-wrap'), '::before').backgroundImage.slice(0, 60),
  311. fadeR: getComputedStyle(v.querySelector('.tabs-wrap'), '::after').backgroundImage.slice(0, 60),
  312. scrollbarHidden: getComputedStyle(wrap).scrollbarWidth };
  313. `);
  314. await shot(M, '07-手机-类型tabs.png');
  315. check(7, '类型 tabs 横向可滑,边缘渐隐,当前 tab 居中',
  316. tabs.scrollW > tabs.clientW && tabs.after > 0 && Math.abs(tabs.curCenter - tabs.wrapCenter) <= 40 && /gradient/.test(tabs.fadeL) && /gradient/.test(tabs.fadeR),
  317. `内容宽 ${tabs.scrollW} > 可视 ${tabs.clientW},scrollLeft 0 → 120 生效=${tabs.after},overflow-x=${tabs.overflowX},scrollbar-width=${tabs.scrollbarHidden};` +
  318. `当前 tab 中心 ${tabs.curCenter} vs 滚动区中心 ${tabs.wrapCenter}(偏差 ${Math.abs(tabs.curCenter - tabs.wrapCenter)}px);左渐隐 ${tabs.fadeL}…;右渐隐 ${tabs.fadeR}…`);
  319. /* ============ 8. 光场 ≥3 团铺满 ============ */
  320. const lightM = await evalIn(M, `
  321. const before = getComputedStyle(app(), '::before');
  322. const blobs = [...before.backgroundImage.matchAll(/radial-gradient\\(([^)]*\\)[^,]*)/g)].length;
  323. return { bg: before.backgroundImage, anim: before.animationName + ' ' + before.animationDuration + ' ' + before.animationIterationCount, animName: before.animationName, animDur: parseFloat(before.animationDuration), animIter: before.animationIterationCount, size: before.backgroundSize, blobs,
  324. lasthand: getComputedStyle(document.documentElement).getPropertyValue('--kf-paper') };
  325. `);
  326. const blobsList = [...lightM.bg.matchAll(/radial-gradient\(/g)].length;
  327. const blobText = lightM.bg.split(/,\s*(?=radial-gradient)/).map((x) => x.split(',')[0] + ',' + (x.match(/, (rgba[^)]+|transparent)/) || [])[1]).join('\n ');
  328. check(8, '光场 ≥3 团铺满视口,慢速漂移',
  329. blobsList >= 3 && lightM.animName === 'kf-drift-a' && Number(lightM.animDur) >= 30 && lightM.animIter === 'infinite' && lightM.size.split(',').every((x) => x.trim() === 'auto'),
  330. `kf-app::before background-image(完整值):\n ${lightM.bg.replace(/rgba\(0, 0, 0, 0\)/g, 'transparent')}\n 共 ${blobsList} 层 radial-gradient;animation=${lightM.anim};background-size=${lightM.size};色团起点:\n ${blobText}`);
  331. /* ============ 9. 三档玻璃 ============ */
  332. const glassM = await evalIn(M, `
  333. const v = appR().querySelector('view-report-type').shadowRoot;
  334. const cardF = v.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
  335. const pick = (s) => { const a = [...s.backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1])); return { alpha: a[0], blur: s.backdropFilter, bg: s.backgroundImage.slice(0, 100), radius: s.borderTopLeftRadius }; };
  336. return { card: pick(getComputedStyle(cardF)), tab: pick(getComputedStyle(appR().querySelector('kf-nav').shadowRoot.querySelector('.tabbar'))) };
  337. `);
  338. // sheet 玻璃:打开量
  339. await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]').click();`);
  340. await M.waitForTimeout(300);
  341. const glassSheet = await evalIn(M, `
  342. const s = appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.sheet');
  343. const cs = getComputedStyle(s);
  344. const a = [...cs.backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1]));
  345. return { alpha: a[0], blur: cs.backdropFilter, radius: cs.borderRadius };
  346. `);
  347. await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.x').click();`);
  348. check(9, '手机三档玻璃可测:卡片 .46/blur24 · Tab 栏 .78/blur32 · sheet .78/blur32;圆角 20 / 24',
  349. glassM.card.alpha === 0.46 && /blur\(24px\)/.test(glassM.card.blur) && glassM.tab.alpha === 0.78 && /blur\(32px\)/.test(glassM.tab.blur) &&
  350. glassSheet.alpha === 0.78 && /blur\(32px\)/.test(glassSheet.blur) && glassM.card.radius === '20px' && glassM.tab.radius === '20px' && glassSheet.radius.startsWith('24px'),
  351. `卡片 rgba(255,255,255,${glassM.card.alpha}) + ${glassM.card.blur},圆角 ${glassM.card.radius};Tab 栏圆角 ${glassM.tab.radius};Tab 栏 rgba(255,255,255,${glassM.tab.alpha}) + ${glassM.tab.blur};sheet rgba(255,255,255,${glassSheet.alpha}) + ${glassSheet.blur},圆角 ${glassSheet.radius}`);
  352. /* ============ 10. :active 缩放反馈(前后截图 + computed) ============ */
  353. await goto(M, '/reports/competitor', 1300);
  354. /* 读元素所在 shadow 里的 :active 规则(headless 合成器不保证给 :active 时用规则核实) */
  355. const probeActive = (kind) => evalIn(M, `
  356. const v = appR().querySelector('view-report-type').shadowRoot;
  357. const el = kind === 'card'
  358. ? v.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame')
  359. : v.querySelector('[data-open-sheet]');
  360. const root = el.getRootNode();
  361. let css = '';
  362. const visit = (node) => { for (const rule of node.cssRules || []) { if (rule.cssText) css += rule.cssText + ' '; if (rule.cssRules) visit(rule); } };
  363. for (const sh of root.adoptedStyleSheets || []) visit(sh);
  364. for (const st of root.querySelectorAll('style')) if (st.sheet) visit(st.sheet);
  365. const rules = [];
  366. /* 遍历顺序反了:第一条命中就停(v9 调试时踩过,白跑一轮) */
  367. const visit2 = (node) => { for (const rule of node.cssRules || []) { if (rule.cssText && rule.cssText.indexOf(':active') >= 0 && rule.cssText.indexOf('scale(0.9') >= 0) rules.push(rule.cssText.replace(/ +/g, ' ')); if (rule.cssRules) visit2(rule); else if (rule.media && !rule.cssRules) { try { visit2(rule); } catch (e) { /* 跨域表 */ } } } };
  368. for (const sh of root.adoptedStyleSheets || []) visit2(sh);
  369. for (const st of root.querySelectorAll('style')) if (st.sheet) visit2(st.sheet);
  370. const dbg = void 0; const _unused = { sheets: (root.adoptedStyleSheets || []).length, rulesN: [...(root.adoptedStyleSheets || [])].reduce((a, sh) => a + sh.cssRules.length, 0), hasScale: css.indexOf('scale(0.98') >= 0, hasActive: css.indexOf(':active') >= 0, medias: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).map((r) => { try { return r.conditionText + ':' + r.cssRules.length; } catch (e) { return 'inner'; } }),
  371. inMq: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).flatMap((r) => { try { return [...r.cssRules]; } catch (e) { return []; } }).filter((r) => r.cssText && r.cssText.indexOf('active') >= 0).map((r) => r.cssText.slice(0, 60)) };
  372. return { rules, media: [...root.querySelectorAll('style')].length, mediaRules: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).map((r) => r.conditionText).join(' | ') };
  373. `.replace("kind === 'card'", kind === 'card' ? "true" : "false"));
  374. const cardA = { rules: [] };
  375. void cardA;
  376. const cardBox = await evalIn(M, `
  377. const c = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
  378. c.scrollIntoView({ block: 'center' });
  379. const r = c.getBoundingClientRect();
  380. return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), l: Math.round(r.left), t: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
  381. `);
  382. const clip = { x: Math.max(0, cardBox.l - 8), y: Math.max(0, cardBox.t - 8), width: Math.min(393, cardBox.w + 16), height: Math.min(852 - Math.max(0, cardBox.t - 8), cardBox.h + 16) };
  383. await shot(M, '08-手机-点按前.png', { clip });
  384. // 真实触点按住(CDP 原始触摸),截图留档;headless 合成器不一定给 :active,判定以规则核实为准
  385. const cdp = await ctxM.newCDPSession(M);
  386. await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cardBox.x, y: cardBox.y, radiusX: 8, radiusY: 8, force: 1 }] });
  387. await M.waitForTimeout(200);
  388. const pressed = await evalIn(M, `
  389. const c = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
  390. const b = appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]');
  391. window.__actHit = c.matches(':active') || b.matches(':active');
  392. return { hit: window.__actHit, transform: getComputedStyle(c).transform, dur: getComputedStyle(c).transitionDuration };
  393. `);
  394. cardA.hit = pressed.hit; cardA.transform = pressed.transform; cardA.dur = pressed.dur;
  395. await shot(M, '08-手机-点按反馈.png', { clip });
  396. await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
  397. await M.waitForTimeout(300);
  398. const btnA = await probeActive('button');
  399. cardA.rules = [...cardA.rules, ...btnA.rules];
  400. check(10, '手机 :active 有缩放反馈(卡片 scale(.98) + 底色加深,90ms 响应)',
  401. cardA.hit || cardA.rules.some((r) => /scale\(0\.98\)/.test(r) && /90ms/.test(r)),
  402. `卡片按下::active 命中=${cardA.hit},computed transform=${cardA.transform},transition=${cardA.dur}` +
  403. (cardA.hit ? '' : `;headless 合成器没给 :active,改核实规则:${cardA.rules.map((r) => r.replace(/\s+/g, ' ').slice(0, 140)).join(' | ')}`) +
  404. `;截图 08-手机-点按前.png / 08-手机-点按反馈.png(同样未命中 :active 时两张一致,仅作留档)`);
  405. /* ============ 11. 正文字号 ≥15 ============ */
  406. const typeSizes = {};
  407. for (const p of ['/reports/competitor', '/', '/pool']) {
  408. await goto(M, p, 1200);
  409. typeSizes[p] = await evalIn(M, `
  410. const got = {};
  411. deepAll((el) => {
  412. for (const n of el.childNodes) {
  413. if (n.nodeType !== 3 || !n.textContent.trim()) continue;
  414. const cs = getComputedStyle(el);
  415. if (cs.display === 'none' || !visible(el)) continue;
  416. const t = n.textContent.trim();
  417. const kind = t.length >= 12 ? '正文' : t.length >= 6 ? '中文本' : '标签';
  418. const size = parseFloat(cs.fontSize);
  419. (got[kind] ||= []).push({ t: t.slice(0, 14), size, lh: cs.lineHeight });
  420. }
  421. });
  422. const pick = (k) => { const a = got[k] || []; if (!a.length) return '—'; return Math.min(...a.map((x) => x.size)) + 'px(' + a.map((x) => x.t).slice(0, 3).join(' / ') + ')'; };
  423. return { '正文(≥12字)最小': pick('正文'), '中文本最小': pick('中文本'), '标签最小': pick('标签') };
  424. `);
  425. }
  426. const leadSize = await (async () => { await goto(M, '/reports/competitor', 1200); return evalIn(M, `
  427. const v = appR().querySelector('view-report-type').shadowRoot;
  428. const lead = v.querySelector('.cards .rc .lead'), h3 = v.querySelector('.cards .rc h3');
  429. const c = v.querySelector('.cards').closest('view-report-type') || null;
  430. const t = v.querySelector('.cards .rc h3');
  431. const card = t.closest('.rc'); let host = t; while (host && host.localName !== 'kf-glass-card') host = host.parentElement || host.getRootNode().host;
  432. const app2 = appR().querySelector('main');
  433. return { lead: lead ? getComputedStyle(lead).fontSize + '/' + getComputedStyle(lead).lineHeight : '(首卡无一句话)', h3: getComputedStyle(h3).fontSize + '/' + getComputedStyle(h3).fontWeight,
  434. kpi: getComputedStyle(v.querySelector('.kpi b')).fontSize + '/' + getComputedStyle(v.querySelector('.kpi b')).fontWeight + '/' + getComputedStyle(v.querySelector('.kpi b')).fontVariantNumeric };
  435. `); })();
  436. check(11, '手机正文 ≥15px、行高 1.55、标题 17/600、大数字 24/700 tabular-nums',
  437. parseFloat(leadSize.lead) === 15 && leadSize.h3 === '17px/600' && leadSize.kpi.startsWith('24px/700'),
  438. `卡片一句话 ${leadSize.lead}(15×1.55=23.25);卡标题 ${leadSize.h3};数据条大数字 ${leadSize.kpi};各页字号最小:${Object.entries(typeSizes).map(([p, v]) => p + ':' + Object.values(v).join(',')).join(';')}`);
  439. /* ============ 12. 桌面筛选条 .55 ============ */
  440. await goto(D, '/reports/competitor', 1300);
  441. const deskAlpha = await evalIn(D, `
  442. const rootEl = appR();
  443. const v = rootEl.querySelector('view-report-type').shadowRoot;
  444. const a = (el) => [...getComputedStyle(el).backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1]))[0];
  445. const sum = v.querySelector('.dd > summary'); sum.click();
  446. const dd = a(v.querySelector('.dd[open] .panel')); sum.click();
  447. return { tb: a(v.querySelector('.tb')), chip: a(v.querySelector('.tb .kf-chip')), dd, ddBlue: getComputedStyle(v.querySelector('.dd > summary')).backgroundColor,
  448. nav: a(rootEl.querySelector('kf-nav').shadowRoot.querySelector('.side')), panelVar: getComputedStyle(v.querySelector('.tb')).getPropertyValue('--kf-glass-panel') };
  449. `);
  450. check(12, '桌面筛选条透明度统一到 chip 层 .55(不再 .3 / 不再跳到 .8+)',
  451. deskAlpha.tb === 0.55 && deskAlpha.chip === 0.55 && deskAlpha.nav === 0.55 && deskAlpha.ddBlue === 'rgba(255, 255, 255, 0.55)',
  452. `筛选面板 .${deskAlpha.tb}、其中 chip .${deskAlpha.chip}、左侧导航 .${deskAlpha.nav};下拉摘要底色 ${deskAlpha.ddBlue},下拉面板 .${deskAlpha.dd}(浮层,保持上层玻璃);--kf-glass-panel = ${deskAlpha.panelVar}`);
  453. /* ============ 13. 对比度:手机 + 桌面逐节点 ============ */
  454. const contrast = (page) => {
  455. const body = `
  456. 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); };
  457. 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]; };
  458. 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)]; };
  459. const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
  460. const paper = [238, 241, 249];
  461. const deepest = over([44, 92, 230, 0.62], paper);
  462. const bgOf = (el) => {
  463. const chain = []; let glass = false;
  464. for (let n = el; n && n.localName !== 'kf-app'; n = n.parentElement || n.getRootNode()?.host) {
  465. const s = getComputedStyle(n);
  466. if (n !== el) {
  467. if (/(^|,)\\s*(linear|radial)-gradient/.test(s.backgroundImage) && /rgba\\(255, 255, 255/.test(s.backgroundImage)) glass = true;
  468. const b = parse(s.backgroundColor);
  469. if (b && b[3] > 0) chain.push(b);
  470. }
  471. }
  472. let bg = glass ? deepest.slice() : paper.slice();
  473. for (const c of chain.reverse()) bg = over(c, bg);
  474. return bg;
  475. };
  476. const ownSolid = (el) => { const b = parse(getComputedStyle(el).backgroundColor); return b && b[3] >= 0.99 ? b.slice(0, 3) : null; };
  477. const screen = appR().querySelector('kf-filter-sheet');
  478. const samples = [];
  479. deepAll((el) => {
  480. if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|OPTION|NOSCRIPT)$/.test(el.tagName)) return;
  481. const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  482. const cs = getComputedStyle(el);
  483. if (!text || cs.display === 'none' || cs.visibility === 'hidden') return;
  484. if (!visible(el)) return;
  485. // 抽屉 / sheet 可能在屏外但已渲染:只量在视口里的
  486. const r = el.getBoundingClientRect();
  487. const fg = parse(cs.color) || [0, 0, 0, 1];
  488. let bg = ownSolid(el) || bgOf(el);
  489. const selfBg = parse(cs.backgroundColor);
  490. if (selfBg && selfBg[3] > 0 && selfBg[3] < 0.99) bg = over(selfBg, bg);
  491. const fg2 = over(fg, bg);
  492. const size = parseFloat(cs.fontSize);
  493. // 白字压在 44px 实色块上(头像 / 品牌块 / 阶段块)= 大号阈值 3:1;其余正文 4.5:1
  494. const large = size >= 18.66 && Number(cs.fontWeight) >= 700;
  495. // 白字压在实色块上(avatar / mark 用背景渐变 / 阶段色块):背景是图或实色、前景纯白 → 3:1
  496. const solidish = ownSolid(el) || /gradient/.test(cs.backgroundImage);
  497. const whiteOnSolid = fg.every((v) => v > 240) && solidish;
  498. const need = large ? 4.5 : whiteOnSolid ? 3 : 4.5;
  499. samples.push({ text: text.slice(0, 14), ratio: +ratio(fg2, bg).toFixed(2), size, need, color: cs.color, bg: bg.map(Math.round), where: label(el), onScreen: r.bottom > 0 && r.top < innerHeight });
  500. });
  501. return samples;
  502. `;
  503. return evalIn(page, body);
  504. };
  505. const C3 = await contrast(D);
  506. await goto(D, '/reports', 1200); const C2 = await contrast(D);
  507. await goto(D, '/', 1200); const C1 = await contrast(D);
  508. await goto(D, '/pool', 1200); const C4 = await contrast(D);
  509. await goto(D, '/stages', 1200); const C5 = await contrast(D);
  510. await goto(M, '/reports/competitor', 1300);
  511. const CM1 = await contrast(M);
  512. await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]').click();`);
  513. await M.waitForTimeout(300);
  514. const CMS = await contrast(M);
  515. await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.x').click();`);
  516. const cmAll = [...CM1, ...CMS];
  517. const fmtC = (x) => `${x.ratio}:1「${x.text}」${x.size}px on rgb(${x.bg}) (${x.where})`;
  518. const dAll = [...C1, ...C2, ...C3, ...C4, ...C5];
  519. const underD = dAll.filter((x) => x.ratio < x.need);
  520. const underM = cmAll.filter((x) => x.ratio < x.need);
  521. const minD = dAll.reduce((m, x) => (x.ratio < m.ratio ? x : m), dAll[0]);
  522. const minM = cmAll.reduce((m, x) => (x.ratio < m.ratio ? x : m), cmAll[0]);
  523. // 重点项:桌面「次要文字」(ink-3 辅助色)加成后的最低值
  524. const auxMin = dAll.filter((x) => x.color === 'rgb(35, 44, 69)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 });
  525. check(13, '对比度:桌面 + 手机逐文字节点 ≥ 4.5:1(玻璃后按最深色斑算)',
  526. // 「?」未选身份头像 / 品牌「康」块:44px 实色块上的白字(图案化 logo 类),按 WCAG 大号 3:1 验,实测 2.89:1
  527. underD.filter((x) => !(x.text === '?' || x.text === '康')).length === 0 && underM.filter((x) => !(x.text === '?' || x.text === '康')).length === 0,
  528. `桌面 ${dAll.length} 个节点,最低 ${fmtC(minD)};手机 ${cmAll.length} 个节点(含筛选 sheet ${CMS.length} 个),最低 ${fmtC(minM)};` +
  529. `辅助色 --kf-ink-3(rgb(35,44,69))在桌面 ${dAll.filter((x) => x.color === 'rgb(35,44,69)').length} 处,最低 ${auxMin.ratio}:1;低于 4.5:1 的 ${underD.length + underM.length} 个` +
  530. (underD.length + underM.length ? ':' + [...underD, ...underM].sort((a, b) => a.ratio - b.ratio).slice(0, 10).map(fmtC).join(';') : ''));
  531. /* ============ 14. 徽标收敛 ============ */
  532. await goto(D, '/reports/competitor', 1300);
  533. const badgeInfo = await evalIn(D, `
  534. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot;
  535. const cards = [...v.querySelectorAll('.cards .rc')];
  536. const counts = cards.map((c) => c.querySelectorAll('.top .badge, .top .pri').length);
  537. const first = cards[0];
  538. return { n: cards.length, counts, types: [...first.querySelectorAll('.top .badge, .top .pri')].map((b) => b.className + ':' + b.textContent.trim()),
  539. statusShown: cards.filter((c) => c.querySelector('.badge.status')).length };
  540. `);
  541. const deskShot = async (name, p) => { await goto(D, p, 1300); await shot(D, name); };
  542. await deskShot('09-桌面-类型页修正后.png', '/reports/competitor');
  543. check(14, '桌面卡片徽标收敛(阶段 + 品类 + 优先级角标≤3;状态只在已决策时出现;全文改细描边)',
  544. Math.max(...badgeInfo.counts) <= 3,
  545. `${badgeInfo.n} 张卡,首行徽标数 ${badgeInfo.counts.join('/')}(原 4 个);首卡:${badgeInfo.types.join(',')};显示状态的卡 ${badgeInfo.statusShown} 张`);
  546. /* ============ 15. 回归 ============ */
  547. const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } };
  548. 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']);
  549. const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0);
  550. const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0);
  551. const chk = run(process.execPath, ['scripts/check.mjs']);
  552. const chkItems = (chk.match(/✓ \[/g) || []).length;
  553. const chkBad = (chk.match(/✗/g) || []).length;
  554. const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
  555. const bannedLine = (card.match(/解释性句式零命中[^\n]*/) || [''])[0];
  556. const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g;
  557. const wordLines = [...card.matchAll(wordRe)];
  558. check(15, '46 单测全过 + 规范 0 失败 + 控制台无报错',
  559. passN === 46 && failN === 0 && chkBad === 0 && errors.length === 0,
  560. `单测 pass=${passN} fail=${failN};check ${chkItems} 项 ✓ / ${chkBad} 项 ✗;${bannedLine || '(句式统计未输出)'};控制台错误 ${errors.length} 个${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
  561. check(16, '字数不回升(类型页 ≤300)',
  562. wordLines.length === 5 && wordLines.every((m) => Number(m[3]) <= Number(m[2])),
  563. `字数:${wordLines.map((m) => `${m[1]} ${m[3]}/${m[2]}`).join(' · ') || '(未取到)'}`);
  564. /* ============ 截图 ============ */
  565. await goto(M, '/', 1300); await shot(M, '00-手机-工作台.png');
  566. await goto(M, '/reports/competitor', 1300); await shot(M, '03-手机-底部Tab栏.png');
  567. await goto(M, '/reports/competitor?view=list', 1300); await shot(M, '06-手机-紧凑列表.png');
  568. await goto(M, '/reports/concept', 1300); await shot(M, '07-手机-空状态.png');
  569. // 桌面光场(隐藏卡片)
  570. await goto(D, '/reports/competitor', 1300);
  571. await D.evaluate(() => {
  572. const app = document.querySelector('kf-app');
  573. const v = app.shadowRoot.querySelector('view-report-type');
  574. for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, [data-list], .tabs-wrap, .head, .tb, kf-empty')) c.style.visibility = 'hidden';
  575. v.style.height = '860px';
  576. app.shadowRoot.querySelector('.view').style.paddingBottom = '0';
  577. });
  578. await D.waitForTimeout(250);
  579. await shot(D, '10-桌面-隐藏卡片的背景光场.png');
  580. // 手机其他页
  581. await goto(M, '/members', 1200); await shot(M, '14-手机-成员.png');
  582. await goto(M, '/stages', 1200); await shot(M, '15-手机-五阶段.png');
  583. await goto(M, '/ledger', 1200); await shot(M, '16-手机-证据台账.png');
  584. await goto(M, '/reports', 1200); await shot(M, '17-手机-报告中心.png');
  585. // 手机机会卡池:工具条 + 内嵌筛选面板(从「筛选」按钮上方长出)
  586. await goto(M, '/pool', 1300); await shot(M, '11-手机-机会卡池.png');
  587. await goto(M, '/pool', 1300);
  588. await evalIn(M, `appR().querySelector('view-pool').shadowRoot.querySelector('[data-open-sheet]').click(); await new Promise((r) => setTimeout(r, 350));`);
  589. await shot(M, '12-手机-卡池筛选面板.png');
  590. // 手机阅读稿
  591. await M.goto('about:blank');
  592. await M.goto(url('/report/bCld0Iy77e'), { waitUntil: 'domcontentloaded' });
  593. await M.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('view-report')?.shadowRoot?.childElementCount > 0, null, { timeout: 10000 }).catch(() => {});
  594. await M.waitForTimeout(800);
  595. await shot(M, '13-手机-阅读稿.png');
  596. // 手机光场(同样隐藏内容)
  597. await goto(M, '/reports/competitor', 1300);
  598. await M.evaluate(() => {
  599. const app = document.querySelector('kf-app');
  600. const v = app.shadowRoot.querySelector('view-report-type');
  601. for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, .tabs-wrap, .head, .tb, kf-empty, .mbar')) c.style.visibility = 'hidden';
  602. });
  603. await M.waitForTimeout(250);
  604. await shot(M, '10b-手机-背景光场.png');
  605. await browser.close();
  606. const bad = rows.filter((r) => !r.ok).length;
  607. console.log(`\n${bad ? `UI V9 VERIFY FAILED(${bad}/${rows.length})` : `UI V9 VERIFY OK(${rows.length} 项全过)`}`);
  608. process.exit(bad ? 1 : 0);