Skip to content
Meith
Browse documentation
Customization

The theme API

How to write a theme, what a theme may do, and what the API freeze covers.

@meith/theme-kit is the frozen contract between the board and a theme.

This document is the policy: how to write a theme, what a theme may do, what the freeze covers, and how something is removed from it. The reference — every slot and every view model — is generated into Theme slots.

Writing a theme

A theme is a module that calls defineTheme with a key, a title, and a map from slot name to component. Nothing else.

// themes/acme/src/theme.ts
import { defineTheme } from "@meith/theme-kit"
import { defaultTheme } from "@meith/theme-default"

import { PostBit } from "./slots/post-bit"

export const acmeTheme = defineTheme({
key: "acme",
title: "Acme",
extends: defaultTheme,
slots: { PostBit },
})

Register it in community.config.ts, and set defaultTheme to its key if it should be the board's default.

Three shipped themes are worth reading before you write one:

  • examples/iris-theme is the minimal one, and the one to copy first. It recolours the default board by overriding one brand group of tokens, plus a single slot (Footer) where its markup genuinely disagrees. It ships as reference code rather than registered; examples/README.md walks through installing it or your copy of it.
  • themes/midnight is a full replacement: 22 slots overridden, the rest inherited, tables where the default theme has lists — and no change to any package to make it possible. What it inherits matters as much as what it overrides: the control panels and the search pages arrived as slots after it was written, and it renders all of them without a line changing.
  • themes/clubhouse is the one to read for a theme built to be recoloured. It keeps the default board's shape but dresses it as a sports club's site, and nothing in it names a colour: the club's own two colours are primary and secondary, so an operator repaints the whole board from the theme screen, without a deploy.

Rules the tooling enforces

Rule Why
Write the slot map inline, with bare identifiers. scripts/slot-kinds.mjs resolves each binding to its module to check the server/client boundary. A map assembled dynamically cannot be checked, so it fails rather than passing unchecked.
A server slot must not be a "use client" module. For PostBit that would ship the whole post list to the browser. Checked statically, and again at defineTheme for anything the bundler marked.
Colours come from tokens. An operator restyles a board by overriding tokens; a hardcoded colour is a region they cannot reach. The no-hardcoded-colour guard rejects hex, rgb() and hsl() literals in any .tsx file across the repository, themes included.
View models are plain JSON data. No Date, no functions, no class instances — the same models cross to client slots and out through the REST API. Serialisable<T> proves it at compile time.

What a theme renders

Every page a member, moderator or administrator can open is rendered through slots. The registry declares 36, all of them stable. They fall into four groups:

Group Slots What it is
The frame Shell, Header, UserPanel, Navigation, Footer, Notice, ForumJump, ErrorNotice, RedirectNotice Wraps every page, including the error pages
Reading BoardIndex, CategoryBlock, ForumRow, ForumDisplay, ThreadRow, ThreadView, PostBit, PostActions, QuickReply, Pagination, SubforumList, Announcement, BoardStats, WhoIsOnline, LatestThreads, LatestPosts, MemberProfile The board itself
Finding SearchForm, SearchResults, DiscoveryView Search, and the "new posts" listings
Doing PostForm, EditorToolbar, AuthPage, PanelShell, PanelNav, PanelPage, PanelSection Writing, signing in, and all three control panels

The three control panels are one set of slots, not three. The member, moderator and administrator panels share a shape — a rail of sections beside a page with a heading — so they are PanelShell + PanelNav + PanelPage + PanelSection between them, and PanelKind (usercp / modcp / admincp) tells a theme which panel it is rendering. This is what makes the admin panel themeable at all: its forty-odd screens fill PanelPage's body with app-rendered forms, and a theme restyles every one of them by overriding the frame once.

PanelPage also frames three pages that are in no panel — who's online, the board statistics and the report form are panel-shaped with no rail beside them. PanelShell centres the pages inside it, so a PanelPage rendered under one must not centre itself; the three standalone pages have no shell, so there it must. PanelPageModel.frame (panel / standalone) says which case a theme is rendering.

What a theme does not own is the body of an individual settings screen. A form posting to a Server Action never crosses the theme contract as data, so an admin screen's controls arrive as children, and a theme restyles them through the tokens the @meith/ui primitives read rather than by replacing their markup.

What a theme can and cannot do

A theme may:

  • Fill any slot in the registry with a component.
  • Inherit from another theme with extends and override only the slots it cares about. Resolution is shallowest-wins per slot, and an override is total.
  • Ship its own token values, which an operator can then override without touching the theme.
  • Ship its own message catalog, and reword the board's messages with it.

A theme cannot:

It cannot Because
Read the database, the request, cookies or the session @meith/theme-kit depends on no workspace package at all, and dependency-cruiser makes a theme's import of @meith/db, a driver or a domain package a hard error
Decide anything about permissions ViewerModel.canAccessAdminCp and its siblings are rendering hints the Authorizer has already resolved. Anything a viewer must not see is not in the model at all — CSS is not authorization
Build a URL Every href arrives resolved, so the board can change its URL shape without breaking installed themes
Render another slot Slots are flat. The page composes them and passes rendered output in regions; there is no way to reach the resolved theme from inside a slot

Words and numbers

A slot receives a view model and its own resolved words — nothing else. There is still no locale to reach for and no translator to call, in the same way there is no database. The app formats; the theme renders. Anything the app already knows arrives pre-written: a timestamp crosses as a TimeModel, and a counter as a CountModel.

export function ForumRow({ forum }: { forum: ForumRowModel }) {
return (
<span>
{forum.postCount.label} {forum.postCount.value === 1 ? 'post' : 'posts'}
</span>
)
}

label is the string — grouped by the reader's language, so 1,204 on an English board and 1.204 on a German one. value is the number, for the work a string cannot do: pluralising a noun, hiding a zero, sizing a bar. Rendering value directly is the bug this shape exists to prevent, and so is reaching for toLocaleString — with a locale it pins every board to one language, without one it follows the host, so the server and the browser disagree. The no-fixed-locale-format guard refuses both.

A number your theme worked out for itself — "and 12 more" over a list you sliced — is yours to render as plain digits. The rule covers what the app hands you, and the app hands you a memberCount rather than making you count members.length.

Put your own words in a catalog, and read them through copy. Every slot component takes a second prop, copy: SlotCopyReadonly<Record<string, string>> — carrying whatever your theme registered for that slot, already resolved for the viewer:

import { defineTheme } from '@meith/theme-kit'

function whoIsOnlineCopy(t: Translator): SlotCopy {
return {
'clubhouse.whoIsOnline.heading': t.t('clubhouse.whoIsOnline.heading'),
'clubhouse.whoIsOnline.empty': t.t('clubhouse.whoIsOnline.empty'),
}
}

export const clubhouseTheme = defineTheme({
key: 'clubhouse',
title: 'Clubhouse',
slots: { WhoIsOnline },
copy: { WhoIsOnline: whoIsOnlineCopy },
})
function WhoIsOnline({ total, copy }: WhoIsOnlineModel & { copy: SlotCopy }) {
return <h2>{copy['clubhouse.whoIsOnline.heading'] ?? 'clubhouse.whoIsOnline.heading'}</h2>
}

A missing key renders as itself, the same fallback every other copy record in the app uses. The values themselves come from your theme's own message catalog — a messages bundle registered alongside slots and copy in community.config.ts:

export const clubhouseMessages = {
en: { 'clubhouse.whoIsOnline.heading': 'In the clubhouse' },
de: { 'clubhouse.whoIsOnline.heading': 'Im Klubhaus' },
}

Because a theme's catalog is merged over the board's, the same mechanism lets a theme reword the board itself — registering nav.home renames Home everywhere, in every language you supply it for. Languages has the message syntax and the merge order.

Namespace your own keys. More than one theme can be registered on the same board — a member picks between them in Your control panel → Appearance — so two themes' catalogs are merged into the same registry at once. A bare key like heading collides the moment a second theme defines one; clubhouse. whoIsOnline.heading cannot, because no other theme owns the clubhouse. prefix. The app resolves your copy with its own translator — which already holds every registered theme's messages, not only the active one's — so it is your key names, not the merge, that keep themes apart.

A slot your theme fills but has nothing of its own to say for gets copy: {}. There is nothing to register for it, and nothing to read.

Theme switching

A member can switch the whole theme, components included. Every registered theme is in the bundle and resolved at module load — an extends chain cannot change between requests — but which resolved map a request renders is a per-request choice, made by currentTheme() from a cookie. The choice works with JavaScript off, and because the server reads it, the page arrives already correct: no flash, no second paint.

?theme=<key> on any page sets that cookie, so a theme can be linked rather than described: https://board.example/f/3-general?theme=phasebook opens that forum in Phasebook and keeps it for the rest of the visit. The middleware writes the cookie and redirects to the same URL without the parameter, so what a reader shares is the page, not the paint. The key is validated in currentThemeKey(): a theme that is not registered, or that the board has disabled, falls back to the board default rather than erroring.

Consequences for a theme author:

  • assertThemeContract runs over every registered theme, not only the board's default. An incomplete theme used to be a latent 500 on whatever page reached its missing slot; now that members can pick any enabled theme, it is a boot failure naming the slots.
  • A theme that fills no slots is a palette, and that is a supported shape: picking it repaints the board and leaves the markup to the default theme. It is how a board offers three looks without maintaining three sets of components.
  • Pairing rules matter. If a theme's ForumRow only makes sense inside its own CategoryBlock, both must be overridden together — a member can now switch to the theme and hit the combination an operator never would.

