Skip to content

Writing docs

This site is Material for MkDocs, laid out from the docsforge template. The Rust API reference is generated by rustdoc and folded into the same site, so there is one place to look and no second link to forget.

Serve it locally

uv venv
uv pip install -r docs/requirements.txt
uv run mkdocs serve

pip install -r docs/requirements.txt works just as well if you would rather not use uv. The site comes up at http://127.0.0.1:8000/surface/ and reloads on save.

Building the docs needs cargo on PATH: the rustdoc hook runs during every build. Nothing else is required — no Python package to install in editable mode, because surface has no Python in it.

Build it the way CI does

uv run mkdocs build --strict

--strict turns warnings into failures, and validation: in mkdocs.yml turns four kinds of silent link rot into warnings — so a broken internal link, a bad anchor or a page missing from the nav fails the build rather than shipping. Run this before opening a pull request; the docs workflow runs exactly the same command.

Outbound links are a separate problem, since a third-party 404 is nobody's fault. links.yml checks those weekly with lychee and opens an issue, deliberately not on pull requests.

Layout

mkdocs.yml                 # theme, nav, extensions, plugins
scripts/rustdoc_hook.py    # runs cargo doc, folds the output into the site
scripts/brand.py           # regenerates docs/assets from the mark's geometry
overrides/
├── main.html              # the in-development banner (Material's announce block)
└── partials/header.html   # Material's header + the Holistic AI attribution
docs/
├── index.md               # home page (hero markup lives here)
├── requirements.txt       # the Python deps for this site only
├── assets/                # the brand kit: mark, lockup, favicon cuts (generated)
├── stylesheets/extra.css  # the entire visual identity
├── getting-started/
├── guide/
└── reference/

The mark is an S carved out of scan lines: tapered bars fill a disc, a sine channel cuts through them, and the gap the channel leaves is the letter. The bars are the language the dashboard already draws in and the sine is the one the glyph field behind these pages rides on, so the mark is the site's two motifs in one shape. Each bar is thick where it meets the S and tapers to a point at the rim — a hard edge on the letter, a feathered one on the disc.

It ships in two tones: logo-mark.svg in Ink for a light surface, logo-mark-inverse.svg in Paper for a dark one. The header always takes the inverse cut, because the header is Ink in both schemes; the hero carries both and hides one in CSS.

favicon.svg is a different drawing, not a smaller one: below about 24px the scan lines land inside a device pixel and antialias into a grey disc, so the small cut drops the striping and knocks the same S out of a solid disc. Two shapes instead of forty.

Nothing in assets/ is hand-edited. scripts/brand.py holds the geometry and regenerates every SVG and PNG in one pass:

pip install fonttools     # outlines the wordmark
brew install librsvg      # provides rsvg-convert, for the PNG cuts
python scripts/brand.py

The lockup's "surface" is Google Sans Flex 500 — the same face as the body text — converted to outlines rather than left as an SVG <text> element. Both the README and the hero load it through an <img> tag, and an image cannot fetch a webfont, so live text would fall back to whatever the viewer has installed.

Every page must appear in nav: in mkdocs.yml. One that does not is reported by omitted_files validation and fails a strict build — which is the point: an unreachable page is worse than a missing one, because nobody knows it is stale.

The rustdoc hook

scripts/rustdoc_hook.py is registered under hooks: in mkdocs.yml. On every build it:

  1. Compares crate source mtimes against target/doc/surface/index.html, and skips the rest when nothing changed.
  2. Runs cargo doc --no-deps --document-private-items.
  3. Stages the output and appends the files to the MkDocs build under rust/.

The files join the build before pages render, so links into the reference validate under --strict like links to any other page.

--document-private-items is not optional here. surface is a binary crate whose modules are private; without the flag rustdoc emits an index with nothing under it. Those module-level comments are the design record — the measurements, the rejected alternatives, the reasons a thing is the way it is — so they are worth publishing.

Editing Rust while serving

mkdocs serve watches docs/, not src/. Changes to the crate do not trigger a rebuild on their own; touch any docs page, or restart the server, and the hook regenerates the reference on the next build.

House style

The prose here follows the same rules as the code comments, which is why the two read alike:

  • Say the mechanism, not the adjective. "330 ms because a domain LIKE filter cannot use an index" beats "blazing fast". A number or a mechanism persuades; an adjective does not.
  • Document the limits with the feature. Every page that describes a figure also says how that figure can be wrong. Costs is half caveats by design.
  • Prefer a table to a list of sentences when the content is field-and-meaning. Most of this site is tables for that reason.
  • Never describe an unread thing as absent. "Could not read" and "nothing there" are different, in the docs exactly as in the program.
  • Link forward. Every page ends by naming where to go next.

Adding a page

  1. Write docs/<section>/<name>.md.
  2. Add it to nav: in mkdocs.yml.
  3. Link to it from a neighbouring page, so it is reachable by reading rather than only by search.
  4. Run uv run mkdocs build --strict.

Keeping copies honest

The configuration reference embeds the real example file rather than a copy of it:

# surface configuration — every setting is optional.
#
# surface works with no config file at all; this documents the defaults. Copy it
# to the path `surface --check` reports as the config directory, as `surface.toml`.
#
#   macOS    ~/Library/Application Support/ai.holistic.surface/surface.toml
#   Linux    ~/.config/surface/surface.toml
#   Windows  %APPDATA%\holistic\surface\config\surface.toml

# ----------------------------------------------------------------- AI sites
#
# Read browser history for visits to known AI domains. Only the domain, a visit
# count and a last-seen date are read: the domain filter runs inside SQLite, so
# no URL, path, query string or page title is ever loaded — not even for the AI
# domains themselves, let alone the rest of your browsing.
[web]
# Set false to skip browser history entirely. The Sites view then says it is
# switched off rather than showing an empty list.
scan_history = true

# How recently a domain must have been visited to appear. Visit counts are the
# browser's lifetime totals; this only decides which domains are listed.
history_lookback_days = 30

# Extra domains to treat as AI services — an internal model gateway, say.
# Matched on the registrable host, so "models.corp.example" also covers
# "www.models.corp.example" but never "notmodels.corp.example".
extra_ai_domains = []

# --------------------------------------------------------------- token usage
#
# Read the transcripts Claude Code, Codex and OpenCode already write, to count
# tokens per day, tool and model. Message content is never read.
[usage]
# Set false to skip transcripts entirely.
scan = true

# How many days of daily totals to keep. Older days are pruned from the ledger,
# along with their deduplication keys.
window_days = 30

# ---------------------------------------------------------------------- cost
#
# surface prices tokens at API rates. If you pay a flat subscription instead,
# tell it what you actually pay and the Cost view will compare the two.
[cost]
# Monthly spend per tool, in USD. Keys are tool ids as they appear in the Usage
# view: claude_code, codex, opencode, gemini_cli, ...
#
# Without an entry here a tool gets no subscription row at all — surface reads no
# account state, so it cannot know your plan and will not guess one.
[cost.subscriptions]
# claude_code = 100.0
# codex = 30.0

# ------------------------------------------------------------ env overrides
#
# Environment variables win over this file, and are handy for one-off runs:
#
#   SURFACE_STATE_DIR              where the ledger and price cache live
#   SURFACE_CONFIG_DIR             where this file is looked for
#   SURFACE_SCAN_HISTORY=false     same as [web] scan_history
#   SURFACE_HISTORY_LOOKBACK_DAYS  same as [web] history_lookback_days
#   SURFACE_SCAN_USAGE=false       same as [usage] scan
#   SURFACE_USAGE_WINDOW_DAYS      same as [usage] window_days

pymdownx.snippets is configured with check_paths: true, so a missing include is a build error rather than a silently empty block. Use this for anything that also exists in the repository — a copy that can drift will drift.

The docs workflow

.github/workflows/docs.yml builds and publishes to GitHub Pages on every push to main that touches docs/, mkdocs.yml, src/, Cargo.toml or the workflow itself. It installs the Rust toolchain (for the hook) and the Cairo libraries (for social cards), runs mkdocs build --strict, and deploys the artifact.

One-time repository setup: Settings → Pages → Build and deployment → Source: GitHub Actions. No token, no gh-pages branch.