# Building

---

# Building and serving

_Trail / Building_

> The two commands — trail build writes a static site into dist/, trail serve adds a watching dev server with live reload. What each flag does, what gets pruned, and how failures behave.

Trail has two commands. `build` writes the site; `serve` writes it and then serves it, rebuilding as you edit.

## trail build

```console
$ trail build [ROOT] [--out DIR] [--strict] [--allow-dangling-links] [--render-llms-full] [--cbpath PATH]
```

`ROOT` is the directory containing `trail.toml`, defaulting to the current directory. Output goes to `ROOT/dist` unless `--out` says otherwise.

```console
$ trail build
built 1227 pages (8 products) → ./dist
```

The page count is HTML pages: articles, product and anthology landings, book covers, `/print` bundles, the front page and the 404 page. The markdown mirrors, `llms.txt`, `site.json`, the sitemap and the search index are not counted as pages.

### The output directory belongs to trail

At the end of every **successful** build, trail deletes everything in the output directory it did not write on that run, then removes any directory left empty. Pages for articles you deleted, superseded search-index fragments, images you stopped referencing: all gone.

Two consequences:

- **Do not keep anything in `dist/` by hand.** A `CNAME`, a `_headers` file, a stray favicon — trail will delete it. Anything that must ship belongs in the source tree and named by [`passthrough`](/trail/building/configuring-the-site.md), which copies it in on every build.
- **A failed build prunes nothing.** The previous output is left completely intact, which is what makes the dev server able to keep serving a working site while you fix an error.

Files whose contents have not changed are left untouched rather than rewritten, so modification times stay stable for rsync-style deploys.

### When a build fails

Loading errors — a bad name, an unknown config key, a malformed frontmatter block — stop the build at the first problem, with the context chained on:

```text
Error: loading product 'pekit'

Caused by:
    0: loading topic in '2--recipes.topic'
    1: loading article 'anatomy'
    2: ./pekit.product/2--recipes.topic/100--anatomy.md has unterminated frontmatter
```

Link errors are different: they are **collected across the whole site** and reported together, because fixing them one build at a time would be miserable.

```text
Error: 3 broken links:
in article '/pekit/recipes/anatomy': link '~pekit/running/invokation' matches no page in product 'pekit'
…
```

Some problems are warnings on stderr rather than errors, and do not stop the build: an image nothing references, and (with `--allow-dangling-links`) missing link targets.

## The flags

### `--allow-dangling-links`

Downgrades missing `~link` and `related:` targets from errors to warnings, and does the same for missing images and unresolvable `§` citations. **Ambiguous references stay fatal** — trail will not pick between two candidate pages.

It is for the middle of a large reorganisation, when you want to see the site before every reference has caught up. Shipping with it on means shipping dead links.

### `--strict`

Fails the build when any article has no `description:`:

```text
Error: --strict: 2 articles without a description:
  article '/pekit/recipes/sources' has no description
  article '/pekit/reference/cli' has no description
```

Descriptions become meta descriptions and social-preview text, so a page without one is a page that presents badly everywhere outside the site. Alias pages are exempt — the original carries the description.

Use it in CI; leave it off while drafting.

### `--render-llms-full`

Writes an extra copy of each unit's `print.md` as `llms-full.txt` beside it. Purely a discovery fallback for agents that probe for that filename instead of reading `llms.txt`, which always points at `print.md` anyway. Off by default, because it doubles a large part of the output for no new information.

### `--cbpath PATH`

Publishes a second copy of the whole site under `PATH`, with every link inside it rewritten to stay inside it. Name it after something that changes on every deploy — a commit hash — and you have a URL nothing can have cached, so what comes back is necessarily this build. See [The cache-busting copy](/trail/building/the-cache-busting-copy.md).

### `--out DIR`

Writes the site somewhere other than `ROOT/dist`. The chosen directory is excluded from the site scan, so a build into a directory inside the site root does not turn the previous build into content.

## trail serve

```console
$ trail serve [ROOT] [--addr ADDR] [build flags]
```

`serve` takes every `build` flag, plus `--addr` (default `127.0.0.1:8724`).

```console
$ trail serve
serving ./dist at http://127.0.0.1:8724 (watching . for changes)
```

It builds the site, serves the output directory over HTTP, and watches the source tree. On a change it debounces for 200 ms of quiet — editors write in bursts — rebuilds, and tells every open page to reload itself over a server-sent-events stream. Changes inside the output directory are ignored, so the build cannot trigger itself, as are paths with a dot-prefixed component, so a `.git` write does not either.

```text
rebuilt 1227 pages
```

