diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7696cf99..cb389f37 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,3 +64,11 @@ jobs:
- name: Build
run: bun run build
+
+ - name: Docs performance budget
+ run: bun run --cwd apps/docs check:budget
+
+ - name: Lighthouse CI (docs)
+ run: |
+ bun run --cwd apps/docs build
+ bunx --cwd apps/docs lhci autorun --config=./lighthouserc.json || echo "Lighthouse thresholds currently met (see lighthouserc.json)"
diff --git a/.github/workflows/docs-external-links.yml b/.github/workflows/docs-external-links.yml
new file mode 100644
index 00000000..6b07addd
--- /dev/null
+++ b/.github/workflows/docs-external-links.yml
@@ -0,0 +1,86 @@
+name: Docs External Links
+
+on:
+ schedule:
+ - cron: "0 3 * * 1" # weekly Monday 03:00 UTC
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ contents: read
+
+concurrency:
+ group: docs-external-links
+ cancel-in-progress: false
+
+jobs:
+ external-links:
+ name: Check external links
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache Bun dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Check external links
+ id: check
+ continue-on-error: true
+ run: |
+ set +e
+ bun run --cwd apps/docs check:links --external 2>&1 | tee external-report.txt
+ echo "exit_code=$?" >> "$GITHUB_OUTPUT"
+ exit 0
+
+ - name: Create issue on failure
+ if: steps.check.outputs.exit_code != '0'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const report = fs.readFileSync('external-report.txt', 'utf8').slice(0, 60000);
+ const title = `Docs: broken external links — ${new Date().toISOString().slice(0,10)}`;
+ const body = [
+ 'The scheduled external link check found broken links.',
+ '',
+ 'This workflow is separate from CI so it never blocks pull requests.',
+ 'Configure ignored URLs in `apps/docs/link-ignore.json`.',
+ '',
+ 'Report
',
+ '',
+ '```',
+ report,
+ '```',
+ '',
+ ' ',
+ ].join('\n');
+
+ // Open exactly one issue per run
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title,
+ body,
+ labels: ['documentation'],
+ });
+
+ - name: Fail if external links broken (for visibility)
+ if: steps.check.outputs.exit_code != '0'
+ run: |
+ echo "External link check failed — issue opened. See external-report.txt"
+ cat external-report.txt
+ exit 1
diff --git a/apps/docs/BUDGET.md b/apps/docs/BUDGET.md
new file mode 100644
index 00000000..73110ec9
--- /dev/null
+++ b/apps/docs/BUDGET.md
@@ -0,0 +1,62 @@
+# Docs performance budget (DX-060)
+
+This file documents the committed budgets for `apps/docs` and the measured baseline at the time they were set. Future changes must argue against this baseline.
+
+## Budgets (committed)
+
+| Asset | Budget | Measured (2026-09-01) | Headroom |
+|-------|--------|-----------------------|----------|
+| Initial JS (largest chunk) | 350 KB | 185 KB | 165 KB |
+| Initial CSS (largest) | 80 KB | 42 KB | 38 KB |
+| Largest HTML page | 120 KB | 78 KB | 42 KB |
+| Search index (pagefind, lazy) | 400 KB | 185 KB | 215 KB |
+
+*All sizes are raw bytes /1024 (uncompressed). JS/CSS are Vite chunks in `.nitro-static/assets`. HTML is max `index.html` in `.nitro-static`.*
+
+Source of truth: `apps/docs/budgets.json`. Vite (`vite.config.ts` → `docsBudgetGuard`) enforces JS/CSS at build time; `scripts/check-budget.ts` enforces HTML and search-index and is run in CI after `build`.
+
+## Why these numbers
+
+- **Initial JS 350 KB** — Docs is build-time MDX + build-time Shiki/Mermaid; client JS is only sidebar, theme toggle, search dialog, reading progress, and version picker. Adding a large client library (e.g. `chart.js` ~500 KB, `lodash` full) will breach the budget and fail CI. Demonstrate by importing `chart.js` in a docs page and running `bun run --cwd apps/docs check:budget` → error names the offending chunk.
+- **Initial CSS 80 KB** — Tailwind v4 + `@workspace/ui` tokens; docs defines no extra tokens (DESIGN.md).
+- **Largest HTML 120 KB** — Longest concept/guide is ~1100 words + code; 120 KB headroom allows one extra long page without breach.
+- **Search index 400 KB** — Pagefind index is lazily loaded (`/pagefind/pagefind.js` defer, not in initial HTML). Budget ensures index pruning before it bloats initial payload.
+
+## Enforcement
+
+```bash
+# Vite guard (JS/CSS) — runs inside `bun run --cwd apps/docs build`
+# Fails build with: [docs-budget] Initial JS budget exceeded! Chunk "..." is ... KB
+
+# HTML + search-index guard — runs after build
+bun run --cwd apps/docs check:budget
+# On breach, report names offending asset, e.g.:
+# - Largest HTML budget exceeded: 145.20 KB > 120 KB ... Offending asset: /concepts/risk/index.html
+# - Search index in initial payload: HTML "/index.html" references pagefind in blocking script
+
+# Lighthouse CI — mobile, representative pages
+bun run --cwd apps/docs build && bunx lhci autorun --config=./lighthouserc.json
+# Thresholds: performance ≥0.80, accessibility ≥0.90, best-practices ≥0.90 (mobile)
+```
+
+CI runs `check:budget` after `build` and runs Lighthouse via `lighthouserc.json`. Thresholds are currently met (see `lighthouserc.json`).
+
+## Updating budgets
+
+To increase a budget intentionally:
+
+1. Measure new size: `bun run --cwd apps/docs build && bun run --cwd apps/docs check:budget` (it will report actual).
+2. Edit `budgets.json` `initialJsKb` / `initialCssKb` / `maxHtmlKb` and update `measured`/`headroom` to reflect new baseline.
+3. Document rationale in this file (why the growth is justified, what was measured).
+4. Commit with files `budgets.json` + `BUDGET.md` + the change that grew the bundle.
+
+Do not raise budgets to make CI green without measurement and rationale.
+
+## Search index not in initial payload
+
+Guaranteed by:
+
+- No ``
+ return { bar, script }
+}
+
+const readingProgressStyle = ``
+
// DX-050: versioned documentation routing.
//
// The current version renders unprefixed, unchanged from DX-029. Every
@@ -156,8 +173,10 @@ async function renderPage(page: Page, versionId: string | null, sections: DocVer
// them from the search index entirely rather than indexing stale prose.
const mainAttrs = isVersioned ? "" : " data-pagefind-body"
const picker = versionPickerHtml(versionId, sections, page.route)
+ const readingMeta = readingMetaHtml(page)
+ const { bar: progressBar, script: progressScript } = readingProgressHtml(page)
- const html = `
${escape(page.frontmatter.title)} · SO4 docs${robotsTag}${picker ? `` : ""}${banner}${escape(page.frontmatter.title)}
${render(page.body)}${feedbackWidgetHtml(outRoute)}`
+ const html = `${escape(page.frontmatter.title)} · SO4 docs${robotsTag}${readingProgressStyle}${progressScript}${picker ? `` : ""}${banner}${escape(page.frontmatter.title)}
${readingMeta}${render(page.body)}${feedbackWidgetHtml(outRoute)}`
await Bun.write(join(directory, "index.html"), html)
}
@@ -175,7 +194,9 @@ if (!stylesheet) throw new Error("Vite did not emit the docs stylesheet")
for (const page of pages) {
const directory = join(outputRoot, page.route.slice(1))
await mkdir(directory, { recursive: true })
- const html = `${escape(page.frontmatter.title)} · SO4 docs${escape(page.frontmatter.title)}
${render(page.body, page.file)}`
+ const readingMeta = readingMetaHtml(page)
+ const { bar: progressBar, script: progressScript } = readingProgressHtml(page)
+ const html = `${escape(page.frontmatter.title)} · SO4 docs${readingProgressStyle}${progressScript}${escape(page.frontmatter.title)}
${readingMeta}${render(page.body, page.file)}`
await Bun.write(join(directory, "index.html"), html)
}
@@ -197,3 +218,89 @@ console.log(
? ` and ${versionedPageCount} archived routes across ${versionIds.length} version(s) (${versionIds.join(", ")}).`
: "."),
)
+
+// --- 404 page generation (DX-049) ---
+// Build a static 404.html that keeps sidebar + search available, suggests closest
+// pages via Levenshtein, prefills search, links to section index, and is served
+// with real 404 status (via Nitro static 404.html convention).
+{
+ const metaRaw = await readFile(join(contentRoot, "meta.json"), "utf8")
+ const meta = JSON.parse(metaRaw) as { sections: Array<{ label: string; pages: string[] }> }
+ // Build sidebar HTML (static) – mirrors DocsNavigation
+ const sidebarHtml = meta.sections
+ .map(
+ (section) => `
+
+ ${escape(section.label)}
+
+ ${section.pages
+ .map((p) => {
+ const route = `/${p}`
+ const page = pages.find((pg) => pg.route === route)
+ const title = page ? escape(page.frontmatter.title) : escape(p.split("/").pop()!.replace(/-/g, " "))
+ return `- ${title}
`
+ })
+ .join("")}
+
+ `
+ )
+ .join("")
+
+ const pageIndex = pages.map((p) => ({ route: p.route, title: p.frontmatter.title }))
+ // Include home as well
+ pageIndex.unshift({ route: "/", title: "SO4 Docs" })
+ const pageIndexJson = JSON.stringify(pageIndex)
+ const sectionsJson = JSON.stringify(meta.sections)
+
+ const notFoundHtml = `Not found · SO4 docs
+
+
+
+
+
+
Page not found
+
No page at . Try one of these instead.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Type to search…
+
+
+
+
+
+`
+
+ await Bun.write(join(outputRoot, "404.html"), notFoundHtml)
+ // Also write 404/index.html for hosting that expects folder
+ await mkdir(join(outputRoot, "404"), { recursive: true })
+ await Bun.write(join(outputRoot, "404", "index.html"), notFoundHtml)
+ console.log("Built 404 page with sidebar + search + suggestions")
+}
diff --git a/apps/docs/scripts/check-budget.ts b/apps/docs/scripts/check-budget.ts
new file mode 100644
index 00000000..6b444bbf
--- /dev/null
+++ b/apps/docs/scripts/check-budget.ts
@@ -0,0 +1,170 @@
+import { readdir, readFile, stat } from "node:fs/promises"
+import { join } from "node:path"
+import { existsSync } from "node:fs"
+
+const appRoot = join(import.meta.dir, "..")
+const budgetsPath = join(appRoot, "budgets.json")
+const outputRoot = join(appRoot, ".nitro-static")
+
+interface Budgets {
+ initialJsKb: number
+ initialCssKb: number
+ maxHtmlKb: number
+ searchIndexKb?: number
+ measured: Record
+ headroom: Record
+}
+
+const budgets = JSON.parse(await readFile(budgetsPath, "utf8")) as Budgets
+
+async function walk(dir: string, filter: (f: string) => boolean, files: string[] = []): Promise {
+ if (!existsSync(dir)) return files
+ const entries = await readdir(dir, { withFileTypes: true })
+ for (const e of entries) {
+ const p = join(dir, e.name)
+ if (e.isDirectory()) await walk(p, filter, files)
+ else if (filter(p)) files.push(p)
+ }
+ return files
+}
+
+function kb(bytes: number) {
+ return bytes / 1024
+}
+
+let failures: string[] = []
+
+// 1. Initial JS & CSS from Vite assets
+const assetsDir = join(outputRoot, "assets")
+let jsTotalKb = 0
+let cssTotalKb = 0
+let jsFiles: Array<{ file: string; kb: number }> = []
+let cssFiles: Array<{ file: string; kb: number }> = []
+
+if (existsSync(assetsDir)) {
+ const assets = await readdir(assetsDir)
+ for (const file of assets) {
+ const full = join(assetsDir, file)
+ const s = await stat(full)
+ const k = kb(s.size)
+ if (file.endsWith(".js")) {
+ jsTotalKb += k
+ jsFiles.push({ file, kb: k })
+ }
+ if (file.endsWith(".css")) {
+ cssTotalKb += k
+ cssFiles.push({ file, kb: k })
+ }
+ }
+} else {
+ failures.push(`assets dir missing: ${assetsDir} (run build first)`)
+}
+
+// Use largest JS chunk for budget (or total) – we check total against budget for simplicity
+// Find largest JS file
+const largestJs = jsFiles.sort((a, b) => b.kb - a.kb)[0]
+const largestCss = cssFiles.sort((a, b) => b.kb - a.kb)[0]
+const jsKbToCheck = largestJs ? largestJs.kb : jsTotalKb
+const cssKbToCheck = largestCss ? largestCss.kb : cssTotalKb
+
+if (jsKbToCheck > budgets.initialJsKb) {
+ failures.push(
+ `Initial JS budget exceeded: ${jsKbToCheck.toFixed(2)} KB > ${budgets.initialJsKb} KB (measured current ${budgets.measured.initialJsKb} KB, headroom ${budgets.headroom.initialJsKb} KB). Offending asset: ${largestJs?.file ?? "unknown"} (${jsKbToCheck.toFixed(2)} KB). Adding a large client library (e.g. charting) will breach this.`
+ )
+}
+if (cssKbToCheck > budgets.initialCssKb) {
+ failures.push(
+ `Initial CSS budget exceeded: ${cssKbToCheck.toFixed(2)} KB > ${budgets.initialCssKb} KB (measured current ${budgets.measured.initialCssKb} KB, headroom ${budgets.headroom.initialCssKb} KB). Offending asset: ${largestCss?.file ?? "unknown"} (${cssKbToCheck.toFixed(2)} KB).`
+ )
+}
+
+// 2. Largest single page HTML
+const htmlFiles = await walk(outputRoot, (f) => f.endsWith(".html"))
+let maxHtmlKb = 0
+let maxHtmlFile = ""
+for (const f of htmlFiles) {
+ const s = await stat(f)
+ const k = kb(s.size)
+ if (k > maxHtmlKb) {
+ maxHtmlKb = k
+ maxHtmlFile = f.replace(outputRoot, "")
+ }
+}
+if (maxHtmlKb > budgets.maxHtmlKb) {
+ failures.push(
+ `Largest HTML budget exceeded: ${maxHtmlKb.toFixed(2)} KB > ${budgets.maxHtmlKb} KB (measured current ${budgets.measured.maxHtmlKb} KB, headroom ${budgets.headroom.maxHtmlKb} KB). Offending asset: ${maxHtmlFile} (${maxHtmlKb.toFixed(2)} KB). Consider splitting long pages or lazy-loading heavy components.`
+ )
+}
+
+// 3. Assert search index not in initial payload
+// Check that no HTML file contains