Compare commits

...

8 commits

Author SHA1 Message Date
1cdc202731 Stop reading clutter 2026-06-25 23:42:34 +02:00
82c723c192 Remove extra tags 2026-06-25 23:41:46 +02:00
11796a8869 Split up 2026-06-25 23:40:45 +02:00
c65114888c Add cspell 2026-06-25 23:40:27 +02:00
4ac244b3ef Make true 2026-06-25 15:45:06 +01:00
8064972c15 Clean up 2026-06-25 15:22:14 +01:00
0d65f01797 Remove rubbish 2026-06-25 15:21:53 +01:00
16c606c3e8 Locks 2026-06-25 11:53:03 +01:00
56 changed files with 2414 additions and 2453 deletions

View file

@ -3,8 +3,10 @@ name: Deploy to Pages
on: on:
push: push:
branches: ['main'] branches: ['main']
pull_request: pull_request:
branches: ['main'] branches: ['main']
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@ -28,33 +30,24 @@ jobs:
run: npm ci run: npm ci
- name: Lint - name: Lint
run: npm run lint run: |
npm run lint
- name: Typecheck npm run typecheck
run: npm run typecheck npm run qa:no-em-dashes
npm run qa:spelling
- name: Check for em dashes
run: npm run qa:no-em-dashes
- name: Build - name: Build
run: npm run build run: npm run build
- name: Astro audit - name: QA build
run: npm run qa:astro-audit run: |
npm run qa:astro-audit
npm run qa:links
npm run qa:no-js
npm run qa:overflow
npm run qa:preview-cropping
- name: Check internal links - name: On main, Copy build to host pages mount
run: npm run qa:links
- name: Check for unexpected JavaScript
run: npm run qa:no-js
- name: Check viewport overflow
run: npm run qa:overflow
- name: Check preview cropping
run: npm run qa:preview-cropping
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
with: with:

110
cspell.json Normal file
View file

@ -0,0 +1,110 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"language": "en,en-GB",
"ignorePaths": [
"node_modules/**",
"dist/**",
".astro/**",
"package-lock.json",
"*.svg",
"public/**",
"todo.md"
],
"words": [
"acked",
"acks",
"adblocker",
"apos",
"backpressure",
"backtest",
"bgsave",
"binaryen",
"bindgen",
"bitpacking",
"blockedbyclient",
"borgmann",
"btrfs",
"cheatable",
"collab",
"conveyal",
"crewmates",
"datasheet",
"datasheets",
"decla",
"dequant",
"desync",
"domcontentloaded",
"emelt",
"érettségi",
"extensionless",
"extrapolators",
"fizika",
"forex",
"fouc",
"funimation",
"gameboy",
"glsl",
"greatai",
"hanning",
"healthcheck",
"immich",
"immich's",
"importmap",
"janky",
"jemalloc",
"keepalives",
"laion",
"loggable",
"malloc",
"metas",
"mixolydian",
"mlockall",
"mosfets",
"nixplay's",
"ntfy",
"numba",
"numpy",
"nums",
"permacomputing",
"physarum",
"pocketbase",
"powerups",
"proptest",
"refetches",
"reimplementable",
"requantised",
"rgba",
"scalex",
"scaley",
"schmelczer",
"scrollables",
"serde",
"setpoints",
"shiki",
"slugifies",
"speculationrules",
"sqlx",
"stdlib",
"stft",
"strobing",
"stucki",
"subvolume",
"syncer",
"szintű",
"tamagotchis",
"tarjan's",
"tidepool",
"tmpfs",
"tweakpane",
"unsets",
"vaultlink",
"waveshare",
"waveshare's",
"webform",
"webgpu",
"wgsl",
"writeups",
"zstd"
]
}

24
package-lock.json generated
View file

@ -2473,6 +2473,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2512,6 +2513,7 @@
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4803,6 +4805,7 @@
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@ -4819,6 +4822,7 @@
"integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@astrojs/compiler": "^2.9.1", "@astrojs/compiler": "^2.9.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
@ -5178,6 +5182,7 @@
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@ -5499,6 +5504,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5547,6 +5553,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6320,6 +6327,7 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@ -7832,7 +7840,8 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true "dev": true,
"peer": true
}, },
"svgo": { "svgo": {
"version": "4.0.1", "version": "4.0.1",
@ -7854,6 +7863,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -9297,13 +9307,15 @@
"version": "3.8.3", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true "dev": true,
"peer": true
}, },
"prettier-plugin-astro": { "prettier-plugin-astro": {
"version": "0.14.1", "version": "0.14.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz", "resolved": "https://registry.npmjs.org/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz",
"integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@astrojs/compiler": "^2.9.1", "@astrojs/compiler": "^2.9.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
@ -9560,6 +9572,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm-eabi": "4.60.3",
"@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3",
@ -9780,7 +9793,8 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true "dev": true,
"peer": true
} }
} }
}, },
@ -9812,7 +9826,8 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true "dev": true,
"peer": true
}, },
"typescript-auto-import-cache": { "typescript-auto-import-cache": {
"version": "0.3.6", "version": "0.3.6",
@ -10277,6 +10292,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",

View file

@ -17,11 +17,12 @@
"audit:astro": "npm run build && node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs", "audit:astro": "npm run build && node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs",
"qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues", "qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues",
"qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs", "qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
"qa:spelling": "cspell --no-progress \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/**/*.mjs\" \"*.md\"",
"qa:links": "node scripts/check-links.mjs", "qa:links": "node scripts/check-links.mjs",
"qa:no-js": "node scripts/check-no-js.mjs", "qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs", "qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
"qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs", "qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs",
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping" "qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run qa:spelling && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -45,6 +46,7 @@
"@astrojs/sitemap": "^3.7.2", "@astrojs/sitemap": "^3.7.2",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"astro": "^6.3.1", "astro": "^6.3.1",
"cspell": "^10.0.1",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",

View file

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

View file

@ -5,7 +5,6 @@ import ProjectLinks from './ProjectLinks.astro';
type Link = CollectionEntry<'work'>['data']['links'][number]; type Link = CollectionEntry<'work'>['data']['links'][number];
interface Props { interface Props {
role?: string;
projectPeriod?: string; projectPeriod?: string;
stack?: string[]; stack?: string[];
scale?: string; scale?: string;
@ -14,18 +13,9 @@ interface Props {
headingId: string; headingId: string;
} }
const { const { projectPeriod, stack = [], scale, outcome, links = [], headingId } = Astro.props;
role,
projectPeriod,
stack = [],
scale,
outcome,
links = [],
headingId,
} = Astro.props;
const rows: Array<[string, string]> = []; const rows: Array<[string, string]> = [];
if (role) rows.push(['Role', role]);
if (projectPeriod) rows.push(['Period', projectPeriod]); if (projectPeriod) rows.push(['Period', projectPeriod]);
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]); if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
if (scale) rows.push(['Scale', scale]); if (scale) rows.push(['Scale', scale]);

View file

@ -18,7 +18,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
{ {
item.type === 'video' ? ( item.type === 'video' ? (
// Decorative videos stay inert and hidden from assistive tech. Meaningful // Decorative videos stay inert and hidden from assistive tech. Meaningful
// videos expose controls, captions, and an accessible name. // videos expose controls and an accessible name.
item.decorative ? ( item.decorative ? (
<video <video
muted muted
@ -44,15 +44,6 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
> >
{item.webm && <source src={item.webm} type="video/webm" />} {item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />} {item.mp4 && <source src={item.mp4} type="video/mp4" />}
{item.captions && (
<track
kind="captions"
src={item.captions}
srclang="en"
label={item.captionsLabel}
default
/>
)}
</video> </video>
) )
) : ( ) : (

View file

@ -111,17 +111,6 @@ const playScript = `
> >
{video.webm && <source src={video.webm} type="video/webm" />} {video.webm && <source src={video.webm} type="video/webm" />}
{video.mp4 && <source src={video.mp4} type="video/mp4" />} {video.mp4 && <source src={video.mp4} type="video/mp4" />}
{
video.captions && (
<track
kind="captions"
src={video.captions}
srclang="en"
label={video.captionsLabel}
default
/>
)
}
</video> </video>
<noscript> <noscript>

View file

@ -64,13 +64,12 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
alt: z.string().min(1, 'Thumbnail alt text must not be empty.'), alt: z.string().min(1, 'Thumbnail alt text must not be empty.'),
}); });
// Partial thumbnail used by the project card to override just the image, just // Thumbnail override used by the project card to swap just the image when the
// the alt text, or both. Anything omitted falls back to the entry's top-level // card wants a different crop from the article. The alt text is never
// thumbnail, so a card that only rewords its alt doesn't repeat the `src`. // overridden, so it stays a single source of truth on the top-level thumbnail.
const thumbnailOverrideSchema = ({ image }: SchemaContext) => const thumbnailOverrideSchema = ({ image }: SchemaContext) =>
z.object({ z.object({
src: image().optional(), src: image(),
alt: z.string().min(1, 'Thumbnail alt text must not be empty.').optional(),
}); });
const mediaSchema = ({ image }: SchemaContext) => const mediaSchema = ({ image }: SchemaContext) =>
@ -90,8 +89,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
poster: image().optional(), poster: image().optional(),
mp4: mediaUrl.optional(), mp4: mediaUrl.optional(),
webm: mediaUrl.optional(), webm: mediaUrl.optional(),
captions: mediaUrl.optional(),
captionsLabel: z.string().default('English captions'),
alt: z.string().optional(), alt: z.string().optional(),
decorative: z.boolean().optional(), decorative: z.boolean().optional(),
caption: z.string().optional(), caption: z.string().optional(),
@ -104,12 +101,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), { .refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
message: 'Meaningful media needs both alt text and a 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( .refine(
(item) => item.type !== 'video' || item.decorative || Boolean(item.transcript), (item) => item.type !== 'video' || item.decorative || Boolean(item.transcript),
{ {
@ -130,11 +121,9 @@ const articleFacet = ({ image }: SchemaContext) =>
iframeThumbnail: z.boolean().default(false), iframeThumbnail: z.boolean().default(false),
featuredOrder: z.number().optional(), featuredOrder: z.number().optional(),
tags: z.array(z.enum(TAGS)).default([]), tags: z.array(z.enum(TAGS)).default([]),
role: z.string().min(1).optional(),
stack: z.array(z.string()).optional(), stack: z.array(z.string()).optional(),
scale: z.string().min(1).optional(), scale: z.string().min(1).optional(),
outcome: z.string().min(1).optional(), outcome: z.string().min(1).optional(),
audience: z.enum(['general', 'technical', 'recruiter-relevant']).default('technical'),
media: z.array(mediaSchema({ image })).default([]), media: z.array(mediaSchema({ image })).default([]),
}); });
@ -145,7 +134,6 @@ const projectFacet = ({ image }: SchemaContext) =>
technologies: z.array(z.string()).optional(), technologies: z.array(z.string()).optional(),
// Card overrides. When omitted, the card uses the top-level value. // Card overrides. When omitted, the card uses the top-level value.
title: z.string().optional(), title: z.string().optional(),
description: z.string().max(160).optional(),
thumbnail: thumbnailOverrideSchema({ image }).optional(), thumbnail: thumbnailOverrideSchema({ image }).optional(),
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

View file

@ -1,7 +1,7 @@
--- ---
title: A 50 FPS Game Engine on an 8-Bit Microcontroller title: A 50 FPS Game Engine on an 8-Bit Microcontroller
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM. 8 MHz, 8-bit ALU, a locked 20 ms frame, and a 52-byte save file.' description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver. 8 MHz, 512 bytes of RAM, and a charming atmosphere.'
date: 2026-05-06 date: 2020-03-26
period: 'Spring 2020' period: 'Spring 2020'
thumbnail: thumbnail:
src: ./_assets/ad-astra.jpg src: ./_assets/ad-astra.jpg
@ -11,59 +11,64 @@ links:
url: https://github.com/schmelczer/ad_astra url: https://github.com/schmelczer/ad_astra
article: article:
tags: ['embedded', 'games', 'systems'] tags: ['embedded', 'games', 'systems']
role: Hardware and firmware author
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad'] stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
scale: 8 MHz, 8-bit ALU, 64×32 rendered pixels at a locked 20 ms frame, ~31 mW at full brightness, ~1.5 mA standby scale: 8 MHz, 8-bit ALU, 64×32 rendered pixels at a locked 20 ms frame, ~31 mW at full brightness, ~1.5 mA standby
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
audience: technical
media: media:
- type: video - type: video
poster: ./_assets/ad-astra.jpg poster: ./_assets/ad-astra.jpg
webm: /media/video/ad_astra.webm webm: /media/video/ad_astra.webm
mp4: /media/video/ad_astra.mp4 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. alt: Video demonstration of the embedded game running on a small OLED display.
caption: The whole thing, from board and firmware to sprites and game loop, runs on a single ATtiny85V at 8 MHz.
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
project: project:
title: Ad Astra title: Ad Astra
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, locked 50 FPS.'
selected: true selected: true
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design'] technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
thumbnail:
alt: The Ad Astra handheld game running on its OLED display.
--- ---
I'd done microcontroller work before, but always on someone else's dev board, and it always felt a little like renting. In spring 2020 I wanted to know what it would feel like to own the whole thing, so I drew a schematic in KiCad, ordered the PCB, and soldered an ATtiny85V onto a board with an OLED, an IR receiver, and not much else. The feeling turned out to be very specific: once the copper was mine, a glitch was never just a software inconvenience again. Every bug was the consequence of a decision I had personally made, somewhere between the board layout and the C. Four years after [my first hardware project](/articles/lights-synchronized-to-music/), this was the one where debugging stopped being guesswork about other people's choices. I grew up playing with little widgets; fake tamagotchis, gameboy imitations with 100+ built-in games and the alike. It's just natural that I wanted to make my own.
The budget the chip gives you: an 8-bit ALU at 8 MHz, 8 KB of flash, 512 bytes of RAM, no FPU, no dedicated SPI peripheral. The game had to fit inside that, hold 50 FPS (the main loop locks every frame to 20 ms), and still leave room for the art. ## The game
## Eight pixels at a time You keep a broken spaceship alive. Asteroids drift past; you mine them and spend the points gained on upgrades, each of which unlocks a new station to tend, or a new crewmate.
The OLED is 128×64, but the firmware never sees that resolution. It renders at 64×32 and lets the panel double every pixel, which nobody notices at that physical size and which quarters the work per frame. ## Reality check
I settled on the ATtiny85 as my platform, connected to a common 128×64 resolution 1-bit OLED display. I strived to keep the footprint small, so intead of a built-in controller as the input device, I opted for an IR receiver which is compatible with a wide-range of remote controllers. I ended up using a generic HiFi system's little plastic remote which was incredibly cheap to get on AliExpress. Routing the PCB was as simple as connecting the square peg with the square hole. The real challenge was the firmware.
I had aspired to build a game with an open world, "advanced" AI, etc. but was quickly got the reality check when I had to realise that even after bitpacking the framebuffer, it would take up twice the space of the microcontroller's 512 byte (S)RAM. Meanwhile, the 8KB program memory wasn't generous enough either to support grandiose ambitions. I still made it work though.
## Rendering engine
Let's look at the rendering engine. To achieve 50 FPS, the rendering happens at 1/4 resolution of 64 by 32 pixels and the OLED panel scales up the image by a factor of 2 on its own so we don't need to waste bandwidth transferring the extra pixels. Even though the screen's pixels are either on or off, a basic expectation of a rendering engine is compositing. So we can't just store the frame as a 1 bit buffer but we have to assign an additional transparency bit per pixel. This way, we can express that in the spaceship's sprite, the black pixels on the inside are meant to hide the stars behind, but the outside black pixels are transparent, so the stars should show through.
> We should realise that 2 bits per pixel is wasteful as the valid states are: black, white, and transparent black; as there's no difference between transparent and non-transparent white, both would result in a white pixel in the end regardless of what was behind it.
> So in practice, I made an arbitrary choice for the 4th state to use it as a "flickering" state which changes between black & white every frame allowing for simple animations without having to mutate the sprites to achieve this.
Keeping the entire framebuffer in memory would be impossible as 64\*32\*2 is exactly the amount of memory available on the microcontroller. A pragmatic choice at this point would've been to just get a bigger microcontroller. But hobby projects are allowed to be non-pragmatic at times. So I carried on and optimised the renderer to work in a streaming manner on 8 bits at a time processing 8 pixels simultenously in a SIMD manner.
Sprites are stored as 16-bit columns: the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of sprite onto the framebuffer is one expression: Sprites are stored as 16-bit columns: the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of sprite onto the framebuffer is one expression:
```c ```c
newColumn = oldColumn & ~invertedMask | fill; newColumn = oldColumn & transparencyMask | fill;
``` ```
One AND, one OR, eight pixels handled. The chip has no SIMD, but every byte-wide operation already works on eight pixels at once if you arrange the data so that it can. Arranging for it (in the sprite format, in the compositing window, in the Python script that generates the C arrays) is most of what the display driver is. The panel itself speaks SPI, which the ATtiny doesn't have, so the driver bit-bangs it through the USI peripheral, four register writes per bit. The sprites are C arrays injected into the source code by a python script, so the somewhat strange layout doesn't leak to the art level. Lastly, the ATtiny bit-bangs the 8 pixels to the display, as there's no dedicated SPI peripheral.
## An object model that fits on one screen ## Game logic
Every game object points at a `Prototype`, a struct holding exactly three things: a `tick` function, a `draw` function, and a size. Dispatch means following a pointer. There are at most ten live objects (one spaceship, one astronaut you steer between stations, up to four asteroids drifting past), so the "engine" is a fixed array and a for loop, and that is genuinely all the engine this game needs. I was inspired by JavaScript's prototype system and Unity's object system for scripting the game engine. Of course, only a lite version was practicable due to the size constraints and the final design ended up with a single game object array which holds a Union of object states and a pointer to the prototype of each object which is a special struct, essentially a vft, pointing to the object's `tick` and `draw` functions. Even though the simulation and rendering follow the same 50 Hz ticks, the functions are separate as `draw` has to support rendering a 8 pixel stripe of the object an thus may get called multiple times. This quasi-vft approach doesn't become a performance bottleneck due to the beauty of the AtTiny only having to deal with SRAM and no complex caching and line-faults.
## The game, since you ask In the end, the game does have "AI" too, in the form of a FSM where your astronaut crewmates have a priority list of which activity to do if possible based on the player's current activity.
You keep a broken spaceship alive. Asteroids drift past; you mine them and spend the proceeds on upgrades (beds, then a turret, then a table), each of which unlocks a new station to tend. Firing the turret costs health, so defending the ship is a real decision rather than a reflex. The table's interaction handler is a function called `showLove`, which is the kind of thing you find in your own four-year-old firmware and decide to leave exactly as it is. ## Saves
## EEPROM holds the art and the saves The program memory is only 8 KB, so the sprites have to live in the EEPROM. However, there's enough space to cram a few dozen bytes in there too for the save files. To avoid corrupting the saved state with an unexpected shutdown, there are two buffers maintained for them inside the EEPROM with a flag to tell which one is the last completed one. The flag is only flipped once the saved data is put into the least recently use save buffer ensuring atomicity during saves. This all happens in the background through interrupts to avoid jitters in the rendering loop.
Flash was for code, so the sprites live in EEPROM, packed into the 16-bit column format by a Python script that eats PNG sheets and emits C arrays; the spaceship alone is 216 bytes. Save state is 52 bytes: hull health, astronaut count, progression, screen contrast. Writes happen byte by byte from an interrupt handler and skip any byte that hasn't changed, so saving never stalls the frame loop and the EEPROM's limited write endurance gets spared. ## Was it worth it?
## What I'd change Absolutely. Don't get me wrong, the gameplay gets old after 15 mins, of which you spend the first 5 completing the game and the rest is just watching your crewmates operate the spaceship automomously.
- **A host-side emulator.** Debugging on the hardware was character-building and slow. A small SDL simulator linking the same C files would have turned "reflash and hope" into "rebuild and run". It was incredibly fun to read the datasheets cover-to-cover and actually understand the limits and how to push them. On the one hand, this is as full-stack as it gets, and it allowed me to think about performance on a different scale than usually. On the other hand, having to decide which part of the code to disable to make space for the UART implementation used for printing logs as by the end of the project, it wouldn't have fit into program memory.
- **Power-loss-safe saves.** The byte-by-byte writes are gentle on the EEPROM but they aren't atomic; pull the battery mid-save and you can get a torn save. A double-buffered save with a commit byte would have cost a handful of the bytes I wasn't using.
- **Battery numbers I'd actually trust.** I measured peak and standby draw, but never a full gameplay session, so the honest answer to "how long does it last" is that it outlasted my patience. Redoing the project, I believe writing a simulator for the console would've been the right call to allow faster iteration without going through the flashing process and better debug the "there's no output, what now" types of issues. Adding a simulator would also mean E2E testing in CI. Fortunately, there's only so much complexity that an 8KB program, but anything bigger would be prohibitivly expensive to develop without a better testing setup.

View file

@ -13,16 +13,11 @@ links:
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
article: article:
tags: ['systems', 'tools'] tags: ['systems', 'tools']
role: Container and script author
stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd'] stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
scale: One container, multiple targets per host, two years of restored incidents scale: One container, multiple targets per host, two years of restored incidents
outcome: A self-hosted backup that has survived every actual incident I've thrown at it outcome: A self-hosted backup that has survived every actual incident I've thrown at it
audience: technical
project: project:
title: Backup Container title: Backup Container
description: A Bash container around BorgBackup. BTRFS snapshot for atomic consistency, numeric env vars for multi-target 3-2-1, sleep-loop instead of cron.
thumbnail:
alt: Placeholder thumbnail for the backup container project.
--- ---
Once you self-host a few services with live databases, the backup question stops being theoretical: everything on the box is mid-write at every moment of the day. This container is my answer, two years and several real restores in (including the photo library behind the [e-ink frame](/articles/frame-eink-photo-display/)). One Alpine container and four short shell scripts (the longest is 84 lines) push a BTRFS snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...); there's no config format and no DSL, because the env file is the configuration. The design has exactly one clever moment, the snapshot, and I've worked to keep everything else too simple to break. Once you self-host a few services with live databases, the backup question stops being theoretical: everything on the box is mid-write at every moment of the day. This container is my answer, two years and several real restores in (including the photo library behind the [e-ink frame](/articles/frame-eink-photo-display/)). One Alpine container and four short shell scripts (the longest is 84 lines) push a BTRFS snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...); there's no config format and no DSL, because the env file is the configuration. The design has exactly one clever moment, the snapshot, and I've worked to keep everything else too simple to break.

View file

@ -8,15 +8,10 @@ thumbnail:
alt: Screenshot of a Unity traffic simulation. alt: Screenshot of a Unity traffic simulation.
article: article:
tags: ['simulation', 'systems'] tags: ['simulation', 'systems']
role: Simulation author
stack: ['Unity', 'C#', 'REST API', 'Blender'] stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: Visible consequences for an otherwise abstract PLC challenge outcome: Visible consequences for an otherwise abstract PLC challenge
audience: technical
project: project:
title: City Simulation title: City Simulation
description: A Unity city where REST-controlled traffic lights made bad PLC code visible as car crashes.
thumbnail:
alt: Screenshot of a Unity city traffic simulation.
--- ---
Most security challenges punish a wrong answer with a red "incorrect". This one punished it with car wrecks. For a PLC cybersecurity event in the summer of 2018, I built a small Unity city where the traffic lights were driven over a REST API by whatever control logic the contestants wrote. Good logic produced boring, flowing traffic. Bad logic produced intersections that people gathered around to watch. Most security challenges punish a wrong answer with a red "incorrect". This one punished it with car wrecks. For a PLC cybersecurity event in the summer of 2018, I built a small Unity city where the traffic lights were driven over a REST API by whatever control logic the contestants wrote. Good logic produced boring, flowing traffic. Bad logic produced intersections that people gathered around to watch.

View file

@ -14,11 +14,9 @@ links:
download: true download: true
article: article:
tags: ['games', 'web', 'systems'] tags: ['games', 'web', 'systems']
role: Game and backend systems author
stack: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL', 'SDF-2D'] stack: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL', 'SDF-2D']
scale: Game servers ticking at 200 Hz, each serving 1632 clients at 25 updates a second, browser and mobile scale: Game servers ticking at 200 Hz, each serving 1632 clients at 25 updates a second, browser and mobile
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
audience: technical
media: media:
- type: image - type: image
src: ./_assets/decla-red.jpg src: ./_assets/decla-red.jpg
@ -26,7 +24,6 @@ article:
caption: Two teams, small planets, real gravity. The renderer underneath is the SDF-2D library from my thesis. caption: Two teams, small planets, real gravity. The renderer underneath is the SDF-2D library from my thesis.
project: project:
title: decla.red title: decla.red
description: A multiplayer space shooter where client and server import the same rules package, the server ticks at 200 Hz, and the wire format is class names.
selected: true selected: true
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL'] technologies: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL']
thumbnail: thumbnail:

View file

@ -13,16 +13,11 @@ links:
url: https://home.schmelczer.dev/git/andras/fizika url: https://home.schmelczer.dev/git/andras/fizika
article: article:
tags: ['web', 'tools'] tags: ['web', 'tools']
role: Question database, frontend, backend
stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage'] stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
outcome: A free practice app real students still find when they search for past érettségi physics papers outcome: A free practice app real students still find when they search for past érettségi physics papers
audience: general
project: project:
title: Fizika title: Fizika
description: 'I needed it for my own physics érettségi: 682 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage'] technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
thumbnail:
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
--- ---
I needed it. In my last year of high school I was about to sit the advanced-level (_emelt szintű_) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening in 2017 I started typing past exam questions into a JSON file. I needed it. In my last year of high school I was about to sit the advanced-level (_emelt szintű_) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening in 2017 I started typing past exam questions into a JSON file.

View file

@ -14,11 +14,9 @@ links:
article: article:
iframeThumbnail: true iframeThumbnail: true
tags: ['graphics', 'simulation', 'web'] tags: ['graphics', 'simulation', 'web']
role: Graphics and shader author
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane'] stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
scale: One HTML file, 10 WGSL shaders, 6 vibe presets, up to 1.5M agents, 60 FPS target on consumer hardware scale: One HTML file, 10 WGSL shaders, 6 vibe presets, up to 1.5M agents, 60 FPS target on consumer hardware
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
audience: technical
media: media:
- type: image - type: image
src: ./_assets/fleeting-garden.jpg src: ./_assets/fleeting-garden.jpg
@ -26,7 +24,6 @@ article:
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone. caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
project: project:
title: Fleeting Garden title: Fleeting Garden
description: A single-file WebGPU drawing toy. Your strokes seed a swarm of up to a million agents; nine numbers per vibe give each preset its personality.
selected: true selected: true
--- ---

View file

@ -8,15 +8,10 @@ thumbnail:
alt: Chart comparing predicted and actual EUR/USD exchange rates. alt: Chart comparing predicted and actual EUR/USD exchange rates.
article: article:
tags: ['systems', 'tools'] tags: ['systems', 'tools']
role: Experiment author
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4'] stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
audience: technical
project: project:
title: Foreign Exchange Prediction Experiment title: Foreign Exchange Prediction Experiment
description: A Hanning-windowed STFT experiment on EUR/USD. Passable backtest, sober conclusions, no real money risked.
thumbnail:
alt: Chart from a foreign exchange prediction experiment.
--- ---
In the autumn of 2019 I was an undergrad with a few free weekends and the quiet conviction that I could find a small edge on EUR/USD. The screenshots that survive are flattering: the predicted rate in blue hugging the actual rate in green closely enough to look like skill. It was a linear extrapolation in the frequency domain wearing a nice coat. In the autumn of 2019 I was an undergrad with a few free weekends and the quiet conviction that I could find a small edge on EUR/USD. The screenshots that survive are flattering: the predicted rate in blue hugging the actual rate in green closely enough to look like skill. It was a linear extrapolation in the frequency domain wearing a nice coat.

View file

@ -11,7 +11,6 @@ links:
url: https://home.schmelczer.dev/git/andras/frame url: https://home.schmelczer.dev/git/andras/frame
article: article:
tags: ['embedded', 'systems', 'tools'] tags: ['embedded', 'systems', 'tools']
role: Frame builder and pipeline author
stack: stack:
[ [
'Python', 'Python',
@ -24,7 +23,6 @@ article:
] ]
scale: One panel, one household, ~64 refreshes a day at peak scale: One panel, one household, ~64 refreshes a day at peak
outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies
audience: general
media: media:
- type: image - type: image
src: ./_assets/frame.jpg src: ./_assets/frame.jpg
@ -32,7 +30,6 @@ article:
caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them. caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them.
project: project:
title: Frame title: Frame
description: A LAN-only e-ink photo frame. Pulls from self-hosted Immich, gated on Home Assistant presence, Atkinson-dithered to 6 colours, no cloud.
selected: true selected: true
technologies: technologies:
[ [

View file

@ -1,26 +0,0 @@
---
title: A JavaFX Editor for the Cooling Simulator
description: Companion editor for the cooling-system sim. Drag-and-drop plant layout, JSON export, upload-to-backend. The smallest tool, and the one the event leaned on.
date: 2026-04-25
period: 'October-November 2018'
thumbnail:
src: ./_assets/process-simulator-input.jpg
alt: JavaFX graph editor for the cooling system simulator.
article:
tags: ['simulation', 'tools']
role: Editor author
stack: ['JavaFX', 'JSON', 'REST API']
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
audience: technical
project:
title: Graph Editor
description: A drag-and-drop JavaFX editor that let event organisers reconfigure the cooling sim without me sitting next to them.
thumbnail:
alt: JavaFX editor interface for the cooling system simulator input graph.
---
The brief was one sentence: non-technical event organisers needed to rewire a simulated cooling plant, live, without me hovering behind them. That sentence quietly ruled out every interface I would have enjoyed building: config files, a DSL, anything with a curly brace in it. The [cooling-system sim](/articles/nuclear-cooling-simulation/) was only going to be as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay the plant out as a graph, drag pipes between pumps and coolers, tune each element's parameters in a side panel, then export JSON or upload it straight to the backend.
It was the smallest piece of the whole project and the piece the event quietly depended on. A simulator only its author can reconfigure is a demo; the editor is what made it a tool other people could own for a weekend.
If I built it again I'd skip JavaFX and put the editor in the browser, next to the monitoring clients. Cross-platform desktop packaging was a fight, and every install I asked of the organisers was one more reason for them to call me over, which was the exact thing the brief said to prevent.

View file

@ -17,11 +17,9 @@ links:
article: article:
featuredOrder: 1 featuredOrder: 1
tags: ['ai', 'systems', 'tools'] tags: ['ai', 'systems', 'tools']
role: Researcher and framework author
stack: ['Python', 'decorators', 'FastAPI', 'survey design'] stack: ['Python', 'decorators', 'FastAPI', 'survey design']
scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers
outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area
audience: recruiter-relevant
media: media:
- type: image - type: image
src: ./_assets/great-ai.png src: ./_assets/great-ai.png
@ -29,7 +27,6 @@ article:
caption: A working GreatAI service is about ten lines on top of a plain prediction function. caption: A working GreatAI service is about ten lines on top of a plain prediction function.
project: project:
title: GreatAI title: GreatAI
description: One decorator on a Python function turned it into a deployed ML service. MSc thesis with a survey to back the API choices.
selected: true selected: true
technologies: ['Python', 'ML deployment', 'API design'] technologies: ['Python', 'ML deployment', 'API design']
--- ---

View file

@ -12,11 +12,9 @@ links:
article: article:
featuredOrder: 4 featuredOrder: 4
tags: ['systems', 'web', 'tools'] tags: ['systems', 'web', 'tools']
role: Full-stack author
stack: ['Python', 'Angular', 'TypeScript', 'FastAPI', 'SQLite'] stack: ['Python', 'Angular', 'TypeScript', 'FastAPI', 'SQLite']
scale: Multi-device goal and task state shared between clients and a server scale: Multi-device goal and task state shared between clients and a server
outcome: A goal tracker still in use, and a lesson about when clever sync isn't worth it outcome: A goal tracker still in use, and a lesson about when clever sync isn't worth it
audience: recruiter-relevant
media: media:
- type: image - type: image
src: ./_assets/towers.jpg src: ./_assets/towers.jpg
@ -24,7 +22,6 @@ article:
caption: Towers of finished tasks, one column per goal. Done blocks fall into place with a small gravity animation. caption: Towers of finished tasks, one column per goal. Done blocks fall into place with a small gravity animation.
project: project:
title: Life Towers title: Life Towers
description: A multi-device goal tracker where the clever trie lost to send-the-whole-tree with a version counter. The towers were the UI; the protocol was the lesson.
selected: true selected: true
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trees'] technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trees']
--- ---

View file

@ -8,15 +8,10 @@ thumbnail:
alt: RGB LED strips lit by a music synchronisation project. alt: RGB LED strips lit by a music synchronisation project.
article: article:
tags: ['systems', 'tools'] tags: ['systems', 'tools']
role: Hardware and software author
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web'] stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
outcome: The first non-trivial project I started and finished outcome: The first non-trivial project I started and finished
audience: technical
project: project:
title: Lights Synchronized to Music title: Lights Synchronized to Music
description: Raspberry Pi music player, NumPy FFT, MOSFETs, RGB strips. The first thing I built that I actually finished.
thumbnail:
alt: RGB LED strips glowing from a music synchronization project.
--- ---
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and no idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard, which is how I learned, slowly and because I had to, to read a datasheet. This was the first thing I ever started and actually finished. Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and no idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard, which is how I learned, slowly and because I had to, to read a datasheet. This was the first thing I ever started and actually finished.

View file

@ -1,27 +0,0 @@
---
title: 'My Notes: A Markdown App for Android'
description: A small Android note app built on Markwon. The idea wasn't new; the point was learning a platform that wasn't the web.
date: 2026-05-02
period: 'November 2019'
thumbnail:
src: ./_assets/my-notes.png
alt: Screenshots of the My Notes Android app.
links:
- label: Source
url: https://github.com/schmelczer/my-notes
article:
tags: ['tools']
role: Android app author
stack: ['Android', 'Markdown', 'Markwon']
outcome: A working notes app and my first time outside the web stack
audience: technical
project:
title: My Notes
description: A small Android Markdown note app. The point was a few weeks outside the web stack.
thumbnail:
alt: Screenshot of the My Notes Android markdown app.
---
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then watched it lose a long, fair fight with Obsidian. The losing was the useful part: I learned what I actually wanted from a notes app by watching mine fail to be it, one small daily annoyance at a time. Years later the same itch resurfaced as [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently edited copies back into one.
The app itself was modest: Markdown notes, hashtag filtering, Markwon doing the rendering. Every developer writes a notes app eventually, and the bar for shipping one isn't high. What I was really buying was a few weeks somewhere that wasn't the web: a platform with different opinions about lifecycle, storage, and what happens to your process when the user looks away. Android delivered exactly that, and I'd still recommend "build a small thing on an unfamiliar platform" as the cheapest way to find out which of your habits are skills and which are just local customs.

View file

@ -9,11 +9,9 @@ thumbnail:
article: article:
featuredOrder: 5 featuredOrder: 5
tags: ['simulation', 'systems', 'tools'] tags: ['simulation', 'systems', 'tools']
role: Simulation and UI author
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX'] stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
audience: recruiter-relevant
media: media:
- type: image - type: image
src: ./_assets/process-simulator.jpg src: ./_assets/process-simulator.jpg
@ -25,7 +23,6 @@ article:
caption: The JavaFX editor produced JSON that the simulator ate as input. caption: The JavaFX editor produced JSON that the simulator ate as input.
project: project:
title: Cooling System Simulation title: Cooling System Simulation
description: 'A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal and heat as a matrix solve: two passes instead of one PDE.'
selected: true selected: true
thumbnail: thumbnail:
src: ./_assets/nuclear-simulation.jpg src: ./_assets/nuclear-simulation.jpg
@ -48,10 +45,12 @@ As physics, this is wrong; flow and heat are coupled, and a real plant knows it.
## The editor mattered more than the solver ## The editor mattered more than the solver
The most-used interface in the whole system was the input editor, a separate JavaFX tool where organisers laid out the plant, parameterised each element, and exported the JSON the sim consumed. It earned [its own write-up](/articles/graph-editor-javafx-simulation-input/), because in hindsight it was its own project: the simulator decided what was possible, but the editor decided who could use it. The brief for the editor was one sentence: non-technical organisers needed to rewire the plant, live, without me hovering behind them. That quietly ruled out every interface I'd have enjoyed building, namely config files, a DSL, anything with a curly brace in it. So the most-used piece of the whole system ended up being a separate JavaFX desktop tool where organisers laid the plant out as a graph, dragged pipes between pumps and coolers, tuned each element's parameters in a side panel, and exported the JSON the sim consumed, or uploaded it straight to the backend.
It was the smallest piece of the project and the one the event quietly depended on. A simulator only its author can reconfigure is a demo; the editor is what made it a tool other people could own for a weekend. The simulator decided what was possible, but the editor decided who could use it.
## What I'd change ## What I'd change
- **State what the model claims.** A convincing simulator owes its audience an honest README about what it does and doesn't capture. Mine didn't have one, and anyone who took the numbers seriously could have walked away believing more than the model deserved. - **State what the model claims.** A convincing simulator owes its audience an honest README about what it does and doesn't capture. Mine didn't have one, and anyone who took the numbers seriously could have walked away believing more than the model deserved.
- **Recorded scenarios as regression tests.** Simulations drift in ways that still look plausible on screen. A few stored "this input over 60 seconds produces these outputs" runs would have caught me when I broke the temperature solver, which I did on the Saturday morning of the event, naturally. - **Recorded scenarios as regression tests.** Simulations drift in ways that still look plausible on screen. A few stored "this input over 60 seconds produces these outputs" runs would have caught me when I broke the temperature solver, which I did on the Saturday morning of the event, naturally.
- **Skip JavaFX.** Cross-platform packaging was a fight, and a web editor living next to the monitoring clients would have meant one fewer install for everyone. - **Skip JavaFX.** Cross-platform desktop packaging was a fight, and every install I asked of the organisers was one more reason to call me over, which is the exact thing the brief said to prevent. A web editor living next to the monitoring clients would have meant one fewer install for everyone.

View file

@ -11,7 +11,6 @@ links:
url: https://perfect-postcode.co.uk url: https://perfect-postcode.co.uk
article: article:
tags: ['systems', 'web', 'tools'] tags: ['systems', 'web', 'tools']
role: Server architect and operator
stack: stack:
[ [
'Rust', 'Rust',
@ -28,7 +27,6 @@ article:
] ]
scale: ~25M historical properties, ~2.5M postcodes, ~150 numeric features per row, all in RAM on a single VM scale: ~25M historical properties, ~2.5M postcodes, ~150 numeric features per row, all in RAM on a single VM
outcome: A single-binary UK property-intelligence service with sub-100ms hexagon aggregations under filter outcome: A single-binary UK property-intelligence service with sub-100ms hexagon aggregations under filter
audience: technical
media: media:
- type: image - type: image
src: ./_assets/perfect-postcode.jpg src: ./_assets/perfect-postcode.jpg
@ -36,10 +34,7 @@ article:
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to three integer compares per row. caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to three integer compares per row.
project: project:
title: Perfect Postcode title: Perfect Postcode
description: A UK property-intelligence map. ~25M historical transactions, ~150 features per row, all u16-quantised in RAM, served from a single Rust binary.
selected: true selected: true
thumbnail:
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties as a heatmap.
--- ---
A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right, and it stung, because the previous version round-tripped to a database, decoded floats, and had spent its entire latency budget before it finished evaluating the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post follows from refusing to let that sluggishness come back. A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right, and it stung, because the previous version round-tripped to a database, decoded floats, and had spent its entire latency budget before it finished evaluating the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post follows from refusing to let that sluggishness come back.

View file

@ -1,26 +0,0 @@
---
title: A Colour Grader Where Distance Was the Whole Idea
description: Pick a colour, transform every nearby colour as a function of distance. A proof-of-concept grader I built to try one interaction idea.
date: 2026-04-30
period: 'June 2018'
thumbnail:
src: ./_assets/photo-colour-grader.jpg
alt: Colour grading interface with tonal controls and an edited preview.
article:
tags: ['graphics', 'web', 'tools']
role: Interface and image processing author
stack: ['JavaScript', 'Canvas', 'Image processing']
outcome: A working proof-of-concept grader and an interaction model I'd still defend
audience: technical
project:
title: Photo Colour Grader
description: Pick a colour, edit every nearby colour as a function of distance. A grader built around one interaction idea.
thumbnail:
alt: Screenshot of a colour grading interface applied to a photograph.
---
In June 2018 I got tired of every photo editor making me think in masks. What I wanted was to point at _this orange_ in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense; the edit defined by distance in colour space, not by a region I'd painted on the image. No tool worked that way, so I built the proof of concept.
The interface was a colour wheel. Click to drop a marker on a colour, drag to shift it, click again to add another marker with its own settings. Every transformation fell off smoothly with distance from the picked colour, so the photo never developed the hard seams that masks produce. There was no mask anywhere in the program, which was the entire experiment.
I never grew it into a real tool, but the idea hasn't stopped feeling right: distance in colour space is the natural unit for the way people describe edits out loud ("warm up the skin a little, leave the sky"). If I came back to it I'd build the pipeline on WebGL rather than canvas, because the interaction only earns its keep when the preview is live on a full-resolution photo, and canvas could never quite get there.

View file

@ -8,13 +8,10 @@ thumbnail:
alt: Screenshot of a generated photography site. alt: Screenshot of a generated photography site.
article: article:
tags: ['web', 'tools'] tags: ['web', 'tools']
role: Site generator author
stack: ['Webpack', 'Vite', 'TypeScript', 'Image processing'] stack: ['Webpack', 'Vite', 'TypeScript', 'Image processing']
outcome: A photography site that updates itself when I drop new images into a folder outcome: A photography site that updates itself when I drop new images into a folder
audience: general
project: project:
title: Photo Site Generator title: Photo Site Generator
description: Point a build script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
--- ---
I take walks with a camera. Most of what I shoot isn't good, but walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm actually working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to run a CMS for it. So: a Webpack script. Point it at a directory of full-size photos, get a static site with responsive variants of each. Drop a photo in, build, deploy. I take walks with a camera. Most of what I shoot isn't good, but walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm actually working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to run a CMS for it. So: a Webpack script. Point it at a directory of full-size photos, get a static site with responsive variants of each. Drop a photo in, build, deploy.

View file

@ -8,15 +8,10 @@ thumbnail:
alt: Screenshot from a 3D platform game written in C. alt: Screenshot from a 3D platform game written in C.
article: article:
tags: ['games', 'systems'] tags: ['games', 'systems']
role: Game author
stack: ['C', 'SDL 1.2', 'Voxel terrain'] stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable course project, and the moment programming clicked outcome: A playable course project, and the moment programming clicked
audience: technical
project: project:
title: Platform Game title: Platform Game
description: My Basics of Programming project. 3D voxel game in C and SDL 1.2. Pointers, learned painfully.
thumbnail:
alt: Screenshot from an early 3D platform game.
--- ---
Autumn 2017, first semester, a course called Basics of Programming, and a free-choice project I aimed far too high on: a 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer, no one to tell me that "3D in software, in C, in your first semester" was an unusual reading of the assignment. Autumn 2017, first semester, a course called Basics of Programming, and a free-choice project I aimed far too high on: a 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer, no one to tell me that "3D in software, in C, in your first semester" was an unusual reading of the assignment.

View file

@ -20,11 +20,9 @@ links:
article: article:
featuredOrder: 2 featuredOrder: 2
tags: ['systems', 'tools', 'web'] tags: ['systems', 'tools', 'web']
role: Library author
stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen'] stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen']
scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin
outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings
audience: recruiter-relevant
media: media:
- type: image - type: image
src: ./_assets/reconcile.png src: ./_assets/reconcile.png
@ -32,7 +30,6 @@ article:
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose. caption: reconcile-text weaves conflicting edits together instead of asking a human to choose.
project: project:
title: reconcile-text title: reconcile-text
description: One Rust core, three packages. Merges Markdown notes from three editors I don't control, with no operation history. Never emits markers.
selected: true selected: true
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff'] technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff']
--- ---

View file

@ -17,7 +17,6 @@ links:
article: article:
featuredOrder: 3 featuredOrder: 3
tags: ['graphics', 'web', 'systems'] tags: ['graphics', 'web', 'systems']
role: Library author
stack: stack:
[ [
'TypeScript', 'TypeScript',
@ -28,7 +27,6 @@ article:
] ]
scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game
audience: recruiter-relevant
media: media:
- type: image - type: image
src: ./_assets/sdf2d.jpg src: ./_assets/sdf2d.jpg
@ -36,7 +34,6 @@ article:
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design. caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
project: project:
title: SDF-2D title: SDF-2D
description: A browser 2D ray-tracer tuned for the phone in your pocket. Tile-based passes, deferred shading, shaders generated per scene and device.
selected: true selected: true
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields'] technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
--- ---

View file

@ -13,7 +13,6 @@ links:
url: https://vault-link.schmelczer.dev url: https://vault-link.schmelczer.dev
article: article:
tags: ['systems', 'web', 'tools'] tags: ['systems', 'web', 'tools']
role: Sync engine and server author
stack: stack:
[ [
'Rust', 'Rust',
@ -29,10 +28,8 @@ article:
] ]
scale: One Rust server, one TypeScript sync engine, three published consumers (Obsidian plugin, CLI, fuzz/deterministic test harnesses) scale: One Rust server, one TypeScript sync engine, three published consumers (Obsidian plugin, CLI, fuzz/deterministic test harnesses)
outcome: A self-hosted Obsidian sync I trust enough to use as my primary vault transport outcome: A self-hosted Obsidian sync I trust enough to use as my primary vault transport
audience: technical
project: project:
title: VaultLink title: VaultLink
description: 'I refuse to give up the editor: Obsidian, Vim, VS Code, sed. Self-hosted sync that survives all four, built on reconcile-text underneath.'
selected: true selected: true
--- ---

View file

@ -137,7 +137,6 @@ const personJsonLd = buildPersonJsonLd();
<AtAGlance <AtAGlance
headingId={`at-a-glance-${post.id}`} headingId={`at-a-glance-${post.id}`}
role={post.data.article?.role}
projectPeriod={post.data.period} projectPeriod={post.data.period}
stack={post.data.article?.stack} stack={post.data.article?.stack}
scale={post.data.article?.scale} scale={post.data.article?.scale}

View file

@ -5,14 +5,14 @@ import type { ImageMetadata } from 'astro';
// Theme background colors, the single source of truth for the // Theme background colors, the single source of truth for the
// <meta name="theme-color"> tags and the FOUC-prevention scripts (injected in // <meta name="theme-color"> tags and the FOUC-prevention scripts (injected in
// Base.astro and Header.astro). Keep --color-bg in global.css in sync; CSS // Base.astro and Header.astro). Keep --color-bg in styles/tokens.css in sync;
// cannot import these values. // CSS cannot import these values.
export const THEME_BG = { light: '#fbfaf7', dark: '#201f1d' }; export const THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
export const site = { export const site = {
brand: 'schmelczer.dev', brand: 'schmelczer.dev',
name: 'Andras Schmelczer', name: 'Andras Schmelczer',
title: 'Andras Schmelczer, Software engineer', title: 'Andras Schmelczer',
description: description:
'Notebook of someone who keeps reaching for the same two moves: let the hard constraint pick the data structure, then keep the API small enough to defend.', 'Notebook of someone who keeps reaching for the same two moves: let the hard constraint pick the data structure, then keep the API small enough to defend.',
url: 'https://schmelczer.dev', url: 'https://schmelczer.dev',
@ -139,10 +139,10 @@ export function projectCard(entry: CollectionEntry<'work'>): ProjectCard {
const project = data.project; const project = data.project;
return { return {
title: project?.title ?? data.title, title: project?.title ?? data.title,
description: project?.description ?? data.description, description: data.description,
thumbnail: { thumbnail: {
src: project?.thumbnail?.src ?? data.thumbnail.src, src: project?.thumbnail?.src ?? data.thumbnail.src,
alt: project?.thumbnail?.alt ?? data.thumbnail.alt, alt: data.thumbnail.alt,
}, },
technologies: project?.technologies ?? data.article?.stack ?? [], technologies: project?.technologies ?? data.article?.stack ?? [],
selected: project?.selected ?? false, selected: project?.selected ?? false,

View file

@ -15,7 +15,7 @@ const STARTING_POINTS = 5;
const posts = await getArticles(); const posts = await getArticles();
const startingPoints = posts const startingPoints = posts
.filter((post) => post.data.article?.audience === 'recruiter-relevant') .filter((post) => post.data.article?.featuredOrder !== undefined)
.sort( .sort(
(a, b) => (a, b) =>
(a.data.article?.featuredOrder ?? 99) - (b.data.article?.featuredOrder ?? 99) (a.data.article?.featuredOrder ?? 99) - (b.data.article?.featuredOrder ?? 99)

View file

@ -21,13 +21,10 @@ const personJsonLd = buildPersonJsonLd();
<section class="home-intro"> <section class="home-intro">
<p class="eyebrow">Engineering notes</p> <p class="eyebrow">Engineering notes</p>
<h1> <h1>
<span class="home-intro-name">Andras Schmelczer</span>, software engineer. Writeups <span class="home-intro-name">Andras Schmelczer</span> sharing cool projects and lessons learned
of finished projects, with the tradeoffs left in.
</h1> </h1>
<p> <p>
Most started because I couldn't yet do the thing: an 8-bit ALU, a mobile GPU, a I enjoy learning by building exciting things that sometimes even turn out to be useful. This page is dedicated to the projects I actually finished. To learn more about me, check out the <a href="/about/">About page</a>.
single static HTML file, a cross-language ABI. The <a href="/about/">About page</a>
covers the patterns I keep returning to.
</p> </p>
</section> </section>

57
src/styles/base.css Normal file
View file

@ -0,0 +1,57 @@
/* Base layer: html/body defaults, links, focus rings, text selection. */
@layer base {
html {
background: var(--color-bg);
scroll-behavior: smooth;
}
body {
min-block-size: 100vh;
min-block-size: 100dvh;
display: flex;
flex-direction: column;
background: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
font-size: var(--fs-body);
line-height: var(--leading-snug);
transition:
background-color 200ms ease,
color 200ms ease;
}
address {
font-style: normal;
}
a {
color: var(--color-link);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
transition: color 150ms ease;
}
a:hover {
color: var(--color-link-hover);
}
a:active {
opacity: 0.85;
transition: opacity 80ms ease;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
main:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
::selection {
background: var(--color-selection-bg);
color: var(--color-fg);
}
}

View file

@ -0,0 +1,160 @@
/* Section headings, breadcrumbs, tag cloud / list / filter. */
@layer components {
.eyebrow {
margin: 0 0 var(--space-3);
color: var(--color-muted);
font-size: var(--fs-caption);
line-height: 1.4;
font-weight: var(--weight-semibold);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-heading {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-4);
padding-top: var(--space-6);
}
:where(.section-heading, .archive-year, .project-section, .facts, .at-a-glance) h2 {
font-size: var(--fs-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
.section-heading a {
min-height: 44px;
display: inline-flex;
align-items: center;
color: var(--color-muted);
font-size: var(--fs-caption);
text-decoration: none;
}
.section-heading a:hover {
color: var(--color-fg);
text-decoration: underline;
text-underline-offset: 0.25em;
}
.section-heading__text {
flex: 1 1 11rem;
min-width: 0;
}
.section-heading__text p {
max-width: var(--measure);
margin-top: var(--space-1);
color: var(--color-muted);
font-size: var(--fs-caption);
line-height: 1.4;
}
.breadcrumbs {
margin: 0 0 var(--space-3);
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
color: var(--color-muted);
font-size: var(--fs-caption);
}
.breadcrumbs li {
display: inline-flex;
align-items: baseline;
min-width: 0;
}
.breadcrumbs li:not(:last-child)::after {
content: '';
margin-left: var(--space-2);
color: var(--color-rule-medium);
flex: none;
}
.breadcrumbs a {
color: var(--color-muted);
text-decoration: none;
}
.breadcrumbs a:hover {
color: var(--color-fg);
text-decoration: underline;
text-underline-offset: 0.25em;
}
.breadcrumbs [aria-current='page'] {
color: var(--color-fg);
}
.tag-cloud {
margin-top: var(--space-2);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
margin: var(--space-2) 0 0;
padding: 0;
list-style: none;
color: var(--color-muted);
font-size: var(--fs-caption);
}
.tag-list a {
min-height: 2rem;
display: inline-flex;
align-items: center;
padding-inline: var(--space-1);
margin-inline: calc(-1 * var(--space-1));
color: var(--color-muted);
text-decoration: none;
}
.tag-list a::before {
content: '#';
color: var(--color-rule-medium);
}
.tag-list .tag-more::before {
content: none;
}
.tag-list .tag-count {
margin-inline-start: 0.35em;
padding: 0 0.4em;
border-radius: var(--radius-pill);
background: var(--color-code-bg);
color: var(--color-fg);
font-size: var(--fs-caption);
font-variant-numeric: tabular-nums;
}
.tag-list a:hover,
.tag-list a[aria-current='page'] {
color: var(--color-fg);
}
.tag-filter {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
margin: 0 0 var(--space-8);
padding-block: var(--space-3);
border-block: 1px solid var(--color-rule);
}
.tag-filter > span {
color: var(--color-muted);
font-size: var(--fs-caption);
}
}

View file

@ -0,0 +1,198 @@
/* Article list (+ timeline variant), project-list grid, entry thumbnails. */
@layer components {
.article-list,
.project-list {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
}
.project-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--space-4);
align-items: stretch;
}
.article-list > li {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr) minmax(6rem, 8rem);
grid-template-areas: 'date content thumb';
align-items: center;
gap: var(--space-4);
padding-block: var(--space-6);
border-top: 1px solid var(--color-rule);
}
.article-list > li:first-child {
border-top: 0;
}
.article-list time {
grid-area: date;
color: var(--color-muted);
font-size: var(--fs-caption);
text-align: end;
}
.article-list > li > article {
grid-area: content;
min-width: 0;
padding-right: var(--space-3);
}
.article-list h3,
.project-list h3 {
font-size: var(--fs-base);
line-height: var(--leading-snug);
}
.project-list h3 {
color: var(--color-fg);
font-weight: var(--weight-semibold);
}
.article-list .entry-title {
display: inline-flex;
align-items: center;
min-height: 28px;
color: var(--color-fg);
font-weight: var(--weight-semibold);
text-decoration: none;
}
.article-list .entry-title:hover {
color: var(--color-link-hover);
}
.article-list p,
.project-list p {
margin: var(--space-1) 0 0;
color: var(--color-muted);
font-size: var(--fs-base);
line-height: var(--leading-snug);
}
.entry-thumbnail {
display: block;
overflow: hidden;
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
aspect-ratio: 4 / 3;
transition: border-color 150ms ease;
}
.entry-thumbnail img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
}
a.entry-thumbnail {
text-decoration: none;
}
a.entry-thumbnail:hover,
a.entry-thumbnail:focus-visible {
border-color: var(--color-rule-strong);
}
.article-list > li:focus-within .entry-thumbnail {
border-color: var(--color-rule-strong);
}
.article-thumbnail {
grid-area: thumb;
align-self: center;
}
.article-list--timeline {
--timeline-date-column: 6.25rem;
--timeline-marker-column: 1.125rem;
--timeline-marker-offset: 0.5625rem;
--timeline-gap: var(--space-4);
--timeline-date-offset: 1.125rem;
--timeline-dot-top: 50%;
--timeline-marker-center: calc(
var(--timeline-date-column) + var(--timeline-gap) + var(--timeline-marker-offset)
);
}
.article-list--timeline > li {
position: relative;
grid-template-columns:
var(--timeline-date-column) var(--timeline-marker-column) minmax(0, 1fr)
minmax(6rem, 8rem);
grid-template-areas: 'date marker content thumb';
column-gap: var(--timeline-gap);
border-top: 0;
}
.article-list--timeline > li::before,
.article-list--timeline > li::after {
content: '';
position: absolute;
left: var(--timeline-marker-center);
z-index: 0;
pointer-events: none;
}
.article-list--timeline > li::before {
inset-block: 0;
width: 1px;
transform: translateX(-50%);
background: var(--color-rule-medium);
}
.article-list--timeline > li:first-child::before {
inset-block-start: var(--timeline-dot-top);
}
.article-list--timeline > li:last-child::before {
inset-block-end: calc(100% - var(--timeline-dot-top));
}
.article-list--timeline > li:only-child::before {
content: none;
}
.article-list--timeline > li::after {
top: var(--timeline-dot-top);
width: 0.75rem;
height: 0.75rem;
border: 2px solid var(--color-bg);
border-radius: var(--radius-pill);
background: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-rule-strong);
transform: translate(-50%, -50%);
}
.article-list--timeline > li > article,
.article-list--timeline time,
.article-list--timeline .entry-thumbnail {
position: relative;
z-index: 1;
}
.article-list--timeline time {
justify-self: end;
align-self: center;
width: max-content;
min-block-size: 0;
margin-inline-end: calc(-1 * var(--timeline-date-offset));
padding-inline-end: 4px;
display: inline-block;
color: var(--color-muted);
font-size: var(--fs-sm);
line-height: 1.2;
text-align: center;
white-space: nowrap;
top: 5px;
transform: rotate(-45deg);
transform-origin: right center;
}
}

View file

@ -0,0 +1,212 @@
/* At-a-glance, facts, media figures, post nav, TOC, gallery, related posts. */
@layer components {
.at-a-glance {
margin-top: var(--space-8);
padding-block: var(--space-5);
border-block: 1px solid var(--color-rule);
}
.at-a-glance dl,
.facts dl {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: var(--space-4) 0 0;
}
.at-a-glance__row,
.facts dl > div {
display: grid;
grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr);
gap: var(--space-4);
}
.at-a-glance dt,
.facts dt {
color: var(--color-muted);
font-size: var(--fs-caption);
}
.facts {
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--color-rule);
}
/* Let prose wrap beside .at-a-glance and continue below it. */
@media (min-width: 1100px) {
.post > .at-a-glance {
float: right;
width: min(18rem, 42%);
margin-top: var(--space-8);
margin-right: 0;
margin-bottom: var(--space-4);
margin-left: var(--space-8);
}
.post > .post-media,
.post > .post-gallery,
.related-posts,
.post-nav {
clear: both;
}
}
.post-media {
max-inline-size: min(100%, var(--measure-wide));
margin: var(--space-8) 0 0;
}
.post-media img,
.post-media video {
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
width: 100%;
}
.post-media figcaption,
.media-transcript {
max-width: var(--measure);
margin-top: var(--space-2);
color: var(--color-muted);
font-size: var(--fs-caption);
line-height: 1.45;
}
.media-transcript strong {
color: var(--color-fg);
font-weight: var(--weight-semibold);
}
.post-nav {
margin-top: var(--space-12);
padding-top: var(--space-6);
border-top: 1px solid var(--color-rule);
}
.post-nav__list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: var(--space-4);
list-style: none;
padding: 0;
margin: 0;
}
.post-nav__next {
justify-self: end;
}
.post-nav a {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
color: var(--color-fg);
text-decoration: none;
min-height: 44px;
transition: border-color 150ms ease;
}
.post-nav a:hover,
.post-nav a:focus-visible {
border-color: var(--color-rule-strong);
}
.post-nav a.next {
text-align: end;
}
.post-nav .post-nav__label {
color: var(--color-muted);
font-size: var(--fs-caption);
}
.post-nav .post-nav__title {
color: var(--color-fg);
font-weight: var(--weight-semibold);
}
.post-toc {
margin-top: var(--space-6);
padding: var(--space-3) var(--space-4);
border-inline-start: 2px solid var(--color-rule);
font-size: var(--fs-caption);
color: var(--color-muted);
max-height: 60vh;
overflow-y: auto;
}
.post-header h1,
.post-nav .post-nav__title,
.project-card h3 {
overflow-wrap: anywhere;
}
.post-toc ol {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.post-toc a {
display: inline-flex;
align-items: center;
min-block-size: 24px;
padding-block: 2px;
color: var(--color-muted);
text-decoration: none;
}
.post-toc a:hover,
.post-toc a:focus-visible {
color: var(--color-fg);
}
.post-gallery {
list-style: none;
padding: 0;
margin: var(--space-8) 0 0;
display: grid;
gap: var(--space-6);
}
.post > .post-gallery {
width: 100%;
}
.post-gallery .post-media {
max-inline-size: 100%;
margin: 0;
}
.external-link-icon {
display: inline-block;
margin-inline-start: 0.25em;
vertical-align: -0.125em;
opacity: 0.85;
}
.related-posts {
margin-top: var(--space-12);
padding-top: var(--space-6);
border-top: 1px solid var(--color-rule);
}
.related-posts h2 {
font-size: var(--fs-lg);
font-weight: var(--weight-semibold);
margin-bottom: var(--space-4);
}
.empty-state {
max-width: var(--measure);
padding-block: var(--space-6);
}
}

View file

@ -0,0 +1,155 @@
/* Project card: thumbnail, summary, stretched-link Open control, links. */
@layer components {
.project-card {
position: relative;
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
'thumb'
'summary';
min-width: 0;
overflow: hidden;
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
background: var(--color-bg);
transition: border-color 150ms ease;
}
.project-card:hover,
.project-card:focus-within,
.project-card:target {
border-color: var(--color-rule-strong);
}
.project-card .project-thumbnail {
grid-area: thumb;
width: 100%;
height: auto;
border: 0;
border-bottom: 1px solid var(--color-rule);
border-radius: 0;
aspect-ratio: 4 / 3;
}
.project-card__summary {
grid-area: summary;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--space-3);
min-width: 0;
padding: var(--space-3) var(--space-4);
}
.project-card p {
margin: 0;
}
.project-card .project-description {
color: var(--color-fg);
font-size: var(--fs-base);
line-height: var(--leading-snug);
}
.project-card__head {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.project-card__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
margin-top: var(--space-3);
}
/* Stretched link: the Open control's overlay covers the whole card so any
click opens the project website. The Article link sits above the overlay
via a higher z-index and stays independently clickable. */
.project-card__open::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
/* Cover the whole card, not the Open button's pill radius. The card's own
overflow:hidden + border-radius clips this to the rounded card shape. */
border-radius: 0;
}
.project-card__open {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.3em;
min-height: 36px;
padding: 0.25em 0.85em;
border: 1px solid var(--color-rule-strong);
border-radius: var(--radius-pill);
background: var(--color-bg);
color: var(--color-link);
font-size: var(--fs-caption);
font-weight: var(--weight-semibold);
text-decoration: none;
transition:
background-color 150ms ease,
border-color 150ms ease,
color 150ms ease;
}
.project-card:hover .project-card__open,
.project-card:focus-within .project-card__open {
border-color: var(--color-link);
background: var(--color-callout-bg);
color: var(--color-link-hover);
}
.project-article-link {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 0.3em;
min-height: 36px;
color: var(--color-link);
font-size: var(--fs-caption);
font-weight: var(--weight-medium);
text-decoration: none;
}
.project-article-link:hover,
.project-article-link:focus-visible {
color: var(--color-link-hover);
text-decoration: underline;
}
.project-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: var(--space-3) 0 0;
padding: 0;
list-style: none;
}
.project-links a {
min-height: 44px;
min-width: 44px;
display: inline-flex;
align-items: center;
color: var(--color-link);
}
.project-links a:hover,
.project-links a:focus-visible {
color: var(--color-link-hover);
}
.project-links a .download-indicator {
margin-left: 0.25em;
color: var(--color-muted);
font-size: 0.85em;
}
}

View file

@ -0,0 +1,196 @@
/* Long-form article prose: headings, lists, quotes, tables, code (Shiki). */
@layer components {
.prose {
max-inline-size: var(--measure);
line-height: var(--leading-prose);
hyphens: auto;
hyphenate-limit-chars: 7 3 3;
}
.prose > * + * {
margin-top: 1.05em;
}
.prose > h2:first-child,
.prose > h3:first-child {
margin-top: 0;
}
.prose p {
text-wrap: pretty;
}
.prose h2,
.prose h3 {
position: relative;
color: var(--color-fg);
line-height: var(--leading-tight);
scroll-margin-top: var(--space-8);
}
.prose h2 {
margin-top: var(--space-12);
font-size: var(--fs-xl);
font-weight: var(--weight-semibold);
}
.prose h3 {
margin-top: var(--space-8);
font-size: var(--fs-lg);
font-weight: var(--weight-semibold);
}
.prose h2:target,
.prose h3:target {
background: var(--color-callout-bg);
border-inline-start: 2px solid var(--color-accent);
padding-inline-start: var(--space-3);
margin-inline-start: calc(-1 * var(--space-3) - 2px);
}
.prose .heading-anchor {
margin-inline-start: 0.4em;
color: var(--color-muted);
font-weight: var(--weight-regular);
font-size: 0.85em;
text-decoration: none;
opacity: 0;
transition: opacity 150ms ease;
}
.prose .heading-anchor:focus-visible {
opacity: 1;
text-decoration: underline;
}
.prose .heading-anchor::before {
content: '#';
}
.prose h2:hover .heading-anchor,
.prose h3:hover .heading-anchor,
.prose .heading-anchor:hover {
opacity: 1;
}
@media (hover: none) {
.prose .heading-anchor {
opacity: 1;
}
}
.prose ul,
.prose ol {
padding-inline-start: 1.25rem;
}
.prose li + li {
margin-top: var(--space-1);
}
.prose strong {
font-weight: var(--weight-bold);
color: var(--color-fg);
}
.prose em {
font-style: italic;
}
.prose blockquote {
margin-inline: 0;
padding: var(--space-2) var(--space-4);
border-inline-start: 3px solid var(--color-rule-medium);
background: var(--color-callout-bg);
color: var(--color-muted);
font-style: italic;
}
.prose blockquote > * + * {
margin-top: 0.6em;
}
.prose hr {
margin-block: var(--space-8);
border: 0;
height: 1px;
background: var(--color-rule);
}
.prose table {
width: 100%;
max-width: 100%;
border-collapse: collapse;
font-size: 0.95em;
display: block;
overflow-x: auto;
}
.prose thead {
background: var(--color-code-bg);
text-align: start;
}
.prose th,
.prose td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-rule);
vertical-align: top;
}
.prose th {
font-weight: var(--weight-semibold);
}
.prose code {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--color-code-bg);
border-radius: var(--radius-sm);
padding: 0.08em 0.25em;
}
.prose pre {
max-width: 100%;
overflow-x: auto;
scrollbar-gutter: stable;
padding: var(--space-4);
background: var(--color-code-bg);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
line-height: 1.55;
scrollbar-width: thin;
scrollbar-color: var(--color-rule-medium) var(--color-code-bg);
}
.prose pre::-webkit-scrollbar {
height: 8px;
}
.prose pre::-webkit-scrollbar-track {
background: var(--color-code-bg);
}
.prose pre::-webkit-scrollbar-thumb {
background: var(--color-rule-medium);
border-radius: var(--radius-sm);
}
.prose pre code {
background: transparent;
padding: 0;
}
/* Shiki dual-theme: defaultColor: false emits --shiki-light / --shiki-dark
vars on every token; light-dark() picks the active variant from
color-scheme on :root. */
.prose pre.astro-code,
.prose pre.astro-code code,
.prose pre.astro-code span {
color: light-dark(var(--shiki-light), var(--shiki-dark));
background-color: light-dark(
var(--shiki-light-bg, var(--color-code-bg)),
var(--shiki-dark-bg, var(--color-code-bg))
);
}
}

View file

@ -0,0 +1,125 @@
/* Post-content width wrappers, archive/project sections, about, starting points. */
@layer components {
.post > .post-media {
max-width: var(--measure);
margin-inline: auto;
}
.post > .prose {
max-width: var(--measure-wide);
max-inline-size: var(--measure-wide);
margin-inline: auto;
}
.post > .at-a-glance,
.post > .post-thumbnail,
.post > .post-gallery,
.post-nav {
max-width: var(--measure-wide);
margin-inline: auto;
}
.archive-year,
.project-section {
width: 100%;
}
.archive-year + .archive-year,
.project-section + .project-section {
margin-top: var(--space-10);
}
.archive-year h2,
.project-section h2 {
margin-bottom: var(--space-3);
}
.about-section {
width: 100%;
margin-top: var(--space-10);
}
.about-section--starting-points {
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-rule);
}
.about-section--starting-points .section-heading {
align-items: flex-start;
padding-top: 0;
}
.about-section.facts {
max-width: none;
}
.about-section.facts > .prose {
margin-top: var(--space-4);
}
.prose.about-copy {
width: 100%;
max-inline-size: none;
}
.starting-points {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
padding: var(--space-4) 0 0;
list-style: none;
}
.starting-points > li {
min-width: 0;
}
.starting-point__thumbnail {
aspect-ratio: 4 / 3;
}
.starting-point__body {
min-width: 0;
padding-block-start: var(--space-2);
}
.starting-point__body h3 {
font-size: var(--fs-sm);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
.starting-point__body a {
color: var(--color-fg);
text-decoration: none;
}
.starting-point__body a:hover {
color: var(--color-link-hover);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.starting-point__body p {
margin-top: var(--space-1);
color: var(--color-muted);
font-size: var(--fs-xs);
line-height: var(--leading-snug);
}
.starting-points > li:focus-within .entry-thumbnail {
border-color: var(--color-rule-strong);
}
.about-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-4);
}
.post > .prose {
margin-top: var(--space-8);
}
}

View file

@ -0,0 +1,118 @@
/* Light/dark theme toggle switch + forced-colors fallback. */
@layer components {
.theme-switcher {
--switcher-w: 2.75rem;
--switcher-h: 1.5rem;
--switcher-icon: 1.05rem;
--switcher-gap: 0.22rem;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--switcher-w);
height: var(--switcher-h);
/* Adjacent header targets remain at least 44px apart while the visual
track stays compact. */
margin: max(var(--space-2), calc((44px - var(--switcher-h)) / 2)) 0;
overflow: hidden;
border: 1px solid var(--color-rule-medium);
border-radius: var(--radius-pill);
appearance: none;
cursor: pointer;
background: var(--theme-switcher-track);
color: inherit;
transition:
background-color 200ms ease,
border-color 150ms ease;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 18%);
}
.theme-switcher:hover {
border-color: var(--color-rule-strong);
}
.no-js .theme-switcher {
display: none !important;
}
.theme-switcher-icon {
position: absolute;
top: 50%;
left: 0;
width: var(--switcher-icon);
height: var(--switcher-icon);
pointer-events: none;
transition:
transform 180ms ease,
opacity 180ms ease,
color 180ms ease;
}
.theme-switcher-icon-sun {
color: var(--theme-switcher-icon-light);
}
.theme-switcher-icon-moon {
color: var(--theme-switcher-icon-dark);
}
.theme-switcher[aria-pressed='false'] .theme-switcher-icon-sun {
transform: translateY(-50%)
translateX(calc(var(--switcher-w) - var(--switcher-icon) - var(--switcher-gap)));
opacity: 1;
}
.theme-switcher[aria-pressed='false'] .theme-switcher-icon-moon {
transform: translateY(-50%) translateX(calc(-1 * var(--switcher-icon)));
opacity: 0;
}
.theme-switcher[aria-pressed='true'] .theme-switcher-icon-sun {
transform: translateY(-50%) translateX(var(--switcher-w));
opacity: 0;
}
.theme-switcher[aria-pressed='true'] .theme-switcher-icon-moon {
transform: translateY(-50%) translateX(var(--switcher-gap));
opacity: 1;
}
/* High-contrast / forced-colors fallback: render a text label. */
@media (forced-colors: active) {
.theme-switcher {
width: auto;
height: auto;
min-block-size: 44px;
min-inline-size: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-1) var(--space-2);
overflow: visible;
background: ButtonFace;
color: ButtonText;
border: 1px solid ButtonBorder;
box-shadow: none;
}
.theme-switcher-icon {
display: none;
}
.theme-switcher::before {
content: 'Light';
position: static;
width: auto;
height: auto;
transform: none;
background: transparent;
border-radius: 0;
}
.theme-switcher[aria-pressed='true']::before {
content: 'Dark';
transform: none;
}
}
}

View file

@ -0,0 +1,243 @@
/* Post thumbnail (iframe embed) and click-to-play video thumbnail. */
@layer components {
.post-thumbnail {
margin: 0;
}
.post-thumbnail img {
width: 100%;
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
}
.post-thumbnail--iframe {
position: relative;
aspect-ratio: var(--post-thumbnail-aspect, 16 / 9);
overflow: hidden;
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
}
.post-thumbnail--iframe picture,
.post-thumbnail--iframe img {
width: 100%;
height: 100%;
}
.post-thumbnail--iframe picture {
display: block;
}
.post-thumbnail--iframe img {
object-fit: cover;
object-position: center center;
border: 0;
border-radius: 0;
}
.post-thumbnail__play {
position: absolute;
inset: 0;
width: 100%;
border: 0;
padding: 0;
display: grid;
place-items: center;
background: color-mix(in oklch, #000 22%, transparent);
color: var(--color-fg);
cursor: pointer;
}
.post-thumbnail__play:hover,
.post-thumbnail__play:focus-visible {
background: color-mix(in oklch, #000 30%, transparent);
}
.post-thumbnail__play-icon {
width: clamp(3.25rem, 9vw, 4.75rem);
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--color-rule-strong);
border-radius: var(--radius-pill);
background: color-mix(in oklch, var(--color-bg) 88%, transparent);
box-shadow: 0 0.75rem 2rem color-mix(in oklch, #000 28%, transparent);
transition:
background-color 150ms ease,
transform 150ms ease;
}
.post-thumbnail__play:hover .post-thumbnail__play-icon,
.post-thumbnail__play:focus-visible .post-thumbnail__play-icon {
background: var(--color-bg);
transform: scale(1.04);
}
.post-thumbnail__play svg {
width: 42%;
height: 42%;
transform: translateX(8%);
fill: currentColor;
}
.post-thumbnail__iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: var(--color-code-bg);
}
.post-thumbnail--iframe.is-active picture,
.post-thumbnail--iframe.is-active .post-thumbnail__play {
display: none;
}
.post-thumbnail__noscript {
position: absolute;
inset-inline: var(--space-3);
inset-block-end: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-bg);
font-size: var(--fs-caption);
}
/* Click-to-play header video: a poster with a play button that swaps in an
inline <video> on click. Shared by the article banner and project cards. */
.video-thumbnail {
position: relative;
display: block;
overflow: hidden;
}
.video-thumbnail picture,
.video-thumbnail img {
display: block;
width: 100%;
height: 100%;
}
.video-thumbnail__video {
/* The global `video { display: block }` reset (author origin) overrides the
UA `[hidden]` rule, so hide the idle video explicitly to keep it from
swallowing the play button's click. */
display: none;
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.video-thumbnail.is-playing .video-thumbnail__video {
display: block;
}
.video-thumbnail__play {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
border: 0;
padding: 0;
display: grid;
place-items: center;
background: color-mix(in oklch, #000 22%, transparent);
color: var(--color-fg);
cursor: pointer;
}
.video-thumbnail__play:hover,
.video-thumbnail__play:focus-visible {
background: color-mix(in oklch, #000 30%, transparent);
}
.video-thumbnail__play-icon {
width: clamp(3.25rem, 9vw, 4.75rem);
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--color-rule-strong);
border-radius: var(--radius-pill);
background: color-mix(in oklch, var(--color-bg) 88%, transparent);
box-shadow: 0 0.75rem 2rem color-mix(in oklch, #000 28%, transparent);
transition:
background-color 150ms ease,
transform 150ms ease;
}
.video-thumbnail__play:hover .video-thumbnail__play-icon,
.video-thumbnail__play:focus-visible .video-thumbnail__play-icon {
background: var(--color-bg);
transform: scale(1.04);
}
.video-thumbnail__play svg {
width: 42%;
height: 42%;
transform: translateX(8%);
fill: currentColor;
}
.video-thumbnail.is-playing picture,
.video-thumbnail.is-playing .video-thumbnail__play {
display: none;
}
.video-thumbnail__noscript {
position: absolute;
inset-inline: var(--space-3);
inset-block-end: var(--space-3);
z-index: 3;
margin: 0;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-bg);
font-size: var(--fs-caption);
}
/* Banner variant: keep the poster's own aspect ratio inside a rounded frame.
Cover with a matching ratio is not a crop, so the no-crop preview QA passes. */
.video-thumbnail--banner {
aspect-ratio: var(--video-aspect, 16 / 9);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
}
.video-thumbnail--banner img {
object-fit: cover;
}
.video-thumbnail--banner.post-thumbnail img {
border: 0;
border-radius: 0;
}
/* Card variant: only the centered badge is the play button, so a click
anywhere else on the thumbnail falls through to the card's Open link. */
.video-thumbnail--card .video-thumbnail__play {
inset: auto;
top: 50%;
left: 50%;
width: auto;
background: transparent;
transform: translate(-50%, -50%);
}
.video-thumbnail--card .video-thumbnail__play:hover,
.video-thumbnail--card .video-thumbnail__play:focus-visible {
background: transparent;
}
.video-thumbnail--card .video-thumbnail__play-icon {
width: clamp(2.25rem, 14vw, 3rem);
box-shadow: 0 0.5rem 1.25rem color-mix(in oklch, #000 32%, transparent);
}
}

16
src/styles/fonts.css Normal file
View file

@ -0,0 +1,16 @@
/* @font-face declarations: self-hosted Source Sans 3 (variable) + IBM Plex Mono. */
@font-face {
font-family: 'Source Sans 3';
src: url('/fonts/source-sans-3-latin-variable.woff2') format('woff2-variations');
font-style: normal;
font-weight: 200 900;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('/fonts/ibm-plex-mono-latin-400.woff2') format('woff2');
font-style: normal;
font-weight: 400;
font-display: swap;
}

File diff suppressed because it is too large Load diff

2
src/styles/layers.css Normal file
View file

@ -0,0 +1,2 @@
/* Cascade layer order, declared once so partials can be authored in any order. */
@layer reset, base, layout, components, utilities, overrides;

196
src/styles/layout.css Normal file
View file

@ -0,0 +1,196 @@
/* Layout layer: page-width shell, header, footer, nav, page/post headers. */
@layer layout {
:where(.site-header, .site-footer, .home-intro, .home-section, .page-shell, .post) {
width: min(100% - 2 * var(--gutter), var(--page));
margin-inline: auto;
}
.post {
width: min(100% - 2 * var(--gutter), var(--measure-wide));
}
.skip-link {
position: absolute;
left: calc(var(--gutter) + env(safe-area-inset-left));
top: calc(var(--space-3) + env(safe-area-inset-top));
z-index: 10;
transform: translateY(-150%);
background: var(--color-fg);
color: var(--color-bg);
padding: var(--space-3) var(--space-4);
min-block-size: 44px;
display: inline-flex;
align-items: center;
text-decoration: none;
transition: transform 150ms ease;
}
.skip-link:focus {
transform: translateY(0);
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
padding-block: var(--space-8) var(--space-6);
border-bottom: 1px solid var(--color-rule);
}
main {
flex: 1 0 auto;
}
.site-title {
color: var(--color-fg);
font-size: var(--fs-lg);
font-weight: var(--weight-bold);
letter-spacing: -0.005em;
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-6);
min-width: 0;
}
.site-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-5);
}
.site-nav a,
.site-footer a {
min-height: 44px;
display: inline-flex;
align-items: center;
color: var(--color-muted);
text-decoration: none;
}
.site-nav a:hover,
.site-footer a:hover {
color: var(--color-fg);
text-decoration: underline;
text-underline-offset: 0.25em;
}
.site-nav a[aria-current='page'],
.site-nav a[aria-current='true'] {
color: var(--color-fg);
}
.site-footer {
border-top: 1px solid var(--color-rule);
margin-top: var(--space-16);
padding-block: var(--space-8) var(--space-10);
}
.footer-meta {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: var(--space-2) var(--space-5);
margin: 0;
padding: 0;
list-style: none;
color: var(--color-muted);
font-size: var(--fs-caption);
white-space: nowrap;
}
.footer-meta a,
.footer-meta > span {
min-height: 44px;
display: inline-flex;
align-items: center;
}
.footer-meta a {
padding-inline: var(--space-1);
margin-inline: calc(-1 * var(--space-1));
}
.footer-copyright {
gap: 0.25em;
}
.footer-contact {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: var(--space-2) var(--space-5);
min-width: 0;
}
/* Page header (shared by .home-intro, .page-header, .post-header) */
.home-intro {
padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
}
.home-intro h1,
.page-header h1,
.post-header h1 {
max-width: var(--measure-wide);
color: var(--color-fg);
font-size: var(--fs-3xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
text-wrap: balance;
}
.home-intro-name {
color: var(--color-accent);
}
.home-intro p:not(.eyebrow),
.page-header p,
.dek {
max-width: var(--measure);
color: var(--color-muted);
font-size: var(--fs-dek);
}
.post-header {
max-width: var(--measure-wide);
padding-block: var(--space-10) var(--space-6);
}
.page-header {
max-width: var(--measure-wide);
padding-block: var(--space-2) var(--space-6);
}
.page-shell--full-width .page-header,
.page-shell--full-width .page-header p,
.page-shell--full-width .about-copy,
.page-shell--full-width > .about-copy > p {
width: 100%;
max-width: none;
max-inline-size: none;
}
.post-header .dek {
margin-block: var(--space-4) 0;
}
.post-meta {
margin-block: var(--space-3) 0;
color: var(--color-muted);
font-size: var(--fs-caption);
line-height: 1.4;
}
.home-section,
.page-shell {
margin-top: var(--space-8);
}
}

359
src/styles/overrides.css Normal file
View file

@ -0,0 +1,359 @@
/* Overrides layer: responsive breakpoints, reduced-motion, print. */
@layer overrides {
/* Tablet */
@media (max-width: 960px) {
.article-list > li {
grid-template-columns: 5rem minmax(0, 1fr) 7rem;
gap: var(--space-4);
padding-block: var(--space-5);
}
.article-list--timeline {
--timeline-date-column: 5.75rem;
--timeline-gap: var(--space-4);
--timeline-date-offset: 1.125rem;
}
.article-list--timeline > li {
grid-template-columns:
var(--timeline-date-column) var(--timeline-marker-column) minmax(0, 1fr)
7rem;
grid-template-areas: 'date marker content thumb';
column-gap: var(--timeline-gap);
}
.starting-points {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* Mobile */
@media (max-width: 700px) {
.site-header {
padding-block: var(--space-6) var(--space-4);
}
.home-intro {
padding-block: var(--space-6) var(--space-5);
}
.home-section {
margin-top: var(--space-6);
}
.page-shell {
margin-top: 0;
}
.at-a-glance__row,
.facts dl > div {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.article-list > li {
grid-template-columns: clamp(4rem, 22vw, 5rem) minmax(0, 1fr);
grid-template-areas:
'thumb content'
'date content';
gap: var(--space-2) var(--space-3);
padding-block: var(--space-4);
}
.article-list--timeline {
--timeline-date-column: clamp(3.75rem, 18vw, 4.75rem);
--timeline-marker-column: 1rem;
--timeline-marker-offset: 0.5rem;
--timeline-gap: var(--space-3);
--timeline-date-offset: 0.875rem;
--timeline-dot-top: 2.1875rem;
}
.article-list--timeline > li {
grid-template-columns:
var(--timeline-date-column) var(--timeline-marker-column)
minmax(0, 1fr);
grid-template-areas:
'date marker content'
'thumb marker content';
gap: var(--space-2) var(--timeline-gap);
}
.article-list > li > article {
padding-right: 0;
}
.article-list time {
text-align: start;
white-space: nowrap;
}
.article-list--timeline time {
align-self: start;
margin-block-start: var(--space-3);
font-size: var(--fs-xs);
text-align: center;
}
.article-list .entry-thumbnail {
aspect-ratio: 1;
}
.starting-points {
display: block;
padding-top: 0;
}
.starting-points > li {
display: grid;
grid-template-columns: 4rem minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
padding-block: var(--space-3);
border-top: 1px solid var(--color-rule);
}
.starting-points > li:first-child {
border-top: 0;
}
.starting-point__thumbnail {
aspect-ratio: 1;
}
.starting-point__body {
padding-block-start: 0;
}
.project-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.project-card__summary {
padding: var(--space-2) var(--space-3);
}
.page-header,
.post-header {
padding-block: var(--space-6) var(--space-4);
}
.post > .prose {
margin-top: var(--space-6);
}
.prose {
line-height: 1.55;
}
.prose code {
overflow-wrap: anywhere;
}
.prose pre {
overflow-x: hidden;
scrollbar-gutter: auto;
white-space: pre-wrap;
}
.prose pre code,
.prose pre .line {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.tag-filter {
display: block;
margin-bottom: var(--space-5);
padding-block: var(--space-2);
}
.tag-filter .tag-list {
gap: 0 var(--space-3);
margin-top: var(--space-2);
}
.tag-list {
gap: 0 var(--space-3);
}
:focus-visible {
outline-offset: 1px;
}
/* Preserve the inset outline on <main> so the post-skip-link focus ring
doesn't escape its container. Repeated here because this layer wins
over the base rule regardless of selector specificity. */
main:focus-visible {
outline-offset: -2px;
}
.post-nav__list {
grid-template-columns: 1fr;
}
.post-nav__next {
justify-self: stretch;
}
.post-nav a.next {
text-align: start;
}
.header-actions {
justify-content: space-between;
width: 100%;
gap: var(--space-2) var(--space-4);
}
.site-nav {
gap: var(--space-1) var(--space-6);
}
.footer-meta,
.footer-contact {
gap: var(--space-2);
}
.footer-meta {
font-size: var(--fs-xs);
}
}
@media (min-width: 430px) and (max-width: 700px) {
.post > .prose > p,
.page-shell--full-width > .about-copy > p {
text-align: justify;
text-align-last: start;
text-justify: auto;
overflow-wrap: normal;
word-break: normal;
}
}
@media (min-width: 701px) {
.page-shell--full-width > .about-copy > p {
text-align: justify;
text-align-last: start;
text-justify: auto;
overflow-wrap: normal;
word-break: normal;
}
}
@media (max-width: 360px) {
.site-header {
position: relative;
}
.theme-switcher {
position: absolute;
inset-block-start: var(--space-6);
inset-inline-end: 0;
margin: 0;
}
.header-actions,
.site-nav {
column-gap: var(--space-3);
}
.footer-name {
display: none;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
/* Print */
@media print {
:root {
--color-bg: #fff;
--color-fg: #000;
--color-muted: #333;
--color-link: #000;
--color-link-hover: #000;
--color-accent: #000;
--color-rule: #999;
--color-rule-medium: #777;
--color-rule-strong: #333;
--color-code-bg: #f4f4f4;
--color-callout-bg: #f8f8f8;
}
body {
font-size: 11pt;
line-height: 1.4;
}
*,
*::before,
*::after {
print-color-adjust: economy;
}
.site-header,
.site-footer,
.skip-link,
.theme-switcher,
.tag-filter,
.post-nav,
.related-posts,
.heading-anchor {
display: none;
}
main {
padding: 0;
}
a,
a:visited {
color: var(--color-fg);
text-decoration: underline;
}
.prose a[href]::after {
content: ' (' attr(href) ')';
font-size: 0.85em;
color: var(--color-muted);
}
.prose a[href^='#']::after,
.prose a[href^='/']::after {
content: '';
}
.prose pre,
.prose code,
.post-thumbnail img,
.post-media img {
page-break-inside: avoid;
}
.prose h2,
.prose h3 {
page-break-after: avoid;
}
}
}

45
src/styles/reset.css Normal file
View file

@ -0,0 +1,45 @@
/* Reset layer: box-sizing, margin zeroing, list/media/form-control normalization. */
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
figure,
blockquote,
dl,
dd {
margin: 0;
}
ul[role='list'],
ol[role='list'] {
list-style: none;
padding: 0;
}
button,
input,
textarea,
select {
font: inherit;
}
img,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
}

97
src/styles/tokens.css Normal file
View file

@ -0,0 +1,97 @@
/* Design tokens: color palette (light-dark()), type scale, spacing, radii, layout. */
:root {
color-scheme: light dark;
--font-sans:
'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
/* Palette: light-dark() pairs each token (light, dark) */
/* Keep --color-bg in sync with THEME_BG in src/lib/site.ts, which drives the
browser-chrome theme-color metas (CSS cannot import those values). */
--color-bg: light-dark(#fbfaf7, #201f1d);
--color-fg: light-dark(#181817, #d8d0c3);
/* Contrast with --color-bg: light ~5.4:1, dark ~6.5:1 (both clear WCAG AA
4.5:1 for normal text). Darken-on-light / lighten-on-dark slightly from
the previous values that fell just below threshold. */
--color-muted: light-dark(#3d3b35, #aaa299);
--color-link: light-dark(#1e4d5e, #7fa8b7);
--color-link-hover: light-dark(
color-mix(in oklch, #1e4d5e 70%, black 30%),
color-mix(in oklch, #7fa8b7 80%, white 20%)
);
--color-accent: light-dark(oklch(55% 0.13 15), oklch(68% 0.11 15));
--color-rule: light-dark(#bcb5a3, #5a5247);
--color-rule-medium: light-dark(#7a7466, #70695f);
--color-rule-strong: light-dark(#4a4340, #aaa196);
--color-code-bg: light-dark(#efede6, #2a2824);
--color-callout-bg: light-dark(#f4f1e8, #292723);
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
--theme-switcher-track: var(--color-rule-medium);
--theme-switcher-icon-light: #f0e2b6;
--theme-switcher-icon-dark: #d8d0c3;
/* Typography */
--fs-xs: 0.75rem;
--fs-sm: 0.8125rem;
--fs-caption: 0.875rem;
--fs-base: 1rem;
--fs-body: 1.1875rem;
--fs-lg: 1.25rem;
--fs-xl: 1.75rem;
--fs-3xl: clamp(2rem, 1.5rem + 1.8vw, 3rem);
--fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem);
--leading-tight: 1.18;
--leading-snug: 1.35;
--leading-prose: 1.6;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 650;
--weight-bold: 700;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-pill: 999px;
/* Layout */
--measure: 36rem;
--measure-wide: 56rem;
--page: 72rem;
--gutter: clamp(20px, 4vw, 32px);
}
:root[data-theme='light'] {
color-scheme: light;
}
:root[data-theme='dark'] {
color-scheme: dark;
}
@media (max-width: 700px) {
:root {
--fs-body: 1.0625rem;
--fs-dek: 1rem;
--fs-lg: 1.125rem;
--fs-xl: 1.375rem;
--fs-3xl: 1.75rem;
}
}

14
src/styles/utilities.css Normal file
View file

@ -0,0 +1,14 @@
/* Utilities layer: single-purpose helpers (.sr-only). */
@layer utilities {
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}