diff --git a/.claude/skills/simic-design/readme.md b/.claude/skills/simic-design/readme.md
index 25cc025..7d63eb5 100644
--- a/.claude/skills/simic-design/readme.md
+++ b/.claude/skills/simic-design/readme.md
@@ -31,13 +31,15 @@ Source repo: https://github.com/foundryside-dev/simic — explore it for the can
## VISUAL FOUNDATIONS
-**Color**: OKLCH throughout. Blue-green world: hue 225 ("ink") carries all structure — backgrounds, text, borders; hue 175 (teal) carries emphasis — links, accents, active nav, callout spines; hue 85 (amber) is the sole warning color. Both themes come from one palette via `light-dark()`; `color-scheme: light dark` follows the OS, `[data-theme]` overrides. Max 2 background colors per page (`--color-bg`, `--color-bg-subtle`), plus tinted note backgrounds. Never pure black/white.
+**Color**: OKLCH throughout. Blue-green world: hue 225 ("ink") carries all structure — backgrounds, text, borders; hue 175 (teal) carries emphasis — links, accents, active nav, callout spines; hue 85 (amber) is the sole warning color. A callout holds its family's hue on both sides of `light-dark()` — never let the dark side drift off-hue. Both themes come from one palette via `light-dark()`. Max 2 background colors per page (`--color-bg`, `--color-bg-subtle`), plus the two tinted note grounds (`--color-bg-note`, `--color-bg-note-status`). Never pure black/white.
+
+**Theme model — two-state, not three.** The marketing site follows the OS via `color-scheme: light dark` and **has no toggle**: it ships zero JavaScript, so nothing ever sets `data-theme`. The `:root[data-theme]` rules in `site/style.css` are an unreached hook, kept for a future toggle; the diagrams cannot follow them at all (they select via ``, resolved by the layout engine). Only the **wiki** is three-state — MkDocs Material supplies its own toggle. Do not describe the marketing site as having a theme switcher.
**Type**: system stacks only, on purpose (zero third-party requests — a privacy stance, not an omission). Body `system-ui…`; code/labels `ui-monospace…`. Mono is the brand's display voice: the wordmark, taglines, note labels, invariant chips, table `name` cells are all mono. Fluid heading sizes via `clamp()`. Base 1rem/1.65. h1 tracking -0.02em; mono labels tracked out (+0.04–0.08em) and often uppercase at ~0.78rem.
**Spacing/layout**: 8px-base scale (`--space-1..7` = 4/8/16/24/32/48/72px). Two widths: prose `--measure` 46rem, wide blocks (tables, diagrams, grids) `--page` 62rem. Prose is narrow by default; wide elements opt out.
-**Backgrounds**: flat solid colors only. No gradients, no textures, no imagery, no photography. The only "images" are pre-rendered Mermaid diagram SVGs, shipped in light+dark pairs and swapped with the color tokens.
+**Backgrounds**: flat solid colors only. No gradients, no textures, no imagery, no photography. The only "images" are pre-rendered Mermaid diagram SVGs, shipped in light+dark pairs and selected by `` + `media="screen and (prefers-color-scheme: dark)"` — one variant fetched, and print falls through to the light one. The single exception is `site/assets/social-card.png`, a 1200×630 Open Graph card rendered from `social-card.src.html`; it is a fixed dark artefact and does not track the viewer's theme.
**Borders & radius**: 1px hairline `--color-border` everywhere (h2 top rules, table rows, cards, code); `--color-border-firm` for firmer edges. Radius `4px` (3px on inline code, 2px on focus rings). Callouts and quotes use a 3px left spine (teal = note/canon, amber = status, neutral = plain quote).
@@ -47,12 +49,27 @@ Source repo: https://github.com/foundryside-dev/simic — explore it for the can
**Motion**: essentially none. One 120ms ease-out transition (skip link). `prefers-reduced-motion` kills everything. Do not add animation.
-**Dark mode**: not an afterthought — every token is a `light-dark()` pair; diagrams swap variants; print forces light.
+**Dark mode**: not an afterthought — every token is a `light-dark()` pair; diagrams ship as light/dark pairs selected by ``; print falls back to light.
+
+
+
+
## ICONOGRAPHY
There is **no icon system**. The site ships zero icon fonts, zero icon SVG sets. The complete iconography:
-- **The mark**: a teal diamond (`assets/mark.svg`, `#0f9b8e`), used as favicon and rendered as the `◈` character before the wordmark (`.wordmark::before`, colored accent). This is the closest thing to a logo — there is no wordmark image; the wordmark is live text in mono.
+- **The mark**: a teal diamond, used as favicon and rendered as the `◈` character before the wordmark (`.wordmark::before`, colored accent). This is the closest thing to a logo — there is no wordmark image; the wordmark is live text in mono. One file, two deployed copies kept byte-identical: `site/assets/mark.svg` (marketing) and `tools/wiki/assets/mark.svg` (wiki logo + favicon). Its `#0f9b8e` is a fixed hex, not a token — a favicon cannot use `light-dark()` — and sits between `--teal-600` and `--teal-400`; do not introduce a fourth teal to match it.
- **Unicode as UI glyphs**: `◈` (wordmark), `→` (forward links "Read the architecture →"), `›` (breadcrumbs), `·` (footer separators).
- **Diagrams**: pre-rendered Mermaid SVGs in `assets/diagrams/` (light/dark pairs) — the only illustrations in the brand.
- The wiki (Material theme) uses Material Design icons only for chrome (theme toggle, GitHub link, permalinks); content uses none.
@@ -61,13 +78,49 @@ Do not introduce icon sets, emoji, or drawn illustrations. When an icon urge str
## Index
-- `styles.css` — global entry (imports everything below)
-- `tokens/` — `colors.css`, `typography.css`, `spacing.css`, `base.css`
-- `assets/` — `mark.svg`, `diagrams/` (6 Mermaid SVGs, light/dark pairs)
-- `guidelines/` — foundation specimen cards
-- `components/site/` — Masthead, SiteFooter, PageHead, Note, CanonQuote, Spine, CardGrid, DataTable, Split, Diagram (the full inventory `site/style.css` defines — nothing invented)
-- `ui_kits/website/` — recreation of the overview page
-- `ui_kits/wiki/` — recreation of a design-docs wiki page (MkDocs Material)
+> **⚠ INCOMPLETE EXPORT — read before using this skill.** Only the eight
+> metadata files were committed (`6e74997`). The 33 files listed below under
+> `tokens/`, `components/`, `guidelines/` and `ui_kits/` are **absent from this
+> directory** and are not gitignored — they were never committed. `styles.css`
+> was therefore four dangling `@import`s loading nothing; it has since been
+> flattened locally (see its provenance header).
+>
+> Until the export is completed, **treat `site/style.css` in the repo as the
+> token source of truth** (it always was — see the note above). Do not
+> hand-author replacements for the files below: they exist in the upstream
+> Claude Design project `SimicDesignSystem_5a908e` and re-exporting or pulling
+> them (`DesignSync`) is the fix. Do not edit this Index to match the disk
+> either — the gap is the record of what is missing.
+>
+> **⚠ THE GENERATED FILES ARE NOW STALE — a re-sync will REGRESS the site.**
+> `_ds_manifest.json` and `_adherence.oxlintrc.json` are generated output and
+> were deliberately **not** hand-edited, so they still describe the palette as
+> it stood on 2026-08-08, before the static-site review (M2, m6). Three
+> divergences, all verified 2026-08-09:
+>
+> | Token | `site/style.css` (correct) | generated files (stale) |
+> |---|---|---|
+> | `--color-bg-note` dark | hue **175** | hue **195** |
+> | `--color-bg-note-status` dark | hue **85** | hue **75** |
+> | `--color-accent-quiet` | **deleted** (unused; 4.22:1 on `--color-bg`, fails AA) | still declared, and allowlisted at `_adherence.oxlintrc.json:124,192` |
+>
+> Nothing shipped is wrong — the consumed stylesheets (`site/style.css` and the
+> flattened `styles.css`) both carry the corrected values. The hazard is
+> directional: **a `DesignSync` re-sync overwrites these files from upstream and
+> would silently reintroduce all three.** Whoever completes the export must
+> re-apply the two hue corrections and re-delete `--color-accent-quiet`
+> (including both `_adherence.oxlintrc.json` entries) as part of that sync, then
+> diff the manifest's tokens against `site/style.css` before considering it
+> done. This is a recorded deferral, not an oversight.
+
+- `styles.css` — global entry (imports everything below) — **present but inert**
+- `tokens/` — `colors.css`, `typography.css`, `spacing.css`, `base.css` — **missing**
+- `assets/` — `mark.svg`, `diagrams/` (6 Mermaid SVGs, light/dark pairs) — **missing** (both live in the repo: `site/assets/mark.svg`, `site/assets/diagrams/`)
+- `guidelines/` — foundation specimen cards (14 files) — **missing**
+- `components/{content,data,navigation}/` — Masthead, SiteFooter, PageHead, Note, CanonQuote, Spine, CardGrid, DataTable, Split, Diagram (the full inventory `site/style.css` defines — nothing invented) — **missing**
+- `ui_kits/website/` — recreation of the overview page — **missing**
+- `ui_kits/wiki/` — recreation of a design-docs wiki page (MkDocs Material) — **missing**
+- `_ds_manifest.json` — component/token index; the only complete record of the palette in this directory
- `SKILL.md` — agent skill entry point
**Intentional additions**: none. The component list is exactly the class inventory of `site/style.css`. The wiki surface is stock MkDocs Material (teal/teal, `font: false`) — recreated as a UI kit screen, not as components.
diff --git a/.claude/skills/simic-design/styles.css b/.claude/skills/simic-design/styles.css
index 45decae..a5a2d3d 100644
--- a/.claude/skills/simic-design/styles.css
+++ b/.claude/skills/simic-design/styles.css
@@ -1,4 +1,110 @@
-@import "tokens/colors.css";
-@import "tokens/typography.css";
-@import "tokens/spacing.css";
-@import "tokens/base.css";
+/* ==========================================================================
+ Simic Design System — global entry point
+
+ PROVENANCE. This file is a LOCAL FLATTENING, not the original export. The
+ upstream Claude Design project (namespace SimicDesignSystem_5a908e) split
+ these tokens across tokens/colors.css, tokens/typography.css,
+ tokens/spacing.css and tokens/base.css, and this file was four @import
+ statements pointing at them. Those four files — and 29 others under
+ components/, guidelines/ and ui_kits/ — were never committed (see the
+ warning in readme.md), so the imports resolved to nothing and this
+ stylesheet loaded no rules at all.
+
+ Rather than leave a dead import chain, the token layer is inlined below,
+ lifted verbatim from `site/style.css`, which is and remains the ground
+ truth for every value here. The four tokens/*.css paths are still named in
+ _ds_manifest.json and readme.md's Index on purpose: that is the record of
+ what is missing, and re-exporting the project is the real fix.
+
+ CONSEQUENCE: this file carries TOKENS ONLY. The component rules (Masthead,
+ Note, CanonQuote, Spine, CardGrid, DataTable, Split, Diagram) live in
+ `site/style.css` and were not duplicated here — one copy of a component
+ layer is correct, and a second would drift. To style a real surface, read
+ `site/style.css`.
+
+ Keep in sync with site/style.css. If the two ever disagree, site/style.css
+ wins.
+ ========================================================================== */
+
+:root {
+ color-scheme: light dark;
+
+ /* --- Primitives -------------------------------------------------------
+ Simic reads blue-green. Hue 225 carries structure, hue 175 carries
+ emphasis, hue 85 is the sole warning colour. OKLCH so the light and dark
+ ramps stay perceptually matched. */
+ --ink-050: oklch(0.985 0.004 225);
+ --ink-100: oklch(0.955 0.008 225);
+ --ink-200: oklch(0.905 0.012 225);
+ --ink-300: oklch(0.825 0.016 225);
+ /* ink-500 is the light-mode muted text; kept dark enough that muted text
+ clears WCAG AA (4.5:1) against --color-bg-subtle, not just --color-bg. */
+ --ink-500: oklch(0.495 0.024 225);
+ --ink-700: oklch(0.375 0.030 225);
+ --ink-850: oklch(0.235 0.026 225);
+ --ink-900: oklch(0.185 0.024 225);
+ --ink-950: oklch(0.145 0.020 225);
+
+ --teal-400: oklch(0.780 0.110 175);
+ /* Ramp midpoint. No current consumer — kept so the ramp stays derivable, and
+ because the mark's fixed #0f9b8e sits beside it. Do not use it for text:
+ it measures 4.22:1 on --color-bg, which fails AA. */
+ --teal-600: oklch(0.560 0.105 175);
+ --teal-700: oklch(0.470 0.098 175);
+
+ --amber-400: oklch(0.800 0.105 85);
+ --amber-700: oklch(0.520 0.105 85);
+
+ /* --- Semantic tokens (one declaration each) --------------------------- */
+ --color-bg: light-dark(var(--ink-050), var(--ink-950));
+ --color-bg-subtle: light-dark(var(--ink-100), var(--ink-900));
+ --color-bg-code: light-dark(var(--ink-100), var(--ink-850));
+ /* Tinted callout grounds. Each holds its family's hue on BOTH sides — teal
+ 175 for the plain note, amber 85 for the status note — so a callout never
+ drifts off the three-hue palette when the theme flips. */
+ --color-bg-note: light-dark(oklch(0.965 0.020 175), oklch(0.235 0.030 175));
+ --color-bg-note-status: light-dark(oklch(0.975 0.022 85), oklch(0.235 0.028 85));
+
+ --color-text: light-dark(var(--ink-900), var(--ink-100));
+ --color-text-muted: light-dark(var(--ink-500), var(--ink-300));
+ --color-heading: light-dark(var(--ink-950), var(--ink-050));
+
+ --color-border: light-dark(var(--ink-200), oklch(0.300 0.020 225));
+ --color-border-firm: light-dark(var(--ink-300), oklch(0.380 0.024 225));
+
+ --color-accent: light-dark(var(--teal-700), var(--teal-400));
+ --color-warn: light-dark(var(--amber-700), var(--amber-400));
+
+ --color-link: var(--color-accent);
+ --color-link-hover: color-mix(in oklch, var(--color-link) 72%, var(--color-text) 28%);
+
+ /* --- Typography ------------------------------------------------------- */
+ --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
+ Arial, sans-serif;
+ --font-code: ui-monospace, "SFMono-Regular", Menlo, Consolas,
+ "Liberation Mono", monospace;
+
+ --text-h1: clamp(2rem, 1.45rem + 2.6vw, 3.1rem);
+ --text-h2: clamp(1.35rem, 1.18rem + 0.8vw, 1.75rem);
+ --text-h3: clamp(1.08rem, 1.02rem + 0.3vw, 1.22rem);
+
+ /* --- Spacing (8px base) ----------------------------------------------- */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 1rem;
+ --space-4: 1.5rem;
+ --space-5: 2rem;
+ --space-6: 3rem;
+ --space-7: 4.5rem;
+
+ /* --- Layout ------------------------------------------------------------ */
+ --measure: 46rem; /* prose column */
+ --page: 62rem; /* wide elements: tables, diagrams */
+ --radius: 4px;
+}
+
+/* Hook for an explicit reader override. NOTHING SETS data-theme on the
+ marketing site: it ships zero JavaScript and is system-preference-only.
+ Only the MkDocs wiki is three-state. See readme.md § VISUAL FOUNDATIONS. */
+:root[data-theme="light"] { color-scheme: light; }
+:root[data-theme="dark"] { color-scheme: dark; }
diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index 1e11433..6839613 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -25,6 +25,9 @@ on:
- 'docs/design/**'
- 'docs/adr/**'
- 'tools/wiki/**'
+ # The pre-upload gate: a change to the checker must re-run against the
+ # current site, not wait for the next content edit to notice it.
+ - 'tools/ci/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
@@ -76,12 +79,24 @@ jobs:
java-version: '21'
- name: Cache diagram toolchain
- # The pinned jars build.sh downloads on first run (~15 MB). Keyed on
- # build.sh because that is where the versions are pinned.
+ # The pinned jars build.sh downloads on first run (~15 MB).
+ #
+ # Keyed on all three inputs that can change what lands in .cache, not
+ # just build.sh: the jar versions are pinned in build.sh, but the cache
+ # also holds mkdocs-material's privacy-plugin vendored assets, whose
+ # content follows the plugin config in mkdocs.yml and the pinned
+ # package versions in requirements.txt. Keying on build.sh alone let an
+ # mkdocs.yml or dependency change reuse assets fetched under the old
+ # config — a stale-cache bug that survives until someone edits build.sh.
uses: actions/cache@v4
with:
path: tools/wiki/.cache
- key: wiki-diagram-jars-${{ hashFiles('tools/wiki/build.sh') }}
+ key: >-
+ wiki-diagram-jars-${{ hashFiles(
+ 'tools/wiki/build.sh',
+ 'tools/wiki/mkdocs.yml',
+ 'tools/wiki/requirements.txt'
+ ) }}
- name: Build the design-docs wiki
# build.sh compiles the Structurizr model to SVGs, stages docs/design/
@@ -102,6 +117,12 @@ jobs:
rm -rf _pages
mkdir -p _pages
cp -r site/. _pages/
+
+ # Build-time sources that live under site/ for convenience but must
+ # not be served. og-card.src.html is the 1200x630 card's source; the
+ # PNG it produces ships, the source does not.
+ rm -f _pages/assets/og-card.src.html
+
test ! -e _pages/design || { echo "site/design would collide with the wiki mount"; exit 1; }
mkdir -p _pages/design
cp -r tools/wiki/build/site/. _pages/design/
@@ -110,6 +131,47 @@ jobs:
echo " root pages: $(find _pages -maxdepth 1 -name '*.html' | wc -l)"
echo " wiki pages: $(find _pages/design -name '*.html' | wc -l)"
+ - name: Validate the hand-written pages
+ # The wiki half is gated by `mkdocs build --strict` above; this is the
+ # equivalent gate for site/, which is hand-written and had none. Scoped
+ # to the ROOT pages on purpose — the generated Material output is the
+ # upstream theme's business, not ours, and linting it here would fail
+ # the deploy on markup we do not author.
+ #
+ # Pinned to the major: unpinned `npx html-validate` resolves latest at
+ # run time, so a new major's rules could fail the deploy on markup that
+ # did not change. @11 is what npx installs today (11.6.2) — do NOT
+ # "stabilise" onto @9, which would be a two-major downgrade and a
+ # behaviour change in its own right.
+ #
+ # The quoted glob is deliberate: it reaches html-validate unexpanded, so
+ # a zero-match glob is html-validate's error ("No files matching
+ # patterns", exit 1) rather than a silently-passing empty shell
+ # expansion. Verified — this gate cannot pass by matching nothing.
+ run: npx --yes html-validate@11 '_pages/*.html'
+
+ - name: Check internal links and fragments
+ # Offline: every href/src in the root pages must resolve to a file in
+ # the artifact, and every #fragment to a real id on the target page.
+ # External URLs are listed, never fetched — a deploy must not fail
+ # because GitHub rate-limited us.
+ #
+ # This exists because the marketing pages deep-link into the generated
+ # wiki (/design/02-constitution/#53-the-canonical-sentence). Those slugs
+ # derive from chapter headings, so rewording a heading silently breaks a
+ # citation on the other half of the site — precisely the class of break
+ # `--strict` catches inside the wiki and nothing caught across the seam.
+ #
+ # WHY NOT lychee: it is a Rust binary, not an npm package, so `npx
+ # lychee` fails outright ("could not determine executable to run"). The
+ # supported route is the lycheeverse/lychee-action, which could not be
+ # exercised locally before committing; this stdlib equivalent was tested
+ # instead, including a negative test (an injected bad slug and a bad
+ # path both produced exit 1 naming the offender). Swapping in the pinned
+ # action later is fine — it needs --include-fragments to cover the
+ # anchor case above, which is the whole point of this gate.
+ run: python3 tools/ci/linkcheck.py _pages
+
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
diff --git a/.gitignore b/.gitignore
index 5f9b660..504b3e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,5 @@ tools/wiki/build/
# Pinned structurizr-cli/plantuml jars fetched by tools/wiki/build.sh
tools/wiki/.cache/
tools/diagrams/build/
+
+prompts/
diff --git a/site/404.html b/site/404.html
new file mode 100644
index 0000000..07e68d4
--- /dev/null
+++ b/site/404.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+Page not found — Simic
+
+
+
+
+
+
+
+Skip to content
+
+
+
+ Nothing is published at that address. The site is small enough to list
+ exhaustively, so one of the links below is almost certainly what you
+ wanted.
+
+ If you followed a link from the design-docs wiki or from
+ the repository and
+ landed here, that is a broken cross-reference worth reporting as an issue.
+
This page is a digest. The canonical text is
- docs/design/02-constitution.md (naming constitution and the
- 45 invariants) and docs/design/04-architecture.md (system
- context, planes, control hierarchy) in
- the repository.
+ 02-constitution.md
+ (naming constitution and the 45 invariants) and
+ 04-architecture.md
+ (system context, planes, control hierarchy), published in full at
+ /design/ and versioned in
+ the repository.
-
Actors have verbs. Infrastructure has prepositions.
+
Actors have verbs. Infrastructure has prepositions.#
The locked convention distinguishes agents from
@@ -68,7 +84,7 @@
Actors have verbs. Infrastructure has prepositions.
happen.
-
+
The three infrastructure domains
@@ -102,7 +118,7 @@
Actors have verbs. Infrastructure has prepositions.
-
+
The eleven agent domains
@@ -139,12 +155,13 @@
Actors have verbs. Infrastructure has prepositions.
Because each domain owns a verb, the narrative grammar doubles as a cheap
@@ -302,7 +323,7 @@
The sentence test
Healthy
-
Nissa published the same TelemetryEnvelope
+
Nissa published the same TelemetryEnvelope
to Narset and Momir.
Narset issued a GrowthIntent for Region A
under Tamiyo's envelope.
@@ -317,7 +338,7 @@
Healthy
Triggers review
-
Narset forwarded a captioned telemetry
+
Narset forwarded a captioned telemetry
summary to Momir.
Narset requested an attention-like topology.
Nissa recommended a wide bottleneck.
@@ -333,12 +354,13 @@
Triggers review
The full catalogue of smells — observation to likely cause — is in
- docs/design/03-principles.md, Appendix A. The sentence test
+ 03-principles.md
+ § Appendix A. The sentence test
is not a proof; it is an intentionally cheap first filter, backed by Leyline
contracts and authority tests that do the real enforcement.
diff --git a/site/assets/diagrams/assurance-loop-dark.svg b/site/assets/diagrams/assurance-loop-dark.svg
index 7647f06..0c15a18 100644
--- a/site/assets/diagrams/assurance-loop-dark.svg
+++ b/site/assets/diagrams/assurance-loop-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/site/assets/diagrams/assurance-loop-light.svg b/site/assets/diagrams/assurance-loop-light.svg
index 3b48bd4..96416dc 100644
--- a/site/assets/diagrams/assurance-loop-light.svg
+++ b/site/assets/diagrams/assurance-loop-light.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/site/assets/diagrams/core-loop-dark.svg b/site/assets/diagrams/core-loop-dark.svg
index 410ac09..40e9447 100644
--- a/site/assets/diagrams/core-loop-dark.svg
+++ b/site/assets/diagrams/core-loop-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/site/assets/diagrams/core-loop-light.svg b/site/assets/diagrams/core-loop-light.svg
index bba05bb..2d226cb 100644
--- a/site/assets/diagrams/core-loop-light.svg
+++ b/site/assets/diagrams/core-loop-light.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/site/assets/diagrams/observation-loop-dark.svg b/site/assets/diagrams/observation-loop-dark.svg
index b29dc65..805b72f 100644
--- a/site/assets/diagrams/observation-loop-dark.svg
+++ b/site/assets/diagrams/observation-loop-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/site/assets/diagrams/observation-loop-light.svg b/site/assets/diagrams/observation-loop-light.svg
index c7df72d..e0c2706 100644
--- a/site/assets/diagrams/observation-loop-light.svg
+++ b/site/assets/diagrams/observation-loop-light.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/tools/wiki/assets/mark.svg b/site/assets/mark.svg
similarity index 100%
rename from tools/wiki/assets/mark.svg
rename to site/assets/mark.svg
diff --git a/site/assets/og-card.png b/site/assets/og-card.png
new file mode 100644
index 0000000..d3b483e
Binary files /dev/null and b/site/assets/og-card.png differ
diff --git a/site/assets/og-card.src.html b/site/assets/og-card.src.html
new file mode 100644
index 0000000..d0ad6b0
--- /dev/null
+++ b/site/assets/og-card.src.html
@@ -0,0 +1,97 @@
+
+
+
+
+Simic social card source
+
+
+
+
+
+
+
simic
+
Counterfactual Generative Morphogenesis
+
+ New neural structure generated from the live state of a host network, then
+ causally screened against doing nothing.
+
- Project status
+ Project status — as of 9 August 2026
- Pre-implementation bootstrap. The design is complete and
- locked — HLD v4.1, Namespec 1.0 — and a Python scaffold exists, but there
- is no functional code yet. First engineering work is
- Phase A: Namespec, Leyline contracts, and dependency boundaries.
- Nothing on this site describes a running system or a measured result.
+ Pre-implementation bootstrap. The design is locked — HLD
+ v4.1, Namespec 1.0 — and under active design review, with
+ findings still open against it, including unresolved contract shapes. A
+ Python scaffold exists, but there is no functional code
+ yet. First
+ engineering work is Phase A: Namespec, Leyline contracts, and
+ dependency boundaries. Nothing on this site describes a running system or
+ a measured result.
The ordinary host-training loop and the growth loop share one execution
reality, and the candidate pool is allowed to lose: the no-op branch is a
measured outcome, not an error path. Source:
- docs/design/01-claim.md §1, with the no-op outcome and the
- maintenance warrant from docs/design/04-architecture.md §7.4
- and INV-15, INV-16, INV-27.
+ 01-claim.md
+ § Executive summary, with the no-op outcome and the maintenance warrant
+ from 04-architecture.md
+ § Candidate assurance and adjudication and INV-15, INV-16, INV-27.
-
The canonical authority is the HLD chapter set in the repository, not this
- site. Paths below are references into
- the repository.
+ site. Each chapter below is published on this domain at
+ /design/, and the same paths locate it in
+ the repository.
-
+
Repository map
Path
Contents
-
docs/design/00-INDEX.md
Entry point to the HLD chapter set, with reading paths
-
docs/design/01-claim.md
Executive summary, problem statement, goals, non-goals, the first defensible claim
-
docs/design/02-constitution.md
Naming constitution and the 45 blocking invariants
-
docs/design/03-principles.md
Design principles and the architectural smell catalogue
Target code layout: one package per domain (scaffold only, today)
Implementation follows Phases A–K, defined in
- docs/design/programme/phases.md. Python is the working
- language. The project is licensed Apache-2.0.
+ programme/phases.md.
+ Python is the working language. The project is licensed Apache-2.0.
@@ -280,7 +304,7 @@
Where the design lives
Simic — a research project by tachyon-beep. Licensed Apache-2.0.
- Simic is the third incarnation of one research programme. Early versions of
- the simic project (known as esper) built the morphogenetic chassis this
- design retains — reversible slots, staged maturation, lifecycle states — and
- that working record is the proximate reason for every major choice made
- here. Whatever succeeds is Simic; the earlier work is history flowing
- backward into that name, not a separate identity.
+ Simic is the third incarnation of one research programme. Its two
+ predecessors — ESPER and ESPER LITE — built the morphogenetic chassis this
+ design retains: reversible slots, staged maturation, lifecycle states. That
+ working record is the proximate reason for every major choice made here, and
+ the two strata below are, roughly, its two eras. Whatever succeeds is Simic;
+ the earlier work is history flowing backward into that name, not a separate
+ identity.
In one generation, machine-generated telemetry code contained
@@ -113,10 +130,10 @@
Stratum one — telemetry-access corruption
rule that absent signal stays absent and is never a fabricated zero
(INV-38), fail-closed typed compatibility (INV-24), direct publication from
Nissa with no editorial intermediary (INV-07), observation binding (INV-08),
- the defaulting-access ban (ADR-0006), and a poison-pill acceptance harness.
+ the defaulting-access ban (ADR-0006), and a poison-pill acceptance harness.
-
Stratum two — learning-loop and instrument failures
+
Stratum two — learning-loop and instrument failures#
After telemetry access was hardened, the reinforcement-learning loop and its
@@ -168,7 +185,7 @@
Stratum two — learning-loop and instrument failures
self-deception. The armour is built in both directions accordingly.
-
Read the claims against this
@@ -205,11 +222,12 @@
Attribution honesty
contributions are the ones that could not: generated (not
selected) structure, separated authorities, and provider
blindness. The success criteria in
- docs/design/01-claim.md §28 should be read against that split.
+ 01-claim.md
+ § Success criteria should be read against that split.
schemas with fail-closed compatibility (INV-24), validity masks and the
absent-is-never-zero rule (INV-38), Nissa's direct publication with no
editorial intermediary (INV-07), observation binding (INV-08), the
- defaulting-access ban (ADR-0006), and the poison-pill negative-space
+ defaulting-access ban (ADR-0006), and the poison-pill negative-space
harness.
Against learning-loop self-deception: no shaped-reward
authority anywhere on the constitutional path, the evidence/judgement split
@@ -260,7 +279,7 @@
diff --git a/site/robots.txt b/site/robots.txt
new file mode 100644
index 0000000..9260a09
--- /dev/null
+++ b/site/robots.txt
@@ -0,0 +1,10 @@
+# simic.foundryside.dev — a public research-project site. Nothing here is
+# private, nothing is tracked, and there is no crawl budget worth protecting.
+User-agent: *
+Allow: /
+
+# Two sitemaps, because the origin serves two artifacts of different classes:
+# the hand-written marketing pages at the root, and the mkdocs-generated design
+# wiki at /design/, which emits its own.
+Sitemap: https://simic.foundryside.dev/sitemap.xml
+Sitemap: https://simic.foundryside.dev/design/sitemap.xml
diff --git a/site/sitemap.xml b/site/sitemap.xml
new file mode 100644
index 0000000..1e5e55d
--- /dev/null
+++ b/site/sitemap.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ https://simic.foundryside.dev/
+ 2026-08-09
+ weekly
+ 1.0
+
+
+ https://simic.foundryside.dev/architecture.html
+ 2026-08-09
+ weekly
+ 0.8
+
+
+ https://simic.foundryside.dev/lineage.html
+ 2026-08-09
+ weekly
+ 0.8
+
+
diff --git a/site/style.css b/site/style.css
index 21b6858..f4caf4b 100644
--- a/site/style.css
+++ b/site/style.css
@@ -24,6 +24,9 @@
--ink-950: oklch(0.145 0.020 225);
--teal-400: oklch(0.780 0.110 175);
+ /* Ramp midpoint. No current consumer — kept so the ramp stays derivable, and
+ because the mark's fixed #0f9b8e sits beside it. Do not use it for text:
+ it measures 4.22:1 on --color-bg, which fails AA. */
--teal-600: oklch(0.560 0.105 175);
--teal-700: oklch(0.470 0.098 175);
@@ -34,7 +37,11 @@
--color-bg: light-dark(var(--ink-050), var(--ink-950));
--color-bg-subtle: light-dark(var(--ink-100), var(--ink-900));
--color-bg-code: light-dark(var(--ink-100), var(--ink-850));
- --color-bg-note: light-dark(oklch(0.965 0.020 175), oklch(0.235 0.030 195));
+ /* Tinted callout grounds. Each holds its family's hue on BOTH sides — teal
+ 175 for the plain note, amber 85 for the status note — so a callout never
+ drifts off the three-hue palette when the theme flips. */
+ --color-bg-note: light-dark(oklch(0.965 0.020 175), oklch(0.235 0.030 175));
+ --color-bg-note-status: light-dark(oklch(0.975 0.022 85), oklch(0.235 0.028 85));
--color-text: light-dark(var(--ink-900), var(--ink-100));
--color-text-muted: light-dark(var(--ink-500), var(--ink-300));
@@ -44,7 +51,6 @@
--color-border-firm: light-dark(var(--ink-300), oklch(0.380 0.024 225));
--color-accent: light-dark(var(--teal-700), var(--teal-400));
- --color-accent-quiet:light-dark(var(--teal-600), oklch(0.660 0.080 175));
--color-warn: light-dark(var(--amber-700), var(--amber-400));
--color-link: var(--color-accent);
@@ -75,8 +81,12 @@
--radius: 4px;
}
-/* Explicit reader override, should one ever be wired up. light-dark() does
- the rest, so no palette is duplicated. */
+/* Hook for an explicit reader override, should one ever be wired up. NOTHING
+ SETS data-theme TODAY: the site ships zero JavaScript, so it is
+ system-preference-only in practice. Kept because it costs two lines and
+ light-dark() does the rest — but see the diagram block below, which cannot
+ follow a [data-theme] override and would need reworking alongside a toggle.
+ Do not describe this site as three-state theming; it is not. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
@@ -210,6 +220,44 @@ h2 {
h3 { font-size: var(--text-h3); margin-top: var(--space-5); }
h4 { font-size: 1rem; margin-top: var(--space-4); }
+/* --- Heading permalinks ---------------------------------------------------
+ Every h2/h3 carries an id, and several pages link across to those fragments,
+ but a reader had no way to DISCOVER one. The marker is a real anchor (not a
+ ::before on the heading) so it is keyboard reachable and copyable; the
+ heading text itself stays plain text, so screen readers announce the heading
+ rather than a link. Revealed on hover of the heading, and on focus of the
+ marker itself — never hidden from keyboard users.
+ -------------------------------------------------------------------------- */
+.hlink {
+ margin-left: 0.4em;
+ font-family: var(--font-code);
+ font-weight: 400;
+ color: var(--color-accent);
+ text-decoration: none;
+ opacity: 0;
+ transition: opacity 120ms ease-out;
+}
+h2:hover > .hlink,
+h3:hover > .hlink,
+.hlink:focus-visible { opacity: 1; }
+
+/* Coarse pointers get no hover, so never hide it from them. */
+@media (hover: none) {
+ .hlink { opacity: 0.55; }
+}
+
+/* --- Citations into the design wiki --------------------------------------
+ Authority citations are links to /design/, not inert text. The inside
+ keeps the mono "file path as citation" voice but takes the link colour. */
+.cite { text-decoration-thickness: 1px; }
+.cite code {
+ color: inherit;
+ background: none;
+ border: 0;
+ padding: 0;
+}
+.cite:hover code { color: inherit; }
+
p, ul, ol, dl { margin: 0 0 var(--space-3); }
ul, ol { padding-left: 1.35rem; }
li { margin-bottom: var(--space-2); }
@@ -265,7 +313,7 @@ strong { color: var(--color-heading); font-weight: 650; }
margin-bottom: var(--space-1);
}
.note--status {
- background: light-dark(oklch(0.975 0.022 85), oklch(0.235 0.028 75));
+ background: var(--color-bg-note-status);
border-left-color: var(--color-warn);
}
.note--status .note__label { color: var(--color-warn); }
@@ -294,13 +342,31 @@ blockquote cite {
/* --- Diagrams -------------------------------------------------------------
Mermaid sources live in tools/diagrams/*.mmd (shared with the PDF pipeline)
and are PRE-RENDERED into assets/diagrams/ by `tools/diagrams/render.sh site`
- — the mermaid runtime is never shipped. Each diagram has a
- light and a dark SVG; the pair is swapped below on the same rules the colour
- tokens use, so `prefers-color-scheme` AND the [data-theme] override both work.
+ — the mermaid runtime is never shipped. Each diagram has a light and a dark
+ SVG, selected by with the light SVG as the fallback.
+
+ Why and not two s swapped in CSS: `display:none` does not stop
+ a fetch, so the CSS-swap version downloaded BOTH variants on every load —
+ 96 KB of SVG on the landing page to show 48 KB. fetches one.
+
+ The cost of that choice: source selection happens in the layout engine, so a
+ [data-theme] class CANNOT override it. That is acceptable only because this
+ site has no theme toggle — it is system-preference-only by design (zero JS,
+ see the header of this file). If a toggle is ever added, the diagrams must
+ move back to a scripted swap; they will not follow [data-theme] on their own.
+
+ The `screen and` prefix on the source query is load-bearing: print does not
+ match it, so a reader in dark mode still prints the light diagram on white
+ paper, which is what the print block below used to force by hand.
The carries alt="" and the accessible name lives on .diagram__frame
- (role="img" + aria-label) so exactly one name is exposed regardless of which
- variant is displayed.
+ (role="img" + aria-label) so exactly one name is exposed.
+
+ .diagram__frame is a scroll container, so it takes tabindex="0" in the markup
+ — Chrome 127+ and Firefox make scrollers keyboard-reachable on their own, but
+ WebKit does not, and without it a Safari keyboard user cannot reach the part
+ of the diagram that is scrolled off.
--dmin is set per diagram inline: the width below which the SVG stops
shrinking and the frame scrolls instead, because a flowchart scaled to a
@@ -321,19 +387,6 @@ blockquote cite {
margin-inline: auto;
}
-/* Light is the default; dark swaps in exactly where the tokens do. */
-.diagram__img--dark { display: none; }
-
-@media (prefers-color-scheme: dark) {
- :root:not([data-theme="light"]) .diagram__img--light { display: none; }
- :root:not([data-theme="light"]) .diagram__img--dark { display: block; }
-}
-
-:root[data-theme="dark"] .diagram__img--light { display: none; }
-:root[data-theme="dark"] .diagram__img--dark { display: block; }
-:root[data-theme="light"] .diagram__img--light { display: block; }
-:root[data-theme="light"] .diagram__img--dark { display: none; }
-
/* --- Code ---------------------------------------------------------------- */
code, kbd, samp {
font-family: var(--font-code);
@@ -507,6 +560,12 @@ a.card:hover {
padding: 0;
list-style: none;
}
+/* inline-block + padding lifts the hit area to the 24px WCAG 2.2 SC 2.5.8
+ minimum; as bare inline links these measured 245x16 at 375px. */
+.sitefoot a {
+ display: inline-block;
+ padding-block: var(--space-1);
+}
/* --- Motion -------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
@@ -523,11 +582,14 @@ a.card:hover {
body { font-size: 11pt; }
.contentwrap { max-width: none; padding: 0; }
pre, .tablewrap { border-color: #999; page-break-inside: avoid; }
- /* Always print the light diagram, whatever the screen theme was. */
- .diagram__img--light { display: block !important; }
- .diagram__img--dark { display: none !important; }
+ /* The light diagram prints whatever the screen theme was: the
+ source query is `screen and (prefers-color-scheme: dark)`, which print
+ never matches, so it falls through to the light . No override here. */
.diagram__frame { overflow: visible; }
.diagram__img { min-width: 0 !important; page-break-inside: avoid; }
+ .hlink { display: none; }
a { color: inherit; text-decoration: underline; }
a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.85em; }
+ /* Site-relative citations resolve against the deployed origin on paper. */
+ a[href^="/"]::after { content: " (simic.foundryside.dev" attr(href) ")"; font-size: 0.85em; }
}
diff --git a/tools/ci/linkcheck.py b/tools/ci/linkcheck.py
new file mode 100755
index 0000000..623a7a0
--- /dev/null
+++ b/tools/ci/linkcheck.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Offline link + fragment check for the assembled Pages artifact.
+
+Checks every href/src in the hand-written root pages: that the target file
+exists in the artifact, and that any #fragment resolves to a real id on the
+target page. Third-party http(s) and mailto links are listed, not fetched —
+the deploy must not depend on someone else's availability.
+
+The fragment half is the point: the marketing pages cite the design wiki by
+deep link, and mkdocs slugs change when a heading is reworded. A silent 404
+inside our own domain is exactly what --strict buys on the wiki side.
+
+Usage: linkcheck.py
+"""
+
+import html
+import re
+import sys
+from pathlib import Path
+
+# Absolute URLs on our own origin are NOT third-party: canonical and og:url
+# point at the very pages in this artifact, and a typo in one is exactly the
+# silent breakage this gate exists to catch. They are rewritten to root-relative
+# and checked like any internal link.
+SELF_ORIGINS = ("https://simic.foundryside.dev", "http://simic.foundryside.dev")
+
+root = Path(sys.argv[1]).resolve()
+pages = sorted(root.glob("*.html"))
+if not pages:
+ sys.exit(f"linkcheck: no root pages found under {root}")
+
+# The attribute name is CAPTURED, not just matched: only srcset is a
+# comma-separated candidate list, and the splitter must be able to tell which
+# attribute it is looking at. (When this group was non-capturing, every value
+# got comma-split — which broke every data: URI, since RFC 2397 puts a comma
+# between the media type and the payload.)
+#
+# `content` is included for the same-origin metadata check below: og:url,
+# og:image and twitter:image are real URLs that must resolve, but they live in
+# content= alongside prose like og:description, so they are filtered by value
+# (must start with one of SELF_ORIGINS) rather than by attribute name.
+ATTR = re.compile(r"\b(href|src|srcset|content)\s*=\s*([\"'])(.*?)\2", re.I | re.S)
+
+# Schemes that never denote a file inside the artifact. Checked BEFORE any
+# comma-splitting: `data:` payloads legitimately contain commas, semicolons and
+# base64 that must never be parsed as a path.
+NON_FILE_SCHEMES = ("data:", "mailto:", "tel:", "javascript:", "blob:", "about:")
+
+# Fragment targets. Accepts single-quoted, double-quoted and bare id values,
+# case-insensitively.
+#
+# ASSUMPTION, and the reason this is deliberately permissive: the ids we resolve
+# against live in the mkdocs-generated wiki under /design/, so this regex is
+# coupled to that build's HTML serializer. Today it emits unminified
+# double-quoted attributes and mkdocs.yml has no minify plugin. If one is ever
+# enabled, single-quoted or unquoted ids would stop matching and this gate would
+# report BROKEN for links that are actually fine — failing a clean deploy, which
+# is the dangerous direction for a gate to fail in. Handling all three quoting
+# styles makes that config change a non-event.
+ID = re.compile(r"""\sid\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>=`]+))""", re.I)
+
+ids_cache: dict[Path, set[str]] = {}
+
+
+def ids_of(path: Path) -> set[str]:
+ if path not in ids_cache:
+ try:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ ids_cache[path] = set()
+ else:
+ ids_cache[path] = {m[0] or m[1] or m[2] for m in ID.findall(text)}
+ return ids_cache[path]
+
+
+def candidate_urls(attr: str, raw: str) -> list[str]:
+ """Every URL in one attribute value.
+
+ Comma-splitting applies to `srcset` ONLY. srcset is a comma-separated
+ candidate list, each entry optionally followed by a descriptor
+ ("img.svg 2x", "img.svg 800w"), so splitting on whitespace alone would
+ check the first candidate and silently ignore the rest.
+
+ Every other attribute is returned whole. Commas are legal and common in
+ ordinary URLs — a `data:` URI always contains one (RFC 2397 separates the
+ media type from the payload), and a path like `/a,b/c.html` is perfectly
+ valid. Splitting those produces garbage fragments that then get reported as
+ missing files, failing a deploy over markup that is completely fine.
+ """
+ value = html.unescape(raw).strip()
+ if attr.lower() != "srcset":
+ return [value] if value else []
+ out = []
+ for part in value.split(","):
+ part = part.strip()
+ if part:
+ out.append(part.split()[0])
+ return out
+
+
+def target_file(url: str, page: Path) -> Path | None:
+ """Resolve a URL to a file in the artifact, or None if it escapes it."""
+ base = root if url.startswith("/") else page.parent
+ p = (base / url.lstrip("/")).resolve()
+ # Containment: a `../` chain can resolve to a real file OUTSIDE the artifact,
+ # which exists on this disk but 404s in production. Refuse to be reassured
+ # by the local filesystem.
+ if not p.is_relative_to(root):
+ return None
+ # A directory URL (/design/, /design/01-claim/) serves its index.html.
+ return p / "index.html" if p.is_dir() else p
+
+
+broken: list[str] = []
+external: set[str] = set()
+selfref = 0
+checked = 0
+
+for page in pages:
+ text = page.read_text(encoding="utf-8")
+ for attr, _quote, raw in ATTR.findall(text):
+ is_content = attr.lower() == "content"
+
+ # Scheme filter runs on the WHOLE value, before any splitting, so a
+ # data: payload is discarded intact rather than shredded on its commas.
+ whole = html.unescape(raw).strip()
+ if whole.lower().startswith(NON_FILE_SCHEMES):
+ continue
+
+ # `content` carries mostly prose (og:description, viewport, the theme
+ # colour...). Only same-origin absolute URLs in it are checkable; a bare
+ # sentence must never be resolved as a path. This is intentionally
+ # stricter than href/src: no relative paths, no root-relative, no
+ # fragments — if it does not start with our own origin, it is ignored.
+ if is_content and not whole.startswith(SELF_ORIGINS):
+ continue
+
+ for url in candidate_urls(attr, raw):
+ if not url:
+ continue
+
+ # Rewrite same-origin absolutes to root-relative, then check them
+ # like any internal link.
+ same_origin = False
+ for origin in SELF_ORIGINS:
+ if url.startswith(origin):
+ url = url[len(origin) :] or "/"
+ selfref += 1
+ same_origin = True
+ break
+
+ if not same_origin:
+ if url.startswith(("http://", "https://", "//")):
+ external.add(url) # third-party: listed, never fetched
+ continue
+ if url.lower().startswith(NON_FILE_SCHEMES):
+ continue # not a file in this artifact
+ # Everything else — including a bare "#frag" (checked against the
+ # page it appears on, which is how the skip link and the heading
+ # permalinks get verified) — falls through to the check below.
+
+ checked += 1
+ path_part, _, frag = url.partition("#")
+ tgt = target_file(path_part, page) if path_part else page
+ if tgt is None:
+ broken.append(f"{page.name}: {url} -> escapes the artifact root")
+ elif not tgt.is_file():
+ broken.append(f"{page.name}: {url} -> missing {tgt.relative_to(root)}")
+ elif frag and frag not in ids_of(tgt):
+ broken.append(f'{page.name}: {url} -> no id="{frag}" in {tgt.relative_to(root)}')
+
+print(
+ f"linkcheck: {len(pages)} root pages, {checked} internal links checked "
+ f"({selfref} same-origin absolute), {len(external)} third-party listed (not fetched)"
+)
+for url in sorted(external):
+ print(f" external: {url}")
+if broken:
+ print("\nlinkcheck: BROKEN INTERNAL LINKS", file=sys.stderr)
+ for b in broken:
+ print(f" {b}", file=sys.stderr)
+ sys.exit(1)
+print("linkcheck: all internal links and fragments resolve")
diff --git a/tools/diagrams/render.sh b/tools/diagrams/render.sh
index b94fabe..cd9f43a 100755
--- a/tools/diagrams/render.sh
+++ b/tools/diagrams/render.sh
@@ -21,10 +21,36 @@
# htmlLabels:false — mermaid otherwise emits labels, which
# are NOT painted for an SVG loaded via (secure
# static mode). The boxes would render with no text.
-# Arial/Helvetica/Liberation Sans — metric-compatible across Windows, macOS
-# and Linux. Node box sizes are baked in at render time,
-# so a font with different metrics on the reader's
-# machine would overflow the boxes.
+# Font stack — mirrors --font-body in ../../site/style.css, so diagram
+# text matches the surrounding page instead of sitting in
+# Arial on a system-ui page. CHANGED 2026-08-09 from
+# Arial/Helvetica/Liberation Sans, which was chosen
+# because those three are metric-compatible across
+# Windows/macOS/Linux.
+#
+# That original concern is real and still applies, so read
+# this before touching the stack: mermaid bakes node box
+# sizes AND the canvas viewBox in at render time, from
+# whatever font THIS machine resolves. system-ui resolves
+# differently per platform, so the geometry is no longer
+# identical everywhere.
+#
+# Why it is nonetheless safe: on the render machine
+# system-ui resolved to DejaVu Sans, which is among the
+# widest common sans faces, so the baked boxes are
+# generous. Narrower reader fonts (Segoe UI, SF Pro) get
+# slack, never clipping — slack is the benign direction.
+# Measured 2026-08-09 across Liberation Sans (== Arial
+# metrics), DejaVu Sans, Ubuntu and FreeSans: 35 nodes per
+# diagram, ZERO box overflow and ZERO canvas clipping in
+# all four.
+#
+# THE TRAP: hand-editing font-family in the rendered SVGs
+# without re-rendering keeps the old geometry and DOES
+# clip — measured at 809px declared width, the left edge
+# label lost its margin and the right one ran off the
+# canvas. Change the stack HERE and re-render; never patch
+# the SVGs.
# -b transparent — the page background shows through, so one SVG works on
# whatever surface it sits on.
# Palette — theme-site-{light,dark}.json mirror the CSS custom
@@ -136,9 +162,15 @@ check_site() {
fi
done
- # The tags carry width/height so the browser reserves the right box
- # before the SVG loads. Editing a node label changes the layout and therefore
- # the viewBox, so those attributes drift silently — assert they still match.
+ # The tag carries width/height so the browser reserves the right box
+ # before the SVG loads. Editing a node label — or changing the font stack
+ # above — changes the layout and therefore the viewBox, so those attributes
+ # drift silently. Assert they still match.
+ #
+ # The page markup is ,
+ # so there is exactly ONE per diagram and the light SVG is the one whose
+ # dimensions are declared. grep -A1 on the light src still lands on the
+ # width/height line.
local svg name w h page
for svg in "$out"/*-light.svg; do
name="$(basename "$svg" -light.svg)"
@@ -147,7 +179,7 @@ check_site() {
for page in ../../site/index.html ../../site/architecture.html ../../site/lineage.html; do
grep -q "diagrams/${name}-light.svg" "$page" 2>/dev/null || continue
if ! grep -A1 "diagrams/${name}-light.svg" "$page" | grep -q "width=\"${w}\" height=\"${h}\""; then
- echo "FAIL $page: ${name} is now ${w}x${h}; update width/height (and --dmin ~= 0.75*w) on BOTH tags"
+ echo "FAIL $page: ${name} is now ${w}x${h}; update width/height (and --dmin ~= 0.75*w) on the tag"
fail=1
fi
done
diff --git a/tools/diagrams/theme-site-dark.json b/tools/diagrams/theme-site-dark.json
index 49aab5b..70dcf6a 100644
--- a/tools/diagrams/theme-site-dark.json
+++ b/tools/diagrams/theme-site-dark.json
@@ -1,9 +1,9 @@
{
"theme": "base",
"htmlLabels": false,
- "fontFamily": "Arial, Helvetica, 'Liberation Sans', sans-serif",
+ "fontFamily": "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
"themeVariables": {
- "fontFamily": "Arial, Helvetica, 'Liberation Sans', sans-serif",
+ "fontFamily": "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
"fontSize": "15px",
"background": "transparent",
"primaryColor": "#102127",
diff --git a/tools/diagrams/theme-site-light.json b/tools/diagrams/theme-site-light.json
index fd80cb7..cf2e520 100644
--- a/tools/diagrams/theme-site-light.json
+++ b/tools/diagrams/theme-site-light.json
@@ -1,9 +1,9 @@
{
"theme": "base",
"htmlLabels": false,
- "fontFamily": "Arial, Helvetica, 'Liberation Sans', sans-serif",
+ "fontFamily": "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
"themeVariables": {
- "fontFamily": "Arial, Helvetica, 'Liberation Sans', sans-serif",
+ "fontFamily": "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
"fontSize": "15px",
"background": "transparent",
"primaryColor": "#ebf1f4",
diff --git a/tools/wiki/README.md b/tools/wiki/README.md
index 01e1844..03cf712 100644
--- a/tools/wiki/README.md
+++ b/tools/wiki/README.md
@@ -23,21 +23,43 @@ pip install -r requirements.txt
the artifact root and `build/site/` to `/design/` — see
`.github/workflows/deploy-site.yml`.
+`build.sh` refuses to run if the installed `mkdocs`, `mkdocs-material` or
+`pymdown-extensions` differ from `requirements.txt`, because CI installs those
+pins exactly and a local build against different versions would produce a
+different site from the same commit while still passing `--strict`. Override
+deliberately with `WIKI_ALLOW_VERSION_DRIFT=1 ./build.sh`.
+
`mkdocs build --strict` is used, so a broken cross-reference between chapters
-**fails the deploy** instead of shipping. Chapter-to-chapter relative `.md`
-links and `#anchor` fragments are both validated.
+**fails the deploy** instead of shipping. Every level in `validation:` is
+`warn`, and under `strict: true` a warning aborts the build — so chapter-to-
+chapter relative `.md` links, `#anchor` fragments (on another page *and* on
+this one), root-relative `](/...)` links and unrecognised links are all hard
+gates. Verified by injecting each kind and watching the build fail.
+
+### 404s
+
+mkdocs emits `404.html`, and it is published at `/design/404.html` — where
+**GitHub Pages will never serve it**, because Pages only uses the 404 at the
+*site* root. A mistyped `/design/...` URL therefore gets whatever `/404.html`
+the marketing half provides. That file is outside this directory; nothing here
+can fix it, and adding a wiki-local 404 would only produce a page nobody
+reaches. Flagged rather than worked around.
## Files
| File | Role |
|---|---|
| `stage.py` | Copies `docs/design/` to `build/src/` and applies the render-only transforms |
+| `hooks.py` | mkdocs hooks: canonical nav order, and edit links that point at the real source |
| `mkdocs.yml` | Theme, extensions, validation. `docs_dir` is the staged copy, never `docs/design/` |
-| `requirements.txt` | Pinned mkdocs + plugins |
+| `requirements.txt` | Pinned mkdocs + plugins — `build.sh` refuses to build against a different version |
+| `overrides/main.html` | Adds the announce-bar link back out to the marketing site at `/` |
| `overrides/partials/source.html` | Material partial override — drops the api.github.com call |
| `assets/mathjax-config.js` | Copied into the staged tree; configures MathJax delimiters |
-| `build.sh` | Compile diagrams, stage, then build or serve |
+| `assets/simic.css` | Simic tokens mapped onto Material, plus the diagram frame and contrast fixes |
+| `build.sh` | Check the version pins, compile diagrams, stage, then build or serve |
| `../../docs/design/assets/model.dsl` | Structurizr model of the 14 domains — canonical, reviewed with chapter edits |
+| `../../site/assets/mark.svg` | The brand mark, staged in — one mark in the repo, owned by the site half |
## What stage.py does, and why
@@ -70,19 +92,53 @@ transforms bridge the gap, all on a throwaway copy:
and `ADR-nnnn` citations become links: each §18 invariant gets an
`#inv-nn` anchor injected into the *staged* constitution (attr_list, so
`--strict` validates the deep links), and ADR citations point at the
- staged pages. Code fences, inline code, HTML comments and
- self-references are left alone; the canonical files keep plain text per
- the citation convention.
+ staged pages. Code fences (both ``` and `~~~`), inline code, HTML comments
+ (including multi-line ones) and self-references are left alone; the
+ canonical files keep plain text per the citation convention.
+7. **Heading levels normalised** so the shallowest heading on a page sits at
+ `##`, directly under the `
` the theme renders from the title. The
+ `domains/*.md` chapters open at `### 13.x` — right in the v4.1 monolith,
+ where §13 was a third-level section, and a heading-order break (WCAG
+ 1.3.1) once the page has its own H1. Chapters already opening at `##` are
+ untouched. **The shift does not move anchors**: python-markdown slugs come
+ from heading text, never its level, and the built heading ids were diffed
+ either side of the change to prove it — 396 ids across 48 pages, zero
+ changed. Inbound deep links from outside the repo survive.
Everything else is left exactly as written: the `` provenance
comments (they render as nothing), the `[← HLD index]` breadcrumbs, the
`\(...\)` and `$$...$$` maths, and the mermaid fence in `04-architecture.md`.
+## Navigation and edit links: `hooks.py`
+
+Two things have to happen inside mkdocs, because they act on objects that only
+exist once the staged tree has been read.
+
+**Edit links.** `edit_uri` is a single prefix mkdocs appends the *staged* path
+to, and three classes of page have no source there: `index.md` (staged from
+`00-INDEX.md`), the eight `decisions/*` pages (staged from `docs/adr/`) and the
+generated `reference/*` and section landing pages. GitHub turns
+`edit/main/` into its **new-file editor**, so the
+pencil invited a reader to create a second, divergent copy of a canonical
+document inside `docs/design/`. `on_files` repoints the first two classes and
+sets `edit_uri = None` on generated pages, which is what mkdocs' own
+`File.edit_uri` docstring prescribes. `stage.py` lists the generated pages, so a
+new one cannot quietly reacquire a broken link.
+
+**Nav order.** Filename order is right for `01-`..`07-` and wrong everywhere
+else: it listed the fourteen domains alphabetically, contradicting
+`reference/domains.md` — generated by this same build — which lists them in
+§13.x order, the order of the canonical sentence. `on_nav` restores the
+canonical order, read from `00-INDEX.md`'s chapter-map table and the §13.x
+headings, never from a hand-written list. Anything the canon does not mention
+still falls back to filename order within its own section, so **adding a
+chapter still needs no config change**.
+
## The generated `reference/` section
-Beyond the transforms, `stage.py` *generates* four pages that exist only in
-the staged tree, never in `docs/design/` — pure projections, so they cannot
-drift from the chapters they are scraped from:
+Beyond the transforms, `stage.py` *generates* pages that exist only in the
+staged tree, never in `docs/design/` — pure projections, so they cannot drift
+from the chapters they are scraped from:
| Page | Scraped from |
|---|---|
@@ -90,10 +146,17 @@ drift from the chapters they are scraped from:
| `reference/invariants.md` | The 45 blocking invariants in `02-constitution.md` §18, labelled `INV-nn` as the chapters cite them |
| `reference/contracts.md` | The `### 9.x` contract headings in `05-leyline-contracts.md`, linked to their anchors |
| `reference/domains.md` | The `### 13.x Name — Epithet` heading of each `domains/*.md` chapter |
+| `appendices/`, `ops/`, `programme/`, `reference/` `index.md` | A linked contents list of that section's staged files |
The scrapers fail the build on surprises (non-contiguous invariant numbering, a
domain chapter without its heading) rather than publishing a partial registry.
+The section landing pages exist because without them `/design/appendices/` and
+friends 404, and `navigation.indexes` leaves the section header linking
+nowhere. They are contents lists only — no prose is invented, because inventing
+prose would be authoring and this is a projection. `domains/` and `decisions/`
+are excluded: they have real authored `README.md` chapters already.
+
## Compiled diagrams
`build.sh` exports every view in `docs/design/assets/model.dsl` with the
@@ -108,38 +171,100 @@ and `stage.py` **fails the build** if its container identifiers stop matching
`docs/design/domains/*.md` exactly. If a diagram and a chapter disagree, the
chapter wins and the model is wrong — fix `model.dsl`.
+The model itself is published at `/design/assets/model.dsl` and linked from the
+diagrams page — compiled diagrams are only worth trusting if the source they
+were compiled from is readable.
+
+### Size and theme
+
+The export is light-only and very wide — the ten views run 1305–4774px, against
+a content column of roughly 589px. Both are handled at render time so the model
+stays untouched:
+
+- **Size.** Each figure sits in a frame that **scrolls** rather than shrinking
+ past `--dmin`, and links to its full-size SVG. Fitting a 3792px diagram to
+ the column put PlantUML's 12px labels at 2–3px; legibility beats fitting,
+ which is the same call `site/style.css` makes for the marketing diagrams.
+- **Theme.** The SVGs carry `background:#FFFFFF` and `#444444` text, so in
+ slate they punched a white slab through an ink-950 page. `assets/simic.css`
+ frames each one as a **light plate** — padded, bordered, rounded, inset from
+ the page — so the light surface reads as a printed figure deliberately laid
+ on the page rather than as a lighting bug. Nothing in the export is
+ recoloured.
+
+The alternative treatment — a second, recoloured SVG swapped on
+`[data-md-color-scheme]`, the way the marketing site swaps its mermaid pair —
+is written up in `stage.py`'s diagram section, along with the element-scoped
+rules it would need. It is not shipped because it cannot be signed off without
+looking at it, and it should not be adopted on reasoning alone. (A blanket CSS
+`filter: invert()` is *not* the alternative: it inverts the teal accents too.)
+
+`DIAGRAM_PALETTE` in `stage.py` lists every colour the export is allowed to
+emit. A new style in `model.dsl` whose legibility on the plate nobody has
+checked **fails the build** rather than publishing a diagram nobody has looked
+at.
+
## Navigation
There is no `nav:` block. mkdocs derives the tree from the staged directory
structure, so **adding a chapter to `docs/design/` puts it in the wiki with no
-config change** — which is the whole point. Filenames order the nav; the
-derived titles label it.
+config change** — which is the whole point. The derived titles label it, and
+`hooks.py` orders it from the canon (see above) rather than from the
+filesystem's alphabet.
## External dependencies
-The marketing pages at `/` make zero external requests. The wiki does not
-inherit that rule — JavaScript is expected here — but every third-party URL is
-**pinned to an exact version**, and two of Material's defaults are switched off:
+**The published wiki makes no third-party request**, which is the same rule the
+marketing pages at `/` follow — they get there by shipping no JavaScript, the
+wiki by vendoring everything at build time.
| Dependency | Why | Status |
|---|---|---|
-| MathJax 3.2.2 (jsDelivr) | Renders the `$$` and `\(...\)` maths | Pinned |
-| mermaid 11.12.0 (jsDelivr) | Renders the fence in `04-architecture.md` | Pinned — and it *replaces* an unpinned dependency: Material's bundle otherwise fetches `unpkg.com/mermaid@11`, which drifts. Loading our own first suppresses it. |
-| Google Fonts | — | **Disabled** (`theme.font: false`). Material otherwise fetches Roboto from `fonts.googleapis.com` on every page load. |
-| api.github.com | — | **Disabled** via `overrides/partials/source.html`. Material otherwise fetches star/fork counts on every page load. |
+| MathJax 3.2.2 | Renders the `$$` and `\(...\)` maths | Pinned, **vendored** into `assets/external/` |
+| mermaid 11.12.0 | Renders the fence in `04-architecture.md` | Pinned, **vendored** — and it *displaces* an unpinned dependency: Material's bundle otherwise fetches `unpkg.com/mermaid@11`, which drifts |
+| Google Fonts | — | **Disabled** (`theme.font: false`). Material otherwise fetches Roboto from `fonts.googleapis.com` on every page load |
+| api.github.com | — | **Disabled** via `overrides/partials/source.html`. Material otherwise fetches star/fork counts on every page load |
-**To go fully self-contained**, vendor both scripts and drop the two CDN lines
-from `mkdocs.yml`:
+`material/privacy` does the vendoring: it downloads each external asset during
+the build into `build/site/assets/external/` (cached in `.cache/plugin/privacy`,
+alongside the diagram jars) and rewrites the `src` attributes. Confirm it after
+a version bump with:
```sh
-npm pack mathjax@3.2.2 mermaid@11.12.0
-# unpack, then copy into assets/:
-# mathjax: es5/tex-mml-chtml.js + es5/output/chtml/fonts/woff-v2/
-# mermaid: dist/mermaid.min.js
-# and reference them as assets/... instead of the https:// URLs
+find tools/wiki/build/site/assets/external -type f
+grep -rhoE '(src|href)="https?://[^"]*"' tools/wiki/build/site --include='*.html' | sort -u
```
-That adds ~2–3 MB to the repo and a node step to the build, which is why it is
-not the default. It is a one-config-change decision, not a rewrite.
+
+The second command should return only `github.com` links, which are navigation
+a reader clicks, not resources the page fetches.
+
+Two consequences worth knowing:
+
+- **MathJax is the SVG build, not CHTML.** `tex-mml-chtml.js` resolves its
+ `woff-v2` fonts from a path it builds at *runtime* relative to its own script
+ URL, so no build-time vendoring step can discover them: privacy fetched the
+ script and none of its fonts, and the maths would have rendered with fallback
+ metrics. `tex-mml-svg.js` embeds every glyph as a path — 2.1 MB instead of
+ 1.2 MB, and genuinely self-contained. (`grep -c woff`: 5 in the CHTML build,
+ 0 in this one.) If you ever switch back to CHTML, the fonts have to be
+ vendored explicitly.
+- **Subresource Integrity is no longer needed and is no longer set.** SRI
+ answers "did the CDN serve me different bytes" — a question that only exists
+ for a runtime fetch. Nothing is fetched at runtime now. Keeping the hashes
+ would only have added a version-bump trap where a stale hash blocks a
+ *local* script.
+
+Privacy also vendors Material's own `unpkg.com/mermaid@11` fallback URL, so the
+guard-order trick in `extra_javascript` — loading our pinned mermaid first so
+Material's `typeof mermaid == "undefined"` check never fires — is now a
+belt-and-braces measure rather than the only thing standing between the wiki
+and an unpinned dependency.
+
+All three scripts are `defer`red: they are large, they previously blocked the
+parser at the end of every page, and deferred scripts still execute in document
+order and still finish before `DOMContentLoaded` — which is what both timing
+dependencies need (`mathjax-config.js` before MathJax, and our mermaid before
+Material's `document$`-driven loader looks for it).
## Known rough edge
diff --git a/tools/wiki/assets/mathjax-config.js b/tools/wiki/assets/mathjax-config.js
index af2fe38..7ba5688 100644
--- a/tools/wiki/assets/mathjax-config.js
+++ b/tools/wiki/assets/mathjax-config.js
@@ -21,10 +21,17 @@ window.MathJax = {
// mkdocs-material's instant navigation swaps page content without a reload,
// so MathJax has to be told to typeset the new document body.
+// `clearCache` is CHTML-only: it clears the font-metric cache, and the SVG
+// output jax this wiki loads (tex-mml-svg.js — see mkdocs.yml) has no such
+// method. Called unguarded it THREW, and because it ran first, the three calls
+// below that actually re-typeset never executed — so maths silently failed to
+// render on every page reached by Material's instant navigation, while a direct
+// load looked fine because MathJax's own startup typesets once. Optional
+// chaining keeps the optimisation when it exists and skips it when it does not.
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
if (window.MathJax && window.MathJax.typesetPromise) {
- window.MathJax.startup.output.clearCache();
+ window.MathJax.startup.output.clearCache?.();
window.MathJax.typesetClear();
window.MathJax.texReset();
window.MathJax.typesetPromise();
diff --git a/tools/wiki/assets/simic.css b/tools/wiki/assets/simic.css
index cbb9cdc..254ebd7 100644
--- a/tools/wiki/assets/simic.css
+++ b/tools/wiki/assets/simic.css
@@ -73,7 +73,13 @@
--md-typeset-color: var(--simic-ink-900);
--md-typeset-a-color: var(--simic-teal-700);
- --md-accent-fg-color: var(--simic-teal-600);
+ /* Material paints link :hover AND :focus with the accent colour, so the
+ accent has to clear AA on its own — teal-600 measured 4.22:1 here, i.e.
+ interacting with a link made it HARDER to read than at rest (6.12:1), and
+ keyboard users got the sub-AA state as their primary focus indicator.
+ This is site/style.css's own --color-link-hover formula, which simic.css
+ had not mapped across: 8.72:1, and darker than the resting link. */
+ --md-accent-fg-color: color-mix(in oklch, var(--simic-teal-700) 72%, var(--simic-ink-900) 28%);
--md-accent-fg-color--transparent: oklch(0.560 0.105 175 / 0.1);
--md-code-bg-color: var(--simic-ink-100);
@@ -98,7 +104,8 @@
--md-typeset-color: var(--simic-ink-100);
--md-typeset-a-color: var(--simic-teal-400);
- --md-accent-fg-color: oklch(0.660 0.080 175);
+ /* Same formula, mirrored: in dark the hover lightens rather than darkens. */
+ --md-accent-fg-color: color-mix(in oklch, var(--simic-teal-400) 72%, var(--simic-ink-100) 28%);
--md-accent-fg-color--transparent: oklch(0.660 0.080 175 / 0.12);
--md-code-bg-color: var(--simic-ink-850);
@@ -119,6 +126,134 @@
filter: brightness(0) invert(0.98);
}
+/* --- The way back out ------------------------------------------------------
+ overrides/main.html puts a link to the marketing site in Material's announce
+ bar, because the wiki is mounted at /design/ and nothing else in the chrome
+ led back to /. Styled as quiet chrome, not a call to action: it is a
+ breadcrumb for readers who landed on a chapter from a search engine.
+ ========================================================================== */
+/* The bar's background is scheme-INDEPENDENT (near-black in both), so its
+ foregrounds must be too. Two traps, both of which shipped and both of which
+ only showed up in the light scheme:
+
+ 1. Material's `.md-banner__inner` carries `md-typeset`, so `.md-typeset a`
+ (0,1,1) beat a bare `.simic-upbar` (0,1,0) and painted the link with
+ --md-typeset-a-color — teal-700 in light, i.e. a DARK teal on a near-black
+ bar: 2.91:1. Hence the `.md-banner` prefix below, which wins on
+ specificity and stops the link tracking the page's link colour.
+ 2. The footer alpha tokens (--md-footer-fg-color--light/--lighter) are 0.7
+ and 0.45 alpha over the bar: 3.79:1. Replaced with opaque ink steps.
+
+ Measured on ink-900 (#06151b): teal-400 9.74:1, ink-300 10.87:1. Both clear
+ AA in both schemes because neither value moves with the scheme. */
+.md-banner {
+ background: var(--simic-ink-900);
+ color: var(--simic-ink-300);
+ font-size: 0.7rem;
+}
+
+.md-banner__inner {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.2rem 0.8rem;
+ align-items: baseline;
+ margin-block: 0.4rem;
+}
+
+.md-banner .simic-upbar {
+ color: var(--simic-teal-400);
+ font-weight: 600;
+}
+
+.md-banner .simic-upbar:hover,
+.md-banner .simic-upbar:focus {
+ text-decoration: underline;
+}
+
+.simic-upbar__arrow { margin-right: 0.2em; }
+
+/* Drops out first when the header gets tight; the link is what must survive. */
+.md-banner .simic-upbar__here { color: var(--simic-ink-300); }
+
+@media screen and (max-width: 44.9375em) {
+ .simic-upbar__here { display: none; }
+}
+
+/* --- Compiled diagrams ----------------------------------------------------
+ Two problems, both from the same source: the Structurizr export is 1305-
+ 4774px wide and hardcoded light.
+
+ SIZE. Material scales images to the content column (~589px), which put
+ PlantUML's 12px labels at 2-3px — the same defect the mermaid fence had,
+ moved rather than fixed. So the frame SCROLLS instead of shrinking past
+ --dmin, and every figure links to the full-size SVG, because no in-column
+ rendering of a 3792px diagram is genuinely readable. Same trade, and same
+ reason, as --dmin in site/style.css: legibility beats fitting.
+
+ THEME. The SVGs carry background:#FFFFFF and #444444 text, so in slate they
+ punched a white slab through an ink-950 page. Rather than recolour a
+ generated artifact nobody could look at, the plate below makes the light
+ surface DELIBERATE: padded, bordered, rounded, and inset from the page, so
+ it reads as a printed figure laid on the page instead of a lighting bug.
+ The alternative — a second, recoloured SVG swapped on the scheme attribute,
+ as the marketing site does for its mermaid pair — is written up in
+ stage.py's diagram section and wants a pair of eyes before it ships.
+ ========================================================================== */
+.md-typeset .simic-diagram {
+ margin-block: 1.2em;
+ margin-inline: 0;
+}
+
+.md-typeset .simic-diagram__frame {
+ overflow-x: auto;
+ /* The plate. In light this is very nearly the page itself and reads as a
+ hairline-bordered figure; in slate it is the deliberate light artifact. */
+ background: var(--simic-ink-050);
+ border: 1px solid var(--md-typeset-table-color);
+ border-radius: 4px;
+ padding: 0.6rem;
+}
+
+.md-typeset .simic-diagram__img {
+ display: block;
+ height: auto;
+ max-width: 100%;
+ min-width: var(--dmin, 0px); /* beats max-width when the frame is narrower */
+ margin-inline: auto;
+}
+
+.md-typeset .simic-diagram figcaption {
+ font-size: 0.75rem;
+ color: var(--md-default-fg-color--light);
+ margin-top: 0.4rem;
+}
+
+/* In slate the plate needs its own border, since the table token is a dark
+ hairline that would vanish against a light surface. */
+[data-md-color-scheme="slate"] .md-typeset .simic-diagram__frame {
+ border-color: var(--simic-ink-300);
+}
+
+/* --- Faint interactive controls -------------------------------------------
+ Material paints two icon controls with --md-default-fg-color--lighter, which
+ this sheet maps to ink-300: 1.64:1 against the page in light mode. Both are
+ interactive, so WCAG 1.4.11 asks 3:1 and they were nowhere near it.
+
+ .headerlink the heading anchor `¶` — the whole point of
+ toc.permalink: true is that headings are
+ deep-linkable, which a control nobody can see is not
+ .md-content__button the "Edit this page" pencil — the C1 fix made these
+ links CORRECT; this makes them visible
+
+ Fixed per-control rather than by remapping the token, because Material also
+ uses --lighter as a background and border colour in ~20 other rules where a
+ darker value would be wrong. --light gives 5.83:1 in light, 11.53:1 in dark
+ (both measured in-browser from computed styles). */
+.md-typeset .headerlink,
+.md-content__button.md-icon {
+ color: var(--md-default-fg-color--light);
+}
+
/* --- Admonitions -----------------------------------------------------------
Two spines only, per the brand: teal carries notes and emphasis, amber is
the sole warning colour. Material's per-type blues/greens/purples are all
diff --git a/tools/wiki/build.sh b/tools/wiki/build.sh
index 8ff6adb..f60b1fe 100755
--- a/tools/wiki/build.sh
+++ b/tools/wiki/build.sh
@@ -22,6 +22,39 @@ if ! command -v "$MKDOCS" >/dev/null 2>&1; then
fi
fi
+# CI installs requirements.txt exactly; a local build resolves whatever mkdocs
+# is on PATH. Without this check the two could silently diverge and produce
+# different sites from the same commit while --strict still passed.
+check_pin() {
+ local dist="$1" want actual
+ want="$(sed -n "s/^${dist}==//p" requirements.txt)"
+ [ -n "$want" ] || return 0
+ actual="$("$PYTHON" - "$dist" <<'PY' 2>/dev/null || true
+import importlib.metadata, sys
+try:
+ print(importlib.metadata.version(sys.argv[1]))
+except importlib.metadata.PackageNotFoundError:
+ pass
+PY
+)"
+ if [ -z "$actual" ]; then
+ echo "build.sh: $dist is not installed — pip install -r requirements.txt" >&2
+ exit 1
+ fi
+ if [ "$actual" != "$want" ]; then
+ echo "build.sh: $dist $actual is installed but requirements.txt pins $want." >&2
+ echo " CI builds against the pin, so this build would not match the deploy." >&2
+ echo " Fix with: pip install -r $(pwd)/requirements.txt" >&2
+ echo " Override deliberately with: WIKI_ALLOW_VERSION_DRIFT=1 ./build.sh" >&2
+ [ "${WIKI_ALLOW_VERSION_DRIFT:-}" = "1" ] || exit 1
+ fi
+}
+
+PYTHON="${PYTHON:-python3}"
+check_pin mkdocs
+check_pin mkdocs-material
+check_pin pymdown-extensions
+
# --- Compile docs/design/assets/model.dsl to SVGs -------------------------
# Structurizr CLI exports the views to PlantUML; PlantUML renders SVG with its
# pure-Java layout engine (no graphviz dependency). Both jars are PINNED and
@@ -75,6 +108,11 @@ case "${1:-build}" in
# --strict is also set in mkdocs.yml; passing it here too means an edited
# config can't silently downgrade a broken link to a warning.
"$MKDOCS" build --strict
+ # --strict validates links that came from MARKDOWN. Anything emitted as raw
+ # HTML bypasses mkdocs' relative-path pass entirely and is never checked —
+ # which is how twenty broken diagram images once shipped a clean build. This
+ # resolves every src/href in the built output against the tree.
+ "$PYTHON" check_links.py
echo
echo "wiki -> $(pwd)/build/site ($(find build/site -name '*.html' | wc -l) pages)"
;;
diff --git a/tools/wiki/check_links.py b/tools/wiki/check_links.py
new file mode 100644
index 0000000..a0e0715
--- /dev/null
+++ b/tools/wiki/check_links.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""Post-build gate: every local URL in the built HTML resolves to a real file.
+
+`mkdocs --strict` does NOT cover this. It validates links that came from
+markdown, which is most of them — but anything emitted as raw HTML is stashed
+by python-markdown and restored *after* the relative-path pass, so its paths
+are never rewritten and never checked. That gap shipped twenty broken diagram
+images and twenty broken full-size links: `../assets/...` from a page served at
+`/reference/diagrams/` resolves one directory too shallow, and every check in
+the build was blind to it.
+
+The same technique had already been applied to the "edit this page" URLs, where
+it caught a real defect — extract the attribute from the built HTML, resolve it,
+assert the target exists. It was applied to one class of URL and not the other.
+This closes that: `src` and `href` on every element, checked against the built
+tree, so the class is gated by the build rather than by whoever remembers to
+look.
+
+Checked: `src`, `href` and `srcset` on , ,