0044: Themes are data a plugin ships: module.theme(...) offers them, hz system theme use picks one¶
Context and Problem Statement¶
The web head (ADR 0039) has one look: its colours, fonts and
radii are CSS custom properties in web/src/index.css, light and dark. The maintainer asked for a theming system: a
plugin can offer one or more themes (colours for light and dark, fonts, border radius and width), and the person
switches between the ones installed. A company's layer plugin (ADR 0040)
is the obvious place for its look, and the public reference company heinzel-acme should prove it. Every value of a
theme ends up inside a CSS declaration in the person's browser, under a CSP that allows no inline script and no
remote font (font-src 'self'). So the questions are what a theme is, how a plugin declares one, which one wins when
two plugins offer the same name, where the choice is kept, and what keeps a theme's values from becoming CSS of their
own.
Decision Drivers¶
- Heads are views (ADR 0003): the web app gets the themes and the choice from operations, the CLI and MCP get the same ones, and a plugin needs no UI code to offer a theme.
- A theme is data, not code: it can be listed, checked and tested without a browser, and it can't run anything.
- A value that becomes a CSS custom property must not be able to end its declaration (
;,}), load anything (url(), or reach another property (var(): checked when the plugin is imported, not trusted in the browser. - Nothing is sent home (ADR 0027): no web font service; fonts are the machine's own.
- Declared beats discovered by convention, and one mechanism for what a layer brings (ADR 0037, ADR 0040).
Considered Options¶
- A
ThemeLetper theme, declared withmodule.theme(Theme | Path); a theme file is TOML; consumed bysystem.theme;[system.theme] nameis a user setting (chosen) - A
themes/directory found by convention in every plugin package, like a playbook folder: ADR 0037 rejected exactly this for playbooks (another loader, nothing the kernel's let listing sees, no check at import), and a plugin's module already is the place it declares what it brings. The chosen option keeps the files (a designer edits TOML, not Python) and makes the module name them. - Themes as recommended settings of a playbook (
defaults={"system.theme...": ...}): a playbook may only recommend capability bindings and its own module's settings (ADR 0037), and a theme is a set of thirty-five values, not one setting; a person would have to accept each. - Free-form CSS from the plugin (a stylesheet or a map of any custom property): the most flexible and the least safe; every new property would be a way to restyle or hide anything, and the app's tokens would stop being a contract a theme fills.
- Remote fonts or bundled font files now: remote fonts break ADR 0027 and the CSP. Bundled files need a route
that serves a plugin's assets under
'self'and a format check; left as an extension (More Information). - Theme selection in the browser only (
localStorage): the CLI and MCP couldn't see or set it, a second browser starts from the default, and a layer can't one day recommend one.
Decision Outcome¶
Chosen option: a ThemeLet per theme, declared with module.theme(...) and consumed by system.theme, because a
theme stays typed data the kernel collects like a playbook, every value passes a grammar that can't leave its CSS
declaration, and the choice is an ordinary setting every head reads.
- A theme.
Theme(pydantic,heinzel):name(a slug, at most 64 characters),title(one printable line, at most 64),lightanddark(ThemeColours),font_sans,font_mono,radiusandborder_width; unknown keys are refused.ThemeColourshas exactly the app's tokens:background,foreground,muted,muted_foreground,card,border,primary,primary_foreground,accent,ok,warn,bad,ring,sidebar,input,popover; each becomes--color-<token>with underscores as dashes. Fonts, radius and border width are the same in both modes (--font-sans,--font-mono,--radius,--border-width). - The grammar. Colours are
#rgbor#rrggbb, kept lowercase. Lengths are0or a number of at most three integer and four decimal digits withpx,remorem: nocalc(),var(), percentages or viewport units. A font stack is one line of at most 300 printable ASCII characters: family names separated by commas, each either unquoted words that start with a letter (one leading dash allowed) and hold letters, digits and dashes (system-ui,-apple-system,Avenir Next) or a double-quoted name of letters, digits, spaces, dots, underscores and dashes ("SF Mono"); spaces around a name are dropped. Nothing else passes: no;,{,},(, backslash, single quote, quote inside a name, comment, newline or other control character, and no unquoted CSS-wide keyword (inherit,initial,unset,revert,revert-layer,default). The fonts are those installed on the machine; ending a stack in a generic family (sans-serif,monospace) is advice, so a missing font falls back. - Declaration.
module.theme(source)takes aTheme, a*.tomlfile of one, or a directory of them (read in name order), read and checked at import like a playbook's templates; each theme is oneThemeLetnamed after it, so a module can offer several and never two of one name. AThemeLetchecks its theme again when it is created, so a theme built without validation (Theme.model_construct) or a let added by hand can't skip the grammar, and both models revalidate nested instances. The namedefaultis refused: it is the built-in theme (DEFAULT_THEMEinheinzel.kernel, the app's palette ofweb/src/index.cssas hex), which always exists. - Ties. When several modules offer a theme of the same name, the more specific layer of the person's stack wins
(ADR 0040: company, org and site, team), then any layer in the stack over a module outside it, then the first
module by qualified name. The list names the hidden ones (
shadows), so nothing is overridden silently. ADR 0040 leaves open which of an org and the site wins; until it is decided, a name an org and the site both offer, with neither the team nor a more specific layer offering it, is left out of the list and reported inconflictswith who can decide it (the team, by offering the name), the same "team decides, no silent merge" rule ADR 0040 applies to settings. - Operations (
system.theme, ADR 0018: about Heinzelmännchen itself):hz system theme list(read-only; every head, the web head included) returns{selected, themes: [{name, title, module, plugin, selected, light, dark, font_sans, font_mono, radius, border_width, shadows}], conflicts, note, next}, the built-in theme first.hz system theme use <name>refuses a name no installed plugin offers and writes[system.theme] nameto the user file throughConfigEditor. Both read the configuration at every call, so a runninghz uisees a pick made in a terminal. - The setting.
[system.theme] name = "default"(HZ_SYSTEM_THEME__NAME), user scope: a projecthz.tomlcan't set it (ignored_in_project), because a repository someone else controls must not decide how a person's tool looks. A configured theme whose plugin is gone, or a value that isn't a theme name at all, falls back todefault, and the list says why and what to run; it never fails, so the web app always has a theme to apply. - The web app applies the selected theme's values as custom properties on
<html>(its own work, outside this record): it sets only the properties above, from the operation's typed result, never from free text. - Public API, additive (ADR 0026):
Theme,ThemeColours,ThemeLetandModule.themeinheinzel;DEFAULT_THEMEandfont_stack_probleminheinzel.kernel.
Consequences¶
- Good: a company's look is a TOML file and one line in its layer plugin; teams diverge by offering the same name in a more specific layer, as with everything else a layer brings.
- Good: the browser only ever receives values a strict grammar accepted at import, so a theme can't inject CSS, load a remote resource or hide part of the app.
- Good: the CLI and agents see and switch themes like any other setting.
- Bad: fonts are limited to what is installed; a company font needs the bundled-files extension below.
- Bad: the token set is now a contract between plugins and the app: a new token in the app is a new required field, and so a breaking change for every theme unless it gets a default derived from another token.
- Neutral: a layer can't yet recommend its theme; the person picks it. A theme recommendation would extend ADR 0037's list of what a playbook may recommend.
Confirmation¶
tests/unit/kernel/themes_test.py: the grammar of colours, lengths, font stacks (injection attempts:;,},url(,var(, quotes, backslashes, comments, newlines, non-ASCII characters, CSS-wide keywords) and titles; unknown and missing tokens; themes built withmodel_constructchecked again; reading theme files and directories with errors that name the file, the field and the rule;module.themerefusingdefaultand a second theme of one name.tests/integration/theme_test.py: with the Acme test layers (tests/fixtures/layers), the list through the CLI and through the web head behind its token, the field names above, the more specific layer winning a name over the company and over a plugin outside the stack, an org and the site offering one name as a reported conflict,usewriting the user file and refusing an unknown name, a running web head seeing a pick made elsewhere, the fallback when a plugin is gone or the configured value is no theme name, the environment and a project file.tests/unit/public_api_test.py: the new names.
More Information¶
- Requested by the maintainer, 2026-10-24: themes in plugins with light and dark colours, fonts, border radius and width; switching between those available; the reference company's theme as the proof.
- The reference company
heinzel-acmeshipsthemes/acme.tomlfrom its company layer plugin. - Open with ADR 0040: the org-site precedence; when it is decided, themes follow the same rule as settings.
- Extension, not decided: a theme a layer recommends; a per-mode font stack if a theme needs one. (Bundled font files, listed here first, are decided in the amendment below.)
Amendment 2026-10-24: type scale, density, icon colours and bundled fonts. Requested by the maintainer the same day: a theme sets the app's type scale, density and icon look, and ships its own font files. Additive: every new field has a default, so a theme written before this stays valid (the token-contract consequence above, met by defaults rather than new required fields).
- Two colours in
ThemeColours, both modes:accent_foreground(text and icons of the active navigation item and of links;foregroundwhen not given) andicon(icons that aren't active;muted_foregroundwhen not given), hex like the others, as--color-accent-foregroundand--color-icon. - Type and density on
Theme, with the built-in values the app uses:font_size(15px),font_size_small(13px),icon_size(18px),row_height(navigation and list rows,36px),radius_control(buttons, inputs, navigation items,0.5rem),radius(cards and dialogs, now0.75remby default and in the built-in theme),heading_weight(600),heading_tracking(0) andspacing(Tailwind's base step,0.25rem). Lengths take the length grammar above;heading_trackingmay also be negative (-0.5px);heading_weightis an integer from 100 to 900 (a TOML integer, not a string). The list returns each under its own name. - The built-in theme takes the app's current palette (
web/src/index.css): neutral greys with a terracotta primary, light and dark. - Font files. A theme file may declare
[[fonts]], at most 16:family(what its stacks call it: letters, digits, single spaces, dots, underscores and dashes, at most 64),file(a path relative to the theme file's directory: at most eight plain names of letters, digits, dots, underscores and dashes, none starting with a dot, ending in.woff2),weight("400"or a variable font's"100 900", 100 to 900, low to high) andstyle(normaloritalic). At import the file must exist, resolve (symlinks followed) inside the theme's directory, be a file, at most 2 MB, and start with the woff2 signature; aThemebuilt in Python can't ship fonts (no directory to be relative to). The let keeps the directory (ThemeLet.directory). - Serving. The web head serves font
nof a theme at/theme-fonts/<theme>/<n>.woff2, outside/apiand without the token, because the browser fetches fonts forFontFaceitself and can't send the header; nothing in a font file is private. Every other check of the web head applies (a loopback Host on its port, no other Origin, no cross-site or same-siteSec-Fetch-Site), and the CSP'sfont-src 'self'already allows it. The request matches a theme-name pattern and a number, never builds a path, and asksThemeFonts(an interface in the kernel, implemented bysystem.theme, so the head knows no module) for the theme that wins its name; the file is checked again as it is read, through one descriptor that follows no symlink, waits on no FIFO and must be a regular file, and on the bytes read, so a file swapped or removed since import is refused (404, a fixed message). Answers arefont/woff2,nosniff,Cache-Control: public, max-age=31536000, immutable. - The list returns
fonts: [{family, url, weight, style}]per theme;urlis the route above with?v=, a hash of the file's content, so a changed font is a new URL and the long cache is safe. A font whose file is gone is left out. The web app loads each with theFontFaceAPI, so no@font-facetext, inline style or new CSP source is needed. - Public API, additive:
ThemeFontandThemeFontsinheinzel,FONT_ROUTEinheinzel.kernel. - Confirmation:
tests/unit/kernel/themes_test.py(each new field's grammar with injection attempts, defaults for older themes, font paths that leave the directory by.., absolute paths, backslashes and symlinks, the size and signature checks, a file swapped after import) andtests/integration/theme_test.py(the list's shape, a font served with its type and cache headers and without the token, refused for an unknown index, theme or file name, for the theme a more specific layer hides, and for another Host, Origin or site).
Amendment 2026-10-24: logos and a main tint. Requested by the maintainer the same day: a theme brings its logo, and the main content area can take a tint of its own. Additive like the amendment above: both are optional, so every theme written before stays valid.
- A logo. A theme file may declare
[logo](ThemeLogo):light(the logo on a light background),dark(lightwhen not given),mark(a square one for narrow places such as a collapsed sidebar; none when not given),alt(what it says, for screen readers: one printable line, at most 64 characters, required) andheight(the length grammar above,24pxby default). The files follow the font files' path rules (relative, plain names, none starting with a dot, inside the theme's directory after symlinks are resolved, a regular file), end in.svgand are at most 200 KB. At import each must be UTF-8 XML whose root is<svg>, and is refused if it holds a DOCTYPE or an entity (so nothing expands or loads before parsing), a<script>or<foreignObject>(case-insensitive), an event-handler attribute (on...=), anhref,xlink:hreforsrcthat isn't a same-document fragment (#id), aurl(...)other thanurl(#id),@import,image-set(, anxml-stylesheetlink, or a<set>/<animate>that rewrites a reference or a handler: no external file, nodata:URI. As with fonts, aThemebuilt in Python can't ship one. - Serving. The web head serves them at
/theme-assets/<theme>/<light|dark|mark>.svg(LOGO_ROUTE), outside/apiand without the token, because an<img>can't send the header, under every other check the font route has, throughThemeFonts.logo(the same interface, so the head still knows no module), re-checked on read through the same descriptor that follows no symlink and on the bytes read. Answers areimage/svg+xml,nosniff, the font route's immutable cache, and a CSP of their own,default-src 'none'; style-src 'unsafe-inline'(LOGO_CSP), so a logo opened on its own loads and runs nothing even if a check missed something. - The list returns
logo: {light, dark, mark, alt, height} | nullper theme: relative URLs with?v=(a hash of the content),darkthe light one's URL when the theme declares none or its file is gone,marknull when there is none; a logo whose light file is gone is null. - The built-in theme's logo is a swarm of Heinzelmännchen (
heinzel-swarm.svg, whichmake iconscopies) and its name as a wordmark, light and dark with explicit fills, and the mark alone: package data of the kernel (heinzel/kernel/assets/theme), asDEFAULT_LOGObesideDEFAULT_THEME, served for the theme namedefault.DEFAULT_THEMEitself names no logo, so a plugin can still copy it into a theme of its own. - A main tint.
ThemeColours.main_tint(both modes, optional, so light and dark can differ): a hex colour behind the main content area,--color-main-tint, which the web app sets only when a theme gives one. - Public API, additive:
ThemeLogoinheinzel;ThemeLogo,ThemeLogoFiles,DEFAULT_LOGO,LOGO_ROUTEandLOGO_CSPinheinzel.kernel;ThemeFonts.logo. - Confirmation:
tests/unit/kernel/themes_test.py(logo paths that leave the directory by.., absolute paths and symlinks, the size limit, files that aren't SVG, scripts, handlers,foreignObject, external anddata:references, DOCTYPEs and entities, same-document references allowed, the defaults and the dark fallback, a file swapped after import, the built-in logo's files,main_tint's grammar) andtests/integration/theme_test.py(the list's shape with the built-in and Acme logos, a logo served with exactly the headers above and without the token, refused for an unknown name or theme and for the theme a more specific layer hides, and for another Host, Origin or site).