diff --git a/README.md b/README.md
index 26b3a2e..c4b04c0 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ required client JavaScript.
```sh
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
diff --git a/package.json b/package.json
index 8443e41..6ff283a 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"qa:links": "node scripts/check-links.mjs",
"qa:no-js": "node scripts/check-no-js.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": {
"type": "git",
diff --git a/scripts/check-preview-cropping.mjs b/scripts/check-preview-cropping.mjs
new file mode 100644
index 0000000..42db40b
--- /dev/null
+++ b/scripts/check-preview-cropping.mjs
@@ -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 ',
+ });
+ 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.`
+);
diff --git a/src/components/EntryThumbnail.astro b/src/components/EntryThumbnail.astro
index 90eae9e..b6a56af 100644
--- a/src/components/EntryThumbnail.astro
+++ b/src/components/EntryThumbnail.astro
@@ -41,6 +41,8 @@ const isDecorativeLink = Boolean(href) && decorative;
href={href}
tabindex={isDecorativeLink ? -1 : undefined}
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
+ data-uncropped-preview
+ data-preview-label={ariaLabel ?? alt}
>
A notebook, written after the fact
+Engineering notes
- Most of these started because I couldn't yet do the thing. An 8-bit ALU, a mobile - GPU, a single static HTML file, a cross-language ABI, three editors I didn't - control. The About page is where I describe what I keep reaching - for; the posts below are the evidence. + Most started because I couldn't yet do the thing: an 8-bit ALU, a mobile GPU, a + single static HTML file, a cross-language ABI. The About page + covers the patterns I keep returning to.