Update content & design (#75)
All checks were successful
Deploy to Pages / build (push) Successful in 2m58s

Reviewed-on: https://home.schmelczer.dev/git/git/andras/schmelczer-dev/pulls/75
This commit is contained in:
Andras Schmelczer 2026-05-28 16:20:12 +01:00
parent 0be50b6c24
commit b554e92e9f
83 changed files with 2995 additions and 723 deletions

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
@ -49,3 +49,10 @@ jobs:
apt update && apt install -y rsync
mkdir -p /pages
rsync -a --delete dist/ /pages/schmelczer-dev
- name: Copy build to staging pages mount
if: github.event_name == 'pull_request'
run: |
apt update && apt install -y rsync
mkdir -p /pages
rsync -a --delete dist/ /pages/schmelczer-dev-staging

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": "/"

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 ATtiny85
description: Building a tiny embedded game engine around an ATtiny85V, OLED display, IR input, EEPROM persistence, and a custom PCB.
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.'
date: 2026-05-06
projectPeriod: 'Spring 2020'
thumbnail:
@ -8,9 +8,9 @@ thumbnail:
alt: The Ad Astra game running on a small OLED display.
tags: ['embedded', 'games', 'systems']
role: Hardware and firmware author
stack: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
scale: 8-bit microcontroller, 8 MHz clock, 15-20 ms maximum frame times during gameplay
outcome: A working low-power handheld game engine and game built from the circuit board up
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
scale: 8 MHz, 8-bit ALU, ~31 mW at full brightness, ~1.5 mA standby, 1520 ms frame budget
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
audience: technical
links:
- label: Source
@ -22,42 +22,23 @@ 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 game engine ran on an ATtiny85V with an OLED display and IR input.
transcript: No spoken dialogue. The demonstration shows the Ad Astra handheld board running its OLED game, with the player moving through the small display while the IR input controls gameplay.
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.
---
Ad Astra came from wanting to combine graphics and microcontrollers without hiding behind a large development board. The result was a small embedded game engine and game built around an ATtiny85V, an OLED display, IR input, EEPROM persistence, and a custom PCB.
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.
The fun part was that every layer mattered. The circuit, display driver, memory layout, object model, sprite tooling, and game loop all had to fit inside a tiny system.
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 Problem
## The bits worth showing
The hardware setup was intentionally constrained: an ATtiny85V, a D096-12864-SPI7 OLED display, a TSOP4838 IR receiver, and a 3.3V regulator. The system was low power, with peak consumption around 31 mW at full brightness and a standby mode around 1.5 mA.
- **SIMD-on-an-8-bit-ALU display driver.** The OLED is 128×64 monochrome, 1024 bytes per frame. The driver packs four pixels into a byte and processes them with bit-parallel tricks. That's how the frame budget stayed under 20 ms with room for game logic.
- **Prototype-based inheritance, in C.** Entities share behaviour by pointing at a struct of function pointers. No vtable, no class, no allocator. Cheap dispatch and the whole object model fits on one screen.
- **Atomic EEPROM commits.** Sprite data and save state both live in EEPROM. The commit path writes a new region, then swaps a tiny header pointer. Pull the battery mid-write and the previous version is intact.
- **PNG-to-C sprite pipeline.** A Python script turns PNG artwork into static C arrays the firmware can include directly. Asset workflow without ever leaving the source tree.
Those numbers made the project feel physical. Performance was not an abstract target. Every frame and every byte had a cost.
## What I'd change
## Constraints
The engine ran at 8 MHz on an 8-bit ALU. That meant the display driver and game loop had to avoid expensive generality.
Even the programming model needed restraint. I wrote the firmware in C, but used a balance of structured and object-oriented ideas to keep game object behaviour manageable without paying for a runtime that did not exist.
## Design
The display driver was the most performance-sensitive layer. I used SIMD-like techniques on the 8-bit ALU to process four pixels at once. That helped keep maximum frame times between 15 and 20 milliseconds during gameplay, so the lowest gameplay frame rate stayed above 50 FPS.
For game objects, I used prototype-based inheritance. It was a pragmatic way to reuse behaviour while keeping the implementation simple enough for the target.
Persistent state used the built-in EEPROM with an atomic commit approach. Sprite data also lived in EEPROM, and I wrote scripts to convert PNG sprites into C array definitions so assets could move into firmware cleanly.
## What Worked
The project worked because the abstraction level stayed close to the hardware. The engine had reusable pieces, but none of them pretended the platform was larger than it was.
The custom PCB also changed the project. Once the system had a real board, bugs felt less like software inconveniences and more like design consequences. That made the final result much more satisfying.
## What I Would Change
Today I would write a more explicit development log around the display driver and persistence layer. Those are the parts that still feel technically interesting, and they deserve diagrams and measurements.
I would also add a small emulator or host-side harness. Debugging firmware directly on constrained hardware is useful, but a fast feedback loop would have made the engine easier to evolve.
- **A host-side emulator.** Debugging firmware directly on hardware was character-building and slow. A small SDL-based simulator linking the same C code would have shortened the iteration loop from "reflash and hope" to "rebuild and run."
- **Power numbers I'd actually trust.** I have peak and standby draw. I don't have a curve over a real gameplay session, so I honestly can't say how long the battery lasts under load. I can only say it outlasted my patience.
- **A development log for the driver.** The display driver and the EEPROM commit protocol are the parts I'd still defend. They deserved diagrams and measurements at the time, not the half page of comments I left them with.

View file

@ -1,6 +1,6 @@
---
title: Avoid, an Early Web Game
description: A tiny archived web game from my first experiments with browser-based interaction.
title: Avoid
description: My first browser game. Tiny, archived for honesty.
date: 2026-04-29
projectPeriod: 'January 2018'
thumbnail:
@ -9,8 +9,8 @@ thumbnail:
tags: ['games', 'web']
role: Game author
stack: ['JavaScript', 'Canvas']
outcome: A small playable web game kept as an archive of early browser work
outcome: My first browser game; kept for the timeline
audience: general
---
I recently found my first web game. It is very simple, but I killed some time with it.
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 Simulation for a Cybersecurity Challenge
description: A client-server Unity simulation where REST-controlled traffic lights made mistakes immediately visible through crashes.
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.
date: 2026-05-01
projectPeriod: 'July-August 2018'
thumbnail:
@ -9,17 +9,13 @@ thumbnail:
tags: ['simulation', 'systems']
role: Simulation author
stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: A visual context for a PLC-focused cybersecurity challenge
outcome: Visible consequences for an otherwise abstract PLC challenge
audience: technical
links: []
---
I simulated a city where car crashes were more frequent than usual.
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.
The state of the traffic lights could be changed through a REST API. Drivers followed the instructions of those lights, so if a mistake was made, collisions appeared in the simulation. There was also support for displaying tweets on a HUD.
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.
The project was the context for a cybersecurity challenge about PLCs. Contestants could see the effect of their work immediately, as crashes.
The architecture was server-client. Every decision of the agents was calculated server-side, and the real challenge was broadcasting those decisions in a fault-tolerant way on minimal bandwidth.
It was built in Unity with C# as the scripting language. I also made the models and animations in Blender.
There was also a HUD overlay for tweets. It felt clever at the time and dated horribly. Skip that part.

View file

@ -1,6 +1,6 @@
---
title: Shared Simulation Code in a Mobile Multiplayer Browser Game
description: How decla.red used shared TypeScript game logic, WebSockets, client prediction, and spatial indexing for a team-based browser game.
title: One Game Library, Imported by Both the Client and the Server
description: A mobile multiplayer browser game where client and server linked the same TypeScript module. One source of truth, one fewer class of bug.
date: 2026-05-07
projectPeriod: 'Autumn-Winter 2020'
thumbnail:
@ -8,9 +8,9 @@ thumbnail:
alt: The decla.red browser game interface showing a space scene.
tags: ['games', 'web', 'systems']
role: Game and backend systems author
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
scale: Multiple servers, each communicating with 16-32 clients
outcome: A mobile-capable online browser game built on top of SDF-2D
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL', 'SDF-2D']
scale: Multiple game servers, each talking to 1632 clients, browser and mobile
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
audience: technical
links:
- label: Source
@ -22,41 +22,27 @@ media:
- type: image
src: ./_assets/decla-red.jpg
alt: The decla.red browser game interface showing a space scene with team controls and planets.
caption: decla.red used the SDF-2D renderer in a real-time multiplayer game.
caption: A real game loop is a worse audience than a tech demo. That's the point.
---
`decla.red` was a conquest-style online multiplayer browser game set in space. Two teams fought over small planets, gained points based on control, and could shoot at the other team while moving through a ray-traced 2D scene.
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 rendering made the game look interesting, but the architecture was the more useful lesson. The game needed to run on phones, talk to multiple servers, keep clients responsive, and avoid duplicating game rules between frontend and backend.
## The split that usually goes wrong
## The Problem
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 games have an awkward split. The server should be authoritative, but the client has to feel immediate. If every meaningful interaction waits for a round trip, the game feels broken. If the client is trusted too much, the game becomes inconsistent or easy to abuse.
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.
For this project, I wanted the same game rules to be used by the server and the client. The server would calculate the actual next state. The client could predict locally with the same code and later reconcile with the server.
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.
## Constraints
## Other choices worth a sentence
The project used TypeScript on both sides: browser code for the client and Node.js for the server. WebSockets carried real-time updates. Firebase helped the servers reach consensus about the active server set.
- **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, just for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
Each server communicated with 16-32 clients. That is not large by industry standards, but it was enough to make careless spatial operations and state updates visible.
## What I'd change
## Design
The key decision was a shared library for game logic. Both the client and server linked to it, so the transition rules lived in one place.
That reduced a common source of bugs: the client and server disagreeing about the meaning of an action. It also made client-side prediction more realistic, because the client was not approximating a different system.
As the game logic became heavier, spatial operations needed attention. I implemented k-d trees to reduce the cost of queries over objects in the world. For the object model, I borrowed ideas from message passing, including a version of the Smalltalk-style `messageNotUnderstood` pattern, to keep behaviour extensible without pushing every entity into a brittle inheritance tree.
## What Worked
Sharing simulation code was the most important architecture choice. It let the project stay coherent as the client and server evolved.
The project also validated SDF-2D outside a toy environment. A rendering library is more convincing when it survives a game loop, input, network updates, and mobile constraints.
## What I Would Change
I would now spend more effort on observability for synchronisation and prediction errors. Multiplayer systems need good visibility into divergence. Without that, debugging becomes a sequence of guesses.
I would also separate the story of rendering and networking more clearly in the codebase. Both were interesting, but they put different kinds of pressure on the architecture.
- **Observability for desync.** Multiplayer systems live or die by visibility into divergence. I had logs; I needed dashboards showing the rate, the shape, and the triggering interaction for every prediction miss. Without those, debugging was guessing.
- **Don't tangle rendering and networking in the same tree.** Both were interesting, both put different kinds of pressure on the architecture, and the directories grew into each other. Separate top-level folders from day one next time.
- **Skip multi-server until the math demands it.** I wired up multi-server early because it sounded right. With 1632 clients per server I was nowhere near needing it; the complexity wasn't free.

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

@ -1,16 +1,17 @@
---
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
description: How Fleeting Garden runs an agent simulation in WebGPU compute shaders, with a 3×3 reaction matrix as the personality of each vibe.
description: A single-file WebGPU drawing toy. You stroke a colour, agents follow it, and a 3×3 matrix per vibe gives each preset its personality.
date: 2026-05-22
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: Author
role: Graphics and shader author
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
scale: A single-file WebGPU bundle, ~10 WGSL shaders, six vibe presets, runs entirely client-side
outcome: A browser drawing toy where user input seeds an agent simulation that rewrites the canvas in real time
scale: One HTML file, ~10 WGSL shaders, 6 vibe presets, 60 FPS target on consumer hardware
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
audience: technical
links:
- label: Demo
@ -20,75 +21,51 @@ links:
media:
- type: image
src: ./_assets/fleeting-garden.jpg
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot, with a fine grain over the whole image.
caption: A snapshot of one Fleeting Garden session. The trail texture is what you see; the agents that drew it are no longer visible.
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot.
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
---
Fleeting Garden began as a chance to spend a few weeks inside WebGPU compute. The first constraint I set for myself was that user input should steer the simulation, not just seed it. The second was that the same engine should produce visibly different behaviour under different presets, without growing a fork per preset.
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.
The shape that emerged is a single-page drawing toy. You pick a palette, drag a colour onto the canvas, and a swarm of agents follows the stroke, branches off, and slowly rewrites the patch you laid down. The strokes themselves vanish immediately. What remains is a trail texture that the agents both read from and write to, blurred and faded a little every frame.
## Why physarum needed a knob
## The Problem
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 simulations are a well-trodden idea. Sense the surrounding trail, turn toward what you like, deposit a bit of your own colour, repeat. Drop a million of these on a texture and you get the familiar branching networks that look biological from a distance.
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.
The interesting question is not how to make one run. It is how to make one feel like something specific. A generic physarum visual converges to the same family of structures regardless of input, which is why so many of them stop being interesting after the first thirty seconds. User input has to do more than seed the initial condition; it has to remain a force inside the system.
## The reaction matrix
The second part of the problem is variety. The same engine had to produce visibly different behaviour under different presets, so that switching vibes felt like changing seasons rather than nudging one slider. That ruled out separate behaviour code per preset, which had been the obvious shape of the first prototype and had not survived contact with the second one.
Each vibe is a 3×3 table of colour-to-colour affinities. When an agent of colour `i` looks at the trail in front of it, it weights the three channels of that sample by row `i` of the matrix, then uses the sign to pick left, right, or straight. That's it. The whole behaviour rule.
## Constraints
Three examples of what nine numbers can do:
The toy had to be a single static file. No server, no account, no save state. Open the URL, draw, close the tab. That is the deal the metaphor makes with the user, and the deployment story falls out of it: `vite build` produces one HTML file, which a CI job rsyncs to a static host.
- **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.
It had to be WebGPU only. Compute shaders are the right tool for this kind of simulation, and writing a Canvas2D or WebGL fallback would have meant either a second implementation or a watered-down primary one. The browserslist is literally `supports webgpu and last 2 years`, and anything older gets a clear message instead of a degraded experience.
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.
It had to run on consumer hardware at sixty frames per second. The number of agents is the obvious lever, so it had to be adaptive. The number of WGSL pipelines is the less obvious one, so the architecture had to keep each frame's compute work split across a small number of focused shaders rather than one fat kernel.
## The compute work, broken into small jobs
## Design
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
The simulation is split into six compute stages, written across ten WGSL files. Each stage has one job:
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.
1. **Agent step** advances every agent by one frame. It samples the trail texture at a sensor offset, picks a turn direction, moves, and deposits a small amount of colour into the next frame's trail texture.
2. **Diffusion** blurs and decays the trail texture, so old marks soften and disappear.
3. **Brush** writes user strokes into the trail texture and a separate "source" texture that the agent shader can read.
4. **Eraser** has two variants. One clears a region of the trail texture, the other kills agents inside the eraser radius.
5. **Agent generation** handles spawning new agents along a stroke, resizing the agent buffer when the cap changes, and compacting the buffer after erasure so dead slots do not waste GPU time.
6. **Render** reads the final trail texture and produces the canvas image, with the palette and grain applied at the last moment.
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.
Each of these is around a few dozen lines of WGSL, and the longest one (agent step) is under 300. Keeping them small is what made the simulation tunable; once they grew tangled, the tuning loop slowed to a crawl.
## Smaller calls
### The Reaction Matrix
- **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.
The piece of the design I would defend hardest is the reaction matrix. Each vibe carries a 3×3 table of colour-to-colour affinities. When an agent of colour `i` senses the trail in front of it, the three channels of that sample are weighted by row `i` of the matrix to decide whether to turn left, turn right, or hold course. That is the entire behaviour rule.
## What I'd change
The matrix is nine numbers in `{-1, 0, 1}`, and it captures most of what makes the six vibes feel different. _Aurora Mycelium_ has a cyclic preference where each colour chases the next, so its agents wind into ribbons. _Velvet Observatory_ has every off-diagonal entry negative, so the colours repel each other and settle into separate islands. _Paper Lantern Fog_ has the matrix filled with ones, which collapses the three colours into one cooperative blob.
Putting the personality of a vibe in a small, legible matrix was deliberate. The earlier prototype had a behaviour function per preset, and that route did not survive the second vibe — every new mood became a new branch in a switch statement. A 3×3 matrix is small enough that I can read it and predict the rough shape of the result, which made tuning new vibes a matter of editing a table rather than writing code.
### Input and Mirroring
The drawing pipeline is intentionally simple. A pointer event becomes a series of stroke segments, each segment spawns agents along its length, and the agents' initial angle points along the stroke with a small amount of jitter. The mirror slider folds each stroke into N copies rotated around the centre, which is the cheapest way I could think of to give the user a sense of composition without a layers panel.
Spawning competes with an adaptive cap. If the framerate drops below the target, the cap shrinks; if there is headroom, it grows. When the cap is hit, new agents overwrite older ones in a circular buffer. That overwrite is what gives the garden its decay: a stroke you drew thirty seconds ago is gone not because anything erased it, but because its agents have been replaced.
### Vibes as URLs
Switching vibes is the only stateful action in the app, and the chosen vibe is encoded in the URL query string. That makes the link itself the share format. A snapshot is a PNG you download; a "send your friend this preset" is a URL with `?vibe=tidepool-lantern` on the end. The URL parser is tolerant about accents, casing, and whitespace, because the names are the kind of thing people retype rather than copy.
## What Worked
The reaction matrix earned its place. Six presets later, I have not had to extend it. Every new vibe so far has been a recolouring plus a different table, sometimes with tweaks to the diffusion or sensor parameters, and the underlying simulation has not changed. At this scale, configuration is cheaper to evolve than code. Adding a tenth number to the matrix would be a tax on every existing vibe; tuning the nine I have is a few minutes of editing a file.
Splitting the compute work across small WGSL stages held up for the same reason in a different form. When the agent-erase shader started killing the wrong agents, I could open one short file and reason about it without touching anything else. The cost of running more pipelines is the bind-group setup, and that was lost in the noise compared to the simulation work itself.
The single-file build is the part I underestimated. The whole app, including all CSS and JavaScript, is one HTML file; the piano samples sit beside it and are preloaded at startup. That makes deployment trivial — `rsync` and done — but the part that actually matters is that the file is self-contained enough to hand around. I can attach it to an email or drop it on a USB stick and it runs offline, which is the closest a web app gets to feeling like an object.
## What I Would Change
The intro animation cost more than it should have. Agents fly in from off-screen to spell out the title, then transition to steady-state behaviour. The choreography is tied to a single `progress: 0 → 1` value that bleeds into timing, easing, and target positions across three different shaders, and that coupling is what makes the intro the part of the code I would least want to refactor today. If I rebuilt this, I would model the intro as its own dispatch with its own agent buffer and hand off to the steady-state pipeline at the boundary.
Property tests would help more than I expected. The simulation has invariants that hand-written unit tests are bad at finding — agent count stays under the cap, every drawn stroke produces a positive-coloured deposit on the next frame, the eraser does not leak agents past its radius — and these are exactly the shape of claim a generator-based test would falsify quickly.
The mobile experience is good enough rather than good. Pointer events behave, but small screens make the toolbar fight the canvas for space, and the agent cap has to shrink hard to keep the framerate up. A real fix means rethinking the toolbar layout and probably making the cap-versus-resolution tradeoff a user-visible choice.
The part I would keep is the asymmetry. You shape the gesture; the garden owns the response. The trail decay and the refusal of save state both look like missing features in isolation, and both stop looking that way the moment the garden is allowed to be fleeting. Most of the rest of the design is what fell out of taking that idea seriously.
- 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 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

@ -1,6 +1,6 @@
---
title: A Frequency-Domain Foreign Exchange Prediction Experiment
description: An older EUR/USD prediction experiment built from smoothing, short-time Fourier transforms, extrapolation, and a Python prediction server.
title: Predicting EUR/USD With Hanning Windows
description: A weekend frequency-domain experiment that did a passable job on EUR/USD. I would not have trusted it with my money, and I didn't.
date: 2026-05-03
projectPeriod: 'Autumn 2019'
thumbnail:
@ -9,15 +9,21 @@ thumbnail:
tags: ['systems', 'tools']
role: Experiment author
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
outcome: A working prediction server connected to an MQL4 client for trading experiments
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
audience: technical
links: []
---
This was an experiment in predicting EUR/USD rates. The animation from the old portfolio showed the implementation doing a passable job: the prediction was the blue graph and the actual values were the green one. I would not have trusted it with my money.
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 algorithm was a fancy linear regression in the frequency domain. The steps were: smoothing the input values, differentiating, applying a short-time Fourier transformation with overlapped and Hanning-windowed windows, extrapolating, and then applying the inverse of these transformations to the resulting values.
The pipeline:
The prediction server was written in Python using NumPy, SciPy, and Flask. It communicated with an MQL4 client that was responsible for handling financial transactions based on the generated data.
- Smooth the input series.
- Differentiate.
- Short-time Fourier transform with overlapped, Hanning-windowed frames.
- Extrapolate the frequency-domain coefficients.
- Invert everything back to a predicted price series.
There was still plenty of room for improvement, but even with this simple algorithm, a sometimes profitable strategy was viable. The project was mostly a look into trading algorithms, their complexity, and the competition around them.
A Python server (NumPy, SciPy, Flask) ran the model. An MQL4 client on a broker terminal called the server and would have placed trades if I'd dared.
What I actually learned: even a naive model can show a sometimes-profitable backtest, and that's the trap. The real game is built by people with co-located servers, microsecond ticks, and millions in infrastructure. This project taught me how far my edge wasn't.

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

@ -1,6 +1,6 @@
---
title: A JavaFX Graph Editor for Simulation Input
description: A small JavaFX editor for creating and uploading graph input for the cooling system simulator.
title: A JavaFX Editor for the Cooling Simulator
description: The companion editor for the cooling-system sim. Drag-and-drop graph layout, JSON export, upload-to-backend. Small tool, mattered more than I expected.
date: 2026-04-25
projectPeriod: 'October-November 2018'
thumbnail:
@ -9,13 +9,11 @@ thumbnail:
tags: ['simulation', 'tools']
role: Editor author
stack: ['JavaFX', 'JSON', 'REST API']
outcome: An editor for building input graphs and sending them to the simulation backend
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
audience: technical
links: []
---
This was a small editor for building input graphs for the cooling system simulator.
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.
Nodes could be moved with drag-and-drop gestures. Element parameters were edited on the right panel.
The UI was built with JavaFX. The output could be exported as JSON or uploaded directly to the simulation backend.
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

@ -1,6 +1,6 @@
---
title: Designing an ML Deployment API Around Best Practices
description: How GreatAI tried to make stronger ML deployment habits accessible through a small Python API.
title: A Python Framework Where Doing the Right Thing Is the Default
description: My MSc thesis. 33 catalogued ML deployment habits, a decorator-shaped Python API, and a survey of working engineers on which actually got adopted.
date: 2026-05-09
projectPeriod: '2022'
thumbnail:
@ -9,9 +9,9 @@ thumbnail:
tags: ['ai', 'systems', 'tools']
featuredOrder: 1
role: Researcher and framework author
stack: ['Python', 'ML deployment', 'API design']
scale: 33 deployment best practices, six proposed additions, evaluated with professional data scientists and software engineers
outcome: A Python framework, thesis, and research-backed API design for production-oriented AI deployments
stack: ['Python', 'decorators', 'FastAPI', 'survey design']
scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers
outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area
audience: recruiter-relevant
links:
- label: PyPI
@ -25,47 +25,29 @@ media:
- type: image
src: ./_assets/great-ai.png
alt: Example Python code using GreatAI decorators and prediction helpers.
caption: GreatAI's public surface was designed to keep deployment best practices close to the application code.
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
---
GreatAI started from a practical frustration: applying machine learning was becoming easier, but deploying it well was still easy to get wrong. Many failures were not about model architecture. They were about missing metadata, weak versioning, poor reproducibility, untracked inputs, or interfaces that made the right behaviour too cumbersome to use.
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.
My thesis work looked at that gap from two sides. First, I collected and organised AI/ML deployment best practices, including 33 practices and six additions proposed through the research. Then I designed a Python framework that tried to make those practices feel like the natural path rather than an enterprise checklist.
## The thing nobody wants to admit
The result was GreatAI: a deployment-oriented framework with a deliberately small API. The design goal was not to wrap every part of an ML stack. It was to make common deployment concerns visible, automatic where possible, and hard to forget.
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.
## The Problem
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.
Deployment quality is often treated as something that happens after model development. That separation creates a bad default. A model can be useful in a notebook, but a deployed AI service also needs traceability, stable interfaces, input/output logging, model metadata, and operational behaviour that can be inspected later.
So the real research question wasn't "what should engineers do." It was "what API shape makes doing the right thing cheaper than not."
The hard part is not listing those needs. The hard part is getting busy engineers and data scientists to adopt them without making their work feel slower.
## The framework's bet
So the core question became: can a framework implement meaningful deployment practices while keeping the API small enough that people would actually use it?
- **A decorator on a plain function.** `@GreatAI.create` turns a regular Python function into a deployed service with metadata, request tracing, and a versioned interface. No inheritance, no project layout, no enforced directory structure. The mental cost is one import.
- **Implicit behaviour only for cross-cutting concerns.** Logging, versioning, metadata are implicit. Anything touching business logic stays explicit. The rule: if it would surprise me when I'm debugging, it shouldn't be implicit.
- **Own the contract, leave the storage alone.** Where you persist logs, models, or metrics is your choice; GreatAI defines the shape and provides defaults. The model registry stays somebody else's library.
## Constraints
The survey backed up the central premise: ease of use and functionality both matter for adoption, and they're independent axes. A framework that ticks every box and is awkward will lose to a smaller one that doesn't.
GreatAI had to satisfy two constraints that usually pull in opposite directions.
## What I'd change
It needed to encode deployment practices such as metadata handling, model loading, request tracing, and reproducible prediction interfaces. It also had to be approachable enough that the basic use case still looked like ordinary Python.
That shaped the API. The framework could not demand a new mental model for every project. The deployment behaviour had to sit close to the prediction function, because that is where the developer already has context.
## Design
The design leaned on decorators and lightweight conventions. The application author should be able to declare the prediction boundary, attach the relevant model and metadata behaviour, and let the framework handle repeated operational concerns.
That is a careful tradeoff. Too much implicit behaviour makes systems difficult to debug. Too much explicit setup makes best practices optional in practice, because the path of least resistance is to skip them. GreatAI tried to keep the implicit parts focused on cross-cutting deployment concerns rather than business logic.
Feedback from professional data scientists and software engineers supported the main premise: ease of use and functionality both matter when people decide whether to adopt deployment tooling. A framework that is technically complete but awkward to use will still fail.
## What Worked
The strongest part of the project was treating API design as part of deployment quality. Best practices are not only documentation. They need interface support, defaults, and feedback loops.
The research also forced the framework to be specific. "Production-ready" is too broad to be useful. A concrete list of deployment practices made it possible to ask which practices can be automated, which ones need explicit developer decisions, and which ones belong outside the framework.
## What I Would Change
If I returned to the project now, I would focus more on integration boundaries: how GreatAI should fit into modern observability, model registry, and evaluation workflows without trying to own them. Deployment frameworks age quickly when they become too broad.
The part I would keep is the central idea: make the right deployment behaviour easy enough that it becomes the default.
- I'd narrow further. Anything GreatAI did that overlapped with MLflow, BentoML, or modern observability stacks would go. The durable bit was always the decorator and the catalogue behind it.
- I'd publish the survey instrument separately. The 33-habit catalogue and the adoption-vs-impact methodology outlive the framework. People still ask about that part.
- I'd stop calling them "best practices." I used that phrase in the thesis and it aged into corporate-speak. The honest name is "things that hurt later if you skip them."

View file

@ -1,6 +1,6 @@
---
title: Syncing State with Immutable Tries
description: How a multi-device life tracking project used trie structure to diff, reconcile, and synchronise goal state.
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.'
date: 2026-05-05
projectPeriod: 'August-September 2019'
thumbnail:
@ -9,9 +9,9 @@ thumbnail:
tags: ['systems', 'web', 'tools']
featuredOrder: 4
role: Full-stack author
stack: ['Python', 'Angular', 'State synchronisation']
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
scale: Multi-device goal and task state shared between clients and a server
outcome: A working synchronisation model built around immutable trie properties
outcome: A working sync protocol where structural sharing made the delta tiny
audience: recruiter-relevant
links:
- label: Source
@ -20,35 +20,39 @@ media:
- type: image
src: ./_assets/towers.jpg
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
caption: The visual idea was simple; the useful lesson was the synchronisation model behind it.
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
---
Life Towers was a multi-device goal and task tracker with an intentionally visual interface. The surface idea was an aesthetic representation of previous and current goals. The more interesting part was synchronising state across clients without sending more data than necessary.
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.
This was not a large distributed system, but it had a real version of a common problem: clients and server drift apart, and the system needs a compact way to compare, reconcile, and update.
## The problem in one paragraph
## The Problem
Pick any non-trivial mutable object graph, sync it across devices, and you end up either sending the whole thing on every change (wasteful) or writing ad-hoc diff logic per shape (brittle). I wanted a representation where the _shape_ of the data made the diff fall out for free.
If a task model is stored as an ordinary mutable object graph, synchronising it often becomes a choice between sending too much data or writing complicated ad hoc diff logic.
## The trie, concretely
I wanted a structure where the shape of the data made synchronisation easier. The client should be able to compare its state with the server's state, find a difference, reconcile it, and send only the delta.
A goal in Life Towers is a path of strings. `Health / Running / 5k`. Tasks under a goal hang off the leaf. A user's whole state is a tree, and a trie is exactly the data structure that makes that tree's _identity_ manipulable.
## Design
Two properties did the heavy lifting:
I used a trie. A trie made the hierarchical shape explicit, and its properties made it easier to reason about differences between stored versions.
- **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).
The immutable nature of the structure simplified much of the logic. Instead of mutating arbitrary branches in place, updates could produce new structure with shared unchanged parts. That made reconciliation easier to reason about and reduced the amount of data that needed to move across the network.
The sync loop falls out:
The project also gave me a reason to deepen my Python and Angular knowledge, but the synchronisation structure was the main lesson.
1. Client holds the last root the server acknowledged plus its own current root.
2. To send: walk only the unshared paths, emit one op per changed leaf. In practice that's a handful of bytes for a typical edit, no matter how large the rest of the tree is.
3. Server applies, returns its new root.
4. Client rebases any in-flight edits by replaying them on top.
## What Worked
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.
The biggest win was choosing a data structure that matched the problem. Once the state was represented in a way that made comparison natural, the network protocol became simpler.
## What I'd change
The other useful lesson was that visual products still need a strong internal model. A pleasant interface is fragile if the underlying state is hard to trust.
- **Property tests around the rebase.** The reconcile path is exactly where a generator finds bugs that hand-written tests never think to write. I had hand-written cases; I'd start with `proptest` now.
- **A standalone spec for the wire format.** The part worth lifting out was the protocol, not the goal tracker. A short spec would let me (or anyone) reimplement it in a different stack without re-deriving everything from the Python source.
- **Strip the visual experiment.** The tower visualisation was fun but it bound the storage to a UI metaphor. The sync model should be a library; the towers should be a separate toy.
## What I Would Change
## If you take one idea from this
Today I would document the sync protocol more formally and add property-based tests around reconciliation. Synchronisation code is exactly the kind of code that benefits from generated edge cases.
I would also separate the visual experiment from the state synchronisation story more explicitly. The latter is the part that aged better.
Most sync problems are diff problems pretending to be transport problems. Pick the data structure that makes the diff free, and the protocol almost writes itself. The corollary: if you're writing a lot of "if this changed, send that" code, you're using the wrong structure.

