Compare commits
6 commits
fd4bb61b5f
...
0be50b6c24
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be50b6c24 | |||
| f27e9ec3fd | |||
| e5a219499e | |||
| b20139cb60 | |||
| c7e8edfbf4 | |||
| 2e02e52661 |
|
|
@ -1 +0,0 @@
|
|||
**/*.js
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["unused-imports", "@typescript-eslint", "prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports-ts": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off"
|
||||
}
|
||||
}
|
||||
|
|
@ -35,8 +35,13 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build & QA
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
npm run qa
|
||||
|
||||
- name: Copy build to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
|
|
|||
11
.github/dependabot.yml
vendored
|
|
@ -1,11 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
2
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
target
|
||||
.astro
|
||||
.DS_Store
|
||||
|
|
|
|||
12
.prettierrc
|
|
@ -4,7 +4,13 @@
|
|||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"endOfLine": "lf",
|
||||
"importOrder": ["^[./]", ".*", ".scss$"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
"plugins": ["prettier-plugin-astro"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.astro",
|
||||
"options": {
|
||||
"parser": "astro"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
|
|
@ -37,6 +37,6 @@
|
|||
"files.exclude": {
|
||||
"node_modules": true
|
||||
},
|
||||
"editor.rulers": [120],
|
||||
"editor.rulers": [90],
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
|
|
|
|||
2
.vscode/tasks.json
vendored
|
|
@ -2,7 +2,7 @@
|
|||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Format and lint",
|
||||
"label": "Lint",
|
||||
"type": "shell",
|
||||
"command": "npm run lint",
|
||||
"group": "test",
|
||||
|
|
|
|||
40
README.md
|
|
@ -1,21 +1,35 @@
|
|||
# Portfolio
|
||||
# schmelczer.dev
|
||||
|
||||
> An easy-to-configure timeline for your projects.
|
||||
A static personal blog for Andras Schmelczer, built with Astro.
|
||||
|
||||
[Check out the live version.](https://schmelczer.dev)
|
||||
The site is article-first: articles live in `src/content/posts`, project index entries
|
||||
live in `src/content/projects`, and normal pages are rendered as static HTML with no
|
||||
required client JavaScript.
|
||||
|
||||
## Configuration
|
||||
## Setup
|
||||
|
||||
- The actual content is in the [data](src/data) folder, starting with [portfolio.ts](src/data/portfolio.ts)
|
||||
- The assets referenced should be located in [data/media](src/data/media)
|
||||
```sh
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium # required before `npm run qa:overflow`
|
||||
```
|
||||
|
||||
## Build
|
||||
## Commands
|
||||
|
||||
1. `npm install`
|
||||
2. `npm run build`
|
||||
3. You can find the results in the [dist](dist) folder
|
||||
```sh
|
||||
npm run dev
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run preview
|
||||
npm run qa
|
||||
```
|
||||
|
||||
## Info
|
||||
## Structure
|
||||
|
||||
- All images are converted to `WebP` after being imported into any file.
|
||||
> Except for the og-image, and SVGs.
|
||||
- `src/content/posts`: Markdown articles
|
||||
- `src/content/projects`: project index entries
|
||||
- `src/pages`: static routes
|
||||
- `src/layouts`: page and post layouts
|
||||
- `src/components`: reusable UI pieces
|
||||
- `src/styles/global.css`: the visual system
|
||||
- `public/media/downloads`: CV and thesis PDFs
|
||||
- `public/media/video`: project videos
|
||||
|
|
|
|||
136
astro.config.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// Build a lookup of post slugs to their last modification dates so the sitemap
|
||||
// can advertise accurate <lastmod> values to crawlers. astro:content isn't
|
||||
// available inside the config, so we read post frontmatter directly. Our posts
|
||||
// always use single-line scalar `date:` / `updated:` keys, so a small regex
|
||||
// extraction is sufficient and intentional.
|
||||
const postsDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'src/content/posts'
|
||||
);
|
||||
|
||||
function extractScalar(frontmatter, key) {
|
||||
const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'));
|
||||
return match?.[1]?.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
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({
|
||||
site: 'https://schmelczer.dev',
|
||||
trailingSlash: 'ignore',
|
||||
integrations: [
|
||||
sitemap({
|
||||
filter: (page) => {
|
||||
const path = new URL(page).pathname;
|
||||
return !/^\/tags\/[^/]+\/?$/.test(path) && path !== '/404/';
|
||||
},
|
||||
serialize(item) {
|
||||
const url = new URL(item.url);
|
||||
const match = url.pathname.match(/^\/articles\/([^/]+)\/?$/);
|
||||
let lastmod = item.lastmod;
|
||||
if (match) {
|
||||
const date = postLastmodLookup.get(match[1]);
|
||||
if (date instanceof Date && !Number.isNaN(date.valueOf())) {
|
||||
lastmod = date.toISOString();
|
||||
}
|
||||
}
|
||||
return { ...item, changefreq: 'monthly', ...(lastmod ? { lastmod } : {}) };
|
||||
},
|
||||
}),
|
||||
],
|
||||
image: {
|
||||
service: { entrypoint: 'astro/assets/services/sharp' },
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
watch: {
|
||||
// Avoid inotify instance limits in dev containers and mounted volumes.
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
light: 'github-light',
|
||||
dark: 'github-dark',
|
||||
},
|
||||
defaultColor: false,
|
||||
wrap: false,
|
||||
},
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: 'append',
|
||||
properties: {
|
||||
className: ['heading-anchor'],
|
||||
},
|
||||
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
|
||||
// when astro:content extracts heading.text from the rendered HTML.
|
||||
content: [],
|
||||
},
|
||||
],
|
||||
// Make scrollable code blocks and tables reachable via keyboard (WCAG
|
||||
// 2.1.1): without tabindex, a keyboard user cannot focus a horizontally
|
||||
// overflowing <pre> or <table> to scroll it. tabindex=0 is sufficient
|
||||
// on its own; role=region would require a meaningful per-block label,
|
||||
// which we don't have at markdown level.
|
||||
function rehypeFocusableScrollables() {
|
||||
const SCROLLABLE = new Set(['pre', 'table']);
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
if (!SCROLLABLE.has(node.tagName)) return;
|
||||
node.properties.tabindex = '0';
|
||||
});
|
||||
};
|
||||
},
|
||||
function rehypeLabelHeadingPermalinks() {
|
||||
function textOf(node) {
|
||||
if (!node) return '';
|
||||
if (node.type === 'text') return node.value ?? '';
|
||||
return (node.children ?? []).map(textOf).join('');
|
||||
}
|
||||
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
if (!/^h[2-6]$/.test(node.tagName)) return;
|
||||
const headingText = textOf(node).trim();
|
||||
if (!headingText) return;
|
||||
|
||||
for (const child of node.children ?? []) {
|
||||
const className = child.properties?.className;
|
||||
const classes = Array.isArray(className) ? className : [className];
|
||||
if (child.tagName === 'a' && classes.includes('heading-anchor')) {
|
||||
child.properties.ariaLabel = `Permalink to ${headingText}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
39
custom.d.ts
vendored
|
|
@ -1,39 +0,0 @@
|
|||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
import { ResponsiveImage } from 'src/types/responsive-image';
|
||||
const content: ResponsiveImage;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
import { ResponsiveImage } from 'src/types/responsive-image';
|
||||
const content: ResponsiveImage;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.mp4' {
|
||||
import { url } from 'src/types/url';
|
||||
const content: url;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.webm' {
|
||||
import { url } from 'src/types/url';
|
||||
const content: url;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.pdf' {
|
||||
import { url } from 'src/types/url';
|
||||
const content: url;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.html' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
25045
package-lock.json
generated
78
package.json
|
|
@ -1,60 +1,56 @@
|
|||
{
|
||||
"name": "portfolio",
|
||||
"description": "An easily configurable timeline of projects.",
|
||||
"name": "schmelczer-dev",
|
||||
"description": "A static personal blog for Andras Schmelczer.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack serve --open --mode development",
|
||||
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.(ts|scss|json|html)\"",
|
||||
"build": "webpack --mode production",
|
||||
"update": "ncu"
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"typecheck": "astro check",
|
||||
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"qa:links": "node scripts/check-links.mjs",
|
||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
||||
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/schmelczer/schmelczer.github.io.git"
|
||||
},
|
||||
"keywords": [
|
||||
"CV",
|
||||
"curriculum",
|
||||
"vitae",
|
||||
"portfolio",
|
||||
"resumé"
|
||||
"blog",
|
||||
"software engineering",
|
||||
"computer science",
|
||||
"portfolio"
|
||||
],
|
||||
"author": "Andras Schmelczer",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://github.com/schmelczer/schmelczer.github.io/issues"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
],
|
||||
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
|
||||
"devDependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||
"css-loader": "^6.8.1",
|
||||
"eslint": "^8.50.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"inline-source-webpack-plugin": "^3.0.1",
|
||||
"mini-css-extract-plugin": "^2.7.6",
|
||||
"npm-check-updates": "^16.14.4",
|
||||
"prettier": "^3.0.3",
|
||||
"resolve-url-loader": "^5.0.0",
|
||||
"responsive-loader": "^3.1.2",
|
||||
"sass": "^1.68.0",
|
||||
"sass-loader": "^13.3.2",
|
||||
"sharp": "^0.32.6",
|
||||
"sitemap-webpack-plugin": "^1.1.1",
|
||||
"string-replace-loader": "^3.1.0",
|
||||
"svg-inline-loader": "^0.8.2",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"ts-loader": "^9.4.4",
|
||||
"typescript": "^5.2.2",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.1"
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"astro": "^6.3.1",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"overrides": {
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9 KiB After Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 538 B After Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/ibm-plex-mono-latin-400.woff2
Normal file
BIN
public/fonts/source-sans-3-latin-variable.woff2
Normal file
BIN
public/media/video/ad_astra.mp4
Normal file
13
public/media/video/ad_astra.vtt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:04.000
|
||||
No spoken dialogue. Game audio only.
|
||||
|
||||
00:04.000 --> 00:35.000
|
||||
The Ad Astra handheld board runs the game on a small OLED display.
|
||||
|
||||
00:35.000 --> 01:05.000
|
||||
The player controls the game through the IR input while the engine updates the display in real time.
|
||||
|
||||
01:05.000 --> 01:34.600
|
||||
The clip continues showing gameplay on the custom ATtiny85-based board.
|
||||
BIN
public/media/video/ad_astra.webm
Normal file
4
public/robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://schmelczer.dev/sitemap-index.xml
|
||||
23
public/site.webmanifest
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "Andras Schmelczer",
|
||||
"short_name": "Schmelczer",
|
||||
"description": "Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.",
|
||||
"lang": "en",
|
||||
"id": "/",
|
||||
"categories": ["education", "personal", "technology"],
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#fbfaf7",
|
||||
"background_color": "#fbfaf7",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/"
|
||||
}
|
||||
136
scripts/check-links.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']);
|
||||
const failures = [];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
return (await stat(file)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function targetExists(pathname) {
|
||||
if (allowedPreservedRoutes.has(pathname)) return true;
|
||||
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
if (await exists(file)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
|
||||
|
||||
function pagePathname(file) {
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === 'index.html') return '/';
|
||||
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
|
||||
return `/${rel}`;
|
||||
}
|
||||
|
||||
function collectUrlReferences(body, rel) {
|
||||
const urls = [];
|
||||
|
||||
for (const match of body.matchAll(/\b(?:href|src|poster)=["']([^"']+)["']/g)) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
|
||||
for (const match of body.matchAll(/\bsrcset=["']([^"']+)["']/g)) {
|
||||
for (const candidate of match[1].split(',')) {
|
||||
const url = candidate.trim().split(/\s+/)[0];
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (rel.endsWith('.css')) {
|
||||
for (const match of body.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (rel.endsWith('.webmanifest')) {
|
||||
try {
|
||||
const manifest = JSON.parse(body);
|
||||
for (const key of ['start_url', 'scope']) {
|
||||
if (typeof manifest[key] === 'string') urls.push(manifest[key]);
|
||||
}
|
||||
for (const icon of manifest.icons ?? []) {
|
||||
if (typeof icon?.src === 'string') urls.push(icon.src);
|
||||
}
|
||||
for (const screenshot of manifest.screenshots ?? []) {
|
||||
if (typeof screenshot?.src === 'string') urls.push(screenshot.src);
|
||||
}
|
||||
} catch {
|
||||
failures.push(`${rel}: invalid web manifest JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
for (const file of checkedFiles) {
|
||||
const body = await readFile(file, 'utf8');
|
||||
const rel = path.relative(dist, file);
|
||||
const baseUrl = new URL(pagePathname(file), 'https://schmelczer.dev');
|
||||
|
||||
for (const raw of collectUrlReferences(body, rel)) {
|
||||
if (/^(mailto:|tel:|data:)/i.test(raw)) continue;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw, baseUrl);
|
||||
} catch {
|
||||
failures.push(`${rel}: invalid URL ${raw}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.origin !== 'https://schmelczer.dev') continue;
|
||||
if (!(await targetExists(parsed.pathname))) {
|
||||
failures.push(`${rel}: missing local target ${parsed.pathname}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('No missing local href/src targets found in dist/.');
|
||||
80
scripts/check-no-js.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const failures = [];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const jsFiles = files.filter((file) => file.endsWith('.js'));
|
||||
|
||||
if (jsFiles.length > 0) {
|
||||
failures.push(
|
||||
`Unexpected JavaScript assets:\n${jsFiles.map((file) => `- ${file}`).join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Script tags are only allowed if they declare one of these safe `type`
|
||||
// attributes (or are tagged with `data-theme-script`). All other scripts —
|
||||
// including untyped ones, which default to executable JavaScript — are
|
||||
// flagged.
|
||||
const SAFE_SCRIPT_TYPES = new Set([
|
||||
'application/ld+json',
|
||||
'importmap',
|
||||
'speculationrules',
|
||||
]);
|
||||
|
||||
function isSafeScriptTag(tag) {
|
||||
if (tag.includes('data-theme-script')) return true;
|
||||
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
|
||||
if (!typeMatch) return false;
|
||||
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());
|
||||
}
|
||||
|
||||
for (const file of files.filter((candidate) => candidate.endsWith('.html'))) {
|
||||
const html = await readFile(file, 'utf8');
|
||||
const scripts = (html.match(/<script\b[^>]*>/gi) ?? []).filter(
|
||||
(tag) => !isSafeScriptTag(tag)
|
||||
);
|
||||
if (scripts.length) {
|
||||
failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`);
|
||||
}
|
||||
|
||||
// Inline event handlers (onclick=, onload=, etc.) execute JavaScript even
|
||||
// without a <script> tag, so flag any attribute matching `on*=`. We strip
|
||||
// <script> blocks first to avoid false positives from JSON-LD payloads.
|
||||
const stripped = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
||||
const handlerMatches = stripped.match(/\son\w+=/gi);
|
||||
if (handlerMatches?.length) {
|
||||
const unique = [...new Set(handlerMatches.map((m) => m.trim()))];
|
||||
failures.push(`Unexpected inline event handler in ${file}:\n${unique.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('No unexpected JavaScript found in dist/.');
|
||||
309
scripts/check-overflow.mjs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
|
||||
const INDEX_FILE = 'index.html';
|
||||
const MAX_NAV_RETRIES = 4;
|
||||
// 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 CLOSE_TIMEOUT_MS = 3000;
|
||||
const LAUNCH_TIMEOUT_MS = 10000;
|
||||
const CONTEXT_TIMEOUT_MS = 8000;
|
||||
const PAGE_TIMEOUT_MS = 15000;
|
||||
const MEASURE_TIMEOUT_MS = 25000;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
function contentType(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function discoverRoutes() {
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
async function resolveFile(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(parsed.pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const fileStat = await stat(file);
|
||||
if (fileStat.isFile()) return file;
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(dist, '404.html');
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
// Some CI/dev containers mount /tmp as a very small tmpfs. Chromium uses the
|
||||
// process temp directory for profiles and internal files; putting it under the
|
||||
// already-ignored .astro/ directory keeps the overflow check reproducible even
|
||||
// when the system temp mount is full.
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
|
||||
const routes = await discoverRoutes();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const file = await resolveFile(req.url ?? '/');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': contentType(file) });
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
const failures = [];
|
||||
|
||||
function launchBrowser() {
|
||||
return chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser.process?.();
|
||||
try {
|
||||
await withTimeout(
|
||||
browser.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Chromium'
|
||||
);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser() {
|
||||
return withTimeout(
|
||||
launchBrowser(),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
async function newMeasurementContext(browser, width) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
await context.route('**/*', (route) => {
|
||||
const type = route.request().resourceType();
|
||||
if (type === 'media') {
|
||||
route.abort('blockedbyclient');
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function openMeasurementContext(browser, width) {
|
||||
return withTimeout(
|
||||
newMeasurementContext(browser, width),
|
||||
CONTEXT_TIMEOUT_MS,
|
||||
`Timed out while creating ${width}px Playwright context`
|
||||
);
|
||||
}
|
||||
|
||||
async function measureViewport(page) {
|
||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
return page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
}
|
||||
|
||||
function shouldRetryNavigation(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
async function measureRoute(context, route) {
|
||||
let page;
|
||||
try {
|
||||
page = await withTimeout(
|
||||
context.newPage(),
|
||||
PAGE_TIMEOUT_MS,
|
||||
`Timed out while creating page for ${route}`
|
||||
);
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
await page.goto(`http://127.0.0.1:${port}${route}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 15000,
|
||||
});
|
||||
return measureViewport(page);
|
||||
})(),
|
||||
MEASURE_TIMEOUT_MS,
|
||||
`Timed out while measuring ${route}`
|
||||
);
|
||||
} finally {
|
||||
if (page) await safeClosePage(page);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const width of VIEWPORT_WIDTHS) {
|
||||
let browser;
|
||||
let context;
|
||||
try {
|
||||
browser = await openBrowser();
|
||||
context = await openMeasurementContext(browser, width);
|
||||
for (const route of routes) {
|
||||
let result;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
|
||||
try {
|
||||
result = await measureRoute(context, route);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) {
|
||||
throw error;
|
||||
}
|
||||
await safeCloseContext(context);
|
||||
await safeCloseBrowser(browser);
|
||||
browser = await openBrowser();
|
||||
context = await openMeasurementContext(browser, width);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.scrollWidth > result.clientWidth + 1) {
|
||||
failures.push(
|
||||
`${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (context) await safeCloseContext(context);
|
||||
if (browser) await safeCloseBrowser(browser);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
server.close();
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`No horizontal overflow detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.`
|
||||
);
|
||||
32
scripts/install-playwright-deps.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const isLinux = process.platform === 'linux';
|
||||
|
||||
if (!isLinux) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const executablePath = chromium.executablePath();
|
||||
let needsInstall = !existsSync(executablePath);
|
||||
|
||||
if (!needsInstall) {
|
||||
const ldd = spawnSync('ldd', [executablePath], { encoding: 'utf8' });
|
||||
const output = `${ldd.stdout ?? ''}${ldd.stderr ?? ''}`;
|
||||
needsInstall = ldd.status !== 0 || output.includes('not found');
|
||||
}
|
||||
|
||||
if (!needsInstall) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync('playwright', ['install', '--with-deps', 'chromium'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
BIN
src/assets/og-default.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
53
src/components/ArticleList.astro
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import TagList from './TagList.astro';
|
||||
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
showYear?: boolean;
|
||||
tagLimit?: number;
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold (home, tag pages). Lists below substantial
|
||||
// content (related, archives by year, 404) should leave this off.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
}
|
||||
|
||||
const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
|
||||
---
|
||||
|
||||
<ol class="article-list">
|
||||
{
|
||||
posts.map((post, index) => {
|
||||
const href = articlePath(post);
|
||||
return (
|
||||
<li>
|
||||
<article>
|
||||
<h3>
|
||||
<a class="entry-title" href={href}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p>{post.data.description}</p>
|
||||
<TagList tags={post.data.tags} limit={tagLimit} />
|
||||
</article>
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||
</time>
|
||||
<EntryThumbnail
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
href={href}
|
||||
class="article-thumbnail"
|
||||
widths={ARTICLE_THUMBNAIL.widths}
|
||||
sizes={ARTICLE_THUMBNAIL.sizes}
|
||||
ariaLabel={`Open article: ${post.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
50
src/components/AtAGlance.astro
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
|
||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||
|
||||
interface Props {
|
||||
role?: string;
|
||||
projectPeriod?: string;
|
||||
stack?: string[];
|
||||
scale?: string;
|
||||
outcome?: string;
|
||||
links?: Link[];
|
||||
headingId: string;
|
||||
}
|
||||
|
||||
const {
|
||||
role,
|
||||
projectPeriod,
|
||||
stack = [],
|
||||
scale,
|
||||
outcome,
|
||||
links = [],
|
||||
headingId,
|
||||
} = Astro.props;
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (role) rows.push(['Role', role]);
|
||||
if (projectPeriod) rows.push(['Period', projectPeriod]);
|
||||
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
|
||||
if (scale) rows.push(['Scale', scale]);
|
||||
if (outcome) rows.push(['Outcome', outcome]);
|
||||
---
|
||||
|
||||
{
|
||||
rows.length > 0 && (
|
||||
<aside class="at-a-glance" aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>At a Glance</h2>
|
||||
<dl>
|
||||
{rows.map(([label, value]) => (
|
||||
<div class="at-a-glance__row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{links.length > 0 && <ProjectLinks links={links} />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
33
src/components/Breadcrumbs.astro
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
interface Crumb {
|
||||
href?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: Crumb[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
const lastIndex = items.length - 1;
|
||||
---
|
||||
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol class="breadcrumbs">
|
||||
{
|
||||
items.map((item, index) => {
|
||||
const isLast = index === lastIndex;
|
||||
const isLink = item.href && !isLast;
|
||||
return (
|
||||
<li>
|
||||
{isLink ? (
|
||||
<a href={item.href}>{item.label}</a>
|
||||
) : (
|
||||
<span aria-current={isLast ? 'page' : undefined}>{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
56
src/components/EntryThumbnail.astro
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { Picture } from 'astro:assets';
|
||||
|
||||
interface Props {
|
||||
src: ImageMetadata;
|
||||
alt: string;
|
||||
href?: string;
|
||||
class?: string;
|
||||
widths: number[];
|
||||
sizes: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
ariaLabel?: string;
|
||||
// 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. The link still needs
|
||||
// a name because some assistive tech exposes non-tabbable links.
|
||||
decorative?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
href,
|
||||
class: extraClass,
|
||||
widths,
|
||||
sizes,
|
||||
loading = 'lazy',
|
||||
fetchpriority,
|
||||
ariaLabel,
|
||||
decorative = true,
|
||||
} = Astro.props;
|
||||
|
||||
const Tag = href ? 'a' : 'div';
|
||||
const isDecorativeLink = Boolean(href) && decorative;
|
||||
---
|
||||
|
||||
<Tag
|
||||
class:list={['entry-thumbnail', extraClass]}
|
||||
href={href}
|
||||
tabindex={isDecorativeLink ? -1 : undefined}
|
||||
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
|
||||
>
|
||||
<Picture
|
||||
src={src}
|
||||
alt={isDecorativeLink ? '' : alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={widths}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
fetchpriority={fetchpriority}
|
||||
/>
|
||||
</Tag>
|
||||
32
src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Footer shows all nav items except Home (which is implicit via the site title).
|
||||
const footerNavItems = navItems.filter((item) => item.href !== '/');
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<nav aria-label="Footer">
|
||||
<ul class="footer-links">
|
||||
{
|
||||
footerNavItems.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="footer-meta">
|
||||
<span>© {year} {site.name}</span>
|
||||
{/* address wraps only the author's contact details, per HTML spec. */}
|
||||
<address class="footer-contact">
|
||||
<a href={`mailto:${site.email}`}>Email</a>
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</address>
|
||||
</div>
|
||||
</footer>
|
||||
128
src/components/Header.astro
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
const current =
|
||||
currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath)
|
||||
? currentPath
|
||||
: `${currentPath}/`;
|
||||
|
||||
// Exact match for the current page; section match (descendant URLs) for
|
||||
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||
// `"true"` indicates an ancestor section.
|
||||
function currentState(href: string): 'page' | 'true' | undefined {
|
||||
if (current === href) return 'page';
|
||||
if (href !== '/' && current.startsWith(href)) return 'true';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Header shows nav items except Home and footer-only entries. RSS lives as a
|
||||
// dedicated icon link to the right of the nav.
|
||||
const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly);
|
||||
---
|
||||
|
||||
<a class="skip-link" href="#content">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
|
||||
<div class="header-actions">
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{
|
||||
headerNavItems.map((item) => (
|
||||
<a href={item.href} aria-current={currentState(item.href)}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
<a class="rss-link" href="/rss.xml" aria-label="RSS feed">
|
||||
<svg
|
||||
class="rss-icon"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.18 17.82a2.18 2.18 0 1 1-4.36 0 2.18 2.18 0 0 1 4.36 0ZM2 9.86v3.13a8.97 8.97 0 0 1 9.01 9.01h3.13A12.1 12.1 0 0 0 2 9.86Zm0-5.86V7.1A14.92 14.92 0 0 1 16.9 22H20A17.9 17.9 0 0 0 2 4Z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">RSS feed</span>
|
||||
</a>
|
||||
<button
|
||||
id="theme-switcher"
|
||||
class="theme-switcher"
|
||||
type="button"
|
||||
aria-label="Dark theme"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline data-theme-script>
|
||||
// Co-located with the button so the initial aria state is set as soon as the
|
||||
// button parses, avoiding a flash of the wrong icon. The theme itself is
|
||||
// already on <html> from theme-init.js in <head>.
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var switcher = document.getElementById('theme-switcher');
|
||||
if (!switcher) return;
|
||||
|
||||
// Keep in sync with --color-bg in global.css and theme-init.js.
|
||||
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
|
||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
|
||||
function sync(theme) {
|
||||
switcher.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||
switcher.setAttribute(
|
||||
'title',
|
||||
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
|
||||
);
|
||||
for (var i = 0; i < themeColorMetas.length; i += 1) {
|
||||
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
|
||||
}
|
||||
}
|
||||
sync(root.dataset.theme === 'dark' ? 'dark' : 'light');
|
||||
|
||||
var reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
switcher.addEventListener('click', function () {
|
||||
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
try {
|
||||
localStorage.setItem('theme', next);
|
||||
} catch (e) {}
|
||||
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>
|
||||
|
||||
<style>
|
||||
.rss-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
color: inherit;
|
||||
line-height: 0;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
.rss-link:hover,
|
||||
.rss-link:focus-visible {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
.rss-icon {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
30
src/components/PostMedia.astro
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import PostMediaFigure from './PostMediaFigure.astro';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
|
||||
// Wrap in a gallery `<ul>` when there's more than one item; otherwise the
|
||||
// figures sit directly in the post flow.
|
||||
const isGallery = items.length > 1;
|
||||
---
|
||||
|
||||
{
|
||||
isGallery ? (
|
||||
<ul role="list" class="post-gallery">
|
||||
{items.map((item) => (
|
||||
<li>
|
||||
<PostMediaFigure item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
items.map((item) => <PostMediaFigure item={item} />)
|
||||
)
|
||||
}
|
||||
80
src/components/PostMediaFigure.astro
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
|
||||
interface Props {
|
||||
item: MediaItem;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
|
||||
const videoWidth = item.type === 'video' ? (item.poster?.width ?? 1280) : undefined;
|
||||
const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undefined;
|
||||
---
|
||||
|
||||
<figure class="post-media">
|
||||
{
|
||||
item.type === 'video' ? (
|
||||
// Decorative videos stay inert and hidden from assistive tech. Meaningful
|
||||
// videos expose controls, captions, and an accessible name.
|
||||
item.decorative ? (
|
||||
<video
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
poster={item.poster?.src}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
</video>
|
||||
) : (
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
poster={item.poster?.src}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
aria-label={item.alt}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
{item.captions && (
|
||||
<track
|
||||
kind="captions"
|
||||
src={item.captions}
|
||||
srclang="en"
|
||||
label={item.captionsLabel}
|
||||
default
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
)
|
||||
) : (
|
||||
item.src && (
|
||||
<Picture
|
||||
src={item.src}
|
||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||
formats={['avif', 'webp']}
|
||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
{item.caption && !item.decorative && <figcaption>{item.caption}</figcaption>}
|
||||
{
|
||||
item.transcript && (
|
||||
<p class="media-transcript">
|
||||
<strong>Transcript:</strong> {item.transcript}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
64
src/components/ProjectLinks.astro
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||
|
||||
interface Props {
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
const { links } = Astro.props;
|
||||
|
||||
function isExternal(url: string) {
|
||||
return /^https?:\/\//.test(url);
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
links.length > 0 && (
|
||||
<ul class="project-links">
|
||||
{links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
download={link.download ? '' : undefined}
|
||||
rel={isExternal(link.url) ? 'noopener noreferrer' : undefined}
|
||||
target={isExternal(link.url) ? '_blank' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
{isExternal(link.url) && (
|
||||
<>
|
||||
<svg
|
||||
class="external-link-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="0.85em"
|
||||
height="0.85em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
<span class="sr-only">(opens in new tab)</span>
|
||||
</>
|
||||
)}
|
||||
{link.download && (
|
||||
<>
|
||||
<span class="download-indicator" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
<span class="sr-only">(download)</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
69
src/components/ProjectList.astro
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
projects: CollectionEntry<'projects'>[];
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold. The home and projects-index lists sit below
|
||||
// other sections, so leave this off there.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
}
|
||||
|
||||
const { projects, eagerFirstThumbnail = false } = Astro.props;
|
||||
|
||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||
const essayHrefs = new Map<string, string>();
|
||||
for (const project of projects) {
|
||||
const essay = project.data.essay;
|
||||
if (!essay) continue;
|
||||
const resolved = await getEntry(essay);
|
||||
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
|
||||
}
|
||||
---
|
||||
|
||||
<ol class="project-list">
|
||||
{
|
||||
projects.map((project, index) => {
|
||||
const anchor = entrySlug(project);
|
||||
const titleId = `${anchor}-title`;
|
||||
const essayHref = essayHrefs.get(project.id);
|
||||
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
||||
|
||||
return (
|
||||
<li class="project-card" id={anchor}>
|
||||
<EntryThumbnail
|
||||
src={project.data.thumbnail.src}
|
||||
alt={project.data.thumbnail.alt}
|
||||
href={primaryHref}
|
||||
class="project-thumbnail"
|
||||
widths={PROJECT_THUMBNAIL.widths}
|
||||
sizes={PROJECT_THUMBNAIL.sizes}
|
||||
ariaLabel={`Open project: ${project.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
<article class="project-card__summary">
|
||||
<h3 id={titleId}>
|
||||
{primaryHref ? (
|
||||
<a href={primaryHref}>{project.data.title}</a>
|
||||
) : (
|
||||
project.data.title
|
||||
)}
|
||||
{essayHref && <span class="project-essay-badge">Article</span>}
|
||||
</h3>
|
||||
<p class="project-description">{project.data.description}</p>
|
||||
<p class="project-meta">
|
||||
{project.data.period} · {project.data.technologies.join(', ')}
|
||||
</p>
|
||||
{project.data.links.length > 0 && <ProjectLinks links={project.data.links} />}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
40
src/components/TagList.astro
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
import { tagPath } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
tags: readonly string[];
|
||||
currentTag?: string;
|
||||
limit?: number;
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const { tags, currentTag, limit, counts } = Astro.props;
|
||||
|
||||
const visibleTags = typeof limit === 'number' ? tags.slice(0, limit) : tags;
|
||||
const remaining =
|
||||
typeof limit === 'number' && tags.length > limit ? tags.length - limit : 0;
|
||||
---
|
||||
|
||||
<ul class="tag-list">
|
||||
{
|
||||
visibleTags.map((tag) => (
|
||||
<li>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||
{tag}
|
||||
{counts && counts[tag] !== undefined && (
|
||||
<span class="tag-count">{counts[tag]}</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
remaining > 0 && (
|
||||
<li>
|
||||
<a href="/tags/" class="tag-more">
|
||||
+{remaining} more
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
143
src/content.config.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { defineCollection, reference } from 'astro:content';
|
||||
import type { SchemaContext } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
|
||||
function isRootRelativeUrl(url: string) {
|
||||
return url.startsWith('/') && !url.startsWith('//');
|
||||
}
|
||||
|
||||
const linkUrl = z.string().refine(
|
||||
(url) => {
|
||||
if (isRootRelativeUrl(url)) return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ['https:', 'mailto:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'URL must be an absolute https/mailto URL or a root-relative path.' }
|
||||
);
|
||||
|
||||
const mediaUrl = z.string().refine(
|
||||
(url) => {
|
||||
if (isRootRelativeUrl(url)) return true;
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'Media URL must be an absolute https URL or a root-relative path.' }
|
||||
);
|
||||
|
||||
const linkSchema = z.object({
|
||||
label: z.string(),
|
||||
url: linkUrl,
|
||||
download: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const thumbnailSchema = ({ image }: SchemaContext) =>
|
||||
z.object({
|
||||
src: image(),
|
||||
alt: z.string(),
|
||||
});
|
||||
|
||||
const mediaSchema = ({ image }: SchemaContext) =>
|
||||
z
|
||||
.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.enum(['image', 'diagram']),
|
||||
src: image(),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('video'),
|
||||
poster: image().optional(),
|
||||
mp4: mediaUrl.optional(),
|
||||
webm: mediaUrl.optional(),
|
||||
captions: mediaUrl.optional(),
|
||||
captionsLabel: z.string().default('English captions'),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
})
|
||||
.refine((item) => Boolean(item.mp4) || Boolean(item.webm), {
|
||||
message: 'Video media needs at least one mp4 or webm source.',
|
||||
}),
|
||||
])
|
||||
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
||||
message: 'Meaningful media needs both alt text and a caption.',
|
||||
})
|
||||
.refine(
|
||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.captions),
|
||||
{
|
||||
message: 'Meaningful video needs captions.',
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.transcript),
|
||||
{
|
||||
message: 'Meaningful video needs a transcript.',
|
||||
}
|
||||
);
|
||||
|
||||
const posts = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
date: z.coerce.date(),
|
||||
updated: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
tags: z.array(
|
||||
z.enum([
|
||||
'ai',
|
||||
'systems',
|
||||
'graphics',
|
||||
'simulation',
|
||||
'embedded',
|
||||
'web',
|
||||
'tools',
|
||||
'games',
|
||||
])
|
||||
),
|
||||
featuredOrder: z.number().optional(),
|
||||
projectPeriod: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
stack: z.array(z.string()).optional(),
|
||||
scale: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
audience: z
|
||||
.enum(['general', 'technical', 'recruiter-relevant'])
|
||||
.default('technical'),
|
||||
links: z.array(linkSchema).default([]),
|
||||
media: z.array(mediaSchema({ image })).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
const projects = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
period: z.string(),
|
||||
sortDate: z.coerce.date(),
|
||||
technologies: z.array(z.string()).default([]),
|
||||
selected: z.boolean().default(false),
|
||||
essay: reference('posts').optional(),
|
||||
links: z.array(linkSchema).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { posts, projects };
|
||||
BIN
src/content/posts/_assets/ad-astra.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/content/posts/_assets/avoid.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/content/posts/_assets/city-simulation.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
src/content/posts/_assets/decla-red.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/content/posts/_assets/fleeting-garden.jpg
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
src/content/posts/_assets/forex.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/content/posts/_assets/great-ai.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/content/posts/_assets/leds.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/content/posts/_assets/my-notes.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
src/content/posts/_assets/photo-colour-grader.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
src/content/posts/_assets/photos.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/content/posts/_assets/platform-game.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/content/posts/_assets/process-simulator-input.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/content/posts/_assets/process-simulator.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/content/posts/_assets/reconcile.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src/content/posts/_assets/sdf2d.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src/content/posts/_assets/towers.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
63
src/content/posts/ad-astra-attiny85-game-engine.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
title: A 50 FPS Game Engine on an ATtiny85
|
||||
description: Building a tiny embedded game engine around an ATtiny85V, OLED display, IR input, EEPROM persistence, and a custom PCB.
|
||||
date: 2026-05-06
|
||||
projectPeriod: 'Spring 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/ad-astra.jpg
|
||||
alt: The Ad Astra game running on a small OLED display.
|
||||
tags: ['embedded', 'games', 'systems']
|
||||
role: Hardware and firmware author
|
||||
stack: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
||||
scale: 8-bit microcontroller, 8 MHz clock, 15-20 ms maximum frame times during gameplay
|
||||
outcome: A working low-power handheld game engine and game built from the circuit board up
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/ad_astra
|
||||
media:
|
||||
- type: video
|
||||
poster: ./_assets/ad-astra.jpg
|
||||
webm: /media/video/ad_astra.webm
|
||||
mp4: /media/video/ad_astra.mp4
|
||||
captions: /media/video/ad_astra.vtt
|
||||
alt: Video demonstration of the embedded game running on a small OLED display.
|
||||
caption: The game engine ran on an ATtiny85V with an OLED display and IR input.
|
||||
transcript: No spoken dialogue. The demonstration shows the Ad Astra handheld board running its OLED game, with the player moving through the small display while the IR input controls gameplay.
|
||||
---
|
||||
|
||||
Ad Astra came from wanting to combine graphics and microcontrollers without hiding behind a large development board. The result was a small embedded game engine and game built around an ATtiny85V, an OLED display, IR input, EEPROM persistence, and a custom PCB.
|
||||
|
||||
The fun part was that every layer mattered. The circuit, display driver, memory layout, object model, sprite tooling, and game loop all had to fit inside a tiny system.
|
||||
|
||||
## The Problem
|
||||
|
||||
The hardware setup was intentionally constrained: an ATtiny85V, a D096-12864-SPI7 OLED display, a TSOP4838 IR receiver, and a 3.3V regulator. The system was low power, with peak consumption around 31 mW at full brightness and a standby mode around 1.5 mA.
|
||||
|
||||
Those numbers made the project feel physical. Performance was not an abstract target. Every frame and every byte had a cost.
|
||||
|
||||
## Constraints
|
||||
|
||||
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 behaviour manageable without paying for a runtime that did not exist.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## What Worked
|
||||
|
||||
The project worked because the abstraction level stayed close to the hardware. The engine had reusable pieces, but none of them pretended the platform was larger than it was.
|
||||
|
||||
The custom PCB also changed the project. Once the system had a real board, bugs felt less like software inconveniences and more like design consequences. That made the final result much more satisfying.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
Today I would write a more explicit development log around the display driver and persistence layer. Those are the parts that still feel technically interesting, and they deserve diagrams and measurements.
|
||||
|
||||
I would also add a small emulator or host-side harness. Debugging firmware directly on constrained hardware is useful, but a fast feedback loop would have made the engine easier to evolve.
|
||||
16
src/content/posts/avoid-early-web-game.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
title: Avoid, an Early Web Game
|
||||
description: A tiny archived web game from my first experiments with browser-based interaction.
|
||||
date: 2026-04-29
|
||||
projectPeriod: 'January 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/avoid.jpg
|
||||
alt: Screenshot of the Avoid web game.
|
||||
tags: ['games', 'web']
|
||||
role: Game author
|
||||
stack: ['JavaScript', 'Canvas']
|
||||
outcome: A small playable web game kept as an archive of early browser work
|
||||
audience: general
|
||||
---
|
||||
|
||||
I recently found my first web game. It is very simple, but I killed some time with it.
|
||||
25
src/content/posts/city-simulation-unity-traffic.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
title: A Unity City Simulation for a Cybersecurity Challenge
|
||||
description: A client-server Unity simulation where REST-controlled traffic lights made mistakes immediately visible through crashes.
|
||||
date: 2026-05-01
|
||||
projectPeriod: 'July-August 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/city-simulation.jpg
|
||||
alt: Screenshot of a Unity traffic simulation.
|
||||
tags: ['simulation', 'systems']
|
||||
role: Simulation author
|
||||
stack: ['Unity', 'C#', 'REST API', 'Blender']
|
||||
outcome: A visual context for a PLC-focused cybersecurity challenge
|
||||
audience: technical
|
||||
links: []
|
||||
---
|
||||
|
||||
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 project was the context for a cybersecurity challenge about PLCs. Contestants could see the effect of their work immediately, as crashes.
|
||||
|
||||
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 built in Unity with C# as the scripting language. I also made the models and animations in Blender.
|
||||
62
src/content/posts/declared-shared-simulation-code.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
title: Shared Simulation Code in a Mobile Multiplayer Browser Game
|
||||
description: How decla.red used shared TypeScript game logic, WebSockets, client prediction, and spatial indexing for a team-based browser game.
|
||||
date: 2026-05-07
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene.
|
||||
tags: ['games', 'web', 'systems']
|
||||
role: Game and backend systems author
|
||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
||||
scale: Multiple servers, each communicating with 16-32 clients
|
||||
outcome: A mobile-capable online browser game built on top of SDF-2D
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
||||
caption: decla.red used the SDF-2D renderer in a real-time multiplayer game.
|
||||
---
|
||||
|
||||
`decla.red` was a conquest-style online multiplayer browser game set in space. Two teams fought over small planets, gained points based on control, and could shoot at the other team while moving through a ray-traced 2D scene.
|
||||
|
||||
The rendering made the game look interesting, but the architecture was the more useful lesson. The game needed to run on phones, talk to multiple servers, keep clients responsive, and avoid duplicating game rules between frontend and backend.
|
||||
|
||||
## The Problem
|
||||
|
||||
Real-time multiplayer games have an awkward split. The server should be authoritative, but the client has to feel immediate. If every meaningful interaction waits for a round trip, the game feels broken. If the client is trusted too much, the game becomes inconsistent or easy to abuse.
|
||||
|
||||
For this project, I wanted the same game rules to be used by the server and the client. The server would calculate the actual next state. The client could predict locally with the same code and later reconcile with the server.
|
||||
|
||||
## Constraints
|
||||
|
||||
The project used TypeScript on both sides: browser code for the client and Node.js for the server. WebSockets carried real-time updates. Firebase helped the servers reach consensus about the active server set.
|
||||
|
||||
Each server communicated with 16-32 clients. That is not large by industry standards, but it was enough to make careless spatial operations and state updates visible.
|
||||
|
||||
## Design
|
||||
|
||||
The key decision was a shared library for game logic. Both the client and server linked to it, so the transition rules lived in one place.
|
||||
|
||||
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 behaviour extensible without pushing every entity into a brittle inheritance tree.
|
||||
|
||||
## What Worked
|
||||
|
||||
Sharing simulation code was the most important architecture choice. It let the project stay coherent as the client and server evolved.
|
||||
|
||||
The project also validated SDF-2D outside a toy environment. A rendering library is more convincing when it survives a game loop, input, network updates, and mobile constraints.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
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.
|
||||
94
src/content/posts/fleeting-garden-webgpu-drawing.md
Normal 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: /fleeting/
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot, 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.
|
||||
23
src/content/posts/foreign-exchange-prediction-experiment.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
title: A Frequency-Domain Foreign Exchange Prediction Experiment
|
||||
description: An older EUR/USD prediction experiment built from smoothing, short-time Fourier transforms, extrapolation, and a Python prediction server.
|
||||
date: 2026-05-03
|
||||
projectPeriod: 'Autumn 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/forex.jpg
|
||||
alt: Chart comparing predicted and actual EUR/USD exchange rates.
|
||||
tags: ['systems', 'tools']
|
||||
role: Experiment author
|
||||
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||
outcome: A working prediction server connected to an MQL4 client for trading experiments
|
||||
audience: technical
|
||||
links: []
|
||||
---
|
||||
|
||||
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 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 strategy was viable. The project was mostly a look into trading algorithms, their complexity, and the competition around them.
|
||||
21
src/content/posts/graph-editor-javafx-simulation-input.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: A JavaFX Graph Editor for Simulation Input
|
||||
description: A small JavaFX editor for creating and uploading graph input for the cooling system simulator.
|
||||
date: 2026-04-25
|
||||
projectPeriod: 'October-November 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/process-simulator-input.jpg
|
||||
alt: JavaFX graph editor for the cooling system simulator.
|
||||
tags: ['simulation', 'tools']
|
||||
role: Editor author
|
||||
stack: ['JavaFX', 'JSON', 'REST API']
|
||||
outcome: An editor for building input graphs and sending them to the simulation backend
|
||||
audience: technical
|
||||
links: []
|
||||
---
|
||||
|
||||
This was a small editor for building input graphs for the cooling system simulator.
|
||||
|
||||
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 uploaded directly to the simulation backend.
|
||||
71
src/content/posts/greatai-ai-deployment-api.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
title: Designing an ML Deployment API Around Best Practices
|
||||
description: How GreatAI tried to make stronger ML deployment habits accessible through a small Python API.
|
||||
date: 2026-05-09
|
||||
projectPeriod: '2022'
|
||||
thumbnail:
|
||||
src: ./_assets/great-ai.png
|
||||
alt: Example Python code using the GreatAI API.
|
||||
tags: ['ai', 'systems', 'tools']
|
||||
featuredOrder: 1
|
||||
role: Researcher and framework author
|
||||
stack: ['Python', 'ML deployment', 'API design']
|
||||
scale: 33 deployment best practices, six proposed additions, evaluated with professional data scientists and software engineers
|
||||
outcome: A Python framework, thesis, and research-backed API design for production-oriented AI deployments
|
||||
audience: recruiter-relevant
|
||||
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
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/great-ai.png
|
||||
alt: Example Python code using GreatAI decorators and prediction helpers.
|
||||
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 behaviour too cumbersome to use.
|
||||
|
||||
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 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 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.
|
||||
|
||||
So the core question became: can a framework implement meaningful deployment practices while keeping the API small enough that people would actually use it?
|
||||
|
||||
## Constraints
|
||||
|
||||
GreatAI had to satisfy two constraints that usually pull in opposite directions.
|
||||
|
||||
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 behaviour had to sit close to the prediction function, because that is where the developer already has context.
|
||||
|
||||
## 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 behaviour, and let the framework handle repeated operational concerns.
|
||||
|
||||
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.
|
||||
|
||||
## What Worked
|
||||
|
||||
The strongest part of the project was treating API design as part of deployment quality. Best practices are not only documentation. They need interface support, defaults, and feedback loops.
|
||||
|
||||
The research also forced the framework to be specific. "Production-ready" is too broad to be useful. A concrete list of deployment practices made it possible to ask which practices can be automated, which ones need explicit developer decisions, and which ones belong outside the framework.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
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 behaviour easy enough that it becomes the default.
|
||||
54
src/content/posts/life-towers-immutable-tries.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
title: Syncing State with Immutable Tries
|
||||
description: How a multi-device life tracking project used trie structure to diff, reconcile, and synchronise goal state.
|
||||
date: 2026-05-05
|
||||
projectPeriod: 'August-September 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
tags: ['systems', 'web', 'tools']
|
||||
featuredOrder: 4
|
||||
role: Full-stack author
|
||||
stack: ['Python', 'Angular', 'State synchronisation']
|
||||
scale: Multi-device goal and task state shared between clients and a server
|
||||
outcome: A working synchronisation model built around immutable trie properties
|
||||
audience: recruiter-relevant
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
||||
caption: The 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 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.
|
||||
|
||||
## The Problem
|
||||
|
||||
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 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
|
||||
|
||||
I used a trie. A trie made the hierarchical shape explicit, and its properties made it easier to reason about differences between stored versions.
|
||||
|
||||
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 synchronisation structure was the main lesson.
|
||||
|
||||
## What Worked
|
||||
|
||||
The biggest win was choosing a data structure that matched the problem. Once the state was represented in a way that made comparison natural, the network protocol became simpler.
|
||||
|
||||
The other useful lesson was that visual products still need a strong internal model. A pleasant interface is fragile if the underlying state is hard to trust.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
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 synchronisation story more explicitly. The latter is the part that aged better.
|
||||
21
src/content/posts/lights-synchronized-to-music.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: Lights Synchronized to Music
|
||||
description: A Raspberry Pi music player that analysed audio output and drove RGB LED strips.
|
||||
date: 2026-04-26
|
||||
projectPeriod: 'Spring 2016'
|
||||
thumbnail:
|
||||
src: ./_assets/leds.jpg
|
||||
alt: RGB LED strips lit by a music synchronisation project.
|
||||
tags: ['systems', 'tools']
|
||||
role: Hardware and software author
|
||||
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
||||
outcome: My first finished non-trivial project, combining a web UI, audio processing, and hardware output
|
||||
audience: technical
|
||||
links: []
|
||||
---
|
||||
|
||||
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 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 was Python, with NumPy doing the FFT. The frontend was a vanilla web page for picking tracks and tweaking settings.
|
||||
23
src/content/posts/my-notes-android-markdown-app.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
title: My Notes, an Android Markdown App
|
||||
description: A small Android notes app for creating, editing, and filtering markdown notes with hashtags.
|
||||
date: 2026-05-02
|
||||
projectPeriod: 'November 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/my-notes.png
|
||||
alt: Screenshots of the My Notes Android app.
|
||||
tags: ['tools']
|
||||
role: Android app author
|
||||
stack: ['Android', 'Markdown', 'Markwon']
|
||||
outcome: A functional markdown note organiser and a first exposure to Android development
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/my-notes
|
||||
---
|
||||
|
||||
My Notes was a small Android note organiser and editor built on top of Markwon.
|
||||
|
||||
It let me create Markdown notes and filter them by hashtag. It was also my first exposure to Android development.
|
||||
|
||||
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.
|
||||
56
src/content/posts/nuclear-cooling-simulation.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
title: Graph Models for a Real-Time Cooling Simulation
|
||||
description: Simulating a nuclear facility cooling system with graph traversal, matrix solving, Flask, NumPy, and real-time monitoring clients.
|
||||
date: 2026-05-04
|
||||
projectPeriod: 'October-November 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/process-simulator.jpg
|
||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
||||
tags: ['simulation', 'systems', 'tools']
|
||||
featuredOrder: 5
|
||||
role: Simulation and UI author
|
||||
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||
scale: Remote simulation server with multiple monitoring clients and a separate graph editor
|
||||
outcome: A believable, extensible cooling-system simulation for a cybersecurity challenge context
|
||||
audience: recruiter-relevant
|
||||
links: []
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/process-simulator.jpg
|
||||
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values.
|
||||
caption: The simulator calculated flow and temperature over graph-based process models.
|
||||
- type: image
|
||||
src: ./_assets/process-simulator-input.jpg
|
||||
alt: Screenshot of the JavaFX graph editor used to define simulator input.
|
||||
caption: A separate JavaFX editor produced JSON inputs for the simulation backend.
|
||||
---
|
||||
|
||||
This project simulated the cooling system of a nuclear facility. It was built for a cybersecurity challenge about PLCs, where participants needed to see the consequences of changing a system state.
|
||||
|
||||
The simulation did not try to be physically complete. It aimed to be cheaply calculated, believable to a non-specialist, scalable enough for the event context, and understandable through a clean GUI.
|
||||
|
||||
## The Problem
|
||||
|
||||
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 behaviour.
|
||||
|
||||
## Design
|
||||
|
||||
The system used two graph models. First, water was distributed by traversing the graph of pipes according to pressures generated by pumps. Then, an adjacency matrix was populated from the relations between nodes based on water flow.
|
||||
|
||||
After accounting for base temperatures, heaters, and heat exchangers, the matrix was solved to calculate current node temperatures. Repeating that process advanced the simulation.
|
||||
|
||||
Python handled the backend logic with Flask and NumPy. The monitoring frontend used an HTML5 canvas. A separate JavaFX graph editor let users move nodes, edit element parameters, export JSON, and upload inputs to the backend.
|
||||
|
||||
## What Worked
|
||||
|
||||
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.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
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 behaviour changes that still look plausible on screen.
|
||||
21
src/content/posts/photo-colour-grader.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: A Proof-of-Concept Photo Colour Grader
|
||||
description: A web UI experiment for selecting colours and transforming nearby ranges based on colour distance.
|
||||
date: 2026-04-30
|
||||
projectPeriod: 'June 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/photo-colour-grader.jpg
|
||||
alt: Screenshot of a photo colour grading interface.
|
||||
tags: ['graphics', 'web', 'tools']
|
||||
role: Interface and image processing author
|
||||
stack: ['JavaScript', 'Canvas', 'Image processing']
|
||||
outcome: A proof-of-concept colour grading interaction model
|
||||
audience: technical
|
||||
links: []
|
||||
---
|
||||
|
||||
This was a colour grader web application I built as a proof-of-concept to try out a few interaction ideas.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
21
src/content/posts/photo-site-generator.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: A Static Photo Site Generator
|
||||
description: A simple photography site generated from a directory of images with automatic resizing to multiple quality settings.
|
||||
date: 2026-04-27
|
||||
projectPeriod: 'Summer 2016'
|
||||
thumbnail:
|
||||
src: ./_assets/photos.jpg
|
||||
alt: Screenshot of a generated photography site.
|
||||
tags: ['web', 'tools']
|
||||
role: Site generator author
|
||||
stack: ['Webpack', 'Image processing', 'Static site generation']
|
||||
outcome: A generated static photo site for publishing photography with responsive image output
|
||||
audience: general
|
||||
links: []
|
||||
---
|
||||
|
||||
Photos was a small webpage where you could view my photos.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
23
src/content/posts/platform-game-c-sdl.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
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.'
|
||||
date: 2026-04-28
|
||||
projectPeriod: 'Autumn 2017'
|
||||
thumbnail:
|
||||
src: ./_assets/platform-game.jpg
|
||||
alt: Screenshot from a 3D platform game written in C.
|
||||
tags: ['games', 'systems']
|
||||
role: Game author
|
||||
stack: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||
outcome: A playable 3D course project that made programming feel like the right long-term direction
|
||||
audience: technical
|
||||
links: []
|
||||
---
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
82
src/content/posts/reconcile-text-3-way-merge.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
title: A 3-Way Text Merger That Never Shows Conflict Markers
|
||||
description: How reconcile-text borrows the idea of operational transformation and applies it to consolidated diffs to auto-resolve conflicting edits.
|
||||
date: 2026-05-21
|
||||
projectPeriod: '2025'
|
||||
thumbnail:
|
||||
src: ./_assets/reconcile.png
|
||||
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
|
||||
tags: ['systems', 'tools', 'web']
|
||||
featuredOrder: 2
|
||||
role: 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, well-tested library that fills a gap between git, CRDTs, and patch-based merging
|
||||
audience: recruiter-relevant
|
||||
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/
|
||||
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 resolves conflicting edits to prose by weaving them together instead of asking a human to choose.
|
||||
---
|
||||
|
||||
`reconcile-text` started from a concrete need. I wanted to synchronise Markdown notes across devices where the editor was not under my control, and where the only thing I could observe was the final text on each side. Vim on one machine, VS Code on another, Obsidian on a third. No keystroke stream, no operation log, just the documents and a shared common ancestor from the last successful sync.
|
||||
|
||||
That setting is awkward for almost every existing tool. Git is the closest fit, but `git merge-file` answers conflicts with markers, which is exactly what a sync tool cannot ship to a user's note. CRDTs and operational transformation assume you control the editing infrastructure all the way down to the keystroke. `diff-match-patch` produces patches without a common ancestor, and on adjacent edits it silently corrupts the output. None of these matched the shape of the problem I had.
|
||||
|
||||
So I wrote a library that does one specific thing: given a parent and two edited versions, return a single merged text that contains both sets of changes, without conflict markers and without dropping edits on the floor.
|
||||
|
||||
## The Problem
|
||||
|
||||
The hard part is not detecting a conflict. The hard part is resolving it well enough that a human is happy to read the result without thinking about merge mechanics.
|
||||
|
||||
Source code has hard correctness requirements, so refusing to choose and emitting markers is the right default. Human prose is more forgiving. A merged paragraph that is slightly clumsy is almost always preferable to one that interrupts the reader with `<<<<<<< HEAD`. That observation is the entire reason this library exists in the form it does.
|
||||
|
||||
The challenge was to commit to that asymmetry honestly. The library should always produce a result. It should never silently lose an edit. It should preserve cursors so a collaborative editor can rely on it. And it should do all of this from end states alone, with no operation history available.
|
||||
|
||||
## Constraints
|
||||
|
||||
The library had to live in three places: a Rust crate, a JavaScript package built through WebAssembly, and a Python package built through `pyo3`. The cross-language story was a constraint, not a stretch goal. The Obsidian plugin I was writing alongside it consumed the npm build, but I also wanted a clean Rust crate for sync engines and a Python package for scripting.
|
||||
|
||||
That ruled out anything that depended on language-specific runtime tricks. Generics, closures, and trait objects could live freely inside the Rust core, but the public surface had to be flat enough to cross both `wasm-bindgen` and `pyo3` without per-binding glue.
|
||||
|
||||
It also had to be predictable. There is no async story, no networking, no concurrency. A merge is a pure function from three strings to one string with some metadata. Everything that is not the merge itself was deliberately kept out.
|
||||
|
||||
## Design
|
||||
|
||||
The pipeline is short. The library tokenises the parent and the two edited versions, runs Myers' diff to compare each edited version against the parent, optimises the resulting edit sequences so that adjacent changes group together cleanly, and then weaves the two diffs into a single ordered sequence of operations that produces the merged text.
|
||||
|
||||
The weaving step borrows the concept of operational transformation, but applies it to a different problem. Classic OT transforms individual keystrokes against each other in real time. Here, OT is applied to the consolidated diff output of two complete edits. The structure is similar, but the inputs are batched and the algorithm only needs to run once per merge point. It became the simplest way I could find to describe how two sets of changes should be interleaved.
|
||||
|
||||
The tokeniser turned out to be more important than I initially expected. It is what decides whether a conflict exists in the first place. Word-level tokenisation, the default for prose, often turns a "conflict" into two adjacent independent edits that can coexist. Line-level tokenisation makes the library behave more like `git merge-file`. Markdown-level tokenisation merges on headings and list items rather than characters. Exposing this as a user-facing knob meant the library could be shaped to the document, not the other way around.
|
||||
|
||||
Cursors and selections were added as first-class merge inputs rather than something users reconstruct after the fact. Each cursor carries a stable ID and rides through the merge, ending up at a sensible position even when both sides edited the surrounding text. This is what made the library useful to anything resembling a collaborative editor.
|
||||
|
||||
The cross-language surface needed extra care. The tokeniser inside Rust is a `dyn Fn(&str) -> Vec<Token<T>>`, which is convenient in Rust and impossible to pass through `wasm-bindgen` or `pyo3`. The fix was to expose a closed enum of built-in tokenisers to non-Rust callers and reserve the generic version for Rust users. WebAssembly users also paid a real binary-size cost, so the release profile is tuned aggressively, and the JS package ships a small leak detector to remind callers that wasm-bindgen objects must be freed explicitly.
|
||||
|
||||
## What Worked
|
||||
|
||||
The strongest part of the project is that the result never has conflict markers and never silently drops an edit. That sounds modest, but it is exactly the property that makes the library usable inside a sync engine without an escape hatch.
|
||||
|
||||
Choosing the tokeniser as the main user-facing knob also held up well. Most of the "tuning" people want when merging prose is not a different algorithm, it is a different idea of what counts as a unit. Letting users choose between character, word, line, and Markdown granularity covered the realistic cases without inventing new merge strategies.
|
||||
|
||||
The comparison example against `diff-match-patch` was probably the most useful piece of writing in the repository. It is a runnable program, not a benchmark table, showing concrete cases where a popular alternative quietly produces wrong output. Having that as a falsifiable claim in the source tree made the value proposition much clearer than any prose description would have.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
If I revisited this now, I would invest more in formal property tests around the merge. Three-way merging is exactly the kind of problem where generated inputs find behaviours that hand-written tests do not, and the snapshot tests I have are good at catching regressions but not at finding unknown edge cases.
|
||||
|
||||
I would also be more explicit about the boundary the library does not cross. It is a merge point primitive, not a live collaboration engine. CRDTs and OT remain the right tools when you actually have a keystroke stream and a real-time channel. `reconcile-text` is for the part of the problem space where you do not.
|
||||
|
||||
The part I would keep is the asymmetry the project rests on. Human text deserves a merger that prefers a slightly imperfect sentence over a conflict marker, and that decision is what shaped every other choice in the design.
|
||||
67
src/content/posts/sdf-2d-ray-tracing.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
title: Tile-Based Optimization for 2D SDF Ray Tracing
|
||||
description: How SDF-2D used signed distance fields, dynamic shaders, and tile-based rendering ideas to make 2D ray tracing run well in the browser.
|
||||
date: 2026-05-08
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: SDF-2D browser demo with soft lighting effects.
|
||||
tags: ['graphics', 'web', 'systems']
|
||||
featuredOrder: 3
|
||||
role: Library author
|
||||
stack: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
|
||||
scale: Browser library with mobile-oriented real-time rendering and reusable demos
|
||||
outcome: Reusable NPM package and thesis project for efficient 2D SDF rendering
|
||||
audience: recruiter-relevant
|
||||
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
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
|
||||
caption: SDF-2D was built as a reusable TypeScript library rather than a single demo.
|
||||
---
|
||||
|
||||
SDF-2D was my attempt to make a small, reusable browser library for 2D scenes rendered with ray-tracing techniques. The rendering is based on signed distance fields, where geometry can be represented as functions that return the distance to the nearest surface.
|
||||
|
||||
The interesting part was not the basic idea. Signed distance fields are a known technique. The interesting part was making the approach fast and reusable enough for browser demos, including on mobile devices.
|
||||
|
||||
The project became one half of my BSc thesis, together with the multiplayer game `decla.red`, which used the rendering library in a real interactive setting.
|
||||
|
||||
## The Problem
|
||||
|
||||
Ray tracing and distance-field rendering can produce appealing 2D lighting and reflections, but a straightforward implementation spends too much work per pixel. A browser library also has to deal with device variation: WebGL capabilities, shader limits, mobile GPUs, and the overhead of generating scenes dynamically.
|
||||
|
||||
The goal was not to render one hand-tuned scene. The goal was a library with a simple API, reusable scene definitions, and real-time behavior.
|
||||
|
||||
## Constraints
|
||||
|
||||
The library had to support both WebGL and WebGL2. It had to run acceptably on phones. It had to avoid shipping scene-specific shader code by hand. And it had to expose an API that felt like a rendering library rather than a shader experiment.
|
||||
|
||||
Those constraints pushed the implementation toward generated shaders and capability-aware rendering paths.
|
||||
|
||||
## Design
|
||||
|
||||
The main optimization was inspired by tiled renderers. Instead of treating the entire screen uniformly, the renderer could reason about groups of pixels and avoid unnecessary work where possible.
|
||||
|
||||
That was paired with deferred shading and dynamic shader generation. Dynamic generation mattered because scenes and devices differ. If a feature or operation was not needed for a given scene or device, the generated shader could avoid carrying that cost.
|
||||
|
||||
The API was deliberately kept in TypeScript. That made the library easier to package, document, and reuse in projects that were already browser-first.
|
||||
|
||||
## What Worked
|
||||
|
||||
The project worked best when the library boundary was respected. A good demo can hide a messy implementation. A reusable package cannot. The API had to explain the rendering model without making every user think like a shader compiler.
|
||||
|
||||
The mobile constraint also improved the design. It forced performance work to be structural rather than cosmetic. When a technique works only on a powerful desktop GPU, it is easy to mistake headroom for good architecture.
|
||||
|
||||
## What I Would Change
|
||||
|
||||
Today I would write more instrumentation around shader variants and device behavior. The project had many optimizations, but stronger profiling output would have made tradeoffs easier to explain and compare.
|
||||
|
||||
I would also document the rendering pipeline with diagrams. The ideas are visual, and the explanation should be too.
|
||||
BIN
src/content/projects/_assets/ad-astra.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/content/projects/_assets/avoid.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/content/projects/_assets/city-simulation.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
src/content/projects/_assets/declared.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/content/projects/_assets/fleeting-garden.jpg
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
src/content/projects/_assets/forex.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/content/projects/_assets/great-ai.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/content/projects/_assets/leds.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/content/projects/_assets/my-notes.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
src/content/projects/_assets/nuclear-simulation.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/content/projects/_assets/photo-colour-grader.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
src/content/projects/_assets/photos.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/content/projects/_assets/platform-game.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/content/projects/_assets/process-simulator-input.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/content/projects/_assets/reconcile.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src/content/projects/_assets/sdf2d.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src/content/projects/_assets/towers.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
15
src/content/projects/ad-astra.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: Ad Astra
|
||||
description: A tiny embedded game engine and custom PCB built around an ATtiny85V.
|
||||
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
|
||||
---
|
||||
12
src/content/projects/avoid.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: Avoid
|
||||
description: A small early web game, kept as an archive of first experiments on the web.
|
||||
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
|
||||
---
|
||||
13
src/content/projects/city-simulation.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
title: City Simulation
|
||||
description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge.
|
||||
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: []
|
||||
---
|
||||