Customization

Everything in this file is a change to one of three places: src/config.ts (what the site is and which features are on), src/styles/theme.css (what it looks like) and src/fonts.ts (what it is set in). Nothing here asks you to edit a component.

What is in src/config.ts

Group Setting What it does
site name description url locale author defaultOgImage Identity. Printed in the wordmark, the footer, the feed and every canonical URL
nav header footer social The desk bar, the footer groups (including Legal) and the social icons
seo titleTemplate twitterHandle jsonLd Title pattern and the site-wide structured data
coverLines overlay divider max layout The signature: how the teasers on a cover plate are set
blog postsPerPage relatedPosts showReadingTime A desk front’s page size, and what the foot of an article carries
newsletter provider id line The subscribe block at the foot of the home page
comments repo repoId category categoryId mapping reactions giscus wiring; features.comments is the switch
forms provider accessKey redirect The letters box on the colophon
features darkMode search i18nRouting comments ogImages The switches. Everything third-party is off until you turn it on
analytics provider id host One tag, or none. host is for a self-hosted install

The rest of this file goes group by group, plus the two files that are not config: the tokens in theme.css and the pairings in fonts.ts.

Site identity

export const site = {
  name: 'Halftone',
  description: 'A newsmagazine where the picture leads...',
  url: 'https://example.com',
  locale: 'en',
  author: 'Linnea Holm',
  defaultOgImage,
};

url is the one you must change before you deploy: canonical links, the feed, the sitemap and robots.txt all print absolute URLs from it. locale is a BCP 47 code and sets both the document language and the way dates and counts are formatted. defaultOgImage is the photograph shared for a page that has none of its own — the import at the top of the file, cropped to 1200x630 at build.

author is the editor: printed in the footer line, in the colophon and in the site-wide JSON-LD.

nav.header is the desk bar. It is a written list rather than a loop over the desks, because the bar also carries the colophon and because five desks fit across a phone and seven do not.

nav.footer is a list of groups, each a title and its links. The theme ships two: More (the back-of-book pages and the feed) and Legal (privacy and terms). A group with no links does not print, so removing the legal set means removing its group here as well as the two page files.

nav.social takes Lucide icon names — lucide:github, lucide:instagram, lucide:rss. An empty list prints no icon row at all.

Colour

src/styles/tokens.css holds every colour the theme uses, each written once as light-dark(<light>, <dark>). Do not edit that file — override what you want in src/styles/theme.css, which is loaded after it and is the file that survives an update:

:root {
  --primary: light-dark(#8c1f2f, #e08792);
}

A plain colour (--primary: #8c1f2f) changes both modes at once. Keep the two apart unless you mean it: the dark-mode value of a spot colour is almost always lighter than the light-mode one, or it disappears into the page.

Token What it paints
--background / --surface the page, and the blocks set into it (callouts, figures)
--foreground / --muted body text; bylines, folios and photo credits
--border hairline rules
--primary / --primary-hover / --on-primary the spot: wordmark, kicker boxes, links
--plate / --on-plate / --on-plate-muted / --primary-on-plate cover plates — the scrim over a photograph, and the type on it

The plate tokens are the same in both modes on purpose. A plate is dark whatever the reader has chosen, because it sits on a photograph.

Presets

Four palettes ship in src/styles/presets/. Import one at the top of theme.css and it becomes the theme; anything you set below the import still wins.

@import './presets/broadsheet.css';
Preset Stock Spot
broadsheet warm newsprint ink blue
signal white, high contrast electric blue
dossier olive rust
noir grey none — the ink is the spot

Contrast

pnpm check:contrast measures every text colour against every background it can land on, in both modes, and fails below WCAG AA. It checks the live theme and all four presets. Change a colour, run it, and fix what it flags before you publish — AA is the one thing in this theme you should not talk yourself out of.

Type

Three CSS variables carry the whole theme: --font-body (article prose and decks), --font-heading (headlines, the wordmark, navigation) and --font-mono (kickers, folios, photo credits, figures).

Webfonts are loaded by Astro in astro.config.mjs, which is why a font is not a theme.css override. To use system fonts instead, set the variables in theme.css and delete the matching block from the config’s fonts array.

Pairings

src/fonts.ts holds two ready-made alternatives to the faces the theme ships with. Set pairing and rebuild:

export const pairing = pairings.quarto;
Pairing Body Headline Reads as
(default) Spectral Schibsted Grotesk a magazine
quarto Source Serif 4 Archivo a quarterly: long pieces, few pictures
wire Inter Libre Franklin a wire service: no serif anywhere

Writing your own: keep the three cssVariable names, give the heading face every weight the theme sets (400, 500, 700, 800 for headlines, 900 for the wordmark), and keep an italic on the body face — the prose uses it.

The cover lines

The signature of the theme: the labelled teasers set on the cover plate. coverLines in config.ts:

Setting Values What it does
overlay true / false on the photograph, or on a plate-coloured panel below it
divider 'plus' / 'rule' / 'none' what separates one entry from the next
max a number how many entries the home plate carries
layout 'stack' / 'numbered' / 'columns' how the column is set

stack runs the entries down the plate. numbered does the same with a mono ordinal in the margin, which reads as a contents page — pair it with divider: 'rule'. columns runs them across the foot of the plate instead, and stacks again below 48rem, because three headlines side by side on a phone are three headlines two words wide.

Set overlay: false when your cover photographs are light or busy. Type on a picture is a promise about the picture, and it is better made once, in config, than guessed at per article.

The desk front and the article

export const blog = {
  postsPerPage: 4,
  relatedPosts: 3,
  showReadingTime: true,
};

A desk front carries its newest piece on a plate and the rest as a list, postsPerPage at a time. Six is chosen so that a desk with seven pieces actually paginates; raise it and page two appears later. relatedPosts is how many pieces the “what to read next” rundown at the foot of an article carries. They are scored, not listed: a shared tag weighs double a shared desk, and anything the rail already printed is taken out first, so nothing on the page comes round twice. showReadingTime: false drops the estimate from the dateline.

Feature switches

export const features = {
  darkMode: true,
  search: true,
  i18nRouting: false,
  comments: false,
  ogImages: true,
};
Switch On Off
darkMode The light/dark control sits in the header The page follows the reader’s system setting and says nothing about it
search /search, and pagefind --site dist at the end of the build No index is written and the page is not built
i18nRouting Every language something is filed in gets a front at /<code>/, and the switch appears beside search A piece with lang still reads correctly; there is no second front
comments The giscus thread prints under an article, once comments is wired Nothing is loaded
ogImages The back-of-book pages are drawn a share card They fall back to site.defaultOgImage

ogImages is the one switch that is on by default, because it needs no account and no key and makes no third-party request. The others cost the reader something, so they start off.

Share cards

An article’s card is its own photograph, cropped to 1200×630 and baked to JPEG at build.

Pages with no photograph — the colophon, the indexes, the legal set — are drawn a card instead: the same plate, kicker box and slab headline the cover uses. features.ogImages switches it; the list of pages is cards in src/lib/og.ts, and adding a page of your own is a row in it. With the feature off, those pages fall back to the photograph in site.defaultOgImage.

If you change --plate or --primary-on-plate (a preset does), repeat the change in the plate constant at the top of src/lib/og.ts. Satori draws the cards without a stylesheet, so it cannot read your tokens.

Newsletter

export const newsletter = {
  provider: 'buttondown' as null | 'buttondown' | 'kit',
  id: 'halftone',
  line: 'One letter each Friday: what we photographed, and what we left out.',
};

The subscribe block is set as a plate at the foot of the home page. id is your Buttondown username (buttondown.com/<username>) or your Kit form id (app.kit.com/forms/<id>/subscriptions). The demo ships with a placeholder; replace it or set provider: null, which prints nothing at all. A form that goes nowhere is worse than no form.

It is a plain form POST, so no script runs and the reader lands on the provider’s own confirmation page. There is no inline success message, because that would mean fetch() and CORS against somebody else’s endpoint for a message they already print.

Comments

export const comments = {
  repo: '',
  repoId: '',
  category: 'Announcements',
  categoryId: '',
  mapping: 'pathname',
  reactions: true,
};

giscus, over a GitHub Discussions thread. Install the giscus app on the repository, then copy the four ids from the form there. features.comments is the switch; these are the wiring, and with any of them empty nothing is loaded and nothing is printed.

mapping decides how a page finds its thread. pathname survives a retitled piece, og:title survives a moved one. A magazine renames more often than it moves, so the default is pathname.

The contact form

export const forms = {
  provider: null as null | 'web3forms',
  accessKey: '',
  redirect: '',
};

The letters box on the colophon. Web3Forms mails the submission on to the address the key was issued for, so there is no backend here and no address on the page for a scraper to lift. Without a key the colophon prints its links only.

redirect is where the browser lands after a successful send. Left empty, that is the provider’s own receipt page; point it at a page of your own to keep the reader on the site.

Analytics

export const analytics = {
  provider: null as null | 'plausible' | 'ga4' | 'umami',
  id: '',
  host: '',
};

One tag, or none. Nothing at all is emitted until a provider is named, so a site that measures nothing ships no third-party script, no cookie banner and no extra request.

id is the identifier the provider gave you: the domain for Plausible, the measurement id (G-XXXXXXX) for GA4, the website id for Umami. host is for a self-hosted Plausible or Umami — the origin the script is served from, with or without a trailing slash. Leave it empty for the hosted service.

Plausible and Umami need no consent notice in their hosted form: neither sets a cookie nor stores an identifier. GA4 does both, and a site using it needs its own notice. That is a legal choice, not a theme setting, and the theme does not make it for you.

UI strings and languages

Every word the theme prints that your writing did not — labels, counts, navigation — is in src/i18n/<code>.ts. English and Korean ship. To add a language, write the module against the Strings type, import it in src/i18n/index.ts, and add a row to locales there.

The standing prose on the colophon, the archive and the other index pages is deliberately not in the string tables. That copy is yours to rewrite in the page it belongs to; a magazine’s about-page voice does not belong in a table of button labels.

With features.i18nRouting on, every language something has actually been filed in gets a front at /<code>/, and the switch appears in the header. A language with nothing behind it is a setting, not an edition, so it is not offered. The switch leads to that language’s front, not to a translation of the page you are on: a piece filed in Korean is its own reporting, not a second version of an English one.

SEO

export const seo = {
  titleTemplate: '%s · Halftone',
  twitterHandle: '',
  jsonLd: { type: 'Person', name: site.author },
};

%s is the page title; the home page prints site.name alone. jsonLd.type is Person for a magazine with an editor and Organization for one with a masthead. Per-page structured data — NewsArticle, BreadcrumbList — is added by the pages themselves through SEO.astro, which is the only file that writes <head> meta.

Favicon

public/favicon.svg and public/favicon.ico. These are the two files in public/ that are meant to be there; everything else belongs in src/assets/ so that Astro processes it.

src/pages/privacy.astro and src/pages/terms.astro are prose, written out in the page the way the colophon is, because they describe your magazine rather than the theme. The privacy page prints a section per data-collecting feature you have switched on and stays quiet about the ones you have not — read it against your own config, delete what does not apply, and have someone who does this for a living look at the result if your jurisdiction asks that of you.

Both are linked from the footer through nav.footer in config.ts. Removing a page means removing its link there, its entry in src/lib/og.ts, and the page file.