verify-glass.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-glass.mjs —— 任务书第 4 节「视觉」项 10–15 的实测(真实浏览器 computed style,不是口头)。
  4. *
  5. * 只量、不判断「好不好看」:
  6. * 10 卡片半透明 + backdrop-filter 生效(读 computed background/backdropFilter/opacity 并实测像素差)
  7. * 11 边框是渐变(background-image 里是 linear-gradient,不是纯色 border)
  8. * 12 背景有光场(卡片背后有可见色斑;blur 把背景抹平 → 对比卡片打开/关闭时背景像素的模糊度)
  9. * 13 三层纵深(卡片 / 面板 / 抽屉 的合成 alpha 各不相同)
  10. * 14 hover 有位移 + 高光变化(transform 矩阵与 box-shadow 前后对比)
  11. * 15 对比度 ≥ 4.5:1(逐个文字节点,合成「玻璃后最坏底色」计算 WCAG)
  12. *
  13. * 用法:node scripts/verify-glass.mjs [--url http://127.0.0.1:4173/] [--shots docs/screenshots]
  14. */
  15. import { existsSync, mkdirSync } from 'node:fs';
  16. import { join, dirname } from 'node:path';
  17. import { fileURLToPath } from 'node:url';
  18. import { spawn } from 'node:child_process';
  19. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  20. const argv = process.argv.slice(2);
  21. const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
  22. const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots'));
  23. mkdirSync(SHOTS, { recursive: true });
  24. async function loadPlaywright() {
  25. for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
  26. if (existsSync(c)) return (await import(join(c, 'index.js'))).default ?? (await import(`${c}/index.js`));
  27. }
  28. return import('playwright');
  29. }
  30. let server = null;
  31. let base = arg('--url', '');
  32. if (!base) {
  33. const { writeConfig } = await import('./config.mjs');
  34. const { prefix } = writeConfig({ env: 'local' });
  35. const port = Number(process.env.VERIFY_PORT || 4175);
  36. server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  37. await new Promise((r) => setTimeout(r, 600));
  38. base = `http://127.0.0.1:${port}${prefix}`;
  39. }
  40. process.on('exit', () => server?.kill());
  41. const pw = await loadPlaywright();
  42. const exe = process.env.CHROME_PATH || undefined;
  43. const browser = await pw.chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
  44. const page = await (await browser.newContext({ viewport: { width: 1440, height: 960 } })).newPage();
  45. const out = [];
  46. const ok = (name, cond, detail = '') => { out.push({ name, ok: Boolean(cond) }); console.log(`${cond ? '✓' : '✗'} ${name}\n ${detail}`); };
  47. const url = (p) => new URL(p, base).href;
  48. await page.goto(url('/reports/competitor'), { waitUntil: 'networkidle' });
  49. await page.waitForSelector('view-report-type');
  50. await page.waitForTimeout(600);
  51. /* ---------- 10 / 11:卡片玻璃本体 ---------- */
  52. const card = await page.evaluate(() => {
  53. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  54. const host = v.shadowRoot.querySelector('.cards kf-glass-card');
  55. const frame = host.shadowRoot.querySelector('.frame');
  56. const cs = getComputedStyle(frame);
  57. return {
  58. bgImage: cs.backgroundImage,
  59. bgColor: cs.backgroundColor,
  60. backdrop: cs.backdropFilter || cs.webkitBackdropFilter,
  61. border: `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}`,
  62. borderImage: cs.borderImageSource,
  63. radius: cs.borderTopLeftRadius,
  64. shadow: cs.boxShadow,
  65. alpha: (cs.backgroundImage.match(/rgba\(255,\s*255,\s*255,\s*([\d.]+)\)/g) || []).map((s) => Number(s.match(/([\d.]+)\)$/)[1])),
  66. };
  67. });
  68. 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)',
  69. `background-image 里的白色层 alpha=${JSON.stringify(card.alpha)};background-color=${card.bgColor};backdrop-filter=${card.backdrop}`);
  70. ok('11 边框是渐变(不是均匀 1px 灰线)', /linear-gradient/.test(card.bgImage) && /solid/.test(card.border) && /rgba\(0, 0, 0, 0\)/.test(card.border),
  71. `border=${card.border};边框层 gradient 片段=${(card.bgImage.match(/linear-gradient\(135deg[^)]*\)[^,]*/g) || [''])[0].slice(0, 140)};圆角=${card.radius};box-shadow 含顶部内高光=${/inset/.test(card.shadow)}`);
  72. /* ---------- 13:三层纵深(不依赖某个页面有数据:卡片 html / 面板 html / 抽屉 CSS 三处实测) ---------- */
  73. const layerCheck = async () => {
  74. await page.goto(url('/'), { waitUntil: 'networkidle' });
  75. await page.waitForTimeout(400);
  76. const layers = await page.evaluate(() => {
  77. const app = document.querySelector('kf-app');
  78. const navSide = app.shadowRoot.querySelector('kf-nav').shadowRoot.querySelector('.side');
  79. const drawerHost = app.shadowRoot.querySelector('kf-drawer');
  80. const panelCss = [...(drawerHost.shadowRoot.adoptedStyleSheets || [])].map((s) => [...s.cssRules].map((r) => r.cssText).join('')).join('');
  81. const panelRule = panelCss.match(/\.panel\s*\{[^}]*\}/)?.[0] || '';
  82. // 共享样式表挂在任一视图组件的 shadow root 上(这里用导航组件,首页也有)
  83. const sheet = app.shadowRoot.querySelector('kf-nav').shadowRoot.adoptedStyleSheets;
  84. const frameCss = [...sheet].map((s) => [...s.cssRules].map((r) => r.cssText).join('')).join('');
  85. return {
  86. frameCss,
  87. nav: getComputedStyle(navSide).backgroundImage.slice(0, 200),
  88. navBlur: getComputedStyle(navSide).backdropFilter,
  89. panelRule,
  90. 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])),
  91. };
  92. });
  93. const alphaOf = (s) => (s.match(/rgba\([^)]*?([\d.]+)\)/g) || []).map((x) => x.match(/rgba\([^)]*?([\d.]+)\)$/)?.[1]).filter(Boolean).map(Number);
  94. // 导航的玻璃底是第二层(第一层是高光径向渐变):取 0.2–0.99 段的第一个
  95. // 第一层是高光径向渐变(alpha .5),玻璃白面是第二层:两种取法都试,取「更像玻璃面」的那个
  96. // 导航:第一层是内高光(alpha .5),玻璃白面在后面的层 —— 取「非首层」的中间值
  97. const navSlashes = (layers.nav.match(/rgba\([^)]*\)/g) || []).map((x) => Number(x.match(/([\d.]+)\)$/)?.[1]));
  98. const aNav = navSlashes.slice(1).find((a) => a > 0.2 && a < 0.99) ?? 0;
  99. const aPanel = Math.max(...alphaOf(layers.panelRule).filter((a) => a < 1 && a > 0.2), 0);
  100. const cardRule = (layers.frameCss.match(/\.kf-card\s*\{[\s\S]*?\}/) || [''])[0];
  101. const cardAlphas = alphaOf(cardRule).filter((a) => a < 0.8);
  102. const aCard = cardAlphas[0] ?? 0;
  103. const aDrawer = Math.max(...alphaOf(layers.panelRule).filter((a) => a >= 0.85), 0);
  104. ok('13 三层纵深:后台层 .46 → 卡片层 .55–.65 → 抽屉 .85–.94', aNav > 0 && aNav < 0.5 && aCard >= 0.55 && aCard <= 0.66 && aDrawer >= 0.85,
  105. `后台面板(导航 .kf-glass.is-panel)实测 ${aNav}(blur=${layers.navBlur});卡片 .kf-card 白层从上到下 alpha=${cardAlphas.join(' → ')}(任务书 3.1 = .55);抽屉 ${aPanel}→${aDrawer}(blur 28px)`);
  106. // 三层同屏:抽屉打开状态下截图(卡片 → 面板 → 抽屉)
  107. await page.evaluate(() => { const d = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer'); d.open = true; d.heading = '三层玻璃 · 卡片 / 面板 / 抽屉'; d.sub = '抽屉 = 最实的一层'; });
  108. await page.waitForTimeout(400);
  109. await page.screenshot({ path: join(SHOTS, '11-glass-layers-drawer.png') });
  110. await page.evaluate(() => { const d = document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer'); d.open = false; });
  111. };
  112. const blurEvidence = await page.evaluate(async () => {
  113. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  114. const host = v.shadowRoot.querySelector('.cards kf-glass-card');
  115. host.scrollIntoView({ block: 'center' });
  116. const r = host.getBoundingClientRect();
  117. return { x: Math.round(r.x + 20), y: Math.max(80, Math.round(r.y + 20)), width: Math.round(r.width - 40), height: 60 };
  118. });
  119. const shotA = await page.screenshot({ clip: blurEvidence });
  120. await page.evaluate(() => {
  121. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  122. const host = v.shadowRoot.querySelector('.cards kf-glass-card');
  123. host.style.display = 'none';
  124. });
  125. await page.waitForTimeout(100);
  126. const shotB = await page.screenshot({ clip: blurEvidence });
  127. await page.evaluate(() => {
  128. const v = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type');
  129. v.shadowRoot.querySelector('.cards kf-glass-card').style.display = '';
  130. });
  131. // 用 PNG 解码比较:背景(卡片移除后)的相邻像素差 vs 页面背景本身是否多彩斑
  132. const analyze = async (buf) => {
  133. // 不依赖第三方库:用 Playwright 页面里的 Image + canvas 解码
  134. return page.evaluate(async (b64) => {
  135. const img = new Image();
  136. img.src = 'data:image/png;base64,' + b64;
  137. await img.decode();
  138. const c = document.createElement('canvas');
  139. c.width = img.width; c.height = img.height;
  140. const ctx = c.getContext('2d');
  141. ctx.drawImage(img, 0, 0);
  142. const d = ctx.getImageData(0, 0, c.width, c.height).data;
  143. let min = [255, 255, 255]; let max = [0, 0, 0]; let sum = 0; let n = 0;
  144. for (let i = 0; i < d.length; i += 4) {
  145. 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]); }
  146. sum += d[i] + d[i + 1] + d[i + 2]; n++;
  147. }
  148. return { min, max, range: max.map((x, k) => x - min[k]), avg: Math.round(sum / (3 * n)) };
  149. }, buf.toString('base64'));
  150. };
  151. const bgOnly = await analyze(shotB);
  152. const withCard = await analyze(shotA);
  153. ok('12 背景有光场(去掉卡片后同一区域能看到色斑变化)', bgOnly.range.some((r) => r >= 6) && withCard.avg > bgOnly.avg,
  154. `同一 60px 条带:无卡片时 RGB 范围=${JSON.stringify(bgOnly.range)}(最小 ${bgOnly.min} / 最大 ${bgOnly.max});有玻璃卡片时平均亮度 ${withCard.avg} vs 背景 ${bgOnly.avg}(卡片把背景提亮/柔化 = 背后确实有东西被糊了)`);
  155. await layerCheck();
  156. /* ---------- 14:hover 位移 + 高光 ---------- */
  157. await page.goto(url('/reports/voc'), { waitUntil: 'networkidle' });
  158. await page.waitForSelector('view-report-type');
  159. await page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot.querySelector('main#view')?.querySelector('view-report-type')?.shadowRoot.querySelector('.cards kf-glass-card'), null, { timeout: 10000 });
  160. await page.waitForTimeout(500);
  161. const hover = await page.evaluate(async () => {
  162. const host = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
  163. const frame = host.shadowRoot.querySelector('.frame');
  164. host.scrollIntoView({ block: 'center' });
  165. const before = { t: getComputedStyle(frame).transform, s: getComputedStyle(frame).boxShadow, after: getComputedStyle(frame, '::after').opacity, afterT: getComputedStyle(frame, '::after').backgroundPosition };
  166. frame.dispatchEvent(new Event('mouseover', { bubbles: true }));
  167. return { before, frame: frame.getBoundingClientRect().top };
  168. });
  169. await page.mouse.move(5, 5);
  170. const box = await page.evaluate(() => {
  171. const host = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
  172. host.scrollIntoView({ block: 'center' });
  173. const r = host.getBoundingClientRect();
  174. return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
  175. });
  176. await page.waitForTimeout(200);
  177. const beforeShot = await page.screenshot({ clip: { x: Math.round(box.x - 170), y: Math.round(box.y - 120), width: 340, height: 240 } });
  178. await page.mouse.move(box.x, box.y);
  179. await page.waitForTimeout(450);
  180. const after = await page.evaluate(() => {
  181. const host = document.querySelector('kf-app').shadowRoot.querySelector('main#view').querySelector('view-report-type').shadowRoot.querySelector('.cards kf-glass-card');
  182. const frame = host.shadowRoot.querySelector('.frame');
  183. return { t: getComputedStyle(frame).transform, s: getComputedStyle(frame).boxShadow, after: getComputedStyle(frame, '::after').opacity, afterT: getComputedStyle(frame, '::after').backgroundPosition };
  184. });
  185. const afterShot = await page.screenshot({ clip: { x: Math.round(box.x - 170), y: Math.round(box.y - 120), width: 340, height: 240 } });
  186. const fs = await import('node:fs');
  187. fs.writeFileSync(join(SHOTS, '12-hover-before.png'), beforeShot);
  188. fs.writeFileSync(join(SHOTS, '13-hover-after.png'), afterShot);
  189. const ty = (m) => (m && m.startsWith('matrix(') ? Number(m.slice(7, -1).split(',')[5]) : 0);
  190. ok('14 hover:位移 -2px + 高光移动 + 阴影加深', ty(after.t) <= -1.9 && after.afterT !== hover.before.afterT && after.s !== hover.before.s,
  191. `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}`);
  192. /* ---------- 15:对比度 ---------- */
  193. const contrastOf = async (path, label) => {
  194. await page.goto(url(path), { waitUntil: 'networkidle' });
  195. await page.waitForTimeout(900);
  196. const res = await page.evaluate(() => {
  197. 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); };
  198. const parse = (s) => (s.match(/[\d.]+/g) || []).map(Number);
  199. const over = (fg, bg) => { const a = fg[3] ?? 1; return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a)); };
  200. const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
  201. const deep = [162, 176, 210]; // --kf-scene-deep:光场最深(两两层叠)
  202. const pageBg = [232, 236, 247];
  203. const samples = [];
  204. const skipped = [];
  205. const walk = (root) => {
  206. for (const el of root.querySelectorAll('*')) {
  207. if (el.tagName === 'STYLE' || el.tagName === 'SCRIPT') continue;
  208. const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
  209. if (!text) { if (el.shadowRoot) walk(el.shadowRoot); continue; }
  210. const cs = getComputedStyle(el);
  211. if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0 || el.closest('[hidden]')) continue;
  212. let op = 1; let q = el;
  213. while (q) { op *= Number(getComputedStyle(q).opacity || 1); q = q.parentElement || q.getRootNode()?.host || null; }
  214. const fgRaw = parse(cs.color);
  215. if (op < 1 && op > 0) fgRaw[3] = (fgRaw[3] ?? 1) * op;
  216. // 排除基准修正:find 链条别把元素自己的 class 容器(bg 透明但带高光图)也算成玻璃?——阶梯算法:
  217. // 自身背景透明、最近的「有背景图」祖先就是它自己时,说明它压在渐变上:用自身父级的玻璃层级判断
  218. // 背景:从自身往上找「有背景图或半透明底色」的层,玻璃一律按最深光场作为底(最坏情况)
  219. let glass = false; let p = el;
  220. let first = true;
  221. const ownLayers = [];
  222. const solidHere = [];
  223. while (p) {
  224. const s = getComputedStyle(p);
  225. const hasImg = s.backgroundImage && s.backgroundImage !== 'none';
  226. if (hasImg && !first) { glass = true; break; }
  227. if (hasImg && first && el.tagName === 'SPAN' && /(^|\s)seed(\s|$)/.test(el.className)) {
  228. // 「示例」角标是淡黄底 + 深字:背景是自身实色,不走玻璃底
  229. const own = parse(s.backgroundColor);
  230. if (own.length >= 3 && (own[3] ?? 1) > 0.9) { glass = false; break; }
  231. }
  232. if (first) {
  233. // 元素自己的底色也是叠在玻璃上的:单独合成一层
  234. const c0 = parse(s.backgroundColor);
  235. if (c0.length === 4 && c0[3] > 0) ownLayers.push(c0);
  236. }
  237. const c = parse(s.backgroundColor);
  238. if (c.length === 4 && c[3] > 0) { glass = true; }
  239. // 半透明深色底(按钮 / 徽标):底色就是它的「背景」,直接叠到画布色上算
  240. if (!first && c.length === 4 && c[3] > 0.5 && c[0] + c[1] + c[2] < 300) { solidHere.push(c); glass = false; break; }
  241. if (!first && c.length >= 3 && (c[3] ?? 1) >= 0.95) { solidHere.push([...c.slice(0, 3), 1]); glass = false; break; }
  242. first = false;
  243. p = p.parentElement || p.getRootNode()?.host || null;
  244. }
  245. void deep;
  246. if (cs.backgroundImage && cs.backgroundImage !== 'none') {
  247. // 压在多色渐变上的白字(品牌方块 / 头像):装饰层,不量化
  248. if (fgRaw[0] > 240 && fgRaw[1] > 240) { skipped.push(text.slice(0, 10)); if (el.shadowRoot) walk(el.shadowRoot); continue; }
  249. }
  250. let bg = glass ? deep : pageBg;
  251. if (solidHere.length) bg = solidHere.reduce((acc, c) => over(c, acc), pageBg);
  252. for (const layer of ownLayers) bg = over(layer, bg);
  253. const ownBg = parse(cs.backgroundColor);
  254. if (ownBg.length >= 3 && (ownBg[3] ?? 1) >= 0.99) {
  255. ownBg.length = 3;
  256. const fgO = over(fgRaw, ownBg);
  257. const r = +ratio(fgO, ownBg).toFixed(2);
  258. samples.push({ text: text.slice(0, 26), ratio: r, size: parseFloat(cs.fontSize), large: parseFloat(cs.fontSize) >= 24, tag: el.tagName, color: cs.color });
  259. if (el.shadowRoot) walk(el.shadowRoot);
  260. continue;
  261. }
  262. const fg = over(fgRaw, bg);
  263. const size = parseFloat(cs.fontSize);
  264. const bold = Number(cs.fontWeight) >= 600;
  265. 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 });
  266. if (el.shadowRoot) walk(el.shadowRoot);
  267. }
  268. };
  269. walk(document);
  270. return { samples, skipped };
  271. });
  272. const s = res.samples;
  273. const fail = s.filter((x) => x.ratio < (x.large ? 3 : 4.6)); // 铁律 4.5,留 0.1 余量给取整
  274. ok(`15 对比度 ${label}(${s.length} 个文字节点,最差 ${Math.min(...s.map((x) => x.ratio))}:1)`, fail.length === 0,
  275. 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} 个未量`);
  276. };
  277. await contrastOf('/reports/competitor', '类型页 /reports/competitor');
  278. await contrastOf('/stages', '阶段页 /stages');
  279. await contrastOf('/reports', '报告中心 /reports');
  280. await browser.close();
  281. if (server) server.kill();
  282. const fails = out.filter((r) => !r.ok).length;
  283. console.log(`\n${fails ? `GLASS VERIFY FAILED(${fails}/${out.length})` : `GLASS VERIFY OK(${out.length} 项)`}`);
  284. process.exit(fails ? 1 : 0);