The post anchor

Resolving every href leaves the other end of a link to the theme. PostBit anchors each post at post-<post.number> — the number in its corner — and that is the whole scheme: permalink points at it, and so does every link the board writes once the thread page has resolved it.

Nothing links a post by its id in a fragment. A link that has to survive deletions carries ?post=<id> in the query instead, and the thread page turns that into the page holding the post plus this anchor. A theme that anchors a post by post.id leaves every such link at the top of the page.

What the freeze covers

Covered Not covered
The name and kind of every stable slot A provisional slot's name and kind are stable, but nothing today is provisional — see below
The fields of the model a stable slot is handed Fields of a provisional slot's model
defineTheme, resolveTheme, requireSlot, slotCopy, hasSlot, assertComplete, assertThemeContract, checkThemeContract Anything not re-exported from packages/theme-kit/src/index.ts
SLOTS, SLOT_NAMES, SLOT_STABILITY, isSlotName, slotKind The markup, class names and token values of the shipped themes

Provisional slots

A slot is named in the registry as soon as it is designed, which can be before any page renders it. Naming it early keeps the slot list from being retrofitted onto a finished page later, but a props contract nobody has rendered yet is a guess — so a new slot ships provisional in SLOT_STABILITY, excluded from the freeze and from assertThemeContract, until a page actually hands its model to a component. Once that happens the slot is promoted to stable in the same change, per the shape that turned out to render rather than the one guessed at the outset.

QuickReply and EditorToolbar — the registry's only client slots — carried this status the longest: named from early on, rendered by no page until 0.17. Nothing is provisional today; every slot in the registry has been rendered at least once.

Versioning

THEME_API_VERSION (currently 0.17) is major.minor, and both halves are promises:

Bump What may land What it costs you
minor Additive only: a new slot, a new optional model field, a new export Nothing. Every existing theme keeps working; upgrading is a redeploy
major Removals and renames — but only for things scheduled through DEPRECATIONS at least one major earlier Work you were warned about

There is no patch component: this is a type-level contract with no runtime behaviour of its own, so a bug fixed in resolveTheme is a package version, not an API version.

Deprecation

No slot is currently deprecated. One field is: PostBitModel.quoteSource, deprecated in 0.5 and scheduled out at 1.0 in favour of PostBitModel.post.id. It is the first entry through this machinery — and it is machinery, not prose:

  1. Mark and schedule. The slot is marked deprecated in SLOT_STABILITY, and an entry is added to DEPRECATIONS naming when it was deprecated, which major removes it, what replaces it, and why. assertDeprecationPolicy refuses a mark with no schedule and a schedule with no mark.
  2. It keeps working. A deprecated slot is still required of a theme, because a page still renders it in this version.
  3. It is reported. checkThemeContract lists deprecated slots a theme still fills in deprecatedInUse, so the admin theme screen and a theme's own CI can both see the removal coming. A deprecated field is visible in the type and the generated reference instead — no runtime report can tell whether a theme reads a prop.
  4. It is removed at the scheduled major — and if it is not, the build fails. assertDeprecationPolicy throws once the current version reaches removeIn. A deadline that can pass quietly is how a deprecation becomes permanent, so this one cannot.

A field is scheduled the same way, as Model.field. A whole model is never deprecated on its own: a model exists because a slot is handed it, so removing the slot is the deprecation.

Tokens

A theme ships LIGHT_TOKENS and DARK_TOKENS using the same names the default theme declares. globals.css maps each name to a Tailwind utility, so a renamed token is a utility pointing at nothing. The values are the theme's own.

Only the default theme's values are compiled into the stylesheet. Any other theme's palette is emitted into <head> as the difference from that baseline, so a board on the default theme pays nothing for the mechanism, and a board on any other theme gets its colours without redeploying CSS.

The cascade, in order:

compiled defaults                       (globals.css)
  → the board default theme's values + its overrides + its custom CSS   :root / .dark
    → each other enabled theme's difference from that   [data-theme="<key>"]

The scoped blocks carry only what a theme disagrees with the board default about — not its difference from the compiled stylesheet — because the unscoped block is still in force when data-theme names another theme. Diffing against the wrong side is the bug that leaks one theme's brand colour into another's palette with nothing failing anywhere.

Operator overrides (themes.token_overrides) are keyed by colour scheme:

{ "light": { "primary": "#1d4ed8" }, "dark": { "primary": "#93c5fd" } }

A flat { "primary": "…" } map is still read and means both schemes — that is what rows written before per-scheme overrides existed hold, and what an exported version-1 document carries.

BROWSER_THEME_COLOR is the one place a literal colour belongs in a theme: <meta name="theme-color"> is ignored by Safari and older Chrome when given oklch(). Keep it equal to the two background tokens converted to hex — a test enforces the pair, because a hand-written copy goes stale silently.

