From 7d0f895074d264d262d633f5fcab07a256965121 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 27 May 2026 19:41:04 +0100 Subject: [PATCH] Better CSS --- README.md | 2 +- package.json | 3 +- scripts/check-preview-cropping.mjs | 533 +++++++++++++++++++++++++++ src/components/EntryThumbnail.astro | 3 + src/components/PostMediaFigure.astro | 1 + src/components/PostThumbnail.astro | 3 + src/layouts/Post.astro | 8 +- src/lib/site.ts | 4 +- src/pages/about.astro | 5 +- src/pages/index.astro | 13 +- src/styles/global.css | 23 +- 11 files changed, 564 insertions(+), 34 deletions(-) create mode 100644 scripts/check-preview-cropping.mjs 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} > diff --git a/src/components/PostThumbnail.astro b/src/components/PostThumbnail.astro index 47568ec..7d53b01 100644 --- a/src/components/PostThumbnail.astro +++ b/src/components/PostThumbnail.astro @@ -57,6 +57,8 @@ for (const root of document.querySelectorAll('.post-thumbnail--iframe')) {
h.depth === 2); 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 blogPosting = { @@ -152,7 +158,7 @@ const personJsonLd = buildPersonJsonLd();
- + { related.length > 0 && ( diff --git a/src/lib/site.ts b/src/lib/site.ts index debdc5c..afc5fec 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -156,12 +156,12 @@ export function buildPersonJsonLd(extra?: Record) { // Responsive image config shared by entry listings. Centralized here so a // change to one breakpoint set is a single edit, not two component changes. 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', }; export const PROJECT_THUMBNAIL = { - widths: [240, 320, 480, 640, 800], + widths: [320, 480, 640, 800, 960, 1200, 1280], sizes: '(max-width: 700px) calc(100vw - 40px), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)', }; diff --git a/src/pages/about.astro b/src/pages/about.astro index 3ce2e04..c7b3a60 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -35,8 +35,9 @@ const startingPointsAnnotated = startingPoints.map((post) => ({ })); const startingPointThumbnail = { - widths: [120, 180, 240, 320], - sizes: '(max-width: 700px) 4rem, (max-width: 960px) 28vw, 10rem', + widths: [160, 240, 320, 480, 640, 800], + 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); diff --git a/src/pages/index.astro b/src/pages/index.astro index cb7f89c..dbcffbb 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -24,16 +24,15 @@ const personJsonLd = buildPersonJsonLd();
-

A notebook, written after the fact

+

Engineering notes

- Andras Schmelczer writes about projects, the tradeoffs - behind them, and what hindsight changed. + Andras Schmelczer — software engineer. Writeups + of finished projects, with the tradeoffs left in.

- 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.

diff --git a/src/styles/global.css b/src/styles/global.css index 141c9b8..16e53ef 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -660,10 +660,11 @@ } .entry-thumbnail img { + display: block; width: 100%; height: 100%; object-fit: cover; - transition: transform 300ms ease; + object-position: center center; } a.entry-thumbnail { @@ -675,11 +676,6 @@ 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 { border-color: var(--color-rule-strong); } @@ -807,15 +803,6 @@ 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 { grid-area: summary; display: flex; @@ -997,11 +984,6 @@ 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 { border-color: var(--color-rule-strong); } @@ -1048,6 +1030,7 @@ .post-thumbnail--iframe img { object-fit: cover; + object-position: center center; border: 0; border-radius: 0; }