audit-spacing.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/audit-spacing.mjs —— v11b 四、P1-4.1:间距分级 + 4.2「撑高留白」实测。
  4. *
  5. * 4.1 间距只许四档:卡内 padding 16 / 卡内 gap 12 / 卡片之间 24 / 区块之间 48(另有 4pt 栅格里的 4 / 8 做行内小缝)。
  6. * 统计 7 个路由上**可见**元素的 margin / padding / gap 的构成值种类;种类 > 6 报警、四档外的值逐个列出。
  7. * 4.2 撑高留白:对每个「卡片类」元素(.kf-card / kf-glass-card / .gcard / tr / 列表行)量
  8. * 内容高度 vs 盒子高度,差额 > 24px 的列为「stretch 撑高」,并打印元素与所在路由。
  9. * 另外 25% 缩放整页截图(docs/screenshots/v11b/spacing-*.png),人工看「块」与「均匀呼吸」。
  10. *
  11. * 用法:node scripts/audit-spacing.mjs [--url …] [--shots …]
  12. */
  13. import { mkdirSync } from 'node:fs';
  14. import { join } from 'node:path';
  15. import { startServer, launch, evalIn, openView, table, ROOT } from './v11b-lib.mjs';
  16. const argv = process.argv.slice(2);
  17. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  18. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b'));
  19. mkdirSync(SHOTS, { recursive: true });
  20. const srv = arg('--url', '') ? null : await startServer(4199);
  21. const base = arg('--url', '') || srv.base;
  22. const browser = await launch();
  23. const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
  24. const cdp = await page.context().newCDPSession(page);
  25. await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
  26. await page.emulateMedia({ reducedMotion: 'reduce' });
  27. const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger'];
  28. /* 四档 + 4pt 栅格里的 4 / 8。其余的「布局距离」值 = 行内小缝、圆点 6px、图标间距 10px、字距补偿(1.32em)等,
  29. 属于「组件内部」而不是「版式留白」;它们单独列出来、不计入报警。报警口径:版式距离(卡内 / 卡间 / 区块间)的种数 > 6。 */
  30. const FOUR = new Set(['4px', '8px', '12px', '16px', '24px', '48px']);
  31. /* 版式容器的判定:卡(kf-glass-card / .kf-card / .gcard)、区块(.p-sec / .hd / .tool / .overview / .grid* / .cards / .latest)、页面头(.p-head) */
  32. 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/;
  33. const LAYOUT_JS = LAYOUT_SRC.source;
  34. const SPA_RE = String.raw`
  35. const vals = {}; const lvals = {}; const tall = []; const gapObs = [];
  36. const LAYOUT = new RegExp(__LAYOUT__);
  37. const isLayout = (el) => LAYOUT.test(String(el.className || '') + ' ' + el.localName);
  38. const bump = (k, v, isL) => { if (!v || v === '0px') return; vals[v] = (vals[v] || 0) + 1; if (isL) lvals[v] = (lvals[v] || 0) + 1; };
  39. const sides = { paddingTop: 'padding-top', paddingRight: 'padding-right', paddingBottom: 'padding-bottom', paddingLeft: 'padding-left',
  40. marginTop: 'margin-top', marginRight: 'margin-right', marginBottom: 'margin-bottom', marginLeft: 'margin-left' };
  41. deepAll((el) => {
  42. if (el.nodeType !== 1 || !inMain(el) || !visible(el)) return;
  43. 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;
  44. const cs = getComputedStyle(el);
  45. const isL = isLayout(el);
  46. for (const [camel, prop] of Object.entries(sides)) {
  47. if (cs[camel] === '0px' || !/\d/.test(cs[camel])) continue;
  48. /* 用 computed 值(已经把 em / 百分比 / 继承都算过);作者写的 prop 名是驼峰,getPropertyValue 拿不到 */
  49. const raw = cs.getPropertyValue(prop);
  50. if (!raw || raw === 'auto' || raw === 'normal' || raw.startsWith('-')) continue;
  51. let v = raw;
  52. if (raw.endsWith('em')) v = Math.round(parseFloat(raw) * parseFloat(cs.fontSize) * 100) / 100 + 'px';
  53. if (!/^\d+(\.\d+)?px$/.test(v)) continue;
  54. if (!/^\d+(\.\d+)?px$/.test(v) || v === '0px') continue;
  55. bump(prop.startsWith('padding') ? 'padding' : 'margin', v, isL);
  56. }
  57. for (const prop of ['row-gap', 'column-gap']) {
  58. if (!/^\d+(\.\d+)?px$/.test(cs.getPropertyValue(prop) || '')) continue;
  59. const raw = cs.getPropertyValue(prop);
  60. if (!raw || raw === '0px') continue;
  61. bump('gap', raw, isL);
  62. if (el.querySelector(':scope > kf-glass-card') || el.localName === 'kf-glass-card') gapObs.push(elLabel(el) + ' gap=' + raw);
  63. }
  64. });
  65. /* 撑高留白:卡面(.frame)里,内容上沿到卡面顶、内容下沿(含卡片自带的分隔线 / 表头)到卡面底 —— 两边扣掉 16px 内边距,
  66. 任一方向 > 24px 判「撑高」。把手动 attachShadow 的子组件、卡片自带的 ::before / ::after 装饰都算进内容框
  67. (判据 = 卡面范围里所有渲染过的后代盒 + 卡片自己的 伪元素 盒)。 */
  68. const PSEUDO_PROBE = (el) => {
  69. /* 伪元素没有 DOM 盒可量:按 computed 的 top/inset/height 估,不作为内容,只是排除「卡面比内容高是因为铺满整卡的底」 */
  70. return null;
  71. };
  72. deepAll((el) => {
  73. if (el.localName !== 'kf-glass-card' || !inMain(el) || !visible(el)) return;
  74. const frame = el.shadowRoot?.querySelector('.frame');
  75. const slot = el.shadowRoot?.querySelector('slot');
  76. if (!frame || !slot) return;
  77. const face = frame.querySelector('.face');
  78. const pt = parseFloat(getComputedStyle(face).paddingTop) || 0;
  79. const pb = parseFloat(getComputedStyle(face).paddingBottom) || 0;
  80. const fr = frame.getBoundingClientRect();
  81. /* 内容 = 卡面里所有「有文本或背景」的叶子盒,外加卡片自带的表头 / 分隔线 */
  82. let top = Infinity, bottom = -Infinity, n = 0;
  83. 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); } };
  84. visit(face);
  85. if (!n) return;
  86. const extraTop = top - fr.top - pt;
  87. const extraBottom = fr.bottom - bottom - pb;
  88. if (extraBottom > 24 || extraTop > 24) tall.push({ el: elLabel(el), frame: Math.round(fr.height), extraBottom: Math.round(extraBottom), extraTop: Math.round(extraTop) });
  89. });
  90. return { vals, lvals, tall, gapObs };
  91. `; // 正则体(字符串),拼进页面脚本时再用 new RegExp 包起来
  92. const values = new Map();
  93. const layoutValues = new Map();
  94. const stretchHits = [];
  95. for (const r of ROUTES) {
  96. await openView(page, base, r, { me: '赵磊', wait: 700 });
  97. /* LAYOUT_JS 本身是正则文字(形如 kf-card|...),并把行的反斜杠保住:JSON 序列化结果里 '\\\\b' 到页面会变成 '\\b' 被当成退格符 */
  98. const res = await evalIn(page, SPA_RE.replace('__LAYOUT__', '/(?:' + LAYOUT_JS.replace(/\\/g, '\\\\') + ')/'));
  99. for (const [v, n] of Object.entries(res.vals)) values.set(v, (values.get(v) || 0) + n);
  100. for (const [v, n] of Object.entries(res.lvals)) layoutValues.set(v, (layoutValues.get(v) || 0) + n);
  101. for (const t of res.tall) stretchHits.push({ ...t, route: r });
  102. await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = '0.25'; });
  103. await page.screenshot({ path: join(SHOTS, `spacing-25-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' });
  104. await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = ''; });
  105. }
  106. const sorted = (m) => [...m.entries()].sort((a, b) => parseFloat(a[0]) - parseFloat(b[0]));
  107. const outside = sorted(layoutValues).filter(([v]) => !FOUR.has(v));
  108. console.log('## 版式距离(卡片 / 区块 / 页面头上的 padding、margin、gap —— 7 个路由合计)\n');
  109. table(sorted(layoutValues).map(([v, n]) => ({ v, n: String(n), ok: FOUR.has(v) ? '✓ 四档内' : '✗ 四档外' })), [['值', 'v'], ['出现次数', 'n'], ['判定', 'ok']]);
  110. console.log(`\n版式距离值种类 = ${layoutValues.size}(报警线 > 6);四档外的 ${outside.length} 种` + (outside.length ? ':' + outside.map(([v, n]) => `${v}×${n}`).join('、') : ''));
  111. console.log(`\n(参考)全页所有元素声明过的间距值共 ${values.size} 种:${sorted(values).map(([v, n]) => `${v}×${n}`).join('、')}`);
  112. console.log('注:0px / auto / normal 不计;负值(-1px 上浮补偿)不计;卡内圆点、图标缝(4/6/8/10)、字距(1.32em)属于组件内部,见上面「参考」一行。');
  113. console.log('\n## 撑高留白(卡面里内容之外的空白 > 24px,已扣 16px 常规内边距)\n');
  114. if (!stretchHits.length) console.log(' 未发现:卡面高度 = 内容高度(卡片在栅格里 align-items: start,不再被行高拉齐)。');
  115. else table(stretchHits.map((x) => ({ route: x.route, el: x.el, frame: `${x.frame}px`, bottom: `${x.extraBottom}px`, top: `${x.extraTop}px` })),
  116. [['路由', 'route'], ['元素', 'el'], ['卡面高', 'frame'], ['底部空隙', 'bottom'], ['顶部空隙', 'top']]);
  117. const fails = [];
  118. if (layoutValues.size > 6) fails.push(`版式距离值种类 ${layoutValues.size} 种 > 6(报警线)`);
  119. if (outside.length) fails.push(`四档外还有 ${outside.length} 种版式距离`);
  120. if (stretchHits.length) fails.push(`${stretchHits.length} 张卡面撑高留白 > 24px`);
  121. await browser.close();
  122. srv?.kill();
  123. if (fails.length) {
  124. console.log('\n失败项:');
  125. for (const f of fails) console.log(` ✗ ${f}`);
  126. process.exit(1);
  127. }
  128. console.log('\n全部达标(exit 0)');