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.
Navigation, footer and social links
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.
The legal pages
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.