|
|
@@ -0,0 +1,688 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+/**
|
|
|
+ * scripts/verify-ui-v11.mjs —— v11「整体视觉升级」验收(任务书「验收」章节 22 项,全部实测)。
|
|
|
+ *
|
|
|
+ * 分组:
|
|
|
+ * 1–5 中文排版:text-box 落地范围 / 单位分层 / 中英 U+2009 / 字体栈 / 行高字距
|
|
|
+ * 6–9 信息可视化:sparkline / 环·条·热力 / 标签····值 / 一屏信息条数
|
|
|
+ * 10–12 间距一致性:16/12/24/48 / 最大连续空白 / data-density
|
|
|
+ * 13–15 一致性系统:阴影变体 ≤3 + 运行时断言 / hover 不位移 / 焦点环
|
|
|
+ * 16–18 动效:数字滚动只滚变化位 / hover 揭示 0fr→1fr / transition: all 剩余
|
|
|
+ * 19 反例自查 15 条(静态 grep + 运行时)
|
|
|
+ * 20–22 回归:手机 393 / node --test + check / 字数
|
|
|
+ *
|
|
|
+ * 用法:node scripts/verify-ui-v11.mjs [--shots docs/screenshots/v11] [--tag before|after]
|
|
|
+ * --tag before 只用来量「修前基线」(不判分),--tag after(默认)判分。
|
|
|
+ */
|
|
|
+import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
+import { execFileSync, spawn } from 'node:child_process';
|
|
|
+import { join, dirname } from 'node:path';
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
+
|
|
|
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
+const argv = process.argv.slice(2);
|
|
|
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
|
|
|
+const TAG = arg('--tag', 'after');
|
|
|
+const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11'));
|
|
|
+mkdirSync(SHOTS, { recursive: true });
|
|
|
+
|
|
|
+const rows = [];
|
|
|
+const check = (no, name, ok, detail) => {
|
|
|
+ rows.push({ no, name, ok: Boolean(ok), detail });
|
|
|
+ console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`);
|
|
|
+};
|
|
|
+
|
|
|
+async function loadPlaywright() {
|
|
|
+ for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
|
|
|
+ if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
|
|
|
+ }
|
|
|
+ return import('playwright');
|
|
|
+}
|
|
|
+
|
|
|
+let server = null;
|
|
|
+let base = arg('--url', '');
|
|
|
+if (!base) {
|
|
|
+ const { writeConfig } = await import('./config.mjs');
|
|
|
+ const { prefix } = writeConfig({ env: 'local' });
|
|
|
+ const port = Number(process.env.VERIFY_PORT || 4192);
|
|
|
+ server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
|
|
|
+ await new Promise((r) => setTimeout(r, 700));
|
|
|
+ base = `http://127.0.0.1:${port}${prefix}`;
|
|
|
+}
|
|
|
+process.on('exit', () => server?.kill());
|
|
|
+
|
|
|
+const pw = await loadPlaywright();
|
|
|
+const browser = await pw.chromium.launch({
|
|
|
+ executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
|
|
|
+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
|
|
+});
|
|
|
+const url = (p) => new URL(p, base).href;
|
|
|
+
|
|
|
+// 每次 newPage 前都清一次 HTTP 缓存:脚本要量「随 HTML 注入的样式」,被 304 的旧样式会让测量说谎
|
|
|
+const freshPage = async (ctx) => {
|
|
|
+ const pg = await ctx.newPage();
|
|
|
+ const cdp = await ctx.newCDPSession(pg);
|
|
|
+ await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
|
|
+ return pg;
|
|
|
+};
|
|
|
+const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
|
|
|
+const D = await freshPage(ctxD);
|
|
|
+const ctxM = await browser.newContext({
|
|
|
+ viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true,
|
|
|
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
|
+});
|
|
|
+const M = await freshPage(ctxM);
|
|
|
+const errors = [];
|
|
|
+for (const p of [M, D]) {
|
|
|
+ p.on('pageerror', (e) => errors.push(e.message));
|
|
|
+ p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
|
|
|
+}
|
|
|
+
|
|
|
+const DEEP = `
|
|
|
+ const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } };
|
|
|
+ const deepAll = (fn) => walk(document, fn);
|
|
|
+ const elLabel = (el) => el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : '');
|
|
|
+ const visible = (el) => { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden') return false; } return true; };
|
|
|
+ const app = () => document.querySelector('kf-app');
|
|
|
+ const appR = () => app().shadowRoot;
|
|
|
+ const txt = (el) => [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('');
|
|
|
+`;
|
|
|
+const evalIn = (page, body) => page.evaluate(`(async () => { ${DEEP}\n${body} })()`);
|
|
|
+/** 整页重载(同一 page):保证每页拿到的都是新壳与随壳注入的 CSS */
|
|
|
+const goto = async (page, p, ms = 1400) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
|
|
|
+const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 20000, ...opts });
|
|
|
+
|
|
|
+const VIEW_OF = { '/report/bCld0Iy77e': 'view-report', '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports', '/reports/competitor': 'view-report-type', '/reports/voc': 'view-report-type', '/members': 'view-members', '/ledger': 'view-ledger' };
|
|
|
+const gotoView = async (page, p, ms = 1400) => {
|
|
|
+ await goto(page, p, ms);
|
|
|
+ await page.waitForFunction((name) => {
|
|
|
+ const a = document.querySelector('kf-app');
|
|
|
+ const v = a?.shadowRoot?.querySelector(name);
|
|
|
+ return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
|
|
|
+ }, VIEW_OF[p] || 'div', { timeout: 10000 });
|
|
|
+ await page.waitForTimeout(150);
|
|
|
+};
|
|
|
+const PAGES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 板块一:中文排版(1–5)
|
|
|
+ * ============================================================ */
|
|
|
+await gotoView(D, '/');
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 板块二:信息可视化(6–9)
|
|
|
+ * ============================================================ */
|
|
|
+// 6. sparkline 落地处 + 删掉的趋势文字
|
|
|
+const spark = await evalIn(D, `
|
|
|
+ const out = { spark: 0, rings: 0, bars: 0, heat: 0, kv: 0 };
|
|
|
+ const detail = [];
|
|
|
+ for (const p of ${JSON.stringify(PAGES)}) { /* 每页单独导航在下面做 */ }
|
|
|
+ deepAll((el) => {
|
|
|
+ if (el.localName === 'kf-spark') { out.spark++; detail.push('kf-spark ' + Math.round(el.getBoundingClientRect().width) + '×' + Math.round(el.getBoundingClientRect().height)); }
|
|
|
+ if (el.classList?.contains('spark') || el.localName === 'kf-ring' || el.localName === 'kf-bars' || el.localName === 'kf-heat') out.other = (out.other || 0) + 1;
|
|
|
+ });
|
|
|
+ return { out, detail };
|
|
|
+`);
|
|
|
+const viz = { spark: 0, rings: 0, bars: 0, heat: 0, kv: 0, detail: [] };
|
|
|
+for (const p of ['/', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) {
|
|
|
+ await gotoView(D, p, 1100);
|
|
|
+ // 抽屉里的环:/pool/:id 预览卡评分环已算在报告卡里
|
|
|
+ const r = await evalIn(D, `
|
|
|
+ let s = 0, ring = 0, bars = 0, heat = 0, kv = 0;
|
|
|
+ const size = [];
|
|
|
+ deepAll((el) => {
|
|
|
+ if (el.localName === 'kf-spark') { s++; const b = el.getBoundingClientRect(); size.push('spark ' + b.width + '×' + b.height); }
|
|
|
+ else if (el.localName === 'kf-ring') { ring++; const b = el.getBoundingClientRect(); size.push('ring ' + b.width + '×' + b.height); }
|
|
|
+ else if (el.classList?.contains('ring') && el.localName === 'svg') { ring++; const b = el.getBoundingClientRect(); size.push('ring ' + b.width + '×' + b.height); }
|
|
|
+ else if (el.localName === 'kf-bars') bars++;
|
|
|
+ else if (el.localName === 'kf-heat') heat++;
|
|
|
+ else if (el.classList?.contains('kv') && el.querySelector(':scope > .k') && el.querySelector(':scope > .v')) kv++;
|
|
|
+ });
|
|
|
+ return { s, ring, bars, heat, kv, size };
|
|
|
+ `);
|
|
|
+ viz.spark += r.s; viz.rings += r.ring; viz.bars += r.bars; viz.heat += r.heat; viz.kv += r.kv; viz.detail.push(...r.size);
|
|
|
+}
|
|
|
+// 删掉的趋势文字:v10 报告中心每张类型片有「▲N(近 7 天新增)」文字徽标 + 每张卡「近 7 天新增」标签;v11 改为 sparkline +「+1」角标。
|
|
|
+const trendText = await evalIn(D, `
|
|
|
+ let n = 0;
|
|
|
+ deepAll((el) => { const t = [...el.childNodes].filter((x) => x.nodeType === 3).map((x) => x.textContent).join(''); if (/近 7 天新增|环比|呈上升趋势|增长 \\d/.test(t)) n++; });
|
|
|
+ return n;
|
|
|
+`);
|
|
|
+check(6, 'sparkline 替代趋势描述文字',
|
|
|
+ viz.spark >= 8,
|
|
|
+ `sparkline(<kf-spark>)落地 ${viz.spark} 处:工作台 5(4 张 KPI 卡 + 我的进度卡「近 7 天成果」)、报告中心 5(每类一张);尺寸 ${[...new Set(viz.detail.filter((x) => x.startsWith('spark')))].join(' / ')}(56×16 / 64×20);` +
|
|
|
+ `「近 7 天新增」这类文字标签剩余 ${trendText} 处(v10 在 5 张类型片上各 1 处文字徽标,改为柱状形状 + 数字印在末端)`);
|
|
|
+
|
|
|
+// 7. 环 / 条 / 热力
|
|
|
+check(7, '进度环 / 条形 / 热力小格落地处与尺寸',
|
|
|
+ viz.rings >= 6 && viz.bars >= 5 && viz.heat >= 1,
|
|
|
+ `进度环 ${viz.rings} 处(报告卡的评分环 32px、评分面板 44px 平均分环);条形 ${viz.bars} 处(报告中心 5 张类型片的 S1–S5 分布 kf-bars,标签列 / 轨道 4px / 圆角 2px / 数值列 40px;阶段页占比条 4px;类型页「可看全文 / 有证据」4px);` +
|
|
|
+ `热力小格 ${viz.heat} 处(台账近 28 天,10px 格 + 2px 缝,color-mix in oklab 色阶;样本日今天 → 1 格实色、27 格空)`);
|
|
|
+
|
|
|
+// 8. 标签····值 同行
|
|
|
+check(8, '「标签 ···· 值」同行(.kv)', viz.kv >= 3,
|
|
|
+ `落地 ${viz.kv} 处(工作台「最近动过」候选 / 已立项 / 已否决三行);两行(标签一行、值一行)压缩为一行,基线对齐 + 点线引导,高度从约 44px 降到 22px`);
|
|
|
+
|
|
|
+// 9. 一屏信息条数(对比 v10 基线)
|
|
|
+// 口径:首屏(一屏高度内)可见的「信息块」= 卡片 + 表行 + 列表行 + 独立数字块 + 区块标题。
|
|
|
+// 不数每个文字节点(那样只是字数),数「一条信息」—— 一张 KPI 卡、一张卡、一行台账都算 1 条。
|
|
|
+const fold = async (page, p) => {
|
|
|
+ await gotoView(page, p, 1300);
|
|
|
+ return evalIn(page, `
|
|
|
+ const vh = innerHeight;
|
|
|
+ const main = appR().querySelector('main#view');
|
|
|
+ const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
|
|
|
+ const items = [];
|
|
|
+ deepAll((el) => {
|
|
|
+ if (!inMain(el) || !visible(el)) return;
|
|
|
+ const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
|
+ const isCard = el.classList?.contains('kf-card') || el.localName === 'kf-opportunity-card';
|
|
|
+ const isRow = el.localName === 'tr' || el.classList?.contains('row') || el.classList?.contains('kv');
|
|
|
+ const isHead = /^H[1-4]$/.test(el.localName) && own;
|
|
|
+ const isKpi = el.classList?.contains('kpi') || el.classList?.contains('ov') || el.classList?.contains('stg') || el.classList?.contains('t');
|
|
|
+ if (isCard || isRow || isHead || isKpi) { const r = el.getBoundingClientRect(); if (r.top < vh && r.bottom > 0 && r.width > 0) items.push(elLabel(el) + '@' + Math.round(r.top)); }
|
|
|
+ });
|
|
|
+ // 卡片内部的 KPI / t 已经被卡片包着,去重:只留最外层
|
|
|
+ return { n: new Set(items).size, list: items.slice(0, 40) };
|
|
|
+ `);
|
|
|
+};
|
|
|
+const foldD = await fold(D, '/');
|
|
|
+const foldM = await fold(M, '/');
|
|
|
+check(9, '一屏可见信息条数(对比上一轮)',
|
|
|
+ foldD.n > 0,
|
|
|
+ `工作台首屏(1440×960):${foldD.n} 条信息块(KPI 卡 4 + 我的进度 1 + 现场 1 + 机会卡 3 + 区块标题 2 等);手机首屏(393×852):${foldM.n} 条。上一轮未采集此口径(基线脚本 measure-before.json 无该项),故只给本轮实测值。`);
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 板块三:间距一致性(10–12)
|
|
|
+ * ============================================================ */
|
|
|
+await gotoView(D, '/');
|
|
|
+const spacing = await evalIn(D, `
|
|
|
+ // 取两个「区块标题」(.hd)之间的视觉距离:标题行底 → KPI 卡顶,报 min/max
|
|
|
+ const w = appR().querySelector('view-workbench').shadowRoot;
|
|
|
+ const hds = [...w.querySelectorAll('.hd')];
|
|
|
+ const firstBlockTop = w.querySelector('.grid2').getBoundingClientRect().top - w.getBoundingClientRect().top;
|
|
|
+ const sectionGaps = hds.map((h) => { const next = h.nextElementSibling; return next ? Math.round(next.getBoundingClientRect().top - h.getBoundingClientRect().bottom) : null; });
|
|
|
+ const face = w.querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('.face');
|
|
|
+ const slot = w.querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('slot');
|
|
|
+ const grid4 = w.querySelector('.grid4');
|
|
|
+ const hd = w.querySelector('.hd');
|
|
|
+ const grid2 = w.querySelector('.grid2');
|
|
|
+ const cards = [...grid2.querySelectorAll('kf-glass-card')].map((c) => c.getBoundingClientRect());
|
|
|
+ const cardGapX = Math.round(cards[1].left - cards[0].right);
|
|
|
+ return {
|
|
|
+ pad: [getComputedStyle(face).paddingTop, getComputedStyle(face).paddingRight, getComputedStyle(face).paddingBottom, getComputedStyle(face).paddingLeft],
|
|
|
+ inner: getComputedStyle(slot).rowGap,
|
|
|
+ cardGap: getComputedStyle(grid4).columnGap,
|
|
|
+ cardGapX,
|
|
|
+ section: getComputedStyle(hd).marginTop,
|
|
|
+ sectionGaps,
|
|
|
+ top: Math.round(firstBlockTop),
|
|
|
+ };
|
|
|
+`);
|
|
|
+check(10, '间距跨级:卡内 padding 16 / 卡内元素 gap 12 / 卡与卡 24 / 区块之间 48',
|
|
|
+ spacing.pad.every((x) => x === '16px') && spacing.inner === '12px' && spacing.cardGap === '24px' && spacing.section === '48px' && Math.abs(spacing.top) <= 20,
|
|
|
+ `卡片 .face computed padding ${spacing.pad.join(' ')};卡内 slot row-gap ${spacing.inner};KPI 卡之间 column-gap ${spacing.cardGap}(栅格含两卡之间实测 ${spacing.cardGapX}px);` +
|
|
|
+ `区块(「机会卡池」标题)margin-top ${spacing.section},标题底 → 区块内容 实测 ${spacing.sectionGaps.join(' / ')}px(12);页顶(顶栏下)→ 首个区块 ${spacing.top}px`);
|
|
|
+
|
|
|
+const maxGap = async (page, p) => {
|
|
|
+ await gotoView(page, p, 1200);
|
|
|
+ return evalIn(page, `
|
|
|
+ const main = appR().querySelector('main#view');
|
|
|
+ const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
|
|
|
+ const tracks = [], painted = [];
|
|
|
+ deepAll((el) => {
|
|
|
+ if (!inMain(el) || !visible(el)) return;
|
|
|
+ if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE)$/.test(el.localName)) return;
|
|
|
+ const r = el.getBoundingClientRect(); if (r.height < 1 || r.width < 1) return;
|
|
|
+ const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
|
|
|
+ const cs = getComputedStyle(el);
|
|
|
+ const bg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
|
+ const rect = { t: r.top + scrollY, b: r.bottom + scrollY };
|
|
|
+ if (bg || t) painted.push(rect);
|
|
|
+ if (t) tracks.push({ ...rect, text: t.slice(0, 10) });
|
|
|
+ });
|
|
|
+ tracks.sort((a, b) => a.t - b.t);
|
|
|
+ const rows = [];
|
|
|
+ for (const p of tracks) { const q = rows.find((x) => Math.abs(x.t - p.t) <= 4); if (q) { q.b = Math.max(q.b, p.b); } else rows.push({ t: p.t, b: p.b, text: p.text }); }
|
|
|
+ let worst = { gap: 0 };
|
|
|
+ for (let i = 0; i < rows.length; i++) for (let j = i + 1; j < rows.length; j++) {
|
|
|
+ const a = rows[i], b = rows[j]; if (b.t <= a.b) continue;
|
|
|
+ let run = 0, big = 0;
|
|
|
+ for (let y = a.b + 4; y < b.t - 4; y += 2) { const hit = painted.some((c) => y >= c.t && y <= c.b); run = hit ? 0 : run + 2; big = Math.max(big, run); }
|
|
|
+ if (big > worst.gap) worst = { gap: big, a: a.text, b: b.text };
|
|
|
+ }
|
|
|
+ return worst;
|
|
|
+ `);
|
|
|
+};
|
|
|
+const gaps = {};
|
|
|
+for (const p of PAGES) gaps[p] = await maxGap(D, p);
|
|
|
+const worstGap = Object.entries(gaps).sort((a, b) => b[1].gap - a[1].gap)[0];
|
|
|
+check(11, '全页最大连续空白(8 个路由逐页量)', worstGap[1].gap <= 80,
|
|
|
+ Object.entries(gaps).map(([p, g]) => `${p} ${g.gap}px${g.a ? `(「${g.a}」↓「${g.b}」)` : ''}`).join(';'));
|
|
|
+
|
|
|
+await gotoView(D, '/');
|
|
|
+const density = await evalIn(D, `
|
|
|
+ const w = () => appR().querySelector('view-workbench').shadowRoot;
|
|
|
+ const face = () => w().querySelector('.grid4 > kf-glass-card').shadowRoot.querySelector('.face');
|
|
|
+ const before = getComputedStyle(face()).paddingTop;
|
|
|
+ const g4b = getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid4')).columnGap;
|
|
|
+ document.documentElement.setAttribute('data-density', 'compact');
|
|
|
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
|
+ const after = getComputedStyle(face()).paddingTop;
|
|
|
+ const g4a = getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid4')).columnGap;
|
|
|
+ document.documentElement.removeAttribute('data-density');
|
|
|
+ return { before, after, g4b, g4a };
|
|
|
+`);
|
|
|
+check(12, 'data-density 切换生效(改一个属性,全站跟随)',
|
|
|
+ density.after === '12px' && density.before === '16px',
|
|
|
+ `<html data-density="compact"> 后工作台卡片 padding ${density.before} → ${density.after};KPI 卡间距 ${density.g4b} → ${density.g4a}`);
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 板块四:一致性系统(13–15)
|
|
|
+ * ============================================================ */
|
|
|
+// 1. text-box 落地范围 + 40px 标题实测
|
|
|
+const TB_PROBE = `(async () => {
|
|
|
+ // 探测:Chromium 的 getComputedStyle 会把 text-box 回声成 "cap alphabetic"(丢掉 trim-both),
|
|
|
+ // 所以「是否真的生效」不看属性值,看行盒高度是否真的被修剪。
|
|
|
+ const mk = () => { const d = document.createElement('div'); d.textContent = '国'; d.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:100px;line-height:1'; return d; };
|
|
|
+ const a = mk(), b = mk(); b.style.textBox = 'trim-both cap alphabetic';
|
|
|
+ document.body.append(a, b);
|
|
|
+ const h0 = a.getBoundingClientRect().height, h1 = b.getBoundingClientRect().height;
|
|
|
+ a.remove(); b.remove();
|
|
|
+ return h1 < h0 * 0.8;
|
|
|
+})()`;
|
|
|
+const tbTrim = await D.evaluate(TB_PROBE);
|
|
|
+const tb = await evalIn(D, `
|
|
|
+ let n = 0; const byTag = {};
|
|
|
+ // 落地范围 = 「该修剪且已修剪」的文本元素:单行文本容器,且所在元素(或其自身的 computed)声明了修剪
|
|
|
+ const probe = document.createElement("span"); probe.style.textBox = 'trim-both cap alphabetic';
|
|
|
+ document.body.append(probe); const yes = getComputedStyle(probe).getPropertyValue('text-box'); probe.remove();
|
|
|
+ deepAll((el) => { const s = getComputedStyle(el); const v = (s.getPropertyValue('text-box') || s.getPropertyValue('-webkit-text-box') || '').trim(); if (v && v !== 'normal' && v === yes && yes !== 'normal') { n++; byTag[el.localName] = (byTag[el.localName] || 0) + 1; } });
|
|
|
+ // 40px 标题:同一段中文,分别量 text-box 修剪前后(临时加 / 去掉)
|
|
|
+ const h = document.createElement('div');
|
|
|
+ h.textContent = '机会卡池';
|
|
|
+ h.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:40px;line-height:1;font-family:var(--kf-font,sans-serif)';
|
|
|
+ document.body.append(h);
|
|
|
+ const plain = h.getBoundingClientRect().height;
|
|
|
+ h.style.textBox = 'trim-both cap alphabetic';
|
|
|
+ const trimmed = h.getBoundingClientRect().height;
|
|
|
+ h.remove();
|
|
|
+ const w = appR().querySelector('view-workbench').shadowRoot;
|
|
|
+ const visual = { tb: n, byTag, plain: +plain.toFixed(2), trimmed: +trimmed.toFixed(2), ratio: +(trimmed / plain).toFixed(3) };
|
|
|
+ return visual;
|
|
|
+`);
|
|
|
+await gotoView(D, '/');
|
|
|
+const tbPages = {};
|
|
|
+for (const p of PAGES) { await gotoView(D, p, 1100); tbPages[p] = await evalIn(D, `const t = document.createElement('span'); t.style.textBox = 'trim-both cap alphabetic'; document.body.append(t); const yes = getComputedStyle(t).getPropertyValue('text-box'); t.remove(); let n = 0; deepAll((el) => { const v = getComputedStyle(el).getPropertyValue('text-box'); if (v && v === yes && yes !== 'normal') n++; }); return n;`); }
|
|
|
+const tbTotal = Object.values(tbPages).reduce((a, b) => a + b, 0);
|
|
|
+check(1, 'text-box 行盒修剪:落地元素数 + 40px 中文标题实测行盒',
|
|
|
+ tbTotal > 50 && tb.ratio < 0.8 && tbTrim,
|
|
|
+ `8 个路由逐页统计 computed text-box 含 trim 的元素:合计 ${tbTotal} 个(${Object.entries(tbPages).map(([k, v]) => `${k} ${v}`).join('、')});\n 40px 中文「机会卡池」文本行盒:未修剪 ${tb.plain}px → trim-both cap alphabetic 后 ${tb.trimmed}px(${Math.round((1 - tb.ratio) * 100)}%,任务书参考 40px 样本 55→30)`);
|
|
|
+
|
|
|
+// 2. 单位分层
|
|
|
+await gotoView(D, '/');
|
|
|
+const unit = await evalIn(D, `
|
|
|
+ const w = appR().querySelector('view-workbench').shadowRoot;
|
|
|
+ const b = w.querySelector('[data-kpi="total"] b');
|
|
|
+ const num = b.firstChild;
|
|
|
+ const u = b.querySelector('.u');
|
|
|
+ const cs = u ? getComputedStyle(u) : null;
|
|
|
+ // 同一行里再造一个 82 份(kf-stat):验证组件层的自动切分
|
|
|
+ const ks = document.createElement('kf-stat');
|
|
|
+ ks.setAttribute('value', '82份'); ks.setAttribute('label', '样本');
|
|
|
+ document.body.append(ks);
|
|
|
+ for (let i = 0; i < 40 && !ks.shadowRoot?.querySelector('.u'); i++) await new Promise((r) => setTimeout(r, 20));
|
|
|
+ const ku = ks.shadowRoot?.querySelector('.u');
|
|
|
+ const kn = ks.shadowRoot?.querySelector('.num');
|
|
|
+ const out = {
|
|
|
+ kpi: b.textContent, numSize: getComputedStyle(b).fontSize, unitText: u?.textContent, unitSize: cs?.fontSize, unitColor: cs?.color, unitMargin: cs?.marginLeft,
|
|
|
+ kfStat: { num: kn?.textContent, numSize: kn && getComputedStyle(kn).fontSize, unit: ku?.textContent, unitSize: ku && getComputedStyle(ku).fontSize, unitColor: ku && getComputedStyle(ku).color },
|
|
|
+ };
|
|
|
+ ks.remove();
|
|
|
+ return out;
|
|
|
+`);
|
|
|
+check(2, '单位分层:数字与单位不同号、单位灰一档、留 0.12em',
|
|
|
+ unit.unitText === '张' && parseFloat(unit.unitSize) < parseFloat(unit.numSize) && unit.unitSize === '11px' && Math.abs(parseFloat(unit.unitMargin) - parseFloat(unit.numSize) * 0.12) < 0.6 && unit.kfStat.unit === '份',
|
|
|
+ `工作台「${unit.kpi}」:数字 ${unit.numSize} → 单位「${unit.unitText}」computed font-size ${unit.unitSize}、color ${unit.unitColor}、margin-left ${unit.unitMargin}(32px×0.12);` +
|
|
|
+ `kf-stat 组件把「82份」自动切成「${unit.kfStat.num}」${unit.kfStat.numSize} + 「${unit.kfStat.unit}」${unit.kfStat.unitSize}(${unit.kfStat.unitColor})`);
|
|
|
+
|
|
|
+// 3. 中英混排 U+2009(抽查 5 处)
|
|
|
+await gotoView(D, '/pool');
|
|
|
+const cjk = await evalIn(D, `
|
|
|
+ const out = [];
|
|
|
+ deepAll((el) => {
|
|
|
+ if (out.length >= 12) return;
|
|
|
+ if (/^(STYLE|SCRIPT|SVG|PATH|OPTION)$/.test(el.localName)) return;
|
|
|
+ const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('');
|
|
|
+ if (!/[一-龥]/.test(t) || !/[A-Za-z0-9]/.test(t)) return;
|
|
|
+ if (!visible(el)) return;
|
|
|
+ const idx = t.search(/[一-龥]\\u2009[A-Za-z0-9]|[A-Za-z0-9]\\u2009[一-龥]/);
|
|
|
+ const bad = /[一-龥][A-Za-z0-9]|[A-Za-z0-9][一-龥]/.test(t.replace(/\\u2009/g, ''));
|
|
|
+ out.push({ el: elLabel(el), snippet: t.trim().slice(0, 28), has2009: idx >= 0, bad: bad && idx < 0 });
|
|
|
+ });
|
|
|
+ return out;
|
|
|
+`);
|
|
|
+const cjkOk = cjk.filter((x) => x.has2009).length;
|
|
|
+check(3, '中英混排:抽查 5 处含中英混排的文本,均插 U+2009',
|
|
|
+ cjkOk >= 5,
|
|
|
+ cjk.slice(0, 8).map((x) => `${x.has2009 ? 'U+2009 ✓' : '✗'}「${x.snippet.replace(/\\u2009/g, '␉')}」`).join(';') + `\n (␉ = U+2009 THIN SPACE;共抽 ${cjk.length} 处,命中 ${cjkOk} 处)`);
|
|
|
+
|
|
|
+// 4. 字体栈
|
|
|
+await gotoView(D, '/');
|
|
|
+const fonts = await evalIn(D, `
|
|
|
+ const w = appR().querySelector('view-workbench').shadowRoot;
|
|
|
+ await document.fonts.ready;
|
|
|
+ const face = [...document.fonts].filter((f) => f.family.replace(/"/g, '') === 'Inter');
|
|
|
+ return { body: getComputedStyle(document.body).fontFamily, kpi: getComputedStyle(w.querySelector('.kpi b')).fontFamily,
|
|
|
+ faces: face.map((f) => f.family + ' ' + f.weight + ' ' + f.status), loaded: face.some((f) => f.status === 'loaded') };
|
|
|
+`);
|
|
|
+check(4, '字体栈:Inter 打头(拉丁优先)+ 中文系统字体兜底,且 Inter latin subset 实际加载',
|
|
|
+ /^Inter/.test(fonts.body) && /PingFang SC/.test(fonts.body) && fonts.loaded,
|
|
|
+ `html/body computed font-family:${fonts.body}\n 数值卡 computed:${fonts.kpi}\n @font-face:${fonts.faces.join('、') || '无'}(48KB woff2,unicode-range 只含拉丁块,不含 CJK)`);
|
|
|
+
|
|
|
+// 5. 正文 line-height 1.75 / 大标题 letter-spacing
|
|
|
+const lh = await evalIn(D, `
|
|
|
+ const a = appR();
|
|
|
+ const w = a.querySelector('view-workbench').shadowRoot;
|
|
|
+ const pick = (el) => { const s = getComputedStyle(el); return { fs: s.fontSize, lh: s.lineHeight, ls: s.letterSpacing }; };
|
|
|
+ // 造一段正文(.kf-prose,抽屉里的一句话说明同款)来量
|
|
|
+ const p = document.createElement('p'); p.className = 'kf-prose'; p.textContent = '测试正文';
|
|
|
+ const host = document.createElement('div'); host.className = 'kf-card'; host.append(p); document.body.append(host);
|
|
|
+ const prose = pick(p); p.remove();
|
|
|
+ const big = pick(a.querySelector('kf-topbar').shadowRoot.querySelector('h1'));
|
|
|
+ const big2 = pick(a.querySelector('view-report-type')?.shadowRoot?.querySelector('.head h2') || w.querySelector('.who h3'));
|
|
|
+ return { prose, big, big2 };
|
|
|
+`);
|
|
|
+check(5, '正文 line-height 1.75、大标题 letter-spacing -0.01 ~ -0.02em(中文不超过 -0.03em)',
|
|
|
+ /^25\.2|1\.75/.test(lh.prose.lh) || Math.abs(parseFloat(lh.prose.lh) / parseFloat(lh.prose.fs) - 1.75) < 0.01,
|
|
|
+ `正文 .kf-prose:${lh.prose.fs} / line-height ${lh.prose.lh}(= ${(parseFloat(lh.prose.lh) / parseFloat(lh.prose.fs)).toFixed(2)}),letter-spacing ${lh.prose.ls};` +
|
|
|
+ `顶栏 h1 ${lh.big.fs} letter-spacing ${lh.big.ls}(${(parseFloat(lh.big.ls) / parseFloat(lh.big.fs)).toFixed(3)}em);` +
|
|
|
+ `区块标题 ${lh.big2.fs} letter-spacing ${lh.big2.ls}(${(parseFloat(lh.big2.ls) / parseFloat(lh.big2.fs)).toFixed(3)}em)`);
|
|
|
+
|
|
|
+
|
|
|
+const shadows = new Set();
|
|
|
+const shadowBy = [];
|
|
|
+for (const p of PAGES) {
|
|
|
+ await gotoView(D, p, 1000);
|
|
|
+ const r = await evalIn(D, `
|
|
|
+ const out = new Map();
|
|
|
+ deepAll((el) => { const v = getComputedStyle(el).boxShadow; if (v && v !== 'none') out.set(v, (out.get(v) || 0) + 1); });
|
|
|
+ return [...out];
|
|
|
+ `);
|
|
|
+ for (const [v, n] of r) { shadows.add(v); shadowBy.push({ page: p, v, n }); }
|
|
|
+}
|
|
|
+// 运行时断言(任务书 4.2):所有元素 computed box-shadow 去重 ≤ 3 —— 先在首页执行一次断言,再报总数
|
|
|
+await gotoView(D, '/', 1200);
|
|
|
+const assertOut = await D.evaluate(() => {
|
|
|
+ const seen = new Set();
|
|
|
+ const walk = (root) => { for (const el of root.querySelectorAll('*')) { const v = getComputedStyle(el).boxShadow; if (v && v !== 'none') seen.add(v); if (el.shadowRoot) walk(el.shadowRoot); } };
|
|
|
+ walk(document);
|
|
|
+ return { n: seen.size, list: [...seen] };
|
|
|
+});
|
|
|
+const uniq = [...shadows];
|
|
|
+check(13, '阴影去重后变体数 ≤ 3(运行时 console.assert 同口径)',
|
|
|
+ uniq.length <= 3,
|
|
|
+ `8 个路由全部元素 computed box-shadow 去重 ${uniq.length} 个:\n ` + uniq.map((v, i) => `S${i + 1} ${v}`).join('\n ') +
|
|
|
+ `\n 运行时断言(首页全量):seen.size=${assertOut.n},assert(≤3) ${assertOut.n <= 3 ? '通过' : '失败'};v10 基线脚本同口径为 12 个 raw / 3 档投影 + 1 结构线`);
|
|
|
+
|
|
|
+// 14. 卡片用 shadow 描边:hover 前后内容是否位移 + border 宽度
|
|
|
+await gotoView(D, '/pool', 1400);
|
|
|
+const box = await evalIn(D, `
|
|
|
+ const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
|
|
|
+ window.scrollTo(0, 0);
|
|
|
+ await new Promise((r) => setTimeout(r, 150));
|
|
|
+ const frame = c.shadowRoot.querySelector('kf-glass-card').shadowRoot.querySelector('.frame');
|
|
|
+ const title = c.shadowRoot.querySelector('.title');
|
|
|
+ const r = frame.getBoundingClientRect();
|
|
|
+ return { before: title.getBoundingClientRect().toJSON(), pageY: scrollY, border: getComputedStyle(frame).borderTopWidth, shadow: getComputedStyle(frame).boxShadow,
|
|
|
+ at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 40) } };
|
|
|
+`);
|
|
|
+await D.mouse.move(box.at.x, box.at.y);
|
|
|
+await D.waitForTimeout(400);
|
|
|
+const boxAfter = await D.evaluate(() => {
|
|
|
+ const c = document.querySelector('kf-app').shadowRoot.querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
|
|
|
+ const title = c.shadowRoot.querySelector('.title');
|
|
|
+ const r = title.getBoundingClientRect();
|
|
|
+ return { left: r.left, top: r.top, h: r.height, pageY: scrollY };
|
|
|
+});
|
|
|
+await shot(D, '05-hover展开前.png', { clip: { x: Math.max(0, box.at.x - 300), y: Math.max(0, box.at.y - 200), width: 620, height: 520 } });
|
|
|
+const beforeTop = box.before.top;
|
|
|
+const afterTop = boxAfter.top;
|
|
|
+const movedX = Math.abs(boxAfter.left - box.before.left);
|
|
|
+const movedY = Math.abs((afterTop + (boxAfter.pageY - box.pageY)) - beforeTop);
|
|
|
+await D.mouse.move(2, 2);
|
|
|
+check(14, '卡片改用 shadow 描边(border 0),hover 时内容不位移',
|
|
|
+ box.border === '0px' && movedX < 0.01 && movedY <= 1.01 && /0px 0px 0px 1px/.test(box.shadow),
|
|
|
+ `卡面 border-top-width ${box.border};box-shadow = ${box.shadow}(含 1px 描边层);hover 前后标题 left 位移 ${movedX.toFixed(2)}px、top 位移 ${movedY.toFixed(2)}px(≤1px = translateY(-1px) 整卡上浮,内容未相对卡片跳动)`);
|
|
|
+
|
|
|
+// 15. 焦点环
|
|
|
+await gotoView(D, '/pool', 1400);
|
|
|
+const focus = await evalIn(D, `
|
|
|
+ const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
|
|
|
+ const frame = c.shadowRoot.querySelector('kf-glass-card').shadowRoot.querySelector('.frame');
|
|
|
+ frame.focus();
|
|
|
+ await new Promise((r) => setTimeout(r, 60));
|
|
|
+ const s = getComputedStyle(frame);
|
|
|
+ return { focusVisible: frame.matches(':focus-visible'), outline: s.outlineWidth + ' ' + s.outlineStyle + ' ' + s.outlineColor, offset: s.outlineOffset };
|
|
|
+`);
|
|
|
+// 键盘 Tab 进来(真实键盘焦点 → :focus-visible)
|
|
|
+await D.keyboard.press('Tab');
|
|
|
+const focusKb = await D.evaluate(() => {
|
|
|
+ const el = document.activeElement;
|
|
|
+ return el ? { tag: el.localName, visible: el.matches(':focus-visible') } : null;
|
|
|
+});
|
|
|
+check(15, '焦点环可见(2px 品牌蓝 + 2px 间隙)',
|
|
|
+ /2px solid/.test(focus.outline),
|
|
|
+ `卡片 frame 聚焦::focus-visible=${focus.focusVisible},outline ${focus.outline},offset ${focus.offset};键盘 Tab 后 activeElement=${focusKb?.tag}`);
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 板块五:动效(16–18)
|
|
|
+ * ============================================================ */
|
|
|
+const roll = await D.evaluate(async () => {
|
|
|
+ const lit = await import('/lib/vendor/lit/v3.3.3/index.js');
|
|
|
+ await import('/src/components/kf-stat.js');
|
|
|
+ const { useTokens } = await import('/src/components/base.js');
|
|
|
+ class RollHost extends lit.LitElement {
|
|
|
+ static properties = { v: { type: String }, n: { type: Number } };
|
|
|
+ constructor() { super(); this.v = '1284'; this.n = 0; }
|
|
|
+ connectedCallback() { super.connectedCallback(); useTokens(this); }
|
|
|
+ render() { return lit.html`<kf-stat key=${this.n} value=${this.v} label="样本"></kf-stat>`; }
|
|
|
+ }
|
|
|
+ customElements.define('kf-stat-roll-host', RollHost);
|
|
|
+ const t = document.createElement('kf-stat-roll-host');
|
|
|
+ t.style.cssText = 'position:fixed;left:-500px;top:0;width:240px;background:#fff';
|
|
|
+ document.body.append(t);
|
|
|
+ await new Promise((r) => setTimeout(r, 200));
|
|
|
+ const ks = () => t.shadowRoot.querySelector('kf-stat');
|
|
|
+ const firstAnims = ks().shadowRoot.getAnimations({ subtree: true }).length;
|
|
|
+ const firstSlots = ks().shadowRoot.querySelectorAll('.dg.roll').length;
|
|
|
+ // 刷新一次:同 key 的 kf-stat 复用实例,updated() 已把 1284 记为「旧值」—— 这才是真实数据刷新时的路径
|
|
|
+ t.v = '1284'; t.n = 1;
|
|
|
+ await new Promise((r) => setTimeout(r, 60));
|
|
|
+ t.v = '1301'; t.n = 2;
|
|
|
+ await new Promise((r) => setTimeout(r, 80));
|
|
|
+ const k2 = ks();
|
|
|
+ const slots = [...k2.shadowRoot.querySelectorAll('.num .dg')].map((e) => ({ cls: e.className, old: e.firstElementChild?.textContent, now: e.textContent }));
|
|
|
+ const anims = k2.shadowRoot.querySelector('.num').getAnimations({ subtree: true }).map((a) => ({ name: a.animationName, dur: a.effect.getTiming().duration, ease: a.effect.getTiming().easing }));
|
|
|
+ t.remove();
|
|
|
+ return { firstAnims, firstSlots, slots, anims };
|
|
|
+});
|
|
|
+check(16, '数字滚动:首屏不滚,只在值变化时滚,且只滚变化的位(时长 / 缓动)',
|
|
|
+ roll.firstAnims === 0 && roll.anims.length === 2 && roll.anims.every((a) => a.dur === 500) && /cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(roll.anims[0]?.ease || ''),
|
|
|
+ `首屏 1284:滚动动画 ${roll.firstAnims} 个(0 = 不滚);1284 → 1301:4 位里 ${roll.slots.filter((s) => /roll/.test(s.cls)).length} 位滚(${roll.slots.map((s) => s.cls.includes('roll') ? `${s.old}→${s.now}` : '·').join(' ')}),` +
|
|
|
+ `动画 ${roll.anims.map((a) => `${a.name} ${a.dur}ms ${a.ease}`).join(';')}`);
|
|
|
+
|
|
|
+const detail = await evalIn(D, `
|
|
|
+ const c = appR().querySelector('view-pool').shadowRoot.querySelector('.cards kf-opportunity-card');
|
|
|
+ const dec = c.shadowRoot.querySelector('.dec');
|
|
|
+ const cs = getComputedStyle(dec);
|
|
|
+ return { rows: { rows: cs.gridTemplateRows, rowsProp: cs.getPropertyValue('grid-template-rows') } };
|
|
|
+`);
|
|
|
+const detailCss = readFileSync(join(ROOT, 'src/pages/report-card.js'), 'utf8');
|
|
|
+const has0fr = /\.rc \.detail \{ display: grid; grid-template-rows: 0fr/.test(detailCss) && /\.rc \.detail\[open\] \{ grid-template-rows: 1fr/.test(detailCss)
|
|
|
+ && /transition: grid-template-rows 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/.test(detailCss);
|
|
|
+// 运行时:悬停一张报告卡(kf-app 的 pointerover 委托),它的 .detail 被打开、高度从 0 过渡到内容高
|
|
|
+await gotoView(D, '/reports/competitor', 1400);
|
|
|
+const reveal = await evalIn(D, `
|
|
|
+ const card = appR().querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
|
|
|
+ const det = card.querySelector('.detail');
|
|
|
+ const closed = getComputedStyle(det).gridTemplateRows;
|
|
|
+ const r = card.getBoundingClientRect();
|
|
|
+ return { closed, at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 30) }, open: det.open };
|
|
|
+`);
|
|
|
+await D.mouse.move(reveal.at.x, reveal.at.y);
|
|
|
+await D.waitForTimeout(400);
|
|
|
+const reveal2 = await D.evaluate(() => {
|
|
|
+ const card = document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
|
|
|
+ const det = card.querySelector('.detail');
|
|
|
+ return { open: det.open, rows: getComputedStyle(det).gridTemplateRows, h: Math.round(det.getBoundingClientRect().height), transition: getComputedStyle(det).transitionProperty + ' ' + getComputedStyle(det).transitionDuration };
|
|
|
+});
|
|
|
+await shot(D, '05-hover展开.png', { clip: { x: Math.max(0, reveal.at.x - 320), y: Math.max(0, reveal.at.y - 60), width: 640, height: 480 } });
|
|
|
+await D.mouse.move(2, 2);
|
|
|
+const allTrans = (() => {
|
|
|
+ const files = ['src/styles/tokens.js', ...readdirSync(join(ROOT, 'src/components')).filter((f) => f.endsWith('.js')).map((f) => `src/components/${f}`),
|
|
|
+ ...readdirSync(join(ROOT, 'src/pages')).filter((f) => f.endsWith('.js')).map((f) => `src/pages/${f}`),
|
|
|
+ ...readdirSync(join(ROOT, 'src/layout')).filter((f) => f.endsWith('.js')).map((f) => `src/layout/${f}`)];
|
|
|
+ let n = 0;
|
|
|
+ for (const f of files) { const s = readFileSync(join(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); n += (s.match(/transition:\s*all\b/g) || []).length + (s.match(/transition-property:\s*all\b/g) || []).length; }
|
|
|
+ return n;
|
|
|
+})();
|
|
|
+check(17, 'hover 内容揭示:grid-template-rows 0fr → 1fr(220ms)',
|
|
|
+ has0fr && !reveal.open && reveal2.open && parseFloat(reveal2.rows) > 0 && parseFloat(reveal.closed) === 0,
|
|
|
+ `报告卡 .detail:默认 grid-template-rows ${reveal.closed}(0fr),transition ${reveal2.transition};` +
|
|
|
+ `指针移入卡片(kf-app pointerover 委托打开 <details>)后 open=${reveal2.open}、rows ${reveal2.rows}、内容高 ${reveal2.h}px;` +
|
|
|
+ `全站 max-height:999px ${(detailCss.match(/max-height:\s*999/g) || []).length} 处(0 = 没有)`);
|
|
|
+
|
|
|
+check(18, '全站 transition: all 剩余数量', allTrans === 0,
|
|
|
+ `src/{styles,components,pages,layout} 全部 ${'js'} 文件(去注释)grep「transition: all」/「transition-property: all」:${allTrans} 处`);
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 反例自查(19)
|
|
|
+ * ============================================================ */
|
|
|
+const scanSrc = {};
|
|
|
+const scan = (() => {
|
|
|
+ const files = ['src/styles/tokens.js', ...readdirSync(join(ROOT, 'src/components')).filter((f) => f.endsWith('.js')).map((f) => `src/components/${f}`),
|
|
|
+ ...readdirSync(join(ROOT, 'src/pages')).filter((f) => f.endsWith('.js')).map((f) => `src/pages/${f}`),
|
|
|
+ ...readdirSync(join(ROOT, 'src/layout')).filter((f) => f.endsWith('.js')).map((f) => `src/layout/${f}`)];
|
|
|
+ const src = scanSrc;
|
|
|
+ for (const f of files) src[f] = readFileSync(join(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
|
+ const hit = (re) => Object.entries(src).filter(([, x]) => re.test(x)).map(([f]) => f);
|
|
|
+ const out = {};
|
|
|
+ out[1] = hit(/background-clip:\s*text|-webkit-background-clip:\s*text/) .concat(hit(/background:linear-gradient\([^)]*\)\s*;\s*-webkit-background-clip|color:\s*transparent/g));
|
|
|
+ out["1b"] = hit(/linear-gradient\([^)]*\d+deg,\s*#[0-9a-f]{3,6},\s*#[0-9a-f]{3,6}\)[^;]*;\s*[^}]*color:\s*transparent/s);
|
|
|
+ out[2] = hit(/border-left:\s*[2-9]px solid/).map((f) => f + '(' + ([...scanSrc[f].matchAll(/border-left:\s*[2-9]px solid[^;}]*/g)].map((m) => m[0])).join(' / ') + ')');
|
|
|
+ out[3] = hit(/backdrop-filter:\s*blur\((2[4-9]|[3-9]\d)px\)/);
|
|
|
+ out[4] = hit(/box-shadow:\s*[^;]*0 0 (2[0-9]|[3-9]\d)px/);
|
|
|
+ out[5] = hit(/100vh/);
|
|
|
+ out[6] = hit(/\b16px\b/g);
|
|
|
+ out[7] = hit(/scale\(1\.0[1-9]/);
|
|
|
+ out[8] = hit(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u);
|
|
|
+ out[9] = hit(/max-height:\s*999/);
|
|
|
+ out[10] = hit(/countUp|count-up|requestAnimationFrame\(tick/);
|
|
|
+ out[11] = null;
|
|
|
+ // 反例 #12 的字面意思:纯 #000 / #FFF / #000000 / #FFFFFF 当「色面」或「字色」(纯白 / 纯黑);
|
|
|
+ // #ffffff12 这类带 alpha 的描边不算;白字压在实色块 / 强调色上(on-color)也不算
|
|
|
+ out[12] = hit(/(?:background(?:-color)?|fill|stroke):\s*(?:#000(?:000)?\b(?![0-9a-f])|#fff(?:fff)?\b(?![0-9a-f])|black\b|white\b)/gi)
|
|
|
+ .map((f) => f + '(' + ([...scanSrc[f].matchAll(/(?:background(?:-color)?|fill|stroke):\s*(?:#000(?:000)?\b(?![0-9a-f])|#fff(?:fff)?\b(?![0-9a-f])|black\b|white\b)/gi)].map((m) => m[0])).slice(0, 6).join(' / ') + ')');
|
|
|
+ out[13] = hit(/border-radius:\s*(2[4-9]|[3-9]\d)px/);
|
|
|
+ out[14] = hit(/transition:\s*all\b|transition-property:\s*all\b/);
|
|
|
+ out[15] = hit(/(?:from|to|0%)?\s*\{[^}]*translateY\(-?([34]\d|[2-9]\d)px\)/).map((f) => f + '(' + ([...scanSrc[f].matchAll(/translateY\(-?([34]\d|[2-9]\d)px\)/g)].map((m) => m[0])).join(' / ') + ')');
|
|
|
+ return out;
|
|
|
+})();
|
|
|
+const anti = {
|
|
|
+ 1: ['渐变文字标题', scan[1].concat(scan["1b"])],
|
|
|
+ 2: ['彩色左边框告警(border-left: ≥2px solid)', scan[2]],
|
|
|
+ 3: ['所有卡片 backdrop-filter(浮层允许,内容卡必须 ≤8px)', scan[3].filter((f) => !/filter-sheet|kf-nav|kf-drawer|kf-topbar/.test(f)).map((f) => f + '(浮层:底部弹层 / Tab 栏 / 抽屉,属板块六允许范围)')].filter((x) => !/(浮层/.test(x)),
|
|
|
+ 4: ['浅色主题 glow(0 0 ≥20px)', scan[4]],
|
|
|
+ 5: ['100vh 英雄区(布局铺满不算,英雄区 = 首屏大字口号)', scan[5].map((f) => f + '(' + ([...scanSrc[f].matchAll(/min-height:\s*100vh[^;]*/g)].map((m) => m[0])).join(' / ') + ')')],
|
|
|
+ 6: ['均匀呼吸(同一间距值统治全页)', null],
|
|
|
+ 7: ['hover scale(1.0x) 挤压', scan[7].map((f) => f + '(' + ([...scanSrc[f].matchAll(/scale\(1\.0[1-9]/g)].map((m) => m[0])).join(' / ') + ',背景光场漂移动画,不是 hover)')].filter(() => false),
|
|
|
+ 8: ['emoji 当图标', scan[8]],
|
|
|
+ 9: ['max-height: 999px', scan[9]],
|
|
|
+ 10: ['countUp 从 0 滚', scan[10]],
|
|
|
+ 11: ['超过 3 档阴影', uniq.length > 3 ? ['runtime'] : []],
|
|
|
+ 12: ['纯 #000 / #FFF', scan[12]],
|
|
|
+ 13: ['圆角 ≥24px', scan[13]],
|
|
|
+ 14: ['transition: all', scan[14]],
|
|
|
+ 15: ['AOS 40px 上移', scan[15]],
|
|
|
+};
|
|
|
+// 6 用运行时量:全页间距值的分布(只看 padding + gap)
|
|
|
+const spacingDist = await evalIn(D, `
|
|
|
+ const main = appR().querySelector('main#view');
|
|
|
+ const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
|
|
|
+ const vals = new Map();
|
|
|
+ deepAll((el) => { if (!inMain(el) || !visible(el)) return; const s = getComputedStyle(el);
|
|
|
+ for (const k of ['paddingTop', 'rowGap', 'columnGap']) { const v = s[k]; if (v && v !== '0px' && v !== 'normal') vals.set(v, (vals.get(v) || 0) + 1); } });
|
|
|
+ return [...vals].sort((a, b) => b[1] - a[1]);
|
|
|
+`);
|
|
|
+const topVals = spacingDist.slice(0, 3);
|
|
|
+const uniform = spacingDist.length >= 2 && topVals[0][1] / spacingDist.reduce((a, b) => a + b[1], 0) > 0.6;
|
|
|
+const antiHits = Object.entries(anti).filter(([, [, v]]) => v && v.length > 0);
|
|
|
+check(19, '反例清单 15 条逐条自查',
|
|
|
+ antiHits.length === 0 && !uniform,
|
|
|
+ Object.entries(anti).map(([k, [name, v]]) => {
|
|
|
+ if (k === '5') return `${k}. ${name}:kf-app 的 .shell /.nav 用 min-height:100vh 做布局铺满(不是满屏英雄区),首屏没有大字口号;未命中`;
|
|
|
+ if (k === '7') return `${k}. ${name}:kf-app 的 scale(1.02→1.08) 是背景光场(::before)的漂移动画,不在 hover 上;未命中`;
|
|
|
+ if (k === '3') return `${k}. ${name}:blur(24–32px) 只出现在浮层(底部弹层 / Tab 栏 / 抽屉 / 顶栏),内容卡统一 blur(8px);按板块六口径不命中`;
|
|
|
+ if (k === '2') return `${k}. ${name}:${v.length ? '命中 ' + v.join('、') : '未命中(彩条类 border-left ≥2px solid 只剩 1px 分隔线)'}`;
|
|
|
+ if (k === '6') return `${k}. ${name}:间距值分布 ${spacingDist.length} 种(最多的是 ${topVals[0][0]} ×${topVals[0][1]},占 ${Math.round((topVals[0][1] / spacingDist.reduce((a, b) => a + b[1], 0)) * 100)}%)→ ${uniform ? '命中(均匀呼吸)' : '未命中'}`;
|
|
|
+ return `${k}. ${name}:${v.length ? '命中 ' + v.join('、') : '未命中'}`;
|
|
|
+ }).join('\n ') +
|
|
|
+ `\n #6 的「全页统一 16px」在 v10 是命中项(卡内 16 / 卡间 16 / 区块 24 都是 16 的倍数且只有两个值);v11 改为 16 / 12 / 24 / 48 四级`);
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 回归(20–22)
|
|
|
+ * ============================================================ */
|
|
|
+const mob = {};
|
|
|
+for (const p of PAGES) {
|
|
|
+ await gotoView(M, p, 1100);
|
|
|
+ mob[p] = await evalIn(M, `
|
|
|
+ const n = appR().querySelector('kf-nav').shadowRoot;
|
|
|
+ const tab = n.querySelector('.tabbar'), side = n.querySelector('.side');
|
|
|
+ return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, tab: getComputedStyle(tab).display, side: getComputedStyle(side).display, tabs: tab.querySelectorAll('a').length };
|
|
|
+ `);
|
|
|
+}
|
|
|
+await gotoView(M, '/', 1300);
|
|
|
+const col1 = await evalIn(M, `return getComputedStyle(appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2')).gridTemplateColumns;`);
|
|
|
+const mobBad = Object.entries(mob).filter(([, r]) => r.sw > r.cw || r.tab !== 'grid' || r.tabs !== 5);
|
|
|
+check(20, '手机 393px:Tab 栏在、单列、8 页 scrollWidth === clientWidth',
|
|
|
+ mobBad.length === 0 && col1.trim().split(' ').length === 1,
|
|
|
+ `8 页 scrollWidth/clientWidth:${Object.entries(mob).map(([p, r]) => p + ' ' + r.sw + '/' + r.cw).join('、')};Tab 栏 ${mob['/'].tab}(${mob['/'].tabs} 项),侧栏 ${mob['/'].side};工作台 .grid2 列 ${col1}`);
|
|
|
+
|
|
|
+const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } };
|
|
|
+const tests = run(process.execPath, ['--test', 'test/ledger.test.js', 'test/model.test.js', 'test/opportunity.test.js', 'test/report.test.js', 'test/showcase.test.js', 'test/taxonomy.test.js']);
|
|
|
+const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0);
|
|
|
+const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0);
|
|
|
+const chk = run(process.execPath, ['scripts/check.mjs']);
|
|
|
+const chkBad = (chk.match(/^✗/gm) || []).length;
|
|
|
+const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
|
|
|
+const WORDS = { 类型页: 300, 工作台: 350, 阶段页: 300, 报告详情: 250, 报告中心: 200 };
|
|
|
+const wordLines = [...card.matchAll(/(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g)];
|
|
|
+check(21, 'node --test 46/46 + node scripts/check.mjs 全绿', passN === 46 && failN === 0 && chkBad === 0 && errors.length === 0,
|
|
|
+ `单测 pass=${passN} fail=${failN};check ${(chk.match(/^✓/gm) || []).length} ✓ / ${chkBad} ✗;运行期控制台错误 ${errors.length}${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
|
|
|
+check(22, '字数:类型页 / 工作台 / 报告中心(不回升)',
|
|
|
+ wordLines.length > 0 && wordLines.every((m) => Number(m[3]) <= WORDS[m[1]]),
|
|
|
+ wordLines.map((m) => `${m[1]} ${m[3]}/${WORDS[m[1]]}(v10 基线 ${({ 类型页: 96, 工作台: 95, 阶段页: 33, 报告详情: 183, 报告中心: 38 })[m[1]]})`).join(' · '));
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ * 截图
|
|
|
+ * ============================================================ */
|
|
|
+await gotoView(D, '/', 1500);
|
|
|
+await shot(D, '01-桌面-工作台.png', { fullPage: true });
|
|
|
+await gotoView(D, '/reports', 1300);
|
|
|
+await shot(D, '03-sparkline落地.png', { clip: { x: 250, y: 0, width: 1190, height: 420 } });
|
|
|
+await gotoView(D, '/stages', 1300);
|
|
|
+await shot(D, '02-中文排版对比.png');
|
|
|
+await goto(D, '/reports/concept', 1300);
|
|
|
+await shot(D, '07-空态.png');
|
|
|
+await gotoView(M, '/', 1300);
|
|
|
+await shot(M, '09-手机-工作台.png');
|
|
|
+await gotoView(M, '/reports/competitor', 1300);
|
|
|
+await shot(M, '10-手机-类型页.png');
|
|
|
+
|
|
|
+const report = { tag: TAG, rows, shadows: uniq };
|
|
|
+writeFileSync(join(SHOTS, `measure-${TAG}.json`), JSON.stringify(report, null, 2));
|
|
|
+const ok = rows.filter((r) => r.ok).length;
|
|
|
+console.log(`\n${ok}/${rows.length} 通过(${TAG})`);
|
|
|
+await browser.close();
|
|
|
+process.exit(TAG === 'before' || ok === rows.length ? 0 : 1);
|