wdoc · reference
Styling rules
Themes covers the colour and type layer: a theme sets thirty-one custom properties and the built-in rules paint with var(--wdoc-…). This chapter covers the rules themselves. Six block kinds — class, base, media, keyframes, font_face and style — are CSS written as typed WCL rather than as a string, and between them they reach anything a stylesheet can.
The one thing to carry over from the previous chapter is the emission order, because it is the whole override story. The build writes library rules first, the theme second, and your rules last, at the same specificity — so later wins and you never write !important. The order rules are emitted in shows it in real output.
Classes
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"] }
You may write hyphenated names 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.
What 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, footnotes and chapter headers |
| sites | Nothing | Restricts the rule to named sites. See Scoping rules to one site |
| dark / light | Extra rules | Per-mode overrides. See below |
| nest | Extra rules | Selectors rooted at this class. See below |
The four SVG fields and accent are shorthands, not a separate mechanism: the build writes them into the same declaration block as css, and it writes them 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.
Per-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. The build merges dark 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 the build emits both data-theme rules 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.
Nesting
A nest block is a selector fragment plus its declarations. One rule joins the fragment to the class selector: & 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: wdoc splits a selector list at its top-level commas and expands each branch 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. wdoc also leaves an & inside an attribute selector 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.
base, 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.
base
A base block is a selector and its declarations, and wdoc adds nothing to either. The label is the selector, written out in full and emitted verbatim. Verbatim emission 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, so a user class reliably overrides a bundled one: the selectors match exactly.
media
A media block wraps class and base children in a media query. Its label is the query without the @media keyword. Any query CSS accepts is legal there, whether a width, a print target or a reduced-motion preference:
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.
keyframes
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. Write 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 limit 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.
font_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 come out in a fixed order: family, weight, style, display, then src. Two faces of one family therefore 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"
}
style 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: the build never emits a style bundle into the page stylesheet. The build renders it 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. Reach for the same mechanism 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. It generates only the palette itself and the one accent rule, because only those two have declarations that come from your data.
The 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. That override is what makes an unthemed document render legibly and a themed one render in its palette. Your rules override the theme. Overriding a built-in class is therefore a one-liner instead of a specificity fight.
There is one wrinkle. Where the block was declared decides its origin, not 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.
The 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 in Themes 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, 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.
Scoping 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. That scoping is per rule, 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. You select a theme per site, and the build writes each site's stylesheet independently, so you can keep a rule tuned to one palette off the other. Visibility covers the parallel sites axis on content.
What 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 the build cannot always tell.
A 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);" }
The 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. It also 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.
Where to go next
- Themes. The colour and type layer these rules sit on: palettes, the hue ring, extends, fonts and metrics.
- Templates and layouts. How a template reads the theme variables, and where css_style places a style bundle.
- Writing your own blocks. Declaring a block type that paints with var(--wdoc-…) and is themed for free, and the Html constructors css_style sits among.
- Text and formatting. The inline patterns that mint the bold, italic and code classes.
- Code. The code-card classes and the syntax-token vocabulary the lint deliberately ignores.