diff --git a/Makefile b/Makefile
index 21b9f381..57449ac2 100644
--- a/Makefile
+++ b/Makefile
@@ -26,7 +26,7 @@ docs-check: docs
node studio/node_modules/prettier/bin/prettier.cjs --check scripts/docs-scene scripts/docs-browser scripts/*docs*.mjs scripts/test_operator_motion.mjs scripts/test_factory_sequence.mjs
node --test scripts/test_docs_scene.mjs scripts/test_operator_motion.mjs scripts/test_factory_sequence.mjs
uv run --group docs python -m unittest scripts.test_docs_scene_hook scripts.test_architecture_diagrams scripts.test_docs_discovery
- node studio/node_modules/@playwright/test/cli.js test --config scripts/docs-playwright.config.mjs
+ uv run --group docs node studio/node_modules/@playwright/test/cli.js test --config scripts/docs-playwright.config.mjs
docs-pages-check:
uv run --group docs mkdocs build --strict --site-dir build/docs-pages/firefly-forge
diff --git a/docs/as-is-handoff.md b/docs/as-is-handoff.md
index b6bc5a67..3a83772f 100644
--- a/docs/as-is-handoff.md
+++ b/docs/as-is-handoff.md
@@ -10,7 +10,12 @@ Inspect the existing repositories before proposing changes. firefly forge reads
Use the logical project's component graph to select the repositories and paths to inspect. The project's `product_repository_id` owns the product definition and assistant conversation. Component repositories remain separate scopes.
```mermaid
-flowchart LR
+---
+config:
+ flowchart:
+ wrappingWidth: 120
+---
+flowchart TD
Source[Committed component source] --> Inspect[Source inspection]
Inspect --> Review[Owner reviews exact evidence digest]
Review --> Context[Frozen assistant context]
diff --git a/docs/product-lifecycle.md b/docs/product-lifecycle.md
index 3f45dd9c..e23ea766 100644
--- a/docs/product-lifecycle.md
+++ b/docs/product-lifecycle.md
@@ -9,7 +9,7 @@ A product can contain several customer journeys, features and integrations. Each
Incremental changes preserve the existing product definition. A request names its base revision and digest, upserts or removes specific contract IDs, and optionally appends journeys, acceptance criteria or user stories. Existing acceptance numbers and untouched features remain unchanged.
```mermaid
-flowchart LR
+flowchart TD
A[Current product revision] --> B[Preview feature change]
B --> C[Save immutable definition revision]
C --> D[Review function, design and architecture]
diff --git a/mkdocs.yml b/mkdocs.yml
index f39e8ac8..4ffa49c8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -50,7 +50,11 @@ markdown_extensions:
- toc:
permalink: true
- pymdownx.highlight
- - pymdownx.superfences
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.details
- pymdownx.tabbed:
alternate_style: true
diff --git a/scripts/docs-browser/image-readiness.mjs b/scripts/docs-browser/image-readiness.mjs
index 1cfd4243..85f330b7 100644
--- a/scripts/docs-browser/image-readiness.mjs
+++ b/scripts/docs-browser/image-readiness.mjs
@@ -6,30 +6,24 @@ const { expect } = createRequire(
)("@playwright/test");
export async function readyImages(images) {
- await images.evaluateAll((nodes) => {
- for (const image of nodes)
- if (image.loading === "lazy" && !image.currentSrc)
- image.loading = "eager";
- });
- await expect
- .poll(() =>
- images.evaluateAll((nodes) =>
- nodes.every((image) => {
- const declared = image.getAttribute("src");
+ for (const image of await images.all()) {
+ await image.scrollIntoViewIfNeeded();
+ await expect
+ .poll(() =>
+ image.evaluate((element) => {
+ const declared = element.getAttribute("src");
return (
declared &&
- image.currentSrc === new URL(declared, document.baseURI).href &&
- image.complete &&
- image.naturalWidth > 0 &&
- image.naturalHeight > 0
+ element.currentSrc === new URL(declared, document.baseURI).href &&
+ element.complete &&
+ element.naturalWidth > 0 &&
+ element.naturalHeight > 0
);
}),
- ),
- )
- .toBe(true);
- await images.evaluateAll((nodes) =>
- Promise.all(nodes.map((image) => image.decode())),
- );
+ )
+ .toBe(true);
+ await image.evaluate((element) => element.decode());
+ }
}
export async function readyPictureImage(image) {
diff --git a/scripts/docs-browser/image-readiness.spec.mjs b/scripts/docs-browser/image-readiness.spec.mjs
index 5a4377a6..093963b6 100644
--- a/scripts/docs-browser/image-readiness.spec.mjs
+++ b/scripts/docs-browser/image-readiness.spec.mjs
@@ -6,6 +6,38 @@ const { test, expect } = createRequire(
new URL("../../studio/package.json", import.meta.url),
)("@playwright/test");
+test("bulk readiness visits offscreen lazy images without changing loading", async ({
+ page,
+}) => {
+ await page.goto("/console-tour/");
+ const source = await page
+ .locator(".forge-shot__link img")
+ .first()
+ .evaluate(
+ (image) => new URL(image.getAttribute("src"), document.baseURI).href,
+ );
+ await page.setContent(
+ `
+
+
`,
+ );
+ await page.evaluate(() => {
+ const decode = HTMLImageElement.prototype.decode;
+ HTMLImageElement.prototype.decode = function () {
+ this.dataset.decodes = String(Number(this.dataset.decodes || 0) + 1);
+ return decode.call(this);
+ };
+ });
+ const images = page.locator("img");
+ expect((await images.last().boundingBox()).y).toBeGreaterThan(10000);
+ await readyImages(images);
+ for (const image of await images.all()) {
+ await expect(image).toHaveAttribute("loading", "lazy");
+ await expect(image).toHaveAttribute("data-decodes", "1");
+ }
+ await expect(images.last()).toBeInViewport();
+});
+
for (const kind of ["screenshot", "picture"])
test(`ready ${kind} does not restart an already loaded image`, async ({
page,
diff --git a/scripts/docs-browser/mermaid-runtime.mjs b/scripts/docs-browser/mermaid-runtime.mjs
new file mode 100644
index 00000000..92f727ca
--- /dev/null
+++ b/scripts/docs-browser/mermaid-runtime.mjs
@@ -0,0 +1,147 @@
+/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+ * https://www.apache.org/licenses/LICENSE-2.0 */
+import {
+ readFileSync,
+ readdirSync,
+ mkdtempSync,
+ writeFileSync,
+ mkdirSync,
+ statSync,
+} from "node:fs";
+import { join, resolve, sep, extname, posix } from "node:path";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import { spawnSync } from "node:child_process";
+
+export function mermaidPages(directory, prefix = "") {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const name = posix.join(prefix, entry.name);
+ if (entry.isDirectory())
+ return mermaidPages(join(directory, entry.name), name);
+ if (!entry.name.endsWith(".md")) return [];
+ const count = [
+ ...readFileSync(join(directory, entry.name), "utf8").matchAll(
+ /^[ \t]*(?:`{3,}|~{3,})[ \t]*mermaid[ \t]*$/gm,
+ ),
+ ].length;
+ return count
+ ? [
+ {
+ path:
+ name.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "/") ||
+ "/",
+ count,
+ },
+ ]
+ : [];
+ });
+}
+
+export async function buildRuntimeFixture(root) {
+ const directory = mkdtempSync(join(tmpdir(), "docs-mermaid-runtime-"));
+ const output = join(directory, "site");
+ const server = createServer((request, response) => {
+ try {
+ let target = resolve(
+ output,
+ "." +
+ decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname),
+ );
+ if (!target.startsWith(output + sep) && target !== output)
+ throw new Error("Outside fixture");
+ if (statSync(target).isDirectory()) target = join(target, "index.html");
+ const types = {
+ ".html": "text/html",
+ ".js": "application/javascript",
+ ".css": "text/css",
+ ".json": "application/json",
+ ".svg": "image/svg+xml",
+ ".xml": "application/xml",
+ };
+ response.setHeader(
+ "Content-Type",
+ types[extname(target)] ?? "application/octet-stream",
+ );
+ response.end(readFileSync(target));
+ } catch {
+ response.writeHead(404);
+ response.end();
+ }
+ });
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
+ const baseURL = `http://127.0.0.1:${server.address().port}/`;
+ const close = () => {
+ server.closeAllConnections();
+ server.close();
+ };
+ const docs = join(directory, "docs");
+ mkdirSync(docs);
+ writeFileSync(
+ join(docs, "index.md"),
+ "# Diagram runtime fixture\n\n[Next diagram](next.md)\n\n```mermaid\nflowchart TD\n Source[Owned source] --> Receipt[Verified receipt]\n```\n",
+ );
+ writeFileSync(
+ join(docs, "next.md"),
+ "# Second diagram\n\n[First diagram](index.md)\n\n```mermaid\nsequenceDiagram\n Caller->>Worker: Execute approved work\n Worker-->>Caller: Retain receipt\n```\n",
+ );
+ const config = join(directory, "mkdocs.yml");
+ writeFileSync(
+ config,
+ `INHERIT: ${JSON.stringify(join(root, "mkdocs.yml"))}\ndocs_dir: ${JSON.stringify(docs)}\nsite_dir: ${JSON.stringify(output)}\nsite_url: ${JSON.stringify(baseURL)}\nhooks: []\nextra_css: []\nextra_javascript: []\nnav:\n - First: index.md\n - Second: next.md\ntheme:\n name: material\n font: false\n logo: null\n favicon: assets/images/favicon.png\n features:\n - navigation.instant\n - navigation.instant.prefetch\n`,
+ );
+ const built = spawnSync(
+ process.env.BRIDGE_PYTHON ?? "python3",
+ ["-m", "mkdocs", "build", "--strict", "-f", config],
+ { cwd: root, encoding: "utf8" },
+ );
+ if (built.status !== 0) {
+ close();
+ throw new Error(built.stderr || built.stdout);
+ }
+ return { baseURL, close };
+}
+
+export async function renderedDiagrams(page) {
+ const session = await page.context().newCDPSession(page);
+ try {
+ const { root } = await session.send("DOM.getDocument", {
+ depth: -1,
+ pierce: true,
+ });
+ const diagrams = [];
+ async function visit(node, diagram = false) {
+ const attributes = node.attributes ?? [];
+ const classes = attributes[attributes.indexOf("class") + 1] ?? "";
+ const inside = diagram || classes.split(" ").includes("mermaid");
+ if (inside && node.localName === "svg") {
+ const { object } = await session.send("DOM.resolveNode", {
+ nodeId: node.nodeId,
+ });
+ const { result } = await session.send("Runtime.callFunctionOn", {
+ objectId: object.objectId,
+ functionDeclaration:
+ "function() { const box=this.getBoundingClientRect(); const shape=this.querySelector('.node rect, .actor'); return {html:this.outerHTML, width:box.width, height:box.height, fill:shape ? getComputedStyle(shape).fill : null, error:Boolean(this.querySelector('.error-icon, .error-text')), labelPixels:[...this.querySelectorAll('.nodeLabel')].map(label => { const transform=(label.closest('foreignObject') ?? label).getScreenCTM(); return parseFloat(getComputedStyle(label).fontSize) * Math.hypot(transform.a, transform.b); })}; }",
+ returnByValue: true,
+ });
+ diagrams.push(result.value);
+ }
+ for (const child of [
+ ...(node.children ?? []),
+ ...(node.shadowRoots ?? []),
+ ])
+ await visit(child, inside);
+ }
+ await visit(root);
+ return diagrams;
+ } catch (error) {
+ // Instant navigation can detach a node while its CDP snapshot is read.
+ if (
+ error.message.includes("DOM.resolveNode") &&
+ error.message.includes("No node with given id found")
+ )
+ return [];
+ throw error;
+ } finally {
+ await session.detach();
+ }
+}
diff --git a/scripts/docs-browser/mermaid.spec.mjs b/scripts/docs-browser/mermaid.spec.mjs
new file mode 100644
index 00000000..e15eb420
--- /dev/null
+++ b/scripts/docs-browser/mermaid.spec.mjs
@@ -0,0 +1,177 @@
+/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+ * https://www.apache.org/licenses/LICENSE-2.0 */
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { join } from "node:path";
+import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import {
+ buildRuntimeFixture,
+ mermaidPages,
+ renderedDiagrams,
+} from "./mermaid-runtime.mjs";
+const { test, expect } = createRequire(
+ new URL("../../studio/package.json", import.meta.url),
+)("@playwright/test");
+const root = fileURLToPath(new URL("../..", import.meta.url));
+
+test("Mermaid enumeration maps nested index pages to portable directory URLs", () => {
+ const docs = mkdtempSync(join(tmpdir(), "docs-mermaid-paths-"));
+ mkdirSync(join(docs, "guides", "nested"), { recursive: true });
+ const fence = " ```mermaid\nflowchart TD\n A --> B\n ```\n";
+ for (const name of ["index.md", "guides/index.md", "guides/nested/graph.md"])
+ writeFileSync(join(docs, name), fence);
+ writeFileSync(join(docs, "ordinary.md"), "# Ordinary prose\n");
+ expect(
+ mermaidPages(docs).sort((a, b) => a.path.localeCompare(b.path)),
+ ).toEqual([
+ { path: "/", count: 1 },
+ { path: "guides/", count: 1 },
+ { path: "guides/nested/graph/", count: 1 },
+ ]);
+});
+
+async function optionalRepositoryBadges(page) {
+ for (const endpoint of [
+ "https://api.github.com/repos/fireflyframework/firefly-forge",
+ "https://api.github.com/repos/fireflyframework/firefly-forge/releases/latest",
+ ]) {
+ await page.route(endpoint, (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ json: { stargazers_count: 0, forks_count: 0, tag_name: "docs-fixture" },
+ }),
+ );
+ }
+}
+
+async function assertDiagrams(page, count) {
+ await expect
+ .poll(async () => (await renderedDiagrams(page)).length)
+ .toBe(count);
+ const diagrams = await renderedDiagrams(page);
+ for (const diagram of diagrams) {
+ expect(diagram.width).toBeGreaterThan(100);
+ expect(diagram.height).toBeGreaterThan(30);
+ expect(diagram.error).toBe(false);
+ }
+ expect(
+ await page
+ .locator("pre")
+ .evaluateAll(
+ (nodes) =>
+ nodes.filter((node) =>
+ /^\s*(flowchart|sequenceDiagram)\b/.test(node.textContent),
+ ).length,
+ ),
+ ).toBe(0);
+ return diagrams;
+}
+
+for (const entry of mermaidPages(join(root, "docs"))) {
+ for (const theme of ["light", "dark"]) {
+ for (const width of [1440, 390]) {
+ test(`all Mermaid fences on ${entry.path} render legible SVG at ${width}px in ${theme}`, async ({
+ page,
+ }, testInfo) => {
+ await optionalRepositoryBadges(page);
+ await page.setViewportSize({ width, height: 2000 });
+ await page.emulateMedia({ colorScheme: theme });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("console", (message) => {
+ if (["warning", "error"].includes(message.type()))
+ errors.push(message.text() + " " + message.location().url);
+ });
+ page.on("response", (response) => {
+ if (response.status() >= 400)
+ errors.push(`${response.status()} ${response.url()}`);
+ });
+ await page.goto(entry.path);
+ await expect(page.locator("body")).toHaveAttribute(
+ "data-md-color-scheme",
+ theme === "dark" ? "slate" : "default",
+ );
+ const diagrams = await assertDiagrams(page, entry.count);
+ await page
+ .locator(".mermaid")
+ .first()
+ .screenshot({ path: testInfo.outputPath("diagram.png") });
+ await testInfo.attach("render-metrics", {
+ body: JSON.stringify(
+ diagrams.map(({ width, height, labelPixels, error }) => ({
+ width,
+ height,
+ labelPixels,
+ error,
+ })),
+ ),
+ contentType: "application/json",
+ });
+ for (const diagram of diagrams) {
+ expect(diagram.labelPixels.length).toBeGreaterThan(0);
+ expect(Math.min(...diagram.labelPixels)).toBeGreaterThanOrEqual(12);
+ }
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ expect(errors).toEqual([]);
+ });
+ }
+ }
+}
+
+test("Mermaid survives theme changes, instant navigation and browser history", async ({
+ page,
+}, testInfo) => {
+ await optionalRepositoryBadges(page);
+ const fixture = await buildRuntimeFixture(root);
+ try {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("console", (message) => {
+ if (["warning", "error"].includes(message.type()))
+ errors.push(message.text() + " " + message.location().url);
+ });
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto(fixture.baseURL);
+ const originalDocument = await page.evaluate(() => {
+ window.diagramDocument = "retained";
+ return window.diagramDocument;
+ });
+ const original = await assertDiagrams(page, 1);
+ await page.getByRole("link", { name: "Next diagram", exact: true }).click();
+ await expect(page).toHaveURL(/\/next\/$/);
+ expect(await page.evaluate(() => window.diagramDocument)).toBe(
+ originalDocument,
+ );
+ const next = await assertDiagrams(page, 1);
+ await page.locator('label[title="Switch to dark appearance"]').click();
+ await expect(page.locator("body")).toHaveAttribute(
+ "data-md-color-scheme",
+ "slate",
+ );
+ const dark = await assertDiagrams(page, 1);
+ expect(dark[0].fill).not.toEqual(next[0].fill);
+ await page
+ .locator(".mermaid")
+ .screenshot({ path: testInfo.outputPath("instant-dark.png") });
+ await page.goBack();
+ await assertDiagrams(page, 1);
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page
+ .locator(".mermaid")
+ .screenshot({ path: testInfo.outputPath("mobile-dark.png") });
+ expect(original[0].html).toContain("Owned source");
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ expect(errors).toEqual([]);
+ } finally {
+ fixture.close();
+ }
+});