#!/usr/bin/env node /** * scripts/verify-visual-v12.mjs —— v12 深度视觉重构验收(任务书「二、硬性数值规范」逐项实测)。 * * 两种用法: * node scripts/verify-visual-v12.mjs --tag before 只打印现状,**不判分**(改前基线) * node scripts/verify-visual-v12.mjs 判分:任一项不达标 exit 1 * --page /pool 只量一页(调试用) * --json out.json 结果落盘(before-after 文档从这里抄数字,不手填) * * 量什么、怎么量(每项都打印分子/分母,不打印「通过」两个字): * 1. 圆角种类 所有可见元素(跨 shadow)的 computed border-radius,去重后按半径归类;只允许 0 / 8 / 12 / 20 / 9999(胶囊) * 2. 原生控件 button/input/select/textarea/summary:外观被显式覆盖(非 appearance:auto / 非浏览器默认灰底 / 有字族与圆角) * 3. 描边 可见元素的 border-width / outline-width 中 > 1px 的数量 * 4. 纯黑 computed color === rgb(0, 0, 0) 且带可见文字的元素数 * 5. 字号档 可见文字的 font-size 去重(容差 0.1px) * 6. 同排卡高 同一个 grid 容器里的直接子项,按 top 分行(±4px),行内 height 极差 * 7. 卡内空白 卡片内部相邻两行内容之间的竖直间隙,> 24px 记一次 * 8. hover 每个可点元素 pointerover 前后,背景色 / 边框色 / 背景图 / 颜色 / 阴影任一变化 * 9. 底部空白 document 可滚高度 - 最后一块主内容底边 * 10. 框内布局 筛选容器是一个整体、排序是分段控件、搜索框与筛选区底边差(P1) * 11. 左右等高 工作台两栏首行卡片高度差(P3) * * 注意(验收脚本的老坑,见 memory kf-verify-browser-quirks): * - 视图异步挂载:goto 后等 kf-app → view-* 的 shadowRoot 有子节点,再等 300ms; * - 跨 shadow 的可见性要沿 parentElement → getRootNode().host 一路走; * - hover 必须用真实指针(page.mouse.move):合成 pointerover / mouseover 事件不会让 :hover 命中。 */ import { mkdirSync, writeFileSync } from 'node:fs'; 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 TAG = arg('--tag', 'after'); const JUDGE = TAG !== 'before'; const ONLY = arg('--page', ''); const JSON_OUT = arg('--json', ''); if (!process.env.CHROME_PATH) { const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell'; const { existsSync } = await import('node:fs'); if (existsSync(pinned)) process.env.CHROME_PATH = pinned; } const { startServer, launch, DEEP } = await import('./v11b-lib.mjs'); const srv = await startServer(Number(process.env.VERIFY_PORT || 4193)); const browser = await launch(); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); }); const VIEW_OF = { '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports' }; const PAGES = ONLY ? [ONLY] : ['/', '/pool', '/stages', '/reports']; const gotoView = async (p) => { await page.goto(new URL(p, srv.base).href, { waitUntil: 'networkidle' }); await page.waitForTimeout(200); try { await page.waitForFunction(() => window.scrollY === 0, null, { timeout: 3000 }); } catch { await page.evaluate(() => window.scrollTo(0, 0)); } await page.waitForFunction((name) => { const a = document.querySelector('kf-app'); const v = a?.shadowRoot?.querySelector(name); return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0; }, VIEW_OF[p], { timeout: 15000 }); await page.waitForTimeout(700); }; /** 页面里执行:所有「测量」都放在这一段里,避免每次 evaluate 的字符串拼装差异 */ const MEASURE = `(async () => { ${DEEP} const root = () => [document, ...[...document.querySelectorAll('*')].map((e) => e.shadowRoot).filter(Boolean)]; const all = []; const collect = (r) => { for (const el of r.querySelectorAll('*')) { all.push(el); if (el.shadowRoot) collect(el.shadowRoot); } }; collect(document); /* 可见:跨 shadow 链上无 display:none / visibility:hidden / opacity<.05;自身盒 ≥ 8×4 或带可见文字 */ const vis = (el) => { const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 4) return false; if (!shownByStyle(el)) return false; return true; }; const hasText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); const PAGES = ${JSON.stringify(PAGES)}; const page = location.pathname.replace(/\\/+$/, '') || '/'; const out = { page, radii: {}, controls: [], thickBorders: [], blackText: [], fontSizes: {}, rows: [], blanks: [], clickables: 0, hoverFail: [], scrollH: 0, lastContentBottom: 0, filterLayout: null, wbCols: null }; /* ---- 1. 圆角 ---- */ const radKinds = {}; const radWhere = {}; for (const el of all) { if (!vis(el)) continue; const s = getComputedStyle(el); const parts = [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomLeftRadius, s.borderBottomRightRadius] .map((v) => v.split(' ')[0]); /* 量条 / 复选框 / 色点这类 ≤ 6px 的小件:形状由尺寸决定(4px 高的进度条圆角 2px = 胶囊), 不作为「圆角体系」计数;8px 以上才算容器圆角,必须落在 8 / 12 / 20。 */ const box = el.getBoundingClientRect(); const tiny = box.width <= 24 || box.height <= 6; if (tiny) continue; for (const p of parts) { const n = Math.round(parseFloat(p) || 0); const pct = p.includes('%'); /* 百分比 = 圆形(头像 / 环),不算圆角档;9999px 以上记作胶囊 */ if (pct) { radKinds.circle = (radKinds.circle || 0) + 1; continue; } if (n > 9000 || p.includes('calc') ) { /* 未解析 */ } const k = n; radKinds[k] = (radKinds[k] || 0) + 1; if (!radWhere[k]) radWhere[k] = elLabel(el).slice(0, 48); } } out.radii = radKinds; out.radiiWhere = radWhere; /* ---- 2. 原生控件 ---- */ const ctrlSel = 'button, input, select, textarea'; for (const el of all) { if (!el.matches?.(ctrlSel)) continue; if (!vis(el)) continue; const s = getComputedStyle(el); const tag = elLabel(el); const bg = s.backgroundColor; const bgImg = s.backgroundImage; const reasons = []; /* 文本框(input)在 Chrome 里 appearance 恒为 auto,但给了 rgba 半透明底色就是已覆盖默认外观; select / textarea 必须显式 appearance:none(select 默认画下拉箭头与系统边框)。 */ const carriesOwnLook = bg !== 'rgba(0, 0, 0, 0)' || bgImg !== 'none' || (parseFloat(s.borderTopWidth) || 0) > 0; const needsAppearance = el.localName === 'select' || el.localName === 'textarea' || (el.localName === 'input' && (s.appearance !== 'none' && !carriesOwnLook)); if (needsAppearance && el.localName !== 'button' && s.appearance !== 'none' && s.webkitAppearance !== 'none') reasons.push('appearance:' + s.appearance); /* 浏览器默认按钮外观:灰底 rgb(239,239,239|221,221,221|192,192,192) + 2px outset */ const m = bg.match(/rgba?\\((\\d+), (\\d+), (\\d+)/); if (m && Math.abs(+m[1] - +m[2]) < 3 && Math.abs(+m[2] - +m[3]) < 3 && +m[1] >= 180 && +m[1] <= 245 && bgImg === 'none') reasons.push('灰底' + bg); const bw = parseFloat(s.borderTopWidth) || 0; if (el.localName === 'button' && bw >= 2 && /outset|inset|groove|ridge/.test(s.borderTopStyle)) reasons.push('浏览器浮雕边 ' + s.borderTopStyle + bw + 'px'); if (/^(Times|serif)/.test(s.fontFamily) || s.fontFamily === 'none') reasons.push('字族泄漏:' + s.fontFamily.slice(0, 24)); if ((el.localName === 'button' || el.localName === 'input') && s.borderRadius === '0px' && bw > 0) reasons.push('无圆角有边框'); const r = el.getBoundingClientRect(); out.controls.push({ tag, w: Math.round(r.width), h: Math.round(r.height), bg, bw, r: s.borderRadius, font: s.fontSize, reasons, text: (el.textContent || el.placeholder || el.getAttribute('aria-label') || '').trim().slice(0, 16) }); } /* ---- 3. 描边 > 1px ---- */ for (const el of all) { if (!vis(el)) continue; const s = getComputedStyle(el); const widths = [s.borderTopWidth, s.borderRightWidth, s.borderBottomWidth, s.borderLeftWidth] .map((v) => parseFloat(v) || 0).filter((w) => w > 1 && s.borderTopStyle !== 'none'); const ow = parseFloat(s.outlineWidth) || 0; if (widths.length || (s.outlineStyle !== 'none' && ow > 1)) out.thickBorders.push({ tag: elLabel(el).slice(0, 50), w: Math.max(...widths, 0), ow, o: s.outlineOffset }); } /* ---- 4. 纯黑文字 ---- */ for (const el of all) { if (!vis(el) || !hasText(el)) continue; /* 只量「直接承载文字」的元素,且字形真的落在里面(子元素另有颜色的不算) */ const s = getComputedStyle(el); if (s.color === 'rgb(0, 0, 0)') out.blackText.push({ tag: elLabel(el).slice(0, 50), text: [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim().slice(0, 20) }); } /* ---- 5. 字号档 ---- */ for (const el of all) { if (!vis(el) || !hasText(el)) continue; /* 表单控件的字族 / 字号按惯例是 1rem(16px);正文排版档不按控件算 */ if (el.matches('button, input, select, textarea, option')) continue; const fs = Math.round(parseFloat(getComputedStyle(el).fontSize) * 100) / 100; out.fontSizes[fs] = (out.fontSizes[fs] || 0) + 1; } /* ---- 6. 同排卡高:grid / flex 容器里直接子项按 top 分行 ---- */ const rowSeen = new Set(); for (const el of all) { const s = getComputedStyle(el); if (!/grid|flex/.test(s.display)) continue; const kids = [...el.children].filter((k) => vis(k) && k.getBoundingClientRect().height > 40); if (kids.length < 2) continue; /* 整页布局容器(.shell:侧栏 + 主列)不是「同排卡片」:它的两个子项一高一低是页面结构本身 */ if (el.matches('.shell, body, main, #view, .main')) continue; const tops = new Map(); for (const k of kids) { const r = k.getBoundingClientRect(); const key = Math.round(r.top / 4) * 4; if (!tops.has(key)) tops.set(key, []); tops.get(key).push(r.height); } for (const [top, hs] of tops) { if (hs.length < 2) continue; const spread = Math.round((Math.max(...hs) - Math.min(...hs)) * 10) / 10; const id = elLabel(el) + '@' + top; if (rowSeen.has(id)) continue; rowSeen.add(id); out.rows.push({ container: elLabel(el).slice(0, 40), top, n: hs.length, spread, heights: hs.map((h) => Math.round(h)) }); } } /* ---- 7. 卡内连续空白:卡片内部「有内容的直接子块」之间的竖直间隙 ---- 卡片 = .kf-card / .frame / kf-glass-card 里的 .wrap;取每张卡内容列的直接子项(渲染树里有文字或有尺寸的), 相邻子项底 → 下一项顶,> 24px 记一次(padding 之外)。 */ const cardRoots = []; for (const el of all) { if (!el.matches) continue; if (el.matches('kf-glass-card')) cardRoots.push(el); } for (const card of cardRoots) { const face = card.shadowRoot?.querySelector('.face'); if (!face || !vis(card)) continue; /* 卡里的内容列:slot 的第一个元素里的 .wrap 或直接子块 */ const slot = face.querySelector('slot'); const inst = slot ? slot.assignedElements()[0] : null; if (!inst) continue; const col = inst.querySelector(':scope > .wrap') || inst.querySelector(':scope > .rc') || inst; const kids = [...col.children].filter((k) => { const r = k.getBoundingClientRect(); return r.height > 0 && getComputedStyle(k).position !== 'absolute'; }); for (let i = 1; i < kids.length; i++) { const a = kids[i - 1].getBoundingClientRect(), b = kids[i].getBoundingClientRect(); const gap = Math.round(b.top - a.bottom); /* 兄弟间 gap(flex gap / margin)为 0 时不算空白:内容是紧挨着的 */ const cs = getComputedStyle(col); const declared = parseFloat(cs.rowGap) || 0; const extra = Math.round(gap - declared); if (extra > 24) out.blanks.push({ card: elLabel(card).slice(0, 30), between: elLabel(kids[i - 1]).slice(0, 30) + ' → ' + elLabel(kids[i]).slice(0, 30), extra, gap }); } } /* ---- 8. hover:穷举可点元素 ---- */ const clickables = []; const seen = new Set(); for (const el of all) { if (!el.matches?.('button, a[href], summary, [role="button"], [role="tab"], [role="menuitemcheckbox"], [role="menuitemradio"], [data-link]')) continue; if (!vis(el)) continue; const r = el.getBoundingClientRect(); /* 只要中心点真的是它自己(不被遮挡),才 hover */ const cx = r.left + r.width / 2, cy = r.top + r.height / 2; /* 视口外(需要滚动才看得到)的先跳过;量之前画面已滚到顶 */ if (cx < 0 || cy < 0 || cx > innerWidth || cy > innerHeight) continue; const key = elLabel(el) + '@' + Math.round(cx) + ',' + Math.round(cy); if (seen.has(key)) continue; seen.add(key); clickables.push({ el, cx, cy, sig: [getComputedStyle(el).backgroundColor, getComputedStyle(el).borderTopColor, getComputedStyle(el).backgroundImage, getComputedStyle(el).color, getComputedStyle(el).boxShadow, getComputedStyle(el).transform] }); } out.clickables = clickables.length; /* 悬停必须用「真实指针」:合成 pointerover 事件不会让 :hover 命中(v11 实测 :hover 规则对合成事件无效), 所以逐个别移动鼠标,并在移动后把命中的元素收集回来由外圈逐一核对。 */ /* clickables 本身不进返回值(元素引用不可序列化):坐标 + 签名 + 标签即可 */ out.pendingHover = clickables.map((c) => ({ x: c.cx, y: c.cy, sig: c.sig.join('|'), tag: elLabel(c.el).slice(0, 40), text: (c.el.textContent || '').trim().slice(0, 12) })); /* ---- 9. 底部空白 ---- */ out.scrollH = Math.round(document.documentElement.scrollHeight); out.viewportH = innerHeight; const mainEl = main(); let bottom = 0; const scan = (root) => { for (const el of root.querySelectorAll('*')) { if (!vis(el)) continue; if (!hasText(el) && !el.matches('img, svg, kf-glass-card, kf-opportunity-card, kf-empty, canvas')) continue; /* 忽略整块容器的盒,只看「叶子」内容与其卡片 */ const r = el.getBoundingClientRect(); bottom = Math.max(bottom, r.bottom + scrollY); if (el.shadowRoot) scan(el.shadowRoot); } }; scan(mainEl); out.lastContentBottom = Math.round(bottom); /* ---- 10. 筛选区(P1)与 11. 工作台两栏(P3) ---- */ const q = (sel) => appR().querySelector('main#view')?.firstElementChild?.shadowRoot?.querySelector(sel) || null; if (page === '/pool') { const filters = q('.filters'); const tb = q('.filters[data-toolbar]'); const seg = filters ? filters.querySelector('.seg') : null; const search = filters ? filters.querySelector('.frow.toolbar input[type="search"]') : null; const chips = [...(q('.filters')?.querySelectorAll('.kf-chip') || [])]; const chipSizes = chips.map((c) => { const r = c.getBoundingClientRect(); return [Math.round(r.height), getComputedStyle(c).fontSize]; }); const segBtns = seg ? [...seg.querySelectorAll('button')] : []; const segGap = seg ? getComputedStyle(seg).gap : ''; const segBorders = seg ? segBtns.map((b) => { const s = getComputedStyle(b); return s.borderRightWidth + ' ' + (s.borderRadius || ''); }) : []; out.filterLayout = { container: !!tb, filtersInsideToolbar: tb ? tb.contains(filters) : false, seg: !!seg, segGap, segBtnHeights: segBtns.map((b) => Math.round(b.getBoundingClientRect().height)), chipHeights: chipSizes.map((x) => x[0]), chipFonts: [...new Set(chipSizes.map((x) => x[1]))], searchH: search ? Math.round(search.getBoundingClientRect().height) : null, bottomDelta: search && filters ? Math.round(search.getBoundingClientRect().bottom - filters.getBoundingClientRect().bottom) : null, /* 底边对齐:搜索框底边 vs 它所在那一行(.frow.toolbar)里排序段与 chip 的底边 —— 三者差 ≤ 2px 即「同一底边」 */ rowBottomDelta: (() => { if (!search) return null; const row = search.closest('.frow'); const segEl = row?.querySelector('.seg'); const sb = search.getBoundingClientRect().bottom; const others = [segEl, ...(row ? row.querySelectorAll('.kf-chip') : [])].filter(Boolean).map((e) => e.getBoundingClientRect().bottom); return others.length ? Math.round(Math.max(...others.map((o) => Math.abs(o - sb))) * 10) / 10 : null; })(), segBorder: segBorders, }; } if (page === '/') { const g = q('.grid2'); if (g) { const ks = [...g.children].map((k) => Math.round(k.getBoundingClientRect().height)); out.wbCols = { heights: ks, diff: ks.length === 2 ? Math.abs(ks[0] - ks[1]) : null }; /* 左卡(或「还没选」空态卡)内的连续空白 */ const first = g.children[0]; const card = first.shadowRoot?.querySelector('.frame'); if (card) { let maxGap = 0; const face = card.querySelector('.face'); const inst = face?.querySelector('slot')?.assignedElements()[0]; if (inst) { const leaves = [...inst.querySelectorAll('*')].filter((e) => vis(e) && hasText(e)); const rs = leaves.map((e) => e.getBoundingClientRect()).sort((a, b) => a.top - b.top); for (let i = 1; i < rs.length; i++) maxGap = Math.max(maxGap, Math.round(rs[i].top - rs[i - 1].bottom)); } out.wbCols.leftMaxGap = maxGap; } } } return out; })()`; const results = []; for (const p of PAGES) { await gotoView(p); const r = await page.evaluate(MEASURE); /* 悬停:逐个用真实鼠标移到元素中心;鼠标离开后等过渡回落 */ await page.mouse.move(2, 2); await page.waitForTimeout(60); for (const c of r.pendingHover || []) { await page.mouse.move(c.x, c.y, { steps: 1 }); await page.waitForTimeout(240); /* 等过渡(最长 240ms)走完 */ const after = await page.evaluate(([x, y]) => { const el = document.elementFromPoint(x, y); if (!el) return null; const s = getComputedStyle(el); return [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|'); }, [c.x, c.y]); if (after === c.sig) r.hoverFail.push({ tag: c.tag, text: c.text }); /* 兜底核对:悬停点上的元素(含 shadow 链)应真的处于 :hover(CSS 被别的规则覆盖时这里能抓出来) */ await page.mouse.move(2, 2); await page.waitForTimeout(40); } delete r.pendingHover; results.push(r); } /* ============ 汇总 ============ */ const ALLOWED_RADII = new Set([0, 8, 12, 20, 9999]); const sum = (k) => results.reduce((n, r) => n + (r.radii[k] || 0), 0); const radiusKinds = {}; for (const r of results) for (const [k, n] of Object.entries(r.radii)) radiusKinds[k] = (radiusKinds[k] || 0) + n; const badRadii = Object.entries(radiusKinds).filter(([k]) => !ALLOWED_RADII.has(Number(k)) && k !== 'circle'); const controls = results.flatMap((r) => r.controls.map((c) => ({ ...c, page: r.page }))); const nativeControls = controls.filter((c) => c.reasons.length); const thick = results.flatMap((r) => r.thickBorders.map((x) => ({ ...x, page: r.page }))); /* 焦点环(outline 2px)只在聚焦时出现,还没聚焦时 computed outline 是 none / 宽度 0 */ const thickReal = thick.filter((x) => x.w > 1); const bright = thick.filter((x) => x.ow > 1); const blacks = results.flatMap((r) => r.blackText.map((x) => ({ ...x, page: r.page }))); const fonts = {}; for (const r of results) for (const [k, n] of Object.entries(r.fontSizes)) fonts[k] = (fonts[k] || 0) + n; const fontKinds = Object.keys(fonts).map(Number).sort((a, b) => a - b); const rows = results.flatMap((r) => r.rows.map((x) => ({ ...x, page: r.page }))); const badRows = rows.filter((x) => x.spread > 2); const blanks = results.flatMap((r) => r.blanks.map((x) => ({ ...x, page: r.page }))); const hoverFails = results.flatMap((r) => r.hoverFail.map((x) => ({ ...x, page: r.page }))); const clickables = results.reduce((n, r) => n + r.clickables, 0); const bottomGaps = results.map((r) => ({ page: r.page, gap: Math.max(0, r.scrollH - r.lastContentBottom), viewportGap: null })); const pool = results.find((r) => r.page === '/pool'); const wb = results.find((r) => r.page === '/'); const checks = []; const check = (name, ok, detail) => { checks.push({ name, ok, detail }); console.log(`\n${JUDGE ? (ok ? '✓' : '✗') : '·'} ${name}\n ${detail}`); }; console.log(`\n===== v12 视觉验收 · tag=${TAG} · 页面 ${PAGES.join(' ')} =====`); check('圆角种类 ≤ 3 且 ∈ {8,12,20}(0 只允许分割线/非容器)', badRadii.length === 0 && Object.keys(radiusKinds).length <= 3, `容器圆角(≤6px 的量条/色点不计)${Object.keys(radiusKinds).length}:${Object.entries(radiusKinds).map(([k, n]) => `${k}px×${n}`).join('、')}` + (badRadii.length ? `\n 违禁:${badRadii.map(([k, n]) => `${k}px×${n}(如 ${results.flatMap((r) => Object.entries(r.radiiWhere || {}).filter(([kk]) => kk === k).map(([, w]) => w)).slice(0, 3).join(' / ')})`).join(';')}` : '')); check('原生外观控件 = 0', nativeControls.length === 0, `控件总数 ${controls.length},原生外观 ${nativeControls.length}` + (nativeControls.length ? '\n ' + nativeControls.slice(0, 12).map((c) => `${c.page} ${c.tag} 「${c.text}」${c.reasons.join(',')}`).join('\n ') : '')); check('描边 > 1px = 0', thickReal.length === 0, `> 1px border ${thickReal.length}` + (thickReal.length ? ':' + thickReal.slice(0, 10).map((x) => `${x.page} ${x.tag} ${x.w}px`).join('、') : '')); check('纯黑 rgb(0,0,0) = 0', blacks.length === 0, `${blacks.length}` + (blacks.length ? ':' + blacks.slice(0, 10).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('、') : '')); check('字号档 ≤ 5 且无 13.3333px', fontKinds.length <= 5 && !fontKinds.some((f) => Math.abs(f - 13.3333) < 0.01), `${fontKinds.length} 档:${fontKinds.map((f) => `${f}×${fonts[f]}`).join(' / ')}`); check('同排卡片高度差 ≤ 2px', badRows.length === 0, `测了 ${rows.length} 行,超差 ${badRows.length}` + (badRows.length ? '\n ' + badRows.slice(0, 8).map((x) => `${x.page} ${x.container} n=${x.n} 极差 ${x.spread}px [${x.heights.join(',')}]`).join('\n ') : '')); check('卡内连续空白 ≤ 24px', blanks.length === 0, `超 24px 的位置 ${blanks.length}` + (blanks.length ? '\n ' + blanks.slice(0, 8).map((x) => `${x.page} ${x.between} +${x.extra}px`).join('\n ') : '')); check('可点元素 hover 反馈', hoverFails.length === 0, `可点 ${clickables},无反馈 ${hoverFails.length}` + (hoverFails.length ? '\n ' + hoverFails.slice(0, 12).map((x) => `${x.page} ${x.tag}「${x.text}」`).join('\n ') : '')); check('P1 筛选栏:一个容器 + 分段排序 + 与搜索框等高底对齐', (() => { const f = pool?.filterLayout; if (!f) return false; /* 分段控件:整块(段与段之间无缝)+ 五个按钮等高。段间分隔是 1px 边框,不是 gap —— 所以 gap 可以是 0 或 normal */ const segOk = f.seg && (f.segGap === '0px' || f.segGap === 'normal') && new Set(f.segBtnHeights).size === 1 && f.segBtnHeights[0] >= 28 && f.segBtnHeights[0] <= 32; const chipOk = f.chipHeights.every((h) => h >= 28 && h <= 32) && f.chipFonts.length === 1 && f.chipFonts[0] === '12px'; /* 底边对齐:搜索框与筛选容器里最后一个 chip 行的底边(不是容器 padding 底) */ return f.container && f.filtersInsideToolbar && segOk && chipOk && f.searchH !== null && f.rowBottomDelta !== null && f.rowBottomDelta <= 2; })(), pool ? `容器 ${pool.filterLayout.container},filters 在内 ${pool.filterLayout.filtersInsideToolbar},seg gap=${pool.filterLayout.segGap} 按钮高[${pool.filterLayout.segBtnHeights}],chip 高[${[...new Set(pool.filterLayout.chipHeights)]}] 字号[${pool.filterLayout.chipFonts}],搜索框高 ${pool.filterLayout.searchH},同行底边差 ${pool.filterLayout.rowBottomDelta}px,与容器底边差 ${pool.filterLayout.bottomDelta}px` : '未量 /pool'); check('P3 工作台两栏等高(差 ≤ 2px)且左卡无 > 32px 连续空白', Boolean(wb?.wbCols) && wb.wbCols.diff !== null && wb.wbCols.diff <= 2 && wb.wbCols.leftMaxGap <= 32, wb?.wbCols ? `两栏高 [${wb.wbCols.heights}],差 ${wb.wbCols.diff}px,左卡最大连续空白 ${wb.wbCols.leftMaxGap}px` : '未量 /'); /* ============ 深滚动测量(同一页,滚到底再量):视口外可点元素 hover + 卡片底线对齐 ============ */ const deep = []; for (const p of PAGES) { await gotoView(p); const info = await page.evaluate(() => ({ h: document.documentElement.scrollHeight, vh: innerHeight })); await page.mouse.move(2, 2); const stops = []; for (let y = 0; y < info.h; y += Math.floor(info.vh * 0.8)) stops.push(y); const seen = new Set(); const fail = []; let total = 0; const baseline = []; for (const y of stops) { await page.evaluate((yy) => window.scrollTo(0, yy), y); await page.waitForTimeout(120); const batch = await page.evaluate(([yy, vh]) => { const all = []; const collect = (r) => { for (const el of r.querySelectorAll('*')) { all.push(el); if (el.shadowRoot) collect(el.shadowRoot); } }; collect(document); const shown = (el) => { 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 lbl = (el) => el.localName + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/ +/).join('.') : ''); const out = []; for (const el of all) { if (!el.matches?.('button, a[href], summary, [role="button"], [role="menuitemcheckbox"], [role="menuitemradio"]')) continue; const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 4 || !shown(el)) continue; const cx = r.left + r.width / 2, cy = r.top + r.height / 2; if (cy < 0 || cy > vh || cx < 0 || cx > innerWidth) continue; /*
收起时里面的行:box 有高度但用户看不到、指针到不了 —— 跳过(不是「没有反馈」) */ const det = el.closest('details'); if (det && !det.open && el !== det.firstElementChild) continue; if (r.height < 28 && el.parentElement && el.parentElement.classList.contains('mor')) continue; const s = getComputedStyle(el); /* 用「元素自己的中心点」而不是估计坐标:表格单元格里的按钮在右侧,元素中心才是它自己 */ out.push({ key: cx + ',' + cy, x: cx, y: cy, tag: lbl(el).slice(0, 40), text: (el.textContent || '').trim().slice(0, 12), sig: [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|') }); } return out; }, [y, info.vh]); /* 先全部「先悬停、后核对」再离开 —— 挨在一起的兄弟元素(分段控件 / 一行里的 chip)在移开再移入之间 指针可能恰好停在两者接缝上,:hover 归谁取决于逐像素命中,不能当成「没有反馈」 */ const probes = batch.filter((x) => !seen.has(x.key)); for (const x of probes) seen.add(x.key); total += probes.length; for (const b of batch) { if (!probes.includes(b)) continue; await page.mouse.move(b.x, b.y); await page.waitForTimeout(220); const after = await page.evaluate(([x, yy]) => { const el = document.elementFromPoint(x, yy); if (!el) return null; const s = getComputedStyle(el); return [s.backgroundColor, s.borderTopColor, s.backgroundImage, s.color, s.boxShadow, s.transform, s.cursor].join('|'); }, [b.x, b.y]); if (after === b.sig) fail.push({ tag: b.tag, text: b.text }); } await page.mouse.move(2, 2); } /* 卡片底线:机会卡里的「来源行 .src」与「操作条 .acts」的页面坐标底边(滚动位置无关,只量一次) */ { const rows = await page.evaluate(() => { const cards = []; const collect = (r) => { for (const el of r.querySelectorAll('*')) { if (el.matches?.('kf-opportunity-card')) cards.push(el); if (el.shadowRoot) collect(el.shadowRoot); } }; collect(document); return cards.map((c) => { /* .src / .acts 在机会卡自己的 shadow root 里(卡片外面还套了一层 kf-glass-card) */ const src = c.shadowRoot.querySelector('.src'), acts = c.shadowRoot.querySelector('.acts'); const r = c.getBoundingClientRect(); /* 行键用「行顶 + 行高」:同一排卡的 top 相同、height 相同,换一个滚动位置也不会串到别的行 */ return { top: Math.round((r.top + scrollY) / 4) * 4, h: Math.round(r.height), src: src ? src.getBoundingClientRect().bottom + scrollY : null, acts: acts ? acts.getBoundingClientRect().bottom + scrollY : null }; }); }); baseline.push(...rows); } /* 滚到底:视口底部到最后一个可见内容(文字 / 卡片 / 页脚)的距离 */ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); await page.waitForTimeout(150); const viewportGap = await page.evaluate(() => { /* 页面底部留白 = 视口底(innerHeight)− 主列里最后一个**画出来的东西**的底边(文字节点 / 卡片 / 按钮)。 纯布局容器(main / #view / .shell)的盒子不算内容 —— 它们的盒子本来就到视口底。 */ const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view'); if (!main) return -1; let bottom = 0; const walk = (r) => { for (const n of r.querySelectorAll('*')) { if (n.localName === 'script' || n.localName === 'style') continue; const b = n.getBoundingClientRect(); if (b.height < 1 || b.bottom <= 0) continue; const own = [...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim()); if (own) bottom = Math.max(bottom, Math.min(b.bottom, innerHeight)); if (n.shadowRoot) walk(n.shadowRoot); } }; walk(main); return Math.max(0, Math.round(innerHeight - bottom)); }); const byRow = new Map(); for (const c of baseline) { const k = c.top + ':' + c.h; if (!byRow.has(k)) byRow.set(k, []); byRow.get(k).push(c); } const misaligned = []; for (const cs of byRow.values()) { if (cs.length < 2) continue; for (const key of ['src', 'acts']) { const vals = cs.map((c) => c[key]).filter((v) => v != null); if (vals.length < 2) continue; const spread = Math.round((Math.max(...vals) - Math.min(...vals)) * 10) / 10; if (spread > 2) misaligned.push({ key, n: vals.length, spread }); } } deep.push({ page: p, viewportGap, total, fail, rowCount: [...byRow.values()].filter((c) => c.length > 1).length, misaligned }); } const deepFail = deep.reduce((n, d) => n + d.fail.length, 0); const mis = deep.flatMap((d) => d.misaligned.map((m) => ({ ...m, page: d.page }))); check('滚动后视口外可点元素 hover 反馈', deepFail === 0, deep.map((d) => `${d.page} 测 ${d.total} 个,无反馈 ${d.fail.length}${d.fail.length ? '(' + d.fail.slice(0, 4).map((f) => f.tag + '「' + f.text + '」').join('、') + ')' : ''}`).join(';')); check('P2 同排卡「来源行 / 操作条」底边差 ≤ 2px', mis.length === 0, mis.length ? mis.slice(0, 8).map((m) => `${m.page} ${m.key} ${m.spread}px`).join('、') : `测了 ${deep.reduce((n, d) => n + d.rowCount, 0)} 个多卡行:全部 ≤ 2px`); for (const b of bottomGaps) b.viewportGap = deep.find((d) => d.page === b.page)?.viewportGap ?? 0; /* 页面底部空白 = 视口下的空白 OR 页面比内容多出来的空白,两者必须同时 ≤ 120px: - 短页(/):滚不动,视口底部到底栏或最后内容的距离; - 长页:滚到底,视口底部到最后一块内容的距离(卷到底之后看到的空白)。 任务书的「页面总高 - 栅格底」这种量法不对:内容是长页时它就是整屏高度,跟留白无关。 */ check('页面底部空白 ≤ 120px(视口下 + 滚到底两处都量)', bottomGaps.every((b) => b.viewportGap >= 0 && Math.min(b.gap, b.viewportGap) <= 120), bottomGaps.map((b) => `${b.page} 滚动余量 ${b.gap}px / 滚到底视口留白 ${b.viewportGap}px`).join(';')); if (errors.length) console.log(`\n页面错误 ${errors.length}:\n ${errors.slice(0, 10).join('\n ')}`); const fail = checks.filter((c) => !c.ok); console.log(`\n${JUDGE ? '判分' : '基线(不判分)'}:${checks.length - fail.length}/${checks.length} 达标${errors.length ? `,页面错误 ${errors.length}` : ''}`); if (JSON_OUT) { mkdirSync(dirname(join(ROOT, JSON_OUT)), { recursive: true }); writeFileSync(join(ROOT, JSON_OUT), JSON.stringify({ tag: TAG, at: new Date().toISOString(), pages: results, checks, errors }, null, 2)); } await browser.close(); srv.kill(); process.exit(JUDGE && (fail.length || errors.length) ? 1 : 0);