How the site is built, why it's arranged this way, and where to make each kind of change. Read top to bottom and you'll understand the whole system.
If you only want to publish a write-up, you don't need this file — see CONTENT.md instead.
A SvelteKit app, fully prerendered to static HTML at build time. There
is no server and no database: npm run build produces plain files, which is why
the site is fast, cheap to host, and trivially cacheable.
Three principles drive the structure. Most "where does this go?" questions are answered by one of them.
Write-ups are Markdown files, not Svelte components. Adding one means adding a file — never a route, never a copied stylesheet, never an entry in a list. The listing, the URL, the sort order, the category chips, and the social preview are all derived from what's on disk.
Anything that could drift out of sync is computed rather than maintained by
hand: categories come from the content, nav links come from the collection
registry, entry URLs come from one href field, colours come from one token
file. If you find yourself typing the same value in two places, that's a bug in
the architecture, not a chore.
Projects and minis are not two features — they're one feature, twice. Adding a blog is a registry entry plus three one-line files, not a copy of the projects directory.
src/content/<collection>/*.md ← you write these
│
▼
src/lib/content/index.js ← discovers, sorts, types
│
┌─────────────┴─────────────┐
▼ ▼
CollectionPage.svelte EntryPage.svelte
(the listing) (one write-up)
│ │
▼ ▼
routes/<name>/ routes/<name>/[slug]/
| Path | What it is |
|---|---|
src/content/projects/*.md |
One file per project. Source of truth. |
src/content/minis/*.md |
One file per mini. Identical format. |
src/content/goals.md |
A standalone page's prose (not part of a collection). |
static/<slug>/ |
Images and video for one entry. Folder name matches the slug. |
| Path | What it is |
|---|---|
src/lib/content/index.js |
Discovers every .md, builds sorted entry metadata, derives categories, and supplies route loaders. |
src/lib/content/collections.js |
The registry. Declares each collection: URL, headings, layout, SEO text. |
src/lib/content/schema.js |
JSDoc typedefs for frontmatter, plus the dev-time validation warnings. |
src/lib/config.js |
Site-wide config: name, author, canonical URL, nav links, social links. |
src/lib/physics/balls.svelte.js |
The homepage ball pit simulation. |
| Path | What it is |
|---|---|
src/lib/components/CollectionPage.svelte |
A whole listing page for any collection. Both listings are one line each. |
src/lib/components/EntryPage.svelte |
A whole write-up page for any collection. |
src/lib/components/ArticleLayout.svelte |
Shared write-up chrome: back-link, title, chips, TOC, and the prose CSS. |
src/lib/components/EntryListCard.svelte |
Wide stacked card — the list listing layout. |
src/lib/components/EntryGridCard.svelte |
Fixed-height card — the grid listing layout. |
src/lib/components/Seo.svelte |
Every page's <head>: title, description, canonical, Open Graph, Twitter. |
src/lib/components/Nav.svelte |
Top nav. Links come from config; highlights the current section. |
src/lib/components/Footer.svelte |
Footer. Its top edge is also the ball pit's floor. |
src/lib/components/TableOfContents.svelte |
Sticky TOC built from the rendered headings. |
src/lib/components/CategoryList.svelte |
Category chips. |
src/lib/components/Figure.svelte |
Captioned image, with a Figure N. label. |
src/lib/components/VideoFigure.svelte |
Captioned looping video, with a Video N. label. |
| Path | What it is |
|---|---|
src/lib/styles/tokens.css |
All colour, spacing, type, radius, shadow, and motion values. |
src/app.css |
Element defaults for Markdown output, plus layout primitives. |
| Path | What it is |
|---|---|
src/routes/+layout.svelte |
App shell: nav, page, footer. |
src/routes/+layout.js |
export const prerender = true — makes the whole site static. |
src/routes/+page.svelte |
Homepage: intro copy and the ball pit. |
src/routes/+error.svelte |
Styled 404 / error page. |
src/routes/<collection>/ |
Listing + [slug] pages. Four one-line files per collection. |
src/routes/{about,resume,goals}/ |
Standalone pages. |
The part worth understanding once.
Vite's import.meta.glob requires a literal pattern — it's resolved at
build time, not at runtime. So there is exactly one pattern covering every
collection, rather than one glob per collection:
'/src/content/*/*.md';Collections are then discovered from the folder structure. That's why adding a collection never means editing the engine.
const metaModules = import.meta.glob('/src/content/*/*.md', {
eager: true,
import: 'metadata'
});
const componentLoaders = import.meta.glob('/src/content/*/*.md');This pairing is deliberate and is the single most important performance detail in the codebase:
metaModulesis eager, butimport: 'metadata'means the bundler pulls in only the frontmatter object from each file. Every compiled article body is tree-shaken away. Listing pages therefore ship a few kilobytes of metadata rather than every article on the site.componentLoadersis lazy, so each write-up becomes its own chunk that loads only when someone visits that page.
Collapsing these into one eager glob is the classic mdsvex trap: it silently
bundles every article into every page that reads the listing. You can verify the
split is intact — after npm run build, exactly one client chunk should contain
any given article's prose.
Each file becomes a plain object: its frontmatter, plus slug, collection, a
derived href, and categories defaulted to []. Entries are sorted
newest-first by created.
href is derived in exactly one place. That's what makes a URL scheme change a
one-line edit instead of a search-and-replace across templates.
entryRoute(name) returns both halves a [slug]/+page.js needs:
export const { load, entries } = entryRoute('projects');loadlooks up the entry's metadata and lazily imports its component, throwing a 404 for an unknown slug.entriestells the prerenderer which URLs exist, so every write-up is rendered to static HTML at build time even if nothing links to it yet.
schema.js warns in dev only about missing required fields, unparseable
created dates, and a non-array categories. Warnings rather than errors is a
deliberate choice: a half-finished draft should still preview. The tradeoff is
that a bad field won't fail CI — if you'd rather it did, throw instead of
console.warn in warnOnInvalidFrontmatter.
src/lib/styles/tokens.css holds every colour, space, radius, shadow, and
duration as a CSS custom property. Components reference var(--color-text),
never #333. Restyling the site is an edit to that one file.
Tokens are named for their role (--color-surface), not their value
(--color-white) — that's what lets the palette change without every name
becoming a lie.
If you're about to type a raw hex code or a magic pixel value into a component, add a token instead.
app.css defines two wrappers so routes don't reinvent padding:
.page— standard gutter, capped reading measure. Text pages use this..page-wide— gutter on all sides. Listing pages use this.
Both read --page-gutter, which narrows at the mobile breakpoint. Because nav,
footer, and page bodies all read that same token, their left edges stay aligned
through the breakpoint automatically.
Content is Markdown, so there's nowhere to hang a class on a heading or paragraph. Two places handle this:
app.csselement selectors (h1,h2,p,li) style Markdown output globally.ArticleLayout.svelte's.post :global(…)rules style write-up prose.:globalis required because Svelte's scoping can't see markup it didn't compile — the article is slotted in as a snippet.
CSS cannot use a custom property inside a media query condition, so breakpoint
values (767px, 1000px, 1200px) are literals in component media queries.
SCROLL_OFFSET in TableOfContents.svelte likewise mirrors --nav-height
because JS can't read a CSS variable without a getComputedStyle round-trip.
These are the only intentional value duplications in the codebase.
Seo.svelte renders every page's <head> — title, description, canonical URL,
Open Graph, and Twitter card tags. Pages pass a title and description; the
component handles the rest.
Entry pages feed it automatically: title from the frontmatter title,
description from overview, and the preview image from thumbnail. Which is
why overview is worth writing for a stranger skimming search results — it's
not just card copy.
Open Graph requires absolute URLs, so site.url in src/lib/config.js must
be the real production domain. It's currently a placeholder — set it, or link
previews will point at the wrong host. Nothing else depends on it.
The simulation lives in src/lib/physics/balls.svelte.js, not in the route, so
the homepage component stays about the homepage. The component only measures the
stage, forwards pointer events, and renders pit.balls.
The file is .svelte.js rather than .js because it uses runes — Svelte only
compiles rune syntax in files with that extension.
createBallPit() returns state through getters and exposes a small command
surface (resize, spawn, grab, drag, release, start). State isn't
returned directly, so the component can't mutate it behind the simulation's
back.
- State. A ball stores only
{ id, x, y, vx, vy }. Its centre is computed asx + radius, never stored — that removed a whole class of bugs where a cached centre drifted out of sync with the position. - Frame-rate independence.
dtis measured in 60fps frames and clamped to 3, so the sim feels identical at 60Hz or 144Hz, and a backgrounded tab can't accumulate a huge jump that flings everything off-screen on return. - Collisions. An O(n²) pass separates overlapping pairs and exchanges velocity along the contact normal, but only for pairs that are closing in — otherwise balls already separating get yanked back together. A held ball shoves others without being pushed itself.
- Throwing. Drag tracks an exponentially smoothed pointer velocity, so a release reflects the gesture's overall direction rather than its last jittery millisecond.
- The floor. The footer's top edge is the ground. The footer sits in normal document flow while the pit is viewport-fixed, so its position in pit coordinates changes with scroll — hence re-measuring every frame.
- Bounds.
MAX_BALLScaps the count so the O(n²) pass and the DOM stay cheap. BelowMIN_BALL_WIDTHthe pit is disabled entirely: there isn't room for it to be fun and dragging fights touch scrolling.
All at the top of the module: GRAVITY, BOUNDS_DAMPING, COLLISION_DAMPING,
BALL_RADIUS, MAX_BALLS, MAX_THROW, MIN_BALL_WIDTH. Adjust feel without
touching logic.
Careful with the grab highlight. The 1.1 scale is applied inline inside the same
transformas the translate. Don't move it to the standalonescale:CSS property — that applies beforetransform, multiplying the translate and shifting the ball off the cursor by ~0.1× its position.
See CONTENT.md. One Markdown file.
-
Create
src/content/posts/. -
Add an entry to
collectionsinsrc/lib/content/collections.js:posts: { name: 'posts', path: '/blog', navLabel: 'Blog', heading: 'Writing', description: 'Essays and notes.', layout: 'list', empty: 'Nothing published yet.' }
-
Create three files:
<!-- routes/blog/+page.svelte --> <script> import CollectionPage from '$lib/components/CollectionPage.svelte'; </script> <CollectionPage collection="posts" />
// routes/blog/[slug]/+page.js import { entryRoute } from '$lib/content/index.js'; export const { load, entries } = entryRoute('posts');
<!-- routes/blog/[slug]/+page.svelte --> <script> import EntryPage from '$lib/components/EntryPage.svelte'; let { data } = $props(); </script> <EntryPage {data} collection="posts" />
The listing, sorting, categories, prerendering, SEO, and the nav link all follow. No CSS, no loader, no card component.
Edit src/lib/styles/tokens.css. Don't touch component styles for colour or
spacing changes.
Edit staticLinks in src/lib/config.js. Collection links are appended
automatically — don't add those by hand.
Flip layout between 'list' and 'grid' in the registry.
- JavaScript with JSDoc, not TypeScript. Types live in comments; the Svelte
language server enforces them in your editor.
schema.jsholds the content types. $lib/…imports, never deep relative paths.$content/…for standalone Markdown pages.- Runes only (
$state,$derived,$effect,$props) — no$:, noexport let, noon:click. The Svelte 4 migration is complete. - Pointer events, not mouse events, so interactions work with touch and pen.
- Comments explain why, not what. If a line looks odd but is correct, that's what deserves a comment.
npm run dev # dev server with hot reload
npm run build # production build; prerenders every page
npm run preview # serve the production build locally
npm run format # apply Prettier
npm run lint # Prettier check + ESLintnpm run build is the real correctness check. Because every page is
prerendered, a broken internal link or bad import fails the build rather than
shipping silently.
svelte/no-navigation-without-resolve is disabled in eslint.config.js. It
exists to keep links working if the app is served from a sub-path, which this
site isn't — it was firing at a dozen link sites and being silenced with
per-line comments, which is worse than one documented decision.
Re-enable it if the site ever moves to a sub-path deployment (a GitHub Pages
project site is the usual case). The content side would be a single edit, since
every entry URL is derived from one href field.
The foundation was built with these in mind.
Everything needed is already exported from src/lib/content/index.js:
getEntries(name) for the full list and getCategories(name) for
[{ name, count }] filter options.
A first version, entirely inside CollectionPage.svelte:
- Hold selected categories in a
$statearray. - Render a chip per
getCategories()entry that toggles selection. - Show
entries.filter((e) => selected.every((c) => e.categories.includes(c))). - For text search, also match
titleandoverviewagainst an input.
To make filters shareable, store them in the query string (?category=Optics)
and read them from page.url. Making the chips in CategoryList.svelte into
links is the natural way to wire that up — it was kept a separate component for
exactly this.
A dropdown re-sorting by created vs updated. Both timestamps are already in
the data; updated is currently collected but unused.
If you ever want category colours or icons, add an optional lookup file
mapping a name to display metadata and have CategoryList consult it — while
still deriving the set of categories from the content. That keeps "adding a
category" free and only annotates the ones you care about.
Neither exists yet. A sitemap is a +server.js endpoint iterating
getEntries() over the registry. CI would be a GitHub Action running
npm run lint && npm run build on push.
Tokens make this tractable: the values are already centralised, so it's a
@media (prefers-color-scheme: dark) block overriding the :root custom
properties in tokens.css. No component would need to change.