Interactive figures
Praetor's documentation draws its architecture figures with interfig, the engine behind the animated figures on the Hindsight documentation site. On the site a figure plays scenario tabs with moving packets, narration, pause and 1×/2× speed. Everywhere else, in the README, the GitHub wiki, or a browser without JavaScript, the same figure is a committed SVG with a caption and a text description. ADR-0015 records the design.
The first figure is the gated pipeline on the C4 architecture page.
Where the engine lives
Everything the figures need sits in one tree, tools/figures/:
| Path | Role |
|---|---|
core.mjs |
The render core: spec validation, the text description, the figure markup and the three outputs of one figure. Hashed. |
build.mjs |
The command line: build, check, sources, site and portable. Node 22.18 or later, no npm package. |
checks.mjs |
The sources, site and portable checks, and the slot filling of the figure markup. Node builtins only. |
loader.ts, player.tsx, keyboard.ts |
The player source: the loader reads each figure's props from its SVG and mounts the React player. |
bundle.mjs |
Builds the committed player in player/ with esbuild, checks it byte for byte against a rebuild from the lock, and holds its size budget. Needs the locked npm install. |
player/ |
The committed player: loader.js, player.js and THIRD-PARTY-LICENSES.txt, in the engine only. A release carries them as figures-player.tar.gz, which adopters fetch (ADR-0021). |
types.ts |
The spec type a docs/figures/<slug>.ts file checks against. |
mkdocs_hook.py |
The MkDocs hook that renders each figure fence and publishes figures.css and the player: player/ in the engine, elsewhere the release archive player.lock.json names, fetched over https and checked against its SHA-256. Python standard library and MkDocs only. |
astro.mjs |
The Astro integration for a Starlight site: renders each figure code block, loads the loader, and serves and publishes the figure and player files. Node builtins only. |
serve.mjs |
Serves and copies the figure and player files for astro.mjs, and serves a built site for the smoke test. Node builtins only. |
figures.css |
The figure stylesheet, reading Material's theme variables with Starlight's as the fallback. |
third_party/interfig/ |
The vendored engine, byte-identical to its pin. |
README.md |
The neutral authoring guide that travels with the engine to adopting repositories. |
assets.go |
Embeds the engine files an adopting repository receives into praetorctl, as an explicit list (ADR-0016, section 2). assets_test.go fails when a tracked file is neither in that list nor named repository-only, or when a listed script imports a file outside it. |
In adopting repositories
The engine is the second managed asset family of the docs:seo-portal facet, beside the
Markdown gate (figureEngine in internal/managedasset/family.go;
ADR-0016, section 5). The facet is in the default facet
set, and removing it is the only way to opt out of figures. With the facet enabled,
praetorctl adopt writes:
- the 16 files
assets.golists, undertools/figures/, andplayer.lock.jsonbeside them, which names the engine release and the SHA-256 of the player archive the docs build fetches (an unreleased engine leaves the release empty and the site shows SVG figures with one warning). A file the repository already had at one of those paths stops the first adoption, with or without--force, becausetools/figures/is a name a repository may use for its own code; - rules in the managed block at the end of
.gitattributesthat keep the engine,docs/figures/*.tsanddocs/assets/figures/*at LF and the vendored interfig files unconverted (internal/adopt/gitattributes.go, rules fromAttributesinassets.go). The block opens with the DevContainer rule adoption writes in every repository (checkout line endings); - a
docs-figurestarget in the managed Makefile block, attached toverify-allbesidedocs-lint, runningbuild.mjs checkandbuild.mjs sources.sourcesreads the pages of the site configuration at the repository root, an MkDocsmkdocs.ymlor a Starlightastro.config.*(sourceSitesintools/figures/checks.mjs), so the target checks the figure fences of either site; - a "Verify figures" step in
.github/workflows/praetor-docs.yml, after "Verify public Markdown", under the same requiredDocumentation Governancecontext.
In a repository with no spec and no committed output, check and sources print that they
skipped and why, and exit 0, so the target and the workflow step cost nothing there.
On the first run, a spec has no outputs yet: the Starlight preset ships its example spec without
them, and a new spec has none until it is rendered. Run node tools/figures/build.mjs build
before the first site build, and commit the outputs; a site build fails on a figure fence
whose JSON is missing. Until then make docs-figures fails, and check and sources name that
command (the first-run tests in tools/figures/figures.test.mjs). The target never renders on
its own, so a gate cannot pass on outputs nobody committed.
With the
facet disabled, adoption removes the canonical engine files, the engine's rules in the
.gitattributes block, the Makefile block and the documentation workflow, and refuses to remove an engine file that was
edited. praetorctl audit compares every engine file byte for byte,
requires the target and the block, and warns when the repository's REUSE.toml has no
annotation labelling tools/figures/third_party/interfig/upstream/** MIT, or has one that a
later table whose path globs match all or some of the same files, such as ** or **/*.js,
relabels (cmd/standardsctl/audit_reuse.go; the tables are read by ReuseLabels in
internal/supplychain/reuse.go, which matches globs as REUSE 3.3 defines them).
An edited .gitattributes block fails a plain praetorctl adopt;
praetorctl adopt --force --lock-source-root=<praetor checkout> restores it and keeps a backup.
Every change to an embedded file reaches adopters on their next praetorctl adopt. The
shipped-text ledger, internal/managedasset/testdata/shipped/figure-engine.sha256, records every
text the family ever shipped; after such a change, append the new digests with
PRAETOR_UPDATE_SHIPPED_TEXTS=1 go test ./internal/managedasset -run 'TestShippedTextLedger$'
and add each outgoing digest to priorDigests in assets.go, so plain adoption refreshes an
unedited copy. The family holds at most 64 earlier texts. The player bundle is no family asset
any more: a React or esbuild bump changes the release archive, whose digest each adopter's
player.lock.json records, and costs the ledger nothing.
Locked assets and linters
Audit locks the engine files byte for byte, so an adopter that runs a linter over every tracked
file cannot fix a finding in one. make hooks-lint (scripts/test_emitted_hook_lint.py)
therefore lints every file of every registered family. It does not list them: it runs
go run ./internal/managedasset/export, which writes managedasset.Assets (the registry's
canonical bytes), so a family added to the registry is linted the day it is registered. Per
language:
| Language | Check |
|---|---|
| Go | gofmt -l, gofumpt -l, and go vet per package directory, in a scratch module |
| Python | black at its defaults and flake8 at 100 columns; ruff check at the rules of templates/python/ruff.toml.tmpl plus S, RUF, PERF, ASYNC and C90 (#845), line length 100, target Python 3.12; and ruff format --check at black's width (ruff's formatter reproduces black's, so one formatting policy holds under both tools) |
| YAML | yamllint --strict with its defaults |
| Shell | shellcheck |
The Markdown assets are held to markdownlint's default rules as far as the in-process subset of
internal/testsupport reaches (TestManagedMarkdownHoldsTheDefaultRules, MD013 at 80 columns
included); make docs-lint runs the real markdownlint library over the repository's copies of the
same files.
The tools come from the declared pin sources: black, flake8, yamllint and ruff from the
hash-locked .config/hook-lint/requirements.txt, gofumpt from tools/go/go.mod. CI installs them
from there and sets PRAETOR_HOOK_LINT_BIN, and while it is set a missing or mismatched tool
fails the gate; without it a missing tool skips its check and says why. The harness plants a
defect per language (an unformatted Go file, a RUF005 concatenation, an unquoted shell
expansion) and fails when a linter accepts it. When a change to a managed file makes it unclean,
fix the file and record its outgoing text as above; do not exclude it.
Mermaid is retired
Every diagram the root site builds is a figure, the generated wiki pages included, so the root
mkdocs.yml declares no mermaid fence. A Mermaid fence on a page the root site builds fails
node tools/figures/build.mjs sources and site, and the finding says to draw it as a figure
fence instead. The adopter preset, docs/presets/mkdocs/, declares no mermaid fence either: its
mkdocs.yml lists the figure hook, and its example page draws the site-build figure, whose spec
and outputs live in the preset. It builds its own docs/ in a repository adopted with
docs:seo-portal, so the root mkdocs.yml excludes that directory (exclude_docs), and the
root checks skip it too. The
documentation governance guide covers
which configuration enables which kind and how CI builds the preset.
Add a figure
- Read the code the figure will show. Old diagrams and prose are hypotheses: every box, edge and narration line must match the code it cites.
- Write the spec as
docs/figures/<slug>.ts, with a lowercase kebab-case slug:
import type { PraetorFigure } from '../../tools/figures/types.ts';
export default {
title: 'Gated pipeline',
alt: 'Six gate stages run in order; a failing stage rejects the change before a receipt is signed.',
evidence: ['internal/gating/pipeline.go:executeStages'],
props: { layout: { children: [] }, edges: [], steps: [] },
} satisfies PraetorFigure;
- Build the committed outputs. This needs Node 22.18 or later and no npm install:
- Name the figure in a page with a
figurefence that holds only the slug:
- Commit the spec,
docs/assets/figures/<slug>.svg,<slug>.static.svgand<slug>.json.
docs/figures/gating-pipeline.ts is a complete example with four scenario tabs.
Spec rules
props takes interfig's figure format, which the
upstream README
describes: a layout tree of groups and boxes, edges, and steps made of beats. Praetor
narrows it (tools/figures/types.ts), and validate in tools/figures/core.mjs enforces the
same rules at build time:
- Every label,
sub,say,caption, hopdataand card row is a string, so the player, the SVG and the text description show the same content. There is no JSX in a spec. altis one sentence of at most 125 characters.titlebecomes the caption.evidencelists at least onepath:Symbolanchor.node tools/figures/build.mjs sourcesfails when the path is gone or the symbol no longer occurs in it.- Ids are unique across boxes and groups. Every edge end is a box or group id, every beat names an
existing edge, and every
showandlightkey is a box id. - At most 40 boxes, 40 groups, 80 edges and 12 steps, with at most 32 beats per step.
- Use
shape: 'store'for data at rest, and no Hindsight names or branding. describeadds lines to the text description.
The text description is derived from the spec: the groups with their boxes, the edges as
sentences, each scenario's label, caption and narration, then describe. It is capped at 2,500
characters on whole lines.
What the build writes
For each spec, node tools/figures/build.mjs build writes to docs/assets/figures/:
| File | Content |
|---|---|
<slug>.svg |
The animated SVG, with role="img", a <title>, a <desc>, a credit comment and the embedded spec. |
<slug>.static.svg |
The same figure without steps, shown under prefers-reduced-motion. |
<slug>.json |
Title, alt, text description, evidence, the edges with box labels, the size of each SVG in whole pixels (width/height animated, static_width/static_height static), the SHA-256 of the spec, the engine and both SVGs, and the figure markup as html. |
A build also deletes the outputs of a spec that no longer exists. Each figure has its own JSON file, so figure changes in parallel branches do not collide.
The engine hash covers the three vendored render files and tools/figures/core.mjs
(ENGINE_FILES in core.mjs). An edit to one of them marks every figure stale; an edit to
build.mjs, checks.mjs or bundle.mjs does not.
The committed player
The player is the same for every site, because it reads each figure's props from the SVG the
figure shows. tools/figures/bundle.mjs builds it once into tools/figures/player/, which is
committed (ADR-0016, section 3):
loader.jsloads on every page;player.jsholds React and interfig and loads only when a figure nears the viewport; the loader imports it by that fixed name from beside itself;THIRD-PARTY-LICENSES.txtholds the full license texts of interfig and of every npm package the player bundles.
esbuild builds the two scripts without code splitting, so no file name carries a hash and a
dependency bump changes bytes, never the file list. No docs build bundles. After a change to the
player source, the lock or the interfig pin, rebuild and commit player/:
node tools/figures/bundle.mjs --check rebuilds in memory and fails when a committed file differs,
is missing or is not a bundle output, when player.js exceeds 250 kB minified, or when the
installed esbuild or a bundled package differs from package-lock.json. It runs on Linux, macOS
and Windows; the managed block at the end of .gitattributes pins tools/figures/** to LF, so
the comparison holds on a Windows checkout.
build.mjs check does not bundle.
How a page shows a figure
tools/figures/mkdocs_hook.py replaces each figure fence with a <figure> holding a
<picture> of both SVGs, the caption and a <details> text description. A fence nested inside a
longer fence stays source text. A slug without JSON logs a warning, which fails
mkdocs build --strict.
The hook imports no checker: MkDocs runs hooks in Python, so the hook keeps its own fence scanner
beside the Node one in checks.mjs. Both replay tools/figures/fence-fixtures.json (in
tools/figures/test_mkdocs_hook.py and tools/figures/checks.test.mjs), and the site check
compares each page's fences with its rendered figures, so the two cannot drift apart unnoticed
(ADR-0016, section 6).
The markup has one source: markup in core.mjs writes it into each figure's JSON as html,
with a {{base}} slot for the URL prefix of the SVGs and a {{link}} slot for the interactive
figure. The hook (render_block) and build.mjs portable (fillSlots in checks.mjs) only fill
the slots; a caller without a link drops the line that holds {{link}}, as the hook always does.
tools/figures/markup-fixtures.json pins the markup for both languages:
tools/figures/figures.test.mjs renders it, and tools/figures/checks.test.mjs and
tools/figures/test_mkdocs_hook.py fill it. build.mjs sources fails a JSON whose html is not
what markup renders from the rest of it.
The static SVG has no scenario area, so it is usually shorter than the animated one. The
reduced-motion <source> carries the static size and the <img> the animated size, so the
browser reserves the height of the image it shows: no letterboxing and no layout shift
(the BUG-1002 test in tools/figures/figures.test.mjs).
When a figure nears the viewport, the loader fetches the SVG its <img> shows (currentSrc, from
the same origin and normally already cached), reads the props from the SVG's
<metadata id="figure-spec">, and mounts the player in place of the <picture>, with the caption
as the name of its scenario tabs. The fetch reads at most 4 MiB and gives up after 10 seconds
(MAX_SVG_BYTES and FETCH_TIMEOUT_MS in tools/figures/loader.ts); a figure whose SVG cannot be
read keeps it. Both SVGs carry the full props, so under reduced motion, where the browser shows the
static SVG, the player mounts all the same and starts paused on each step's last beat. The caption
and the text description stay. The loader runs again after Material's instant navigation and on
Astro's astro:page-load event.
tools/figures/figures.css maps the player's --fig-* colours to Material's variables, falling
back to Starlight's (var(--md-…, var(--sl-color-…))), so the palette toggle carries through on
either site. It and player/ sit outside docs_dir, so the hook publishes them,
the CSS at assets/stylesheets/figures.css and the player files under
assets/javascripts/figures/, and links the stylesheet and the loader (as a module script) from
every page; mkdocs.yml lists only the hook
(test_a_site_build_renders_figures_and_publishes_the_stylesheet_and_the_player in
tools/figures/test_mkdocs_hook.py). The
SVGs keep interfig's own palette and follow the operating system's colour scheme, not the site
toggle.
The keyboard reaches every tab, button and the full-screen toggle, and Esc closes full screen.
tools/figures/keyboard.ts adds Left, Right, Home and End across the scenario tabs.
Figures on an Astro Starlight site
A Starlight site draws the same figures through tools/figures/astro.mjs, an Astro integration
that imports only Node builtins and the engine's own modules, so the site's lockfile does not
change (ADR-0016, section 6). Add it and the stylesheet to
astro.config.mjs:
import figures from './tools/figures/astro.mjs';
export default defineConfig({
integrations: [starlight({ customCss: ['./tools/figures/figures.css'] }), figures()],
});
- A Markdown plugin turns each
figurecode block, in.mdand.mdxpages alike, into the figure's JSONhtml, with{{base}}set to the root-absolute URL of the figures under the site'sbase(/assets/figures, or/docs/assets/figuresfor base/docs/).addFigurePluginadds it to the Markdown processor the site runs,markdown.processorfrom Astro 6.4: Sätteri, Astro 7's default (satteriFigures), orunified()from@astrojs/markdown-remark, Astro 6's default and an option on Astro 7 (remarkFigures). Astro 6.3 and earlier have no processor and takeremarkFiguresinmarkdown.remarkPlugins. A processor that runs neither fails the setup. On an.mdxpagesatteriFigureshands the markup to Astro's<Fragment set:html>as one string, because Sätteri's MDX compiler refuses a raw HTML node unless MDX optimizes static content. Starlight's ownmdx()optimizes; anmdx()the site registers itself does not by default, and the figure renders byte for byte the same under both. A block naming a figure without JSON fails the build of an.mdxpage; on an.mdpage Astro's content loader logs the error and builds the page without its content (astro/dist/content/loaders/glob.js), somake docs-figures, whosesourcescheck fails on the block, is the gate for both. - Astro keeps the rendered
.mdpages of a content collection innode_modules/.astro/and renders one again only when the page or the Astro configuration changes. The plugin's options carry a digest ofdocs/assets/figures/*.json(figuresDigest), so afterbuild.mjs builda warmastro buildrenders every page again, andastro devrestarts when a figure JSON changes, is added or is removed (watchFigures). - A head script on every page imports
assets/javascripts/figures/loader.jsunder the same base. astro devanswers the figure files andplayer/from the repository, andastro buildcopies them into the built site atassets/figures/andassets/javascripts/figures/, the paths the MkDocs hook uses. A file the built site already holds at one of those paths is kept and reported.figures({ root })names the repository root that holdsdocs/assets/figures/when it is not the directory two levels aboveastro.mjs.- The loader gives the player host Starlight's
not-contentclass, so Starlight's Markdown typography does not reach into the player.
tools/figures/astro.test.mjs covers the plugin, the hooks and serve.mjs. It compiles .md and
.mdx pages through satteri, pinned in tools/figures/package.json to the version the Starlight
preset's lock installs, and fails when the two versions differ.
The Starlight preset, docs/presets/starlight/, wires both lines and ships an example figure,
docs/presets/starlight/docs/figures/site-build.ts, on an .mdx page. The CI docs-presets job builds it in an adopter
fixture and runs the checks below on each of its four builds, the fourth with an mdx() of its
own (preset README).
Check a Starlight build with its configuration, its docs collection and the base it was built for; serve it to the smoke test under the same base:
node tools/figures/build.mjs site --config astro.config.mjs --docs src/content/docs --site dist --base /docs/
node tools/figures/smoke.mjs --site dist --base /docs/ --require-browser
The configuration's name tells the checks which generator built the site: astro.config.* is
Astro, any other file MkDocs (siteFlavor in tools/figures/checks.mjs). For Astro they read the
pages Starlight's docs loader reads (every Markdown extension it accepts, without files whose name
starts with an underscore), map each page to the directory of its slug (the front matter's slug,
else the path as Astro slugs it), enable figures when the configuration names
tools/figures/astro.mjs, and never enable Mermaid. sources takes the same --config and
--docs; without --config it finds the configuration at the repository root under any name
Astro loads (astro.config.mjs, .js, .ts, .mts, .cjs or .cts) and reads
src/content/docs/, beside an mkdocs.yml and its docs/ when the root holds both. --docs
without --config is refused when the root holds both. --base applies to an Astro site only;
the MkDocs hook writes relative URLs.
Outside the site
- GitHub wiki. The wiki sync runs
node tools/figures/build.mjs portable --wikion the copied pages, which reads the site URL fromsite_urlinmkdocs.yml(a!ENV NAMEvalue is read from that environment variable). Each fence becomes the same<picture>markup with absolute URLs to the published SVGs, followed by a link to the interactive figure on the site (see the wiki sync guide). - README. A figure sits between
<!-- figure:<slug> -->and<!-- /figure -->, with repository-relative image paths so pull-request previews show the new SVG. Refresh the block withnode tools/figures/build.mjs portable --write README.md.
GitHub keeps figure, figcaption, picture, img and details, and strips class and
data-* attributes.
Checks
| Command | Fails when |
|---|---|
npm --prefix tools/figures test |
a validation rule, the text derivation, the figure markup, the stale check, a figure check, the loader's spec reader, the committed player, the keyboard shim, the Astro integration or the file server regresses |
npm --prefix tools/figures run typecheck |
a spec or the player code does not type-check against tools/figures/types.ts and interfig |
node tools/figures/build.mjs check |
a spec breaks a rule, or a committed output differs from a fresh build; needs no npm package |
node tools/figures/bundle.mjs --check |
a file in tools/figures/player/ differs from a rebuild from the lock, is missing or is not a bundle output; player.js exceeds 250 kB minified; the install differs from the lock; needs the locked npm install |
node tools/figures/build.mjs sources |
a JSON hash no longer matches its spec, the engine or its SVGs; a JSON lacks a positive whole-number size for either SVG; its html is not the markup core.mjs renders from it; a spec or JSON is missing its pair; a fence on a page of the site configuration at the root (MkDocs or Starlight) names an unknown or unrendered figure; a root-site page holds a Mermaid fence; the README block differs; an evidence anchor is gone; needs no npm package |
node tools/figures/build.mjs site --config mkdocs.yml --docs docs --site site |
after mkdocs build (or astro build, with --config astro.config.mjs and --base): a figure did not render, an image does not resolve or embeds no usable <metadata id="figure-spec">, the page does not load the loader or no player.js sits beside it, or a page holds a Mermaid fence; needs no npm package |
python3 -B tools/figures/test_mkdocs_hook.py |
the hook's fence scanner stops matching the fence fixtures, its slot filling stops matching the markup fixtures, or a site build no longer renders a figure or publishes figures.css |
npm --prefix tools/figures run smoke -- --site <dir> [--base <path>] |
in Chromium, against a built site served under its base path: a figure did not mount the player; a figure with scenario tabs did not advance its active step under autoplay within 8 s (another selected tab or a longer progress line, since a paused player still draws its first packets), or showed no packet under autoplay or after starting any of its tabs; a packet showed under reduced motion; or a page logged an error |
make docs-figures-check runs the steps only this repository runs, because they need the npm
lock: the tests, the type check and bundle.mjs --check. The managed make docs-figures target,
which every adopting repository receives, runs check and sources; both skip, saying why, in a
repository without a spec or output. make docs-diagrams-test runs the hook's tests. All three
are part of make verify-all. Each check exits 0 on a pass, 1 on findings and 2 on a usage error or an input it
cannot read. The
Platform Neutrality workflow runs the same figure commands on Linux, macOS and Windows. The Pages
workflow runs check and bundle.mjs --check, builds the site with the committed player, runs
site and sources, then the smoke test with --require-browser, before it uploads the site. Locally the smoke test needs Chromium
(npx --prefix tools/figures playwright install chromium); without it, it exits 0 and says it
skipped.
The Documentation Preset Builds fixtures pin the unreleased checkout and use --expect-svg,
which requires Chromium and checks each figure's visible SVG, no player requests or mounts,
and no console errors in both motion modes; the Pages engine site keeps its player-mount check.
Updating interfig
The engine is vendored at a pinned commit. The interfig-sync.yml workflow runs
scripts/sync_interfig.py check weekly and fails with the update command when upstream has
moved; an HTTP error or timeout fails it with a different message, so an outage does not read
as drift. To take the update:
The command refuses a LICENSE change, an unlisted upstream file or an incompatible React peer
range before it touches the tree, then swaps in the new files, runs the upstream tests,
rebuilds every figure, because the engine hash changed, and rebuilds the committed player from the
locked install, because it bundles interfig. Commit tools/figures/third_party/interfig/,
docs/assets/figures/ and tools/figures/player/ together. The full procedure is in
VENDOR.md.
Credit
The engine is interfig by Vectorize AI, Inc., MIT-licensed, vendored unmodified at a pinned
commit under tools/figures/third_party/interfig/
(VENDOR.md).
The credit does not imply endorsement. The site footer, every exported SVG and both player scripts
carry the notice, and tools/figures/player/THIRD-PARTY-LICENSES.txt carries the full license texts
of interfig, React, react-dom and scheduler. REUSE.toml labels player/ EUPL-1.2 AND MIT. In an
adopting repository that declares its licensing in REUSE.toml, praetorctl audit warns until
an annotation labels the vendored interfig files MIT.
Credits names the React, react-dom and scheduler versions the player bundles;
CheckCredits in internal/supplychain/credits.go fails make test when that row stops
matching tools/figures/package-lock.json.