Compare commits

..

12 commits

Author SHA1 Message Date
070a03142f Clean up 2026-07-12 21:34:59 +01:00
82a241ddee Fast fail 2026-07-12 21:33:57 +01:00
53ef4d9147 Remove clutter 2026-07-12 21:33:49 +01:00
728dcdb3d9 todos 2026-07-12 14:53:54 +01:00
cdca57012a no forex 2026-07-12 14:53:50 +01:00
84543184e7 add backup script 2026-07-12 14:53:44 +01:00
d384ecfa9d No github 2026-07-10 10:59:56 -04:00
c86d8052c5 photos 2026-07-10 10:58:59 -04:00
0e78c506a4 avoid 2026-07-10 10:57:09 -04:00
d14fd083f3 Small 2026-07-10 10:56:55 -04:00
1ea3024e4e No github 2026-06-26 17:56:05 +02:00
283df32afe No github 2026-06-26 00:01:37 +02:00
38 changed files with 2047 additions and 312 deletions

View file

@ -31,16 +31,19 @@ jobs:
- name: Lint
run: |
set -eo pipefail
npm run lint
npm run typecheck
npm run qa:no-em-dashes
npm run qa:spelling
npm run qa:no-github-links
- name: Build
run: npm run build
- name: QA build
run: |
set -eo pipefail
npm run qa:astro-audit
npm run qa:links
npm run qa:no-js

41
.vscode/settings.json vendored
View file

@ -1,42 +1,9 @@
{
"cSpell.words": [
"andras",
"Andras",
"astra",
"colour",
"colours",
"datetime",
"decla",
"EEPROM",
"favicons",
"favourite",
"forex",
"froms",
"Glsl",
"leds",
"linkedin",
"mesmerising",
"microcontrollers",
"mixins",
"noopener",
"optimisations",
"optimised",
"organiser",
"playsinline",
"realised",
"schmelczer",
"serialisation",
"synchronised",
"tabindex",
"utilised",
"utilising",
"webm",
"webp",
"youtube"
],
"files.exclude": {
"node_modules": true
},
"editor.rulers": [90],
"editor.rulers": [
90
],
"editor.wordWrap": "on"
}
}

View file

@ -51,6 +51,7 @@
"immich",
"immich's",
"importmap",
"incrementality",
"janky",
"jemalloc",
"keepalives",
@ -58,6 +59,7 @@
"loggable",
"malloc",
"metas",
"mispredictions",
"mixolydian",
"mlockall",
"mosfets",
@ -68,10 +70,12 @@
"nums",
"permacomputing",
"physarum",
"piskel",
"pocketbase",
"powerups",
"proptest",
"refetches",
"reflashing",
"reimplementable",
"requantised",
"rgba",
@ -92,6 +96,7 @@
"subvolume",
"syncer",
"szintű",
"tamagotchi",
"tamagotchis",
"tarjan's",
"tidepool",
@ -99,6 +104,7 @@
"tweakpane",
"unsets",
"vaultlink",
"vtable",
"waveshare",
"waveshare's",
"webform",

1937
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,15 +18,16 @@
"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:spelling": "cspell --no-progress \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/**/*.mjs\" \"*.md\"",
"qa:no-github-links": "node scripts/check-no-github-links.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: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 qa:spelling && 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"
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run qa:spelling && npm run qa:no-github-links && 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",
"url": "git+https://github.com/schmelczer/schmelczer.github.io.git"
"url": "git+https://git.schmelczer.dev/andras/schmelczer.dev.git"
},
"keywords": [
"blog",
@ -37,9 +38,9 @@
"author": "Andras Schmelczer",
"license": "GPL-3.0-or-later",
"bugs": {
"url": "https://github.com/schmelczer/schmelczer.github.io/issues"
"url": "https://git.schmelczer.dev/andras/schmelczer.dev/issues"
},
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
"homepage": "https://git.schmelczer.dev/andras/schmelczer.dev#readme",
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/rss": "^4.0.18",

View file

@ -15,7 +15,6 @@ const textExtensions = new Set([
'.mjs',
'.ts',
'.txt',
'.vtt',
'.webmanifest',
'.xml',
]);

View file

@ -0,0 +1,99 @@
// cspell:ignore githubusercontent streetsidesoftware forgejo
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { walk } from './lib/walk.mjs';
const root = process.cwd();
// GitHub is no longer linked from anywhere on the site: project source links,
// the social links, and the repo metadata all point at the self-hosted git
// host instead. Matching the host substrings (rather than the bare word
// "github") keeps the Shiki `github-light`/`github-dark` themes and the
// Forgejo Actions `github.*` context variables from tripping the check.
const bannedHosts = [/github\.com/i, /github\.io/i, /githubusercontent\.com/i];
// Upstream tooling URLs that genuinely live on GitHub and have no self-hosted
// equivalent (e.g. the cspell config JSON Schema). A flagged URL is permitted
// only when it exactly equals one of these.
const permitted = new Set([
'https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json',
]);
const textExtensions = new Set([
'.astro',
'.css',
'.html',
'.js',
'.json',
'.md',
'.mjs',
'.ts',
'.txt',
'.webmanifest',
'.xml',
]);
const roots = [
'src',
'public',
'scripts',
'README.md',
'package.json',
'astro.config.mjs',
'cspell.json',
].map((entry) => path.resolve(root, entry));
async function exists(filePath) {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
// Grows a match outward to the whole whitespace/quote-delimited URL token so it
// can be compared against the permitted set.
function urlToken(line, index, length) {
const boundary = /[\s"'`<>()[\]{}]/;
let start = index;
while (start > 0 && !boundary.test(line[start - 1])) start -= 1;
let end = index + length;
while (end < line.length && !boundary.test(line[end])) end += 1;
return line.slice(start, end);
}
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 lines = (await readFile(file, 'utf8')).split('\n');
lines.forEach((line, lineIndex) => {
for (const pattern of bannedHosts) {
for (const match of line.matchAll(new RegExp(pattern, 'gi'))) {
const token = urlToken(line, match.index, match[0].length);
if (permitted.has(token)) continue;
const location = `${path.relative(root, file)}:${lineIndex + 1}:${match.index + 1}`;
failures.push(location);
}
}
});
}
if (failures.length > 0) {
console.error(
`GitHub links are not allowed; link the self-hosted git host instead:\n${failures.join('\n')}`
);
process.exit(1);
}
console.log('No GitHub links found.');

View file

@ -79,7 +79,6 @@ const MIME = {
'.woff2': 'font/woff2',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.vtt': 'text/vtt; charset=utf-8',
'.pdf': 'application/pdf',
};

View file

@ -8,18 +8,16 @@ interface Props {
projectPeriod?: string;
stack?: string[];
scale?: string;
outcome?: string;
links?: Link[];
headingId: string;
}
const { projectPeriod, stack = [], scale, outcome, links = [], headingId } = Astro.props;
const { projectPeriod, stack = [], scale, links = [], headingId } = Astro.props;
const rows: Array<[string, string]> = [];
if (projectPeriod) rows.push(['Period', projectPeriod]);
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
if (scale) rows.push(['Scale', scale]);
if (outcome) rows.push(['Outcome', outcome]);
---
{

View file

@ -15,7 +15,7 @@ const year = new Date().getFullYear();
<address class="footer-contact">
<a href={`mailto:${site.email}`}>Email</a>
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
<a href={site.git} rel="noopener me">Git</a>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</address>
</div>

View file

@ -40,7 +40,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
poster={item.poster?.src}
width={videoWidth}
height={videoHeight}
aria-label={item.alt}
aria-label={item.caption}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
@ -50,7 +50,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
item.src && (
<Picture
src={item.src}
alt={item.decorative ? '' : (item.alt ?? '')}
alt={item.decorative ? '' : (item.caption ?? '')}
formats={['avif', 'webp']}
widths={[480, 960, 1280, 1920]}
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"

View file

@ -90,7 +90,7 @@ const playScript = `
class="video-thumbnail__play"
type="button"
data-video-play
aria-label={`Play video: ${video.alt ?? alt}`}
aria-label={`Play video: ${video.caption ?? alt}`}
>
<span class="video-thumbnail__play-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
@ -106,7 +106,7 @@ const playScript = `
preload="none"
playsinline
poster={video.poster?.src}
aria-label={video.alt}
aria-label={video.caption}
hidden
>
{video.webm && <source src={video.webm} type="video/webm" />}

View file

@ -72,13 +72,15 @@ const thumbnailOverrideSchema = ({ image }: SchemaContext) =>
src: image(),
});
// The caption is the single source of truth for a media item's text: it shows
// as the visible <figcaption> and doubles as the image alt / accessible name,
// so meaningful media carries one string instead of a caption/alt pair.
const mediaSchema = ({ image }: SchemaContext) =>
z
.discriminatedUnion('type', [
z.object({
type: z.enum(['image', 'diagram']),
src: image(),
alt: z.string().optional(),
decorative: z.boolean().optional(),
caption: z.string().optional(),
transcript: z.string().optional(),
@ -89,7 +91,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
poster: image().optional(),
mp4: mediaUrl.optional(),
webm: mediaUrl.optional(),
alt: z.string().optional(),
decorative: z.boolean().optional(),
caption: z.string().optional(),
transcript: z.string().optional(),
@ -98,15 +99,9 @@ const mediaSchema = ({ image }: SchemaContext) =>
message: 'Video media needs at least one mp4 or webm source.',
}),
])
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
message: 'Meaningful media needs both alt text and a caption.',
})
.refine(
(item) => item.type !== 'video' || item.decorative || Boolean(item.transcript),
{
message: 'Meaningful video needs a transcript.',
}
);
.refine((item) => item.decorative || Boolean(item.caption), {
message: 'Meaningful media needs a caption.',
});
// A single collection where each entry can carry an `article` facet (a written
// page under /articles/<slug>), a `project` facet (a card in the /projects/
@ -123,7 +118,6 @@ const articleFacet = ({ image }: SchemaContext) =>
tags: z.array(z.enum(TAGS)).default([]),
stack: z.array(z.string()).optional(),
scale: z.string().min(1).optional(),
outcome: z.string().min(1).optional(),
media: z.array(mediaSchema({ image })).default([]),
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

