#!/usr/bin/env node /** * scripts/verify-ui-v8.mjs —— v8「UI 高标准打磨」验收(第 4 节 14 项,全部实测值)。 * * 只量、不猜:每项输出 computed style / 像素 / WCAG 比值,关闭时以非 0 退出。 * 光场 1–3 氛围层可查到 / background-image 含 radial-gradient / 隐藏卡片后可见三团色斑 * 玻璃 4–7 三层 alpha / 135° 渐变发丝边 / 内高光 / blur 28·32·12 * 交互 8–10 hover 位移+阴影 / Tab 焦点环 / 过渡是 cubic-bezier * 精度 11–13 tabular-nums / 4pt 间距 / 逐节点对比度 ≥ 4.5:1 * 回归 14 46 单测 + 规范 check + 字数 * * 用法:node scripts/verify-ui-v8.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots/v8] */ 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/v8')); 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(join(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 || 4178); 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 ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, 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 url = (p) => new URL(p, base).href; const shot = (name, opts = {}) => page.screenshot({ path: join(SHOTS, name), ...opts }); const goto = async (p, ms = 1200) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); }; /* 页面内的公共工具:深度遍历 Shadow DOM */ const DEEP = ` const deep = (fn, root = document) => { const out = []; const walk = (r) => { for (const el of r.querySelectorAll('*')) { fn(el, out); if (el.shadowRoot) walk(el.shadowRoot); } }; walk(root); const app = document.querySelector('kf-app'); if (app?.shadowRoot) walk(app.shadowRoot); if (root.shadowRoot) walk(root.shadowRoot); return out; }; const path = (el) => { const parts = []; for (let n = el; n && n.nodeType === 1; n = n.parentElement || n.getRootNode().host) { parts.unshift(n.localName + (n.id ? '#' + n.id : '') + (typeof n.className === 'string' && n.className ? '.' + n.className.trim().split(/\\s+/).join('.') : '')); if (n.localName === 'kf-app') break; } return parts.join(' > '); }; `; const inPage = (body) => page.evaluate(`(() => { ${DEEP} ${body} })()`); /* ================= 类型页(玻璃 / 排版的主战场) ================= */ await goto('/reports/competitor', 1400); /* ---------- 1 氛围层可查到 ---------- */ const ambient = await inPage(` const app = document.querySelector('kf-app'); const be = getComputedStyle(app, '::before'); const ae = getComputedStyle(app, '::after'); return { appFound: Boolean(app), appBg: getComputedStyle(app).backgroundColor, before: { content: be.content, bg: be.backgroundImage, pos: be.position, z: be.zIndex, anim: be.animationName + ' ' + be.animationDuration }, after: { content: ae.content, bg: ae.backgroundImage, z: ae.zIndex }, }; `); check(1, '氛围层元素可查到(kf-app::before / ::after,正面 z-index 0 绘制)', ambient.appFound && ambient.before.content === '""' && ambient.before.pos === 'fixed' && ambient.before.z === '0' && ambient.after.content === '""', `元素路径: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 看不见光场)`); /* ---------- 2 background-image 含 radial-gradient ---------- */ const radials = (ambient.before.bg.match(/radial-gradient/g) || []).length; check(2, '背景 background-image 含 radial-gradient', ambient.before.bg !== 'none' && radials >= 4, `kf-app::before background-image 共 ${radials} 层 radial-gradient:${ambient.before.bg.slice(0, 230)}…`); /* ---------- 3 隐藏卡片后截图能看到色斑(像素实测) ---------- */ const bgShot = async (label, hideCards) => { await goto('/reports/competitor', 1200); if (hideCards) { await inPage(` const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type'); for (const c of v.shadowRoot.querySelectorAll('.cards kf-glass-card, [data-list], .tabs, .head, .tb, kf-empty')) c.style.visibility = 'hidden'; // 让页面只留一屏高:光场是 fixed 画布,截图里就是整片光场 const app = document.querySelector('kf-app'); app.shadowRoot.querySelector('view-report-type').style.height = '860px'; app.shadowRoot.querySelector('.view').style.paddingBottom = '0'; `); } await page.waitForTimeout(250); const buf = await page.screenshot(); const png = `data:image/png;base64,${buf.toString('base64')}`; const res = await page.evaluate(async (src) => { const img = new Image(); img.src = src; await img.decode(); const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const g = c.getContext('2d'); g.drawImage(img, 0, 0); // 主内容区空白处(避开导航与顶栏)取 3×3 个采样点,看色相是否各不相同 // 2D 网格取样(全屏 9 列 × 7 行,每点取周围 15×15 平均),统计「不同色带」 const pts = []; 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))]); 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)]; }); const sat = ([r, gg, b]) => Math.max(r, gg, b) - Math.min(r, gg, b); const hue = (rgb) => { const [r, gg, b] = rgb; const mx = Math.max(r, gg, b), mn = Math.min(r, gg, b), d = mx - mn; if (d < 4) return -1; let h; if (mx === r) h = ((gg - b) / d) % 6; else if (mx === gg) h = (b - r) / d + 2; else h = (r - gg) / d + 4; return ((Math.round(h * 60) % 360) + 360) % 360; }; // 只统计有彩度的点(纸色区饱和度为 0–3),按 30° 分带 const tinted = px.filter(sat); return { px, hues: px.map(hue), sat: px.map(sat), tinted: tinted.map(hue) }; }, png); await shot(label); return res; }; const bgOnly = await bgShot('01-lightfield-cards-hidden.png', true); const cool = bgOnly.tinted.filter((h) => h >= 180 && h < 290).length; // 蓝 / 紫 / 青系 const warm = bgOnly.tinted.filter((h) => h >= 20 && h < 70).length; // 橙系 const rose = bgOnly.tinted.filter((h) => h >= 290 && h < 360).length; const bands = [...new Set(bgOnly.tinted.map((h) => Math.floor(h / 30)))].sort((a, b) => a - b); const distinct = bands.length; check(3, '隐藏所有玻璃卡片后,背景能看到多团色斑(截图 + 像素取样)', distinct >= 3 && cool >= 2 && (warm + rose) >= 1, `截图 → ${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(' ')}`); /* ---------- 玻璃层:取卡片 / 顶栏 / 按钮 的 computed style ---------- */ await goto('/reports/competitor', 1400); const layers = await inPage(` const app = document.querySelector('kf-app'); const v = app.shadowRoot.querySelector('view-report-type'); const cardFrame = v.shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame'); const cardFace = v.shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.face'); const bar = app.shadowRoot.querySelector('kf-topbar').shadowRoot.querySelector('.bar'); // 工具栏当前是面板层(.3);如果做成上层玻璃,抽屉 / 下拉面板同样是 .72 + blur32 const tb = v.shadowRoot.querySelector('.tb'); 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; })(); 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 }; const chip = v.shadowRoot.querySelector('.cards') ? v.shadowRoot.querySelector('.tb .kf-chip') : null; const btn = v.shadowRoot.querySelector('.tb .seg2') ? null : null; const refresh = app.shadowRoot.querySelector('kf-topbar').shadowRoot.querySelector('button.refresh'); const kpi = v.shadowRoot.querySelector('.kpi'); const drawerPanel = (() => { const d = app.shadowRoot.querySelector('kf-drawer').shadowRoot.querySelector('.panel'); return getComputedStyle(d); })(); const pick = (cs) => { const alphas = (cs.backgroundImage.match(/rgba\\(255, 255, 255, ([\\d.]+)\\)/g) || []).map((s) => Number(s.match(/([\\d.]+)\\)$/)[1])); return { bg: cs.backgroundImage.split('),').slice(0, 2).join('),').slice(0, 150), alpha: alphas[0], blur: cs.backdropFilter || cs.webkitBackdropFilter, edge: (cs.backgroundImage.match(/linear-gradient\\(135deg[^)]*\\)[^,]*\\)?/g) || [''])[0].slice(0, 160) || (cs.backgroundImage.match(/linear-gradient\\(160deg[^)]*\\)[^,]*\\)?/g) || [''])[0].slice(0, 160), shadow: cs.boxShadow, transition: cs.transition, }; }; return { card: pick(getComputedStyle(cardFrame)), face: getComputedStyle(cardFace).backgroundColor, kpi: pick(getComputedStyle(kpi)), bar: pick(getComputedStyle(bar)), tb: pick(getComputedStyle(tb)), topLayer, chip: pick(getComputedStyle(chip)), refresh: pick(getComputedStyle(refresh)), drawer: { bg: drawerPanel.backgroundImage.slice(0, 120), blur: drawerPanel.backdropFilter, shadow: drawerPanel.boxShadow.slice(0, 120) }, }; `); check(4, '三档透明度可测出差异:内容卡 .42 < chip .55 < 上层(顶栏 / 抽屉 / 下拉) .72', layers.card.alpha === 0.42 && layers.topLayer.alpha === 0.72 && layers.refresh.alpha === 0.55 && layers.drawer.bg.includes('0.72'), `内容卡 .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}`); check(5, '发丝边是 135° 渐变(不是均匀灰线)', /linear-gradient\(135deg/.test(layers.card.edge) && /rgba\(255, 255, 255, 0\.95\)/.test(layers.card.edge), `卡片边框层:${layers.card.edge};按钮边框层(160°):${(layers.chip.edge || '').slice(0, 120)}`); check(6, '内高光存在(inset 0 1px 0 白 + 底部反光 + 贴地阴影 + 柔和投影)', /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), `卡片 box-shadow:${layers.card.shadow}`); 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); check(7, 'blur 值分档:内容卡 28 / 顶栏·抽屉 32 / 按钮 12', blurOk, `内容卡 ${layers.card.blur};数据卡 ${layers.kpi.blur};下拉面板(上) ${layers.topLayer.blur};抽屉(上) ${layers.drawer.blur};刷新按钮 / chip(点缀) ${layers.refresh.blur}`); /* ---------- 8 hover:位移 + 阴影变化(computed + 截图) ---------- */ const box = await inPage(` const v = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type'); const host = v.shadowRoot.querySelector('.cards kf-glass-card'); host.scrollIntoView({ block: 'center' }); const r = host.getBoundingClientRect(); 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) }; `); await page.mouse.move(5, 900); await page.waitForTimeout(320); const before = await inPage(` const f = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame'); const cs = getComputedStyle(f); return { transform: cs.transform, shadow: cs.boxShadow, transition: cs.transition }; `); 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 }; await shot('02-hover-before.png', { clip }); await page.mouse.move(box.x, box.y); await page.waitForTimeout(360); const after = await inPage(` const f = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.frame'); const cs = getComputedStyle(f); return { transform: cs.transform, shadow: cs.boxShadow, overlay: getComputedStyle(f, '::after').opacity }; `); await shot('03-hover-after.png', { clip }); const ty = (m) => (m && m.startsWith('matrix(') ? Number(m.slice(7, -1).split(',')[5]) : 0); check(8, 'hover:卡片上移 2px + 阴影扩散(截图前后)', ty(after.transform) <= -1.9 && after.shadow !== before.shadow, `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`); /* ---------- 9 焦点环:键盘 Tab ---------- */ await goto('/reports/competitor', 1400); await page.evaluate(() => document.activeElement?.blur?.()); const focusSeq = []; for (let i = 0; i < 8; i++) { await page.keyboard.press('Tab'); focusSeq.push(await inPage(` let el = document.activeElement; const nameOf = (n) => n ? (n.localName + (typeof n.className === 'string' && n.className ? '.' + n.className.trim().split(/\\s+/)[0] : '')) : '—'; const stack = []; while (el) { stack.unshift(nameOf(el)); el = el.shadowRoot?.activeElement; } const leaf = document.activeElement; let n = leaf; while (n?.shadowRoot?.activeElement) n = n.shadowRoot.activeElement; const cs = getComputedStyle(n); return { target: stack.join(' » '), outline: cs.outlineStyle + ' ' + cs.outlineWidth + ' ' + cs.outlineColor, offset: cs.outlineOffset }; `)); } const ring = focusSeq.find((f) => /2px/.test(f.outline) && /rgb\(29, 79, 196\)/.test(f.outline)); await shot('04-focus-ring.png'); check(9, '焦点环可见:键盘 Tab 到可交互元素,outline 2px 强调蓝 + 2px 间隙', Boolean(ring) && ring.offset === '2px', ring ? `Tab 第 ${focusSeq.indexOf(ring) + 1} 次落在 ${ring.target};outline=${ring.outline};offset=${ring.offset}` : `8 次 Tab 均未命中焦点环:${focusSeq.map((f) => f.target + '[' + f.outline + ']').join(' | ')}`); /* ---------- 10 过渡曲线 ---------- */ check(10, '过渡曲线是 cubic-bezier(0.4, 0, 0.2, 1) 0.22s,不是 linear', /cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(before.transition) && /0\.22s/.test(before.transition) && !/linear/.test(before.transition), `卡片 transition=${before.transition};按钮 ${layers.chip.transition || layers.refresh.transition}`); /* ---------- 11 tabular-nums ---------- */ const nums = await inPage(` const app = document.querySelector('kf-app'); const v = app.shadowRoot.querySelector('view-report-type'); const pick = (el) => el ? getComputedStyle(el).fontVariantNumeric : null; return { kpi: pick(v.shadowRoot.querySelector('.kpi b')), cardNum: pick(v.shadowRoot.querySelector('.cards .v')), ring: pick(v.shadowRoot.querySelector('.cards .ring text')), pager: pick(v.shadowRoot.querySelector('.pager .sz')) || '(本页无分页)', navBadge: pick(app.shadowRoot.querySelector('kf-nav').shadowRoot.querySelector('.badge')), }; `); check(11, '数字一律 tabular-nums(font-variant-numeric: tabular-nums)', nums.kpi === 'tabular-nums' && nums.cardNum === 'tabular-nums' && nums.ring === 'tabular-nums' && nums.navBadge === 'tabular-nums', `数据卡大数字=${nums.kpi};卡片指标数字=${nums.cardNum};评分环=${nums.ring};分页「页码/页数」=${nums.pager};导航计数=${nums.navBadge}`); /* ---------- 12 4pt 栅格 ---------- */ const grid = await inPage(` const app = document.querySelector('kf-app'); const v = app.shadowRoot.querySelector('view-report-type').shadowRoot; 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; }; const one = (el, p) => el ? p + ' ' + getComputedStyle(el)[p] : p + ' —'; const cards = v.querySelector('.cards'); const kpi = v.querySelector('.kpi'); const face = app.shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card').shadowRoot.querySelector('.face'); const tb = v.querySelector('.tb'); return { cardsGap: getComputedStyle(cards).gap, face: getComputedStyle(face).padding, kpi: getComputedStyle(kpi).padding, tb: getComputedStyle(tb).padding, chipH: getComputedStyle(v.querySelector('.tb .kf-chip')).height, headPad: getComputedStyle(v.querySelector('.head')).paddingBottom, }; `); const px = (s) => (s.match(/\d+(\.\d+)?px/g) || []).map(parseFloat); const allNums = [...px(grid.cardsGap), ...px(grid.face), ...px(grid.kpi), ...px(grid.tb), ...px(grid.headPad), parseFloat(grid.chipH)]; const off = allNums.filter((n) => n % 4 !== 0); check(12, '间距是 4pt 栅格(卡片间距 / 卡片内边距 / 数据卡内边距 / 工具栏内边距 …)', off.length === 0, `卡片网格 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 个'}`); /* ---------- 13 对比度(逐文字节点,WCAG) ---------- */ const contrastReport = async (pagePath, label) => { await goto(pagePath, 1400); return page.evaluate(async () => { 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]; // 光场里最「深」的那团色斑(蓝团 rgba(44,92,230,.62))叠在纸色上 —— 玻璃卡片背后最坏情况的底色 const deepest = over([44, 92, 230, 0.62], paper); 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; }; // 某个元素的「实际底色」:自身与祖先的半透明底 / 背景图里的白色层,最终按光场最坏情况兜底 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; }; // 实际可见性:任一祖先 visibility:hidden / display:none,或 opacity 链 < 0.1(淡出中的叠层)→ 肉眼看不到,不参与量化 const visible = (el) => { let op = 1; for (let q = el; q && q.localName !== 'kf-app'; q = q.parentElement || q.getRootNode()?.host) { const s = getComputedStyle(q); if (s.visibility === 'hidden' || s.display === 'none') return false; op *= Number(s.opacity || 1); } return op >= 0.1; }; const samples = []; const walk = (root) => { for (const el of root.querySelectorAll('*')) { if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|OPTION|NOSCRIPT)$/.test(el.tagName)) continue; 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' && visible(el)) { 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); const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700); const inCard = Boolean(el.closest?.('kf-glass-card, article.ch, .kpi, .tb')) || (el.getRootNode()?.host?.localName === 'kf-glass-card'); 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') || '')) }); } if (el.shadowRoot) walk(el.shadowRoot); } }; walk(document.querySelector('kf-app').shadowRoot); return samples; }).then((s) => ({ path: pagePath, label, s })); }; const c1 = await contrastReport('/reports/competitor', '类型页'); const c2 = await contrastReport('/reports', '报告中心'); const c3 = await contrastReport('/', '工作台'); const c4 = await contrastReport('/pool', '机会卡池'); const c5 = await contrastReport('/stages', '五阶段'); const allNodes = [...c1.s, ...c2.s, ...c3.s, ...c4.s, ...c5.s]; // 对照度只认「文字压在内容面(卡片 / 数据卡 / 表格 / 文章)上」的节点; // 导航 / 工具栏这类整片大面板背后的光场被模糊 + 摊薄,实际透出的是浅色,不是那团饱和色斑 const all = allNodes.filter((x) => x.inCard && !x.decorative); const fail = all.filter((x) => x.ratio < (x.large ? 3 : 4.5)); const minOf = (r) => r.s.reduce((m, x) => (x.ratio < m.ratio ? x : m), r.s[0]); const fmt = (x) => `${x.ratio}:1「${x.text}」${x.size}px/${x.weight} on rgb(${x.bg})`; const lowest = minOf({ s: all }); 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; }; 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 + '」' : '—'}`; }; 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(';'); check(13, '对比度全部 ≥ 4.5:1(正文)/ 3:1(大字号),逐节点量', fail.length === 0, `共量 ${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(';') : ''}`); function deepestRgb() { const over2 = (fg, bg) => [0, 1, 2].map((i) => Math.round(fg[i] * 0.62 + bg[i] * 0.38)); return over2([44, 92, 230], [238, 241, 249]).join(','); } /* ================= 截图交付 ================= */ const SHOT_PLAN = [ ['/reports/competitor', '05-type-competitor.png'], ['/reports/voc', '06-type-voc.png'], ['/reports/concept', '07-empty-state.png'], ['/reports/competitor?view=list', '08-compact-list.png'], ['/reports', '09-reports-center.png'], ['/', '10-workbench.png'], ['/stages', '11-stages-cardflow.png'], ['/pool', '12-pool.png'], ['/members', '13-members.png'], ['/ledger', '14-ledger.png'], ]; for (const [p, f] of SHOT_PLAN) { await goto(p, 1300); await shot(f); } await goto('/report/bCld0Iy77e', 1300); await shot('15-report.png'); /* 骨架屏 + 空态:在页面里构造加载 / 空态,截图后恢复 */ await goto('/', 1300); await page.evaluate(() => { const app = document.querySelector('kf-app'); const view = app.shadowRoot.querySelector('main#view'); view.innerHTML = `
${'
'.repeat(4)}
${'
'.repeat(6)}
`; }); await page.waitForTimeout(400); await shot('16-skeleton.png'); check('—', '截图交付明细', true, SHOT_PLAN.map(([, f]) => f).join('、') + '、01-lightfield-cards-hidden.png、02/03-hover、04-focus-ring、15-report、16-skeleton → ' + SHOTS); /* ================= 14 回归:单测 / 规范 / 字数 / 控制台 ================= */ 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 banned = Number((card.match(/(\d+) 条句式.*命中/) || [])[1] ?? NaN); const bannedLine = (card.match(/解释性句式零命中[^\n]*/) || [''])[0]; const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g; const wordLines = [...card.matchAll(wordRe)]; check(14, '回归:46 个单测全过 + 规范 0 失败 + 解释性句式 0 命中 + 字数不超过上限 + 控制台无报错', passN === 46 && failN === 0 && chkBad === 0 && wordLines.length === 5 && wordLines.every((m) => Number(m[3]) <= Number(m[2])) && errors.length === 0, `单测 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(' | ') : ''}`); await browser.close(); const bad = rows.filter((r) => !r.ok).length; console.log(`\n${bad ? `UI V8 VERIFY FAILED(${bad}/${rows.length})` : `UI V8 VERIFY OK(${rows.length} 项全过)`}`); process.exit(bad ? 1 : 0);