Themes and styling
A wdoc site has no stylesheet on disk. Everything it looks like is computed at build time from two kinds of block: a theme, which is a pair of colour palettes, and a family of structured CSS blocks — class, base, nest, media, keyframes, font_face and style — which are CSS rules written as typed WCL rather than as a string. This chapter covers both, the cascade that joins them, the seven themes that ship, and how to replace any part of it.
Everything below was built and read back out of the emitted HTML. Type the examples and compare.
§ 1Two 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 — and 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:
- Library rules — the bundled class and base blocks that ship inside wdoc (lib/css-classes.wcl and its neighbours). They stand on their own without a theme, so a document with no site block still renders legibly: most carry a literal colour, and the syntax-token rules carry a variable with a literal fallback — color: var(--wdoc-syn-kw, #cf222e).
- The theme — the custom properties for the selected palette, followed by the rules that apply them (lib/theme-rules.wcl). These overwrite the library colours.
- Your rules — every class / base / media / keyframes / font_face block you write yourself.
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.
§ 2Selecting a theme
Selection is one field on the site block. Save this as handbook.wcl:
# 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."
}
$ 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:
-- --/* … 31 roles in all … */}
}
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 palette is emitted 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.
§ 3What a theme is
A theme block is a name, up to three font stacks, and a palette child per mode:
theme midnight {
font_head = "'Inter', system-ui, sans-serif"
font_body = "'Inter', system-ui, sans-serif"
font_mono = "'JetBrains Mono', ui-monospace, monospace"
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.
§ 3.1The 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.
| Group | Roles | What paints with them |
|---|---|---|
| Surfaces | bg, book_bg, bg_alt, bg_inset, overlay, border, border_strong | Page gutter, reading column, sidebars and code cards, table headers, rules, kbd outlines |
| Ink | fg, fg_muted, fg_subtle, heading, selection | Body text, captions and nav, comments and metadata, headings and strong text, text selection |
| Accent | accent, accent_2, link, on_accent | Active nav and markers, secondary highlights, hyperlinks, text drawn on an accent fill |
| Syntax | syn_kw, syn_str, syn_num, syn_fn, syn_type, syn_comment, syn_punct | Code-block highlighting — see Code |
| Hue ring | red, orange, yellow, green, cyan, blue, purple, pink | Chart 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 — note takes blue, info cyan, tip and 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.
§ 3.2From 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:
| Role | Custom property | Why |
|---|---|---|
| accent | --wdoc-accent-pal | --wdoc-accent is the active accent, set separately — see The accent hue |
| accent_2 | --wdoc-accent2 | No 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 — which is rarely the colour you wanted. Set every role you paint with, or start from a copy of a built-in palette and edit it.
One place an omitted role does fall back
The wireframe renderer bakes concrete colours into its SVG rather than referencing variables, because it has no CSS to lean on once the 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. See Wireframes.
§ 4The 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/dark behaviour in the rest of the chapter:
| # | Rule | Carries |
|---|---|---|
| 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 it sets any |
| 9 | :root { --wdoc-accent: … } | The active accent |
| 10 | The apply rules | Everything 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.
§ 5Light and dark
Three mechanisms choose a palette, and they are layered by specificity rather than by cascade order.
- Dark is the default. The bare :root rule carries the dark palette. A reader with no preference and no toggle gets dark.
- The system preference switches it. @media (prefers-color-scheme: light) re-declares the light palette on :root. Same specificity, later in the file, so it wins when it matches.
- The toggle overrides both. theme_toggle = true renders a button that sets data-theme on the <html> element and remembers the choice in localStorage under the key wdoc-theme. :root[data-theme="…"] outranks a bare :root on specificity, so an explicit choice beats the system preference either way.
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.
§ 6Scoped 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" {
class = ["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 will re-scope the palette for its own subtree:
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 {} / light {} blocks, because those hang off prefers-color-scheme and :root[data-theme] — neither of which a wrapper class can change. 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. Two classes are declared at the top of this chapter's source: ch19-var paints with var(--wdoc-accent), and ch19-mode sets #88c0d0 in a dark {} block and #5e81ac in a light {} one. Both are applied below, in a demo:
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.
§ 7The accent hue
--wdoc-accent is the colour of active navigation, heading markers, chapter kickers, footnote references, badges and blockquote rules. It is set by one generated rule at the end of the palette block, and it points at another variable rather than at a colour:
| site.accent | Rule emitted | Effect |
|---|---|---|
| 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.
§ 8The 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.
§ 8.1forge
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 the only one whose eight hues are all distinct in both modes — which makes it the safest choice for an eight-series chart.
§ 8.2nord
Cool blue-grey, the classic Nord palette. Dark reads on #2e3440, light on #eceff4. Its hue ring is deliberately narrow: in dark mode cyan, blue, purple and pink are all #81a1c1, so only five of the eight hues differ. This book is set in nord, which is why its charts repeat a colour past four series.
§ 8.3tokyonight
A vivid night palette — near-black #1a1b26 under saturated blues and purples, with a notably desaturated light mode built on #e1e2e7 rather than white. Seven distinct hues in each mode; purple and pink share a value.
§ 8.4gruvbox
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. Six distinct hues per mode.
§ 8.5catppuccin
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.
§ 8.6rose
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 — an unusual pairing, and the reason the theme reads calmer than its saturation suggests. Six distinct hues per mode.
§ 8.7paper
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 is a warm near-black. Five distinct hues in each mode makes it the narrowest ring of the seven — a poor fit for a chart-heavy site and a good one for prose.
§ 8.8Comparing 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 actually differ. The last column is the one that decides whether a chart with six or eight series is readable.
| Theme | Dark surface | Light surface | Palette accent (dark / light) | Distinct hues (dark / light) | Fonts |
|---|---|---|---|---|---|
| forge | #11141a | #ffffff | #2389e2 / #0069ca | 8 / 8 | Sans headings and body |
| nord | #2e3440 | #eceff4 | #88c0d0 / #5e81ac | 5 / 6 | Defaults |
| tokyonight | #1a1b26 | #e1e2e7 | #7aa2f7 / #2e7de9 | 7 / 7 | Defaults |
| gruvbox | #282828 | #fbf1c7 | #fe8019 / #af3a03 | 6 / 6 | Defaults |
| catppuccin | #1e1e2e | #eff1f5 | #cba6f7 / #8839ef | 7 / 7 | Defaults |
| rose | #1f1d2e | #fffaf3 | #c4a7e7 / #907aa9 | 6 / 6 | Defaults |
| paper | #211e1b | #fcfbf7 | #d98b8b / #8a1c1c | 5 / 5 | Serif headings |
"Defaults" means IBM Plex Sans for headings, Source Serif 4 for body copy and JetBrains Mono for code — see Fonts.
§ 9Writing 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:
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 rules of thumb turn that into a good theme. Start from a built-in and edit it — copy the palette pair from lib/theme.wcl and change what you need, rather than starting 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 not per-site
Themes are document-level blocks 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 palette is written into each site's stylesheet separately, so a theme nothing selects costs nothing.
§ 10Fonts
§ 10.1The 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. Their defaults are emitted for every themed site:
-- --A theme's font_head / font_body / font_mono fields override them, one at a time — the theme's rule is emitted after the defaults, and only for the fields it 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 value is written into the property verbatim.
Those three families are embedded in the wcl binary and written 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.
§ 10.2Shipping 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:
# 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, which 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. The bundled faces are declared as font_face "'Source Serif 4'" — quotes inside the string — for that reason. Do the same and it is always right.
§ 11Classes
A class block is a CSS class written as data. Its label is the class name, and you apply it by listing that name in any block's class field.
class callout-accent {
css = "font-weight:600;"
fill = "#ff0000"
dark { css = "color:#fabd2f;" }
light { css = "color:#b57614;" }
nest "&:hover" { css = "opacity:0.8;" }
nest "code, kbd" { css = "letter-spacing:0;" }
}
p "A highlighted line." { class = ["callout-accent"] }
Hyphenated names may be written bare, as above, or quoted — class "callout-accent" is the same block. Quote a name that is not a valid identifier for any other reason. Bare is worth reaching for, because the names you most often override are the built-in hyphenated ones.
§ 11.1What a class block holds
| Field | Emits | For |
|---|---|---|
| css | The string, verbatim | Anything. The escape hatch, and the field most rules use |
| fill | fill: … | SVG shapes — diagram nodes, chart series, timeline markers |
| stroke | stroke: … | SVG outlines and lines |
| stroke_width | stroke-width: … | SVG line weight |
| opacity | opacity: … | Both SVG and HTML |
| accent | --callout-accent: … | A callout's heading, border and icon colour — see Callouts |
| sites | Nothing | Restricts the rule to named sites — see Scoping rules to one site |
| dark / light | Extra rules | Per-mode overrides — below |
| nest | Extra rules | Selectors rooted at this class — below |
The four SVG fields and accent are shorthands, not a separate mechanism: they are written into the same declaration block as css, and they are written first. So on a collision css wins, and class x { fill = "#f00" css = "fill:#00f;" } paints blue. They exist because a diagram shape's class reaches an SVG element, where fill and stroke are the properties that matter and a raw string would be all quoting.
§ 11.2Per-mode overrides: dark and light
A dark {} or light {} child carries the same styling fields as the class itself, minus the name. Adding either turns one class into four rules. This is the class above, as it comes out of a build:
}
{ } }
}
}
}
}
Three things in there are worth naming. Dark is merged into the base rule, because dark is the default mode — there is no @media (prefers-color-scheme: dark) anywhere. The light rule carries only its own declarations, so it overrides the base for the properties it names and inherits the rest. And both data-theme rules are emitted whenever either mode block is present, including the side that declared nothing: a class with only a light {} still gets a data-theme="dark" rule carrying the base alone. Without it, a reader on a light-preferring system would press the toggle and see the media rule keep winning.
§ 11.3Nesting
A nest block is a selector fragment plus its declarations. The fragment is joined to the class selector by one rule: & is the class, and a fragment with no & is a descendant.
| Written on class card | Emitted |
|---|---|
| nest ".title" { … } | .card .title { … } |
| nest "&:hover" { … } | .card:hover { … } |
| nest "&.active" { … } | .card.active { … } |
| nest "> :first-child" { … } | .card > :first-child { … } |
| nest "th, td" { … } | .card th, .card td { … } |
The last row is the subtle one: a selector list is split at its top-level commas and each branch is expanded independently, so th, td becomes two descendant selectors rather than one broken one. Commas inside a functional pseudo-class such as :is(…), inside an attribute selector, or inside a quoted string stay where they are — and an & inside an attribute selector is left alone.
One class block may carry any number of nest children. Repeating the class block is equally fine and often reads better — the bundled rules do it, one nest per block.
§ 12base, media, keyframes and font_face
class covers the rule whose root is one bare class. Four sibling blocks cover everything else. All four are top-level document blocks like class is, and all four take the same sites field.
§ 12.1base
A base block is a selector and its declarations, and nothing is added to either. The label is the selector, written out in full and emitted verbatim — which makes base the block for everything a class root cannot express: an element, a reset, a selector list, a pseudo-element, a tag-qualified rule, or :root itself when you want custom properties of your own.
base "figcaption" { css = "text-align:center;" }
base "h2 + h3, h3 + p" { css = "margin-top:0.2rem;" }
base "::-moz-selection" { css = "background:rgba(136,192,208,0.28);" }
# `:root` is just another selector, so your own custom properties are a
# `base` away — and, because they are properties, they inherit into
# everything under the root exactly as the theme's `--wdoc-*` ones do.
base ":root" { css = "--house-gap:1.25rem;" }
That last rule comes out as :root { --house-gap:1.25rem; }, and any class can then read it. What a base block cannot carry is a nest child, a dark {} or light {} block, or the SVG shorthands: those hang off a class root, so they live on class.
Choose base or class by the selector's root
class "card" and base ".card" emit the same rule, but they are not interchangeable in intent. Use class when the root is a single bare class, because that is what nest, the mode blocks and the SVG shorthands attach to. Use base for everything else. The bundled library follows the same split, which is why a user class reliably overrides a bundled one: the selectors match exactly.
§ 12.2media
A media block wraps class and base children in a media query. Its label is the query without the @media keyword, so any query CSS accepts — a width, a print target, a reduced-motion preference — is legal there:
media "print" {
base ".book-sidebar, .book-rail" { css = "display:none;" }
base "a" { css = "border-bottom:none;" }
}
media "(prefers-reduced-motion: reduce)" {
base "*" { css = "animation-duration:0.01ms !important;" }
}
media "(max-width: 48rem)" {
class house-grid { css = "grid-template-columns:1fr;" }
}
A class inside a media keeps everything a top-level one has, mode blocks included — a class with a light {} emits its own @media (prefers-color-scheme: light) inside the outer query. Nested at-rules are valid CSS and the browser reads them as an and, so the inner rule applies only where both queries match.
§ 12.3keyframes
A keyframes block is an animation. Its label is the animation name, and each frame is a base child whose selector is the frame position — from, to, or a percentage:
keyframes "house-fade-in" {
base "from" { css = "opacity:0;transform:translateY(0.4rem);" }
base "60%" { css = "opacity:1;" }
base "to" { css = "opacity:1;transform:none;" }
}
# Reference it from any rule that can carry declarations.
class house-entrance { css = "animation:house-fade-in 240ms ease-out;" }
base is the only child kind a keyframes accepts, and that is enforced rather than ignored: a class inside one fails the build with block kind 'class' is not allowed inside 'keyframes'. Pair an animation with the prefers-reduced-motion query above, since a reader who has asked for less motion should get it.
§ 12.4font_face
A font_face block is an @font-face rule with typed descriptors instead of a declaration string. Its label is the family and src is required; weight, style and display are optional and emit font-weight, font-style and font-display. The descriptors are written in a fixed order — family, weight, style, display, then src — so two faces of one family always read alike. Shipping your own font walks the whole three-step job; the block itself is one declaration per face:
font_face "'Public Sans'" {
src = "url('fonts/PublicSans-Regular.woff2') format('woff2')"
weight = "400"
style = "normal"
display = "swap"
}
font_face "'Public Sans'" {
src = "url('fonts/PublicSans-Italic.woff2') format('woff2')"
weight = "400"
style = "italic"
display = "swap"
}
§ 13style bundles
A style block is a named set of the same rules — class, base, font_face, media and keyframes children, under one identifier. It differs from a top-level rule in exactly one way, and it is the important one: a style bundle is not emitted into the page stylesheet. It is rendered where a template asks for it.
style card_extras {
class "kicker" { css = "letter-spacing:0.08em;" }
base ".kicker + p" { css = "margin-top:0;" }
}
Nothing above reaches a page until a template writes css_style(:card_extras), which places the bundle's rules as a <style> element at that point in the output. Declare that same class "kicker" at the top level instead and it is in every page of the site whether anything uses it or not.
This is how the built-in templates keep their layout CSS beside themselves — the book, webpage, website and presentation templates each render one bundle by name — and it is the mechanism to reach for when you write your own. Templates and layouts covers the template side; Writing your own blocks covers css_style among the other Html constructors.
The theme is two style bundles
The apply rules that paint with var(--wdoc-…) are not special-cased Rust strings. They are a style bundle named wdoc-theme-apply, and the font defaults are another named wdoc-theme-font-defaults; the theme emitter looks both up by name and splices them around the palette. Only the palette itself and the one accent rule are generated, because only those two have declarations that come from your data.
§ 14The order rules are emitted in
The three-group order from the top of this chapter is worth seeing in real output. This document selects nord and overrides one chart series:
site s { default_template = :book title = "T" theme = :nord }
class wdoc-series-1 { fill = "#ff00ff" stroke = "#ff00ff" }
The emitted stylesheet holds all three rules, in this order:
} /* library default */
} /* nord */
} /* yours */
Identical specificity three times over, so the last one paints. Two consequences follow, and both are load-bearing. A theme overrides the library's neutral defaults, which is what makes an unthemed document render legibly and a themed one render in its palette. Your rules override the theme, which is what makes overriding a built-in class a one-liner instead of a specificity fight.
There is one wrinkle. Origin is decided by where the block was declared, not by which file the build read it from: blocks from the embedded library are library rules, blocks from your source are yours. A wdoc_repeater or a component instance that generates class blocks keeps the origin of the block that generated it, so a repeater in your document emitting a design system's worth of classes still lands in the user group and still wins.
§ 15The built-in class vocabulary
Almost every colour the renderer paints into HTML goes through a named class, and every one of those names is yours to override. (The exception is the wireframe family, which bakes concrete colours into its SVG for the reason the callout above gives.) The families:
| Family | Classes | Set by |
|---|---|---|
| Inline emphasis | bold, italic, code | The **bold**, _italic_ and backtick-code patterns in prose — see Text and formatting |
| Headings | heading-1 … heading-6 | Derived from the level on every h1–h6 |
| Chart series | wdoc-series-1 … wdoc-series-8 | Cycled over series, and over timeline phases — see Charts |
| Chart chrome | wdoc-axis, wdoc-grid, wdoc-axis-label, wdoc-chart-title, wdoc-legend, wdoc-line, wdoc-point-label, wdoc-annotation | Chart structure |
| Diagram shapes | wdoc-process, wdoc-decision, wdoc-terminator, wdoc-node, wdoc-shape-text, wdoc-boundary, wdoc-boundary-label, wdoc-edge-label | Flowchart blocks — see Flowcharts and swimlanes |
| Sequence and state | wdoc-participant, wdoc-participant-line, wdoc-lifeline, wdoc-seq-message, wdoc-seq-arrow, wdoc-seq-text, wdoc-note, wdoc-note-text, wdoc-state, wdoc-state-initial | See Sequence and state diagrams |
| Timelines | wdoc-timeline-divider, -marker, -connector, -label, -phase-label | See Timelines and dopesheets |
| Callouts | callout plus note, info, tip, warning, error, success | A callout's class field |
| Code cards | code-card, code-filename, code-lang, code-dots, code-block | See Code |
| Page chrome | wdoc-body, wdoc-table, wdoc-card, wdoc-map-card, wdoc-preview, wdoc-badge, wdoc-footnotes, footnote-ref, heading-marker, chapter-kicker, chapter-meta, chapter-subtitle | Templates and the content renderer |
| Template navigation | site-header, book-chapter, book-section, book-onpage-link, current | The book and webpage sidebars |
Two of those repay a closer look. The heading classes are derived, not authored — an h3 becomes <h3 class="heading-3">, and the class is computed from the level rather than written by you, so class "heading-3" { css = "font-size:1.5rem;" } restyles every third-level heading in the site. And the six callout kinds are hue roles in disguise: the theme sets --callout-accent per kind from the ring, so moving your palette's yellow moves every warning callout without touching a callout rule.
For anything the vocabulary does not name, the class field is on every content block, so an authored class always reaches the element you meant.
§ 16Scoping rules to one site
In a document with several site blocks, every styling rule carries an optional sites list of symbols:
class brand-mark { css = "color:#c00;" sites = [:marketing] }
base "figure" { css = "margin:0;" sites = [:handbook, :marketing] }
The list names sites; a rule with no sites field, or an empty one, belongs to every site. This is per-rule scoping, unlike a page, which a multi-site document requires to declare its membership. It is also the only way one document can carry two visual identities — since a theme is selected per site, and each site's stylesheet is built independently, a rule tuned to one palette can be kept off the other. Visibility covers the parallel sites axis on content.
§ 17What the build checks
Two things about your CSS the build looks at. One is an error, because it is never right; the other is a warning, because it cannot always tell.
§ 17.1A line comment is an error
// is not CSS. A browser reading it throws away the rest of the declaration, so one stray line comment silently deletes the rules after it. wdoc rejects a css value containing one, the same way it rejects a schema violation. The comment form CSS has is /* … */.
class card { css = "color:red; // muted" } // rejected
class card { css = "color:red; /* muted */" } // fine
// A `//` inside a quoted string or a url() is a URL, and passes.
base "body" { css = "background:url(https://example.com/bg.png);" }
§ 17.2The class lint
After a build has rendered, wdoc reads the class names its pages actually carry, compares them with the class names its rules select, and warns about each direction:
- A name in the markup that no rule selects — a misspelled class name, or a hook nothing styles.
- A rule this document authors that no page carries — a misspelled selector, or a rule left behind.
It reads the rendered output rather than your source because a class name reaches markup three ways: a class field, a raw HTML string inside a template, and the renderer's own markup. Only the finished page sees all three — and it sees a computed name, such as format("level-{}", h.level), already resolved to level-2.
Two things it deliberately does not judge. The bundled rules: wdoc's stylesheet ships the whole built-in vocabulary and your document uses a slice of it, so an unused library rule is another document's rule, not dead code. Generator vocabularies: syntax highlighting mints one class per grammar scope (tok-…) and one per language (language-…), an open-ended set no stylesheet could ever declare.
The remaining case is a class you emit on purpose and style nowhere — a hook for a script, or a name you want a reader to be able to restyle. Nothing in the markup tells that apart from a typo, so you say it in the source: an empty class block declares the name and emits no CSS.
class ws-main {}
The lint runs over every site of the document at once, because a rule scoped to one site would otherwise read as dead while another site rendered. Two builds therefore skip it: wcl wdoc build --site one and the dev server's targeted page rebuild both produce partial output, and a partial build cannot judge either direction. A class a wireframe or terminal resolves counts as used even though it never reaches an element, since those renderers bake the colour into their SVG.
§ 18Where to go next
- Documents, pages and sites — the whole site block: templates, titles, tables of contents, search, multi-site routing.
- Templates and layouts — how a template reads the theme variables, and where css_style fits.
- Demo blocks — the side-by-side light/dark preview the scoped palettes exist for.
- Callouts, footnotes and chapter headers — the six callout kinds and the --callout-accent property a class can set.
- Charts — the eight-series cycle that reads the hue ring.
- Writing your own blocks — declaring a block type that paints with var(--wdoc-…) and is themed for free.