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-monoto keep their columns lined up. Afont-feature-settings: 'tnum'on the sans face does nothing. - The share cards do not follow a pairing.
src/lib/og.tsreads.wofffiles fromnode_modules, so cards stay in Hanken Grotesk until you install the new family’s@fontsourcepackage and pointog.tsat 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.PluralRulespicks the form. - A site in another language. Set
site.locale.kois ready; for any other language copyen.tstosrc/i18n/<code>.ts, translate it, and add it todictsint.ts. The file is typed againsten.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 ofabout,privacy,termsand the contact line, and everything insrc/content/.
The letter (Pro)
With newsletter.provider and newsletter.id set, the site gains:
/letter—newsletter.lineas 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
Letterlink 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/sentand/letter/confirmed— where the reader lands after the form, and after the link in the confirmation email. Not indexed, and insite.localeonly: 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
-
The account. Your username is the part after
buttondown.com/in your newsletter’s public address. Check thathttps://buttondown.com/<username>opens — that page is also where the “Past letters” row on/letterpoints, at/archive. -
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>, };linecan be a single string if the site has one language. Build and deploy. -
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/sentAfter the confirmation link https://<your site>/letter/confirmedOver Buttondown’s API the same two settings are
subscription_redirect_urlandsubscription_confirmation_redirect_urlon 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 — includinglocalhost— is refused with “We can’t seem to reach this URL”. -
Try it once, on the deployed site, with an address you can read:
/letter→ enter the address → Subscribe lands on/letter/sentin 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:
- The language.
i18n.localesinsrc/config.tslists every language by code and the name it is listed under.site.localeis the default and keeps the bare paths; the rest are served under/<code>/whilefeatures.i18nRoutingis on. - The theme’s words. A dictionary at
src/i18n/<code>.ts(see above).koships. - Your words.
i18n.descriptionsandi18n.categoriescarry the site line and the category names and blurbs; a nav label can be{ en: 'Work', ko: '작업' }. - The content. A product or note in another language goes in a folder named for it:
src/content/notes/ko/staging-urls-that-expire.mdis/ko/notes/staging-urls-that-expire, and it is the Korean edition of the English file of the same name. A note’swork: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.
Legal pages
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.