#!/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);