**A broken site does not kill the server.** A failed initial build or rebuild prints the error and keeps serving the last good output:

```text
rebuild failed (still serving the last good build): loading product 'pekit': …
```

Fix the file and the next successful rebuild reloads the page.

Two details worth knowing:

- **The live-reload script is only ever injected by `serve`.** Output written by `trail build` never contains it.
- **Misses serve the built `404.html` with a real 404 status**, exactly as a static host configured for that file would — so you can check the not-found page locally.

## Deploying

The output directory is plain static files. Any host that serves a directory will do.

Two things to configure on the host:

- **`404.html` as the not-found page.** Trail writes it; the host has to be told to use it.
- **Directory indexes.** Pages are written as `<path>/index.html`, so the URL `/pekit/reference/cli` needs to serve `/pekit/reference/cli/index.html`. Nearly every static host does this by default.

Set `url` in the root `trail.toml` before deploying: without it there is no `sitemap.xml`, no `Sitemap:` line in `robots.txt`, and canonical and `og:url` tags stay relative. See [Configuring the site](/trail/building/configuring-the-site.md).

If the host's caching is aggressive enough that you cannot tell a fresh deploy from a stale one — and on GitHub Pages you cannot, because the headers are not yours to set — build with `--cbpath "${GITHUB_SHA::8}"` and you get a URL per deploy that nothing has ever cached. See [The cache-busting copy](/trail/building/the-cache-busting-copy.md).

---

# Configuring the site

_Trail / Building_

> The root trail.toml — the four required keys, the base URL and what it unlocks, featured products, header navigation, favicon, edit links, and injecting your own HTML into every head.

One `trail.toml` at the site root configures the whole site. It is the only place these settings live — there is no per-environment config, no CLI equivalent for most of them, and unknown keys are build errors rather than typos you find out about later.

```toml
sitename = "Peios Learn"
title = "Documentation for everything Peios"
url = "https://learn.peios.org"
accent = "#3b82f6"
description = "Concepts, guides, and reference for the Peios operating system and every project around it — from the kernel to the toolchain that builds it."

featured = ["peios", "pekit", "provium", "universal-directory", "trail"]

footer = "Peios Learn — documentation for the Peios project."
```

[The `trail.toml` reference](/trail/reference/trail-toml.md) is the exhaustive table; this page is what each setting is *for*.

## The four required keys

`sitename`, `title`, `description` and `footer` must all be present.

**`sitename`** is the site's identity: the wordmark in the header, the suffix on every page's `<title>`, the search box's placeholder, and `og:site_name`. Its **last word is accented** in the wordmark — "Peios *Learn*" — so a two-word name reads best. A one-word name simply gets no accent.

**`title`** and **`description`** are the front page's headline and standfirst, and its meta description and social-preview text. The description is also the first line of `llms.txt`, so write it as a summary of the whole site rather than a slogan.

**`footer`** is the line at the foot of every page. Beneath it, "Built with Trail." appears unless `built_by_trail = false` turns it off.

## The base URL

```toml
url = "https://learn.peios.org"
```

Optional, and worth setting before you deploy. Without it:

- **no `sitemap.xml` is written**, because sitemaps require absolute URLs, and `robots.txt` gets no `Sitemap:` line;
- **canonical URLs stay root-relative** rather than absolute;
- **`og:url` is omitted**, since a relative one is meaningless to the crawlers reading it;
- **`/print` bundles link outward relatively**, which is right on the site and wrong on paper.

Trailing slashes are trimmed, so `https://learn.peios.org/` and `https://learn.peios.org` behave identically.

> [!NOTE]
> Trail builds for the **root of a domain**. Internal links are root-relative (`/pekit/reference/cli`), so serving the site from a subpath is not supported.

## Featured products

```toml
featured = ["peios", "pekit", "provium", "universal-directory", "trail"]
```

The list does two jobs: it orders products, and it decides which appear on the front page.

- Products in the list sort first, in the list's order; everything else follows, by title.
- **Only listed products get a card on the front page.** An unlisted product is still built, still searchable, still linkable, and still in the header's products menu.
- Naming a product that does not exist is a build error.

## Header navigation

```toml
[[nav]]
label = "Specs"
url = "~peios/pgss"

[[nav]]
label = "Source"
url = "https://github.com/peios/peios"
```

Each `[[nav]]` block adds a link to the header, beside the products menu. On narrow screens they move inside that menu.

Keep the `[[nav]]` blocks at the **end of the file**: in TOML every key after a table header belongs to that table, so an ordinary key written below them becomes a nav field and fails the build.

