Compare commits

...

7 commits

Author SHA1 Message Date
fcec028c74 Update
All checks were successful
Deploy to Pages / build (pull_request) Successful in 3m1s
2026-05-28 15:36:34 +01:00
f5f017b01f Lint 2026-05-28 14:27:52 +01:00
d83691323f add vault link 2026-05-28 13:16:47 +01:00
dc5b49c373 add fizika 2026-05-28 11:59:19 +01:00
1b0a5c0b5d More posts 2026-05-28 08:46:39 +01:00
7d0f895074 Better CSS 2026-05-27 19:41:04 +01:00
31648541a2 polish 2026-05-26 08:28:37 +01:00
73 changed files with 2733 additions and 397 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Read(//volumes/projects/vault-link/**)"
]
}
}

View file

@ -38,7 +38,7 @@ jobs:
- name: Typecheck
run: npm run typecheck
- name: Build & QA
- name: Build, Astro Audit & QA
run: |
npx playwright install chromium
npm run qa

View file

@ -1,16 +1,14 @@
# schmelczer.dev
A static personal blog for Andras Schmelczer, built with Astro.
Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript.
The site is article-first: articles live in `src/content/posts`, project index entries
live in `src/content/projects`, and normal pages are rendered as static HTML with no
required client JavaScript.
Articles live in `src/content/posts`, project index entries in `src/content/projects`, and normal pages are rendered as static HTML.
## Setup
```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

View file

@ -62,6 +62,8 @@ export default defineConfig({
],
image: {
service: { entrypoint: 'astro/assets/services/sharp' },
// SVG sources in src/content/**/_assets are author-controlled.
dangerouslyProcessSVG: true,
},
vite: {
server: {

View file

@ -8,17 +8,20 @@
"node": ">=22.13.0"
},
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"dev": "astro dev --host 0.0.0.0 --port 5173",
"typecheck": "astro check",
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"build": "astro build",
"preview": "astro preview",
"audit:astro": "npm run build && node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs",
"qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues",
"qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
"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 build && 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",

View file

@ -15,8 +15,8 @@
"purpose": "maskable"
}
],
"theme_color": "#fbfaf7",
"background_color": "#fbfaf7",
"theme_color": "#201f1d",
"background_color": "#201f1d",
"display": "standalone",
"start_url": "/",
"scope": "/"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,95 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const forbidden = String.fromCodePoint(0x2014);
const root = process.cwd();
const textExtensions = new Set([
'.astro',
'.css',
'.html',
'.js',
'.json',
'.md',
'.mjs',
'.ts',
'.txt',
'.vtt',
'.webmanifest',
'.xml',
]);
const roots = [
'src',
'public',
'scripts',
'README.md',
'package.json',
'astro.config.mjs',
].map((entry) => path.resolve(root, entry));
async function exists(filePath) {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
async function walk(entryPath) {
const entryStat = await stat(entryPath);
if (entryStat.isFile()) return [entryPath];
const entries = await readdir(entryPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(entryPath, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(fullPath)));
} else if (entry.isFile()) {
files.push(fullPath);
}
}
return files;
}
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,
};
}
const files = [];
for (const entry of roots) {
if (!(await exists(entry))) continue;
files.push(...(await walk(entry)));
}
const textFiles = files.filter((file) => textExtensions.has(path.extname(file)));
const failures = [];
for (const file of textFiles) {
const text = await readFile(file, 'utf8');
let index = text.indexOf(forbidden);
while (index !== -1) {
const { line, column } = lineAndColumn(text, index);
failures.push(`${path.relative(root, file)}:${line}:${column}`);
index = text.indexOf(forbidden, index + forbidden.length);
}
}
if (failures.length > 0) {
console.error(`Em dashes are not allowed:\n${failures.join('\n')}`);
process.exit(1);
}
console.log('No em dashes found.');

View file

@ -36,9 +36,8 @@ if (jsFiles.length > 0) {
}
// Script tags are only allowed if they declare one of these safe `type`
// attributes (or are tagged with `data-theme-script`). All other scripts —
// including untyped ones, which default to executable JavaScript — are
// flagged.
// attributes (or are tagged with `data-theme-script`). All other scripts,
// including untyped ones, which default to executable JavaScript, are flagged.
const SAFE_SCRIPT_TYPES = new Set([
'application/ld+json',
'importmap',
@ -47,6 +46,7 @@ const SAFE_SCRIPT_TYPES = new Set([
function isSafeScriptTag(tag) {
if (tag.includes('data-theme-script')) return true;
if (tag.includes('data-thumbnail-iframe-script')) return true;
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
if (!typeMatch) return false;
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());

View file

@ -0,0 +1,536 @@
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];
// Only inspect rules that target elements explicitly opted in to the
// no-crop contract via [data-uncropped-preview]. Listing thumbnails
// that intentionally cover-crop don't carry this attribute.
if (!selector || !/\[data-uncropped-preview\b/.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.`
);

View file

@ -0,0 +1,484 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { createServer as createNetServer } from 'node:net';
import path from 'node:path';
import { chromium } from 'playwright';
const dist = path.resolve('dist');
const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
const outputJson = path.resolve(
process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
);
const outputMarkdown = path.resolve(
process.env.ASTRO_AUDIT_OUTPUT_MD ?? '.astro/astro-audit-results.md'
);
const astroBin = path.resolve(
'node_modules',
'.bin',
process.platform === 'win32' ? 'astro.cmd' : 'astro'
);
const HOST = '127.0.0.1';
const INDEX_FILE = 'index.html';
const SERVER_START_TIMEOUT_MS = 60000;
const CLOSE_TIMEOUT_MS = 3000;
const NAV_TIMEOUT_MS = 20000;
const AUDIT_TIMEOUT_MS = 30000;
const DEFAULT_VIEWPORTS = '1440x900';
const failOnIssues =
process.argv.includes('--fail-on-issues') ||
process.env.ASTRO_AUDIT_FAIL_ON_ISSUES === '1';
// Heuristic above-fold / below-fold loading rules flip based on the heights of
// items rendered above them, which shift whenever a post's description length
// changes. They produce false positives that can't be resolved with a single
// `eagerThumbnailCount` per list, so the audit suppresses them.
const IGNORED_AUDIT_CODES = new Set(['perf-use-loading-eager', 'perf-use-loading-lazy']);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWPORTS) {
return raw.split(',').map((entry) => {
const match = entry.trim().match(/^(\d+)x(\d+)$/i);
if (!match) {
throw new Error(
`Invalid ASTRO_AUDIT_VIEWPORTS entry "${entry}". Use values like 1440x900,390x844.`
);
}
return { width: Number(match[1]), height: Number(match[2]) };
});
}
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() {
if (process.env.ASTRO_AUDIT_ROUTES) {
return process.env.ASTRO_AUDIT_ROUTES.split(',')
.map((route) => route.trim())
.filter(Boolean)
.map((route) => (route.startsWith('/') ? route : `/${route}`));
}
try {
await stat(dist);
} catch {
throw new Error('dist/ does not exist. Run npm run build first.');
}
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 getFreePort() {
const server = createNetServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, HOST, resolve);
});
const { port } = server.address();
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
function startAstroDev(port) {
const child = spawn(astroBin, ['dev', '--host', HOST, '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
ASTRO_TELEMETRY_DISABLED: '1',
NO_COLOR: '1',
TMPDIR: browserTmp,
TMP: browserTmp,
TEMP: browserTmp,
},
});
let output = '';
const append = (chunk) => {
output = `${output}${chunk.toString()}`.slice(-20000);
};
child.stdout.on('data', append);
child.stderr.on('data', append);
child.getRecentOutput = () => output.trim();
return child;
}
async function waitForDevServer(baseUrl, child) {
const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`Astro dev server exited before it was ready.\n${child.getRecentOutput()}`
);
}
try {
const response = await fetch(baseUrl);
if (response.status < 500) return;
} catch {
// Server is not listening yet.
}
await sleep(250);
}
throw new Error(
`Timed out waiting for Astro dev server at ${baseUrl}.\n${child.getRecentOutput()}`
);
}
async function stopProcess(child) {
if (!child || child.exitCode !== null) return;
child.kill('SIGTERM');
const exited = once(child, 'exit');
const timedOut = sleep(CLOSE_TIMEOUT_MS).then(() => 'timeout');
if ((await Promise.race([exited, timedOut])) === 'timeout') {
child.kill('SIGKILL');
}
}
async function safeCloseBrowser(browser) {
const childProcess = browser?.process?.();
try {
await Promise.race([
browser.close(),
sleep(CLOSE_TIMEOUT_MS).then(() => {
throw new Error('Timed out while closing Chromium');
}),
]);
} catch {
childProcess?.kill('SIGKILL');
}
}
function viewportLabel(viewport) {
return `${viewport.width}x${viewport.height}`;
}
async function extractAuditResults(page) {
return page.evaluate(
async ({ auditTimeoutMs }) => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const deadline = Date.now() + auditTimeoutMs;
function describeElement(element) {
if (!element) return '';
const pieces = [];
let current = element;
while (
current instanceof Element &&
current !== document.documentElement &&
pieces.length < 5
) {
let piece = current.localName;
if (current.id) {
piece += `#${current.id}`;
} else {
const classes = [...current.classList].slice(0, 2);
if (classes.length > 0) piece += `.${classes.join('.')}`;
const parent = current.parentElement;
if (parent) {
const siblings = [...parent.children].filter(
(child) => child.localName === current.localName
);
if (siblings.length > 1) {
piece += `:nth-of-type(${siblings.indexOf(current) + 1})`;
}
}
}
pieces.unshift(piece);
current = current.parentElement;
}
return pieces.join(' > ');
}
function resolveRuleValue(value, element) {
try {
if (typeof value === 'function') return String(value(element) ?? '');
return String(value ?? '');
} catch (error) {
return `Error resolving audit value: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
function sourceForAudit(audit) {
const tooltip = audit.highlight?.shadowRoot?.querySelector(
'astro-dev-toolbar-tooltip'
);
const sourceSection = tooltip?.sections?.find(
(section) => section.clickDescription === 'Click to go to file'
);
return sourceSection?.content ?? null;
}
while (Date.now() < deadline) {
const toolbar = document.querySelector('astro-dev-toolbar');
const button = toolbar?.shadowRoot?.querySelector('[data-app-id="astro:audit"]');
if (button && !button.classList.contains('active')) {
button.click();
}
const auditCanvas = toolbar?.shadowRoot?.querySelector(
'astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]'
);
const auditWindow = auditCanvas?.shadowRoot?.querySelector(
'astro-dev-toolbar-audit-window'
);
if (button?.classList.contains('active') && auditWindow) {
await sleep(100);
return {
results: (auditWindow.audits ?? []).map((audit) => {
const element = audit.auditedElement;
return {
code: audit.rule.code,
category: audit.rule.code?.split('-')[0] ?? '',
title: resolveRuleValue(audit.rule.title, element),
message: resolveRuleValue(audit.rule.message, element),
description: resolveRuleValue(audit.rule.description, element),
ruleSelector: audit.rule.selector,
source: sourceForAudit(audit),
element: {
selector: describeElement(element),
tagName: element?.tagName?.toLowerCase() ?? '',
html: element?.outerHTML?.replace(/\s+/g, ' ').slice(0, 800) ?? '',
},
};
}),
};
}
await sleep(250);
}
return {
error:
'Timed out waiting for the Astro Dev Toolbar Audit app. Make sure devToolbar is enabled.',
};
},
{ auditTimeoutMs: AUDIT_TIMEOUT_MS }
);
}
async function auditRoute(context, baseUrl, route, viewport) {
const page = await context.newPage();
try {
await page.goto(new URL(route, baseUrl).href, {
waitUntil: 'domcontentloaded',
timeout: NAV_TIMEOUT_MS,
});
await page.waitForSelector('astro-dev-toolbar', {
state: 'attached',
timeout: AUDIT_TIMEOUT_MS,
});
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
const audit = await extractAuditResults(page);
if (audit.error) {
throw new Error(`${route} at ${viewportLabel(viewport)}: ${audit.error}`);
}
return audit.results
.filter((result) => !IGNORED_AUDIT_CODES.has(result.code))
.map((result) => ({
route,
url: page.url(),
viewport: viewportLabel(viewport),
...result,
}));
} finally {
await page.close().catch(() => {});
}
}
function markdownEscape(value) {
return String(value ?? '').replaceAll('|', '\\|');
}
function renderMarkdown(report) {
const lines = [
'# Astro Audit Results',
'',
`Generated: ${report.generatedAt}`,
`Routes checked: ${report.routes.length}`,
`Viewports: ${report.viewports.map(viewportLabel).join(', ')}`,
`Issues found: ${report.results.length}`,
'',
];
if (report.results.length === 0) {
lines.push('No accessibility or performance issues detected.');
return `${lines.join('\n')}\n`;
}
const byRoute = Map.groupBy(report.results, (result) => result.route);
for (const [route, routeResults] of byRoute) {
lines.push(`## ${route}`, '');
lines.push('| Viewport | Code | Title | Source | Element |');
lines.push('| --- | --- | --- | --- | --- |');
for (const result of routeResults) {
lines.push(
`| ${markdownEscape(result.viewport)} | \`${markdownEscape(result.code)}\` | ${markdownEscape(
result.title
)} | ${markdownEscape(result.source ?? '')} | \`${markdownEscape(
result.element.selector
)}\` |`
);
}
lines.push('');
}
lines.push('## Details', '');
for (const result of report.results) {
lines.push(
`### ${result.route} ${result.viewport} ${result.code}`,
'',
`- Title: ${result.title}`,
`- Source: ${result.source ?? 'unknown'}`,
`- Message: ${result.message}`,
`- Description: ${result.description || 'none'}`,
`- Element: \`${result.element.selector}\``,
'',
'```html',
result.element.html,
'```',
''
);
}
return `${lines.join('\n')}\n`;
}
const viewports = parseViewports();
const routes = await discoverRoutes();
if (routes.length === 0) {
throw new Error('No HTML routes found to audit.');
}
await rm(browserTmp, { recursive: true, force: true });
await mkdir(browserTmp, { recursive: true });
await mkdir(path.dirname(outputJson), { recursive: true });
await mkdir(path.dirname(outputMarkdown), { recursive: true });
process.env.TMPDIR = browserTmp;
process.env.TMP = browserTmp;
process.env.TEMP = browserTmp;
const port = await getFreePort();
const baseUrl = `http://${HOST}:${port}/`;
let devServer;
let browser;
const results = [];
try {
devServer = startAstroDev(port);
await waitForDevServer(baseUrl, devServer);
browser = await chromium.launch({
headless: true,
env: {
...process.env,
TMPDIR: browserTmp,
TMP: browserTmp,
TEMP: browserTmp,
},
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
});
for (const viewport of viewports) {
const context = await browser.newContext({
viewport,
javaScriptEnabled: true,
});
await context.route('**/*', (route) => {
if (route.request().resourceType() === 'media') {
route.abort('blockedbyclient');
} else {
route.continue();
}
});
try {
for (const route of routes) {
results.push(...(await auditRoute(context, baseUrl, route, viewport)));
}
} finally {
await context.close().catch(() => {});
}
}
const report = {
generatedAt: new Date().toISOString(),
baseUrl,
routes,
viewports,
results,
};
await writeFile(outputJson, `${JSON.stringify(report, null, 2)}\n`);
await writeFile(outputMarkdown, renderMarkdown(report));
console.log(
`Exported ${results.length} Astro audit issue${
results.length === 1 ? '' : 's'
} across ${routes.length} routes.`
);
console.log(`JSON: ${path.relative(process.cwd(), outputJson)}`);
console.log(`Markdown: ${path.relative(process.cwd(), outputMarkdown)}`);
if (results.length > 0) {
for (const result of results.slice(0, 20)) {
console.log(
`- ${result.route} [${result.viewport}] ${result.code}: ${result.title}${
result.source ? ` (${result.source})` : ''
}`
);
}
if (results.length > 20) {
console.log(`...and ${results.length - 20} more. See the report files.`);
}
}
if (failOnIssues && results.length > 0) {
process.exitCode = 1;
}
} finally {
if (browser) await safeCloseBrowser(browser);
if (devServer) await stopProcess(devServer);
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
}

View file

@ -8,19 +8,28 @@ interface Props {
posts: CollectionEntry<'posts'>[];
showYear?: boolean;
tagLimit?: number;
// Opt-in: eagerly load the first thumbnail. Only set when the list is
// reliably above the fold (home, tag pages). Lists below substantial
// content (related, archives by year, 404) should leave this off.
timeline?: boolean;
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
// below substantial content (related, about, 404) should leave this at zero.
eagerFirstThumbnail?: boolean;
eagerThumbnailCount?: number;
}
const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
const {
posts,
showYear = true,
tagLimit = 3,
timeline = false,
eagerFirstThumbnail = false,
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
} = Astro.props;
---
<ol class="article-list">
<ol class:list={['article-list', timeline && 'article-list--timeline']}>
{
posts.map((post, index) => {
const href = articlePath(post);
const eager = index < eagerThumbnailCount;
return (
<li>
<article>
@ -43,8 +52,8 @@ const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = As
widths={ARTICLE_THUMBNAIL.widths}
sizes={ARTICLE_THUMBNAIL.sizes}
ariaLabel={`Open article: ${post.data.title}`}
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager && index === 0 ? 'high' : undefined}
/>
</li>
);

View file

@ -49,6 +49,7 @@ const isDecorativeLink = Boolean(href) && decorative;
fallbackFormat="jpg"
widths={widths}
sizes={sizes}
quality="high"
loading={loading}
decoding="async"
fetchpriority={fetchpriority}

View file

@ -1,27 +1,17 @@
---
import { navItems, site } from '../lib/site';
import { site } from '../lib/site';
const year = new Date().getFullYear();
// Footer shows all nav items except Home (which is implicit via the site title).
const footerNavItems = navItems.filter((item) => item.href !== '/');
---
<footer class="site-footer">
<nav aria-label="Footer">
<ul class="footer-links">
{
footerNavItems.map((item) => (
<li>
<a href={item.href}>{item.label}</a>
</li>
))
}
</ul>
</nav>
<div class="footer-meta">
<span>© {year} {site.name}</span>
{/* address wraps only the author's contact details, per HTML spec. */}
<span class="footer-copyright">
<span>&copy;</span>
<span>{year}</span>
<span class="footer-name">{site.name}</span>
</span>
{/* address marks only the author's contact details, per HTML spec. */}
<address class="footer-contact">
<a href={`mailto:${site.email}`}>Email</a>
<a href={site.cv} rel="noopener">CV</a>

View file

@ -23,7 +23,7 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
<a class="skip-link" href="#content">Skip to content</a>
<header class="site-header">
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
<a class="site-title" href="/" aria-current={currentState('/')}>{site.brand}</a>
<div class="header-actions">
<nav class="site-nav" aria-label="Primary">
{
@ -57,6 +57,36 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
aria-label="Dark theme"
aria-pressed="false"
>
<svg
class="theme-switcher-icon theme-switcher-icon-sun"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"
></circle>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="2"
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
></path>
</svg>
<svg
class="theme-switcher-icon theme-switcher-icon-moon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M21 13.79A8.5 8.5 0 0 1 10.21 3a7 7 0 1 0 10.8 10.79Z"></path>
</svg>
<span class="sr-only">Toggle theme</span>
</button>
</div>
@ -72,7 +102,7 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
if (!switcher) return;
// Keep in sync with --color-bg in global.css and theme-init.js.
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
var THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
function sync(theme) {

View file

@ -63,6 +63,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
formats={['avif', 'webp']}
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"
quality="high"
loading="lazy"
decoding="async"
/>

View file

@ -0,0 +1,119 @@
---
import { Picture } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';
import { absoluteUrl } from '../lib/site';
interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
const demoLink = post.data.links.find(
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
);
const iframeUrl = post.data.iframeThumbnail ? demoLink?.url : undefined;
const iframeSrc = iframeUrl?.startsWith('/') ? absoluteUrl(iframeUrl) : iframeUrl;
const iframeTitle = demoLink
? `${demoLink.label}: ${post.data.title}`
: `Embedded demo: ${post.data.title}`;
const aspectRatio = `${post.data.thumbnail.src.width} / ${post.data.thumbnail.src.height}`;
const iframeThumbnailScript = `
for (const root of document.querySelectorAll('.post-thumbnail--iframe')) {
const trigger = root.querySelector('[data-thumbnail-iframe-trigger]');
const frame = root.querySelector('[data-thumbnail-iframe-frame]');
if (!(trigger instanceof HTMLButtonElement) || !(frame instanceof HTMLIFrameElement)) {
continue;
}
trigger.addEventListener(
'click',
() => {
const src = trigger.dataset.iframeSrc;
if (!src) return;
if (window.isSecureContext === false) {
const opened = window.open('', '_blank');
if (!opened) {
window.location.href = src;
} else {
opened.opener = null;
opened.location.href = src;
}
return;
}
frame.src = src;
frame.hidden = false;
root.classList.add('is-active');
trigger.setAttribute('aria-expanded', 'true');
frame.focus();
},
{ once: true }
);
}
`;
---
<div
class:list={['post-thumbnail', iframeSrc && 'post-thumbnail--iframe']}
style={iframeSrc ? `--post-thumbnail-aspect: ${aspectRatio}` : undefined}
data-uncropped-preview
data-preview-label={post.data.title}
>
<Picture
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={[640, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
quality="high"
loading="eager"
fetchpriority="high"
decoding="async"
/>
{
iframeSrc && (
<>
<button
class="post-thumbnail__play"
type="button"
data-thumbnail-iframe-trigger
data-iframe-src={iframeSrc}
aria-label={`Play ${demoLink?.label.toLowerCase() ?? 'demo'}`}
aria-expanded="false"
>
<span class="post-thumbnail__play-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M8 5v14l11-7z" />
</svg>
</span>
<span class="sr-only">Play {demoLink?.label.toLowerCase() ?? 'demo'}</span>
</button>
<iframe
class="post-thumbnail__iframe"
data-thumbnail-iframe-frame
title={iframeTitle}
src="about:blank"
allow="fullscreen; webgpu"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
tabindex="0"
hidden
/>
<noscript>
<p class="post-thumbnail__noscript">
<a href={iframeSrc}>Open {demoLink?.label.toLowerCase() ?? 'demo'}</a>
</p>
</noscript>
</>
)
}
</div>
{
iframeSrc && (
<script is:inline data-thumbnail-iframe-script set:html={iframeThumbnailScript} />
)
}

View file

@ -7,22 +7,27 @@ import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
interface Props {
projects: CollectionEntry<'projects'>[];
// Opt-in: eagerly load the first thumbnail. Only set when the list is
// reliably above the fold. The home and projects-index lists sit below
// other sections, so leave this off there.
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
// below substantial content should leave this at zero.
eagerFirstThumbnail?: boolean;
eagerThumbnailCount?: number;
}
const { projects, eagerFirstThumbnail = false } = Astro.props;
const {
projects,
eagerFirstThumbnail = false,
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
} = Astro.props;
// The `essay` field is a `reference('posts')`, so when present it's always a
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
// Drafts are skipped because their article page is not built.
const essayHrefs = new Map<string, string>();
for (const project of projects) {
const essay = project.data.essay;
if (!essay) continue;
const resolved = await getEntry(essay);
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
if (resolved && !resolved.data.draft) essayHrefs.set(project.id, articlePath(resolved));
}
---
@ -33,6 +38,7 @@ for (const project of projects) {
const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id);
const primaryHref = essayHref ?? project.data.links[0]?.url;
const eager = index < eagerThumbnailCount;
return (
<li class="project-card" id={anchor}>
@ -44,8 +50,8 @@ for (const project of projects) {
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
ariaLabel={`Open project: ${project.data.title}`}
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager && index === 0 ? 'high' : undefined}
/>
<article class="project-card__summary">
<h3 id={titleId}>

View file

@ -32,6 +32,15 @@ const mediaUrl = z.string().refine(
{ message: 'Media URL must be an absolute https URL or a root-relative path.' }
);
function isIframeUrl(url: string) {
if (isRootRelativeUrl(url)) return true;
try {
return new URL(url).protocol === 'https:';
} catch {
return false;
}
}
const linkSchema = z.object({
label: z.string(),
url: linkUrl,
@ -91,37 +100,54 @@ const mediaSchema = ({ image }: SchemaContext) =>
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string().max(160),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
draft: z.boolean().default(false),
thumbnail: thumbnailSchema({ image }),
tags: z.array(
z.enum([
'ai',
'systems',
'graphics',
'simulation',
'embedded',
'web',
'tools',
'games',
])
z
.object({
title: z.string(),
description: z.string().max(160),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
draft: z.boolean().default(false),
thumbnail: thumbnailSchema({ image }),
iframeThumbnail: z.boolean().default(false),
tags: z.array(
z.enum([
'ai',
'systems',
'graphics',
'simulation',
'embedded',
'web',
'tools',
'games',
])
),
featuredOrder: z.number().optional(),
projectPeriod: z.string().optional(),
role: z.string().optional(),
stack: z.array(z.string()).optional(),
scale: z.string().optional(),
outcome: z.string().optional(),
audience: z
.enum(['general', 'technical', 'recruiter-relevant'])
.default('technical'),
links: z.array(linkSchema).default([]),
media: z.array(mediaSchema({ image })).default([]),
})
.refine(
(post) =>
!post.iframeThumbnail ||
post.links.some(
(link) =>
!link.download &&
link.label.trim().toLowerCase() === 'demo' &&
isIframeUrl(link.url)
),
{
path: ['iframeThumbnail'],
message:
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
}
),
featuredOrder: z.number().optional(),
projectPeriod: z.string().optional(),
role: z.string().optional(),
stack: z.array(z.string()).optional(),
scale: z.string().optional(),
outcome: z.string().optional(),
audience: z
.enum(['general', 'technical', 'recruiter-relevant'])
.default('technical'),
links: z.array(linkSchema).default([]),
media: z.array(mediaSchema({ image })).default([]),
}),
});
const projects = defineCollection({

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

View file

@ -0,0 +1,47 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="90" fill="url(#grad1)" opacity="0.15"/>
<!-- Main vault icon -->
<g transform="translate(100, 100)">
<!-- Vault body -->
<rect x="-45" y="-50" width="90" height="80" rx="8" fill="none" stroke="url(#grad1)" stroke-width="6"/>
<!-- Vault door circle -->
<circle cx="0" cy="-10" r="22" fill="none" stroke="url(#grad1)" stroke-width="5"/>
<circle cx="0" cy="-10" r="14" fill="none" stroke="url(#grad1)" stroke-width="3"/>
<circle cx="0" cy="-10" r="6" fill="url(#grad1)"/>
<!-- Vault handle -->
<line x1="0" y1="-4" x2="18" y2="-4" stroke="url(#grad1)" stroke-width="3" stroke-linecap="round"/>
<circle cx="18" cy="-4" r="4" fill="url(#grad1)"/>
<!-- Link chain -->
<g opacity="0.9">
<!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g>
<!-- Sync arrows (subtle) -->
<g opacity="0.5">
<!-- Clockwise arrow top-right -->
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- Counter-clockwise arrow bottom-left -->
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,6 +1,6 @@
---
title: A 50 FPS Game Engine on an 8-Bit Microcontroller
description: A handheld game built from the PCB up — ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.'
date: 2026-05-06
projectPeriod: 'Spring 2020'
thumbnail:
@ -22,21 +22,13 @@ media:
mp4: /media/video/ad_astra.mp4
captions: /media/video/ad_astra.vtt
alt: Video demonstration of the embedded game running on a small OLED display.
caption: The whole thing — board, firmware, sprites, game loop — runs on a single ATtiny85V at 8 MHz.
caption: The whole thing, from board and firmware to sprites and game loop, runs on a single ATtiny85V at 8 MHz.
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
---
**The short version:**
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my own soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way. Four years on from [my first hardware project](/articles/lights-synchronized-to-music/), the lesson was that owning the whole stack down to the copper changes how you debug.
- A handheld game built from the PCB up: ATtiny85V, 0.96" SPI OLED, TSOP4838 IR receiver, 3.3V regulator, battery.
- 8-bit ALU at 8 MHz. Every byte and every cycle has a price tag, and that's the whole point.
- 1520 ms peak frame time, so gameplay never drops below 50 FPS. Power draw ~31 mW peak, ~1.5 mA standby.
## Why the PCB changed the project
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way.
The constraint that mattered: an 8-bit ALU at 8 MHz, with no FPU, no SIMD instruction set, and 8 KB of flash. Anything I built had to fit inside that, or I'd be staring at a brick.
This one is a handheld game built from the PCB up around an ATtiny85V: 8-bit ALU at 8 MHz, no FPU, no SIMD, 8 KB of flash. Anything I built had to fit inside that, or I'd be staring at a brick.
## The bits worth showing

View file

@ -13,4 +13,4 @@ outcome: My first browser game; kept for the timeline
audience: general
---
The first browser game I wrote. It's not good, but it was the moment a `<canvas>` element stopped being mysterious. Keeping it here because pretending the older work didn't happen would be dishonest.
Keeping it here because pretending the older work didn't happen would be dishonest. The first browser game I wrote, January 2018. It isn't good, but it was the moment a `<canvas>` element stopped being mysterious.

View file

@ -0,0 +1,97 @@
---
title: Backing Up Running Databases Without Stopping Them
description: A Bash container around BorgBackup. BTRFS snapshots give atomic consistency, numeric env vars give multi-target 3-2-1, the loop is sleep not cron.
date: 2026-05-29
projectPeriod: '2024-2026'
thumbnail:
src: ./_assets/backup.png
alt: Placeholder thumbnail for the backup container post.
tags: ['systems', 'tools']
role: Container and script author
stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
scale: One container, multiple targets per host, two years of restored incidents
outcome: A self-hosted backup that has survived every actual incident I've thrown at it
audience: technical
links:
- label: Source
url: https://github.com/schmelczer/backup-container
- label: Container image
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
---
Once you self-host a few services with live databases, the backup question stops being theoretical. A Postgres or SQLite file half-written when `tar` reads it goes into the archive in a state nothing on Earth will replay; you just don't find out until the restore. Two years in, with multiple incidents I had to actually recover from (including the photos behind the [e-ink frame](/articles/frame-eink-photo-display/)), I trust this stack precisely because the correctness argument is short: BTRFS gives me an atomic snapshot, and everything above it can be a shell script. One Alpine container, ~75 lines of Bash, pushes that snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...). No config format, no DSL; the env file is the configuration.
## The problem the snapshot solves
I self-host several databases that are mid-write at every moment of the day. `tar | borg create` against the live volume is a race: a Postgres or SQLite file that's half-written when borg reads it goes into the archive in a state nothing on Earth can replay. The "right" answer is to coordinate a quiesce with every database: a fan-out of `pg_dump`, SQLite `.backup`, Redis `BGSAVE`, and so on, all with retry, timeouts, and per-app credentials.
The cheaper answer, if you've put everything on one BTRFS volume, is `btrfs subvolume snapshot`. It returns instantly with a copy-on-write fork of the entire filesystem. Every file is now atomically consistent at exactly the same instant. Run borg against the snapshot, not against the live volume.
```bash
btrfs subvolume snapshot /btrfs-root /snapshot
cd "/snapshot/btrfs-root${BACKUP_RELATIVE_PATH:-}"
borg create ... ::"{hostname}-{now:%Y-%m-%dT%H:%M:%S}" .
```
The snapshot lives only for the duration of the borg run. A `trap cleanup EXIT` deletes the subvolume whether the backup succeeded, failed, or was killed. The next run snapshots fresh.
This shifts the entire correctness argument from "did I quiesce every database in time" to "does BTRFS give me a consistent snapshot." It does. That's why everything below it can be a shell script.
## Multi-target as numeric env vars
The 3-2-1 backup rule wants three copies, two media, one offsite. My answer is a remote (rsync.net) and a local HDD, both fed from the same snapshot. The wire format for "multiple targets" is just numbered env vars:
```sh
BORG_PASSPHRASE_0=...
BORG_REMOTE_PATH_0=borg1
BORG_REPO_0=username@username.rsync.net:~/backup
BORG_PASSPHRASE_1=...
BORG_REPO_1=/local-backup
```
`backup-wrapper.sh` loops `index=0` upward, exports `BORG_PASSPHRASE` / `BORG_REPO` / `BORG_REMOTE_PATH` from the indexed copies, runs `backup.sh`, unsets them, increments. Stops the first time the next index has no passphrase.
There's also a no-index fallback (`BORG_REPO=...` with no number) for the single-target case. Same script, no extra config plane.
I keep coming back to this pattern for small-system orchestration. The env file _is_ the data structure. There's no YAML parsing, no JSON schema, no config-validation layer between you and the variable that actually matters.
## The scheduler is a sleep, not cron
```bash
while true; do
/src/backup-wrapper.sh 2>&1 | log_message
sleep "$SLEEP_TIME"
done
```
A comment in the file says it out loud: "Using a simple sleep loop to schedule backups instead of cron to avoid concurrency issues." Cron with a one-hour cadence and a backup that occasionally takes 70 minutes will eventually overlap itself. The sleep-loop can't: the next run starts when the previous one is done, plus the interval. One process, one snapshot, one borg invocation. Concurrency bugs you can't have are concurrency bugs you don't have.
## Healthcheck is a file mtime
`borg create` succeeded? Write `date > /health/backup_completion_time.log`. The Docker healthcheck shells out every 10 seconds and compares that mtime against `MAX_BACKUP_AGE_SECONDS` (default 86400). Older than that, container is unhealthy and whatever's watching containers (in my case a notification hook) finds out.
Two subtleties worth naming:
- **First-boot grace period.** If `backup_completion_time.log` doesn't exist yet (fresh container, first backup still running), fall back to `container_start_time.log` so the container isn't reported unhealthy during the first scheduled run.
- **Partial success is not success.** In multi-target mode, the completion log is only written if _every_ target succeeded. One repo failing means the healthcheck stays red even if the other two are fine. Stale-but-quiet was the failure mode I wanted to make impossible.
## Smaller calls
- **`borg break-lock` at the start of every run.** If the previous container was killed mid-backup, the repo is locked and the next `borg create` will hang. Just break it. There's only ever one writer because of the sleep loop.
- **`set -e` after `borg init`, not before.** The init line is the only one allowed to fail (first run on a fresh repo). Everything after halts on error.
- **`BORG_RSH='ssh -oBatchMode=yes'`.** Fail fast if SSH would have prompted, instead of hanging forever inside a detached container.
- **`ServerAliveInterval 30` in `ssh_config`.** Long borg transfers across home-ISP NAT get killed if nothing flows for a few minutes. Keepalives keep the tunnel open.
- **`--files-cache=ctime,size,inode`.** The default `mtime,size,inode` re-hashes files when their mtime changes; on BTRFS, ctime is the more honest signal of "this content actually changed."
- **`compression=zstd,12`.** The sweet spot for backup data on my hardware: substantially better than zlib, not so slow it dominates the run.
- **`borg compact --threshold=5 --cleanup-commits`.** Reclaims space from pruned archives whenever the segment-file fragmentation crosses 5%.
- **`IGNORE_GIT_UNTRACKED=true`.** Optional. Walks every `.git` dir under the snapshot, runs `git ls-files --others --exclude-standard`, and feeds the result into `--exclude-from`. Skips `target/`, `node_modules/`, build caches; anything the repo already knows isn't worth keeping.
- **`SYS_ADMIN` capability on the container.** Needed for `btrfs subvolume snapshot` and `delete` from inside the namespace. The narrower capability set didn't have a way through.
## What I'd change
- **A test rig that restores into an empty volume on a schedule.** "Backups exist" is not the property I care about. "Backups restore" is. I have anecdotal evidence after every incident; I don't have a green checkmark before one.
- **A failure notifier separate from the healthcheck.** Docker healthcheck-unhealthy is one signal; I'd also want an explicit push (ntfy, email, Telegram) on first failure of a run, so I don't have to be watching the container state.
- **Parallel targets when network and disk don't compete.** The current loop is strictly sequential: rsync.net then local HDD. They share neither bandwidth nor spindles; they could run in parallel and halve the wall-clock. Sequential made the wrapper trivial; the trade was knowable and I made it.
Two years in, the part I'd defend hardest is the snapshot. Everything above it is a wrapper that could be rewritten in an afternoon. The snapshot is what makes the wrapper allowed to be one.

View file

@ -1,6 +1,6 @@
---
title: A Unity City Where Bad PLC Code Made Cars Crash
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes the most honest feedback loop I've shipped.
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped.
date: 2026-05-01
projectPeriod: 'July-August 2018'
thumbnail:
@ -14,12 +14,8 @@ audience: technical
links: []
---
A small city in Unity where the traffic lights were driven by a REST API. Contestants in a PLC cybersecurity event would write control logic; bad logic made cars crash, and they'd watch it happen.
Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster. A PLC cybersecurity event in the summer of 2018 needed something visceral; I built a small Unity city where the traffic lights were driven by a REST API and contestants wrote the control logic.
Three things are worth saying about it:
All decisions ran on the server and got broadcast to clients. The harder problem wasn't the simulation; it was making the broadcast fault-tolerant on conference Wi-Fi without flooding it. I built it solo, including the models and animations in Blender. Not a flex, just context for why everything's a little janky.
- **Visual feedback was the whole point.** Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster.
- **Server-client, all decisions on the server.** Every agent's behaviour was computed centrally and broadcast. The harder problem wasn't simulation — it was getting the broadcast to be fault-tolerant on the conference Wi-Fi without flooding it.
- **Built it solo, including the models and animations in Blender.** Not a flex, just context for why everything's a little janky.
There was also a HUD overlay for tweets, which felt clever at the time and dated horribly. Skip that part.
There was also a HUD overlay for tweets. It felt clever at the time and dated horribly. Skip that part.

View file

@ -25,25 +25,21 @@ media:
caption: A real game loop is a worse audience than a tech demo. That's the point.
---
**The short version:**
- Conquest-style space shooter. Two teams, small planets, points for control, ray-traced 2D rendering.
- Built on top of [SDF-2D](/articles/sdf-2d-ray-tracing/), partly to prove the renderer worked outside a thesis demo.
- The architecture decision worth remembering: one TypeScript module containing the game rules, linked by both the Node server and the browser client. Same code, both sides of the wire.
My thesis was a renderer; proving it in a real multiplayer loop was the point. A real game loop is a worse audience than a tech demo. That's the point. So through autumn 2020 I built decla.red on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): a conquest-style space shooter, two teams, small planets, ray-traced 2D rendering, browser and mobile. The architecture decision worth remembering came out of needing the server and the client to stop lying to each other: one TypeScript module containing the game rules, linked by both sides of the wire.
## The split that usually goes wrong
Real-time multiplayer has an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. If you write the rules twice — once on each side — they will drift. Eventually a player's screen will say one thing and the server will think another.
Real-time multiplayer has an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. If you write the rules twice, once on each side, they will drift. Eventually a player's screen will say one thing and the server will think another.
I wanted the server's "compute the next state" function and the client's "predict the next state locally" function to be literally the same function. So I put the rules in a shared TypeScript library, published nothing, and had both `package.json` files link to it.
The win wasn't elegance, it was the bugs that didn't happen. Client prediction stopped being an approximation of the server it _was_ the server, run optimistically and reconciled when the authoritative update came back.
The win wasn't elegance, it was the bugs that didn't happen. Client prediction stopped being an approximation of the server; it _was_ the server, run optimistically and reconciled when the authoritative update came back.
## Other choices worth a sentence
- **k-d trees for spatial queries.** Once the world held more than a few dozen objects, naive collision and proximity checks dominated the server tick. A k-d tree dropped them out of the profile.
- **Message-passing object model.** Lifted from Smalltalk's `doesNotUnderstand:` idea. Entities respond to messages they care about and ignore the rest. Easier to extend than the inheritance tree I tried first, and less brittle.
- **Firebase only for server discovery.** Not for game state for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
- **Firebase only for server discovery.** Not for game state, just for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
## What I'd change

View file

@ -0,0 +1,33 @@
---
title: A Physics Practice App for the Hungarian Érettségi
description: A static jQuery site I built in high school to drill past exam questions. 659 questions, more than a decade of past papers, still online and still used.
date: 2026-05-28
projectPeriod: '2017-2018'
thumbnail:
src: ./_assets/fizika.jpg
alt: Screenshot of the Fizika practice app showing topic-selection buttons over a light textured background.
tags: ['web', 'tools']
role: Question database, frontend, backend
stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
outcome: A free practice app real students still find when they search for past érettségi physics papers
audience: general
links:
- label: Live
url: https://fizika.schmelczer.dev
- label: Source
url: https://home.schmelczer.dev/git/andras/fizika
---
I needed it. In my last year of high school I was about to sit the _emelt szintű_ (advanced-level) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening I started typing past exam questions into a JSON file. A few weeks later I had something resembling a study tool, and a few weeks after that I had 659 questions covering more than a decade of past papers.
The site is intentionally small. A static frontend on jQuery, four CSS files, a JSON blob of questions, a folder of scanned diagrams from the original papers. You pick a topic (_Mechanika, Hőtan, Elektromosság, Atomfizika_) or hunt down a specific year's exam, get a randomised quiz, answer, and the page colours each row green or red. Past results sit in `localStorage`, because the audience was high schoolers; account-less was the privacy answer.
It outgrew Firebase eventually. I moved the data to a small Express backend so I could keep editing questions without a paid plan, with a JSON file and an image folder as the storage layer. The admin routes have no auth; instead, the service stays off the public internet and I edit through an SSH-forwarded localhost. Fine for a one-person CMS, terrible advice for anything with multiple editors.
What I'd change if I were starting it now:
- **Astro instead of jQuery plus a Node server.** The whole thing could be one static site that re-renders on push. No backend, no CSP fiddling, no Docker.
- **Markdown source, not a hand-edited JSON file.** Editing questions in JSON is fine until you forget a comma at 1am and the site stops loading.
- **A real licence note on the question text.** The papers are public exam material, but it's worth saying so somewhere on the page.
It's been online in some form for eight years. Every spring I get a few emails from students asking whether I'll add the latest year's paper. I usually do, eventually. The thing I made for myself in 2017 is still doing its job for someone else's last year of high school, and that's the only metric on it I actually care about.

View file

@ -6,6 +6,7 @@ projectPeriod: '2026'
thumbnail:
src: ./_assets/fleeting-garden.jpg
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
iframeThumbnail: true
tags: ['graphics', 'simulation', 'web']
role: Graphics and shader author
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
@ -24,15 +25,11 @@ media:
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
---
**The three-bullet pitch:**
- Draw a stroke; agents spawn along it, follow the trail, and slowly overwrite the patch you laid down. You shape the gesture, the garden owns the response.
- Six "vibes" share the same simulation. The personality of each one lives in a 3×3 matrix of nine numbers in `{-1, 0, 1}`. That's the entire behaviour rule.
- One static HTML file. `vite build`, `rsync`, done. Runs offline, can be emailed, no account, no save state.
Nine numbers in `{-1, 0, 1}` arranged in a 3×3 matrix decide an entire vibe's personality. That constraint is what kept me up: proving simplicity can be expressive, that you don't need a behaviour function per preset. A WebGPU drawing toy where you stroke a colour, agents spawn along it, and the garden slowly overwrites the patch you laid down. One static HTML file, six compute stages, none of them skippable.
## Why physarum needed a knob
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
My second self-imposed constraint was that one engine had to produce six visibly different presets without forking. The first prototype had a `switch (preset)` with one behaviour function per vibe and it was already painful at vibe two. I needed the personality to live in data, not code.
@ -42,9 +39,9 @@ Each vibe is a 3×3 table of colour-to-colour affinities. When an agent of colou
Three examples of what nine numbers can do:
- **Aurora Mycelium** cyclic, each colour chases the next. Agents wind into ribbons.
- **Velvet Observatory** every off-diagonal entry negative. Colours repel into separate islands.
- **Paper Lantern Fog** matrix filled with ones. Colours collapse into one cooperative blob.
- **Aurora Mycelium:** cyclic, each colour chases the next. Agents wind into ribbons.
- **Velvet Observatory:** every off-diagonal entry negative. Colours repel into separate islands.
- **Paper Lantern Fog:** matrix filled with ones. Colours collapse into one cooperative blob.
Adding a tenth number to the matrix would tax every existing vibe. Tuning the nine I have is a text edit. Six presets in, I haven't extended it.
@ -52,18 +49,18 @@ Adding a tenth number to the matrix would tax every existing vibe. Tuning the ni
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
1. **Agent step** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
2. **Diffusion** blur and decay so old marks soften.
3. **Brush** write user strokes into both the trail texture and a separate "source" texture the agents can read.
4. **Eraser** two variants: one clears a region of the trail, the other kills agents in a radius.
5. **Agent generation** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
6. **Render** read the trail, apply palette and grain.
1. **Agent step:** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
2. **Diffusion:** blur and decay so old marks soften. The boring one, and the one you can't skip: without it, strokes stay forever and the garden collapses into noise.
3. **Brush:** write user strokes into both the trail texture and a separate "source" texture the agents can read.
4. **Eraser:** two variants: one clears a region of the trail, the other kills agents in a radius.
5. **Agent generation:** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
6. **Render:** read the trail, apply palette and grain.
The bind-group setup overhead from running more pipelines was lost in the noise next to the simulation cost. The win was that when the eraser shader started killing the wrong agents, I opened one file and reasoned about it without touching anything else.
## Smaller calls
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see — a stroke vanishing thirty seconds after you drew it — isn't an explicit eraser, it's the buffer wrapping around.
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see, a stroke vanishing thirty seconds after you drew it, isn't an explicit eraser, it's the buffer wrapping around.
- **URL is the share format.** The chosen vibe is in the query string. The "send your friend this preset" link is just a URL with `?vibe=tidepool-lantern` on it. The parser is tolerant about accents and casing because people retype these.
- **One HTML file.** All CSS and JS inline. The piano samples sit beside it. Self-contained enough to email or drop on a USB stick.
@ -71,4 +68,4 @@ The bind-group setup overhead from running more pipelines was lost in the noise
- The intro animation (agents fly in to spell the title, then transition to steady state) couples three shaders through a single `progress: 0 → 1` value. It's the bit I'd least want to refactor today. Next time I'd model the intro as its own dispatch with its own buffer and hand off cleanly.
- Mobile works, but the toolbar fights the canvas for screen and the agent cap has to shrink hard to keep frame time down. A proper fix means rethinking the toolbar and exposing the cap-vs-resolution tradeoff to the user.
- The simulation has invariants agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, the eraser doesn't leak agents past its radius — that proptest would falsify in minutes. Snapshot tests aren't the right tool here.
- The simulation has invariants that proptest would falsify in minutes: agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, and the eraser doesn't leak agents past its radius. Snapshot tests aren't the right tool here.

View file

@ -14,7 +14,7 @@ audience: technical
links: []
---
A linear regression in the frequency domain, dressed up. Old portfolio screenshots showed the prediction (blue) tracking the actual rate (green) closely enough to be flattering. I did not trade real money with it.
In the autumn of 2019 I was an undergrad with a few weekends free and the quiet conviction that I could find a small edge on EUR/USD. The screenshots were flattering: the prediction (blue) hugged the actual rate (green) in a way that looked like skill. A linear regression in the frequency domain, dressed up. I did not trade real money with it, and that restraint is the only thing about the project that aged well.
The pipeline:

View file

@ -0,0 +1,80 @@
---
title: An E-Ink Photo Frame That Sleeps When the House Is Empty
description: A Pi, a 6-colour e-ink panel, and a self-hosted Immich library. Photos chosen by date and favourites, gated on Home Assistant presence, dithered with Atkinson.
date: 2026-05-27
projectPeriod: '2026'
thumbnail:
src: ./_assets/frame.jpg
alt: The e-ink frame on the wall showing a dithered landscape scene with the capture age and EXIF location painted into the bottom corners.
tags: ['embedded', 'systems', 'tools']
role: Frame builder and pipeline author
stack:
[
'Python',
'Raspberry Pi Zero 2W',
'Waveshare 7.3" 6-colour panel',
'Immich',
'Home Assistant',
'numba',
'Atkinson dither',
]
scale: One panel, one household, ~64 refreshes a day at peak
outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies
audience: general
links:
- label: Source
url: https://home.schmelczer.dev/git/andras/frame
media:
- type: image
src: ./_assets/frame.jpg
alt: The frame on the wall showing a 6-colour Atkinson-dithered landscape photo, with "2 years ago" and a location label painted into the bottom corners.
caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them.
---
In 2024, researchers found family-blog photos of Brazilian children inside the LAION training set. Self-hosting your photos used to be a preference; it's a safeguarding decision now. Nixplay's cloud-tied frames have bricked. Funimation deleted libraries people had paid for. I wanted a photo frame on the hallway wall, and I wasn't going to hand the family album to a vendor who could close the doors on it.
So it's a Raspberry Pi Zero 2W driving Waveshare's [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter) panel, pulling from my self-hosted [Immich](https://immich.app/) library, part of the same [self-hosting setup I back up with btrfs and borg](/articles/backup-container-btrfs-borg/). A few hundred lines of stdlib Python on top of the reference driver.
## Why a stupid amount of engineering for a picture on a wall
That's the point. Albert Borgmann once distinguished _devices_ (which efficiently deliver a commodity and disappear into the wall) from _focal things_, which gather a practice around them. A Nest Hub is a device; it shows you photos the way a microwave delivers heat. The frame is a focal thing. I curated the weights. I hung it where the light was right. I tweak it when something feels off. It doesn't sell my attention back to me; it asks me to pay some.
The medium helps. E-ink doesn't glow and doesn't beep. From across the room it reads as _image_, not as _screen_, and that one perceptual difference changes how often I actually look at it.
## The presence gate
The cron line does most of the work. Every 15 minutes, the script checks the time of day, then asks Home Assistant whether anyone in `HA_PRESENCE` is home. If not, it quits. The panel keeps showing the last photo, because e-ink, so you walk in to whatever was there when the house emptied.
The point isn't power saving. John Berger drew a line between photographs kept inside a context of lived meaning (private), and ones severed and circulated (public). Google Photos hands you the public mode dressed as the private. A wall in the hallway, lit only when your people are home, restores the context. The same photograph means something different surfacing while you're cooking dinner than it does in a feed at 11pm.
## How a photo gets picked
The pool is biased the way memory is biased: four buckets, weighted ~30% "on this day" (dropping to ~10% if only the ±3-day fallback fires), ~18% favourites, ~36% the last 30 days, ~36% everything else. Within those buckets, orientation-match against the current frame gets 4x the weight of mismatch, because cropping landscape to portrait works less often than the reverse.
A 7-day rolling history filters repeats. Before accepting a candidate, the picker runs `heads_fit_in_crop` against Immich's detected face boxes, extended upward to cover the skull and padded by `HEAD_SAFETY_MARGIN`: if the planned crop would slice into any visible head, that candidate is rejected and another is drawn. A wall photo with half a face in it is worse than the same photo not on the wall at all.
`face_aware_crop` does the actual cropping: resize-cropping to fill the frame while biasing the window around detected faces. A landscape shot with room around the subject usually crops cleanly to portrait this way; the guardrail above catches the ones that don't.
## Tuning the pipeline somewhere else
Iterating on the Pi means waiting 12+ seconds per refresh. Both the face-aware crop and the dither were tuned in Jupyter against a local pool of a few hundred photos, then frozen and shipped.
The dither is where the choice visibly matters. The panel can only show black, white, red, yellow, blue, green; no intensity control, every pixel is one of those six. I compared Floyd-Steinberg, Stucki, and a couple of ordered variants. Atkinson kept the highest perceived contrast on the 6-colour palette without smearing skin tones into the nearest yellow. Pure-Python Atkinson on the Pi Zero was unusably slow, so the inner loop runs through `numba` with perceptual-weighted nearest-colour matching (0.299/0.587/0.114). Roughly 100x faster after the JIT cache warms.
## The weekend-reimplementable rule
Hundred Rabbits, a couple who live offshore on a sailboat doing permacomputing in practice, hold themselves to a rule: any system they depend on should be reimplementable in a weekend. The frame meets the bar. A few hundred lines of stdlib Python on a documented panel, reading from an HTTP endpoint that returns JPEGs. It came together over an afternoon with Claude Code plus a couple of weekends tuning the picker and the dither; the repo is public partly as a reference for anyone wanting to do something similar. If Immich disappears tomorrow the selection logic is eighty lines I can repoint at whatever replaces it.
## Smaller calls
- **Capture age and EXIF location painted as text.** White on a black stroke, written _after_ dithering, so the labels stay sharp on the 6-colour palette.
- **Swap masked, journald volatile.** The SD card is the most likely thing to die on this build. Don't write to it unless you have to.
- **Wifi power-save reconnect job.** The Pi Zero 2W's wifi drops if power-save kicks in. A separate `wifi-check.sh` every five minutes brings it back.
## What I'd change
- **Lower-power hardware.** The Pi Zero 2W is overkill and idles 14 minutes out of every 15. The Waveshare board didn't have an RTC interrupt pin soldered, and rather than hack one in, I'd reach for an ESP32 next time. Deep sleep has plenty of time to do the image work inside a 15-minute window.
- **A bigger panel and a small light.** The [Inky Impression](https://shop.pimoroni.com/products/inky-impression) 13" with a custom frame and integrated lighting would help most in the evenings, when the e-ink reads muddled under warm lamps.
- **A daytime cadence curve.** 15 minutes is constant. It should slow at night and speed up around the times we're actually in the hallway.
The frame is small, slow, and almost entirely silent. It does one thing for one household and doesn't tell anyone about it. The smallness is the point. There should be more of this kind of thing.

View file

@ -14,8 +14,6 @@ audience: technical
links: []
---
The input-side companion to the [cooling system sim](/articles/nuclear-cooling-simulation/). A JavaFX desktop tool where you'd lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload directly to the simulator backend.
Non-technical event organisers needed to rewire a cooling plant in real time without me hovering. That was the brief, and it ruled out every interface I'd have enjoyed writing. The [cooling system sim](/articles/nuclear-cooling-simulation/) was only as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload straight to the backend.
It was small but it punched above its weight at the actual event. The simulator's value depended entirely on the organisers being able to change the plant without me sitting next to them. The editor was what made that possible.
If I built it again I'd skip JavaFX and put the editor in the browser — same place the monitoring clients lived. One install fewer for everyone.
Small tool, and the whole event hinged on it. If I built it again I'd skip JavaFX and put the editor in the browser next to the monitoring clients. One install fewer for everyone, and one fewer reason for someone to call me over.

View file

@ -28,17 +28,13 @@ media:
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
---
**The short version:**
- I surveyed working data scientists and engineers on a catalogue of 33 ML deployment habits. The honest finding: people skip habits not because they disagree, but because adopting one means writing five lines of glue, and they have a deadline.
- That's a design problem, not a discipline problem. The thesis built a Python framework around one bet: put the deployment behaviour next to the prediction function with a single decorator, and the right thing becomes the path of least resistance.
- Pip-installable as `great-ai`. Worth a look mainly for the survey methodology behind it.
By the end of 2021 I had stopped believing the people skipping ML deployment best practices were the problem. They knew the list. They agreed with the list. They had a deadline, and every item on the list cost five lines of glue. My MSc thesis turned that into the actual research question: not "what should engineers do" but "what API shape makes doing the right thing cheaper than not." The framework that fell out, `great-ai`, is a decorator on a plain Python function. The thesis behind it is the part worth reading.
## The thing nobody wants to admit
The literature has a long list of habits you should adopt when shipping an ML service: track inputs, version models, expose health, log decisions, keep predictions reproducible. Everyone agrees with the list. Almost nobody implements all of it.
I spent the bulk of the thesis catalogueing 33 such habits, proposing 6 more, and surveying engineers on which actually got applied in their day jobs. The data was pretty clear about the failure mode: it wasn't ignorance, it wasn't laziness, it wasn't budget. It was that the cost of doing the right thing — five lines of glue per habit, multiplied across a stack — was higher than the visible cost of skipping it. So skipping it became the default.
I spent the bulk of the thesis catalogueing 33 such habits, proposing 6 more, and surveying engineers on which actually got applied in their day jobs. The data was pretty clear about the failure mode: it wasn't ignorance, it wasn't laziness, it wasn't budget. It was that the cost of doing the right thing, five lines of glue per habit multiplied across a stack, was higher than the visible cost of skipping it. So skipping it became the default.
So the real research question wasn't "what should engineers do." It was "what API shape makes doing the right thing cheaper than not."

View file

@ -1,6 +1,6 @@
---
title: Syncing State with an Immutable Trie
description: A visual goal tracker whose lasting idea was the sync model an immutable trie so structural diffs are trivial and only deltas cross the wire.
description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.'
date: 2026-05-05
projectPeriod: 'August-September 2019'
thumbnail:
@ -23,11 +23,7 @@ media:
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
---
**The short version:**
- Built as a personal task tracker with a tower metaphor. The UI was the fun bit; the sync model is the part I'd still write the same way today.
- Clients and server hold the same data as an immutable trie. Comparing two versions is a walk over shared nodes. Sending the delta is sending only the nodes that aren't shared.
- No CRDT, no OT, no clever conflict resolution. The structure does the work, the protocol stays boring.
In August 2019 I wanted a goal tracker I'd actually open, on whichever device was nearest, without watching it disagree with itself. Nothing off the shelf fit, so I built one over a couple of weekends. The tower metaphor was the part friends saw; the part that aged well was the sync model that fell out of needing the same state in three places at once.
## The problem in one paragraph
@ -40,7 +36,7 @@ A goal in Life Towers is a path of strings. `Health / Running / 5k`. Tasks under
Two properties did the heavy lifting:
- **Structural sharing.** When you tick off a task under `Health / Running / 5k`, the new root reuses every untouched subtree by reference. The `Career` branch and the `Reading` branch are the same objects they were before. Comparing the old and new roots is mostly pointer equality; only the path that actually changed gets walked.
- **Immutability.** Updates produce new structure instead of mutating. "Where I was" and "where I am" become two pointers, not two snapshots. The diff between them is whatever's not shared and that walk is O(changes), not O(state).
- **Immutability.** Updates produce new structure instead of mutating. "Where I was" and "where I am" become two pointers, not two snapshots. The diff between them is whatever's not shared, and that walk is O(changes), not O(state).
The sync loop falls out:
@ -49,7 +45,7 @@ The sync loop falls out:
3. Server applies, returns its new root.
4. Client rebases any in-flight edits by replaying them on top.
There's no conflict resolution layer because the operations commute on the structure — two clients adding tasks under different branches produce non-overlapping deltas that compose trivially. The hard cases (two clients editing the same leaf) are tiny and obvious, because they're the _only_ place the deltas touch the same path.
There's no conflict resolution layer because the operations commute on the structure. Two clients adding tasks under different branches produce non-overlapping deltas that compose trivially. The hard cases (two clients editing the same leaf) are tiny and obvious, because they're the _only_ place the deltas touch the same path.
## What I'd change

View file

@ -1,5 +1,5 @@
---
title: My First Real Project — LEDs Driven by an FFT
title: 'My First Real Project: LEDs Driven by an FFT'
description: A Raspberry Pi music player that drove RGB strips through MOSFETs. The first thing I started and actually finished.
date: 2026-04-26
projectPeriod: 'Spring 2016'
@ -14,12 +14,10 @@ audience: technical
links: []
---
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. The plan was something like: play music, look at it, make the lights match.
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one. This was the first thing I started and actually finished.
I got bands wrong first. I tried mapping raw audio amplitude to brightness, which made the lights pulse with anything — clipping, voice, fan noise — and produced a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
The MOSFETs took longer than they should have. I wired one backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one.
The plan was something like: play music, look at it, make the lights match. I got bands wrong first. Mapping raw audio amplitude to brightness made the lights pulse with anything (clipping, voice, fan noise), a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
The frontend was a vanilla web page on the same Pi: pick a track, tweak the band thresholds, see what changed. No framework. Just a `<select>`, a few sliders, and an `XMLHttpRequest`. It worked.
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs; it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery; [the ATtiny85 handheld](/articles/ad-astra-attiny85-game-engine/) four years later is the same instinct with the soldering iron held steadier. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.

View file

@ -1,5 +1,5 @@
---
title: My Notes — A Markdown App for Android
title: 'My Notes: A Markdown App for Android'
description: A small Android note app built on Markwon. The idea wasn't new; the point was learning a platform that wasn't the web.
date: 2026-05-02
projectPeriod: 'November 2019'
@ -16,8 +16,6 @@ links:
url: https://github.com/schmelczer/my-notes
---
A small Android app for writing Markdown notes and filtering them by hashtag. Built on top of Markwon for the rendering.
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then it lost a long battle with Obsidian. The loss was the lesson: I learned what I actually wanted from a notes app by watching mine fail to be it. Years later that same itch is why I wrote [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently-edited copies back into one.
The idea wasn't original — every developer writes their own notes app eventually — and the bar for shipping one wasn't high. What I actually wanted from the project was a few weeks somewhere outside the web stack, in a platform with different conventions about lifecycle, storage, and resource constraints. Android delivered that.
I don't use the app anymore (it lost a long battle with Obsidian, which is also why I later wrote [reconcile-text](/articles/reconcile-text-3-way-merge/)). I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.
The app itself was small: Markdown notes, hashtag filtering, Markwon for rendering. Every developer writes their own notes app eventually and the bar for shipping one isn't high. What I actually wanted was a few weeks outside the web stack, somewhere with different conventions about lifecycle, storage, and resource constraints. Android delivered that. I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.

View file

@ -1,5 +1,5 @@
---
title: Two Graphs Are Simpler Than One — A Cooling System Simulator
title: 'Two Graphs Are Simpler Than One: A Cooling System Simulator'
description: A live cooling-system sim for a PLC cybersecurity event. Splitting flow and heat into two graph passes kept the calculation cheap and the behaviour believable.
date: 2026-05-04
projectPeriod: 'October-November 2018'
@ -25,11 +25,7 @@ media:
caption: The JavaFX editor produced JSON that the simulator ate as input.
---
**The short version:**
- A cooling-system simulator with reactors, coolers, pumps, heat exchangers, drains, and pipes. Built for a cybersecurity event where contestants poked at PLCs and watched the consequences in real time.
- Not physically accurate. Aimed at "cheap to compute, plausible to a non-specialist, runs all weekend on one server."
- The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. A cybersecurity event in late 2018 needed a cooling-system simulator that contestants could poke at through PLCs over a weekend, and the deadline shaped every decision after it: cheap to compute, plausible to a non-specialist, runs all weekend on one server. The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
## What the event needed
@ -41,7 +37,7 @@ The challenge was about PLCs. Contestants would change setpoints, valves, or pum
## The split that made it cheap
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. Instead:
Instead of the coupled solver:
1. **Flow first, as graph traversal.** Walk the pipe graph from the pumps, accumulate pressure, distribute water to nodes.
2. **Heat second, as a linear system.** Build the adjacency matrix from the flow result, add boundary conditions (heaters, exchangers, base temperatures), solve for node temperatures with NumPy.
@ -51,7 +47,7 @@ This is wrong as physics. It's right as a model. Flow doesn't react to instantan
## Why the editor mattered
The simulator's most-used UI was the _input_ editor a separate JavaFX tool where you laid out the plant, set parameters per element, and exported JSON the sim ate. I wrote up the editor's [own story here](/articles/graph-editor-javafx-simulation-input/), because in hindsight it deserved to be its own project.
The simulator's most-used UI was the _input_ editor, a separate JavaFX tool where you laid out the plant, set parameters per element, and exported JSON the sim ate. I wrote up the editor's [own story here](/articles/graph-editor-javafx-simulation-input/), because in hindsight it deserved to be its own project.
The lesson: a simulation is only as useful as its input pipeline. If editing the plant requires editing source, organisers won't use it.

View file

@ -0,0 +1,122 @@
---
title: 25 Million UK Property Rows in a Single Rust Process
description: Notes on the perfect-postcode.co.uk server. Every numeric feature is u16-quantised in a flat row-major array, so filter eval is two integer compares per row.
date: 2026-05-28
projectPeriod: '2026'
thumbnail:
src: ./_assets/perfect-postcode.jpg
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties highlighted as a heatmap.
tags: ['systems', 'web', 'tools']
role: Server architect and operator
stack:
[
'Rust',
'Axum',
'Polars',
'h3o',
'rayon',
'PocketBase',
'PMTiles',
'MapLibre',
'deck.gl',
'Conveyal R5',
'Gemini',
]
scale: ~25M historical properties, ~2.5M postcodes, ~150 numeric features per row, all in RAM on a single VM
outcome: A single-binary UK property-intelligence service with sub-100ms hexagon aggregations under filter
audience: technical
links:
- label: Site
url: https://perfect-postcode.co.uk
media:
- type: image
src: ./_assets/perfect-postcode.jpg
alt: A Perfect Postcode dashboard view of Manchester with five active filters (property type, price, public-transport time to Manchester city centre, crime, noise) and a hex heatmap of 1,247 matching properties.
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to two u16 compares per row.
---
A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right. The previous version round-tripped to a database, decoded floats, and lost the budget for a single pan inside the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post is the consequence of refusing to break that latency again.
## The constraint that shapes everything
The answer to _"what's the median price in this hexagon, filtered to four-bedroom terraces under £450k with a 35-minute transit to Manchester"_ needs to come back inside a single map pan. Per visible cell, per request, every time the user moves anything. That's the work.
At the resolution we want, the inputs are roughly 25M historical transactions, each with around 150 numeric features (price, EPC, deprivation deciles, school catchment metrics, POI proximities, noise, crime, …). Naively f32 per cell, that's ~15 GB before you count anything else: postcodes, POIs, places, tiles, travel times. The rest of the architecture is the consequence of insisting it all lives in one process on one rentable box.
## u16 quantisation in a row-major flat array
Every numeric feature is encoded as `((value - feature_min) / feature_range) * 65534`. Dequant is `raw * dequant_a + quant_min`. `u16::MAX` is reserved as `NAN_U16` (the explicit missing-value sentinel), so the live range is 65534, not 65535. Per feature we keep a `(min, scale, p1, p99)` tuple and a 100-bucket histogram for the UI sliders.
Storage is a single `Vec<u16>` laid out row-major: `feature_data[row * num_features + feat_idx]`. Sixteen features fit in one 64-byte cache line; a row scan stays in L1 for several rows at a time. With 25M rows × ~150 features × 2 bytes, the property matrix is around 7.5 GB, comfortably inside a 16 GB instance once the rest of the data joins it.
The precision loss is real but bounded: 0.010.1% per feature on the data we have, below the noise floor of any downstream statistic. The win is that the hot loop never touches an `f32`.
## The hot loop is three integer compares
`ParsedFilter` carries `min_u16` and `max_u16`: the user's bounds requantised against the same per-feature `(min, scale)` at parse time. The row test is literal:
```rust
let raw = feature_data[base + filter.feat_idx];
raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16
```
No string keys. No `f32` decoding. Enum features go through a pre-built `FxHashSet<u16>` of allowed raw values, same shape.
Two small parse-time choices made this fast in practice:
- **Sort filters by selectivity.** `numeric.sort_unstable_by_key(|f| f.max_u16.saturating_sub(f.min_u16))` puts the narrowest ranges first. A 50-filter request usually short-circuits on filter two or three.
- **Reject inverted ranges at parse time.** `min > max` errors out, so `saturating_sub` can't wrap a huge u16 into the sort key and silently reorder things.
## Spatial: a CSR grid plus precomputed H3
Two indexes, used for different things.
A 0.01° (~1 km) regular grid in CSR layout (a single flat `values: Vec<u32>` of row indices and an `offsets: Vec<u32>` of per-cell starts) answers bbox queries. CSR avoids the 24-byte-per-cell `Vec` header you'd pay with `Vec<Vec<u32>>`, which is the difference between a few MB and a few hundred MB at UK scale. `for_each_in_bounds` is the variant that skips the result allocation; aggregators stream into it directly.
An H3 cell at resolution 12 is precomputed per property at boot, stored as `Vec<u64>`. Lower-resolution cells are derived via `CellIndex::parent()`; fast and exact. The hexagon endpoint thresholds at `PARALLEL_THRESHOLD = 50_000`: below, plain serial aggregation; above, `rayon::par_chunks()` with `chunk = max(1000, rows / num_threads)`. Below the threshold, rayon's per-chunk overhead dominates the work it's parallelising; it's worse than the obvious thing. Above, the slope flips.
A small per-thread `FxHashMap<u64, u64>` H3 cache inside each rayon chunk takes care of properties touched by multiple aggregations within the same chunk.
## State is an Arc-clone away
`AppState` is large and immutable after the boot-time loads. `SharedState = RwLock<Arc<AppState>>` wraps it; every handler does `shared.load_state()`: a brief read lock, an `Arc::clone`, no further lock contention for the request.
The standard read-mostly pattern, but worth naming for one reason: it makes hot-reloading the parquet trivial later. Build a new `AppState` from disk, take the write lock, swap the `Arc`, drop the old one when the last in-flight request finishes. None of the handlers need to change.
On top of that there's a per-endpoint `ConcurrencyLimitLayer::new(N)`. The expensive endpoints (filter-counts, hexagon-stats, screenshot, export) get 35; the cheap ones get 2030. It is the simplest backpressure you can write and it does most of the work.
## PocketBase as the distributed lock
For mutations that need exclusion (subscription state transitions, redeem-invite races), there is no Redis. Instead, `acquire_pocketbase_lock` does an optimistic create against a `locks` collection. If create succeeds, we own it; if it fails on conflict, we fetch the existing lock, check `expires_at_unix`, and if it's expired we delete and retry. Owner ID is a 24-char random string so stale-lock detection doesn't rely on host identity or wall-clock skew.
Release is a `Drop` handler that spawns a tokio task to delete the record; async cleanup keeps the synchronous drop path free of I/O. 100 ms retry, 10-second acquire deadline. Coarse, but correct, audit-loggable in PocketBase, and adds zero new infrastructure to operate.
## Cost-capping the LLM endpoint
The AI filter parser is a Gemini call. Two structural choices made it cheap enough to leave on:
- **One system prompt, computed once.** `build_system_prompt(features, mode_destinations)` runs at boot. The feature catalogue, the enum of available travel modes, the few-shot examples: all concatenated once into a `String` on `AppState`. Every request reuses the same bytes, which Gemini's input cache likes.
- **A `search_destinations` tool with a closed enum of modes.** The LLM doesn't get to invent place slugs. It can call the function; the server slugifies and resolves against the loaded travel-time directory using a word-overlap matcher tolerant of `kings-cross` vs `King's Cross`.
On top: a per-week token budget (`AI_FILTERS_WEEKLY_TOKEN_LIMIT = 10_000_000`) and a 2,000-token output cap. The budget is the actual cost guarantee; the per-call cap is belt-and-braces.
## Smaller calls
- **`mlockall(MCL_CURRENT | MCL_FUTURE)` at startup.** The hot dataset has to never page out. With `CAP_IPC_LOCK` it works; without it we log and continue.
- **`malloc_trim(0)` after each big load.** Polars leaves a high allocator water-mark after parquet scans. Trimming after each major load gives back hundreds of MB of RSS before steady state.
- **Prometheus path normalisation.** `/api/tiles/5/16/10` becomes `/api/tiles/:z/:x/:y` before it becomes a label. Otherwise `/.env`, `/wp-admin/...`, and bot scans explode cardinality.
- **Median-half eviction over LRU.** Token, share-bounds, and superuser-token caches evict the older half on overflow instead of one entry at a time. Cheap, and it spreads the re-validation cost instead of triggering a thundering herd.
- **`spawn_blocking` for Polars I/O.** Parquet scans are CPU-bound. They block the tokio executor if you let them; they don't if you don't.
- **`Box<[T]>` instead of `Vec<T>` for aggregator accumulators.** No `capacity` field, 8 bytes saved per slot. At hundreds of hexagons × six features per request it adds up.
- **String interning, three times.** Postcodes (~2.5M unique from 25M rows) live in a `lasso::RodeoReader`; each row stores a `Spur` (~4 bytes). Address tokens are flattened into one buffer with per-row `(offset, length)` arrays. The same pattern for enum value strings.
- **Free-zone bbox check, not point check.** Unlicensed queries must have their _entire_ bbox inside `FREE_ZONE_BOUNDS`. Point-in-zone would be convenient and wrong; it would let users pan to anywhere from a free-zone centre.
- **Share-link bounds are server-computed.** `bounds_from_view(lat, lon, zoom)` derives the bbox from a UK-aware longitude/latitude span (`half_lat = half_lon * 0.6`) and clamps it. Legacy short URLs without server-stored bounds grant nothing.
## What I'd change
- **Pin the allocator.** I rely on `malloc_trim` to keep RSS predictable. A jemalloc with explicit purge would behave better than glibc plus periodic trimming, especially under sustained load.
- **One bench for the hot loop.** I trust the structure but I have no number for _filter throughput per row per filter under typical load_. That number would tell me when the u16 trick stops being enough.
- **Move free-zone bounds to PocketBase.** `FREE_ZONE_BOUNDS` is a `const`. It's been right for the demo region for a year. The next time it changes I'll regret hardcoding it.
- **A typed query DSL instead of `;;`-separated strings.** The current filter wire format is `name:min:max;;name:val1|val2`. Cheap to parse, awful to evolve. A small JSON envelope would survive the next feature.
There's something a little embarrassing about a binary that just memory-maps a country. But the architecture made the latencies trivial, and the latencies are most of what a user feels.

View file

@ -5,7 +5,7 @@ date: 2026-04-30
projectPeriod: 'June 2018'
thumbnail:
src: ./_assets/photo-colour-grader.jpg
alt: Screenshot of a photo colour grading interface.
alt: Colour grading interface with tonal controls and an edited preview.
tags: ['graphics', 'web', 'tools']
role: Interface and image processing author
stack: ['JavaScript', 'Canvas', 'Image processing']
@ -14,8 +14,8 @@ audience: technical
links: []
---
A web-based photo grader I built to try one specific interaction idea: you pick colours from the image, and any transformation you apply spreads to other colours weighted by how far they are from your selection in colour space.
In June 2018 I got tired of every grader I tried making me think in masks. I wanted to point at "this orange" in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense. Distance in colour space, not a brush. So I built the proof.
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings. The transformations smoothly fell off with distance, so editing "this orange" subtly nudged the nearby reds and yellows without me ever having to think in masks.
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings; transformations fell off smoothly with distance from the picked colour. No masks, ever.
I never built it into a real tool. The idea — that distance in colour space is the natural unit for prose-style editing of an image — still feels right to me. If I returned to it, I'd reach for WebGL instead of canvas to make the interaction live-preview-able on a real photo.
I never built it into a real tool. The idea still feels right: distance in colour space is the natural unit for prose-style editing of an image. If I returned to it, I'd reach for WebGL instead of canvas. The interaction only earns its keep if the preview is live on a real photo, and canvas couldn't get there.

View file

@ -14,8 +14,8 @@ audience: general
links: []
---
A Webpack script that took a directory of full-size photos and produced a static site with multiple resized variants per image. Drop a new photo into the source folder, run the build, get a deployable site.
I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to maintain a CMS for it.
The reason this exists at all: I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. The site was an excuse to make that habit visible.
So a Webpack script: point it at a directory of full-size photos, get a static site with responsive variants per image. Drop in a new photo, run the build, deploy. The pipeline mattered less than making the habit visible. The same habit later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
If I rebuilt it today I'd use Astro, which is what this site runs on.

View file

@ -13,10 +13,10 @@ outcome: A playable course project, and the moment programming clicked
audience: technical
---
The course project that convinced me to keep going.
Autumn 2017, Basics of Programming, a deadline that forced me to learn C the hard way. I'd write almost none of it the same way today, and I'd defend every choice in it anyway. A 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer.
Pure C, SDL 1.2, no engine, no scripting layer. The maps were randomly generated and destructible voxel by voxel, which meant the player could dig their way out of trouble, or build walls against flying enemies that would merge into larger ones as they got closer. Powerups let you shoot, or slow down time at the cost of points.
Maps were randomly generated and destructible voxel by voxel, so the player could dig their way out of trouble or wall off flying enemies that merged into larger ones as they got closer. Powerups let you shoot, or slow down time at the cost of points.
What I actually learned was pointers, painfully, through an adequate number of segfaults. The course was meant to teach the basics of programming; for me it was the moment programming stopped feeling like a list of facts and started feeling like a thing I could build with.
What I actually learned was pointers, painfully, through an adequate number of segfaults. The course was meant to teach the basics of programming; for me it was the moment programming stopped feeling like a list of facts and started feeling like a thing I could build with. The next time I reached for C it was on hardware that punished waste; see [Ad Astra](/articles/ad-astra-attiny85-game-engine/).
I'd write almost none of it the same way today, and I'd defend every choice in it anyway. First-project privilege.
First-project privilege.

View file

@ -31,11 +31,6 @@ media:
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose.
---
**The two-bullet version:**
- Given a parent text and two edited versions, return one merged string. No conflict markers, no dropped edits, no operation log required.
- Single Rust core, shipped as a crate, an npm package (via wasm-bindgen), and a PyPI package (via pyo3). The Obsidian sync plugin I wrote alongside it is the first consumer.
## Why I wrote it
I keep Markdown notes in three editors I don't control the internals of: Vim on my laptop, VS Code on my work machine, Obsidian on my phone. When two of them edit the same note between syncs, I have three files: the last-synced parent and two divergent children. That's the input. I want one merged file out, and I want to hand it back to the editors without conflict markers, because `<<<<<<< HEAD` is not something a notes app should ever show me.
@ -52,9 +47,9 @@ So the library does exactly one thing: pure function from three strings to one.
**Myers diff per side, then weave the diffs.** Each child is diffed against the parent, the two edit scripts are optimised so adjacent changes group cleanly, then a single weaving pass interleaves them into one ordered op sequence that produces the merged text. The weave borrows the shape of operational transformation, but the inputs are batched complete diffs, not live keystrokes, so it only runs once per merge.
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just my sync script.
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/).
**The Rust core is generic; the FFI surface is not.** Inside Rust, the tokeniser is a `dyn Fn(&str) -> Vec<Token<T>>`. That dies the moment you try to pass it through wasm-bindgen or pyo3. The fix was a closed enum of built-in tokenisers for non-Rust callers, with the generic version reserved for Rust users. Not elegant, but the alternative was per-binding glue forever.

View file

@ -1,6 +1,6 @@
---
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
description: My BSc thesis library. The mobile GPU constraint did the architectural work — tile-based passes, deferred shading, shaders generated per scene and device.
description: 'My BSc thesis library. The mobile GPU constraint shaped the architecture: tile-based passes, deferred shading, shaders generated per scene and device.'
date: 2026-05-08
projectPeriod: 'Autumn-Winter 2020'
thumbnail:
@ -29,11 +29,7 @@ media:
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
---
**The short version:**
- 2D ray tracing in the browser via signed distance fields. Soft shadows, smooth reflections, no triangle mesh.
- The hard constraint was running on a mid-range Android phone, not a desktop GPU. That single requirement picked tile-based passes, deferred shading, and shaders generated per scene and per device.
- Half of my BSc thesis. The other half was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game built on top of it, which proved the library survived a real game loop.
Winter 2020, BSc thesis deadline closing in, and the thing had to run acceptably on my advisor's laptop the day he graded it. That single shipping pressure exposed every lazy assumption in the architecture and picked the design: tile-based passes, deferred shading, shaders generated per scene and per device. A 2D ray tracer in the browser via signed distance fields: soft shadows, smooth reflections, no triangle mesh. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer survived a real game loop.
## What "mobile GPU" actually meant
@ -43,14 +39,14 @@ Three constraints did most of the design work:
- **WebGL1 and WebGL2 both supported.** No "modern browser only" cheat. That ruled out anything that needed compute shaders or storage buffers.
- **No per-scene hand-tuned shader.** This is a library; users plug in their own scene descriptions. The renderer has to compile something appropriate at runtime.
- **Acceptable on a phone.** Not "good when the user owns the right hardware" — acceptable on the laptop my advisor used to grade the thesis.
- **Acceptable on a phone.** Not "good when the user owns the right hardware." It had to be acceptable on the laptop my advisor used to grade the thesis.
## How it actually runs
- **Tile-based rendering.** Group pixels and reason about them together. Most regions of a frame share the same nearby geometry, so you can early-out enormous swathes of pixel work if you know the tile's bounds. This was the single biggest perf win.
- **Deferred shading.** Separate "find the surface" from "shade the surface." Shadow casting and reflections need the same geometry queries; doing them once per pixel and reusing the result was worth the extra texture bandwidth.
- **Generated shaders per scene and device.** If a scene has no reflective surfaces, the generated shader doesn't carry the reflection path. If the device only supports WebGL1, the shader doesn't reach for WebGL2 features. Static feature flags do this badly; runtime generation does it well.
- **TypeScript scene descriptions, no DSL.** I prototyped a small DSL for SDF authoring and threw it away. Users describe scenes in plain TypeScript and the library compiles them down. A DSL would have meant one more language to teach and one more compiler to debug.
- **TypeScript scene descriptions, no DSL.** I prototyped a small DSL for SDF authoring and threw it away. Pride's expensive. Users describe scenes in plain TypeScript and the library compiles them down. A DSL would have meant one more language to teach and one more compiler to debug.
## Held up, didn't hold up

View file

@ -0,0 +1,106 @@
---
title: An Obsidian Sync Built Around the Merger I Already Had
description: 'VaultLink: self-hosted Obsidian sync. Edit in any editor, online or off, then come back to a converged vault. The application that justified reconcile-text.'
date: 2026-05-30
projectPeriod: '2025-2026'
thumbnail:
src: ./_assets/vault-link.svg
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
tags: ['systems', 'web', 'tools']
role: Sync engine and server author
stack:
[
'Rust',
'axum',
'sqlx',
'SQLite',
'WebSockets',
'TypeScript',
'Obsidian plugin',
'ts-rs',
'wasm-bindgen',
'reconcile-text',
]
scale: One Rust server, one TypeScript sync engine, three published consumers (Obsidian plugin, CLI, fuzz/deterministic test harnesses)
outcome: A self-hosted Obsidian sync I trust enough to use as my primary vault transport
audience: technical
links:
- label: Source
url: https://github.com/schmelczer/vault-link
- label: Docs
url: https://vault-link.schmelczer.dev
---
I refuse to give up the editor. Obsidian on the phone, Vim on the laptop, VS Code at work, the occasional headless `sed` across the whole vault. None of them know about each other, none of them are going to learn to, and I'm not switching to whichever sync product picks a favourite. VaultLink is the architecture that falls out of that refusal: one Rust server, one TypeScript sync engine, an Obsidian plugin, a CLI, and two test harnesses. The merge primitive underneath it all is [reconcile-text](/articles/reconcile-text-3-way-merge/), which I wrote first. VaultLink is the question that made it worth writing, finally asked in earnest.
## The constraint that picks the algorithm
The consequence of that refusal is that the server never sees keystrokes. It sees end states: a file as it stood when sync caught it. That kills CRDTs (which need every operation) and OT-as-it's-usually-implemented (same). It leaves you with one primitive: 3-way merge given a parent, a left, and a right. Which is reconcile-text. Which I'd written exactly because no existing tool took three independently-edited file states and gave one back.
The other consequence is that the _path placement_ is its own problem. Two clients might both move the same file. A file might land on a slot another file already occupies. A rename and a content edit might race. That's the part I underestimated.
## Two loops, separate invariants
The sync engine is two loops, deliberately disentangled:
- **Wire loop** (`syncer.ts`). Drains the single-consumer FIFO of pending HTTP and WebSocket ops. Updates a document's record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and writes content to whatever path the record currently holds. _Never moves files for path placement._
- **Path reconciler** (`reconciler.ts`). Runs after every drained event. Best-effort pass that moves files on disk so `localPath === remoteRelativePath`. The move graph is topologically sorted. Records with pending local events are skipped; the reconciler only operates on settled ones. Failures (slot occupied by something untracked) are silent skips; the next pass retries.
The split is the load-bearing decision. It used to be one loop with both responsibilities, and the bug catalogue was a parade of slot-collision stashes, "conflict-uuid" hacks, and `MoveOnConflict.NEW`/`EXISTING` policy choices. Separating wire transport from path placement made most of that vanish: the wire loop can freely write `remoteRelativePath` to whatever the server returned, even if it disagrees with the file on disk, because the reconciler won't move anything out from under a queued user rename.
Cycles in the move graph (A→B, B→C, C→A) are resolved by reading every file in the cycle into memory and writing each back to its new slot; no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg. On startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. `.vaultlink/**` is hardcoded into the internal ignore pattern so the swap markers never themselves get synced.
## Pending creates are Promises, not strings
When the user creates a file locally and _then_ immediately edits or renames it before the create has been acknowledged, the engine doesn't know the document's id yet; the server assigns it. So queued events for that doc carry a `Promise<DocumentId>` in their `documentId` slot, threaded back to the still-in-flight `LocalCreate`. When the server acks the create, `resolveCreate` fulfils the promise and `replacePendingDocumentId` walks the queue swapping the resolved string into every dependent event.
If you're walking `events[]` and comparing docIds with `===`, you'll silently fail to match until the swap happens. There's a comment in `sync-event-queue.ts` that warns about exactly that, in slightly more alarmed punctuation. The shape is unusual but the alternative (synchronously waiting for the create ack before letting the user type more) is the kind of thing that makes a notes app feel like a 1998 webform.
## MinCovered: the watermark that doesn't lie
The catch-up handshake says "give me everything newer than `lastSeenUpdateId`." If the client advances that id as it receives a stream of RemoteChange ids out of order, it'll publish a too-high cursor, and the next reconnect will request from a point past events it never actually applied. Permanent gap. Replay-forever bug, with extra steps.
The fix is a small data structure called `MinCovered`: a contiguous-prefix tracker over a stream of integers. It advances the public min only when the next consecutive id has been processed. Out-of-order arrivals stash without bumping the cursor. Five files of test, one screen of implementation, and an entire category of confusing data-loss bugs disappears.
## reconcile-text on the server
The merge sits on the server. When two clients submit edits against the same `parent_version_id`, the second submission triggers a 3-way merge against the parent and the freshly-committed first edit. Three strings in, one out. No conflict markers. The engine commits the merged result, increments the version, and broadcasts the new state to every connected client.
Two restrictions, both honest:
- **Only `.md` and `.txt`.** Markdown that fails UTF-8 validation gets treated as binary, same as PNGs and PDFs.
- **Last-write-wins for everything else.** Concurrent edits to a `.docx` lose one of the writes. The right fix is "don't edit binaries concurrently," which is unsatisfying but true.
Merge quality is exactly what reconcile-text gives me. Word-level tokenisation turns most prose conflicts into two adjacent edits that coexist. If the merge looks slightly clumsy now and then, the alternative is a `<<<<<<< HEAD` block in my notes, and I'd take the clumsy sentence every time.
## Two test harnesses, one workflow
Distributed-sync bugs are confusing the first time and impossible the second. The fix is two harnesses:
- **`test-client` (fuzz).** N parallel processes hammering random ops against a shared server for minutes at a time. Catches bugs nobody thought to write a test for. Reproductions are noisy.
- **`deterministic-tests`.** Scripted multi-client scenarios with a step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`) using an in-memory filesystem against a real server binary. Used to capture a fuzz-found bug as a minimal repro before fixing it.
The workflow: fuzz finds something, I sift logs for a root cause, write the minimal deterministic test that fails on it, fix until both that test and the fuzz pass. Without the deterministic harness, every bug fix would be vibes-based.
## Smaller calls
- **TS types are generated from Rust via `ts-rs`.** The HTTP/WS API has one source of truth: the Serde types in the server. `scripts/update-api-types.sh` re-emits `frontend/sync-client/src/services/types/`. Hand-edits to those files are explicitly banned.
- **`sqlx::query!` macros over a checked-in `.sqlx` cache.** SQL is verified against the schema at compile time. Touching SQL means re-running `cargo sqlx prepare --workspace`; if you forget, CI catches it.
- **One sync engine, four consumers.** `sync-client` is the engine. Obsidian plugin, standalone CLI, fuzz harness, and deterministic harness all depend on it via `file:../sync-client`. Bugs are fixed once and inherited everywhere.
- **`record.localPath` mutates in place across awaits.** The watcher can rename a doc while a wire-loop handler is mid-HTTP. Snapshotting `localPath` into a local at function entry and reading it after the await reads a vacated slot. Read it live; only snapshot when you deliberately want to compare _before_ and _after_ the await.
- **Watermark advancement is load-bearing both ways.** Branches that skip a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that advance without applying the content lose data. The rule that survives review is: advance only if you applied the event or deliberately discarded it.
## The race I haven't structurally fixed
Pause-or-disable-sync mid-flight is the one left. An HTTP that committed server-side but whose response was dropped leaves the server holding a doc the client never recorded. On resume, the offline scan finds the file again, uploads it as a new create, and server-side dedupe merges the duplicate into the existing doc. If the merge produces a deconflict file (two real divergences), the user picks up an extra file in their vault. Not data loss, but a small ugliness.
The two-loop split doesn't fix this and probably shouldn't. The honest path is something like a persisted client-side "have I acked this op?" log, sitting in the same SQLite the engine already uses. It's on my list, below several things I want more.
## What I'd change
- **Move the merge to the client.** Right now reconcile-text runs on the server. Putting it in the WASM build of reconcile-text on each client, and letting the server be a dumb commit log, would let the merge benefit from device-specific tokenisers (Markdown-aware on the desktop, word-level on mobile). It would also stop the server from needing to understand the file format at all.
- **Property tests for the move graph.** The cycle resolver is the part I trust least under crash. Snapshot tests can't go where proptest can; I should be generating arbitrary move-graph + interruption combinations.
- **A first-class "pause" with a write-ahead op log.** See above.
- **More than `.md` and `.txt`.** A canvas-aware merge for Obsidian's `.canvas` files is one reconcile-text tokeniser away. Not because anyone asked, but because the asymmetry annoys me.
The way I think about VaultLink now: reconcile-text was the bet. VaultLink is what I built once the bet looked like it might pay off. The interesting part of the bet was always that three independently-edited files can become one without anyone telling the system about the keystrokes that produced them. The interesting part of the application is everything you have to do _around_ that merge to stop the rest of the system from undoing it.

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

View file

@ -0,0 +1,47 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="90" fill="url(#grad1)" opacity="0.15"/>
<!-- Main vault icon -->
<g transform="translate(100, 100)">
<!-- Vault body -->
<rect x="-45" y="-50" width="90" height="80" rx="8" fill="none" stroke="url(#grad1)" stroke-width="6"/>
<!-- Vault door circle -->
<circle cx="0" cy="-10" r="22" fill="none" stroke="url(#grad1)" stroke-width="5"/>
<circle cx="0" cy="-10" r="14" fill="none" stroke="url(#grad1)" stroke-width="3"/>
<circle cx="0" cy="-10" r="6" fill="url(#grad1)"/>
<!-- Vault handle -->
<line x1="0" y1="-4" x2="18" y2="-4" stroke="url(#grad1)" stroke-width="3" stroke-linecap="round"/>
<circle cx="18" cy="-4" r="4" fill="url(#grad1)"/>
<!-- Link chain -->
<g opacity="0.9">
<!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g>
<!-- Sync arrows (subtle) -->
<g opacity="0.5">
<!-- Clockwise arrow top-right -->
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- Counter-clockwise arrow bottom-left -->
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,6 +1,6 @@
---
title: Ad Astra
description: A handheld game built from a custom PCB up — ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
thumbnail:
src: ./_assets/ad-astra.jpg
alt: The Ad Astra handheld game running on its OLED display.

View file

@ -0,0 +1,17 @@
---
title: Backup Container
description: A Bash container around BorgBackup. BTRFS snapshot for atomic consistency, numeric env vars for multi-target 3-2-1, sleep-loop instead of cron.
thumbnail:
src: ./_assets/backup.png
alt: Placeholder thumbnail for the backup container project.
period: '2024-2026'
sortDate: 2024-06-01
technologies: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
selected: false
essay: backup-container-btrfs-borg
links:
- label: Source
url: https://github.com/schmelczer/backup-container
- label: Container image
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
---

View file

@ -1,6 +1,6 @@
---
title: decla.red
description: Mobile multiplayer browser game where the client and server linked the same TypeScript game-rules module.
description: Browser multiplayer where the client and server linked the same TypeScript rules module. Concurrency bugs you can't have are bugs you don't have.
thumbnail:
src: ./_assets/declared.jpg
alt: The decla.red browser game interface showing a space scene.

View file

@ -0,0 +1,17 @@
---
title: Fizika
description: 'I needed it for my own physics érettségi: 659 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
thumbnail:
src: ./_assets/fizika.jpg
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
period: '2017-2018'
sortDate: 2018-05-01
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
selected: false
essay: fizika-erettsegi-practice-app
links:
- label: Live
url: https://fizika.schmelczer.dev
- label: Source
url: https://home.schmelczer.dev/git/andras/fizika
---

View file

@ -0,0 +1,24 @@
---
title: Frame
description: A LAN-only e-ink photo frame. Pulls from self-hosted Immich, gated on Home Assistant presence, Atkinson-dithered to 6 colours, no cloud.
thumbnail:
src: ./_assets/frame.jpg
alt: The e-ink frame on the wall showing a dithered landscape photo with the capture age and EXIF location painted into the bottom corners.
period: '2026'
sortDate: 2026-05-01
technologies:
[
'Python',
'Raspberry Pi Zero 2W',
'Waveshare PhotoPainter',
'Immich',
'Home Assistant',
'numba',
'Atkinson dither',
]
selected: true
essay: frame-eink-photo-display
links:
- label: Source
url: https://home.schmelczer.dev/git/andras/frame
---

View file

@ -1,6 +1,6 @@
---
title: Cooling System Simulation
description: A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal, heat as a matrix solve — two passes instead of one PDE.
description: 'A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal and heat as a matrix solve: two passes instead of one PDE.'
thumbnail:
src: ./_assets/nuclear-simulation.jpg
alt: Cooling system simulator interface with pipes, pumps, and temperature values.

View file

@ -0,0 +1,28 @@
---
title: Perfect Postcode
description: A UK property-intelligence map. ~25M historical transactions, ~150 features per row, all u16-quantised in RAM, served from a single Rust binary.
thumbnail:
src: ./_assets/perfect-postcode.jpg
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties as a heatmap.
period: '2026'
sortDate: 2026-05-01
technologies:
[
'Rust',
'Axum',
'Polars',
'h3o',
'rayon',
'PocketBase',
'PMTiles',
'MapLibre',
'deck.gl',
'Conveyal R5',
'Gemini',
]
selected: true
essay: perfect-postcode-rust-property-server
links:
- label: Site
url: https://perfect-postcode.co.uk
---

View file

@ -0,0 +1,29 @@
---
title: VaultLink
description: 'I refuse to give up the editor: Obsidian, Vim, VS Code, sed. Self-hosted sync that survives all four, built on reconcile-text underneath.'
thumbnail:
src: ./_assets/vault-link.svg
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
period: '2025-2026'
sortDate: 2025-12-01
technologies:
[
'Rust',
'axum',
'sqlx',
'SQLite',
'WebSockets',
'TypeScript',
'Obsidian plugin',
'ts-rs',
'wasm-bindgen',
'reconcile-text',
]
selected: true
essay: vault-link-obsidian-sync
links:
- label: Source
url: https://github.com/schmelczer/vault-link
- label: Docs
url: https://vault-link.schmelczer.dev
---

View file

@ -101,7 +101,7 @@ const jsonLdStrings = jsonLdEntries.map((entry) =>
{noindex && <meta name="robots" content="noindex,follow" />}
{!noindex && <link rel="canonical" href={canonical} />}
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#201f1d" media="(prefers-color-scheme: dark)" />
<script is:inline data-theme-script set:html={themeInit} />
<link
rel="preload"
@ -170,7 +170,7 @@ const jsonLdStrings = jsonLdEntries.map((entry) =>
</head>
<body>
<Header />
<main id="content" tabindex="-1">
<main id="content">
<slot />
</main>
<Footer />

View file

@ -2,16 +2,19 @@
import type { ComponentProps } from 'astro/types';
import Base from './Base.astro';
type Props = Omit<ComponentProps<typeof Base>, 'title'> & { title: string };
type Props = Omit<ComponentProps<typeof Base>, 'title'> & {
title: string;
fullWidth?: boolean;
};
const { title, description } = Astro.props;
const { title, description, fullWidth } = Astro.props;
if (!title) {
throw new Error('Page layout requires a `title` prop.');
}
---
<Base {...Astro.props}>
<div class="page-shell">
<div class:list={['page-shell', fullWidth && 'page-shell--full-width']}>
<header class="page-header">
<slot name="breadcrumbs" />
<h1>{title}</h1>

View file

@ -1,11 +1,11 @@
---
import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content';
import { Picture } from 'astro:assets';
import ArticleList from '../components/ArticleList.astro';
import AtAGlance from '../components/AtAGlance.astro';
import Breadcrumbs from '../components/Breadcrumbs.astro';
import PostMedia from '../components/PostMedia.astro';
import PostThumbnail from '../components/PostThumbnail.astro';
import TagList from '../components/TagList.astro';
import {
absoluteUrl,
@ -52,6 +52,12 @@ const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
const h2Headings = headings.filter((h) => 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 = {
@ -122,19 +128,7 @@ const personJsonLd = buildPersonJsonLd();
<TagList tags={post.data.tags} />
</header>
<div class="post-thumbnail">
<Picture
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={[640, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="eager"
fetchpriority="high"
decoding="async"
/>
</div>
<PostThumbnail post={post} />
<AtAGlance
headingId={`at-a-glance-${post.id}`}
@ -164,7 +158,7 @@ const personJsonLd = buildPersonJsonLd();
<Content />
</div>
<PostMedia items={post.data.media} />
<PostMedia items={trailingMedia} />
{
related.length > 0 && (

View file

@ -4,8 +4,9 @@ import { getImage } from 'astro:assets';
import type { ImageMetadata } from 'astro';
export const site = {
brand: 'schmelczer.dev',
name: 'Andras Schmelczer',
title: 'Andras Schmelczer Software engineer',
title: 'Andras Schmelczer, Software engineer',
description:
'Notebook of someone who keeps reaching for the same two moves: let the hard constraint pick the data structure, then keep the API small enough to defend.',
url: 'https://schmelczer.dev',
@ -30,7 +31,7 @@ export const navItems: readonly NavItem[] = [
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
{ href: '/tags/', label: 'Tags' },
{ href: '/tags/', label: 'Tags', footerOnly: true },
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
];
@ -155,13 +156,14 @@ export function buildPersonJsonLd(extra?: Record<string, unknown>) {
// 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],
sizes: '(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem',
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)',
};
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by

View file

@ -1,7 +1,8 @@
---
import ArticleList from '../components/ArticleList.astro';
import EntryThumbnail from '../components/EntryThumbnail.astro';
import Page from '../layouts/Page.astro';
import {
articlePath,
absoluteUrl,
buildPersonJsonLd,
getPublishedPosts,
@ -19,19 +20,25 @@ const startingPoints = posts
.slice(0, STARTING_POINTS);
const STARTING_POINT_NOTES: Record<string, string> = {
'greatai-ai-deployment-api': 'Small API as policy.',
'reconcile-text-3-way-merge':
'Constraints (no history, three runtimes) pick the design.',
'sdf-2d-ray-tracing': 'Mobile GPU as the architecture.',
'life-towers-immutable-tries': 'Data structure as the protocol.',
'nuclear-cooling-simulation': 'Two graphs are simpler than one big one.',
'greatai-ai-deployment-api': 'Policy expressed as a small API.',
'reconcile-text-3-way-merge': 'A merge design shaped by no history and three editors.',
'sdf-2d-ray-tracing': 'Mobile GPU limits drive the rendering architecture.',
'life-towers-immutable-tries': 'Immutable tries make sync cheap and explicit.',
'nuclear-cooling-simulation': 'Separate graph passes keep simulation logic readable.',
};
const startingPointsAnnotated = startingPoints.map((post) => ({
post,
href: articlePath(post),
note: STARTING_POINT_NOTES[post.id.replace(/\.mdx?$/, '')],
}));
const startingPointThumbnail = {
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);
// Canonical Person JSON-LD. Other pages reference this entity by @id.
@ -57,13 +64,14 @@ const personJsonLd = buildPersonJsonLd({
description="A few sentences about the two moves I keep reaching for, and the posts that show them in different shapes."
jsonLd={personJsonLd}
ogType="profile"
fullWidth
>
<div class="prose">
<div class="prose about-copy">
<p>
I'm Andras. I write software for a living, and have done so for about six years. MSc
in CS. The first non-trivial thing I finished was a Raspberry Pi music visualiser
driving LED strips through MOSFETs in 2016, and I've been chasing that same feeling
— pick something I can't yet do, finish it — ever since.
ever since: pick something I can't yet do, then finish it.
</p>
<p>
Two patterns show up in almost everything here. First, the hard constraint usually
@ -79,12 +87,12 @@ const personJsonLd = buildPersonJsonLd({
<dl>
<div>
<dt>Lives in</dt>
<dd>Code. Also Europe.</dd>
<dd>Europe.</dd>
</div>
<div>
<dt>Studied</dt>
<dd>
MSc Computer Science. BSc thesis on SDF-2D, MSc thesis on GreatAI both have
MSc Computer Science. BSc thesis on SDF-2D, MSc thesis on GreatAI; both have
writeups below.
</dd>
</div>
@ -111,31 +119,43 @@ const personJsonLd = buildPersonJsonLd({
</dl>
</section>
<section class="about-section">
<section class="about-section about-section--starting-points">
<div class="section-heading">
<h2 id="best-starting-points">Five posts that show the two moves</h2>
<div class="section-heading__text">
<h2 id="selected-writeups">Selected writeups</h2>
<p>Finished projects where a hard constraint did most of the design work.</p>
</div>
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={startingPoints} />
<div class="prose starting-point-notes">
<p><strong>Why these five:</strong></p>
<ul>
{
startingPointsAnnotated.map(({ post, note }) =>
note ? (
<li>
<strong>{post.data.title}</strong> — {note}
</li>
) : null
)
}
</ul>
</div>
<ol class="starting-points" aria-label="Selected article writeups">
{
startingPointsAnnotated.map(({ post, href, note }) => (
<li>
<EntryThumbnail
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
href={href}
class="starting-point__thumbnail"
widths={startingPointThumbnail.widths}
sizes={startingPointThumbnail.sizes}
ariaLabel={`Open article: ${post.data.title}`}
loading="eager"
/>
<div class="starting-point__body">
<h3>
<a href={href}>{post.data.title}</a>
</h3>
{note && <p>{note}</p>}
</div>
</li>
))
}
</ol>
</section>
<section class="about-section facts">
<h2 id="working-style">A few things I believe</h2>
<div class="prose">
<div class="prose about-copy">
<ul>
<li>
Most "interesting algorithm" problems are actually data-structure problems

View file

@ -29,7 +29,7 @@ const personId = absoluteUrl('/about/#person');
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: `${site.name} Articles`,
name: `${site.name}: Articles`,
url: absoluteUrl('/articles/'),
description,
publisher: { '@id': personId },
@ -65,7 +65,8 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
<ArticleList
posts={postsForYear}
showYear={false}
eagerFirstThumbnail={year === years[0]}
timeline
eagerThumbnailCount={year === years[0] ? 3 : 0}
/>
</section>
);

View file

@ -24,16 +24,15 @@ const personJsonLd = buildPersonJsonLd();
<Base jsonLd={personJsonLd}>
<section class="home-intro">
<p class="eyebrow">A notebook, written after the fact</p>
<p class="eyebrow">Engineering notes</p>
<h1>
Andras Schmelczer — writing up the projects, the trades I made inside them, and the
ones I'd make differently now.
<span class="home-intro-name">Andras Schmelczer</span>, software engineer. Writeups
of finished projects, with the tradeoffs left in.
</h1>
<p>
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 <a href="/about/">About page</a> 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 <a href="/about/">About page</a>
covers the patterns I keep returning to.
</p>
</section>
@ -46,7 +45,7 @@ const personJsonLd = buildPersonJsonLd();
<span aria-hidden="true">→</span></a
>
</div>
<ArticleList posts={latestPosts} />
<ArticleList posts={latestPosts} timeline eagerThumbnailCount={2} />
</section>
<section class="home-section">

View file

@ -19,7 +19,7 @@ const older = projects.filter((project) => !project.data.selected);
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} Projects`,
name: `${site.name}: Projects`,
url: absoluteUrl('/projects/'),
description,
};
@ -32,7 +32,7 @@ const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
<Page title="Projects" description={description} jsonLd={jsonLd}>
<section class="project-section">
<h2 id="selected-projects">Selected Projects</h2>
<ProjectList projects={selected} eagerFirstThumbnail />
<ProjectList projects={selected} eagerThumbnailCount={6} />
</section>
<section class="project-section">

View file

@ -45,5 +45,5 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
</nav>
<h2 class="sr-only">Articles</h2>
<ArticleList posts={filteredPosts} eagerFirstThumbnail />
<ArticleList posts={filteredPosts} eagerThumbnailCount={3} />
</Page>

View file

@ -25,7 +25,7 @@ for (const post of posts) {
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} Tags`,
name: `${site.name}: Tags`,
url: absoluteUrl('/tags/'),
description,
};

View file

@ -10,13 +10,14 @@
document.documentElement.classList.add('js');
var STORAGE_KEY = 'theme';
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
var THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
var saved = null;
try {
var value = localStorage.getItem(STORAGE_KEY);
if (value === 'light' || value === 'dark') saved = value;
} catch (e) {}
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
var theme =
saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');

View file

@ -21,7 +21,7 @@
@layer reset, base, layout, components, utilities, overrides;
/* =========================================================================
Tokens colors, type, space, radius, weights, layout widths
Tokens: colors, type, space, radius, weights, layout widths
========================================================================= */
:root {
@ -32,29 +32,29 @@
'Segoe UI', sans-serif;
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
/* Palette light-dark() pairs each token (light, dark) */
--color-bg: light-dark(#fbfaf7, #151514);
--color-fg: light-dark(#181817, #f1eee7);
/* Contrast with --color-bg: light ~5.4:1, dark ~7.1:1 (both clear WCAG AA
/* Palette: light-dark() pairs each token (light, dark) */
--color-bg: light-dark(#fbfaf7, #201f1d);
--color-fg: light-dark(#181817, #d8d0c3);
/* Contrast with --color-bg: light ~5.4:1, dark ~6.5:1 (both clear WCAG AA
4.5:1 for normal text). Darken-on-light / lighten-on-dark slightly from
the previous values that fell just below threshold. */
--color-muted: light-dark(#3d3b35, #c8c0b3);
--color-link: light-dark(#285f74, #8ab8c8);
--color-muted: light-dark(#3d3b35, #aaa299);
--color-link: light-dark(#285f74, #7fa8b7);
--color-link-hover: light-dark(
color-mix(in oklch, #285f74 70%, black 30%),
color-mix(in oklch, #8ab8c8 70%, white 30%)
color-mix(in oklch, #7fa8b7 80%, white 20%)
);
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
--color-rule: light-dark(#d9d5ca, #39352f);
--color-rule-medium: light-dark(#7a7466, #8a8478);
--color-rule-strong: light-dark(#4a4340, #d0c5b7);
--color-code-bg: light-dark(#efede6, #2f2c27);
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
--color-accent: light-dark(oklch(55% 0.13 15), oklch(68% 0.11 15));
--color-rule: light-dark(#d9d5ca, #5a5247);
--color-rule-medium: light-dark(#7a7466, #70695f);
--color-rule-strong: light-dark(#4a4340, #aaa196);
--color-code-bg: light-dark(#efede6, #2a2824);
--color-callout-bg: light-dark(#f4f1e8, #292723);
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
--theme-switcher-track: var(--color-rule-medium);
--theme-switcher-icon-light: #f0e2b6;
--theme-switcher-icon-dark: #f1eee7;
--theme-switcher-icon-dark: #d8d0c3;
/* Typography */
--fs-xs: 0.75rem;
@ -109,6 +109,16 @@
color-scheme: dark;
}
@media (max-width: 700px) {
:root {
--fs-body: 1.0625rem;
--fs-dek: 1rem;
--fs-lg: 1.125rem;
--fs-xl: 1.375rem;
--fs-3xl: 1.75rem;
}
}
/* =========================================================================
Reset
========================================================================= */
@ -169,6 +179,10 @@
}
body {
min-block-size: 100vh;
min-block-size: 100dvh;
display: flex;
flex-direction: column;
background: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
@ -234,7 +248,7 @@
}
/* =========================================================================
Layout site shell, header, footer, skip link
Layout: site shell, header, footer, skip link
========================================================================= */
@layer layout {
@ -279,6 +293,10 @@
border-bottom: 1px solid var(--color-rule);
}
main {
flex: 1 0 auto;
}
.site-title {
color: var(--color-fg);
font-size: var(--fs-lg);
@ -326,48 +344,47 @@
border-top: 1px solid var(--color-rule);
margin-top: var(--space-16);
padding-block: var(--space-8) var(--space-10);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.footer-links,
.footer-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: var(--space-2) var(--space-5);
margin: 0;
padding: 0;
list-style: none;
color: var(--color-muted);
font-size: var(--fs-caption);
white-space: nowrap;
}
.footer-links a,
.footer-meta a,
.footer-meta span {
.footer-meta > span {
min-height: 44px;
display: inline-flex;
align-items: center;
}
.footer-links a,
.footer-meta a {
padding-inline: var(--space-1);
margin-inline: calc(-1 * var(--space-1));
}
.footer-copyright {
gap: 0.25em;
}
.footer-contact {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: var(--space-2) var(--space-5);
min-width: 0;
}
/* Page header (shared by .home-intro, .page-header, .post-header) */
.home-intro {
max-width: var(--measure-wide);
padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
}
@ -382,6 +399,10 @@
text-wrap: balance;
}
.home-intro-name {
color: var(--color-accent);
}
.home-intro p:not(.eyebrow),
.page-header p,
.dek {
@ -390,12 +411,25 @@
font-size: var(--fs-dek);
}
.page-header,
.post-header {
max-width: var(--measure-wide);
padding-block: var(--space-10) var(--space-6);
}
.page-header {
max-width: var(--measure-wide);
padding-block: var(--space-2) var(--space-6);
}
.page-shell--full-width .page-header,
.page-shell--full-width .page-header p,
.page-shell--full-width .about-copy,
.page-shell--full-width > .about-copy > p {
width: 100%;
max-width: none;
max-inline-size: none;
}
.post-header .dek {
margin-block: var(--space-4) 0;
}
@ -464,6 +498,19 @@
text-underline-offset: 0.25em;
}
.section-heading__text {
flex: 1 1 11rem;
min-width: 0;
}
.section-heading__text p {
max-width: var(--measure);
margin-top: var(--space-1);
color: var(--color-muted);
font-size: var(--fs-caption);
line-height: 1.4;
}
/* -- Breadcrumbs ------------------------------------------------------ */
.breadcrumbs {
@ -523,11 +570,11 @@
}
.tag-list a {
min-height: 44px;
min-height: 2rem;
display: inline-flex;
align-items: center;
padding-inline: var(--space-2);
margin-inline: calc(-1 * var(--space-2));
padding-inline: var(--space-1);
margin-inline: calc(-1 * var(--space-1));
color: var(--color-muted);
text-decoration: none;
}
@ -583,7 +630,7 @@
.project-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--space-4);
align-items: stretch;
}
@ -640,6 +687,8 @@
.project-list p {
margin: var(--space-1) 0 0;
color: var(--color-muted);
font-size: var(--fs-base);
line-height: var(--leading-snug);
}
/* -- Thumbnail -------------------------------------------------------- */
@ -655,10 +704,11 @@
}
.entry-thumbnail img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 300ms ease;
object-position: center center;
}
a.entry-thumbnail {
@ -670,11 +720,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);
}
@ -684,15 +729,100 @@
align-self: center;
}
.article-list--timeline {
--timeline-date-column: 6.25rem;
--timeline-marker-column: 1.125rem;
--timeline-marker-offset: 0.5625rem;
--timeline-gap: var(--space-4);
--timeline-date-offset: 1.125rem;
--timeline-dot-top: 50%;
--timeline-marker-center: calc(
var(--timeline-date-column) + var(--timeline-gap) + var(--timeline-marker-offset)
);
}
.article-list--timeline > li {
position: relative;
grid-template-columns:
var(--timeline-date-column) var(--timeline-marker-column) minmax(0, 1fr)
minmax(6rem, 8rem);
grid-template-areas: 'date marker content thumb';
column-gap: var(--timeline-gap);
border-top: 0;
}
.article-list--timeline > li::before,
.article-list--timeline > li::after {
content: '';
position: absolute;
left: var(--timeline-marker-center);
z-index: 0;
pointer-events: none;
}
.article-list--timeline > li::before {
inset-block: 0;
width: 1px;
transform: translateX(-50%);
background: var(--color-rule-medium);
}
.article-list--timeline > li:first-child::before {
inset-block-start: var(--timeline-dot-top);
}
.article-list--timeline > li:last-child::before {
inset-block-end: calc(100% - var(--timeline-dot-top));
}
.article-list--timeline > li:only-child::before {
content: none;
}
.article-list--timeline > li::after {
top: var(--timeline-dot-top);
width: 0.75rem;
height: 0.75rem;
border: 2px solid var(--color-bg);
border-radius: var(--radius-pill);
background: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-rule-strong);
transform: translate(-50%, -50%);
}
.article-list--timeline > li > article,
.article-list--timeline time,
.article-list--timeline .entry-thumbnail {
position: relative;
z-index: 1;
}
.article-list--timeline time {
justify-self: end;
align-self: center;
width: max-content;
min-block-size: 0;
margin-inline-end: calc(-1 * var(--timeline-date-offset));
padding-inline-end: 4px;
display: inline-block;
color: var(--color-muted);
font-size: var(--fs-sm);
line-height: 1.2;
text-align: center;
white-space: nowrap;
top: 5px;
transform: rotate(-45deg);
transform-origin: right center;
}
/* -- Project card ----------------------------------------------------- */
.project-card {
--project-thumb-size: clamp(7rem, 18vw, 9.5rem);
display: grid;
grid-template-columns: var(--project-thumb-size) minmax(0, 1fr);
grid-template-areas: 'thumb summary';
min-height: var(--project-thumb-size);
grid-template-columns: 1fr;
grid-template-areas:
'thumb'
'summary';
min-width: 0;
overflow: hidden;
border: 1px solid var(--color-rule);
@ -710,20 +840,11 @@
.project-card .project-thumbnail {
grid-area: thumb;
width: 100%;
height: 100%;
height: auto;
border: 0;
border-right: 1px solid var(--color-rule);
border-bottom: 1px solid var(--color-rule);
border-radius: 0;
aspect-ratio: 1 / 1;
}
.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);
aspect-ratio: 4 / 3;
}
.project-card__summary {
@ -812,13 +933,18 @@
/* -- Post layout ------------------------------------------------------ */
.post > .prose,
.post > .post-media,
.facts {
max-width: var(--measure);
margin-inline: auto;
}
.post > .prose {
max-width: var(--measure-wide);
max-inline-size: var(--measure-wide);
margin-inline: auto;
}
.post > .at-a-glance,
.post > .post-thumbnail,
.post > .post-gallery,
@ -847,6 +973,17 @@
margin-top: var(--space-10);
}
.about-section--starting-points {
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-rule);
}
.about-section--starting-points .section-heading {
align-items: flex-start;
padding-top: 0;
}
.about-section.facts {
max-width: none;
}
@ -855,14 +992,59 @@
margin-top: var(--space-4);
}
.starting-point-notes {
margin-top: var(--space-6);
color: var(--color-muted);
font-size: 0.95em;
.prose.about-copy {
width: 100%;
max-inline-size: none;
}
.starting-point-notes p {
margin-bottom: var(--space-2);
.starting-points {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
padding: var(--space-4) 0 0;
list-style: none;
}
.starting-points > li {
min-width: 0;
}
.starting-point__thumbnail {
aspect-ratio: 4 / 3;
}
.starting-point__body {
min-width: 0;
padding-block-start: var(--space-2);
}
.starting-point__body h3 {
font-size: var(--fs-sm);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
.starting-point__body a {
color: var(--color-fg);
text-decoration: none;
}
.starting-point__body a:hover {
color: var(--color-link-hover);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.starting-point__body p {
margin-top: var(--space-1);
color: var(--color-muted);
font-size: var(--fs-xs);
line-height: var(--leading-snug);
}
.starting-points > li:focus-within .entry-thumbnail {
border-color: var(--color-rule-strong);
}
.about-links {
@ -886,11 +1068,108 @@
background: var(--color-code-bg);
}
.post-thumbnail--iframe {
position: relative;
aspect-ratio: var(--post-thumbnail-aspect, 16 / 9);
overflow: hidden;
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
}
.post-thumbnail--iframe picture,
.post-thumbnail--iframe img {
width: 100%;
height: 100%;
}
.post-thumbnail--iframe picture {
display: block;
}
.post-thumbnail--iframe img {
object-fit: cover;
object-position: center center;
border: 0;
border-radius: 0;
}
.post-thumbnail__play {
position: absolute;
inset: 0;
width: 100%;
border: 0;
padding: 0;
display: grid;
place-items: center;
background: color-mix(in oklch, #000 22%, transparent);
color: var(--color-fg);
cursor: pointer;
}
.post-thumbnail__play:hover,
.post-thumbnail__play:focus-visible {
background: color-mix(in oklch, #000 30%, transparent);
}
.post-thumbnail__play-icon {
width: clamp(3.25rem, 9vw, 4.75rem);
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--color-rule-strong);
border-radius: var(--radius-pill);
background: color-mix(in oklch, var(--color-bg) 88%, transparent);
box-shadow: 0 0.75rem 2rem color-mix(in oklch, #000 28%, transparent);
transition:
background-color 150ms ease,
transform 150ms ease;
}
.post-thumbnail__play:hover .post-thumbnail__play-icon,
.post-thumbnail__play:focus-visible .post-thumbnail__play-icon {
background: var(--color-bg);
transform: scale(1.04);
}
.post-thumbnail__play svg {
width: 42%;
height: 42%;
transform: translateX(8%);
fill: currentColor;
}
.post-thumbnail__iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: var(--color-code-bg);
}
.post-thumbnail--iframe.is-active picture,
.post-thumbnail--iframe.is-active .post-thumbnail__play {
display: none;
}
.post-thumbnail__noscript {
position: absolute;
inset-inline: var(--space-3);
inset-block-end: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-bg);
font-size: var(--fs-caption);
}
/* -- Prose ------------------------------------------------------------ */
.prose {
max-inline-size: var(--measure);
line-height: var(--leading-prose);
hyphens: auto;
hyphenate-limit-chars: 7 3 3;
}
.prose > * + * {
@ -1116,40 +1395,22 @@
border-top: 1px solid var(--color-rule);
}
/* Float .at-a-glance beside .prose at wide viewports. */
/* Let prose wrap beside .at-a-glance and continue below it. */
@media (min-width: 1100px) {
.post {
width: min(100% - 2 * var(--gutter), var(--page));
display: grid;
grid-template-columns: minmax(0, var(--measure)) minmax(14rem, 18rem);
column-gap: var(--space-10);
align-items: start;
}
.post > .post-header,
.post > .post-thumbnail,
.post > .post-gallery,
.post > .post-media,
.post > .post-nav {
grid-column: 1 / -1;
max-width: var(--measure-wide);
margin-inline: auto;
width: 100%;
}
.post > .prose {
grid-column: 1;
margin-inline: 0;
margin-top: var(--space-8);
}
.post > .at-a-glance {
grid-column: 2;
grid-row: span 5;
float: right;
width: min(18rem, 42%);
margin-top: var(--space-8);
position: sticky;
top: var(--space-6);
align-self: start;
margin-right: 0;
margin-bottom: var(--space-4);
margin-left: var(--space-8);
}
.post > .post-media,
.post > .post-gallery,
.related-posts,
.post-nav {
clear: both;
}
}
@ -1337,12 +1598,12 @@
--switcher-w: 2.75rem;
--switcher-h: 1.5rem;
--switcher-icon: 1.05rem;
--switcher-mask: 0.78rem;
--switcher-gap: 0.22rem;
--switcher-mask-offset: 0.32rem;
position: relative;
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--switcher-w);
height: var(--switcher-h);
/* Adjacent header targets remain at least 44px apart while the visual
@ -1369,48 +1630,46 @@
display: none !important;
}
.theme-switcher::before,
.theme-switcher::after {
content: '';
.theme-switcher-icon {
position: absolute;
top: 50%;
border-radius: var(--radius-pill);
transition:
transform 180ms ease,
background-color 180ms ease;
}
.theme-switcher::before {
z-index: 1;
left: 0;
width: var(--switcher-icon);
height: var(--switcher-icon);
pointer-events: none;
transition:
transform 180ms ease,
opacity 180ms ease,
color 180ms ease;
}
.theme-switcher::after {
z-index: 2;
width: var(--switcher-mask);
height: var(--switcher-mask);
.theme-switcher-icon-sun {
color: var(--theme-switcher-icon-light);
}
.theme-switcher[aria-pressed='false']::before {
.theme-switcher-icon-moon {
color: var(--theme-switcher-icon-dark);
}
.theme-switcher[aria-pressed='false'] .theme-switcher-icon-sun {
transform: translateY(-50%)
translateX(calc(var(--switcher-w) - var(--switcher-icon) - var(--switcher-gap)));
background-color: var(--theme-switcher-icon-light);
opacity: 1;
}
.theme-switcher[aria-pressed='false']::after {
.theme-switcher[aria-pressed='false'] .theme-switcher-icon-moon {
transform: translateY(-50%) translateX(calc(-1 * var(--switcher-icon)));
opacity: 0;
}
.theme-switcher[aria-pressed='true'] .theme-switcher-icon-sun {
transform: translateY(-50%) translateX(var(--switcher-w));
opacity: 0;
}
.theme-switcher[aria-pressed='true']::before {
.theme-switcher[aria-pressed='true'] .theme-switcher-icon-moon {
transform: translateY(-50%) translateX(var(--switcher-gap));
background-color: var(--theme-switcher-icon-dark);
}
.theme-switcher[aria-pressed='true']::after {
transform: translateY(-50%)
translateX(calc(var(--switcher-gap) + var(--switcher-mask-offset)));
background-color: var(--theme-switcher-track);
opacity: 1;
}
/* High-contrast / forced-colors fallback: render a text label. */
@ -1431,8 +1690,8 @@
box-shadow: none;
}
.theme-switcher::after {
content: none;
.theme-switcher-icon {
display: none;
}
.theme-switcher::before {
@ -1453,7 +1712,7 @@
}
/* =========================================================================
Responsive tablet + mobile breakpoints
Responsive: tablet + mobile breakpoints
========================================================================= */
@layer overrides {
@ -1464,6 +1723,24 @@
gap: var(--space-4);
padding-block: var(--space-5);
}
.article-list--timeline {
--timeline-date-column: 5.75rem;
--timeline-gap: var(--space-4);
--timeline-date-offset: 1.125rem;
}
.article-list--timeline > li {
grid-template-columns:
var(--timeline-date-column) var(--timeline-marker-column) minmax(0, 1fr)
7rem;
grid-template-areas: 'date marker content thumb';
column-gap: var(--timeline-gap);
}
.starting-points {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* Mobile */
@ -1473,7 +1750,15 @@
}
.home-intro {
padding-block: var(--space-8) var(--space-6);
padding-block: var(--space-6) var(--space-5);
}
.home-section {
margin-top: var(--space-6);
}
.page-shell {
margin-top: 0;
}
.at-a-glance__row,
@ -1491,6 +1776,25 @@
padding-block: var(--space-4);
}
.article-list--timeline {
--timeline-date-column: clamp(3.75rem, 18vw, 4.75rem);
--timeline-marker-column: 1rem;
--timeline-marker-offset: 0.5rem;
--timeline-gap: var(--space-3);
--timeline-date-offset: 0.875rem;
--timeline-dot-top: 2.1875rem;
}
.article-list--timeline > li {
grid-template-columns:
var(--timeline-date-column) var(--timeline-marker-column)
minmax(0, 1fr);
grid-template-areas:
'date marker content'
'thumb marker content';
gap: var(--space-2) var(--timeline-gap);
}
.article-list > li > article {
padding-right: 0;
}
@ -1500,24 +1804,41 @@
white-space: nowrap;
}
.article-list--timeline time {
align-self: start;
margin-block-start: var(--space-3);
font-size: var(--fs-xs);
text-align: center;
}
.article-list .entry-thumbnail {
aspect-ratio: 1;
}
.project-card {
--project-thumb-size: 7rem;
grid-template-columns: 1fr;
grid-template-areas:
'thumb'
'summary';
.starting-points {
display: block;
padding-top: 0;
}
.project-card .project-thumbnail {
height: auto;
border-right: 0;
border-bottom: 1px solid var(--color-rule);
aspect-ratio: 16 / 9;
.starting-points > li {
display: grid;
grid-template-columns: 4rem minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
padding-block: var(--space-3);
border-top: 1px solid var(--color-rule);
}
.starting-points > li:first-child {
border-top: 0;
}
.starting-point__thumbnail {
aspect-ratio: 1;
}
.starting-point__body {
padding-block-start: 0;
}
.project-card .project-meta {
@ -1530,13 +1851,48 @@
.page-header,
.post-header {
padding-block: var(--space-8) var(--space-5);
padding-block: var(--space-6) var(--space-4);
}
.post > .prose {
margin-top: var(--space-6);
}
.prose {
line-height: 1.55;
}
.prose code {
overflow-wrap: anywhere;
}
.prose pre {
overflow-x: hidden;
scrollbar-gutter: auto;
white-space: pre-wrap;
}
.prose pre code,
.prose pre .line {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.tag-filter {
display: block;
margin-bottom: var(--space-5);
padding-block: var(--space-2);
}
.tag-filter .tag-list {
gap: 0 var(--space-3);
margin-top: var(--space-2);
}
.tag-list {
gap: 0 var(--space-3);
}
:focus-visible {
outline-offset: 1px;
}
@ -1569,6 +1925,58 @@
.site-nav {
gap: var(--space-1) var(--space-6);
}
.footer-meta,
.footer-contact {
gap: var(--space-2);
}
.footer-meta {
font-size: var(--fs-xs);
}
}
@media (min-width: 430px) and (max-width: 700px) {
.post > .prose > p,
.page-shell--full-width > .about-copy > p {
text-align: justify;
text-align-last: start;
text-justify: auto;
overflow-wrap: normal;
word-break: normal;
}
}
@media (min-width: 701px) {
.page-shell--full-width > .about-copy > p {
text-align: justify;
text-align-last: start;
text-justify: auto;
overflow-wrap: normal;
word-break: normal;
}
}
@media (max-width: 360px) {
.site-header {
position: relative;
}
.theme-switcher {
position: absolute;
inset-block-start: var(--space-6);
inset-inline-end: 0;
margin: 0;
}
.header-actions,
.site-nav {
column-gap: var(--space-3);
}
.footer-name {
display: none;
}
}
/* Reduced motion */