|
@@ -0,0 +1,316 @@
|
|
|
|
|
+#!/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 <path>),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();
|