`url` may be an external URL, a root-relative path, or a [`~` page reference](/trail/writing/links-and-references.md). References are resolved **strictly, at load time** — even with `--allow-dangling-links`, a broken nav link fails the build. Site chrome appears on every page; it must never dangle.

## Favicon

```toml
favicon = "favicon.png"
```

A path relative to the site root. The file is copied to the output root under its own name and linked from every page. The file must exist, or the build fails.

Naming a file here is also what makes it *legal* to keep in the site root, which otherwise accepts only `trail.toml` and `*.product` directories.

## Edit links

```toml
edit_url = "https://github.com/peios/learn/edit/main/{path}"
```

Adds an "Edit this page" link to the foot of every article. `{path}` is replaced with the article's source file path relative to the site root — the placeholder is required, and a template without it is a build error.

For an [alias page](/trail/structuring-a-site/link-aliases.md) the link points at the *original's* source file, since that is the file to edit.

## Injecting your own head HTML

```toml
head_html = "head.html"
```

A path to a file whose contents are injected verbatim at the end of every page's `<head>`: analytics snippets, extra meta tags, a webfont, a verification token.

It is inserted unescaped and unchecked, at the end of the head, so it can override earlier tags. It is the escape hatch for everything trail does not have a setting for — and, like all escape hatches, the thing to reach for last.

## Files trail does not build

```toml
passthrough = ["CNAME", ".well-known"]
```

Some files have to be in the published site without trail having any opinion about them: a `CNAME` for a custom domain, a `.well-known/` directory, a search-engine verification file. `passthrough` names them, and each one is copied into the output exactly as it is — directories whole.

This matters most when the site root **is** a repository root. Trail accepts only `trail.toml` and `*.product` directories there, so a `CNAME` sitting beside them would otherwise fail the build. Naming it here makes it legal *and* publishes it.

Two details worth knowing:

- Entries are copied **after** everything trail generates, so naming `robots.txt` replaces the generated one — useful, and a foot-gun if you name `assets`.
- Dot-entries like `.github/` are already skipped by the root scan, so they only need naming here if you want them **published**. `.well-known/` does; `.github/` does not.

> [!TIP]
> A repository whose root is a trail site cannot hold a `README.md` — it is an unexpected root file. Put it at `.github/README.md`, which GitHub renders on the repository page and trail skips.

## Theming

Four keys control colour:

```toml
accent = "#3b82f6"
accent_dark = "#7aa8ff"     # optional; derived from accent if absent
custom_css = "custom.css"   # optional
product_theming = true      # default
```

`accent` is the site's colour. `accent_dark` overrides its dark-mode variant, which is otherwise derived by mixing the accent toward white. `custom_css` names a stylesheet shipped at `/assets/custom.css` and loaded after the built-in one. `product_theming = false` stops each product tinting its own pages.

Both colours must be `#rrggbb`, and setting `accent_dark` without `accent` is an error. [Theming](/trail/building/theming.md) covers what these actually reach.

## What may live in the site root

The site root accepts `trail.toml`, `*.product` directories, the build output directory, dot-entries (`.git` and friends), and **whatever `favicon`, `custom_css`, `head_html` and `passthrough` name**. A setting naming `assets/site.css` makes the whole `assets/` directory legal at the root — it is the first path component that is tolerated. Anything else is a build error.

That last clause is the reason those three settings take paths rather than inline content: naming a file is what makes it part of the site.

---

# The output surface

_Trail / Building_

> Everything a build writes into dist/ — HTML pages, single-page /print views, markdown mirrors of every page, llms.txt, site.json, sitemap.xml, robots.txt, the 404 page, and the search bundle.

A build writes rather more than the pages you authored. This page is the full inventory of what lands in the output directory and what each part is for.

```text
dist/
├── index.html                        the front page
├── 404.html                          the not-found page
├── robots.txt
├── sitemap.xml                       only when the site config has a `url`
├── llms.txt                          the site's map, for language models
├── site.json                         the site's structure, machine-readable
├── pekit.md                          a product, as markdown
├── pekit/
│   ├── index.html                    the product landing page
│   ├── print/index.html              all of pekit on one page
│   ├── print.md                      all of pekit as one markdown file
│   └── reference/
│       ├── cli/index.html            an article
│       ├── cli.md                    the same article, as markdown
│       ├── print/index.html          all of the Reference topic on one page
│       └── print.md
├── assets/                           style.css, search.js, fonts, licences
└── pagefind/                         the search index and its WASM core
```

## HTML pages