View file

@ -1,6 +1,6 @@
---
title: Lights Synchronized to Music
description: A Raspberry Pi music player that analysed audio output and drove RGB LED strips.
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'
thumbnail:
@ -8,14 +8,16 @@ thumbnail:
alt: RGB LED strips lit by a music synchronisation project.
tags: ['systems', 'tools']
role: Hardware and software author
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
outcome: My first finished non-trivial project, combining a web UI, audio processing, and hardware output
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
outcome: The first non-trivial project I started and finished
audience: technical
links: []
---
A Raspberry Pi ran a small music player, and the audio it produced drove the colour of a couple of RGB LED strips through some MOSFETs.
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.
It was the first non-trivial project I actually finished. Far from perfect, but I am still proud that I built it on my own.
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 backend was Python, with NumPy doing the FFT. The frontend was a vanilla web page for picking tracks and tweaking settings.
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; [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,6 +1,6 @@
---
title: My Notes, an Android Markdown App
description: A small Android notes app for creating, editing, and filtering markdown notes with hashtags.
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'
thumbnail:
@ -9,15 +9,13 @@ thumbnail:
tags: ['tools']
role: Android app author
stack: ['Android', 'Markdown', 'Markwon']
outcome: A functional markdown note organiser and a first exposure to Android development
outcome: A working notes app and my first time outside the web stack
audience: technical
links:
- label: Source
url: https://github.com/schmelczer/my-notes
---
My Notes was a small Android note organiser and editor built on top of Markwon.
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.
It let me create Markdown notes and filter them by hashtag. It was also my first exposure to Android development.
The idea was not new, but the app worked, and the platform was different enough from the full-stack web work I had been doing that the project was worth finishing.
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,6 +1,6 @@
---
title: Graph Models for a Real-Time Cooling Simulation
description: Simulating a nuclear facility cooling system with graph traversal, matrix solving, Flask, NumPy, and real-time monitoring clients.
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'
thumbnail:
@ -10,47 +10,49 @@ tags: ['simulation', 'systems', 'tools']
featuredOrder: 5
role: Simulation and UI author
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
scale: Remote simulation server with multiple monitoring clients and a separate graph editor
outcome: A believable, extensible cooling-system simulation for a cybersecurity challenge context
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
audience: recruiter-relevant
links: []
media:
- type: image
src: ./_assets/process-simulator.jpg
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values.
caption: The simulator calculated flow and temperature over graph-based process models.
caption: Flow ran first as a graph traversal, then heat solved as a matrix equation.
- type: image
src: ./_assets/process-simulator-input.jpg
alt: Screenshot of the JavaFX graph editor used to define simulator input.
caption: A separate JavaFX editor produced JSON inputs for the simulation backend.
caption: The JavaFX editor produced JSON that the simulator ate as input.
---
This project simulated the cooling system of a nuclear facility. It was built for a cybersecurity challenge about PLCs, where participants needed to see the consequences of changing a system state.
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.
The simulation did not try to be physically complete. It aimed to be cheaply calculated, believable to a non-specialist, scalable enough for the event context, and understandable through a clean GUI.
## What the event needed
## The Problem
The challenge was about PLCs. Contestants would change setpoints, valves, or pump speeds, and we needed them to see whether their action made the plant stable, wasted coolant, or melted something. That meant:
The simulated system needed reactors, coolers, pumps, heat exchangers, drains, sources, and pipes. Those elements had to be configurable, and multiple monitoring clients needed to update in real time from a remote server.
- Multiple monitoring clients had to update from one simulation server in near real time.
- The system had to be configurable enough that the event organisers could ship me a new plant on Friday night and have it running Saturday morning.
- It had to be obvious. A simulator nobody understands isn't a teaching tool, it's noise.
The key challenge was representing flow and temperature in a way that was simple enough to calculate repeatedly but structured enough to produce plausible behaviour.
## The split that made it cheap
## Design
Instead of the coupled solver:
The system used two graph models. First, water was distributed by traversing the graph of pipes according to pressures generated by pumps. Then, an adjacency matrix was populated from the relations between nodes based on water flow.
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.
3. Repeat both passes per tick.
After accounting for base temperatures, heaters, and heat exchangers, the matrix was solved to calculate current node temperatures. Repeating that process advanced the simulation.
This is wrong as physics. It's right as a model. Flow doesn't react to instantaneous heat in any way contestants could perceive, and the cost of solving them separately was a tiny fraction of solving them together. The clean phase boundary also meant when "the heat is weird," I knew exactly which pass to look at.
Python handled the backend logic with Flask and NumPy. The monitoring frontend used an HTML5 canvas. A separate JavaFX graph editor let users move nodes, edit element parameters, export JSON, and upload inputs to the backend.
## Why the editor mattered
## What Worked
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 graph/matrix split was a useful modelling boundary. Flow and heat exchange are related, but treating them as separate calculation phases kept the implementation easier to reason about.
The lesson: a simulation is only as useful as its input pipeline. If editing the plant requires editing source, organisers won't use it.
The editor also mattered. A simulation is much more useful when its input is inspectable and editable by people who are not editing source files.
## What I'd change
## What I Would Change
Today I would formalise the model limitations more clearly. A convincing simulation can be useful, but it should say exactly what it does and does not claim.
I would also add recorded scenarios and regression tests. Simulation projects are vulnerable to accidental behaviour changes that still look plausible on screen.
- **State what the model claims.** A convincing sim needs an honest README about what it does and doesn't model. Mine didn't. Anyone who took the numbers seriously could have walked away believing more than the model deserved.
- **Recorded scenarios as regression tests.** Sim projects drift in ways that look plausible on screen. Storing "this input over 60 seconds produces these outputs" would have caught me when I broke the temperature solver on Saturday morning at the event.
- **Skip JavaFX.** Cross-platform packaging was painful and the desktop dependency made the editor harder to hand off than it should have been. A web-based editor in the same browser the monitors used would have meant one fewer install for the organisers.

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

@ -1,21 +1,21 @@
---
title: A Proof-of-Concept Photo Colour Grader
description: A web UI experiment for selecting colours and transforming nearby ranges based on colour distance.
title: A Colour Grader Where Distance Was the Whole Idea
description: Pick a colour, transform every nearby colour as a function of distance. A proof-of-concept grader I built to try one interaction idea.
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']
outcome: A proof-of-concept colour grading interaction model
outcome: A working proof-of-concept grader and an interaction model I'd still defend
audience: technical
links: []
---
This was a colour grader web application I built as a proof-of-concept to try out a few interaction ideas.
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 main feature was the colour selector UI. The core idea was that you could select some colours and then apply transformations to other colours as a function of their distance to the selected colour.
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.
Clicking a coloured circle let you change its settings. New circles could be created by clicking inside the large circle, and they could be moved with drag and drop.
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

@ -1,6 +1,6 @@
---
title: A Static Photo Site Generator
description: A simple photography site generated from a directory of images with automatic resizing to multiple quality settings.
title: A Photo Site That Generated Itself From a Folder
description: A Webpack script that turns a folder of photos into a static site with responsive image variants. Mostly here as an excuse to talk about walks.
date: 2026-04-27
projectPeriod: 'Summer 2016'
thumbnail:
@ -9,13 +9,13 @@ thumbnail:
tags: ['web', 'tools']
role: Site generator author
stack: ['Webpack', 'Image processing', 'Static site generation']
outcome: A generated static photo site for publishing photography with responsive image output
outcome: A photography site that updated itself when I dropped new images into a folder
audience: general
links: []
---
Photos was a small webpage where you could view my photos.
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.
Taking time to appreciate the world around us fills me with joy, which is why I like to go on walks with a camera. I might not end up with great photos, but I usually come back with some inspiration for the current or next project.
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.
The site itself was generated by a Webpack script from a directory of images. Automatic resizing to multiple quality settings was part of the pipeline.
If I rebuilt it today I'd use Astro, which is what this site runs on.

View file

@ -1,6 +1,6 @@
---
title: A 3D Platform Game in C and SDL 1.2
description: 'My first proper project: a 3D game with random maps, destructible voxels, enemies, powerups, and time slowdown.'
title: A 3D Voxel Game in C, Built While Learning Pointers
description: My Basics of Programming course project. 3D platformer in C with SDL 1.2, destructible terrain, time-slowdown powerups, and a great many segmentation faults.
date: 2026-04-28
projectPeriod: 'Autumn 2017'
thumbnail:
@ -9,15 +9,14 @@ thumbnail:
tags: ['games', 'systems']
role: Game author
stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable 3D course project that made programming feel like the right long-term direction
outcome: A playable course project, and the moment programming clicked
audience: technical
links: []
---
This was my first proper project: a 3D game written in pure C on top of SDL 1.2.
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.
The maps were randomly generated and destructible voxel by voxel. That let the player build structures to hide from flying enemies, which chased the player and could destroy the terrain after merging together and growing larger.
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.
After collecting enough powerups, the player could shoot and even slow down time, in exchange for losing some 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. The next time I reached for C it was on hardware that punished waste; see [Ad Astra](/articles/ad-astra-attiny85-game-engine/).
I built it as the final project for my Basics of Programming course. I learned a lot about pointers after an adequate number of segmentation faults, and it was the project that convinced me programming was the right long-term direction.
First-project privilege.

View file

@ -1,6 +1,6 @@
---
title: A 3-Way Text Merger That Never Shows Conflict Markers
description: How reconcile-text borrows the idea of operational transformation and applies it to consolidated diffs to auto-resolve conflicting edits.
description: reconcile-text merges Markdown notes from three editors I don't control, with no operation history. Here's why git, CRDTs, and diff-match-patch each failed me.
date: 2026-05-21
projectPeriod: '2025'
thumbnail:
@ -8,10 +8,10 @@ thumbnail:
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
tags: ['systems', 'tools', 'web']
featuredOrder: 2
role: Author
role: Library author
stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen']
scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin
outcome: A small, well-tested library that fills a gap between git, CRDTs, and patch-based merging
outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings
audience: recruiter-relevant
links:
- label: Demo
@ -28,55 +28,40 @@ media:
- type: image
src: ./_assets/reconcile.png
alt: The reconcile-text logo, a stylised merge arrow, with the tagline "Conflict-free 3-way text merging".
caption: reconcile-text resolves conflicting edits to prose by weaving them together instead of asking a human to choose.
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose.
---
`reconcile-text` started from a concrete need. I wanted to synchronise Markdown notes across devices where the editor was not under my control, and where the only thing I could observe was the final text on each side. Vim on one machine, VS Code on another, Obsidian on a third. No keystroke stream, no operation log, just the documents and a shared common ancestor from the last successful sync.
## Why I wrote it
That setting is awkward for almost every existing tool. Git is the closest fit, but `git merge-file` answers conflicts with markers, which is exactly what a sync tool cannot ship to a user's note. CRDTs and operational transformation assume you control the editing infrastructure all the way down to the keystroke. `diff-match-patch` produces patches without a common ancestor, and on adjacent edits it silently corrupts the output. None of these matched the shape of the problem I had.
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.
So I wrote a library that does one specific thing: given a parent and two edited versions, return a single merged text that contains both sets of changes, without conflict markers and without dropping edits on the floor.
Every existing tool got close and missed:
## The Problem
- `git merge-file` does exactly the right thing structurally, then writes markers into the output. That's correct for source code and wrong for prose.
- CRDTs and OT both assume you own the editing pipeline down to the keystroke. I don't. I'm looking at three files.
- `diff-match-patch` doesn't take a common ancestor. On adjacent edits it quietly produces wrong output. I have a runnable example in the repo.
The hard part is not detecting a conflict. The hard part is resolving it well enough that a human is happy to read the result without thinking about merge mechanics.
So the library does exactly one thing: pure function from three strings to one. No async, no networking, no concurrency, no plugins. Anything outside that boundary is somebody else's library.
Source code has hard correctness requirements, so refusing to choose and emitting markers is the right default. Human prose is more forgiving. A merged paragraph that is slightly clumsy is almost always preferable to one that interrupts the reader with `<<<<<<< HEAD`. That observation is the entire reason this library exists in the form it does.
## The decisions worth naming
The challenge was to commit to that asymmetry honestly. The library should always produce a result. It should never silently lose an edit. It should preserve cursors so a collaborative editor can rely on it. And it should do all of this from end states alone, with no operation history available.
**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.
## Constraints
**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.
The library had to live in three places: a Rust crate, a JavaScript package built through WebAssembly, and a Python package built through `pyo3`. The cross-language story was a constraint, not a stretch goal. The Obsidian plugin I was writing alongside it consumed the npm build, but I also wanted a clean Rust crate for sync engines and a Python package for scripting.
**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/).
That ruled out anything that depended on language-specific runtime tricks. Generics, closures, and trait objects could live freely inside the Rust core, but the public surface had to be flat enough to cross both `wasm-bindgen` and `pyo3` without per-binding glue.
**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.
It also had to be predictable. There is no async story, no networking, no concurrency. A merge is a pure function from three strings to one string with some metadata. Everything that is not the merge itself was deliberately kept out.
**WASM size mattered enough to tune for it.** The release profile is aggressive about size, and the JS package ships a small leak detector that warns if you forget to free wasm-bindgen objects. I lost an afternoon to that the first time and didn't want anyone else to.
## Design
## What's held up, what I'd change
The pipeline is short. The library tokenises the parent and the two edited versions, runs Myers' diff to compare each edited version against the parent, optimises the resulting edit sequences so that adjacent changes group together cleanly, and then weaves the two diffs into a single ordered sequence of operations that produces the merged text.
- **Kept:** the never-emits-markers, never-drops-edits guarantee. It's the only reason a sync engine can call this library without an escape hatch.
- **Kept:** the comparison example against `diff-match-patch`. It's a runnable program in the repo showing exact inputs where the alternative is wrong. Way more convincing than a benchmark table.
- **Cut:** the snapshot tests do well on regressions and badly on unknown edge cases. Three-way merging is exactly what proptest was made for, and I should have written generators on day one.
- **Next:** I want to be more explicit about the boundary. reconcile-text is a merge primitive, not a live collab engine. If you have a keystroke stream and a real-time channel, use Yjs or Automerge. This library is for when you don't.
The weaving step borrows the concept of operational transformation, but applies it to a different problem. Classic OT transforms individual keystrokes against each other in real time. Here, OT is applied to the consolidated diff output of two complete edits. The structure is similar, but the inputs are batched and the algorithm only needs to run once per merge point. It became the simplest way I could find to describe how two sets of changes should be interleaved.
## If you take one idea from this
The tokeniser turned out to be more important than I initially expected. It is what decides whether a conflict exists in the first place. Word-level tokenisation, the default for prose, often turns a "conflict" into two adjacent independent edits that can coexist. Line-level tokenisation makes the library behave more like `git merge-file`. Markdown-level tokenisation merges on headings and list items rather than characters. Exposing this as a user-facing knob meant the library could be shaped to the document, not the other way around.
Cursors and selections were added as first-class merge inputs rather than something users reconstruct after the fact. Each cursor carries a stable ID and rides through the merge, ending up at a sensible position even when both sides edited the surrounding text. This is what made the library useful to anything resembling a collaborative editor.
The cross-language surface needed extra care. The tokeniser inside Rust is a `dyn Fn(&str) -> Vec<Token<T>>`, which is convenient in Rust and impossible to pass through `wasm-bindgen` or `pyo3`. The fix was to expose a closed enum of built-in tokenisers to non-Rust callers and reserve the generic version for Rust users. WebAssembly users also paid a real binary-size cost, so the release profile is tuned aggressively, and the JS package ships a small leak detector to remind callers that wasm-bindgen objects must be freed explicitly.
## What Worked
The strongest part of the project is that the result never has conflict markers and never silently drops an edit. That sounds modest, but it is exactly the property that makes the library usable inside a sync engine without an escape hatch.
Choosing the tokeniser as the main user-facing knob also held up well. Most of the "tuning" people want when merging prose is not a different algorithm, it is a different idea of what counts as a unit. Letting users choose between character, word, line, and Markdown granularity covered the realistic cases without inventing new merge strategies.
The comparison example against `diff-match-patch` was probably the most useful piece of writing in the repository. It is a runnable program, not a benchmark table, showing concrete cases where a popular alternative quietly produces wrong output. Having that as a falsifiable claim in the source tree made the value proposition much clearer than any prose description would have.
## What I Would Change
If I revisited this now, I would invest more in formal property tests around the merge. Three-way merging is exactly the kind of problem where generated inputs find behaviours that hand-written tests do not, and the snapshot tests I have are good at catching regressions but not at finding unknown edge cases.
I would also be more explicit about the boundary the library does not cross. It is a merge point primitive, not a live collaboration engine. CRDTs and OT remain the right tools when you actually have a keystroke stream and a real-time channel. `reconcile-text` is for the part of the problem space where you do not.
The part I would keep is the asymmetry the project rests on. Human text deserves a merger that prefers a slightly imperfect sentence over a conflict marker, and that decision is what shaped every other choice in the design.
Prose deserves a merger that prefers a slightly clumsy sentence over a marker. Code doesn't. That one asymmetry is the whole reason the library exists in the shape it does; everything else fell out of taking it seriously.