Converting a token to sRGB

@meith/theme-kit exports the colour maths the board itself uses on tokens — parseColour, oklchToRgb, rgbToOklch, rgbToHex, formatOklch, relativeLuminance and colourToHex, the last of which takes either notation and answers a six-digit hex or null.

They are exported because more than one surface needs them and every copy is a copy that drifts: <meta name="theme-color">, the contrast readouts and the OKLCH picker on the theme screen, and outgoing mail, which must be hex because no mail client parses oklch(). A theme that computes a colour of its own should reach for these rather than carry its own matrices.

colourToHex answers null for anything it cannot read — a named colour, an rgb() string, a color-mix() — so a caller falls back to a value it chose rather than emitting something a client will ignore.

The default palette is neutral on purpose

Every greyscale token the default theme ships is at chroma zero. The one colour in the palette is primary, so a board brands itself by overriding one group — primary, primary-hover, primary-foreground, ring, or a single press of a brand preset on the theme screen — and nothing else in the palette carries a hue to clash with it.

Two conventions follow, and both are conventions rather than contract:

  • accent is a hover surface, not a highlight. It carries shadcn/ui's meaning here. Anything that needs to shout uses a semantic token.
  • Link text is weight and an underline; only the underline takes primary. Colouring the text itself would put the operator's brand choice between members and the words they are reading.

A theme is free to disagree — deliberately. themes/clubhouse is the shipped disagreement: a club's colours are the point of a club's site, so primary marks a dozen surfaces there rather than four, and each one reads its text back as primary-foreground, the pair the contrast checks already measure.

Components: @meith/ui

@meith/ui is shadcn/ui's component vocabulary implemented on Base UI (@base-ui/react). It is available to themes, and the shipped default theme is built from it.

The package is split by rendering cost, and the split is the thing to understand before importing from it:

Import What it is
@meith/ui Everything that renders on the server: Card, Badge, Alert, Avatar, Field, Input, NativeSelect, Separator, Empty, Disclosure, plus the buttonVariants and badgeVariants class recipes
@meith/ui/button The Base UI Button — a "use client" island
@meith/ui/menu The Base UI Menu — the other "use client" island

Nothing reachable from the main barrel declares "use client", which is what makes it safe in a server slot. PostBit renders fifty times on a thread page; a design system that pulled a client boundary in behind a <Card> would give away the property the slot registry exists to protect.

That is also why buttonVariants is a separate export from Button. Most buttons on a forum are not buttons — "New thread" is a link, "Mark read" is a native form submit — and both want the class recipe on a plain element:

<a href={newThreadHref} className={buttonVariants({ variant: 'primary' })}>
New thread
</a>

Reach for @meith/ui/button only when the control genuinely lives in an island.

None of this is required. @meith/theme-kit is the only dependency a theme needs, and a theme that builds its own markup from scratch (as themes/midnight largely does) is a supported thing to be.

Form controls are 16px on a touch screen, whatever a theme asks for

globals.css ends with one rule, outside every Tailwind layer, that sets font-size: 1rem on input, select and textarea under @media (pointer: coarse). Being unlayered, it beats a utility class: text-sm on an input is honoured on a desktop and overruled on a phone.

It is there because iOS Safari zooms the page in when a control with text smaller than 16px takes focus, and does not zoom back out afterwards. Every form on the board — signing in, registering, the reply box, search — was built at text-sm, so every one of them jumped on the way in and left the member on a page wider than their screen. The rule is scoped to coarse pointers, so a theme's density on a desktop is untouched.

A theme that wants a larger control on a phone can still have one: the rule sets a size, it does not lock it, and a selector of higher specificity (or another unlayered rule loaded after) wins. Going below 16px is the thing that brings the zoom back.

Testing a theme

apps/community/src/theme/contract.test.ts renders every theme registered in community.config.ts through every stable slot with the same fixture models, and asserts the properties that are true of any theme:

  • Required slots are filled.
  • Each one renders.
  • The values a reader is owed appear in the output.
  • Nothing renders [object Object], undefined, or an empty href.
  • No server slot emits a script.

Registering a theme enrols it — there is no list to add yourself to.

The suite deliberately does not assert appearance. A theme is free to be a table, a card grid or a wall of text; a suite that required matching the default theme's markup would make the second theme's job "look like the first", which is the opposite of the point.

The generated reference is a gate

Theme slots is written by scripts/theme-api-docs.mjs from the source files that are the contract. pnpm verify and CI run pnpm theme:docs:check, which fails when the file and the code disagree — so a change to the theme contract cannot land without the documentation change appearing in the same diff, which is exactly when a reviewer should be asked whether the change is allowed at all.

If the check fails, run pnpm theme:docs and commit the result.