= []
- ) {
- this.htmlRoot = PageElement.createElement(content);
- }
-
- public attachToDOM(target: HTMLElement) {
- target.appendChild(this.htmlRoot);
- this.initialize();
- }
-
- protected initialize(): void {
- this.children.forEach((c) => c.initialize());
- }
-
- protected query(query: string): HTMLElement {
- return this.htmlRoot.querySelector(query) as HTMLElement;
- }
-
- protected attachElement(element: PageElement) {
- this.htmlRoot.appendChild(element.htmlRoot);
- this.children.push(element);
- }
-
- protected attachElementByReplacing(query: string, element: PageElement) {
- const old = this.query(query);
- old.parentElement!.replaceChild(element.htmlRoot, old);
- this.children.push(element);
- }
-
- private static createElement(from: html): HTMLElement {
- // won't work for all elements, eg.:
- const element: HTMLElement = document.createElement('div');
- element.innerHTML = from;
- return element.firstElementChild as HTMLElement;
- }
-}
diff --git a/src/page/timeline-element/timeline-element-parameters.ts b/src/page/timeline-element/timeline-element-parameters.ts
deleted file mode 100644
index fc07ce1..0000000
--- a/src/page/timeline-element/timeline-element-parameters.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { html } from '../../types/html';
-import { Figure } from '../figure/figure';
-
-export interface TimelineElementParameters {
- date: string;
- figure: Figure;
- title: string;
- description: string;
- more?: Array;
- links: Array;
-}
diff --git a/src/page/timeline-element/timeline-element.html.ts b/src/page/timeline-element/timeline-element.html.ts
deleted file mode 100644
index ea14f0c..0000000
--- a/src/page/timeline-element/timeline-element.html.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import info from '../../../static/icons/info.svg';
-import { titleToFragment } from '../../helper/title-to-fragment';
-import { html } from '../../types/html';
-import { ImageButtonFactory } from '../image-button/image-button.html';
-import { TimelineElementParameters } from './timeline-element-parameters';
-import './timeline-element.scss';
-
-export const generate = (
- { date, title, description, more, links }: TimelineElementParameters,
- showMore: string
-): html => `
-
-
-
-
-
-
-
-
${description}
-
- ${
- more
- ? `
-
- ${more.map((t) => `
${t}
`).join('')}
-
`
- : ''
- }
-
-
- ${more ? ImageButtonFactory(info, showMore)() : ''}
- ${links.join('')}
-
-
-
-
-`;
diff --git a/src/page/timeline-element/timeline-element.scss b/src/page/timeline-element/timeline-element.scss
deleted file mode 100644
index fa7cbe1..0000000
--- a/src/page/timeline-element/timeline-element.scss
+++ /dev/null
@@ -1,166 +0,0 @@
-@use '../../style/mixins' as *;
-
-@mixin q-dependent-line-container($q) {
- > .line {
- height: calc(#{$q} - var(--icon-size) / 2);
-
- &:before {
- height: calc(100% - #{$q} - var(--icon-size) / 2);
- }
-
- &:after {
- top: calc(#{$q} - var(--icon-size) / 2);
- }
- }
-
- > .date {
- top: calc(#{$q} - 0.5ch);
- }
-}
-
-.timeline-element {
- display: flex;
- width: var(--body-width);
- margin: auto;
-
- > .line-container {
- position: relative;
- @include q-dependent-line-container(33%);
- transform: translate3d(0, 0, 0); // fix visual glitches in webkit
-
- > .line {
- &,
- &:before {
- background: var(--accent-color);
- width: var(--line-width);
- }
-
- &:before,
- &:after {
- content: '';
- position: absolute;
- }
-
- &:before {
- left: 0;
- bottom: 0;
- }
-
- &:after {
- @include square(var(--icon-size));
- border-radius: 1000px;
- border: var(--line-width) solid var(--accent-color);
- left: calc((var(--line-width) - var(--icon-size)) / 2);
- }
- }
-
- > .date {
- @include special-text-font();
- position: absolute;
- transform-origin: left center;
- transform: rotate(30deg) translateX(calc(var(--icon-size) / 2 + 6px))
- translateY(-10%);
-
- padding-right: var(--normal-margin);
- }
- }
-
- > .card {
- @include blurred-background();
- box-shadow: var(--shadow);
- overflow: hidden;
- border-radius: var(--border-radius);
- background-color: var(--blurred-card-color);
- transition: background-color var(--transition-time);
-
- > .figure-container {
- border-radius: var(--border-radius) var(--border-radius) 0 0;
- }
-
- > .lower {
- > * {
- padding: 0 var(--normal-margin);
- margin-top: var(--small-margin);
- }
-
- > h2 {
- text-align: center;
- margin-bottom: -6px;
-
- > a {
- @include sub-title-font();
- @include title-fragment-link();
- }
- }
-
- > .description {
- text-align: center;
- padding: 0 var(--large-margin);
- }
-
- > .more {
- overflow: hidden;
- margin: 0;
- height: 0;
- transition: height var(--transition-time-long);
-
- > p {
- margin-top: var(--line-height);
- }
- }
-
- $border-width: 1px;
-
- > .buttons {
- display: flex;
- justify-content: center;
- border-top: $border-width solid var(--normal-text-color);
-
- margin: 0;
- padding: 0;
- margin-top: var(--small-margin);
-
- > * {
- flex: 1;
-
- &:not(:last-child) {
- border-right: $border-width solid var(--normal-text-color);
- }
- }
- }
- }
- }
-
- @include on-large-screen {
- &:first-of-type > .line-container > .line {
- border-radius: 100px 100px 0 0;
- }
-
- &:last-of-type > .line-container > .line:before {
- border-radius: 0 0 100px 100px;
- }
-
- > .line-container {
- min-width: 10rem;
- }
-
- &:not(:first-of-type) > .card {
- margin-top: var(--large-margin);
- }
- }
-
- @include on-small-screen {
- flex-direction: column;
- align-items: center;
-
- > .line-container {
- @include q-dependent-line-container(50%);
- height: 150px;
- min-width: 64%;
-
- > .date {
- transform: translateX(calc(var(--icon-size) / 2 + 12px)) translateY(-10%);
- }
- }
- }
-}
diff --git a/src/page/timeline-element/timeline-element.ts b/src/page/timeline-element/timeline-element.ts
deleted file mode 100644
index 46bda47..0000000
--- a/src/page/timeline-element/timeline-element.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { titleToFragment } from '../../helper/title-to-fragment';
-import { BorderedImage } from '../figure/bordered-image/bordered-image';
-import { ImageViewer } from '../image-viewer/image-viewer';
-import { PageElement } from '../page-element';
-import { TimelineElementParameters } from './timeline-element-parameters';
-import { generate } from './timeline-element.html';
-
-export class TimelineElement extends PageElement {
- private isOpen = false;
- private readonly more?: HTMLElement;
-
- public constructor(
- private timelineElement: TimelineElementParameters,
- private readonly showMore: string,
- private readonly showLess: string,
- imageViewer?: ImageViewer
- ) {
- super(generate(timelineElement, showMore));
-
- addEventListener('resize', this.handleResize.bind(this));
-
- this.more = this.query('.more');
- if (this.more) {
- this.query('.buttons > .image-button').addEventListener(
- 'click',
- this.toggleOpen.bind(this)
- );
- }
-
- if (timelineElement.figure instanceof BorderedImage) {
- timelineElement.figure.imageViewer = imageViewer;
- }
-
- this.attachElementByReplacing('.figure', timelineElement.figure);
- }
-
- protected initialize(): void {
- super.initialize();
-
- if (titleToFragment(this.timelineElement.title) === window.location.hash) {
- setTimeout(this.openMore.bind(this), 100);
- }
- }
-
- private toggleOpen() {
- if (this.isOpen) {
- this.closeMore();
- } else {
- this.openMore();
- }
- }
-
- private openMore() {
- if (!this.more) {
- return;
- }
-
- this.isOpen = true;
-
- this.query('.buttons > .image-button p').innerText = this.showLess;
-
- const deltaHeight = this.more.scrollHeight;
- this.more.style.height = `${deltaHeight.toString()}px`;
- }
-
- private closeMore() {
- if (!this.more) {
- return;
- }
-
- this.isOpen = false;
-
- this.query('.buttons > .image-button p').innerText = this.showMore;
-
- this.more.style.height = '0';
- }
-
- private handleResize() {
- if (!this.more) {
- return;
- }
-
- if (this.isOpen) {
- this.more.style.height = 'auto';
- setTimeout(this.openMore.bind(this), 100);
- }
- }
-}
diff --git a/src/page/up-arrow-button/up-arrow-button.html.ts b/src/page/up-arrow-button/up-arrow-button.html.ts
deleted file mode 100644
index 02cd5a8..0000000
--- a/src/page/up-arrow-button/up-arrow-button.html.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import arrow from '../../../static/icons/arrow.svg';
-import { html } from '../../types/html';
-import './up-arrow-button.scss';
-
-export const generate = (label: string): html => `
-
- ${arrow}
-
-`;
diff --git a/src/page/up-arrow-button/up-arrow-button.scss b/src/page/up-arrow-button/up-arrow-button.scss
deleted file mode 100644
index a508189..0000000
--- a/src/page/up-arrow-button/up-arrow-button.scss
+++ /dev/null
@@ -1,33 +0,0 @@
-@use '../../style/mixins' as *;
-
-#up-arrow-button {
- @include blurred-background();
-
- cursor: pointer;
- box-shadow: var(--shadow);
- transition:
- opacity var(--transition-time),
- transform var(--transition-time-long);
-
- border-radius: var(--border-radius);
-
- position: fixed;
- bottom: var(--small-margin);
- right: var(--normal-margin);
- padding: 0.25rem;
-
- &:hover {
- transform: scale(1.1);
- }
-
- &.down {
- transform: rotate(180deg);
- &:hover {
- transform: scale(1.1) rotate(180deg);
- }
- }
-
- svg {
- @include square(var(--large-icon-size));
- }
-}
diff --git a/src/page/up-arrow-button/up-arrow-button.ts b/src/page/up-arrow-button/up-arrow-button.ts
deleted file mode 100644
index 3d5fe57..0000000
--- a/src/page/up-arrow-button/up-arrow-button.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { PageElement } from '../page-element';
-import { generate } from './up-arrow-button.html';
-
-export class UpArrowButton extends PageElement {
- private static readonly defaultTimeToLive = 3500;
- private static readonly interval = 50;
- private timeToLive = 0;
-
- public constructor(
- private scrollTarget: PageElement,
- private turningThreshold: PageElement,
- label: string
- ) {
- super(generate(label));
-
- this.htmlRoot.addEventListener('click', this.scrollToTop.bind(this));
-
- setInterval(() => {
- this.timeToLive = Math.max(0, this.timeToLive - UpArrowButton.interval);
- if (this.timeToLive == 0) {
- this.htmlRoot.style.opacity = '0';
- }
- }, UpArrowButton.interval);
- }
-
- protected initialize() {
- this.scrollTarget.htmlRoot.addEventListener('scroll', () => {
- this.timeToLive = UpArrowButton.defaultTimeToLive;
- this.htmlRoot.style.opacity = '1';
- });
-
- this.htmlRoot.addEventListener('mouseover', () => {
- this.timeToLive = UpArrowButton.defaultTimeToLive;
- this.htmlRoot.style.opacity = '1';
- });
-
- new IntersectionObserver((e) => {
- if (e[0].isIntersecting) {
- this.htmlRoot.classList.remove('down');
- } else {
- this.htmlRoot.classList.add('down');
- }
- }).observe(this.turningThreshold.htmlRoot);
-
- super.initialize();
- }
-
- private scrollToTop() {
- this.scrollTarget.htmlRoot.scrollTo({
- top: this.htmlRoot.classList.contains('down')
- ? this.scrollTarget.htmlRoot.scrollHeight
- : 0,
- left: 0,
- behavior: 'smooth',
- });
- }
-}
diff --git a/src/pages/404.astro b/src/pages/404.astro
new file mode 100644
index 0000000..ae6fda9
--- /dev/null
+++ b/src/pages/404.astro
@@ -0,0 +1,33 @@
+---
+import ArticleList from '../components/ArticleList.astro';
+import Page from '../layouts/Page.astro';
+import { getPublishedPosts } from '../lib/site';
+
+const RECENT_ARTICLES = 5;
+
+const posts = await getPublishedPosts();
+const recent = posts.slice(0, RECENT_ARTICLES);
+---
+
+
+
+
+
+
diff --git a/src/pages/about.astro b/src/pages/about.astro
new file mode 100644
index 0000000..bac12e9
--- /dev/null
+++ b/src/pages/about.astro
@@ -0,0 +1,120 @@
+---
+import ArticleList from '../components/ArticleList.astro';
+import Page from '../layouts/Page.astro';
+import {
+ absoluteUrl,
+ buildPersonJsonLd,
+ getPublishedPosts,
+ optimizeOgImage,
+ site,
+} from '../lib/site';
+import defaultOg from '../assets/og-default.jpg';
+
+const STARTING_POINTS = 4;
+
+const posts = await getPublishedPosts();
+const startingPoints = posts
+ .filter((post) => post.data.audience === 'recruiter-relevant')
+ .sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
+ .slice(0, STARTING_POINTS);
+
+const personImage = await optimizeOgImage(defaultOg);
+
+// Canonical Person JSON-LD. Other pages reference this entity by @id.
+const personJsonLd = buildPersonJsonLd({
+ jobTitle: 'Software Engineer',
+ description:
+ 'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
+ knowsAbout: [
+ 'Software architecture',
+ 'AI/ML systems',
+ 'Web platforms',
+ 'Computer graphics',
+ 'Simulations',
+ 'Data visualization',
+ ],
+ image: absoluteUrl(personImage.src),
+ mainEntityOfPage: absoluteUrl('/about/'),
+});
+---
+
+
+
+
+ I am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
+ than six years of professional engineering experience. My work spans AI/ML systems,
+ web platforms, graphics, simulations, and tools, and I like projects where
+ architecture, constraints, and product usefulness all matter.
+
+
+ I am especially interested in architecting and building large-scale systems,
+ particularly around AI/ML. In my own time I also return to shaders, data
+ visualization, simulations, and occasionally microcontrollers. The
+ articles and projects indexes are the
+ best way to understand that range; the CV and contact links are here when a direct summary
+ is more useful.
+
+
+
+
+ Quick Facts
+
+
+
Focus
+
+ Software systems, AI deployment, architecture, graphics, data visualization
+
+
+
+
Education
+ MSc in Computer Science
+
+
+
+
+
+
+
+
+
+ How I Work
+
+
+ I am strongest when I can reason through a system end to end: the data model, the
+ API shape, the performance constraints, the operational risks, and the human path
+ through the tool. The projects on this site are older and newer examples of that
+ habit.
+
+
+ I care about simple interfaces over accidental complexity, and I prefer technical
+ depth that can be explained clearly. That is why this site is structured around
+ articles rather than screenshots and slogans.
+
+
+
+
diff --git a/src/pages/articles/[slug].astro b/src/pages/articles/[slug].astro
new file mode 100644
index 0000000..6c6bd8e
--- /dev/null
+++ b/src/pages/articles/[slug].astro
@@ -0,0 +1,16 @@
+---
+import Post from '../../layouts/Post.astro';
+import { entrySlug, getPublishedPosts } from '../../lib/site';
+
+export async function getStaticPaths() {
+ const posts = await getPublishedPosts();
+ return posts.map((post) => ({
+ params: { slug: entrySlug(post) },
+ props: { post },
+ }));
+}
+
+const { post } = Astro.props;
+---
+
+
diff --git a/src/pages/articles/index.astro b/src/pages/articles/index.astro
new file mode 100644
index 0000000..48c0df7
--- /dev/null
+++ b/src/pages/articles/index.astro
@@ -0,0 +1,74 @@
+---
+import ArticleList from '../../components/ArticleList.astro';
+import TagList from '../../components/TagList.astro';
+import Page from '../../layouts/Page.astro';
+import {
+ absoluteUrl,
+ articlePath,
+ buildBreadcrumbJsonLd,
+ buildBreadcrumbTrail,
+ getAllTags,
+ getPublishedPosts,
+ optimizeOgImage,
+ site,
+ yearOf,
+} from '../../lib/site';
+
+const description =
+ 'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.';
+
+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',
+ name: `${site.name} — Articles`,
+ url: absoluteUrl('/articles/'),
+ description,
+ 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 breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles: true }));
+
+const jsonLd = [blogJsonLd, breadcrumbJsonLd];
+---
+
+
+
+ Browse by tag
+
+
+
+ {
+ years.map((year) => {
+ const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
+ return (
+
+ );
+ })
+ }
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..81a1ef3
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,69 @@
+---
+import ArticleList from '../components/ArticleList.astro';
+import ProjectList from '../components/ProjectList.astro';
+import TagList from '../components/TagList.astro';
+import Base from '../layouts/Base.astro';
+import {
+ buildPersonJsonLd,
+ getAllTags,
+ getProjects,
+ getPublishedPosts,
+} from '../lib/site';
+
+const LATEST_ARTICLES = 5;
+
+const posts = await getPublishedPosts();
+const latestPosts = posts.slice(0, LATEST_ARTICLES);
+const projects = await getProjects();
+const selectedProjects = projects.filter((project) => project.data.selected);
+const tags = getAllTags(posts);
+
+// Reference the canonical Person (defined on /about/) by @id.
+const personJsonLd = buildPersonJsonLd();
+---
+
+
+
+
+ Software systems, AI deployment, graphics, simulations, and tools
+
+
+ Andras Schmelczer writes about building software that has to work under real
+ constraints.
+
+
+ I am a software engineer with an MSc in Computer Science. This site is mostly a
+ notebook of technical articles and project writeups; the hiring details live on the
+ About page.
+
+
+
+
+
+
+
+
+
+
Browse by Topic
+
+
+
+
+
+
diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro
new file mode 100644
index 0000000..4fb93eb
--- /dev/null
+++ b/src/pages/projects/index.astro
@@ -0,0 +1,42 @@
+---
+import ProjectList from '../../components/ProjectList.astro';
+import Page from '../../layouts/Page.astro';
+import {
+ absoluteUrl,
+ buildBreadcrumbJsonLd,
+ buildBreadcrumbTrail,
+ getProjects,
+ site,
+} from '../../lib/site';
+
+const description =
+ 'A compact index of systems, tools, simulations, graphics experiments, games, and older work.';
+
+const projects = await getProjects();
+const selected = projects.filter((project) => project.data.selected);
+const older = projects.filter((project) => !project.data.selected);
+
+const collectionJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+ name: `${site.name} — Projects`,
+ url: absoluteUrl('/projects/'),
+ description,
+};
+
+const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
+
+const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
+---
+
+
+
+
+
+ Older and Smaller Projects
+
+
+
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts
new file mode 100644
index 0000000..d87cbd2
--- /dev/null
+++ b/src/pages/rss.xml.ts
@@ -0,0 +1,103 @@
+import rss from '@astrojs/rss';
+import type { APIRoute } from 'astro';
+import { experimental_AstroContainer as AstroContainer } from 'astro/container';
+import { render } from 'astro:content';
+import ogDefault from '../assets/og-default.jpg';
+import {
+ absoluteUrl,
+ articlePath,
+ getPublishedPosts,
+ optimizeOgImage,
+ site,
+} from '../lib/site';
+
+// Escape characters that would otherwise break XML parsing inside text nodes
+// (the `customData` strings are inserted as-is by @astrojs/rss).
+function escapeXml(value: string) {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+// Rewrite root-relative URLs to absolute so RSS readers (which load the HTML
+// outside any page context) can still resolve assets and links.
+function absolutizeUrls(html: string, baseUrl: string) {
+ return html
+ .replace(/(<(?:a|link)\b[^>]*\bhref=")(\/[^"]*)(")/g, `$1${baseUrl}$2$3`)
+ .replace(
+ /(<(?:img|source|video|audio)\b[^>]*\bsrc=")(\/[^"]*)(")/g,
+ `$1${baseUrl}$2$3`
+ )
+ .replace(/(\bsrcset=")([^"]+)(")/g, (_, prefix, value, suffix) => {
+ const rewritten = value
+ .split(',')
+ .map((candidate: string) => {
+ const trimmed = candidate.trim();
+ if (!trimmed.startsWith('/')) return trimmed;
+ return baseUrl + trimmed;
+ })
+ .join(', ');
+ return prefix + rewritten + suffix;
+ });
+}
+
+export const GET: APIRoute = async () => {
+ const posts = await getPublishedPosts();
+ const feedUrl = absoluteUrl('/rss.xml');
+ const channelImage = await optimizeOgImage(ogDefault);
+ const channelImageUrl = absoluteUrl(channelImage.src);
+ const creator = escapeXml(site.name);
+ const container = await AstroContainer.create();
+
+ const items = await Promise.all(
+ posts.map(async (post) => {
+ const url = absoluteUrl(articlePath(post));
+ const updated = post.data.updated
+ ? `${post.data.updated.toISOString()} `
+ : '';
+ const { Content } = await render(post);
+ const html = await container.renderToString(Content);
+ // @astrojs/rss XML-escapes the `content` string and emits it inside
+ // . RSS readers decode the escaped HTML the same as if
+ // it were wrapped in CDATA, so escaping is fine and safer to author.
+ const content = absolutizeUrls(html, site.url);
+ return {
+ title: post.data.title,
+ description: post.data.description,
+ pubDate: post.data.date,
+ link: url,
+ author: `${site.email} (${site.name})`,
+ categories: [...post.data.tags],
+ content,
+ customData: [`${creator} `, updated]
+ .filter(Boolean)
+ .join('\n'),
+ };
+ })
+ );
+
+ return rss({
+ title: site.name,
+ description: site.description,
+ site: site.url,
+ xmlns: {
+ atom: 'http://www.w3.org/2005/Atom',
+ content: 'http://purl.org/rss/1.0/modules/content/',
+ dc: 'http://purl.org/dc/elements/1.1/',
+ },
+ customData: [
+ 'en-us ',
+ `${new Date().toUTCString()} `,
+ ` `,
+ '',
+ ` ${channelImageUrl} `,
+ ` ${escapeXml(site.name)} `,
+ ` ${site.url}`,
+ ' ',
+ ].join('\n'),
+ items,
+ });
+};
diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro
new file mode 100644
index 0000000..44dc0f6
--- /dev/null
+++ b/src/pages/tags/[tag].astro
@@ -0,0 +1,49 @@
+---
+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 {
+ buildBreadcrumbJsonLd,
+ buildBreadcrumbTrail,
+ getAllTags,
+ getPublishedPosts,
+ tagSlug,
+} from '../../lib/site';
+
+export async function getStaticPaths() {
+ const posts = await getPublishedPosts();
+ return getAllTags(posts).map((tag) => ({
+ params: { tag: tagSlug(tag) },
+ props: { tag },
+ }));
+}
+
+const { tag } = Astro.props;
+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 = buildBreadcrumbTrail({ tag });
+const visibleTrail = trail.map((c, i) => ({
+ label: c.name,
+ href: i === trail.length - 1 ? undefined : c.href,
+}));
+const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
+---
+
+
+
+
+ Browse other tags
+
+
+
+ Articles
+
+
diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro
new file mode 100644
index 0000000..820e54a
--- /dev/null
+++ b/src/pages/tags/index.astro
@@ -0,0 +1,45 @@
+---
+import TagList from '../../components/TagList.astro';
+import Page from '../../layouts/Page.astro';
+import {
+ absoluteUrl,
+ buildBreadcrumbJsonLd,
+ buildBreadcrumbTrail,
+ getAllTags,
+ getPublishedPosts,
+ site,
+} from '../../lib/site';
+
+const description = 'Every tag used across the articles archive.';
+
+const posts = await getPublishedPosts();
+const tags = getAllTags(posts);
+
+const tagCounts: Record = {};
+for (const post of posts) {
+ for (const tag of post.data.tags) {
+ tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
+ }
+}
+
+const collectionJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+ name: `${site.name} — Tags`,
+ url: absoluteUrl('/tags/'),
+ description,
+};
+
+const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
+
+const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
+---
+
+
+
+ {posts.length}
+ {posts.length === 1 ? 'article' : 'articles'} across {tags.length}
+ {tags.length === 1 ? 'tag' : 'tags'}.
+
+
+
diff --git a/src/scripts/theme-init.js b/src/scripts/theme-init.js
new file mode 100644
index 0000000..c2c3606
--- /dev/null
+++ b/src/scripts/theme-init.js
@@ -0,0 +1,26 @@
+// FOUC prevention: runs in before paint. Sets the theme on so
+// the page renders with the right colors on first load. The theme switcher
+// button is wired up separately, after it is parsed, in Header.astro.
+//
+// Keep THEME_BG values in sync with --color-bg in global.css. They drive the
+// browser-chrome so it follows the user's manual
+// toggle (the static media-keyed metas only tracked OS preference).
+(function () {
+ document.documentElement.classList.remove('no-js');
+ document.documentElement.classList.add('js');
+
+ var STORAGE_KEY = 'theme';
+ var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
+ var saved = null;
+ try {
+ var value = localStorage.getItem(STORAGE_KEY);
+ if (value === 'light' || value === 'dark') saved = value;
+ } catch (e) {}
+ var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.colorScheme = theme;
+ var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
+ for (var i = 0; i < themeColorMetas.length; i += 1) {
+ themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
+ }
+})();
diff --git a/src/style/fonts.scss b/src/style/fonts.scss
deleted file mode 100644
index 70e4db5..0000000
--- a/src/style/fonts.scss
+++ /dev/null
@@ -1,25 +0,0 @@
-/* comfortaa-regular - latin */
-@font-face {
- font-family: 'Comfortaa';
- font-style: normal;
- font-weight: 400;
- font-display: swap;
- src:
- local(''),
- url('../../static/fonts/comfortaa-v40-latin-regular.woff2') format('woff2'),
- /* Chrome 26+, Opera 23+, Firefox 39+ */
- url('../../static/fonts/comfortaa-v40-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-
-/* open-sans-regular - latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: swap;
- src:
- local(''),
- url('../../static/fonts/open-sans-v34-latin-regular.woff2') format('woff2'),
- /* Chrome 26+, Opera 23+, Firefox 39+ */
- url('../../static/fonts/open-sans-v34-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
diff --git a/src/style/mixins.scss b/src/style/mixins.scss
deleted file mode 100644
index 07e1ec8..0000000
--- a/src/style/mixins.scss
+++ /dev/null
@@ -1,168 +0,0 @@
-@use 'sass:math';
-
-$breakpoint-width: 700px !default;
-
-@mixin on-small-screen() {
- @media (max-width: ($breakpoint-width - 1px)) {
- @content;
- }
-}
-
-@mixin on-large-screen() {
- @media (min-width: $breakpoint-width) {
- @content;
- }
-}
-
-@mixin in-dark-mode() {
- html[theme='dark'] {
- @content;
- }
-}
-
-@mixin image-button($icon-size) {
- display: block;
- box-sizing: content-box;
- cursor: pointer;
-
- &:hover svg {
- transform: translateX(-50%) translateY(-50%) scale(1.15);
- }
-
- svg {
- @include absolute-center;
- @include square($icon-size);
- transition: transform var(--transition-time);
- transform-origin: center center;
- }
-}
-
-@mixin title-fragment-link() {
- position: relative;
-
- &:before {
- content: '#';
- position: absolute;
- left: -0.5ch;
- top: 50%;
- opacity: 0;
- transform: translateX(-100%) translateY(-50%);
- transition: opacity var(--transition-time);
- }
-
- &:hover:before {
- opacity: 0.5;
- }
-}
-
-@mixin center-children() {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-@mixin absolute-center() {
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translateX(-50%) translateY(-50%);
-}
-
-@mixin blurred-background() {
- backdrop-filter: blur(var(--blur-radius));
- -webkit-backdrop-filter: blur(var(--blur-radius));
-
- @supports not (
- (backdrop-filter: blur(var(--blur-radius))) or
- (-webkit-backdrop-filter: blur(var(--blur-radius)))
- ) {
- background-color: var(--card-color);
- }
-}
-
-@mixin square($size) {
- width: $size;
- height: $size;
-}
-
-@mixin title-font() {
- font:
- 400 3rem 'Comfortaa',
- sans-serif;
- color: var(--normal-text-color);
- line-height: 1;
-
- @include on-small-screen {
- font-size: 3rem;
- line-height: 1.1;
- }
-}
-
-@mixin sub-title-font() {
- font:
- 400 1.75rem 'Comfortaa',
- sans-serif;
- color: var(--normal-text-color);
- hyphens: auto;
-}
-
-@mixin main-font() {
- font:
- 400 1.1rem 'Open Sans',
- sans-serif;
- color: var(--normal-text-color);
- line-height: 1.8;
- hyphens: auto;
-}
-
-@mixin special-text-font() {
- font:
- 400 1rem 'Open Sans',
- sans-serif;
- color: var(--special-text-color);
- hyphens: auto;
- font-style: italic;
-}
-
-@mixin link {
- $border-shift: 10px;
- $line-width: 2px;
-
- @include special-text-font();
- cursor: pointer;
- position: relative;
- display: inline-block;
- overflow: hidden;
-
- padding: 0 3px $line-width 0;
-
- &:before,
- &:after {
- content: '';
- display: block;
- position: absolute;
- bottom: 0;
- }
-
- &:before {
- width: calc(100% + #{$border-shift});
- border-bottom: $line-width dashed var(--accent-color);
- transition: transform var(--transition-time);
- }
-
- &:after {
- width: 100%;
- height: $line-width;
- background: linear-gradient(
- 90deg,
- var(--card-color) 0,
- transparent 4px,
- transparent calc(100% - 4px),
- var(--card-color) 100%
- );
- }
-
- &:hover:before {
- transform: translateX(-$border-shift);
- }
-}
diff --git a/src/style/vars.scss b/src/style/vars.scss
deleted file mode 100644
index 8212bbf..0000000
--- a/src/style/vars.scss
+++ /dev/null
@@ -1,44 +0,0 @@
-@use 'mixins' as *;
-
-:root {
- --transition-time: 200ms;
- --transition-time-long: 350ms;
- --line-width: 4px;
- --line-height: 1.125rem;
- --accent-color: #b7455e;
- --sun-color: #f7f78c;
- --very-light-text-color: #ffffff;
- --background: #ffffff;
- --normal-text-color: #31343f;
- --card-color: #ffffff;
- --blurred-card-color: transparent;
- --blur-radius: 12px;
- --special-text-color: var(--accent-color);
- --inset-shadow: inset 0 0 4px 1px rgba(0, 0, 0, 0.05), inset 0 0 5px rgba(0, 0, 0, 0.2);
- --border-radius: 0.85rem;
-
- --large-margin: 4.6rem;
- --normal-margin: 2.8rem;
- --small-margin: 1.4rem;
- --shadow: 0 0 5px 2px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
- --icon-size: 2.8rem;
- --large-icon-size: 3.75rem;
- --body-width: min(80%, 60rem);
-}
-
-@include on-small-screen {
- :root {
- --body-width: 90%;
- --large-margin: 2.8rem;
- --normal-margin: 2rem;
- }
-}
-
-@include in-dark-mode {
- --background: #242638;
- --normal-text-color: #ffffff;
- --card-color: #263551;
- --blurred-card-color: #212f4a77;
- --special-text-color: #ffffff;
- --inset-shadow: inset 0 0 10px 2px rgba(0, 0, 0, 0.3), inset 0 0 4px rgba(0, 0, 0, 0.5);
-}
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..290bbdc
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,1656 @@
+/* =========================================================================
+ Fonts
+ ========================================================================= */
+
+@font-face {
+ font-family: 'Source Sans 3';
+ src: url('/fonts/source-sans-3-latin-variable.woff2') format('woff2-variations');
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'IBM Plex Mono';
+ src: url('/fonts/ibm-plex-mono-latin-400.woff2') format('woff2');
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+}
+
+@layer reset, base, layout, components, utilities, overrides;
+
+/* =========================================================================
+ Tokens — colors, type, space, radius, weights, layout widths
+ ========================================================================= */
+
+:root {
+ color-scheme: light dark;
+
+ --font-sans:
+ 'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ 'Segoe UI', sans-serif;
+ --font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
+
+ /* Palette — light-dark() pairs each token (light, dark) */
+ --color-bg: light-dark(#fbfaf7, #151514);
+ --color-fg: light-dark(#181817, #f1eee7);
+ /* Contrast with --color-bg: light ~5.4:1, dark ~7.1:1 (both clear WCAG AA
+ 4.5:1 for normal text). Darken-on-light / lighten-on-dark slightly from
+ the previous values that fell just below threshold. */
+ --color-muted: light-dark(#3d3b35, #c8c0b3);
+ --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%, white 30%)
+ );
+ --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, #8a8478);
+ --color-rule-strong: light-dark(#4a4340, #d0c5b7);
+ --color-code-bg: light-dark(#efede6, #2f2c27);
+ --color-callout-bg: light-dark(#f4f1e8, #211f1c);
+ --color-selection-bg: light-dark(#ecddd0, #4a3a2e);
+
+ --theme-switcher-track: var(--color-rule-medium);
+ --theme-switcher-icon-light: #f0e2b6;
+ --theme-switcher-icon-dark: #f1eee7;
+
+ /* Typography */
+ --fs-xs: 0.75rem;
+ --fs-sm: 0.8125rem;
+ --fs-caption: 0.875rem;
+ --fs-base: 1rem;
+ --fs-body: 1.1875rem;
+ --fs-lg: 1.25rem;
+ --fs-xl: 1.75rem;
+ --fs-3xl: clamp(2rem, 1.5rem + 1.8vw, 3rem);
+ --fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem);
+
+ --leading-tight: 1.18;
+ --leading-snug: 1.35;
+ --leading-prose: 1.6;
+
+ --weight-regular: 400;
+ --weight-medium: 500;
+ --weight-semibold: 650;
+ --weight-bold: 700;
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+
+ /* Radius */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-pill: 999px;
+
+ /* Layout */
+ --measure: 36rem;
+ --measure-wide: 56rem;
+ --page: 72rem;
+ --gutter: clamp(20px, 4vw, 32px);
+}
+
+:root[data-theme='light'] {
+ color-scheme: light;
+}
+
+:root[data-theme='dark'] {
+ color-scheme: dark;
+}
+
+/* =========================================================================
+ Reset
+ ========================================================================= */
+
+@layer reset {
+ *,
+ *::before,
+ *::after {
+ box-sizing: border-box;
+ }
+
+ body,
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ p,
+ figure,
+ blockquote,
+ dl,
+ dd {
+ margin: 0;
+ }
+
+ ul[role='list'],
+ ol[role='list'] {
+ list-style: none;
+ padding: 0;
+ }
+
+ button,
+ input,
+ textarea,
+ select {
+ font: inherit;
+ }
+
+ img,
+ video,
+ canvas,
+ svg {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ }
+}
+
+/* =========================================================================
+ Base
+ ========================================================================= */
+
+@layer base {
+ html {
+ background: var(--color-bg);
+ scroll-behavior: smooth;
+ }
+
+ body {
+ background: var(--color-bg);
+ color: var(--color-fg);
+ font-family: var(--font-sans);
+ font-size: var(--fs-body);
+ line-height: var(--leading-snug);
+ transition:
+ background-color 200ms ease,
+ color 200ms ease;
+ }
+
+ address {
+ font-style: normal;
+ }
+
+ a {
+ color: var(--color-link);
+ text-decoration-thickness: 0.08em;
+ text-underline-offset: 0.18em;
+ transition: color 150ms ease;
+ }
+
+ a:hover {
+ color: var(--color-link-hover);
+ }
+
+ a:active {
+ opacity: 0.85;
+ transition: opacity 80ms ease;
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 3px;
+ }
+
+ main:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: -2px;
+ }
+
+ ::selection {
+ background: var(--color-selection-bg);
+ color: var(--color-fg);
+ }
+}
+
+/* =========================================================================
+ Utilities
+ ========================================================================= */
+
+@layer utilities {
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+ }
+}
+
+/* =========================================================================
+ Layout — site shell, header, footer, skip link
+ ========================================================================= */
+
+@layer layout {
+ :where(.site-header, .site-footer, .home-intro, .home-section, .page-shell, .post) {
+ width: min(100% - 2 * var(--gutter), var(--page));
+ margin-inline: auto;
+ }
+
+ .post {
+ width: min(100% - 2 * var(--gutter), var(--measure-wide));
+ }
+
+ .skip-link {
+ position: absolute;
+ left: calc(var(--gutter) + env(safe-area-inset-left));
+ top: calc(var(--space-3) + env(safe-area-inset-top));
+ z-index: 10;
+ transform: translateY(-150%);
+ background: var(--color-fg);
+ color: var(--color-bg);
+ padding: var(--space-3) var(--space-4);
+ min-block-size: 44px;
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+ transition: transform 150ms ease;
+ }
+
+ .skip-link:focus {
+ transform: translateY(0);
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+ }
+
+ .site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+ padding-block: var(--space-8) var(--space-6);
+ border-bottom: 1px solid var(--color-rule);
+ }
+
+ .site-title {
+ color: var(--color-fg);
+ font-size: var(--fs-lg);
+ font-weight: var(--weight-bold);
+ letter-spacing: -0.005em;
+ text-decoration: none;
+ }
+
+ .header-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-6);
+ min-width: 0;
+ }
+
+ .site-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-5);
+ }
+
+ .site-nav a,
+ .site-footer a {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-muted);
+ text-decoration: none;
+ }
+
+ .site-nav a:hover,
+ .site-footer a:hover {
+ color: var(--color-fg);
+ text-decoration: underline;
+ text-underline-offset: 0.25em;
+ }
+
+ .site-nav a[aria-current='page'],
+ .site-nav a[aria-current='true'] {
+ color: var(--color-fg);
+ }
+
+ .site-footer {
+ border-top: 1px solid var(--color-rule);
+ margin-top: var(--space-16);
+ padding-block: var(--space-8) var(--space-10);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ }
+
+ .footer-links,
+ .footer-meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-5);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ color: var(--color-muted);
+ 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));
+ }
+
+ .footer-contact {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-5);
+ }
+
+ /* Page header (shared by .home-intro, .page-header, .post-header) */
+ .home-intro {
+ max-width: var(--measure-wide);
+ padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
+ }
+
+ .home-intro h1,
+ .page-header h1,
+ .post-header h1 {
+ max-width: var(--measure-wide);
+ color: var(--color-fg);
+ font-size: var(--fs-3xl);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+ text-wrap: balance;
+ }
+
+ .home-intro p:not(.eyebrow),
+ .page-header p,
+ .dek {
+ max-width: var(--measure);
+ color: var(--color-muted);
+ font-size: var(--fs-dek);
+ }
+
+ .page-header,
+ .post-header {
+ max-width: var(--measure-wide);
+ padding-block: var(--space-10) var(--space-6);
+ }
+
+ .post-header .dek {
+ margin-block: var(--space-4) 0;
+ }
+
+ .post-meta {
+ margin-block: var(--space-3) 0;
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ line-height: 1.4;
+ }
+
+ .home-section,
+ .page-shell {
+ margin-top: var(--space-8);
+ }
+}
+
+/* =========================================================================
+ Components
+ ========================================================================= */
+
+@layer components {
+ /* -- Eyebrow ---------------------------------------------------------- */
+
+ .eyebrow {
+ margin: 0 0 var(--space-3);
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ line-height: 1.4;
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+
+ /* -- Section heading -------------------------------------------------- */
+
+ .section-heading {
+ width: 100%;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-4);
+ padding-top: var(--space-6);
+ }
+
+ :where(.section-heading, .archive-year, .project-section, .facts, .at-a-glance) h2 {
+ font-size: var(--fs-lg);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-snug);
+ }
+
+ .section-heading a {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ text-decoration: none;
+ }
+
+ .section-heading a:hover {
+ color: var(--color-fg);
+ text-decoration: underline;
+ text-underline-offset: 0.25em;
+ }
+
+ /* -- Breadcrumbs ------------------------------------------------------ */
+
+ .breadcrumbs {
+ margin: 0 0 var(--space-3);
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ }
+
+ .breadcrumbs li {
+ display: inline-flex;
+ align-items: baseline;
+ min-width: 0;
+ }
+
+ .breadcrumbs li:not(:last-child)::after {
+ content: '›';
+ margin-left: var(--space-2);
+ color: var(--color-rule-medium);
+ flex: none;
+ }
+
+ .breadcrumbs a {
+ color: var(--color-muted);
+ text-decoration: none;
+ }
+
+ .breadcrumbs a:hover {
+ color: var(--color-fg);
+ text-decoration: underline;
+ text-underline-offset: 0.25em;
+ }
+
+ .breadcrumbs [aria-current='page'] {
+ color: var(--color-fg);
+ }
+
+ /* -- Tag list + filter ------------------------------------------------ */
+
+ .tag-cloud {
+ margin-top: var(--space-2);
+ }
+
+ .tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-2);
+ margin: var(--space-2) 0 0;
+ padding: 0;
+ list-style: none;
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ }
+
+ .tag-list a {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ padding-inline: var(--space-2);
+ margin-inline: calc(-1 * var(--space-2));
+ color: var(--color-muted);
+ text-decoration: none;
+ }
+
+ .tag-list a::before {
+ content: '#';
+ color: var(--color-rule-medium);
+ }
+
+ .tag-list .tag-more::before {
+ content: none;
+ }
+
+ .tag-list .tag-count {
+ margin-inline-start: 0.35em;
+ padding: 0 0.4em;
+ border-radius: var(--radius-pill);
+ background: var(--color-code-bg);
+ color: var(--color-fg);
+ font-size: var(--fs-caption);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .tag-list a:hover,
+ .tag-list a[aria-current='page'] {
+ color: var(--color-fg);
+ }
+
+ .tag-filter {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-4);
+ margin: 0 0 var(--space-8);
+ padding-block: var(--space-3);
+ border-block: 1px solid var(--color-rule);
+ }
+
+ .tag-filter > span {
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ }
+
+ /* -- Lists: article + project ---------------------------------------- */
+
+ .article-list,
+ .project-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ }
+
+ .project-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
+ gap: var(--space-4);
+ align-items: stretch;
+ }
+
+ .article-list > li {
+ display: grid;
+ grid-template-columns: 4.5rem minmax(0, 1fr) minmax(6rem, 8rem);
+ grid-template-areas: 'date content thumb';
+ align-items: center;
+ gap: var(--space-4);
+ padding-block: var(--space-6);
+ border-top: 1px solid var(--color-rule);
+ }
+
+ .article-list > li:first-child {
+ border-top: 0;
+ }
+
+ .article-list time {
+ grid-area: date;
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ text-align: end;
+ }
+
+ .article-list > li > article {
+ grid-area: content;
+ min-width: 0;
+ padding-right: var(--space-3);
+ }
+
+ .article-list h3,
+ .project-list h3 {
+ font-size: var(--fs-base);
+ line-height: var(--leading-snug);
+ }
+
+ .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;
+ }
+
+ .article-list .entry-title:hover,
+ .project-list h3 a:hover {
+ color: var(--color-link-hover);
+ }
+
+ .article-list p,
+ .project-list p {
+ margin: var(--space-1) 0 0;
+ color: var(--color-muted);
+ }
+
+ /* -- Thumbnail -------------------------------------------------------- */
+
+ .entry-thumbnail {
+ display: block;
+ overflow: hidden;
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-md);
+ background: var(--color-code-bg);
+ aspect-ratio: 4 / 3;
+ transition: border-color 150ms ease;
+ }
+
+ .entry-thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 300ms ease;
+ }
+
+ a.entry-thumbnail {
+ text-decoration: none;
+ }
+
+ a.entry-thumbnail:hover,
+ a.entry-thumbnail:focus-visible {
+ border-color: var(--color-rule-strong);
+ }
+
+ .article-list > li:hover .entry-thumbnail img,
+ .article-list > li:focus-within .entry-thumbnail img {
+ transform: scale(1.02);
+ }
+
+ .article-list > li:focus-within .entry-thumbnail {
+ border-color: var(--color-rule-strong);
+ }
+
+ .article-thumbnail {
+ grid-area: thumb;
+ align-self: center;
+ }
+
+ /* -- Project card ----------------------------------------------------- */
+
+ .project-card {
+ --project-thumb-size: clamp(7rem, 18vw, 9.5rem);
+
+ display: grid;
+ grid-template-columns: var(--project-thumb-size) minmax(0, 1fr);
+ grid-template-areas: 'thumb summary';
+ min-height: var(--project-thumb-size);
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-lg);
+ background: var(--color-bg);
+ transition: border-color 150ms ease;
+ }
+
+ .project-card:hover,
+ .project-card:focus-within,
+ .project-card:target {
+ border-color: var(--color-rule-strong);
+ }
+
+ .project-card .project-thumbnail {
+ grid-area: thumb;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ border-right: 1px solid var(--color-rule);
+ border-radius: 0;
+ aspect-ratio: 1 / 1;
+ }
+
+ .project-card .project-thumbnail img {
+ transition: transform 300ms ease;
+ }
+
+ .project-card:hover .project-thumbnail img,
+ .project-card:focus-within .project-thumbnail img {
+ transform: scale(1.02);
+ }
+
+ .project-card__summary {
+ grid-area: summary;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ min-width: 0;
+ padding: var(--space-3) var(--space-4);
+ }
+
+ .project-card p {
+ margin: 0;
+ }
+
+ .project-card .project-description {
+ color: var(--color-fg);
+ font-size: var(--fs-base);
+ line-height: var(--leading-snug);
+ }
+
+ .project-card .project-meta {
+ color: var(--color-muted);
+ font-size: var(--fs-sm);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .project-essay-badge {
+ display: inline-flex;
+ align-items: center;
+ margin-left: var(--space-2);
+ padding: 0.1em 0.5em;
+ background: var(--color-callout-bg);
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-pill);
+ color: var(--color-muted);
+ font-size: var(--fs-xs);
+ font-weight: var(--weight-medium);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ vertical-align: 0.15em;
+ }
+
+ /* -- Project links ---------------------------------------------------- */
+
+ .project-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-3);
+ margin: var(--space-3) 0 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .project-links a {
+ min-height: 44px;
+ min-width: 44px;
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-link);
+ }
+
+ .project-links a:hover,
+ .project-links a:focus-visible {
+ color: var(--color-link-hover);
+ }
+
+ .project-links a .download-indicator {
+ margin-left: 0.25em;
+ color: var(--color-muted);
+ font-size: 0.85em;
+ }
+
+ .project-card .project-links {
+ gap: 0 var(--space-3);
+ margin-top: auto;
+ font-size: var(--fs-caption);
+ }
+
+ .project-card .project-links a {
+ min-height: 44px;
+ }
+
+ /* -- Post layout ------------------------------------------------------ */
+
+ .post > .prose,
+ .post > .post-media,
+ .facts {
+ max-width: var(--measure);
+ margin-inline: auto;
+ }
+
+ .post > .at-a-glance,
+ .post > .post-thumbnail,
+ .post > .post-gallery,
+ .post-nav {
+ max-width: var(--measure-wide);
+ margin-inline: auto;
+ }
+
+ .archive-year,
+ .project-section {
+ width: 100%;
+ }
+
+ .archive-year + .archive-year,
+ .project-section + .project-section {
+ margin-top: var(--space-10);
+ }
+
+ .archive-year h2,
+ .project-section h2 {
+ margin-bottom: var(--space-3);
+ }
+
+ .about-section {
+ width: 100%;
+ margin-top: var(--space-10);
+ }
+
+ .about-section.facts {
+ max-width: none;
+ }
+
+ .about-section.facts > .prose {
+ 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);
+ }
+
+ .post-thumbnail {
+ margin: 0;
+ }
+
+ .post-thumbnail img {
+ width: 100%;
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-md);
+ background: var(--color-code-bg);
+ }
+
+ /* -- Prose ------------------------------------------------------------ */
+
+ .prose {
+ max-inline-size: var(--measure);
+ line-height: var(--leading-prose);
+ }
+
+ .prose > * + * {
+ margin-top: 1.05em;
+ }
+
+ .prose > h2:first-child,
+ .prose > h3:first-child {
+ margin-top: 0;
+ }
+
+ .prose p {
+ text-wrap: pretty;
+ }
+
+ .prose h2,
+ .prose h3 {
+ position: relative;
+ color: var(--color-fg);
+ line-height: var(--leading-tight);
+ scroll-margin-top: var(--space-8);
+ }
+
+ .prose h2 {
+ margin-top: var(--space-12);
+ font-size: var(--fs-xl);
+ font-weight: var(--weight-semibold);
+ }
+
+ .prose h3 {
+ margin-top: var(--space-8);
+ font-size: var(--fs-lg);
+ font-weight: var(--weight-semibold);
+ }
+
+ .prose h2:target,
+ .prose h3:target {
+ background: var(--color-callout-bg);
+ border-inline-start: 2px solid var(--color-accent);
+ padding-inline-start: var(--space-3);
+ margin-inline-start: calc(-1 * var(--space-3) - 2px);
+ }
+
+ .prose .heading-anchor {
+ margin-inline-start: 0.4em;
+ color: var(--color-muted);
+ font-weight: var(--weight-regular);
+ font-size: 0.85em;
+ text-decoration: none;
+ opacity: 0;
+ transition: opacity 150ms ease;
+ }
+
+ .prose .heading-anchor:focus-visible {
+ opacity: 1;
+ text-decoration: underline;
+ }
+
+ .prose .heading-anchor::before {
+ content: '#';
+ }
+
+ .prose h2:hover .heading-anchor,
+ .prose h3:hover .heading-anchor,
+ .prose .heading-anchor:hover,
+ .prose .heading-anchor:focus-visible {
+ opacity: 1;
+ }
+
+ @media (hover: none) {
+ .prose .heading-anchor {
+ opacity: 1;
+ }
+ }
+
+ .prose ul,
+ .prose ol {
+ padding-inline-start: 1.25rem;
+ }
+
+ .prose li + li {
+ margin-top: var(--space-1);
+ }
+
+ .prose strong {
+ font-weight: var(--weight-bold);
+ color: var(--color-fg);
+ }
+
+ .prose em {
+ font-style: italic;
+ }
+
+ .prose blockquote {
+ margin-inline: 0;
+ padding: var(--space-2) var(--space-4);
+ border-inline-start: 3px solid var(--color-rule-medium);
+ background: var(--color-callout-bg);
+ color: var(--color-muted);
+ font-style: italic;
+ }
+
+ .prose blockquote > * + * {
+ margin-top: 0.6em;
+ }
+
+ .prose hr {
+ margin-block: var(--space-8);
+ border: 0;
+ height: 1px;
+ background: var(--color-rule);
+ }
+
+ .prose table {
+ width: 100%;
+ max-width: 100%;
+ border-collapse: collapse;
+ font-size: 0.95em;
+ display: block;
+ overflow-x: auto;
+ }
+
+ .prose thead {
+ background: var(--color-code-bg);
+ text-align: start;
+ }
+
+ .prose th,
+ .prose td {
+ padding: var(--space-2) var(--space-3);
+ border-bottom: 1px solid var(--color-rule);
+ vertical-align: top;
+ }
+
+ .prose th {
+ font-weight: var(--weight-semibold);
+ }
+
+ .prose code {
+ font-family: var(--font-mono);
+ font-size: 0.88em;
+ background: var(--color-code-bg);
+ border-radius: var(--radius-sm);
+ padding: 0.08em 0.25em;
+ }
+
+ .prose pre {
+ max-width: 100%;
+ overflow-x: auto;
+ scrollbar-gutter: stable;
+ padding: var(--space-4);
+ background: var(--color-code-bg);
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-md);
+ line-height: 1.55;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-rule-medium) var(--color-code-bg);
+ }
+
+ .prose pre::-webkit-scrollbar {
+ height: 8px;
+ }
+
+ .prose pre::-webkit-scrollbar-track {
+ background: var(--color-code-bg);
+ }
+
+ .prose pre::-webkit-scrollbar-thumb {
+ background: var(--color-rule-medium);
+ border-radius: var(--radius-sm);
+ }
+
+ .prose pre code {
+ background: transparent;
+ padding: 0;
+ }
+
+ /* Shiki dual-theme: defaultColor: false emits --shiki-light / --shiki-dark
+ vars on every token; light-dark() picks the active variant from
+ color-scheme on :root. */
+ .prose pre.astro-code,
+ .prose pre.astro-code code,
+ .prose pre.astro-code span {
+ color: light-dark(var(--shiki-light), var(--shiki-dark));
+ background-color: light-dark(
+ var(--shiki-light-bg, var(--color-code-bg)),
+ var(--shiki-dark-bg, var(--color-code-bg))
+ );
+ }
+
+ /* -- At-a-glance + facts --------------------------------------------- */
+
+ .at-a-glance {
+ margin-top: var(--space-8);
+ padding-block: var(--space-5);
+ border-block: 1px solid var(--color-rule);
+ }
+
+ .at-a-glance dl,
+ .facts dl {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin: var(--space-4) 0 0;
+ }
+
+ .at-a-glance__row,
+ .facts dl > div {
+ display: grid;
+ grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr);
+ gap: var(--space-4);
+ }
+
+ .at-a-glance dt,
+ .facts dt {
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ }
+
+ .facts {
+ margin-top: var(--space-8);
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--color-rule);
+ }
+
+ /* Float .at-a-glance beside .prose at wide viewports. */
+ @media (min-width: 1100px) {
+ .post {
+ width: min(100% - 2 * var(--gutter), var(--page));
+ display: grid;
+ grid-template-columns: minmax(0, var(--measure)) minmax(14rem, 18rem);
+ column-gap: var(--space-10);
+ align-items: start;
+ }
+
+ .post > .post-header,
+ .post > .post-thumbnail,
+ .post > .post-gallery,
+ .post > .post-media,
+ .post > .post-nav {
+ grid-column: 1 / -1;
+ max-width: var(--measure-wide);
+ margin-inline: auto;
+ width: 100%;
+ }
+
+ .post > .prose {
+ grid-column: 1;
+ margin-inline: 0;
+ margin-top: var(--space-8);
+ }
+
+ .post > .at-a-glance {
+ grid-column: 2;
+ grid-row: span 5;
+ margin-top: var(--space-8);
+ position: sticky;
+ top: var(--space-6);
+ align-self: start;
+ }
+ }
+
+ /* -- Post media ------------------------------------------------------- */
+
+ .post-media {
+ max-inline-size: min(100%, var(--measure-wide));
+ margin: var(--space-8) 0 0;
+ }
+
+ .post-media img,
+ .post-media video {
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-md);
+ background: var(--color-code-bg);
+ width: 100%;
+ }
+
+ .post-media figcaption,
+ .media-transcript {
+ max-width: var(--measure);
+ margin-top: var(--space-2);
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ line-height: 1.45;
+ }
+
+ .media-transcript strong {
+ color: var(--color-fg);
+ font-weight: var(--weight-semibold);
+ }
+
+ /* -- Post nav --------------------------------------------------------- */
+
+ .post-nav {
+ margin-top: var(--space-12);
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--color-rule);
+ }
+
+ .post-nav__list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
+ gap: var(--space-4);
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ .post-nav__next {
+ justify-self: end;
+ }
+
+ .post-nav a {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--color-rule);
+ border-radius: var(--radius-md);
+ color: var(--color-fg);
+ text-decoration: none;
+ min-height: 44px;
+ transition: border-color 150ms ease;
+ }
+
+ .post-nav a:hover,
+ .post-nav a:focus-visible {
+ border-color: var(--color-rule-strong);
+ }
+
+ .post-nav a.next {
+ text-align: end;
+ }
+
+ .post-nav .post-nav__label {
+ color: var(--color-muted);
+ font-size: var(--fs-caption);
+ }
+
+ .post-nav .post-nav__title {
+ color: var(--color-fg);
+ font-weight: var(--weight-semibold);
+ }
+
+ /* -- Post TOC --------------------------------------------------------- */
+
+ .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;
+ }
+
+ .project-card h3 a {
+ min-height: 44px;
+ min-width: 44px;
+ }
+
+ .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);
+ }
+
+ .post > .post-gallery {
+ width: 100%;
+ }
+
+ .post-gallery .post-media {
+ max-inline-size: 100%;
+ margin: 0;
+ }
+
+ /* -- External link affordance ----------------------------------------- */
+
+ .external-link-icon {
+ display: inline-block;
+ margin-inline-start: 0.25em;
+ vertical-align: -0.125em;
+ opacity: 0.85;
+ }
+
+ /* -- Related ---------------------------------------------------------- */
+
+ .related-posts {
+ margin-top: var(--space-12);
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--color-rule);
+ }
+
+ .related-posts h2 {
+ font-size: var(--fs-lg);
+ font-weight: var(--weight-semibold);
+ margin-bottom: var(--space-4);
+ }
+
+ /* -- Empty state (e.g. 404) ----------------------------------------- */
+
+ .empty-state {
+ max-width: var(--measure);
+ padding-block: var(--space-6);
+ }
+
+ /* -- Theme switcher --------------------------------------------------- */
+
+ .theme-switcher {
+ --switcher-w: 2.75rem;
+ --switcher-h: 1.5rem;
+ --switcher-icon: 1.05rem;
+ --switcher-mask: 0.78rem;
+ --switcher-gap: 0.22rem;
+ --switcher-mask-offset: 0.32rem;
+
+ position: relative;
+ display: inline-block;
+ width: var(--switcher-w);
+ height: var(--switcher-h);
+ /* Adjacent header targets remain at least 44px apart while the visual
+ track stays compact. */
+ margin: max(var(--space-2), calc((44px - var(--switcher-h)) / 2)) 0;
+ overflow: hidden;
+ border: 1px solid var(--color-rule-medium);
+ border-radius: var(--radius-pill);
+ appearance: none;
+ cursor: pointer;
+ background: var(--theme-switcher-track);
+ color: inherit;
+ transition:
+ background-color 200ms ease,
+ border-color 150ms ease;
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 18%);
+ }
+
+ .theme-switcher:hover {
+ border-color: var(--color-rule-strong);
+ }
+
+ .no-js .theme-switcher {
+ display: none !important;
+ }
+
+ .theme-switcher::before,
+ .theme-switcher::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ border-radius: var(--radius-pill);
+ transition:
+ transform 180ms ease,
+ background-color 180ms ease;
+ }
+
+ .theme-switcher::before {
+ z-index: 1;
+ width: var(--switcher-icon);
+ height: var(--switcher-icon);
+ }
+
+ .theme-switcher::after {
+ z-index: 2;
+ width: var(--switcher-mask);
+ height: var(--switcher-mask);
+ }
+
+ .theme-switcher[aria-pressed='false']::before {
+ transform: translateY(-50%)
+ translateX(calc(var(--switcher-w) - var(--switcher-icon) - var(--switcher-gap)));
+ background-color: var(--theme-switcher-icon-light);
+ }
+
+ .theme-switcher[aria-pressed='false']::after {
+ transform: translateY(-50%) translateX(var(--switcher-w));
+ }
+
+ .theme-switcher[aria-pressed='true']::before {
+ transform: translateY(-50%) translateX(var(--switcher-gap));
+ background-color: var(--theme-switcher-icon-dark);
+ }
+
+ .theme-switcher[aria-pressed='true']::after {
+ transform: translateY(-50%)
+ translateX(calc(var(--switcher-gap) + var(--switcher-mask-offset)));
+ background-color: var(--theme-switcher-track);
+ }
+
+ /* High-contrast / forced-colors fallback: render a text label. */
+ @media (forced-colors: active) {
+ .theme-switcher {
+ width: auto;
+ height: auto;
+ min-block-size: 44px;
+ min-inline-size: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-1) var(--space-2);
+ overflow: visible;
+ background: ButtonFace;
+ color: ButtonText;
+ border: 1px solid ButtonBorder;
+ box-shadow: none;
+ }
+
+ .theme-switcher::after {
+ content: none;
+ }
+
+ .theme-switcher::before {
+ content: 'Light';
+ position: static;
+ width: auto;
+ height: auto;
+ transform: none;
+ background: transparent;
+ border-radius: 0;
+ }
+
+ .theme-switcher[aria-pressed='true']::before {
+ content: 'Dark';
+ transform: none;
+ }
+ }
+}
+
+/* =========================================================================
+ Responsive — tablet + mobile breakpoints
+ ========================================================================= */
+
+@layer overrides {
+ /* Tablet */
+ @media (max-width: 960px) {
+ .article-list > li {
+ grid-template-columns: 5rem minmax(0, 1fr) 7rem;
+ gap: var(--space-4);
+ padding-block: var(--space-5);
+ }
+ }
+
+ /* Mobile */
+ @media (max-width: 700px) {
+ .site-header {
+ padding-block: var(--space-6) var(--space-4);
+ }
+
+ .home-intro {
+ padding-block: var(--space-8) var(--space-6);
+ }
+
+ .at-a-glance__row,
+ .facts dl > div {
+ grid-template-columns: 1fr;
+ gap: var(--space-1);
+ }
+
+ .article-list > li {
+ grid-template-columns: clamp(4rem, 22vw, 5rem) minmax(0, 1fr);
+ grid-template-areas:
+ 'thumb content'
+ 'date content';
+ gap: var(--space-2) var(--space-3);
+ padding-block: var(--space-4);
+ }
+
+ .article-list > li > article {
+ padding-right: 0;
+ }
+
+ .article-list time {
+ text-align: start;
+ white-space: nowrap;
+ }
+
+ .article-list .entry-thumbnail {
+ aspect-ratio: 1;
+ }
+
+ .project-card {
+ --project-thumb-size: 7rem;
+
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ 'thumb'
+ 'summary';
+ }
+
+ .project-card .project-thumbnail {
+ height: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--color-rule);
+ aspect-ratio: 16 / 9;
+ }
+
+ .project-card .project-meta {
+ -webkit-line-clamp: 3;
+ }
+
+ .project-card__summary {
+ padding: var(--space-2) var(--space-3);
+ }
+
+ .page-header,
+ .post-header {
+ padding-block: var(--space-8) var(--space-5);
+ }
+
+ .post > .prose {
+ margin-top: var(--space-6);
+ }
+
+ :focus-visible {
+ outline-offset: 1px;
+ }
+
+ /* Preserve the inset outline on so the post-skip-link focus ring
+ doesn't escape its container. Repeated here because this layer wins
+ over the base rule regardless of selector specificity. */
+ main:focus-visible {
+ outline-offset: -2px;
+ }
+
+ .post-nav__list {
+ grid-template-columns: 1fr;
+ }
+
+ .post-nav__next {
+ justify-self: stretch;
+ }
+
+ .post-nav a.next {
+ text-align: start;
+ }
+
+ .header-actions {
+ justify-content: space-between;
+ width: 100%;
+ gap: var(--space-2) var(--space-4);
+ }
+
+ .site-nav {
+ gap: var(--space-1) var(--space-6);
+ }
+ }
+
+ /* Reduced motion */
+ @media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+
+ ::view-transition-group(*),
+ ::view-transition-old(*),
+ ::view-transition-new(*) {
+ animation: none;
+ }
+ }
+
+ /* Print */
+ @media print {
+ :root {
+ --color-bg: #fff;
+ --color-fg: #000;
+ --color-muted: #333;
+ --color-link: #000;
+ --color-link-hover: #000;
+ --color-accent: #000;
+ --color-rule: #999;
+ --color-rule-medium: #777;
+ --color-rule-strong: #333;
+ --color-code-bg: #f4f4f4;
+ --color-callout-bg: #f8f8f8;
+ }
+
+ body {
+ font-size: 11pt;
+ line-height: 1.4;
+ }
+
+ *,
+ *::before,
+ *::after {
+ print-color-adjust: economy;
+ }
+
+ .site-header,
+ .site-footer,
+ .skip-link,
+ .theme-switcher,
+ .tag-filter,
+ .post-nav,
+ .related-posts,
+ .heading-anchor {
+ display: none;
+ }
+
+ main {
+ padding: 0;
+ }
+
+ a,
+ a:visited {
+ color: var(--color-fg);
+ text-decoration: underline;
+ }
+
+ .prose a[href]::after {
+ content: ' (' attr(href) ')';
+ font-size: 0.85em;
+ color: var(--color-muted);
+ }
+
+ .prose a[href^='#']::after,
+ .prose a[href^='/']::after {
+ content: '';
+ }
+
+ .prose pre,
+ .prose code,
+ .post-thumbnail img,
+ .post-media img {
+ page-break-inside: avoid;
+ }
+
+ .prose h2,
+ .prose h3 {
+ page-break-after: avoid;
+ }
+ }
+}
diff --git a/src/types/html.ts b/src/types/html.ts
deleted file mode 100644
index 52c482a..0000000
--- a/src/types/html.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type html = string;
diff --git a/src/types/responsive-image.ts b/src/types/responsive-image.ts
deleted file mode 100644
index 489b9a1..0000000
--- a/src/types/responsive-image.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { url } from './url';
-
-export type ResponsiveImage = {
- srcSet: string;
- src: url;
- placeholder: string;
- width: number;
- height: number;
- images: Array<{
- path: url;
- width: number;
- height: number;
- }>;
-};
diff --git a/src/types/url.ts b/src/types/url.ts
deleted file mode 100644
index ca13e58..0000000
--- a/src/types/url.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type url = string;