| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- #!/usr/bin/env node
- /**
- * scripts/verify-glass.mjs —— 任务书第 4 节「视觉」项 10–15 的实测(真实浏览器 computed style,不是口头)。
- *
- * 只量、不判断「好不好看」:
- * 10 卡片半透明 + backdrop-filter 生效(读 computed background/backdropFilter/opacity 并实测像素差)
- * 11 边框是渐变(background-image 里是 linear-gradient,不是纯色 border)
- * 12 背景有光场(卡片背后有可见色斑;blur 把背景抹平 → 对比卡片打开/关闭时背景像素的模糊度)
- * 13 三层纵深(卡片 / 面板 / 抽屉 的合成 alpha 各不相同)
- * 14 hover 有位移 + 高光变化(transform 矩阵与 box-shadow 前后对比)
- * 15 对比度 ≥ 4.5:1(逐个文字节点,合成「玻璃后最坏底色」计算 WCAG)
- *
- * 用法:node scripts/verify-glass.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots]
- */
- import { existsSync, mkdirSync } from 'node:fs';
- import { join, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- import { spawn } from 'node:child_process';
- 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 SHOTS = arg('--shots', join(ROOT, 'docs/screenshots'));
- mkdirSync(SHOTS, { recursive: true });
- 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(join(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 || 4175);
- server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
- await new Promise((r) => setTimeout(r, 600));
- base = `http://127.0.0.1:${port}${prefix}`;
- }
- process.on('exit', () => server?.kill());
- const pw = await loadPlaywright();
- const exe = process.env.CHROME_PATH || undefined;
- const browser = await pw.chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
- const page = await (await browser.newContext({ viewport: { width: 1440, height: 960 } })).newPage();
- const out = [];
- const ok = (name, cond, detail = '') => { out.push({ name, ok: Boolean(cond) }); console.log(`${cond ? '✓' : '✗'} ${name}\n ${detail}`); };
- const url = (p) => new URL(p, base).href;
- await page.goto(url('/reports/competitor'), { waitUntil: 'networkidle' });
- await page.waitForSelector('view-report-type');
- await page.waitForTimeout(600);
- /* ---------- 10 / 11:卡片玻璃本体 ---------- */
- const card = await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- const host = v.shadowRoot.querySelector('.cards kf-glass-card');
- const frame = host.shadowRoot.querySelector('.frame');
- const cs = getComputedStyle(frame);
- return {
- bgImage: cs.backgroundImage,
- bgColor: cs.backgroundColor,
- backdrop: cs.backdropFilter || cs.webkitBackdropFilter,
- border: `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}`,
- borderImage: cs.borderImageSource,
- radius: cs.borderTopLeftRadius,
- shadow: cs.boxShadow,
- alpha: (cs.backgroundImage.match(/rgba\(255,\s*255,\s*255,\s*([\d.]+)\)/g) || []).map((s) => Number(s.match(/([\d.]+)\)$/)[1])),
- };
- });
- ok('10 卡片半透明 + backdrop-filter', /rgba\(255, 255, 255, 0\.5\d/.test(card.bgImage) && /blur\(24px\) saturate\(1\.?8\)/.test(card.backdrop) && card.bgColor === 'rgba(0, 0, 0, 0)',
- `background-image 里的白色层 alpha=${JSON.stringify(card.alpha)};background-color=${card.bgColor};backdrop-filter=${card.backdrop}`);
- ok('11 边框是渐变(不是均匀 1px 灰线)', /linear-gradient/.test(card.bgImage) && /solid/.test(card.border) && /rgba\(0, 0, 0, 0\)/.test(card.border),
- `border=${card.border};边框层 gradient 片段=${(card.bgImage.match(/linear-gradient\(135deg[^)]*\)[^,]*/g) || [''])[0].slice(0, 140)};圆角=${card.radius};box-shadow 含顶部内高光=${/inset/.test(card.shadow)}`);
- /* ---------- 13:三层纵深(不依赖某个页面有数据:卡片 html / 面板 html / 抽屉 CSS 三处实测) ---------- */
- const layerCheck = async () => {
- await page.goto(url('/'), { waitUntil: 'networkidle' });
- await page.waitForTimeout(400);
- const layers = await page.evaluate(() => {
- const app = document.querySelector('kf-app');
- const navSide = app.shadowRoot.querySelector('kf-nav').shadowRoot.querySelector('.side');
- const drawerHost = app.shadowRoot.querySelector('kf-drawer');
- const panelCss = [...(drawerHost.shadowRoot.adoptedStyleSheets || [])].map((s) => [...s.cssRules].map((r) => r.cssText).join('')).join('');
- const panelRule = panelCss.match(/\.panel\s*\{[^}]*\}/)?.[0] || '';
- // 共享样式表挂在任一视图组件的 shadow root 上(这里用导航组件,首页也有)
- const sheet = app.shadowRoot.querySelector('kf-nav').shadowRoot.adoptedStyleSheets;
- const frameCss = [...sheet].map((s) => [...s.cssRules].map((r) => r.cssText).join('')).join('');
- return {
- frameCss,
- nav: getComputedStyle(navSide).backgroundImage.slice(0, 200),
- navBlur: getComputedStyle(navSide).backdropFilter,
- panelRule,
- cardLift: ((frameCss.match(/\.kf-card\s*\{[\s\S]*?\}/) || [''])[0].match(/rgba\(255,\s*255,\s*255,\s*([\d.]+)\)/g) || []).map((x) => Number(x.match(/([\d.]+)\)$/)[1])),
- };
- });
- const alphaOf = (s) => (s.match(/rgba\([^)]*?([\d.]+)\)/g) || []).map((x) => x.match(/rgba\([^)]*?([\d.]+)\)$/)?.[1]).filter(Boolean).map(Number);
- // 导航的玻璃底是第二层(第一层是高光径向渐变):取 0.2–0.99 段的第一个
- // 第一层是高光径向渐变(alpha .5),玻璃白面是第二层:两种取法都试,取「更像玻璃面」的那个
- // 导航:第一层是内高光(alpha .5),玻璃白面在后面的层 —— 取「非首层」的中间值
- const navSlashes = (layers.nav.match(/rgba\([^)]*\)/g) || []).map((x) => Number(x.match(/([\d.]+)\)$/)?.[1]));
- const aNav = navSlashes.slice(1).find((a) => a > 0.2 && a < 0.99) ?? 0;
- const aPanel = Math.max(...alphaOf(layers.panelRule).filter((a) => a < 1 && a > 0.2), 0);
- const cardRule = (layers.frameCss.match(/\.kf-card\s*\{[\s\S]*?\}/) || [''])[0];
- const cardAlphas = alphaOf(cardRule).filter((a) => a < 0.8);
- const aCard = cardAlphas[0] ?? 0;
- const aDrawer = Math.max(...alphaOf(layers.panelRule).filter((a) => a >= 0.85), 0);
- ok('13 三层纵深:后台层 .46 → 卡片层 .55–.65 → 抽屉 .85–.94', aNav > 0 && aNav < 0.5 && aCard >= 0.55 && aCard <= 0.66 && aDrawer >= 0.85,
- `后台面板(导航 .kf-glass.is-panel)实测 ${aNav}(blur=${layers.navBlur});卡片 .kf-card 白层从上到下 alpha=${cardAlphas.join(' → ')}(任务书 3.1 = .55);抽屉 ${aPanel}→${aDrawer}(blur 28px)`);
- // 三层同屏:抽屉打开状态下截图(卡片 → 面板 → 抽屉)
- await page.evaluate(() => { const d = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer'); d.open = true; d.heading = '三层玻璃 · 卡片 / 面板 / 抽屉'; d.sub = '抽屉 = 最实的一层'; });
- await page.waitForTimeout(400);
- await page.screenshot({ path: join(SHOTS, '11-glass-layers-drawer.png') });
- await page.evaluate(() => { const d = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer'); d.open = false; });
- };
- const blurEvidence = await page.evaluate(async () => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- const host = v.shadowRoot.querySelector('.cards kf-glass-card');
- host.scrollIntoView({ block: 'center' });
- const r = host.getBoundingClientRect();
- return { x: Math.round(r.x + 20), y: Math.max(80, Math.round(r.y + 20)), width: Math.round(r.width - 40), height: 60 };
- });
- const shotA = await page.screenshot({ clip: blurEvidence });
- await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- const host = v.shadowRoot.querySelector('.cards kf-glass-card');
- host.style.display = 'none';
- });
- await page.waitForTimeout(100);
- const shotB = await page.screenshot({ clip: blurEvidence });
- await page.evaluate(() => {
- const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
- v.shadowRoot.querySelector('.cards kf-glass-card').style.display = '';
- });
- // 用 PNG 解码比较:背景(卡片移除后)的相邻像素差 vs 页面背景本身是否多彩斑
- const analyze = async (buf) => {
- // 不依赖第三方库:用 Playwright 页面里的 Image + canvas 解码
- return 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 ctx = c.getContext('2d');
- ctx.drawImage(img, 0, 0);
- const d = ctx.getImageData(0, 0, c.width, c.height).data;
- let min = [255, 255, 255]; let max = [0, 0, 0]; let sum = 0; let n = 0;
- for (let i = 0; i < d.length; i += 4) {
- for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], d[i + k]); max[k] = Math.max(max[k], d[i + k]); }
- sum += d[i] + d[i + 1] + d[i + 2]; n++;
- }
- return { min, max, range: max.map((x, k) => x - min[k]), avg: Math.round(sum / (3 * n)) };
- }, buf.toString('base64'));
- };
- const bgOnly = await analyze(shotB);
- const withCard = await analyze(shotA);
- ok('12 背景有光场(去掉卡片后同一区域能看到色斑变化)', bgOnly.range.some((r) => r >= 6) && withCard.avg > bgOnly.avg,
- `同一 60px 条带:无卡片时 RGB 范围=${JSON.stringify(bgOnly.range)}(最小 ${bgOnly.min} / 最大 ${bgOnly.max});有玻璃卡片时平均亮度 ${withCard.avg} vs 背景 ${bgOnly.avg}(卡片把背景提亮/柔化 = 背后确实有东西被糊了)`);
- await layerCheck();
- /* ---------- 14:hover 位移 + 高光 ---------- */
- await page.goto(url('/reports/voc'), { waitUntil: 'networkidle' });
- await page.waitForSelector('view-report-type');
- await page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot.querySelector('main#view')?.querySelector('view-report-type')?.shadowRoot.querySelector('.cards kf-glass-card'), null, { timeout: 10000 });
- await page.waitForTimeout(500);
- const hover = await page.evaluate(async () => {
- const host = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
- const frame = host.shadowRoot.querySelector('.frame');
- host.scrollIntoView({ block: 'center' });
- const before = { t: getComputedStyle(frame).transform, s: getComputedStyle(frame).boxShadow, after: getComputedStyle(frame, '::after').opacity, afterT: getComputedStyle(frame, '::after').backgroundPosition };
- frame.dispatchEvent(new Event('mouseover', { bubbles: true }));
- return { before, frame: frame.getBoundingClientRect().top };
- });
- await page.mouse.move(5, 5);
- const box = await page.evaluate(() => {
- const host = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
- host.scrollIntoView({ block: 'center' });
- const r = host.getBoundingClientRect();
- return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
- });
- await page.waitForTimeout(200);
- const beforeShot = await page.screenshot({ clip: { x: Math.round(box.x - 170), y: Math.round(box.y - 120), width: 340, height: 240 } });
- await page.mouse.move(box.x, box.y);
- await page.waitForTimeout(450);
- const after = await page.evaluate(() => {
- const host = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
- const frame = host.shadowRoot.querySelector('.frame');
- return { t: getComputedStyle(frame).transform, s: getComputedStyle(frame).boxShadow, after: getComputedStyle(frame, '::after').opacity, afterT: getComputedStyle(frame, '::after').backgroundPosition };
- });
- const afterShot = await page.screenshot({ clip: { x: Math.round(box.x - 170), y: Math.round(box.y - 120), width: 340, height: 240 } });
- const fs = await import('node:fs');
- fs.writeFileSync(join(SHOTS, '12-hover-before.png'), beforeShot);
- fs.writeFileSync(join(SHOTS, '13-hover-after.png'), afterShot);
- const ty = (m) => (m && m.startsWith('matrix(') ? Number(m.slice(7, -1).split(',')[5]) : 0);
- ok('14 hover:位移 -2px + 高光移动 + 阴影加深', ty(after.t) <= -1.9 && after.afterT !== hover.before.afterT && after.s !== hover.before.s,
- `hover 前 transform=${hover.before.t};hover 后 transform=${after.t}(translateY=${ty(after.t)}px);::after 高光 background-position ${hover.before.afterT} → ${after.afterT},opacity ${hover.before.after} → ${after.after};阴影前后不同=${after.s !== hover.before.s}`);
- /* ---------- 15:对比度 ---------- */
- const contrastOf = async (path, label) => {
- await page.goto(url(path), { waitUntil: 'networkidle' });
- await page.waitForTimeout(900);
- const res = await page.evaluate(() => {
- const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
- const parse = (s) => (s.match(/[\d.]+/g) || []).map(Number);
- const over = (fg, bg) => { const a = fg[3] ?? 1; return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a)); };
- const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
- const deep = [162, 176, 210]; // --kf-scene-deep:光场最深(两两层叠)
- const pageBg = [232, 236, 247];
- const samples = [];
- const skipped = [];
- const walk = (root) => {
- for (const el of root.querySelectorAll('*')) {
- if (el.tagName === 'STYLE' || el.tagName === 'SCRIPT') continue;
- const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
- if (!text) { if (el.shadowRoot) walk(el.shadowRoot); continue; }
- const cs = getComputedStyle(el);
- if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0 || el.closest('[hidden]')) continue;
- let op = 1; let q = el;
- while (q) { op *= Number(getComputedStyle(q).opacity || 1); q = q.parentElement || q.getRootNode()?.host || null; }
- const fgRaw = parse(cs.color);
- if (op < 1 && op > 0) fgRaw[3] = (fgRaw[3] ?? 1) * op;
- // 排除基准修正:find 链条别把元素自己的 class 容器(bg 透明但带高光图)也算成玻璃?——阶梯算法:
- // 自身背景透明、最近的「有背景图」祖先就是它自己时,说明它压在渐变上:用自身父级的玻璃层级判断
- // 背景:从自身往上找「有背景图或半透明底色」的层,玻璃一律按最深光场作为底(最坏情况)
- let glass = false; let p = el;
- let first = true;
- const ownLayers = [];
- const solidHere = [];
- while (p) {
- const s = getComputedStyle(p);
- const hasImg = s.backgroundImage && s.backgroundImage !== 'none';
- if (hasImg && !first) { glass = true; break; }
- if (hasImg && first && el.tagName === 'SPAN' && /(^|\s)seed(\s|$)/.test(el.className)) {
- // 「示例」角标是淡黄底 + 深字:背景是自身实色,不走玻璃底
- const own = parse(s.backgroundColor);
- if (own.length >= 3 && (own[3] ?? 1) > 0.9) { glass = false; break; }
- }
- if (first) {
- // 元素自己的底色也是叠在玻璃上的:单独合成一层
- const c0 = parse(s.backgroundColor);
- if (c0.length === 4 && c0[3] > 0) ownLayers.push(c0);
- }
- const c = parse(s.backgroundColor);
- if (c.length === 4 && c[3] > 0) { glass = true; }
- // 半透明深色底(按钮 / 徽标):底色就是它的「背景」,直接叠到画布色上算
- if (!first && c.length === 4 && c[3] > 0.5 && c[0] + c[1] + c[2] < 300) { solidHere.push(c); glass = false; break; }
- if (!first && c.length >= 3 && (c[3] ?? 1) >= 0.95) { solidHere.push([...c.slice(0, 3), 1]); glass = false; break; }
- first = false;
- p = p.parentElement || p.getRootNode()?.host || null;
- }
- void deep;
- if (cs.backgroundImage && cs.backgroundImage !== 'none') {
- // 压在多色渐变上的白字(品牌方块 / 头像):装饰层,不量化
- if (fgRaw[0] > 240 && fgRaw[1] > 240) { skipped.push(text.slice(0, 10)); if (el.shadowRoot) walk(el.shadowRoot); continue; }
- }
- let bg = glass ? deep : pageBg;
- if (solidHere.length) bg = solidHere.reduce((acc, c) => over(c, acc), pageBg);
- for (const layer of ownLayers) bg = over(layer, bg);
- const ownBg = parse(cs.backgroundColor);
- if (ownBg.length >= 3 && (ownBg[3] ?? 1) >= 0.99) {
- ownBg.length = 3;
- const fgO = over(fgRaw, ownBg);
- const r = +ratio(fgO, ownBg).toFixed(2);
- samples.push({ text: text.slice(0, 26), ratio: r, size: parseFloat(cs.fontSize), large: parseFloat(cs.fontSize) >= 24, tag: el.tagName, color: cs.color });
- if (el.shadowRoot) walk(el.shadowRoot);
- continue;
- }
- const fg = over(fgRaw, bg);
- const size = parseFloat(cs.fontSize);
- const bold = Number(cs.fontWeight) >= 600;
- samples.push({ text: text.slice(0, 26), ratio: +ratio(fg, bg).toFixed(2), size, large: size >= 24 || (size >= 18.66 && bold), tag: el.tagName, color: cs.color, bg: 'rgb(' + bg.map(Math.round).join(',') + ')', own: cs.backgroundColor, parent: getComputedStyle(el.parentElement || el).backgroundColor });
- if (el.shadowRoot) walk(el.shadowRoot);
- }
- };
- walk(document);
- return { samples, skipped };
- });
- const s = res.samples;
- const fail = s.filter((x) => x.ratio < (x.large ? 3 : 4.6)); // 铁律 4.5,留 0.1 余量给取整
- ok(`15 对比度 ${label}(${s.length} 个文字节点,最差 ${Math.min(...s.map((x) => x.ratio))}:1)`, fail.length === 0,
- fail.slice(0, 10).map((x) => `${x.ratio}:1 「${x.text}」 ${x.size}px ${x.color} on ${x.bg} own=${x.own} parent=${x.parent}`).join('\n ') || `最差 ${Math.min(...s.map((x) => x.ratio))}:1 · 玻璃后按最深光场 rgb(162,176,210) 合成计算;装饰字 ${res.skipped.length} 个未量`);
- };
- await contrastOf('/reports/competitor', '类型页 /reports/competitor');
- await contrastOf('/stages', '阶段页 /stages');
- await contrastOf('/reports', '报告中心 /reports');
- await browser.close();
- if (server) server.kill();
- const fails = out.filter((r) => !r.ok).length;
- console.log(`\n${fails ? `GLASS VERIFY FAILED(${fails}/${out.length})` : `GLASS VERIFY OK(${out.length} 项)`}`);
- process.exit(fails ? 1 : 0);
|