View file

@ -1,6 +1,6 @@
---
title: Tile-Based Optimization for 2D SDF Ray Tracing
description: How SDF-2D used signed distance fields, dynamic shaders, and tile-based rendering ideas to make 2D ray tracing run well in the browser.
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
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:
@ -9,9 +9,10 @@ thumbnail:
tags: ['graphics', 'web', 'systems']
featuredOrder: 3
role: Library author
stack: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
scale: Browser library with mobile-oriented real-time rendering and reusable demos
outcome: Reusable NPM package and thesis project for efficient 2D SDF rendering
stack:
['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields', 'Dynamic shader generation']
scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game
audience: recruiter-relevant
links:
- label: NPM package
@ -25,43 +26,31 @@ media:
- type: image
src: ./_assets/sdf2d.jpg
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
caption: SDF-2D was built as a reusable TypeScript library rather than a single demo.
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
---
SDF-2D was my attempt to make a small, reusable browser library for 2D scenes rendered with ray-tracing techniques. The rendering is based on signed distance fields, where geometry can be represented as functions that return the distance to the nearest surface.
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.
The interesting part was not the basic idea. Signed distance fields are a known technique. The interesting part was making the approach fast and reusable enough for browser demos, including on mobile devices.
## What "mobile GPU" actually meant
The project became one half of my BSc thesis, together with the multiplayer game `decla.red`, which used the rendering library in a real interactive setting.
A 2D SDF ray tracer is conceptually simple: for each pixel, march along a ray, sample the distance field, accumulate light. The implementation that works on a desktop NVIDIA card spends so much per pixel that a mobile GPU melts. So the design problem was never "can SDFs do soft shadows" (yes, easily), it was "what work can I avoid per pixel without giving up the look."
## The Problem
Three constraints did most of the design work:
Ray tracing and distance-field rendering can produce appealing 2D lighting and reflections, but a straightforward implementation spends too much work per pixel. A browser library also has to deal with device variation: WebGL capabilities, shader limits, mobile GPUs, and the overhead of generating scenes dynamically.
- **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." It had to be acceptable on the laptop my advisor used to grade the thesis.
The goal was not to render one hand-tuned scene. The goal was a library with a simple API, reusable scene definitions, and real-time behavior.
## How it actually runs
## Constraints
- **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. 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.
The library had to support both WebGL and WebGL2. It had to run acceptably on phones. It had to avoid shipping scene-specific shader code by hand. And it had to expose an API that felt like a rendering library rather than a shader experiment.
## Held up, didn't hold up
Those constraints pushed the implementation toward generated shaders and capability-aware rendering paths.
## Design
The main optimization was inspired by tiled renderers. Instead of treating the entire screen uniformly, the renderer could reason about groups of pixels and avoid unnecessary work where possible.
That was paired with deferred shading and dynamic shader generation. Dynamic generation mattered because scenes and devices differ. If a feature or operation was not needed for a given scene or device, the generated shader could avoid carrying that cost.
The API was deliberately kept in TypeScript. That made the library easier to package, document, and reuse in projects that were already browser-first.
## What Worked
The project worked best when the library boundary was respected. A good demo can hide a messy implementation. A reusable package cannot. The API had to explain the rendering model without making every user think like a shader compiler.
The mobile constraint also improved the design. It forced performance work to be structural rather than cosmetic. When a technique works only on a powerful desktop GPU, it is easy to mistake headroom for good architecture.
## What I Would Change
Today I would write more instrumentation around shader variants and device behavior. The project had many optimizations, but stronger profiling output would have made tradeoffs easier to explain and compare.
I would also document the rendering pipeline with diagrams. The ideas are visual, and the explanation should be too.
- **Held up:** the mobile constraint forced structural perf work instead of cosmetic perf work. When something only runs on a desktop GPU you mistake headroom for good architecture, and the rude awakening comes from a user.
- **Held up:** keeping the library boundary clean. A demo can hide a messy implementation; a published package can't.
- **Didn't:** I had no instrumentation around shader variants. Today I'd ship a small `?debug=1` overlay that prints exactly which shader got compiled for that session and why.
- **Didn't:** the docs are words about ray marching. The ideas are visual; the explanation should have been too. Diagrams next time.

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 tiny embedded game engine and custom PCB built around an ATtiny85V.
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