View file

@ -8,7 +8,7 @@ thumbnail:
alt: The Ad Astra game running on a small OLED display.
links:
- label: Source
url: https://github.com/schmelczer/ad_astra
url: https://git.schmelczer.dev/andras/ad_astra
article:
tags: ['embedded', 'games', 'systems']
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']

View file

@ -1,6 +1,6 @@
---
title: Avoid
description: My first browser game, and later the handout for a small JS/Canvas workshop. Kept around so the timeline stays honest.
description: My first browser game, and later the handout for a JS/Canvas workshop at SchDesign, a society at the Budapest Technology University (BME). It's trivial yet I still have fun playing with it now, so it made the cut.
date: 2026-04-29
period: 'January 2018'
thumbnail:
@ -8,18 +8,13 @@ thumbnail:
alt: Screenshot of the Avoid web game.
article:
tags: ['games', 'web']
role: Game author
stack: ['JavaScript', 'Canvas']
outcome: My first browser game; kept for the timeline, reused for teaching
audience: general
project:
description: My first browser game, kept around so the timeline is honest.
thumbnail:
alt: Screenshot of the Avoid canvas game.
project: {}
---
January 2018, my first browser game. You're a peach-coloured dot; red dots stream in from the edges and chase you; the score counts how many the game has dared to spawn so far. It isn't good. I keep it here because pretending the older work didn't happen would be revisionism, and because it marks the moment a `<canvas>` element stopped being mysterious to me.
January 2018, my first browser game. You're a dot; other dots stream in from the edges and chase you; you have to out-manoeuvre them to escape while getting them to collide with each other. The game is simple but rather fun, at least for the first few minutes.
It had a second job, too. The README isn't documentation; it's the handout for a small JS/Canvas workshop, in Hungarian: what JavaScript is, what a 2D context gives you, why a game loop wants `requestAnimationFrame` and a delta time, why `var` is past tense. The little game turned out to be the right size for explaining things to people who'd never drawn a pixel from code before. The process advice in that handout (write working code first, then make it pretty) is still roughly the only process I follow.
This project marks my first encounter with the `<canvas>` element acting as the catalyst for my later (and still going) obsession with graphics programming.
When I archived the repo in 2022 I allowed myself to fix the retina scaling and make the page vaguely responsive, and then made myself stop.
A few months later I also reused this for our design society's web design workshop. We rebuilt the webpage based on the handout in the README while getting familiar with expressing art and movement through JavaScript. The little game turned out to be the right size for explaining things to people who'd never drawn a pixel from code before. The process advice in that handout (write working code first, then make it pretty) is still roughly the only process I follow.

