wdoc · reference

Themes

28 min read · 2026-08-22 · wcl 0.33.2-alpha

A wdoc site has no stylesheet on disk. The build computes everything it looks like from two kinds of block, and this chapter covers the first. A theme is a pair of colour palettes, up to three font stacks and a type scale. The second kind — the structured CSS blocks class, base, nest, media, keyframes, font_face and style — is Styling rules, the chapter after this one. Read this one first: the cascade the two layers form is described here, and the rules chapter assumes it.

Everything below was built and read back out of the emitted HTML. Type the examples and compare.

Two layers, one cascade

The two layers do not compete. A theme sets CSS custom properties: --wdoc-bg, --wdoc-fg, --wdoc-blue, thirty-one of them. Every built-in rule paints with var(--wdoc-…) rather than with a colour. So the theme decides the colours and the rules decide the shapes, and a new block type needs no theme work at all: it references the variables and it is themed.

The rules themselves arrive in three groups, and the build emits them into one <style> element in this order:

Same specificity, later wins. That single ordering is the whole override story: your class "wdoc-series-1" beats the theme's, which beats the library's, and you never write !important. The section on emission order shows the three rules for one class stacked up in real output.

Selecting a theme

Selection is one field on the site block. Save this as handbook.wcl:

handbook.wclwcl
# handbook.wcl — a book site with a theme, an accent and a toggle.
import <wdoc.wcl>

site handbook {
  default_template = :book
  title            = "Handbook"
  theme            = :gruvbox     # names a `theme` block
  accent           = :green       # one of the eight hue roles
  theme_toggle     = true         # adds the light/dark button

  toc {
    chapter "Colour" { page = colour }
  }
}

page colour {
  title = "Colour"

  h1 "Colour"

  p "Nothing on this page names a colour."
}
console
$ wcl wdoc build handbook.wcl --out _site
wrote 1 page

Open _site/colour.html and the <style> element holds the palette, as literal hex, on :root:

