More AI
This commit is contained in:
parent
f3fc893675
commit
bb5b4c4cf3
43 changed files with 585 additions and 524 deletions
|
|
@ -35,8 +35,13 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build & QA
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
npm run qa
|
||||
|
||||
- name: Copy build to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
|
|
|||
|
|
@ -83,10 +83,11 @@ export default defineConfig({
|
|||
behavior: 'append',
|
||||
properties: {
|
||||
className: ['heading-anchor'],
|
||||
'aria-hidden': 'true',
|
||||
tabIndex: -1,
|
||||
ariaLabel: 'Permalink',
|
||||
},
|
||||
content: { type: 'text', value: '#' },
|
||||
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
|
||||
// when astro:content extracts heading.text from the rendered HTML.
|
||||
content: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@
|
|||
"typecheck": "astro check",
|
||||
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"format:check": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||
"qa:overflow": "node scripts/check-overflow.mjs",
|
||||
"qa": "npm run build && npm run qa:no-js && npm run qa:overflow"
|
||||
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:no-js && npm run qa:overflow"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ const { posts, showYear = true, currentTag } = Astro.props;
|
|||
|
||||
<ol class="article-list">
|
||||
{
|
||||
posts.map((post) => {
|
||||
posts.map((post, index) => {
|
||||
const href = articlePath(post);
|
||||
const isFirst = index === 0;
|
||||
return (
|
||||
<li>
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
|
|
@ -36,6 +37,8 @@ const { posts, showYear = true, currentTag } = Astro.props;
|
|||
class="article-thumbnail"
|
||||
widths={[120, 180, 240, 320, 480]}
|
||||
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem"
|
||||
loading={isFirst ? 'eager' : 'lazy'}
|
||||
fetchpriority={isFirst ? 'high' : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const {
|
|||
} = Astro.props;
|
||||
|
||||
const Tag = href ? 'a' : 'div';
|
||||
const resolvedFallback: FallbackFormat =
|
||||
fallbackFormat ?? (src.format === 'png' ? 'png' : 'jpg');
|
||||
// Listing thumbnails are screenshots with no required transparency; force JPG
|
||||
// fallback to avoid shipping multi-hundred-KB PNG derivatives.
|
||||
const resolvedFallback: FallbackFormat = fallbackFormat ?? 'jpg';
|
||||
const isDecorativeLink = Boolean(href) && decorative;
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,8 @@ import { navItems, site } from '../lib/site';
|
|||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
|
||||
// derive footer items locally. Footer mirrors Header (Home filtered out) and
|
||||
// adds Tags + RSS.
|
||||
const footerNavItems = [
|
||||
...navItems.filter((item) => item.href !== '/'),
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
{ href: '/rss.xml', label: 'RSS' },
|
||||
];
|
||||
// Footer shows all nav items except Home (which is implicit via the site title).
|
||||
const footerNavItems = navItems.filter((item) => item.href !== '/');
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
|
|
@ -25,19 +19,21 @@ const footerNavItems = [
|
|||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<address>
|
||||
<ul class="footer-meta">
|
||||
<li><span>© {year} {site.name}</span></li>
|
||||
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
||||
<li>
|
||||
<a href={site.cv} rel="noopener noreferrer">CV</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.github} rel="noopener noreferrer me">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.linkedin} rel="noopener noreferrer me">LinkedIn</a>
|
||||
</li>
|
||||
</ul>
|
||||
</address>
|
||||
<ul class="footer-meta">
|
||||
<li><span>© {year} {site.name}</span></li>
|
||||
<li>
|
||||
<address>
|
||||
<a href={`mailto:${site.email}`}>Email</a>
|
||||
</address>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -3,28 +3,28 @@ import { navItems, site } from '../lib/site';
|
|||
|
||||
const current = Astro.url.pathname;
|
||||
|
||||
function isCurrent(href: string) {
|
||||
if (href === '/') return current === '/';
|
||||
return current.startsWith(href);
|
||||
// Exact match for the current page; section match (descendant URLs) for
|
||||
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||
// `"true"` indicates an ancestor section.
|
||||
function currentState(href: string): 'page' | 'true' | undefined {
|
||||
if (current === href) return 'page';
|
||||
if (href !== '/' && current.startsWith(href)) return 'true';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
|
||||
// derive header items locally. Header shows nav (minus Home) + Tags. RSS lives
|
||||
// in the header as a dedicated icon link.
|
||||
const headerNavItems = [
|
||||
...navItems.filter((item) => item.href !== '/'),
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
];
|
||||
// Header shows nav items except Home and footer-only entries. RSS lives as a
|
||||
// dedicated icon link to the right of the nav.
|
||||
const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly);
|
||||
---
|
||||
|
||||
<a class="skip-link" href="#content">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<a class="site-title" href="/">{site.name}</a>
|
||||
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
|
||||
<div class="header-actions">
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{
|
||||
headerNavItems.map((item) => (
|
||||
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
||||
<a href={item.href} aria-current={currentState(item.href)}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
|
|
@ -112,8 +112,15 @@ const headerNavItems = [
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
color: inherit;
|
||||
line-height: 0;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
.rss-link:hover,
|
||||
.rss-link:focus-visible {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
.rss-icon {
|
||||
display: block;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import PostMediaFigure from './PostMediaFigure.astro';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
|
||||
|
|
@ -10,76 +10,21 @@ interface Props {
|
|||
|
||||
const { items } = Astro.props;
|
||||
|
||||
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
||||
format === 'png' ? 'png' : 'jpg';
|
||||
// Wrap in a gallery `<ul>` when there's more than one item; otherwise the
|
||||
// figures sit directly in the post flow.
|
||||
const isGallery = items.length > 1;
|
||||
---
|
||||
|
||||
{
|
||||
items.length > 1 ? (
|
||||
isGallery ? (
|
||||
<ul role="list" class="post-gallery">
|
||||
{items.map((item) => (
|
||||
<li>
|
||||
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
||||
{item.type === 'video' ? (
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={item.poster?.src}
|
||||
aria-label={item.decorative ? 'Decorative video' : item.alt}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
</video>
|
||||
) : (
|
||||
item.src && (
|
||||
<Picture
|
||||
src={item.src}
|
||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat={fallbackFormatFor(item.src.format)}
|
||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{item.caption && <figcaption>{item.caption}</figcaption>}
|
||||
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||
</figure>
|
||||
<PostMediaFigure item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
||||
{item.type === 'video' ? (
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={item.poster?.src}
|
||||
aria-label={item.decorative ? 'Decorative video' : item.alt}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
</video>
|
||||
) : (
|
||||
item.src && (
|
||||
<Picture
|
||||
src={item.src}
|
||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat={fallbackFormatFor(item.src.format)}
|
||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{item.caption && <figcaption>{item.caption}</figcaption>}
|
||||
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||
</figure>
|
||||
))
|
||||
items.map((item) => <PostMediaFigure item={item} />)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
46
src/components/PostMediaFigure.astro
Normal file
46
src/components/PostMediaFigure.astro
Normal 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>
|
||||
|
|
@ -16,7 +16,7 @@ function isExternal(url: string) {
|
|||
|
||||
{
|
||||
links.length > 0 && (
|
||||
<ul class="project-links" aria-label="Project links">
|
||||
<ul class="project-links">
|
||||
{links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
|
|
@ -27,30 +27,35 @@ function isExternal(url: string) {
|
|||
>
|
||||
{link.label}
|
||||
{isExternal(link.url) && (
|
||||
<svg
|
||||
class="external-link-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="0.85em"
|
||||
height="0.85em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
<>
|
||||
<svg
|
||||
class="external-link-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="0.85em"
|
||||
height="0.85em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
<span class="sr-only">(opens in new tab)</span>
|
||||
</>
|
||||
)}
|
||||
{link.download && (
|
||||
<span class="download-indicator" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
<>
|
||||
<span class="download-indicator" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
<span class="sr-only">(download)</span>
|
||||
</>
|
||||
)}
|
||||
{link.download && <span class="sr-only">(download)</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -12,57 +12,26 @@ interface Props {
|
|||
const { projects } = Astro.props;
|
||||
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||
|
||||
async function resolveEssayHref(
|
||||
essay: CollectionEntry<'projects'>['data']['essay']
|
||||
): Promise<string | undefined> {
|
||||
if (!essay) return undefined;
|
||||
// Defensively handle both `string` (legacy) and resolved-entry / reference shapes.
|
||||
if (typeof essay === 'string') {
|
||||
return articlePath(essay);
|
||||
}
|
||||
if (typeof essay === 'object') {
|
||||
const ref = essay as {
|
||||
collection?: string;
|
||||
id?: string;
|
||||
slug?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
// Already a resolved CollectionEntry (has `data`)
|
||||
if (ref.data && ref.id) {
|
||||
return articlePath({ id: ref.id });
|
||||
}
|
||||
// A reference: { collection, id } — resolve via getEntry
|
||||
if (ref.collection && ref.id) {
|
||||
const resolved = await getEntry(ref.collection as 'posts', ref.id);
|
||||
if (resolved) return articlePath(resolved);
|
||||
return articlePath(ref.id);
|
||||
}
|
||||
if (ref.id) return articlePath(ref.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||
const essayHrefs = new Map<string, string>();
|
||||
for (const project of projects) {
|
||||
const href = await resolveEssayHref(project.data.essay);
|
||||
if (href) essayHrefs.set(project.id, href);
|
||||
const essay = project.data.essay;
|
||||
if (!essay) continue;
|
||||
const resolved = await getEntry(essay);
|
||||
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
|
||||
}
|
||||
---
|
||||
|
||||
<ol class="project-list">
|
||||
{
|
||||
projects.map((project) => {
|
||||
projects.map((project, index) => {
|
||||
const anchor = projectAnchor(project);
|
||||
const titleId = `${anchor}-title`;
|
||||
const essayHref = essayHrefs.get(project.id);
|
||||
const essayLink: ProjectLink | undefined = essayHref
|
||||
? { label: 'Article', type: 'site', url: essayHref }
|
||||
: undefined;
|
||||
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
||||
const links: ProjectLink[] = [
|
||||
...(essayLink ? [essayLink] : []),
|
||||
...project.data.links,
|
||||
];
|
||||
const links: ProjectLink[] = project.data.links;
|
||||
const isFirst = index === 0;
|
||||
|
||||
return (
|
||||
<li class="project-card" id={anchor}>
|
||||
|
|
@ -73,6 +42,8 @@ for (const project of projects) {
|
|||
class="project-thumbnail"
|
||||
widths={[240, 320, 480, 640, 800]}
|
||||
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
|
||||
loading={isFirst ? 'eager' : 'lazy'}
|
||||
fetchpriority={isFirst ? 'high' : undefined}
|
||||
/>
|
||||
<article class="project-card__summary">
|
||||
<h3 id={titleId}>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { tagPath } from '../lib/site';
|
|||
interface Props {
|
||||
tags: readonly string[];
|
||||
currentTag?: string;
|
||||
labelled?: boolean;
|
||||
limit?: number;
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
|
|
@ -20,7 +19,7 @@ const remaining =
|
|||
{
|
||||
visibleTags.map((tag) => (
|
||||
<li>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'true' : undefined}>
|
||||
{tag}
|
||||
{counts && counts[tag] !== undefined && (
|
||||
<span class="tag-count">{counts[tag]}</span>
|
||||
|
|
|
|||
|
|
@ -5,16 +5,6 @@ import { z } from 'astro/zod';
|
|||
|
||||
const linkSchema = z.object({
|
||||
label: z.string(),
|
||||
type: z.enum([
|
||||
'source',
|
||||
'demo',
|
||||
'package',
|
||||
'paper',
|
||||
'thesis',
|
||||
'video',
|
||||
'site',
|
||||
'contact',
|
||||
]),
|
||||
url: z.string(),
|
||||
download: z.boolean().optional(),
|
||||
});
|
||||
|
|
@ -37,7 +27,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
|||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
role: z.enum(['evidence', 'og', 'inline']).default('evidence'),
|
||||
})
|
||||
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
||||
message: 'Meaningful media needs both alt text and a caption.',
|
||||
|
|
@ -89,7 +78,6 @@ const projects = defineCollection({
|
|||
thumbnail: thumbnailSchema({ image }),
|
||||
period: z.string(),
|
||||
sortDate: z.coerce.date(),
|
||||
status: z.string().optional(),
|
||||
technologies: z.array(z.string()).default([]),
|
||||
selected: z.boolean().default(false),
|
||||
essay: reference('posts').optional(),
|
||||
|
|
|
|||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
|||
alt: The Ad Astra handheld game running on its OLED display.
|
||||
period: 'Spring 2020'
|
||||
sortDate: 2020-04-01
|
||||
status: Embedded game engine
|
||||
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
||||
selected: true
|
||||
essay: ad-astra-attiny85-game-engine
|
||||
legacyAnchor: embedded-game-engine
|
||||
links:
|
||||
- label: Source
|
||||
type: source
|
||||
url: https://github.com/schmelczer/ad_astra
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
|||
alt: Screenshot of the Avoid canvas game.
|
||||
period: 'January 2018'
|
||||
sortDate: 2018-01-01
|
||||
status: Early web game
|
||||
technologies: ['JavaScript', 'Canvas']
|
||||
selected: false
|
||||
essay: avoid-early-web-game
|
||||
legacyAnchor: avoid
|
||||
links:
|
||||
- label: Demo
|
||||
type: demo
|
||||
url: https://schmelczer.dev/avoid
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: Screenshot of a Unity city traffic simulation.
|
||||
period: 'July-August 2018'
|
||||
sortDate: 2018-08-01
|
||||
status: Simulation
|
||||
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
||||
selected: false
|
||||
essay: city-simulation-unity-traffic
|
||||
legacyAnchor: city-simulation-unity
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: Screenshot of a colour grading interface applied to a photograph.
|
||||
period: 'June 2018'
|
||||
sortDate: 2018-06-01
|
||||
status: UI experiment
|
||||
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
||||
selected: false
|
||||
essay: photo-colour-grader
|
||||
legacyAnchor: photo-colour-grader
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,20 +7,15 @@ thumbnail:
|
|||
alt: The decla.red browser game interface showing a space scene.
|
||||
period: 'Autumn-Winter 2020'
|
||||
sortDate: 2020-11-01
|
||||
status: Thesis project and browser game
|
||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
||||
selected: true
|
||||
essay: declared-shared-simulation-code
|
||||
legacyAnchor: multiplayer-mobile-game
|
||||
links:
|
||||
- label: Source
|
||||
type: source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
- label: Demo
|
||||
type: demo
|
||||
url: https://decla.red
|
||||
- label: BSc thesis
|
||||
type: thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: Chart from a foreign exchange prediction experiment.
|
||||
period: 'Autumn 2019'
|
||||
sortDate: 2019-10-01
|
||||
status: Experiment
|
||||
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||
selected: false
|
||||
essay: foreign-exchange-prediction-experiment
|
||||
legacyAnchor: predicting-foreign-exchange-rates
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,20 +7,15 @@ thumbnail:
|
|||
alt: Example Python code using the GreatAI API.
|
||||
period: '2022'
|
||||
sortDate: 2022-01-01
|
||||
status: Research project and framework
|
||||
technologies: ['Python', 'ML deployment', 'API design']
|
||||
selected: true
|
||||
essay: greatai-ai-deployment-api
|
||||
legacyAnchor: great-ai-ai-deployment-framework
|
||||
links:
|
||||
- label: PyPI
|
||||
type: package
|
||||
url: https://pypi.org/project/great-ai/
|
||||
- label: Project site
|
||||
type: site
|
||||
url: https://great-ai.scoutinscience.com
|
||||
- label: MSc thesis
|
||||
type: thesis
|
||||
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
||||
download: true
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: RGB LED strips glowing from a music synchronization project.
|
||||
period: 'Spring 2016'
|
||||
sortDate: 2016-04-01
|
||||
status: Early hardware/software project
|
||||
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
||||
selected: false
|
||||
essay: lights-synchronized-to-music
|
||||
legacyAnchor: lights-synchronised-to-music
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
|||
alt: Screenshot of the My Notes Android markdown app.
|
||||
period: 'November 2019'
|
||||
sortDate: 2019-11-01
|
||||
status: Android app
|
||||
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
|
||||
selected: false
|
||||
essay: my-notes-android-markdown-app
|
||||
legacyAnchor: my-notes-android-app
|
||||
links:
|
||||
- label: Source
|
||||
type: source
|
||||
url: https://github.com/schmelczer/my-notes
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: JavaFX editor interface for the cooling system simulator input graph.
|
||||
period: 'October-November 2018'
|
||||
sortDate: 2018-10-15
|
||||
status: Input editor
|
||||
technologies: ['JavaFX', 'JSON', 'REST API']
|
||||
selected: false
|
||||
essay: graph-editor-javafx-simulation-input
|
||||
legacyAnchor: graph-editor-javafx
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
||||
period: 'October-November 2018'
|
||||
sortDate: 2018-11-01
|
||||
status: Simulation and editor
|
||||
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||
selected: true
|
||||
essay: nuclear-cooling-simulation
|
||||
legacyAnchor: simulating-the-cooling-system-of-a-nuclear-facility
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
|||
alt: Screenshot of a generated photography site.
|
||||
period: 'Summer 2016'
|
||||
sortDate: 2016-07-01
|
||||
status: Static site generator
|
||||
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
||||
selected: false
|
||||
essay: photo-site-generator
|
||||
legacyAnchor: photos
|
||||
links:
|
||||
- label: Site
|
||||
type: site
|
||||
url: https://photo.schmelczer.dev
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
|||
alt: Screenshot from an early 3D platform game.
|
||||
period: 'Autumn 2017'
|
||||
sortDate: 2017-10-01
|
||||
status: Early game project
|
||||
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||
selected: false
|
||||
essay: platform-game-c-sdl
|
||||
legacyAnchor: platform-game
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,23 +7,17 @@ thumbnail:
|
|||
alt: SDF-2D browser demo with soft lighting effects.
|
||||
period: 'Autumn-Winter 2020'
|
||||
sortDate: 2020-12-01
|
||||
status: Thesis project and NPM package
|
||||
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'SDF rendering']
|
||||
selected: true
|
||||
essay: sdf-2d-ray-tracing
|
||||
legacyAnchor: optimising-2d-ray-tracing
|
||||
links:
|
||||
- label: NPM package
|
||||
type: package
|
||||
url: https://www.npmjs.com/package/sdf-2d
|
||||
- label: Demo
|
||||
type: demo
|
||||
url: https://sdf2d.schmelczer.dev
|
||||
- label: Video
|
||||
type: video
|
||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
||||
- label: BSc thesis
|
||||
type: thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@ thumbnail:
|
|||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
period: 'August-September 2019'
|
||||
sortDate: 2019-09-01
|
||||
status: Full-stack web app
|
||||
technologies: ['Python', 'Angular', 'State synchronization']
|
||||
selected: true
|
||||
essay: life-towers-immutable-tries
|
||||
legacyAnchor: multi-device-life-tracking
|
||||
links:
|
||||
- label: Source
|
||||
type: source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
- label: Demo
|
||||
type: demo
|
||||
url: https://towers.schmelczer.dev
|
||||
---
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Footer from '../components/Footer.astro';
|
|||
import Header from '../components/Header.astro';
|
||||
import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
|
||||
import defaultOg from '../assets/og-default.jpg';
|
||||
import themeInit from '../scripts/theme-init.ts?raw';
|
||||
import themeInit from '../scripts/theme-init.js?raw';
|
||||
import '../styles/global.css';
|
||||
|
||||
interface ArticleMeta {
|
||||
|
|
@ -48,8 +48,8 @@ const ogTitle = isRoot ? site.title : title;
|
|||
const canonical = absoluteUrl(canonicalPath);
|
||||
|
||||
let resolvedOgImage = ogImage;
|
||||
let resolvedOgWidth = ogImageWidth;
|
||||
let resolvedOgHeight = ogImageHeight;
|
||||
let resolvedOgWidth = ogImageWidth ?? 1200;
|
||||
let resolvedOgHeight = ogImageHeight ?? 630;
|
||||
|
||||
if (!resolvedOgImage) {
|
||||
const generated = await optimizeOgImage(defaultOg);
|
||||
|
|
@ -62,6 +62,64 @@ const ogImageUrl = resolvedOgImage.startsWith('http')
|
|||
? resolvedOgImage
|
||||
: absoluteUrl(resolvedOgImage);
|
||||
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||
|
||||
// Head meta tags built as a single HTML string so prettier-plugin-astro
|
||||
// doesn't shuffle them outside `<head>` when reformatting (it has trouble
|
||||
// with mixed JSX-expression and raw element siblings inside <head>).
|
||||
const attr = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const articleMetaParts = article
|
||||
? [
|
||||
`<meta property="article:published_time" content="${attr(article.publishedTime)}">`,
|
||||
article.modifiedTime
|
||||
? `<meta property="article:modified_time" content="${attr(article.modifiedTime)}">`
|
||||
: '',
|
||||
`<meta property="article:author" content="${attr(absoluteUrl('/about/'))}">`,
|
||||
...(article.tags ?? []).map(
|
||||
(tag) => `<meta property="article:tag" content="${attr(tag)}">`
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
const monoPreloadHtml = preloadMono
|
||||
? '<link rel="preload" href="/fonts/ibm-plex-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin>'
|
||||
: '';
|
||||
|
||||
const headHtml = [
|
||||
monoPreloadHtml,
|
||||
`<link rel="alternate" type="application/rss+xml" title="${attr(`${site.name} RSS`)}" href="/rss.xml">`,
|
||||
`<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">`,
|
||||
`<link rel="icon" href="/favicon.ico" type="image/x-icon">`,
|
||||
`<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">`,
|
||||
`<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">`,
|
||||
`<link rel="manifest" href="/site.webmanifest">`,
|
||||
`<meta property="og:site_name" content="${attr(site.name)}">`,
|
||||
`<meta property="og:title" content="${attr(ogTitle)}">`,
|
||||
`<meta property="og:description" content="${attr(description)}">`,
|
||||
`<meta property="og:url" content="${attr(canonical)}">`,
|
||||
`<meta property="og:image" content="${attr(ogImageUrl)}">`,
|
||||
`<meta property="og:image:type" content="image/jpeg">`,
|
||||
`<meta property="og:image:alt" content="${attr(ogImageAlt)}">`,
|
||||
`<meta property="og:image:width" content="${resolvedOgWidth}">`,
|
||||
`<meta property="og:image:height" content="${resolvedOgHeight}">`,
|
||||
`<meta property="og:type" content="${attr(ogType)}">`,
|
||||
`<meta property="og:locale" content="en">`,
|
||||
...articleMetaParts,
|
||||
`<meta name="twitter:card" content="summary_large_image">`,
|
||||
`<meta name="twitter:title" content="${attr(ogTitle)}">`,
|
||||
`<meta name="twitter:description" content="${attr(description)}">`,
|
||||
`<meta name="twitter:image" content="${attr(ogImageUrl)}">`,
|
||||
`<meta name="twitter:image:alt" content="${attr(ogImageAlt)}">`,
|
||||
...jsonLdEntries.map(
|
||||
(entry) =>
|
||||
`<script type="application/ld+json">${JSON.stringify(entry).replace(/<\/script/gi, '<\\/script')}</script>`
|
||||
),
|
||||
].join('');
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
|
|
@ -79,10 +137,15 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|||
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
|
||||
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
{!noindex && <link rel="canonical" href={canonical} />}
|
||||
<script is:inline data-theme-script set:html={themeInit} />
|
||||
|
||||
<noscript
|
||||
><style>
|
||||
.theme-switcher {
|
||||
display: none !important;
|
||||
}
|
||||
</style></noscript
|
||||
>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/source-sans-3-latin-variable.woff2"
|
||||
|
|
@ -90,76 +153,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
{
|
||||
preloadMono && (
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/ibm-plex-mono-latin-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={`${site.name} RSS`}
|
||||
href="/rss.xml"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<meta property="og:site_name" content={site.name} />
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImageUrl} />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:alt" content={ogImageAlt} />
|
||||
{
|
||||
resolvedOgWidth && (
|
||||
<meta property="og:image:width" content={String(resolvedOgWidth)} />
|
||||
)
|
||||
}
|
||||
{
|
||||
resolvedOgHeight && (
|
||||
<meta property="og:image:height" content={String(resolvedOgHeight)} />
|
||||
)
|
||||
}
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:locale" content="en" />
|
||||
|
||||
{
|
||||
article && (
|
||||
<>
|
||||
<meta property="article:published_time" content={article.publishedTime} />
|
||||
{article.modifiedTime && (
|
||||
<meta property="article:modified_time" content={article.modifiedTime} />
|
||||
)}
|
||||
<meta property="article:author" content={absoluteUrl('/about/')} />
|
||||
{article.tags?.map((tag) => (
|
||||
<meta property="article:tag" content={tag} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl} />
|
||||
<meta name="twitter:image:alt" content={ogImageAlt} />
|
||||
|
||||
{
|
||||
jsonLdEntries.map((entry) => (
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(entry)} />
|
||||
))
|
||||
}
|
||||
<Fragment set:html={headHtml} />
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const { title, description } = Astro.props;
|
|||
<Base {...Astro.props}>
|
||||
<div class="page-shell">
|
||||
<header class="page-header">
|
||||
<slot name="breadcrumbs" />
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
absoluteUrl,
|
||||
adjacentPosts,
|
||||
articlePath,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
formatDate,
|
||||
getPublishedPosts,
|
||||
|
|
@ -42,6 +43,10 @@ const breadcrumbTrail = trail.map((c, i) => ({
|
|||
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
// Only preload the monospace font if the post body actually contains code
|
||||
// (inline `…` or fenced ``` blocks). Saves ~15 KB on every code-free article.
|
||||
const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
|
||||
|
||||
// TOC: only show when there are >= 3 h2 headings.
|
||||
const h2Headings = headings.filter((h) => h.depth === 2);
|
||||
const showToc = h2Headings.length >= 3;
|
||||
|
|
@ -66,16 +71,7 @@ const blogPosting = {
|
|||
},
|
||||
};
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: trail.map((c, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: c.name,
|
||||
item: absoluteUrl(c.href),
|
||||
})),
|
||||
};
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||
---
|
||||
|
||||
<Base
|
||||
|
|
@ -87,7 +83,7 @@ const breadcrumbJsonLd = {
|
|||
ogImageWidth={1200}
|
||||
ogImageHeight={630}
|
||||
ogType="article"
|
||||
preloadMono={true}
|
||||
preloadMono={hasCode}
|
||||
article={{
|
||||
publishedTime: post.data.date.toISOString(),
|
||||
modifiedTime: post.data.updated?.toISOString(),
|
||||
|
|
@ -130,7 +126,7 @@ const breadcrumbJsonLd = {
|
|||
alt={post.data.thumbnail.alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={[640, 960, 1280, 1600]}
|
||||
widths={[640, 960, 1280, 1600, 1920, 2400]}
|
||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
|
|
@ -170,8 +166,8 @@ const breadcrumbJsonLd = {
|
|||
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="related-posts" aria-labelledby="related-heading">
|
||||
<h2 id="related-heading">Related articles</h2>
|
||||
<section class="related-posts">
|
||||
<h2>Related articles</h2>
|
||||
<ArticleList posts={related} />
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,18 +19,20 @@ export const site = {
|
|||
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
|
||||
// the site title). The Footer renders every entry regardless. Items marked
|
||||
// `footerOnly: true` appear only in the Footer.
|
||||
export const navItems = [
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
footerOnly?: boolean;
|
||||
}
|
||||
|
||||
export const navItems: readonly NavItem[] = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/articles/', label: 'Articles' },
|
||||
{ href: '/projects/', label: 'Projects' },
|
||||
{ href: '/about/', label: 'About' },
|
||||
{ href: '/tags/', label: 'Tags', footerOnly: false },
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
href: string;
|
||||
label: string;
|
||||
footerOnly?: boolean;
|
||||
}>;
|
||||
];
|
||||
|
||||
export function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
|
|
@ -176,28 +178,53 @@ interface BreadcrumbCrumb {
|
|||
|
||||
interface BreadcrumbInput {
|
||||
articles?: boolean;
|
||||
projects?: boolean;
|
||||
tagsIndex?: boolean;
|
||||
tag?: string;
|
||||
post?: CollectionEntry<'posts'>;
|
||||
}
|
||||
|
||||
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
||||
// visible Breadcrumbs component. Home is always first. Pass `articles: true`
|
||||
// to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass
|
||||
// a `post` to append the post title (linking to its article path).
|
||||
// visible Breadcrumbs component. Home is always first. Flags append crumbs
|
||||
// in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
|
||||
// implies both Articles and Tags so callers don't have to set every flag.
|
||||
export function buildBreadcrumbTrail({
|
||||
articles,
|
||||
projects,
|
||||
tagsIndex,
|
||||
tag,
|
||||
post,
|
||||
}: BreadcrumbInput): BreadcrumbCrumb[] {
|
||||
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
|
||||
if (articles || post) {
|
||||
if (articles || post || tagsIndex || tag) {
|
||||
trail.push({ name: 'Articles', href: '/articles/' });
|
||||
}
|
||||
if (tagsIndex || tag) {
|
||||
trail.push({ name: 'Tags', href: '/tags/' });
|
||||
}
|
||||
if (tag) {
|
||||
trail.push({ name: tag, href: tagPath(tag) });
|
||||
trail.push({ name: `#${tag}`, href: tagPath(tag) });
|
||||
}
|
||||
if (post) {
|
||||
trail.push({ name: post.data.title, href: articlePath(post) });
|
||||
}
|
||||
if (projects) {
|
||||
trail.push({ name: 'Projects', href: '/projects/' });
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
|
||||
// Shared by every page that emits breadcrumb structured data.
|
||||
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: trail.map((crumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: absoluteUrl(crumb.href),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ const recent = posts.slice(0, 5);
|
|||
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="404-recent">Recent articles</h2>
|
||||
<a href="/articles/">All articles →</a>
|
||||
<h2 id="recent-articles-404">Recent articles</h2>
|
||||
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ArticleList posts={recent} />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import Page from '../layouts/Page.astro';
|
||||
import { absoluteUrl, buildPersonJsonLd, getPublishedPosts, site } from '../lib/site';
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPersonJsonLd,
|
||||
getPublishedPosts,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
} from '../lib/site';
|
||||
import defaultOg from '../assets/og-default.jpg';
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const startingPoints = posts
|
||||
|
|
@ -9,6 +16,8 @@ const startingPoints = posts
|
|||
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
|
||||
.slice(0, 4);
|
||||
|
||||
const personImage = await optimizeOgImage(defaultOg);
|
||||
|
||||
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
||||
const personJsonLd = buildPersonJsonLd({
|
||||
jobTitle: 'Software Engineer',
|
||||
|
|
@ -22,7 +31,7 @@ const personJsonLd = buildPersonJsonLd({
|
|||
'Simulations',
|
||||
'Data visualization',
|
||||
],
|
||||
image: absoluteUrl('/og-image.jpg'),
|
||||
image: absoluteUrl(personImage.src),
|
||||
mainEntityOfPage: absoluteUrl('/about/'),
|
||||
});
|
||||
---
|
||||
|
|
@ -52,38 +61,40 @@ const personJsonLd = buildPersonJsonLd({
|
|||
|
||||
<section class="about-section facts">
|
||||
<h2 id="quick-facts">Quick Facts</h2>
|
||||
<address>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Focus</dt>
|
||||
<dd>
|
||||
Software systems, AI deployment, architecture, graphics, data visualization
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Education</dt>
|
||||
<dd>MSc in Computer Science</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Contact</dt>
|
||||
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Links</dt>
|
||||
<dd>
|
||||
<a href={site.cv} rel="noopener">CV</a>,
|
||||
<a href={site.github} rel="noopener me">GitHub</a>,
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</address>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Focus</dt>
|
||||
<dd>
|
||||
Software systems, AI deployment, architecture, graphics, data visualization
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Education</dt>
|
||||
<dd>MSc in Computer Science</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Contact</dt>
|
||||
<dd>
|
||||
<address>
|
||||
<a href={`mailto:${site.email}`}>{site.email}</a>
|
||||
</address>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Links</dt>
|
||||
<dd class="about-links">
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="best-starting-points">Best Starting Points</h2>
|
||||
<a href="/articles/">Browse all articles →</a>
|
||||
<a href="/articles/">Browse all articles <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ArticleList posts={startingPoints} />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../../layouts/Post.astro';
|
||||
import { entrySlug } from '../../lib/site';
|
||||
import { entrySlug, getPublishedPosts } from '../../lib/site';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = (await getCollection('posts')).filter((post) => !post.data.draft);
|
||||
const posts = await getPublishedPosts();
|
||||
return posts.map((post) => ({
|
||||
params: { slug: entrySlug(post) },
|
||||
props: { post },
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import Page from '../../layouts/Page.astro';
|
|||
import {
|
||||
absoluteUrl,
|
||||
articlePath,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
yearOf,
|
||||
} from '../../lib/site';
|
||||
|
|
@ -16,6 +18,11 @@ const posts = await getPublishedPosts();
|
|||
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
|
||||
const tags = getAllTags(posts);
|
||||
|
||||
const postOgImages = await Promise.all(
|
||||
posts.map((post) => optimizeOgImage(post.data.thumbnail.src))
|
||||
);
|
||||
|
||||
const personId = absoluteUrl('/about/#person');
|
||||
const blogJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
|
|
@ -23,26 +30,20 @@ const blogJsonLd = {
|
|||
url: absoluteUrl('/articles/'),
|
||||
description:
|
||||
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
|
||||
blogPost: posts.map((post) => ({
|
||||
publisher: { '@id': personId },
|
||||
blogPost: posts.map((post, index) => ({
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
datePublished: post.data.date.toISOString(),
|
||||
url: absoluteUrl(articlePath(post)),
|
||||
author: { '@id': personId },
|
||||
image: absoluteUrl(postOgImages[index].src),
|
||||
keywords: post.data.tags.join(', '),
|
||||
})),
|
||||
};
|
||||
|
||||
const breadcrumbTrail = buildBreadcrumbTrail({ articles: true });
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbTrail.map((crumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: absoluteUrl(crumb.href),
|
||||
})),
|
||||
};
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles: true }));
|
||||
|
||||
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
||||
---
|
||||
|
|
@ -54,7 +55,7 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
|||
>
|
||||
<nav id="tags" class="tag-filter" aria-label="Browse by tag">
|
||||
<span>Browse by tag</span>
|
||||
<TagList tags={tags} labelled={false} />
|
||||
<TagList tags={tags} />
|
||||
</nav>
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ const personJsonLd = buildPersonJsonLd();
|
|||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="latest-articles">Latest Articles</h2>
|
||||
<a href="/articles/">All {posts.length} articles →</a>
|
||||
<a href="/articles/"
|
||||
>All {posts.length}
|
||||
{posts.length === 1 ? 'article' : 'articles'}
|
||||
<span aria-hidden="true">→</span></a
|
||||
>
|
||||
</div>
|
||||
<ArticleList posts={latestPosts} />
|
||||
</section>
|
||||
|
|
@ -47,7 +51,7 @@ const personJsonLd = buildPersonJsonLd();
|
|||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="home-selected-projects">Selected Projects</h2>
|
||||
<a href="/projects/">All projects →</a>
|
||||
<a href="/projects/">All projects <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<ProjectList projects={selectedProjects} />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
---
|
||||
import ProjectList from '../../components/ProjectList.astro';
|
||||
import Page from '../../layouts/Page.astro';
|
||||
import { absoluteUrl, buildBreadcrumbTrail, getProjects, site } from '../../lib/site';
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getProjects,
|
||||
site,
|
||||
} from '../../lib/site';
|
||||
|
||||
const projects = await getProjects();
|
||||
const selected = projects.filter((project) => project.data.selected);
|
||||
|
|
@ -16,20 +22,7 @@ const collectionJsonLd = {
|
|||
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
|
||||
};
|
||||
|
||||
const breadcrumbTrail = [
|
||||
...buildBreadcrumbTrail({}),
|
||||
{ name: 'Projects', href: '/projects/' },
|
||||
];
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbTrail.map((crumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: absoluteUrl(crumb.href),
|
||||
})),
|
||||
};
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
|
||||
|
||||
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||
---
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import ArticleList from '../../components/ArticleList.astro';
|
|||
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
||||
import TagList from '../../components/TagList.astro';
|
||||
import Page from '../../layouts/Page.astro';
|
||||
import { getAllTags, getPublishedPosts, tagSlug } from '../../lib/site';
|
||||
import {
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
tagSlug,
|
||||
} from '../../lib/site';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPublishedPosts();
|
||||
|
|
@ -18,22 +24,23 @@ const posts = await getPublishedPosts();
|
|||
const allTags = getAllTags(posts);
|
||||
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
|
||||
const title = `Articles tagged "${tag}"`;
|
||||
const trail = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/articles/', label: 'Articles' },
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
{ label: `#${tag}` },
|
||||
];
|
||||
const trail = buildBreadcrumbTrail({ tag });
|
||||
const visibleTrail = trail.map((c, i) => ({
|
||||
label: c.name,
|
||||
href: i === trail.length - 1 ? undefined : c.href,
|
||||
}));
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||
---
|
||||
|
||||
<Page
|
||||
title={title}
|
||||
description={`Project articles and technical notes filed under #${tag}.`}
|
||||
jsonLd={breadcrumbJsonLd}
|
||||
>
|
||||
<Breadcrumbs items={trail} />
|
||||
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
|
||||
<nav class="tag-filter" aria-label="Browse other tags">
|
||||
<span>Browse other tags</span>
|
||||
<TagList tags={allTags} currentTag={tag} labelled={false} />
|
||||
<TagList tags={allTags} currentTag={tag} />
|
||||
</nav>
|
||||
|
||||
<h2 class="sr-only">Articles</h2>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import TagList from '../../components/TagList.astro';
|
|||
import Page from '../../layouts/Page.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
|
|
@ -27,20 +28,7 @@ const collectionJsonLd = {
|
|||
description: 'Every tag used across the articles archive.',
|
||||
};
|
||||
|
||||
const breadcrumbTrail = [
|
||||
...buildBreadcrumbTrail({ articles: true }),
|
||||
{ name: 'Tags', href: '/tags/' },
|
||||
];
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbTrail.map((crumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: absoluteUrl(crumb.href),
|
||||
})),
|
||||
};
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
|
||||
|
||||
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||
---
|
||||
|
|
|
|||
22
src/scripts/theme-init.js
Normal file
22
src/scripts/theme-init.js
Normal 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;
|
||||
})();
|
||||
|
|
@ -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;
|
||||
})();
|
||||
|
|
@ -39,14 +39,14 @@
|
|||
--color-link: light-dark(#285f74, #8ab8c8);
|
||||
--color-link-hover: light-dark(
|
||||
color-mix(in oklch, #285f74 70%, black 30%),
|
||||
color-mix(in oklch, #8ab8c8 70%, black 30%)
|
||||
color-mix(in oklch, #8ab8c8 70%, white 30%)
|
||||
);
|
||||
--color-link-visited: var(--color-link);
|
||||
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
|
||||
--color-rule: light-dark(#d9d5ca, #39352f);
|
||||
--color-rule-medium: light-dark(#7a7466, #6c655c);
|
||||
--color-rule-medium: light-dark(#7a7466, #8a8478);
|
||||
--color-rule-strong: light-dark(#4a4340, #d0c5b7);
|
||||
--color-code-bg: light-dark(#efede6, #24221f);
|
||||
--color-code-bg: light-dark(#efede6, #2f2c27);
|
||||
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
|
||||
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
|
||||
|
||||
|
|
@ -173,6 +173,7 @@
|
|||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--leading-snug);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-wrap: break-word;
|
||||
|
|
@ -181,6 +182,10 @@
|
|||
color 200ms ease;
|
||||
}
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
text-decoration-thickness: 0.08em;
|
||||
|
|
@ -198,7 +203,7 @@
|
|||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-rule-strong);
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
|
|
@ -228,17 +233,6 @@
|
|||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(100% - 2 * var(--gutter), var(--page));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.tap-target {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
|
|
@ -246,20 +240,12 @@
|
|||
========================================================================= */
|
||||
|
||||
@layer layout {
|
||||
:where(
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.home-intro,
|
||||
.home-section,
|
||||
.page-shell,
|
||||
.post,
|
||||
.post-footer-shell
|
||||
) {
|
||||
:where(.site-header, .site-footer, .home-intro, .home-section, .page-shell, .post) {
|
||||
width: min(100% - 2 * var(--gutter), var(--page));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
:where(.post, .post-footer-shell) {
|
||||
.post {
|
||||
width: min(100% - 2 * var(--gutter), var(--measure-wide));
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +257,10 @@
|
|||
transform: translateY(-150%);
|
||||
background: var(--color-fg);
|
||||
color: var(--color-bg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
min-block-size: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
|
@ -294,7 +283,9 @@
|
|||
|
||||
.site-title {
|
||||
color: var(--color-fg);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.005em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +293,10 @@
|
|||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.site-title[aria-current='page'] {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -337,17 +332,11 @@
|
|||
text-underline-offset: 0.25em;
|
||||
}
|
||||
|
||||
.site-nav a[aria-current='page'] {
|
||||
.site-nav a[aria-current='page'],
|
||||
.site-nav a[aria-current='true'] {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.theme-control {
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-rule);
|
||||
margin-top: var(--space-16);
|
||||
|
|
@ -370,16 +359,24 @@
|
|||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.footer-links a,
|
||||
.footer-meta a,
|
||||
.footer-meta span {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-links a,
|
||||
.footer-meta a {
|
||||
padding-inline: var(--space-1);
|
||||
margin-inline: calc(-1 * var(--space-1));
|
||||
}
|
||||
|
||||
/* Page header (shared by .home-intro, .page-header, .post-header) */
|
||||
.home-intro {
|
||||
max-width: var(--measure-wide);
|
||||
padding-block: clamp(2rem, 5vw, 4rem) var(--space-10);
|
||||
padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
|
||||
}
|
||||
|
||||
.home-intro h1,
|
||||
|
|
@ -545,8 +542,23 @@
|
|||
color: var(--color-rule-medium);
|
||||
}
|
||||
|
||||
.tag-list .tag-more::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.tag-list .tag-count {
|
||||
margin-inline-start: 0.35em;
|
||||
padding: 0 0.4em;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-code-bg);
|
||||
color: var(--color-fg);
|
||||
font-size: var(--fs-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tag-list a:hover,
|
||||
.tag-list a[aria-current='page'] {
|
||||
.tag-list a[aria-current='page'],
|
||||
.tag-list a[aria-current='true'] {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
|
|
@ -584,10 +596,10 @@
|
|||
|
||||
.article-list > li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5rem, auto) minmax(0, 1fr) minmax(6rem, 8rem);
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr) minmax(6rem, 8rem);
|
||||
grid-template-areas: 'date content thumb';
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-6);
|
||||
border-top: 1px solid var(--color-rule);
|
||||
}
|
||||
|
|
@ -611,6 +623,9 @@
|
|||
|
||||
.article-list .entry-title,
|
||||
.project-list h3 a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
color: var(--color-fg);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-decoration: none;
|
||||
|
|
@ -734,9 +749,10 @@
|
|||
.project-card .project-meta {
|
||||
color: var(--color-muted);
|
||||
font-size: var(--fs-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-essay-badge {
|
||||
|
|
@ -778,6 +794,11 @@
|
|||
color: var(--color-link);
|
||||
}
|
||||
|
||||
.project-links a:hover,
|
||||
.project-links a:focus-visible {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
.project-links a .download-indicator {
|
||||
margin-left: 0.25em;
|
||||
color: var(--color-muted);
|
||||
|
|
@ -838,6 +859,12 @@
|
|||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.about-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-4);
|
||||
}
|
||||
|
||||
.post > .prose {
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
|
@ -864,6 +891,11 @@
|
|||
margin-top: 1.05em;
|
||||
}
|
||||
|
||||
.prose > h2:first-child,
|
||||
.prose > h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
|
@ -902,19 +934,24 @@
|
|||
font-weight: var(--weight-regular);
|
||||
font-size: 0.85em;
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
opacity: 0.25;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.prose .heading-anchor::before {
|
||||
content: '#';
|
||||
}
|
||||
|
||||
.prose h2:hover .heading-anchor,
|
||||
.prose h3:hover .heading-anchor,
|
||||
.prose .heading-anchor:hover,
|
||||
.prose .heading-anchor:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.prose .heading-anchor {
|
||||
opacity: 0.4;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1043,10 +1080,17 @@
|
|||
|
||||
.at-a-glance dl,
|
||||
.facts dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-4) 0 0;
|
||||
}
|
||||
|
||||
.at-a-glance__row,
|
||||
.facts dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr);
|
||||
gap: var(--space-2) var(--space-4);
|
||||
margin: var(--space-4) 0 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.at-a-glance dt,
|
||||
|
|
@ -1089,24 +1133,23 @@
|
|||
|
||||
.post > .at-a-glance {
|
||||
grid-column: 2;
|
||||
grid-row: span 5;
|
||||
margin-top: var(--space-8);
|
||||
position: sticky;
|
||||
top: var(--space-6);
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Post media (formerly EvidenceMedia) ----------------------------- */
|
||||
/* -- Post media ------------------------------------------------------- */
|
||||
|
||||
.post-media,
|
||||
.evidence-media {
|
||||
.post-media {
|
||||
max-inline-size: min(100%, var(--measure-wide));
|
||||
margin: var(--space-8) 0 0;
|
||||
}
|
||||
|
||||
.post-media img,
|
||||
.post-media video,
|
||||
.evidence-media img,
|
||||
.evidence-media video {
|
||||
.post-media video {
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-code-bg);
|
||||
|
|
@ -1114,7 +1157,6 @@
|
|||
}
|
||||
|
||||
.post-media figcaption,
|
||||
.evidence-media figcaption,
|
||||
.media-transcript {
|
||||
max-width: var(--measure);
|
||||
margin-top: var(--space-2);
|
||||
|
|
@ -1126,14 +1168,24 @@
|
|||
/* -- Post nav --------------------------------------------------------- */
|
||||
|
||||
.post-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-12);
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--color-rule);
|
||||
}
|
||||
|
||||
.post-nav__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-nav__next {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.post-nav a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1170,6 +1222,67 @@
|
|||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
/* -- Post TOC --------------------------------------------------------- */
|
||||
|
||||
.post-toc {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-inline-start: 2px solid var(--color-rule);
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-muted);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.post-toc .post-nav__title,
|
||||
.post-header h1,
|
||||
.post-nav .post-nav__title,
|
||||
.project-card h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.post-toc ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.post-toc a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: 24px;
|
||||
padding-block: 2px;
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-toc a:hover,
|
||||
.post-toc a:focus-visible {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
/* -- Post media gallery ----------------------------------------------- */
|
||||
|
||||
.post-gallery {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-8) 0 0;
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* -- External link affordance ----------------------------------------- */
|
||||
|
||||
.external-link-icon {
|
||||
display: inline-block;
|
||||
margin-inline-start: 0.25em;
|
||||
vertical-align: -0.125em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* -- Related ---------------------------------------------------------- */
|
||||
|
||||
.related-posts {
|
||||
|
|
@ -1187,36 +1300,27 @@
|
|||
/* -- Empty state (e.g. 404) ----------------------------------------- */
|
||||
|
||||
.empty-state {
|
||||
min-height: 50vh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
max-width: var(--measure);
|
||||
margin-inline: auto;
|
||||
padding-block: var(--space-10);
|
||||
}
|
||||
|
||||
.empty-state .prose {
|
||||
margin-inline: auto;
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
/* -- Theme switcher --------------------------------------------------- */
|
||||
|
||||
.theme-switcher {
|
||||
--switcher-w: 2.5rem;
|
||||
--switcher-h: 1.25rem;
|
||||
--switcher-icon: 0.85rem;
|
||||
--switcher-mask: 0.68rem;
|
||||
--switcher-gap: 0.2rem;
|
||||
--switcher-mask-offset: 0.28rem;
|
||||
--switcher-w: 2.75rem;
|
||||
--switcher-h: 1.5rem;
|
||||
--switcher-icon: 1.05rem;
|
||||
--switcher-mask: 0.78rem;
|
||||
--switcher-gap: 0.22rem;
|
||||
--switcher-mask-offset: 0.32rem;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
display: inline-block;
|
||||
width: var(--switcher-w);
|
||||
height: var(--switcher-h);
|
||||
margin: 0;
|
||||
margin: var(--space-2) 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-rule);
|
||||
border: 1px solid var(--color-rule-medium);
|
||||
border-radius: var(--radius-pill);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -1225,9 +1329,7 @@
|
|||
transition:
|
||||
background-color 200ms ease,
|
||||
border-color 150ms ease;
|
||||
box-shadow:
|
||||
inset 0 0 10px 2px rgb(0 0 0 / 17.5%),
|
||||
inset 0 0 1px rgb(0 0 0 / 40%);
|
||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.theme-switcher:hover {
|
||||
|
|
@ -1329,8 +1431,8 @@
|
|||
padding-block: var(--space-8) var(--space-6);
|
||||
}
|
||||
|
||||
.at-a-glance dl,
|
||||
.facts dl {
|
||||
.at-a-glance__row,
|
||||
.facts dl > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
|
@ -1357,7 +1459,7 @@
|
|||
}
|
||||
|
||||
.project-card .project-meta {
|
||||
white-space: normal;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.project-card__summary {
|
||||
|
|
@ -1377,10 +1479,14 @@
|
|||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.post-nav {
|
||||
.post-nav__list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-nav__next {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.post-nav a.next {
|
||||
text-align: start;
|
||||
}
|
||||
|
|
@ -1402,6 +1508,14 @@
|
|||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
|
|
@ -1430,10 +1544,17 @@
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
print-color-adjust: economy;
|
||||
-webkit-print-color-adjust: economy;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.skip-link,
|
||||
.theme-control,
|
||||
.theme-switcher,
|
||||
.tag-filter,
|
||||
.post-nav,
|
||||
.related-posts,
|
||||
|
|
@ -1465,8 +1586,7 @@
|
|||
.prose pre,
|
||||
.prose code,
|
||||
.post-thumbnail img,
|
||||
.post-media img,
|
||||
.evidence-media img {
|
||||
.post-media img {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue