Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
248 lines
14 KiB
JavaScript
248 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-nav-responsive.mjs — 顶栏响应式验收(≤1366px 折叠为汉堡 + 抽屉)
|
||
*
|
||
* 前置:仓库根目录起 dev-server(node site/dev-server.js)
|
||
* 依赖:npm i -D playwright(CI 专用;运行时零依赖不变)
|
||
* 运行:node tools/verify-nav-responsive.mjs
|
||
* 退出码:0 全通过 / 1 有断言失败 / 2 环境问题
|
||
*
|
||
* 为什么需要它:顶栏的窄屏行为只在真实布局下才成立 —— 元素"可见"不等于"点得到"
|
||
* (遮罩层叠、visibility 过渡都会让点击静默失效),也不等于"文字没顶出 60px 栏"。
|
||
* 本脚本把这两类都测了,改动前实测到的缺陷即由它定位。
|
||
*
|
||
* 断言:
|
||
* A. 18 个宽度 × 中英双语:顶栏无横向溢出;无链接文字顶出 60px 顶栏(出血);
|
||
* 宽屏(>1366)链接必须单行且在视口内;窄屏(≤1366)汉堡可见。
|
||
* B. 抽屉交互:点汉堡打开 → 7 条链接全部落在视口内、遮罩出现、页面滚动被锁;
|
||
* Esc / 点遮罩 / 点链接 三种方式都能关闭;aria-expanded 同步;
|
||
* 打开时焦点进抽屉、关闭时焦点还给汉堡;放大回宽屏自动复位(不留滚动锁)。
|
||
*
|
||
* 断点依据(改动前实测,Chromium):
|
||
* 中文 1280px 起 7 条链接全部折行,1100px 起文字顶出顶栏,960px 起顶栏溢出;
|
||
* 英文 1366px 起顶栏溢出,1200px 起最右链接已不可达,480px 起 7 条全部不可见。
|
||
* 英文是约束方,故折叠阈值取 1366px。
|
||
*/
|
||
import { chromium } from 'playwright';
|
||
|
||
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
|
||
const URL = `${BASE}/site/`;
|
||
const COLLAPSE = 1366; // 与 style.css 的 @media (max-width: 1366px) 必须一致
|
||
const WIDTHS = [1600, 1440, 1401, 1400, 1367, 1366, 1320, 1280, 1100, 1024, 960, 900, 820, 768, 640, 480, 375, 320];
|
||
|
||
const failures = [];
|
||
function assert(ok, msg) {
|
||
if (!ok) failures.push(msg);
|
||
console.log(`[nav-responsive] ${ok ? 'OK ' : 'FAIL'} ${msg}`);
|
||
}
|
||
|
||
/* 在页面上下文里量顶栏:溢出、出血、链接可达性 */
|
||
const probeLayout = (collapse) => {
|
||
const q = (s) => document.querySelector(s);
|
||
const inner = q('.topbar-inner');
|
||
const nav = q('.topnav');
|
||
const btn = q('#nav-toggle');
|
||
const tb = q('.topbar').getBoundingClientRect();
|
||
const vw = document.documentElement.clientWidth;
|
||
const lines = (el) => {
|
||
const r = document.createRange();
|
||
r.selectNodeContents(el);
|
||
const rects = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5);
|
||
return { n: rects.length, top: rects.length ? Math.min(...rects.map((x) => x.top)) : 0, bottom: rects.length ? Math.max(...rects.map((x) => x.bottom)) : 0 };
|
||
};
|
||
const links = [...nav.querySelectorAll('a')].map((a) => {
|
||
const b = a.getBoundingClientRect();
|
||
const t = lines(a);
|
||
const inViewX = b.left >= 0 && b.right <= vw;
|
||
return {
|
||
text: a.textContent.trim(),
|
||
inView: inViewX && b.top >= 0 && b.top < 900,
|
||
lines: t.n,
|
||
// 只判视口内链接:抽屉关闭时链接被 translateX 移出视口属预期,不是缺陷
|
||
bleed: inViewX && (t.bottom > tb.bottom + 0.5 || t.top < tb.top - 0.5),
|
||
};
|
||
});
|
||
return {
|
||
overflow: inner.scrollWidth - inner.clientWidth,
|
||
bleed: links.filter((x) => x.bleed).map((x) => x.text),
|
||
inView: links.filter((x) => x.inView).length,
|
||
total: links.length,
|
||
maxLines: Math.max(...links.map((x) => x.lines)),
|
||
hamburger: btn ? getComputedStyle(btn).display !== 'none' : false,
|
||
drawerOpen: document.body.classList.contains('nav-open'),
|
||
navVisibility: getComputedStyle(nav).visibility,
|
||
backdrop: q('#nav-backdrop') ? getComputedStyle(q('#nav-backdrop')).display !== 'none' : false,
|
||
bodyOverflow: getComputedStyle(document.body).overflow,
|
||
drawerLeft: Math.round(nav.getBoundingClientRect().left),
|
||
expanded: btn ? btn.getAttribute('aria-expanded') : null,
|
||
};
|
||
};
|
||
|
||
const browser = await chromium.launch();
|
||
try {
|
||
/* 环境自检:服务未起或页面结构缺失时报 exit 2(同 run-regression.mjs 的口径),
|
||
否则在 CI 上会退化成一串看不懂的断言失败。 */
|
||
{
|
||
const probe = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||
try {
|
||
const res = await probe.goto(URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||
if (!res || !res.ok()) throw new Error(`HTTP ${res ? res.status() : 'no response'}`);
|
||
/* 必须用 attached:汉堡按钮在 >1366px 是 display:none(设计如此),
|
||
默认的 visible 状态会在这里误报环境失败。 */
|
||
await probe.waitForSelector('#nav-toggle', { state: 'attached', timeout: 8000 });
|
||
} catch (err) {
|
||
console.error(`[nav-responsive] ENV FAIL: 无法加载 ${URL}(${err.message})`);
|
||
console.error('[nav-responsive] 先起服务:node site/dev-server.js');
|
||
await browser.close();
|
||
process.exit(2);
|
||
}
|
||
/* 字体度量基准:宽度断言依赖字体度量,CI 上缺 CJK 字体时中文会退化成豆腐块/拉丁回退,
|
||
宽度随之改变。打印基准值,好让日后的失败一眼可定位到"字体变了"而不是"布局坏了"。 */
|
||
const m = await probe.evaluate(() => {
|
||
const c = document.createElement('canvas').getContext('2d');
|
||
c.font = '14px sans-serif';
|
||
return {
|
||
han1: +c.measureText('国').width.toFixed(1),
|
||
han4: +c.measureText('快速开始').width.toFixed(1),
|
||
lat: +c.measureText('Getting Started').width.toFixed(1),
|
||
};
|
||
});
|
||
console.log(`[nav-responsive] 字体度量基准 14px:中文单字=${m.han1}px 快速开始=${m.han4}px "Getting Started"=${m.lat}px`);
|
||
await probe.close();
|
||
}
|
||
|
||
/* 字体容忍度诊断:把顶栏所有承载文字的元素字号乘以 k,二分找出仍不折行/不溢出的最大 k。
|
||
用途是把"CI 字体与开发机不同"这一风险变成可见的余量数字 —— 若某天 k 掉到接近 1,
|
||
说明余量被吃掉,此时失败信息能直接指向字体而不是"布局坏了"。
|
||
本机实测:k≈1.175(Verdana 最宽也只有 1.058,Arial/Segoe UI 为 0.918)。 */
|
||
{
|
||
const p = await browser.newPage({ viewport: { width: COLLAPSE + 1, height: 900 } });
|
||
await p.addInitScript(() => { try { localStorage.setItem('kole-lang', 'en'); } catch (e) {} });
|
||
await p.goto(URL, { waitUntil: 'domcontentloaded' });
|
||
await p.waitForTimeout(1200);
|
||
await p.evaluate(() => {
|
||
/* 承载顶栏文字的元素清单:随 UI 走。2026-09-20 技术栈选择器从 <label>+<select> 改为
|
||
胶囊触发器后,文字落在 .fw-cur 上(原 .fw-label 已不存在)—— 清单不跟着改,
|
||
字体容忍度就会少算一个元素,把余量报得偏乐观。 */
|
||
const SEL = ['.logo-text', '.topnav a', '.search-trigger .st-text', '.search-trigger kbd', '.fw-cur', '.fw-menu .fw-opt', '.lang-cur'];
|
||
window.__b = [];
|
||
SEL.forEach((s) => document.querySelectorAll(s).forEach((el) => window.__b.push([el, parseFloat(getComputedStyle(el).fontSize)])));
|
||
window.__k = (k) => window.__b.forEach(([el, base]) => { el.style.fontSize = (base * k) + 'px'; });
|
||
window.__ok = () => {
|
||
const inner = document.querySelector('.topbar-inner');
|
||
const nav = document.querySelector('.topnav');
|
||
const lc = (el) => { const r = document.createRange(); r.selectNodeContents(el); return [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5).length; };
|
||
return inner.scrollWidth - inner.clientWidth === 0 && Math.max(...[...nav.querySelectorAll('a')].map(lc)) === 1;
|
||
};
|
||
});
|
||
let lo = 1, hi = 1.6;
|
||
for (let i = 0; i < 12; i++) {
|
||
const mid = (lo + hi) / 2;
|
||
await p.evaluate((k) => window.__k(k), mid);
|
||
await p.waitForTimeout(60);
|
||
if (await p.evaluate(() => window.__ok())) lo = mid; else hi = mid;
|
||
}
|
||
console.log(`[nav-responsive] 字体容忍度(${COLLAPSE + 1}px · 英文):文字宽度可再增 ${((lo - 1) * 100).toFixed(1)}% 仍不折行、不溢出`);
|
||
if (lo < 1.05) {
|
||
console.log('[nav-responsive] 注意:余量已不足 5%,真实字体的宽度差异可能触发折行 —— 需要收紧顶栏或提高折叠阈值');
|
||
}
|
||
await p.close();
|
||
}
|
||
|
||
for (const lang of ['zh', 'en']) {
|
||
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
|
||
await page.addInitScript((l) => { try { localStorage.setItem('kole-lang', l); } catch (e) {} }, lang);
|
||
await page.goto(URL, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForTimeout(1500);
|
||
|
||
for (const w of WIDTHS) {
|
||
await page.setViewportSize({ width: w, height: 900 });
|
||
await page.waitForTimeout(180);
|
||
const L = await page.evaluate(probeLayout, COLLAPSE);
|
||
const tag = `${lang}@${w}px`;
|
||
|
||
assert(L.overflow === 0, `${tag} 顶栏无横向溢出${L.overflow ? `(实测 ${L.overflow}px)` : ''}`);
|
||
assert(L.bleed.length === 0, `${tag} 无链接文字顶出顶栏${L.bleed.length ? `(${L.bleed.join(',')})` : ''}`);
|
||
|
||
if (w > COLLAPSE) {
|
||
assert(!L.hamburger, `${tag} 宽屏不显示汉堡`);
|
||
assert(L.maxLines === 1, `${tag} 链接单行(实测最多 ${L.maxLines} 行)`);
|
||
assert(L.inView === L.total, `${tag} 链接全部可见(${L.inView}/${L.total})`);
|
||
} else {
|
||
assert(L.hamburger, `${tag} 窄屏显示汉堡`);
|
||
await page.click('#nav-toggle');
|
||
await page.waitForTimeout(340);
|
||
const O = await page.evaluate(probeLayout, COLLAPSE);
|
||
assert(O.drawerOpen && O.navVisibility === 'visible', `${tag} 点汉堡后抽屉打开`);
|
||
assert(O.inView === O.total, `${tag} 抽屉内链接全部可达(${O.inView}/${O.total})`);
|
||
assert(O.backdrop, `${tag} 遮罩出现`);
|
||
assert(O.bodyOverflow === 'hidden', `${tag} 抽屉打开时锁定页面滚动`);
|
||
assert(O.drawerLeft >= 0, `${tag} 抽屉未被推出视口(left=${O.drawerLeft})`);
|
||
assert(O.expanded === 'true', `${tag} aria-expanded 同步为 true`);
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(340);
|
||
const C = await page.evaluate(probeLayout, COLLAPSE);
|
||
assert(!C.drawerOpen, `${tag} Esc 关闭抽屉`);
|
||
assert(C.expanded === 'false', `${tag} 关闭后 aria-expanded 同步为 false`);
|
||
}
|
||
}
|
||
await page.close();
|
||
}
|
||
|
||
/* ---- 交互序列(390px 中文)---- */
|
||
const p = await browser.newPage({ viewport: { width: 390, height: 800 } });
|
||
await p.goto(URL, { waitUntil: 'domcontentloaded' });
|
||
await p.waitForTimeout(1500);
|
||
const open = async () => { await p.click('#nav-toggle'); await p.waitForTimeout(340); };
|
||
const isOpen = () => p.evaluate(() => document.body.classList.contains('nav-open'));
|
||
|
||
await open();
|
||
assert(await p.evaluate(() => document.activeElement === document.querySelector('.topnav a')), '交互 打开后焦点进入抽屉首链接');
|
||
const left = (await p.evaluate(() => Math.round(document.querySelector('.topnav').getBoundingClientRect().left)));
|
||
await p.mouse.click(Math.max(5, left - 20), 500);
|
||
await p.waitForTimeout(340);
|
||
assert(!(await isOpen()), '交互 点遮罩关闭');
|
||
assert(await p.evaluate(() => document.activeElement === document.querySelector('#nav-toggle')), '交互 关闭后焦点还给汉堡');
|
||
|
||
await open();
|
||
await p.click('.topnav a[data-nav="design"]');
|
||
await p.waitForTimeout(700);
|
||
assert(!(await isOpen()), '交互 点链接后抽屉关闭');
|
||
assert(p.url().includes('/site/design'), '交互 点链接后完成跳转');
|
||
assert(await p.evaluate(() => !!document.querySelector('.topnav a[data-nav="design"].active')), '交互 跳转后链接高亮');
|
||
|
||
/* 语言选择器的窄屏可见性由顶栏拥挤度决定(工作区里出现过又撤掉了「≤420px 让位」那条规则),
|
||
因此这里不绑定具体规则:叠层检查固定放在语言选择器可见的宽度(480px)上做,
|
||
390px 这一段只覆盖抽屉交互本身。 */
|
||
await p.setViewportSize({ width: 1600, height: 800 });
|
||
await p.waitForTimeout(450);
|
||
assert(!(await isOpen()), '交互 放大回宽屏自动复位 nav-open');
|
||
assert((await p.evaluate(() => getComputedStyle(document.body).overflow)) !== 'hidden', '交互 放大后页面滚动解锁');
|
||
assert(await p.evaluate(() => getComputedStyle(document.querySelector('#nav-toggle')).display === 'none'), '交互 放大后汉堡隐藏');
|
||
assert(await p.evaluate(() => {
|
||
const b = document.querySelector('.topnav').getBoundingClientRect();
|
||
return b.top < 60 && b.height <= 61 && b.right <= innerWidth;
|
||
}), '交互 放大后导航回到顶栏内单行');
|
||
await p.close();
|
||
|
||
/* ---- 语言菜单与抽屉的叠层关系(480px:语言选择器可见,抽屉仍为汉堡形态)---- */
|
||
const pLang = await browser.newPage({ viewport: { width: 480, height: 800 } });
|
||
await pLang.goto(URL, { waitUntil: 'domcontentloaded' });
|
||
await pLang.waitForTimeout(1200);
|
||
const langVisible = await pLang.evaluate(() => getComputedStyle(document.querySelector('.lang-select')).display !== 'none');
|
||
assert(langVisible, '交互 480px 语言选择器可见');
|
||
await pLang.click('#lang-trigger');
|
||
await pLang.waitForTimeout(300);
|
||
const menuBefore = await pLang.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
|
||
await pLang.click('#nav-toggle');
|
||
await pLang.waitForTimeout(340);
|
||
const menuAfter = await pLang.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
|
||
assert(menuBefore && !menuAfter, '交互 开抽屉会收起语言菜单(避免叠层)');
|
||
await pLang.close();
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
console.log(`\n[nav-responsive] ${failures.length === 0 ? 'OK — all checks passed' : `FAIL — ${failures.length} 项未通过`}`);
|
||
process.exit(failures.length === 0 ? 0 : 1);
|