One page per article, product, anthology and book cover, plus the front page, the 404 page and one `/print` page per unit. Each is written as `<path>/index.html`, so `/pekit/reference/cli` needs a host that serves directory indexes — nearly all of them do.

Topics, subfolders, chapters and shelves have no page. The count `trail build` prints is exactly this set.

## Single-page views: /print

Every **unit** — a product, an anthology, a topic or a book — also publishes its whole contents as one page:

```text
/pekit/print                  every article in pekit, in reading order
/pekit/reference/print        every article in the Reference topic
/peios/pgss/print             the whole book
```

Each article becomes a section with its title, its breadcrumb, and a heading id unique within the bundle. The page is styled for reading and for printing: browser chrome, the site header and footer, the pager and the tab buttons all drop out when it goes to paper, and tab panels expand so nothing is hidden.

Links are rewritten so the document works away from the site: a link to a page **inside** the same bundle becomes an in-document anchor, and one **outside** it becomes an absolute URL when the site config has a `url`, staying root-relative otherwise.

Every article page carries a "Single page" link in its breadcrumbs, pointing at its unit's bundle **anchored at that article** — so a reader can go from a page to the whole document without losing their place.

Two things do not carry over into a bundle: [inline reference phrases and `§` citations](/trail/writing/inline-references.md) are not linked there. RFC-2119 keywords still are.

A unit with no articles gets no bundle.

## Markdown mirrors

**Every page is also available as markdown at its own URL with `.md` appended.**

```text
/pekit/reference/cli      →  /pekit/reference/cli.md
/pekit                    →  /pekit.md
/pekit/reference/print    →  /pekit/reference/print.md
```

A mirror is the article's *source*, not a re-derivation of the HTML — the same headings, the same fenced code blocks with their info strings, the same tables — with three rewrites:

- `~` references become the target's own `.md` URL, so following links keeps you in the markdown surface;
- relative image destinations become the images' published URLs, since the mirror is served away from the article's folder;
- inside a book, `h2`/`h3` headings gain their section numbers as text.

Each one opens with the title, the breadcrumb trail, and the description:

```markdown
# Installing

_Acme CLI / Guides_

> How to install Acme.

See [Hello](/docs/guides/hello.md) first.

Related content:

- [Hello](/docs/guides/hello.md)
```

Every HTML page also advertises its mirror in the head:

```html
<link rel="alternate" type="text/markdown" href="/pekit/reference/cli.md">
```

## llms.txt

