AI fixes
Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m5s

This commit is contained in:
Andras Schmelczer 2026-05-24 10:34:24 +01:00
parent eceb31a9ad
commit e9b6035c58
48 changed files with 354 additions and 340 deletions

View file

@ -3,9 +3,9 @@ updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "weekly"
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "weekly"

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
node_modules node_modules
dist dist
.astro .astro
target
.DS_Store .DS_Store

View file

@ -37,6 +37,6 @@
"files.exclude": { "files.exclude": {
"node_modules": true "node_modules": true
}, },
"editor.rulers": [120], "editor.rulers": [90],
"editor.wordWrap": "on" "editor.wordWrap": "on"
} }

2
.vscode/tasks.json vendored
View file

@ -2,7 +2,7 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "Format and lint", "label": "Lint",
"type": "shell", "type": "shell",
"command": "npm run lint", "command": "npm run lint",
"group": "test", "group": "test",

View file

@ -7,34 +7,35 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug'; import rehypeSlug from 'rehype-slug';
// Build a lookup of post slugs to their last modification dates so the sitemap // Build a lookup of post slugs to their last modification dates so the sitemap
// can advertise accurate <lastmod> values to crawlers. We parse the markdown // can advertise accurate <lastmod> values to crawlers. astro:content isn't
// frontmatter ourselves rather than importing `astro:content` (a virtual module // available inside the config, so we read post frontmatter directly. Our posts
// that may not be available inside the config). Failures are non-fatal — // always use single-line scalar `date:` / `updated:` keys, so a small regex
// sitemap entries simply fall back to no lastmod. // extraction is sufficient and intentional.
const postLastmodLookup = new Map(); const postsDir = path.resolve(
try { path.dirname(fileURLToPath(import.meta.url)),
const postsDir = path.resolve( 'src/content/posts'
path.dirname(fileURLToPath(import.meta.url)), );
'src/content/posts'
); function extractScalar(frontmatter, key) {
for (const entry of readdirSync(postsDir, { withFileTypes: true })) { const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'));
if (!entry.isFile() || !entry.name.endsWith('.md')) continue; return match?.[1]?.replace(/^['"]|['"]$/g, '');
const slug = entry.name.replace(/\.md$/, '');
const raw = readFileSync(path.join(postsDir, entry.name), 'utf8');
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!frontmatterMatch) continue;
const frontmatter = frontmatterMatch[1];
const updatedMatch = frontmatter.match(/^updated:\s*(.+?)\s*$/m);
const dateMatch = frontmatter.match(/^date:\s*(.+?)\s*$/m);
const rawDate = (updatedMatch ?? dateMatch)?.[1]?.replace(/^['"]|['"]$/g, '');
if (!rawDate) continue;
const parsed = new Date(rawDate);
if (!Number.isNaN(parsed.valueOf())) postLastmodLookup.set(slug, parsed);
}
} catch {
// Directory missing or unreadable; sitemap will fall back to no lastmod.
} }
const postLastmodLookup = new Map(
readdirSync(postsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => {
const raw = readFileSync(path.join(postsDir, entry.name), 'utf8');
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const rawDate =
extractScalar(frontmatter, 'updated') ?? extractScalar(frontmatter, 'date');
const parsed = rawDate ? new Date(rawDate) : null;
const valid = parsed && !Number.isNaN(parsed.valueOf()) ? parsed : null;
return [entry.name.replace(/\.md$/, ''), valid];
})
.filter(([, date]) => date !== null)
);
export default defineConfig({ export default defineConfig({
site: 'https://schmelczer.dev', site: 'https://schmelczer.dev',
trailingSlash: 'always', trailingSlash: 'always',

View file

@ -30,9 +30,6 @@
"bugs": { "bugs": {
"url": "https://github.com/schmelczer/schmelczer.github.io/issues" "url": "https://github.com/schmelczer/schmelczer.github.io/issues"
}, },
"browserslist": [
"defaults"
],
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme", "homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",

View file

@ -4,7 +4,11 @@ import path from 'node:path';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
const dist = path.resolve('dist'); const dist = path.resolve('dist');
const widths = [320, 390, 430, 768, 1024, 1440, 1920]; const INDEX_FILE = 'index.html';
const MAX_NAV_RETRIES = 3;
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
// iPad landscape / common laptop / full HD desktop.
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
const MIME = { const MIME = {
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
@ -50,10 +54,13 @@ async function discoverRoutes() {
if (!file.endsWith('.html')) continue; if (!file.endsWith('.html')) continue;
const rel = path.relative(dist, file).replaceAll(path.sep, '/'); const rel = path.relative(dist, file).replaceAll(path.sep, '/');
if (rel === '404.html') continue; if (rel === '404.html') continue;
if (rel.endsWith('/index.html')) { // /writing/* are meta-refresh redirect stubs to /articles/*, not real
routes.add('/' + rel.slice(0, -'index.html'.length)); // pages — measuring them would just remeasure /articles/.
} else if (rel === 'index.html') { if (rel.startsWith('writing/')) continue;
if (rel === INDEX_FILE) {
routes.add('/'); routes.add('/');
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
} else { } else {
routes.add('/' + rel.replace(/\.html$/, '/')); routes.add('/' + rel.replace(/\.html$/, '/'));
} }
@ -112,7 +119,7 @@ const browser = await chromium.launch({ headless: true });
const failures = []; const failures = [];
async function measureViewport(page) { async function measureViewport(page) {
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
try { try {
await page.waitForLoadState('load'); await page.waitForLoadState('load');
return await page.evaluate(() => ({ return await page.evaluate(() => ({
@ -121,7 +128,8 @@ async function measureViewport(page) {
})); }));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (attempt === 2 || !/Execution context was destroyed|navigation/i.test(message)) { const isLast = attempt === MAX_NAV_RETRIES - 1;
if (isLast || !/Execution context was destroyed|navigation/i.test(message)) {
throw error; throw error;
} }
await page.waitForLoadState('load').catch(() => {}); await page.waitForLoadState('load').catch(() => {});
@ -130,7 +138,7 @@ async function measureViewport(page) {
} }
try { try {
for (const width of widths) { for (const width of VIEWPORT_WIDTHS) {
const page = await browser.newPage({ const page = await browser.newPage({
viewport: { width, height: 900 }, viewport: { width, height: 900 },
javaScriptEnabled: false, javaScriptEnabled: false,
@ -138,11 +146,6 @@ try {
for (const route of routes) { for (const route of routes) {
await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' }); await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' });
if (route.startsWith('/writing/')) {
await page
.waitForURL((url) => url.pathname.startsWith('/articles/'), { timeout: 1000 })
.catch(() => {});
}
const result = await measureViewport(page); const result = await measureViewport(page);
if (result.scrollWidth > result.clientWidth + 1) { if (result.scrollWidth > result.clientWidth + 1) {
@ -165,5 +168,5 @@ if (failures.length > 0) {
} }
console.log( console.log(
`No horizontal overflow detected at ${widths.join(', ')}px across ${routes.length} routes.` `No horizontal overflow detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.`
); );

View file

@ -2,43 +2,43 @@
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro'; import EntryThumbnail from './EntryThumbnail.astro';
import TagList from './TagList.astro'; import TagList from './TagList.astro';
import { articlePath, formatDate, formatDateShort } from '../lib/site'; import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
interface Props { interface Props {
posts: CollectionEntry<'posts'>[]; posts: CollectionEntry<'posts'>[];
showYear?: boolean; showYear?: boolean;
currentTag?: string; currentTag?: string;
tagLimit?: number;
} }
const { posts, showYear = true, currentTag } = Astro.props; const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props;
--- ---
<ol class="article-list"> <ol class="article-list">
{ {
posts.map((post, index) => { posts.map((post, index) => {
const href = articlePath(post); const href = articlePath(post);
const isFirst = index === 0;
return ( return (
<li> <li>
<time datetime={post.data.date.toISOString()}>
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
</time>
<article> <article>
<a class="entry-title" href={href}> <a class="entry-title" href={href}>
{post.data.title} {post.data.title}
</a> </a>
<p>{post.data.description}</p> <p>{post.data.description}</p>
<TagList tags={post.data.tags} currentTag={currentTag} limit={3} /> <TagList tags={post.data.tags} currentTag={currentTag} limit={tagLimit} />
</article> </article>
<time datetime={post.data.date.toISOString()}>
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
</time>
<EntryThumbnail <EntryThumbnail
src={post.data.thumbnail.src} src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt} alt={post.data.thumbnail.alt}
href={href} href={href}
class="article-thumbnail" class="article-thumbnail"
widths={[120, 180, 240, 320, 480]} widths={ARTICLE_THUMBNAIL.widths}
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem" sizes={ARTICLE_THUMBNAIL.sizes}
loading={isFirst ? 'eager' : 'lazy'} loading={index === 0 ? 'eager' : 'lazy'}
fetchpriority={isFirst ? 'high' : undefined} fetchpriority={index === 0 ? 'high' : undefined}
/> />
</li> </li>
); );

View file

@ -11,7 +11,7 @@ interface Props {
scale?: string; scale?: string;
outcome?: string; outcome?: string;
links?: Link[]; links?: Link[];
headingId?: string; headingId: string;
} }
const { const {
@ -21,16 +21,15 @@ const {
scale, scale,
outcome, outcome,
links = [], links = [],
headingId = 'at-a-glance-heading', headingId,
} = Astro.props; } = Astro.props;
const rows: Array<[string, string]> = [ const rows: Array<[string, string]> = [];
['Role', role ?? ''], if (role) rows.push(['Role', role]);
['Period', projectPeriod ?? ''], if (projectPeriod) rows.push(['Period', projectPeriod]);
['Stack', stack.join(', ')], if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
['Scale', scale ?? ''], if (scale) rows.push(['Scale', scale]);
['Outcome', outcome ?? ''], if (outcome) rows.push(['Outcome', outcome]);
].filter((row): row is [string, string] => Boolean(row[1]));
--- ---
{ {

View file

@ -9,21 +9,25 @@ interface Props {
} }
const { items } = Astro.props; const { items } = Astro.props;
const last = items.length - 1; const lastIndex = items.length - 1;
--- ---
<nav aria-label="Breadcrumb"> <nav aria-label="Breadcrumb">
<ol class="breadcrumbs"> <ol class="breadcrumbs">
{ {
items.map((item, index) => ( items.map((item, index) => {
<li> const isLast = index === lastIndex;
{item.href && index !== last ? ( const isLink = item.href && !isLast;
<a href={item.href}>{item.label}</a> return (
) : ( <li>
<span aria-current={index === last ? 'page' : undefined}>{item.label}</span> {isLink ? (
)} <a href={item.href}>{item.label}</a>
</li> ) : (
)) <span aria-current={isLast ? 'page' : undefined}>{item.label}</span>
)}
</li>
);
})
} }
</ol> </ol>
</nav> </nav>

View file

@ -2,8 +2,6 @@
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
type FallbackFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | 'gif';
interface Props { interface Props {
src: ImageMetadata; src: ImageMetadata;
alt: string; alt: string;
@ -13,8 +11,11 @@ interface Props {
sizes: string; sizes: string;
loading?: 'lazy' | 'eager'; loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto'; fetchpriority?: 'high' | 'low' | 'auto';
// When the listing already has a focusable, screen-reader-visible title
// link, the thumbnail link is visually duplicative. We keep it clickable
// for pointer users but drop it from the tab order and announce no alt
// text, so assistive tech doesn't read the same target twice.
decorative?: boolean; decorative?: boolean;
fallbackFormat?: FallbackFormat;
} }
const { const {
@ -27,27 +28,22 @@ const {
loading = 'lazy', loading = 'lazy',
fetchpriority, fetchpriority,
decorative = true, decorative = true,
fallbackFormat,
} = Astro.props; } = Astro.props;
const Tag = href ? 'a' : 'div'; const Tag = href ? 'a' : 'div';
// Listing thumbnails are screenshots with no required transparency; force JPG
// fallback to avoid shipping multi-hundred-KB PNG derivatives.
const resolvedFallback: FallbackFormat = fallbackFormat ?? 'jpg';
const isDecorativeLink = Boolean(href) && decorative; const isDecorativeLink = Boolean(href) && decorative;
--- ---
<Tag <Tag
class:list={['entry-thumbnail', extraClass]} class:list={['entry-thumbnail', extraClass]}
href={href} href={href}
aria-hidden={isDecorativeLink ? 'true' : undefined}
tabindex={isDecorativeLink ? -1 : undefined} tabindex={isDecorativeLink ? -1 : undefined}
> >
<Picture <Picture
src={src} src={src}
alt={alt} alt={isDecorativeLink ? '' : alt}
formats={['avif', 'webp']} formats={['avif', 'webp']}
fallbackFormat={resolvedFallback} fallbackFormat="jpg"
widths={widths} widths={widths}
sizes={sizes} sizes={sizes}
loading={loading} loading={loading}

View file

@ -19,21 +19,11 @@ const footerNavItems = navItems.filter((item) => item.href !== '/');
} }
</ul> </ul>
</nav> </nav>
<ul class="footer-meta"> <address class="footer-meta">
<li><span>© {year} {site.name}</span></li> <span>© {year} {site.name}</span>
<li> <a href={`mailto:${site.email}`}>Email</a>
<address> <a href={site.cv} rel="noopener">CV</a>
<a href={`mailto:${site.email}`}>Email</a> <a href={site.github} rel="noopener me">GitHub</a>
</address> <a href={site.linkedin} rel="noopener me">LinkedIn</a>
</li> </address>
<li>
<a href={site.cv} rel="noopener">CV</a>
</li>
<li>
<a href={site.github} rel="noopener me">GitHub</a>
</li>
<li>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</li>
</ul>
</footer> </footer>

View file

@ -46,63 +46,46 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
</svg> </svg>
<span class="sr-only">RSS feed</span> <span class="sr-only">RSS feed</span>
</a> </a>
<button <button id="theme-switcher" class="theme-switcher" type="button">
id="theme-switcher"
class="theme-switcher"
type="button"
aria-label="Switch to dark theme"
aria-pressed="false"
>
<span class="sr-only">Toggle theme</span> <span class="sr-only">Toggle theme</span>
</button> </button>
</div> </div>
</header> </header>
<script is:inline data-theme-script> <script is:inline data-theme-script>
(() => { // Co-located with the button so the initial aria state is set as soon as the
var key = 'theme'; // button parses, avoiding a flash of the wrong icon. The theme itself is
var legacyKey = 'dark-mode'; // already on <html> from theme-init.js in <head>.
(function () {
var root = document.documentElement;
var switcher = document.getElementById('theme-switcher'); var switcher = document.getElementById('theme-switcher');
if (!switcher) return; if (!switcher) return;
function syncSwitcher(theme) { function sync(theme) {
switcher.setAttribute('aria-pressed', String(theme === 'dark')); switcher.setAttribute('aria-pressed', String(theme === 'dark'));
switcher.setAttribute( switcher.setAttribute(
'aria-label', 'aria-label',
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
); );
} }
sync(root.dataset.theme === 'dark' ? 'dark' : 'light');
var initial = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
syncSwitcher(initial);
var reduced = matchMedia('(prefers-reduced-motion: reduce)'); var reduced = matchMedia('(prefers-reduced-motion: reduce)');
function apply(theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
syncSwitcher(theme);
}
function runApply(theme) {
if (!reduced.matches && typeof document.startViewTransition === 'function') {
document.startViewTransition(function () {
apply(theme);
});
} else {
apply(theme);
}
}
switcher.addEventListener('click', function () { switcher.addEventListener('click', function () {
var currentTheme = var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
var next = currentTheme === 'dark' ? 'light' : 'dark';
try { try {
localStorage.setItem(key, next); localStorage.setItem('theme', next);
localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
} catch (e) {} } catch (e) {}
runApply(next); var run = function () {
root.dataset.theme = next;
root.style.colorScheme = next;
sync(next);
};
if (!reduced.matches && typeof document.startViewTransition === 'function') {
document.startViewTransition(run);
} else {
run();
}
}); });
})(); })();
</script> </script>

View file

@ -3,14 +3,13 @@ import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content'; import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro'; import EntryThumbnail from './EntryThumbnail.astro';
import ProjectLinks from './ProjectLinks.astro'; import ProjectLinks from './ProjectLinks.astro';
import { articlePath, projectAnchor } from '../lib/site'; import { PROJECT_THUMBNAIL, articlePath, projectAnchor } from '../lib/site';
interface Props { interface Props {
projects: CollectionEntry<'projects'>[]; projects: CollectionEntry<'projects'>[];
} }
const { projects } = Astro.props; const { projects } = Astro.props;
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
// The `essay` field is a `reference('posts')`, so when present it's always a // The `essay` field is a `reference('posts')`, so when present it's always a
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry. // `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
@ -30,8 +29,6 @@ for (const project of projects) {
const titleId = `${anchor}-title`; const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id); const essayHref = essayHrefs.get(project.id);
const primaryHref = essayHref ?? project.data.links[0]?.url; const primaryHref = essayHref ?? project.data.links[0]?.url;
const links: ProjectLink[] = project.data.links;
const isFirst = index === 0;
return ( return (
<li class="project-card" id={anchor}> <li class="project-card" id={anchor}>
@ -40,10 +37,10 @@ for (const project of projects) {
alt={project.data.thumbnail.alt} alt={project.data.thumbnail.alt}
href={primaryHref} href={primaryHref}
class="project-thumbnail" class="project-thumbnail"
widths={[240, 320, 480, 640, 800]} widths={PROJECT_THUMBNAIL.widths}
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem" sizes={PROJECT_THUMBNAIL.sizes}
loading={isFirst ? 'eager' : 'lazy'} loading={index === 0 ? 'eager' : 'lazy'}
fetchpriority={isFirst ? 'high' : undefined} fetchpriority={index === 0 ? 'high' : undefined}
/> />
<article class="project-card__summary"> <article class="project-card__summary">
<h3 id={titleId}> <h3 id={titleId}>
@ -58,7 +55,7 @@ for (const project of projects) {
<p class="project-meta"> <p class="project-meta">
{project.data.period} · {project.data.technologies.join(', ')} {project.data.period} · {project.data.technologies.join(', ')}
</p> </p>
{links.length > 0 && <ProjectLinks links={links} />} {project.data.links.length > 0 && <ProjectLinks links={project.data.links} />}
</article> </article>
</li> </li>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

View file

@ -7,9 +7,6 @@ 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'] tags: ['embedded', 'games', 'systems']
selected: true
featuredOrder: 5
project: ad-astra
role: Hardware and firmware author role: Hardware and firmware author
stack: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design'] stack: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
scale: 8-bit microcontroller, 8 MHz clock, 15-20 ms maximum frame times during gameplay scale: 8-bit microcontroller, 8 MHz clock, 15-20 ms maximum frame times during gameplay
@ -17,7 +14,6 @@ outcome: A working low-power handheld game engine and game built from the circui
audience: technical audience: technical
links: links:
- label: Source - label: Source
type: source
url: https://github.com/schmelczer/ad_astra url: https://github.com/schmelczer/ad_astra
media: media:
- type: video - type: video
@ -42,13 +38,13 @@ Those numbers made the project feel physical. Performance was not an abstract ta
The engine ran at 8 MHz on an 8-bit ALU. That meant the display driver and game loop had to avoid expensive generality. The engine ran at 8 MHz on an 8-bit ALU. That meant the display driver and game loop had to avoid expensive generality.
Even the programming model needed restraint. I wrote the firmware in C, but used a balance of structured and object-oriented ideas to keep game object behavior manageable without paying for a runtime that did not exist. Even the programming model needed restraint. I wrote the firmware in C, but used a balance of structured and object-oriented ideas to keep game object behaviour manageable without paying for a runtime that did not exist.
## Design ## Design
The display driver was the most performance-sensitive layer. I used SIMD-like techniques on the 8-bit ALU to process four pixels at once. That helped keep maximum frame times between 15 and 20 milliseconds during gameplay, so the lowest gameplay frame rate stayed above 50 FPS. The display driver was the most performance-sensitive layer. I used SIMD-like techniques on the 8-bit ALU to process four pixels at once. That helped keep maximum frame times between 15 and 20 milliseconds during gameplay, so the lowest gameplay frame rate stayed above 50 FPS.
For game objects, I used prototype-based inheritance. It was a pragmatic way to reuse behavior while keeping the implementation simple enough for the target. For game objects, I used prototype-based inheritance. It was a pragmatic way to reuse behaviour while keeping the implementation simple enough for the target.
Persistent state used the built-in EEPROM with an atomic commit approach. Sprite data also lived in EEPROM, and I wrote scripts to convert PNG sprites into C array definitions so assets could move into firmware cleanly. Persistent state used the built-in EEPROM with an atomic commit approach. Sprite data also lived in EEPROM, and I wrote scripts to convert PNG sprites into C array definitions so assets could move into firmware cleanly.

View file

@ -7,16 +7,13 @@ 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'] tags: ['games', 'web']
selected: false
project: avoid
role: Game author role: Game author
stack: ['JavaScript', 'Canvas'] stack: ['JavaScript', 'Canvas']
outcome: A small playable web game kept as an archive of early browser work outcome: A small playable web game kept as an archive of early browser work
audience: general audience: general
links: links:
- label: Demo - label: Demo
type: demo
url: https://schmelczer.dev/avoid url: https://schmelczer.dev/avoid
--- ---
I recently found my first-ever web game. It is incredibly simple, but I killed some time with it, so feel free to try it out and do not judge too harshly. I recently found my first web game. It is very simple, but I killed some time with it — feel free to try it out, and do not judge too harshly.

View file

@ -7,8 +7,6 @@ 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'] tags: ['simulation', 'systems']
selected: false
project: city-simulation
role: Simulation author role: Simulation author
stack: ['Unity', 'C#', 'REST API', 'Blender'] stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: A visual context for a PLC-focused cybersecurity challenge outcome: A visual context for a PLC-focused cybersecurity challenge
@ -20,8 +18,8 @@ I simulated a city where car crashes were more frequent than usual.
The state of the traffic lights could be changed through a REST API. Drivers followed the instructions of those lights, so if a mistake was made, collisions appeared in the simulation. There was also support for displaying tweets on a HUD. The state of the traffic lights could be changed through a REST API. Drivers followed the instructions of those lights, so if a mistake was made, collisions appeared in the simulation. There was also support for displaying tweets on a HUD.
The project was created as the context for a cybersecurity challenge about PLCs. With the help of this program, contestants could instantly see the effect of their work. The project was the context for a cybersecurity challenge about PLCs. Contestants could see the effect of their work immediately, as crashes.
An exciting aspect of the project was building it with a server-client architecture. Every decision of the agents was calculated server-side. The real challenge was broadcasting these decisions in a fault-tolerant way using minimal bandwidth. The architecture was server-client. Every decision of the agents was calculated server-side, and the real challenge was broadcasting those decisions in a fault-tolerant way on minimal bandwidth.
It was made with Unity using C# as the scripting language. I also made the models and animations in Blender. It was built in Unity with C# as the scripting language. I also made the models and animations in Blender.

View file

@ -7,9 +7,6 @@ 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'] tags: ['games', 'web', 'systems']
selected: true
featuredOrder: 4
project: declared
role: Game and backend systems author role: Game and backend systems author
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL'] stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
scale: Multiple servers, each communicating with 16-32 clients scale: Multiple servers, each communicating with 16-32 clients
@ -17,13 +14,10 @@ outcome: A mobile-capable online browser game built on top of SDF-2D
audience: technical audience: technical
links: links:
- label: Source - label: Source
type: source
url: https://github.com/schmelczer/decla.red url: https://github.com/schmelczer/decla.red
- label: Demo - label: Demo
type: demo
url: https://decla.red url: https://decla.red
- label: BSc thesis - label: BSc thesis
type: thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true download: true
media: media:
@ -55,7 +49,7 @@ The key decision was a shared library for game logic. Both the client and server
That reduced a common source of bugs: the client and server disagreeing about the meaning of an action. It also made client-side prediction more realistic, because the client was not approximating a different system. That reduced a common source of bugs: the client and server disagreeing about the meaning of an action. It also made client-side prediction more realistic, because the client was not approximating a different system.
As the game logic became heavier, spatial operations needed attention. I implemented k-d trees to reduce the cost of queries over objects in the world. For the object model, I borrowed ideas from message passing, including a version of the Smalltalk-style `messageNotUnderstood` pattern, to keep behavior extensible without pushing every entity into a brittle inheritance tree. As the game logic became heavier, spatial operations needed attention. I implemented k-d trees to reduce the cost of queries over objects in the world. For the object model, I borrowed ideas from message passing, including a version of the Smalltalk-style `messageNotUnderstood` pattern, to keep behaviour extensible without pushing every entity into a brittle inheritance tree.
## What Worked ## What Worked
@ -65,6 +59,6 @@ The project also validated SDF-2D outside a toy environment. A rendering library
## What I Would Change ## What I Would Change
I would now spend more effort on observability for synchronization and prediction errors. Multiplayer systems need good visibility into divergence. Without that, debugging becomes a sequence of guesses. I would now spend more effort on observability for synchronisation and prediction errors. Multiplayer systems need good visibility into divergence. Without that, debugging becomes a sequence of guesses.
I would also separate the story of rendering and networking more clearly in the codebase. Both were interesting, but they put different kinds of pressure on the architecture. I would also separate the story of rendering and networking more clearly in the codebase. Both were interesting, but they put different kinds of pressure on the architecture.

View file

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

View file

@ -7,8 +7,6 @@ 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'] tags: ['systems', 'tools']
selected: false
project: forex
role: Experiment author role: Experiment author
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4'] stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
outcome: A working prediction server connected to an MQL4 client for trading experiments outcome: A working prediction server connected to an MQL4 client for trading experiments
@ -16,10 +14,10 @@ audience: technical
links: [] links: []
--- ---
This was an experiment in predicting EUR/USD rates. The animation from the old portfolio showed the implementation doing a somewhat good job predicting the rates: the prediction was the blue graph and the actual values were the green chart. Of course, I would not trust it with my money. This was an experiment in predicting EUR/USD rates. The animation from the old portfolio showed the implementation doing a passable job: the prediction was the blue graph and the actual values were the green one. I would not have trusted it with my money.
The algorithm was a fancy linear regression in the frequency domain. The steps were: smoothing the input values, differentiating, applying a short-time Fourier transformation with overlapped and Hanning-windowed windows, extrapolating, and then applying the inverse of these transformations to the resulting values. The algorithm was a fancy linear regression in the frequency domain. The steps were: smoothing the input values, differentiating, applying a short-time Fourier transformation with overlapped and Hanning-windowed windows, extrapolating, and then applying the inverse of these transformations to the resulting values.
The prediction server was written in Python using NumPy, SciPy, and Flask. It communicated with an MQL4 client that was responsible for handling financial transactions based on the generated data. The prediction server was written in Python using NumPy, SciPy, and Flask. It communicated with an MQL4 client that was responsible for handling financial transactions based on the generated data.
There was still plenty of room for improvement, but even with this simple algorithm, a sometimes profitable trading strategy was viable. More importantly, the project gave me a useful look into trading algorithms, their complexity, and the fierce competition around them. There was still plenty of room for improvement, but even with this simple algorithm, a sometimes profitable strategy was viable. The project was mostly a look into trading algorithms, their complexity, and the competition around them.

View file

@ -7,8 +7,6 @@ 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'] tags: ['simulation', 'tools']
selected: false
project: nuclear-editor
role: Editor author role: Editor author
stack: ['JavaFX', 'JSON', 'REST API'] stack: ['JavaFX', 'JSON', 'REST API']
outcome: An editor for building input graphs and sending them to the simulation backend outcome: An editor for building input graphs and sending them to the simulation backend
@ -16,8 +14,8 @@ audience: technical
links: [] links: []
--- ---
This was an intuitive editor to create and edit input for the nuclear facility simulator. This was a small editor for building input graphs for the cooling system simulator.
Nodes could be moved with drag and drop gestures. Editing the parameters of elements was done on the right panel. Nodes could be moved with drag-and-drop gestures. Element parameters were edited on the right panel.
The UI was built with JavaFX. The output could be exported as JSON or directly uploaded to the simulation backend. The UI was built with JavaFX. The output could be exported as JSON or uploaded directly to the simulation backend.

View file

@ -7,9 +7,7 @@ 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'] tags: ['ai', 'systems', 'tools']
selected: true
featuredOrder: 1 featuredOrder: 1
project: great-ai
role: Researcher and framework author role: Researcher and framework author
stack: ['Python', 'ML deployment', 'API design'] stack: ['Python', 'ML deployment', 'API design']
scale: 33 deployment best practices, six proposed additions, evaluated with professional data scientists and software engineers scale: 33 deployment best practices, six proposed additions, evaluated with professional data scientists and software engineers
@ -17,13 +15,10 @@ outcome: A Python framework, thesis, and research-backed API design for producti
audience: recruiter-relevant audience: recruiter-relevant
links: links:
- label: PyPI - label: PyPI
type: package
url: https://pypi.org/project/great-ai/ url: https://pypi.org/project/great-ai/
- label: Project site - label: Project site
type: site
url: https://great-ai.scoutinscience.com url: https://great-ai.scoutinscience.com
- label: MSc thesis - label: MSc thesis
type: thesis
url: /media/downloads/great-ai-andras-schmelczer.pdf url: /media/downloads/great-ai-andras-schmelczer.pdf
download: true download: true
media: media:
@ -33,15 +28,15 @@ media:
caption: GreatAI's public surface was designed to keep deployment best practices close to the application code. caption: GreatAI's public surface was designed to keep deployment best practices close to the application code.
--- ---
GreatAI started from a practical frustration: applying machine learning was becoming easier, but deploying it well was still easy to get wrong. Many failures were not about model architecture. They were about missing metadata, weak versioning, poor reproducibility, untracked inputs, or interfaces that made the right behavior too cumbersome to use. GreatAI started from a practical frustration: applying machine learning was becoming easier, but deploying it well was still easy to get wrong. Many failures were not about model architecture. They were about missing metadata, weak versioning, poor reproducibility, untracked inputs, or interfaces that made the right behaviour too cumbersome to use.
My thesis work looked at that gap from two sides. First, I collected and organized AI/ML deployment best practices, including 33 practices and six additions proposed through the research. Then I designed a Python framework that tried to make those practices feel like the natural path rather than an enterprise checklist. My thesis work looked at that gap from two sides. First, I collected and organised AI/ML deployment best practices, including 33 practices and six additions proposed through the research. Then I designed a Python framework that tried to make those practices feel like the natural path rather than an enterprise checklist.
The result was GreatAI: a deployment-oriented framework with a deliberately small API. The design goal was not to wrap every part of an ML stack. It was to make common deployment concerns visible, automatic where possible, and hard to forget. The result was GreatAI: a deployment-oriented framework with a deliberately small API. The design goal was not to wrap every part of an ML stack. It was to make common deployment concerns visible, automatic where possible, and hard to forget.
## The Problem ## The Problem
Deployment quality is often treated as something that happens after model development. That separation creates a bad default. A model can be useful in a notebook, but a deployed AI service also needs traceability, stable interfaces, input/output logging, model metadata, and operational behavior that can be inspected later. Deployment quality is often treated as something that happens after model development. That separation creates a bad default. A model can be useful in a notebook, but a deployed AI service also needs traceability, stable interfaces, input/output logging, model metadata, and operational behaviour that can be inspected later.
The hard part is not listing those needs. The hard part is getting busy engineers and data scientists to adopt them without making their work feel slower. The hard part is not listing those needs. The hard part is getting busy engineers and data scientists to adopt them without making their work feel slower.
@ -51,15 +46,15 @@ So the core question became: can a framework implement meaningful deployment pra
GreatAI had to satisfy two constraints that usually pull in opposite directions. GreatAI had to satisfy two constraints that usually pull in opposite directions.
It needed to be robust enough to encode deployment practices such as metadata handling, model loading, request tracing, and reproducible prediction interfaces. But it also needed to be approachable enough that the basic use case still looked like ordinary Python. It needed to encode deployment practices such as metadata handling, model loading, request tracing, and reproducible prediction interfaces. It also had to be approachable enough that the basic use case still looked like ordinary Python.
That shaped the API. The framework could not demand a new mental model for every project. The deployment behavior had to sit close to the prediction function, because that is where the developer already has context. That shaped the API. The framework could not demand a new mental model for every project. The deployment behaviour had to sit close to the prediction function, because that is where the developer already has context.
## Design ## Design
The design leaned on decorators and lightweight conventions. The application author should be able to declare the prediction boundary, attach the relevant model and metadata behavior, and let the framework handle repeated operational concerns. The design leaned on decorators and lightweight conventions. The application author should be able to declare the prediction boundary, attach the relevant model and metadata behaviour, and let the framework handle repeated operational concerns.
That is a careful tradeoff. Too much implicit behavior makes systems difficult to debug. Too much explicit setup makes best practices optional in practice, because the path of least resistance is to skip them. GreatAI tried to keep the implicit parts focused on cross-cutting deployment concerns rather than business logic. That is a careful tradeoff. Too much implicit behaviour makes systems difficult to debug. Too much explicit setup makes best practices optional in practice, because the path of least resistance is to skip them. GreatAI tried to keep the implicit parts focused on cross-cutting deployment concerns rather than business logic.
Feedback from professional data scientists and software engineers supported the main premise: ease of use and functionality both matter when people decide whether to adopt deployment tooling. A framework that is technically complete but awkward to use will still fail. Feedback from professional data scientists and software engineers supported the main premise: ease of use and functionality both matter when people decide whether to adopt deployment tooling. A framework that is technically complete but awkward to use will still fail.
@ -73,4 +68,4 @@ The research also forced the framework to be specific. "Production-ready" is too
If I returned to the project now, I would focus more on integration boundaries: how GreatAI should fit into modern observability, model registry, and evaluation workflows without trying to own them. Deployment frameworks age quickly when they become too broad. If I returned to the project now, I would focus more on integration boundaries: how GreatAI should fit into modern observability, model registry, and evaluation workflows without trying to own them. Deployment frameworks age quickly when they become too broad.
The part I would keep is the central idea: make the right deployment behavior easy enough that it becomes the default. The part I would keep is the central idea: make the right deployment behaviour easy enough that it becomes the default.

View file

@ -1,43 +1,39 @@
--- ---
title: Syncing State with Immutable Tries title: Syncing State with Immutable Tries
description: How a multi-device life tracking project used trie structure to diff, reconcile, and synchronize goal state. description: How a multi-device life tracking project used trie structure to diff, reconcile, and synchronise goal state.
date: 2026-05-05 date: 2026-05-05
projectPeriod: 'August-September 2019' projectPeriod: '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'] tags: ['systems', 'web', 'tools']
selected: true featuredOrder: 4
featuredOrder: 3
project: towers
role: Full-stack author role: Full-stack author
stack: ['Python', 'Angular', 'State synchronization'] stack: ['Python', 'Angular', 'State synchronisation']
scale: Multi-device goal and task state shared between clients and a server scale: Multi-device goal and task state shared between clients and a server
outcome: A working synchronization model built around immutable trie properties outcome: A working synchronisation model built around immutable trie properties
audience: recruiter-relevant audience: recruiter-relevant
links: links:
- label: Source - label: Source
type: source
url: https://github.com/schmelczer/life-towers/ url: https://github.com/schmelczer/life-towers/
- label: Demo - label: Demo
type: demo
url: https://towers.schmelczer.dev url: https://towers.schmelczer.dev
media: media:
- type: image - type: image
src: ./_assets/towers.jpg src: ./_assets/towers.jpg
alt: Screenshot of a life tracking web interface represented with tower-like visual structures. alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
caption: The visual idea was simple; the useful lesson was the synchronization model behind it. caption: The visual idea was simple; the useful lesson was the synchronisation model behind it.
--- ---
Life Towers was a multi-device goal and task tracker with an intentionally visual interface. The surface idea was an aesthetic representation of previous and current goals. The more interesting part was synchronizing state across clients without sending more data than necessary. Life Towers was a multi-device goal and task tracker with an intentionally visual interface. The surface idea was an aesthetic representation of previous and current goals. The more interesting part was synchronising state across clients without sending more data than necessary.
This was not a large distributed system, but it had a real version of a common problem: clients and server drift apart, and the system needs a compact way to compare, reconcile, and update. This was not a large distributed system, but it had a real version of a common problem: clients and server drift apart, and the system needs a compact way to compare, reconcile, and update.
## The Problem ## The Problem
If a task model is stored as an ordinary mutable object graph, synchronizing it often becomes a choice between sending too much data or writing complicated ad hoc diff logic. If a task model is stored as an ordinary mutable object graph, synchronising it often becomes a choice between sending too much data or writing complicated ad hoc diff logic.
I wanted a structure where the shape of the data made synchronization easier. The client should be able to compare its state with the server's state, find a difference, reconcile it, and send only the delta. I wanted a structure where the shape of the data made synchronisation easier. The client should be able to compare its state with the server's state, find a difference, reconcile it, and send only the delta.
## Design ## Design
@ -45,7 +41,7 @@ I used a trie. A trie made the hierarchical shape explicit, and its properties m
The immutable nature of the structure simplified much of the logic. Instead of mutating arbitrary branches in place, updates could produce new structure with shared unchanged parts. That made reconciliation easier to reason about and reduced the amount of data that needed to move across the network. The immutable nature of the structure simplified much of the logic. Instead of mutating arbitrary branches in place, updates could produce new structure with shared unchanged parts. That made reconciliation easier to reason about and reduced the amount of data that needed to move across the network.
The project also gave me a reason to deepen my Python and Angular knowledge, but the synchronization structure was the main lesson. The project also gave me a reason to deepen my Python and Angular knowledge, but the synchronisation structure was the main lesson.
## What Worked ## What Worked
@ -55,6 +51,6 @@ The other useful lesson was that visual products still need a strong internal mo
## What I Would Change ## What I Would Change
Today I would document the sync protocol more formally and add property-based tests around reconciliation. Synchronization code is exactly the kind of code that benefits from generated edge cases. Today I would document the sync protocol more formally and add property-based tests around reconciliation. Synchronisation code is exactly the kind of code that benefits from generated edge cases.
I would also separate the visual experiment from the state synchronization story more explicitly. The latter is the part that aged better. I would also separate the visual experiment from the state synchronisation story more explicitly. The latter is the part that aged better.

View file

@ -1,14 +1,12 @@
--- ---
title: Lights Synchronized to Music title: Lights Synchronized to Music
description: A Raspberry Pi music player that analyzed audio output and drove RGB LED strips. description: A Raspberry Pi music player that analysed audio output and drove RGB LED strips.
date: 2026-04-26 date: 2026-04-26
projectPeriod: 'Spring 2016' projectPeriod: 'Spring 2016'
thumbnail: thumbnail:
src: ./_assets/leds.jpg src: ./_assets/leds.jpg
alt: RGB LED strips lit by a music synchronization project. alt: RGB LED strips lit by a music synchronisation project.
tags: ['systems', 'tools'] tags: ['systems', 'tools']
selected: false
project: leds
role: Hardware and software author role: Hardware and software author
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web'] stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
outcome: My first finished non-trivial project, combining a web UI, audio processing, and hardware output outcome: My first finished non-trivial project, combining a web UI, audio processing, and hardware output
@ -16,8 +14,8 @@ audience: technical
links: [] links: []
--- ---
This was a full-stack application with a built-in music player, the output of which controlled the colour of a couple of RGB LED strips through a Raspberry Pi and some MOSFETs. A Raspberry Pi ran a small music player, and the audio it produced drove the colour of a couple of RGB LED strips through some MOSFETs.
It was my first non-trivial project that got finished. Obviously, it was rather far from perfect, but I am still proud that I was able to build it on my own. It was the first non-trivial project I actually finished. Far from perfect, but I am still proud that I built it on my own.
The backend logic was written in Python, and the FFT implementation was provided by NumPy. I also built a simple frontend for accessing the music player and changing the settings using vanilla web development technologies. The backend was Python, with NumPy doing the FFT. The frontend was a vanilla web page for picking tracks and tweaking settings.

View file

@ -7,20 +7,17 @@ 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'] tags: ['tools']
selected: false
project: my-notes
role: Android app author role: Android app author
stack: ['Android', 'Markdown', 'Markwon'] stack: ['Android', 'Markdown', 'Markwon']
outcome: A functional markdown note organizer and a first exposure to Android development outcome: A functional markdown note organiser and a first exposure to Android development
audience: technical audience: technical
links: links:
- label: Source - label: Source
type: source
url: https://github.com/schmelczer/my-notes url: https://github.com/schmelczer/my-notes
--- ---
My Notes was a minimalist Android note organizer and editor powered by Markwon. My Notes was a small Android note organiser and editor built on top of Markwon.
It was a basic app for creating and filtering markdown notes based on hashtags. It was also my first exposure to Android development. It let me create Markdown notes and filter them by hashtag. It was also my first exposure to Android development.
All in all, it was not a unique idea, but it was functional. It also exposed me to a wildly different paradigm than I was used to from full-stack web development, which made the project worthwhile. The idea was not new, but the app worked, and the platform was different enough from the full-stack web work I had been doing that the project was worth finishing.

View file

@ -7,9 +7,7 @@ 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'] tags: ['simulation', 'systems', 'tools']
selected: true featuredOrder: 5
featuredOrder: 6
project: nuclear-simulation
role: Simulation and UI author role: Simulation and UI author
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX'] stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
scale: Remote simulation server with multiple monitoring clients and a separate graph editor scale: Remote simulation server with multiple monitoring clients and a separate graph editor
@ -35,7 +33,7 @@ The simulation did not try to be physically complete. It aimed to be cheaply cal
The simulated system needed reactors, coolers, pumps, heat exchangers, drains, sources, and pipes. Those elements had to be configurable, and multiple monitoring clients needed to update in real time from a remote server. The simulated system needed reactors, coolers, pumps, heat exchangers, drains, sources, and pipes. Those elements had to be configurable, and multiple monitoring clients needed to update in real time from a remote server.
The key challenge was representing flow and temperature in a way that was simple enough to calculate repeatedly but structured enough to produce plausible behavior. The key challenge was representing flow and temperature in a way that was simple enough to calculate repeatedly but structured enough to produce plausible behaviour.
## Design ## Design
@ -47,12 +45,12 @@ Python handled the backend logic with Flask and NumPy. The monitoring frontend u
## What Worked ## What Worked
The graph/matrix split was a useful modeling boundary. Flow and heat exchange are related, but treating them as separate calculation phases kept the implementation easier to reason about. The graph/matrix split was a useful modelling boundary. Flow and heat exchange are related, but treating them as separate calculation phases kept the implementation easier to reason about.
The editor also mattered. A simulation is much more useful when its input is inspectable and editable by people who are not editing source files. The editor also mattered. A simulation is much more useful when its input is inspectable and editable by people who are not editing source files.
## What I Would Change ## What I Would Change
Today I would formalize the model limitations more clearly. A convincing simulation can be useful, but it should say exactly what it does and does not claim. Today I would formalise the model limitations more clearly. A convincing simulation can be useful, but it should say exactly what it does and does not claim.
I would also add recorded scenarios and regression tests. Simulation projects are vulnerable to accidental behavior changes that still look plausible on screen. I would also add recorded scenarios and regression tests. Simulation projects are vulnerable to accidental behaviour changes that still look plausible on screen.

View file

@ -7,8 +7,6 @@ thumbnail:
src: ./_assets/photo-colour-grader.jpg src: ./_assets/photo-colour-grader.jpg
alt: Screenshot of a photo colour grading interface. alt: Screenshot of a photo colour grading interface.
tags: ['graphics', 'web', 'tools'] tags: ['graphics', 'web', 'tools']
selected: false
project: colors
role: Interface and image processing author role: Interface and image processing author
stack: ['JavaScript', 'Canvas', 'Image processing'] stack: ['JavaScript', 'Canvas', 'Image processing']
outcome: A proof-of-concept colour grading interaction model outcome: A proof-of-concept colour grading interaction model
@ -16,10 +14,8 @@ audience: technical
links: [] links: []
--- ---
This was an innovative, or at least I thought so at the time, colour grader web application. This was a colour grader web application I built as a proof-of-concept to try out a few interaction ideas.
The most noteworthy feature of the application was the colour selector UI. The program was only intended as a proof-of-concept. I wanted to experiment with a few interaction ideas, and this was the outcome. The main feature was the colour selector UI. The core idea was that you could select some colours and then apply transformations to other colours as a function of their distance to the selected colour.
The core idea was that you could select some colours and then apply transformations to other colours as a function of their distance to the selected colour. Clicking a coloured circle let you change its settings. New circles could be created by clicking inside the large circle, and they could be moved with drag and drop.
By clicking on a coloured circle, you could change its settings. New circles could be created by clicking inside the large circle, and they could also be moved with drag and drop.

View file

@ -7,20 +7,17 @@ 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'] tags: ['web', 'tools']
selected: false
project: photos
role: Site generator author role: Site generator author
stack: ['Webpack', 'Image processing', 'Static site generation'] stack: ['Webpack', 'Image processing', 'Static site generation']
outcome: A generated static photo site for publishing photography with responsive image output outcome: A generated static photo site for publishing photography with responsive image output
audience: general audience: general
links: links:
- label: Site - label: Site
type: site
url: https://photo.schmelczer.dev url: https://photo.schmelczer.dev
--- ---
Photos was a simple webpage where you could view my photos. Photos was a small webpage where you could view my photos.
Taking time to appreciate the world around us fills me with joy. That is why I like to go on walks with a camera. I might not end up with great photos. Nonetheless, I usually end up with some inspiration regarding my current or next project. Taking time to appreciate the world around us fills me with joy, which is why I like to go on walks with a camera. I might not end up with great photos, but I usually come back with some inspiration for the current or next project.
As for the webpage, a Webpack script generated the site from the photos in a directory. Automatic resizing to multiple quality settings was also part of the pipeline. The site itself was generated by a Webpack script from a directory of images. Automatic resizing to multiple quality settings was part of the pipeline.

View file

@ -1,5 +1,5 @@
--- ---
title: 'A 3D Platform Game in C and SDL 1.2' title: A 3D Platform Game in C and SDL 1.2
description: 'My first proper project: a 3D game with random maps, destructible voxels, enemies, powerups, and time slowdown.' description: 'My first proper project: a 3D game with random maps, destructible voxels, enemies, powerups, and time slowdown.'
date: 2026-04-28 date: 2026-04-28
projectPeriod: 'Autumn 2017' projectPeriod: 'Autumn 2017'
@ -7,8 +7,6 @@ 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'] tags: ['games', 'systems']
selected: false
project: platform-game
role: Game author role: Game author
stack: ['C', 'SDL 1.2', 'Voxel terrain'] stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable 3D course project that made programming feel like the right long-term direction outcome: A playable 3D course project that made programming feel like the right long-term direction
@ -16,10 +14,10 @@ audience: technical
links: [] links: []
--- ---
This was my first proper project. I created an actually fun 3D game written in pure C with the help of SDL 1.2. This was my first proper project: a 3D game written in pure C on top of SDL 1.2.
The maps were randomly generated and fully destroyable voxel by voxel. That also allowed the player to create structures for hiding from flying enemies, which chased the player and could destroy the terrain after merging together and growing larger. The maps were randomly generated and destructible voxel by voxel. That let the player build structures to hide from flying enemies, which chased the player and could destroy the terrain after merging together and growing larger.
After collecting enough powerups, the player could shoot and even slow down time in exchange for losing some points. After collecting enough powerups, the player could shoot and even slow down time, in exchange for losing some points.
I did this as my final project for my Basics of Programming course. Through making it, I learned a lot about pointers after an adequate number of segmentation faults. It also made me realize my passion for programming. I built it as the final project for my Basics of Programming course. I learned a lot about pointers after an adequate number of segmentation faults, and it was the project that convinced me programming was the right long-term direction.

View file

@ -7,9 +7,7 @@ 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'] tags: ['systems', 'tools', 'web']
selected: true
featuredOrder: 2 featuredOrder: 2
project: reconcile
role: Author role: Author
stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen'] stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen']
scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin
@ -17,19 +15,14 @@ outcome: A small, well-tested library that fills a gap between git, CRDTs, and p
audience: recruiter-relevant audience: recruiter-relevant
links: links:
- label: Demo - label: Demo
type: demo
url: https://schmelczer.dev/reconcile url: https://schmelczer.dev/reconcile
- label: Source - label: Source
type: source
url: https://github.com/schmelczer/reconcile url: https://github.com/schmelczer/reconcile
- label: crates.io - label: crates.io
type: package
url: https://crates.io/crates/reconcile-text url: https://crates.io/crates/reconcile-text
- label: npm - label: npm
type: package
url: https://www.npmjs.com/package/reconcile-text url: https://www.npmjs.com/package/reconcile-text
- label: PyPI - label: PyPI
type: package
url: https://pypi.org/project/reconcile-text/ url: https://pypi.org/project/reconcile-text/
media: media:
- type: image - type: image

View file

@ -7,9 +7,7 @@ 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'] tags: ['graphics', 'web', 'systems']
selected: true featuredOrder: 3
featuredOrder: 2
project: sdf-2d
role: Library author role: Library author
stack: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields'] stack: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
scale: Browser library with mobile-oriented real-time rendering and reusable demos scale: Browser library with mobile-oriented real-time rendering and reusable demos
@ -17,16 +15,12 @@ outcome: Reusable NPM package and thesis project for efficient 2D SDF rendering
audience: recruiter-relevant audience: recruiter-relevant
links: links:
- label: NPM package - label: NPM package
type: package
url: https://www.npmjs.com/package/sdf-2d url: https://www.npmjs.com/package/sdf-2d
- label: Demo - label: Demo
type: demo
url: https://sdf2d.schmelczer.dev url: https://sdf2d.schmelczer.dev
- label: Video - label: Video
type: video
url: https://www.youtube.com/watch?v=K3cEtnZUNR0 url: https://www.youtube.com/watch?v=K3cEtnZUNR0
- label: BSc thesis - label: BSc thesis
type: thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true download: true
media: media:

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

View file

@ -0,0 +1,18 @@
---
sourceProjectId: fleeting-garden
title: Fleeting Garden
description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down.
thumbnail:
src: ./_assets/fleeting-garden.jpg
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
period: '2026'
sortDate: 2026-05-01
technologies: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Physarum']
selected: true
essay: fleeting-garden-webgpu-drawing
links:
- label: Demo
url: https://schmelczer.dev/fleeting/
- label: Source
url: https://github.com/schmelczer/webgpu
---

View file

@ -1,5 +1,5 @@
--- ---
sourceProjectId: nuclear sourceProjectId: nuclear-simulation
title: Cooling System Simulation title: Cooling System Simulation
description: A graph-based process simulation with a monitoring client and JavaFX input editor. description: A graph-based process simulation with a monitoring client and JavaFX input editor.
thumbnail: thumbnail:

View file

@ -1,5 +1,5 @@
--- ---
sourceProjectId: sdf2d sourceProjectId: sdf-2d
title: SDF-2D title: SDF-2D
description: A browser rendering library for optimized 2D ray tracing with signed distance fields. description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
thumbnail: thumbnail:

View file

@ -61,6 +61,19 @@ if (!resolvedOgImage) {
const ogImageUrl = resolvedOgImage.startsWith('http') const ogImageUrl = resolvedOgImage.startsWith('http')
? resolvedOgImage ? resolvedOgImage
: absoluteUrl(resolvedOgImage); : absoluteUrl(resolvedOgImage);
const ogImageExt = ogImageUrl
.match(/\.(png|webp|gif|svg|jpe?g)(?:\?|$)/i)?.[1]
?.toLowerCase();
const ogImageType =
ogImageExt === 'png'
? 'image/png'
: ogImageExt === 'webp'
? 'image/webp'
: ogImageExt === 'gif'
? 'image/gif'
: ogImageExt === 'svg'
? 'image/svg+xml'
: 'image/jpeg';
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
// Head meta tags built as a single HTML string so prettier-plugin-astro // Head meta tags built as a single HTML string so prettier-plugin-astro
@ -103,7 +116,7 @@ const headHtml = [
`<meta property="og:description" content="${attr(description)}">`, `<meta property="og:description" content="${attr(description)}">`,
`<meta property="og:url" content="${attr(canonical)}">`, `<meta property="og:url" content="${attr(canonical)}">`,
`<meta property="og:image" content="${attr(ogImageUrl)}">`, `<meta property="og:image" content="${attr(ogImageUrl)}">`,
`<meta property="og:image:type" content="image/jpeg">`, `<meta property="og:image:type" content="${ogImageType}">`,
`<meta property="og:image:alt" content="${attr(ogImageAlt)}">`, `<meta property="og:image:alt" content="${attr(ogImageAlt)}">`,
`<meta property="og:image:width" content="${resolvedOgWidth}">`, `<meta property="og:image:width" content="${resolvedOgWidth}">`,
`<meta property="og:image:height" content="${resolvedOgHeight}">`, `<meta property="og:image:height" content="${resolvedOgHeight}">`,

View file

@ -12,7 +12,7 @@ const { title, description } = Astro.props;
<header class="page-header"> <header class="page-header">
<slot name="breadcrumbs" /> <slot name="breadcrumbs" />
<h1>{title}</h1> <h1>{title}</h1>
<p>{description}</p> {description && <p>{description}</p>}
</header> </header>
<slot /> <slot />
</div> </div>

View file

@ -159,6 +159,18 @@ export function buildPersonJsonLd(extra?: Record<string, unknown>) {
}; };
} }
// Responsive image config shared by entry listings. Centralized here so a
// change to one breakpoint set is a single edit, not two component changes.
export const ARTICLE_THUMBNAIL = {
widths: [120, 180, 240, 320, 480],
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
};
export const PROJECT_THUMBNAIL = {
widths: [240, 320, 480, 640, 800],
sizes: '(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem',
};
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by // Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
// Base.astro for the default OG image and by Post.astro for per-post // Base.astro for the default OG image and by Post.astro for per-post
// thumbnails. Keeps OG output consistent across the site. // thumbnails. Keeps OG output consistent across the site.

View file

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

View file

@ -10,11 +10,13 @@ import {
} from '../lib/site'; } from '../lib/site';
import defaultOg from '../assets/og-default.jpg'; import defaultOg from '../assets/og-default.jpg';
const STARTING_POINTS = 4;
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const startingPoints = posts const startingPoints = posts
.filter((post) => post.data.audience === 'recruiter-relevant') .filter((post) => post.data.audience === 'recruiter-relevant')
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99)) .sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
.slice(0, 4); .slice(0, STARTING_POINTS);
const personImage = await optimizeOgImage(defaultOg); const personImage = await optimizeOgImage(defaultOg);

View file

@ -14,6 +14,9 @@ import {
yearOf, yearOf,
} from '../../lib/site'; } from '../../lib/site';
const description =
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))]; const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
const tags = getAllTags(posts); const tags = getAllTags(posts);
@ -28,8 +31,7 @@ const blogJsonLd = {
'@type': 'Blog', '@type': 'Blog',
name: `${site.name} — Articles`, name: `${site.name} — Articles`,
url: absoluteUrl('/articles/'), url: absoluteUrl('/articles/'),
description: description,
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
publisher: { '@id': personId }, publisher: { '@id': personId },
blogPost: posts.map((post, index) => ({ blogPost: posts.map((post, index) => ({
'@type': 'BlogPosting', '@type': 'BlogPosting',
@ -48,11 +50,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles:
const jsonLd = [blogJsonLd, breadcrumbJsonLd]; const jsonLd = [blogJsonLd, breadcrumbJsonLd];
--- ---
<Page <Page title="Articles" description={description} jsonLd={jsonLd}>
title="Articles"
description="Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools."
jsonLd={jsonLd}
>
<nav id="tags" class="tag-filter" aria-label="Browse by tag"> <nav id="tags" class="tag-filter" aria-label="Browse by tag">
<span>Browse by tag</span> <span>Browse by tag</span>
<TagList tags={tags} /> <TagList tags={tags} />

View file

@ -10,8 +10,10 @@ import {
getPublishedPosts, getPublishedPosts,
} from '../lib/site'; } from '../lib/site';
const LATEST_ARTICLES = 5;
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const latestPosts = posts.slice(0, 5); const latestPosts = posts.slice(0, LATEST_ARTICLES);
const projects = await getProjects(); const projects = await getProjects();
const selectedProjects = projects.filter((project) => project.data.selected); const selectedProjects = projects.filter((project) => project.data.selected);
const tags = getAllTags(posts); const tags = getAllTags(posts);

View file

@ -9,6 +9,9 @@ import {
site, site,
} from '../../lib/site'; } from '../../lib/site';
const description =
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.';
const projects = await getProjects(); const projects = await getProjects();
const selected = projects.filter((project) => project.data.selected); const selected = projects.filter((project) => project.data.selected);
const older = projects.filter((project) => !project.data.selected); const older = projects.filter((project) => !project.data.selected);
@ -18,8 +21,7 @@ const collectionJsonLd = {
'@type': 'CollectionPage', '@type': 'CollectionPage',
name: `${site.name} — Projects`, name: `${site.name} — Projects`,
url: absoluteUrl('/projects/'), url: absoluteUrl('/projects/'),
description: description,
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
}; };
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true })); const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
@ -27,11 +29,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects:
const jsonLd = [collectionJsonLd, breadcrumbJsonLd]; const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
--- ---
<Page <Page title="Projects" description={description} jsonLd={jsonLd}>
title="Projects"
description="A compact index of systems, tools, simulations, graphics experiments, games, and older work."
jsonLd={jsonLd}
>
<section class="project-section"> <section class="project-section">
<h2 id="selected-projects">Selected Projects</h2> <h2 id="selected-projects">Selected Projects</h2>
<ProjectList projects={selected} /> <ProjectList projects={selected} />

View file

@ -74,7 +74,7 @@ export const GET: APIRoute = async (context) => {
updated, updated,
] ]
.filter(Boolean) .filter(Boolean)
.join(''), .join('\n'),
}; };
}), }),
}); });

View file

@ -10,6 +10,8 @@ import {
site, site,
} from '../../lib/site'; } from '../../lib/site';
const description = 'Every tag used across the articles archive.';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const tags = getAllTags(posts); const tags = getAllTags(posts);
@ -25,7 +27,7 @@ const collectionJsonLd = {
'@type': 'CollectionPage', '@type': 'CollectionPage',
name: `${site.name} — Tags`, name: `${site.name} — Tags`,
url: absoluteUrl('/tags/'), url: absoluteUrl('/tags/'),
description: 'Every tag used across the articles archive.', description,
}; };
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true })); const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
@ -33,11 +35,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex:
const jsonLd = [collectionJsonLd, breadcrumbJsonLd]; const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
--- ---
<Page <Page title="Tags" description={description} jsonLd={jsonLd}>
title="Tags"
description="Every tag used across the articles archive."
jsonLd={jsonLd}
>
<p class="dek"> <p class="dek">
{posts.length} articles across {tags.length} tags. {posts.length} articles across {tags.length} tags.
</p> </p>

View file

@ -1,22 +1,20 @@
// FOUC prevention: runs in <head> before paint. Sets the theme on <html> so
// the page renders with the right colors on first load. The theme switcher
// button is wired up separately, after it is parsed, in Header.astro.
(function () { (function () {
var key = 'theme'; var STORAGE_KEY = 'theme';
var legacyKey = 'dark-mode'; var LEGACY_KEY = 'dark-mode';
var saved = null; var saved = null;
try { try {
var value = localStorage.getItem(key); var value = localStorage.getItem(STORAGE_KEY);
if (value === 'light' || value === 'dark') { if (value === 'light' || value === 'dark') {
saved = value; saved = value;
} else { } else {
var legacyValue = localStorage.getItem(legacyKey); var legacy = localStorage.getItem(LEGACY_KEY);
if (legacyValue !== null) { if (legacy !== null) saved = JSON.parse(legacy) ? 'dark' : 'light';
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
}
} }
} catch (e) { } catch (e) {}
saved = null; var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
var systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
var theme = saved || (systemDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
})(); })();

View file

@ -41,7 +41,6 @@
color-mix(in oklch, #285f74 70%, black 30%), color-mix(in oklch, #285f74 70%, black 30%),
color-mix(in oklch, #8ab8c8 70%, white 30%) color-mix(in oklch, #8ab8c8 70%, white 30%)
); );
--color-link-visited: var(--color-link);
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15)); --color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
--color-rule: light-dark(#d9d5ca, #39352f); --color-rule: light-dark(#d9d5ca, #39352f);
--color-rule-medium: light-dark(#7a7466, #8a8478); --color-rule-medium: light-dark(#7a7466, #8a8478);
@ -62,7 +61,6 @@
--fs-body: 1.1875rem; --fs-body: 1.1875rem;
--fs-lg: 1.25rem; --fs-lg: 1.25rem;
--fs-xl: 1.75rem; --fs-xl: 1.75rem;
--fs-2xl: 2.1rem;
--fs-3xl: clamp(2rem, 1.5rem + 1.8vw, 3rem); --fs-3xl: clamp(2rem, 1.5rem + 1.8vw, 3rem);
--fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem); --fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem);
@ -164,7 +162,6 @@
@layer base { @layer base {
html { html {
background: var(--color-bg); background: var(--color-bg);
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth; scroll-behavior: smooth;
} }
@ -174,9 +171,6 @@
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--fs-body); font-size: var(--fs-body);
line-height: var(--leading-snug); line-height: var(--leading-snug);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
overflow-wrap: break-word;
transition: transition:
background-color 200ms ease, background-color 200ms ease,
color 200ms ease; color 200ms ease;
@ -229,7 +223,7 @@
padding: 0; padding: 0;
margin: -1px; margin: -1px;
overflow: hidden; overflow: hidden;
clip: rect(0, 0, 0, 0); clip-path: inset(50%);
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
@ -289,14 +283,6 @@
text-decoration: none; text-decoration: none;
} }
.site-title:visited {
color: var(--color-fg);
}
.site-title[aria-current='page'] {
color: var(--color-fg);
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -320,11 +306,6 @@
text-decoration: none; text-decoration: none;
} }
.site-nav a:visited,
.site-footer a:visited {
color: var(--color-muted);
}
.site-nav a:hover, .site-nav a:hover,
.site-footer a:hover { .site-footer a:hover {
color: var(--color-fg); color: var(--color-fg);
@ -533,10 +514,6 @@
text-decoration: none; text-decoration: none;
} }
.tag-list a:visited {
color: var(--color-muted);
}
.tag-list a::before { .tag-list a::before {
content: '#'; content: '#';
color: var(--color-rule-medium); color: var(--color-rule-medium);
@ -615,7 +592,7 @@
text-align: end; text-align: end;
} }
.article-list > li > div { .article-list > li > article {
grid-area: content; grid-area: content;
min-width: 0; min-width: 0;
padding-right: var(--space-3); padding-right: var(--space-3);
@ -631,11 +608,6 @@
text-decoration: none; text-decoration: none;
} }
.article-list .entry-title:visited,
.project-list h3 a:visited {
color: var(--color-fg);
}
.article-list .entry-title:hover, .article-list .entry-title:hover,
.project-list h3 a:hover { .project-list h3 a:hover {
color: var(--color-link-hover); color: var(--color-link-hover);
@ -1199,10 +1171,6 @@
transition: border-color 150ms ease; transition: border-color 150ms ease;
} }
.post-nav a:visited {
color: var(--color-fg);
}
.post-nav a:hover, .post-nav a:hover,
.post-nav a:focus-visible { .post-nav a:focus-visible {
border-color: var(--color-rule-strong); border-color: var(--color-rule-strong);
@ -1548,7 +1516,6 @@
*::before, *::before,
*::after { *::after {
print-color-adjust: economy; print-color-adjust: economy;
-webkit-print-color-adjust: economy;
} }
.site-header, .site-header,