Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/as-is-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion docs/product-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 14 additions & 20 deletions scripts/docs-browser/image-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions scripts/docs-browser/image-readiness.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<img src="${source}" loading="lazy" width="320" height="200">
<div style="height:20000px"></div>
<img src="${source}" loading="lazy" width="320" height="200">`,
);
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,
Expand Down
147 changes: 147 additions & 0 deletions scripts/docs-browser/mermaid-runtime.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading