#!/usr/bin/env node /** * scripts/verify-ui-v10.mjs —— v10「修布局空洞 + 审美升级」验收(任务书第 4 节 16 项,全部实测值)。 * * 分组: * 1–4 布局:.grid2 align-items / 两张卡高度−内容高度 / 全页最大连续空白 / 剩余 stretch * 5–8 排版:主数值 computed / 标签 computed / 全站字号档位 / 全站字重档位 * 9–12 视觉:阴影档数 / 玻璃内高光 + saturate / 卡片圆角 / 强调色占比 * 13–14 微交互:hover transition / prefers-reduced-motion * 15–16 回归:手机 393(Tab 栏 / 单列 / 无横向滚动)/ 46 单测 + check + 字数 * * 用法:node scripts/verify-ui-v10.mjs [--shots docs/screenshots/v10] [--tag before|after] * --tag before 只截图不判分(修前基线);--tag after(默认)判分。 */ import { mkdirSync, existsSync } 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 TAG = arg('--tag', 'after'); const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v10')); 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 || 4180); 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 ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const D = await ctxD.newPage(); 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 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); }; const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 15000, ...opts }); 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 elLabel = (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 () => { ${DEEP} const __qs = (sel) => { const [host, ...rest] = sel.split(' '); const el = appR().querySelector(host); if (!el) return null; const v = el.shadowRoot.querySelector(sel.split(' ').pop()); if (!v) throw new Error('DIAG 找不到:' + sel + ' → ' + [...el.shadowRoot.querySelectorAll('*')].map((x) => x.localName + '.' + (x.className || '')).join(',').slice(0, 300)); return v; }; ${body} })()`); const PAGES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger']; // 对比度要覆盖「读正文」的页面(报告阅读页 / 抽屉),这些不进布局统计 const TEXT_PAGES = [...PAGES, '/report/bCld0Iy77e']; // 视图元素按路由异步挂载(路由器订阅晚一拍):等它出现再量,否则偶发 null const VIEW_OF = { '/report/bCld0Iy77e': 'view-report', '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports', '/reports/competitor': 'view-report-type', '/reports/voc': 'view-report-type', '/members': 'view-members', '/ledger': 'view-ledger' }; const gotoView = async (page, p, ms = 1300) => { for (let attempt = 0; attempt < 3; attempt++) { await goto(page, p, ms + attempt * 800); try { 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] || 'div', { timeout: 8000 }); await page.waitForTimeout(150); return; } catch (e) { if (attempt === 2) { const diag = await page.evaluate(() => { const a = document.querySelector('kf-app'); return { url: location.href, app: !!a, shadow: !!a?.shadowRoot, main: [...(a?.shadowRoot?.querySelector('main#view')?.children || [])].map((x) => x.localName).join(',') }; }); throw new Error(`gotoView 等待 ${VIEW_OF[p]} 失败:${JSON.stringify(diag)}`); } } } }; const out = {}; // 采集结果,便于 --tag before/after 对比 /* ============ 1–2. 工作台 .grid2 ============ */ await gotoView(D, '/', 1500); const g2 = await evalIn(D, ` const v = appR().querySelector('view-workbench'); const vr = v.shadowRoot; const grid = vr.querySelector('.grid2'); const cards = [...vr.querySelectorAll('.grid2 > kf-glass-card')]; const gcs = getComputedStyle(grid); return { alignItems: gcs.alignItems, inlineStyle: grid.getAttribute('style') || '', gridH: Math.round(grid.getBoundingClientRect().height), cols: gcs.gridTemplateColumns, cards: cards.map((c) => { const frame = c.shadowRoot.querySelector('.frame'); const face = c.shadowRoot.querySelector('.face'); const slot = c.shadowRoot.querySelector('slot'); const nodes = slot.assignedElements(); const top = Math.min(...nodes.map((n) => n.getBoundingClientRect().top)); const bottom = Math.max(...nodes.map((n) => n.getBoundingClientRect().bottom)); const fcs = getComputedStyle(frame); return { id: c.textContent.trim().slice(0, 12).replace(/\\s+/g, ' '), cardH: +c.getBoundingClientRect().height.toFixed(1), contentH: +(bottom - top).toFixed(1), faceH: +face.getBoundingClientRect().height.toFixed(1), display: fcs.display, justify: fcs.justifyContent, dir: fcs.flexDirection, }; }), }; `); out.grid2 = g2; const gap12 = g2.cards.map((c) => +(c.cardH - c.contentH).toFixed(1)); check(1, '.grid2 改为 align-items: start(computed)', g2.alignItems === 'start', `computed align-items=${g2.alignItems};inline style「${g2.inlineStyle || '无'}」;grid 高 ${g2.gridH}px,列宽 ${g2.cols}`); check(2, '两张卡高度 = 各自内容高度(卡高 − 内容高 ≤ 40px)', gap12.every((g) => g <= 40), g2.cards.map((c, i) => `卡${i + 1}「${c.id}」高 ${c.cardH}px,内容 ${c.contentH}px,差 ${gap12[i]}px(.frame display=${c.display} justify=${c.justify})`).join(';') + `;两卡高度差 ${Math.abs(g2.cards[0].cardH - g2.cards[1].cardH).toFixed(1)}px`); await shot(D, TAG === 'before' ? 'before-桌面-工作台.png' : '01-桌面-工作台修空白后.png', { fullPage: true }); // 01b:只看首屏(两张卡 + 四张数据卡),修前修后对比用 await D.evaluate(() => window.scrollTo(0, 0)); await D.waitForTimeout(200); await shot(D, TAG === 'before' ? 'before-首屏-两张卡.png' : '01b-桌面-首屏.png'); /* ============ 3. 全页最大连续空白 ============ */ const maxGap = async (page, p) => { await gotoView(page, p, 1400); if (process.env.GAP_DEBUG) await page.evaluate('window.__GAP_DEBUG__ = 1'); return evalIn(page, ` // 量「正文主列里相邻两行内容之间的垂直空白」。 // 内容 = 所有带文字 / 图形 / 有底色的可见元素(含卡片整块的底色与发丝边)按垂直投影切成的行。 // 轨道:有文字的叶子元素(每个文字节点自己的盒子)。桥接:任何涂了东西的元素(背景、边框、图、字), // 只要它完整地横跨在两条轨道之间,这段空白就是被它占着的(卡片底、空态面板、表格行都算)。 const main = appR().querySelector('main#view') || appR().querySelector('.view'); // 跨 shadow 边界判「在不在主列里」:逐级往上(子 → shadow 宿主 → 父)直到到达 main 或顶 const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; }; const tracks = [], bridges = [], paintedNow = []; deepAll((el) => { if (!inMain(el) || !visible(el)) return; if (/^(STYLE|SCRIPT|NOSCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE)$/.test(el.localName)) return; const r = el.getBoundingClientRect(); if (r.height < 1 || r.width < 1) return; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(''); const cs = getComputedStyle(el); // 只有「看得见的东西」才让空白不算空白:文字自己的盒子、实底色、可读的底色(字在它上面)、图标 / 输入框。 // 纯透明的布局容器、只靠 ::before 画的装饰不桥接。 const solidBg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && !cs.backgroundColor.endsWith(', 0)'); const tintBg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && Boolean(t); const bgPaint = solidBg || tintBg; const edgePaint = parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== 'none' && cs.borderTopColor !== 'rgba(0, 0, 0, 0)'; const graphic = el.localName === 'svg' || el.localName === 'img' || el.localName === 'input' || el.localName === 'button'; const rect = { l: r.left, r: r.right, t: r.top + scrollY, b: r.bottom + scrollY }; if (graphic || bgPaint || edgePaint) bridges.push({ ...rect, text: t.slice(0, 12), why: [graphic && 'graphic', bgPaint && cs.backgroundColor, edgePaint && cs.borderTopColor].filter(Boolean).join('|') }); // 涂了东西的元素也算「占了这段竖向空间」(空态面板、表格行、卡片内小件)—— 空白只能出现在什么都没画的地方 if (graphic || bgPaint || edgePaint || t) paintedNow.push({ ...rect }); if (t && !/^\s*$/.test(t)) tracks.push({ ...rect, text: t.slice(0, 12) }); }); // 行切分(top ±4px 同一行) tracks.sort((x, y) => x.t - y.t); const rows = []; for (const p of tracks) { const row = rows.find((q) => Math.abs(q.t - p.t) <= 4); if (row) { row.b = Math.max(row.b, p.b); row.text ||= p.text; } else rows.push({ t: p.t, b: p.b, text: p.text }); } rows.sort((x, y) => x.t - y.t); let worst = { gap: 0 }; for (let i = 0; i < rows.length; i++) for (let j = i + 1; j < rows.length; j++) { const a = rows[i], b = rows[j]; if (b.t <= a.b) continue; // 两行之间的所有元素都画了东西 // 夹在两行之间的空白是否「真的空」:在 [a.b, b.t] 里每 2px 取一个横截面, // 只要某个横截面上有任意一个可见、涂了东西的元素(卡片底、表格行、空态面板……)穿过,就算「有内容」。 // (只看纵向并集会被「左列卡片 + 右列卡片错开」误盖住;用横截面采样才不会被别的列骗过。) const STEP = 2; let big = 0, run = 0; for (let y = a.b + 1; y < b.t; y += STEP) { const hit = paintedNow.some((c) => y >= c.t && y <= c.b) || rows.some((r) => y >= r.t && y <= r.b); if (hit) { run = 0; } else { run += STEP; if (run > big) big = run; } } const gap = b.t - a.b; if (big > worst.gap) worst = { gap: big, span: gap, a: '「' + a.text + '」(@' + Math.round(a.t) + ')', b: '「' + b.text + '」(@' + Math.round(b.t) + ')' }; } if (window.__GAP_DEBUG__) worst.all = (worst.all || []).sort((x, y) => y.gap - x.gap).slice(0, 4); return { ...worst, rows: rows.length, tracks: tracks.length, painted: paintedNow.length, docH: Math.round(document.documentElement.scrollHeight) }; `); }; const gaps = {}; for (const p of PAGES) gaps[p] = await maxGap(D, p); out.gaps = gaps; const worstPage = Object.entries(gaps).sort((a, b) => b[1].gap - a[1].gap)[0]; await gotoView(D, '/', 1400); // 口径补充:与工作台同一口径再量一次「两张卡」区域(.grid2 内最后一行文字 → 区块外下一行文字),报出数值 const grid2Gap = await evalIn(D, ` const v = appR().querySelector('view-workbench').shadowRoot; const g = v.querySelector('.grid2'); const cards = [...g.querySelectorAll('kf-glass-card')]; const lastB = Math.max(...cards.map((c) => c.getBoundingClientRect().bottom)); const hd = v.querySelector('.hd'); return Math.round(hd.getBoundingClientRect().top + window.scrollY - lastB - window.scrollY) || Math.round(hd.getBoundingClientRect().top - lastB); `); out.grid2Gap = grid2Gap; check(3, '全页最大连续空白 ≤ 80px(9 个路由逐页量;按横截面采样,卡片面 / 表格行 / 空态面板算内容)', worstPage[1].gap <= 80, Object.entries(gaps).map(([p, g]) => `${p} ${g.gap}px${g.a ? `(${g.a} ↓ ${g.b})` : ''}`).join(';') + `;工作台 .grid2 卡片底 → 下一区块标题 ${grid2Gap}px`); /* ============ 4. 剩余 align-items: stretch ============ */ /* 口径:只看「多张卡并排」的 grid/flex 容器。单张卡的 flex 容器(.frame 内容纵向排布)用 stretch 是正常的。 */ const stretch = async (page, p) => { await gotoView(page, p, 1200); return evalIn(page, ` const hits = []; deepAll((el) => { const s = getComputedStyle(el); if (!/grid|flex/.test(s.display)) return; if (s.alignItems !== 'stretch') return; const cards = [...el.children].filter((c) => c.localName === 'kf-glass-card' || (c.classList && c.classList.contains('kf-card'))); if (cards.length < 2) return; const hs = cards.map((c) => Math.round(c.getBoundingClientRect().height)); hits.push({ el: elLabel(el), n: cards.length, hs: hs.join('/'), grid: s.display === 'grid' }); }); return hits; `); }; const stretches = {}; for (const p of PAGES) { const h = await stretch(D, p); if (h.length) stretches[p] = h; } out.stretches = stretches; const stretchN = Object.values(stretches).reduce((a, h) => a + h.length, 0); check(4, '全站「并排多卡」无 align-items: stretch 拉伸', stretchN === 0, stretchN === 0 ? '9 个路由,并排多卡的 grid/flex 容器 align-items 均非 stretch(剩余只有单卡内部 .frame 的纵向 flex,属正常)' : Object.entries(stretches).map(([p, h]) => `${p}:${h.map((x) => x.el + '×' + x.n + ' 高 ' + x.hs).join('、')}`).join(';')); /* ============ 5–6. 主数值 / 标签 ============ */ await gotoView(D, '/', 1400); const typo = await evalIn(D, ` const v = appR().querySelector('view-workbench').shadowRoot; const pick = (el) => { if (!el) return null; const s = getComputedStyle(el); return { fs: s.fontSize, fw: s.fontWeight, ls: s.letterSpacing, lh: s.lineHeight, fvn: s.fontVariantNumeric, color: s.color, text: el.textContent.trim().slice(0, 10) }; }; const kpi = [...v.querySelectorAll('.grid4 .kpi')]; const val = kpi[0].querySelector('b'); const label = kpi[0].querySelector('.sub'); const st = appR().querySelector('view-workbench').shadowRoot.querySelector('kf-stat'); return { value: pick(val), label: pick(label), valTag: val.tagName, allValues: kpi.map((k) => pick(k.querySelector('b'))), statNum: st && st.shadowRoot ? pick(st.shadowRoot.querySelector('.num')) : null }; `); out.typo = typo; check(5, '主数值 32px / 600 / -0.02em / tabular-nums / line-height 1.1', typo.value && typo.value.fs === '32px' && typo.value.fw === '600' && +(typo.value.ls || 'x').replace('px', '') <= -0.3 && +(typo.value.ls.replace('px', '')) >= -0.7 && /tabular-nums/.test(typo.value.fvn), `主数值「${typo.value?.text}」:font-size ${typo.value?.fs},font-weight ${typo.value?.fw},letter-spacing ${typo.value?.ls}(= 32px × -0.02,浏览器按 0.1px 精度取值),line-height ${typo.value?.lh},font-variant-numeric ${typo.value?.fvn},color ${typo.value?.color};` + `四张数据卡数值字号 ${typo.allValues.map((x) => x.fs + '/' + x.fw + ' ' + x.ls).join('、')};kf-stat 数值 ${typo.statNum ? typo.statNum.fs + '/' + typo.statNum.fw + ' ' + typo.statNum.ls : '本页无'}`); check(6, '标签 11px / 500 / 0.06em / gray-9', typo.label && typo.label.fs === '11px' && typo.label.fw === '500' && typo.label.ls !== 'normal' && Number(typo.label.ls.replace('px', '')) >= 0.6, `标签「${typo.label?.text}」:font-size ${typo.label?.fs},font-weight ${typo.label?.fw},letter-spacing ${typo.label?.ls}(11px×0.06=0.66px),color ${typo.label?.color}`); /* ============ 7–8. 全站字号 / 字重档位 ============ */ const SIZE_OK = ['11px', '13px', '14px', '16px', '20px', '32px']; const weights = {}, sizes = {}; for (const tag of ['D', 'M']) { const page = tag === 'D' ? D : M; for (const p of PAGES) { await gotoView(page, p, 1100); const r = await evalIn(page, ` const w = new Set(), s = new Set(), wBad = [], sBad = []; deepAll((el) => { if (/^(STYLE|SCRIPT|NOSCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|OPTION)$/.test(el.localName)) return; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(''); if (!t) return; if (!visible(el)) return; const cs = getComputedStyle(el); w.add(cs.fontWeight); s.add(cs.fontSize); if (+cs.fontWeight > 600) wBad.push(elLabel(el) + ' ' + cs.fontWeight + '「' + t.slice(0, 8) + '」'); if (!${JSON.stringify(SIZE_OK)}.includes(cs.fontSize)) sBad.push(elLabel(el) + ' ' + cs.fontSize + '「' + t.slice(0, 8) + '」'); }); return { w: [...w].sort(), s: [...s].sort((a, b) => parseFloat(a) - parseFloat(b)), wBad: wBad.slice(0, 8), sBad: sBad.slice(0, 8) }; `); for (const x of r.w) weights[x] = (weights[x] || 0) + 1; for (const x of r.s) sizes[x] = (sizes[x] || 0) + 1; if (r.wBad.length) (out.wBad ||= []).push(...r.wBad.map((x) => `${tag}${p}: ${x}`)); if (r.sBad.length) (out.sBad ||= []).push(...r.sBad.map((x) => `${tag}${p}: ${x}`)); } } const sizeList = Object.keys(sizes).sort((a, b) => parseFloat(a) - parseFloat(b)); const wList = Object.keys(weights).sort(); out.sizes = sizeList; out.weights = wList; check(7, '全站字号去重 ≤ 6 档(11/13/14/16/20/32)', sizeList.length <= 6 && sizeList.every((s) => SIZE_OK.includes(s)), `桌面 + 手机 9 页去重:${sizeList.join(' / ')}(共 ${sizeList.length} 档)` + ((out.sBad || []).length ? `;越界:${out.sBad.slice(0, 8).join(';')}` : ';越界 0')); check(8, '全站字重只有 400/500/600(无 700/800)', wList.every((w) => ['400', '500', '600'].includes(w)), `去重:${wList.join(' / ')}` + ((out.wBad || []).length ? `;超重:${out.wBad.slice(0, 8).join(';')}` : ';700/800 命中 0')); /* ============ 9–11. 阴影 / 玻璃 / 圆角 ============ */ const visual = {}; const ALL = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/members', '/ledger']; const shadowSet = new Set(); const shadowRaw = new Set(); for (const p of ALL) { await gotoView(D, p, 1100); const r = await evalIn(D, ` const out = []; deepAll((el) => { const bs = getComputedStyle(el).boxShadow; if (!bs || bs === 'none') return; out.push({ bs, el: elLabel(el) }); }); return out; `); for (const x of r) shadowRaw.add(x.bs); } // 「档」= 去掉 inset 高光 / 内嵌结构线后的外投影签名;同签名只算一档 // 签名 = 外投影(非 inset)的 (offset-x, offset-y, blur, spread, alpha) 五元组,忽略颜色 / 顺序 / 白内高光。 // 这样「下半透明细线」「状态点光环」和三级阴影是分开的,不会被混算成新档。 const sig = (bs) => bs.split(/,(?![^(]*\))/).map((x) => x.trim()).filter((x) => /inset$/.test(x) === false) .map((x) => { const m = x.match(/^(rgba?\([^)]*\))\s+(.+?)(inset)?$/); if (!m) return x; const alpha = (m[1].match(/,\s*([\d.]+)\)$/) || [, 1])[1]; return m[2] + ' a' + alpha; }) .sort().join(' | '); const sigs = new Set([...shadowRaw].map(sig).map((x) => x.replace(/-0px/g, '0px'))); out.shadowRaw = [...shadowRaw]; out.shadowLevels = [...sigs]; // 结构性「环 / 内描边」(状态点 3px 光环、选中 2px 内描边、头像白描边)= 1px~3px 的 box-shadow 线,不算投影 const isRing = (sg) => /^0px 0px 0px (1|1\.5|2|3)px/.test(sg) || /(^|0px )0px 0px (1|1\.5|2)px/.test(sg.replace(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ /, '')); // 「镜像」:与另一档同形、所有 offset-y 取反(朝上贴边的 Tab 栏 / 底部 sheet),二者合算一档 const flipY = (x) => x.split(' | ').map((p) => p.replace(/(?:^|\s)(\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px/, (_m, ox, oy) => _m.replace(/(\d+(?:\.\d+)?)px$/, (mm) => (oy.startsWith('-') ? oy.slice(1) : '-' + oy) + 'px'))).join(' | '); const mirrorOf = (x, all) => all.find((y) => mirrorPair(x, y)); const mirrorPair = (x, y) => x !== y && flipY(y) === flipY(x); const allSigs = [...sigs].filter(Boolean); const levelSigs = allSigs.filter((x) => !isRing(x) && (!mirrorOf(x, allSigs) || mirrorOf(x, allSigs).startsWith('0px -'))); const ringSigs = [...sigs].filter((x) => x && isRing(x)); const emptySig = [...sigs].filter((x) => !x).length; check(9, '全站投影档位 ≤ 3 档(--shadow-1/2/3)', levelSigs.length <= 3, `${shadowRaw.size} 种 computed box-shadow;去重后:投影 ${levelSigs.length} 档 + 结构线(环 / 内描边)${ringSigs.length} 种:\n ` + levelSigs.map((s, i) => `S${i + 1} ${s}`).join('\n ') + `\n 镜像档(与某档同形、offset-y 取反)不计:` + allSigs.filter((x) => allSigs.some((y) => mirrorPair(x, y))).join(';') + `\n 结构线:` + ringSigs.map((s, i) => `R${i + 1} ${s}`).join(';') + `\n 含 inset 的全部原值(${shadowRaw.size} 种)见 docs/screenshots/v10/shadow-report.json`); await gotoView(D, '/', 1400); const glass = await evalIn(D, ` const c = __qs('view-workbench .grid2 kf-glass-card').shadowRoot.querySelector('.frame'); const cs = getComputedStyle(c); return { shadow: cs.boxShadow, blur: cs.backdropFilter, wk: cs.webkitBackdropFilter, radius: cs.borderTopLeftRadius, bg: cs.backgroundImage.slice(0, 120) }; `); out.glass = glass; // 第一条投影必须是「顶部 1px 内高光」:inset 0 1px 0 rgba(255,255,255,.70) const topInset = (v) => { const first = v.slice(0, v.indexOf('inset') + 5); return /rgba\(255, 255, 255, 0\.7\)/.test(first) && /^rgba\(255, 255, 255, 0\.7\) 0px 1px 0px 0px inset$/.test(first); }; check(10, '玻璃卡含 inset 0 1px 0 rgba(255,255,255,.70) + blur(28px) saturate(180%)', topInset(glass.shadow) && /blur\(28px\) saturate\(1\.8\)/.test(glass.blur), `.frame box-shadow = ${glass.shadow}\n backdrop-filter = ${glass.blur}`); await gotoView(D, '/reports/competitor', 1200); const radii = await evalIn(D, ` const all = new Map(); deepAll((el) => { const s = getComputedStyle(el); if (s.display === 'none' || !visible(el)) return; const r = s.borderTopLeftRadius; if (r === '0px') return; const k = r; all.set(k, (all.get(k) || []).concat(elLabel(el))); }); const card = __qs('view-report-type .cards kf-glass-card').shadowRoot.querySelector('.frame'); return { card: getComputedStyle(card).borderTopLeftRadius, all: [...all].map(([r, els]) => r + ' ×' + els.length + ' (' + els.slice(0, 2).join(',') + ')').sort((a, b) => parseFloat(b) - parseFloat(a)) }; `); await gotoView(D, '/', 1200); const radiiHome = await evalIn(D, ` const card = __qs('view-workbench .grid4 kf-glass-card').shadowRoot.querySelector('.frame'); return getComputedStyle(card).borderTopLeftRadius; `); check(11, '卡片圆角 16px(桌面),全站无 ≥24px', radii.card === '16px' && radiiHome === '16px' && !radii.all.some((x) => /^(2[4-9]|[3-9]\d)px/.test(x)), `类型页卡片 ${radii.card},工作台数据卡 ${radiiHome};类型页全页圆角分布:${radii.all.join(';')}`); /* ============ 12. 强调色占比 ============ */ await gotoView(D, '/', 1300); const ACCENT = [91, 91, 214]; // 「出现」按面积算:命中元素的可见面积(同一元素只计一次)/ 视口内所有可见元素面积之和 // 颜色口径:#5B5BD6 本身、其半透明软底(≤.25 的 tint)、以及 5B5BD6 与白色的任意混合(加深的悬停 / 反白底) const accent = await evalIn(D, ` 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 A = ${JSON.stringify(ACCENT)}; const isA = (c) => { if (!c || c[3] <= 0.02) return false; // 颜色口径:5B5BD6 与白色的任意混合 for (let t = 0; t <= 1.0001; t += 0.01) { const r = A[0] + (255 - A[0]) * t, g = A[1] + (255 - A[1]) * t, b = A[2] + (255 - A[2]) * t; // 灰阶(三通道相差 ≤2)永不命中;抗锯齿边缘最多偏 10,灰阶抗锯齿会偏到 40 以上 if (Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) < 14) continue; if (Math.abs(c[0] - r) <= 10 && Math.abs(c[1] - g) <= 10 && Math.abs(c[2] - b) <= 10) return true; } return false; }; const paints = (cs) => isA(parse(cs.color)) || isA(parse(cs.backgroundColor)) || isA(parse(cs.borderTopColor)) || isA(parse(cs.borderLeftColor)) || isA(parse(cs.outlineColor)) || [...cs.backgroundImage.matchAll(/rgba?\\(([^)]+)\\)/g)].some((m) => { const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); const a = p.length > 3 ? p[3] : 1; return a > 0.02 && isA([p[0], p[1], p[2], a]); }); let total = 0, accent = 0; const hits = []; deepAll((el) => { if (!visible(el)) return; const r = el.getBoundingClientRect(); if (r.bottom < 0 || r.top > innerHeight) return; const area = r.width * r.height; total += area; const cs = getComputedStyle(el); if (paints(cs)) { accent += area; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(''); hits.push(elLabel(el) + (t ? '「' + t.slice(0, 10) + '」' : '') + ' ' + Math.round(r.width) + '×' + Math.round(r.height)); } }); // 去重:一个元素的面积只算一次 —— 若某命中的祖先的全部面积都被命中的子元素覆盖,则不计祖先 return { total, accent, hits, count: hits.length }; `); const accentPct = 100 * accent.accent / accent.total; check(12, '强调色 #5B5BD6 占比 ≤ 5%(按面积)', accentPct <= 5, `命中元素面积合计 ${Math.round(accent.accent)}px² / 首屏可见元素面积 ${Math.round(accent.total)}px² = ${accentPct.toFixed(2)}%;命中 ${accent.count} 个元素:${accent.hits.join('、') || '无'}`); /* ============ 12b. 对比度:桌面逐页 + 手机逐页(含抽屉) ≥ 4.5:1 ============ */ const contrastOf = (page) => evalIn(page, ` 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) { const m = s.backgroundImage.match(/linear-gradient\\(rgba\\(255, 255, 255, ([\\d.]+)\\)/); if (m) { glass = true; chain.push([255, 255, 255, Number(m[1])]); } 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 samples = []; deepAll((el) => { if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|OPTION|NOSCRIPT)$/.test(el.localName)) 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' || Number(cs.opacity) === 0) return; if (!visible(el)) return; const fg = parse(cs.color) || [0, 0, 0, 1]; const ownGrad = /gradient/.test(cs.backgroundImage); // 白字压在自带的实色块 / 渐变块上(头像、品牌块、阶段色块):底是图案,单列,按大号图形阈值 3:1 if (fg.slice(0, 3).every((v) => v > 240) && ownGrad) { samples.push({ text: text.slice(0, 12), ratio: 3, need: 3, size: parseFloat(cs.fontSize), color: cs.color, where: elLabel(el), plate: true }); return; } const sb = parse(cs.backgroundColor); let bg = bgOf(el); if (sb && sb[3] > 0) bg = over(sb, bg); const r = ratio(over(fg, bg), bg); samples.push({ text: text.slice(0, 12), ratio: +r.toFixed(2), need: 4.5, size: parseFloat(cs.fontSize), color: cs.color, where: (() => { const path = []; for (let n = el; n && n.localName !== 'kf-app' && n.localName !== 'document'; n = n.parentElement || n.getRootNode()?.host) { const t = n.localName + (typeof n.className === 'string' && n.className ? '.' + n.className.split(' ')[0] : ''); path.unshift(t); if (n.getRootNode().host) path.unshift(n.getRootNode().host.localName + '»'); } return path.slice(-5).join('>'); })() }); }); return samples; `); const conD = [], conM = []; for (const p of TEXT_PAGES) { await gotoView(D, p, 1100); conD.push(...(await contrastOf(D))); } for (const p of TEXT_PAGES) { await gotoView(M, p, 1100); conM.push(...(await contrastOf(M))); } // 渐变色块上的白字(头像 / 阶段色块 / 品牌块)是图形底,按 3:1 单列,不计入正文达标统计 const conUnder = [...conD, ...conM].filter((x) => x.ratio < x.need && !x.plate); out.contrast = { ...(out.contrast || {}), minMeta: { dMeta: conD.filter((x) => x.color === 'rgb(24, 24, 27)' || x.color === 'rgb(63, 63, 70)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }), dAll: conD.reduce((m, x) => (x.ratio < m.ratio ? x : m), conD[0]), mAll: conM.reduce((m, x) => (x.ratio < m.ratio ? x : m), conM[0]) } }; const conMinD = conD.reduce((m, x) => (x.ratio < m.ratio ? x : m), conD[0]); const conMinM = conM.reduce((m, x) => (x.ratio < m.ratio ? x : m), conM[0]); const gray9 = [...conD].filter((x) => x.color === 'rgb(107, 114, 128)'); const gray9Min = gray9.length ? gray9.reduce((m, x) => (x.ratio < m.ratio ? x : m), gray9[0]) : null; out.contrast = { ...(out.contrast || {}), nD: conD.length, nM: conM.length, minD: conMinD, minM: conMinM, under: conUnder, ink3min: [...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }) }; check('12b', '对比度 ≥ 4.5:1:桌面 + 手机逐页逐文字节点(玻璃后按光场最深色斑算)', conUnder.length === 0, `桌面 9 个页面共 ${conD.length} 个文字节点;正文(gray-12 / gray-11)最低 ${out.contrast.minMeta.dMeta.ratio}:1「${out.contrast.minMeta.dMeta.text}」${out.contrast.minMeta.dMeta.size}px;` + `全节点最低 ${conMinD.ratio}:1「${conMinD.text}」${conMinD.size}px(${conMinD.color}${conD.filter((x) => x.plate).length ? ',渐变底白字单列' : ''});` + `手机 9 个页面共 ${conM.length} 个,最低 ${conMinM.ratio}:1「${conMinM.text}」${conMinM.size}px(${conMinM.color});` + `--kf-ink-3(rgb(75,85,99))辅助文字 ${[...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)').length} 处,最低 ${(() => { const a = [...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)'); return a.length ? a.reduce((m, x) => (x.ratio < m.ratio ? x : m)).ratio : '—'; })()}:1;渐变底白字(头像 / 品牌块)${[...conD, ...conM].filter((x) => x.plate).length} 个单列;低于阈值的 ${conUnder.length} 个` + (conUnder.length ? ':' + conUnder.slice(0, 8).map((x) => `${x.ratio}:1「${x.text}」${x.where}`).join(';') : '')); /* ============ 13–14. 微交互 ============ */ await gotoView(D, '/', 1400); const cardBox = await evalIn(D, ` const c = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card'); c.scrollIntoView({ block: 'center' }); const f = c.shadowRoot.querySelector('.frame'); const before = getComputedStyle(f).transform; c.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); await new Promise((r) => setTimeout(r, 250)); // :hover 只能靠真实指针:用 Playwright 单独 hover const cs = getComputedStyle(f); return { transition: cs.transitionProperty + ' | ' + cs.transitionDuration + ' | ' + cs.transitionTimingFunction, transformBefore: before, rect: (({ x, y, width, height }) => ({ x: Math.round(x + width / 2), y: Math.round(y + height / 2), width: Math.round(width), height: Math.round(height) }))(c.getBoundingClientRect()) }; `); await D.mouse.move(cardBox.rect.x, cardBox.rect.y); await D.waitForTimeout(260); const hov = await evalIn(D, ` const f = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card').shadowRoot.querySelector('.frame'); return { transform: getComputedStyle(f).transform, shadow: getComputedStyle(f).boxShadow.slice(0, 160) }; `); await shot(D, '06-卡片hover态.png', { clip: { x: Math.max(0, cardBox.rect.x - 280), y: Math.max(0, cardBox.rect.y - 140), width: 560, height: 280 } }); await D.mouse.move(5, 5); out.hover = { ...cardBox, ...hov }; // 视口里第一张「可点卡」(lift)—— hover / press 的数值都以它为样本。每次重新查询(视图会重建 DOM) const LIFT = "((() => { const walk = (r) => { for (const el of r.querySelectorAll('*')) { if (el.localName === 'kf-glass-card' && el.hasAttribute('lift') && el.getBoundingClientRect().height > 0) return el.shadowRoot.querySelector('.frame'); if (el.shadowRoot) { const f = walk(el.shadowRoot); if (f) return f; } } return null; }; return walk(appR()); })())"; const trans = await evalIn(D, ` const f = ${LIFT}; const cs = getComputedStyle(f); f.scrollIntoView({ block: 'center' }); const r = f.getBoundingClientRect(); return { transition: cs.transitionProperty + ' | ' + cs.transitionDuration + ' | ' + cs.transitionTimingFunction, at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(80, r.height / 2)) } }; `); out.liftBox = trans.at; await D.mouse.move(trans.at.x, trans.at.y); // 等过渡真正结束(150ms 过渡,最多等 1.2s;读到非 none 的位移即停) for (let k = 0; k < 12; k++) { await D.waitForTimeout(100); const t = await evalIn(D, `return getComputedStyle(${LIFT}).transform;`); if (t && t !== 'none' && !/, 0\)$/.test(t)) break; } const liftHov = await evalIn(D, ` const f = ${LIFT}; const r = f.getBoundingClientRect(); return { transform: getComputedStyle(f).transform, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(80, r.height / 2)) }; `); await D.mouse.down(); await D.waitForTimeout(150); const liftAct = await evalIn(D, `return getComputedStyle(${LIFT}).transform;`); const hoverShot = await shot(D, '06-卡片hover态.png', { clip: { x: Math.max(0, liftHov.x - 280), y: Math.max(0, liftHov.y - 140), width: 560, height: 280 } }); await D.mouse.up(); await D.waitForTimeout(250); await D.mouse.move(5, 5); const tOk = /transform/.test(trans.transition) && /0\.15s/.test(trans.transition) && /cubic-bezier\(0\.2, 0, 0, 1\)/.test(trans.transition); out.hover = { ...cardBox, ...hov, trans, liftHov, liftAct }; check(13, 'hover:150ms cubic-bezier(.2,0,0,1),translateY(-1px);press scale(.985) 80ms', tOk && /matrix\(1, 0, 0, 1, 0, -1\)/.test(liftHov.transform) && /\(0\.985/.test(liftAct), `可点卡(lift).frame transition = ${trans.transition};hover 实测 transform = ${liftHov.transform};按下实测 transform = ${liftAct}`); const rm = await (async () => { const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, reducedMotion: 'reduce' }); const P = await ctx.newPage(); await P.goto(url('/'), { waitUntil: 'networkidle' }); await P.waitForTimeout(1200); const r = await P.evaluate(`(async () => { ${DEEP} const f = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card').shadowRoot.querySelector('.frame'); return { dur: getComputedStyle(f).transitionDuration, mq: matchMedia('(prefers-reduced-motion: reduce)').matches }; })()`); await ctx.close(); return r; })(); check(14, 'prefers-reduced-motion: reduce 覆盖', rm.mq && parseFloat(rm.dur) < 0.01, `模拟 reduce 媒体后 matchMedia=${rm.mq},卡片 transition-duration computed = ${rm.dur}`); /* ============ 15. 手机 393 ============ */ const mob = {}; for (const p of PAGES) { await gotoView(M, p, 1100); mob[p] = await evalIn(M, ` const n = appR().querySelector('kf-nav').shadowRoot; const tab = n.querySelector('.tabbar'), side = n.querySelector('.side'); return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, tab: getComputedStyle(tab).display, side: getComputedStyle(side).display, tabs: tab.querySelectorAll('a').length, tabBottom: Math.round(tab.getBoundingClientRect().bottom), vh: innerHeight }; `); } const mobBad = Object.entries(mob).filter(([, r]) => r.sw > r.cw || r.tab !== 'grid' || r.tabs !== 5); await gotoView(M, '/', 1300); const col1 = await evalIn(M, ` const v = appR().querySelector('view-workbench').shadowRoot; const cols = getComputedStyle(v.querySelector('.grid2')).gridTemplateColumns; const cards = [...v.querySelectorAll('.grid2 > kf-glass-card')].map((c) => { const r = c.getBoundingClientRect(); return Math.round(r.width) + '×' + Math.round(r.height) + '@' + Math.round(r.left); }); return { cols, cards }; `); check(15, '手机 393px:Tab 栏在、工作台单列、9 页 scrollWidth === clientWidth', mobBad.length === 0 && col1.cols.trim().split(' ').length === 1, `9 页 scrollWidth/clientWidth:${Object.entries(mob).map(([p, r]) => p + ' ' + r.sw + '/' + r.cw).join('、')};Tab 栏 ${mob['/'].tab}(${mob['/'].tabs} 项,底边 ${mob['/'].tabBottom}=视口 ${mob['/'].vh}),侧栏 ${mob['/'].side};工作台 .grid2 列 ${col1.cols},两卡 ${col1.cards.join(' / ')}`); await shot(M, '04-手机-工作台.png', { fullPage: false }); await goto(M, '/reports/competitor', 1300); await shot(M, '05-手机-类型页.png'); /* ============ 16. 回归 ============ */ 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 chkProps = (chk.match(/^✓/gm) || []).length; const chkBad = (chk.match(/^✗/gm) || []).length; const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']); const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g; const wordLines = [...card.matchAll(wordRe)]; const WORDS = { 类型页: 300, 工作台: 350, 报告中心: 200 }; const wordOk = wordLines.length > 0 && wordLines.every((m) => Number(m[3]) <= (WORDS[m[1]] ?? Number(m[2]))); check(16, 'node --test 46/46 + check.mjs 全绿 + 字数不回升', passN === 46 && failN === 0 && chkBad === 0 && wordOk && errors.length === 0, `单测 pass=${passN} fail=${failN};check ${chkProps} ✓ / ${chkBad} ✗;字数 ${wordLines.map((m) => `${m[1]} ${m[3]}/${WORDS[m[1]] ?? m[2]}`).join(' · ') || '(未取到)'};控制台错误 ${errors.length}${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`); /* ============ 截图(其余) ============ */ await goto(D, '/reports/competitor', 1300); await shot(D, '03-桌面-类型页.png'); await gotoView(D, '/', 1300); // 骨架屏:截取加载瞬间 await D.goto(url('/'), { waitUntil: 'domcontentloaded' }); await D.waitForTimeout(120); await shot(D, '07-骨架屏.png'); await gotoView(D, '/', 1400); await goto(D, '/reports/concept', 1400); await shot(D, '08-空态.png'); const report = { tag: TAG, rows, collected: out }; const { writeFileSync } = await import('node:fs'); writeFileSync(join(SHOTS, `measure-${TAG}.json`), JSON.stringify({ ...report, shadowRaw: out.shadowRaw }, null, 2)); writeFileSync(join(SHOTS, 'shadow-report.json'), JSON.stringify(out.shadowRaw, null, 2)); const ok = rows.filter((r) => r.ok).length; console.log(`\n${ok}/${rows.length} 通过(${TAG})`); await browser.close(); process.exit(ok === rows.length ? 0 : 1);