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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,17 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro'; import EntryThumbnail from './EntryThumbnail.astro';
import VideoThumbnail from './VideoThumbnail.astro'; import VideoThumbnail from './VideoThumbnail.astro';
import type { HeaderVideo } from '../lib/site'; import {
import { PROJECT_THUMBNAIL, articlePath, entrySlug, getHeaderVideo } from '../lib/site'; PROJECT_THUMBNAIL,
articlePath,
entrySlug,
getHeaderVideo,
projectCard,
} from '../lib/site';
interface Props { interface Props {
projects: CollectionEntry<'projects'>[]; projects: CollectionEntry<'work'>[];
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists // Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
// below substantial content should leave this at zero. // below substantial content should leave this at zero.
eagerFirstThumbnail?: boolean; eagerFirstThumbnail?: boolean;
@ -24,23 +28,15 @@ function isExternal(url: string) {
return /^https?:\/\//.test(url); return /^https?:\/\//.test(url);
} }
// The `essay` field is a `reference('posts')`, so when present it's always a // Project and article are the same entry now. A card links to its article when
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry. // the entry has a published (non-draft) `article` facet; an entry without one
// Drafts are skipped because their article page is not built. A project may // is a project card with no article page. When that article has a header video,
// have no essay (no article) just as an article may have no project; the // the card thumbnail becomes a click-to-play poster (the card body still opens
// relationship is optional in both directions. // the project site).
const essayHrefs = new Map<string, string>(); function publishedArticleHref(project: CollectionEntry<'work'>) {
// When the linked article has a header video, the card thumbnail becomes a const article = project.data.article;
// click-to-play poster (the card body still opens the project site). if (!article || article.draft) return undefined;
const essayVideos = new Map<string, HeaderVideo>(); return articlePath(project);
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);
} }
// The whole card opens the project's website: the first link that isn't a // 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 // 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. Projects without such a link have no Open button and are not
// clickable; their article, if any, is reachable through the Article link. // 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; return project.data.links.find((link) => !link.download)?.url;
} }
--- ---
@ -58,8 +54,9 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
projects.map((project, index) => { projects.map((project, index) => {
const anchor = entrySlug(project); const anchor = entrySlug(project);
const titleId = `${anchor}-title`; const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id); const card = projectCard(project);
const headerVideo = essayVideos.get(project.id); const essayHref = publishedArticleHref(project);
const headerVideo = essayHref ? getHeaderVideo(project) : undefined;
const website = websiteUrl(project); const website = websiteUrl(project);
const websiteExternal = website ? isExternal(website) : false; const websiteExternal = website ? isExternal(website) : false;
const eager = index < eagerThumbnailCount; const eager = index < eagerThumbnailCount;
@ -70,8 +67,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
<VideoThumbnail <VideoThumbnail
class="entry-thumbnail project-thumbnail" class="entry-thumbnail project-thumbnail"
variant="card" variant="card"
poster={project.data.thumbnail.src} poster={card.thumbnail.src}
alt={project.data.thumbnail.alt} alt={card.thumbnail.alt}
video={headerVideo} video={headerVideo}
widths={PROJECT_THUMBNAIL.widths} widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes} sizes={PROJECT_THUMBNAIL.sizes}
@ -80,8 +77,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
/> />
) : ( ) : (
<EntryThumbnail <EntryThumbnail
src={project.data.thumbnail.src} src={card.thumbnail.src}
alt={project.data.thumbnail.alt} alt={card.thumbnail.alt}
class="project-thumbnail" class="project-thumbnail"
widths={PROJECT_THUMBNAIL.widths} widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes} sizes={PROJECT_THUMBNAIL.sizes}
@ -91,8 +88,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
)} )}
<article class="project-card__summary"> <article class="project-card__summary">
<div class="project-card__head"> <div class="project-card__head">
<h3 id={titleId}>{project.data.title}</h3> <h3 id={titleId}>{card.title}</h3>
<p class="project-description">{project.data.description}</p> <p class="project-description">{card.description}</p>
</div> </div>
{(essayHref || website) && ( {(essayHref || website) && (
<div class="project-card__actions"> <div class="project-card__actions">
@ -100,7 +97,7 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
<a <a
class="project-article-link" class="project-article-link"
href={essayHref} href={essayHref}
aria-label={`Read the article about ${project.data.title}`} aria-label={`Read the article about ${card.title}`}
> >
Article Article
<span aria-hidden="true">→</span> <span aria-hidden="true">→</span>
@ -114,8 +111,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
target={websiteExternal ? '_blank' : undefined} target={websiteExternal ? '_blank' : undefined}
aria-label={ aria-label={
websiteExternal websiteExternal
? `Open the ${project.data.title} site in a new tab` ? `Open the ${card.title} site in a new tab`
: `Open ${project.data.title}` : `Open ${card.title}`
} }
> >
Open 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 type { SchemaContext } from 'astro:content';
import { glob } from 'astro/loaders'; import { glob } from 'astro/loaders';
import { z } from 'astro/zod'; 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({ const linkSchema = z.object({
label: z.string(), label: z.string(),
url: linkUrl, url: linkUrl,
@ -53,6 +64,15 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
alt: z.string().min(1, 'Thumbnail alt text must not be empty.'), 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) => const mediaSchema = ({ image }: SchemaContext) =>
z z
.discriminatedUnion('type', [ .discriminatedUnion('type', [
@ -97,73 +117,72 @@ const mediaSchema = ({ image }: SchemaContext) =>
} }
); );
const posts = defineCollection({ // A single collection where each entry can carry an `article` facet (a written
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }), // 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 }) => schema: ({ image }) =>
z z
.object({ .object({
// Shared identity (single source of truth).
title: z.string(), title: z.string(),
description: z.string().max(160), description: z.string().max(160),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
draft: z.boolean().default(false),
thumbnail: thumbnailSchema({ image }), thumbnail: thumbnailSchema({ image }),
iframeThumbnail: z.boolean().default(false), period: z.string().optional(),
tags: z.array( date: z.coerce.date(),
z.enum([
'ai',
'systems',
'graphics',
'simulation',
'embedded',
'web',
'tools',
'games',
])
),
featuredOrder: z.number().optional(),
projectPeriod: z.string().optional(),
role: z.string().optional(),
stack: z.array(z.string()).optional(),
scale: z.string().optional(),
outcome: z.string().optional(),
audience: z
.enum(['general', 'technical', 'recruiter-relevant'])
.default('technical'),
links: z.array(linkSchema).default([]), 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( .refine(
(post) => (entry) =>
!post.iframeThumbnail || !entry.article?.iframeThumbnail ||
post.links.some( entry.links.some(
(link) => (link) =>
!link.download && !link.download &&
link.label.trim().toLowerCase() === 'demo' && link.label.trim().toLowerCase() === 'demo' &&
isIframeUrl(link.url) isIframeUrl(link.url)
), ),
{ {
path: ['iframeThumbnail'], path: ['article', 'iframeThumbnail'],
message: message:
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.', 'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
} }
), ),
}); });
const projects = defineCollection({ export const collections = { work };
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 };

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 title: A 50 FPS Game Engine on an 8-Bit Microcontroller
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.' description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.'
date: 2026-05-06 date: 2026-05-06
projectPeriod: 'Spring 2020' period: 'Spring 2020'
thumbnail: thumbnail:
src: ./_assets/ad-astra.jpg src: ./_assets/ad-astra.jpg
alt: The Ad Astra game running on a small OLED display. 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: links:
- label: Source - label: Source
url: https://github.com/schmelczer/ad_astra url: https://github.com/schmelczer/ad_astra
media: article:
- type: video tags: ['embedded', 'games', 'systems']
poster: ./_assets/ad-astra.jpg role: Hardware and firmware author
webm: /media/video/ad_astra.webm stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
mp4: /media/video/ad_astra.mp4 scale: 8 MHz, 8-bit ALU, ~31 mW at full brightness, ~1.5 mA standby, 1520 ms frame budget
captions: /media/video/ad_astra.vtt outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
alt: Video demonstration of the embedded game running on a small OLED display. audience: technical
caption: The whole thing, from board and firmware to sprites and game loop, runs on a single ATtiny85V at 8 MHz. media:
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay. - 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. 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 title: Avoid
description: My first browser game. Tiny, archived for honesty. description: My first browser game. Tiny, archived for honesty.
date: 2026-04-29 date: 2026-04-29
projectPeriod: 'January 2018' period: 'January 2018'
thumbnail: thumbnail:
src: ./_assets/avoid.jpg src: ./_assets/avoid.jpg
alt: Screenshot of the Avoid web game. alt: Screenshot of the Avoid web game.
tags: ['games', 'web'] article:
role: Game author tags: ['games', 'web']
stack: ['JavaScript', 'Canvas'] role: Game author
outcome: My first browser game; kept for the timeline stack: ['JavaScript', 'Canvas']
audience: general 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. 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 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. 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 date: 2026-05-29
projectPeriod: '2024-2026' period: '2024-2026'
thumbnail: thumbnail:
src: ./_assets/backup.png src: ./_assets/backup.png
alt: Placeholder thumbnail for the backup container post. 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: links:
- label: Source - label: Source
url: https://github.com/schmelczer/backup-container url: https://github.com/schmelczer/backup-container
- label: Container image - label: Container image
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container 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. 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 title: A Unity City Where Bad PLC Code Made Cars Crash
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped. description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped.
date: 2026-05-01 date: 2026-05-01
projectPeriod: 'July-August 2018' period: 'July-August 2018'
thumbnail: thumbnail:
src: ./_assets/city-simulation.jpg src: ./_assets/city-simulation.jpg
alt: Screenshot of a Unity traffic simulation. alt: Screenshot of a Unity traffic simulation.
tags: ['simulation', 'systems'] article:
role: Simulation author tags: ['simulation', 'systems']
stack: ['Unity', 'C#', 'REST API', 'Blender'] role: Simulation author
outcome: Visible consequences for an otherwise abstract PLC challenge stack: ['Unity', 'C#', 'REST API', 'Blender']
audience: technical outcome: Visible consequences for an otherwise abstract PLC challenge
links: [] 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. 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 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. 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 date: 2026-05-07
projectPeriod: 'Autumn-Winter 2020' period: 'Autumn-Winter 2020'
thumbnail: thumbnail:
src: ./_assets/decla-red.jpg src: ./_assets/decla-red.jpg
alt: The decla.red browser game interface showing a space scene. 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: links:
- label: Source - label: Source
url: https://github.com/schmelczer/decla.red url: https://github.com/schmelczer/decla.red
- label: BSc thesis - label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true download: true
media: article:
- type: image tags: ['games', 'web', 'systems']
src: ./_assets/decla-red.jpg role: Game and backend systems author
alt: The decla.red browser game interface showing a space scene with team controls and planets. stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL', 'SDF-2D']
caption: A real game loop is a worse audience than a tech demo. That's the point. 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. 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 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. 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 date: 2026-05-28
projectPeriod: '2017-2018' period: '2017-2018'
thumbnail: thumbnail:
src: ./_assets/fizika.jpg src: ./_assets/fizika.jpg
alt: Screenshot of the Fizika practice app showing topic-selection buttons over a light textured background. 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: links:
- label: Live - label: Live
url: https://fizika.schmelczer.dev url: https://fizika.schmelczer.dev
- label: Source - label: Source
url: https://home.schmelczer.dev/git/andras/fizika 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. 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 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. 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 date: 2026-05-22
projectPeriod: '2026' period: '2026'
thumbnail: thumbnail:
src: ./_assets/fleeting-garden.jpg src: ./_assets/fleeting-garden.jpg
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot. 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: links:
- label: Demo - label: Demo
url: /fleeting/ url: /fleeting/
- label: Source - label: Source
url: https://home.schmelczer.dev/git/andras/webgpu url: https://home.schmelczer.dev/git/andras/webgpu
media: article:
- type: image iframeThumbnail: true
src: ./_assets/fleeting-garden.jpg tags: ['graphics', 'simulation', 'web']
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot. role: Graphics and shader author
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone. 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. 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 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. 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 date: 2026-05-03
projectPeriod: 'Autumn 2019' period: 'Autumn 2019'
thumbnail: thumbnail:
src: ./_assets/forex.jpg src: ./_assets/forex.jpg
alt: Chart comparing predicted and actual EUR/USD exchange rates. alt: Chart comparing predicted and actual EUR/USD exchange rates.
tags: ['systems', 'tools'] article:
role: Experiment author tags: ['systems', 'tools']
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4'] role: Experiment author
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
audience: technical outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
links: [] 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. 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 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. 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 date: 2026-05-27
projectPeriod: '2026' period: '2026'
thumbnail: thumbnail:
src: ./_assets/frame.jpg 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. 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: links:
- label: Source - label: Source
url: https://home.schmelczer.dev/git/andras/frame url: https://home.schmelczer.dev/git/andras/frame
media: article:
- type: image tags: ['embedded', 'systems', 'tools']
src: ./_assets/frame.jpg role: Frame builder and pipeline author
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. stack:
caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them. [
'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. 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 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. 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 date: 2026-04-25
projectPeriod: 'October-November 2018' period: 'October-November 2018'
thumbnail: thumbnail:
src: ./_assets/process-simulator-input.jpg src: ./_assets/process-simulator-input.jpg
alt: JavaFX graph editor for the cooling system simulator. alt: JavaFX graph editor for the cooling system simulator.
tags: ['simulation', 'tools'] article:
role: Editor author tags: ['simulation', 'tools']
stack: ['JavaFX', 'JSON', 'REST API'] role: Editor author
outcome: A drag-and-drop graph editor that let non-developers feed the simulator stack: ['JavaFX', 'JSON', 'REST API']
audience: technical outcome: A drag-and-drop graph editor that let non-developers feed the simulator
links: [] 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. 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 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. 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 date: 2026-05-09
projectPeriod: '2022' period: '2022'
thumbnail: thumbnail:
src: ./_assets/great-ai.png src: ./_assets/great-ai.png
alt: Example Python code using the GreatAI API. 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: links:
- label: PyPI - label: PyPI
url: https://pypi.org/project/great-ai/ url: https://pypi.org/project/great-ai/
@ -21,11 +14,24 @@ links:
- label: MSc thesis - label: MSc thesis
url: /media/downloads/great-ai-andras-schmelczer.pdf url: /media/downloads/great-ai-andras-schmelczer.pdf
download: true download: true
media: article:
- type: image featuredOrder: 1
src: ./_assets/great-ai.png tags: ['ai', 'systems', 'tools']
alt: Example Python code using GreatAI decorators and prediction helpers. role: Researcher and framework author
caption: A working GreatAI service is about ten lines on top of a plain prediction function. 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. 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 title: Syncing State with an Immutable Trie
description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.' description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.'
date: 2026-05-05 date: 2026-05-05
projectPeriod: 'August-September 2019' period: 'August-September 2019'
thumbnail: thumbnail:
src: ./_assets/towers.jpg src: ./_assets/towers.jpg
alt: Life Towers goal tracking interface with tower-like visual structures. 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: links:
- label: Source - label: Source
url: https://github.com/schmelczer/life-towers/ url: https://github.com/schmelczer/life-towers/
media: article:
- type: image featuredOrder: 4
src: ./_assets/towers.jpg tags: ['systems', 'web', 'tools']
alt: Screenshot of a life tracking web interface represented with tower-like visual structures. role: Full-stack author
caption: The interface was a 2019 weekend experiment. The trie underneath aged better. 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. 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' 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. description: A Raspberry Pi music player that drove RGB strips through MOSFETs. The first thing I started and actually finished.
date: 2026-04-26 date: 2026-04-26
projectPeriod: 'Spring 2016' period: 'Spring 2016'
thumbnail: thumbnail:
src: ./_assets/leds.jpg src: ./_assets/leds.jpg
alt: RGB LED strips lit by a music synchronisation project. alt: RGB LED strips lit by a music synchronisation project.
tags: ['systems', 'tools'] article:
role: Hardware and software author tags: ['systems', 'tools']
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web'] role: Hardware and software author
outcome: The first non-trivial project I started and finished stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
audience: technical outcome: The first non-trivial project I started and finished
links: [] 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. 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' 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. 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 date: 2026-05-02
projectPeriod: 'November 2019' period: 'November 2019'
thumbnail: thumbnail:
src: ./_assets/my-notes.png src: ./_assets/my-notes.png
alt: Screenshots of the My Notes Android app. 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: links:
- label: Source - label: Source
url: https://github.com/schmelczer/my-notes 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. 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' 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. 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 date: 2026-05-04
projectPeriod: 'October-November 2018' period: 'October-November 2018'
thumbnail: thumbnail:
src: ./_assets/process-simulator.jpg src: ./_assets/process-simulator.jpg
alt: Cooling system simulator interface with pipes, pumps, and temperature values. alt: Cooling system simulator interface with pipes, pumps, and temperature values.
tags: ['simulation', 'systems', 'tools'] article:
featuredOrder: 5 featuredOrder: 5
role: Simulation and UI author tags: ['simulation', 'systems', 'tools']
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX'] role: Simulation and UI author
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
audience: recruiter-relevant outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
links: [] audience: recruiter-relevant
media: media:
- type: image - type: image
src: ./_assets/process-simulator.jpg src: ./_assets/process-simulator.jpg
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values. 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. caption: Flow ran first as a graph traversal, then heat solved as a matrix equation.
- type: image - type: image
src: ./_assets/process-simulator-input.jpg src: ./_assets/process-simulator-input.jpg
alt: Screenshot of the JavaFX graph editor used to define simulator input. alt: Screenshot of the JavaFX graph editor used to define simulator input.
caption: The JavaFX editor produced JSON that the simulator ate as 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. 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 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. 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 date: 2026-05-28
projectPeriod: '2026' period: '2026'
thumbnail: thumbnail:
src: ./_assets/perfect-postcode.jpg 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. 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: links:
- label: Site - label: Site
url: https://perfect-postcode.co.uk url: https://perfect-postcode.co.uk
media: article:
- type: image tags: ['systems', 'web', 'tools']
src: ./_assets/perfect-postcode.jpg role: Server architect and operator
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. stack:
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to two u16 compares per row. [
'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. 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 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. 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 date: 2026-04-30
projectPeriod: 'June 2018' period: 'June 2018'
thumbnail: thumbnail:
src: ./_assets/photo-colour-grader.jpg src: ./_assets/photo-colour-grader.jpg
alt: Colour grading interface with tonal controls and an edited preview. alt: Colour grading interface with tonal controls and an edited preview.
tags: ['graphics', 'web', 'tools'] article:
role: Interface and image processing author tags: ['graphics', 'web', 'tools']
stack: ['JavaScript', 'Canvas', 'Image processing'] role: Interface and image processing author
outcome: A working proof-of-concept grader and an interaction model I'd still defend stack: ['JavaScript', 'Canvas', 'Image processing']
audience: technical outcome: A working proof-of-concept grader and an interaction model I'd still defend
links: [] 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. 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 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. 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 date: 2026-04-27
projectPeriod: 'Summer 2016' period: 'Summer 2016'
thumbnail: thumbnail:
src: ./_assets/photos.jpg src: ./_assets/photos.jpg
alt: Screenshot of a generated photography site. alt: Screenshot of a generated photography site.
tags: ['web', 'tools'] article:
role: Site generator author tags: ['web', 'tools']
stack: ['Webpack', 'Image processing', 'Static site generation'] role: Site generator author
outcome: A photography site that updated itself when I dropped new images into a folder stack: ['Webpack', 'Image processing', 'Static site generation']
audience: general outcome: A photography site that updated itself when I dropped new images into a folder
links: [] 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. 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 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. 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 date: 2026-04-28
projectPeriod: 'Autumn 2017' period: 'Autumn 2017'
thumbnail: thumbnail:
src: ./_assets/platform-game.jpg src: ./_assets/platform-game.jpg
alt: Screenshot from a 3D platform game written in C. alt: Screenshot from a 3D platform game written in C.
tags: ['games', 'systems'] article:
role: Game author tags: ['games', 'systems']
stack: ['C', 'SDL 1.2', 'Voxel terrain'] role: Game author
outcome: A playable course project, and the moment programming clicked stack: ['C', 'SDL 1.2', 'Voxel terrain']
audience: technical 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. 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 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. 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 date: 2026-05-21
projectPeriod: '2025' period: '2025'
thumbnail: thumbnail:
src: ./_assets/reconcile.png src: ./_assets/reconcile.png
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging". 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: links:
- label: Demo - label: Demo
url: /reconcile/ url: /reconcile/
@ -24,11 +17,24 @@ links:
url: https://www.npmjs.com/package/reconcile-text url: https://www.npmjs.com/package/reconcile-text
- label: PyPI - label: PyPI
url: https://pypi.org/project/reconcile-text/ url: https://pypi.org/project/reconcile-text/
media: article:
- type: image featuredOrder: 2
src: ./_assets/reconcile.png tags: ['systems', 'tools', 'web']
alt: The reconcile-text logo, a stylised merge arrow, with the tagline "Conflict-free 3-way text merging". role: Library author
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose. 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 ## 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 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.' 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 date: 2026-05-08
projectPeriod: 'Autumn-Winter 2020' period: 'Autumn-Winter 2020'
thumbnail: thumbnail:
src: ./_assets/sdf2d.jpg src: ./_assets/sdf2d.jpg
alt: SDF-2D browser demo with soft lighting effects. 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: links:
- label: NPM package - label: NPM package
url: https://www.npmjs.com/package/sdf-2d url: https://www.npmjs.com/package/sdf-2d
@ -22,11 +14,31 @@ links:
- label: BSc thesis - label: BSc thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true download: true
media: article:
- type: image featuredOrder: 3
src: ./_assets/sdf2d.jpg tags: ['graphics', 'web', 'systems']
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects. role: Library author
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design. 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. 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 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.' 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 date: 2026-05-30
projectPeriod: '2025-2026' period: '2025-2026'
thumbnail: thumbnail:
src: ./_assets/vault-link.svg src: ./_assets/vault-link.svg
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.' 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: links:
- label: Source - label: Source
url: https://github.com/schmelczer/vault-link url: https://github.com/schmelczer/vault-link
- label: Docs - label: Docs
url: https://vault-link.schmelczer.dev 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. 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 TagList from '../components/TagList.astro';
import { import {
absoluteUrl, absoluteUrl,
adjacentPosts, adjacentArticles,
articlePath, articlePath,
buildBreadcrumbJsonLd, buildBreadcrumbJsonLd,
buildPersonJsonLd, buildPersonJsonLd,
buildBreadcrumbTrail, buildBreadcrumbTrail,
formatDate, formatDate,
getArticles,
getHeaderVideo, getHeaderVideo,
getPublishedPosts, getRelatedArticles,
getRelatedPosts,
optimizeOgImage, optimizeOgImage,
} from '../lib/site'; } from '../lib/site';
import Base from './Base.astro'; import Base from './Base.astro';
interface Props { interface Props {
post: CollectionEntry<'posts'>; post: CollectionEntry<'work'>;
} }
const { post } = Astro.props; const { post } = Astro.props;
const { Content, headings } = await render(post); const { Content, headings } = await render(post);
const allPosts = await getPublishedPosts(); const allPosts = await getArticles();
const { previous, next } = adjacentPosts(allPosts, post); const { previous, next } = adjacentArticles(allPosts, post);
const related = getRelatedPosts(allPosts, post, 3); const related = getRelatedArticles(allPosts, post, 3);
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src); 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. // banner image and, when present, plays the header video inline.
const thumbnailSrc = post.data.thumbnail.src.src; const thumbnailSrc = post.data.thumbnail.src.src;
const headerVideo = getHeaderVideo(post); 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 === headerVideo) return false;
if (item.type === 'video') return true; if (item.type === 'video') return true;
return item.src.src !== thumbnailSrc; return item.src.src !== thumbnailSrc;
@ -71,12 +71,14 @@ const blogPosting = {
headline: post.data.title, headline: post.data.title,
description: post.data.description, description: post.data.description,
datePublished: post.data.date.toISOString(), 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 }, author: { '@id': personId },
publisher: { '@id': personId }, publisher: { '@id': personId },
image: absoluteUrl(ogImageOptimized.src), image: absoluteUrl(ogImageOptimized.src),
url: absoluteUrl(articlePath(post)), url: absoluteUrl(articlePath(post)),
keywords: post.data.tags.join(', '), keywords: (post.data.article?.tags ?? []).join(', '),
mainEntityOfPage: { mainEntityOfPage: {
'@type': 'WebPage', '@type': 'WebPage',
'@id': absoluteUrl(articlePath(post)), '@id': absoluteUrl(articlePath(post)),
@ -99,15 +101,15 @@ const personJsonLd = buildPersonJsonLd();
preloadMono={hasCode} preloadMono={hasCode}
article={{ article={{
publishedTime: post.data.date.toISOString(), publishedTime: post.data.date.toISOString(),
modifiedTime: post.data.updated?.toISOString(), modifiedTime: post.data.article?.updated?.toISOString(),
tags: post.data.tags, tags: post.data.article?.tags ?? [],
}} }}
jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]} jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]}
> >
<article class="post"> <article class="post">
<header class="post-header"> <header class="post-header">
<Breadcrumbs items={breadcrumbTrail} /> <Breadcrumbs items={breadcrumbTrail} />
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p> <p class="eyebrow">{post.data.period ?? 'Article'}</p>
<h1>{post.data.title}</h1> <h1>{post.data.title}</h1>
<p class="dek">{post.data.description}</p> <p class="dek">{post.data.description}</p>
<div class="post-meta"> <div class="post-meta">
@ -115,13 +117,13 @@ const personJsonLd = buildPersonJsonLd();
{formatDate(post.data.date)} {formatDate(post.data.date)}
</time> </time>
{ {
post.data.updated && ( post.data.article?.updated && (
<> <>
{' · '} {' · '}
<span> <span>
Updated{' '} Updated{' '}
<time datetime={post.data.updated.toISOString()}> <time datetime={post.data.article.updated.toISOString()}>
{formatDate(post.data.updated)} {formatDate(post.data.article.updated)}
</time> </time>
</span> </span>
</> </>
@ -130,18 +132,18 @@ const personJsonLd = buildPersonJsonLd();
{' · '} {' · '}
<span>{readingMinutes} min read</span> <span>{readingMinutes} min read</span>
</div> </div>
<TagList tags={post.data.tags} /> <TagList tags={post.data.article?.tags ?? []} />
</header> </header>
<PostThumbnail post={post} /> <PostThumbnail post={post} />
<AtAGlance <AtAGlance
headingId={`at-a-glance-${post.id}`} headingId={`at-a-glance-${post.id}`}
role={post.data.role} role={post.data.article?.role}
projectPeriod={post.data.projectPeriod} projectPeriod={post.data.period}
stack={post.data.stack} stack={post.data.article?.stack}
scale={post.data.scale} scale={post.data.article?.scale}
outcome={post.data.outcome} outcome={post.data.article?.outcome}
links={post.data.links} links={post.data.links}
/> />

View file

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

View file

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

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