Compare commits
8 commits
4ce8a4f41d
...
1cdc202731
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cdc202731 | |||
| 82c723c192 | |||
| 11796a8869 | |||
| c65114888c | |||
| 4ac244b3ef | |||
| 8064972c15 | |||
| 0d65f01797 | |||
| 16c606c3e8 |
56 changed files with 2414 additions and 2453 deletions
|
|
@ -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
110
cspell.json
Normal 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
24
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 16–32 clients at 25 updates a second, browser and mobile
|
scale: Game servers ticking at 200 Hz, each serving 16–32 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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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']
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
57
src/styles/base.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/styles/components/headings-and-tags.css
Normal file
160
src/styles/components/headings-and-tags.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/styles/components/listings.css
Normal file
198
src/styles/components/listings.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/styles/components/post.css
Normal file
212
src/styles/components/post.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/styles/components/project-card.css
Normal file
155
src/styles/components/project-card.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/styles/components/prose.css
Normal file
196
src/styles/components/prose.css
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/styles/components/sections.css
Normal file
125
src/styles/components/sections.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/styles/components/theme-switcher.css
Normal file
118
src/styles/components/theme-switcher.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/styles/components/thumbnails.css
Normal file
243
src/styles/components/thumbnails.css
Normal 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
16
src/styles/fonts.css
Normal 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
2
src/styles/layers.css
Normal 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
196
src/styles/layout.css
Normal 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
359
src/styles/overrides.css
Normal 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
45
src/styles/reset.css
Normal 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
97
src/styles/tokens.css
Normal 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
14
src/styles/utilities.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue