verify-ui-v8.mjs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-ui-v8.mjs —— v8「UI 高标准打磨」验收(第 4 节 14 项,全部实测值)。
  4. *
  5. * 只量、不猜:每项输出 computed style / 像素 / WCAG 比值,关闭时以非 0 退出。
  6. * 光场 1–3 氛围层可查到 / background-image 含 radial-gradient / 隐藏卡片后可见三团色斑
  7. * 玻璃 4–7 三层 alpha / 135° 渐变发丝边 / 内高光 / blur 28·32·12
  8. * 交互 8–10 hover 位移+阴影 / Tab 焦点环 / 过渡是 cubic-bezier
  9. * 精度 11–13 tabular-nums / 4pt 间距 / 逐节点对比度 ≥ 4.5:1
  10. * 回归 14 46 单测 + 规范 check + 字数
  11. *
  12. * 用法:node scripts/verify-ui-v8.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v8]
  13. */
  14. import { existsSync, mkdirSync } from 'node:fs';
  15. import { execFileSync, spawn } from 'node:child_process';
  16. import { join, dirname } from 'node:path';
  17. import { fileURLToPath } from 'node:url';
  18. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  19. const argv = process.argv.slice(2);
  20. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  21. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v8'));
  22. mkdirSync(SHOTS, { recursive: true });
  23. const rows = [];
  24. const check = (no, name, ok, detail) => {
  25. rows.push({ no, name, ok: Boolean(ok), detail });
  26. console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`);
  27. };
  28. async function loadPlaywright() {
  29. for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
  30. if (existsSync(c)) return (await import(join(c, 'index.js'))).default ?? (await import(`${c}/index.js`));
  31. }
  32. return import('playwright');
  33. }
  34. let server = null;
  35. let base = arg('--url', '');
  36. if (!base) {
  37. const { writeConfig } = await import('./config.mjs');
  38. const { prefix } = writeConfig({ env: 'local' });
  39. const port = Number(process.env.VERIFY_PORT || 4178);
  40. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  41. await new Promise((r) => setTimeout(r, 700));
  42. base = `http://127.0.0.1:${port}${prefix}`;
  43. }
  44. process.on('exit', () => server?.kill());
  45. const pw = await loadPlaywright();
  46. const browser = await pw.chromium.launch({
  47. executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
  48. args: ['--no-sandbox', '--disable-dev-shm-usage'],
  49. });
  50. const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  51. const page = await ctx.newPage();
  52. const errors = [];
  53. page.on('pageerror', (e) => errors.push(e.message));
  54. page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
  55. const url = (p) => new URL(p, base).href;
  56. const shot = (name, opts = {}) => page.screenshot({ path: join(SHOTS, name), ...opts });
  57. const goto = async (p, ms = 1200) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
  58. /* 页面内的公共工具:深度遍历 Shadow DOM */
  59. const DEEP = `
  60. const deep = (fn, root = document) => {
  61. const out = [];
  62. const walk = (r) => {
  63. for (const el of r.querySelectorAll('*')) { fn(el, out); if (el.shadowRoot) walk(el.shadowRoot); }
  64. };
  65. walk(root);
  66. const app = document.querySelector('kf-app');
  67. if (app?.shadowRoot) walk(app.shadowRoot);
  68. if (root.shadowRoot) walk(root.shadowRoot);
  69. return out;
  70. };
  71. const path = (el) => {
  72. const parts = [];
  73. for (let n = el; n && n.nodeType === 1; n = n.parentElement || n.getRootNode().host) {
  74. parts.unshift(n.localName + (n.id ? '#' + n.id : '') + (typeof n.className === 'string' && n.className ? '.' + n.className.trim().split(/\\s+/).join('.') : ''));
  75. if (n.localName === 'kf-app') break;
  76. }
  77. return parts.join(' > ');
  78. };
  79. `;
  80. const inPage = (body) => page.evaluate(`(() => { ${DEEP} ${body} })()`);
  81. /* ================= 类型页(玻璃 / 排版的主战场) ================= */
  82. await goto('/reports/competitor', 1400);
  83. /* ---------- 1 氛围层可查到 ---------- */
  84. const ambient = await inPage(`
  85. const app = document.querySelector('kf-app');
  86. const be = getComputedStyle(app, '::before');
  87. const ae = getComputedStyle(app, '::after');
  88. return {
  89. appFound: Boolean(app),
  90. appBg: getComputedStyle(app).backgroundColor,
  91. before: { content: be.content, bg: be.backgroundImage, pos: be.position, z: be.zIndex, anim: be.animationName + ' ' + be.animationDuration },
  92. after: { content: ae.content, bg: ae.backgroundImage, z: ae.zIndex },
  93. };
  94. `);
  95. check(1, '氛围层元素可查到(kf-app::before / ::after,正面 z-index 0 绘制)',
  96. ambient.appFound && ambient.before.content === '""' && ambient.before.pos === 'fixed' && ambient.before.z === '0' && ambient.after.content === '""',
  97. `元素路径:kf-app::before(content=${ambient.before.content},position=${ambient.before.pos},z-index=${ambient.before.z},animation=${ambient.before.anim});kf-app::after(z-index=${ambient.after.z});kf-app 宿主 background-color=${ambient.appBg}(必须透明,否则子元素 backdrop-filter 看不见光场)`);
  98. /* ---------- 2 background-image 含 radial-gradient ---------- */
  99. const radials = (ambient.before.bg.match(/radial-gradient/g) || []).length;
  100. check(2, '背景 background-image 含 radial-gradient',
  101. ambient.before.bg !== 'none' && radials >= 4,
  102. `kf-app::before background-image 共 ${radials} 层 radial-gradient:${ambient.before.bg.slice(0, 230)}…`);
  103. /* ---------- 3 隐藏卡片后截图能看到色斑(像素实测) ---------- */
  104. const bgShot = async (label, hideCards) => {
  105. await goto('/reports/competitor', 1200);
  106. if (hideCards) {
  107. await inPage(`
  108. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type');
  109. for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, [data-list], .tabs, .head, .tb, kf-empty')) c.style.visibility = 'hidden';
  110. // 让页面只留一屏高:光场是 fixed 画布,截图里就是整片光场
  111. const app = document.querySelector('kf-app');
  112. app.shadowRoot.querySelector('view-report-type').style.height = '860px';
  113. app.shadowRoot.querySelector('.view').style.paddingBottom = '0';
  114. `);
  115. }
  116. await page.waitForTimeout(250);
  117. const buf = await page.screenshot();
  118. const png = `data:image/png;base64,${buf.toString('base64')}`;
  119. const res = await page.evaluate(async (src) => {
  120. const img = new Image(); img.src = src; await img.decode();
  121. const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
  122. const g = c.getContext('2d'); g.drawImage(img, 0, 0);
  123. // 主内容区空白处(避开导航与顶栏)取 3×3 个采样点,看色相是否各不相同
  124. // 2D 网格取样(全屏 9 列 × 7 行,每点取周围 15×15 平均),统计「不同色带」
  125. const pts = [];
  126. for (let gy = 0; gy < 7; gy++) for (let gx = 0; gx < 9; gx++) pts.push([Math.round(img.width * (0.06 + gx * 0.11)), Math.round(img.height * (0.08 + gy * 0.13))]);
  127. const px = pts.map(([x, y]) => { const d = g.getImageData(Math.max(0, x - 7), Math.max(0, y - 7), 15, 15).data; let r = 0, gg = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; gg += d[i + 1]; b += d[i + 2]; } const n = d.length / 4; return [Math.round(r / n), Math.round(gg / n), Math.round(b / n)]; });
  128. const sat = ([r, gg, b]) => Math.max(r, gg, b) - Math.min(r, gg, b);
  129. const hue = (rgb) => {
  130. const [r, gg, b] = rgb; const mx = Math.max(r, gg, b), mn = Math.min(r, gg, b), d = mx - mn;
  131. if (d < 4) return -1;
  132. let h;
  133. if (mx === r) h = ((gg - b) / d) % 6; else if (mx === gg) h = (b - r) / d + 2; else h = (r - gg) / d + 4;
  134. return ((Math.round(h * 60) % 360) + 360) % 360;
  135. };
  136. // 只统计有彩度的点(纸色区饱和度为 0–3),按 30° 分带
  137. const tinted = px.filter(sat);
  138. return { px, hues: px.map(hue), sat: px.map(sat), tinted: tinted.map(hue) };
  139. }, png);
  140. await shot(label);
  141. return res;
  142. };
  143. const bgOnly = await bgShot('01-lightfield-cards-hidden.png', true);
  144. const cool = bgOnly.tinted.filter((h) => h >= 180 && h < 290).length; // 蓝 / 紫 / 青系
  145. const warm = bgOnly.tinted.filter((h) => h >= 20 && h < 70).length; // 橙系
  146. const rose = bgOnly.tinted.filter((h) => h >= 290 && h < 360).length;
  147. const bands = [...new Set(bgOnly.tinted.map((h) => Math.floor(h / 30)))].sort((a, b) => a - b);
  148. const distinct = bands.length;
  149. check(3, '隐藏所有玻璃卡片后,背景能看到多团色斑(截图 + 像素取样)',
  150. distinct >= 3 && cool >= 2 && (warm + rose) >= 1,
  151. `截图 → ${join(SHOTS, '01-lightfield-cards-hidden.png')};${bgOnly.px.length} 个网格取样点,其中 ${bgOnly.tinted.length} 个有彩度(饱和 > 0),色相落在 ${distinct} 个 30° 色带(蓝 ${bands.includes(7) ? 7 * 30 : ''}°、紫 ${bands.includes(8) ? 240 : ''}°段、青 ${bands.filter((b) => b >= 6 && b <= 7).join('/')}×30°、橙 / 玫瑰 ${bands.filter((b) => b <= 2 || b >= 10).map((b) => b * 30).join(' / ')}°);蓝紫系 ${cool} 点、暖色系 ${warm + rose} 点;示例 RGB:${bgOnly.px.filter((_, i) => bgOnly.sat[i] > 0).filter((_, i) => i % 7 === 0).slice(0, 6).map((p) => `rgb(${p})`).join(' ')}`);
  152. /* ---------- 玻璃层:取卡片 / 顶栏 / 按钮 的 computed style ---------- */
  153. await goto('/reports/competitor', 1400);
  154. const layers = await inPage(`
  155. const app = document.querySelector('kf-app');
  156. const v = app.shadowRoot.querySelector('view-report-type');
  157. const cardFrame = v.shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
  158. const cardFace = v.shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.face');
  159. const bar = app.shadowRoot.querySelector('kf-topbar').shadowRoot.querySelector('.bar');
  160. // 工具栏当前是面板层(.3);如果做成上层玻璃,抽屉 / 下拉面板同样是 .72 + blur32
  161. const tb = v.shadowRoot.querySelector('.tb');
  162. const dd = (() => { const sum = v.shadowRoot.querySelector('.dd > summary'); sum?.click(); const p = v.shadowRoot.querySelector('.dd[open] .panel'); const cs = p && getComputedStyle(p); sum?.parentElement?.removeAttribute('open'); return cs; })();
  163. const topLayer = { bg: dd ? dd.backgroundImage.slice(0, 80) : '', blur: dd ? dd.backdropFilter : '', alpha: dd ? Number((dd.backgroundImage.match(/rgba\\(255, 255, 255, ([\\d.]+)\\)/) || [])[1]) : null };
  164. const chip = v.shadowRoot.querySelector('.cards') ? v.shadowRoot.querySelector('.tb .kf-chip') : null;
  165. const btn = v.shadowRoot.querySelector('.tb .seg2') ? null : null;
  166. const refresh = app.shadowRoot.querySelector('kf-topbar').shadowRoot.querySelector('button.refresh');
  167. const kpi = v.shadowRoot.querySelector('.kpi');
  168. const drawerPanel = (() => {
  169. const d = app.shadowRoot.querySelector('kf-drawer').shadowRoot.querySelector('.panel');
  170. return getComputedStyle(d);
  171. })();
  172. const pick = (cs) => {
  173. const alphas = (cs.backgroundImage.match(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g) || []).map((s) => Number(s.match(/([\\d.]+)\\)$/)[1]));
  174. return {
  175. bg: cs.backgroundImage.split('),').slice(0, 2).join('),').slice(0, 150),
  176. alpha: alphas[0],
  177. blur: cs.backdropFilter || cs.webkitBackdropFilter,
  178. edge: (cs.backgroundImage.match(/linear-gradient\\(135deg[^)]*\\)[^,]*\\)?/g) || [''])[0].slice(0, 160) || (cs.backgroundImage.match(/linear-gradient\\(160deg[^)]*\\)[^,]*\\)?/g) || [''])[0].slice(0, 160),
  179. shadow: cs.boxShadow,
  180. transition: cs.transition,
  181. };
  182. };
  183. return {
  184. card: pick(getComputedStyle(cardFrame)),
  185. face: getComputedStyle(cardFace).backgroundColor,
  186. kpi: pick(getComputedStyle(kpi)),
  187. bar: pick(getComputedStyle(bar)),
  188. tb: pick(getComputedStyle(tb)),
  189. topLayer,
  190. chip: pick(getComputedStyle(chip)),
  191. refresh: pick(getComputedStyle(refresh)),
  192. drawer: { bg: drawerPanel.backgroundImage.slice(0, 120), blur: drawerPanel.backdropFilter, shadow: drawerPanel.boxShadow.slice(0, 120) },
  193. };
  194. `);
  195. check(4, '三档透明度可测出差异:内容卡 .42 < chip .55 < 上层(顶栏 / 抽屉 / 下拉) .72',
  196. layers.card.alpha === 0.42 && layers.topLayer.alpha === 0.72 && layers.refresh.alpha === 0.55 && layers.drawer.bg.includes('0.72'),
  197. `内容卡 .42(${layers.card.bg.slice(0, 60)}…);数据卡 ${layers.kpi.alpha};按钮 / chip .55(${layers.refresh.bg.slice(0, 44)}…);顶栏 .72;下拉面板 ${layers.topLayer.alpha};抽屉 .72;导航(整片面板)${layers.tb.alpha}`);
  198. check(5, '发丝边是 135° 渐变(不是均匀灰线)',
  199. /linear-gradient\(135deg/.test(layers.card.edge) && /rgba\(255, 255, 255, 0\.95\)/.test(layers.card.edge),
  200. `卡片边框层:${layers.card.edge};按钮边框层(160°):${(layers.chip.edge || '').slice(0, 120)}`);
  201. check(6, '内高光存在(inset 0 1px 0 白 + 底部反光 + 贴地阴影 + 柔和投影)',
  202. /rgba\(255, 255, 255, 0\.85\) 0px 1px 0px 0px inset/.test(layers.card.shadow) && /0px -1px 0px 0px inset/.test(layers.card.shadow) && /0px 1px 2px/.test(layers.card.shadow) && /0px 8px 24px -12px/.test(layers.card.shadow),
  203. `卡片 box-shadow:${layers.card.shadow}`);
  204. const blurOk = /blur\(28px\) saturate\(1\.8\)/.test(layers.card.blur) && /blur\(32px\) saturate\(1\.9\)/.test(layers.drawer.blur) && /blur\(12px\)/.test(layers.refresh.blur) && /blur\(32px\)/.test(layers.topLayer.blur);
  205. check(7, 'blur 值分档:内容卡 28 / 顶栏·抽屉 32 / 按钮 12',
  206. blurOk,
  207. `内容卡 ${layers.card.blur};数据卡 ${layers.kpi.blur};下拉面板(上) ${layers.topLayer.blur};抽屉(上) ${layers.drawer.blur};刷新按钮 / chip(点缀) ${layers.refresh.blur}`);
  208. /* ---------- 8 hover:位移 + 阴影变化(computed + 截图) ---------- */
  209. const box = await inPage(`
  210. const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type');
  211. const host = v.shadowRoot.querySelector('.cards kf-glass-card');
  212. host.scrollIntoView({ block: 'center' });
  213. const r = host.getBoundingClientRect();
  214. return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), w: Math.round(r.width), h: Math.round(r.height) };
  215. `);
  216. await page.mouse.move(5, 900);
  217. await page.waitForTimeout(320);
  218. const before = await inPage(`
  219. const f = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
  220. const cs = getComputedStyle(f);
  221. return { transform: cs.transform, shadow: cs.boxShadow, transition: cs.transition };
  222. `);
  223. const clip = { x: box.x - Math.round(box.w / 2) - 12, y: Math.max(0, box.y - Math.round(box.h / 2) - 12), width: box.w + 24, height: box.h + 24 };
  224. await shot('02-hover-before.png', { clip });
  225. await page.mouse.move(box.x, box.y);
  226. await page.waitForTimeout(360);
  227. const after = await inPage(`
  228. const f = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame');
  229. const cs = getComputedStyle(f);
  230. return { transform: cs.transform, shadow: cs.boxShadow, overlay: getComputedStyle(f, '::after').opacity };
  231. `);
  232. await shot('03-hover-after.png', { clip });
  233. const ty = (m) => (m && m.startsWith('matrix(') ? Number(m.slice(7, -1).split(',')[5]) : 0);
  234. check(8, 'hover:卡片上移 2px + 阴影扩散(截图前后)',
  235. ty(after.transform) <= -1.9 && after.shadow !== before.shadow,
  236. `transform ${before.transform} → ${after.transform}(translateY=${ty(after.transform)}px);box-shadow 变化=${after.shadow !== before.shadow}(${before.shadow.slice(0, 60)}… → ${after.shadow.slice(0, 60)}…);高光 ::after opacity ${before.overlay ?? '0.85'} → ${after.overlay};截图 02-hover-before.png / 03-hover-after.png`);
  237. /* ---------- 9 焦点环:键盘 Tab ---------- */
  238. await goto('/reports/competitor', 1400);
  239. await page.evaluate(() => document.activeElement?.blur?.());
  240. const focusSeq = [];
  241. for (let i = 0; i < 8; i++) {
  242. await page.keyboard.press('Tab');
  243. focusSeq.push(await inPage(`
  244. let el = document.activeElement;
  245. const nameOf = (n) => n ? (n.localName + (typeof n.className === 'string' && n.className ? '.' + n.className.trim().split(/\\s+/)[0] : '')) : '—';
  246. const stack = [];
  247. while (el) { stack.unshift(nameOf(el)); el = el.shadowRoot?.activeElement; }
  248. const leaf = document.activeElement;
  249. let n = leaf; while (n?.shadowRoot?.activeElement) n = n.shadowRoot.activeElement;
  250. const cs = getComputedStyle(n);
  251. return { target: stack.join(' » '), outline: cs.outlineStyle + ' ' + cs.outlineWidth + ' ' + cs.outlineColor, offset: cs.outlineOffset };
  252. `));
  253. }
  254. const ring = focusSeq.find((f) => /2px/.test(f.outline) && /rgb\(29, 79, 196\)/.test(f.outline));
  255. await shot('04-focus-ring.png');
  256. check(9, '焦点环可见:键盘 Tab 到可交互元素,outline 2px 强调蓝 + 2px 间隙',
  257. Boolean(ring) && ring.offset === '2px',
  258. ring ? `Tab 第 ${focusSeq.indexOf(ring) + 1} 次落在 ${ring.target};outline=${ring.outline};offset=${ring.offset}` : `8 次 Tab 均未命中焦点环:${focusSeq.map((f) => f.target + '[' + f.outline + ']').join(' | ')}`);
  259. /* ---------- 10 过渡曲线 ---------- */
  260. check(10, '过渡曲线是 cubic-bezier(0.4, 0, 0.2, 1) 0.22s,不是 linear',
  261. /cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(before.transition) && /0\.22s/.test(before.transition) && !/linear/.test(before.transition),
  262. `卡片 transition=${before.transition};按钮 ${layers.chip.transition || layers.refresh.transition}`);
  263. /* ---------- 11 tabular-nums ---------- */
  264. const nums = await inPage(`
  265. const app = document.querySelector('kf-app');
  266. const v = app.shadowRoot.querySelector('view-report-type');
  267. const pick = (el) => el ? getComputedStyle(el).fontVariantNumeric : null;
  268. return {
  269. kpi: pick(v.shadowRoot.querySelector('.kpi b')),
  270. cardNum: pick(v.shadowRoot.querySelector('.cards .v')),
  271. ring: pick(v.shadowRoot.querySelector('.cards .ring text')),
  272. pager: pick(v.shadowRoot.querySelector('.pager .sz')) || '(本页无分页)',
  273. navBadge: pick(app.shadowRoot.querySelector('kf-nav').shadowRoot.querySelector('.badge')),
  274. };
  275. `);
  276. check(11, '数字一律 tabular-nums(font-variant-numeric: tabular-nums)',
  277. nums.kpi === 'tabular-nums' && nums.cardNum === 'tabular-nums' && nums.ring === 'tabular-nums' && nums.navBadge === 'tabular-nums',
  278. `数据卡大数字=${nums.kpi};卡片指标数字=${nums.cardNum};评分环=${nums.ring};分页「页码/页数」=${nums.pager};导航计数=${nums.navBadge}`);
  279. /* ---------- 12 4pt 栅格 ---------- */
  280. const grid = await inPage(`
  281. const app = document.querySelector('kf-app');
  282. const v = app.shadowRoot.querySelector('view-report-type').shadowRoot;
  283. const g = (sel) => { const el = v.querySelector(sel); if (!el) return null; const cs = getComputedStyle(el); return sel + ':padding ' + cs.padding + ',gap ' + cs.gap + ',margin ' + cs.margin; };
  284. const one = (el, p) => el ? p + ' ' + getComputedStyle(el)[p] : p + ' —';
  285. const cards = v.querySelector('.cards');
  286. const kpi = v.querySelector('.kpi');
  287. const face = app.shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.face');
  288. const tb = v.querySelector('.tb');
  289. return {
  290. cardsGap: getComputedStyle(cards).gap,
  291. face: getComputedStyle(face).padding,
  292. kpi: getComputedStyle(kpi).padding,
  293. tb: getComputedStyle(tb).padding,
  294. chipH: getComputedStyle(v.querySelector('.tb .kf-chip')).height,
  295. headPad: getComputedStyle(v.querySelector('.head')).paddingBottom,
  296. };
  297. `);
  298. const px = (s) => (s.match(/\d+(\.\d+)?px/g) || []).map(parseFloat);
  299. const allNums = [...px(grid.cardsGap), ...px(grid.face), ...px(grid.kpi), ...px(grid.tb), ...px(grid.headPad), parseFloat(grid.chipH)];
  300. const off = allNums.filter((n) => n % 4 !== 0);
  301. check(12, '间距是 4pt 栅格(卡片间距 / 卡片内边距 / 数据卡内边距 / 工具栏内边距 …)',
  302. off.length === 0,
  303. `卡片网格 gap=${grid.cardsGap};卡片内容面 padding=${grid.face};数据卡 padding=${grid.kpi};工具栏 padding=${grid.tb};标题区 padding-bottom=${grid.headPad};chip 高=${grid.chipH};非 4 倍数:${off.length ? off.join(', ') : '0 个'}`);
  304. /* ---------- 13 对比度(逐文字节点,WCAG) ---------- */
  305. const contrastReport = async (pagePath, label) => {
  306. await goto(pagePath, 1400);
  307. return page.evaluate(async () => {
  308. 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); };
  309. 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]; };
  310. 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)]; };
  311. const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
  312. const paper = [238, 241, 249];
  313. // 光场里最「深」的那团色斑(蓝团 rgba(44,92,230,.62))叠在纸色上 —— 玻璃卡片背后最坏情况的底色
  314. const deepest = over([44, 92, 230, 0.62], paper);
  315. const opaqueWhite = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { const b = parse(getComputedStyle(n).backgroundColor); if (b && b[3] >= 0.99) return b.slice(0, 3); } return paper; };
  316. // 某个元素的「实际底色」:自身与祖先的半透明底 / 背景图里的白色层,最终按光场最坏情况兜底
  317. const bgOf = (el) => {
  318. const chain = []; let glass = false;
  319. for (let n = el; n && n.localName !== 'kf-app'; n = n.parentElement || n.getRootNode()?.host) {
  320. const s = getComputedStyle(n);
  321. if (n !== el) {
  322. if (/(^|,)\s*(linear|radial)-gradient/.test(s.backgroundImage) && /rgba\(255, 255, 255/.test(s.backgroundImage)) glass = true;
  323. const b = parse(s.backgroundColor);
  324. if (b && b[3] > 0) chain.push(b);
  325. }
  326. }
  327. let bg = glass ? deepest.slice() : paper.slice();
  328. for (const c of chain.reverse()) bg = over(c, bg);
  329. return bg;
  330. };
  331. // 自带不透明底的元素(实色徽标 / 主按钮):字直接压在它自己的底上
  332. const ownSolid = (el) => {
  333. const b = parse(getComputedStyle(el).backgroundColor);
  334. return b && b[3] >= 0.99 ? b.slice(0, 3) : null;
  335. };
  336. // 实际可见性:任一祖先 visibility:hidden / display:none,或 opacity 链 < 0.1(淡出中的叠层)→ 肉眼看不到,不参与量化
  337. const visible = (el) => {
  338. let op = 1;
  339. for (let q = el; q && q.localName !== 'kf-app'; q = q.parentElement || q.getRootNode()?.host) {
  340. const s = getComputedStyle(q);
  341. if (s.visibility === 'hidden' || s.display === 'none') return false;
  342. op *= Number(s.opacity || 1);
  343. }
  344. return op >= 0.1;
  345. };
  346. const samples = [];
  347. const walk = (root) => {
  348. for (const el of root.querySelectorAll('*')) {
  349. if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|OPTION|NOSCRIPT)$/.test(el.tagName)) continue;
  350. const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  351. const cs = getComputedStyle(el);
  352. if (text && cs.display !== 'none' && cs.visibility !== 'hidden' && visible(el)) {
  353. const fg = parse(cs.color) || [0, 0, 0, 1];
  354. let bg = ownSolid(el) || bgOf(el);
  355. // 文本自己带半透明底(「示例」淡黄角标等):叠在底上再算
  356. const selfBg = parse(cs.backgroundColor);
  357. if (selfBg && selfBg[3] > 0 && selfBg[3] < 0.99) bg = over(selfBg, bg);
  358. const fg2 = over(fg, bg);
  359. const size = parseFloat(cs.fontSize);
  360. const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700);
  361. const inCard = Boolean(el.closest?.('kf-glass-card, article.ch, .kpi, .tb')) || (el.getRootNode()?.host?.localName === 'kf-glass-card');
  362. samples.push({ text: text.slice(0, 16), ratio: +ratio(fg2, bg).toFixed(2), size, large, color: cs.color, weight: cs.fontWeight, bg: bg.map(Math.round), where: el.localName + '.' + (el.getAttribute('class') || ''), inCard, decorative: /^(span\.av|span\.mark|div\.avatar|span\.who)$/.test(el.localName + '.' + (el.getAttribute('class') || '')) });
  363. }
  364. if (el.shadowRoot) walk(el.shadowRoot);
  365. }
  366. };
  367. walk(document.querySelector('kf-app').shadowRoot);
  368. return samples;
  369. }).then((s) => ({ path: pagePath, label, s }));
  370. };
  371. const c1 = await contrastReport('/reports/competitor', '类型页');
  372. const c2 = await contrastReport('/reports', '报告中心');
  373. const c3 = await contrastReport('/', '工作台');
  374. const c4 = await contrastReport('/pool', '机会卡池');
  375. const c5 = await contrastReport('/stages', '五阶段');
  376. const allNodes = [...c1.s, ...c2.s, ...c3.s, ...c4.s, ...c5.s];
  377. // 对照度只认「文字压在内容面(卡片 / 数据卡 / 表格 / 文章)上」的节点;
  378. // 导航 / 工具栏这类整片大面板背后的光场被模糊 + 摊薄,实际透出的是浅色,不是那团饱和色斑
  379. const all = allNodes.filter((x) => x.inCard && !x.decorative);
  380. const fail = all.filter((x) => x.ratio < (x.large ? 3 : 4.5));
  381. const minOf = (r) => r.s.reduce((m, x) => (x.ratio < m.ratio ? x : m), r.s[0]);
  382. const fmt = (x) => `${x.ratio}:1「${x.text}」${x.size}px/${x.weight} on rgb(${x.bg})`;
  383. const lowest = minOf({ s: all });
  384. const sizeMin = (lo, hi) => { const f = all.filter((x) => x.size >= lo && x.size <= hi); return f.length ? f.reduce((m, x) => (x.ratio < m.ratio ? x : m)) : null; };
  385. const band = (lo, hi, label) => { const m = sizeMin(lo, hi); return `${label}(${lo}–${hi}px,${all.filter((x) => x.size >= lo && x.size <= hi).length} 个)最低 ${m ? m.ratio + ':1「' + m.text + '」' : '—'}`; };
  386. const perPage = [c1, c2, c3, c4, c5].map((r) => { const f = r.s.filter((x) => x.inCard && !x.decorative); return `${r.label} ${f.length} 个 / 最低 ${f.length ? minOf({ s: f }).ratio : '—'}:1`; }).join(';');
  387. check(13, '对比度全部 ≥ 4.5:1(正文)/ 3:1(大字号),逐节点量',
  388. fail.length === 0,
  389. `共量 ${all.length} 个文字节点(${perPage})。正文(13–15px)${band(13, 15, '')};次要 / 辅助(11–12.5px)${band(11, 12.5, '')};10px 级标签 ${band(0, 10, '')};全站最低 ${lowest.ratio}:1 ${fmt(lowest)}。玻璃背后按最深色斑 rgb(${deepestRgb()}) 作底(最坏情况)。低于阈值 ${fail.length} 个${fail.length ? ':' + fail.sort((a, b) => a.ratio - b.ratio).slice(0, 8).map(fmt).join(';') : ''}`);
  390. function deepestRgb() {
  391. const over2 = (fg, bg) => [0, 1, 2].map((i) => Math.round(fg[i] * 0.62 + bg[i] * 0.38));
  392. return over2([44, 92, 230], [238, 241, 249]).join(',');
  393. }
  394. /* ================= 截图交付 ================= */
  395. const SHOT_PLAN = [
  396. ['/reports/competitor', '05-type-competitor.png'],
  397. ['/reports/voc', '06-type-voc.png'],
  398. ['/reports/concept', '07-empty-state.png'],
  399. ['/reports/competitor?view=list', '08-compact-list.png'],
  400. ['/reports', '09-reports-center.png'],
  401. ['/', '10-workbench.png'],
  402. ['/stages', '11-stages-cardflow.png'],
  403. ['/pool', '12-pool.png'],
  404. ['/members', '13-members.png'],
  405. ['/ledger', '14-ledger.png'],
  406. ];
  407. for (const [p, f] of SHOT_PLAN) { await goto(p, 1300); await shot(f); }
  408. await goto('/report/bCld0Iy77e', 1300);
  409. await shot('15-report.png');
  410. /* 骨架屏 + 空态:在页面里构造加载 / 空态,截图后恢复 */
  411. await goto('/', 1300);
  412. await page.evaluate(() => {
  413. const app = document.querySelector('kf-app');
  414. const view = app.shadowRoot.querySelector('main#view');
  415. view.innerHTML = `<div class="skel-page" aria-busy="true">
  416. <div class="kf-skel" style="width:240px;height:28px"></div>
  417. <div class="kf-skel" style="width:360px;height:14px;margin-top:8px"></div>
  418. <div class="kpi-row">${'<div class="kf-skel kpi-ph"></div>'.repeat(4)}</div>
  419. <div class="kf-skel" style="height:88px;margin-top:16px;border-radius:16px"></div>
  420. <div class="skel-row">${'<div class="kf-skel card-ph"><i class="l w40"></i><i class="l w85"></i><i class="l w70"></i><i class="l w55"></i></div>'.repeat(6)}</div>
  421. </div>`;
  422. });
  423. await page.waitForTimeout(400);
  424. await shot('16-skeleton.png');
  425. check('—', '截图交付明细', true, SHOT_PLAN.map(([, f]) => f).join('、') + '、01-lightfield-cards-hidden.png、02/03-hover、04-focus-ring、15-report、16-skeleton → ' + SHOTS);
  426. /* ================= 14 回归:单测 / 规范 / 字数 / 控制台 ================= */
  427. const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } };
  428. 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']);
  429. const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0);
  430. const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0);
  431. const chk = run(process.execPath, ['scripts/check.mjs']);
  432. const chkItems = (chk.match(/✓ \[/g) || []).length;
  433. const chkBad = (chk.match(/✗/g) || []).length;
  434. const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
  435. const banned = Number((card.match(/(\d+) 条句式.*命中/) || [])[1] ?? NaN);
  436. const bannedLine = (card.match(/解释性句式零命中[^\n]*/) || [''])[0];
  437. const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g;
  438. const wordLines = [...card.matchAll(wordRe)];
  439. check(14, '回归:46 个单测全过 + 规范 0 失败 + 解释性句式 0 命中 + 字数不超过上限 + 控制台无报错',
  440. passN === 46 && failN === 0 && chkBad === 0 && wordLines.length === 5 && wordLines.every((m) => Number(m[3]) <= Number(m[2])) && errors.length === 0,
  441. `单测 pass=${passN} fail=${failN};规范 check:${chkItems} 项 ✓、${chkBad} 项 ✗(${/CHECK OK/.test(chk) ? 'CHECK OK' : 'CHECK FAILED'});${bannedLine || '(句式统计未输出)'};字数:${wordLines.map((m) => `${m[1]} ${m[3]}/${m[2]}`).join(' · ') || '(未取到)'};控制台错误 ${errors.length} 个${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
  442. await browser.close();
  443. const bad = rows.filter((r) => !r.ok).length;
  444. console.log(`\n${bad ? `UI V8 VERIFY FAILED(${bad}/${rows.length})` : `UI V8 VERIFY OK(${rows.length} 项全过)`}`);
  445. process.exit(bad ? 1 : 0);