diff --git a/scripts/src/actions/build/action.test.ts b/scripts/src/actions/build/action.test.ts
index 4263645..7cce73b 100644
--- a/scripts/src/actions/build/action.test.ts
+++ b/scripts/src/actions/build/action.test.ts
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
+import { JSDOM } from "jsdom";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { run } from "./action.js";
@@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints"
rmSync(dir, { recursive: true, force: true });
}
});
+
+test("build emits parseable XML when generic model attributes collide with fixed attributes", () => {
+ const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-"));
+ const input = join(dir, "model-attributes.yaml");
+ const output = join(dir, "model-attributes.drawio");
+ try {
+ writeFileSync(input, `version: 2
+pages:
+ - id: page
+ title: Page
+ layout: { type: manual, gridSize: 20 }
+ extensions:
+ $drawio:
+ attributes:
+ model:grid: "0"
+ model:gridSize: "99"
+ model:page: "0"
+ model:pageWidth: "1"
+ model:pageHeight: "2"
+ model:background: "#ffffff"
+ nodes:
+ - { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } }
+ edges: []
+`, "utf8");
+ run(input, 0, output);
+
+ const xml = readFileSync(output, "utf8");
+ const dom = new JSDOM("
");
+ const document = new dom.window.DOMParser().parseFromString(xml, "text/xml");
+ assert.equal(document.querySelector("parsererror"), null);
+ const model = document.querySelector("mxGraphModel");
+ assert.ok(model);
+ assert.equal(model.getAttribute("grid"), "1");
+ assert.equal(model.getAttribute("gridSize"), "20");
+ assert.equal(model.getAttribute("page"), "1");
+ assert.equal(model.getAttribute("pageWidth"), "1169");
+ assert.equal(model.getAttribute("pageHeight"), "827");
+ assert.equal(model.getAttribute("background"), "#ffffff");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/scripts/src/authoring/ir-to-drawio.ts b/scripts/src/authoring/ir-to-drawio.ts
index b0408ed..8d783bc 100644
--- a/scripts/src/authoring/ir-to-drawio.ts
+++ b/scripts/src/authoring/ir-to-drawio.ts
@@ -6,6 +6,7 @@ import { layoutPage } from "../services/layout/layout-engine.js";
const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
+const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]);
type Preservation = {
host?: string;
@@ -50,14 +51,21 @@ function dataAttributes(values: Record): string {
return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join("");
}
-function rawAttributes(values: Record | undefined, prefix?: string): string {
- return Object.entries(values ?? {}).filter(([key]) => !prefix || key.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(prefix ? key.slice(prefix.length) : key)}="${escapeXml(value)}"`).join("");
+function rawAttributes(values: Record | undefined, prefix?: string, reserved = new Set()): string {
+ return Object.entries(values ?? {})
+ .filter(([key]) => !prefix || key.startsWith(prefix))
+ .map(([key, value]) => [prefix ? key.slice(prefix.length) : key, value] as const)
+ .filter(([key]) => !reserved.has(key) && /^[A-Za-z_][\w:.-]*$/.test(key))
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`)
+ .join("");
}
function unprefixedAttributes(values: Record | undefined): string {
return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join("");
}
+
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
if (node.geometry) return node.geometry;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
@@ -128,7 +136,7 @@ function serializePage(input: DiagramPage): string {
const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`);
const modelAttributes = p.modelAttributes ?? {};
- return ` \n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} \n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} \n \n \n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n \n \n `;
+ return ` \n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} \n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} \n \n \n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n \n \n `;
}
export function diagramIRToDrawio(ir: DiagramIR): string {
diff --git a/scripts/src/services/shape-catalog/shape-catalog.test.ts b/scripts/src/services/shape-catalog/shape-catalog.test.ts
new file mode 100644
index 0000000..e68790c
--- /dev/null
+++ b/scripts/src/services/shape-catalog/shape-catalog.test.ts
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js";
+
+test("offline catalog entries carry explicit public provenance and licensing", () => {
+ assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6);
+ for (const entry of OFFLINE_SHAPE_CATALOG) {
+ assert.equal(entry.provenance.license, "Apache-2.0");
+ assert.match(entry.provenance.sourceUrl, /^https:\/\//);
+ assert.match(entry.style, /shape=/);
+ }
+});
+
+test("shape search ranks exact, alias, and fuzzy matches deterministically", () => {
+ assert.deepEqual(searchShapes("database"), searchShapes("database"));
+ assert.equal(searchShapes("database").matches[0].id, "cylinder");
+ assert.equal(searchShapes("database").matches[0].matchType, "alias");
+ assert.equal(searchShapes("rhombus").matches[0].matchType, "exact");
+ const fuzzy = searchShapes("proces box").matches[0];
+ assert.equal(fuzzy.id, "rectangle");
+ assert.equal(fuzzy.matchType, "fuzzy");
+ assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9);
+});
+
+test("shape search returns an explicit generic fallback for unknown or blank queries", () => {
+ for (const query of ["quantum-flibbertigibbet", " "]) {
+ const result = searchShapes(query);
+ assert.equal(result.matches.length, 0);
+ assert.equal(result.fallback.id, "rectangle");
+ assert.equal(result.fallback.matchType, "fallback");
+ assert.equal(result.fallback.confidence, 0);
+ }
+});
diff --git a/scripts/src/services/shape-catalog/shape-catalog.ts b/scripts/src/services/shape-catalog/shape-catalog.ts
new file mode 100644
index 0000000..e9c0610
--- /dev/null
+++ b/scripts/src/services/shape-catalog/shape-catalog.ts
@@ -0,0 +1,98 @@
+export interface ShapeProvenance {
+ sourceName: string;
+ sourceUrl: string;
+ license: "Apache-2.0";
+ note: string;
+}
+
+export interface ShapeCatalogEntry {
+ id: string;
+ name: string;
+ aliases: readonly string[];
+ style: string;
+ provenance: ShapeProvenance;
+}
+
+export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback";
+export interface ShapeSearchMatch extends ShapeCatalogEntry {
+ confidence: number;
+ matchType: ShapeMatchType;
+}
+export interface ShapeSearchResult {
+ query: string;
+ matches: ShapeSearchMatch[];
+ fallback: ShapeSearchMatch;
+}
+
+const PROVENANCE: ShapeProvenance = {
+ sourceName: "diagrams.net generic built-in shape vocabulary",
+ sourceUrl: "https://github.com/jgraph/drawio",
+ license: "Apache-2.0",
+ note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.",
+};
+
+export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([
+ entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"),
+ entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"),
+ entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"),
+ entry("document", "Document", ["file", "report", "paper"], "shape=document;"),
+ entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"),
+ entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"),
+ entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"),
+ entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"),
+]);
+
+function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry {
+ return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) });
+}
+
+function normalize(value: string): string {
+ return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " ");
+}
+
+function similarity(left: string, right: string): number {
+ if (left === right) return 1;
+ const a = [...left];
+ const b = [...right];
+ if (a.length === 0 || b.length === 0) return 0;
+ const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
+ for (let row = 1; row <= a.length; row += 1) {
+ const current = [row];
+ for (let column = 1; column <= b.length; column += 1) {
+ current[column] = Math.min(
+ current[column - 1] + 1,
+ previous[column] + 1,
+ previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
+ );
+ }
+ previous.splice(0, previous.length, ...current);
+ }
+ return 1 - previous[b.length] / Math.max(a.length, b.length);
+}
+
+function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined {
+ const id = normalize(entryValue.id);
+ const name = normalize(entryValue.name);
+ const aliases = entryValue.aliases.map(normalize);
+ if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" };
+ if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" };
+ const terms = [id, name, ...aliases];
+ const score = Math.max(...terms.map((term) => similarity(query, term)));
+ if (score < 0.45) return undefined;
+ return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" };
+}
+
+export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult {
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer");
+ const query = normalize(rawQuery);
+ const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!;
+ const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" };
+ const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG
+ .map((candidate) => match(candidate, query))
+ .filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined)
+ .sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US"))
+ .slice(0, limit);
+ return { query: rawQuery, matches, fallback };
+}
+
+export const searchShapeCatalog = searchShapes;
diff --git a/scripts/src/services/themes/theme-service.test.ts b/scripts/src/services/themes/theme-service.test.ts
new file mode 100644
index 0000000..eadbf78
--- /dev/null
+++ b/scripts/src/services/themes/theme-service.test.ts
@@ -0,0 +1,134 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
+import type { DiagramIRV2 } from "../../model/diagram-ir.js";
+import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js";
+
+const IR: DiagramIRV2 = {
+ version: 2,
+ pages: [{
+ id: "p", title: "Page",
+ nodes: [
+ { id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" },
+ { id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" },
+ ],
+ edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }],
+ }],
+};
+
+test("built-in themes validate and meet role contrast requirements", () => {
+ assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]);
+ for (const theme of Object.values(BUILT_IN_THEMES)) {
+ assert.doesNotThrow(() => validateTheme(theme));
+ assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name);
+ }
+});
+
+test("built-in theme registry and nested role styles reject mutation", () => {
+ const dark = BUILT_IN_THEMES.dark;
+ assert.ok(Object.isFrozen(dark));
+ assert.ok(Object.isFrozen(dark.node));
+ assert.ok(Object.isFrozen(dark.container));
+ assert.ok(Object.isFrozen(dark.edge));
+ assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError);
+ assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447");
+});
+
+test("theme application is deterministic, immutable, role based, and preserves structural style", () => {
+ const snapshot = structuredClone(IR);
+ const first = applyTheme(IR, "corporate");
+ assert.deepEqual(first, applyTheme(IR, "corporate"));
+ assert.deepEqual(IR, snapshot);
+ assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/);
+ assert.match(first.pages[0].nodes[0].style!, /rounded=1/);
+ assert.match(first.pages[0].nodes[1].style!, /fillColor=none/);
+ assert.match(first.pages[0].nodes[1].style!, /swimlane/);
+ assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/);
+ assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/);
+});
+
+test("theme result is deeply isolated from source objects", () => {
+ const source = structuredClone(IR);
+ source.assets = { icons: [{ name: "api" }] };
+ source.pages[0].nodes[0].properties = { config: { retries: 3 } };
+ source.pages[0].edges[0].extensions = { route: { owner: "network" } };
+ const snapshot = structuredClone(source);
+
+ const themed = applyTheme(source, "corporate");
+ ((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed";
+ ((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9;
+ ((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed";
+
+ assert.deepEqual(source, snapshot);
+});
+
+test("theme application emits each visual style key exactly once", () => {
+ const source = structuredClone(IR);
+ source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;";
+
+ const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!;
+
+ for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
+ assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style);
+ }
+ assert.match(style, /fillColor=#FFFDF5;/);
+ assert.match(style, /strokeColor=#444444;/);
+ assert.match(style, /fontColor=#222222;/);
+ assert.match(style, /sketch=1;/);
+});
+
+test("theme application preserves page extensions and serializes its canvas background", () => {
+ const source = structuredClone(IR);
+ source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } };
+
+ const themed = applyTheme(source, "dark");
+ const xml = diagramIRToDrawio(themed);
+
+ assert.deepEqual(themed.pages[0].extensions, {
+ owner: "platform",
+ $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } },
+ });
+ assert.match(xml, /]* data-extensions="\{"owner":"platform"\}"[^>]* custom="kept"/);
+ assert.match(xml, /]* background="#111827"/);
+ assert.match(xml, /]* pageScale="1.25"/);
+ assert.doesNotMatch(xml, /data-extensions="[^"]*\$drawio/);
+});
+
+test("transparent nodes use a readable dark font on a light canvas", () => {
+ const custom = structuredClone(BUILT_IN_THEMES.dark);
+ custom.name = "custom-light-canvas";
+ custom.backgroundColor = "#FFFFFF";
+ custom.edge.fontColor = "#000000";
+
+ const style = applyTheme(IR, custom).pages[0].nodes[1].style!;
+
+ assert.match(style, /fillColor=none;/);
+ assert.match(style, /fontColor=#000000;/);
+ assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5);
+});
+
+test("transparent nodes use a readable light font on a dark canvas", () => {
+ const custom = structuredClone(BUILT_IN_THEMES.default);
+ custom.name = "custom-dark-canvas";
+ custom.backgroundColor = "#000000";
+ custom.edge.fontColor = "#FFFFFF";
+
+ const source = structuredClone(IR);
+ source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;";
+ const style = applyTheme(source, custom).pages[0].nodes[0].style!;
+
+ assert.match(style, /fillColor=none;/);
+ assert.match(style, /fontColor=#FFFFFF;/);
+ assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5);
+});
+
+test("theme validation rejects invalid colors and insufficient contrast", () => {
+ const invalidColor = structuredClone(BUILT_IN_THEMES.default);
+ invalidColor.node.fillColor = "red";
+ assert.throws(() => validateTheme(invalidColor), /color/i);
+ const lowContrast = structuredClone(BUILT_IN_THEMES.default);
+ lowContrast.node.fontColor = "#777777";
+ lowContrast.node.fillColor = "#777777";
+ assert.throws(() => validateTheme(lowContrast), /contrast/i);
+});
diff --git a/scripts/src/services/themes/theme-service.ts b/scripts/src/services/themes/theme-service.ts
new file mode 100644
index 0000000..901a8f8
--- /dev/null
+++ b/scripts/src/services/themes/theme-service.ts
@@ -0,0 +1,148 @@
+import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
+
+export type BuiltInThemeName = "default" | "corporate" | "dark" | "colorblind-safe" | "handdrawn";
+
+export interface ThemeRoleStyle {
+ fillColor: string;
+ strokeColor: string;
+ fontColor: string;
+}
+
+export interface ThemeDefinition {
+ name: BuiltInThemeName | (string & {});
+ backgroundColor: string;
+ node: ThemeRoleStyle;
+ container: ThemeRoleStyle;
+ edge: Pick;
+ sketch?: boolean;
+}
+
+function deepFreeze(value: T): T {
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
+ for (const nested of Object.values(value as Record)) deepFreeze(nested);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+export const BUILT_IN_THEMES: Readonly> = deepFreeze({
+ default: theme("default", "#FFFFFF", ["#FFFFFF", "#4A5568", "#1A202C"], ["#F7FAFC", "#718096", "#1A202C"], ["#4A5568", "#1A202C"]),
+ corporate: theme("corporate", "#FFFFFF", ["#E8F0FE", "#1F5AA6", "#102A43"], ["#F5F8FC", "#526D82", "#102A43"], ["#1F5AA6", "#102A43"]),
+ dark: theme("dark", "#111827", ["#243447", "#94A3B8", "#FFFFFF"], ["#1F2937", "#64748B", "#FFFFFF"], ["#CBD5E1", "#FFFFFF"]),
+ "colorblind-safe": theme("colorblind-safe", "#FFFFFF", ["#FFF4CC", "#0072B2", "#1A1A1A"], ["#E8F3F8", "#009E73", "#1A1A1A"], ["#0072B2", "#1A1A1A"]),
+ handdrawn: { ...theme("handdrawn", "#FFFDF5", ["#FFFDF5", "#444444", "#222222"], ["#F6F1E5", "#555555", "#222222"], ["#444444", "#222222"]), sketch: true },
+});
+
+function theme(name: BuiltInThemeName, backgroundColor: string, node: [string, string, string], container: [string, string, string], edge: [string, string]): ThemeDefinition {
+ return {
+ name,
+ backgroundColor,
+ node: { fillColor: node[0], strokeColor: node[1], fontColor: node[2] },
+ container: { fillColor: container[0], strokeColor: container[1], fontColor: container[2] },
+ edge: { strokeColor: edge[0], fontColor: edge[1] },
+ };
+}
+
+const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
+
+export function contrastRatio(left: string, right: string): number {
+ const luminance = (color: string): number => {
+ if (!HEX_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
+ const values = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
+ .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
+ return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
+ };
+ const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
+ return (high + 0.05) / (low + 0.05);
+}
+
+export function validateTheme(themeDefinition: ThemeDefinition): ThemeDefinition {
+ if (!themeDefinition || typeof themeDefinition.name !== "string" || themeDefinition.name.trim() === "") throw new Error("Theme requires a name");
+ const colors: Array<[string, string]> = [
+ ["backgroundColor", themeDefinition.backgroundColor],
+ ["node.fillColor", themeDefinition.node?.fillColor], ["node.strokeColor", themeDefinition.node?.strokeColor], ["node.fontColor", themeDefinition.node?.fontColor],
+ ["container.fillColor", themeDefinition.container?.fillColor], ["container.strokeColor", themeDefinition.container?.strokeColor], ["container.fontColor", themeDefinition.container?.fontColor],
+ ["edge.strokeColor", themeDefinition.edge?.strokeColor], ["edge.fontColor", themeDefinition.edge?.fontColor],
+ ];
+ for (const [path, color] of colors) if (typeof color !== "string" || !HEX_COLOR.test(color)) throw new Error(`Theme ${path} must be a #RRGGBB color`);
+ for (const role of ["node", "container"] as const) {
+ if (contrastRatio(themeDefinition[role].fontColor, themeDefinition[role].fillColor) < 4.5) throw new Error(`Theme ${role} text has insufficient contrast`);
+ }
+ if (contrastRatio(themeDefinition.edge.fontColor, themeDefinition.backgroundColor) < 4.5) throw new Error("Theme edge text has insufficient contrast");
+ return themeDefinition;
+}
+
+interface StyleEntry { key: string; value?: string }
+
+function parseStyle(style = ""): StyleEntry[] {
+ return style.split(";").filter(Boolean).map((token) => {
+ const separator = token.indexOf("=");
+ return separator < 0 ? { key: token } : { key: token.slice(0, separator), value: token.slice(separator + 1) };
+ });
+}
+
+function themedStyle(style: string | undefined, colors: Partial, sketch: boolean, backgroundColor?: string): string {
+ const entries = parseStyle(style);
+ const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
+ const visual = new Map();
+ if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor);
+ if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor);
+ if (colors.fontColor) {
+ let fontColor = colors.fontColor;
+ if (originalFillNone && backgroundColor && contrastRatio(fontColor, backgroundColor) < 4.5) {
+ fontColor = contrastRatio("#000000", backgroundColor) >= 4.5 ? "#000000" : "#FFFFFF";
+ }
+ visual.set("fontColor", fontColor);
+ }
+ visual.set("sketch", sketch ? "1" : "0");
+ const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]);
+ const handled = new Set();
+ const output = entries.flatMap((entry) => {
+ if (!visualKeys.has(entry.key)) return [entry];
+ if (handled.has(entry.key)) return [];
+ handled.add(entry.key);
+ const replacement = visual.get(entry.key);
+ if (replacement === undefined) return [entry];
+ visual.delete(entry.key);
+ return [{ key: entry.key, value: replacement }];
+ });
+ for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
+ const value = visual.get(key);
+ if (value !== undefined) output.push({ key, value });
+ }
+ return output.map(({ key, value }) => value === undefined ? key : `${key}=${value}`).join(";") + ";";
+}
+
+export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDefinition): DiagramIRV2 {
+ const selectedTheme = typeof selected === "string" ? BUILT_IN_THEMES[selected] : selected;
+ if (!selectedTheme) throw new Error(`Unknown built-in theme: ${selected}`);
+ validateTheme(selectedTheme);
+ const isolated = structuredClone(ir);
+ const applyNode = (node: DiagramNode): DiagramNode => ({
+ ...node,
+ style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor),
+ });
+ const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) });
+ return {
+ ...isolated,
+ theme: selectedTheme.name,
+ pages: isolated.pages.map((page) => {
+ const extensions = page.extensions ?? {};
+ const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio)
+ ? extensions.$drawio as Record
+ : {};
+ const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes)
+ ? drawio.attributes as Record
+ : {};
+ return {
+ ...page,
+ extensions: {
+ ...extensions,
+ $drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } },
+ },
+ nodes: page.nodes.map(applyNode),
+ edges: page.edges.map(applyEdge),
+ };
+ }),
+ };
+}
diff --git a/scripts/src/services/transforms/heatmap.test.ts b/scripts/src/services/transforms/heatmap.test.ts
new file mode 100644
index 0000000..eddfe79
--- /dev/null
+++ b/scripts/src/services/transforms/heatmap.test.ts
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import type { DiagramIRV2 } from "../../model/diagram-ir.js";
+import { applyMetricsHeatmap } from "./heatmap.js";
+import { contrastRatio } from "../themes/theme-service.js";
+
+const IR: DiagramIRV2 = {
+ version: 2,
+ pages: [{ id: "p", title: "Page", nodes: [
+ { id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } },
+ { id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } },
+ ], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }],
+};
+
+test("metrics heatmap is deterministic with legend metadata and preserves topology", () => {
+ const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } };
+ const snapshot = structuredClone(IR);
+ const first = applyMetricsHeatmap(IR, request);
+ assert.deepEqual(first, applyMetricsHeatmap(IR, request));
+ assert.deepEqual(IR, snapshot);
+ assert.deepEqual(first.legend, {
+ metric: "latency_ms", min: 0, max: 100,
+ stops: [
+ { value: 0, color: "#440154", fontColor: "#FFFFFF" },
+ { value: 25, color: "#3B528B", fontColor: "#FFFFFF" },
+ { value: 50, color: "#21918C", fontColor: "#1A1A1A" },
+ { value: 75, color: "#5EC962", fontColor: "#1A1A1A" },
+ { value: 100, color: "#FDE725", fontColor: "#1A1A1A" },
+ ],
+ });
+ assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/);
+ assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/);
+ assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/);
+ assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges);
+ assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })));
+});
+
+test("metrics heatmap sets readable text for every palette stop", () => {
+ const result = applyMetricsHeatmap(IR, {
+ metric: "latency_ms", min: 0, max: 100,
+ values: { "p/low": 0, "p/high": 100 },
+ });
+
+ for (const stop of result.legend.stops) {
+ assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`);
+ }
+ assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/);
+ assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/);
+});
+
+test("metrics heatmap switches colors at the legend stop boundaries", () => {
+ const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, {
+ metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high },
+ }).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? "");
+
+ assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]);
+ assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]);
+ assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]);
+ assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]);
+});
+
+test("metrics heatmap rejects palettes without a readable text color", () => {
+ assert.throws(() => applyMetricsHeatmap(IR, {
+ metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"],
+ }), /no readable font color/i);
+});
+
+test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => {
+ assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i);
+ assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i);
+ assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i);
+ assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i);
+});
diff --git a/scripts/src/services/transforms/heatmap.ts b/scripts/src/services/transforms/heatmap.ts
new file mode 100644
index 0000000..173090c
--- /dev/null
+++ b/scripts/src/services/transforms/heatmap.ts
@@ -0,0 +1,85 @@
+import type { DiagramIRV2 } from "../../model/diagram-ir.js";
+
+export interface HeatmapRequest {
+ metric: string;
+ min: number;
+ max: number;
+ values: Readonly>;
+ colors?: readonly string[];
+}
+export interface HeatmapLegendStop { value: number; color: string; fontColor: string }
+export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] }
+export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend }
+
+export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]);
+const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
+
+function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string {
+ const entries = (style ?? "").split(";").filter(Boolean);
+ const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]);
+ const targetKeys = new Set(replacements.keys());
+ const output = entries.map((entry) => {
+ const key = entry.split("=", 1)[0];
+ if (!targetKeys.has(key)) return entry;
+ const replacement = replacements.get(key);
+ if (replacement === undefined) return undefined;
+ replacements.delete(key);
+ return `${key}=${replacement}`;
+ }).filter((entry): entry is string => entry !== undefined);
+ for (const [key, value] of replacements) output.push(`${key}=${value}`);
+ return `${output.join(";")};`;
+}
+
+function contrastRatio(left: string, right: string): number {
+ const luminance = (color: string): number => [1, 3, 5]
+ .map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255)
+ .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
+ .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0);
+ const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a);
+ return (high + 0.05) / (low + 0.05);
+}
+
+const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const;
+
+function readableFontColor(background: string): string {
+ const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5);
+ if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`);
+ return color;
+}
+
+function exactNumber(value: number): number {
+ return Number(value.toFixed(12));
+}
+
+export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult {
+ if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string");
+ if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max");
+ if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object");
+ const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)];
+ if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors");
+ const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`)));
+ for (const [key, value] of Object.entries(request.values)) {
+ if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`);
+ if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) {
+ throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`);
+ }
+ }
+ const stops = colors.map((color, index) => ({
+ value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)),
+ color,
+ fontColor: readableFontColor(color),
+ }));
+ const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops };
+ const diagram = structuredClone(ir);
+ for (const page of diagram.pages) for (const node of page.nodes) {
+ const value = request.values[`${page.id}/${node.id}`];
+ if (value === undefined) continue;
+ const nextStopIndex = stops.findIndex((stop) => value < stop.value);
+ const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1);
+ node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor);
+ }
+ diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend };
+ return { diagram, legend };
+}
+
+export const transformMetricsHeatmap = applyMetricsHeatmap;
diff --git a/scripts/src/services/transforms/relabel.test.ts b/scripts/src/services/transforms/relabel.test.ts
new file mode 100644
index 0000000..9f9fd68
--- /dev/null
+++ b/scripts/src/services/transforms/relabel.test.ts
@@ -0,0 +1,33 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import type { DiagramIRV2 } from "../../model/diagram-ir.js";
+import { relabelDiagram } from "./relabel.js";
+
+const IR: DiagramIRV2 = {
+ version: 2,
+ title: "Original",
+ pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [
+ { id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } },
+ { id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } },
+ ], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }],
+};
+
+test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => {
+ const snapshot = structuredClone(IR);
+ const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } });
+ assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]);
+ assert.equal(result.pages[0].edges[0].label, "new");
+ const restored = structuredClone(result);
+ restored.pages[0].nodes[0].label = "A";
+ restored.pages[0].nodes[1].label = "B";
+ restored.pages[0].edges[0].label = "old";
+ assert.deepEqual(restored, IR);
+ assert.deepEqual(IR, snapshot);
+});
+
+test("relabel requires a complete exact explicit map", () => {
+ assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i);
+ assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i);
+ assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i);
+});
diff --git a/scripts/src/services/transforms/relabel.ts b/scripts/src/services/transforms/relabel.ts
new file mode 100644
index 0000000..8de4f09
--- /dev/null
+++ b/scripts/src/services/transforms/relabel.ts
@@ -0,0 +1,35 @@
+import type { DiagramIRV2 } from "../../model/diagram-ir.js";
+
+export interface CompleteRelabelRequest {
+ mode: "complete";
+ labels: Readonly>;
+}
+
+export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 {
+ if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) {
+ throw new Error("Relabel requires an explicit complete map mode");
+ }
+ const required: string[] = [];
+ for (const page of ir.pages) {
+ for (const node of page.nodes) required.push(`${page.id}/${node.id}`);
+ for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`);
+ }
+ required.sort((left, right) => left.localeCompare(right, "en-US"));
+ const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US"));
+ const missing = required.filter((key) => !Object.hasOwn(request.labels, key));
+ if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`);
+ const unknown = provided.filter((key) => !required.includes(key));
+ if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`);
+ for (const key of required) {
+ const label = request.labels[key];
+ if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`);
+ }
+ const output = structuredClone(ir);
+ for (const page of output.pages) {
+ for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`];
+ for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`];
+ }
+ return output;
+}
+
+export const relabelDiagramIR = relabelDiagram;
diff --git a/scripts/src/services/transforms/reverse.test.ts b/scripts/src/services/transforms/reverse.test.ts
new file mode 100644
index 0000000..5b41925
--- /dev/null
+++ b/scripts/src/services/transforms/reverse.test.ts
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import type { DiagramIRV2 } from "../../model/diagram-ir.js";
+import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js";
+
+const IR: DiagramIRV2 = {
+ version: 2,
+ title: "Platform",
+ pages: [{
+ id: "system", title: "System",
+ nodes: [
+ { id: "a", label: "Client" },
+ { id: "b", label: "API \"danger\"]\n%%{init: {}}%% [click](javascript:alert(1))" },
+ ],
+ edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }],
+ }],
+};
+
+test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => {
+ const output = diagramIRToMermaid(IR);
+ assert.equal(output, diagramIRToMermaid(IR));
+ assert.match(output, /^flowchart LR/m);
+ assert.match(output, /n0\["Client"\]/);
+ assert.match(output, /n0 -->\|"calls | inject"\| n1/);
+ assert.doesNotMatch(output, /