A map of the site at the root, following the [llms.txt](https://llmstxt.org) convention: the sitename and description, a note that every page has a `.md` twin, and then one section per product listing its units by their `print.md` bundles.

```text
# Peios Learn

> Concepts, guides, and reference for the Peios operating system…

Every page on this site is also available as markdown at the same URL with
`.md` appended. Each section below links whole units as single markdown
files. The full machine-readable structure is at /site.json.

## pekit

- [pekit overview](/pekit.md): The recipe-driven build and packaging tool…
- [All pekit docs in one file](/pekit/print.md)
- [Getting started](/pekit/getting-started/print.md): 2 articles
```

The `--render-llms-full` flag additionally writes each unit's `print.md` as `llms-full.txt` beside it, for agents that probe for that filename rather than reading `llms.txt`. It is off by default: it duplicates a large part of the output and adds nothing new.

## site.json

The whole site model as JSON: the site's own metadata, then products, and inside them the full tree of anthologies, topics, folders, books, chapters and articles.

```json
{
  "sitename": "Peios Learn",
  "url": "https://learn.peios.org",
  "llms": "/llms.txt",
  "products": [
    {
      "slug": "pekit",
      "path": "/pekit",
      "md": "/pekit.md",
      "print": "/pekit/print.md",
      "title": "pekit",
      "monogram": "pk",
      "color": "#22c55e",
      "description": "…",
      "items": [
        {
          "kind": "topic",
          "slug": "reference",
          "path": "/pekit/reference",
          "print": "/pekit/reference/print.md",
          "title": "Reference",
          "entry": "/pekit/reference/recipe-format",
          "children": [
            {
              "slug": "cli",
              "path": "/pekit/reference/cli",
              "md": "/pekit/reference/cli.md",
              "title": "Command-line reference",
              "type": "reference",
              "description": "…",
              "related": ["/pekit/running/invocation"]
            }
          ]
        }
      ]
    }
  ]
}
```

Every node carries the URLs of its own alternative representations, so a consumer can walk the structure and fetch exactly the parts it wants. Book entries add `number` and, for appendices, `appendix: true`; [alias pages](/trail/structuring-a-site/link-aliases.md) add `original`.

## sitemap.xml and robots.txt

`robots.txt` is always written, allowing everything, with a `Sitemap:` line when the site config has a `url`.

`sitemap.xml` is written **only** when it has one, because sitemaps require absolute URLs. It lists every HTML page — the front page, products, anthologies, book covers, articles and `/print` bundles — each with a `<lastmod>` taken from that page's `updated:` date, or for a container, from the most recent one beneath it. [Alias pages](/trail/structuring-a-site/link-aliases.md) are left out: their canonical is the original.

## The 404 page

`404.html` is a real page in the site's theme, carrying `<meta name="robots" content="noindex">`. Trail writes it; your host has to be told to use it. `trail serve` already does, and returns a real 404 status with it.

## Search

`/pagefind/` holds a [Pagefind](https://pagefind.app) index built from the rendered pages: the index chunks, the WASM search core, and the `pagefind.js` module the theme's search script imports.

The front page is not indexed. Neither are [alias pages](/trail/structuring-a-site/link-aliases.md) — the original covers the same content — nor the parts of a page marked as chrome: breadcrumbs, the meta line, the Related Content list, the pager and the edit link. Each indexed page carries its breadcrumb trail as metadata, which is what search results show under the title.

## Assets

`/assets/` holds the stylesheet, the search script, the two variable fonts with their licences, and `LICENSE-Syntaxes.txt` — the licences of the bundled syntax-highlighting definitions, which ask to travel with them. `mermaid.min.js` and its licence join them only if some article on the site contains a [diagram](/trail/writing/admonitions-tabs-and-diagrams.md). A configured `custom_css` ships as `/assets/custom.css`; a configured `favicon` ships at the output root under its own name.

Images are published at the URL of the directory they were found in — but only the ones some article actually references. See [Images](/trail/writing/images.md).

## The cache-busting copy

`--cbpath <PATH>` adds one more entry to the output: `dist/<PATH>/`, holding everything above a second time, with every link inside it rewritten to stay inside it. It is how you get a URL that no cache can have seen. See [The cache-busting copy](/trail/building/the-cache-busting-copy.md).

## Passthrough files

Anything named by the root config's [`passthrough`](/trail/building/configuring-the-site.md) is copied in verbatim — a `CNAME`, a `.well-known/` directory, a verification file — keeping its path relative to the site root. It is written after everything else, so a passthrough named for a generated file (`robots.txt`) replaces it.

Passthrough files are tracked like any other output, so they survive the prune between builds.

---

# The cache-busting copy

_Trail / Building_

> What --cbpath publishes, why a second copy of the whole site is the only version of the idea that works, what carries the path prefix and what deliberately does not, and how to wire it into a deploy.

Static hosts serve your pages with cache headers you do not control. GitHub Pages sets its own; so does every CDN in front of it. For the minutes or hours those headers claim, a reader — or, far more often, an automated fetcher — can be served the *previous* build with nothing to tell them apart.

`--cbpath` answers that by publishing a second, complete copy of the site under a path you name:

```sh
trail build --cbpath 8f3a91c
```

```text
built 1227 pages (8 products) → ./dist
cache-busting copy → ./dist/8f3a91c/
```

Now `/8f3a91c/` is the site's front page and `/8f3a91c/pekit/reference/cli` is that article. Name the copy after something that changes on every deploy — a commit hash — and every URL inside it has never been requested by anyone, so nothing anywhere can have it cached. What you get back is necessarily this build.

## Why a copy and not a redirect

The obvious cheap version — one busted URL that redirects, or a byte-for-byte copy of the tree — does not work, and fails in a way you cannot see.

Every URL trail emits is site-absolute: `/pekit/reference/cli`, `/assets/style.css`, `/pagefind/pagefind.js`. A plain copy at `/8f3a91c/` would serve you a fresh HTML document for the one page you asked for, and that document would then load the **cached** stylesheet, the **cached** search script, and every link out of it would land back in the cached tree. One click and you are reading the old build again, with nothing on the page to say so.

So the copy is not copied. It is built, with the path baked into every URL it emits.

## What carries the prefix

| | |
|---|---|
| Every link between pages | breadcrumbs, sidebars, cards, the pager, `~` references in prose |
| `§` section citations and [inline references](/trail/writing/inline-references.md) | |
| Images that live in the tree | the ones trail publishes for you |
| Assets | the stylesheet, the search script, mermaid, your `custom.css`, the favicon |
| The search index | so a result inside the copy stays inside it |
| The whole [markdown surface](/trail/building/the-output-surface.md) | `.md` mirrors, `print.md`, `llms.txt`, `site.json` — the copy is mostly *for* machine readers, so these matter most |
| `passthrough` entries | copied into the copy as well as the site |

And what does not:

| | |
|---|---|
| `<link rel="canonical">` | names the **real** page, so the copy never competes with it |
| URLs you wrote out in full | `https://…`, and a leading-slash path you typed yourself — trail emits those exactly as written rather than second-guessing them |
| `edit_url` | it points at your repository, not at the site |
| `robots.txt` and `sitemap.xml` | see below |

## Search engines

The copy is a complete duplicate of the site at a path that changes on every deploy — exactly the thing a search engine should never index. Trail makes sure it isn't:

- every page in the copy carries `<meta name="robots" content="noindex, follow">`;
- every page names the real page as its canonical;
- the copy has no `sitemap.xml`, and the site's sitemap does not mention it.

There is deliberately **no** `Disallow` in `robots.txt`. Some automated fetchers honour `robots.txt` on a URL you handed them directly, and blocking the copy would defeat the point of having it.

## The path

One plain segment, made of letters, digits, `-`, `_` and `.`, not starting with a dot. It must not be a path the site already uses — a product slug, `assets`, `pagefind`, `site.json`, a `passthrough` entry — or the copy would bury it. Trail checks before it builds anything:

```text
Error: --cbpath 'assets' is already a path in the site; the copy would bury it — pick a name the site does not use
```

## What it costs

The output roughly doubles: twice the pages, twice the mirrors, a second search index. On a large site that is real — Peios Learn goes from 115 MB to 232 MB, and its deploy artifact from 27 MB to 54 MB. Build time roughly doubles too. Nothing about the site itself gets slower; the copy is only ever fetched by someone who asks for it by name.

## In a deploy

The point is to inject the commit, so the path is new on every push:

```yaml
- name: Build site
  run: trail build --cbpath "${GITHUB_SHA::8}"
```

On a `push`, `GITHUB_SHA` is the commit that `actions/checkout` just checked out, so the path and the content it names cannot disagree. The first eight characters are plenty to be unique per deploy and short enough to paste by hand.

Each deploy replaces the output wholesale, so only the current commit's copy exists at any moment — the previous one is pruned along with everything else the build did not write. Do not bookmark one.

## Finding it as a reader

The site itself learns where its copy is, and the search box gets a command for it. Type `/cb` (or `/cachebust`) and you land on the same page you were reading, in the copy. Type it again from inside the copy and you come back out. See [the reading experience](/trail/building/the-reading-experience.md).

---

# Theming

_Trail / Building_

> How a trail site is coloured — the site accent, per-product tints, the three-state light/dark system, the CSS custom properties everything resolves through, and the custom stylesheet escape hatch.

Trail ships one theme, compiled into the binary. There is no template directory to override and no theme to install. What you do control is colour — through configuration for the ordinary cases, and through CSS custom properties for everything else.

## The three colour settings

```toml
accent = "#3b82f6"          # the site's colour
accent_dark = "#7aa8ff"     # optional; derived from accent if absent
product_theming = true      # default
```

**`accent`** is the site's colour: links, focus rings, the wordmark's accented word, the hero glow, the front-page cards. Without it the theme's own default is used.

**`accent_dark`** is the dark-mode variant. Without it, trail derives one by mixing the accent 75% toward white — a light accent reads badly on a dark background, and most brand colours need lifting. Set it explicitly when the derived one is wrong. Setting it *without* `accent` is a build error.

Both must be `#rrggbb`.

**Each product also has a `color`**, and by default it displaces the site accent on every page belonging to that product: cards, the monogram tile, sidebar highlights, heading numbers, RFC-2119 keywords, focus rings. `product_theming = false` turns this off site-wide — every per-product tint collapses to the site accent, and products keep only their monogram and their card.

## Three-state light and dark

Every reader is in one of three states, and the header toggle cycles between them:

| State | |
|---|---|
| **System** | Follow the operating system's preference. The default. |
| **Light** | Pinned light, whatever the system says. |
| **Dark** | Pinned dark, whatever the system says. |

The choice is stored per reader in their browser and applied **before first paint**, so a reader who has chosen dark never gets a flash of white.

In CSS this is three blocks: `:root` carries the light tokens; `:root[data-theme="dark"]` overrides them when the toggle has pinned dark; and `@media (prefers-color-scheme: dark) :root:not([data-theme="light"])` overrides them for system-dark readers who have not pinned light.

> [!IMPORTANT]
> If you write custom CSS with colours in it, follow the same three-state pattern. Defining a colour only inside a `prefers-color-scheme` block means the header toggle cannot override it, and a reader who pins light on a dark machine gets your dark colour on trail's light page.

## The tokens

Everything resolves through CSS custom properties, so overriding a token restyles every place it is used.

| Token | |
|---|---|
| `--bg` | Page background. |
| `--surface` | Cards, menus, the sidebar — translucent over the background. |
| `--text` | Body text. |
| `--text-muted` | Secondary text: crumbs, meta lines, captions. |
| `--border`, `--border-strong` | Hairlines and emphasised edges. |
| `--accent` | The site accent, as resolved for the current theme. |
| `--pc` | The **product colour** in scope. Falls back to `--accent`. |
| `--glow`, `--glow-mix` | The accent glow behind heroes. |
| `--headline` | The gradient headings are painted with. |
| `--tile-mix`, `--tile-fg-mix` | How strongly monogram tiles take their product colour. |
| `--card-shadow` | Card elevation. |
| `--menu-bg` | The products dropdown, which must be opaque. |
| `--code-comment`, `--code-keyword`, `--code-string`, `--code-constant`, `--code-function`, `--code-type` | The syntax-highlighting palette. |

`--pc` is the one to know. It is set inline on every element scoped to a product, which is how one stylesheet tints five products differently — and why `product_theming = false` can collapse them all with a single rule.

## Syntax highlighting follows the theme

Code is highlighted at build time into **class names**, not inline colours: `hl-comment`, `hl-keyword`, `hl-string` and so on. Those classes resolve through the six `--code-*` tokens above, which are redefined in each theme state — which is why code goes light and dark with the rest of the page. Override those six tokens to change the code palette; see [Code blocks](/trail/writing/code-blocks.md).

## Fonts

Two variable fonts ship with the site and are served from `/assets/fonts/`: **Inter** for body text and **Space Grotesk** for headings. Both are self-hosted — no third-party font request — and their licences ship beside them, as the SIL Open Font License requires.

To use different fonts, add `@font-face` rules and a `font-family` override in your custom stylesheet. The built-in fonts are still emitted; there is no setting to suppress them.

## The custom stylesheet

```toml
custom_css = "custom.css"
```

The named file ships at `/assets/custom.css` and is loaded **after** the built-in stylesheet, on every page.

Overriding tokens goes furthest, and survives changes to trail's own CSS:

```css
:root {
  --accent: #0f766e;
  --card-shadow: none;
}
:root[data-theme="dark"] { --accent: #5eead4; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --accent: #5eead4; }
}
```

Beyond that it is ordinary CSS against trail's own class names — `.card`, `.sidebar`, `.admonition`, `.code-block` and the rest. Those class names are not a stable interface: they are what the compiled templates happen to emit today, and a trail upgrade may change them. Token overrides are the durable half.

## Injecting into the head

For anything that is not a stylesheet — a webfont link, an analytics snippet, extra meta tags — [`head_html`](/trail/building/configuring-the-site.md) names a file injected verbatim at the end of every page's `<head>`.

## What you cannot change

- **The templates.** They are compiled into the binary and are not user-replaceable. Page structure is trail's.
- **The layout**, beyond what CSS can reach.
- **The set of page types.** There is no way to add a new kind of page.

That is the trade trail makes: no plugin system and no theme directory, in exchange for a single binary with nothing to install and one appearance to maintain.

---

# The reading experience

_Trail / Building_

> Everything the theme gives a reader — search, three-state theming, the sidebar and On this page, text size, copy buttons, permalinks, prev/next, the single-page link and the mobile drawer.

None of this needs configuring. It is what every trail site does, and it is here so you know what your readers have — and so you can write pages that make use of it.

## Search

<kbd>⌘K</kbd> — or <kbd>Ctrl K</kbd>, and the pills in the header say which, based on the reader's platform — opens a search modal from any page. So does clicking the search box in the header or the one on the front page.

Search is powered by a [Pagefind](https://pagefind.app) index built at build time. **The engine and its index chunks load lazily, on the first open**, so a reader who never searches pays nothing for it.

- Results show the page title, its breadcrumb trail, and an excerpt with the matches highlighted.
- Up to eight results are shown; the status line says how many more there were.
- <kbd>↑</kbd> and <kbd>↓</kbd> move; <kbd>Enter</kbd> opens; <kbd>Esc</kbd> or a click on the backdrop closes.
- **The query is carried through to the page**, which highlights every occurrence of each term in the article body and scrolls the first into view. Matches inside code blocks and breadcrumbs are skipped.

What is indexed is the article body. Chrome — breadcrumbs, the meta line, Related Content, the pager, the edit link — is excluded, and so is the front page and every [alias page](/trail/structuring-a-site/link-aliases.md).

## Commands

A query beginning with `/` is a command rather than a search. Typing `/` on its own lists the ones this page has; typing more narrows the list, and <kbd>Enter</kbd> runs the highlighted one.

| Command | |
|---|---|
| `/theme` | Cycles **system → light → dark**, exactly as the header button does. |
| `/theme <name>` | Goes straight to `system`, `light` or `dark`. |
| `/cachebust`, `/cb` | Opens the page you are on in [the cache-busting copy](/trail/building/the-cache-busting-copy.md) — a URL nothing has cached. From inside the copy it takes you back out to the real page. Offered only when the build published a copy. |

Nothing needs enabling. The commands a page offers are the ones that can do something there, which is why `/cb` appears on a site built with `--cbpath` and nowhere else.

## Light, dark and system

The header's theme button cycles **system → light → dark**, and its icon shows which state is active. The choice is remembered in the reader's browser and applied before first paint, so there is no flash of the wrong theme.

"System" is the default and follows the operating system's preference live. See [Theming](/trail/building/theming.md).

## Navigation

**The sidebar** on an article page shows the article's own topic — its title above its contents in reading order, subfolders as collapsible groups (expanded automatically around the current page), and the current article marked. In a [book](/trail/structuring-a-site/books.md) it is the whole book as a tree with section numbers, chapters collapsible, and the trail to the current page opened. Chapters a reader opens by hand stay open as they move through the book, for that tab.

**"On this page"** lists the article's `h2` and `h3` headings in a right-hand column, and highlights the one currently in view as the reader scrolls. When the window is too narrow for a third column it moves into the sidebar. An article with no headings gets no list.

**Every heading has a permalink** — a `#` that appears on hover and links to that heading's stable id.

**Previous and next** links at the foot of an article step through the whole topic, or the whole book, in reading order.

**Breadcrumbs** run from the product down through anthologies, the topic and any subfolder.

**A back-to-top button** appears once the page has been scrolled past about 600 pixels.

## Text size

Three buttons at the top of the right-hand column set the prose to small, medium or large. The choice is remembered per reader.

It scales the **article text only** — the sidebar, header and chrome stay put. Making the whole page bigger costs a reader half their screen; making the prose bigger is what they actually wanted. On narrow screens the controls ride into the drawer with the rest of the sidebar.

## Code and content

- **Every code block has a copy button**, top-right, appearing on hover and always visible on touch devices. It says "Copied" briefly, and falls back to telling the reader to press <kbd>⌘C</kbd> if the browser refuses clipboard access.
- **Syntax highlighting follows the theme**, because it is class-based rather than baked-in colours.
- **Tables scroll inside their own container**, so a wide table never makes the whole page scroll sideways.
- **Images carry their real dimensions**, so text does not jump around as they load.
- **[Tab groups](/trail/writing/admonitions-tabs-and-diagrams.md)** keep one panel open at a time, independently per group.
- **[Mermaid diagrams](/trail/writing/admonitions-tabs-and-diagrams.md)** are drawn in the reader's theme, and the library is only shipped to pages that contain one.

## Page furniture

**A meta line** under the title gives the estimated reading time and, when the article has an `updated:` date, when it last changed. Containers total theirs up, so a book cover says how long the whole book takes.

**A "Single page" link** in the breadcrumbs opens the whole topic or book as one page, anchored at the article the reader was on. It is the right link for someone who wants to read the lot, print it, or search the whole document at once. See [The output surface](/trail/building/the-output-surface.md).

**"Edit this page"** appears at the foot of every article when the site config sets `edit_url`, pointing at the article's source file.

**A "Related Content" list** appears above the pager when the article's frontmatter has a `related:` list.

## On small screens

The sidebar becomes a drawer, opened by the menu button in the header and closed by the overlay, the button again, or following any link inside it. "On this page" and the text-size controls ride in with it, so nothing is lost on a phone.

## On paper

Every page prints. The header, footer, search modal, pager, "Single page" link and back-to-top button all drop out; tab groups expand so every panel is visible, each labelled with its tab's name.

For printing a whole topic or book, use its [`/print` view](/trail/building/the-output-surface.md) rather than printing pages one at a time.

## Without JavaScript

The pages are static HTML and read fine with scripts disabled. What is lost is the interactive layer: search, the theme toggle, text size, copy buttons, tab switching, the drawer, scroll-spying and diagram rendering. Content, navigation links, the sidebar tree and every `~link` work regardless.