View file

@ -1,99 +1,28 @@
---
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.
title: Simple Sophisticated Backup Strategy
description: When simple beats complex and a case for not reinventing the wheel.
date: 2026-05-29
period: '2024-2026'
period: '2023-2026'
thumbnail:
src: ./_assets/backup.png
alt: Placeholder thumbnail for the backup container post.
links:
- label: Source
url: https://github.com/schmelczer/backup-container
url: https://git.schmelczer.dev/andras/backup-container
- label: Container image
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
url: https://git.schmelczer.dev/andras/-/packages/container/backup-container
article:
tags: ['systems', 'tools']
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
project:
title: Backup Container
scale: One container, multiple targets per host, four years of restored incidents
---
Once you self-host a few services with live databases, the backup question stops being theoretical: everything on the box is mid-write at every moment of the day. This container is my answer, two years and several real restores in (including the photo library behind the [e-ink frame](/articles/frame-eink-photo-display/)). One Alpine container and four short shell scripts (the longest is 84 lines) push a BTRFS 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`, ...); there's no config format and no DSL, because the env file is the configuration. The design has exactly one clever moment, the snapshot, and I've worked to keep everything else too simple to break.
There's merit to keeping systems simpler the more critical they are. More precisely, we must keep them easily understandable by building on solid foundations and only adding on top the most necessary extra logic.
## The problem the snapshot solves
A good example of this is my home server's backup. I ran various databases, even when I aim to mostly use sqlite, there are multiple postgres instances, redis, message queues, etc. Who doesn't like time machine like backups which allow granular file history? To achieve this, we need frequent backups, my setup uses an hourly cadence. However, we can't shut down the entire stack every hour to back it up. So the only solution is to backup everything live.
`tar | borg create` against a 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, and you find out at restore time, which is the one moment you can least afford the discovery. The "right" answer is to coordinate a quiesce with every database: a fan-out of `pg_dump`, SQLite `.backup`, Redis `BGSAVE`, and so on, each with retries, timeouts, and per-app credentials.
I could've gone down the rabbit hole of using each database's dedicated backup mechanism, however, that would've been everything but simple. So instead I rely on the DB's crash recovery mechanism being solid.
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.
With the above context, I can now reveal that the backup container is just a short shell script that takes a btrfs snapshot (all container volumes are mounted from a btrfs subvolume) and then feeding into borg which handles incrementality and deduplication. The backup script supports configuring borg to run against multiple remote backup servers, gracefully handle failures, and to maintain a healthcheck status in case human intervention is needed.
```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, and two borg processes writing the same repo is a bad afternoon. The sleep loop can't overlap: the next run starts when the previous one finishes, plus the interval. One process, one snapshot, one borg invocation, and a whole category of bug that has nowhere to live.
## 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 hide in there:
- **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%.
- **Retention: 6 daily, 3 weekly, 48 monthly, 10 yearly.** Four years of monthly archives sounds extravagant until the question becomes "when exactly did this file change?", which is the question every incident eventually asks.
- **`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 still the snapshot. Everything above it is a wrapper anyone could rewrite in an afternoon, and that's not an apology for the wrapper, it's the property I now optimise for on purpose.
I've been using this for 4 years now and I restored various files and folders and did two full disaster recoveries. These were enough to convince me of the soundness of borg, btrfs, and my backup configuration. That's why I don't think I will migrate away from this method any time soon.

View file

@ -9,7 +9,6 @@ thumbnail:
article:
tags: ['simulation', 'systems']
stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: Visible consequences for an otherwise abstract PLC challenge
project:
title: City Simulation
---

View file

@ -8,7 +8,7 @@ thumbnail:
alt: The decla.red browser game interface showing a space scene.
links:
- label: Source
url: https://github.com/schmelczer/decla.red
url: https://git.schmelczer.dev/andras/decla.red
- label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
@ -16,11 +16,9 @@ article:
tags: ['games', 'web', 'systems']
stack: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL', 'SDF-2D']
scale: Game servers ticking at 200 Hz, each serving 1632 clients at 25 updates a second, browser and mobile
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
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: Two teams, small planets, real gravity. The renderer underneath is the SDF-2D library from my thesis.
project:
title: decla.red

View file

@ -14,7 +14,6 @@ links:
article:
tags: ['web', 'tools']
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
project:
title: Fizika
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']

View file

@ -16,11 +16,9 @@ article:
tags: ['graphics', 'simulation', 'web']
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
scale: One HTML file, 10 WGSL shaders, 6 vibe presets, up to 1.5M agents, 60 FPS target on consumer hardware
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
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.
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
project:
title: Fleeting Garden

View file

@ -1,21 +0,0 @@
---
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
period: 'Autumn 2019'
thumbnail:
src: ./_assets/forex.jpg
alt: Chart comparing predicted and actual EUR/USD exchange rates.
article:
tags: ['systems', 'tools']
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
project:
title: Foreign Exchange Prediction Experiment
---
In the autumn of 2019 I was an undergrad with a few free weekends and the quiet conviction that I could find a small edge on EUR/USD. The screenshots that survive are flattering: the predicted rate in blue hugging the actual rate in green closely enough to look like skill. It was a linear extrapolation in the frequency domain wearing a nice coat.
The pipeline: smooth the price series, differentiate it, run a short-time Fourier transform over overlapped Hanning-windowed frames, extrapolate the frequency-domain coefficients forward, then invert everything back into a predicted price. A Python server (NumPy, SciPy, Flask) served the model; an MQL4 client sitting in a broker terminal called it and stood ready to place trades, if I'd dared. I never dared, and that restraint is the part of the project that aged best.
What the weekends actually bought me was a working understanding of the trap: even a naive model will hand you a sometimes-profitable backtest, and a sometimes-profitable backtest is the most persuasive wrong evidence there is. The people playing this game for real have co-located servers, microsecond ticks, and teams whose whole job is the thing I was doing between lectures. I didn't learn how to predict currencies. I learned, precisely and cheaply, how far my edge wasn't, which I've come to think is the best possible return on a project like this.

View file

@ -22,11 +22,9 @@ article:
'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
media:
- type: image
src: ./_assets/frame.jpg
alt: The frame on the wall showing a 6-colour Atkinson-dithered landscape scene, 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.
project:
title: Frame

View file

@ -19,11 +19,9 @@ article:
tags: ['ai', 'systems', 'tools']
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
media:
- type: image
src: ./_assets/great-ai.png
alt: Example Python code using GreatAI decorators and prediction helpers.
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
project:
title: GreatAI

View file

@ -8,17 +8,15 @@ thumbnail:
alt: Life Towers goal tracking interface with tower-like visual structures.
links:
- label: Source
url: https://github.com/schmelczer/life-towers/
url: https://git.schmelczer.dev/andras/life-towers/
article:
featuredOrder: 4
tags: ['systems', 'web', 'tools']
stack: ['Python', 'Angular', 'TypeScript', 'FastAPI', 'SQLite']
scale: Multi-device goal and task state shared between clients and a server
outcome: A goal tracker still in use, and a lesson about when clever sync isn't worth it
media:
- type: image
src: ./_assets/towers.jpg
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
caption: Towers of finished tasks, one column per goal. Done blocks fall into place with a small gravity animation.
project:
title: Life Towers

View file

@ -9,7 +9,6 @@ thumbnail:
article:
tags: ['systems', 'tools']
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
outcome: The first non-trivial project I started and finished
project:
title: Lights Synchronized to Music
---

View file

@ -11,15 +11,12 @@ article:
tags: ['simulation', 'systems', 'tools']
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
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
media:
- type: image
src: ./_assets/process-simulator.jpg
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values.
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: The JavaFX editor produced JSON that the simulator ate as input.
project:
title: Cooling System Simulation

View file

@ -26,11 +26,9 @@ article:
'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
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 three integer compares per row.
project:
title: Perfect Postcode

View file

@ -1,21 +1,16 @@
---
title: A Photo Site That Generated Itself From a Folder
description: 'A folder of photos becomes a static site: five widths, three formats, hashed filenames. Started as a Webpack script in 2016; mostly an excuse to walk.'
description: 'Simple slideshow of my photos'
date: 2026-04-27
period: '2016 onwards'
thumbnail:
src: ./_assets/photos.jpg
alt: Screenshot of a generated photography site.
article:
tags: ['web', 'tools']
stack: ['Webpack', 'Vite', 'TypeScript', 'Image processing']
outcome: A photography site that updates itself when I drop new images into a folder
project:
title: Photo Site Generator
---
I take walks with a camera. Most of what I shoot isn't good, but 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 actually working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to run a CMS for it. So: a Webpack script. Point it at a directory of full-size photos, get a static site with responsive variants of each. Drop a photo in, build, deploy.
I bought my first camera, a proper DSLR (a Nikon D3200) from my first summer job's salary at 15. I've been taking photos of everything that captures my imagination, or sparks aesthetic joy in me ever since. I wanted to have a simple but personal way of sharing these photos while avoiding going through social media, so this was the perfect excuse to build a photo portfolio website.
The site still exists and still works exactly that way, though almost none of the 2016 code survives. The current build renders every photo into five widths and three formats (AVIF, WebP, and a JPEG fallback), strips the EXIF, and bakes a content hash into each filename so a change to the encoder settings can never serve anyone a stale image (a lesson from the first iteration, learned the usual way). A catalogue file and the photo folder have to agree exactly, in both directions, or the build refuses to run. It's a lot of pipeline for a site whose whole job is showing pictures without a framework attached, but the pipeline is what keeps the habit cheap: photograph, drop in the folder, push.
It started off with PHP running on a Raspberry PI in my room but, to keep maintenance simple, has since evolved into a simple Vite script generating a static HTML and variously sized photos from a folder.
The same walks later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
The only rule for good tech is it has to give me more value than the effort it takes to use it. Given that part of photography I enjoy is taking photos, the website gives me little value, thus, committing a JPEG into a Git repo and pushing it is the right amount of effort to expect of me. It's still one more step than what I had to do with PHP version but I have taste in tech too, not just photography.

View file

@ -9,7 +9,6 @@ thumbnail:
article:
tags: ['games', 'systems']
stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable course project, and the moment programming clicked
project:
title: Platform Game
---

View file

@ -10,7 +10,7 @@ links:
- label: Demo
url: /reconcile/
- label: Source
url: https://github.com/schmelczer/reconcile
url: https://git.schmelczer.dev/andras/reconcile
- label: crates.io
url: https://crates.io/crates/reconcile-text
- label: npm
@ -22,11 +22,9 @@ article:
tags: ['systems', 'tools', 'web']
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 Rust library that auto-resolves prose conflicts, with WASM and Python bindings
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 weaves conflicting edits together instead of asking a human to choose.
project:
title: reconcile-text

View file

@ -26,11 +26,9 @@ article:
'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
media:
- type: image
src: ./_assets/sdf2d.jpg
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
project:
title: SDF-2D

View file

@ -8,7 +8,7 @@ thumbnail:
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
links:
- label: Source
url: https://github.com/schmelczer/vault-link
url: https://git.schmelczer.dev/andras/vault-link
- label: Docs
url: https://vault-link.schmelczer.dev
article:
@ -27,7 +27,6 @@ article:
'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
project:
title: VaultLink
selected: true

View file

@ -140,7 +140,6 @@ const personJsonLd = buildPersonJsonLd();
projectPeriod={post.data.period}
stack={post.data.article?.stack}
scale={post.data.article?.scale}
outcome={post.data.article?.outcome}
links={post.data.links}
/>

View file

@ -17,7 +17,7 @@ export const site = {
'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',
git: 'https://git.schmelczer.dev/andras',
linkedin: 'https://www.linkedin.com/in/andras-schmelczer',
cv: '/media/downloads/cv-andras-schmelczer.pdf',
};
@ -211,7 +211,7 @@ export function buildPersonJsonLd(extra?: Record<string, unknown>) {
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
sameAs: [site.git, site.linkedin],
description: site.description,
...extra,
};

View file

@ -115,7 +115,7 @@ const personJsonLd = buildPersonJsonLd({
<dt>Elsewhere</dt>
<dd class="about-links">
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
<a href={site.git} rel="noopener me">Git</a>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</dd>
</div>

View file

@ -19,12 +19,14 @@ const personJsonLd = buildPersonJsonLd();
<Base jsonLd={personJsonLd}>
<section class="home-intro">
<p class="eyebrow">Engineering notes</p>
<h1>
<span class="home-intro-name">Andras Schmelczer</span> sharing cool projects and lessons learned
<span class="home-intro-name">Andras Schmelczer</span> sharing cool projects and lessons
learned
</h1>
<p>
I enjoy learning by building exciting things that sometimes even turn out to be useful. This page is dedicated to the projects I actually finished. To learn more about me, check out the <a href="/about/">About page</a>.
I enjoy learning by building exciting things that sometimes even turn out to be
useful. This page is dedicated to the projects I actually finished. To learn more
about me, check out the <a href="/about/">About page</a>.
</p>
</section>

37
todo.md Normal file
View file

@ -0,0 +1,37 @@
- Update vault-link/fleeting-notes cover
- remove "Engineering notes"
- new og photo
- download cv from forgejo
- copy reconcile text and frame text
- css is crazy long
- ban github links
- enforce cspell in CI
- astro extension, cspell extension
- don't duplicate description
- remove role & audience fields from articles
- no vtt caption
- link avoid and repo
- should the photo generator be an article
- double check BTU
- remove outcome field
- no caption and alt text at the same time
- merge graph-editor-javafx-simulation-input.md and nuclear-cooling-simulation.md into one
- Look at src/content/work/ad-astra-attiny85-game-engine.md and fix typos, phrasing, and pacing. Don't change much of what I wrote but make it more interesting to read, explain the vocabulary and make it something postable on hacker news.
website to take a link or copypasted text, extract all text, format it into markdown (use gemini flash), send to reader and render into TTS
- portable ac
- printer/scanner
- bike
- books
Impressionint water-colour painting of an Autumn forest with trees reaching the sky. The trees are sparsely covered in orange and brown leaves with a few branches exposed bare. The sky is grey and barely visible between the straight towering trees. There's a lightly travelled path curving across the scene with 2 people holding hands in the distance. You can't make out much detail from the little dark people who are barely visible. There are leaves mid-fall. The scene is painted from a low position looking up.
Recommend me audiobooks. I've really enjoyed listening to the 3-body problem trilogy, Project Hail Mary, the 1st Foundation book, hitchikers guide to the galaxy. What similar titles are there?