Cascade layers
Partas.Nacara.Theme is the documentation theme this site is built with: layout, navbar, design tokens and the web components behind them. It is configured as a pipeline, the same shape as the site itself.
open Partas.Nacara.Theme
let theme =
Theme.defaults
|> Theme.navbar [ NavbarSection("Guide", "guide", "/guide/getting-started/") ]
|> Theme.editUrl "https://github.com/you/repo/edit/main/docs"
|> Theme.footer (Html.p [ Html.text "Built with Nacara" ])
let site =
Site.create "My site"
|> Theme.register theme
|> Site.collection (Theme.docs theme "content")
Theme.register installs it; Theme.docs builds the collection that renders your Markdown with it.
To put a logo before the site's title in the navbar, give Theme.brandIcon an image path relative to the site root, or inline SVG:
Theme.defaults |> Theme.brandIcon (BrandIcon.Image "logo.svg")
Theme.defaults |> Theme.brandIcon (BrandIcon.Svg """<svg viewBox="0 0 24 24">...</svg>""")
The icon is 1.5rem tall. SVG drawn with currentColor follows the colour scheme.
Layouts
A page chooses its layout in its front matter:
---
title: Partas.Nacara.Plugins
layout: splash
---
| Layout | What it is |
|---|---|
docs | The ordinary page, and what a page with no layout gets: menu, table of contents, title, edit link, previous and next. |
splash | A landing page. One column as wide as --nacara-splash-width, with no menu, no table of contents and no title, so the page writes its own heading. |
bare | A canvas between the navbar and the footer, which the page lays out itself. |
A layout of your own is a name and what it does to the page. Start from the built-in layout it is nearest to, and change what differs:
Theme.defaults
|> Theme.addLayout "reference" DocPage.withoutPageNav
|> Theme.addLayout "landing" (DocPage.splash >> fun page -> { page with ShowTitle = true })
Naming a built-in layout replaces it, and a page naming a layout the theme does not have fails the build. The layout's element carries data-layout, so its own CSS reaches only its pages:
Theme.defaults
|> Theme.css """[data-layout="landing"] { --nacara-splash-width: 80rem; }"""
The Figma plugin draws a template of each layout, and exports one you draw as the Theme.addLayout that adds it: see Designing in Figma.
Components
Each part of the page is a function a site can replace, and the rest of the page stays the theme's:
| Setter | Replaces |
|---|---|
Theme.replaceNavbar | The bar across the top, and the drawer it folds into |
Theme.replaceSidebar | The section's menu |
Theme.replaceToc | The page's headings, beside the content |
Theme.replacePageNav | The previous and next links |
Theme.replaceEditLink | The link that edits the page at its source |
Theme.replaceFooter | The whole <footer> element |
A replacement is given the theme's options, the page's DocPage and its context, and returns Feliz markup:
Theme.defaults
|> Theme.replaceFooter (fun options page context ->
Html.footer [
prop.className "nacara-footer"
prop.text $"%s{page.Title} · %s{context.Site.Title}"
])
The theme's own parts are public, as Components.navbar, Components.sidebar, Components.toc, Components.pageNav and Components.editLink, so a replacement can wrap one instead of starting over:
Theme.defaults
|> Theme.replaceNavbar (fun options page context ->
Html.div [
Html.p [ prop.className "banner"; prop.text "2.0 is out" ]
Components.navbar options context
])
The page still decides what is drawn: a splash page has no table of contents or page links, whoever renders them. The sidebar is drawn on every page, since on a narrow screen the navbar's sections fold into it; page.ShowMenu says whether the page wants a menu. The context's front matter is boxed, because one theme serves collections of more than one front-matter type: unbox it to the type of your collection.
Before and after the content
Two more places take markup of the site's, where the theme has none of its own:
| Setter | Where |
|---|---|
Theme.beforeContent | Above the page's title, inside its <main>: an announcement, an old-version warning |
Theme.afterContent | After the content, before the edit link and page links: comments, a feedback form |
Theme.defaults
|> Theme.beforeContent (fun _ page _ ->
Html.aside [
prop.className "nacara-callout"
prop.custom ("data-kind", "note")
prop.text "2.0 is out"
])
They are drawn on every page, bare ones too, unless a layout says otherwise.
Parts for one layout
A layout can render parts its own way, and leave the rest to the site:
Theme.defaults
|> Theme.addLayout "landing" DocPage.splash
|> Theme.beforeContent announcement
|> Theme.layoutComponents "landing" (
ThemeComponents.navbar heroNavbar
>> ThemeComponents.beforeContent (fun _ _ _ -> Html.none))
On landing pages the navbar is heroNavbar and there is no announcement; every other page keeps the site's announcement and the theme's navbar. The ThemeComponents functions are the same parts as the Theme setters: navbar, sidebar, toc, pageNav, editLink, footer, beforeContent and afterContent. The layout has to exist already, built in or added before, and calling layoutComponents again for it adds to what it has.
Design tokens
The tokens layer is the one layer you do not write CSS for. It is a record, and every field in it is a custom property the rest of the theme reads:
Theme.defaults
|> Theme.lightTokens (fun tokens -> { tokens with Primary = "#c0392b" })
|> Theme.darkTokens (fun tokens -> { tokens with Primary = "#e74c3c" })
A field's name is its property's name, in kebab case, under --nacara-: Primary is --nacara-primary, BgSubtle is --nacara-bg-subtle, Space12 is --nacara-space-12. So the completion list in your editor is the list of tokens, and a token that does not exist does not compile.
Values are CSS, unparsed — a colour, a length, a var() reading another token. Only the names are typed.
| Function | Reaches |
|---|---|
Theme.lightTokens | the --nacara-* properties, light scheme |
Theme.darkTokens | the same, dark scheme |
Theme.lightSyntax | the --tok-* highlighting colours, light scheme |
Theme.darkSyntax | the same, dark scheme |
Theme.tokens | all four at once, for a wholesale replacement |
The dark records are read as overrides: a dark value identical to its light one is not written twice, so restating a light value in darkTokens costs nothing.
Each of these rewrites the tokens layer, so it fails if you dropped that layer — there is then nothing for the properties to go in.
Tokens as documents
DesignTokens writes one colour scheme as a design tokens document, and reads documents back over a set of tokens:
let tokens = Tokens.defaults
File.WriteAllText("design/tokens/light.tokens.json", DesignTokens.write tokens.Light tokens.SyntaxLight)
File.WriteAllText("design/tokens/dark.tokens.json", DesignTokens.write tokens.Dark tokens.SyntaxDark)
Theme.defaults |> Theme.tokens (DesignTokens.fromDirectory "design/tokens")
DesignTokens.fromDirectory reads light.tokens.json and dark.tokens.json, the names the Figma plugin saves under. A scheme whose document is missing keeps its tokens. DesignTokens.readTheme does the same from two strings, and DesignTokens.readTokens and readSyntax read one record at a time.
A token's path is its prefix, its kind and its CSS name: --nacara-bg-subtle is nacara.color.bg-subtle, --nacara-space-4 is nacara.size.space-4, --tok-keyword is tok.color.keyword. A var() of another token becomes an alias to it. The kinds:
| Kind | Holds | Example |
|---|---|---|
color | a hex colour | nacara.color.primary |
size | a length in px or rem, font sizes included | nacara.size.font-size-2xl |
number | a line height, or a fontWeight | nacara.number.line-height |
shadow | a box-shadow, one entry per layer | nacara.shadow.shadow |
font | a font stack | nacara.font.font-sans |
css | anything else, kept as written | nacara.css.content-width (75ch) |
Figma reads and writes these documents: Designing in Figma has the theme's Figma file, the plugin, and how a theme designed there comes back to the site.
Style layers
The theme's stylesheet is not one file. It is a list of named parts, each written into a CSS cascade layer of its own, in this order:
| Layer | Holds |
|---|---|
tokens | the custom properties every other layer reads, rendered from the record above |
base | element defaults |
navbar | the top bar |
layout | page structure |
components | callouts, cards, tables |
code | code blocks and highlighting |
responsive | breakpoints |
Because they are cascade layers, order decides ties rather than specificity. A later layer wins against an earlier one whatever its selectors look like, so restyling the theme does not mean out-specifying it.
Replacing a layer
Theme.defaults
|> Theme.replaceLayer "components" (File.ReadAllText "css/components.css")
The layer keeps its place in the cascade, so everything that depended on its position still works.
Dropping a layer
Theme.defaults |> Theme.dropLayer "code"
For a part something else already provides — dropping code where a highlighting plugin brings its own.
Adding a layer
Theme.defaults
|> Theme.layerAfter "components" "widgets" widgetCss
|> Theme.layerBefore "navbar" "banner" bannerCss
layerAfter puts yours later in the cascade, so it wins a tie against the layer it follows. layerBefore puts it earlier, so the layer it precedes wins.
Naming a layer the theme does not have fails the build, with the known names in the message. That is deliberate: a typo in a layer name is otherwise a silent no-op.
Replacing everything
Theme.defaults |> Theme.styles myLayers
The whole stylesheet, yours, as a StyleLayer list in cascade order. replaceLayer and dropLayer are the usual way in — they leave the parts you did not mention alone.
Plugins with styles of their own
A plugin does not reach into the theme's layers. It offers one, and the theme bundles it. See Plugin style layers.
Licensing
This project is a derivative work of Nacara.Theme.Default and is licensed under Apache-2.0 rather than the MIT that covers the rest of the repository. See LICENSE and NOTICE.