This commit is contained in:
Andras Schmelczer 2026-05-11 21:30:57 +01:00
parent f3fc893675
commit bb5b4c4cf3
43 changed files with 585 additions and 524 deletions

View file

@ -35,8 +35,13 @@ jobs:
exit 1
fi
- name: Build
run: npm run build
- name: Typecheck
run: npm run typecheck
- name: Build & QA
run: |
npx playwright install chromium
npm run qa
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

View file

@ -83,10 +83,11 @@ export default defineConfig({
behavior: 'append',
properties: {
className: ['heading-anchor'],
'aria-hidden': 'true',
tabIndex: -1,
ariaLabel: 'Permalink',
},
content: { type: 'text', value: '#' },
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
// when astro:content extracts heading.text from the rendered HTML.
content: [],
},
],
],

View file

@ -9,12 +9,11 @@
"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\"",
"format:check": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"build": "astro build",
"preview": "astro preview",
"qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/check-overflow.mjs",
"qa": "npm run build && npm run qa:no-js && npm run qa:overflow"
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:no-js && npm run qa:overflow"
},
"repository": {
"type": "git",

View file

@ -15,8 +15,9 @@ const { posts, showYear = true, currentTag } = Astro.props;
<ol class="article-list">
{
posts.map((post) => {
posts.map((post, index) => {
const href = articlePath(post);
const isFirst = index === 0;
return (
<li>
<time datetime={post.data.date.toISOString()}>
@ -36,6 +37,8 @@ const { posts, showYear = true, currentTag } = Astro.props;
class="article-thumbnail"
widths={[120, 180, 240, 320, 480]}
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem"
loading={isFirst ? 'eager' : 'lazy'}
fetchpriority={isFirst ? 'high' : undefined}
/>
</li>
);

View file

@ -31,8 +31,9 @@ const {
} = Astro.props;
const Tag = href ? 'a' : 'div';
const resolvedFallback: FallbackFormat =
fallbackFormat ?? (src.format === 'png' ? 'png' : 'jpg');
// Listing thumbnails are screenshots with no required transparency; force JPG
// fallback to avoid shipping multi-hundred-KB PNG derivatives.
const resolvedFallback: FallbackFormat = fallbackFormat ?? 'jpg';
const isDecorativeLink = Boolean(href) && decorative;
---

View file

@ -3,14 +3,8 @@ import { navItems, site } from '../lib/site';
const year = new Date().getFullYear();
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
// derive footer items locally. Footer mirrors Header (Home filtered out) and
// adds Tags + RSS.
const footerNavItems = [
...navItems.filter((item) => item.href !== '/'),
{ href: '/tags/', label: 'Tags' },
{ href: '/rss.xml', label: 'RSS' },
];
// 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">
@ -25,19 +19,21 @@ const footerNavItems = [
}
</ul>
</nav>
<address>
<ul class="footer-meta">
<li><span>© {year} {site.name}</span></li>
<li><a href={`mailto:${site.email}`}>Email</a></li>
<li>
<a href={site.cv} rel="noopener noreferrer">CV</a>
</li>
<li>
<a href={site.github} rel="noopener noreferrer me">GitHub</a>
</li>
<li>
<a href={site.linkedin} rel="noopener noreferrer me">LinkedIn</a>
</li>
</ul>
</address>
<ul class="footer-meta">
<li><span>© {year} {site.name}</span></li>
<li>
<address>
<a href={`mailto:${site.email}`}>Email</a>
</address>
</li>
<li>
<a href={site.cv} rel="noopener">CV</a>
</li>
<li>
<a href={site.github} rel="noopener me">GitHub</a>
</li>
<li>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</li>
</ul>
</footer>

View file

@ -3,28 +3,28 @@ import { navItems, site } from '../lib/site';
const current = Astro.url.pathname;
function isCurrent(href: string) {
if (href === '/') return current === '/';
return current.startsWith(href);
// Exact match for the current page; section match (descendant URLs) for
// ancestor links. `aria-current="page"` is reserved for the exact page,
// `"true"` indicates an ancestor section.
function currentState(href: string): 'page' | 'true' | undefined {
if (current === href) return 'page';
if (href !== '/' && current.startsWith(href)) return 'true';
return undefined;
}
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
// derive header items locally. Header shows nav (minus Home) + Tags. RSS lives
// in the header as a dedicated icon link.
const headerNavItems = [
...navItems.filter((item) => item.href !== '/'),
{ href: '/tags/', label: 'Tags' },
];
// Header shows nav items except Home and footer-only entries. RSS lives as a
// dedicated icon link to the right of the nav.
const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly);
---
<a class="skip-link" href="#content">Skip to content</a>
<header class="site-header">
<a class="site-title" href="/">{site.name}</a>
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
<div class="header-actions">
<nav class="site-nav" aria-label="Primary">
{
headerNavItems.map((item) => (
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
<a href={item.href} aria-current={currentState(item.href)}>
{item.label}
</a>
))
@ -112,8 +112,15 @@ const headerNavItems = [
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 44px;
min-inline-size: 44px;
color: inherit;
line-height: 0;
transition: color 150ms ease;
}
.rss-link:hover,
.rss-link:focus-visible {
color: var(--color-link-hover);
}
.rss-icon {
display: block;

View file

@ -1,6 +1,6 @@
---
import type { CollectionEntry } from 'astro:content';
import { Picture } from 'astro:assets';
import PostMediaFigure from './PostMediaFigure.astro';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
@ -10,76 +10,21 @@ interface Props {
const { items } = Astro.props;
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
format === 'png' ? 'png' : 'jpg';
// Wrap in a gallery `<ul>` when there's more than one item; otherwise the
// figures sit directly in the post flow.
const isGallery = items.length > 1;
---
{
items.length > 1 ? (
isGallery ? (
<ul role="list" class="post-gallery">
{items.map((item) => (
<li>
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
{item.type === 'video' ? (
<video
controls
preload="metadata"
poster={item.poster?.src}
aria-label={item.decorative ? 'Decorative video' : item.alt}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
item.src && (
<Picture
src={item.src}
alt={item.decorative ? '' : (item.alt ?? '')}
formats={['avif', 'webp']}
fallbackFormat={fallbackFormatFor(item.src.format)}
widths={[480, 720, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
loading="lazy"
decoding="async"
/>
)
)}
{item.caption && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>
<PostMediaFigure item={item} />
</li>
))}
</ul>
) : (
items.map((item) => (
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
{item.type === 'video' ? (
<video
controls
preload="metadata"
poster={item.poster?.src}
aria-label={item.decorative ? 'Decorative video' : item.alt}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
item.src && (
<Picture
src={item.src}
alt={item.decorative ? '' : (item.alt ?? '')}
formats={['avif', 'webp']}
fallbackFormat={fallbackFormatFor(item.src.format)}
widths={[480, 720, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
loading="lazy"
decoding="async"
/>
)
)}
{item.caption && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>
))
items.map((item) => <PostMediaFigure item={item} />)
)
}

View file

@ -0,0 +1,46 @@
---
import type { CollectionEntry } from 'astro:content';
import { Picture } from 'astro:assets';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
interface Props {
item: MediaItem;
}
const { item } = Astro.props;
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
format === 'png' ? 'png' : 'jpg';
---
<figure class="post-media">
{
item.type === 'video' ? (
<video
controls
preload="none"
poster={item.poster?.src}
{...(item.decorative ? { 'aria-hidden': 'true' } : { 'aria-label': item.alt })}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
item.src && (
<Picture
src={item.src}
alt={item.decorative ? '' : (item.alt ?? '')}
formats={['avif', 'webp']}
fallbackFormat={fallbackFormatFor(item.src.format)}
widths={[480, 720, 960, 1280, 1600, 1920, 2400]}
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
loading="lazy"
decoding="async"
/>
)
)
}
{item.caption && !item.decorative && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>

View file

@ -16,7 +16,7 @@ function isExternal(url: string) {
{
links.length > 0 && (
<ul class="project-links" aria-label="Project links">
<ul class="project-links">
{links.map((link) => (
<li>
<a
@ -27,30 +27,35 @@ function isExternal(url: string) {
>
{link.label}
{isExternal(link.url) && (
<svg
class="external-link-icon"
xmlns="http://www.w3.org/2000/svg"
width="0.85em"
height="0.85em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
<>
<svg
class="external-link-icon"
xmlns="http://www.w3.org/2000/svg"
width="0.85em"
height="0.85em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
<span class="sr-only">(opens in new tab)</span>
</>
)}
{link.download && (
<span class="download-indicator" aria-hidden="true">
</span>
<>
<span class="download-indicator" aria-hidden="true">
</span>
<span class="sr-only">(download)</span>
</>
)}
{link.download && <span class="sr-only">(download)</span>}
</a>
</li>
))}

View file

@ -12,57 +12,26 @@ interface Props {
const { projects } = Astro.props;
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
async function resolveEssayHref(
essay: CollectionEntry<'projects'>['data']['essay']
): Promise<string | undefined> {
if (!essay) return undefined;
// Defensively handle both `string` (legacy) and resolved-entry / reference shapes.
if (typeof essay === 'string') {
return articlePath(essay);
}
if (typeof essay === 'object') {
const ref = essay as {
collection?: string;
id?: string;
slug?: string;
data?: unknown;
};
// Already a resolved CollectionEntry (has `data`)
if (ref.data && ref.id) {
return articlePath({ id: ref.id });
}
// A reference: { collection, id } — resolve via getEntry
if (ref.collection && ref.id) {
const resolved = await getEntry(ref.collection as 'posts', ref.id);
if (resolved) return articlePath(resolved);
return articlePath(ref.id);
}
if (ref.id) return articlePath(ref.id);
}
return undefined;
}
// The `essay` field is a `reference('posts')`, so when present it's always a
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
const essayHrefs = new Map<string, string>();
for (const project of projects) {
const href = await resolveEssayHref(project.data.essay);
if (href) essayHrefs.set(project.id, href);
const essay = project.data.essay;
if (!essay) continue;
const resolved = await getEntry(essay);
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
}
---
<ol class="project-list">
{
projects.map((project) => {
projects.map((project, index) => {
const anchor = projectAnchor(project);
const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id);
const essayLink: ProjectLink | undefined = essayHref
? { label: 'Article', type: 'site', url: essayHref }
: undefined;
const primaryHref = essayHref ?? project.data.links[0]?.url;
const links: ProjectLink[] = [
...(essayLink ? [essayLink] : []),
...project.data.links,
];
const links: ProjectLink[] = project.data.links;
const isFirst = index === 0;
return (
<li class="project-card" id={anchor}>
@ -73,6 +42,8 @@ for (const project of projects) {
class="project-thumbnail"
widths={[240, 320, 480, 640, 800]}
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
loading={isFirst ? 'eager' : 'lazy'}
fetchpriority={isFirst ? 'high' : undefined}
/>
<article class="project-card__summary">
<h3 id={titleId}>

View file

@ -4,7 +4,6 @@ import { tagPath } from '../lib/site';
interface Props {
tags: readonly string[];
currentTag?: string;
labelled?: boolean;
limit?: number;
counts?: Record<string, number>;
}
@ -20,7 +19,7 @@ const remaining =
{
visibleTags.map((tag) => (
<li>
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'true' : undefined}>
{tag}
{counts && counts[tag] !== undefined && (
<span class="tag-count">{counts[tag]}</span>

View file

@ -5,16 +5,6 @@ import { z } from 'astro/zod';
const linkSchema = z.object({
label: z.string(),
type: z.enum([
'source',
'demo',
'package',
'paper',
'thesis',
'video',
'site',
'contact',
]),
url: z.string(),
download: z.boolean().optional(),
});
@ -37,7 +27,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
decorative: z.boolean().optional(),
caption: z.string().optional(),
transcript: z.string().optional(),
role: z.enum(['evidence', 'og', 'inline']).default('evidence'),
})
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
message: 'Meaningful media needs both alt text and a caption.',
@ -89,7 +78,6 @@ const projects = defineCollection({
thumbnail: thumbnailSchema({ image }),
period: z.string(),
sortDate: z.coerce.date(),
status: z.string().optional(),
technologies: z.array(z.string()).default([]),
selected: z.boolean().default(false),
essay: reference('posts').optional(),

View file

@ -7,13 +7,10 @@ thumbnail:
alt: The Ad Astra handheld game running on its OLED display.
period: 'Spring 2020'
sortDate: 2020-04-01
status: Embedded game engine
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
selected: true
essay: ad-astra-attiny85-game-engine
legacyAnchor: embedded-game-engine
links:
- label: Source
type: source
url: https://github.com/schmelczer/ad_astra
---

View file

@ -7,13 +7,10 @@ thumbnail:
alt: Screenshot of the Avoid canvas game.
period: 'January 2018'
sortDate: 2018-01-01
status: Early web game
technologies: ['JavaScript', 'Canvas']
selected: false
essay: avoid-early-web-game
legacyAnchor: avoid
links:
- label: Demo
type: demo
url: https://schmelczer.dev/avoid
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: Screenshot of a Unity city traffic simulation.
period: 'July-August 2018'
sortDate: 2018-08-01
status: Simulation
technologies: ['Unity', 'C#', 'REST API', 'Blender']
selected: false
essay: city-simulation-unity-traffic
legacyAnchor: city-simulation-unity
links: []
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: Screenshot of a colour grading interface applied to a photograph.
period: 'June 2018'
sortDate: 2018-06-01
status: UI experiment
technologies: ['JavaScript', 'Canvas', 'Image processing']
selected: false
essay: photo-colour-grader
legacyAnchor: photo-colour-grader
links: []
---

View file

@ -7,20 +7,15 @@ thumbnail:
alt: The decla.red browser game interface showing a space scene.
period: 'Autumn-Winter 2020'
sortDate: 2020-11-01
status: Thesis project and browser game
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
selected: true
essay: declared-shared-simulation-code
legacyAnchor: multiplayer-mobile-game
links:
- label: Source
type: source
url: https://github.com/schmelczer/decla.red
- label: Demo
type: demo
url: https://decla.red
- label: BSc thesis
type: thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: Chart from a foreign exchange prediction experiment.
period: 'Autumn 2019'
sortDate: 2019-10-01
status: Experiment
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
selected: false
essay: foreign-exchange-prediction-experiment
legacyAnchor: predicting-foreign-exchange-rates
links: []
---

View file

@ -7,20 +7,15 @@ thumbnail:
alt: Example Python code using the GreatAI API.
period: '2022'
sortDate: 2022-01-01
status: Research project and framework
technologies: ['Python', 'ML deployment', 'API design']
selected: true
essay: greatai-ai-deployment-api
legacyAnchor: great-ai-ai-deployment-framework
links:
- label: PyPI
type: package
url: https://pypi.org/project/great-ai/
- label: Project site
type: site
url: https://great-ai.scoutinscience.com
- label: MSc thesis
type: thesis
url: /media/downloads/great-ai-andras-schmelczer.pdf
download: true
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: RGB LED strips glowing from a music synchronization project.
period: 'Spring 2016'
sortDate: 2016-04-01
status: Early hardware/software project
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
selected: false
essay: lights-synchronized-to-music
legacyAnchor: lights-synchronised-to-music
links: []
---

View file

@ -7,13 +7,10 @@ thumbnail:
alt: Screenshot of the My Notes Android markdown app.
period: 'November 2019'
sortDate: 2019-11-01
status: Android app
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
selected: false
essay: my-notes-android-markdown-app
legacyAnchor: my-notes-android-app
links:
- label: Source
type: source
url: https://github.com/schmelczer/my-notes
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: JavaFX editor interface for the cooling system simulator input graph.
period: 'October-November 2018'
sortDate: 2018-10-15
status: Input editor
technologies: ['JavaFX', 'JSON', 'REST API']
selected: false
essay: graph-editor-javafx-simulation-input
legacyAnchor: graph-editor-javafx
links: []
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
period: 'October-November 2018'
sortDate: 2018-11-01
status: Simulation and editor
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
selected: true
essay: nuclear-cooling-simulation
legacyAnchor: simulating-the-cooling-system-of-a-nuclear-facility
links: []
---

View file

@ -7,13 +7,10 @@ thumbnail:
alt: Screenshot of a generated photography site.
period: 'Summer 2016'
sortDate: 2016-07-01
status: Static site generator
technologies: ['Webpack', 'Image processing', 'Static site generation']
selected: false
essay: photo-site-generator
legacyAnchor: photos
links:
- label: Site
type: site
url: https://photo.schmelczer.dev
---

View file

@ -7,10 +7,8 @@ thumbnail:
alt: Screenshot from an early 3D platform game.
period: 'Autumn 2017'
sortDate: 2017-10-01
status: Early game project
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
selected: false
essay: platform-game-c-sdl
legacyAnchor: platform-game
links: []
---

View file

@ -7,23 +7,17 @@ thumbnail:
alt: SDF-2D browser demo with soft lighting effects.
period: 'Autumn-Winter 2020'
sortDate: 2020-12-01
status: Thesis project and NPM package
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'SDF rendering']
selected: true
essay: sdf-2d-ray-tracing
legacyAnchor: optimising-2d-ray-tracing
links:
- label: NPM package
type: package
url: https://www.npmjs.com/package/sdf-2d
- label: Demo
type: demo
url: https://sdf2d.schmelczer.dev
- label: Video
type: video
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
- label: BSc thesis
type: thesis
url: /media/downloads/sdf2d-andras-schmelczer.pdf
download: true
---

View file

@ -7,16 +7,12 @@ thumbnail:
alt: Life Towers goal tracking interface with tower-like visual structures.
period: 'August-September 2019'
sortDate: 2019-09-01
status: Full-stack web app
technologies: ['Python', 'Angular', 'State synchronization']
selected: true
essay: life-towers-immutable-tries
legacyAnchor: multi-device-life-tracking
links:
- label: Source
type: source
url: https://github.com/schmelczer/life-towers/
- label: Demo
type: demo
url: https://towers.schmelczer.dev
---

View file

@ -3,7 +3,7 @@ import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
import themeInit from '../scripts/theme-init.ts?raw';
import themeInit from '../scripts/theme-init.js?raw';
import '../styles/global.css';
interface ArticleMeta {
@ -48,8 +48,8 @@ const ogTitle = isRoot ? site.title : title;
const canonical = absoluteUrl(canonicalPath);
let resolvedOgImage = ogImage;
let resolvedOgWidth = ogImageWidth;
let resolvedOgHeight = ogImageHeight;
let resolvedOgWidth = ogImageWidth ?? 1200;
let resolvedOgHeight = ogImageHeight ?? 630;
if (!resolvedOgImage) {
const generated = await optimizeOgImage(defaultOg);
@ -62,6 +62,64 @@ const ogImageUrl = resolvedOgImage.startsWith('http')
? resolvedOgImage
: absoluteUrl(resolvedOgImage);
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
// Head meta tags built as a single HTML string so prettier-plugin-astro
// doesn't shuffle them outside `<head>` when reformatting (it has trouble
// with mixed JSX-expression and raw element siblings inside <head>).
const attr = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const articleMetaParts = article
? [
`<meta property="article:published_time" content="${attr(article.publishedTime)}">`,
article.modifiedTime
? `<meta property="article:modified_time" content="${attr(article.modifiedTime)}">`
: '',
`<meta property="article:author" content="${attr(absoluteUrl('/about/'))}">`,
...(article.tags ?? []).map(
(tag) => `<meta property="article:tag" content="${attr(tag)}">`
),
]
: [];
const monoPreloadHtml = preloadMono
? '<link rel="preload" href="/fonts/ibm-plex-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin>'
: '';
const headHtml = [
monoPreloadHtml,
`<link rel="alternate" type="application/rss+xml" title="${attr(`${site.name} RSS`)}" href="/rss.xml">`,
`<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">`,
`<link rel="icon" href="/favicon.ico" type="image/x-icon">`,
`<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">`,
`<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">`,
`<link rel="manifest" href="/site.webmanifest">`,
`<meta property="og:site_name" content="${attr(site.name)}">`,
`<meta property="og:title" content="${attr(ogTitle)}">`,
`<meta property="og:description" content="${attr(description)}">`,
`<meta property="og:url" content="${attr(canonical)}">`,
`<meta property="og:image" content="${attr(ogImageUrl)}">`,
`<meta property="og:image:type" content="image/jpeg">`,
`<meta property="og:image:alt" content="${attr(ogImageAlt)}">`,
`<meta property="og:image:width" content="${resolvedOgWidth}">`,
`<meta property="og:image:height" content="${resolvedOgHeight}">`,
`<meta property="og:type" content="${attr(ogType)}">`,
`<meta property="og:locale" content="en">`,
...articleMetaParts,
`<meta name="twitter:card" content="summary_large_image">`,
`<meta name="twitter:title" content="${attr(ogTitle)}">`,
`<meta name="twitter:description" content="${attr(description)}">`,
`<meta name="twitter:image" content="${attr(ogImageUrl)}">`,
`<meta name="twitter:image:alt" content="${attr(ogImageAlt)}">`,
...jsonLdEntries.map(
(entry) =>
`<script type="application/ld+json">${JSON.stringify(entry).replace(/<\/script/gi, '<\\/script')}</script>`
),
].join('');
---
<!doctype html>
@ -79,10 +137,15 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
{noindex && <meta name="robots" content="noindex,follow" />}
<link rel="canonical" href={canonical} />
{!noindex && <link rel="canonical" href={canonical} />}
<script is:inline data-theme-script set:html={themeInit} />
<noscript
><style>
.theme-switcher {
display: none !important;
}
</style></noscript
>
<link
rel="preload"
href="/fonts/source-sans-3-latin-variable.woff2"
@ -90,76 +153,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
type="font/woff2"
crossorigin
/>
{
preloadMono && (
<link
rel="preload"
href="/fonts/ibm-plex-mono-latin-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
)
}
<link
rel="alternate"
type="application/rss+xml"
title={`${site.name} RSS`}
href="/rss.xml"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta property="og:site_name" content={site.name} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content={ogImageAlt} />
{
resolvedOgWidth && (
<meta property="og:image:width" content={String(resolvedOgWidth)} />
)
}
{
resolvedOgHeight && (
<meta property="og:image:height" content={String(resolvedOgHeight)} />
)
}
<meta property="og:type" content={ogType} />
<meta property="og:locale" content="en" />
{
article && (
<>
<meta property="article:published_time" content={article.publishedTime} />
{article.modifiedTime && (
<meta property="article:modified_time" content={article.modifiedTime} />
)}
<meta property="article:author" content={absoluteUrl('/about/')} />
{article.tags?.map((tag) => (
<meta property="article:tag" content={tag} />
))}
</>
)
}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={ogImageAlt} />
{
jsonLdEntries.map((entry) => (
<script is:inline type="application/ld+json" set:html={JSON.stringify(entry)} />
))
}
<Fragment set:html={headHtml} />
</head>
<body>
<Header />

View file

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

View file

@ -11,6 +11,7 @@ import {
absoluteUrl,
adjacentPosts,
articlePath,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
formatDate,
getPublishedPosts,
@ -42,6 +43,10 @@ const breadcrumbTrail = trail.map((c, i) => ({
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
// Only preload the monospace font if the post body actually contains code
// (inline `…` or fenced ``` blocks). Saves ~15 KB on every code-free article.
const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
// TOC: only show when there are >= 3 h2 headings.
const h2Headings = headings.filter((h) => h.depth === 2);
const showToc = h2Headings.length >= 3;
@ -66,16 +71,7 @@ const blogPosting = {
},
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: trail.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
item: absoluteUrl(c.href),
})),
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
---
<Base
@ -87,7 +83,7 @@ const breadcrumbJsonLd = {
ogImageWidth={1200}
ogImageHeight={630}
ogType="article"
preloadMono={true}
preloadMono={hasCode}
article={{
publishedTime: post.data.date.toISOString(),
modifiedTime: post.data.updated?.toISOString(),
@ -130,7 +126,7 @@ const breadcrumbJsonLd = {
alt={post.data.thumbnail.alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={[640, 960, 1280, 1600]}
widths={[640, 960, 1280, 1600, 1920, 2400]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="eager"
fetchpriority="high"
@ -170,8 +166,8 @@ const breadcrumbJsonLd = {
{
related.length > 0 && (
<section class="related-posts" aria-labelledby="related-heading">
<h2 id="related-heading">Related articles</h2>
<section class="related-posts">
<h2>Related articles</h2>
<ArticleList posts={related} />
</section>
)

View file

@ -19,18 +19,20 @@ export const site = {
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
// the site title). The Footer renders every entry regardless. Items marked
// `footerOnly: true` appear only in the Footer.
export const navItems = [
export interface NavItem {
href: string;
label: string;
footerOnly?: boolean;
}
export const navItems: readonly NavItem[] = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
{ href: '/tags/', label: 'Tags', footerOnly: false },
{ href: '/tags/', label: 'Tags' },
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
] as const satisfies ReadonlyArray<{
href: string;
label: string;
footerOnly?: boolean;
}>;
];
export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', {
@ -176,28 +178,53 @@ interface BreadcrumbCrumb {
interface BreadcrumbInput {
articles?: boolean;
projects?: boolean;
tagsIndex?: boolean;
tag?: string;
post?: CollectionEntry<'posts'>;
}
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
// visible Breadcrumbs component. Home is always first. Pass `articles: true`
// to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass
// a `post` to append the post title (linking to its article path).
// visible Breadcrumbs component. Home is always first. Flags append crumbs
// in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
// implies both Articles and Tags so callers don't have to set every flag.
export function buildBreadcrumbTrail({
articles,
projects,
tagsIndex,
tag,
post,
}: BreadcrumbInput): BreadcrumbCrumb[] {
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
if (articles || post) {
if (articles || post || tagsIndex || tag) {
trail.push({ name: 'Articles', href: '/articles/' });
}
if (tagsIndex || tag) {
trail.push({ name: 'Tags', href: '/tags/' });
}
if (tag) {
trail.push({ name: tag, href: tagPath(tag) });
trail.push({ name: `#${tag}`, href: tagPath(tag) });
}
if (post) {
trail.push({ name: post.data.title, href: articlePath(post) });
}
if (projects) {
trail.push({ name: 'Projects', href: '/projects/' });
}
return trail;
}
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
// Shared by every page that emits breadcrumb structured data.
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: trail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
}

View file

@ -23,8 +23,8 @@ const recent = posts.slice(0, 5);
<section class="home-section">
<div class="section-heading">
<h2 id="404-recent">Recent articles</h2>
<a href="/articles/">All articles </a>
<h2 id="recent-articles-404">Recent articles</h2>
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={recent} />
</section>

View file

@ -1,7 +1,14 @@
---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro';
import { absoluteUrl, buildPersonJsonLd, getPublishedPosts, site } from '../lib/site';
import {
absoluteUrl,
buildPersonJsonLd,
getPublishedPosts,
optimizeOgImage,
site,
} from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
const posts = await getPublishedPosts();
const startingPoints = posts
@ -9,6 +16,8 @@ const startingPoints = posts
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
.slice(0, 4);
const personImage = await optimizeOgImage(defaultOg);
// Canonical Person JSON-LD. Other pages reference this entity by @id.
const personJsonLd = buildPersonJsonLd({
jobTitle: 'Software Engineer',
@ -22,7 +31,7 @@ const personJsonLd = buildPersonJsonLd({
'Simulations',
'Data visualization',
],
image: absoluteUrl('/og-image.jpg'),
image: absoluteUrl(personImage.src),
mainEntityOfPage: absoluteUrl('/about/'),
});
---
@ -52,38 +61,40 @@ const personJsonLd = buildPersonJsonLd({
<section class="about-section facts">
<h2 id="quick-facts">Quick Facts</h2>
<address>
<dl>
<div>
<dt>Focus</dt>
<dd>
Software systems, AI deployment, architecture, graphics, data visualization
</dd>
</div>
<div>
<dt>Education</dt>
<dd>MSc in Computer Science</dd>
</div>
<div>
<dt>Contact</dt>
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
</div>
<div>
<dt>Links</dt>
<dd>
<a href={site.cv} rel="noopener">CV</a>,
<a href={site.github} rel="noopener me">GitHub</a>,
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</dd>
</div>
</dl>
</address>
<dl>
<div>
<dt>Focus</dt>
<dd>
Software systems, AI deployment, architecture, graphics, data visualization
</dd>
</div>
<div>
<dt>Education</dt>
<dd>MSc in Computer Science</dd>
</div>
<div>
<dt>Contact</dt>
<dd>
<address>
<a href={`mailto:${site.email}`}>{site.email}</a>
</address>
</dd>
</div>
<div>
<dt>Links</dt>
<dd class="about-links">
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</dd>
</div>
</dl>
</section>
<section class="about-section">
<div class="section-heading">
<h2 id="best-starting-points">Best Starting Points</h2>
<a href="/articles/">Browse all articles </a>
<a href="/articles/">Browse all articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={startingPoints} />
</section>

View file

@ -1,10 +1,9 @@
---
import { getCollection } from 'astro:content';
import Post from '../../layouts/Post.astro';
import { entrySlug } from '../../lib/site';
import { entrySlug, getPublishedPosts } from '../../lib/site';
export async function getStaticPaths() {
const posts = (await getCollection('posts')).filter((post) => !post.data.draft);
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: entrySlug(post) },
props: { post },

View file

@ -5,9 +5,11 @@ import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
articlePath,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
optimizeOgImage,
site,
yearOf,
} from '../../lib/site';
@ -16,6 +18,11 @@ const posts = await getPublishedPosts();
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
const tags = getAllTags(posts);
const postOgImages = await Promise.all(
posts.map((post) => optimizeOgImage(post.data.thumbnail.src))
);
const personId = absoluteUrl('/about/#person');
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
@ -23,26 +30,20 @@ const blogJsonLd = {
url: absoluteUrl('/articles/'),
description:
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
blogPost: posts.map((post) => ({
publisher: { '@id': personId },
blogPost: posts.map((post, index) => ({
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
url: absoluteUrl(articlePath(post)),
author: { '@id': personId },
image: absoluteUrl(postOgImages[index].src),
keywords: post.data.tags.join(', '),
})),
};
const breadcrumbTrail = buildBreadcrumbTrail({ articles: true });
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbTrail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles: true }));
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
---
@ -54,7 +55,7 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
>
<nav id="tags" class="tag-filter" aria-label="Browse by tag">
<span>Browse by tag</span>
<TagList tags={tags} labelled={false} />
<TagList tags={tags} />
</nav>
{

View file

@ -39,7 +39,11 @@ const personJsonLd = buildPersonJsonLd();
<section class="home-section">
<div class="section-heading">
<h2 id="latest-articles">Latest Articles</h2>
<a href="/articles/">All {posts.length} articles →</a>
<a href="/articles/"
>All {posts.length}
{posts.length === 1 ? 'article' : 'articles'}
<span aria-hidden="true">→</span></a
>
</div>
<ArticleList posts={latestPosts} />
</section>
@ -47,7 +51,7 @@ const personJsonLd = buildPersonJsonLd();
<section class="home-section">
<div class="section-heading">
<h2 id="home-selected-projects">Selected Projects</h2>
<a href="/projects/">All projects </a>
<a href="/projects/">All projects <span aria-hidden="true">→</span></a>
</div>
<ProjectList projects={selectedProjects} />
</section>

View file

@ -1,7 +1,13 @@
---
import ProjectList from '../../components/ProjectList.astro';
import Page from '../../layouts/Page.astro';
import { absoluteUrl, buildBreadcrumbTrail, getProjects, site } from '../../lib/site';
import {
absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getProjects,
site,
} from '../../lib/site';
const projects = await getProjects();
const selected = projects.filter((project) => project.data.selected);
@ -16,20 +22,7 @@ const collectionJsonLd = {
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
};
const breadcrumbTrail = [
...buildBreadcrumbTrail({}),
{ name: 'Projects', href: '/projects/' },
];
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbTrail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
---

View file

@ -3,7 +3,13 @@ import ArticleList from '../../components/ArticleList.astro';
import Breadcrumbs from '../../components/Breadcrumbs.astro';
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import { getAllTags, getPublishedPosts, tagSlug } from '../../lib/site';
import {
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
tagSlug,
} from '../../lib/site';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
@ -18,22 +24,23 @@ const posts = await getPublishedPosts();
const allTags = getAllTags(posts);
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
const title = `Articles tagged "${tag}"`;
const trail = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/tags/', label: 'Tags' },
{ label: `#${tag}` },
];
const trail = buildBreadcrumbTrail({ tag });
const visibleTrail = trail.map((c, i) => ({
label: c.name,
href: i === trail.length - 1 ? undefined : c.href,
}));
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
---
<Page
title={title}
description={`Project articles and technical notes filed under #${tag}.`}
jsonLd={breadcrumbJsonLd}
>
<Breadcrumbs items={trail} />
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
<nav class="tag-filter" aria-label="Browse other tags">
<span>Browse other tags</span>
<TagList tags={allTags} currentTag={tag} labelled={false} />
<TagList tags={allTags} currentTag={tag} />
</nav>
<h2 class="sr-only">Articles</h2>

View file

@ -3,6 +3,7 @@ import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
@ -27,20 +28,7 @@ const collectionJsonLd = {
description: 'Every tag used across the articles archive.',
};
const breadcrumbTrail = [
...buildBreadcrumbTrail({ articles: true }),
{ name: 'Tags', href: '/tags/' },
];
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbTrail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
---

22
src/scripts/theme-init.js Normal file
View file

@ -0,0 +1,22 @@
(function () {
var key = 'theme';
var legacyKey = 'dark-mode';
var saved = null;
try {
var value = localStorage.getItem(key);
if (value === 'light' || value === 'dark') {
saved = value;
} else {
var legacyValue = localStorage.getItem(legacyKey);
if (legacyValue !== null) {
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
}
}
} catch (e) {
saved = null;
}
var systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
var theme = saved || (systemDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();

View file

@ -1,22 +0,0 @@
(() => {
const key = 'theme';
const legacyKey = 'dark-mode';
let saved: 'light' | 'dark' | null = null;
try {
const value = localStorage.getItem(key);
if (value === 'light' || value === 'dark') {
saved = value;
} else {
const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue !== null) {
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
}
}
} catch {
saved = null;
}
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (systemDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();

View file

@ -39,14 +39,14 @@
--color-link: light-dark(#285f74, #8ab8c8);
--color-link-hover: light-dark(
color-mix(in oklch, #285f74 70%, black 30%),
color-mix(in oklch, #8ab8c8 70%, black 30%)
color-mix(in oklch, #8ab8c8 70%, white 30%)
);
--color-link-visited: var(--color-link);
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
--color-rule: light-dark(#d9d5ca, #39352f);
--color-rule-medium: light-dark(#7a7466, #6c655c);
--color-rule-medium: light-dark(#7a7466, #8a8478);
--color-rule-strong: light-dark(#4a4340, #d0c5b7);
--color-code-bg: light-dark(#efede6, #24221f);
--color-code-bg: light-dark(#efede6, #2f2c27);
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
@ -173,6 +173,7 @@
color: var(--color-fg);
font-family: var(--font-sans);
font-size: var(--fs-body);
line-height: var(--leading-snug);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
overflow-wrap: break-word;
@ -181,6 +182,10 @@
color 200ms ease;
}
address {
font-style: normal;
}
a {
color: var(--color-link);
text-decoration-thickness: 0.08em;
@ -198,7 +203,7 @@
}
:focus-visible {
outline: 2px solid var(--color-rule-strong);
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
@ -228,17 +233,6 @@
white-space: nowrap;
border: 0;
}
.shell {
width: min(100% - 2 * var(--gutter), var(--page));
margin-inline: auto;
}
.tap-target {
min-height: 44px;
display: inline-flex;
align-items: center;
}
}
/* =========================================================================
@ -246,20 +240,12 @@
========================================================================= */
@layer layout {
:where(
.site-header,
.site-footer,
.home-intro,
.home-section,
.page-shell,
.post,
.post-footer-shell
) {
:where(.site-header, .site-footer, .home-intro, .home-section, .page-shell, .post) {
width: min(100% - 2 * var(--gutter), var(--page));
margin-inline: auto;
}
:where(.post, .post-footer-shell) {
.post {
width: min(100% - 2 * var(--gutter), var(--measure-wide));
}
@ -271,7 +257,10 @@
transform: translateY(-150%);
background: var(--color-fg);
color: var(--color-bg);
padding: var(--space-2) var(--space-3);
padding: var(--space-3) var(--space-4);
min-block-size: 44px;
display: inline-flex;
align-items: center;
text-decoration: none;
transition: transform 150ms ease;
}
@ -294,7 +283,9 @@
.site-title {
color: var(--color-fg);
font-weight: var(--weight-semibold);
font-size: var(--fs-lg);
font-weight: var(--weight-bold);
letter-spacing: -0.005em;
text-decoration: none;
}
@ -302,6 +293,10 @@
color: var(--color-fg);
}
.site-title[aria-current='page'] {
color: var(--color-fg);
}
.header-actions {
display: flex;
align-items: center;
@ -337,17 +332,11 @@
text-underline-offset: 0.25em;
}
.site-nav a[aria-current='page'] {
.site-nav a[aria-current='page'],
.site-nav a[aria-current='true'] {
color: var(--color-fg);
}
.theme-control {
cursor: pointer;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.site-footer {
border-top: 1px solid var(--color-rule);
margin-top: var(--space-16);
@ -370,16 +359,24 @@
font-size: var(--fs-caption);
}
.footer-links a,
.footer-meta a,
.footer-meta span {
min-height: 44px;
display: inline-flex;
align-items: center;
}
.footer-links a,
.footer-meta a {
padding-inline: var(--space-1);
margin-inline: calc(-1 * var(--space-1));
}
/* 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-10);
padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
}
.home-intro h1,
@ -545,8 +542,23 @@
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'] {
.tag-list a[aria-current='page'],
.tag-list a[aria-current='true'] {
color: var(--color-fg);
}
@ -584,10 +596,10 @@
.article-list > li {
display: grid;
grid-template-columns: minmax(5rem, auto) minmax(0, 1fr) minmax(6rem, 8rem);
grid-template-columns: 4.5rem minmax(0, 1fr) minmax(6rem, 8rem);
grid-template-areas: 'date content thumb';
align-items: center;
gap: var(--space-5);
gap: var(--space-4);
padding-block: var(--space-6);
border-top: 1px solid var(--color-rule);
}
@ -611,6 +623,9 @@
.article-list .entry-title,
.project-list h3 a {
display: inline-flex;
align-items: center;
min-height: 28px;
color: var(--color-fg);
font-weight: var(--weight-semibold);
text-decoration: none;
@ -734,9 +749,10 @@
.project-card .project-meta {
color: var(--color-muted);
font-size: var(--fs-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-essay-badge {
@ -778,6 +794,11 @@
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);
@ -838,6 +859,12 @@
margin-top: var(--space-4);
}
.about-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-4);
}
.post > .prose {
margin-top: var(--space-8);
}
@ -864,6 +891,11 @@
margin-top: 1.05em;
}
.prose > h2:first-child,
.prose > h3:first-child {
margin-top: 0;
}
.prose p {
text-wrap: pretty;
}
@ -902,19 +934,24 @@
font-weight: var(--weight-regular);
font-size: 0.85em;
text-decoration: none;
opacity: 0;
opacity: 0.25;
transition: opacity 150ms ease;
}
.prose .heading-anchor::before {
content: '#';
}
.prose h2:hover .heading-anchor,
.prose h3:hover .heading-anchor,
.prose .heading-anchor:hover,
.prose .heading-anchor:focus-visible {
opacity: 1;
}
@media (hover: none) {
.prose .heading-anchor {
opacity: 0.4;
opacity: 0.5;
}
}
@ -1043,10 +1080,17 @@
.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-2) var(--space-4);
margin: var(--space-4) 0 0;
gap: var(--space-4);
}
.at-a-glance dt,
@ -1089,24 +1133,23 @@
.post > .at-a-glance {
grid-column: 2;
grid-row: span 5;
margin-top: var(--space-8);
position: sticky;
top: var(--space-6);
align-self: start;
}
}
/* -- Post media (formerly EvidenceMedia) ----------------------------- */
/* -- Post media ------------------------------------------------------- */
.post-media,
.evidence-media {
.post-media {
max-inline-size: min(100%, var(--measure-wide));
margin: var(--space-8) 0 0;
}
.post-media img,
.post-media video,
.evidence-media img,
.evidence-media video {
.post-media video {
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
@ -1114,7 +1157,6 @@
}
.post-media figcaption,
.evidence-media figcaption,
.media-transcript {
max-width: var(--measure);
margin-top: var(--space-2);
@ -1126,14 +1168,24 @@
/* -- Post nav --------------------------------------------------------- */
.post-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: var(--space-4);
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;
@ -1170,6 +1222,67 @@
font-weight: var(--weight-semibold);
}
/* -- Post TOC --------------------------------------------------------- */
.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-toc .post-nav__title,
.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 media gallery ----------------------------------------------- */
.post-gallery {
list-style: none;
padding: 0;
margin: var(--space-8) 0 0;
display: grid;
gap: var(--space-6);
}
/* -- External link affordance ----------------------------------------- */
.external-link-icon {
display: inline-block;
margin-inline-start: 0.25em;
vertical-align: -0.125em;
opacity: 0.85;
}
/* -- Related ---------------------------------------------------------- */
.related-posts {
@ -1187,36 +1300,27 @@
/* -- Empty state (e.g. 404) ----------------------------------------- */
.empty-state {
min-height: 50vh;
display: grid;
place-content: center;
text-align: center;
max-width: var(--measure);
margin-inline: auto;
padding-block: var(--space-10);
}
.empty-state .prose {
margin-inline: auto;
padding-block: var(--space-6);
}
/* -- Theme switcher --------------------------------------------------- */
.theme-switcher {
--switcher-w: 2.5rem;
--switcher-h: 1.25rem;
--switcher-icon: 0.85rem;
--switcher-mask: 0.68rem;
--switcher-gap: 0.2rem;
--switcher-mask-offset: 0.28rem;
--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: block;
display: inline-block;
width: var(--switcher-w);
height: var(--switcher-h);
margin: 0;
margin: var(--space-2) 0;
overflow: hidden;
border: 1px solid var(--color-rule);
border: 1px solid var(--color-rule-medium);
border-radius: var(--radius-pill);
appearance: none;
cursor: pointer;
@ -1225,9 +1329,7 @@
transition:
background-color 200ms ease,
border-color 150ms ease;
box-shadow:
inset 0 0 10px 2px rgb(0 0 0 / 17.5%),
inset 0 0 1px rgb(0 0 0 / 40%);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 18%);
}
.theme-switcher:hover {
@ -1329,8 +1431,8 @@
padding-block: var(--space-8) var(--space-6);
}
.at-a-glance dl,
.facts dl {
.at-a-glance__row,
.facts dl > div {
grid-template-columns: 1fr;
gap: var(--space-1);
}
@ -1357,7 +1459,7 @@
}
.project-card .project-meta {
white-space: normal;
-webkit-line-clamp: 3;
}
.project-card__summary {
@ -1377,10 +1479,14 @@
outline-offset: 1px;
}
.post-nav {
.post-nav__list {
grid-template-columns: 1fr;
}
.post-nav__next {
justify-self: stretch;
}
.post-nav a.next {
text-align: start;
}
@ -1402,6 +1508,14 @@
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(*) {
@ -1430,10 +1544,17 @@
line-height: 1.4;
}
*,
*::before,
*::after {
print-color-adjust: economy;
-webkit-print-color-adjust: economy;
}
.site-header,
.site-footer,
.skip-link,
.theme-control,
.theme-switcher,
.tag-filter,
.post-nav,
.related-posts,
@ -1465,8 +1586,7 @@
.prose pre,
.prose code,
.post-thumbnail img,
.post-media img,
.evidence-media img {
.post-media img {
page-break-inside: avoid;
}