@ -1,6 +1,6 @@
---
title: Avoid
description: A small early web game, kept as an archive of first experiments on the web.
description: My first browser game, kept around so the timeline is honest.
thumbnail:
src: ./_assets/avoid.jpg
alt: Screenshot of the Avoid canvas game.

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: City Simulation
description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge.
description: A Unity city where REST-controlled traffic lights made bad PLC code visible as car crashes.
thumbnail:
src: ./_assets/city-simulation.jpg
alt: Screenshot of a Unity city traffic simulation.

View file

@ -1,6 +1,6 @@
---
title: Photo Colour Grader
description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges.
description: Pick a colour, edit every nearby colour as a function of distance. A grader built around one interaction idea.
thumbnail:
src: ./_assets/photo-colour-grader.jpg
alt: Screenshot of a colour grading interface applied to a photograph.

View file

@ -1,6 +1,6 @@
---
title: decla.red
description: A team-based mobile multiplayer browser game with shared client/server game logic.
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

@ -1,12 +1,12 @@
---
title: Fleeting Garden
description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down.
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
thumbnail:
src: ./_assets/fleeting-garden.jpg
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
period: '2026'
sortDate: 2026-05-01
technologies: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Physarum']
technologies: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
selected: true
essay: fleeting-garden-webgpu-drawing
links:

View file

@ -1,6 +1,6 @@
---
title: Foreign Exchange Prediction Experiment
description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms.
description: A Hanning-windowed STFT experiment on EUR/USD. Passable backtest, sober conclusions, no real money risked.
thumbnail:
src: ./_assets/forex.jpg
alt: Chart from a foreign exchange prediction experiment.

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: GreatAI
description: A Python framework and research project for making AI deployment best practices easier to adopt.
description: One decorator on a Python function turned it into a deployed ML service. MSc thesis with a survey to back the API choices.
thumbnail:
src: ./_assets/great-ai.png
alt: Example Python code using the GreatAI API.

View file

@ -1,12 +1,12 @@
---
title: Lights Synchronized to Music
description: A Raspberry Pi music player that drove RGB LED strips from audio analysis.
description: Raspberry Pi music player, NumPy FFT, MOSFETs, RGB strips. The first thing I built that I actually finished.
thumbnail:
src: ./_assets/leds.jpg
alt: RGB LED strips glowing from a music synchronization project.
period: 'Spring 2016'
sortDate: 2016-04-01
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
selected: false
essay: lights-synchronized-to-music
links: []

View file

@ -1,12 +1,12 @@
---
title: My Notes
description: A minimalist Android markdown note organizer and editor powered by Markwon.
description: A small Android Markdown note app. The point was a few weeks outside the web stack.
thumbnail:
src: ./_assets/my-notes.png
alt: Screenshot of the My Notes Android markdown app.
period: 'November 2019'
sortDate: 2019-11-01
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
technologies: ['Android', 'Markdown', 'Markwon']
selected: false
essay: my-notes-android-markdown-app
links:

View file

@ -1,6 +1,6 @@
---
title: Graph Editor
description: A JavaFX editor for creating and editing input graphs for the cooling system simulator.
description: A drag-and-drop JavaFX editor that let event organisers reconfigure the cooling sim without me sitting next to them.
thumbnail:
src: ./_assets/process-simulator-input.jpg
alt: JavaFX editor interface for the cooling system simulator input graph.

View file

@ -1,6 +1,6 @@
---
title: Cooling System Simulation
description: A graph-based process simulation with a monitoring client and JavaFX input editor.
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

@ -1,6 +1,6 @@
---
title: Photo Site Generator
description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings.
description: Point a Webpack script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
thumbnail:
src: ./_assets/photos.jpg
alt: Screenshot of a generated photography site.

View file

@ -1,6 +1,6 @@
---
title: Platform Game
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
description: My Basics of Programming project. 3D voxel game in C and SDL 1.2. Pointers, learned painfully.
thumbnail:
src: ./_assets/platform-game.jpg
alt: Screenshot from an early 3D platform game.

View file

@ -1,12 +1,12 @@
---
title: reconcile-text
description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings.
description: One Rust core, three packages. Merges Markdown notes from three editors I don't control, with no operation history. Never emits markers.
thumbnail:
src: ./_assets/reconcile.png
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
period: '2025'
sortDate: 2025-05-01
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'Operational Transformation']
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff']
selected: true
essay: reconcile-text-3-way-merge
links:

