This commit is contained in:
Andras Schmelczer 2026-06-06 21:03:26 +01:00
parent fcb0e25ebd
commit 3441a7e4af
108 changed files with 641 additions and 869 deletions

View file

@ -7,14 +7,15 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { visit } from 'unist-util-visit';
// Build a lookup of post slugs to their last modification dates so the sitemap
// can advertise accurate <lastmod> values to crawlers. astro:content isn't
// available inside the config, so we read post frontmatter directly. Our posts
// always use single-line scalar `date:` / `updated:` keys, so a small regex
// extraction is sufficient and intentional.
// Build a lookup of article slugs to their last modification dates so the
// sitemap can advertise accurate <lastmod> values to crawlers. astro:content
// isn't available inside the config, so we read entry frontmatter directly.
// Each entry uses a single-line top-level scalar `date:` key, so a small regex
// extraction is sufficient and intentional. (`updated:` now lives nested under
// `article:`; no entry sets it, so falling back to `date` is correct.)
const postsDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'src/content/posts'
'src/content/work'
);
function extractScalar(frontmatter, key) {

View file

@ -5,7 +5,7 @@ import TagList from './TagList.astro';
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
interface Props {
posts: CollectionEntry<'posts'>[];
posts: CollectionEntry<'work'>[];
showYear?: boolean;
tagLimit?: number;
timeline?: boolean;
@ -39,7 +39,7 @@ const {
</a>
</h3>
<p>{post.data.description}</p>
<TagList tags={post.data.tags} limit={tagLimit} />
<TagList tags={post.data.article?.tags ?? []} limit={tagLimit} />
</article>
<time datetime={post.data.date.toISOString()}>
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}

View file

@ -2,7 +2,7 @@
import type { CollectionEntry } from 'astro:content';
import ProjectLinks from './ProjectLinks.astro';
type Link = CollectionEntry<'projects'>['data']['links'][number];
type Link = CollectionEntry<'work'>['data']['links'][number];
interface Props {
role?: string;

View file

@ -1,8 +1,8 @@
---
import type { CollectionEntry } from 'astro:content';
import PostMediaFigure from './PostMediaFigure.astro';
import type { WorkMedia } from '../lib/site';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
type MediaItem = WorkMedia;
interface Props {
items: MediaItem[];

View file

@ -1,8 +1,8 @@
---
import type { CollectionEntry } from 'astro:content';
import { Picture } from 'astro:assets';
import type { WorkMedia } from '../lib/site';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
type MediaItem = WorkMedia;
interface Props {
item: MediaItem;

View file

@ -5,7 +5,7 @@ import VideoThumbnail from './VideoThumbnail.astro';
import { absoluteUrl, getHeaderVideo } from '../lib/site';
interface Props {
post: CollectionEntry<'posts'>;
post: CollectionEntry<'work'>;
}
const { post } = Astro.props;
@ -13,7 +13,7 @@ const headerVideo = getHeaderVideo(post);
const demoLink = post.data.links.find(
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
);
const iframeUrl = post.data.iframeThumbnail ? demoLink?.url : undefined;
const iframeUrl = post.data.article?.iframeThumbnail ? demoLink?.url : undefined;
const iframeSrc = iframeUrl?.startsWith('/') ? absoluteUrl(iframeUrl) : iframeUrl;
const iframeTitle = demoLink
? `${demoLink.label}: ${post.data.title}`

View file

@ -1,7 +1,7 @@
---
import type { CollectionEntry } from 'astro:content';
type Link = CollectionEntry<'projects'>['data']['links'][number];
type Link = CollectionEntry<'work'>['data']['links'][number];
interface Props {
links: Link[];

View file

@ -1,13 +1,17 @@
---
import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro';
import VideoThumbnail from './VideoThumbnail.astro';
import type { HeaderVideo } from '../lib/site';
import { PROJECT_THUMBNAIL, articlePath, entrySlug, getHeaderVideo } from '../lib/site';
import {
PROJECT_THUMBNAIL,
articlePath,
entrySlug,
getHeaderVideo,
projectCard,
} from '../lib/site';
interface Props {
projects: CollectionEntry<'projects'>[];
projects: CollectionEntry<'work'>[];
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
// below substantial content should leave this at zero.
eagerFirstThumbnail?: boolean;
@ -24,23 +28,15 @@ function isExternal(url: string) {
return /^https?:\/\//.test(url);
}
// 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. A project may
// have no essay (no article) just as an article may have no project; the
// relationship is optional in both directions.
const essayHrefs = new Map<string, string>();
// When the linked article has a header video, the card thumbnail becomes a
// click-to-play poster (the card body still opens the project site).
const essayVideos = new Map<string, HeaderVideo>();
for (const project of projects) {
const essay = project.data.essay;
if (!essay) continue;
const resolved = await getEntry(essay);
if (!resolved || resolved.data.draft) continue;
essayHrefs.set(project.id, articlePath(resolved));
const headerVideo = getHeaderVideo(resolved);
if (headerVideo) essayVideos.set(project.id, headerVideo);
// Project and article are the same entry now. A card links to its article when
// the entry has a published (non-draft) `article` facet; an entry without one
// is a project card with no article page. When that article has a header video,
// the card thumbnail becomes a click-to-play poster (the card body still opens
// the project site).
function publishedArticleHref(project: CollectionEntry<'work'>) {
const article = project.data.article;
if (!article || article.draft) return undefined;
return articlePath(project);
}
// The whole card opens the project's website: the first link that isn't a
@ -48,7 +44,7 @@ for (const project of projects) {
// The Open button is that link, and its overlay makes the entire card
// clickable. Projects without such a link have no Open button and are not
// clickable; their article, if any, is reachable through the Article link.
function websiteUrl(project: CollectionEntry<'projects'>) {
function websiteUrl(project: CollectionEntry<'work'>) {
return project.data.links.find((link) => !link.download)?.url;
}
---
@ -58,8 +54,9 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
projects.map((project, index) => {
const anchor = entrySlug(project);
const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id);
const headerVideo = essayVideos.get(project.id);
const card = projectCard(project);
const essayHref = publishedArticleHref(project);
const headerVideo = essayHref ? getHeaderVideo(project) : undefined;
const website = websiteUrl(project);
const websiteExternal = website ? isExternal(website) : false;
const eager = index < eagerThumbnailCount;
@ -70,8 +67,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
<VideoThumbnail
class="entry-thumbnail project-thumbnail"
variant="card"
poster={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
poster={card.thumbnail.src}
alt={card.thumbnail.alt}
video={headerVideo}
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
@ -80,8 +77,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
/>
) : (
<EntryThumbnail
src={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
src={card.thumbnail.src}
alt={card.thumbnail.alt}
class="project-thumbnail"
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
@ -91,8 +88,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
)}
<article class="project-card__summary">
<div class="project-card__head">
<h3 id={titleId}>{project.data.title}</h3>
<p class="project-description">{project.data.description}</p>
<h3 id={titleId}>{card.title}</h3>
<p class="project-description">{card.description}</p>
</div>
{(essayHref || website) && (
<div class="project-card__actions">
@ -100,7 +97,7 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
<a
class="project-article-link"
href={essayHref}
aria-label={`Read the article about ${project.data.title}`}
aria-label={`Read the article about ${card.title}`}
>
Article
<span aria-hidden="true">→</span>
@ -114,8 +111,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
target={websiteExternal ? '_blank' : undefined}
aria-label={
websiteExternal
? `Open the ${project.data.title} site in a new tab`
: `Open ${project.data.title}`
? `Open the ${card.title} site in a new tab`
: `Open ${card.title}`
}
>
Open

View file

@ -1,4 +1,4 @@
import { defineCollection, reference } from 'astro:content';
import { defineCollection } from 'astro:content';
import type { SchemaContext } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
@ -41,6 +41,17 @@ function isIframeUrl(url: string) {
}
}
export const TAGS = [
'ai',
'systems',
'graphics',
'simulation',
'embedded',
'web',
'tools',
'games',
] as const;
const linkSchema = z.object({
label: z.string(),
url: linkUrl,
@ -53,6 +64,15 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
alt: z.string().min(1, 'Thumbnail alt text must not be empty.'),
});
// Partial thumbnail used by the project card to override just the image, just
// the alt text, or both. Anything omitted falls back to the entry's top-level
// thumbnail, so a card that only rewords its alt doesn't repeat the `src`.
const thumbnailOverrideSchema = ({ image }: SchemaContext) =>
z.object({
src: image().optional(),
alt: z.string().min(1, 'Thumbnail alt text must not be empty.').optional(),
});
const mediaSchema = ({ image }: SchemaContext) =>
z
.discriminatedUnion('type', [
@ -97,73 +117,72 @@ const mediaSchema = ({ image }: SchemaContext) =>
}
);
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
// 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/
// index), or both. Fields shared by both facets live at the top level and are
// written once; the few where the project card deliberately differs from the
// article are overridden inside `project`. The markdown body is the article's
// content.
const articleFacet = ({ image }: SchemaContext) =>
z.object({
updated: z.coerce.date().optional(),
draft: z.boolean().default(false),
iframeThumbnail: z.boolean().default(false),
featuredOrder: z.number().optional(),
tags: z.array(z.enum(TAGS)).default([]),
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'),
media: z.array(mediaSchema({ image })).default([]),
});
const projectFacet = ({ image }: SchemaContext) =>
z.object({
selected: z.boolean().default(false),
// Card chips. When omitted, the card falls back to the article's `stack`.
technologies: z.array(z.string()).optional(),
// Card overrides. When omitted, the card uses the top-level value.
title: z.string().optional(),
description: z.string().max(160).optional(),
thumbnail: thumbnailOverrideSchema({ image }).optional(),
});
const work = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/work' }),
schema: ({ image }) =>
z
.object({
// Shared identity (single source of truth).
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'),
period: z.string().optional(),
date: z.coerce.date(),
links: z.array(linkSchema).default([]),
media: z.array(mediaSchema({ image })).default([]),
article: articleFacet({ image }).optional(),
project: projectFacet({ image }).optional(),
})
.refine((entry) => Boolean(entry.article) || Boolean(entry.project), {
message: 'An entry needs at least an `article` or a `project` facet.',
})
.refine(
(post) =>
!post.iframeThumbnail ||
post.links.some(
(entry) =>
!entry.article?.iframeThumbnail ||
entry.links.some(
(link) =>
!link.download &&
link.label.trim().toLowerCase() === 'demo' &&
isIframeUrl(link.url)
),
{
path: ['iframeThumbnail'],
path: ['article', 'iframeThumbnail'],
message:
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
}
),
});
const projects = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string().max(160),
thumbnail: thumbnailSchema({ image }),
period: z.string(),
sortDate: z.coerce.date(),
technologies: z.array(z.string()).default([]),
selected: z.boolean().default(false),
essay: reference('posts').optional(),
links: z.array(linkSchema).default([]),
}),
});
export const collections = { posts, projects };
export const collections = { work };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,47 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,15 +0,0 @@
---
title: Ad Astra
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
thumbnail:
src: ./_assets/ad-astra.jpg
alt: The Ad Astra handheld game running on its OLED display.
period: 'Spring 2020'
sortDate: 2020-04-01
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
selected: true
essay: ad-astra-attiny85-game-engine
links:
- label: Source
url: https://github.com/schmelczer/ad_astra
---

View file

@ -1,12 +0,0 @@
---
title: Avoid
description: My first browser game, kept around so the timeline is honest.
thumbnail:
src: ./_assets/avoid.jpg
alt: Screenshot of the Avoid canvas game.
period: 'January 2018'
sortDate: 2018-01-01
technologies: ['JavaScript', 'Canvas']
selected: false
essay: avoid-early-web-game
---

View file

@ -1,17 +0,0 @@
---
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,13 +0,0 @@
---
title: City Simulation
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.
period: 'July-August 2018'
sortDate: 2018-08-01
technologies: ['Unity', 'C#', 'REST API', 'Blender']
selected: false
essay: city-simulation-unity-traffic
links: []
---

View file

@ -1,13 +0,0 @@
---
title: Photo Colour Grader
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.
period: 'June 2018'
sortDate: 2018-06-01
technologies: ['JavaScript', 'Canvas', 'Image processing']
selected: false
essay: photo-colour-grader
links: []
---

View file

@ -1,18 +0,0 @@
---
title: decla.red
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.
period: 'Autumn-Winter 2020'
sortDate: 2020-11-01
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
selected: true
essay: declared-shared-simulation-code
links:
- label: Source
url: https://github.com/schmelczer/decla.red
- label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
---

View file

@ -1,17 +0,0 @@
---
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,17 +0,0 @@
---
title: Fleeting Garden
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', 'Tweakpane']
selected: true
essay: fleeting-garden-webgpu-drawing
links:
- label: Demo
url: /fleeting/
- label: Source
url: https://home.schmelczer.dev/git/andras/webgpu
---

View file

@ -1,13 +0,0 @@
---
title: Foreign Exchange Prediction Experiment
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.
period: 'Autumn 2019'
sortDate: 2019-10-01
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
selected: false
essay: foreign-exchange-prediction-experiment
links: []
---

View file

@ -1,24 +0,0 @@
---
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 scene 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,20 +0,0 @@
---
title: GreatAI
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.
period: '2022'
sortDate: 2022-01-01
technologies: ['Python', 'ML deployment', 'API design']
selected: true
essay: greatai-ai-deployment-api
links:
- label: PyPI
url: https://pypi.org/project/great-ai/
- label: Project site
url: https://great-ai.scoutinscience.com
- label: MSc thesis
url: /media/downloads/great-ai-andras-schmelczer.pdf
download: true
---

View file

@ -1,13 +0,0 @@
---
title: Lights Synchronized to Music
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', 'MOSFETs', 'vanilla web']
selected: false
essay: lights-synchronized-to-music
links: []
---

View file

@ -1,15 +0,0 @@
---
title: My Notes
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', 'Markdown', 'Markwon']
selected: false
essay: my-notes-android-markdown-app
links:
- label: Source
url: https://github.com/schmelczer/my-notes
---

View file

@ -1,13 +0,0 @@
---
title: Graph Editor
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.
period: 'October-November 2018'
sortDate: 2018-10-15
technologies: ['JavaFX', 'JSON', 'REST API']
selected: false
essay: graph-editor-javafx-simulation-input
links: []
---

View file

@ -1,13 +0,0 @@
---
title: Cooling System Simulation
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.
period: 'October-November 2018'
sortDate: 2018-11-01
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
selected: true
essay: nuclear-cooling-simulation
links: []
---

View file

@ -1,28 +0,0 @@
---
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,13 +0,0 @@
---
title: Photo Site Generator
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.
period: 'Summer 2016'
sortDate: 2016-07-01
technologies: ['Webpack', 'Image processing', 'Static site generation']
selected: false
essay: photo-site-generator
links: []
---

View file

@ -1,13 +0,0 @@
---
title: Platform Game
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.
period: 'Autumn 2017'
sortDate: 2017-10-01
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
selected: false
essay: platform-game-c-sdl
links: []
---

View file

@ -1,23 +0,0 @@
---
title: reconcile-text
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', 'wasm-bindgen', 'Myers diff']
selected: true
essay: reconcile-text-3-way-merge
links:
- label: Demo
url: /reconcile/
- label: Source
url: https://github.com/schmelczer/reconcile
- label: crates.io
url: https://crates.io/crates/reconcile-text
- label: npm
url: https://www.npmjs.com/package/reconcile-text
- label: PyPI
url: https://pypi.org/project/reconcile-text/
---

View file

@ -1,20 +0,0 @@
---
title: SDF-2D
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', 'Signed distance fields']
selected: true
essay: sdf-2d-ray-tracing
links:
- label: NPM package
url: https://www.npmjs.com/package/sdf-2d
- label: Video
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
- label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
---

View file

@ -1,15 +0,0 @@
---
title: Life Towers
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', 'TypeScript', 'Immutable trie']
selected: true
essay: life-towers-immutable-tries
links:
- label: Source
url: https://github.com/schmelczer/life-towers/
---

View file

@ -1,29 +0,0 @@
---
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

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -2,28 +2,36 @@
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'
period: 'Spring 2020'
thumbnail:
src: ./_assets/ad-astra.jpg
alt: The Ad Astra game running on a small OLED display.
tags: ['embedded', 'games', 'systems']
role: Hardware and firmware author
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
url: https://github.com/schmelczer/ad_astra
media:
- type: video
poster: ./_assets/ad-astra.jpg
webm: /media/video/ad_astra.webm
mp4: /media/video/ad_astra.mp4
captions: /media/video/ad_astra.vtt
alt: Video demonstration of the embedded game running on a small OLED display.
caption: The whole thing, 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.
article:
tags: ['embedded', 'games', 'systems']
role: Hardware and firmware author
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
media:
- type: video
poster: ./_assets/ad-astra.jpg
webm: /media/video/ad_astra.webm
mp4: /media/video/ad_astra.mp4
captions: /media/video/ad_astra.vtt
alt: Video demonstration of the embedded game running on a small OLED display.
caption: The whole thing, 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.
project:
title: Ad Astra
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
selected: true
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
thumbnail:
alt: The Ad Astra handheld game running on its OLED display.
---
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.

View file

@ -2,15 +2,20 @@
title: Avoid
description: My first browser game. Tiny, archived for honesty.
date: 2026-04-29
projectPeriod: 'January 2018'
period: 'January 2018'
thumbnail:
src: ./_assets/avoid.jpg
alt: Screenshot of the Avoid web game.
tags: ['games', 'web']
role: Game author
stack: ['JavaScript', 'Canvas']
outcome: My first browser game; kept for the timeline
audience: general
article:
tags: ['games', 'web']
role: Game author
stack: ['JavaScript', 'Canvas']
outcome: My first browser game; kept for the timeline
audience: general
project:
description: My first browser game, kept around so the timeline is honest.
thumbnail:
alt: Screenshot of the Avoid canvas game.
---
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

@ -2,21 +2,27 @@
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'
period: '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
article:
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
project:
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:
alt: Placeholder thumbnail for the backup container project.
---
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.

View file

@ -2,16 +2,21 @@
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'
period: 'July-August 2018'
thumbnail:
src: ./_assets/city-simulation.jpg
alt: Screenshot of a Unity traffic simulation.
tags: ['simulation', 'systems']
role: Simulation author
stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: Visible consequences for an otherwise abstract PLC challenge
audience: technical
links: []
article:
tags: ['simulation', 'systems']
role: Simulation author
stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: Visible consequences for an otherwise abstract PLC challenge
audience: technical
project:
title: City Simulation
description: A Unity city where REST-controlled traffic lights made bad PLC code visible as car crashes.
thumbnail:
alt: Screenshot of a Unity city traffic simulation.
---
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.

View file

@ -2,27 +2,35 @@
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'
period: 'Autumn-Winter 2020'
thumbnail:
src: ./_assets/decla-red.jpg
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', '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
url: https://github.com/schmelczer/decla.red
- label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
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: A real game loop is a worse audience than a tech demo. That's the point.
article:
tags: ['games', 'web', 'systems']
role: Game and backend systems author
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
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: A real game loop is a worse audience than a tech demo. That's the point.
project:
title: decla.red
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.
selected: true
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
thumbnail:
src: ./_assets/declared.jpg
---
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.

View file

@ -2,20 +2,27 @@
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, a decade of past papers, still online and still used.
date: 2026-05-28
projectPeriod: '2017-2018'
period: '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
article:
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
project:
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.'
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
thumbnail:
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
---
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.

View file

@ -2,27 +2,32 @@
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
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'
period: '2026'
thumbnail:
src: ./_assets/fleeting-garden.jpg
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
iframeThumbnail: true
tags: ['graphics', 'simulation', 'web']
role: Graphics and shader author
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
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
url: /fleeting/
- label: Source
url: https://home.schmelczer.dev/git/andras/webgpu
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.
article:
iframeThumbnail: true
tags: ['graphics', 'simulation', 'web']
role: Graphics and shader author
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
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
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
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
selected: true
---
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.

View file

@ -2,16 +2,21 @@
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'
period: 'Autumn 2019'
thumbnail:
src: ./_assets/forex.jpg
alt: Chart comparing predicted and actual EUR/USD exchange rates.
tags: ['systems', 'tools']
role: Experiment author
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
audience: technical
links: []
article:
tags: ['systems', 'tools']
role: Experiment author
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
audience: technical
project:
title: Foreign Exchange Prediction Experiment
description: A Hanning-windowed STFT experiment on EUR/USD. Passable backtest, sober conclusions, no real money risked.
thumbnail:
alt: Chart from a foreign exchange prediction experiment.
---
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.

View file

@ -2,33 +2,48 @@
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 picked by date and favourites, gated on Home Assistant presence, Atkinson-dithered.
date: 2026-05-27
projectPeriod: '2026'
period: '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 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.
article:
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
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
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.
selected: true
technologies:
[
'Python',
'Raspberry Pi Zero 2W',
'Waveshare PhotoPainter',
'Immich',
'Home Assistant',
'numba',
'Atkinson dither',
]
---
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.

View file

@ -2,16 +2,21 @@
title: A JavaFX Editor for the Cooling Simulator
description: 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'
period: 'October-November 2018'
thumbnail:
src: ./_assets/process-simulator-input.jpg
alt: JavaFX graph editor for the cooling system simulator.
tags: ['simulation', 'tools']
role: Editor author
stack: ['JavaFX', 'JSON', 'REST API']
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
audience: technical
links: []
article:
tags: ['simulation', 'tools']
role: Editor author
stack: ['JavaFX', 'JSON', 'REST API']
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
audience: technical
project:
title: Graph Editor
description: A drag-and-drop JavaFX editor that let event organisers reconfigure the cooling sim without me sitting next to them.
thumbnail:
alt: JavaFX editor interface for the cooling system simulator input graph.
---
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.

View file

@ -2,17 +2,10 @@
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'
period: '2022'
thumbnail:
src: ./_assets/great-ai.png
alt: Example Python code using the GreatAI API.
tags: ['ai', 'systems', 'tools']
featuredOrder: 1
role: Researcher and framework author
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
url: https://pypi.org/project/great-ai/
@ -21,11 +14,24 @@ links:
- label: MSc thesis
url: /media/downloads/great-ai-andras-schmelczer.pdf
download: true
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.
article:
featuredOrder: 1
tags: ['ai', 'systems', 'tools']
role: Researcher and framework author
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
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
description: One decorator on a Python function turned it into a deployed ML service. MSc thesis with a survey to back the API choices.
selected: true
technologies: ['Python', 'ML deployment', 'API design']
---
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.

View file

@ -2,25 +2,31 @@
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'
period: 'August-September 2019'
thumbnail:
src: ./_assets/towers.jpg
alt: Life Towers goal tracking interface with tower-like visual structures.
tags: ['systems', 'web', 'tools']
featuredOrder: 4
role: Full-stack author
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
scale: Multi-device goal and task state shared between clients and a server
outcome: A working sync protocol where structural sharing made the delta tiny
audience: recruiter-relevant
links:
- label: Source
url: https://github.com/schmelczer/life-towers/
media:
- type: image
src: ./_assets/towers.jpg
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
article:
featuredOrder: 4
tags: ['systems', 'web', 'tools']
role: Full-stack author
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
scale: Multi-device goal and task state shared between clients and a server
outcome: A working sync protocol where structural sharing made the delta tiny
audience: recruiter-relevant
media:
- type: image
src: ./_assets/towers.jpg
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
project:
title: Life Towers
description: A multi-device goal tracker. The trie underneath made the sync diff free; the towers were just the UI.
selected: true
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trie']
---
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.

View file

@ -2,16 +2,21 @@
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'
period: 'Spring 2016'
thumbnail:
src: ./_assets/leds.jpg
alt: RGB LED strips lit by a music synchronisation project.
tags: ['systems', 'tools']
role: Hardware and software author
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
outcome: The first non-trivial project I started and finished
audience: technical
links: []
article:
tags: ['systems', 'tools']
role: Hardware and software author
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
outcome: The first non-trivial project I started and finished
audience: technical
project:
title: Lights Synchronized to Music
description: Raspberry Pi music player, NumPy FFT, MOSFETs, RGB strips. The first thing I built that I actually finished.
thumbnail:
alt: RGB LED strips glowing from a music synchronization project.
---
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.

View file

@ -2,18 +2,24 @@
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'
period: 'November 2019'
thumbnail:
src: ./_assets/my-notes.png
alt: Screenshots of the My Notes Android app.
tags: ['tools']
role: Android app author
stack: ['Android', 'Markdown', 'Markwon']
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
article:
tags: ['tools']
role: Android app author
stack: ['Android', 'Markdown', 'Markwon']
outcome: A working notes app and my first time outside the web stack
audience: technical
project:
title: My Notes
description: A small Android Markdown note app. The point was a few weeks outside the web stack.
thumbnail:
alt: Screenshot of the My Notes Android markdown app.
---
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.

View file

@ -2,27 +2,33 @@
title: 'Two Graphs Are Simpler Than One: A Cooling System Simulator'
description: Live cooling-system sim for a PLC cybersecurity event. Splitting flow and heat into two graph passes kept it cheap and the behaviour believable.
date: 2026-05-04
projectPeriod: 'October-November 2018'
period: 'October-November 2018'
thumbnail:
src: ./_assets/process-simulator.jpg
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
tags: ['simulation', 'systems', 'tools']
featuredOrder: 5
role: Simulation and UI author
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
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: 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.
article:
featuredOrder: 5
tags: ['simulation', 'systems', 'tools']
role: Simulation and UI author
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
audience: recruiter-relevant
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
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.'
selected: true
thumbnail:
src: ./_assets/nuclear-simulation.jpg
---
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.

View file

@ -2,37 +2,44 @@
title: 25 Million UK Property Rows in a Single Rust Process
description: Notes on perfect-postcode.co.uk. Every numeric feature is u16-quantised in a row-major array, so filter eval is two integer compares per row.
date: 2026-05-28
projectPeriod: '2026'
period: '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.
article:
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
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.
project:
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.
selected: true
thumbnail:
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.
---
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.

View file

@ -2,16 +2,21 @@
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'
period: 'June 2018'
thumbnail:
src: ./_assets/photo-colour-grader.jpg
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 working proof-of-concept grader and an interaction model I'd still defend
audience: technical
links: []
article:
tags: ['graphics', 'web', 'tools']
role: Interface and image processing author
stack: ['JavaScript', 'Canvas', 'Image processing']
outcome: A working proof-of-concept grader and an interaction model I'd still defend
audience: technical
project:
title: Photo Colour Grader
description: Pick a colour, edit every nearby colour as a function of distance. A grader built around one interaction idea.
thumbnail:
alt: Screenshot of a colour grading interface applied to a photograph.
---
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.

View file

@ -2,16 +2,19 @@
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'
period: 'Summer 2016'
thumbnail:
src: ./_assets/photos.jpg
alt: Screenshot of a generated photography site.
tags: ['web', 'tools']
role: Site generator author
stack: ['Webpack', 'Image processing', 'Static site generation']
outcome: A photography site that updated itself when I dropped new images into a folder
audience: general
links: []
article:
tags: ['web', 'tools']
role: Site generator author
stack: ['Webpack', 'Image processing', 'Static site generation']
outcome: A photography site that updated itself when I dropped new images into a folder
audience: general
project:
title: Photo Site Generator
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.
---
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.

View file

@ -2,15 +2,21 @@
title: A 3D Voxel Game in C, Built While Learning Pointers
description: My Basics of Programming 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'
period: 'Autumn 2017'
thumbnail:
src: ./_assets/platform-game.jpg
alt: Screenshot from a 3D platform game written in C.
tags: ['games', 'systems']
role: Game author
stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable course project, and the moment programming clicked
audience: technical
article:
tags: ['games', 'systems']
role: Game author
stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable course project, and the moment programming clicked
audience: technical
project:
title: Platform Game
description: My Basics of Programming project. 3D voxel game in C and SDL 1.2. Pointers, learned painfully.
thumbnail:
alt: Screenshot from an early 3D platform game.
---
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.

View file

@ -2,17 +2,10 @@
title: A 3-Way Text Merger That Never Shows Conflict Markers
description: reconcile-text merges Markdown notes from three editors I don't control, with no history. Why git, CRDTs, and diff-match-patch each failed me.
date: 2026-05-21
projectPeriod: '2025'
period: '2025'
thumbnail:
src: ./_assets/reconcile.png
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
tags: ['systems', 'tools', 'web']
featuredOrder: 2
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 Rust library that auto-resolves prose conflicts, with WASM and Python bindings
audience: recruiter-relevant
links:
- label: Demo
url: /reconcile/
@ -24,11 +17,24 @@ links:
url: https://www.npmjs.com/package/reconcile-text
- label: PyPI
url: https://pypi.org/project/reconcile-text/
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.
article:
featuredOrder: 2
tags: ['systems', 'tools', 'web']
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 Rust library that auto-resolves prose conflicts, with WASM and Python bindings
audience: recruiter-relevant
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
description: One Rust core, three packages. Merges Markdown notes from three editors I don't control, with no operation history. Never emits markers.
selected: true
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff']
---
## Why I wrote it

View file

@ -2,18 +2,10 @@
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
description: 'My BSc thesis library. The mobile GPU shaped the architecture: tile-based passes, deferred shading, shaders generated per scene and device.'
date: 2026-05-08
projectPeriod: 'Autumn-Winter 2020'
period: 'Autumn-Winter 2020'
thumbnail:
src: ./_assets/sdf2d.jpg
alt: SDF-2D browser demo with soft lighting effects.
tags: ['graphics', 'web', 'systems']
featuredOrder: 3
role: Library author
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
url: https://www.npmjs.com/package/sdf-2d
@ -22,11 +14,31 @@ links:
- label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
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.
article:
featuredOrder: 3
tags: ['graphics', 'web', 'systems']
role: Library author
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
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
description: A browser 2D ray-tracer tuned for the phone in your pocket. Tile-based passes, deferred shading, shaders generated per scene and device.
selected: true
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
---
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.

View file

@ -2,33 +2,38 @@
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 app that justified reconcile-text.'
date: 2026-05-30
projectPeriod: '2025-2026'
period: '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
article:
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
project:
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.'
selected: true
---
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.

View file

@ -9,29 +9,29 @@ import PostThumbnail from '../components/PostThumbnail.astro';
import TagList from '../components/TagList.astro';
import {
absoluteUrl,
adjacentPosts,
adjacentArticles,
articlePath,
buildBreadcrumbJsonLd,
buildPersonJsonLd,
buildBreadcrumbTrail,
formatDate,
getArticles,
getHeaderVideo,
getPublishedPosts,
getRelatedPosts,
getRelatedArticles,
optimizeOgImage,
} from '../lib/site';
import Base from './Base.astro';
interface Props {
post: CollectionEntry<'posts'>;
post: CollectionEntry<'work'>;
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
const allPosts = await getPublishedPosts();
const { previous, next } = adjacentPosts(allPosts, post);
const related = getRelatedPosts(allPosts, post, 3);
const allPosts = await getArticles();
const { previous, next } = adjacentArticles(allPosts, post);
const related = getRelatedArticles(allPosts, post, 3);
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
@ -57,7 +57,7 @@ const showToc = h2Headings.length >= 3;
// banner image and, when present, plays the header video inline.
const thumbnailSrc = post.data.thumbnail.src.src;
const headerVideo = getHeaderVideo(post);
const trailingMedia = post.data.media.filter((item) => {
const trailingMedia = (post.data.article?.media ?? []).filter((item) => {
if (item === headerVideo) return false;
if (item.type === 'video') return true;
return item.src.src !== thumbnailSrc;
@ -71,12 +71,14 @@ const blogPosting = {
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
...(post.data.article?.updated && {
dateModified: post.data.article.updated.toISOString(),
}),
author: { '@id': personId },
publisher: { '@id': personId },
image: absoluteUrl(ogImageOptimized.src),
url: absoluteUrl(articlePath(post)),
keywords: post.data.tags.join(', '),
keywords: (post.data.article?.tags ?? []).join(', '),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': absoluteUrl(articlePath(post)),
@ -99,15 +101,15 @@ const personJsonLd = buildPersonJsonLd();
preloadMono={hasCode}
article={{
publishedTime: post.data.date.toISOString(),
modifiedTime: post.data.updated?.toISOString(),
tags: post.data.tags,
modifiedTime: post.data.article?.updated?.toISOString(),
tags: post.data.article?.tags ?? [],
}}
jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]}
>
<article class="post">
<header class="post-header">
<Breadcrumbs items={breadcrumbTrail} />
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
<p class="eyebrow">{post.data.period ?? 'Article'}</p>
<h1>{post.data.title}</h1>
<p class="dek">{post.data.description}</p>
<div class="post-meta">
@ -115,13 +117,13 @@ const personJsonLd = buildPersonJsonLd();
{formatDate(post.data.date)}
</time>
{
post.data.updated && (
post.data.article?.updated && (
<>
{' · '}
<span>
Updated{' '}
<time datetime={post.data.updated.toISOString()}>
{formatDate(post.data.updated)}
<time datetime={post.data.article.updated.toISOString()}>
{formatDate(post.data.article.updated)}
</time>
</span>
</>
@ -130,18 +132,18 @@ const personJsonLd = buildPersonJsonLd();
{' · '}
<span>{readingMinutes} min read</span>
</div>
<TagList tags={post.data.tags} />
<TagList tags={post.data.article?.tags ?? []} />
</header>
<PostThumbnail post={post} />
<AtAGlance
headingId={`at-a-glance-${post.id}`}
role={post.data.role}
projectPeriod={post.data.projectPeriod}
stack={post.data.stack}
scale={post.data.scale}
outcome={post.data.outcome}
role={post.data.article?.role}
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

@ -74,77 +74,109 @@ export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`;
}
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
a.localeCompare(b)
);
export function getAllTags(articles: CollectionEntry<'work'>[]) {
return [
...new Set(articles.flatMap((article) => article.data.article?.tags ?? [])),
].sort((a, b) => a.localeCompare(b));
}
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
// Caching the promise means `getCollection('posts')` runs once per build.
let publishedPostsPromise: Promise<CollectionEntry<'posts'>[]> | undefined;
// Memoized article loader. Build steps call `getArticles()` from many pages
// (index, articles, RSS, sitemap, tag pages, article layout). Caching the
// promise means `getCollection('work')` runs once per build. An entry is an
// article when it carries an `article` facet and isn't a draft.
let articlesPromise: Promise<CollectionEntry<'work'>[]> | undefined;
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
if (!publishedPostsPromise) {
publishedPostsPromise = getCollection('posts').then((posts) =>
posts
.filter((post) => !post.data.draft)
export function getArticles(): Promise<CollectionEntry<'work'>[]> {
if (!articlesPromise) {
articlesPromise = getCollection('work').then((entries) =>
entries
.filter((entry) => entry.data.article && !entry.data.article.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
);
}
return publishedPostsPromise;
return articlesPromise;
}
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
return (await getCollection('projects')).sort(
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
);
// Entries shown in the projects index: those carrying a `project` facet,
// newest first.
export async function getProjects(): Promise<CollectionEntry<'work'>[]> {
return (await getCollection('work'))
.filter((entry) => entry.data.project)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
type PostMediaItem = CollectionEntry<'posts'>['data']['media'][number];
export type HeaderVideo = Extract<PostMediaItem, { type: 'video' }>;
export interface ProjectCard {
title: string;
description: string;
thumbnail: { src: ImageMetadata; alt: string };
technologies: string[];
selected: boolean;
}
// A post has a "header video" when one of its media items is a video whose
// poster is the same image as the post thumbnail. That video can stand in for
// the static banner: the header shows the poster, then plays inline on click.
// Posts without such a video keep the plain image header.
export function getHeaderVideo(post: CollectionEntry<'posts'>): HeaderVideo | undefined {
const thumbnailSrc = post.data.thumbnail.src.src;
return post.data.media.find(
// Resolves a project card's presentation. Shared identity lives at the top
// level; the `project` facet overrides only the fields where the card
// deliberately differs from the article. Technologies fall back to the
// article's stack.
export function projectCard(entry: CollectionEntry<'work'>): ProjectCard {
const { data } = entry;
const project = data.project;
return {
title: project?.title ?? data.title,
description: project?.description ?? data.description,
thumbnail: {
src: project?.thumbnail?.src ?? data.thumbnail.src,
alt: project?.thumbnail?.alt ?? data.thumbnail.alt,
},
technologies: project?.technologies ?? data.article?.stack ?? [],
selected: project?.selected ?? false,
};
}
type ArticleData = NonNullable<CollectionEntry<'work'>['data']['article']>;
export type WorkMedia = ArticleData['media'][number];
export type HeaderVideo = Extract<WorkMedia, { type: 'video' }>;
// An entry has a "header video" when one of its article media items is a video
// whose poster is the same image as the top-level thumbnail. That video can
// stand in for the static banner: the header shows the poster, then plays
// inline on click. Entries without such a video keep the plain image header.
export function getHeaderVideo(entry: CollectionEntry<'work'>): HeaderVideo | undefined {
const thumbnailSrc = entry.data.thumbnail.src.src;
return (entry.data.article?.media ?? []).find(
(item): item is HeaderVideo =>
item.type === 'video' && item.poster?.src === thumbnailSrc
);
}
export function adjacentPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>
export function adjacentArticles(
articles: CollectionEntry<'work'>[],
current: CollectionEntry<'work'>
) {
const index = posts.findIndex((post) => post.id === current.id);
const index = articles.findIndex((article) => article.id === current.id);
if (index === -1) return { previous: undefined, next: undefined };
return {
previous: index < posts.length - 1 ? posts[index + 1] : undefined,
next: index > 0 ? posts[index - 1] : undefined,
previous: index < articles.length - 1 ? articles[index + 1] : undefined,
next: index > 0 ? articles[index - 1] : undefined,
};
}
export function getRelatedPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>,
export function getRelatedArticles(
articles: CollectionEntry<'work'>[],
current: CollectionEntry<'work'>,
limit = 3
) {
const currentTags = new Set(current.data.tags);
return posts
.filter((post) => post.id !== current.id)
.map((post) => ({
post,
overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length,
const currentTags = new Set(current.data.article?.tags ?? []);
return articles
.filter((article) => article.id !== current.id)
.map((article) => ({
article,
overlap: (article.data.article?.tags ?? []).filter((tag) => currentTags.has(tag))
.length,
}))
.filter(({ overlap }) => overlap > 0)
.sort((a, b) => b.overlap - a.overlap)
.slice(0, limit)
.map(({ post }) => post);
.map(({ article }) => article);
}
export function absoluteUrl(path: string) {
@ -203,7 +235,7 @@ interface BreadcrumbInput {
projects?: boolean;
tagsIndex?: boolean;
tag?: string;
post?: CollectionEntry<'posts'>;
post?: CollectionEntry<'work'>;
}
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the

View file

@ -1,11 +1,11 @@
---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro';
import { getPublishedPosts } from '../lib/site';
import { getArticles } from '../lib/site';
const RECENT_ARTICLES = 5;
const posts = await getPublishedPosts();
const posts = await getArticles();
const recent = posts.slice(0, RECENT_ARTICLES);
---

Some files were not shown because too many files have changed in this diff Show more