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 exit 1
fi fi
- name: Build - name: Typecheck
run: npm run build run: npm run typecheck
- name: Build & QA
run: |
npx playwright install chromium
npm run qa
- name: Copy build to host pages mount - name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'

View file

@ -83,10 +83,11 @@ export default defineConfig({
behavior: 'append', behavior: 'append',
properties: { properties: {
className: ['heading-anchor'], className: ['heading-anchor'],
'aria-hidden': 'true', ariaLabel: 'Permalink',
tabIndex: -1,
}, },
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", "typecheck": "astro check",
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"", "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": "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", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"qa:no-js": "node scripts/check-no-js.mjs", "qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/check-overflow.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": { "repository": {
"type": "git", "type": "git",

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { Picture } from 'astro:assets'; import PostMediaFigure from './PostMediaFigure.astro';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number]; type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
@ -10,76 +10,21 @@ interface Props {
const { items } = Astro.props; const { items } = Astro.props;
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' => // Wrap in a gallery `<ul>` when there's more than one item; otherwise the
format === 'png' ? 'png' : 'jpg'; // figures sit directly in the post flow.
const isGallery = items.length > 1;
--- ---
{ {
items.length > 1 ? ( isGallery ? (
<ul role="list" class="post-gallery"> <ul role="list" class="post-gallery">
{items.map((item) => ( {items.map((item) => (
<li> <li>
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}> <PostMediaFigure item={item} />
{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>
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
items.map((item) => ( items.map((item) => <PostMediaFigure item={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>
))
) )
} }

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

View file

@ -12,57 +12,26 @@ interface Props {
const { projects } = Astro.props; const { projects } = Astro.props;
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number]; type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
async function resolveEssayHref( // The `essay` field is a `reference('posts')`, so when present it's always a
essay: CollectionEntry<'projects'>['data']['essay'] // `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
): 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;
}
const essayHrefs = new Map<string, string>(); const essayHrefs = new Map<string, string>();
for (const project of projects) { for (const project of projects) {
const href = await resolveEssayHref(project.data.essay); const essay = project.data.essay;
if (href) essayHrefs.set(project.id, href); if (!essay) continue;
const resolved = await getEntry(essay);
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
} }
--- ---
<ol class="project-list"> <ol class="project-list">
{ {
projects.map((project) => { projects.map((project, index) => {
const anchor = projectAnchor(project); const anchor = projectAnchor(project);
const titleId = `${anchor}-title`; const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id); 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 primaryHref = essayHref ?? project.data.links[0]?.url;
const links: ProjectLink[] = [ const links: ProjectLink[] = project.data.links;
...(essayLink ? [essayLink] : []), const isFirst = index === 0;
...project.data.links,
];
return ( return (
<li class="project-card" id={anchor}> <li class="project-card" id={anchor}>
@ -73,6 +42,8 @@ for (const project of projects) {
class="project-thumbnail" class="project-thumbnail"
widths={[240, 320, 480, 640, 800]} widths={[240, 320, 480, 640, 800]}
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem" 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"> <article class="project-card__summary">
<h3 id={titleId}> <h3 id={titleId}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,13 +7,10 @@ thumbnail:
alt: Screenshot of the My Notes Android markdown app. alt: Screenshot of the My Notes Android markdown app.
period: 'November 2019' period: 'November 2019'
sortDate: 2019-11-01 sortDate: 2019-11-01
status: Android app
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon'] technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
selected: false selected: false
essay: my-notes-android-markdown-app essay: my-notes-android-markdown-app
legacyAnchor: my-notes-android-app
links: links:
- label: Source - label: Source
type: source
url: https://github.com/schmelczer/my-notes 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. alt: JavaFX editor interface for the cooling system simulator input graph.
period: 'October-November 2018' period: 'October-November 2018'
sortDate: 2018-10-15 sortDate: 2018-10-15
status: Input editor
technologies: ['JavaFX', 'JSON', 'REST API'] technologies: ['JavaFX', 'JSON', 'REST API']
selected: false selected: false
essay: graph-editor-javafx-simulation-input essay: graph-editor-javafx-simulation-input
legacyAnchor: graph-editor-javafx
links: [] links: []
--- ---

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import { absoluteUrl, optimizeOgImage, site } from '../lib/site'; import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
import defaultOg from '../assets/og-default.jpg'; 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'; import '../styles/global.css';
interface ArticleMeta { interface ArticleMeta {
@ -48,8 +48,8 @@ const ogTitle = isRoot ? site.title : title;
const canonical = absoluteUrl(canonicalPath); const canonical = absoluteUrl(canonicalPath);
let resolvedOgImage = ogImage; let resolvedOgImage = ogImage;
let resolvedOgWidth = ogImageWidth; let resolvedOgWidth = ogImageWidth ?? 1200;
let resolvedOgHeight = ogImageHeight; let resolvedOgHeight = ogImageHeight ?? 630;
if (!resolvedOgImage) { if (!resolvedOgImage) {
const generated = await optimizeOgImage(defaultOg); const generated = await optimizeOgImage(defaultOg);
@ -62,6 +62,64 @@ const ogImageUrl = resolvedOgImage.startsWith('http')
? resolvedOgImage ? resolvedOgImage
: absoluteUrl(resolvedOgImage); : absoluteUrl(resolvedOgImage);
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; 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> <!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="#fbfaf7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
{noindex && <meta name="robots" content="noindex,follow" />} {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} /> <script is:inline data-theme-script set:html={themeInit} />
<noscript
><style>
.theme-switcher {
display: none !important;
}
</style></noscript
>
<link <link
rel="preload" rel="preload"
href="/fonts/source-sans-3-latin-variable.woff2" href="/fonts/source-sans-3-latin-variable.woff2"
@ -90,76 +153,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
type="font/woff2" type="font/woff2"
crossorigin crossorigin
/> />
{ <Fragment set:html={headHtml} />
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)} />
))
}
</head> </head>
<body> <body>
<Header /> <Header />

View file

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

View file

@ -11,6 +11,7 @@ import {
absoluteUrl, absoluteUrl,
adjacentPosts, adjacentPosts,
articlePath, articlePath,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail, buildBreadcrumbTrail,
formatDate, formatDate,
getPublishedPosts, 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 wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200)); 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. // TOC: only show when there are >= 3 h2 headings.
const h2Headings = headings.filter((h) => h.depth === 2); const h2Headings = headings.filter((h) => h.depth === 2);
const showToc = h2Headings.length >= 3; const showToc = h2Headings.length >= 3;
@ -66,16 +71,7 @@ const blogPosting = {
}, },
}; };
const breadcrumbJsonLd = { const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: trail.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
item: absoluteUrl(c.href),
})),
};
--- ---
<Base <Base
@ -87,7 +83,7 @@ const breadcrumbJsonLd = {
ogImageWidth={1200} ogImageWidth={1200}
ogImageHeight={630} ogImageHeight={630}
ogType="article" ogType="article"
preloadMono={true} preloadMono={hasCode}
article={{ article={{
publishedTime: post.data.date.toISOString(), publishedTime: post.data.date.toISOString(),
modifiedTime: post.data.updated?.toISOString(), modifiedTime: post.data.updated?.toISOString(),
@ -130,7 +126,7 @@ const breadcrumbJsonLd = {
alt={post.data.thumbnail.alt} alt={post.data.thumbnail.alt}
formats={['avif', 'webp']} formats={['avif', 'webp']}
fallbackFormat="jpg" 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" sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
@ -170,8 +166,8 @@ const breadcrumbJsonLd = {
{ {
related.length > 0 && ( related.length > 0 && (
<section class="related-posts" aria-labelledby="related-heading"> <section class="related-posts">
<h2 id="related-heading">Related articles</h2> <h2>Related articles</h2>
<ArticleList posts={related} /> <ArticleList posts={related} />
</section> </section>
) )

View file

@ -19,18 +19,20 @@ export const site = {
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via // entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
// the site title). The Footer renders every entry regardless. Items marked // the site title). The Footer renders every entry regardless. Items marked
// `footerOnly: true` appear only in the Footer. // `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: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' }, { href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' }, { href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' }, { href: '/about/', label: 'About' },
{ href: '/tags/', label: 'Tags', footerOnly: false }, { href: '/tags/', label: 'Tags' },
{ href: '/rss.xml', label: 'RSS', footerOnly: true }, { href: '/rss.xml', label: 'RSS', footerOnly: true },
] as const satisfies ReadonlyArray<{ ];
href: string;
label: string;
footerOnly?: boolean;
}>;
export function formatDate(date: Date) { export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', { return new Intl.DateTimeFormat('en', {
@ -176,28 +178,53 @@ interface BreadcrumbCrumb {
interface BreadcrumbInput { interface BreadcrumbInput {
articles?: boolean; articles?: boolean;
projects?: boolean;
tagsIndex?: boolean;
tag?: string; tag?: string;
post?: CollectionEntry<'posts'>; post?: CollectionEntry<'posts'>;
} }
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the // Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
// visible Breadcrumbs component. Home is always first. Pass `articles: true` // visible Breadcrumbs component. Home is always first. Flags append crumbs
// to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass // in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
// a `post` to append the post title (linking to its article path). // implies both Articles and Tags so callers don't have to set every flag.
export function buildBreadcrumbTrail({ export function buildBreadcrumbTrail({
articles, articles,
projects,
tagsIndex,
tag, tag,
post, post,
}: BreadcrumbInput): BreadcrumbCrumb[] { }: BreadcrumbInput): BreadcrumbCrumb[] {
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }]; const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
if (articles || post) { if (articles || post || tagsIndex || tag) {
trail.push({ name: 'Articles', href: '/articles/' }); trail.push({ name: 'Articles', href: '/articles/' });
} }
if (tagsIndex || tag) {
trail.push({ name: 'Tags', href: '/tags/' });
}
if (tag) { if (tag) {
trail.push({ name: tag, href: tagPath(tag) }); trail.push({ name: `#${tag}`, href: tagPath(tag) });
} }
if (post) { if (post) {
trail.push({ name: post.data.title, href: articlePath(post) }); trail.push({ name: post.data.title, href: articlePath(post) });
} }
if (projects) {
trail.push({ name: 'Projects', href: '/projects/' });
}
return trail; 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"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="404-recent">Recent articles</h2> <h2 id="recent-articles-404">Recent articles</h2>
<a href="/articles/">All articles </a> <a href="/articles/">All articles <span aria-hidden="true">→</span></a>
</div> </div>
<ArticleList posts={recent} /> <ArticleList posts={recent} />
</section> </section>

View file

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

View file

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

View file

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

View file

@ -39,7 +39,11 @@ const personJsonLd = buildPersonJsonLd();
<section class="home-section"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="latest-articles">Latest Articles</h2> <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> </div>
<ArticleList posts={latestPosts} /> <ArticleList posts={latestPosts} />
</section> </section>
@ -47,7 +51,7 @@ const personJsonLd = buildPersonJsonLd();
<section class="home-section"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="home-selected-projects">Selected Projects</h2> <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> </div>
<ProjectList projects={selectedProjects} /> <ProjectList projects={selectedProjects} />
</section> </section>

View file

@ -1,7 +1,13 @@
--- ---
import ProjectList from '../../components/ProjectList.astro'; import ProjectList from '../../components/ProjectList.astro';
import Page from '../../layouts/Page.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 projects = await getProjects();
const selected = projects.filter((project) => project.data.selected); 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.', 'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
}; };
const breadcrumbTrail = [ const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
...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 jsonLd = [collectionJsonLd, breadcrumbJsonLd]; const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
--- ---

View file

@ -3,7 +3,13 @@ import ArticleList from '../../components/ArticleList.astro';
import Breadcrumbs from '../../components/Breadcrumbs.astro'; import Breadcrumbs from '../../components/Breadcrumbs.astro';
import TagList from '../../components/TagList.astro'; import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.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() { export async function getStaticPaths() {
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
@ -18,22 +24,23 @@ const posts = await getPublishedPosts();
const allTags = getAllTags(posts); const allTags = getAllTags(posts);
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag)); const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
const title = `Articles tagged "${tag}"`; const title = `Articles tagged "${tag}"`;
const trail = [ const trail = buildBreadcrumbTrail({ tag });
{ href: '/', label: 'Home' }, const visibleTrail = trail.map((c, i) => ({
{ href: '/articles/', label: 'Articles' }, label: c.name,
{ href: '/tags/', label: 'Tags' }, href: i === trail.length - 1 ? undefined : c.href,
{ label: `#${tag}` }, }));
]; const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
--- ---
<Page <Page
title={title} title={title}
description={`Project articles and technical notes filed under #${tag}.`} 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"> <nav class="tag-filter" aria-label="Browse other tags">
<span>Browse other tags</span> <span>Browse other tags</span>
<TagList tags={allTags} currentTag={tag} labelled={false} /> <TagList tags={allTags} currentTag={tag} />
</nav> </nav>
<h2 class="sr-only">Articles</h2> <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 Page from '../../layouts/Page.astro';
import { import {
absoluteUrl, absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail, buildBreadcrumbTrail,
getAllTags, getAllTags,
getPublishedPosts, getPublishedPosts,
@ -27,20 +28,7 @@ const collectionJsonLd = {
description: 'Every tag used across the articles archive.', description: 'Every tag used across the articles archive.',
}; };
const breadcrumbTrail = [ const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
...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 jsonLd = [collectionJsonLd, breadcrumbJsonLd]; 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: light-dark(#285f74, #8ab8c8);
--color-link-hover: light-dark( --color-link-hover: light-dark(
color-mix(in oklch, #285f74 70%, black 30%), 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-link-visited: var(--color-link);
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15)); --color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
--color-rule: light-dark(#d9d5ca, #39352f); --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-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-callout-bg: light-dark(#f4f1e8, #211f1c);
--color-selection-bg: light-dark(#ecddd0, #4a3a2e); --color-selection-bg: light-dark(#ecddd0, #4a3a2e);
@ -173,6 +173,7 @@
color: var(--color-fg); color: var(--color-fg);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--fs-body); font-size: var(--fs-body);
line-height: var(--leading-snug);
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -181,6 +182,10 @@
color 200ms ease; color 200ms ease;
} }
address {
font-style: normal;
}
a { a {
color: var(--color-link); color: var(--color-link);
text-decoration-thickness: 0.08em; text-decoration-thickness: 0.08em;
@ -198,7 +203,7 @@
} }
:focus-visible { :focus-visible {
outline: 2px solid var(--color-rule-strong); outline: 2px solid var(--color-accent);
outline-offset: 3px; outline-offset: 3px;
} }
@ -228,17 +233,6 @@
white-space: nowrap; white-space: nowrap;
border: 0; 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 { @layer layout {
:where( :where(.site-header, .site-footer, .home-intro, .home-section, .page-shell, .post) {
.site-header,
.site-footer,
.home-intro,
.home-section,
.page-shell,
.post,
.post-footer-shell
) {
width: min(100% - 2 * var(--gutter), var(--page)); width: min(100% - 2 * var(--gutter), var(--page));
margin-inline: auto; margin-inline: auto;
} }
:where(.post, .post-footer-shell) { .post {
width: min(100% - 2 * var(--gutter), var(--measure-wide)); width: min(100% - 2 * var(--gutter), var(--measure-wide));
} }
@ -271,7 +257,10 @@
transform: translateY(-150%); transform: translateY(-150%);
background: var(--color-fg); background: var(--color-fg);
color: var(--color-bg); 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; text-decoration: none;
transition: transform 150ms ease; transition: transform 150ms ease;
} }
@ -294,7 +283,9 @@
.site-title { .site-title {
color: var(--color-fg); 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; text-decoration: none;
} }
@ -302,6 +293,10 @@
color: var(--color-fg); color: var(--color-fg);
} }
.site-title[aria-current='page'] {
color: var(--color-fg);
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -337,17 +332,11 @@
text-underline-offset: 0.25em; 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); color: var(--color-fg);
} }
.theme-control {
cursor: pointer;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.site-footer { .site-footer {
border-top: 1px solid var(--color-rule); border-top: 1px solid var(--color-rule);
margin-top: var(--space-16); margin-top: var(--space-16);
@ -370,16 +359,24 @@
font-size: var(--fs-caption); font-size: var(--fs-caption);
} }
.footer-links a,
.footer-meta a,
.footer-meta span { .footer-meta span {
min-height: 44px; min-height: 44px;
display: inline-flex; display: inline-flex;
align-items: center; 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) */ /* Page header (shared by .home-intro, .page-header, .post-header) */
.home-intro { .home-intro {
max-width: var(--measure-wide); 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, .home-intro h1,
@ -545,8 +542,23 @@
color: var(--color-rule-medium); 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:hover,
.tag-list a[aria-current='page'] { .tag-list a[aria-current='page'],
.tag-list a[aria-current='true'] {
color: var(--color-fg); color: var(--color-fg);
} }
@ -584,10 +596,10 @@
.article-list > li { .article-list > li {
display: grid; 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'; grid-template-areas: 'date content thumb';
align-items: center; align-items: center;
gap: var(--space-5); gap: var(--space-4);
padding-block: var(--space-6); padding-block: var(--space-6);
border-top: 1px solid var(--color-rule); border-top: 1px solid var(--color-rule);
} }
@ -611,6 +623,9 @@
.article-list .entry-title, .article-list .entry-title,
.project-list h3 a { .project-list h3 a {
display: inline-flex;
align-items: center;
min-height: 28px;
color: var(--color-fg); color: var(--color-fg);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
text-decoration: none; text-decoration: none;
@ -734,9 +749,10 @@
.project-card .project-meta { .project-card .project-meta {
color: var(--color-muted); color: var(--color-muted);
font-size: var(--fs-sm); font-size: var(--fs-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.project-essay-badge { .project-essay-badge {
@ -778,6 +794,11 @@
color: var(--color-link); color: var(--color-link);
} }
.project-links a:hover,
.project-links a:focus-visible {
color: var(--color-link-hover);
}
.project-links a .download-indicator { .project-links a .download-indicator {
margin-left: 0.25em; margin-left: 0.25em;
color: var(--color-muted); color: var(--color-muted);
@ -838,6 +859,12 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
.about-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-4);
}
.post > .prose { .post > .prose {
margin-top: var(--space-8); margin-top: var(--space-8);
} }
@ -864,6 +891,11 @@
margin-top: 1.05em; margin-top: 1.05em;
} }
.prose > h2:first-child,
.prose > h3:first-child {
margin-top: 0;
}
.prose p { .prose p {
text-wrap: pretty; text-wrap: pretty;
} }
@ -902,19 +934,24 @@
font-weight: var(--weight-regular); font-weight: var(--weight-regular);
font-size: 0.85em; font-size: 0.85em;
text-decoration: none; text-decoration: none;
opacity: 0; opacity: 0.25;
transition: opacity 150ms ease; transition: opacity 150ms ease;
} }
.prose .heading-anchor::before {
content: '#';
}
.prose h2:hover .heading-anchor, .prose h2:hover .heading-anchor,
.prose h3:hover .heading-anchor, .prose h3:hover .heading-anchor,
.prose .heading-anchor:hover,
.prose .heading-anchor:focus-visible { .prose .heading-anchor:focus-visible {
opacity: 1; opacity: 1;
} }
@media (hover: none) { @media (hover: none) {
.prose .heading-anchor { .prose .heading-anchor {
opacity: 0.4; opacity: 0.5;
} }
} }
@ -1043,10 +1080,17 @@
.at-a-glance dl, .at-a-glance dl,
.facts 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; display: grid;
grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr); grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr);
gap: var(--space-2) var(--space-4); gap: var(--space-4);
margin: var(--space-4) 0 0;
} }
.at-a-glance dt, .at-a-glance dt,
@ -1089,24 +1133,23 @@
.post > .at-a-glance { .post > .at-a-glance {
grid-column: 2; grid-column: 2;
grid-row: span 5;
margin-top: var(--space-8); margin-top: var(--space-8);
position: sticky; position: sticky;
top: var(--space-6); top: var(--space-6);
align-self: start;
} }
} }
/* -- Post media (formerly EvidenceMedia) ----------------------------- */ /* -- Post media ------------------------------------------------------- */
.post-media, .post-media {
.evidence-media {
max-inline-size: min(100%, var(--measure-wide)); max-inline-size: min(100%, var(--measure-wide));
margin: var(--space-8) 0 0; margin: var(--space-8) 0 0;
} }
.post-media img, .post-media img,
.post-media video, .post-media video {
.evidence-media img,
.evidence-media video {
border: 1px solid var(--color-rule); border: 1px solid var(--color-rule);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-code-bg); background: var(--color-code-bg);
@ -1114,7 +1157,6 @@
} }
.post-media figcaption, .post-media figcaption,
.evidence-media figcaption,
.media-transcript { .media-transcript {
max-width: var(--measure); max-width: var(--measure);
margin-top: var(--space-2); margin-top: var(--space-2);
@ -1126,14 +1168,24 @@
/* -- Post nav --------------------------------------------------------- */ /* -- Post nav --------------------------------------------------------- */
.post-nav { .post-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: var(--space-4);
margin-top: var(--space-12); margin-top: var(--space-12);
padding-top: var(--space-6); padding-top: var(--space-6);
border-top: 1px solid var(--color-rule); 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 { .post-nav a {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1170,6 +1222,67 @@
font-weight: var(--weight-semibold); 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 ---------------------------------------------------------- */
.related-posts { .related-posts {
@ -1187,36 +1300,27 @@
/* -- Empty state (e.g. 404) ----------------------------------------- */ /* -- Empty state (e.g. 404) ----------------------------------------- */
.empty-state { .empty-state {
min-height: 50vh;
display: grid;
place-content: center;
text-align: center;
max-width: var(--measure); max-width: var(--measure);
margin-inline: auto; padding-block: var(--space-6);
padding-block: var(--space-10);
}
.empty-state .prose {
margin-inline: auto;
} }
/* -- Theme switcher --------------------------------------------------- */ /* -- Theme switcher --------------------------------------------------- */
.theme-switcher { .theme-switcher {
--switcher-w: 2.5rem; --switcher-w: 2.75rem;
--switcher-h: 1.25rem; --switcher-h: 1.5rem;
--switcher-icon: 0.85rem; --switcher-icon: 1.05rem;
--switcher-mask: 0.68rem; --switcher-mask: 0.78rem;
--switcher-gap: 0.2rem; --switcher-gap: 0.22rem;
--switcher-mask-offset: 0.28rem; --switcher-mask-offset: 0.32rem;
position: relative; position: relative;
display: block; display: inline-block;
width: var(--switcher-w); width: var(--switcher-w);
height: var(--switcher-h); height: var(--switcher-h);
margin: 0; margin: var(--space-2) 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-rule); border: 1px solid var(--color-rule-medium);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
appearance: none; appearance: none;
cursor: pointer; cursor: pointer;
@ -1225,9 +1329,7 @@
transition: transition:
background-color 200ms ease, background-color 200ms ease,
border-color 150ms ease; border-color 150ms ease;
box-shadow: box-shadow: inset 0 1px 2px rgb(0 0 0 / 18%);
inset 0 0 10px 2px rgb(0 0 0 / 17.5%),
inset 0 0 1px rgb(0 0 0 / 40%);
} }
.theme-switcher:hover { .theme-switcher:hover {
@ -1329,8 +1431,8 @@
padding-block: var(--space-8) var(--space-6); padding-block: var(--space-8) var(--space-6);
} }
.at-a-glance dl, .at-a-glance__row,
.facts dl { .facts dl > div {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-1); gap: var(--space-1);
} }
@ -1357,7 +1459,7 @@
} }
.project-card .project-meta { .project-card .project-meta {
white-space: normal; -webkit-line-clamp: 3;
} }
.project-card__summary { .project-card__summary {
@ -1377,10 +1479,14 @@
outline-offset: 1px; outline-offset: 1px;
} }
.post-nav { .post-nav__list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.post-nav__next {
justify-self: stretch;
}
.post-nav a.next { .post-nav a.next {
text-align: start; text-align: start;
} }
@ -1402,6 +1508,14 @@
scroll-behavior: auto; 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-group(*),
::view-transition-old(*), ::view-transition-old(*),
::view-transition-new(*) { ::view-transition-new(*) {
@ -1430,10 +1544,17 @@
line-height: 1.4; line-height: 1.4;
} }
*,
*::before,
*::after {
print-color-adjust: economy;
-webkit-print-color-adjust: economy;
}
.site-header, .site-header,
.site-footer, .site-footer,
.skip-link, .skip-link,
.theme-control, .theme-switcher,
.tag-filter, .tag-filter,
.post-nav, .post-nav,
.related-posts, .related-posts,
@ -1465,8 +1586,7 @@
.prose pre, .prose pre,
.prose code, .prose code,
.post-thumbnail img, .post-thumbnail img,
.post-media img, .post-media img {
.evidence-media img {
page-break-inside: avoid; page-break-inside: avoid;
} }