ADR-0166 — A theme may bring its own layout: Halcyon
- Status: Accepted
- Date: 2026-10-10
- Extends ADR-0071 (Theme Studio) and ADR-0086 (a theme designs the whole site).
0. The question
Every theme was CSS over one set of templates. That made a theme cheap to add and safe to switch, and it set a ceiling: a lead story, an outline beside the text, reading settings, a topic index by month and a phone dock are markup, not paint, so no theme could have them. The operator asked for a theme in the sense of the console's Still Air (calm, content first, one accent, depth that explains) that would be VayuPress's own and the theme new sites start on, with every one of its styles a choice in Theme Studio, its ads shown properly, and no speed lost.
1. Decision
A theme may name a template set. theme.Tokens gains Layout; Halcyon's
preset sets "halcyon", and no other value is recognised. render.ActivateTheme
is the one place a theme becomes live (boot, Theme Studio, the API and the
connector's apply_theme), so the stylesheet and the templates can never
belong to different themes. The public entry points (Home, a post, search, the
missing page, the topic index and a topic) dispatch to Halcyon's templates only
while its layout is active. Every other theme renders exactly as before:
TestSharedTemplatesUnchangedByHalcyon holds each of those pages byte for byte
to what the commit before Halcyon rendered from the same inputs. The one
difference is the version in three widget script URLs, below.
Halcyon's own files. A stylesheet (9.4 KB gzipped against a 16 KB budget)
and a script (3.8 KB against 6 KB) are compiled into the binary and served
immutable under content hashes. Newsreader (SIL OFL) joins the self-hosted
fonts. Halcyon pages load neither Pico nor article.css. The editor's
component rules (video facades, embed cards, vp-* blocks) are taken from
article.css at start-up, so there is one source for them. /theme.css
still follows, carrying the accent and any custom CSS.
The reader's own choices. Face, size (17–23 px), line width and
appearance are saved in that browser only, under the appearance key the
shared theme switch already uses. They are applied before first paint by an
inline script admitted by its hash, which is added to script-src on every
page. A site default never overrides a reader's saved choice, and "Use the
site's settings" clears it.
Its options are per-theme options. Home (front page, river, index), archive pages and topics (index, same as Home), articles (column, margin), reading face, text size, running text (small caps, as written), accent (site, Still Air blue, custom), appearance for new readers, and the sections and reading tools, each on or off. The theme compiler refuses a Halcyon accent below 4.5:1 on any of its surfaces, in either scheme. The refusal is in the compiler, so every path that applies a theme meets it.
Presentation at render time, never in the stored post. On the sanitised HTML, in one token pass:
- the opening quotation becomes the standfirst;
- table cells carry their column's name, so a phone can set a row as labelled values;
- section headings carry anchors for the outline;
- runs of capitals are set in small caps.
The stored post is not touched, and tests check that no text is lost.
Data without counting on a request. Topic counts and the front page's
desks come from the topic index's own memo, refreshed off the request path.
Related reading is ranked by the topics a post shares with the one being
read. A topic carried by more than half of all posts is ignored, as it is
for the desks; on johal.in, every post carries tutorial. On a multi-domain
install every figure is the active domain's.
Ads. Halcyon's templates keep every anchor the ad injectors use, where the design wants each slot. The sidebar placement, never shown before because no theme had a side column, now fills Halcyon's rail; on narrower screens it follows the post rather than hiding, because ad networks forbid a unit that is served and never shown.
Member pages (sign-in, sign-up, the account, plans, the paywall,
checkout, the author's page) keep their markup and signup.css. Under Halcyon
they take its stylesheets after it, its header, and no pictographs in their
headings. Under any other theme they are returned unchanged.
Theme Studio shows Halcyon's options alone while it is loaded. Its preview is the real page (Home, a topic, the newest post, a search, sign-in) rendered from the site's posts with the options being edited, at Desktop (1440 px, scaled to fit and saying so), Tablet and Phone, and in either scheme. A stylesheet swap cannot show a layout option.
New installs start on Halcyon; existing installs do not move. Migration
108 saves Default(), exactly as theme.Load returned it, for every install
that already has a user or a post and no saved theme. theme.Load now returns
Halcyon for a missing row, which only a new install has.
2. Consequences
- Shared widgets change slightly, for every theme. The pictographs in the
comments and trending widgets now sit in
aria-hiddenspans (.vayu-ico), so screen readers stop reading emoji and Halcyon can hide them. Trending groups carrydata-group. The search sheet gives focus back to whatever opened it. Other themes look the same; their pages' script versions change. - Every page's
script-srcnames one more hash. theme.ContrastRatiois the one contrast formula. The console's copy is gone, and its guard that rated black on black as 21:1 is removed.- E2E boots a new install, so it runs on Halcyon. The existing public
specs do not depend on the shared templates' markup;
halcyon.spec.jsadds checks that:- nothing in a post is wider than its column, at 390, 820 and 1440 px and reader sizes 17 to 23;
- every overlay follows its rules;
- the reader's choices apply before paint;
- motion stops under reduced motion;
- no request leaves the host;
- a link copies where the clipboard API is not offered (a .onion).
- Not done here:
- Inline SVG diagrams are removed by the article sanitiser on every theme and their labels leak as loose text. Admitting or dropping them is a sanitiser decision.
- A topic page's scoped search and "Most read" sort need backend support.
- Your own comment awaiting moderation would need the comments API to return it.