#!/usr/bin/env node /** * scripts/audit-spacing.mjs —— v11b 四、P1-4.1:间距分级 + 4.2「撑高留白」实测。 * * 4.1 间距只许四档:卡内 padding 16 / 卡内 gap 12 / 卡片之间 24 / 区块之间 48(另有 4pt 栅格里的 4 / 8 做行内小缝)。 * 统计 7 个路由上**可见**元素的 margin / padding / gap 的构成值种类;种类 > 6 报警、四档外的值逐个列出。 * 4.2 撑高留白:对每个「卡片类」元素(.kf-card / kf-glass-card / .gcard / tr / 列表行)量 * 内容高度 vs 盒子高度,差额 > 24px 的列为「stretch 撑高」,并打印元素与所在路由。 * 另外 25% 缩放整页截图(docs/screenshots/v11b/spacing-*.png),人工看「块」与「均匀呼吸」。 * * 用法:node scripts/audit-spacing.mjs [--url …] [--shots …] */ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { startServer, launch, evalIn, openView, table, ROOT } from './v11b-lib.mjs'; 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/v11b')); mkdirSync(SHOTS, { recursive: true }); const srv = arg('--url', '') ? null : await startServer(4199); const base = arg('--url', '') || srv.base; const browser = await launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const cdp = await page.context().newCDPSession(page); await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); await page.emulateMedia({ reducedMotion: 'reduce' }); const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger']; /* 四档 + 4pt 栅格里的 4 / 8。其余的「布局距离」值 = 行内小缝、圆点 6px、图标间距 10px、字距补偿(1.32em)等, 属于「组件内部」而不是「版式留白」;它们单独列出来、不计入报警。报警口径:版式距离(卡内 / 卡间 / 区块间)的种数 > 6。 */ const FOUR = new Set(['4px', '8px', '12px', '16px', '24px', '48px']); /* 版式容器的判定:卡(kf-glass-card / .kf-card / .gcard)、区块(.p-sec / .hd / .tool / .overview / .grid* / .cards / .latest)、页面头(.p-head) */ const LAYOUT_SRC = /kf-glass-card|\bkf-card\b|\bgcard\b|\bp-sec\b|\bhd\b|\btool\b|\boverview\b|\bgrid[24]\b|\bcards\b|\blatest\b|\bp-head\b|\bheatx\b/; const LAYOUT_JS = LAYOUT_SRC.source; const SPA_RE = String.raw` const vals = {}; const lvals = {}; const tall = []; const gapObs = []; const LAYOUT = new RegExp(__LAYOUT__); const isLayout = (el) => LAYOUT.test(String(el.className || '') + ' ' + el.localName); const bump = (k, v, isL) => { if (!v || v === '0px') return; vals[v] = (vals[v] || 0) + 1; if (isL) lvals[v] = (lvals[v] || 0) + 1; }; const sides = { paddingTop: 'padding-top', paddingRight: 'padding-right', paddingBottom: 'padding-bottom', paddingLeft: 'padding-left', marginTop: 'margin-top', marginRight: 'margin-right', marginBottom: 'margin-bottom', marginLeft: 'margin-left' }; deepAll((el) => { if (el.nodeType !== 1 || !inMain(el) || !visible(el)) return; if (/^(SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE|TEXT|STYLE|SCRIPT|I|svg|path|circle|line|rect|text|use|defs)$/.test(el.localName)) return; const cs = getComputedStyle(el); const isL = isLayout(el); for (const [camel, prop] of Object.entries(sides)) { if (cs[camel] === '0px' || !/\d/.test(cs[camel])) continue; /* 用 computed 值(已经把 em / 百分比 / 继承都算过);作者写的 prop 名是驼峰,getPropertyValue 拿不到 */ const raw = cs.getPropertyValue(prop); if (!raw || raw === 'auto' || raw === 'normal' || raw.startsWith('-')) continue; let v = raw; if (raw.endsWith('em')) v = Math.round(parseFloat(raw) * parseFloat(cs.fontSize) * 100) / 100 + 'px'; if (!/^\d+(\.\d+)?px$/.test(v)) continue; if (!/^\d+(\.\d+)?px$/.test(v) || v === '0px') continue; bump(prop.startsWith('padding') ? 'padding' : 'margin', v, isL); } for (const prop of ['row-gap', 'column-gap']) { if (!/^\d+(\.\d+)?px$/.test(cs.getPropertyValue(prop) || '')) continue; const raw = cs.getPropertyValue(prop); if (!raw || raw === '0px') continue; bump('gap', raw, isL); if (el.querySelector(':scope > kf-glass-card') || el.localName === 'kf-glass-card') gapObs.push(elLabel(el) + ' gap=' + raw); } }); /* 撑高留白:卡面(.frame)里,内容上沿到卡面顶、内容下沿(含卡片自带的分隔线 / 表头)到卡面底 —— 两边扣掉 16px 内边距, 任一方向 > 24px 判「撑高」。把手动 attachShadow 的子组件、卡片自带的 ::before / ::after 装饰都算进内容框 (判据 = 卡面范围里所有渲染过的后代盒 + 卡片自己的 伪元素 盒)。 */ const PSEUDO_PROBE = (el) => { /* 伪元素没有 DOM 盒可量:按 computed 的 top/inset/height 估,不作为内容,只是排除「卡面比内容高是因为铺满整卡的底」 */ return null; }; deepAll((el) => { if (el.localName !== 'kf-glass-card' || !inMain(el) || !visible(el)) return; const frame = el.shadowRoot?.querySelector('.frame'); const slot = el.shadowRoot?.querySelector('slot'); if (!frame || !slot) return; const face = frame.querySelector('.face'); const pt = parseFloat(getComputedStyle(face).paddingTop) || 0; const pb = parseFloat(getComputedStyle(face).paddingBottom) || 0; const fr = frame.getBoundingClientRect(); /* 内容 = 卡面里所有「有文本或背景」的叶子盒,外加卡片自带的表头 / 分隔线 */ let top = Infinity, bottom = -Infinity, n = 0; const visit = (root) => { for (const k of root.children) { const r = k.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { n++; top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); } if (k.shadowRoot) visit(k.shadowRoot); } }; visit(face); if (!n) return; const extraTop = top - fr.top - pt; const extraBottom = fr.bottom - bottom - pb; if (extraBottom > 24 || extraTop > 24) tall.push({ el: elLabel(el), frame: Math.round(fr.height), extraBottom: Math.round(extraBottom), extraTop: Math.round(extraTop) }); }); return { vals, lvals, tall, gapObs }; `; // 正则体(字符串),拼进页面脚本时再用 new RegExp 包起来 const values = new Map(); const layoutValues = new Map(); const stretchHits = []; for (const r of ROUTES) { await openView(page, base, r, { me: '赵磊', wait: 700 }); /* LAYOUT_JS 本身是正则文字(形如 kf-card|...),并把行的反斜杠保住:JSON 序列化结果里 '\\\\b' 到页面会变成 '\\b' 被当成退格符 */ const res = await evalIn(page, SPA_RE.replace('__LAYOUT__', '/(?:' + LAYOUT_JS.replace(/\\/g, '\\\\') + ')/')); for (const [v, n] of Object.entries(res.vals)) values.set(v, (values.get(v) || 0) + n); for (const [v, n] of Object.entries(res.lvals)) layoutValues.set(v, (layoutValues.get(v) || 0) + n); for (const t of res.tall) stretchHits.push({ ...t, route: r }); await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = '0.25'; }); await page.screenshot({ path: join(SHOTS, `spacing-25-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' }); await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = ''; }); } const sorted = (m) => [...m.entries()].sort((a, b) => parseFloat(a[0]) - parseFloat(b[0])); const outside = sorted(layoutValues).filter(([v]) => !FOUR.has(v)); console.log('## 版式距离(卡片 / 区块 / 页面头上的 padding、margin、gap —— 7 个路由合计)\n'); table(sorted(layoutValues).map(([v, n]) => ({ v, n: String(n), ok: FOUR.has(v) ? '✓ 四档内' : '✗ 四档外' })), [['值', 'v'], ['出现次数', 'n'], ['判定', 'ok']]); console.log(`\n版式距离值种类 = ${layoutValues.size}(报警线 > 6);四档外的 ${outside.length} 种` + (outside.length ? ':' + outside.map(([v, n]) => `${v}×${n}`).join('、') : '')); console.log(`\n(参考)全页所有元素声明过的间距值共 ${values.size} 种:${sorted(values).map(([v, n]) => `${v}×${n}`).join('、')}`); console.log('注:0px / auto / normal 不计;负值(-1px 上浮补偿)不计;卡内圆点、图标缝(4/6/8/10)、字距(1.32em)属于组件内部,见上面「参考」一行。'); console.log('\n## 撑高留白(卡面里内容之外的空白 > 24px,已扣 16px 常规内边距)\n'); if (!stretchHits.length) console.log(' 未发现:卡面高度 = 内容高度(卡片在栅格里 align-items: start,不再被行高拉齐)。'); else table(stretchHits.map((x) => ({ route: x.route, el: x.el, frame: `${x.frame}px`, bottom: `${x.extraBottom}px`, top: `${x.extraTop}px` })), [['路由', 'route'], ['元素', 'el'], ['卡面高', 'frame'], ['底部空隙', 'bottom'], ['顶部空隙', 'top']]); const fails = []; if (layoutValues.size > 6) fails.push(`版式距离值种类 ${layoutValues.size} 种 > 6(报警线)`); if (outside.length) fails.push(`四档外还有 ${outside.length} 种版式距离`); if (stretchHits.length) fails.push(`${stretchHits.length} 张卡面撑高留白 > 24px`); await browser.close(); srv?.kill(); if (fails.length) { console.log('\n失败项:'); for (const f of fails) console.log(` ✗ ${f}`); process.exit(1); } console.log('\n全部达标(exit 0)');