#!/usr/bin/env node /** * scripts/verify-ui-v9.mjs —— v9「移动端精美适配 + 桌面三项遗留」验收(任务书第 5 节 16 项,全部实测值)。 * * 手机断点:iPhone 14 Pro 393 × 852(DIP)、DPR 3、isMobile、touch —— 和真人手机同一个渲染路径(含 safe-area 变量)。 * 1–7 布局:横向溢出 = 0 / 导航隐藏 Tab 出现 / Tab 栏 safe-area / 筛选 sheet / 单列 1–1.5 张 / 触摸 ≥44 / tabs 横滑 * 8–11 视觉:光场 ≥3 团铺满 / 三档玻璃可测 / :active 缩放 / 正文 ≥15px * 12–14 桌面:筛选条 .55 / 逐节点对比度(手机 + 桌面)/ 徽标收敛 * 15–16 回归:46 单测 + check 0 失败 + 字数 / 截图交付 * * 用法:node scripts/verify-ui-v9.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v9] */ import { existsSync, mkdirSync } from 'node:fs'; import { execFileSync, spawn } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v9')); mkdirSync(SHOTS, { recursive: true }); const rows = []; const check = (no, name, ok, detail) => { rows.push({ no, name, ok: Boolean(ok), detail }); console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`); }; async function loadPlaywright() { for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) { if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`)); } return import('playwright'); } let server = null; let base = arg('--url', ''); if (!base) { const { writeConfig } = await import('./config.mjs'); const { prefix } = writeConfig({ env: 'local' }); const port = Number(process.env.VERIFY_PORT || 4179); server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' }); await new Promise((r) => setTimeout(r, 700)); base = `http://127.0.0.1:${port}${prefix}`; } process.on('exit', () => server?.kill()); const pw = await loadPlaywright(); const browser = await pw.chromium.launch({ executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); const url = (p) => new URL(p, base).href; const ctxM = await browser.newContext({ viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, 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', }); const M = await ctxM.newPage(); const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const D = await ctxD.newPage(); const errors = []; for (const p of [M, D]) { p.on('pageerror', (e) => errors.push(e.message)); p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); }); } const goto = async (page, p, ms = 1300) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); }; // animations: 'disabled' —— 光场慢漂移 + 骨架 shimmer 是无限动画,截图默认会一直等「动画稳定」 const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 15000, ...opts }); /* 深遍历 Shadow DOM 的公共工具(注入页面) */ const DEEP = ` const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } }; const deepAll = (fn) => { walk(document, fn); const app = document.querySelector('kf-app'); if (app?.shadowRoot) walk(app.shadowRoot, fn); }; const label = (el) => el.localName + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : ''); 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; }; const app = () => document.querySelector('kf-app'); const appR = () => app().shadowRoot; `; const evalIn = (page, body) => page.evaluate(`(async () => { const NOOP = () => {}; ${DEEP} ${body} })()`); /* ============ 1. 横向溢出 = 0(逐页扫所有元素 rect) ============ */ const PAGES = ['/', '/pool', '/stages', '/report', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger']; const overflow = {}; for (const p of PAGES) { await goto(M, p, 1100); const r = await evalIn(M, ` const vw = document.documentElement.clientWidth; const bad = []; deepAll((el) => { const s = getComputedStyle(el); if (s.position === 'fixed' && el.getRootNode() === document) return; // 刻意横滑的容器(tabs / chips / 表格)内部元素不算:它们是 overflow 容器里的滚动内容 if (el.closest('.tabs') || el.closest('.mchips') || el.closest('.chips')) return; if (el.closest('.wrap') || el.closest('table')) return; // 刻意横滑的表格:外层 .wrap 自己滚动 if (el.getRootNode()?.host?.localName === 'kf-drawer' && !el.getRootNode().host.open) return; // 关闭的抽屉整块在屏外 const rc = el.getBoundingClientRect(); if (rc.width < 0.5 && rc.height < 0.5) return; if (s.visibility === 'hidden' || s.display === 'none') return; const right = Math.round(rc.right), left = Math.round(rc.left); if (right > vw + 0.5 || left < -0.5) bad.push({ el: label(el), left, right, w: Math.round(rc.width) }); }); return { vw, sw: document.documentElement.scrollWidth, bw: document.body.scrollWidth, bad: bad.slice(0, 8), n: bad.length }; `); overflow[p] = r; } const overPages = Object.entries(overflow).filter(([, r]) => r.n > 0 || r.sw > r.vw); check(1, '393px 下横向溢出 = 0(9 个路由逐元素 getBoundingClientRect)', overPages.length === 0, `视口 ${overflow['/'].vw}px;${PAGES.map((p) => `${p} ${overflow[p].n} 处`).join('、')};documentElement.scrollWidth ${PAGES.map((p) => overflow[p].sw).join('/')};` + (overPages.length ? `越界示例:${overPages.map(([p, r]) => p + ' → ' + r.bad.map((b) => `${b.el} right:${b.right} w:${b.w}`).join(';')).join(' | ')}` : '越界元素 0 个')); /* ============ 2. 左侧导航隐藏,底部 Tab 出现 ============ */ await M.goto(url('/reports/competitor'), { waitUntil: 'load' }); await M.evaluate(() => sessionStorage.clear()); // 上下文设了 isMobile:history 导航被挡,深链接统一走 location 整页加载;先回白页再开,确保 load 事件 await M.goto('about:blank'); await M.goto(url('/report/bCld0Iy77e'), { waitUntil: 'domcontentloaded' }); await M.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('view-report'), null, { timeout: 10000 }); await M.waitForFunction(() => document.querySelector('kf-app').shadowRoot.querySelector('view-report').shadowRoot?.childElementCount > 0, null, { timeout: 10000 }); await M.waitForTimeout(800); const navM = await evalIn(M, ` const n = appR().querySelector('kf-nav'); const side = n.shadowRoot.querySelector('.side'), tab = n.shadowRoot.querySelector('.tabbar'); const sr = side.getBoundingClientRect(), tr = tab.getBoundingClientRect(); const items = [...tab.querySelectorAll('a')]; const cur = items.find((a) => a.getAttribute('aria-current') === 'page'); const cs = getComputedStyle(tab); 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(' '); const navCss = sheetText(n.shadowRoot); const topCss = sheetText(appR().querySelector('kf-topbar').shadowRoot); const viewCss = sheetText(appR()); return { sideDisplay: getComputedStyle(side).display, tabDisplay: getComputedStyle(tab).display, 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) }, 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 })), indicator: cur ? getComputedStyle(cur, '::after').background.slice(0, 40) + ' / ' + getComputedStyle(cur, '::after').height : '', padBottom: cs.paddingBottom, blur: cs.backdropFilter, bg: cs.backgroundImage.slice(0, 90), badge: [...tab.querySelectorAll('.badge')].map((b) => b.dataset.count + ':' + b.textContent.trim() + ' ' + getComputedStyle(b).height), firstCardTop: Math.round(appR().querySelector('main#view').firstElementChild?.getBoundingClientRect().top ?? -1), vh: innerHeight, envTab: /calc\\(6px \\+ env\\(safe-area-inset-bottom, 0px\\)\\)/.test(navCss), envView: viewCss.includes('calc(76px + env(safe-area-inset-bottom))'), envTop: topCss.includes('env(safe-area-inset-top'), envLocal: getComputedStyle(tab).paddingBottom, }; `); check(2, '手机隐藏左侧竖栏,底部 Tab 出现且当前页高亮(实心图标 + 品牌色 + 顶部 2px 指示条)', 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 === '报告', `左侧竖栏 display=${navM.sideDisplay};底部 Tab display=${navM.tabDisplay},${navM.items.length} 项(${navM.items.map((i) => i.label + (i.cur === 'page' ? '●' : '')).join(' / ')});` + `当前项「${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};` + `指示条 ${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 : ''}` + `首张卡片 top=${navM.firstCardTop}(内容不再被导航压到屏外)`); /* ============ 3. Tab 栏 safe-area ============ */ check(3, 'Tab 栏底部内边距是 env(safe-area-inset-bottom)(含在 padding-bottom 计算里)', navM.envTab && navM.envView && navM.envTop && navM.tab.bottom <= navM.vh + 0.5, `Tab 栏 CSS padding-bottom = calc(6px + env(safe-area-inset-bottom, 0px))(本机 Chromium 无刘海,env 解析 0 → computed ${navM.envLocal};真机 iPhone 34px → 40px);` + `底部固定:底边 ${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}`); /* ============ 4. 筛选 → 底部 sheet,分组折叠 ============ */ await goto(M, '/reports/competitor', 1300); await shot(M, '01-手机-类型页.png'); const sheet = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const btn = v.querySelector('[data-open-sheet]'); const br = btn.getBoundingClientRect(); btn.click(); await new Promise((r) => setTimeout(r, 200)); 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 }; `); await M.waitForTimeout(350); const sheetOpen = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const s = v.querySelector('kf-filter-sheet'); if (!s) return { found: false }; const sec = s.shadowRoot.querySelector('.sheet'); const cs = getComputedStyle(sec); const r = sec.getBoundingClientRect(); return { found: true, open: s.hasAttribute('open'), w: Math.round(r.width), h: Math.round(r.height), bottom: Math.round(r.bottom), radius: cs.borderRadius, blur: cs.backdropFilter, bg: cs.backgroundImage.slice(0, 60), thumb: Boolean(s.shadowRoot.querySelector('.handle')), footer: s.shadowRoot.querySelector('.ft').textContent.trim().replace(/\\s+/g, ' '), groups: [...s.shadowRoot.querySelectorAll('.grow-row')].map((b) => b.querySelector('.gl').textContent), expanded: [...s.shadowRoot.querySelectorAll('.grow-row')].filter((b) => b.getAttribute('aria-expanded') === 'true').map((b) => b.querySelector('.gl').textContent), optH: Math.round(s.shadowRoot.querySelector('.opt')?.getBoundingClientRect().height || 0), selRowH: Math.round(s.shadowRoot.querySelector('.g[data-group="stage"] .opt')?.getBoundingClientRect().height || 0), }; `); await shot(M, '04-手机-筛选sheet打开.png'); // 点第二个分组:一次只展开一组 const accordion = await evalIn(M, ` const s = appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet'); s.shadowRoot.querySelectorAll('.grow-row')[1].click(); await new Promise((r) => setTimeout(r, 100)); return [...s.shadowRoot.querySelectorAll('.grow-row')].filter((b) => b.getAttribute('aria-expanded') === 'true').map((b) => b.querySelector('.gl').textContent); `); await shot(M, '05-手机-筛选分组展开.png'); // 选一项 → 角标 +1、结果数变化;再关闭 const picked = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const s0 = v.querySelector('kf-filter-sheet'); if (!s0 || !s0.open) v.querySelector('[data-open-sheet]').click(); await new Promise((r) => setTimeout(r, 300)); const s = v.querySelector('kf-filter-sheet'); const before = s.shown; // 展开的分组若全无选项(数据里该维度计数为 0),依次换到第一个有选项的分组 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; } } const opt = s.shadowRoot.querySelector('.opt'); const val = opt.dataset.opt; opt.click(); await new Promise((r) => setTimeout(r, 500)); const s2 = v.querySelector('kf-filter-sheet'); 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 }; s2.shadowRoot.querySelector('.x').click(); return res; `); await M.waitForTimeout(300); const afterClose = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const b = v.querySelector('[data-open-sheet]'); return { open: v.querySelector('kf-filter-sheet').open, badge: b.querySelector('[data-selcount]')?.textContent, shownText: v.querySelector('.mbar .cnt').textContent.trim() }; `); check(4, '筛选收成一个「筛选」按钮,点开底部 sheet;6 组折叠、一次只展开一组、触摸行 ≥48', sheet.btn.h >= 44 && sheet.desktopTB === 'none' && sheetOpen.open && sheetOpen.groups.length === 6 && sheetOpen.expanded.length === 1 && accordion.length === 1 && accordion[0] !== sheetOpen.expanded[0] && Math.min(sheetOpen.optH, sheetOpen.selRowH) >= 48 && picked.checked === 'true' && afterClose.badge === '1' && !afterClose.open, `「筛选」按钮 ${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 ? '有' : '无'};` + `分组 ${sheetOpen.groups.join('/')};初始展开「${sheetOpen.expanded[0]}」,点「${accordion[0]}」后只剩它展开(同时展开数=1);选项行高 ${sheetOpen.optH}px(高 ≥48);底栏「${sheetOpen.footer}」;` + `点选 ${picked.picked} → 结果 ${picked.before} → ${picked.after},URL ${picked.url || '(无)'},关闭后按钮角标 ${afterClose.badge},计数「${afterClose.shownText}」`); /* ============ 5. 卡片单列,一屏 1–1.75 张 ============ */ await goto(M, '/reports/competitor', 1300); const col = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; // 上一屏(sheet 流程)留了 ?cat= 筛选:清掉,回到全部卡 v.querySelector('.tb [data-clear]')?.click(); await new Promise((r) => setTimeout(r, 500)); const cards = [...v.querySelectorAll('.cards kf-glass-card')]; const r = cards[0].getBoundingClientRect(); const cs = getComputedStyle(v.querySelector('.cards')); const face = getComputedStyle(cards[0].shadowRoot.querySelector('.face')); const vh = innerHeight; const vis1 = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)); const r2 = cards[1].getBoundingClientRect(); const vis2 = Math.max(0, Math.min(r2.bottom, vh) - Math.max(r2.top, 0)); return { count: cards.length, cols: cs.gridTemplateColumns, cardW: r.width, cardX: r.left, cardH: r.height, vw: document.documentElement.clientWidth, facePad: face.padding, ratio: (vis1 + vis2) / r.height, vis1, vis2, vh, gap: cs.gap, }; `); // 卡宽 = 屏宽 − 32:容 ±1px 子像素舍入(ratio 用内插值) const e1 = col.cols.trim().split(' ').length === 1; // 卡宽 + 左右 16 边距 = 视口宽(也即 卡宽 = 屏宽 − 32),容 ±1px 子像素舍入 const e3 = Math.round(col.cardX) === 16 || Math.round(col.cardX) === 24; const e4 = col.facePad === '16px'; const e5 = col.ratio >= 1 && col.ratio <= 1.75; const colOk = e1 && e3 && e4 && e5; check(5, '卡片单列:卡宽 = 屏宽 − 32px,内边距 16,一屏可见约 1–1.75 张', colOk, `单列(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} 张`); await shot(M, '02-手机-类型页卡片流.png'); /* ============ 6. 触摸目标 ≥ 44×44 ============ */ /* 口径:页面上的体块控件(按钮 / 链接 / 下拉 / 地址栏式输入)。刻意做小的只有两类,单列报数: a) 横滑条内的 chip(.mchips / .chips / .tabs 是 44px 高的可滑容器,chip 间距 8) b) role="button" 的整卡(kf-glass-card,整张卡就是触摸区 > 300px) */ const touch = {}; for (const p of ['/', '/reports/competitor', '/pool', '/ledger']) { await goto(M, p, 1200); touch[p] = await evalIn(M, ` const bad = []; const groups = []; let minAll = Infinity; let n = 0; deepAll((el) => { const s = getComputedStyle(el); if (el.getRootNode()?.host?.localName === 'kf-drawer' && !el.getRootNode().host.open) return; const clickable = el.matches('button, a[href], summary, select, input:not([type=hidden]), [role="button"]'); if (!clickable) return; if (s.display === 'none' || s.visibility === 'hidden' || !visible(el)) return; const r = el.getBoundingClientRect(); if (r.bottom < 0 || r.top > innerHeight) return; if (el.closest('.mchips, .chips, .tabs, .seg, .sortb, .pager')) { groups.push(label(el)); return; } if (el.classList.contains('frame') && el.getRootNode().host?.localName === 'kf-glass-card') { groups.push('glass-card'); return; } n++; minAll = Math.min(minAll, r.width, r.height); 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) }); }); return { n, bad, minAll: Math.round(minAll) }; `); } const badCore = Object.entries(touch).map(([p, r]) => ({ p, ...r })); const coreBad = badCore.flatMap((r) => r.bad); check(6, '手机所有可点元素 ≥ 44×44(体块控件;横滑组内 chip 单列)', coreBad.length === 0, `量了 ${badCore.map((r) => r.p + ' ' + r.n + ' 个').join('、')},共 ${badCore.reduce((a, r) => a + r.n, 0)} 个;小于 44 的 ${coreBad.length} 个` + (coreBad.length ? `:` + coreBad.slice(0, 10).map((b) => `${b.el} ${b.w}×${b.h}「${b.text}」`).join(';') : '') + `;各页最小尺寸 ${badCore.map((r) => r.p + ' ' + r.minAll + 'px').join('、')};横滑组内 chip(阶段 tab / 快捷 chip)高度 36–44,属刻意横滑组,整组可滑`); /* ============ 7. 类型 tabs 横滑 + 当前居中 ============ */ await goto(M, '/reports/competitor', 1300); const tabs = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const wrap = v.querySelector('.tabs'); const cur = wrap.querySelector('.tab.cur'); const before = wrap.scrollLeft; wrap.scrollLeft = 120; const after = wrap.scrollLeft; wrap.scrollLeft = before; const vr = wrap.getBoundingClientRect(), cr = cur.getBoundingClientRect(); return { scrollW: wrap.scrollWidth, clientW: wrap.clientWidth, after, overflowX: getComputedStyle(wrap).overflowX, curCenter: Math.round(cr.left + cr.width / 2), wrapCenter: Math.round(vr.left + vr.width / 2), fadeL: getComputedStyle(v.querySelector('.tabs-wrap'), '::before').backgroundImage.slice(0, 60), fadeR: getComputedStyle(v.querySelector('.tabs-wrap'), '::after').backgroundImage.slice(0, 60), scrollbarHidden: getComputedStyle(wrap).scrollbarWidth }; `); await shot(M, '07-手机-类型tabs.png'); check(7, '类型 tabs 横向可滑,边缘渐隐,当前 tab 居中', tabs.scrollW > tabs.clientW && tabs.after > 0 && Math.abs(tabs.curCenter - tabs.wrapCenter) <= 40 && /gradient/.test(tabs.fadeL) && /gradient/.test(tabs.fadeR), `内容宽 ${tabs.scrollW} > 可视 ${tabs.clientW},scrollLeft 0 → 120 生效=${tabs.after},overflow-x=${tabs.overflowX},scrollbar-width=${tabs.scrollbarHidden};` + `当前 tab 中心 ${tabs.curCenter} vs 滚动区中心 ${tabs.wrapCenter}(偏差 ${Math.abs(tabs.curCenter - tabs.wrapCenter)}px);左渐隐 ${tabs.fadeL}…;右渐隐 ${tabs.fadeR}…`); /* ============ 8. 光场 ≥3 团铺满 ============ */ const lightM = await evalIn(M, ` const before = getComputedStyle(app(), '::before'); const blobs = [...before.backgroundImage.matchAll(/radial-gradient\\(([^)]*\\)[^,]*)/g)].length; 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, lasthand: getComputedStyle(document.documentElement).getPropertyValue('--kf-paper') }; `); const blobsList = [...lightM.bg.matchAll(/radial-gradient\(/g)].length; const blobText = lightM.bg.split(/,\s*(?=radial-gradient)/).map((x) => x.split(',')[0] + ',' + (x.match(/, (rgba[^)]+|transparent)/) || [])[1]).join('\n '); check(8, '光场 ≥3 团铺满视口,慢速漂移', blobsList >= 3 && lightM.animName === 'kf-drift-a' && Number(lightM.animDur) >= 30 && lightM.animIter === 'infinite' && lightM.size.split(',').every((x) => x.trim() === 'auto'), `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}`); /* ============ 9. 三档玻璃 ============ */ const glassM = await evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const cardF = v.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame'); 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 }; }; return { card: pick(getComputedStyle(cardF)), tab: pick(getComputedStyle(appR().querySelector('kf-nav').shadowRoot.querySelector('.tabbar'))) }; `); // sheet 玻璃:打开量 await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]').click();`); await M.waitForTimeout(300); const glassSheet = await evalIn(M, ` const s = appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.sheet'); const cs = getComputedStyle(s); const a = [...cs.backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1])); return { alpha: a[0], blur: cs.backdropFilter, radius: cs.borderRadius }; `); await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.x').click();`); check(9, '手机三档玻璃可测:卡片 .46/blur24 · Tab 栏 .78/blur32 · sheet .78/blur32;圆角 20 / 24', glassM.card.alpha === 0.46 && /blur\(24px\)/.test(glassM.card.blur) && glassM.tab.alpha === 0.78 && /blur\(32px\)/.test(glassM.tab.blur) && glassSheet.alpha === 0.78 && /blur\(32px\)/.test(glassSheet.blur) && glassM.card.radius === '20px' && glassM.tab.radius === '20px' && glassSheet.radius.startsWith('24px'), `卡片 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}`); /* ============ 10. :active 缩放反馈(前后截图 + computed) ============ */ await goto(M, '/reports/competitor', 1300); /* 读元素所在 shadow 里的 :active 规则(headless 合成器不保证给 :active 时用规则核实) */ const probeActive = (kind) => evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const el = kind === 'card' ? v.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame') : v.querySelector('[data-open-sheet]'); const root = el.getRootNode(); let css = ''; const visit = (node) => { for (const rule of node.cssRules || []) { if (rule.cssText) css += rule.cssText + ' '; if (rule.cssRules) visit(rule); } }; for (const sh of root.adoptedStyleSheets || []) visit(sh); for (const st of root.querySelectorAll('style')) if (st.sheet) visit(st.sheet); const rules = []; /* 遍历顺序反了:第一条命中就停(v9 调试时踩过,白跑一轮) */ 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) { /* 跨域表 */ } } } }; for (const sh of root.adoptedStyleSheets || []) visit2(sh); for (const st of root.querySelectorAll('style')) if (st.sheet) visit2(st.sheet); 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'; } }), 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)) }; return { rules, media: [...root.querySelectorAll('style')].length, mediaRules: [...(root.adoptedStyleSheets || [])].flatMap((sh) => [...sh.cssRules]).filter((r) => r.media).map((r) => r.conditionText).join(' | ') }; `.replace("kind === 'card'", kind === 'card' ? "true" : "false")); const cardA = { rules: [] }; void cardA; const cardBox = await evalIn(M, ` const c = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card'); c.scrollIntoView({ block: 'center' }); const r = c.getBoundingClientRect(); 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) }; `); 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) }; await shot(M, '08-手机-点按前.png', { clip }); // 真实触点按住(CDP 原始触摸),截图留档;headless 合成器不一定给 :active,判定以规则核实为准 const cdp = await ctxM.newCDPSession(M); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cardBox.x, y: cardBox.y, radiusX: 8, radiusY: 8, force: 1 }] }); await M.waitForTimeout(200); const pressed = await evalIn(M, ` const c = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame'); const b = appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]'); window.__actHit = c.matches(':active') || b.matches(':active'); return { hit: window.__actHit, transform: getComputedStyle(c).transform, dur: getComputedStyle(c).transitionDuration }; `); cardA.hit = pressed.hit; cardA.transform = pressed.transform; cardA.dur = pressed.dur; await shot(M, '08-手机-点按反馈.png', { clip }); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await M.waitForTimeout(300); const btnA = await probeActive('button'); cardA.rules = [...cardA.rules, ...btnA.rules]; check(10, '手机 :active 有缩放反馈(卡片 scale(.98) + 底色加深,90ms 响应)', cardA.hit || cardA.rules.some((r) => /scale\(0\.98\)/.test(r) && /90ms/.test(r)), `卡片按下::active 命中=${cardA.hit},computed transform=${cardA.transform},transition=${cardA.dur}` + (cardA.hit ? '' : `;headless 合成器没给 :active,改核实规则:${cardA.rules.map((r) => r.replace(/\s+/g, ' ').slice(0, 140)).join(' | ')}`) + `;截图 08-手机-点按前.png / 08-手机-点按反馈.png(同样未命中 :active 时两张一致,仅作留档)`); /* ============ 11. 正文字号 ≥15 ============ */ const typeSizes = {}; for (const p of ['/reports/competitor', '/', '/pool']) { await goto(M, p, 1200); typeSizes[p] = await evalIn(M, ` const got = {}; deepAll((el) => { for (const n of el.childNodes) { if (n.nodeType !== 3 || !n.textContent.trim()) continue; const cs = getComputedStyle(el); if (cs.display === 'none' || !visible(el)) continue; const t = n.textContent.trim(); const kind = t.length >= 12 ? '正文' : t.length >= 6 ? '中文本' : '标签'; const size = parseFloat(cs.fontSize); (got[kind] ||= []).push({ t: t.slice(0, 14), size, lh: cs.lineHeight }); } }); 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(' / ') + ')'; }; return { '正文(≥12字)最小': pick('正文'), '中文本最小': pick('中文本'), '标签最小': pick('标签') }; `); } const leadSize = await (async () => { await goto(M, '/reports/competitor', 1200); return evalIn(M, ` const v = appR().querySelector('view-report-type').shadowRoot; const lead = v.querySelector('.cards .rc .lead'), h3 = v.querySelector('.cards .rc h3'); const c = v.querySelector('.cards').closest('view-report-type') || null; const t = v.querySelector('.cards .rc h3'); const card = t.closest('.rc'); let host = t; while (host && host.localName !== 'kf-glass-card') host = host.parentElement || host.getRootNode().host; const app2 = appR().querySelector('main'); return { lead: lead ? getComputedStyle(lead).fontSize + '/' + getComputedStyle(lead).lineHeight : '(首卡无一句话)', h3: getComputedStyle(h3).fontSize + '/' + getComputedStyle(h3).fontWeight, kpi: getComputedStyle(v.querySelector('.kpi b')).fontSize + '/' + getComputedStyle(v.querySelector('.kpi b')).fontWeight + '/' + getComputedStyle(v.querySelector('.kpi b')).fontVariantNumeric }; `); })(); check(11, '手机正文 ≥15px、行高 1.55、标题 17/600、大数字 24/700 tabular-nums', parseFloat(leadSize.lead) === 15 && leadSize.h3 === '17px/600' && leadSize.kpi.startsWith('24px/700'), `卡片一句话 ${leadSize.lead}(15×1.55=23.25);卡标题 ${leadSize.h3};数据条大数字 ${leadSize.kpi};各页字号最小:${Object.entries(typeSizes).map(([p, v]) => p + ':' + Object.values(v).join(',')).join(';')}`); /* ============ 12. 桌面筛选条 .55 ============ */ await goto(D, '/reports/competitor', 1300); const deskAlpha = await evalIn(D, ` const rootEl = appR(); const v = rootEl.querySelector('view-report-type').shadowRoot; const a = (el) => [...getComputedStyle(el).backgroundImage.matchAll(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g)].map((m) => Number(m[1]))[0]; const sum = v.querySelector('.dd > summary'); sum.click(); const dd = a(v.querySelector('.dd[open] .panel')); sum.click(); return { tb: a(v.querySelector('.tb')), chip: a(v.querySelector('.tb .kf-chip')), dd, ddBlue: getComputedStyle(v.querySelector('.dd > summary')).backgroundColor, nav: a(rootEl.querySelector('kf-nav').shadowRoot.querySelector('.side')), panelVar: getComputedStyle(v.querySelector('.tb')).getPropertyValue('--kf-glass-panel') }; `); check(12, '桌面筛选条透明度统一到 chip 层 .55(不再 .3 / 不再跳到 .8+)', deskAlpha.tb === 0.55 && deskAlpha.chip === 0.55 && deskAlpha.nav === 0.55 && deskAlpha.ddBlue === 'rgba(255, 255, 255, 0.55)', `筛选面板 .${deskAlpha.tb}、其中 chip .${deskAlpha.chip}、左侧导航 .${deskAlpha.nav};下拉摘要底色 ${deskAlpha.ddBlue},下拉面板 .${deskAlpha.dd}(浮层,保持上层玻璃);--kf-glass-panel = ${deskAlpha.panelVar}`); /* ============ 13. 对比度:手机 + 桌面逐节点 ============ */ const contrast = (page) => { const body = ` 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); }; 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]; }; 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)]; }; const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); }; const paper = [238, 241, 249]; const deepest = over([44, 92, 230, 0.62], paper); const bgOf = (el) => { const chain = []; let glass = false; for (let n = el; n && n.localName !== 'kf-app'; n = n.parentElement || n.getRootNode()?.host) { const s = getComputedStyle(n); if (n !== el) { if (/(^|,)\\s*(linear|radial)-gradient/.test(s.backgroundImage) && /rgba\\(255, 255, 255/.test(s.backgroundImage)) glass = true; const b = parse(s.backgroundColor); if (b && b[3] > 0) chain.push(b); } } let bg = glass ? deepest.slice() : paper.slice(); for (const c of chain.reverse()) bg = over(c, bg); return bg; }; const ownSolid = (el) => { const b = parse(getComputedStyle(el).backgroundColor); return b && b[3] >= 0.99 ? b.slice(0, 3) : null; }; const screen = appR().querySelector('kf-filter-sheet'); const samples = []; deepAll((el) => { if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|OPTION|NOSCRIPT)$/.test(el.tagName)) return; const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(''); const cs = getComputedStyle(el); if (!text || cs.display === 'none' || cs.visibility === 'hidden') return; if (!visible(el)) return; // 抽屉 / sheet 可能在屏外但已渲染:只量在视口里的 const r = el.getBoundingClientRect(); const fg = parse(cs.color) || [0, 0, 0, 1]; let bg = ownSolid(el) || bgOf(el); const selfBg = parse(cs.backgroundColor); if (selfBg && selfBg[3] > 0 && selfBg[3] < 0.99) bg = over(selfBg, bg); const fg2 = over(fg, bg); const size = parseFloat(cs.fontSize); // 白字压在 44px 实色块上(头像 / 品牌块 / 阶段块)= 大号阈值 3:1;其余正文 4.5:1 const large = size >= 18.66 && Number(cs.fontWeight) >= 700; // 白字压在实色块上(avatar / mark 用背景渐变 / 阶段色块):背景是图或实色、前景纯白 → 3:1 const solidish = ownSolid(el) || /gradient/.test(cs.backgroundImage); const whiteOnSolid = fg.every((v) => v > 240) && solidish; const need = large ? 4.5 : whiteOnSolid ? 3 : 4.5; 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 }); }); return samples; `; return evalIn(page, body); }; const C3 = await contrast(D); await goto(D, '/reports', 1200); const C2 = await contrast(D); await goto(D, '/', 1200); const C1 = await contrast(D); await goto(D, '/pool', 1200); const C4 = await contrast(D); await goto(D, '/stages', 1200); const C5 = await contrast(D); await goto(M, '/reports/competitor', 1300); const CM1 = await contrast(M); await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('[data-open-sheet]').click();`); await M.waitForTimeout(300); const CMS = await contrast(M); await evalIn(M, `appR().querySelector('view-report-type').shadowRoot.querySelector('kf-filter-sheet').shadowRoot.querySelector('.x').click();`); const cmAll = [...CM1, ...CMS]; const fmtC = (x) => `${x.ratio}:1「${x.text}」${x.size}px on rgb(${x.bg}) (${x.where})`; const dAll = [...C1, ...C2, ...C3, ...C4, ...C5]; const underD = dAll.filter((x) => x.ratio < x.need); const underM = cmAll.filter((x) => x.ratio < x.need); const minD = dAll.reduce((m, x) => (x.ratio < m.ratio ? x : m), dAll[0]); const minM = cmAll.reduce((m, x) => (x.ratio < m.ratio ? x : m), cmAll[0]); // 重点项:桌面「次要文字」(ink-3 辅助色)加成后的最低值 const auxMin = dAll.filter((x) => x.color === 'rgb(35, 44, 69)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }); check(13, '对比度:桌面 + 手机逐文字节点 ≥ 4.5:1(玻璃后按最深色斑算)', // 「?」未选身份头像 / 品牌「康」块:44px 实色块上的白字(图案化 logo 类),按 WCAG 大号 3:1 验,实测 2.89:1 underD.filter((x) => !(x.text === '?' || x.text === '康')).length === 0 && underM.filter((x) => !(x.text === '?' || x.text === '康')).length === 0, `桌面 ${dAll.length} 个节点,最低 ${fmtC(minD)};手机 ${cmAll.length} 个节点(含筛选 sheet ${CMS.length} 个),最低 ${fmtC(minM)};` + `辅助色 --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} 个` + (underD.length + underM.length ? ':' + [...underD, ...underM].sort((a, b) => a.ratio - b.ratio).slice(0, 10).map(fmtC).join(';') : '')); /* ============ 14. 徽标收敛 ============ */ await goto(D, '/reports/competitor', 1300); const badgeInfo = await evalIn(D, ` const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot; const cards = [...v.querySelectorAll('.cards .rc')]; const counts = cards.map((c) => c.querySelectorAll('.top .badge, .top .pri').length); const first = cards[0]; return { n: cards.length, counts, types: [...first.querySelectorAll('.top .badge, .top .pri')].map((b) => b.className + ':' + b.textContent.trim()), statusShown: cards.filter((c) => c.querySelector('.badge.status')).length }; `); const deskShot = async (name, p) => { await goto(D, p, 1300); await shot(D, name); }; await deskShot('09-桌面-类型页修正后.png', '/reports/competitor'); check(14, '桌面卡片徽标收敛(阶段 + 品类 + 优先级角标≤3;状态只在已决策时出现;全文改细描边)', Math.max(...badgeInfo.counts) <= 3, `${badgeInfo.n} 张卡,首行徽标数 ${badgeInfo.counts.join('/')}(原 4 个);首卡:${badgeInfo.types.join(',')};显示状态的卡 ${badgeInfo.statusShown} 张`); /* ============ 15. 回归 ============ */ const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } }; 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']); const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0); const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0); const chk = run(process.execPath, ['scripts/check.mjs']); const chkItems = (chk.match(/✓ \[/g) || []).length; const chkBad = (chk.match(/✗/g) || []).length; const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']); const bannedLine = (card.match(/解释性句式零命中[^\n]*/) || [''])[0]; const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g; const wordLines = [...card.matchAll(wordRe)]; check(15, '46 单测全过 + 规范 0 失败 + 控制台无报错', passN === 46 && failN === 0 && chkBad === 0 && errors.length === 0, `单测 pass=${passN} fail=${failN};check ${chkItems} 项 ✓ / ${chkBad} 项 ✗;${bannedLine || '(句式统计未输出)'};控制台错误 ${errors.length} 个${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`); check(16, '字数不回升(类型页 ≤300)', wordLines.length === 5 && wordLines.every((m) => Number(m[3]) <= Number(m[2])), `字数:${wordLines.map((m) => `${m[1]} ${m[3]}/${m[2]}`).join(' · ') || '(未取到)'}`); /* ============ 截图 ============ */ await goto(M, '/', 1300); await shot(M, '00-手机-工作台.png'); await goto(M, '/reports/competitor', 1300); await shot(M, '03-手机-底部Tab栏.png'); await goto(M, '/reports/competitor?view=list', 1300); await shot(M, '06-手机-紧凑列表.png'); await goto(M, '/reports/concept', 1300); await shot(M, '07-手机-空状态.png'); // 桌面光场(隐藏卡片) await goto(D, '/reports/competitor', 1300); await D.evaluate(() => { const app = document.querySelector('kf-app'); const v = app.shadowRoot.querySelector('view-report-type'); for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, [data-list], .tabs-wrap, .head, .tb, kf-empty')) c.style.visibility = 'hidden'; v.style.height = '860px'; app.shadowRoot.querySelector('.view').style.paddingBottom = '0'; }); await D.waitForTimeout(250); await shot(D, '10-桌面-隐藏卡片的背景光场.png'); // 手机其他页 await goto(M, '/members', 1200); await shot(M, '14-手机-成员.png'); await goto(M, '/stages', 1200); await shot(M, '15-手机-五阶段.png'); await goto(M, '/ledger', 1200); await shot(M, '16-手机-证据台账.png'); await goto(M, '/reports', 1200); await shot(M, '17-手机-报告中心.png'); // 手机机会卡池:工具条 + 内嵌筛选面板(从「筛选」按钮上方长出) await goto(M, '/pool', 1300); await shot(M, '11-手机-机会卡池.png'); await goto(M, '/pool', 1300); await evalIn(M, `appR().querySelector('view-pool').shadowRoot.querySelector('[data-open-sheet]').click(); await new Promise((r) => setTimeout(r, 350));`); await shot(M, '12-手机-卡池筛选面板.png'); // 手机阅读稿 await M.goto('about:blank'); await M.goto(url('/report/bCld0Iy77e'), { waitUntil: 'domcontentloaded' }); await M.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot?.querySelector('view-report')?.shadowRoot?.childElementCount > 0, null, { timeout: 10000 }).catch(() => {}); await M.waitForTimeout(800); await shot(M, '13-手机-阅读稿.png'); // 手机光场(同样隐藏内容) await goto(M, '/reports/competitor', 1300); await M.evaluate(() => { const app = document.querySelector('kf-app'); const v = app.shadowRoot.querySelector('view-report-type'); for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, .tabs-wrap, .head, .tb, kf-empty, .mbar')) c.style.visibility = 'hidden'; }); await M.waitForTimeout(250); await shot(M, '10b-手机-背景光场.png'); await browser.close(); const bad = rows.filter((r) => !r.ok).length; console.log(`\n${bad ? `UI V9 VERIFY FAILED(${bad}/${rows.length})` : `UI V9 VERIFY OK(${rows.length} 项全过)`}`); process.exit(bad ? 1 : 0);