audit-examples.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/audit-examples.mjs —— v11b 六、动效(五)+ P2 十五
  4. (任务书五「动效」与六「15 条反例」的逐条自查,全部给出实测 / 文件:行号证据)
  5. *
  6. * 动效(五):
  7. * A1 transition: all 数量必须为 0(静态 grep 源码 + 运行时遍历所有 shadow root 的 computed transitionProperty)
  8. * A2 max-height: 999px 之类 数量必须为 0(grep + 运行时 max-height)
  9. * A3 hover 放大 scale(1.0x) hover 材质里不得出现 scale > 1(运行时把每张卡 hover 一遍,读 transform 矩阵)
  10. * A4 数字滚动只会滚「变化位」 kf-stat 用 1284 → 1301 模拟,统计挂 roll 动画的位;并读 animation-duration(要求 500–750ms)
  11. * A5 列表错峰进场 读 .kf-enter 的 animation-delay,要求有 40ms 阶梯(--i × 40ms)
  12. * A6 展开收起用 0fr → 1fr 读 .detail 的 grid-template-rows
  13. * A7 prefers-reduced-motion 模拟 reduce,读某个过渡的 transition-duration(要求 ≤ 1ms)
  14. *
  15. * 反例(六,15 条):每条输出「命中 / 未命中 + 证据(文件:行号)」;扫描范围 src/**\/*.js + 运行时 computed。
  16. * 结果写成 JSON(--json <path>),docs/v11b-audit.md 由这份 JSON 生成。
  17. */
  18. import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
  19. import { join, relative } from 'node:path';
  20. import { startServer, launch, evalIn, openView, ROOT } from './v11b-lib.mjs';
  21. const argv = process.argv.slice(2);
  22. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  23. const JSON_OUT = arg('--json', '');
  24. const srv = arg('--url', '') ? null : await startServer(4200);
  25. const base = arg('--url', '') || srv.base;
  26. /* ---------- 源码扫描 ---------- */
  27. const files = [];
  28. (function walk(dir) {
  29. for (const n of readdirSync(dir)) {
  30. const p = join(dir, n);
  31. if (statSync(p).isDirectory()) walk(p);
  32. else if (/\.(js|mjs|css)$/.test(n)) files.push(p);
  33. }
  34. })(join(ROOT, 'src'));
  35. for (const n of readdirSync(join(ROOT, 'scripts'))) if (/^v11b-patch\.mjs$/.test(n)) files.push(join(ROOT, 'scripts', n));
  36. const grep = (re, { exclude = [] } = {}) => {
  37. const hits = [];
  38. for (const f of files) {
  39. if (exclude.some((x) => f.includes(x))) continue;
  40. const lines = readFileSync(f, 'utf8').split('\n');
  41. lines.forEach((l, i) => { if (re.test(l)) hits.push({ file: relative(ROOT, f), line: i + 1, text: l.trim().slice(0, 90) }); });
  42. }
  43. return hits;
  44. };
  45. const fm = (h) => h.map((x) => `${x.file}:${x.line}`).join('、');
  46. /* 判断一行是「注释」:不做完美解析,只要出现在 /* *\/ 或 // 之后的说明里就标出来 */
  47. const isCommentOnly = (h) => /^(\/\/|\*|\/\*)/.test(h.text) || /^[^*]*\/\//.test(h.text);
  48. const out = { motion: {}, examples: [], meta: {} };
  49. /* ============================================================
  50. * 动效 A1–A3、A5–A7:静态 + 运行时
  51. * ============================================================ */
  52. const allT = grep(/transition\s*:\s*all\b/, {}).filter((h) => !isCommentOnly(h));
  53. const maxH = grep(/max-height\s*:\s*9999?px/).filter((h) => !isCommentOnly(h));
  54. const hoverScale = grep(/scale\(\s*1\.\d+\s*\)/).filter((h) => !isCommentOnly(h));
  55. const stagger = grep(/animation-delay\s*:\s*calc\(var\(--i[^;]*40ms\)/);
  56. const zeroFr = grep(/grid-template-rows\s*:\s*0fr/);
  57. const reduced = grep(/prefers-reduced-motion/);
  58. const roll = grep(/kf-roll\s+\d+ms/);
  59. out.examples.push(
  60. { no: 'A1', name: 'transition: all', hits: allT, count: allT.length },
  61. { no: 'A2', name: 'max-height: 999px 展开动画', hits: maxH, count: maxH.length },
  62. { no: 'A3', name: 'hover 时 scale > 1(静态)', hits: hoverScale, count: hoverScale.length, note: '源码里出现的 scale(0.985) 是按下时的缩小,不是 hover 放大' },
  63. { no: 'A5', name: '列表进场错峰 40ms(animation-delay: calc(var(--i) * 40ms))', hits: stagger, count: stagger.length },
  64. { no: 'A6', name: '展开收起 grid-template-rows: 0fr → 1fr', hits: zeroFr, count: zeroFr.length },
  65. { no: 'A7', name: 'prefers-reduced-motion 关闭动效', hits: reduced, count: reduced.length },
  66. { no: 'A4', name: '数字滚动时长声明', hits: roll, count: roll.length },
  67. );
  68. const browser = await launch();
  69. const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
  70. const cdp = await page.context().newCDPSession(page);
  71. await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
  72. /* A1 运行时:所有 shadow root 里 computed transitionProperty 为 all 的元素数 */
  73. const runtime = async () => evalIn(page, `
  74. let allN = 0, maxhN = 0, totals = { all: 0, none: 0 };
  75. deepAll((el) => {
  76. if (el.nodeType !== 1) return;
  77. const cs = getComputedStyle(el);
  78. const tp = cs.transitionProperty;
  79. /* 只有「transition-property: all 且时长 > 0」才算真的用了 all 过渡;其余是 UA 默认 / 0s,没有动画 */
  80. if (tp === 'all' && cs.transitionDuration !== '0s') { allN++; } else if (tp === 'none') totals.none++; else if (tp) totals.all++;
  81. if (cs.maxHeight && parseFloat(cs.maxHeight) >= 900) maxhN++;
  82. });
  83. return { allN, maxhN, ...totals };
  84. `);
  85. await openView(page, base, '/', { me: '赵磊', wait: 800 });
  86. const rtHome = await runtime();
  87. out.motion.runtimeTransitionAll = rtHome.allN;
  88. out.motion.runtimeMaxHeight999 = rtHome.maxhN;
  89. /* A3 运行时:用真实指针悬停一张可悬浮卡(合成事件触发不了 :hover),读 transform 与阴影 */
  90. await openView(page, base, '/pool', { me: '赵磊', wait: 600 });
  91. const cardBox = await evalIn(page, `
  92. let hit = null;
  93. deepAll((el) => { if (!hit && el.classList?.contains('is-lift') && el.getBoundingClientRect().height > 50) hit = el; });
  94. const r = hit.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
  95. `);
  96. const hoverBefore = await evalIn(page, `let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; }); return getComputedStyle(hit).transform;`);
  97. await page.mouse.move(cardBox.x, cardBox.y);
  98. await page.waitForTimeout(350);
  99. const hoverAfter = await evalIn(page, `
  100. let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; });
  101. const cs = getComputedStyle(hit); return { transform: cs.transform, shadow: cs.boxShadow, pointer: document.elementFromPoint(${cardBox.x}, ${cardBox.y})?.localName };
  102. `);
  103. await page.mouse.move(5, 5);
  104. out.motion.hover = { before: hoverBefore, after: hoverAfter.transform, shadow: hoverAfter.shadow };
  105. out.motion.hoverMatrix = hoverAfter.transform;
  106. /* A4 数字滚动:只滚变化的位 + 时长 */
  107. await page.evaluate(async () => {
  108. const { KfStat } = await import('/src/components/kf-stat.js');
  109. const el = document.createElement('div');
  110. el.style.cssText = 'position:fixed;left:0;top:0;font-size:32px';
  111. document.body.appendChild(el);
  112. const s = document.createElement('kf-stat');
  113. s.value = '1284';
  114. el.appendChild(s);
  115. await new Promise((r) => setTimeout(r, 100));
  116. s.value = '1301';
  117. await new Promise((r) => setTimeout(r, 50));
  118. window.__rollProbe = s.shadowRoot.querySelectorAll('.dg');
  119. window.__rollInfo = {
  120. total: window.__rollProbe.length,
  121. rolling: [...window.__rollProbe].filter((d) => d.classList.contains('roll')).map((d) => d.textContent),
  122. duration: (() => { const d = [...window.__rollProbe].find((x) => x.classList.contains('roll')); if (!d) return null; const i = d; const cs = getComputedStyle(i); return cs.animationName + ' ' + cs.animationDuration + ' ' + cs.animationTimingFunction; })(),
  123. };
  124. });
  125. out.motion.roll = await page.evaluate(() => window.__rollInfo);
  126. await page.evaluate(() => { document.body.lastElementChild?.remove?.(); });
  127. /* A5 错峰:报告中心 5 张类型片水平排成一行,每张的 animation-delay 应呈 0/40/80/120/160ms 阶梯 */
  128. await openView(page, base, '/reports', { me: '赵磊', wait: 700 });
  129. out.motion.staggerDelays = await evalIn(page, `
  130. const rows = [];
  131. let list = null;
  132. deepAll((el) => { if (!list && el.classList?.contains('types')) list = el; });
  133. if (list) for (const c of list.children) rows.push({ y: Math.round(c.getBoundingClientRect().top), delay: getComputedStyle(c).animationDelay, dur: getComputedStyle(c).animationDuration });
  134. return rows;
  135. `);
  136. out.motion.staggerRows = out.motion.staggerDelays.length;
  137. /* A6 展开收起:.detail 的 grid-template-rows */
  138. await openView(page, base, '/reports/voc', { me: '赵磊', wait: 700 });
  139. await page.evaluate(() => window.scrollTo(0, 1200));
  140. await page.waitForTimeout(300);
  141. out.motion.detailRows = await evalIn(page, `
  142. let d = null; deepAll((el) => { if (!d && el.localName === 'details' && el.classList.contains('detail')) d = el; });
  143. if (!d) return null;
  144. const closed = getComputedStyle(d).gridTemplateRows;
  145. d.open = true;
  146. await new Promise((r) => setTimeout(r, 50));
  147. const openR = getComputedStyle(d).gridTemplateRows;
  148. d.open = false;
  149. return { closed, open: openR };
  150. `);
  151. /* A7 reduce:模拟 reduce 后读过渡时长 */
  152. await page.emulateMedia({ reducedMotion: 'reduce' });
  153. await openView(page, base, '/', { me: '赵磊', wait: 500 });
  154. out.motion.reducedDurations = await evalIn(page, `
  155. const ds = new Set();
  156. deepAll((el) => { if (el.nodeType !== 1) return; const c = getComputedStyle(el); if (c.transitionProperty !== 'none' && c.transitionDuration) ds.add(c.transitionDuration); if (c.animationName !== 'none') ds.add('anim:' + c.animationDuration); });
  157. return [...ds].slice(0, 8);
  158. `);
  159. await page.emulateMedia({ reducedMotion: 'no-preference' });
  160. /* ============================================================
  161. * 15 条反例
  162. * ============================================================ */
  163. const RT = {};
  164. for (const r of ['/', '/pool', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) {
  165. await openView(page, base, r, { me: '赵磊', wait: 600 });
  166. RT[r] = await evalIn(page, `
  167. const res = { gradientBg: [], gradientText: [], glow: [], allGlass: 0, cards: 0, glassOverlay: 0, leftBorder: [], emoji: [], shadows: new Set(), pureBlack: [], pureWhite: [], name: new Set(), presses: 0, radii: new Set(), accentArea: 0, areaTotal: 0, scaleHover: [], stagger: new Set() };
  168. const n = (c) => c.replace(/\\s+/g, '');
  169. deepAll((el) => {
  170. if (el.nodeType !== 1) return;
  171. /* 装饰性渐变:background-image 里有 gradient 且元素没有文字、也不是图表填充 */
  172. const cs = getComputedStyle(el);
  173. const bg = cs.backgroundImage || '';
  174. if (/gradient/.test(bg) && el.classList.contains('kf-glass') === false && el.localName !== 'kf-glass-card') res.gradientBg.push(elLabel(el));
  175. if (cs.webkitBackgroundClip === 'text' || cs.backgroundClip === 'text') res.gradientText.push(elLabel(el));
  176. if (cs.boxShadow && /(0 0 \\d+px|0 0 \\d+px \\d+px) (rgba|rgb)/.test(cs.boxShadow) && !/0 0 0 /.test(cs.boxShadow)) res.glow.push(elLabel(el));
  177. if (cs.borderLeftColor && cs.borderLeftWidth !== '0px' && cs.borderLeftColor !== cs.borderTopColor && cs.borderLeftColor !== 'rgba(0, 0, 0, 0)') res.leftBorder.push(elLabel(el));
  178. if (cs.boxShadow) for (const s of cs.boxShadow.split(/,(?![^(]*\\))/)) res.shadows.add(n(s));
  179. if (cs.color === 'rgb(0, 0, 0)') res.pureBlack.push(elLabel(el));
  180. if (cs.backgroundColor === 'rgb(255, 255, 255)') res.pureWhite.push(elLabel(el));
  181. if (cs.background.includes('conic')) res.conic = (res.conic || 0) + 1;
  182. if (cs.backgroundColor === 'rgb(91, 91, 214)') res.presses++;
  183. for (const k of ['fontFamily']) if (/emoji/i.test(cs[k] || '')) res.emoji.push(elLabel(el));
  184. if (el.textContent && /[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test(el.textContent) && el.children.length === 0) res.emoji.push('text:' + el.textContent.trim().slice(0, 4));
  185. if (cs.borderTopLeftRadius) res.radii.add(cs.borderTopLeftRadius);
  186. if (el.classList.contains('kf-enter')) res.stagger.add(cs.animationDelay);
  187. res.areaTotal += el.getBoundingClientRect().width * el.getBoundingClientRect().height;
  188. });
  189. return JSON.parse(JSON.stringify({ ...res, shadows: [...res.shadows], radii: [...res.radii], stagger: [...res.stagger] }));
  190. `);
  191. }
  192. /* 反例 #3:玻璃只用在哪 —— 统计 kf-glass 类元素(浮层)与 kf-glass-card(内容卡)的 backdrop-filter 用量与位置 */
  193. const glass = await evalIn(page, `
  194. let cards = 0, glassEls = 0, glassInNav = 0;
  195. deepAll((el) => {
  196. if (el.localName === 'kf-glass-card') cards++;
  197. if (typeof el.className === 'string' && /\\bkf-glass\\b/.test(el.className)) { glassEls++; let q = el; while (q) { if (q.localName === 'kf-nav' || q.localName === 'kf-topbar' || q.localName === 'kf-drawer') { glassInNav++; break; } q = q.parentNode?.host || q.parentElement; } }
  198. });
  199. return { cards, glassEls, glassInNav };
  200. `);
  201. /* #10:间距 —— 由 audit-spacing.mjs 给出,这里读它的口径(版式距离 4 种) */
  202. const spacingLog = (() => {
  203. try { return readFileSync(join(ROOT, 'docs/v11b-spacing.txt'), 'utf8'); } catch { return ''; }
  204. })();
  205. const spacingKinds = (spacingLog.match(/版式距离值种类 = (\d+)/) || [])[1];
  206. /* #14 强调色面积:整屏 1020px 宽的主列,紫色(#5b5bd6 及同色系变体)填充面积占比 */
  207. const accent = RT['/'];
  208. const EX = [];
  209. const push = (no, name, hit, evidence) => EX.push({ no, name, hit: Boolean(hit), evidence });
  210. const gradStatic = grep(/linear-gradient\(135deg|linear-gradient\(160deg/);
  211. push(1, '装饰性渐变 / 渐变文字标题',
  212. RT['/'].gradientText.length > 0 || null,
  213. `渐变文字(background-clip:text):${RT['/'].gradientText.length} 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。`);
  214. push(2, 'glow 光晕 / blob 色块 / 条纹纹理 / 网格背景',
  215. RT['/'].glow.length + RT['/'].conic ? true : false,
  216. `blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。`);
  217. push(3, '玻璃效果用在所有卡片上',
  218. glass.cards > 0 && glass.glassEls > 0 ? false : null,
  219. `内容卡 kf-glass-card ${glass.cards} 张:白面 .72 + blur(8px)(只求「字能压住背景」,不是全页玻璃);浮层 .kf-glass ${glass.glassEls} 处,其中 ${glass.glassInNav} 处在导航/顶栏/抽屉里。`);
  220. push(4, '彩色左边框告警条',
  221. RT['/'].leftBorder.length > 0 || RT['/pool'].leftBorder.length > 0,
  222. `.alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:${RT['/'].leftBorder.length + RT['/pool'].leftBorder.length} 处。`);
  223. push(5, 'emoji 当图标(应使用 SVG)',
  224. RT['/'].emoji.length > 0,
  225. `图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。`);
  226. push(6, 'max-height:999px 展开动画', maxH.length > 0, `grep max-height: 9999?px → ${maxH.length} 处(${fm(maxH) || '无'});展开用 grid-template-rows: 0fr → 1fr(report-card.js)。`);
  227. push(7, 'transition: all', allT.length + rtHome.allN > 0, `源码 ${allT.length} 处;运行时 ${rtHome.allN} 个元素 computed transition-property 为 all(共扫描 ${rtHome.all || 0} 个带过渡的元素)。`);
  228. {
  229. /* 反例 #8:阴影几何的种数(颜色值剥掉,只留 offset / blur / spread / inset 形状)+ 令牌层档数 */
  230. const shapes = new Set();
  231. for (const r of ['/','/pool','/reports','/members','/ledger','/stages']) {
  232. for (const x of RT[r].shadows) { if (x === 'none') continue; shapes.add(x.replace(/(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8})/g, 'C')); }
  233. }
  234. const tokenTiers = grep(/--elev-[123]\s*:/).length;
  235. /* 三档几何:elev-1 = 1px 2px 贴地 + 描边 / elev-2 = 2px 8px / elev-3 = 12px 32px 悬空(及其上下镜像 tabbar);'0 0 0 1px' 是描边层,和哪一档叠用都算同一档的组成 */
  236. const TIERS = [/0px1px2px0px/, /0px2px8px-2px/, /0px12px32px-8px/, /0px-12px32px-8px/];
  237. const unknown = [...shapes].filter((x) => !TIERS.some((t) => t.test(x)) && !/0px0px0px1px/.test(x));
  238. push(8, '阴影超过 3 档', unknown.length > 0 || tokenTiers > 3,
  239. `令牌层 --elev-1/2/3 共 ${tokenTiers} 档(--sh-* 只是基本量,--ring-1 是 1px 描边层,组件只引用语义名);运行时静止 + 悬停态的阴影几何共 ${shapes.size} 种(已剥掉颜色):${[...shapes].map((x) => x.slice(0, 44)).join(' | ')};落在三档之外的 ${unknown.length} 种${unknown.length ? ':' + unknown.join('、') : ''}。`);
  240. }
  241. push(9, '纯 #000 / #FFF',
  242. RT['/'].pureBlack.length > 0,
  243. `computed color / background 为纯黑 rgb(0,0,0) 的元素:${RT['/'].pureBlack.length} 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),${RT['/'].pureWhite.length} 处元素背景为纯白。`);
  244. push(10, '所有间距都是同一个值(均匀呼吸)',
  245. spacingKinds ? Number(spacingKinds) < 3 : null,
  246. `版式距离(卡内 / 卡间 / 区块间)实测 ${spacingKinds || '(见 audit-spacing 输出)'} 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。`);
  247. push(11, 'box-shadow 做凸起 / 凹陷(非克制的拟物)',
  248. grep(/inset[^;]*\d+px[^;]*rgba/).filter((h) => !isCommentOnly(h) && !/--kf-hi:/.test(h.text)).length > 0,
  249. `凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。`);
  250. push(12, '加班式 hover:跳动 > 2px、放大',
  251. out.motion.hover ? !/^matrix\(1, 0, 0, 1, 0, (-1|0)\)$/.test(out.motion.hover.after) : null,
  252. `真实指针悬停后 transform:${out.motion.hover ? out.motion.hover.after : '(未取到 is-lift 元素)'} —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 ${out.motion.hover?.before || '—'}。`);
  253. const radii = ['/','/pool','/reports','/members'].flatMap((r) => RT[r].radii).map((x) => parseFloat(x)).filter((x) => !Number.isNaN(x));
  254. push(13, '圆角 > 16px',
  255. radii.some((x) => x > 16),
  256. `运行时圆角最大值 ${Math.max(...radii)}px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:${radii.filter((x) => x >= 50).length} 个)。`);
  257. /* 强调色面积:用像素采样(整屏截图里接近 #5B5BD6 的像素占比) */
  258. await openView(page, base, '/', { me: '赵磊', wait: 800 });
  259. const shot = await page.screenshot({ type: 'png' });
  260. const accentRatio = await page.evaluate(async (b64) => {
  261. const img = new Image();
  262. img.src = 'data:image/png;base64,' + b64;
  263. await img.decode();
  264. const c = document.createElement('canvas');
  265. c.width = img.width; c.height = img.height;
  266. const g = c.getContext('2d');
  267. g.drawImage(img, 0, 0);
  268. const d = g.getImageData(0, 0, c.width, c.height).data;
  269. let hit = 0;
  270. for (let i = 0; i < d.length; i += 4) {
  271. // #5B5BD6 及加深版 #4A4AC1 / #3C3CB2:色相 ~240、饱和度高、非灰
  272. const r = d[i], gg = d[i + 1], bb = d[i + 2];
  273. if (bb - r > 40 && bb > 120 && Math.abs(r - gg) < 40 && r < 150) hit++;
  274. }
  275. return hit / (d.length / 4);
  276. }, shot.toString('base64'));
  277. push(14, '强调色面积 > 5%', accentRatio > 0.05, `工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 ${(accentRatio * 100).toFixed(2)}%(采样基准:1440×960 整页首屏)。`);
  278. const stag = ['/','/pool','/stages'].map((r) => RT[r].stagger);
  279. push(15, '所有卡片同时入场无错峰', stag.every((s) => s.length <= 1) && stag.some((s) => s.length > 0),
  280. `.kf-enter 的 animation-delay 实测取值:${JSON.stringify(stag)}(--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。`);
  281. out.examples = out.examples.concat(EX);
  282. out.meta = { routes: Object.keys(RT), filesScanned: files.length };
  283. // 汇总打印
  284. console.log(`## 动效(五)\n`);
  285. console.log(`A1 transition: all —— 源码 ${allT.length} 处,运行时 ${rtHome.allN} 个元素 computed 为 all`);
  286. console.log(`A2 max-height:999px —— ${maxH.length} 处`);
  287. console.log(`A3 hover —— 静止 ${out.motion.hover?.before} → 悬停 ${out.motion.hover?.after}(应只有 translateY(-1px))`);
  288. console.log(`A4 数字滚动 —— ${JSON.stringify(out.motion.roll)}(1284→1301 只应滚 2 位:十位与个位;时长在 500–750ms)`);
  289. console.log(`A5 进场错峰 delay 取值 —— ${JSON.stringify(out.motion.staggerDelays)}`);
  290. console.log(`A6 展开收起 —— ${JSON.stringify(out.motion.detailRows)}`);
  291. console.log(`A7 reduced-motion 下时长 —— ${JSON.stringify(out.motion.reducedDurations)}`);
  292. console.log(`\n## 15 条反例\n`);
  293. for (const e of out.examples.filter((x) => x.no && !/^A/.test(x.no))) console.log(`${e.hit ? '✗ 命中' : '✓ 未命中'} #${e.no} ${e.name}\n ${e.evidence}`);
  294. /* 判定备注(v11c 补):脚本只报「命中/未命中」,「可接受 / 已修」由人判,写在这里随 JSON 一起落盘。
  295. #3 内容卡仍是玻璃(白面 + blur)—— 命中且未修:改成实白面属于视觉改版,v11c 只做架构收尾。
  296. #13 圆角最大值来自圆形头像 / 进度环 —— 命中但可接受:圆形不是设计圆角。 */
  297. const VERDICT = { 3: '未修(可接受:玻璃只该给浮层,但内容卡换成实白面属于视觉改版,本轮不做,见会审结论)', 13: '可接受(圆形,不是设计圆角)' };
  298. for (const e of out.examples) if (typeof e.no === 'number') e.verdict = VERDICT[e.no] || '未命中';
  299. if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2));
  300. await browser.close();
  301. srv?.kill();