Files
hoelee-blog/scripts/probe-layout.mjs
T
hoelee 041471d25e feat(listing): portfolio-first archive, case-study hero, row action links
/posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the
complete year/month archive, with the flagship block collapsing (inert, h=0) as
soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact
two-column hero with a 21:9 deterministic topology banner, flagship ordering from
lib/portfolio.ts instead of pubDate, and one measured pill per card.

Also fixes what the screenshot review turned up: ZH archive rows and category chips
were printing raw English slugs, the two chip strips were unlabelled and read as one
list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav
had no current-section indicator, and mobile title links were under the 44px tap
minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport
checks and the collapse test.
2026-10-27 04:06:54 +08:00

195 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Layout probe over CDP: horizontal-overflow + interaction checks at fixed viewports.
*
* Uses Node's built-in WebSocket client (Node >= 22) so nothing has to be installed.
*
* node scripts/probe-layout.mjs [baseUrl]
*
* Requires a headless Chrome with remote debugging and a static server on `baseUrl`
* (default http://127.0.0.1:4321, i.e. `python -m http.server 4321 --directory dist`).
*
* Checks per page × width:
* - documentElement.scrollWidth === innerWidth (no horizontal scroll)
* - names every element whose right edge exceeds the viewport by > 1px (so a failure
* is actionable instead of "something overflows")
* - the /posts/ featured block collapses on the first keystroke and comes back when
* the filter is cleared
*/
const BASE = process.argv[2] ?? 'http://127.0.0.1:4321';
const CDP = process.argv[3] ?? 'http://127.0.0.1:9333';
const PAGES = [
'/',
'/posts/',
'/zh/posts/',
'/categories/',
'/categories/case-studies/',
'/zh/categories/case-studies/',
'/categories/devops/',
'/tags/docker/',
'/about/',
];
const WIDTHS = [1920, 1280, 960, 768, 430, 390, 375, 320];
async function target(url) {
const res = await fetch(`${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
if (!res.ok) throw new Error(`cannot create target: ${res.status} ${await res.text()}`);
return res.json();
}
function connect(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
ws.addEventListener('open', () => resolve(ws));
ws.addEventListener('error', reject);
});
}
let nextId = 0;
function send(ws, method, params = {}) {
const id = ++nextId;
return new Promise((resolve, reject) => {
const onMessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id !== id) return;
ws.removeEventListener('message', onMessage);
if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));
else resolve(msg.result);
};
ws.addEventListener('message', onMessage);
ws.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(ws, expression) {
const r = await send(ws, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
userGesture: true,
});
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text);
return r.result.value;
}
const OVERFLOW_JS = `(() => {
const vw = window.innerWidth;
const offenders = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (r.right > vw + 1 || r.left < -1) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: (el.className && String(el.className).slice(0, 60)) || '',
left: Math.round(r.left),
right: Math.round(r.right),
});
}
}
return {
vw,
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
docScroll: document.documentElement.scrollHeight,
offenders: offenders.slice(0, 6),
};
})()`;
const COLLAPSE_JS = `(async () => {
const feat = document.getElementById('archive-featured');
const q = document.getElementById('archive-q');
const status = document.getElementById('archive-status-text');
const clear = document.getElementById('archive-clear');
if (!feat || !q) return { skipped: true };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const before = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() };
q.value = 'docker';
q.dispatchEvent(new Event('input', { bubbles: true }));
await wait(420);
const during = {
collapsed: feat.dataset.collapsed,
h: Math.round(feat.getBoundingClientRect().height),
status: status?.textContent?.trim(),
clearVisible: clear ? !clear.hidden : null,
inert: feat.inert === true,
visibleRows: [...document.querySelectorAll('.archive-row')].filter((r) => !r.hidden).length,
};
clear?.click();
await wait(420);
const after = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() };
return { before, during, after };
})()`;
const results = [];
const fails = [];
for (const path of PAGES) {
const t = await target('about:blank');
const ws = await connect(t.webSocketDebuggerUrl);
await send(ws, 'Page.enable');
await send(ws, 'Runtime.enable');
for (const width of WIDTHS) {
await send(ws, 'Emulation.setDeviceMetricsOverride', {
width,
height: 900,
deviceScaleFactor: 1,
mobile: width < 700,
});
await send(ws, 'Page.navigate', { url: BASE + path });
await new Promise((r) => setTimeout(r, 700));
const m = await evaluate(ws, OVERFLOW_JS);
const ok = m.scrollWidth <= m.clientWidth + 1;
results.push({ path, width, ok, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders.length });
if (!ok || m.offenders.length) {
fails.push({ path, width, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders });
}
}
// Interaction check only where the featured block exists (desktop + phone widths).
if (path === '/posts/' || path === '/zh/posts/') {
for (const width of [1280, 390]) {
await send(ws, 'Emulation.setDeviceMetricsOverride', {
width,
height: 900,
deviceScaleFactor: 1,
mobile: width < 700,
});
await send(ws, 'Page.navigate', { url: BASE + path });
await new Promise((r) => setTimeout(r, 700));
const c = await evaluate(ws, COLLAPSE_JS);
results.push({ path, width, collapse: c });
if (!c.skipped) {
const good =
c.before.collapsed === 'false' &&
c.during.collapsed === 'true' &&
c.during.h === 0 &&
c.during.inert === true &&
c.during.clearVisible === true &&
/docker/.test(String(c.during.status)) === false &&
c.after.collapsed === 'false' &&
c.after.h > 100;
if (!good) fails.push({ path, width, collapse: c });
else console.log(`collapse ok ${path} @${width}: ${c.before.status} → ${c.during.status} → ${c.after.status} (h ${c.before.h} → ${c.during.h} → ${c.after.h})`);
}
}
}
await send(ws, 'Page.close');
ws.close();
}
const byWidth = results.filter((r) => r.ok !== undefined);
const okCount = byWidth.filter((r) => r.ok).length;
console.log(`\nviewport checks: ${okCount}/${byWidth.length} clean`);
console.log(`pages × widths: ${PAGES.length} × ${WIDTHS.length}`);
if (fails.length) {
console.log('\nFAILURES:');
for (const f of fails) console.log(JSON.stringify(f));
process.exit(1);
}
console.log('no horizontal overflow, featured collapse verified');