css
:root{--wdoc-bg:#1d2021;--wdoc-book-bg:#282828;--wdoc-bg-alt:#32302f;
      --wdoc-fg:#d5c4a1;--wdoc-heading:#ebdbb2;--wdoc-accent-pal:#fe8019;
      --wdoc-link:#83a598; /* … 31 roles in all … */}
:root{--wdoc-accent:var(--wdoc-green);}

Three fields did that. theme = :gruvbox chose the palette. accent = :green pointed the active accent at the palette's green. theme_toggle = true added a button. The page itself named no colour, and that is the point. Documents, pages and sites covers the rest of the site block.

A site-less document is unthemed

The build emits the palette per site. A document with no site block gets the library defaults and nothing else: no --wdoc-* variables at all. That is deliberate: a bare page fragment should not carry a page background. (The bundled @font-face rules still ship, because they are plain top-level font_face blocks rather than part of a theme.) A site that declares no theme gets :forge, and so does a theme name that resolves to nothing.

What a theme is

A theme block is a name, up to three font stacks, an optional metrics child holding the type scale, and a palette child per mode:

wcl
theme midnight {
  font_head = "'Inter', system-ui, sans-serif"
  font_body = "'Inter', system-ui, sans-serif"
  font_mono = "'JetBrains Mono', ui-monospace, monospace"

  metrics { body_size = "16px"  measure = "46rem" }

  palette dark  { bg = "#0b0b14"  fg = "#e6e6f0"  blue = "#8be9fd" }
  palette light { bg = "#fafafe"  fg = "#1b1b28"  blue = "#0060a0" }
}

The palette label is the mode, and only two labels mean anything: dark and light. A palette labelled anything else is ignored, not rejected. Every colour role on a palette is optional.

metrics has no mode, because type does not change between light and dark. It is covered under Typography, below the fonts.

The thirty-one palette roles

A palette declares up to thirty-one colour roles, in five groups. They are generic on purpose. The roles describe what a colour is for, not which block uses it, so a block type added later already has what it needs.

GroupRolesWhat paints with them
Surfacesbg, book_bg, bg_alt, bg_inset, overlay, border, border_strongPage gutter, reading column, sidebars and code cards, table headers, rules, kbd outlines
Inkfg, fg_muted, fg_subtle, heading, selectionBody text, captions and nav, comments and metadata, headings and strong text, text selection
Accentaccent, accent_2, link, on_accentActive nav and markers, secondary highlights, hyperlinks, text drawn on an accent fill
Syntaxsyn_kw, syn_str, syn_num, syn_fn, syn_type, syn_comment, syn_punctCode-block highlighting. See Code
Hue ringred, orange, yellow, green, cyan, blue, purple, pinkChart series 1–8, callout accents, diagram shape borders, timeline phases

The hue ring is the one to understand, because three separate systems read it and they must agree. Chart series 1 through 8 are blue, green, yellow, red, purple, cyan, orange, pink in that order. Callouts key on it too, one hue per kind: note takes blue, info cyan, tip purple, success green, warning yellow, error red. Flowchart shapes take blue for process, orange for decision, green for terminator. Move a theme's green and every one of those follows.

Because the six callout kinds take six hues, a ring that repeats a colour makes two kinds look alike. All seven built-in themes keep those six distinct in both modes, and a test in the build asserts it. Fill the ring the same way in a theme of your own: eight distinct colours, or two admonitions that mean different things will paint the same.

From role to custom property

A role becomes a custom property by lower-casing the name and turning underscores into hyphens: bg_alt becomes --wdoc-bg-alt, syn_kw becomes --wdoc-syn-kw. There are exactly two exceptions, both in the accent group:

RoleCustom propertyWhy
accent--wdoc-accent-pal--wdoc-accent is the active accent, set separately. See The accent hue
accent_2--wdoc-accent2No hyphen before the digit

A role you leave out emits no property at all. That matters more than it sounds: there is no second source of --wdoc-* in a themed site, so an omitted role is an undefined variable, not an inherited one. A rule that reads it becomes invalid at computed-value time and the property falls back to its initial or inherited value. That value is rarely the colour you wanted.

So a theme is normally all-or-nothing per role — unless it inherits. extends gives a theme a second source for every role it does not state, which is what makes a partial palette safe to write. See Inheriting a theme.

One renderer is the exception, and it is worth knowing about before you write a partial palette. The wireframe renderer bakes concrete colours into its SVG rather than referencing variables, because it has no CSS to lean on once that SVG is embedded in a PDF. That path resolves eight roles — book_bg, bg_alt, bg_inset, overlay, border, fg, fg_muted and the accent hue — and fills any the palette omits from the built-in Forge palette of the same mode. So a wireframe on a half-written theme reads Forge where the theme is silent, rather than reading nothing. See Wireframes.

The stylesheet a theme emits

The theme layer is a fixed sequence of rule blocks, always in this order. Reading it top to bottom explains every light and dark behaviour in the rest of the chapter:

#RuleCarries
1:root { --wdoc-font-head … }The default font stacks
2:root { … }The dark palette. Dark is the default mode
3@media (prefers-color-scheme: light) { :root { … } }The light palette, for a reader whose system prefers light
4:root[data-theme="dark"] { … }The dark palette again, for the explicit toggle
5:root[data-theme="light"] { … }The light palette again, for the explicit toggle
6.wdoc-theme-dark { … }The dark palette scoped to a subtree. See Scoped palettes
7.wdoc-theme-light { … }The light palette scoped to a subtree
8:root { --wdoc-font-* }The theme's own font stacks, overriding rule 1. Emitted only if the theme sets any
9:root { --wdoc-accent: … }The active accent
10The apply rulesEverything that reads the variables: body, links, headings, code cards, callouts, tables, charts, diagram shapes, trees

Rules 2 to 7 are the same two lists of declarations, each written three times over: once for the default, once for the toggle, once for a scoped subtree. That redundancy is what makes the toggle and the side-by-side preview work. Rule 10 is where those variables finally become visible styling.

Light and dark

Three mechanisms choose a palette, and they are layered by specificity rather than by cascade order.

That third point is why rules 4 and 5 above both exist. Without the data-theme="dark" copy, a reader on a light-preferring system could press the button and see nothing change: the media rule would still be matching.

Scoped palettes

Rules 6 and 7 are the interesting ones. .wdoc-theme-dark and .wdoc-theme-light re-declare the whole palette on an ordinary class rather than on the root. Custom properties inherit, so the nearest ancestor carrying one of those classes decides the colours for everything inside it, whatever the reader's global mode.

That is how a demo block shows the same content under both palettes at once. It renders its children twice, wrapping one copy in each class. Here is a live one. The callout below is authored once and painted twice:

Preview

Tip

Author once, see both. The light pane comes first, the dark pane second, and the accent stripe differs because a tip callout paints it with var(--wdoc-green).

Tip

Author once, see both. The light pane comes first, the dark pane second, and the accent stripe differs because a tip callout paints it with var(--wdoc-green).

Example

callout "Tip" {
  kind = :tip
  body = "Author once, see both. The light pane comes first, the dark pane second, and the accent stripe differs because a `tip` callout paints it with `var(--wdoc-green)`."
}

The classes are not private to demo. They are two ordinary class rules in the site stylesheet, so any block whose class field you can set re-scopes the palette for its own subtree:

wcl
callout "Always light" {
  class = ["wdoc-theme-light", "note"]
  body  = "This box keeps the light palette even in dark mode."
}

Scoped palettes move variables, not mode blocks

A scoped palette re-declares custom properties. It does not re-evaluate a class's dark {} and light {} blocks, because those hang off prefers-color-scheme and :root[data-theme], and a wrapper class can change neither. So a class that paints with var(--wdoc-accent) flips inside a demo pane, and a class that carries mode blocks shows the same colour in both panes. When you want a class to follow a scoped palette, paint it with a variable.

That claim is easy to check rather than take on faith. This chapter's source declares two classes at the top: ch19-var paints with var(--wdoc-accent), and ch19-mode sets #88c0d0 in a dark {} block and #5e81ac in a light {} one. The demo below applies both:

Preview

Painted with var(--wdoc-accent) — different in each pane.

Painted with dark {} / light {} — the same in both panes.

Painted with var(--wdoc-accent) — different in each pane.

Painted with dark {} / light {} — the same in both panes.

Example

p "Painted with var(--wdoc-accent) — different in each pane." {
  class = ["ch19-var"]
}
p "Painted with dark {} / light {} — the same in both panes." {
  class = ["ch19-mode"]
}

Demo blocks covers the block itself, including the diagram = true layout and how it degrades on the Markdown and PDF targets.

The accent hue

--wdoc-accent is the colour of active navigation, heading markers, chapter kickers, footnote references, badges and blockquote rules. One generated rule at the end of the palette block sets it, and that rule points at another variable rather than at a colour:

site.accentRule emittedEffect
One of :red :orange :yellow :green :cyan :blue :purple :pink--wdoc-accent: var(--wdoc-green)The named hue from the ring, in whichever mode is active
Absent, or any other symbol--wdoc-accent: var(--wdoc-accent-pal)The palette's own accent role, the theme's designed choice

Because the rule points at a variable and not a colour, the accent follows the mode for free: accent = :green picks gruvbox's #b8bb26 in dark and its #79740e in light, with no second declaration anywhere.

The default accent is the theme's, not blue

A site that sets no accent gets var(--wdoc-accent-pal), the accent role its palette declares. Gruvbox's orange, rosé's mauve, forge's blue. There is one place where an unrecognised hue name does fall back to blue, and it is the separate path that themes wf_* wireframe elements: it reads ui_accent, falls back to accent, and blues anything it does not recognise. The document's own accent never does.

The seven built-in themes

Seven theme blocks ship inside wdoc, each with a co-ordinated dark and light palette. They are ordinary blocks in the embedded library, so they are visible to your document without an import beyond import <wdoc.wcl>, and you select one by name.

forge

The default, and the one a site gets when it names no theme or names one that does not resolve. A compact, high-contrast palette whose colours are hex equivalents of the Forge design system's OKLCH tokens, so they survive the SVG and PDF renderers that cannot evaluate OKLCH. It is the only built-in that sets all three font stacks, and all eight of its hues are distinct in both modes, which makes it a safe choice for an eight-series chart.

nord

Cool blue-grey, the classic Nord palette. Dark reads on #2e3440, light on #eceff4. Its ring spends the full Nord frost and aurora range — #88c0d0 for cyan, #81a1c1 for blue, #b48ead for purple — so all eight hues differ in both modes. This book is set in nord.

tokyonight

A vivid night palette: near-black #1a1b26 under saturated blues and purples, with a desaturated light mode built on #e1e2e7 rather than white. Seven distinct hues in each mode. purple and pink share a value.

gruvbox

Warm retro. Dark is #282828 under muted earth tones, light is the cream #fbf1c7 that gives it its character, the warmest light surface of the seven. It is also the one built-in that sets an orange palette accent, so a gruvbox site that names no accent reads warm throughout. Seven distinct hues per mode: purple and pink share Gruvbox's one mauve.

catppuccin

Soft pastel: Mocha for dark, Latte for light. Lower contrast than forge or tokyonight by design, with a mauve accent and a blue link. Seven distinct hues per mode.

rose

Rosé Pine: muted rose, Main for dark and Dawn for light. Its Dawn surface is a near-white with a warm cast, #fffaf3, and its accent is a mauve paired with a teal link. That pairing is unusual, and it is the reason the theme reads calmer than its saturation suggests. Seven distinct hues per mode: orange and pink share Rosé Pine's rose. The palette has no green of its own, so green is a muted sage picked to sit with foam without matching it.

paper

A warm print look. It is the only built-in that overrides just one font stack: font_head becomes 'Source Serif 4', Georgia, serif, leaving body and mono at the defaults, so headings are serif over a serif body. Its light mode is near-white #fcfbf7, its dark mode a warm near-black, and all eight hues differ in both — muted enough for prose without collapsing a chart or a callout.

Comparing them

The table below is the comparison a per-theme description cannot make: the reading surface each theme puts your text on, the accent it chooses for itself, and how many of the eight hue roles differ. The last column is the one that decides whether a chart with six or eight series is readable.

ThemeDark surfaceLight surfacePalette accent (dark / light)Distinct hues (dark / light)Fonts
forge#11141a#ffffff#2389e2 / #0069ca8 / 8Sans headings and body
nord#2e3440#eceff4#88c0d0 / #5e81ac8 / 8Defaults
tokyonight#1a1b26#e1e2e7#7aa2f7 / #2e7de97 / 7Defaults
gruvbox#282828#fbf1c7#fe8019 / #af3a037 / 7Defaults
catppuccin#1e1e2e#eff1f5#cba6f7 / #8839ef7 / 7Defaults
rose#1f1d2e#fffaf3#c4a7e7 / #907aa97 / 7Defaults
paper#211e1b#fcfbf7#d98b8b / #8a1c1c8 / 8Serif headings

No theme repeats a hue among the six the callout kinds use. Where a count is 7 rather than 8, the repeat is between orange and pink, which only an eight-series chart notices.

"Defaults" means IBM Plex Sans for headings, Source Serif 4 for body copy and JetBrains Mono for code. See Fonts.

Writing your own theme

A theme is a block, so declaring one is the same work as declaring anything else. Give it a name, give it two palettes, and select it by symbol:

house.wclwcl
import <wdoc.wcl>

theme house {
  font_head = "'Inter', system-ui, sans-serif"
  font_body = "'Inter', system-ui, sans-serif"

  palette dark {
    bg      = "#0e0e12"   book_bg = "#15151c"   bg_alt  = "#1c1c26"
    border  = "#2a2a38"   fg      = "#dcdce6"   heading = "#ffffff"
    accent  = "#7aa2f7"   link    = "#7aa2f7"
    red = "#f7768e"  orange = "#ff9e64"  yellow = "#e0af68"  green = "#9ece6a"
    cyan = "#7dcfff"  blue = "#7aa2f7"  purple = "#bb9af7"  pink = "#ff75a0"
  }

  palette light {
    bg      = "#f5f5f7"   book_bg = "#ffffff"   bg_alt  = "#ececed"
    border  = "#d8d8de"   fg      = "#22222a"   heading = "#000000"
    accent  = "#2e5bd9"   link    = "#2e5bd9"
    red = "#c2255c"  orange = "#b15c00"  yellow = "#8c6c3e"  green = "#2f7a2f"
    cyan = "#07879d"  blue = "#2e5bd9"  purple = "#7040c0"  pink = "#b3306f"
  }
}

site handbook {
  default_template = :book
  title            = "Handbook"
  theme            = :house
  theme_toggle     = true

  toc { chapter "Colour" { page = colour } }
}

page colour {
  title = "Colour"

  h1 "Colour"

  p "A house palette, no accent field: the palette's own accent drives it."
}

Two habits turn that into a good theme. Start from a built-in — by extending it, as the next section shows — rather than from the six roles you happen to think of. And fill the hue ring even if you draw no charts, because callouts and diagram shape borders read it too.

A theme block is document-level rather than per-site, and each site picks one by name. So a multi-site document may declare one theme and select it everywhere, or declare five and give each site its own. What is not document-level is the emission: the build writes the palette into each site's stylesheet separately, so a theme nothing selects costs nothing.

Inheriting a theme

Most themes are not new designs. They are a shipped theme with one thing changed — a narrower measure, a different heading face, one corrected colour. Writing those from scratch means restating all thirty-one roles across two palettes to change one number, and that copy goes stale the day the theme it was copied from moves.

extends names a theme to inherit from:

narrow.wclwcl
theme narrow {
  extends = :forge
  metrics { measure = "46rem" }
}

site handbook { theme = :narrow }

That renders as forge in every respect — thirty-one roles, both palettes, all three font stacks, the whole type scale — with a 46rem reading column. Three lines, and it tracks forge when forge changes.

Inheritance is resolved per role, not per palette. A theme that extends forge and declares palette dark { bg = "#000000" } overrides that one colour and inherits the other thirty dark roles, the entire light palette, the fonts and the metrics. The same is true one metric at a time, and one font stack at a time. Anything the derived theme states wins; everything else falls through to the theme it extends.

A chain may be any depth — a house theme extends a built-in, a project theme extends the house theme — and each role resolves to the nearest link that states it. Two rules keep a chain finite:

CaseWhat happens
extends names no themeThe chain ends there. The theme keeps its own roles and inherits nothing
The chain loops back on itselfThe chain ends at the repeat. Every theme in the loop still contributes what it states

An unknown extends is not the same as an unknown site theme

site { theme = :nonexistent } falls back to forge, because a site must render something. extends = :nonexistent does not — it ends the chain instead. Silently splicing forge into the middle of an inheritance chain would hand a theme colours nobody asked for, and hide the typo.

A theme with no colour at all

metrics and the font stacks live inside a theme block, so wanting one of them means declaring a theme — and a theme that declares no palette and extends nothing has no colours to emit. Most bundled rules read colour as a bare var(--wdoc-bg) with no fallback, so the page renders with browser defaults: white background, black text, no accent. It looks like a stylesheet failed to load.

The build says so, once per mode, and still exits 0 — nothing is malformed, and the pages are written:

text
$ wcl wdoc build narrow.wcl --out _site
warning: theme "narrow" states no dark palette, directly or through `extends`,
  so every `--wdoc-*` colour is undeclared and the page renders unstyled —
  add a `palette dark { … }`, or inherit one with `extends = :forge`
wrote 1 page

Either fix clears it: give the theme its own palette dark and palette light, or add extends.

Fonts

The three stacks

The templates read exactly three font variables. --wdoc-font-head sets headings, sidebars, table headers and badges. --wdoc-font-body sets body copy. --wdoc-font-mono sets code, kbd, heading markers and chapter kickers. The build emits their defaults for every themed site:

css
:root { --wdoc-font-head:'IBM Plex Sans',system-ui,sans-serif;
        --wdoc-font-body:'Source Serif 4',Georgia,serif;
        --wdoc-font-mono:'JetBrains Mono',ui-monospace,monospace; }

A theme's font_head, font_body and font_mono fields override them, one at a time. The build emits the theme's rule after the defaults, and only for the fields the theme sets. That is how paper gets serif headings over an unchanged serif body, and how forge gets sans body copy. All three take a complete CSS font stack, quotes and fallbacks included, because the build writes the value into the property verbatim.

The wcl binary embeds those three families, and the build writes them into the output's _wdoc/ folder as @font-face rules alongside the other shared assets. They resolve when the site is served, not when a page is opened directly from disk.

Shipping your own font

Three pieces, and each is a block you already know. Declare the face, ship the file, and name the family in a theme:

wcl
# 1. Declare the face. The label is written into `font-family:` verbatim,
#    so quote a multi-word family *inside* the string.
font_face "'Inter'" {
  src     = "url('fonts/Inter-Regular.woff2') format('woff2')"
  weight  = "400"
  style   = "normal"
  display = "swap"
}

# 2. Ship the file. `assets` copies a folder verbatim into the site output,
#    keeping its name — so `fonts/Inter-Regular.woff2` is the output path.
site handbook {
  default_template = :book
  title            = "Handbook"
  theme            = :house
  assets           = ["fonts"]
}

# 3. Name the family. A theme's stacks are what the templates read.
theme house {
  font_head = "'Inter', system-ui, sans-serif"
  font_body = "'Inter', system-ui, sans-serif"
  palette dark  { bg = "#0e0e12"  fg = "#dcdce6" }
  palette light { bg = "#f5f5f7"  fg = "#22222a" }
}

That emits @font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('fonts/Inter-Regular.woff2') format('woff2'); } and copies fonts/ next to the pages. For a hosted font service, skip steps 1 and 2 and put the stylesheet URL in the site's fonts list. Each entry becomes a <link rel="stylesheet"> in every page head.

The family label is not quoted for you

font_face writes its label into font-family: exactly as given. font_face "Inter" emits font-family: Inter;, which is valid CSS for a single-word family and invalid for Source Serif 4. wdoc declares the bundled faces as font_face "'Source Serif 4'", with quotes inside the string, for that reason. Do the same and it is always right.

Typography

A font stack says which letterforms. It says nothing about how big they are, how far apart the lines sit, or how wide the column runs — and those decide whether a page is comfortable to read far more than the choice of face does. A metrics block holds them.

wcl
theme narrow {
  metrics {
    body_size   = "16px"
    line_height = "1.65"
    measure     = "46rem"
    h1 = "2.2rem"  h2 = "1.7rem"  h3 = "1.3rem"
    h4 = "1.1rem"  h5 = "1rem"    h6 = "0.85rem"
  }
  palette dark  { bg = "#101014"  fg = "#e6e6ea" }
  palette light { bg = "#fbfbfd"  fg = "#16161a" }
}

One metrics per theme, no mode label, every field optional.

FieldCustom propertyWhat it setsDefault
body_size--wdoc-body-sizeBody copy size17px
line_height--wdoc-line-heightBody leading, unitless1.7
measure--wdoc-measureThe book template's reading column60rem
h1h6--wdoc-h1--wdoc-h6The six heading steps2.6rem, 1.85rem, 1.35rem, 1.12rem, 1rem, 0.85rem

