Skip to content
Leader

Documentation

Customization

Everything below is one file. Start at src/config.ts, and keep colours and metrics in src/styles/theme.css rather than editing the defaults.

Run pnpm dev and open /styleguide while you work: it prints every token, the type scale, the leader row and the body copy on one page. The page is a development tool and is never built into dist/.

Site settings — src/config.ts

Setting What it does
site.name, site.description Nameplate, footer, feed and meta
site.url Canonical URLs, sitemap, feed, share cards. Change it before you publish, or every absolute link points at example.com
site.locale lang on <html>, og:locale, the prose date format, and which dictionary in src/i18n/ the theme’s own words come from
site.author Footer copyright, the JSON-LD publisher, and the byline on a note that names no author
site.defaultOgImage The share image for pages with no card of their own
nav.header Three items. A fourth overflows at 360px — put it in the footer instead
nav.footer, nav.social Link lists. icon is a Lucide name, e.g. lucide:github. A label or title is a string, or one per language ({ en: 'Work', ko: '작업' })
seo.titleTemplate %s is the page title
seo.twitterHandle Omitted from the meta when empty
seo.jsonLd Person or Organization, and the name shown as publisher
seo.ogImages Pro; draws a share card for every product and note at build time. Off, everything falls back to site.defaultOgImage
categories The first axis. name is printed, slug is what content refers to, accent is 1–4 and points at --accent-N, blurb heads the category page
work.leaderRule 'line' draws the signature; 'none' drops the rule and leaves the two ends of the row
work.showBuilding On, a product with status: building is dimmed. Off, it is left out of the site entirely until its status changes
blog.postsPerPage Notes per page on /notes
blog.relatedPosts Pro; how many related notes follow a note
blog.showReadingTime The reading estimate at the end of the byline
contact.endpoint Any service that accepts a plain POST: Formspree, Web3Forms, Basin. Empty, /contact prints the address instead of a form with nowhere to post
contact.accessKey, contact.redirect Pro; Web3Forms wants its key in the body, and redirect is where the browser lands afterwards. Neither does anything without an endpoint
contact.email Printed on /contact, /about and the legal pages
newsletter Pro; provider: 'buttondown' and id (your buttondown.com username) add /letter, a footer link and a row at the foot of every note. line is the sentence that introduces it, a string or one per language. See The letter
comments Pro; the four giscus ids from giscus.app, the thread mapping, and whether the reaction row shows
features.darkMode Off removes the switcher; the site then follows the reader’s system setting
features.search Pro; off removes /search and the Pagefind index
features.i18nRouting Pro; serves the languages in i18n.locales under /<code>/. See A second language
i18n Pro; the languages, and site.description and the category names and blurbs in each of them
features.comments Pro; the switch for the giscus thread. Both this and the four ids are needed before anything is loaded
analytics provider ('plausible', 'ga4', 'umami') and id. Nothing is loaded while provider is null. host is for self-hosted Plausible or Umami

Colours

The palette is CSS custom properties in src/styles/tokens.css. Do not edit that file; override what you want in src/styles/theme.css, which is loaded last and unlayered:

:root {
  --primary: light-dark(#8c1f2f, #e08792);
  --accent-2: light-dark(#7a4b12, #e0b075);
}

Each colour is a light-dark() pair — light value first, dark second. Overriding with a plain colour sets both modes at once, which is usually not what you want.

Token Where it shows
--background, --surface The canvas, and the few blocks raised off it
--foreground, --muted Body text; metadata, summaries, bylines
--border Hairlines: section rules, table rules, the band around a callout
--border-soft The leader rule at rest, before it takes a category’s colour
--accent-1 … --accent-4 The categories. accent in config.ts chooses which one a category gets
--primary, --primary-hover, --on-primary Links and the inverted label on the one button
--focus-ring Keyboard focus

After any colour change run pnpm check:contrast. It measures every text token against every background, in light and dark, across the default palette and all four presets, and exits non-zero on anything below WCAG AA (4.5:1).

Picking four accents

Contrast is the part a script can check. The other part is whether the four read as four. In the default palette the clay and moss accents are 3.6 apart in OKLab under protanopia against 20.5 for normal vision, which means a red-green colourblind reader cannot tell infra from data by colour. Every preset has a pair like it somewhere; four hues that all clear AA against two canvases do not leave much room to also be far apart from each other.

The theme is built so that this is a nuisance rather than a barrier: no place on the site uses a colour as the only mark of a category. A leader row prints the category name at the far end, the filter on /work prints it, the foot of a note prints it, the share card prints it. Keep it that way if you change the accents, and if you write a figure of your own, name the categories in it rather than relying on a legend.

Colour presets

Four complete palettes ship beside the default in src/styles/. Turn one on with the matching @import at the top of src/styles/theme.css — the imports are there, commented out:

@import './tokens-kraft.css';
  • graph — graph paper: a cool blue-grey sheet, almost-navy ink, four cold accents.
  • kraft — a tan sheet the colour of a parts envelope, brown-black ink, earth accents.
  • ledger — no hue loud enough to name; the four accents are greys with a trace of colour left in them.
  • signal — white paper, black ink, four accents at full strength. The leader rule reads hardest here.

A preset moves the canvas, the ink, the rules and the four accents together. The import has to stay at the top of the file, which is also the order that lets your own rules below it win: take kraft and change only --accent-1 if you like.

To add your own, copy one of the files, name it tokens-<name>.css and import it the same way. pnpm check:contrast finds it by filename — there is nothing to register.

Type

Webfonts are declared in the fonts: block of astro.config.mjs and served from your own domain by Astro’s font provider. Replace a face by changing name and the weights; keep cssVariable as --font-sans-face or --font-mono-face and the rest of the theme follows.

Two whole alternatives ship in src/fonts.ts. Set the export at the foot of that file and rebuild:

export const pairing = pairings.quarto;
  • quarto — Spectral with IBM Plex Mono. A text face rather than a grotesque: the rows keep their rules but read as a printed table of contents. For a site whose notes are longer than its product list.
  • jobbing — Jost with JetBrains Mono. Geometric, the cold reading of the same light weights.

Two things to know before you swap a face by hand:

  • Keep 200 on the text face. Display type here is set at 200 and never goes above 400. A family whose light end stops at 300 changes the theme more than a colour preset does.
  • Numbers are set in the mono face on purpose. Hanken Grotesk ships without tabular figures, so dates, years and counts use --font-mono to keep their columns lined up. A font-feature-settings: 'tnum' on the sans face does nothing.
  • The share cards do not follow a pairing. src/lib/og.ts reads .woff files from node_modules, so cards stay in Hanken Grotesk until you install the new family’s @fontsource package and point og.ts at it. Colour presets do move the cards.

For system fonts instead, delete the fonts: block and set the stacks in theme.css:

:root {
  --font-body: Charter, 'Iowan Old Style', Georgia, serif;
  --font-heading: var(--font-body);
  --font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
}

Do not add <link> tags to an external font host; it costs a connection on first paint and puts your readers on someone else’s log.

The leader rule

The signature is work.leaderRule in src/config.ts and src/components/LeaderRow.astro. Every list on the site is that one component: the shelf, the notes, the category pages, the archive, the search results. Changing the row changes all of them, which is the point.

To change Where
Drop the rule work.leaderRule: 'none'
The rule at rest, and on hover --border-soft and the category accent; the widths are in the <style> block of LeaderRow.astro
What the right-hand end says The right slot where the list is built — WorkList.astro, NoteList.astro, archive.astro
Narrow layout Below the breakpoint in LeaderRow.astro the rule is hidden and the category moves above the title

--radius is 0. The theme is built on rules; rounding them mixes two shape languages.

Logo and favicon

There is no logo image: the nameplate is site.name set in the display face. To use a mark instead, replace the <a href="/"> block in src/components/common/Header.astro and the matching line in src/components/common/Footer.astro with an <Image> from astro:assets.

Favicons are public/favicon.svg and public/favicon.ico. public/og-default.png is the share card for pages that have none of their own.

Pages and layout

To change Edit
Home src/pages/[...lang]/index.astro
Shelf and its filters src/pages/[...lang]/work/index.astro, src/components/WorkFilter.astro
Product page src/pages/[...lang]/work/[...slug].astro
Notes list, note src/pages/[...lang]/notes/[...page].astro, [...slug].astro
Category page and its feed src/pages/[...lang]/category/[slug].astro, src/pages/category/[slug]/rss.xml.ts
Tags, authors, archive (Pro) src/pages/tag/[slug].astro, src/pages/authors/[slug].astro, src/pages/archive.astro
Search (Pro) src/pages/_search.astro — injected at /search from astro.config.mjs
Markdown twins, llms.txt (Pro) src/lib/agents.ts — the two routes beside it are four lines each
About, contact, legal, 404 src/pages/about.astro, contact.astro, privacy.astro, terms.astro, 404.astro
Header, footer src/components/common/Header.astro, Footer.astro
<head> meta src/components/common/SEO.astro — pages pass title and description to Base

A new page is a file in src/pages/ using the Base layout:

---
import Base from '../layouts/Base.astro';
import PageHead from '../components/PageHead.astro';

const description = 'What this site is built with.';
---

<Base title="Colophon" {description}>
  <PageHead title="Colophon" lead={description} />

  <section class="border-border border-t">
    <div class="prose mx-auto max-w-wide px-4 py-12 md:py-16">
      <p>…</p>
    </div>
  </section>
</Base>

Add it to nav.header or nav.footer in config.ts to link it. A page made of other pages’ titles should pass indexed={false} so it stays out of the search results.

Selling what is on the shelf (Pro)

A product with offers in its file prints a price list on its page and a price in its row; cover puts one screenshot at the top of the page (fields in docs/content.md). The currency is work.currency in config.ts.

For things a buyer has to see before paying — themes, templates, icon sets — give them a category of their own and set view: 'gallery' on it. That category’s page becomes a grid of cover plates, each with its row under it; /work and the home shelf stay rows. Then point the header at the gallery rather than at the whole shelf, so a visitor from a marketplace lands on pictures:

export const categories = [
  { name: 'themes', slug: 'themes', accent: 1, view: 'gallery', blurb: '…' },
  // …
];

export const nav = {
  header: [
    { label: 'Themes', href: '/category/themes' },
    { label: 'Notes', href: '/notes' },
    { label: 'About', href: '/about' },
  ],
  // …
};

The header holds three links; swap one rather than adding a fourth. A product filed under a gallery links back to that gallery, not to /work, and its share card is its cover.

Body copy

Notes, product pages and the legal pages use one class, .prose, defined at the end of src/styles/global.css. There is no typography plugin to configure: change the rules there, and wrap anything that should keep its own look in .not-prose.

Changing the theme’s own words

Every word the theme prints itself – headings such as Work and Related, the metadata line, the filter, search, the contact form, the 404 – is in src/i18n/en.ts, with a Korean edition in src/i18n/ko.ts. Pages and components read them through useT() in src/i18n/t.ts, in the language of the page.

  • Rewording. Edit en.ts. A key is a string, or a { one, other } pair where the number changes the word (1 note, 2 notes); Intl.PluralRules picks the form.
  • A site in another language. Set site.locale. ko is ready; for any other language copy en.ts to src/i18n/<code>.ts, translate it, and add it to dicts in t.ts. The file is typed against en.ts, so a missing key is a build error rather than an English word on a translated page.
  • Not in the dictionaries: your own words in src/config.ts (site name and description, nav labels, category names and blurbs, the newsletter line), the page copy of about, privacy, terms and the contact line, and everything in src/content/.

The letter (Pro)

With newsletter.provider and newsletter.id set, the site gains:

  • /letter — newsletter.line as its lead, the form, and one row out to the past issues in the provider’s archive. Each language has its own (/ko/letter).
  • A Letter link at the front of the first footer group, and one row at the foot of every note, below its tags. That is the whole of the ask: no form on every page, and nothing on the product pages or the lists.
  • /letter/sent and /letter/confirmed — where the reader lands after the form, and after the link in the confirmation email. Not indexed, and in site.locale only: Buttondown takes one address for each.

The form is a plain POST in the same tab; nothing from the provider loads on the page. Leave the provider null and none of the above is built.

Setting it up with Buttondown

  1. The account. Your username is the part after buttondown.com/ in your newsletter’s public address. Check that https://buttondown.com/<username> opens — that page is also where the “Past letters” row on /letter points, at /archive.

  2. The config. In src/config.ts:

    export const newsletter = {
      provider: 'buttondown' as null | 'buttondown',
      id: '<username>',
      line: {
        en: 'A letter when something ships, and the note that came with it.',
        ko: '뭔가 나오면 보내는 편지, 그리고 그때 쓴 노트.',
      } as string | Record<string, string>,
    };

    line can be a single string if the site has one language. Build and deploy.

  3. The two return addresses. In Buttondown, open Settings → Subscribing and find Redirects. After subscribing is where a reader lands right after the form; After confirming is where the link in the confirmation email takes them. Set them to the deployed site and press Save:

    When Address
    After the form is submitted https://<your site>/letter/sent
    After the confirmation link https://<your site>/letter/confirmed

    Over Buttondown’s API the same two settings are subscription_redirect_url and subscription_confirmation_redirect_url on the newsletter (Buttondown: updating a newsletter). Buttondown opens each address when you save, so the site has to be deployed with step 2 first: an address it cannot reach — including localhost — is refused with “We can’t seem to reach this URL”.

  4. Try it once, on the deployed site, with an address you can read:

    • /letter → enter the address → Subscribe lands on /letter/sent in the same tab.
    • The confirmation email arrives; its link lands on /letter/confirmed.
    • The address shows up as a subscriber in Buttondown.

If the form lands on a Buttondown page instead, step 3 is not saved yet — Buttondown prints its own pages until it has the two addresses. If it lands on the site’s 404, the deployed build predates step 2, or the addresses name a different domain from the one you deployed to. Before deploying, the form itself can be tried from pnpm preview — it posts to Buttondown either way, and Buttondown answers with its own pages until step 3 is saved.

A second language (Pro)

The demo ships in English with a Korean edition under /ko/. The pieces:

  1. The language. i18n.locales in src/config.ts lists every language by code and the name it is listed under. site.locale is the default and keeps the bare paths; the rest are served under /<code>/ while features.i18nRouting is on.
  2. The theme’s words. A dictionary at src/i18n/<code>.ts (see above). ko ships.
  3. Your words. i18n.descriptions and i18n.categories carry the site line and the category names and blurbs; a nav label can be { en: 'Work', ko: '작업' }.
  4. The content. A product or note in another language goes in a folder named for it: src/content/notes/ko/staging-urls-that-expire.md is /ko/notes/staging-urls-that-expire, and it is the Korean edition of the English file of the same name. A note’s work: still names the product by its English id (work: cusp); the Korean page links to the Korean product when there is one.

An edition only has what was written in it. The Korean shelf is the products with a Korean page, and a language with nothing in it has no home page at all. The home, /work, /notes, the product and note pages, the category pages and /search have an edition per language. Tags, series, authors, the archive, the feeds, the share cards, llms.txt, and about, contact and the legal pages stay in site.locale; a link to one of them from another edition goes to the default-language page.

The footer lists the other languages. A page that has a translation links to it and is announced to search engines with hreflang; one that does not links to that language’s home. Pagefind splits its index by <html lang>, so /ko/search searches the Korean pages.

Two things the theme does not carry for you: a webfont for the second script — the Korean edition is set in the reader’s system face, since a CJK webfont is megabytes — and the share card, which is drawn in a Latin-only face and so is not drawn for other editions.

To go back to one language, set features.i18nRouting to false and delete the ko/ folders in src/content/work/ and src/content/notes/. With the switch off they are never built, so deleting them is tidiness rather than a fix.

privacy.astro and terms.astro are placeholders written for a static site that collects nothing. Read them and replace them with your own before you publish.