Better CSS
This commit is contained in:
parent
31648541a2
commit
7d0f895074
11 changed files with 564 additions and 34 deletions
|
|
@ -10,7 +10,7 @@ required client JavaScript.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npx playwright install --with-deps chromium # required before `npm run qa:overflow`
|
npx playwright install --with-deps chromium # required before Playwright QA checks
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
"qa:links": "node scripts/check-links.mjs",
|
"qa:links": "node scripts/check-links.mjs",
|
||||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||||
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
||||||
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
|
"qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs",
|
||||||
|
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
533
scripts/check-preview-cropping.mjs
Normal file
533
scripts/check-preview-cropping.mjs
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const dist = path.resolve('dist');
|
||||||
|
const previewCss = path.resolve('src/styles/global.css');
|
||||||
|
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
|
||||||
|
const INDEX_FILE = 'index.html';
|
||||||
|
const PREVIEW_SELECTOR = '[data-uncropped-preview]';
|
||||||
|
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
||||||
|
// iPad landscape / common laptop / full HD desktop.
|
||||||
|
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||||
|
const MAX_NAV_RETRIES = 4;
|
||||||
|
const CLOSE_TIMEOUT_MS = 3000;
|
||||||
|
const LAUNCH_TIMEOUT_MS = 10000;
|
||||||
|
const CONTEXT_TIMEOUT_MS = 8000;
|
||||||
|
const PAGE_TIMEOUT_MS = 15000;
|
||||||
|
const MEASURE_TIMEOUT_MS = 30000;
|
||||||
|
const CLIP_TOLERANCE_PX = 0.75;
|
||||||
|
const RATIO_TOLERANCE = 0.01;
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.vtt': 'text/vtt; charset=utf-8',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
function contentType(file) {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
return MIME[ext] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(dir) {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await walk(fullPath)));
|
||||||
|
} else {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverRoutes() {
|
||||||
|
const files = await walk(dist);
|
||||||
|
const routes = new Set();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.html')) continue;
|
||||||
|
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||||
|
if (rel === '404.html') continue;
|
||||||
|
if (rel === INDEX_FILE) {
|
||||||
|
routes.add('/');
|
||||||
|
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||||
|
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||||
|
} else {
|
||||||
|
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...routes].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFile(url) {
|
||||||
|
const parsed = new URL(url, 'http://localhost');
|
||||||
|
const safePath = path
|
||||||
|
.normalize(decodeURIComponent(parsed.pathname))
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||||
|
const candidate = path.join(dist, safePath);
|
||||||
|
const candidates = [
|
||||||
|
candidate,
|
||||||
|
path.join(candidate, 'index.html'),
|
||||||
|
path.join(dist, `${safePath}.html`),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of candidates) {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(file);
|
||||||
|
if (fileStat.isFile()) return file;
|
||||||
|
} catch {
|
||||||
|
// Try the next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(dist, '404.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(dist);
|
||||||
|
} catch {
|
||||||
|
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineAndColumn(text, index) {
|
||||||
|
const before = text.slice(0, index);
|
||||||
|
const lines = before.split('\n');
|
||||||
|
return {
|
||||||
|
line: lines.length,
|
||||||
|
column: lines.at(-1).length + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function declarationValue(body, property) {
|
||||||
|
const pattern = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i');
|
||||||
|
return body.match(pattern)?.[1]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleValueExpands(value) {
|
||||||
|
for (const match of value.matchAll(/\bscale(?:3d|x|y)?\(([^)]*)\)/gi)) {
|
||||||
|
const name = match[0].slice(0, match[0].indexOf('(')).toLowerCase();
|
||||||
|
const values = match[1]
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number)
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
|
||||||
|
if (name === 'scalex' || name === 'scaley') {
|
||||||
|
if ((values[0] ?? 1) > 1) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((values[0] ?? 1) > 1 || (values[1] ?? values[0] ?? 1) > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number)
|
||||||
|
.filter(Number.isFinite)
|
||||||
|
.some((number) => number > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPreviewCroppingStyles() {
|
||||||
|
const css = await readFile(previewCss, 'utf8');
|
||||||
|
const styleFailures = [];
|
||||||
|
const blockPattern = /([^{}]+)\{([^{}]*)\}/g;
|
||||||
|
|
||||||
|
for (const match of css.matchAll(blockPattern)) {
|
||||||
|
const selector = match[1].replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||||
|
const body = match[2];
|
||||||
|
if (!selector || !/thumbnail|preview/i.test(selector)) continue;
|
||||||
|
|
||||||
|
const targetsMedia = /\b(img|picture|video|canvas)\b/i.test(selector);
|
||||||
|
const objectFit = declarationValue(body, 'object-fit');
|
||||||
|
const backgroundSize = declarationValue(body, 'background-size');
|
||||||
|
const transform = declarationValue(body, 'transform');
|
||||||
|
const scale = declarationValue(body, 'scale');
|
||||||
|
|
||||||
|
const addFailure = (property, reason) => {
|
||||||
|
const propertyIndex = css.indexOf(property, match.index);
|
||||||
|
const { line, column } = lineAndColumn(css, propertyIndex);
|
||||||
|
styleFailures.push(
|
||||||
|
`${path.relative(process.cwd(), previewCss)}:${line}:${column}: ${selector} ${reason}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetsMedia && /^cover\b/i.test(objectFit ?? '')) {
|
||||||
|
addFailure('object-fit', 'uses object-fit: cover');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^cover\b/i.test(backgroundSize ?? '')) {
|
||||||
|
addFailure('background-size', 'uses background-size: cover');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetsMedia && transform && scaleValueExpands(transform)) {
|
||||||
|
addFailure('transform', 'scales preview media larger than its container');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetsMedia && scale && scaleValueExpands(scale)) {
|
||||||
|
addFailure('scale', 'scales preview media larger than its container');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleFailures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Chromium temp files inside the repo so the check is reproducible in CI
|
||||||
|
// containers with very small /tmp mounts.
|
||||||
|
await rm(browserTmp, { recursive: true, force: true });
|
||||||
|
await mkdir(browserTmp, { recursive: true });
|
||||||
|
process.env.TMPDIR = browserTmp;
|
||||||
|
process.env.TMP = browserTmp;
|
||||||
|
process.env.TEMP = browserTmp;
|
||||||
|
|
||||||
|
const routes = await discoverRoutes();
|
||||||
|
const failures = await checkPreviewCroppingStyles();
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = await resolveFile(req.url ?? '/');
|
||||||
|
const body = await readFile(file);
|
||||||
|
res.writeHead(200, { 'content-type': contentType(file) });
|
||||||
|
res.end(body);
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end(String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
|
||||||
|
function launchBrowser() {
|
||||||
|
return chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TMPDIR: browserTmp,
|
||||||
|
TMP: browserTmp,
|
||||||
|
TEMP: browserTmp,
|
||||||
|
},
|
||||||
|
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout(promise, timeoutMs, label) {
|
||||||
|
let timeout;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeClosePage(page) {
|
||||||
|
await withTimeout(
|
||||||
|
page.close(),
|
||||||
|
CLOSE_TIMEOUT_MS,
|
||||||
|
'Timed out while closing Playwright page'
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeCloseContext(context) {
|
||||||
|
await withTimeout(
|
||||||
|
context.close(),
|
||||||
|
CLOSE_TIMEOUT_MS,
|
||||||
|
'Timed out while closing Playwright context'
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeCloseBrowser(browser) {
|
||||||
|
const childProcess = browser.process?.();
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
browser.close(),
|
||||||
|
CLOSE_TIMEOUT_MS,
|
||||||
|
'Timed out while closing Chromium'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
childProcess?.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBrowser() {
|
||||||
|
return withTimeout(
|
||||||
|
launchBrowser(),
|
||||||
|
LAUNCH_TIMEOUT_MS,
|
||||||
|
'Timed out while launching Chromium'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newMeasurementContext(browser, width) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/*', (route) => {
|
||||||
|
const type = route.request().resourceType();
|
||||||
|
if (type === 'image' || type === 'media') {
|
||||||
|
route.abort('blockedbyclient');
|
||||||
|
} else {
|
||||||
|
route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMeasurementContext(browser, width) {
|
||||||
|
return withTimeout(
|
||||||
|
newMeasurementContext(browser, width),
|
||||||
|
CONTEXT_TIMEOUT_MS,
|
||||||
|
`Timed out while creating ${width}px Playwright context`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectPreviews(page, route, width, phase, index = null) {
|
||||||
|
const findings = await page.evaluate(
|
||||||
|
({ clipTolerancePx, index, phase, ratioTolerance, selector }) => {
|
||||||
|
const failures = [];
|
||||||
|
const previews = [...document.querySelectorAll(selector)];
|
||||||
|
const selectedPreviews =
|
||||||
|
index === null ? previews : [previews[index]].filter(Boolean);
|
||||||
|
const clippingValues = new Set(['hidden', 'clip', 'scroll', 'auto']);
|
||||||
|
|
||||||
|
function isClippingElement(element) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
clippingValues.has(style.overflow) ||
|
||||||
|
clippingValues.has(style.overflowX) ||
|
||||||
|
clippingValues.has(style.overflowY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestClippingAncestor(image, preview) {
|
||||||
|
let current = image.parentElement;
|
||||||
|
|
||||||
|
while (current && preview.contains(current)) {
|
||||||
|
if (isClippingElement(current)) return current;
|
||||||
|
if (current === preview) break;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(preview, image) {
|
||||||
|
const label =
|
||||||
|
preview.getAttribute('data-preview-label') ||
|
||||||
|
preview.getAttribute('aria-label') ||
|
||||||
|
image.alt ||
|
||||||
|
'preview image';
|
||||||
|
const source = image.currentSrc || image.src;
|
||||||
|
|
||||||
|
let pathname = source;
|
||||||
|
try {
|
||||||
|
pathname = new URL(source, document.baseURI).pathname;
|
||||||
|
} catch {
|
||||||
|
// Keep the raw source if URL parsing fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label} (${pathname})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioDelta(first, second) {
|
||||||
|
return Math.abs(first - second) / Math.max(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRectClipped(rect, clipRect) {
|
||||||
|
return (
|
||||||
|
rect.left < clipRect.left - clipTolerancePx ||
|
||||||
|
rect.top < clipRect.top - clipTolerancePx ||
|
||||||
|
rect.right > clipRect.right + clipTolerancePx ||
|
||||||
|
rect.bottom > clipRect.bottom + clipTolerancePx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preview of selectedPreviews) {
|
||||||
|
if (!(preview instanceof HTMLElement)) continue;
|
||||||
|
|
||||||
|
const image = preview.querySelector('img');
|
||||||
|
if (!(image instanceof HTMLImageElement)) {
|
||||||
|
failures.push({
|
||||||
|
label: preview.getAttribute('data-preview-label') || 'preview image',
|
||||||
|
reason: 'has no rendered <img>',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = labelFor(preview, image);
|
||||||
|
const sourceWidth = Number(image.getAttribute('width')) || image.naturalWidth;
|
||||||
|
const sourceHeight = Number(image.getAttribute('height')) || image.naturalHeight;
|
||||||
|
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||||
|
failures.push({ label, reason: 'image has no intrinsic dimensions' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageRect = image.getBoundingClientRect();
|
||||||
|
const imageWidth = image.clientWidth || imageRect.width;
|
||||||
|
const imageHeight = image.clientHeight || imageRect.height;
|
||||||
|
|
||||||
|
if (imageWidth <= 0 || imageHeight <= 0) {
|
||||||
|
failures.push({ label, reason: 'image rendered with no visible size' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageStyle = getComputedStyle(image);
|
||||||
|
const sourceRatio = sourceWidth / sourceHeight;
|
||||||
|
const boxRatio = imageWidth / imageHeight;
|
||||||
|
|
||||||
|
if (
|
||||||
|
imageStyle.objectFit === 'cover' &&
|
||||||
|
ratioDelta(sourceRatio, boxRatio) > ratioTolerance
|
||||||
|
) {
|
||||||
|
failures.push({
|
||||||
|
label,
|
||||||
|
reason: `uses object-fit: cover for a ${sourceWidth}x${sourceHeight} source in a ${Math.round(imageWidth)}x${Math.round(imageHeight)} box`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clippingAncestor = nearestClippingAncestor(image, preview);
|
||||||
|
if (clippingAncestor) {
|
||||||
|
const clipRect = clippingAncestor.getBoundingClientRect();
|
||||||
|
if (isRectClipped(imageRect, clipRect)) {
|
||||||
|
failures.push({
|
||||||
|
label,
|
||||||
|
reason: 'image bounds are clipped by an overflow container',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures.map((failure) => ({
|
||||||
|
...failure,
|
||||||
|
phase,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clipTolerancePx: CLIP_TOLERANCE_PX,
|
||||||
|
index,
|
||||||
|
phase,
|
||||||
|
ratioTolerance: RATIO_TOLERANCE,
|
||||||
|
selector: PREVIEW_SELECTOR,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return findings.map(
|
||||||
|
(finding) =>
|
||||||
|
`${route} at ${width}px (${finding.phase}): ${finding.label}: ${finding.reason}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryNavigation(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureRoute(context, route, width) {
|
||||||
|
let page;
|
||||||
|
|
||||||
|
try {
|
||||||
|
page = await withTimeout(
|
||||||
|
context.newPage(),
|
||||||
|
PAGE_TIMEOUT_MS,
|
||||||
|
`Timed out while creating page for ${route}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return await withTimeout(
|
||||||
|
(async () => {
|
||||||
|
await page.goto(`http://127.0.0.1:${port}${route}`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||||
|
|
||||||
|
return inspectPreviews(page, route, width, 'normal');
|
||||||
|
})(),
|
||||||
|
MEASURE_TIMEOUT_MS,
|
||||||
|
`Timed out while checking preview cropping for ${route}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (page) await safeClosePage(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const width of VIEWPORT_WIDTHS) {
|
||||||
|
let browser;
|
||||||
|
let context;
|
||||||
|
|
||||||
|
try {
|
||||||
|
browser = await openBrowser();
|
||||||
|
context = await openMeasurementContext(browser, width);
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
|
||||||
|
try {
|
||||||
|
result = await measureRoute(context, route, width);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await safeCloseContext(context);
|
||||||
|
await safeCloseBrowser(browser);
|
||||||
|
browser = await openBrowser();
|
||||||
|
context = await openMeasurementContext(browser, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
failures.push(...result);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (context) await safeCloseContext(context);
|
||||||
|
if (browser) await safeCloseBrowser(browser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(failures.join('\n'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`No cropped previews detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.`
|
||||||
|
);
|
||||||
|
|
@ -41,6 +41,8 @@ const isDecorativeLink = Boolean(href) && decorative;
|
||||||
href={href}
|
href={href}
|
||||||
tabindex={isDecorativeLink ? -1 : undefined}
|
tabindex={isDecorativeLink ? -1 : undefined}
|
||||||
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
|
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
|
||||||
|
data-uncropped-preview
|
||||||
|
data-preview-label={ariaLabel ?? alt}
|
||||||
>
|
>
|
||||||
<Picture
|
<Picture
|
||||||
src={src}
|
src={src}
|
||||||
|
|
@ -49,6 +51,7 @@ const isDecorativeLink = Boolean(href) && decorative;
|
||||||
fallbackFormat="jpg"
|
fallbackFormat="jpg"
|
||||||
widths={widths}
|
widths={widths}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
|
quality="high"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority={fetchpriority}
|
fetchpriority={fetchpriority}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
|
||||||
formats={['avif', 'webp']}
|
formats={['avif', 'webp']}
|
||||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||||
|
quality="high"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ for (const root of document.querySelectorAll('.post-thumbnail--iframe')) {
|
||||||
<div
|
<div
|
||||||
class:list={['post-thumbnail', iframeSrc && 'post-thumbnail--iframe']}
|
class:list={['post-thumbnail', iframeSrc && 'post-thumbnail--iframe']}
|
||||||
style={iframeSrc ? `--post-thumbnail-aspect: ${aspectRatio}` : undefined}
|
style={iframeSrc ? `--post-thumbnail-aspect: ${aspectRatio}` : undefined}
|
||||||
|
data-uncropped-preview
|
||||||
|
data-preview-label={post.data.title}
|
||||||
>
|
>
|
||||||
<Picture
|
<Picture
|
||||||
src={post.data.thumbnail.src}
|
src={post.data.thumbnail.src}
|
||||||
|
|
@ -65,6 +67,7 @@ for (const root of document.querySelectorAll('.post-thumbnail--iframe')) {
|
||||||
fallbackFormat="jpg"
|
fallbackFormat="jpg"
|
||||||
widths={[640, 960, 1280, 1600, 1920]}
|
widths={[640, 960, 1280, 1600, 1920]}
|
||||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||||
|
quality="high"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
|
||||||
const h2Headings = headings.filter((h) => h.depth === 2);
|
const h2Headings = headings.filter((h) => h.depth === 2);
|
||||||
const showToc = h2Headings.length >= 3;
|
const showToc = h2Headings.length >= 3;
|
||||||
|
|
||||||
|
// Don't repeat the banner image at the end — PostThumbnail already rendered it.
|
||||||
|
const thumbnailSrc = post.data.thumbnail.src.src;
|
||||||
|
const trailingMedia = post.data.media.filter(
|
||||||
|
(item) => item.type === 'video' || item.src.src !== thumbnailSrc,
|
||||||
|
);
|
||||||
|
|
||||||
const personId = absoluteUrl('/about/#person');
|
const personId = absoluteUrl('/about/#person');
|
||||||
|
|
||||||
const blogPosting = {
|
const blogPosting = {
|
||||||
|
|
@ -152,7 +158,7 @@ const personJsonLd = buildPersonJsonLd();
|
||||||
<Content />
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PostMedia items={post.data.media} />
|
<PostMedia items={trailingMedia} />
|
||||||
|
|
||||||
{
|
{
|
||||||
related.length > 0 && (
|
related.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -156,12 +156,12 @@ export function buildPersonJsonLd(extra?: Record<string, unknown>) {
|
||||||
// Responsive image config shared by entry listings. Centralized here so a
|
// Responsive image config shared by entry listings. Centralized here so a
|
||||||
// change to one breakpoint set is a single edit, not two component changes.
|
// change to one breakpoint set is a single edit, not two component changes.
|
||||||
export const ARTICLE_THUMBNAIL = {
|
export const ARTICLE_THUMBNAIL = {
|
||||||
widths: [120, 180, 240, 320, 480],
|
widths: [160, 240, 320, 480, 640],
|
||||||
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
|
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROJECT_THUMBNAIL = {
|
export const PROJECT_THUMBNAIL = {
|
||||||
widths: [240, 320, 480, 640, 800],
|
widths: [320, 480, 640, 800, 960, 1200, 1280],
|
||||||
sizes:
|
sizes:
|
||||||
'(max-width: 700px) calc(100vw - 40px), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)',
|
'(max-width: 700px) calc(100vw - 40px), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,9 @@ const startingPointsAnnotated = startingPoints.map((post) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const startingPointThumbnail = {
|
const startingPointThumbnail = {
|
||||||
widths: [120, 180, 240, 320],
|
widths: [160, 240, 320, 480, 640, 800],
|
||||||
sizes: '(max-width: 700px) 4rem, (max-width: 960px) 28vw, 10rem',
|
sizes:
|
||||||
|
'(max-width: 700px) 4rem, (max-width: 960px) calc((100vw - 64px - 1.5rem) / 3), calc((min(100vw - 64px, 72rem) - 3rem) / 5)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const personImage = await optimizeOgImage(defaultOg);
|
const personImage = await optimizeOgImage(defaultOg);
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,15 @@ const personJsonLd = buildPersonJsonLd();
|
||||||
|
|
||||||
<Base jsonLd={personJsonLd}>
|
<Base jsonLd={personJsonLd}>
|
||||||
<section class="home-intro">
|
<section class="home-intro">
|
||||||
<p class="eyebrow">A notebook, written after the fact</p>
|
<p class="eyebrow">Engineering notes</p>
|
||||||
<h1>
|
<h1>
|
||||||
<span class="home-intro-name">Andras Schmelczer</span> writes about projects, the tradeoffs
|
<span class="home-intro-name">Andras Schmelczer</span> — software engineer. Writeups
|
||||||
behind them, and what hindsight changed.
|
of finished projects, with the tradeoffs left in.
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Most of these started because I couldn't yet do the thing. An 8-bit ALU, a mobile
|
Most started because I couldn't yet do the thing: an 8-bit ALU, a mobile GPU, a
|
||||||
GPU, a single static HTML file, a cross-language ABI, three editors I didn't
|
single static HTML file, a cross-language ABI. The <a href="/about/">About page</a>
|
||||||
control. The <a href="/about/">About page</a> is where I describe what I keep reaching
|
covers the patterns I keep returning to.
|
||||||
for; the posts below are the evidence.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -660,10 +660,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-thumbnail img {
|
.entry-thumbnail img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transition: transform 300ms ease;
|
object-position: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.entry-thumbnail {
|
a.entry-thumbnail {
|
||||||
|
|
@ -675,11 +676,6 @@
|
||||||
border-color: var(--color-rule-strong);
|
border-color: var(--color-rule-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-list > li:hover .entry-thumbnail img,
|
|
||||||
.article-list > li:focus-within .entry-thumbnail img {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-list > li:focus-within .entry-thumbnail {
|
.article-list > li:focus-within .entry-thumbnail {
|
||||||
border-color: var(--color-rule-strong);
|
border-color: var(--color-rule-strong);
|
||||||
}
|
}
|
||||||
|
|
@ -807,15 +803,6 @@
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card .project-thumbnail img {
|
|
||||||
transition: transform 300ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card:hover .project-thumbnail img,
|
|
||||||
.project-card:focus-within .project-thumbnail img {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card__summary {
|
.project-card__summary {
|
||||||
grid-area: summary;
|
grid-area: summary;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -997,11 +984,6 @@
|
||||||
line-height: var(--leading-snug);
|
line-height: var(--leading-snug);
|
||||||
}
|
}
|
||||||
|
|
||||||
.starting-points > li:hover .entry-thumbnail img,
|
|
||||||
.starting-points > li:focus-within .entry-thumbnail img {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.starting-points > li:focus-within .entry-thumbnail {
|
.starting-points > li:focus-within .entry-thumbnail {
|
||||||
border-color: var(--color-rule-strong);
|
border-color: var(--color-rule-strong);
|
||||||
}
|
}
|
||||||
|
|
@ -1048,6 +1030,7 @@
|
||||||
|
|
||||||
.post-thumbnail--iframe img {
|
.post-thumbnail--iframe img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center center;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue