Work

Everything I have built and kept building: side projects, client work and the tools that came out of both. Where a repo is public it is linked; most are not, so the running site is the link that matters.

My role across these is architectural. I choose the stack, cut the system boundaries and set the quality gates, then direct the implementation and review what comes back. Accessibility, privacy and test coverage are requirements I impose rather than defaults inherited from a framework. Knowing when an implementation is wrong is the part that matters.

Projects
21
Running live
9
Categories
5

Web apps

8

GourMerge

Git for recipes: branches, commits, forks and diffable merge requests, applied to cooking.

Full GDPR self-service built on a GitHub-style ghost tombstone account, so hard-deleting a user never breaks referential integrity.

Nuxt 4.5 · Vue 3.5 · Drizzle ORM · MySQL 8.4 · TipTap 3 · Playwright

Diffing two recipe commits by indexing both sides, not walking a tree GourMerge · server/utils/diff.ts
export function computeIngredientDiff(
  oldIngredients: CommitIngredient[],
  newIngredients: CommitIngredient[]
): IngredientDiffEntry[] {
  const oldMap = new Map<number, IngredientSnapshot>()
  const newMap = new Map<number, IngredientSnapshot>()

  for (const ing of normalizeIngredients(oldIngredients)) {
    oldMap.set(ing.ingredientId, ing)
  }
  for (const ing of normalizeIngredients(newIngredients)) {
    newMap.set(ing.ingredientId, ing)
  }

  const result: IngredientDiffEntry[] = []

  for (const [id, oldIng] of oldMap) {
    const newIng = newMap.get(id)

    if (!newIng) {
      result.push({ type: 'removed', ingredientId: id, /* ... */ })
    } else {
      const changes: ('quantity' | 'unit')[] = []
      if (oldIng.quantity !== newIng.quantity) changes.push('quantity')
      if (oldIng.unit !== newIng.unit) changes.push('unit')
      // ... push 'modified' when changes.length > 0, else 'unchanged'
    }
  }
  // ... a second pass picks up ids present only in newMap
}
GourMerge recipe page showing a recipe with its branch and commit history controls GourMerge recipe page showing a recipe with its branch and commit history controls

bewerby

Privacy-first job application tracker that keeps your applications on your own device.

Audited against WCAG 2.2 AAA rather than AA, via automated axe-core scans across every route, theme and viewport.

SvelteKit 2 · Svelte 5 Runes · Dexie 4 · IndexedDB + OPFS · axe-core · PWA

bewerby application board with its Kanban columns bewerby application board with its Kanban columns

EuroDraft

Draft an all-time XI from sixty years of tournament squads, then simulate the tournament.

Runs on a self-built data pipeline that assembles a roughly 4,600-player database from Wikipedia and an external football data source.

Nuxt 4 · Pinia · Nuxt UI v4 · MySQL · i18n · 10 locales · Playwright

EuroDraft landing page showing the dream team draft concept EuroDraft landing page showing the dream team draft concept

footyguess

Guess the footballer from progressively revealed career stats, transfers and nationality.

An autonomous Puppeteer and stealth batch scraper fills the SQLite database with real career data.

Nuxt 4 · better-sqlite3 · Valibot · Puppeteer

footyguess game view with a player card and revealed clues

Twittr Archivr

Search and explore a downloaded Twitter/X export locally, with nothing uploaded.

The whole archive is unzipped, parsed and searched in the browser, so personal data never reaches a server.

Vue 3 · Vite 8 · zip.js · Tailwind CSS 4 · vue-i18n

Twittr Archivr interface for browsing an imported Twitter archive Twittr Archivr interface for browsing an imported Twitter archive

tower67 Work in progress

Retro 2D tower-control game played by mouse, keyboard or voice across five schematic airfields, with flight plans, pushback and emergencies. Every input becomes a radio call and goes through the same parser.

Speech recognition (Vosk) and the pilots' voices (Piper) run in the browser on WASM models served from the site itself; an end-to-end test fails if a single request leaves the origin.

Vanilla TypeScript · Canvas 2D · Web Audio · Vosk + Piper (WASM) · Playwright + axe

The test that stops mouse and voice input from drifting apart tower67 · src/commands/CommandBus.test.ts
it('produces an identical command from the click path and the speech path', () => {
  const text = 'HANSA 471 TAXI TO RUNWAY 27 VIA ALPHA';
  const issuedAt = 1_700_000_000_000;

  const fromMouse = makeCommand({ raw: text, source: 'mouse', issuedAt });
  const fromSpeech = makeCommand({ raw: text, source: 'speech', issuedAt });

  const { source: _mouseSource, ...mouseRest } = fromMouse;
  const { source: _speechSource, ...speechRest } = fromSpeech;

  expect(speechRest).toEqual(mouseRest);
  expect(bus.dispatch(fromMouse)).toEqual(bus.dispatch(fromSpeech));
});
tower67 game view: a pixel-art airfield with runway 27, taxiways and two dot-matrix printers logging the radio traffic

Ned Flichs Work in progress

Streaming picks matched to your mood, the time you have and the access features you need.

Accessibility is a filter rather than an afterthought: titles can be narrowed to SDH subtitles, audio description or a low-sensory cut.

Astro 7 · Svelte 5 · Hono · Drizzle · MySQL · Better Auth

spotifyscandals

Scans Spotify playlists and surfaces documented controversies about the artists, with verified sources.

Gemini handles both live web research and cost-efficient batch processing across many artists at once.

Svelte 5 · FastAPI · SQLAlchemy 2.0 · Google Gemini

Mobile

2

scaNLand Work in progress

Offline camera-OCR scanner that identifies producers from EU food approval numbers.

The full 16,000-entry lookup database ships inside the app, so scanning works with no connection at all.

Ionic 8 · Vue 3 · Capacitor 8 · Fastify 5 · SQLite · Turborepo

Weltcoup Work in progress

Satirical Flutter collectible-album game with gacha mechanics and local peer-to-peer trading.

Offline-first SQLite with server sync, plus real P2P trading over Bluetooth and WiFi Direct.

Flutter · Riverpod · drift · Express 5 · MySQL

Tools & CLIs

4

Threads Deleter

CLI and web tool that bulk-deletes Threads posts through Meta's official API.

One hexagonal business core serves both the CLI and the web dashboard, with exponential backoff, a hard daily delete ceiling and token masking in logs.

Node.js · Express 5 · Commander · Docker · Jest

Threads Deleter web dashboard

remCSS

Pure-CSS framework rooted in the golden ratio, with zero JavaScript runtime.

A unit test forbids every px value in the source, so the golden-ratio rem scale cannot quietly rot.

CSS Layers · oklch() · Container Queries · @scope

remCSS documentation site showing the framework's type scale remCSS documentation site showing the framework's type scale

fli

npm CLI that downloads Google Webfonts locally and injects optimised @font-face CSS.

Detects the framework, picks the target directory, writes atomically and rolls back on failure. This site uses it as a real dependency.

TypeScript · tsup · @clack/prompts · fonteditor-core

Atomic write — a crashed run can never leave a half-written stylesheet fli · src/css.ts
const atomicWrite = async (filePath: string, content: string): Promise<void> => {
  const tmpPath = `${filePath}.${randomUUID()}.tmp`;
  try {
    await fs.promises.writeFile(tmpPath, content, 'utf8');
    await fs.promises.rename(tmpPath, filePath);
  } catch (err) {
    await fs.promises.unlink(tmpPath).catch(() => {});
    throw err;
  }
};

Axie

Self-hosted accessibility audit microservice running Playwright and axe-core scans.

Generated reports work fully offline: base64 fonts, inline SVGs, zero CDN dependencies.

Fastify 5 · Drizzle ORM · MariaDB 11 · Playwright · Docker

Content, CMS & dashboards

5

LawNetz

A modern, responsive replacement for gesetze-im-internet.de: the same 6,130 federal statutes and 135 English translations, readable on a phone, searchable, no tracking.

Held to WCAG 2.2 AAA in CI, not AA: axe-core scans every route in both themes with the 7:1 enhanced-contrast rule switched on.

Astro 7 SSR · Drizzle ORM · MySQL · Pagefind · axe-core · Passenger

Checking 6,130 laws for changes with conditional HEAD requests instead of downloads lawnetz.de · scripts/sync-daily.ts
if (!force && currentEntry?.etag) {
  headers['If-None-Match'] = currentEntry.etag;
}
if (!force && currentEntry?.lastModified) {
  headers['If-Modified-Since'] = currentEntry.lastModified;
}

const res = await fetch(`https://www.gesetze-im-internet.de/${slug}/xml.zip`, {
  method: 'HEAD',
  headers,
});

if (res.status === 304) {
  // Law has not changed since last check
  skippedUnchanged++;
  return;
}

if (res.status === 404) {
  // Obsolete or repealed law with no XML archive
  notFoundCount++;
  manifest[slug] = {
    ...currentEntry,
    status: 'not_found',
    lastSynced: new Date().toISOString(),
  };
  return;
}

if (res.status === 200) {
  const etag = res.headers.get('etag') || undefined;
  const lastModified = res.headers.get('last-modified') || undefined;
  toUpdate.push({ slug, etag, lastModified });
  return;
}
// ... the surrounding try/catch queues any other status, or a failed HEAD, for a full download
LawNetz statute page for the German Civil Code with its section search and table of contents LawNetz statute page for the German Civil Code with its section search and table of contents

VSGraph

Interactive dashboard for German domestic intelligence statistics on politically motivated crime.

Turns a dense government PDF report into a public dashboard you can search and compare across years.

Next.js 16 · React 19 · Recharts 3 · SSG

VSGraph dashboard charting politically motivated crime statistics over time VSGraph dashboard charting politically motivated crime statistics over time

mainstreet40

Monorepo for a rock cover band: Astro frontend, Directus headless CMS, fully editor-maintainable.

A dedicated test:a11y script pairs axe-core with Lighthouse CI as the default test suite.

Astro 7 · Directus 12 · Tailwind CSS 4 · MySQL 8 · Docker Compose

WC26 Data Engine Work in progress

Parses FIFA World Cup 2026 PDF reports into an analytics dashboard over 96 matches and 1,248 players.

End-to-end typed contract: openapi-typescript generates the frontend types straight from the FastAPI OpenAPI schema.

SvelteKit · FastAPI · SQLAlchemy 2.0 · MySQL 8 · Docker Compose

dstn.github.io

This site. Static Astro, zero JS by default, hand-written CSS on oklch tokens.

Self-hosts its fonts through my own npm package, @dstn/fli.

Astro 7 · TypeScript · Vanilla CSS · LightningCSS

Games & pipelines

2

detectivegame Work in progress

Existentialist neo-noir detective RPG in Godot, set in an occupied dieselpunk Paris.

CI verifies the whole project headlessly, with no editor GUI: import, compile sweep, boot smoke test, unit tests.

Godot 4.5 · GDScript 2.0 · GdUnit4

Video Factory

Dockerised pipeline that turns Reddit horror stories into narrated vertical videos.

Hybrid of a cloud LLM and fully local models, with voice cloning on CPU and WhisperX plus NVENC rendering on GPU inside an 8 GB VRAM budget.

Python 3.12 · FastAPI · Gemini · WhisperX · MoviePy · Docker