diff --git a/.changelog/unreleased/566-build-time-mermaid.md b/.changelog/unreleased/566-build-time-mermaid.md
new file mode 100644
index 00000000..5735d893
--- /dev/null
+++ b/.changelog/unreleased/566-build-time-mermaid.md
@@ -0,0 +1,7 @@
+---
+type: added
+area: docs
+pr: 566
+breaking: false
+---
+Added build-time Mermaid diagram compilation to inline SVG with dual theme support, required accessibility captions, and responsive container scrolling.
diff --git a/.changelog/unreleased/571-docs-e2e.md b/.changelog/unreleased/571-docs-e2e.md
new file mode 100644
index 00000000..3b6aab52
--- /dev/null
+++ b/.changelog/unreleased/571-docs-e2e.md
@@ -0,0 +1,7 @@
+---
+type: added
+area: docs
+pr: 571
+breaking: false
+---
+Added end-to-end Playwright test coverage for documentation navigation, Pagefind search, section pagers, deep-link anchor focus, mobile drawer, and theme persistence against production build output.
diff --git a/apps/docs/content/concepts/oracles.mdx b/apps/docs/content/concepts/oracles.mdx
index 15a7fb5b..bded5e46 100644
--- a/apps/docs/content/concepts/oracles.mdx
+++ b/apps/docs/content/concepts/oracles.mdx
@@ -93,27 +93,20 @@ seconds old and the dot turns red.
From source to liquidation check, the value flows like this:
-```text
- SO4 oracle service ─┐
- Pyth Hermes ────────┤ first source that answers wins
- Binance REST ───────┤ (fetchTokenPrices, oracle.ts)
- GMX oracle ─────────┤
- DUMMY_PRICES ───────┘
- │
- ▼
- TokenPrice { minPrice, maxPrice, updatedAt, source }
- │
- ├──▶ trade page: mark price + staleness dot
- │
- ▼
- position valuation ──▶ maintenance-margin check ──▶ liquidation
- (contract + client, see /concepts/liquidation)
+```mermaid caption="Oracle price ingestion pipeline and liquidation check flow"
+graph TD
+ SO4[SO4 oracle service] --> TP[TokenPrice
minPrice, maxPrice, updatedAt, source]
+ Pyth[Pyth Hermes] --> TP
+ Binance[Binance REST] --> TP
+ GMX[GMX oracle] --> TP
+ Dummy[DUMMY_PRICES] --> TP
+ TP --> Trade[trade page: mark price + staleness dot]
+ TP --> PosVal[position valuation]
+ PosVal --> Margin[maintenance-margin check]
+ Margin --> Liq[liquidation]
```
-Caption: the client only owns the top half — choosing a source and shaping a
-`TokenPrice`. Position valuation and the liquidation decision run against that
-price in contract and client code covered by
-[/concepts/liquidation](/concepts/liquidation).
+The client only owns the top half — choosing a source and shaping a `TokenPrice`. Position valuation and the liquidation decision run against that price in contract and client code covered by [/concepts/liquidation](/concepts/liquidation).
## Related
diff --git a/apps/docs/content/developers/architecture.mdx b/apps/docs/content/developers/architecture.mdx
index c032bac4..5ef0bb81 100644
--- a/apps/docs/content/developers/architecture.mdx
+++ b/apps/docs/content/developers/architecture.mdx
@@ -26,16 +26,12 @@ Most trading logic lives in `apps/web`. The indexer watches contracts and makes
Market data, position state, and order history flow through four stops:
-```
-Soroban contracts (on-chain state)
- ↓
- RPC nodes (live reads)
- ↓
- s03-indexer (database sync)
- ↓
- web app (TanStack Query + Zustand)
- ↓
- trader screen
+```mermaid caption="SO4 architecture data flow from on-chain contracts to the trader screen"
+graph TD
+ Contracts[Soroban contracts
on-chain state] --> RPC[RPC nodes
live reads]
+ RPC --> Indexer[s03-indexer
database sync]
+ Indexer --> WebApp[web app
TanStack Query + Zustand]
+ WebApp --> Screen[trader screen]
```
### 1. Contracts
diff --git a/apps/docs/scripts/build.ts b/apps/docs/scripts/build.ts
index 3d28cc15..f97f0c4b 100644
--- a/apps/docs/scripts/build.ts
+++ b/apps/docs/scripts/build.ts
@@ -4,6 +4,7 @@ import { join } from "node:path"
import { $ } from "bun"
import { appRoot, loadPages, slugifyHeading } from "./content"
import { DEFAULT_SITE_URL } from "../src/lib/seo"
+import { renderMermaidFigureHtml } from "../src/lib/mermaid"
await $`bun run ${join(appRoot, "scripts/check-content.ts")}`
await $`bun run ${join(appRoot, "scripts/check-links.ts")}`
@@ -34,10 +35,22 @@ function renderInline(value: string) {
.replace(/`([^`]+)`/g, "$1")
}
-function render(body: string) {
+function render(body: string, filePath: string) {
const blocks = body.split(/\n\n+/)
return blocks
.map((block) => {
+ if (block.startsWith("```mermaid")) {
+ const firstLineEnd = block.indexOf("\n")
+ const header = firstLineEnd !== -1 ? block.slice(0, firstLineEnd) : block
+ const content = firstLineEnd !== -1 ? block.slice(firstLineEnd + 1).replace(/```$/, "").trim() : ""
+
+ const captionMatch = header.match(/caption=(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
+ const titleMatch = header.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
+ const caption = captionMatch?.[1] || captionMatch?.[2] || captionMatch?.[3]
+ const title = titleMatch?.[1] || titleMatch?.[2] || titleMatch?.[3]
+
+ return renderMermaidFigureHtml(content, { caption, title, file: filePath })
+ }
const heading = block.match(/^(#{2,6}) (.+?)(?: \{#([a-z0-9-]+)\})?$/)
if (heading) {
const level = heading[1].length
@@ -81,7 +94,7 @@ 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)}`
+ const html = `${escape(page.frontmatter.title)} · SO4 docs${escape(page.frontmatter.title)}
${render(page.body, page.file)}`
await Bun.write(join(directory, "index.html"), html)
}
diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts
index ad43bd66..8e6860ea 100644
--- a/apps/docs/scripts/check-content.ts
+++ b/apps/docs/scripts/check-content.ts
@@ -3,6 +3,7 @@ import { join } from "node:path"
import { contentRoot, headingEntries, loadPages } from "./content"
import { validateFrontmatter } from "../src/lib/frontmatter"
+import { parseMermaid } from "../src/lib/mermaid"
const pages = await loadPages()
const errors: Array = []
@@ -29,6 +30,26 @@ for (const page of pages) {
errors.push(`${page.route}: image "${src}" missing required alt text`)
}
}
+
+ // DX-054: Enforce Mermaid diagram captions and valid syntax
+ const mermaidMatches = page.body.matchAll(/```mermaid([^\n]*)\n([\s\S]*?)```/g)
+ for (const match of mermaidMatches) {
+ const metaStr = match[1]
+ const code = match[2].trim()
+ const captionMatch = metaStr.match(/caption=(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
+ const titleMatch = metaStr.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
+ const caption = captionMatch?.[1] || captionMatch?.[2] || captionMatch?.[3]
+ const title = titleMatch?.[1] || titleMatch?.[2] || titleMatch?.[3]
+
+ try {
+ const ast = parseMermaid(code, { caption, title, file: page.route })
+ if (!caption && !ast.accDescr && !ast.accTitle && !title) {
+ errors.push(`${page.route}: mermaid diagram missing required caption="..."`)
+ }
+ } catch (err: any) {
+ errors.push(`${page.route}: ${err.message || String(err)}`)
+ }
+ }
}
diff --git a/apps/docs/scripts/mermaid.test.ts b/apps/docs/scripts/mermaid.test.ts
new file mode 100644
index 00000000..33fabaaa
--- /dev/null
+++ b/apps/docs/scripts/mermaid.test.ts
@@ -0,0 +1,228 @@
+import { test, expect, describe } from "bun:test"
+import {
+ parseMermaid,
+ renderMermaidSvg,
+ renderMermaidFigureHtml,
+ generateDiagramId,
+} from "../src/lib/mermaid"
+import { rehypeMermaid } from "../src/lib/rehype-mermaid"
+
+describe("DX-054: Build-time Mermaid diagram support", () => {
+ const validFlowchart = `
+graph TD
+ Contracts[Soroban contracts
on-chain state] --> RPC[RPC nodes
live reads]
+ RPC --> Indexer[s03-indexer
database sync]
+ Indexer --> WebApp[web app
TanStack Query]
+ WebApp --> Screen[trader screen]
+`
+
+ const validSequence = `
+sequenceDiagram
+ autonumber
+ actor Trader
+ participant Web as Web App
+ participant Router as ExchangeRouter
+ participant Vault as OrderVault
+
+ Trader->>Web: Click Submit Order
+ Web->>Router: buildCreateOrderTransaction()
+ Router->>Vault: Lock collateral
+ Vault-->>Web: OrderCreated event
+ Web-->>Trader: Toast confirmation
+`
+
+ test("parses flowchart AST correctly with directions and node shapes", () => {
+ const ast = parseMermaid(validFlowchart, { file: "test.mdx" })
+ expect(ast.type).toBe("flowchart")
+ if (ast.type === "flowchart") {
+ expect(ast.direction).toBe("TD")
+ expect(ast.nodes.size).toBe(5)
+ expect(ast.nodes.get("Contracts")?.label).toBe(
+ "Soroban contracts
on-chain state"
+ )
+ expect(ast.edges.length).toBe(4)
+ expect(ast.edges[0].from).toBe("Contracts")
+ expect(ast.edges[0].to).toBe("RPC")
+ }
+ })
+
+ test("parses sequence diagram AST correctly with actors and messages", () => {
+ const ast = parseMermaid(validSequence, { file: "seq.mdx" })
+ expect(ast.type).toBe("sequence")
+ if (ast.type === "sequence") {
+ expect(ast.actors.length).toBe(4)
+ expect(ast.actors.find((a) => a.id === "Trader")?.isActor).toBe(true)
+ expect(ast.messages.length).toBe(5)
+ }
+ })
+
+ test("renders dual theme SVGs with token-derived colors and accessible tags", () => {
+ const ast = parseMermaid(validFlowchart)
+ const result = renderMermaidSvg(
+ ast,
+ { caption: "Data flow architecture" },
+ "dia-1"
+ )
+
+ expect(result.lightSvg).toContain("