#!/usr/bin/env node /** * scripts/audit-examples.mjs —— v11b 六、动效(五)+ P2 十五 (任务书五「动效」与六「15 条反例」的逐条自查,全部给出实测 / 文件:行号证据) * * 动效(五): * A1 transition: all 数量必须为 0(静态 grep 源码 + 运行时遍历所有 shadow root 的 computed transitionProperty) * A2 max-height: 999px 之类 数量必须为 0(grep + 运行时 max-height) * A3 hover 放大 scale(1.0x) hover 材质里不得出现 scale > 1(运行时把每张卡 hover 一遍,读 transform 矩阵) * A4 数字滚动只会滚「变化位」 kf-stat 用 1284 → 1301 模拟,统计挂 roll 动画的位;并读 animation-duration(要求 500–750ms) * A5 列表错峰进场 读 .kf-enter 的 animation-delay,要求有 40ms 阶梯(--i × 40ms) * A6 展开收起用 0fr → 1fr 读 .detail 的 grid-template-rows * A7 prefers-reduced-motion 模拟 reduce,读某个过渡的 transition-duration(要求 ≤ 1ms) * * 反例(六,15 条):每条输出「命中 / 未命中 + 证据(文件:行号)」;扫描范围 src/**\/*.js + 运行时 computed。 * 结果写成 JSON(--json ),docs/v11b-audit.md 由这份 JSON 生成。 */ import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { startServer, launch, evalIn, openView, 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 JSON_OUT = arg('--json', ''); const srv = arg('--url', '') ? null : await startServer(4200); const base = arg('--url', '') || srv.base; /* ---------- 源码扫描 ---------- */ const files = []; (function walk(dir) { for (const n of readdirSync(dir)) { const p = join(dir, n); if (statSync(p).isDirectory()) walk(p); else if (/\.(js|mjs|css)$/.test(n)) files.push(p); } })(join(ROOT, 'src')); for (const n of readdirSync(join(ROOT, 'scripts'))) if (/^v11b-patch\.mjs$/.test(n)) files.push(join(ROOT, 'scripts', n)); const grep = (re, { exclude = [] } = {}) => { const hits = []; for (const f of files) { if (exclude.some((x) => f.includes(x))) continue; const lines = readFileSync(f, 'utf8').split('\n'); lines.forEach((l, i) => { if (re.test(l)) hits.push({ file: relative(ROOT, f), line: i + 1, text: l.trim().slice(0, 90) }); }); } return hits; }; const fm = (h) => h.map((x) => `${x.file}:${x.line}`).join('、'); /* 判断一行是「注释」:不做完美解析,只要出现在 /* *\/ 或 // 之后的说明里就标出来 */ const isCommentOnly = (h) => /^(\/\/|\*|\/\*)/.test(h.text) || /^[^*]*\/\//.test(h.text); const out = { motion: {}, examples: [], meta: {} }; /* ============================================================ * 动效 A1–A3、A5–A7:静态 + 运行时 * ============================================================ */ const allT = grep(/transition\s*:\s*all\b/, {}).filter((h) => !isCommentOnly(h)); const maxH = grep(/max-height\s*:\s*9999?px/).filter((h) => !isCommentOnly(h)); const hoverScale = grep(/scale\(\s*1\.\d+\s*\)/).filter((h) => !isCommentOnly(h)); const stagger = grep(/animation-delay\s*:\s*calc\(var\(--i[^;]*40ms\)/); const zeroFr = grep(/grid-template-rows\s*:\s*0fr/); const reduced = grep(/prefers-reduced-motion/); const roll = grep(/kf-roll\s+\d+ms/); out.examples.push( { no: 'A1', name: 'transition: all', hits: allT, count: allT.length }, { no: 'A2', name: 'max-height: 999px 展开动画', hits: maxH, count: maxH.length }, { no: 'A3', name: 'hover 时 scale > 1(静态)', hits: hoverScale, count: hoverScale.length, note: '源码里出现的 scale(0.985) 是按下时的缩小,不是 hover 放大' }, { no: 'A5', name: '列表进场错峰 40ms(animation-delay: calc(var(--i) * 40ms))', hits: stagger, count: stagger.length }, { no: 'A6', name: '展开收起 grid-template-rows: 0fr → 1fr', hits: zeroFr, count: zeroFr.length }, { no: 'A7', name: 'prefers-reduced-motion 关闭动效', hits: reduced, count: reduced.length }, { no: 'A4', name: '数字滚动时长声明', hits: roll, count: roll.length }, ); const browser = await launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); const cdp = await page.context().newCDPSession(page); await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); /* A1 运行时:所有 shadow root 里 computed transitionProperty 为 all 的元素数 */ const runtime = async () => evalIn(page, ` let allN = 0, maxhN = 0, totals = { all: 0, none: 0 }; deepAll((el) => { if (el.nodeType !== 1) return; const cs = getComputedStyle(el); const tp = cs.transitionProperty; /* 只有「transition-property: all 且时长 > 0」才算真的用了 all 过渡;其余是 UA 默认 / 0s,没有动画 */ if (tp === 'all' && cs.transitionDuration !== '0s') { allN++; } else if (tp === 'none') totals.none++; else if (tp) totals.all++; if (cs.maxHeight && parseFloat(cs.maxHeight) >= 900) maxhN++; }); return { allN, maxhN, ...totals }; `); await openView(page, base, '/', { me: '赵磊', wait: 800 }); const rtHome = await runtime(); out.motion.runtimeTransitionAll = rtHome.allN; out.motion.runtimeMaxHeight999 = rtHome.maxhN; /* A3 运行时:用真实指针悬停一张可悬浮卡(合成事件触发不了 :hover),读 transform 与阴影 */ await openView(page, base, '/pool', { me: '赵磊', wait: 600 }); const cardBox = await evalIn(page, ` let hit = null; deepAll((el) => { if (!hit && el.classList?.contains('is-lift') && el.getBoundingClientRect().height > 50) hit = el; }); const r = hit.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; `); const hoverBefore = await evalIn(page, `let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; }); return getComputedStyle(hit).transform;`); await page.mouse.move(cardBox.x, cardBox.y); await page.waitForTimeout(350); const hoverAfter = await evalIn(page, ` let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; }); const cs = getComputedStyle(hit); return { transform: cs.transform, shadow: cs.boxShadow, pointer: document.elementFromPoint(${cardBox.x}, ${cardBox.y})?.localName }; `); await page.mouse.move(5, 5); out.motion.hover = { before: hoverBefore, after: hoverAfter.transform, shadow: hoverAfter.shadow }; out.motion.hoverMatrix = hoverAfter.transform; /* A4 数字滚动:只滚变化的位 + 时长 */ await page.evaluate(async () => { const { KfStat } = await import('/src/components/kf-stat.js'); const el = document.createElement('div'); el.style.cssText = 'position:fixed;left:0;top:0;font-size:32px'; document.body.appendChild(el); const s = document.createElement('kf-stat'); s.value = '1284'; el.appendChild(s); await new Promise((r) => setTimeout(r, 100)); s.value = '1301'; await new Promise((r) => setTimeout(r, 50)); window.__rollProbe = s.shadowRoot.querySelectorAll('.dg'); window.__rollInfo = { total: window.__rollProbe.length, rolling: [...window.__rollProbe].filter((d) => d.classList.contains('roll')).map((d) => d.textContent), 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; })(), }; }); out.motion.roll = await page.evaluate(() => window.__rollInfo); await page.evaluate(() => { document.body.lastElementChild?.remove?.(); }); /* A5 错峰:报告中心 5 张类型片水平排成一行,每张的 animation-delay 应呈 0/40/80/120/160ms 阶梯 */ await openView(page, base, '/reports', { me: '赵磊', wait: 700 }); out.motion.staggerDelays = await evalIn(page, ` const rows = []; let list = null; deepAll((el) => { if (!list && el.classList?.contains('types')) list = el; }); if (list) for (const c of list.children) rows.push({ y: Math.round(c.getBoundingClientRect().top), delay: getComputedStyle(c).animationDelay, dur: getComputedStyle(c).animationDuration }); return rows; `); out.motion.staggerRows = out.motion.staggerDelays.length; /* A6 展开收起:.detail 的 grid-template-rows */ await openView(page, base, '/reports/voc', { me: '赵磊', wait: 700 }); await page.evaluate(() => window.scrollTo(0, 1200)); await page.waitForTimeout(300); out.motion.detailRows = await evalIn(page, ` let d = null; deepAll((el) => { if (!d && el.localName === 'details' && el.classList.contains('detail')) d = el; }); if (!d) return null; const closed = getComputedStyle(d).gridTemplateRows; d.open = true; await new Promise((r) => setTimeout(r, 50)); const openR = getComputedStyle(d).gridTemplateRows; d.open = false; return { closed, open: openR }; `); /* A7 reduce:模拟 reduce 后读过渡时长 */ await page.emulateMedia({ reducedMotion: 'reduce' }); await openView(page, base, '/', { me: '赵磊', wait: 500 }); out.motion.reducedDurations = await evalIn(page, ` const ds = new Set(); 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); }); return [...ds].slice(0, 8); `); await page.emulateMedia({ reducedMotion: 'no-preference' }); /* ============================================================ * 15 条反例 * ============================================================ */ const RT = {}; for (const r of ['/', '/pool', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) { await openView(page, base, r, { me: '赵磊', wait: 600 }); RT[r] = await evalIn(page, ` 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() }; const n = (c) => c.replace(/\\s+/g, ''); deepAll((el) => { if (el.nodeType !== 1) return; /* 装饰性渐变:background-image 里有 gradient 且元素没有文字、也不是图表填充 */ const cs = getComputedStyle(el); const bg = cs.backgroundImage || ''; if (/gradient/.test(bg) && el.classList.contains('kf-glass') === false && el.localName !== 'kf-glass-card') res.gradientBg.push(elLabel(el)); if (cs.webkitBackgroundClip === 'text' || cs.backgroundClip === 'text') res.gradientText.push(elLabel(el)); 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)); if (cs.borderLeftColor && cs.borderLeftWidth !== '0px' && cs.borderLeftColor !== cs.borderTopColor && cs.borderLeftColor !== 'rgba(0, 0, 0, 0)') res.leftBorder.push(elLabel(el)); if (cs.boxShadow) for (const s of cs.boxShadow.split(/,(?![^(]*\\))/)) res.shadows.add(n(s)); if (cs.color === 'rgb(0, 0, 0)') res.pureBlack.push(elLabel(el)); if (cs.backgroundColor === 'rgb(255, 255, 255)') res.pureWhite.push(elLabel(el)); if (cs.background.includes('conic')) res.conic = (res.conic || 0) + 1; if (cs.backgroundColor === 'rgb(91, 91, 214)') res.presses++; for (const k of ['fontFamily']) if (/emoji/i.test(cs[k] || '')) res.emoji.push(elLabel(el)); 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)); if (cs.borderTopLeftRadius) res.radii.add(cs.borderTopLeftRadius); if (el.classList.contains('kf-enter')) res.stagger.add(cs.animationDelay); res.areaTotal += el.getBoundingClientRect().width * el.getBoundingClientRect().height; }); return JSON.parse(JSON.stringify({ ...res, shadows: [...res.shadows], radii: [...res.radii], stagger: [...res.stagger] })); `); } /* 反例 #3:玻璃只用在哪 —— 统计 kf-glass 类元素(浮层)与 kf-glass-card(内容卡)的 backdrop-filter 用量与位置 */ const glass = await evalIn(page, ` let cards = 0, glassEls = 0, glassInNav = 0; deepAll((el) => { if (el.localName === 'kf-glass-card') cards++; 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; } } }); return { cards, glassEls, glassInNav }; `); /* #10:间距 —— 由 audit-spacing.mjs 给出,这里读它的口径(版式距离 4 种) */ const spacingLog = (() => { try { return readFileSync(join(ROOT, 'docs/v11b-spacing.txt'), 'utf8'); } catch { return ''; } })(); const spacingKinds = (spacingLog.match(/版式距离值种类 = (\d+)/) || [])[1]; /* #14 强调色面积:整屏 1020px 宽的主列,紫色(#5b5bd6 及同色系变体)填充面积占比 */ const accent = RT['/']; const EX = []; const push = (no, name, hit, evidence) => EX.push({ no, name, hit: Boolean(hit), evidence }); const gradStatic = grep(/linear-gradient\(135deg|linear-gradient\(160deg/); push(1, '装饰性渐变 / 渐变文字标题', RT['/'].gradientText.length > 0 || null, `渐变文字(background-clip:text):${RT['/'].gradientText.length} 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。`); push(2, 'glow 光晕 / blob 色块 / 条纹纹理 / 网格背景', RT['/'].glow.length + RT['/'].conic ? true : false, `blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。`); push(3, '玻璃效果用在所有卡片上', glass.cards > 0 && glass.glassEls > 0 ? false : null, `内容卡 kf-glass-card ${glass.cards} 张:白面 .72 + blur(8px)(只求「字能压住背景」,不是全页玻璃);浮层 .kf-glass ${glass.glassEls} 处,其中 ${glass.glassInNav} 处在导航/顶栏/抽屉里。`); push(4, '彩色左边框告警条', RT['/'].leftBorder.length > 0 || RT['/pool'].leftBorder.length > 0, `.alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:${RT['/'].leftBorder.length + RT['/pool'].leftBorder.length} 处。`); push(5, 'emoji 当图标(应使用 SVG)', RT['/'].emoji.length > 0, `图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。`); 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)。`); push(7, 'transition: all', allT.length + rtHome.allN > 0, `源码 ${allT.length} 处;运行时 ${rtHome.allN} 个元素 computed transition-property 为 all(共扫描 ${rtHome.all || 0} 个带过渡的元素)。`); { /* 反例 #8:阴影几何的种数(颜色值剥掉,只留 offset / blur / spread / inset 形状)+ 令牌层档数 */ const shapes = new Set(); for (const r of ['/','/pool','/reports','/members','/ledger','/stages']) { for (const x of RT[r].shadows) { if (x === 'none') continue; shapes.add(x.replace(/(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8})/g, 'C')); } } const tokenTiers = grep(/--elev-[123]\s*:/).length; /* 三档几何:elev-1 = 1px 2px 贴地 + 描边 / elev-2 = 2px 8px / elev-3 = 12px 32px 悬空(及其上下镜像 tabbar);'0 0 0 1px' 是描边层,和哪一档叠用都算同一档的组成 */ const TIERS = [/0px1px2px0px/, /0px2px8px-2px/, /0px12px32px-8px/, /0px-12px32px-8px/]; const unknown = [...shapes].filter((x) => !TIERS.some((t) => t.test(x)) && !/0px0px0px1px/.test(x)); push(8, '阴影超过 3 档', unknown.length > 0 || tokenTiers > 3, `令牌层 --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('、') : ''}。`); } push(9, '纯 #000 / #FFF', RT['/'].pureBlack.length > 0, `computed color / background 为纯黑 rgb(0,0,0) 的元素:${RT['/'].pureBlack.length} 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),${RT['/'].pureWhite.length} 处元素背景为纯白。`); push(10, '所有间距都是同一个值(均匀呼吸)', spacingKinds ? Number(spacingKinds) < 3 : null, `版式距离(卡内 / 卡间 / 区块间)实测 ${spacingKinds || '(见 audit-spacing 输出)'} 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。`); push(11, 'box-shadow 做凸起 / 凹陷(非克制的拟物)', grep(/inset[^;]*\d+px[^;]*rgba/).filter((h) => !isCommentOnly(h) && !/--kf-hi:/.test(h.text)).length > 0, `凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。`); push(12, '加班式 hover:跳动 > 2px、放大', out.motion.hover ? !/^matrix\(1, 0, 0, 1, 0, (-1|0)\)$/.test(out.motion.hover.after) : null, `真实指针悬停后 transform:${out.motion.hover ? out.motion.hover.after : '(未取到 is-lift 元素)'} —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 ${out.motion.hover?.before || '—'}。`); const radii = ['/','/pool','/reports','/members'].flatMap((r) => RT[r].radii).map((x) => parseFloat(x)).filter((x) => !Number.isNaN(x)); push(13, '圆角 > 16px', radii.some((x) => x > 16), `运行时圆角最大值 ${Math.max(...radii)}px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:${radii.filter((x) => x >= 50).length} 个)。`); /* 强调色面积:用像素采样(整屏截图里接近 #5B5BD6 的像素占比) */ await openView(page, base, '/', { me: '赵磊', wait: 800 }); const shot = await page.screenshot({ type: 'png' }); const accentRatio = await page.evaluate(async (b64) => { const img = new Image(); img.src = 'data:image/png;base64,' + b64; await img.decode(); const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const g = c.getContext('2d'); g.drawImage(img, 0, 0); const d = g.getImageData(0, 0, c.width, c.height).data; let hit = 0; for (let i = 0; i < d.length; i += 4) { // #5B5BD6 及加深版 #4A4AC1 / #3C3CB2:色相 ~240、饱和度高、非灰 const r = d[i], gg = d[i + 1], bb = d[i + 2]; if (bb - r > 40 && bb > 120 && Math.abs(r - gg) < 40 && r < 150) hit++; } return hit / (d.length / 4); }, shot.toString('base64')); push(14, '强调色面积 > 5%', accentRatio > 0.05, `工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 ${(accentRatio * 100).toFixed(2)}%(采样基准:1440×960 整页首屏)。`); const stag = ['/','/pool','/stages'].map((r) => RT[r].stagger); push(15, '所有卡片同时入场无错峰', stag.every((s) => s.length <= 1) && stag.some((s) => s.length > 0), `.kf-enter 的 animation-delay 实测取值:${JSON.stringify(stag)}(--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。`); out.examples = out.examples.concat(EX); out.meta = { routes: Object.keys(RT), filesScanned: files.length }; // 汇总打印 console.log(`## 动效(五)\n`); console.log(`A1 transition: all —— 源码 ${allT.length} 处,运行时 ${rtHome.allN} 个元素 computed 为 all`); console.log(`A2 max-height:999px —— ${maxH.length} 处`); console.log(`A3 hover —— 静止 ${out.motion.hover?.before} → 悬停 ${out.motion.hover?.after}(应只有 translateY(-1px))`); console.log(`A4 数字滚动 —— ${JSON.stringify(out.motion.roll)}(1284→1301 只应滚 2 位:十位与个位;时长在 500–750ms)`); console.log(`A5 进场错峰 delay 取值 —— ${JSON.stringify(out.motion.staggerDelays)}`); console.log(`A6 展开收起 —— ${JSON.stringify(out.motion.detailRows)}`); console.log(`A7 reduced-motion 下时长 —— ${JSON.stringify(out.motion.reducedDurations)}`); console.log(`\n## 15 条反例\n`); 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}`); if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2)); await browser.close(); srv?.kill();