| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519 |
- #!/usr/bin/env node
- /**
- * scripts/verify-v16.mjs —— v16 验收(v15 的 1–9 项全保留 + 任务书 v16 的 10–12 项)。
- *
- * node scripts/verify-v16.mjs --tag before --from HEAD 改前基线(从 git 导出旧代码单独起服务)
- * node scripts/verify-v16.mjs --tag after --from worktree 改后
- * 新增:[10] 短卡内容密度(跟 dr1 量得的基准比) [11] 深色卡色相数(≤2) [12] 底部按钮重量
- * 基准出处:docs/v16-research.md §1.2 / §2.1 / §5
- *
- * node scripts/verify-v15.mjs --tag before 改前基线
- * node scripts/verify-v15.mjs --tag after 改后
- * node scripts/verify-v15.mjs --json out.json 结果落盘(docs/v15-before-after.md 从这里抄数字)
- * --me 赵磊 量「有数据」的我的推进(默认赵磊,仓库里有 2 份成果)
- * --no-regress 顺带跑 verify-visual-v12.mjs 再打一遍
- *
- * 数据源:dev server(默认 127.0.0.1:4173,--port 改)→ 生产 Parse 只读。localhost 是安全上下文,
- * 所以 localStorage 里写 `kf-board:me` 等价于真实用户点过「我是」。
- *
- * 量什么(工作台 /,1440×900 与 393×852 两档):
- * 1. A 卡宽 / B 卡宽 / 高度差(≤2px);A 占 12 栅格里的几列
- * 2. B 卡底色亮度(<0.35)与反白文字最差对比度(≥7:1)
- * 3. C/D/E 三卡宽高差(高差 ≤2px)
- * 4. 页面主列直接子块的相邻 gap(剔除页头与页脚)
- * 5. 卡片内边距实测(.face 的 computed padding)
- * 6. 全页可见文字字号档位(≤5 档、无 11px)
- * 7. kf-spark 残留(工作台 0;全站单独打印)
- * 8. 393px:scrollWidth === clientWidth、A/B 上下堆叠、字号档、无 11px
- * 9. 全站普通卡片:backdrop-filter 个数、box-shadow 非 none 个数(跨 shadow)
- * 10. --no-regress:调用 verify-visual-v12.mjs --tag before,把摘要原样打印
- */
- import { writeFileSync } from 'node:fs';
- import { join, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- import { execFileSync } from 'node:child_process';
- 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 JSON_OUT = arg('--json', '');
- const PORT = Number(arg('--port', process.env.PORT || 4173));
- const ME = arg('--me', '赵磊');
- const BASE = `http://127.0.0.1:${PORT + 30}`;
- 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;
- }
- /* 「改前基线」必须对着改前的代码跑:
- * 默认 --from HEAD:把 git 里的整棵树导出到临时目录,dev server 指向那里(工作区改了也不影响);
- * --from worktree:跑工作区当前代码(改完自检用)。
- * 每次运行都打印 /src/pages/view-workbench.js 的 sha1 写进 JSON —— before / after 两份能直接核对跑的是哪版。
- */
- const FROM = arg('--from', 'HEAD');
- const { createHash } = await import('node:crypto');
- const { readFileSync, mkdtempSync, cpSync, rmSync } = await import('node:fs');
- const { tmpdir } = await import('node:os');
- let codeDir = ROOT;
- if (FROM !== 'worktree') {
- codeDir = mkdtempSync(join(tmpdir(), 'kf-v15-'));
- execFileSync('sh', ['-c', `git archive ${JSON.stringify(FROM)} | tar -x -C ${JSON.stringify(codeDir)}`], { cwd: ROOT, stdio: 'ignore' });
- /* lib/vendor(lit 等)没进 git,要和工作区的一起拷;serve.mjs 的静态根 = 它自己所在的 src/../..,
- 所以把 serve.mjs 挪到临时根的 src/dev/ 下即可整体切换 */
- cpSync(join(ROOT, 'src/dev/serve.mjs'), join(codeDir, 'src/dev/serve.mjs'));
- cpSync(join(ROOT, 'lib'), join(codeDir, 'lib'), { recursive: true });
- }
- const codeSha = createHash('sha1').update(readFileSync(join(codeDir, 'src/pages/view-workbench.js'))).digest('hex').slice(0, 12);
- const cleanup = () => { if (FROM !== 'worktree') rmSync(codeDir, { recursive: true, force: true }); };
- const { launch, DEEP } = await import('./v11b-lib.mjs');
- const browser = await launch();
- /* 独立的 dev server:静态根指向 codeDir,/parse 仍代理生产(只读) */
- const { spawn } = await import('node:child_process');
- const srv = spawn(process.execPath, [join(codeDir, 'src/dev/serve.mjs'), '--port', String(PORT + 30), '--mount', '/'], { stdio: 'ignore', cwd: codeDir });
- /* 起不来就报错退出,不要悄悄连到别的 4173 */
- const SBASE = `http://127.0.0.1:${PORT + 30}`;
- for (let i = 0; i < 60; i++) {
- await new Promise((r) => setTimeout(r, 100));
- try { if ((await fetch(SBASE + '/index.html')).ok) break; } catch { /* 还没起来 */ }
- }
- const main = async () => {
- /* ---------------- 桌面 1440 ---------------- */
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
- /* 只在第一次导航(about:blank,同源之前)种一次:addInitScript 会在每次导航时重跑,
- 那样后面「清掉我是」再刷新会被它写回去(实测:空态 A 量到的是赵磊)。 */
- await ctx.addInitScript((name) => { try { if (!localStorage.getItem('kf-v15-seeded')) { localStorage.setItem('kf-v15-seeded', '1'); localStorage.setItem('kf-board:me', JSON.stringify(name)); } } catch { /* 首次在 about:blank 里没有 localStorage,忽略 */ } }, ME);
- 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 mount = async (p) => {
- await page.goto(new URL(p, BASE).href, { waitUntil: 'networkidle' });
- await page.waitForFunction(() => {
- const a = document.querySelector('kf-app');
- const v = a?.shadowRoot?.querySelector('main#view')?.firstElementChild;
- return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
- }, null, { timeout: 20000 });
- await page.waitForTimeout(600);
- /* 光场 / 入场动效会让第一轮几何还没稳定:滚动回顶部,动效按系统偏好关掉 */
- try { await page.waitForFunction(() => window.scrollY === 0, null, { timeout: 3000 }); } catch { await page.evaluate(() => window.scrollTo(0, 0)); }
- await page.waitForTimeout(200);
- };
- const DESKTOP = `(() => { ${DEEP}
- const view = document.getElementById('v15-det') || appR().querySelector('main#view').firstElementChild;
- const vr = view.shadowRoot;
- const R = (el) => { const b = el.getBoundingClientRect(); return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1), top: +b.top.toFixed(1), bottom: +b.bottom.toFixed(1) }; };
- const rgb = (s) => { const m = s.match(/rgba?\\(([\\d.]+), ([\\d.]+), ([\\d.]+)(?:, ([\\d.]+))?\\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] == null ? 1 : +m[4] } : null; };
- const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
- const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return +(x + 0.05) / (y + 0.05); };
- /* 跨 shadow 向上找第一个不透明底色 */
- const bgOf = (el) => { for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const c = rgb(getComputedStyle(q).backgroundColor); if (c && c.a >= 0.99) return c; } return { r: 255, g: 255, b: 255 }; };
- /* 一个元素「画出来」的底色 = 自身背景色;没有就看最近的不透明祖先 */
- const paintOf = (el) => { const own = rgb(getComputedStyle(el).backgroundColor); return own && own.a >= 0.5 ? own : bgOf(el.parentElement || el.getRootNode()?.host); };
- const host = (sel) => vr.querySelector(sel);
- const frameOf = (card) => card?.shadowRoot?.querySelector('.frame') || null;
- const cards = [...vr.querySelectorAll('kf-glass-card')].filter((c) => c.getBoundingClientRect().height > 0);
- /* ---------- 栅格:找行容器 ---------- */
- const gridRows = [...vr.querySelectorAll('*')].filter((el) => {
- const s = getComputedStyle(el); return /grid/.test(s.display) && el.querySelector(':scope > kf-glass-card');
- }).map((el) => {
- const kids = [...el.querySelectorAll(':scope > kf-glass-card')].map((k) => ({ rect: R(k), role: k.dataset.role || '' }));
- return { el, cls: el.className, cols: getComputedStyle(el).gridTemplateColumns, kids };
- });
- const byRole = (r) => gridRows.flatMap((g) => g.kids).find((k) => k.role === r)?.rect || null;
- const A = byRole('a'), B = byRole('b'), C = byRole('c'), D = byRole('d'), E = byRole('e'), F = byRole('f')
- || (vr.querySelector('kf-glass-card[data-role="f"]') ? R(vr.querySelector('kf-glass-card[data-role="f"]')) : null);
- /* 列数:找 A 所在的栅格容器,读它的 grid-template-columns 与 column-gap,算出单列宽,再数 A 占几列 */
- const aEl = vr.querySelector('kf-glass-card[data-role="a"]');
- /* 直接在已找到的行容器里挑「装着 A 的那一行」——不要 [...vr.children](vr 是 ShadowRoot,children 是 HTMLCollection,
- 展开后第一项是属性本身,不是元素) */
- const rowABC = gridRows.find((g) => g.el.contains(aEl))?.el || null;
- let gapRow = null, colW = null, colDbg = null;
- if (rowABC) {
- gapRow = parseFloat(getComputedStyle(rowABC).columnGap) || parseFloat(getComputedStyle(rowABC).gap) || 0;
- const tracks = getComputedStyle(rowABC).gridTemplateColumns.split(/ +/).filter(Boolean).map(parseFloat);
- colW = tracks.length ? (rowABC.getBoundingClientRect().width - gapRow * (tracks.length - 1)) / tracks.length : null;
- colDbg = { n: tracks.length, w: rowABC.getBoundingClientRect().width, gapRow, colW };
- }
- /* 列数:单列宽 = (行宽 − 间距 × (列数 − 1)) / 列数;A 占几列 = (A 宽 + 间距) / (单列宽 + 间距) */
- const aColsExact = A && colW ? Math.round((A.w + gapRow) / (colW + gapRow)) : null;
- const colWDbg = colW;
- /* ---------- B 卡深色 ---------- */
- let Bbg = null, BbgText = '', Bcontrast = null, Bother = [];
- const bc = cards.find((c) => c.dataset.role === 'b');
- if (bc) {
- const frame = frameOf(bc);
- Bbg = rgb(getComputedStyle(frame)?.backgroundColor || '');
- if (!Bbg) Bbg = paintOf(bc);
- /* 文本都在 slotted 内容里(bc 的 light DOM),不在它自己的 shadow 内 */
- const texts = [...bc.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()));
- const worst = texts.map((e) => { const fg = rgb(getComputedStyle(e).color); return { t: e.textContent.trim().slice(0, 16), fg, c: +contrast(fg, Bbg).toFixed(2) }; }).sort((p, q) => p.c - q.c);
- Bcontrast = worst[0] || null;
- BbgText = 'rgb(' + Bbg.r + ', ' + Bbg.g + ', ' + Bbg.b + ')';
- Bother = texts.map((e) => ({ t: e.textContent.trim().slice(0, 16), c: +contrast(rgb(getComputedStyle(e).color), Bbg).toFixed(2) }));
- }
- /* ---------- 主列直接子块间距 ---------- */
- const blocks = [...vr.children].filter((el) => {
- const s = getComputedStyle(el); const b = el.getBoundingClientRect();
- return s.display !== 'none' && b.height > 0;
- }).map((el) => ({ el, tag: el.localName + (el.className ? '.' + String(el.className).split(' ')[0] : ''), rect: R(el) }));
- /* 间距 = 两块之间真实露出的竖直空白。box 底边不含 margin-bottom / padding-bottom / border-bottom,
- 下一块 box 顶边也不含它自己的 border-top(外边框在 rect 内,margin 折叠也已体现在 rect 里)。
- 两者相减后扣掉上一块自带的尾随边距,再扣掉下一块自己的 border-top —— 得到「眼睛看到的空白」。 */
- const gaps = [];
- for (let i = 1; i < blocks.length; i++) {
- const prev = blocks[i - 1], cur = blocks[i];
- const sp = getComputedStyle(prev.el), sc = getComputedStyle(cur.el);
- const trail = (parseFloat(sp.marginBottom) || 0) + (parseFloat(sp.paddingBottom) || 0) + (parseFloat(sp.borderBottomWidth) || 0)
- + (parseFloat(sc.borderTopWidth) || 0);
- gaps.push({ between: prev.tag + ' → ' + cur.tag, gap: +(cur.rect.top - prev.rect.bottom - trail).toFixed(1) });
- }
- /* ---------- 卡片内边距 ---------- */
- const padding = [...new Set([...vr.querySelectorAll('*')].filter((el) => /grid|flex/.test(getComputedStyle(el).display)).map((el) => getComputedStyle(el).paddingTop))];
- const facePad = cards.map((c) => c.dataset.role || 'card').map((role, i) => ({ role, pad: getComputedStyle(frameOf(cards[i]).querySelector('.face')).padding }));
- /* ---------- 字号档 ---------- */
- const fsCount = {};
- const fsWhere = {};
- walk(vr, (el) => {
- if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
- if (!visible(el)) return;
- const s = getComputedStyle(el);
- const k = Math.round(parseFloat(s.fontSize) * 10) / 10 + 'px';
- fsCount[k] = (fsCount[k] || 0) + 1;
- (fsWhere[k] = fsWhere[k] || new Set()).add(el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/)[0] : ''));
- });
- /* ---------- v16 [10] 短卡密度:每张短卡「可见文字字符数 / 卡面积」 ---------- */
- const statCards = ['c', 'd', 'e', 'f'].map((r) => vr.querySelector('kf-glass-card[data-role="' + r + '"]')).filter(Boolean);
- const statDensity = statCards.map((k) => {
- const rect = R(k);
- const txt = [...k.querySelectorAll('*')].filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()) && visible(el))
- .map((el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.replace(/\s+/g, '')).join('')).join('');
- return { role: k.dataset.role, w: rect.w, h: rect.h, chars: [...txt].length,
- per1e4: +([...txt].length / (rect.w * rect.h) * 10000).toFixed(2), text: txt.slice(0, 60) };
- });
- /* ---------- v16 [11] 深色卡色相数 ---------- */
- const bEl = vr.querySelector('kf-glass-card[data-role="b"]');
- const hueOf = (c) => { if (!c || c.a < 0.15) return null; const mx = Math.max(c.r, c.g, c.b), mn = Math.min(c.r, c.g, c.b);
- if (mx - mn < 24) return null; let h; const d = mx - mn;
- if (mx === c.r) h = ((c.g - c.b) / d) % 6; else if (mx === c.g) h = (c.b - c.r) / d + 2; else h = (c.r - c.g) / d + 4;
- h *= 60; if (h < 0) h += 360;
- return h < 40 ? 'red/orange' : h < 70 ? 'amber' : h < 160 ? 'green' : h < 200 ? 'teal' : h < 260 ? 'blue' : h < 320 ? 'violet' : 'rose'; };
- const hues = new Map();
- if (bEl) walk(bEl, (el) => {
- if (!visible(el)) return;
- const s = getComputedStyle(el);
- for (const [prop, val] of [['color', s.color], ['bg', s.backgroundColor], ['border', s.borderTopColor]]) {
- const h = hueOf(rgb(val)); if (h) { const k = h; hues.set(k, (hues.get(k) || 0) + 1); }
- }
- });
- /* ---------- v16 [12] 底部按钮:工作台里所有 button / .kf-btn / .btn-key 的高度与背景 ---------- */
- const btns = [];
- walk(vr, (el) => {
- if (!visible(el)) return;
- const isBtn = el.localName === 'button' || el.classList?.contains('kf-btn') || el.classList?.contains('btn-key');
- if (!isBtn) return;
- const r = el.getBoundingClientRect(); const s = getComputedStyle(el);
- btns.push({ label: el.textContent.replace(/\s+/g, ' ').trim().slice(0, 12), h: Math.round(r.height), bg: s.backgroundColor, border: s.borderTopWidth + ' ' + s.borderTopColor });
- });
- return {
- view: view.localName,
- statDensity, hues: [...hues.entries()], btns,
- rows: gridRows.map((g) => ({ cls: g.cls, cols: g.cols, kids: g.kids.map((k) => ({ role: k.role, ...k.rect })) })),
- A, B, C, D, E, F,
- bento: {
- A: A?.w ?? null, B: B?.w ?? null, aCols: aColsExact, colDbg,
- ABheightDiff: A && B ? +Math.abs(A.h - B.h).toFixed(1) : null,
- CDE: C && D && E ? { w: [C.w, D.w, E.w], h: [C.h, D.h, E.h], hDiff: +(Math.max(C.h, D.h, E.h) - Math.min(C.h, D.h, E.h)).toFixed(1) } : null,
- F: F || null,
- },
- Bbg, BbgText, Bcontrast, Bother,
- gaps,
- facePad,
- fonts: { tiers: Object.keys(fsCount).sort((p, q) => parseFloat(p) - parseFloat(q)), count: fsCount, where: Object.fromEntries(Object.entries(fsWhere).map(([k, v]) => [k, [...v]])) },
- sparks: { inView: vr.querySelectorAll('kf-spark').length, inPage: document.querySelectorAll('*').length && (() => { let n = 0; walk(document, (e) => { if (e.localName === 'kf-spark') n++; }); return n; })() },
- overflow: { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth },
- };
- })()`;
- /* 确定性:实例化一个 view-workbench(自定义元素,不需要 import 模块),挂到主列最前面;
- .db 是页面已加载的同一份,但卡池只留前 5 张——「5 张」是改前数据快照里的真实值。
- 之前想 new ViewWorkbench() 是死路:模块里的类没 define,Lit 的属性表是空的(见 git 记录)。 */
- const DET_FN = async () => {
- const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
- const tpl = document.createElement('template');
- tpl.innerHTML = '<view-workbench id="v15-det"></view-workbench>';
- const v = tpl.content.firstElementChild;
- /* evaluate 每次拿到的是不同的 JS realm,顶层 import 拿到的是模块的**另一份实例**(Lit 会直接拒绝构造)。
- 所以不 import 任何模块:从 realm 的全局拿到页面里同一个 kf-app,再顺着它的 shadow root 取。 */
- const app = document.querySelector('kf-app');
- const view = app.shadowRoot.querySelector('main#view').firstElementChild;
- const db = view.db;
- v.db = { ...db, opportunities: db.opportunities.slice(0, 5) };
- v.me = '赵磊';
- main.prepend(v);
- await new Promise((r) => setTimeout(r, 600));
- return { kids: v.shadowRoot?.childElementCount ?? -1 };
- };
- /* 数据的随机部分(卡池状态 / 进程数)会随时间漂:卡多一张,首行卡就高一截。所以每个状态都量两次:
- - live:页面真身(刷新即变)
- - det(确定性):同一页面、再跑一次,只把卡池那一截的随机变量抹平(见 DET_FN),用于 before/after 对比 */
- await mount('/');
- const desktop = await page.evaluate(DESKTOP);
- const probeInfo = await page.evaluate(DET_FN);
- await page.waitForTimeout(500);
- const desktopDet = await page.evaluate(DESKTOP);
- if (!probeInfo || probeInfo.kids <= 0) console.log('[probe] 确定性实例未挂上:', JSON.stringify(probeInfo));
- /* 全站普通卡片:backdrop-filter / box-shadow 计数(四页) */
- const glassScan = async () => {
- await page.goto(new URL('/', BASE).href, { waitUntil: 'networkidle' }); // 同源,占位
- const SCAN = `(() => { ${DEEP}
- let blur = 0, shadow = 0; const blurWhere = new Set(), shadowWhere = new Set();
- walk(document, (el) => {
- const s = getComputedStyle(el);
- const bf = s.backdropFilter || s.webkitBackdropFilter;
- const vis = el.getBoundingClientRect().width > 8 && el.getBoundingClientRect().height > 4 && shownByStyle(el);
- if (!vis) return;
- if (bf && bf !== 'none') { blur++; blurWhere.add(elLabel(el)); }
- if (s.boxShadow && s.boxShadow !== 'none') { shadow++; shadowWhere.add(elLabel(el)); }
- });
- return { blur, shadow, blurWhere: [...blurWhere], shadowWhere: [...shadowWhere] };
- })()`;
- const out = {};
- for (const p of ['/', '/pool', '/stages', '/reports']) {
- await mount(p);
- out[p] = await page.evaluate(SCAN);
- }
- return out;
- };
- const glass = await glassScan();
- /* 空状态 B:本月没有我的成果 —— 真实数据里每个人都有成果,所以就地实例化一个 view-workbench
- (同一份 db,me 给一个不存在的名字 = 「选了个本月没交东西的人」)。用 customElements 里已注册的类,
- 不要 import 模块 —— 模块里的类没有 define(自定义元素已存在),new 它出来 render 不会生成任何节点。 */
- await page.evaluate(() => localStorage.setItem('kf-board:me', JSON.stringify('赵磊')));
- await mount('/');
- const nobodyProbe = await page.evaluate(async () => {
- /* 每次刷新都会重建 kf-app;离屏的确定性实例先在页面里摘掉(页面重新渲染时也会清掉) */
- document.getElementById('v15-det')?.remove();
- const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
- const tpl = document.createElement('template');
- tpl.innerHTML = '<view-workbench id="v15-probe"></view-workbench>';
- const v = tpl.content.firstElementChild;
- v.db = main.querySelector('view-workbench').db; v.me = '_nobody_';
- main.prepend(v);
- await new Promise((r) => setTimeout(r, 600));
- return { kids: v.shadowRoot?.childElementCount ?? -1 };
- });
- const nobody = await page.evaluate(`(() => { ${DEEP}
- const vr = (document.getElementById('v15-probe') || appR().querySelector('main#view').firstElementChild).shadowRoot;
- const a = vr.querySelector('kf-glass-card[data-role="a"]');
- return { kids: vr.childElementCount,
- roles: [...vr.querySelectorAll('kf-glass-card')].map((c) => c.dataset.role || '').filter(Boolean),
- Aheight: a ? Math.round(a.getBoundingClientRect().height) : null,
- text: vr.textContent.replace(/\\s+/g, ' ').trim().slice(0, 240) };
- })()`);
- nobody.probe = nobodyProbe;
- /* 空状态 A:未选身份 */
- await page.evaluate(() => document.getElementById('v15-probe')?.remove());
- await page.waitForTimeout(300);
- /* 清掉「我是」再重新挂载:kf-app 在 constructor 里读 localStorage,只清值不刷新不会生效 */
- await page.evaluate(() => localStorage.removeItem('kf-board:me'));
- await mount('/');
- const noMe = await page.evaluate(`(() => { ${DEEP}
- const vr = (document.getElementById('v15-probe') || appR().querySelector('main#view').firstElementChild).shadowRoot;
- const a = vr.querySelector('kf-glass-card[data-role="a"]');
- return { roles: [...vr.querySelectorAll('kf-glass-card')].map((c) => c.dataset.role || '').filter(Boolean),
- Aheight: a ? Math.round(a.getBoundingClientRect().height) : null,
- allstages: vr.querySelectorAll('[data-allstages] [data-stage]').length,
- text: vr.textContent.replace(/\\s+/g, ' ').trim().slice(0, 240) };
- })()`);
- /* 空状态 C:没人卡住 —— 用真数据克隆一份 db,把每个人的 stuck 置 false(等于「8 小时内都有新成果」),
- 同一个页面里挂第二个实例(设成 overflow:hidden 的离屏盒里量高度,不影响页面)。 */
- const noStall = await page.evaluate(async () => {
- const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
- const live = main.querySelector('view-workbench');
- const db = live.db;
- const members = db.members.map((m) => ({ ...m, stuck: false, stuckHours: 0 }));
- const box = document.createElement('div');
- box.id = 'v15-ok';
- box.style.cssText = 'position:absolute;left:-9999px;top:0;width:1200px';
- document.body.append(box);
- const tpl = document.createElement('template');
- tpl.innerHTML = '<view-workbench></view-workbench>';
- const v = tpl.content.firstElementChild;
- v.db = { ...db, members, memberByName: Object.fromEntries(members.map((m) => [m.name, m])) };
- v.me = '赵磊';
- box.append(v);
- await new Promise((r) => setTimeout(r, 700));
- const vr = v.shadowRoot;
- const a = vr.querySelector('kf-glass-card[data-role="a"]') || vr.querySelector('.grid2 > kf-glass-card');
- const b = vr.querySelector('kf-glass-card[data-role="b"]') || vr.querySelector('.grid2 > kf-glass-card:nth-child(2)');
- const out = { stalledItems: vr.querySelectorAll('[data-stalled]').length,
- /* 改前代码没有 data-role:B 卡是首行第二张 kf-glass-card */
- bText: b ? b.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) : '',
- aH: a ? Math.round(a.getBoundingClientRect().height) : null, bH: b ? Math.round(b.getBoundingClientRect().height) : null,
- dot: b?.querySelector('.dot') ? getComputedStyle(b.querySelector('.dot')).backgroundColor : '',
- bg: b ? getComputedStyle(b.shadowRoot.querySelector('.frame')).backgroundColor : '' };
- /* 不摘离屏实例:摘掉的一瞬间它的 Lit 更新周期会把空的更新排队,影响到页面里同一组件的其它实例 */
- window.__v15ok = box;
- return out;
- });
- const stalled = await page.evaluate(() => (document.querySelector('kf-app')?.shadowRoot?.querySelector('main#view')?.firstElementChild?.shadowRoot?.querySelectorAll('[data-stalled]').length) ?? -1);
- /* ---------------- 手机 393 ---------------- */
- const mctx = await browser.newContext({ viewport: { width: 393, height: 852 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
- await mctx.addInitScript((name) => { try { localStorage.setItem('kf-board:me', JSON.stringify(name)); } catch { /* ignore */ } }, ME);
- /* 手机上下文只加载一次,initScript 不会重跑,不需要上面那个一次性开关 */
- const mpage = await mctx.newPage();
- mpage.on('pageerror', (e) => errors.push('mobile: ' + e.message));
- await mpage.goto(new URL('/', BASE).href, { waitUntil: 'networkidle' });
- await mpage.waitForFunction(() => {
- const a = document.querySelector('kf-app');
- const v = a?.shadowRoot?.querySelector('main#view')?.firstElementChild;
- return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
- }, null, { timeout: 20000 });
- await mpage.waitForTimeout(600);
- await mpage.evaluate(DET_FN);
- const mobile = await mpage.evaluate(`(() => { ${DEEP}
- const vr = (document.getElementById('v15-det') || appR().querySelector('main#view').firstElementChild).shadowRoot;
- const R = (el) => { const b = el.getBoundingClientRect(); return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; };
- const a = vr.querySelector('kf-glass-card[data-role="a"]'), b = vr.querySelector('kf-glass-card[data-role="b"]');
- const rowInfo = (() => { const out = []; for (let q = a.parentElement; q && q !== vr; q = q.parentElement) out.push(q.localName + '.' + q.className + '@' + Math.round(q.getBoundingClientRect().top) + '-' + Math.round(q.getBoundingClientRect().bottom)); return out; })();
- const fsCount = {};
- walk(vr, (el) => {
- if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
- if (!visible(el)) return;
- const k = Math.round(parseFloat(getComputedStyle(el).fontSize) * 10) / 10 + 'px';
- fsCount[k] = (fsCount[k] || 0) + 1;
- });
- /* 横向溢出:任一可见元素右边超出视口 */
- let over = [];
- /* 只算「看得见」的:display/visibility/opacity 链要正常 —— 关着的抽屉/弹层还在 DOM 里(在视口右外侧),不算溢出 */
- walk(vr, (el) => { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > document.documentElement.clientWidth + 1 && shownByStyle(el)) over.push(elLabel(el) + '@' + Math.round(r.right)); });
- return { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth,
- A: a ? R(a) : null, B: b ? R(b) : null,
- /* 堆叠 = B 在垂直方向完全位于 A 下方且与 A 左边缘对齐(同一列里上下排)。
- 不能拿「一行」的 bottom 比 —— 行的 bottom 跟着最高的卡走,B 自己就是行内最高时恒为 false。 */
- stacked: a && b ? Math.abs(a.getBoundingClientRect().left - b.getBoundingClientRect().left) < 2
- && b.getBoundingClientRect().top >= a.getBoundingClientRect().bottom - 1 : null,
- CDE: ['c', 'd', 'e'].map((r) => { const k = vr.querySelector('kf-glass-card[data-role="' + r + '"]'); return k ? R(k) : null; }),
- fonts: Object.keys(fsCount).sort((p, q) => parseFloat(p) - parseFloat(q)), fontSizeCount: fsCount,
- overflowing: [...new Set(over)].slice(0, 10) };
- })()`);
- /* ---------------- v12 回归 ---------------- */
- let v12 = null;
- if (argv.includes('--no-regress')) {
- for (const p of ['v15/v12-desktop.txt', 'v15/v12-mobile.txt']) void p;
- try {
- const out = execFileSync(process.execPath, [join(ROOT, 'scripts/verify-visual-v12.mjs'), '--tag', 'before'], { cwd: ROOT, encoding: 'utf8', timeout: 300000, env: { ...process.env, VERIFY_PORT: '4199' } });
- v12 = out;
- } catch (e) {
- v12 = (e.stdout || '') + (e.stderr || '') + `\n[exit ${e.status}]`;
- }
- }
- const result = { codeFrom: FROM, codeSha, probeInfo, tag: TAG, at: new Date().toISOString(), desktop, desktopDet, glass, noMe, nobody, noStall, stalledCount: stalled, mobile, v12, errors };
- /* ---------------- 打印 ---------------- */
- const L = (s = '') => console.log(s);
- L(`\n===== verify-v15 · ${TAG} · ${result.at} =====`);
- L(`代码来源 ${FROM} · view-workbench.js sha1 ${codeSha}`);
- L(`\n[1] Bento 栅格(1440,确定性数据:我的成果=2,卡池状态/份数保持数据快照「before」时刻)`);
- const bt = desktopDet.bento;
- L(` A 宽 ${bt.A ?? '—'}px / B 宽 ${bt.B ?? '—'}px → 视觉 ${bt.aCols ?? '—'} 列 : ${bt.A && bt.B ? 12 - bt.aCols : '—'} 列`);
- L(` A/B 高度差 ${bt.ABheightDiff}px ${bt.ABheightDiff != null && bt.ABheightDiff <= 2 ? '✓' : '✗(要求 ≤2)'}`);
- L(` 短卡 C/D/E 宽 ${bt.CDE?.w.join(' / ')} 高 ${bt.CDE?.h.join(' / ')} 高差 ${bt.CDE?.hDiff}px ${bt.CDE?.hDiff != null && bt.CDE.hDiff <= 2 ? '✓' : '✗(要求 ≤2)'}`);
- L(` F 通栏 ${bt.F ? `宽 ${bt.F.w}px 高 ${bt.F.h}px` : '(无)'}`);
- L(`\n[2] B 卡深色 / C/D/E/F 在确定性数据下`);
- L(` B 背景 ${desktopDet.BbgText || '(无 data-role="b" 的卡)'}`);
- L(` C/D/E ${desktopDet.bento.CDE ? `宽 ${desktopDet.bento.CDE.w.join(' / ')} 高 ${desktopDet.bento.CDE.h.join(' / ')} 高差 ${desktopDet.bento.CDE.hDiff}px` : '(无 data-role=c/d/e 的卡)'}`);
- L(` F 通栏 ${desktopDet.bento.F ? `宽 ${desktopDet.bento.F.w}px 高 ${desktopDet.bento.F.h}px` : '(无 data-role="f" 的卡)'}`);
- L(` A 占 ${desktopDet.bento.aCols} / 12 列`);
- L(` — 实时数据(页面真身):A ${desktop.bento.A} / B ${desktop.bento.B} 高差 ${desktop.bento.ABheightDiff}px;C/D/E 高差 ${desktop.bento.CDE?.hDiff}px`);
- L(` B 背景 ${desktop.BbgText}`);
- if (desktop.Bbg) {
- const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
- L(` 亮度 ${lum(desktop.Bbg).toFixed(3)} ${lum(desktop.Bbg) < 0.35 ? '✓(<0.35)' : '✗'}`);
- }
- if (desktop.Bcontrast) {
- L(` 最差对比度 ${desktop.Bcontrast.c}:1(“${desktop.Bcontrast.t}”) ${desktop.Bcontrast.c >= 7 ? '✓(≥7)' : '✗(要求 ≥7)'}`);
- L(` 逐段:${desktop.Bother.map((o) => `${o.c}:1「${o.t}」`).join(' ')}`);
- }
- L(`\n[3] 主列相邻块间距(上→下)`);
- for (const g of desktopDet.gaps) L(` ${String(g.gap).padStart(6)}px ${g.between}`);
- L(`\n[4] 卡片 .face 内边距`);
- const padCount = {};
- for (const p of desktopDet.facePad) padCount[p.pad] = (padCount[p.pad] || 0) + 1;
- L(` ${JSON.stringify(padCount)} ${Object.keys(padCount).length === 1 && Object.keys(padCount)[0] === '20px' ? '✓' : '✗(要求 20px)'}`);
- L(` 逐卡:${desktopDet.facePad.map((p) => `${p.role || '?'}=${p.pad}`).join(' ')}`);
- L(`\n[5] 字号档(工作台可见文字,1440)`);
- L(` ${desktopDet.fonts.tiers.length} 档:${desktopDet.fonts.tiers.join(' / ')} ${desktopDet.fonts.tiers.length <= 5 && !desktopDet.fonts.tiers.includes('11px') ? '✓' : '✗'}`);
- for (const k of desktopDet.fonts.tiers) L(` ${k.padStart(5)} × ${desktopDet.fonts.count[k]} ${desktopDet.fonts.where[k].join(', ')}`);
- if (desktop.fonts.tiers.join() !== desktopDet.fonts.tiers.join()) L(` (实时数据下的档位:${desktop.fonts.tiers.join(' / ')})`);
- L(`\n[6] kf-spark:工作台 ${desktop.sparks.inView} 个,全站 ${desktop.sparks.inPage} 个`);
- L(` 评分是实时数据:B 卡当前 ${stalled} 人卡住;C/D/E 数值随数据变化,对比只看结构与高差`);
- L(`\n[7] 普通卡片玻璃/阴影(跨 shadow,可见元素)`);
- for (const [p, v] of Object.entries(glass)) L(` ${p.padEnd(9)} backdrop-filter ${String(v.blur).padStart(3)} box-shadow ${String(v.shadow).padStart(3)} ${v.blur ? '[' + v.blurWhere.slice(0, 6).join(', ') + ']' : ''}`);
- L(`\n[8] 空状态`);
- L(` 未选身份:roles=${noMe.roles.join(',')} A 高 ${noMe.Aheight}px [data-allstages] ${noMe.allstages}`);
- L(` 「${noMe.text.slice(0, 140)}」`);
- L(` 不存在的人(本月无成果):roles=${nobody.roles.join(',')} A 高 ${nobody.Aheight}px`);
- L(` 「${nobody.text.slice(0, 160)}」`);
- L(` 没人卡住(克隆 db,stuck 全置 false):[data-stalled] ${noStall.stalledItems} A 高 ${noStall.aH} / B 高 ${noStall.bH}(差 ${Math.abs(noStall.aH - noStall.bH)})`);
- L(` B 底 ${noStall.bg} 点 ${noStall.dot} 「${noStall.bText}」`);
- L(` 当前实时数据里卡住的人:[data-stalled] ${stalled}`);
- L(`\n[9] 移动端 393×852`);
- const m = mobile;
- L(` scrollWidth ${m.scrollWidth} / clientWidth ${m.clientWidth} ${m.scrollWidth === m.clientWidth ? '✓' : '✗ 横向溢出'}`);
- if (m.A && m.B) L(` A ${m.A.w}×${m.A.h} @y${m.A.y} B ${m.B.w}×${m.B.h} @y${m.B.y} 堆叠 ${m.stacked ? '✓' : '✗'}`);
- L(` C/D/E ${m.CDE.map((k) => k ? `${k.w}×${k.h}@y${k.y}` : '—').join(' ')}`);
- L(` 字号档 ${m.fonts.join(' / ')} ${m.fonts.length <= 5 && !m.fonts.includes('11px') ? '✓' : '✗'}`);
- if (m.overflowing.length) L(` 超出视口:${m.overflowing.join(', ')}`);
- if (errors.length) L(`\n[!] 页面错误 ${errors.length}:\n ${errors.join('\n ')}`);
- if (v12) {
- L(`\n[10] v12 回归(verify-visual-v12 --tag before)`);
- L(v12.split('\n').filter((l) => /✗|FAIL|失败|exit|总|汇总|项/.test(l)).slice(0, 60).join('\n'));
- }
- L(`\n[10] 短卡内容密度(字符数 / 卡面积 × 10⁴;dr1 基准 29 字 / 33 600px² = 8.6)`);
- for (const s of desktopDet.statDensity || []) L(` ${s.role} ${s.w}×${s.h} ${s.chars} 字 → ${s.per1e4} ${s.per1e4 >= 8.6 * 0.7 ? '✓(≥ 基准 70%)' : '✗'} 「${s.text}」`);
- L(`\n[11] 深色卡色相数(要求 ≤2;dr6 实测 1)`);
- L(` ${(desktop.hues || []).map(([h, n]) => `${h}×${n}`).join(' ') || '(无色相)'} → ${(desktop.hues || []).length} 种 ${(desktop.hues || []).length <= 2 ? '✓' : '✗'}`);
- L(`\n[12] 工作台按钮(高度 / 背景 / 描边)`);
- for (const b of desktop.btns || []) L(` ${String(b.h).padStart(3)}px ${b.label.padEnd(8)} ${b.bg} ${b.border}`);
- L(` → ${(desktop.btns || []).filter((b) => b.h > 32).length} 个 >32px ${(desktop.btns || []).filter((b) => b.h > 32).length === 0 ? '✓' : '✗'}`);
- if (JSON_OUT) {
- const p = JSON_OUT.startsWith('/') ? JSON_OUT : join(ROOT, JSON_OUT);
- writeFileSync(p, JSON.stringify(result, null, 2));
- L(`\n→ ${p}`);
- }
- };
- try {
- await main();
- } finally {
- await browser.close();
- try { srv.kill(); } catch { /* ignore */ }
- cleanup();
- }
|