View file

@ -1,12 +1,12 @@
---
title: SDF-2D
description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
description: A browser 2D ray-tracer tuned for the phone in your pocket. Tile-based passes, deferred shading, shaders generated per scene and device.
thumbnail:
src: ./_assets/sdf2d.jpg
alt: SDF-2D browser demo with soft lighting effects.
period: 'Autumn-Winter 2020'
sortDate: 2020-12-01
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'SDF rendering']
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
selected: true
essay: sdf-2d-ray-tracing
links:

View file

@ -1,12 +1,12 @@
---
title: Life Towers
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
description: A multi-device goal tracker. The trie underneath made the sync diff free; the towers were just the UI.
thumbnail:
src: ./_assets/towers.jpg
alt: Life Towers goal tracking interface with tower-like visual structures.
period: 'August-September 2019'
sortDate: 2019-09-01
technologies: ['Python', 'Angular', 'State synchronization']
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trie']
selected: true
essay: life-towers-immutable-tries
links:

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,10 +4,11 @@ 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:
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
'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',
email: 'andras@schmelczer.dev',
github: 'https://github.com/schmelczer',
@ -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,
@ -10,7 +11,7 @@ import {
} from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
const STARTING_POINTS = 4;
const STARTING_POINTS = 5;
const posts = await getPublishedPosts();
const startingPoints = posts
@ -18,13 +19,33 @@ const startingPoints = posts
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
.slice(0, STARTING_POINTS);
const STARTING_POINT_NOTES: Record<string, string> = {
'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.
const personJsonLd = buildPersonJsonLd({
jobTitle: 'Software Engineer',
description:
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
'Software engineer who keeps reaching for the same two moves: let the hard constraint pick the data structure, then keep the API small enough to defend.',
knowsAbout: [
'Software architecture',
'AI/ML systems',
@ -40,24 +61,24 @@ const personJsonLd = buildPersonJsonLd({
<Page
title="About"
description="A direct summary of my background, technical interests, and best starting points."
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 am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
than six years of professional engineering experience. My work spans AI/ML systems,
web platforms, graphics, simulations, and tools, and I like projects where
architecture, constraints, and product usefulness all matter.
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
ever since: pick something I can't yet do, then finish it.
</p>
<p>
I am especially interested in architecting and building large-scale systems,
particularly around AI/ML. In my own time I also return to shaders, data
visualization, simulations, and occasionally microcontrollers. The
<a href="/articles/">articles</a> and <a href="/projects/">projects</a> indexes are the
best way to understand that range; the CV and contact links are here when a direct summary
is more useful.
Two patterns show up in almost everything here. First, the hard constraint usually
picks the data structure: an 8-bit ALU, a mobile GPU, a single static HTML file, a
cross-language ABI, no edit history. Second, once the data structure is right, the
API shrinks to something I can defend in one paragraph. When I get those two right I
tend to like the result years later. When I don't, I say so in the writeup.
</p>
</div>
@ -65,17 +86,22 @@ const personJsonLd = buildPersonJsonLd({
<h2 id="quick-facts">Quick Facts</h2>
<dl>
<div>
<dt>Focus</dt>
<dt>Lives in</dt>
<dd>Europe.</dd>
</div>
<div>
<dt>Studied</dt>
<dd>
Software systems, AI deployment, architecture, graphics, data visualization
MSc Computer Science. BSc thesis on SDF-2D, MSc thesis on GreatAI; both have
writeups below.
</dd>
</div>
<div>
<dt>Education</dt>
<dd>MSc in Computer Science</dd>
<dt>Languages I'm fastest in</dt>
<dd>TypeScript, Python, Rust. C or Rust when bytes matter.</dd>
</div>
<div>
<dt>Contact</dt>
<dt>Email</dt>
<dd>
<address>
<a href={`mailto:${site.email}`}>{site.email}</a>
@ -83,7 +109,7 @@ const personJsonLd = buildPersonJsonLd({
</dd>
</div>
<div>
<dt>Links</dt>
<dt>Elsewhere</dt>
<dd class="about-links">
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
@ -93,28 +119,68 @@ 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">Best Starting Points</h2>
<a href="/articles/">Browse all articles <span aria-hidden="true">→</span></a>
<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} />
<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">How I Work</h2>
<div class="prose">
<p>
I am strongest when I can reason through a system end to end: the data model, the
API shape, the performance constraints, the operational risks, and the human path
through the tool. The projects on this site are older and newer examples of that
habit.
</p>
<p>
I care about simple interfaces over accidental complexity, and I prefer technical
depth that can be explained clearly. That is why this site is structured around
articles rather than screenshots and slogans.
</p>
<h2 id="working-style">A few things I believe</h2>
<div class="prose about-copy">
<ul>
<li>
Most "interesting algorithm" problems are actually data-structure problems
wearing a costume. Pick the structure that makes the comparison, the query, or
the merge trivial, and the algorithm fits in a screen.
</li>
<li>
A library you can explain in one paragraph beats a framework you have to teach.
I'll take five lines of WGSL I can read over one beautiful 300-line kernel.
</li>
<li>
The cost of a project is whether I'll still trust it in three years. The ones I
trust are the ones with a tiny surface and a boring centre.
</li>
<li>
I like working at the seams: firmware meeting a render loop, a Rust core
crossing wasm-bindgen and pyo3, a client and server agreeing on what "next
state" means. That's where the design work actually is.
</li>
<li>
Caveat: the writeups here are biased toward things that worked. The dead
prototypes don't have URLs and I should probably write up one of them honestly
sometime.
</li>
</ul>
</div>
</section>
</Page>

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,17 +24,15 @@ const personJsonLd = buildPersonJsonLd();
<Base jsonLd={personJsonLd}>
<section class="home-intro">
<p class="eyebrow">
Software systems, AI deployment, graphics, simulations, and tools
</p>
<p class="eyebrow">Engineering notes</p>
<h1>
Andras Schmelczer writes about building software that has to work under real
constraints.
<span class="home-intro-name">Andras Schmelczer</span>, software engineer. Writeups
of finished projects, with the tradeoffs left in.
</h1>
<p>
I am a software engineer with an MSc in Computer Science. This site is mostly a
notebook of technical articles and project writeups; the hiring details live on the
<a href="/about/">About</a> page.
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>
@ -47,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"]');

File diff suppressed because it is too large Load diff