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
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
--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:
- Compares crate source mtimes against
target/doc/surface/index.html, and skips the rest when nothing changed. - Runs
cargo doc --no-deps --document-private-items. - 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
LIKEfilter 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
- Write
docs/<section>/<name>.md. - Add it to
nav:inmkdocs.yml. - Link to it from a neighbouring page, so it is reachable by reading rather than only by search.
- 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.