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,239 @@
|
||||
/**
|
||||
* Generates the case-studies portfolio banner — 21:9, no baked-in text.
|
||||
*
|
||||
* The consultant's spec for /categories/case-studies/ asks for a wide architecture
|
||||
* visual: three production hosts, Docker, Prometheus and the automation flow, in a
|
||||
* dark terminal grey-green with restrained warm-copper accents, rendered into a
|
||||
* 240–300 px tall band beside the hero copy. Everything here is geometry, no words:
|
||||
* at that display height any label would be unreadable, and the headline copy has to
|
||||
* stay semantic HTML.
|
||||
*
|
||||
* Deterministic (seeded), so regenerating never churns the committed asset.
|
||||
*
|
||||
* node scripts/gen-case-studies-banner.mjs
|
||||
*
|
||||
* Writes:
|
||||
* public/images/categories/case-studies-banner.webp 2400x1029 (21:9)
|
||||
* public/images/categories/case-studies-banner-1200.webp 1200x514
|
||||
* public/images/categories/case-studies-banner.svg the source geometry
|
||||
*/
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const W = 2400;
|
||||
const H = 1029; // 21:9
|
||||
|
||||
// Host panels: the real fleet is 3 hosts / 162 containers, and the visual carries the
|
||||
// same proportions (Unraid 62, Synology 78, VPS 22) so the art is not decorative lies.
|
||||
const HOSTS = [
|
||||
{ x: 130, y: 300, w: 300, rows: 10, cols: 10, count: 62, hot: [3, 17, 41] },
|
||||
{ x: 520, y: 210, w: 320, rows: 12, cols: 10, count: 78, hot: [2, 9, 33, 60] },
|
||||
{ x: 930, y: 396, w: 280, rows: 7, cols: 10, count: 22, hot: [6, 15] },
|
||||
];
|
||||
|
||||
const HUB = { cx: 1560, cy: 514 };
|
||||
|
||||
// Deterministic pseudo-random: same seed -> same picture, forever.
|
||||
function rnd(seed) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) % 2147483648;
|
||||
return s / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
function hostHeight(host) {
|
||||
return 26 + 14 * 2 + host.rows * 15;
|
||||
}
|
||||
|
||||
function hostPanel(host, seed) {
|
||||
const r = rnd(seed);
|
||||
const head = 26;
|
||||
const pad = 14;
|
||||
const cols = host.cols;
|
||||
const cw = 20;
|
||||
const ch = 11;
|
||||
const gx = (host.w - pad * 2 - cols * cw) / (cols - 1);
|
||||
const gy = 15;
|
||||
const h = hostHeight(host);
|
||||
|
||||
const chips = [];
|
||||
let drawn = 0;
|
||||
const hot = new Set(host.hot);
|
||||
for (let row = 0; row < host.rows && drawn < host.count; row++) {
|
||||
for (let col = 0; col < cols && drawn < host.count; col++) {
|
||||
const i = row * cols + col;
|
||||
const accent = hot.has(i)
|
||||
? '#d08b4a'
|
||||
: r() > 0.86
|
||||
? '#4fae7a'
|
||||
: r() > 0.5
|
||||
? '#3a7157'
|
||||
: '#2e5c48';
|
||||
chips.push(
|
||||
`<rect x="${(host.x + pad + col * (cw + gx)).toFixed(1)}" y="${(host.y + head + pad + row * gy).toFixed(1)}" width="${cw}" height="${ch}" rx="2.5" fill="${accent}" />`,
|
||||
);
|
||||
drawn++;
|
||||
}
|
||||
}
|
||||
|
||||
return `<g>
|
||||
<rect x="${host.x}" y="${host.y}" width="${host.w}" height="${h}" rx="12" fill="#0f1d18" stroke="#2f5342" stroke-width="1.8" />
|
||||
<path d="M ${host.x} ${host.y + head} h ${host.w}" stroke="#355944" stroke-width="1.4" />
|
||||
<circle cx="${host.x + 18}" cy="${host.y + 13}" r="4" fill="#4fae7a" />
|
||||
<circle cx="${host.x + 32}" cy="${host.y + 13}" r="4" fill="#3a7157" />
|
||||
<circle cx="${host.x + 46}" cy="${host.y + 13}" r="4" fill="#d08b4a" />
|
||||
<rect x="${host.x + host.w - 44}" y="${host.y + 8}" width="32" height="10" rx="5" fill="#1d3229" />
|
||||
${chips.join('\n ')}
|
||||
<rect x="${host.x + pad}" y="${host.y + h - 12}" width="${host.w - pad * 2}" height="2" rx="1" fill="#3a7157" opacity="0.8" />
|
||||
<rect x="${host.x + host.w / 2 - 26}" y="${host.y + h + 8}" width="52" height="4" rx="2" fill="#2f5342" />
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/** A Prometheus-ish readout hub: concentric rings, a flame glyph, four metric tiles. */
|
||||
function hub() {
|
||||
const { cx, cy } = HUB;
|
||||
const tiles = [
|
||||
{ top: cy - 196, pts: '0,34 14,26 28,30 42,16 56,20 70,8 84,12' },
|
||||
{ top: cy - 66, pts: '0,10 14,22 28,14 42,28 56,18 70,30 84,22' },
|
||||
{ top: cy + 66, pts: '0,28 14,12 28,20 42,6 56,14 70,10 84,18' },
|
||||
{ top: cy + 196, pts: '0,20 14,18 28,24 42,12 56,16 70,6 84,14' },
|
||||
].map((t) => ({ ...t, x: cx + 206, y: t.top - 43 }));
|
||||
|
||||
const tileSvg = tiles.map(
|
||||
(t) => `<g>
|
||||
<rect x="${t.x}" y="${t.y}" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
|
||||
<polyline points="${t.pts
|
||||
.split(' ')
|
||||
.map((p) => {
|
||||
const [a, b] = p.split(',').map(Number);
|
||||
return `${t.x + 16 + a},${t.y + 48 + (b - 20)}`;
|
||||
})
|
||||
.join(' ')}" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
|
||||
<rect x="${t.x + 16}" y="${t.y + 16}" width="46" height="6" rx="3" fill="#3a7157" />
|
||||
</g>`,
|
||||
);
|
||||
|
||||
return `<g>
|
||||
<circle cx="${cx}" cy="${cy}" r="196" fill="none" stroke="#3a7157" stroke-width="1.4" stroke-dasharray="4 9" opacity="0.9" />
|
||||
<circle cx="${cx}" cy="${cy}" r="152" fill="none" stroke="#4a8f6f" stroke-width="1.6" opacity="0.85" />
|
||||
<circle cx="${cx}" cy="${cy}" r="110" fill="#0d1f1a" stroke="#528b74" stroke-width="2.2" />
|
||||
<circle cx="${cx}" cy="${cy}" r="110" fill="url(#hubGlow)" opacity="0.55" />
|
||||
<path d="M ${cx} ${cy - 52} c 30 25 39 39 39 57 a 39 39 0 0 1 -78 0 c 0 -18 9 -32 39 -57 z" fill="#d08b4a" />
|
||||
<path d="M ${cx} ${cy - 18} c 14 14 18 21 18 30 a 18 18 0 0 1 -36 0 c 0 -9 4 -16 18 -30 z" fill="#0d1f1a" opacity="0.72" />
|
||||
${tileSvg.join('\n ')}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts → hub wiring, drawn as a network bus rather than three curves across the
|
||||
* panels: each host drops a stub into a shared copper bus below the fleet, and the
|
||||
* bus rises into the hub's outer ring. Crossing the panels looked like a mistake at
|
||||
* banner size, which is exactly the kind of detail nobody should have to decode.
|
||||
*/
|
||||
function flows() {
|
||||
const busY = 760;
|
||||
const busX = HUB.cx;
|
||||
const drops = HOSTS.map((host) => {
|
||||
const x = host.x + host.w / 2;
|
||||
const y0 = host.y + hostHeight(host) + 22;
|
||||
return `<g>
|
||||
<path d="M ${x} ${y0} V ${busY - 6}" fill="none" stroke="#d08b4a" stroke-width="2.4" opacity="0.7" />
|
||||
<circle cx="${x}" cy="${y0}" r="4.5" fill="#d08b4a" />
|
||||
<circle cx="${x}" cy="${busY}" r="4" fill="#e8ab76" />
|
||||
</g>`;
|
||||
}).join('\n ');
|
||||
|
||||
return `<g>
|
||||
<path d="M 300 ${busY} H ${busX}" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" stroke-linecap="round" />
|
||||
<path d="M ${busX} ${busY} V ${HUB.cy + 200}" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" />
|
||||
<circle cx="${busX}" cy="${HUB.cy + 200}" r="5" fill="#e8ab76" />
|
||||
${drops}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/** The automation tail: hub → three workflow nodes, drawn as a green chain. */
|
||||
function automation() {
|
||||
const nodes = [
|
||||
{ x: 1960, y: 320, w: 170, h: 112 },
|
||||
{ x: 2160, y: 470, w: 170, h: 112 },
|
||||
{ x: 1960, y: 620, w: 170, h: 112 },
|
||||
];
|
||||
const chain = nodes
|
||||
.map(
|
||||
(n) => `<g>
|
||||
<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" fill="#0f1d18" stroke="#3a7157" stroke-width="1.6" />
|
||||
<rect x="${n.x + 18}" y="${n.y + 22}" width="62" height="7" rx="3.5" fill="#4fae7a" />
|
||||
<rect x="${n.x + 18}" y="${n.y + 44}" width="112" height="6" rx="3" fill="#2e5c48" />
|
||||
<rect x="${n.x + 18}" y="${n.y + 62}" width="84" height="6" rx="3" fill="#2e5c48" />
|
||||
<circle cx="${n.x + n.w - 24}" cy="${n.y + 26}" r="5.5" fill="#4fae7a" />
|
||||
<circle cx="${n.x + n.w - 24}" cy="${n.y + 26}" r="12" fill="none" stroke="#4fae7a" stroke-width="1.2" opacity="0.5" />
|
||||
</g>`,
|
||||
)
|
||||
.join('\n ');
|
||||
|
||||
return `<g>
|
||||
<path d="M ${HUB.cx + 200} ${HUB.cy - 20} C 1860 ${HUB.cy - 20}, 1860 376, 1960 376" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<path d="M 2130 376 C 2240 376, 2220 526, 2160 526" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<path d="M 2245 582 C 2245 640, 2160 676, 2130 676" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
${chain}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#08120f" />
|
||||
<stop offset="0.55" stop-color="#0c1a15" />
|
||||
<stop offset="1" stop-color="#0a1512" />
|
||||
</linearGradient>
|
||||
<radialGradient id="copperGlow" cx="0.18" cy="0.9" r="0.7">
|
||||
<stop offset="0" stop-color="#c07a3e" stop-opacity="0.16" />
|
||||
<stop offset="1" stop-color="#c07a3e" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="hubGlow" cx="0.5" cy="0.45" r="0.6">
|
||||
<stop offset="0" stop-color="#d08b4a" stop-opacity="0.55" />
|
||||
<stop offset="1" stop-color="#d08b4a" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#8fb3a2" stroke-width="0.5" opacity="0.06" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="${W}" height="${H}" fill="url(#bg)" />
|
||||
<rect width="${W}" height="${H}" fill="url(#grid)" />
|
||||
<rect width="${W}" height="${H}" fill="url(#copperGlow)" />
|
||||
|
||||
<!-- baseline: the network everything sits on -->
|
||||
<path d="M 60 ${H - 120} H ${W - 60}" stroke="#2f5342" stroke-width="2" />
|
||||
<path d="M 60 ${H - 120} H ${W - 60}" stroke="#d08b4a" stroke-width="2" stroke-dasharray="120 26 8 26" opacity="0.45" />
|
||||
${Array.from({ length: 24 }, (_, i) => `<rect x="${90 + i * 96}" y="${H - 136}" width="2" height="30" fill="#2f5342" />`).join('\n ')}
|
||||
|
||||
<g transform="translate(0, 24)">
|
||||
${HOSTS.map((h, i) => hostPanel(h, 97 + i * 13)).join('\n ')}
|
||||
${flows()}
|
||||
${hub()}
|
||||
${automation()}
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const outDir = path.join(process.cwd(), 'public', 'images', 'categories');
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
await sharp(Buffer.from(svg)).webp({ quality: 82 }).toFile(path.join(outDir, 'case-studies-banner.webp'));
|
||||
await sharp(Buffer.from(svg))
|
||||
.resize(1200, 514, { fit: 'fill' })
|
||||
.webp({ quality: 80 })
|
||||
.toFile(path.join(outDir, 'case-studies-banner-1200.webp'));
|
||||
|
||||
// Keep the source next to the render so a future edit does not have to reverse-engineer
|
||||
// the geometry out of a WebP.
|
||||
await writeFile(path.join(outDir, 'case-studies-banner.svg'), svg, 'utf8');
|
||||
|
||||
const report = [];
|
||||
for (const f of ['case-studies-banner.webp', 'case-studies-banner-1200.webp']) {
|
||||
const { size } = await stat(path.join(outDir, f));
|
||||
report.push(`${f}: ${(size / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
console.log(report.join('\n'));
|
||||
@@ -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