polish
This commit is contained in:
parent
2c37e7fa62
commit
31648541a2
39 changed files with 1273 additions and 252 deletions
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build & QA
|
||||
- name: Build, Astro Audit & QA
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
npm run qa
|
||||
|
|
|
|||
|
|
@ -8,17 +8,19 @@
|
|||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"dev": "astro dev --host 0.0.0.0 --port 5173",
|
||||
"typecheck": "astro check",
|
||||
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"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:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
|
||||
"qa:links": "node scripts/check-links.mjs",
|
||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
||||
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
|
||||
"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"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
BIN
screenshots/homepage-1440.png
Normal file
BIN
screenshots/homepage-1440.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
screenshots/timeline-crop-right-origin.png
Normal file
BIN
screenshots/timeline-crop-right-origin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
screenshots/timeline-crop.png
Normal file
BIN
screenshots/timeline-crop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
95
scripts/check-no-em-dashes.mjs
Normal file
95
scripts/check-no-em-dashes.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const forbidden = String.fromCodePoint(0x2014);
|
||||
const root = process.cwd();
|
||||
|
||||
const textExtensions = new Set([
|
||||
'.astro',
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.ts',
|
||||
'.txt',
|
||||
'.vtt',
|
||||
'.webmanifest',
|
||||
'.xml',
|
||||
]);
|
||||
|
||||
const roots = [
|
||||
'src',
|
||||
'public',
|
||||
'scripts',
|
||||
'README.md',
|
||||
'package.json',
|
||||
'astro.config.mjs',
|
||||
].map((entry) => path.resolve(root, entry));
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(entryPath) {
|
||||
const entryStat = await stat(entryPath);
|
||||
if (entryStat.isFile()) return [entryPath];
|
||||
|
||||
const entries = await readdir(entryPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(entryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function lineAndColumn(text, index) {
|
||||
const before = text.slice(0, index);
|
||||
const lines = before.split('\n');
|
||||
return {
|
||||
line: lines.length,
|
||||
column: lines.at(-1).length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const files = [];
|
||||
|
||||
for (const entry of roots) {
|
||||
if (!(await exists(entry))) continue;
|
||||
files.push(...(await walk(entry)));
|
||||
}
|
||||
|
||||
const textFiles = files.filter((file) => textExtensions.has(path.extname(file)));
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const file of textFiles) {
|
||||
const text = await readFile(file, 'utf8');
|
||||
let index = text.indexOf(forbidden);
|
||||
|
||||
while (index !== -1) {
|
||||
const { line, column } = lineAndColumn(text, index);
|
||||
failures.push(`${path.relative(root, file)}:${line}:${column}`);
|
||||
index = text.indexOf(forbidden, index + forbidden.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Em dashes are not allowed:\n${failures.join('\n')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('No em dashes found.');
|
||||
|
|
@ -36,9 +36,8 @@ if (jsFiles.length > 0) {
|
|||
}
|
||||
|
||||
// Script tags are only allowed if they declare one of these safe `type`
|
||||
// attributes (or are tagged with `data-theme-script`). All other scripts —
|
||||
// including untyped ones, which default to executable JavaScript — are
|
||||
// flagged.
|
||||
// attributes (or are tagged with `data-theme-script`). All other scripts,
|
||||
// including untyped ones, which default to executable JavaScript, are flagged.
|
||||
const SAFE_SCRIPT_TYPES = new Set([
|
||||
'application/ld+json',
|
||||
'importmap',
|
||||
|
|
@ -47,6 +46,7 @@ const SAFE_SCRIPT_TYPES = new Set([
|
|||
|
||||
function isSafeScriptTag(tag) {
|
||||
if (tag.includes('data-theme-script')) return true;
|
||||
if (tag.includes('data-thumbnail-iframe-script')) return true;
|
||||
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
|
||||
if (!typeMatch) return false;
|
||||
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());
|
||||
|
|
|
|||
476
scripts/export-astro-audit.mjs
Normal file
476
scripts/export-astro-audit.mjs
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
|
||||
const outputJson = path.resolve(
|
||||
process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
|
||||
);
|
||||
const outputMarkdown = path.resolve(
|
||||
process.env.ASTRO_AUDIT_OUTPUT_MD ?? '.astro/astro-audit-results.md'
|
||||
);
|
||||
const astroBin = path.resolve(
|
||||
'node_modules',
|
||||
'.bin',
|
||||
process.platform === 'win32' ? 'astro.cmd' : 'astro'
|
||||
);
|
||||
const HOST = '127.0.0.1';
|
||||
const INDEX_FILE = 'index.html';
|
||||
const SERVER_START_TIMEOUT_MS = 60000;
|
||||
const CLOSE_TIMEOUT_MS = 3000;
|
||||
const NAV_TIMEOUT_MS = 20000;
|
||||
const AUDIT_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_VIEWPORTS = '1440x900';
|
||||
const failOnIssues =
|
||||
process.argv.includes('--fail-on-issues') ||
|
||||
process.env.ASTRO_AUDIT_FAIL_ON_ISSUES === '1';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWPORTS) {
|
||||
return raw.split(',').map((entry) => {
|
||||
const match = entry.trim().match(/^(\d+)x(\d+)$/i);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid ASTRO_AUDIT_VIEWPORTS entry "${entry}". Use values like 1440x900,390x844.`
|
||||
);
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
});
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function discoverRoutes() {
|
||||
if (process.env.ASTRO_AUDIT_ROUTES) {
|
||||
return process.env.ASTRO_AUDIT_ROUTES.split(',')
|
||||
.map((route) => route.trim())
|
||||
.filter(Boolean)
|
||||
.map((route) => (route.startsWith('/') ? route : `/${route}`));
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = createNetServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, HOST, resolve);
|
||||
});
|
||||
const { port } = server.address();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
function startAstroDev(port) {
|
||||
const child = spawn(astroBin, ['dev', '--host', HOST, '--port', String(port)], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
ASTRO_TELEMETRY_DISABLED: '1',
|
||||
NO_COLOR: '1',
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
});
|
||||
|
||||
let output = '';
|
||||
const append = (chunk) => {
|
||||
output = `${output}${chunk.toString()}`.slice(-20000);
|
||||
};
|
||||
child.stdout.on('data', append);
|
||||
child.stderr.on('data', append);
|
||||
child.getRecentOutput = () => output.trim();
|
||||
return child;
|
||||
}
|
||||
|
||||
async function waitForDevServer(baseUrl, child) {
|
||||
const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Astro dev server exited before it was ready.\n${child.getRecentOutput()}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(baseUrl);
|
||||
if (response.status < 500) return;
|
||||
} catch {
|
||||
// Server is not listening yet.
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for Astro dev server at ${baseUrl}.\n${child.getRecentOutput()}`
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child) {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
child.kill('SIGTERM');
|
||||
const exited = once(child, 'exit');
|
||||
const timedOut = sleep(CLOSE_TIMEOUT_MS).then(() => 'timeout');
|
||||
if ((await Promise.race([exited, timedOut])) === 'timeout') {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser?.process?.();
|
||||
try {
|
||||
await Promise.race([
|
||||
browser.close(),
|
||||
sleep(CLOSE_TIMEOUT_MS).then(() => {
|
||||
throw new Error('Timed out while closing Chromium');
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
function viewportLabel(viewport) {
|
||||
return `${viewport.width}x${viewport.height}`;
|
||||
}
|
||||
|
||||
async function extractAuditResults(page) {
|
||||
return page.evaluate(
|
||||
async ({ auditTimeoutMs }) => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + auditTimeoutMs;
|
||||
|
||||
function describeElement(element) {
|
||||
if (!element) return '';
|
||||
const pieces = [];
|
||||
let current = element;
|
||||
while (
|
||||
current instanceof Element &&
|
||||
current !== document.documentElement &&
|
||||
pieces.length < 5
|
||||
) {
|
||||
let piece = current.localName;
|
||||
if (current.id) {
|
||||
piece += `#${current.id}`;
|
||||
} else {
|
||||
const classes = [...current.classList].slice(0, 2);
|
||||
if (classes.length > 0) piece += `.${classes.join('.')}`;
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = [...parent.children].filter(
|
||||
(child) => child.localName === current.localName
|
||||
);
|
||||
if (siblings.length > 1) {
|
||||
piece += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.unshift(piece);
|
||||
current = current.parentElement;
|
||||
}
|
||||
return pieces.join(' > ');
|
||||
}
|
||||
|
||||
function resolveRuleValue(value, element) {
|
||||
try {
|
||||
if (typeof value === 'function') return String(value(element) ?? '');
|
||||
return String(value ?? '');
|
||||
} catch (error) {
|
||||
return `Error resolving audit value: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceForAudit(audit) {
|
||||
const tooltip = audit.highlight?.shadowRoot?.querySelector(
|
||||
'astro-dev-toolbar-tooltip'
|
||||
);
|
||||
const sourceSection = tooltip?.sections?.find(
|
||||
(section) => section.clickDescription === 'Click to go to file'
|
||||
);
|
||||
return sourceSection?.content ?? null;
|
||||
}
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const toolbar = document.querySelector('astro-dev-toolbar');
|
||||
const button = toolbar?.shadowRoot?.querySelector('[data-app-id="astro:audit"]');
|
||||
|
||||
if (button && !button.classList.contains('active')) {
|
||||
button.click();
|
||||
}
|
||||
|
||||
const auditCanvas = toolbar?.shadowRoot?.querySelector(
|
||||
'astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]'
|
||||
);
|
||||
const auditWindow = auditCanvas?.shadowRoot?.querySelector(
|
||||
'astro-dev-toolbar-audit-window'
|
||||
);
|
||||
|
||||
if (button?.classList.contains('active') && auditWindow) {
|
||||
await sleep(100);
|
||||
return {
|
||||
results: (auditWindow.audits ?? []).map((audit) => {
|
||||
const element = audit.auditedElement;
|
||||
return {
|
||||
code: audit.rule.code,
|
||||
category: audit.rule.code?.split('-')[0] ?? '',
|
||||
title: resolveRuleValue(audit.rule.title, element),
|
||||
message: resolveRuleValue(audit.rule.message, element),
|
||||
description: resolveRuleValue(audit.rule.description, element),
|
||||
ruleSelector: audit.rule.selector,
|
||||
source: sourceForAudit(audit),
|
||||
element: {
|
||||
selector: describeElement(element),
|
||||
tagName: element?.tagName?.toLowerCase() ?? '',
|
||||
html: element?.outerHTML?.replace(/\s+/g, ' ').slice(0, 800) ?? '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
return {
|
||||
error:
|
||||
'Timed out waiting for the Astro Dev Toolbar Audit app. Make sure devToolbar is enabled.',
|
||||
};
|
||||
},
|
||||
{ auditTimeoutMs: AUDIT_TIMEOUT_MS }
|
||||
);
|
||||
}
|
||||
|
||||
async function auditRoute(context, baseUrl, route, viewport) {
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(new URL(route, baseUrl).href, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: NAV_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForSelector('astro-dev-toolbar', {
|
||||
state: 'attached',
|
||||
timeout: AUDIT_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
const audit = await extractAuditResults(page);
|
||||
if (audit.error) {
|
||||
throw new Error(`${route} at ${viewportLabel(viewport)}: ${audit.error}`);
|
||||
}
|
||||
|
||||
return audit.results.map((result) => ({
|
||||
route,
|
||||
url: page.url(),
|
||||
viewport: viewportLabel(viewport),
|
||||
...result,
|
||||
}));
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function markdownEscape(value) {
|
||||
return String(value ?? '').replaceAll('|', '\\|');
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const lines = [
|
||||
'# Astro Audit Results',
|
||||
'',
|
||||
`Generated: ${report.generatedAt}`,
|
||||
`Routes checked: ${report.routes.length}`,
|
||||
`Viewports: ${report.viewports.map(viewportLabel).join(', ')}`,
|
||||
`Issues found: ${report.results.length}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (report.results.length === 0) {
|
||||
lines.push('No accessibility or performance issues detected.');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const byRoute = Map.groupBy(report.results, (result) => result.route);
|
||||
for (const [route, routeResults] of byRoute) {
|
||||
lines.push(`## ${route}`, '');
|
||||
lines.push('| Viewport | Code | Title | Source | Element |');
|
||||
lines.push('| --- | --- | --- | --- | --- |');
|
||||
for (const result of routeResults) {
|
||||
lines.push(
|
||||
`| ${markdownEscape(result.viewport)} | \`${markdownEscape(result.code)}\` | ${markdownEscape(
|
||||
result.title
|
||||
)} | ${markdownEscape(result.source ?? '')} | \`${markdownEscape(
|
||||
result.element.selector
|
||||
)}\` |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('## Details', '');
|
||||
for (const result of report.results) {
|
||||
lines.push(
|
||||
`### ${result.route} ${result.viewport} ${result.code}`,
|
||||
'',
|
||||
`- Title: ${result.title}`,
|
||||
`- Source: ${result.source ?? 'unknown'}`,
|
||||
`- Message: ${result.message}`,
|
||||
`- Description: ${result.description || 'none'}`,
|
||||
`- Element: \`${result.element.selector}\``,
|
||||
'',
|
||||
'```html',
|
||||
result.element.html,
|
||||
'```',
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const viewports = parseViewports();
|
||||
const routes = await discoverRoutes();
|
||||
|
||||
if (routes.length === 0) {
|
||||
throw new Error('No HTML routes found to audit.');
|
||||
}
|
||||
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
await mkdir(path.dirname(outputJson), { recursive: true });
|
||||
await mkdir(path.dirname(outputMarkdown), { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://${HOST}:${port}/`;
|
||||
let devServer;
|
||||
let browser;
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
devServer = startAstroDev(port);
|
||||
await waitForDevServer(baseUrl, devServer);
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
await context.route('**/*', (route) => {
|
||||
if (route.request().resourceType() === 'media') {
|
||||
route.abort('blockedbyclient');
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
for (const route of routes) {
|
||||
results.push(...(await auditRoute(context, baseUrl, route, viewport)));
|
||||
}
|
||||
} finally {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseUrl,
|
||||
routes,
|
||||
viewports,
|
||||
results,
|
||||
};
|
||||
|
||||
await writeFile(outputJson, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeFile(outputMarkdown, renderMarkdown(report));
|
||||
|
||||
console.log(
|
||||
`Exported ${results.length} Astro audit issue${
|
||||
results.length === 1 ? '' : 's'
|
||||
} across ${routes.length} routes.`
|
||||
);
|
||||
console.log(`JSON: ${path.relative(process.cwd(), outputJson)}`);
|
||||
console.log(`Markdown: ${path.relative(process.cwd(), outputMarkdown)}`);
|
||||
|
||||
if (results.length > 0) {
|
||||
for (const result of results.slice(0, 20)) {
|
||||
console.log(
|
||||
`- ${result.route} [${result.viewport}] ${result.code}: ${result.title}${
|
||||
result.source ? ` (${result.source})` : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (results.length > 20) {
|
||||
console.log(`...and ${results.length - 20} more. See the report files.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failOnIssues && results.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
if (browser) await safeCloseBrowser(browser);
|
||||
if (devServer) await stopProcess(devServer);
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
|
@ -8,19 +8,28 @@ interface Props {
|
|||
posts: CollectionEntry<'posts'>[];
|
||||
showYear?: boolean;
|
||||
tagLimit?: number;
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold (home, tag pages). Lists below substantial
|
||||
// content (related, archives by year, 404) should leave this off.
|
||||
timeline?: boolean;
|
||||
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
|
||||
// below substantial content (related, about, 404) should leave this at zero.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
eagerThumbnailCount?: number;
|
||||
}
|
||||
|
||||
const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
|
||||
const {
|
||||
posts,
|
||||
showYear = true,
|
||||
tagLimit = 3,
|
||||
timeline = false,
|
||||
eagerFirstThumbnail = false,
|
||||
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<ol class="article-list">
|
||||
<ol class:list={['article-list', timeline && 'article-list--timeline']}>
|
||||
{
|
||||
posts.map((post, index) => {
|
||||
const href = articlePath(post);
|
||||
const eager = index < eagerThumbnailCount;
|
||||
return (
|
||||
<li>
|
||||
<article>
|
||||
|
|
@ -43,8 +52,8 @@ const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = As
|
|||
widths={ARTICLE_THUMBNAIL.widths}
|
||||
sizes={ARTICLE_THUMBNAIL.sizes}
|
||||
ariaLabel={`Open article: ${post.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
fetchpriority={eager && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,10 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
import { site } from '../lib/site';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Footer shows all nav items except Home (which is implicit via the site title).
|
||||
const footerNavItems = navItems.filter((item) => item.href !== '/');
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<nav aria-label="Footer">
|
||||
<ul class="footer-links">
|
||||
{
|
||||
footerNavItems.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="footer-meta">
|
||||
<span>© {year} {site.name}</span>
|
||||
{/* address wraps only the author's contact details, per HTML spec. */}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
|
|||
|
||||
<a class="skip-link" href="#content">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
|
||||
<a class="site-title" href="/" aria-current={currentState('/')}>{site.brand}</a>
|
||||
<div class="header-actions">
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{
|
||||
|
|
@ -57,6 +57,36 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
|
|||
aria-label="Dark theme"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg
|
||||
class="theme-switcher-icon theme-switcher-icon-sun"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"
|
||||
></circle>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
class="theme-switcher-icon theme-switcher-icon-moon"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
height="18"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21 13.79A8.5 8.5 0 0 1 10.21 3a7 7 0 1 0 10.8 10.79Z"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
116
src/components/PostThumbnail.astro
Normal file
116
src/components/PostThumbnail.astro
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { absoluteUrl } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const demoLink = post.data.links.find(
|
||||
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
|
||||
);
|
||||
const iframeUrl = post.data.iframeThumbnail ? demoLink?.url : undefined;
|
||||
const iframeSrc = iframeUrl?.startsWith('/') ? absoluteUrl(iframeUrl) : iframeUrl;
|
||||
const iframeTitle = demoLink
|
||||
? `${demoLink.label}: ${post.data.title}`
|
||||
: `Embedded demo: ${post.data.title}`;
|
||||
const aspectRatio = `${post.data.thumbnail.src.width} / ${post.data.thumbnail.src.height}`;
|
||||
const iframeThumbnailScript = `
|
||||
for (const root of document.querySelectorAll('.post-thumbnail--iframe')) {
|
||||
const trigger = root.querySelector('[data-thumbnail-iframe-trigger]');
|
||||
const frame = root.querySelector('[data-thumbnail-iframe-frame]');
|
||||
if (!(trigger instanceof HTMLButtonElement) || !(frame instanceof HTMLIFrameElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
trigger.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
const src = trigger.dataset.iframeSrc;
|
||||
if (!src) return;
|
||||
|
||||
if (window.isSecureContext === false) {
|
||||
const opened = window.open('', '_blank');
|
||||
if (!opened) {
|
||||
window.location.href = src;
|
||||
} else {
|
||||
opened.opener = null;
|
||||
opened.location.href = src;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
frame.src = src;
|
||||
frame.hidden = false;
|
||||
root.classList.add('is-active');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
frame.focus();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
`;
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['post-thumbnail', iframeSrc && 'post-thumbnail--iframe']}
|
||||
style={iframeSrc ? `--post-thumbnail-aspect: ${aspectRatio}` : undefined}
|
||||
>
|
||||
<Picture
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={[640, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
{
|
||||
iframeSrc && (
|
||||
<>
|
||||
<button
|
||||
class="post-thumbnail__play"
|
||||
type="button"
|
||||
data-thumbnail-iframe-trigger
|
||||
data-iframe-src={iframeSrc}
|
||||
aria-label={`Play ${demoLink?.label.toLowerCase() ?? 'demo'}`}
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="post-thumbnail__play-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sr-only">Play {demoLink?.label.toLowerCase() ?? 'demo'}</span>
|
||||
</button>
|
||||
<iframe
|
||||
class="post-thumbnail__iframe"
|
||||
data-thumbnail-iframe-frame
|
||||
title={iframeTitle}
|
||||
src="about:blank"
|
||||
allow="fullscreen; webgpu"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
tabindex="0"
|
||||
hidden
|
||||
/>
|
||||
<noscript>
|
||||
<p class="post-thumbnail__noscript">
|
||||
<a href={iframeSrc}>Open {demoLink?.label.toLowerCase() ?? 'demo'}</a>
|
||||
</p>
|
||||
</noscript>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
iframeSrc && (
|
||||
<script is:inline data-thumbnail-iframe-script set:html={iframeThumbnailScript} />
|
||||
)
|
||||
}
|
||||
|
|
@ -7,13 +7,17 @@ import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
|
|||
|
||||
interface Props {
|
||||
projects: CollectionEntry<'projects'>[];
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold. The home and projects-index lists sit below
|
||||
// other sections, so leave this off there.
|
||||
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
|
||||
// below substantial content should leave this at zero.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
eagerThumbnailCount?: number;
|
||||
}
|
||||
|
||||
const { projects, eagerFirstThumbnail = false } = Astro.props;
|
||||
const {
|
||||
projects,
|
||||
eagerFirstThumbnail = false,
|
||||
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
|
||||
} = Astro.props;
|
||||
|
||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||
|
|
@ -33,6 +37,7 @@ for (const project of projects) {
|
|||
const titleId = `${anchor}-title`;
|
||||
const essayHref = essayHrefs.get(project.id);
|
||||
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
||||
const eager = index < eagerThumbnailCount;
|
||||
|
||||
return (
|
||||
<li class="project-card" id={anchor}>
|
||||
|
|
@ -44,8 +49,8 @@ for (const project of projects) {
|
|||
widths={PROJECT_THUMBNAIL.widths}
|
||||
sizes={PROJECT_THUMBNAIL.sizes}
|
||||
ariaLabel={`Open project: ${project.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
fetchpriority={eager && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
<article class="project-card__summary">
|
||||
<h3 id={titleId}>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ const mediaUrl = z.string().refine(
|
|||
{ message: 'Media URL must be an absolute https URL or a root-relative path.' }
|
||||
);
|
||||
|
||||
function isIframeUrl(url: string) {
|
||||
if (isRootRelativeUrl(url)) return true;
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const linkSchema = z.object({
|
||||
label: z.string(),
|
||||
url: linkUrl,
|
||||
|
|
@ -91,13 +100,15 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
|||
const posts = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
z
|
||||
.object({
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
date: z.coerce.date(),
|
||||
updated: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
iframeThumbnail: z.boolean().default(false),
|
||||
tags: z.array(
|
||||
z.enum([
|
||||
'ai',
|
||||
|
|
@ -121,7 +132,22 @@ const posts = defineCollection({
|
|||
.default('technical'),
|
||||
links: z.array(linkSchema).default([]),
|
||||
media: z.array(mediaSchema({ image })).default([]),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(post) =>
|
||||
!post.iframeThumbnail ||
|
||||
post.links.some(
|
||||
(link) =>
|
||||
!link.download &&
|
||||
link.label.trim().toLowerCase() === 'demo' &&
|
||||
isIframeUrl(link.url)
|
||||
),
|
||||
{
|
||||
path: ['iframeThumbnail'],
|
||||
message:
|
||||
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const projects = defineCollection({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A 50 FPS Game Engine on an 8-Bit Microcontroller
|
||||
description: A handheld game built from the PCB up — ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.
|
||||
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.'
|
||||
date: 2026-05-06
|
||||
projectPeriod: 'Spring 2020'
|
||||
thumbnail:
|
||||
|
|
@ -22,7 +22,7 @@ media:
|
|||
mp4: /media/video/ad_astra.mp4
|
||||
captions: /media/video/ad_astra.vtt
|
||||
alt: Video demonstration of the embedded game running on a small OLED display.
|
||||
caption: The whole thing — board, firmware, sprites, game loop — runs on a single ATtiny85V at 8 MHz.
|
||||
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.
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A Unity City Where Bad PLC Code Made Cars Crash
|
||||
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes — the most honest feedback loop I've shipped.
|
||||
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped.
|
||||
date: 2026-05-01
|
||||
projectPeriod: 'July-August 2018'
|
||||
thumbnail:
|
||||
|
|
@ -19,7 +19,7 @@ A small city in Unity where the traffic lights were driven by a REST API. Contes
|
|||
Three things are worth saying about it:
|
||||
|
||||
- **Visual feedback was the whole point.** Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster.
|
||||
- **Server-client, all decisions on the server.** Every agent's behaviour was computed centrally and broadcast. The harder problem wasn't simulation — it was getting the broadcast to be fault-tolerant on the conference Wi-Fi without flooding it.
|
||||
- **Server-client, all decisions on the server.** Every agent's behaviour was computed centrally and broadcast. The harder problem wasn't simulation; it was getting the broadcast to be fault-tolerant on the conference Wi-Fi without flooding it.
|
||||
- **Built it solo, including the models and animations in Blender.** Not a flex, just context for why everything's a little janky.
|
||||
|
||||
There was also a HUD overlay for tweets, which felt clever at the time and dated horribly. Skip that part.
|
||||
|
|
|
|||
|
|
@ -33,17 +33,17 @@ media:
|
|||
|
||||
## The split that usually goes wrong
|
||||
|
||||
Real-time multiplayer has an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. If you write the rules twice — once on each side — they will drift. Eventually a player's screen will say one thing and the server will think another.
|
||||
Real-time multiplayer has an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. If you write the rules twice, once on each side, they will drift. Eventually a player's screen will say one thing and the server will think another.
|
||||
|
||||
I wanted the server's "compute the next state" function and the client's "predict the next state locally" function to be literally the same function. So I put the rules in a shared TypeScript library, published nothing, and had both `package.json` files link to it.
|
||||
|
||||
The win wasn't elegance, it was the bugs that didn't happen. Client prediction stopped being an approximation of the server — it _was_ the server, run optimistically and reconciled when the authoritative update came back.
|
||||
The win wasn't elegance, it was the bugs that didn't happen. Client prediction stopped being an approximation of the server; it _was_ the server, run optimistically and reconciled when the authoritative update came back.
|
||||
|
||||
## Other choices worth a sentence
|
||||
|
||||
- **k-d trees for spatial queries.** Once the world held more than a few dozen objects, naive collision and proximity checks dominated the server tick. A k-d tree dropped them out of the profile.
|
||||
- **Message-passing object model.** Lifted from Smalltalk's `doesNotUnderstand:` idea. Entities respond to messages they care about and ignore the rest. Easier to extend than the inheritance tree I tried first, and less brittle.
|
||||
- **Firebase only for server discovery.** Not for game state — for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
|
||||
- **Firebase only for server discovery.** Not for game state, just for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
|
||||
|
||||
## What I'd change
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ projectPeriod: '2026'
|
|||
thumbnail:
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
|
||||
iframeThumbnail: true
|
||||
tags: ['graphics', 'simulation', 'web']
|
||||
role: Graphics and shader author
|
||||
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
||||
|
|
@ -32,7 +33,7 @@ media:
|
|||
|
||||
## Why physarum needed a knob
|
||||
|
||||
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough — the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
|
||||
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
|
||||
|
||||
My second self-imposed constraint was that one engine had to produce six visibly different presets without forking. The first prototype had a `switch (preset)` with one behaviour function per vibe and it was already painful at vibe two. I needed the personality to live in data, not code.
|
||||
|
||||
|
|
@ -42,9 +43,9 @@ Each vibe is a 3×3 table of colour-to-colour affinities. When an agent of colou
|
|||
|
||||
Three examples of what nine numbers can do:
|
||||
|
||||
- **Aurora Mycelium** — cyclic, each colour chases the next. Agents wind into ribbons.
|
||||
- **Velvet Observatory** — every off-diagonal entry negative. Colours repel into separate islands.
|
||||
- **Paper Lantern Fog** — matrix filled with ones. Colours collapse into one cooperative blob.
|
||||
- **Aurora Mycelium:** cyclic, each colour chases the next. Agents wind into ribbons.
|
||||
- **Velvet Observatory:** every off-diagonal entry negative. Colours repel into separate islands.
|
||||
- **Paper Lantern Fog:** matrix filled with ones. Colours collapse into one cooperative blob.
|
||||
|
||||
Adding a tenth number to the matrix would tax every existing vibe. Tuning the nine I have is a text edit. Six presets in, I haven't extended it.
|
||||
|
||||
|
|
@ -52,18 +53,18 @@ Adding a tenth number to the matrix would tax every existing vibe. Tuning the ni
|
|||
|
||||
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
|
||||
|
||||
1. **Agent step** — sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
|
||||
2. **Diffusion** — blur and decay so old marks soften.
|
||||
3. **Brush** — write user strokes into both the trail texture and a separate "source" texture the agents can read.
|
||||
4. **Eraser** — two variants: one clears a region of the trail, the other kills agents in a radius.
|
||||
5. **Agent generation** — spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
|
||||
6. **Render** — read the trail, apply palette and grain.
|
||||
1. **Agent step:** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
|
||||
2. **Diffusion:** blur and decay so old marks soften.
|
||||
3. **Brush:** write user strokes into both the trail texture and a separate "source" texture the agents can read.
|
||||
4. **Eraser:** two variants: one clears a region of the trail, the other kills agents in a radius.
|
||||
5. **Agent generation:** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
|
||||
6. **Render:** read the trail, apply palette and grain.
|
||||
|
||||
The bind-group setup overhead from running more pipelines was lost in the noise next to the simulation cost. The win was that when the eraser shader started killing the wrong agents, I opened one file and reasoned about it without touching anything else.
|
||||
|
||||
## Smaller calls
|
||||
|
||||
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see — a stroke vanishing thirty seconds after you drew it — isn't an explicit eraser, it's the buffer wrapping around.
|
||||
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see, a stroke vanishing thirty seconds after you drew it, isn't an explicit eraser, it's the buffer wrapping around.
|
||||
- **URL is the share format.** The chosen vibe is in the query string. The "send your friend this preset" link is just a URL with `?vibe=tidepool-lantern` on it. The parser is tolerant about accents and casing because people retype these.
|
||||
- **One HTML file.** All CSS and JS inline. The piano samples sit beside it. Self-contained enough to email or drop on a USB stick.
|
||||
|
||||
|
|
@ -71,4 +72,4 @@ The bind-group setup overhead from running more pipelines was lost in the noise
|
|||
|
||||
- The intro animation (agents fly in to spell the title, then transition to steady state) couples three shaders through a single `progress: 0 → 1` value. It's the bit I'd least want to refactor today. Next time I'd model the intro as its own dispatch with its own buffer and hand off cleanly.
|
||||
- Mobile works, but the toolbar fights the canvas for screen and the agent cap has to shrink hard to keep frame time down. A proper fix means rethinking the toolbar and exposing the cap-vs-resolution tradeoff to the user.
|
||||
- The simulation has invariants — agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, the eraser doesn't leak agents past its radius — that proptest would falsify in minutes. Snapshot tests aren't the right tool here.
|
||||
- The simulation has invariants that proptest would falsify in minutes: agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, and the eraser doesn't leak agents past its radius. Snapshot tests aren't the right tool here.
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@ The input-side companion to the [cooling system sim](/articles/nuclear-cooling-s
|
|||
|
||||
It was small but it punched above its weight at the actual event. The simulator's value depended entirely on the organisers being able to change the plant without me sitting next to them. The editor was what made that possible.
|
||||
|
||||
If I built it again I'd skip JavaFX and put the editor in the browser — same place the monitoring clients lived. One install fewer for everyone.
|
||||
If I built it again I'd skip JavaFX and put the editor in the browser, the same place the monitoring clients lived. One install fewer for everyone.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ media:
|
|||
|
||||
The literature has a long list of habits you should adopt when shipping an ML service: track inputs, version models, expose health, log decisions, keep predictions reproducible. Everyone agrees with the list. Almost nobody implements all of it.
|
||||
|
||||
I spent the bulk of the thesis catalogueing 33 such habits, proposing 6 more, and surveying engineers on which actually got applied in their day jobs. The data was pretty clear about the failure mode: it wasn't ignorance, it wasn't laziness, it wasn't budget. It was that the cost of doing the right thing — five lines of glue per habit, multiplied across a stack — was higher than the visible cost of skipping it. So skipping it became the default.
|
||||
I spent the bulk of the thesis catalogueing 33 such habits, proposing 6 more, and surveying engineers on which actually got applied in their day jobs. The data was pretty clear about the failure mode: it wasn't ignorance, it wasn't laziness, it wasn't budget. It was that the cost of doing the right thing, five lines of glue per habit multiplied across a stack, was higher than the visible cost of skipping it. So skipping it became the default.
|
||||
|
||||
So the real research question wasn't "what should engineers do." It was "what API shape makes doing the right thing cheaper than not."
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Syncing State with an Immutable Trie
|
||||
description: A visual goal tracker whose lasting idea was the sync model — an immutable trie so structural diffs are trivial and only deltas cross the wire.
|
||||
description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.'
|
||||
date: 2026-05-05
|
||||
projectPeriod: 'August-September 2019'
|
||||
thumbnail:
|
||||
|
|
@ -40,7 +40,7 @@ A goal in Life Towers is a path of strings. `Health / Running / 5k`. Tasks under
|
|||
Two properties did the heavy lifting:
|
||||
|
||||
- **Structural sharing.** When you tick off a task under `Health / Running / 5k`, the new root reuses every untouched subtree by reference. The `Career` branch and the `Reading` branch are the same objects they were before. Comparing the old and new roots is mostly pointer equality; only the path that actually changed gets walked.
|
||||
- **Immutability.** Updates produce new structure instead of mutating. "Where I was" and "where I am" become two pointers, not two snapshots. The diff between them is whatever's not shared — and that walk is O(changes), not O(state).
|
||||
- **Immutability.** Updates produce new structure instead of mutating. "Where I was" and "where I am" become two pointers, not two snapshots. The diff between them is whatever's not shared, and that walk is O(changes), not O(state).
|
||||
|
||||
The sync loop falls out:
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ The sync loop falls out:
|
|||
3. Server applies, returns its new root.
|
||||
4. Client rebases any in-flight edits by replaying them on top.
|
||||
|
||||
There's no conflict resolution layer because the operations commute on the structure — two clients adding tasks under different branches produce non-overlapping deltas that compose trivially. The hard cases (two clients editing the same leaf) are tiny and obvious, because they're the _only_ place the deltas touch the same path.
|
||||
There's no conflict resolution layer because the operations commute on the structure. Two clients adding tasks under different branches produce non-overlapping deltas that compose trivially. The hard cases (two clients editing the same leaf) are tiny and obvious, because they're the _only_ place the deltas touch the same path.
|
||||
|
||||
## What I'd change
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: My First Real Project — LEDs Driven by an FFT
|
||||
title: 'My First Real Project: LEDs Driven by an FFT'
|
||||
description: A Raspberry Pi music player that drove RGB strips through MOSFETs. The first thing I started and actually finished.
|
||||
date: 2026-04-26
|
||||
projectPeriod: 'Spring 2016'
|
||||
|
|
@ -16,10 +16,10 @@ links: []
|
|||
|
||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. The plan was something like: play music, look at it, make the lights match.
|
||||
|
||||
I got bands wrong first. I tried mapping raw audio amplitude to brightness, which made the lights pulse with anything — clipping, voice, fan noise — and produced a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
|
||||
I got bands wrong first. I tried mapping raw audio amplitude to brightness, which made the lights pulse with anything: clipping, voice, fan noise. It produced a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
|
||||
|
||||
The MOSFETs took longer than they should have. I wired one backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one.
|
||||
|
||||
The frontend was a vanilla web page on the same Pi: pick a track, tweak the band thresholds, see what changed. No framework. Just a `<select>`, a few sliders, and an `XMLHttpRequest`. It worked.
|
||||
|
||||
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs — it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
|
||||
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs; it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: My Notes — A Markdown App for Android
|
||||
title: 'My Notes: A Markdown App for Android'
|
||||
description: A small Android note app built on Markwon. The idea wasn't new; the point was learning a platform that wasn't the web.
|
||||
date: 2026-05-02
|
||||
projectPeriod: 'November 2019'
|
||||
|
|
@ -18,6 +18,6 @@ links:
|
|||
|
||||
A small Android app for writing Markdown notes and filtering them by hashtag. Built on top of Markwon for the rendering.
|
||||
|
||||
The idea wasn't original — every developer writes their own notes app eventually — and the bar for shipping one wasn't high. What I actually wanted from the project was a few weeks somewhere outside the web stack, in a platform with different conventions about lifecycle, storage, and resource constraints. Android delivered that.
|
||||
The idea wasn't original; every developer writes their own notes app eventually, and the bar for shipping one wasn't high. What I actually wanted from the project was a few weeks somewhere outside the web stack, in a platform with different conventions about lifecycle, storage, and resource constraints. Android delivered that.
|
||||
|
||||
I don't use the app anymore (it lost a long battle with Obsidian, which is also why I later wrote [reconcile-text](/articles/reconcile-text-3-way-merge/)). I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Two Graphs Are Simpler Than One — A Cooling System Simulator
|
||||
title: 'Two Graphs Are Simpler Than One: A Cooling System Simulator'
|
||||
description: A live cooling-system sim for a PLC cybersecurity event. Splitting flow and heat into two graph passes kept the calculation cheap and the behaviour believable.
|
||||
date: 2026-05-04
|
||||
projectPeriod: 'October-November 2018'
|
||||
|
|
@ -51,7 +51,7 @@ This is wrong as physics. It's right as a model. Flow doesn't react to instantan
|
|||
|
||||
## Why the editor mattered
|
||||
|
||||
The simulator's most-used UI was the _input_ editor — a separate JavaFX tool where you laid out the plant, set parameters per element, and exported JSON the sim ate. I wrote up the editor's [own story here](/articles/graph-editor-javafx-simulation-input/), because in hindsight it deserved to be its own project.
|
||||
The simulator's most-used UI was the _input_ editor, a separate JavaFX tool where you laid out the plant, set parameters per element, and exported JSON the sim ate. I wrote up the editor's [own story here](/articles/graph-editor-javafx-simulation-input/), because in hindsight it deserved to be its own project.
|
||||
|
||||
The lesson: a simulation is only as useful as its input pipeline. If editing the plant requires editing source, organisers won't use it.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ date: 2026-04-30
|
|||
projectPeriod: 'June 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/photo-colour-grader.jpg
|
||||
alt: Screenshot of a photo colour grading interface.
|
||||
alt: Colour grading interface with tonal controls and an edited preview.
|
||||
tags: ['graphics', 'web', 'tools']
|
||||
role: Interface and image processing author
|
||||
stack: ['JavaScript', 'Canvas', 'Image processing']
|
||||
|
|
@ -18,4 +18,4 @@ A web-based photo grader I built to try one specific interaction idea: you pick
|
|||
|
||||
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings. The transformations smoothly fell off with distance, so editing "this orange" subtly nudged the nearby reds and yellows without me ever having to think in masks.
|
||||
|
||||
I never built it into a real tool. The idea — that distance in colour space is the natural unit for prose-style editing of an image — still feels right to me. If I returned to it, I'd reach for WebGL instead of canvas to make the interaction live-preview-able on a real photo.
|
||||
I never built it into a real tool. The idea still feels right to me: distance in colour space is the natural unit for prose-style editing of an image. If I returned to it, I'd reach for WebGL instead of canvas to make the interaction live-preview-able on a real photo.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ So the library does exactly one thing: pure function from three strings to one.
|
|||
|
||||
**Myers diff per side, then weave the diffs.** Each child is diffed against the parent, the two edit scripts are optimised so adjacent changes group cleanly, then a single weaving pass interleaves them into one ordered op sequence that produces the merged text. The weave borrows the shape of operational transformation, but the inputs are batched complete diffs, not live keystrokes, so it only runs once per merge.
|
||||
|
||||
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm — it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
|
||||
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
|
||||
|
||||
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just my sync script.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
|
||||
description: My BSc thesis library. The mobile GPU constraint did the architectural work — tile-based passes, deferred shading, shaders generated per scene and device.
|
||||
description: 'My BSc thesis library. The mobile GPU constraint shaped the architecture: tile-based passes, deferred shading, shaders generated per scene and device.'
|
||||
date: 2026-05-08
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
|
|
@ -43,7 +43,7 @@ Three constraints did most of the design work:
|
|||
|
||||
- **WebGL1 and WebGL2 both supported.** No "modern browser only" cheat. That ruled out anything that needed compute shaders or storage buffers.
|
||||
- **No per-scene hand-tuned shader.** This is a library; users plug in their own scene descriptions. The renderer has to compile something appropriate at runtime.
|
||||
- **Acceptable on a phone.** Not "good when the user owns the right hardware" — acceptable on the laptop my advisor used to grade the thesis.
|
||||
- **Acceptable on a phone.** Not "good when the user owns the right hardware." It had to be acceptable on the laptop my advisor used to grade the thesis.
|
||||
|
||||
## How it actually runs
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Ad Astra
|
||||
description: A handheld game built from a custom PCB up — ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.
|
||||
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
|
||||
thumbnail:
|
||||
src: ./_assets/ad-astra.jpg
|
||||
alt: The Ad Astra handheld game running on its OLED display.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Cooling System Simulation
|
||||
description: A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal, heat as a matrix solve — two passes instead of one PDE.
|
||||
description: 'A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal and heat as a matrix solve: two passes instead of one PDE.'
|
||||
thumbnail:
|
||||
src: ./_assets/nuclear-simulation.jpg
|
||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ const jsonLdStrings = jsonLdEntries.map((entry) =>
|
|||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<main id="content" tabindex="-1">
|
||||
<main id="content">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { render } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import AtAGlance from '../components/AtAGlance.astro';
|
||||
import Breadcrumbs from '../components/Breadcrumbs.astro';
|
||||
import PostMedia from '../components/PostMedia.astro';
|
||||
import PostThumbnail from '../components/PostThumbnail.astro';
|
||||
import TagList from '../components/TagList.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
|
|
@ -122,19 +122,7 @@ const personJsonLd = buildPersonJsonLd();
|
|||
<TagList tags={post.data.tags} />
|
||||
</header>
|
||||
|
||||
<div class="post-thumbnail">
|
||||
<Picture
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={[640, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<PostThumbnail post={post} />
|
||||
|
||||
<AtAGlance
|
||||
headingId={`at-a-glance-${post.id}`}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { getImage } from 'astro:assets';
|
|||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
export const site = {
|
||||
brand: 'schmelczer.dev',
|
||||
name: 'Andras Schmelczer',
|
||||
title: 'Andras Schmelczer — Software engineer',
|
||||
title: 'Andras Schmelczer, Software engineer',
|
||||
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.',
|
||||
url: 'https://schmelczer.dev',
|
||||
|
|
@ -30,7 +31,7 @@ export const navItems: readonly NavItem[] = [
|
|||
{ href: '/articles/', label: 'Articles' },
|
||||
{ href: '/projects/', label: 'Projects' },
|
||||
{ href: '/about/', label: 'About' },
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
{ href: '/tags/', label: 'Tags', footerOnly: true },
|
||||
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
|
||||
];
|
||||
|
||||
|
|
@ -161,7 +162,8 @@ export const ARTICLE_THUMBNAIL = {
|
|||
|
||||
export const PROJECT_THUMBNAIL = {
|
||||
widths: [240, 320, 480, 640, 800],
|
||||
sizes: '(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem',
|
||||
sizes:
|
||||
'(max-width: 700px) calc(100vw - 40px), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)',
|
||||
};
|
||||
|
||||
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import EntryThumbnail from '../components/EntryThumbnail.astro';
|
||||
import Page from '../layouts/Page.astro';
|
||||
import {
|
||||
articlePath,
|
||||
absoluteUrl,
|
||||
buildPersonJsonLd,
|
||||
getPublishedPosts,
|
||||
|
|
@ -29,9 +30,15 @@ const STARTING_POINT_NOTES: Record<string, string> = {
|
|||
|
||||
const startingPointsAnnotated = startingPoints.map((post) => ({
|
||||
post,
|
||||
href: articlePath(post),
|
||||
note: STARTING_POINT_NOTES[post.id.replace(/\.mdx?$/, '')],
|
||||
}));
|
||||
|
||||
const startingPointThumbnail = {
|
||||
widths: [120, 180, 240, 320],
|
||||
sizes: '(max-width: 700px) 4rem, (max-width: 960px) 28vw, 10rem',
|
||||
};
|
||||
|
||||
const personImage = await optimizeOgImage(defaultOg);
|
||||
|
||||
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
||||
|
|
@ -63,7 +70,7 @@ const personJsonLd = buildPersonJsonLd({
|
|||
I'm Andras. I write software for a living, and have done so for about six years. MSc
|
||||
in CS. The first non-trivial thing I finished was a Raspberry Pi music visualiser
|
||||
driving LED strips through MOSFETs in 2016, and I've been chasing that same feeling
|
||||
— pick something I can't yet do, finish it — ever since.
|
||||
ever since: pick something I can't yet do, then finish it.
|
||||
</p>
|
||||
<p>
|
||||
Two patterns show up in almost everything here. First, the hard constraint usually
|
||||
|
|
@ -79,12 +86,12 @@ const personJsonLd = buildPersonJsonLd({
|
|||
<dl>
|
||||
<div>
|
||||
<dt>Lives in</dt>
|
||||
<dd>Code. Also Europe.</dd>
|
||||
<dd>Europe.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Studied</dt>
|
||||
<dd>
|
||||
MSc Computer Science. BSc thesis on SDF-2D, MSc thesis on GreatAI — both have
|
||||
MSc Computer Science. BSc thesis on SDF-2D, MSc thesis on GreatAI; both have
|
||||
writeups below.
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -113,24 +120,32 @@ const personJsonLd = buildPersonJsonLd({
|
|||
|
||||
<section class="about-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="best-starting-points">Five posts that show the two moves</h2>
|
||||
<h2 id="why-these-five">Why these five</h2>
|
||||
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ArticleList posts={startingPoints} />
|
||||
<div class="prose starting-point-notes">
|
||||
<p><strong>Why these five:</strong></p>
|
||||
<ul>
|
||||
<ol class="starting-points" aria-label="Starting point articles">
|
||||
{
|
||||
startingPointsAnnotated.map(({ post, note }) =>
|
||||
note ? (
|
||||
startingPointsAnnotated.map(({ post, href, note }) => (
|
||||
<li>
|
||||
<strong>{post.data.title}</strong> — {note}
|
||||
</li>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
<EntryThumbnail
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
href={href}
|
||||
class="starting-point__thumbnail"
|
||||
widths={startingPointThumbnail.widths}
|
||||
sizes={startingPointThumbnail.sizes}
|
||||
ariaLabel={`Open article: ${post.data.title}`}
|
||||
/>
|
||||
<div class="starting-point__body">
|
||||
<h3>
|
||||
<a href={href}>{post.data.title}</a>
|
||||
</h3>
|
||||
{note && <p>{note}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="about-section facts">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const personId = absoluteUrl('/about/#person');
|
|||
const blogJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: `${site.name} — Articles`,
|
||||
name: `${site.name}: Articles`,
|
||||
url: absoluteUrl('/articles/'),
|
||||
description,
|
||||
publisher: { '@id': personId },
|
||||
|
|
@ -65,7 +65,8 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
|||
<ArticleList
|
||||
posts={postsForYear}
|
||||
showYear={false}
|
||||
eagerFirstThumbnail={year === years[0]}
|
||||
timeline
|
||||
eagerThumbnailCount={year === years[0] ? 3 : 0}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ const personJsonLd = buildPersonJsonLd();
|
|||
<section class="home-intro">
|
||||
<p class="eyebrow">A notebook, written after the fact</p>
|
||||
<h1>
|
||||
Andras Schmelczer — writing up the projects, the trades I made inside them, and the
|
||||
ones I'd make differently now.
|
||||
<span class="home-intro-name">Andras Schmelczer</span> writes about projects, the tradeoffs
|
||||
behind them, and what hindsight changed.
|
||||
</h1>
|
||||
<p>
|
||||
Most of these started because I couldn't yet do the thing. An 8-bit ALU, a mobile
|
||||
|
|
@ -46,7 +46,7 @@ const personJsonLd = buildPersonJsonLd();
|
|||
<span aria-hidden="true">→</span></a
|
||||
>
|
||||
</div>
|
||||
<ArticleList posts={latestPosts} />
|
||||
<ArticleList posts={latestPosts} timeline eagerThumbnailCount={2} />
|
||||
</section>
|
||||
|
||||
<section class="home-section">
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const older = projects.filter((project) => !project.data.selected);
|
|||
const collectionJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: `${site.name} — Projects`,
|
||||
name: `${site.name}: Projects`,
|
||||
url: absoluteUrl('/projects/'),
|
||||
description,
|
||||
};
|
||||
|
|
@ -32,7 +32,7 @@ const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
|||
<Page title="Projects" description={description} jsonLd={jsonLd}>
|
||||
<section class="project-section">
|
||||
<h2 id="selected-projects">Selected Projects</h2>
|
||||
<ProjectList projects={selected} eagerFirstThumbnail />
|
||||
<ProjectList projects={selected} eagerThumbnailCount={6} />
|
||||
</section>
|
||||
|
||||
<section class="project-section">
|
||||
|
|
|
|||
|
|
@ -45,5 +45,5 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
|||
</nav>
|
||||
|
||||
<h2 class="sr-only">Articles</h2>
|
||||
<ArticleList posts={filteredPosts} eagerFirstThumbnail />
|
||||
<ArticleList posts={filteredPosts} eagerThumbnailCount={3} />
|
||||
</Page>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ for (const post of posts) {
|
|||
const collectionJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: `${site.name} — Tags`,
|
||||
name: `${site.name}: Tags`,
|
||||
url: absoluteUrl('/tags/'),
|
||||
description,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
@layer reset, base, layout, components, utilities, overrides;
|
||||
|
||||
/* =========================================================================
|
||||
Tokens — colors, type, space, radius, weights, layout widths
|
||||
Tokens: colors, type, space, radius, weights, layout widths
|
||||
========================================================================= */
|
||||
|
||||
:root {
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
'Segoe UI', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
/* Palette — light-dark() pairs each token (light, dark) */
|
||||
/* Palette: light-dark() pairs each token (light, dark) */
|
||||
--color-bg: light-dark(#fbfaf7, #151514);
|
||||
--color-fg: light-dark(#181817, #f1eee7);
|
||||
/* Contrast with --color-bg: light ~5.4:1, dark ~7.1:1 (both clear WCAG AA
|
||||
|
|
@ -169,6 +169,10 @@
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
@ -234,7 +238,7 @@
|
|||
}
|
||||
|
||||
/* =========================================================================
|
||||
Layout — site shell, header, footer, skip link
|
||||
Layout: site shell, header, footer, skip link
|
||||
========================================================================= */
|
||||
|
||||
@layer layout {
|
||||
|
|
@ -279,6 +283,10 @@
|
|||
border-bottom: 1px solid var(--color-rule);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
color: var(--color-fg);
|
||||
font-size: var(--fs-lg);
|
||||
|
|
@ -326,12 +334,8 @@
|
|||
border-top: 1px solid var(--color-rule);
|
||||
margin-top: var(--space-16);
|
||||
padding-block: var(--space-8) var(--space-10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.footer-links,
|
||||
.footer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -344,7 +348,6 @@
|
|||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.footer-links a,
|
||||
.footer-meta a,
|
||||
.footer-meta span {
|
||||
min-height: 44px;
|
||||
|
|
@ -352,7 +355,6 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-links a,
|
||||
.footer-meta a {
|
||||
padding-inline: var(--space-1);
|
||||
margin-inline: calc(-1 * var(--space-1));
|
||||
|
|
@ -367,7 +369,6 @@
|
|||
|
||||
/* Page header (shared by .home-intro, .page-header, .post-header) */
|
||||
.home-intro {
|
||||
max-width: var(--measure-wide);
|
||||
padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
|
||||
}
|
||||
|
||||
|
|
@ -382,6 +383,10 @@
|
|||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.home-intro-name {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.home-intro p:not(.eyebrow),
|
||||
.page-header p,
|
||||
.dek {
|
||||
|
|
@ -583,7 +588,7 @@
|
|||
|
||||
.project-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
|
@ -684,15 +689,100 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/* -- Project card ----------------------------------------------------- */
|
||||
|
||||
.project-card {
|
||||
--project-thumb-size: clamp(7rem, 18vw, 9.5rem);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--project-thumb-size) minmax(0, 1fr);
|
||||
grid-template-areas: 'thumb summary';
|
||||
min-height: var(--project-thumb-size);
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'thumb'
|
||||
'summary';
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-rule);
|
||||
|
|
@ -710,11 +800,11 @@
|
|||
.project-card .project-thumbnail {
|
||||
grid-area: thumb;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--color-rule);
|
||||
border-bottom: 1px solid var(--color-rule);
|
||||
border-radius: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.project-card .project-thumbnail img {
|
||||
|
|
@ -812,13 +902,18 @@
|
|||
|
||||
/* -- Post layout ------------------------------------------------------ */
|
||||
|
||||
.post > .prose,
|
||||
.post > .post-media,
|
||||
.facts {
|
||||
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,
|
||||
|
|
@ -855,14 +950,60 @@
|
|||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.starting-point-notes {
|
||||
margin-top: var(--space-6);
|
||||
color: var(--color-muted);
|
||||
font-size: 0.95em;
|
||||
.starting-points {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
padding: var(--space-4) 0 0;
|
||||
border-top: 1px solid var(--color-rule);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.starting-point-notes p {
|
||||
margin-bottom: var(--space-2);
|
||||
.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:hover .entry-thumbnail img,
|
||||
.starting-points > li:focus-within .entry-thumbnail img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.starting-points > li:focus-within .entry-thumbnail {
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.about-links {
|
||||
|
|
@ -886,6 +1027,100 @@
|
|||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
/* -- Prose ------------------------------------------------------------ */
|
||||
|
||||
.prose {
|
||||
|
|
@ -1116,40 +1351,22 @@
|
|||
border-top: 1px solid var(--color-rule);
|
||||
}
|
||||
|
||||
/* Float .at-a-glance beside .prose at wide viewports. */
|
||||
/* Let prose wrap beside .at-a-glance and continue below it. */
|
||||
@media (min-width: 1100px) {
|
||||
.post {
|
||||
width: min(100% - 2 * var(--gutter), var(--page));
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, var(--measure)) minmax(14rem, 18rem);
|
||||
column-gap: var(--space-10);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.post > .post-header,
|
||||
.post > .post-thumbnail,
|
||||
.post > .post-gallery,
|
||||
.post > .post-media,
|
||||
.post > .post-nav {
|
||||
grid-column: 1 / -1;
|
||||
max-width: var(--measure-wide);
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post > .prose {
|
||||
grid-column: 1;
|
||||
margin-inline: 0;
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.post > .at-a-glance {
|
||||
grid-column: 2;
|
||||
grid-row: span 5;
|
||||
float: right;
|
||||
width: min(18rem, 42%);
|
||||
margin-top: var(--space-8);
|
||||
position: sticky;
|
||||
top: var(--space-6);
|
||||
align-self: start;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1337,12 +1554,12 @@
|
|||
--switcher-w: 2.75rem;
|
||||
--switcher-h: 1.5rem;
|
||||
--switcher-icon: 1.05rem;
|
||||
--switcher-mask: 0.78rem;
|
||||
--switcher-gap: 0.22rem;
|
||||
--switcher-mask-offset: 0.32rem;
|
||||
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
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
|
||||
|
|
@ -1369,48 +1586,46 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
.theme-switcher::before,
|
||||
.theme-switcher::after {
|
||||
content: '';
|
||||
.theme-switcher-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
border-radius: var(--radius-pill);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
background-color 180ms ease;
|
||||
}
|
||||
|
||||
.theme-switcher::before {
|
||||
z-index: 1;
|
||||
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::after {
|
||||
z-index: 2;
|
||||
width: var(--switcher-mask);
|
||||
height: var(--switcher-mask);
|
||||
.theme-switcher-icon-sun {
|
||||
color: var(--theme-switcher-icon-light);
|
||||
}
|
||||
|
||||
.theme-switcher[aria-pressed='false']::before {
|
||||
.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)));
|
||||
background-color: var(--theme-switcher-icon-light);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-switcher[aria-pressed='false']::after {
|
||||
.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']::before {
|
||||
.theme-switcher[aria-pressed='true'] .theme-switcher-icon-moon {
|
||||
transform: translateY(-50%) translateX(var(--switcher-gap));
|
||||
background-color: var(--theme-switcher-icon-dark);
|
||||
}
|
||||
|
||||
.theme-switcher[aria-pressed='true']::after {
|
||||
transform: translateY(-50%)
|
||||
translateX(calc(var(--switcher-gap) + var(--switcher-mask-offset)));
|
||||
background-color: var(--theme-switcher-track);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* High-contrast / forced-colors fallback: render a text label. */
|
||||
|
|
@ -1431,8 +1646,8 @@
|
|||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-switcher::after {
|
||||
content: none;
|
||||
.theme-switcher-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switcher::before {
|
||||
|
|
@ -1453,7 +1668,7 @@
|
|||
}
|
||||
|
||||
/* =========================================================================
|
||||
Responsive — tablet + mobile breakpoints
|
||||
Responsive: tablet + mobile breakpoints
|
||||
========================================================================= */
|
||||
|
||||
@layer overrides {
|
||||
|
|
@ -1464,6 +1679,24 @@
|
|||
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 */
|
||||
|
|
@ -1491,6 +1724,25 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1500,24 +1752,41 @@
|
|||
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;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
--project-thumb-size: 7rem;
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'thumb'
|
||||
'summary';
|
||||
.starting-points {
|
||||
display: block;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.project-card .project-thumbnail {
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-rule);
|
||||
aspect-ratio: 16 / 9;
|
||||
.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-card .project-meta {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue