【本次核心 · S6-P21】 - 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams; data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。 - 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。 - 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 / 变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。 - 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成 TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed; 三份 CSS md5 完全相同 = 一份样式表服务三个组件。 - 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、 tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。 【同时清掉此前已完成但未提交的批次】 生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告) 跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交: - Package:三端可 import(S5-P18)+ 发布到私有 npm 源 - Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性 - Security:生产部署链审计修复(2026-09-19)+ 线上部署 - Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性; S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程 - 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、 安全审计修复与待决策项.md 【验收】 - node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变) - node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44) - node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时 - 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误 - 零运行时依赖 OK;build-site.ps1 ASCII-only OK 【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
199 lines
10 KiB
JavaScript
199 lines
10 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();
|
||
}
|
||
|
||
for (const lang of ['zh', 'en']) {
|
||
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
|
||
await page.addInitScript((l) => { try { localStorage.setItem('aa-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('#/design'), '交互 点链接后完成跳转');
|
||
assert(await p.evaluate(() => !!document.querySelector('.topnav a[data-nav="design"].active')), '交互 跳转后链接高亮');
|
||
|
||
await p.click('#lang-trigger');
|
||
await p.waitForTimeout(300);
|
||
const menuBefore = await p.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
|
||
await open();
|
||
const menuAfter = await p.evaluate(() => !document.querySelector('#lang-menu').classList.contains('hidden'));
|
||
assert(menuBefore && !menuAfter, '交互 开抽屉会收起语言菜单(避免叠层)');
|
||
|
||
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();
|
||
} 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);
|