The scale is six explicit sizes rather than a base size and a ratio. A modular scale is fewer numbers and harder to get visually wrong, and it was the tempting choice — but the scale wdoc already shipped is not modular. Its steps run 1.41, 1.37, 1.21, 1.12 and 1.18, so no single ratio reproduces it, and adopting one would have reflowed every book written before this existed.

Nothing reflows as it is. Every bundled rule reads its metric through var() with the shipped constant as the fallback — font-size: var(--wdoc-h1, 2.6rem) — so an omitted field, or a theme with no metrics block at all, renders exactly as it did. The seven built-ins all declare the defaults explicitly, which is why they set the same type as each other.

The measure is the one to look at

60rem is the book template's reading column. Take off its 3.5rem of horizontal padding on each side and about 53rem of text is left, which at 17px is roughly 130 characters a line. Conventional long-form measure is 60 to 75. The width suits a wide code listing and is generous for prose.

Whichever way you want it, it is now a field rather than a CSS override — and if the measure is the only thing you are changing, extend a built-in and that is the whole theme:

wcl
// The only change from the shipped look.
theme narrow {
  extends = :forge
  metrics { measure = "46rem" }
}

// Or, in a theme that brings its own colours.
theme house {
  metrics { measure = "46rem" }
  palette dark  { bg = "#101014"  fg = "#e6e6ea" }
  palette light { bg = "#fbfbfd"  fg = "#16161a" }
}

Metrics reach the web output only

The PDF backend lays out with its own print type scale and does not read metrics. It reads a theme for colour — see Callouts — but a page size, a printed body size and a printed measure are print decisions, and print owns them.

Where to go next