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.
This commit is contained in:
1 parent
68771101ea
commit
041471d25e
17 files changed
+1632
-76
No files matched your search
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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');
|
||||
Reference in new issue
Block a user