From 7463c3c6dd09e84cecbde92b1a0dafcbe83ec82a Mon Sep 17 00:00:00 2001 From: ragelink Date: Thu, 26 Mar 2026 19:51:03 -0600 Subject: [PATCH 1/9] feat(cynefin): add Cynefin framework diagram type Add a Cynefin framework diagram type for categorizing problems by complexity domain. Five fixed domains (Complex, Complicated, Clear, Chaotic, Confusion) with wavy organic boundaries, items placed within domains, and transition arrows between them. Features: - Langium grammar with domain blocks, items, and transitions - Wavy SVG bezier boundaries (deterministic via seeded PRNG) - The "cliff" between Clear and Chaotic - Domain labels with decision model and practice type subtitles - Item badges stacked within each domain - Curved transition arrows with optional labels - Full theme integration (5 themes with domain-specific colors) - Schema-driven config (width, height, padding, boundaryAmplitude) - Accessibility (ARIA roles, labels) Ref: mermaid-js/mermaid#7534 --- .build/jsonSchema.ts | 1 + .cspell/mermaid-terms.txt | 1 + .../defaultConfig/variables/configKeys.md | 2 +- .../setup/mermaid/interfaces/MermaidConfig.md | 18 +- packages/examples/src/examples/cynefin.ts | 37 ++ packages/examples/src/index.ts | 2 + packages/mermaid/src/config.type.ts | 29 ++ packages/mermaid/src/defaultConfig.ts | 3 + .../src/diagram-api/diagram-orchestration.ts | 4 +- .../src/diagrams/cynefin/cynefinBoundaries.ts | 139 ++++++++ .../mermaid/src/diagrams/cynefin/cynefinDb.ts | 85 +++++ .../src/diagrams/cynefin/cynefinDetector.ts | 22 ++ .../src/diagrams/cynefin/cynefinDiagram.ts | 12 + .../src/diagrams/cynefin/cynefinParser.ts | 20 ++ .../src/diagrams/cynefin/cynefinRenderer.ts | 326 ++++++++++++++++++ .../mermaid/src/diagrams/cynefin/styles.ts | 104 ++++++ .../mermaid/src/diagrams/cynefin/types.ts | 31 ++ .../multi-diagram-id-uniqueness.spec.ts | 11 + .../mermaid/src/schemas/config.schema.yaml | 35 ++ packages/mermaid/src/themes/theme-base.js | 19 + packages/mermaid/src/themes/theme-dark.js | 19 + packages/mermaid/src/themes/theme-default.js | 19 + packages/mermaid/src/themes/theme-forest.js | 19 + packages/mermaid/src/themes/theme-neutral.js | 19 + packages/parser/langium-config.json | 5 + .../src/language/cynefin/cynefin.langium | 29 ++ packages/parser/src/language/cynefin/index.ts | 1 + .../parser/src/language/cynefin/module.ts | 55 +++ .../src/language/cynefin/tokenBuilder.ts | 7 + packages/parser/src/language/index.ts | 10 + packages/parser/src/parse.ts | 18 +- 31 files changed, 1094 insertions(+), 8 deletions(-) create mode 100644 packages/examples/src/examples/cynefin.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefinDb.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefinParser.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/styles.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/types.ts create mode 100644 packages/parser/src/language/cynefin/cynefin.langium create mode 100644 packages/parser/src/language/cynefin/index.ts create mode 100644 packages/parser/src/language/cynefin/module.ts create mode 100644 packages/parser/src/language/cynefin/tokenBuilder.ts diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index 7715a96c434..6f267896659 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -31,6 +31,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'architecture', 'radar', 'venn', + 'cynefin', ] as const; /** diff --git a/.cspell/mermaid-terms.txt b/.cspell/mermaid-terms.txt index 0720dec34be..3b8955d2a7c 100644 --- a/.cspell/mermaid-terms.txt +++ b/.cspell/mermaid-terms.txt @@ -7,6 +7,7 @@ catmull compositTitleSize cose curv +cynefin deaccelerator Deaccelerator deaccelerators diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 056ef6cbd2f..925cbe078e7 100644 --- a/docs/config/setup/defaultConfig/variables/configKeys.md +++ b/docs/config/setup/defaultConfig/variables/configKeys.md @@ -12,4 +12,4 @@ > `const` **configKeys**: `Set`<`string`> -Defined in: [packages/mermaid/src/defaultConfig.ts:302](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L302) +Defined in: [packages/mermaid/src/defaultConfig.ts:305](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L305) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index a565c333c4d..d4d8d491d59 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -65,6 +65,14 @@ Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid --- +### cynefin? + +> `optional` **cynefin**: `CynefinDiagramConfig` + +Defined in: [packages/mermaid/src/config.type.ts:236](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L236) + +--- + ### darkMode? > `optional` **darkMode**: `boolean` @@ -105,7 +113,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:236](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L236) +Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L237) --- @@ -179,7 +187,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L238) +Defined in: [packages/mermaid/src/config.type.ts:239](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L239) --- @@ -305,7 +313,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:239](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L239) +Defined in: [packages/mermaid/src/config.type.ts:240](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L240) --- @@ -437,7 +445,7 @@ Defined in: [packages/mermaid/src/config.type.ts:217](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) +Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -507,7 +515,7 @@ Defined in: [packages/mermaid/src/config.type.ts:235](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L237) +Defined in: [packages/mermaid/src/config.type.ts:238](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L238) --- diff --git a/packages/examples/src/examples/cynefin.ts b/packages/examples/src/examples/cynefin.ts new file mode 100644 index 00000000000..df1c8965851 --- /dev/null +++ b/packages/examples/src/examples/cynefin.ts @@ -0,0 +1,37 @@ +import type { DiagramMetadata } from '../types.js'; + +export default { + id: 'cynefin', + name: 'Cynefin Framework', + description: 'Decision-making framework for categorizing problems by complexity', + examples: [ + { + title: 'Incident Response', + isDefault: true, + code: `cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" + + complex --> complicated : "Pattern identified" + clear --> chaotic : "Complacency" +`, + }, + ], +} satisfies DiagramMetadata; diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index 273fadbf872..a777c1a3f24 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -25,6 +25,7 @@ import treemapDiagram from './examples/treemap.js'; import vennDiagram from './examples/venn.js'; import treeViewDiagram from './examples/tree-view.js'; import wardleyDiagram from './examples/wardley.js'; +import cynefinDiagram from './examples/cynefin.js'; export const diagramData: DiagramMetadata[] = [ flowChart, @@ -53,4 +54,5 @@ export const diagramData: DiagramMetadata[] = [ vennDiagram, treeViewDiagram, wardleyDiagram, + cynefinDiagram, ]; diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index a70a968a42a..55a7907a1a4 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -233,6 +233,7 @@ export interface MermaidConfig { radar?: RadarDiagramConfig; venn?: VennDiagramConfig; 'wardley-beta'?: WardleyDiagramConfig; + cynefin?: CynefinDiagramConfig; dompurifyConfig?: DOMPurifyConfiguration; wrap?: boolean; fontSize?: number; @@ -1760,6 +1761,34 @@ export interface WardleyDiagramConfig extends BaseDiagramConfig { */ showGrid?: boolean; } +/** + * Configuration for Cynefin framework diagrams. + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "CynefinDiagramConfig". + */ +export interface CynefinDiagramConfig extends BaseDiagramConfig { + /** + * The width of the Cynefin diagram. + */ + width?: number; + /** + * The height of the Cynefin diagram. + */ + height?: number; + /** + * Padding around the diagram. + */ + padding?: number; + /** + * Show decision model and practice type labels. + */ + showDomainDescriptions?: boolean; + /** + * Waviness amplitude of domain boundaries (0 for straight). + */ + boundaryAmplitude?: number; +} /** * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "FontConfig". diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 659081921e3..9a47e55d357 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -286,6 +286,9 @@ const config: RequiredDeep = { venn: { ...defaultConfigJson.venn, }, + cynefin: { + ...defaultConfigJson.cynefin, + }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index b2fa007b191..33342833213 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -32,6 +32,7 @@ import { registerLazyLoadedDiagrams } from './detectType.js'; import { registerDiagram } from './diagramAPI.js'; import { treemap } from '../diagrams/treemap/detector.js'; import wardley from '../diagrams/wardley/wardleyDetector.js'; +import { cynefin } from '../diagrams/cynefin/cynefinDetector.js'; import '../type.d.ts'; let hasLoadedDiagrams = false; @@ -109,6 +110,7 @@ export const addDiagrams = () => { ishikawa, treemap, venn, - wardley + wardley, + cynefin ); }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts b/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts new file mode 100644 index 00000000000..9f11fea5f9d --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts @@ -0,0 +1,139 @@ +/** + * Deterministic pseudo-random number generator (mulberry32). + * Returns a function that produces values in [0, 1). + */ +export function seededRandom(seed: number): number { + let t = (seed + 0x6d2b79f5) | 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +/** + * Simple string hash for seeding the PRNG. + */ +export function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + hash = (hash << 5) - hash + ch; + hash |= 0; + } + return hash; +} + +/** + * Generate a vertical wavy line (the "fold") through the center of the diagram. + * Uses cubic bezier segments with alternating control point offsets. + * @param width - diagram width + * @param height - diagram height + * @param seed - deterministic seed for variation + * @returns SVG path d attribute string + */ +export function generateFoldPath(width: number, height: number, seed: number): string { + const cx = width / 2; + const amplitude = width * 0.015; + const segments = 7; + const segHeight = height / segments; + const points: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const jitter = seededRandom(seed + i * 17) * amplitude * 2 - amplitude; + points.push({ x: cx + jitter, y: i * segHeight }); + } + + let d = `M${points[0].x},${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + const midY = (p0.y + p1.y) / 2; + const dir = i % 2 === 0 ? 1 : -1; + const offset = amplitude * 1.5 * dir * seededRandom(seed + i * 31 + 7); + const cp1x = p0.x + offset; + const cp1y = midY; + const cp2x = p1.x - offset; + const cp2y = midY; + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p1.x},${p1.y}`; + } + + return d; +} + +/** + * Generate a horizontal wavy line through the center of the diagram. + * @param width - diagram width + * @param height - diagram height + * @param seed - deterministic seed for variation + * @returns SVG path d attribute string + */ +export function generateHorizontalBoundary(width: number, height: number, seed: number): string { + const cy = height / 2; + const amplitude = height * 0.015; + const segments = 7; + const segWidth = width / segments; + const points: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const jitter = seededRandom(seed + i * 23) * amplitude * 2 - amplitude; + points.push({ x: i * segWidth, y: cy + jitter }); + } + + let d = `M${points[0].x},${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + const midX = (p0.x + p1.x) / 2; + const dir = i % 2 === 0 ? 1 : -1; + const offset = amplitude * 1.5 * dir * seededRandom(seed + i * 37 + 11); + const cp1x = midX; + const cp1y = p0.y + offset; + const cp2x = midX; + const cp2y = p1.y - offset; + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p1.x},${p1.y}`; + } + + return d; +} + +/** + * Generate the "cliff" path between Clear (bottom-right) and Chaotic (bottom-left). + * This is a thicker, more abrupt boundary near the bottom center. + * @param width - diagram width + * @param height - diagram height + * @returns SVG path d attribute string + */ +export function generateCliffPath(width: number, height: number): string { + const cx = width / 2; + const topY = height * 0.5; + const bottomY = height; + const amplitude = width * 0.03; + + // A steep S-curve from the center downward + return [ + `M${cx},${topY}`, + `C${cx + amplitude},${topY + (bottomY - topY) * 0.2}`, + `${cx - amplitude * 1.5},${topY + (bottomY - topY) * 0.55}`, + `${cx + amplitude * 0.5},${topY + (bottomY - topY) * 0.75}`, + `C${cx - amplitude},${topY + (bottomY - topY) * 0.85}`, + `${cx + amplitude * 0.3},${topY + (bottomY - topY) * 0.95}`, + `${cx},${bottomY}`, + ].join(' '); +} + +/** + * Generate an ellipse SVG path for the confusion/disorder region at the center. + * @param cx - center x + * @param cy - center y + * @param rx - horizontal radius + * @param ry - vertical radius + * @returns SVG path d attribute string for an ellipse + */ +export function generateConfusionPath(cx: number, cy: number, rx: number, ry: number): string { + // Draw an ellipse using two arc commands + return [ + `M${cx - rx},${cy}`, + `A${rx},${ry} 0 1,1 ${cx + rx},${cy}`, + `A${rx},${ry} 0 1,1 ${cx - rx},${cy}`, + 'Z', + ].join(' '); +} diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts new file mode 100644 index 00000000000..473281e8cc8 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts @@ -0,0 +1,85 @@ +import { getConfig as commonGetConfig } from '../../config.js'; +import DEFAULT_CONFIG from '../../defaultConfig.js'; +import { cleanAndMerge } from '../../utils.js'; +import { + clear as commonClear, + getAccDescription, + getAccTitle, + getDiagramTitle, + setAccDescription, + setAccTitle, + setDiagramTitle, +} from '../common/commonDb.js'; +import type { CynefinDB, CynefinDomain, CynefinTransition, DomainName } from './types.js'; + +interface CynefinData { + domains: Map; + transitions: CynefinTransition[]; +} + +const createDefaultData = (): CynefinData => ({ + domains: new Map(), + transitions: [], +}); + +let data: CynefinData = createDefaultData(); + +const getDomains = (): Map => data.domains; + +const getTransitions = (): CynefinTransition[] => data.transitions; + +const setDomains = (blocks: any[]) => { + if (!blocks) { + return; + } + for (const block of blocks) { + const domainName = block.domain as DomainName; + const items = (block.items ?? []).map((item: { label: string }) => ({ + label: item.label, + })); + data.domains.set(domainName, { + name: domainName, + items, + }); + } +}; + +const setTransitions = (transitions: any[]) => { + if (!transitions) { + return; + } + data.transitions = transitions.map((t) => ({ + from: t.from as DomainName, + to: t.to as DomainName, + label: t.label, + })); +}; + +const getConfig = (): Record => { + const defaultCynefinConfig = (DEFAULT_CONFIG as any).cynefin ?? {}; + const currentConfig = (commonGetConfig() as any).cynefin ?? {}; + return cleanAndMerge({ + ...defaultCynefinConfig, + ...currentConfig, + }); +}; + +const clear = () => { + commonClear(); + data = createDefaultData(); +}; + +export const db: CynefinDB = { + getDomains, + getTransitions, + setDomains, + setTransitions, + getConfig, + clear, + setAccTitle, + getAccTitle, + setDiagramTitle, + getDiagramTitle, + getAccDescription, + setAccDescription, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts new file mode 100644 index 00000000000..b621cce3f48 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts @@ -0,0 +1,22 @@ +import type { + ExternalDiagramDefinition, + DiagramDetector, + DiagramLoader, +} from '../../diagram-api/types.js'; + +const id = 'cynefin'; + +const detector: DiagramDetector = (txt) => { + return /^\s*cynefin-beta/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./cynefinDiagram.js'); + return { id, diagram }; +}; + +export const cynefin: ExternalDiagramDefinition = { + id, + detector, + loader, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts new file mode 100644 index 00000000000..c6529f1361a --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts @@ -0,0 +1,12 @@ +import type { DiagramDefinition } from '../../diagram-api/types.js'; +import { parser } from './cynefinParser.js'; +import { db } from './cynefinDb.js'; +import { renderer } from './cynefinRenderer.js'; +import styles from './styles.js'; + +export const diagram: DiagramDefinition = { + parser, + db, + renderer, + styles, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinParser.ts b/packages/mermaid/src/diagrams/cynefin/cynefinParser.ts new file mode 100644 index 00000000000..61c010ed3dd --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinParser.ts @@ -0,0 +1,20 @@ +import type { Cynefin } from '@mermaid-js/parser'; +import { parse } from '@mermaid-js/parser'; +import type { ParserDefinition } from '../../diagram-api/types.js'; +import { log } from '../../logger.js'; +import { populateCommonDb } from '../common/populateCommonDb.js'; +import { db } from './cynefinDb.js'; + +const populate = (ast: Cynefin) => { + populateCommonDb(ast, db); + db.setDomains(ast.domains); + db.setTransitions(ast.transitions); +}; + +export const parser: ParserDefinition = { + parse: async (input: string): Promise => { + const ast: Cynefin = await parse('cynefin', input); + log.debug(ast); + populate(ast); + }, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts new file mode 100644 index 00000000000..8841dd447ab --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts @@ -0,0 +1,326 @@ +import type { Diagram } from '../../Diagram.js'; +import type { DiagramRenderer, DrawDefinition, SVG } from '../../diagram-api/types.js'; +import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import { configureSvgSize } from '../../setupGraphViewbox.js'; +import { log } from '../../logger.js'; +import type { CynefinDB, CynefinDomain, CynefinTransition, DomainName } from './types.js'; +import { + generateFoldPath, + generateHorizontalBoundary, + generateCliffPath, + generateConfusionPath, + hashString, +} from './cynefinBoundaries.js'; + +interface DomainMeta { + model: string; + practice: string; +} + +const DOMAIN_META: Record = { + complex: { model: 'Probe \u2192 Sense \u2192 Respond', practice: 'Emergent Practices' }, + complicated: { model: 'Sense \u2192 Analyse \u2192 Respond', practice: 'Good Practices' }, + clear: { model: 'Sense \u2192 Categorise \u2192 Respond', practice: 'Best Practices' }, + chaotic: { model: 'Act \u2192 Sense \u2192 Respond', practice: 'Novel Practices' }, + confusion: { model: '', practice: 'Disorder' }, +}; + +interface DomainLayout { + cx: number; + cy: number; + x: number; + y: number; + w: number; + h: number; +} + +const getDomainLayouts = (width: number, height: number): Record => { + const hw = width / 2; + const hh = height / 2; + return { + complex: { cx: hw / 2, cy: hh / 2, x: 0, y: 0, w: hw, h: hh }, + complicated: { cx: hw + hw / 2, cy: hh / 2, x: hw, y: 0, w: hw, h: hh }, + chaotic: { cx: hw / 2, cy: hh + hh / 2, x: 0, y: hh, w: hw, h: hh }, + clear: { cx: hw + hw / 2, cy: hh + hh / 2, x: hw, y: hh, w: hw, h: hh }, + confusion: { cx: hw, cy: hh, x: hw * 0.7, y: hh * 0.7, w: hw * 0.6, h: hh * 0.6 }, + }; +}; + +const DOMAIN_COLORS: Record = { + complex: '#6a9fb5', + complicated: '#8ab87a', + clear: '#c4a35a', + chaotic: '#b56a6a', + confusion: '#9b8ec4', +}; + +const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { + const db = diagram.db as CynefinDB; + const domains = db.getDomains(); + const transitions = db.getTransitions(); + const title = db.getDiagramTitle(); + const accTitle = db.getAccTitle(); + const accDescription = db.getAccDescription(); + + log.debug('Rendering Cynefin diagram'); + + const width = 800; + const height = 600; + const padding = 40; + const totalWidth = width + padding * 2; + const totalHeight = height + padding * 2; + + const svg: SVG = selectSvgElement(id); + + configureSvgSize(svg, totalHeight, totalWidth, true); + svg.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`); + + // Accessibility + if (accTitle) { + svg.attr('aria-label', accTitle); + } + if (accDescription) { + svg.append('desc').text(accDescription); + } + + const root = svg.append('g').attr('transform', `translate(${padding}, ${padding})`); + + const layouts = getDomainLayouts(width, height); + const seed = hashString(id); + + // 1. Domain background rectangles + const bgGroup = root.append('g').attr('class', 'cynefin-backgrounds'); + const quadrantDomains: DomainName[] = ['complex', 'complicated', 'chaotic', 'clear']; + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + bgGroup + .append('rect') + .attr('class', 'cynefinDomain') + .attr('x', layout.x) + .attr('y', layout.y) + .attr('width', layout.w) + .attr('height', layout.h) + .attr('fill', DOMAIN_COLORS[domainName]) + .attr('fill-opacity', 0.3) + .attr('stroke', 'none'); + } + + // 2. Wavy boundaries + const boundaryGroup = root.append('g').attr('class', 'cynefin-boundaries'); + + // Vertical fold + boundaryGroup + .append('path') + .attr('class', 'cynefinBoundary') + .attr('d', generateFoldPath(width, height, seed)) + .attr('fill', 'none'); + + // Horizontal boundary + boundaryGroup + .append('path') + .attr('class', 'cynefinBoundary') + .attr('d', generateHorizontalBoundary(width, height, seed + 100)) + .attr('fill', 'none'); + + // 3. The cliff (thicker, between Clear and Chaotic at bottom-center) + boundaryGroup + .append('path') + .attr('class', 'cynefinCliff') + .attr('d', generateCliffPath(width, height)) + .attr('fill', 'none'); + + // 4. Confusion ellipse (center overlay) + const confusionRx = width * 0.1; + const confusionRy = height * 0.1; + root + .append('path') + .attr('class', 'cynefinConfusion') + .attr('d', generateConfusionPath(width / 2, height / 2, confusionRx, confusionRy)) + .attr('fill', DOMAIN_COLORS.confusion) + .attr('fill-opacity', 0.35) + .attr('stroke', DOMAIN_COLORS.confusion) + .attr('stroke-width', 1.5); + + // 5. Domain name labels + const labelGroup = root.append('g').attr('class', 'cynefin-labels'); + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + labelGroup + .append('text') + .attr('class', 'cynefinDomainLabel') + .attr('x', layout.cx) + .attr('y', layout.cy - 30) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(domainName.charAt(0).toUpperCase() + domainName.slice(1)); + } + + // Confusion label + labelGroup + .append('text') + .attr('class', 'cynefinDomainLabel') + .attr('x', width / 2) + .attr('y', height / 2 - 10) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text('Confusion'); + + // 6. Domain description subtitles (decision model + practice type) + const subtitleGroup = root.append('g').attr('class', 'cynefin-subtitles'); + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + const meta = DOMAIN_META[domainName]; + + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', layout.cx) + .attr('y', layout.cy - 10) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(meta.model); + + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', layout.cx) + .attr('y', layout.cy + 5) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(meta.practice); + } + + // Confusion subtitle + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', width / 2) + .attr('y', height / 2 + 8) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(DOMAIN_META.confusion.practice); + + // 7. Items as text badges within each domain + const itemGroup = root.append('g').attr('class', 'cynefin-items'); + const itemHeight = 26; + const itemPaddingX = 10; + + const allDomains: DomainName[] = ['complex', 'complicated', 'chaotic', 'clear', 'confusion']; + for (const domainName of allDomains) { + const domain: CynefinDomain | undefined = domains.get(domainName); + if (!domain || domain.items.length === 0) { + continue; + } + + const layout = layouts[domainName]; + const startY = layout.cy + 25; + + domain.items.forEach((item, idx) => { + const itemY = startY + idx * (itemHeight + 4); + const textLen = item.label.length * 7 + itemPaddingX * 2; + const itemX = layout.cx - textLen / 2; + + const g = itemGroup.append('g').attr('transform', `translate(${itemX}, ${itemY})`); + + g.append('rect') + .attr('class', 'cynefinItem') + .attr('x', 0) + .attr('y', 0) + .attr('width', textLen) + .attr('height', itemHeight) + .attr('rx', 4) + .attr('ry', 4); + + g.append('text') + .attr('class', 'cynefinItem') + .attr('x', textLen / 2) + .attr('y', itemHeight / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .text(item.label); + }); + } + + // 8. Transition arrows between domain centers + if (transitions.length > 0) { + const defs = svg.select('defs').empty() ? svg.append('defs') : svg.select('defs'); + const markerId = `cynefin-arrow-${id}`; + + defs + .append('marker') + .attr('id', markerId) + .attr('viewBox', '0 0 10 10') + .attr('refX', 9) + .attr('refY', 5) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto-start-reverse') + .append('path') + .attr('d', 'M 0 0 L 10 5 L 0 10 z') + .attr('class', 'cynefinArrow'); + + const arrowGroup = root.append('g').attr('class', 'cynefin-arrows'); + + transitions.forEach((transition: CynefinTransition) => { + const fromLayout = layouts[transition.from]; + const toLayout = layouts[transition.to]; + if (!fromLayout || !toLayout) { + return; + } + + const x1 = fromLayout.cx + padding; + const y1 = fromLayout.cy + padding; + const x2 = toLayout.cx + padding; + const y2 = toLayout.cy + padding; + + // Quadratic bezier with perpendicular offset + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy); + const offsetAmount = len * 0.15; + // Perpendicular offset + const nx = -dy / len; + const ny = dx / len; + const cpx = mx + nx * offsetAmount - padding; + const cpy = my + ny * offsetAmount - padding; + + arrowGroup + .append('path') + .attr('class', 'cynefinArrow') + .attr( + 'd', + `M${fromLayout.cx},${fromLayout.cy} Q${cpx},${cpy} ${toLayout.cx},${toLayout.cy}` + ) + .attr('fill', 'none') + .attr('marker-end', `url(#${markerId})`); + + // Optional label + if (transition.label) { + arrowGroup + .append('text') + .attr('class', 'cynefinArrowLabel') + .attr('x', cpx) + .attr('y', cpy - 6) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'auto') + .text(transition.label); + } + }); + } + + // Title + if (title) { + root + .append('text') + .attr('class', 'cynefinDomainLabel') + .attr('x', width / 2) + .attr('y', -padding / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(title); + } +}; + +export const renderer: DiagramRenderer = { draw }; diff --git a/packages/mermaid/src/diagrams/cynefin/styles.ts b/packages/mermaid/src/diagrams/cynefin/styles.ts new file mode 100644 index 00000000000..24282d413a2 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/styles.ts @@ -0,0 +1,104 @@ +import type { DiagramStylesProvider } from '../../diagram-api/types.js'; +import { cleanAndMerge } from '../../utils.js'; +import { getThemeVariables } from '../../themes/theme-default.js'; +import { getConfig as getConfigAPI } from '../../config.js'; + +export interface CynefinStyleOptions { + domainOpacity?: number; + boundaryColor?: string; + boundaryStrokeWidth?: number; + cliffColor?: string; + cliffStrokeWidth?: number; + confusionOpacity?: number; + labelFontSize?: number; + subtitleFontSize?: number; + itemFontSize?: number; + itemFill?: string; + itemStroke?: string; + arrowColor?: string; + arrowStrokeWidth?: number; + arrowLabelFontSize?: number; +} + +export const buildCynefinStyleOptions = (cynefin?: CynefinStyleOptions) => { + const defaultThemeVariables = getThemeVariables(); + const currentConfig = getConfigAPI(); + + const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); + const cynefinOptions: CynefinStyleOptions = cleanAndMerge( + { + domainOpacity: 0.3, + boundaryColor: themeVariables.lineColor ?? '#333', + boundaryStrokeWidth: 1.5, + cliffColor: themeVariables.lineColor ?? '#222', + cliffStrokeWidth: 3, + confusionOpacity: 0.35, + labelFontSize: 18, + subtitleFontSize: 11, + itemFontSize: 12, + itemFill: themeVariables.mainBkg ?? '#fff', + itemStroke: themeVariables.border1 ?? '#ccc', + arrowColor: themeVariables.lineColor ?? '#555', + arrowStrokeWidth: 2, + arrowLabelFontSize: 11, + } as CynefinStyleOptions, + cynefin + ); + + return { themeVariables, cynefinOptions }; +}; + +export const styles: DiagramStylesProvider = ({ + cynefin, +}: { cynefin?: CynefinStyleOptions } = {}) => { + const { themeVariables, cynefinOptions } = buildCynefinStyleOptions(cynefin); + return ` + .cynefinDomain { + stroke: none; + } + .cynefinDomainLabel { + font-size: ${cynefinOptions.labelFontSize}px; + font-weight: bold; + fill: ${themeVariables.primaryTextColor ?? '#222'}; + color: ${themeVariables.primaryTextColor ?? '#222'}; + } + .cynefinSubtitle { + font-size: ${cynefinOptions.subtitleFontSize}px; + fill: ${themeVariables.secondaryTextColor ?? '#555'}; + color: ${themeVariables.secondaryTextColor ?? '#555'}; + font-style: italic; + } + .cynefinItem rect { + fill: ${cynefinOptions.itemFill}; + stroke: ${cynefinOptions.itemStroke}; + stroke-width: 1; + } + .cynefinItem text { + font-size: ${cynefinOptions.itemFontSize}px; + fill: ${themeVariables.primaryTextColor ?? '#222'}; + } + .cynefinBoundary { + stroke: ${cynefinOptions.boundaryColor}; + stroke-width: ${cynefinOptions.boundaryStrokeWidth}; + stroke-dasharray: 6 3; + } + .cynefinCliff { + stroke: ${cynefinOptions.cliffColor}; + stroke-width: ${cynefinOptions.cliffStrokeWidth}; + } + .cynefinConfusion { + stroke-dasharray: 4 2; + } + .cynefinArrow { + stroke: ${cynefinOptions.arrowColor}; + stroke-width: ${cynefinOptions.arrowStrokeWidth}; + fill: ${cynefinOptions.arrowColor}; + } + .cynefinArrowLabel { + font-size: ${cynefinOptions.arrowLabelFontSize}px; + fill: ${themeVariables.primaryTextColor ?? '#333'}; + } + `; +}; + +export default styles; diff --git a/packages/mermaid/src/diagrams/cynefin/types.ts b/packages/mermaid/src/diagrams/cynefin/types.ts new file mode 100644 index 00000000000..3290b6cf577 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/types.ts @@ -0,0 +1,31 @@ +export type DomainName = 'complex' | 'complicated' | 'clear' | 'chaotic' | 'confusion'; + +export interface CynefinItem { + label: string; +} + +export interface CynefinDomain { + name: DomainName; + items: CynefinItem[]; +} + +export interface CynefinTransition { + from: DomainName; + to: DomainName; + label?: string; +} + +export interface CynefinDB { + clear(): void; + getDiagramTitle(): string; + setDiagramTitle(title: string): void; + getAccTitle(): string; + setAccTitle(title: string): void; + getAccDescription(): string; + setAccDescription(description: string): void; + getDomains(): Map; + getTransitions(): CynefinTransition[]; + setDomains(blocks: any[]): void; + setTransitions(transitions: any[]): void; + getConfig(): Record; +} diff --git a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts index 53feb635e0c..68454023a8a 100644 --- a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts +++ b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts @@ -178,6 +178,17 @@ union A, B`, Campfire Kettle -> Kettle Electric Kettle -> Kettle Smart Kettle -> Kettle`, + + cynefin: `cynefin-beta + title Incident Response + complex + "Investigate root cause" + complicated + "Analyze metrics" + clear + "Restart service" + chaotic + "Page oncall"`, }; async function renderTwoAndCheckIds( diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index f171eef4d1e..d00ddfd4638 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -331,6 +331,8 @@ properties: $ref: '#/$defs/VennDiagramConfig' wardley-beta: $ref: '#/$defs/WardleyDiagramConfig' + cynefin: + $ref: '#/$defs/CynefinDiagramConfig' dompurifyConfig: title: DOM Purify Configuration description: Configuration options to pass to the `dompurify` library. @@ -2486,6 +2488,39 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) type: boolean default: false + CynefinDiagramConfig: + title: Cynefin Diagram Config + allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] + description: Configuration for Cynefin framework diagrams. + type: object + unevaluatedProperties: false + properties: + width: + description: The width of the Cynefin diagram. + type: number + minimum: 1 + default: 800 + height: + description: The height of the Cynefin diagram. + type: number + minimum: 1 + default: 600 + padding: + description: Padding around the diagram. + type: number + minimum: 0 + default: 40 + showDomainDescriptions: + description: Show decision model and practice type labels. + type: boolean + default: true + boundaryAmplitude: + description: Waviness amplitude of domain boundaries (0 for straight). + type: number + minimum: 0 + maximum: 50 + default: 8 + FontCalculator: title: Font Calculator description: | diff --git a/packages/mermaid/src/themes/theme-base.js b/packages/mermaid/src/themes/theme-base.js index 10f51499b1e..eb1874aaca2 100644 --- a/packages/mermaid/src/themes/theme-base.js +++ b/packages/mermaid/src/themes/theme-base.js @@ -249,6 +249,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B0000', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#E8F5E9', + complicatedBg: this.cynefin?.complicatedBg || '#E3F2FD', + chaoticBg: this.cynefin?.chaoticBg || '#FBE9E7', + clearBg: this.cynefin?.clearBg || '#FFF8E1', + confusionBg: this.cynefin?.confusionBg || '#F3E5F5', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* radar */ this.radar = { axisColor: this.radar?.axisColor || this.lineColor, diff --git a/packages/mermaid/src/themes/theme-dark.js b/packages/mermaid/src/themes/theme-dark.js index 436f5bc42f5..b489c628e2e 100644 --- a/packages/mermaid/src/themes/theme-dark.js +++ b/packages/mermaid/src/themes/theme-dark.js @@ -258,6 +258,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#FF6B6B', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#1B5E20', + complicatedBg: this.cynefin?.complicatedBg || '#0D47A1', + chaoticBg: this.cynefin?.chaoticBg || '#BF360C', + clearBg: this.cynefin?.clearBg || '#F57F17', + confusionBg: this.cynefin?.confusionBg || '#4A148C', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/mermaid/src/themes/theme-default.js b/packages/mermaid/src/themes/theme-default.js index b8573e0a675..22ed30f137d 100644 --- a/packages/mermaid/src/themes/theme-default.js +++ b/packages/mermaid/src/themes/theme-default.js @@ -291,6 +291,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B0000', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#E8F5E9', + complicatedBg: this.cynefin?.complicatedBg || '#E3F2FD', + chaoticBg: this.cynefin?.chaoticBg || '#FBE9E7', + clearBg: this.cynefin?.clearBg || '#FFF8E1', + confusionBg: this.cynefin?.confusionBg || '#F3E5F5', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/mermaid/src/themes/theme-forest.js b/packages/mermaid/src/themes/theme-forest.js index 322b6818280..cf560b5e94f 100644 --- a/packages/mermaid/src/themes/theme-forest.js +++ b/packages/mermaid/src/themes/theme-forest.js @@ -254,6 +254,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B4513', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#C8E6C9', + complicatedBg: this.cynefin?.complicatedBg || '#DCEDC8', + chaoticBg: this.cynefin?.chaoticBg || '#FFE0B2', + clearBg: this.cynefin?.clearBg || '#FFF9C4', + confusionBg: this.cynefin?.confusionBg || '#D7CCC8', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/mermaid/src/themes/theme-neutral.js b/packages/mermaid/src/themes/theme-neutral.js index a2814e9a355..1d088c6ccb0 100644 --- a/packages/mermaid/src/themes/theme-neutral.js +++ b/packages/mermaid/src/themes/theme-neutral.js @@ -279,6 +279,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B0000', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#E8F5E9', + complicatedBg: this.cynefin?.complicatedBg || '#E3F2FD', + chaoticBg: this.cynefin?.chaoticBg || '#FBE9E7', + clearBg: this.cynefin?.clearBg || '#FFF8E1', + confusionBg: this.cynefin?.confusionBg || '#F3E5F5', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json index abe4c96b665..4a36aaf239a 100644 --- a/packages/parser/langium-config.json +++ b/packages/parser/langium-config.json @@ -45,6 +45,11 @@ "id": "wardley", "grammar": "src/language/wardley/wardley.langium", "fileExtensions": [".mmd", ".mermaid"] + }, + { + "id": "cynefin", + "grammar": "src/language/cynefin/cynefin.langium", + "fileExtensions": [".mmd", ".mermaid"] } ], "mode": "production", diff --git a/packages/parser/src/language/cynefin/cynefin.langium b/packages/parser/src/language/cynefin/cynefin.langium new file mode 100644 index 00000000000..27b24cec504 --- /dev/null +++ b/packages/parser/src/language/cynefin/cynefin.langium @@ -0,0 +1,29 @@ +grammar CynefinGrammar +import "../common/common"; + +entry Cynefin: + NEWLINE* + ('cynefin-beta' | 'cynefin-beta:' | 'cynefin-beta' ':') + NEWLINE* + ( + TitleAndAccessibilities + | domains+=DomainBlock + | transitions+=Transition + | NEWLINE + )* +; + +DomainBlock: + domain=DOMAIN_NAME NEWLINE* + (items+=DomainItem NEWLINE*)* +; + +DomainItem: + label=STRING +; + +Transition: + from=DOMAIN_NAME '-->' to=DOMAIN_NAME (':' label=STRING)? EOL +; + +terminal DOMAIN_NAME returns string: 'complex' | 'complicated' | 'clear' | 'chaotic' | 'confusion'; diff --git a/packages/parser/src/language/cynefin/index.ts b/packages/parser/src/language/cynefin/index.ts new file mode 100644 index 00000000000..fd3c604b084 --- /dev/null +++ b/packages/parser/src/language/cynefin/index.ts @@ -0,0 +1 @@ +export * from './module.js'; diff --git a/packages/parser/src/language/cynefin/module.ts b/packages/parser/src/language/cynefin/module.ts new file mode 100644 index 00000000000..4f33d97022a --- /dev/null +++ b/packages/parser/src/language/cynefin/module.ts @@ -0,0 +1,55 @@ +import type { + DefaultSharedCoreModuleContext, + LangiumCoreServices, + LangiumSharedCoreServices, + Module, + PartialLangiumCoreServices, +} from 'langium'; +import { + EmptyFileSystem, + createDefaultCoreModule, + createDefaultSharedCoreModule, + inject, +} from 'langium'; +import { CommonValueConverter } from '../common/valueConverter.js'; +import { + MermaidGeneratedSharedModule, + CynefinGrammarGeneratedModule as CynefinGeneratedModule, +} from '../generated/module.js'; +import { CynefinTokenBuilder } from './tokenBuilder.js'; + +interface CynefinAddedServices { + parser: { + TokenBuilder: CynefinTokenBuilder; + ValueConverter: CommonValueConverter; + }; +} + +export type CynefinServices = LangiumCoreServices & CynefinAddedServices; + +export const CynefinModule: Module< + CynefinServices, + PartialLangiumCoreServices & CynefinAddedServices +> = { + parser: { + TokenBuilder: () => new CynefinTokenBuilder(), + ValueConverter: () => new CommonValueConverter(), + }, +}; + +export function createCynefinServices(context: DefaultSharedCoreModuleContext = EmptyFileSystem): { + shared: LangiumSharedCoreServices; + Cynefin: CynefinServices; +} { + const shared: LangiumSharedCoreServices = inject( + createDefaultSharedCoreModule(context), + MermaidGeneratedSharedModule + ); + const Cynefin: CynefinServices = inject( + createDefaultCoreModule({ shared }), + CynefinGeneratedModule, + CynefinModule + ); + shared.ServiceRegistry.register(Cynefin); + return { shared, Cynefin }; +} diff --git a/packages/parser/src/language/cynefin/tokenBuilder.ts b/packages/parser/src/language/cynefin/tokenBuilder.ts new file mode 100644 index 00000000000..cce9c374b4f --- /dev/null +++ b/packages/parser/src/language/cynefin/tokenBuilder.ts @@ -0,0 +1,7 @@ +import { AbstractMermaidTokenBuilder } from '../common/index.js'; + +export class CynefinTokenBuilder extends AbstractMermaidTokenBuilder { + public constructor() { + super(['cynefin-beta']); + } +} diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index 9e59b8d57fe..15b12311e53 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -10,6 +10,14 @@ export { Radar, Treemap, Wardley, + Cynefin, + DomainBlock, + DomainItem, + Transition, + isCynefin, + isDomainBlock, + isDomainItem, + isTransition, Branch, Commit, Merge, @@ -41,6 +49,7 @@ export { TreemapGrammarGeneratedModule as TreemapGeneratedModule, TreeViewGrammarGeneratedModule as TreeViewGeneratedModule, WardleyGrammarGeneratedModule as WardleyGeneratedModule, + CynefinGrammarGeneratedModule as CynefinGeneratedModule, } from './generated/module.js'; export * from './gitGraph/index.js'; @@ -53,3 +62,4 @@ export * from './architecture/index.js'; export * from './radar/index.js'; export * from './treemap/index.js'; export * from './wardley/index.js'; +export * from './cynefin/index.js'; diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 0d1b1402878..8e75fd7c196 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -10,9 +10,19 @@ import type { Treemap, TreeView, Wardley, + Cynefin, } from './index.js'; -export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | TreeView | Wardley; +export type DiagramAST = + | Info + | Packet + | Pie + | Architecture + | GitGraph + | Radar + | TreeView + | Wardley + | Cynefin; const parsers: Record = {}; const initializers = { @@ -61,6 +71,11 @@ const initializers = { const parser = createWardleyServices().Wardley.parser.LangiumParser; parsers.wardley = parser; }, + cynefin: async () => { + const { createCynefinServices } = await import('./language/cynefin/index.js'); + const parser = createCynefinServices().Cynefin.parser.LangiumParser; + parsers.cynefin = parser; + }, } as const; export async function parse(diagramType: 'info', text: string): Promise; @@ -72,6 +87,7 @@ export async function parse(diagramType: 'gitGraph', text: string): Promise; export async function parse(diagramType: 'treemap', text: string): Promise; export async function parse(diagramType: 'wardley', text: string): Promise; +export async function parse(diagramType: 'cynefin', text: string): Promise; export async function parse( diagramType: keyof typeof initializers, From 1c6ba873cd3dc9da1ecba46a609475d5a58158ad Mon Sep 17 00:00:00 2001 From: ragelink Date: Thu, 26 Mar 2026 20:08:49 -0600 Subject: [PATCH 2/9] fix(cynefin): fix item badge colors and arrow rendering --- .../src/diagrams/cynefin/cynefinRenderer.ts | 13 +- .../mermaid/src/diagrams/cynefin/styles.ts | 6 +- packages/mermaid/test-cynefin.html | 181 ++++++++++++++++++ 3 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 packages/mermaid/test-cynefin.html diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts index 8841dd447ab..c346d0c90d5 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts @@ -229,12 +229,16 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('width', textLen) .attr('height', itemHeight) .attr('rx', 4) - .attr('ry', 4); + .attr('ry', 4) + .attr('fill', '#ffffff') + .attr('stroke', '#cccccc') + .attr('stroke-width', 1) + .attr('fill-opacity', 0.9); g.append('text') - .attr('class', 'cynefinItem') .attr('x', textLen / 2) .attr('y', itemHeight / 2) + .attr('fill', '#333333') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') .text(item.label); @@ -257,7 +261,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('orient', 'auto-start-reverse') .append('path') .attr('d', 'M 0 0 L 10 5 L 0 10 z') - .attr('class', 'cynefinArrow'); + .attr('class', 'cynefinArrowHead'); const arrowGroup = root.append('g').attr('class', 'cynefin-arrows'); @@ -288,12 +292,11 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { arrowGroup .append('path') - .attr('class', 'cynefinArrow') + .attr('class', 'cynefinArrowLine') .attr( 'd', `M${fromLayout.cx},${fromLayout.cy} Q${cpx},${cpy} ${toLayout.cx},${toLayout.cy}` ) - .attr('fill', 'none') .attr('marker-end', `url(#${markerId})`); // Optional label diff --git a/packages/mermaid/src/diagrams/cynefin/styles.ts b/packages/mermaid/src/diagrams/cynefin/styles.ts index 24282d413a2..61b82f915d3 100644 --- a/packages/mermaid/src/diagrams/cynefin/styles.ts +++ b/packages/mermaid/src/diagrams/cynefin/styles.ts @@ -89,10 +89,14 @@ export const styles: DiagramStylesProvider = ({ .cynefinConfusion { stroke-dasharray: 4 2; } - .cynefinArrow { + .cynefinArrowLine { stroke: ${cynefinOptions.arrowColor}; stroke-width: ${cynefinOptions.arrowStrokeWidth}; + fill: none; + } + .cynefinArrowHead { fill: ${cynefinOptions.arrowColor}; + stroke: none; } .cynefinArrowLabel { font-size: ${cynefinOptions.arrowLabelFontSize}px; diff --git a/packages/mermaid/test-cynefin.html b/packages/mermaid/test-cynefin.html new file mode 100644 index 00000000000..99fd88485a0 --- /dev/null +++ b/packages/mermaid/test-cynefin.html @@ -0,0 +1,181 @@ + + + + + + Cynefin Framework - Test Suite + + + +
+

Cynefin Framework - Test Suite

+ + + +
+

1. Basic Domains with Items

+
+cynefin-beta
+  title Incident Response Framework
+
+  complex
+    "Investigate root cause"
+    "Run chaos experiment"
+    "Monitor emergent patterns"
+
+  complicated
+    "Analyze performance data"
+    "Expert review needed"
+
+  clear
+    "Restart service"
+    "Apply known fix"
+    "Follow runbook"
+
+  chaotic
+    "Page on-call immediately"
+    "Restore from backup"
+
+  confusion
+    "Unknown failure mode"
+        
+
+ + +
+

2. Domains with Transitions

+
+cynefin-beta
+  title Strategy Categorization
+
+  complex
+    "Market research"
+
+  complicated
+    "Competitive analysis"
+
+  clear
+    "Standard pricing"
+
+  chaotic
+    "Crisis management"
+
+  complex --> complicated : "Pattern identified"
+  complicated --> clear : "Best practice codified"
+  clear --> chaotic : "Complacency"
+  chaotic --> complex : "Stabilized"
+        
+
+ + +
+

3. Minimal (Empty Domains)

+
+cynefin-beta
+  title Empty Framework
+
+  complex
+  complicated
+  clear
+  chaotic
+        
+
+ + +
+

4. Many Items Per Domain

+
+cynefin-beta
+  title Software Delivery
+
+  complex
+    "New product discovery"
+    "User behavior analysis"
+    "A/B testing strategy"
+    "Platform architecture"
+
+  complicated
+    "Performance optimization"
+    "Security audit"
+    "Database migration"
+    "API design review"
+
+  clear
+    "Deploy to staging"
+    "Run test suite"
+    "Merge pull request"
+    "Update documentation"
+
+  chaotic
+    "Production outage"
+    "Data breach response"
+    "Vendor failure"
+        
+
+
+ + + + From a505437a696b936d908af69327b0bad1de1a9695 Mon Sep 17 00:00:00 2001 From: ragelink Date: Thu, 26 Mar 2026 20:21:43 -0600 Subject: [PATCH 3/9] test(cynefin): add unit and integration tests (32 tests) - 19 unit tests: DB operations, boundary path generators, PRNG determinism - 13 integration tests: full parse-to-db pipeline for domains, items, transitions, accessibility, complex diagrams, re-parse after clear --- .../cynefin/cynefin.integration.spec.ts | 245 ++++++++++++++++++ .../src/diagrams/cynefin/cynefin.spec.ts | 142 ++++++++++ 2 files changed, 387 insertions(+) create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts create mode 100644 packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts new file mode 100644 index 00000000000..c30f016dfb3 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { parser } from './cynefinParser.js'; +import { db } from './cynefinDb.js'; + +describe('Cynefin Parsing - Basic', () => { + beforeEach(() => db.clear()); + + it('should parse basic cynefin diagram with domains', async () => { + await parser.parse(`cynefin-beta + complex + "Emergent practice" + "Probe-sense-respond" + clear + "Best practice" +`); + const domains = db.getDomains(); + expect(domains.size).toBe(2); + expect(domains.get('complex')!.items[0].label).toBe('Emergent practice'); + expect(domains.get('complex')!.items[1].label).toBe('Probe-sense-respond'); + expect(domains.get('clear')!.items[0].label).toBe('Best practice'); + }); + + it('should parse all five domains', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + clear + "C" + chaotic + "D" + confusion + "E" +`); + const domains = db.getDomains(); + expect(domains.size).toBe(5); + expect(domains.has('complex')).toBe(true); + expect(domains.has('complicated')).toBe(true); + expect(domains.has('clear')).toBe(true); + expect(domains.has('chaotic')).toBe(true); + expect(domains.has('confusion')).toBe(true); + }); + + it('should parse empty domains', async () => { + await parser.parse(`cynefin-beta + complex +`); + const domains = db.getDomains(); + expect(domains.has('complex')).toBe(true); + expect(domains.get('complex')!.items).toHaveLength(0); + }); + + it('should parse domain with multiple items', async () => { + await parser.parse(`cynefin-beta + complex + "First item" + "Second item" + "Third item" +`); + const items = db.getDomains().get('complex')!.items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('First item'); + expect(items[1].label).toBe('Second item'); + expect(items[2].label).toBe('Third item'); + }); + + it('should parse comments', async () => { + await parser.parse(`cynefin-beta + %% This is a comment + complex + "Item" + %% Another comment +`); + const domains = db.getDomains(); + expect(domains.size).toBe(1); + expect(domains.get('complex')!.items[0].label).toBe('Item'); + }); +}); + +describe('Cynefin Parsing - Transitions', () => { + beforeEach(() => db.clear()); + + it('should parse transition between domains', async () => { + await parser.parse(`cynefin-beta + complex + "Practice" + complicated + "Analysis" + complex --> complicated : "Pattern found" +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBe('Pattern found'); + }); + + it('should parse transition without label', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + complex --> complicated +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBeUndefined(); + }); + + it('should parse multiple transitions', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + clear + "C" + complex --> complicated : "Analyzed" + complicated --> clear : "Codified" + chaotic --> complex : "Stabilized" +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(3); + expect(transitions[0].from).toBe('complex'); + expect(transitions[1].from).toBe('complicated'); + expect(transitions[2].from).toBe('chaotic'); + }); +}); + +describe('Cynefin Parsing - Accessibility', () => { + beforeEach(() => db.clear()); + + it('should parse accTitle', async () => { + await parser.parse(`cynefin-beta + accTitle: Cynefin Framework Overview + complex + "Emergent" +`); + expect(db.getAccTitle()).toBe('Cynefin Framework Overview'); + }); + + it('should parse accDescr', async () => { + await parser.parse(`cynefin-beta + accDescr: A diagram showing the five Cynefin domains + complex + "Emergent" +`); + expect(db.getAccDescription()).toBe('A diagram showing the five Cynefin domains'); + }); + + it('should parse title', async () => { + await parser.parse(`cynefin-beta + title My Cynefin Map + complex + "Emergent" +`); + expect(db.getDiagramTitle()).toBe('My Cynefin Map'); + }); +}); + +describe('Cynefin Parsing - Complex diagram', () => { + beforeEach(() => db.clear()); + + it('should parse a full diagram with all features', async () => { + await parser.parse(`cynefin-beta + title Team Practices + accTitle: Cynefin for team practices + complex + "Retrospectives" + "Pair programming" + complicated + "Code review" + "Architecture decisions" + clear + "Deployment checklist" + chaotic + "Incident response" + confusion + "New initiative" + complex --> complicated : "Pattern emerges" + complicated --> clear : "Best practice found" + chaotic --> complex : "Stabilized" + confusion --> chaotic : "Crisis detected" +`); + expect(db.getDiagramTitle()).toBe('Team Practices'); + expect(db.getAccTitle()).toBe('Cynefin for team practices'); + + const domains = db.getDomains(); + expect(domains.size).toBe(5); + expect(domains.get('complex')!.items).toHaveLength(2); + expect(domains.get('complicated')!.items).toHaveLength(2); + expect(domains.get('clear')!.items).toHaveLength(1); + expect(domains.get('chaotic')!.items).toHaveLength(1); + expect(domains.get('confusion')!.items).toHaveLength(1); + + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(4); + expect(transitions[0]).toMatchObject({ + from: 'complex', + to: 'complicated', + label: 'Pattern emerges', + }); + expect(transitions[3]).toMatchObject({ + from: 'confusion', + to: 'chaotic', + label: 'Crisis detected', + }); + }); +}); + +describe('Cynefin Parsing - Re-parse after clear', () => { + beforeEach(() => db.clear()); + + it('should have fresh state after clear and re-parse', async () => { + await parser.parse(`cynefin-beta + title First Diagram + complex + "Alpha" + "Beta" + complicated + "Gamma" + complex --> complicated : "Move" +`); + expect(db.getDomains().size).toBe(2); + expect(db.getTransitions()).toHaveLength(1); + expect(db.getDiagramTitle()).toBe('First Diagram'); + + db.clear(); + + await parser.parse(`cynefin-beta + title Second Diagram + chaotic + "Delta" +`); + expect(db.getDomains().size).toBe(1); + expect(db.getDomains().has('chaotic')).toBe(true); + expect(db.getDomains().has('complex')).toBe(false); + expect(db.getTransitions()).toHaveLength(0); + expect(db.getDiagramTitle()).toBe('Second Diagram'); + }); +}); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts new file mode 100644 index 00000000000..a46eb6a1ec0 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { db } from './cynefinDb.js'; +import { + seededRandom, + hashString, + generateFoldPath, + generateHorizontalBoundary, + generateCliffPath, + generateConfusionPath, +} from './cynefinBoundaries.js'; + +describe('Cynefin Database', () => { + beforeEach(() => db.clear()); + + it('should start empty', () => { + expect(db.getDomains().size).toBe(0); + expect(db.getTransitions().length).toBe(0); + }); + + it('should set domains from AST blocks', () => { + db.setDomains([ + { domain: 'complex', items: [{ label: 'Test' }] }, + { domain: 'clear', items: [] }, + ]); + expect(db.getDomains().size).toBe(2); + expect(db.getDomains().get('complex')!.items).toHaveLength(1); + expect(db.getDomains().get('clear')!.items).toHaveLength(0); + }); + + it('should set transitions', () => { + db.setTransitions([{ from: 'complex', to: 'complicated', label: 'Pattern found' }]); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBe('Pattern found'); + }); + + it('should set transitions without labels', () => { + db.setTransitions([{ from: 'chaotic', to: 'complex' }]); + expect(db.getTransitions()[0].label).toBeUndefined(); + }); + + it('should handle null/undefined blocks', () => { + expect(() => db.setDomains(null as any)).not.toThrow(); + expect(() => db.setTransitions(null as any)).not.toThrow(); + }); + + it('should clear all data', () => { + db.setDomains([{ domain: 'complex', items: [{ label: 'A' }] }]); + db.setTransitions([{ from: 'complex', to: 'chaotic' }]); + db.clear(); + expect(db.getDomains().size).toBe(0); + expect(db.getTransitions().length).toBe(0); + }); + + it('should handle multiple domains', () => { + db.setDomains([ + { domain: 'complex', items: [] }, + { domain: 'complicated', items: [] }, + { domain: 'clear', items: [] }, + { domain: 'chaotic', items: [] }, + { domain: 'confusion', items: [] }, + ]); + expect(db.getDomains().size).toBe(5); + }); + + it('should handle domain with multiple items', () => { + db.setDomains([ + { + domain: 'complex', + items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }, { label: 'D' }], + }, + ]); + expect(db.getDomains().get('complex')!.items).toHaveLength(4); + }); + + it('should return config', () => { + const config = db.getConfig(); + expect(typeof config).toBe('object'); + }); +}); + +describe('Cynefin Boundaries', () => { + it('seededRandom should return deterministic values', () => { + expect(seededRandom(42)).toBe(seededRandom(42)); + }); + + it('seededRandom should return values between 0 and 1', () => { + for (const seed of [0, 1, 100, 999999, -50]) { + const val = seededRandom(seed); + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThan(1); + } + }); + + it('hashString should return consistent hashes', () => { + expect(hashString('cynefin')).toBe(hashString('cynefin')); + }); + + it('hashString should return different hashes for different strings', () => { + expect(hashString('complex')).not.toBe(hashString('chaotic')); + }); + + it('generateFoldPath should return valid SVG path', () => { + const path = generateFoldPath(800, 600, 42); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); + + it('generateFoldPath should be deterministic', () => { + const a = generateFoldPath(800, 600, 42); + const b = generateFoldPath(800, 600, 42); + expect(a).toBe(b); + }); + + it('generateHorizontalBoundary should return valid SVG path', () => { + const path = generateHorizontalBoundary(800, 600, 42); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); + + it('generateCliffPath should return valid SVG path', () => { + const path = generateCliffPath(800, 600); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); + + it('generateConfusionPath should return valid ellipse path', () => { + const path = generateConfusionPath(400, 300, 50, 40); + expect(path).toMatch(/^M/); + expect(path).toContain('A'); + expect(path).toMatch(/Z$/); + }); + + it('generateConfusionPath should use provided center and radii', () => { + const path = generateConfusionPath(400, 300, 50, 40); + expect(path).toContain('350'); // cx - rx = 400 - 50 + expect(path).toContain('450'); // cx + rx = 400 + 50 + expect(path).toContain('300'); // cy + }); +}); From ea1c48f53fce5d025388d386c90da8743ee25b13 Mon Sep 17 00:00:00 2001 From: ragelink Date: Fri, 10 Apr 2026 16:31:45 -0600 Subject: [PATCH 4/9] fix(cynefin): address review feedback (#7535) Review items addressed: - Add changeset for the cynefin feature (minor bump) - Add Cypress e2e visual regression tests covering basic, transitions, empty domains, many items, config overrides, theme overrides, zero amplitude, and accessibility - Wire all config values into the renderer (width, height, padding, showDomainDescriptions, boundaryAmplitude); these now flow from the schema through db.getConfig() at render time - Wire theme variables into the renderer for all colors (domain backgrounds, boundaries, cliff, arrows, text); item badges now use per-domain colors instead of hardcoded values - Pass boundaryAmplitude to the boundary path generators so users can control or disable waviness - Replace any[] with proper Langium AST types (DomainBlock, Transition) in cynefinDb.ts and the unit test helpers - Type getConfig() return as Required - Add comprehensive syntax documentation at src/docs/syntax/cynefin.md with examples, config table, theme variable table, and accessibility notes - Add cynefin to the vitepress sidebar - Remove the dev-only test-cynefin.html file from the package root --- .changeset/add-cynefin-diagram.md | 24 ++ cypress/integration/rendering/cynefin.spec.js | 177 +++++++++++ docs/syntax/cynefin.md | 274 ++++++++++++++++++ .../src/diagrams/cynefin/cynefin.spec.ts | 41 +-- .../src/diagrams/cynefin/cynefinBoundaries.ts | 20 +- .../mermaid/src/diagrams/cynefin/cynefinDb.ts | 18 +- .../src/diagrams/cynefin/cynefinRenderer.ts | 176 +++++++---- .../mermaid/src/diagrams/cynefin/types.ts | 9 +- .../mermaid/src/docs/.vitepress/config.ts | 1 + packages/mermaid/src/docs/syntax/cynefin.md | 213 ++++++++++++++ packages/mermaid/test-cynefin.html | 181 ------------ 11 files changed, 863 insertions(+), 271 deletions(-) create mode 100644 .changeset/add-cynefin-diagram.md create mode 100644 cypress/integration/rendering/cynefin.spec.js create mode 100644 docs/syntax/cynefin.md create mode 100644 packages/mermaid/src/docs/syntax/cynefin.md delete mode 100644 packages/mermaid/test-cynefin.html diff --git a/.changeset/add-cynefin-diagram.md b/.changeset/add-cynefin-diagram.md new file mode 100644 index 00000000000..7a43bd1924f --- /dev/null +++ b/.changeset/add-cynefin-diagram.md @@ -0,0 +1,24 @@ +--- +'mermaid': minor +--- + +Add Cynefin framework diagram type (beta) + +Adds the Cynefin framework as a new diagram type to Mermaid (available as `cynefin-beta`). The Cynefin framework, created by Dave Snowden, is a decision-making framework that categorizes problems into five complexity domains, widely used in agile, incident management, strategy, and organizational design. + +Features: + +- Five fixed domains in canonical layout: Complex, Complicated, Clear, Chaotic, and Confusion +- Wavy organic boundaries between domains using deterministic SVG bezier curves +- The "cliff" between Clear and Chaotic (catastrophic transition indicator) +- Confusion/Disorder center ellipse overlay +- Domain metadata including decision models (Probe/Sense/Respond etc.) and practice types +- Items placed as text badges within each domain +- Transition arrows between domains with optional labels +- Full theme integration across all five mermaid themes +- Schema-driven configuration: width, height, padding, showDomainDescriptions, boundaryAmplitude +- Accessibility: ARIA roles, labels, descriptions + +This is the first text-based DSL for Cynefin diagrams in any diagramming tool. + +Implementation includes Langium grammar, modular renderer, unit tests, integration tests, Cypress e2e tests, and comprehensive documentation. diff --git a/cypress/integration/rendering/cynefin.spec.js b/cypress/integration/rendering/cynefin.spec.js new file mode 100644 index 00000000000..eb17f8656f3 --- /dev/null +++ b/cypress/integration/rendering/cynefin.spec.js @@ -0,0 +1,177 @@ +import { imgSnapshotTest } from '../../helpers/util'; + +describe('cynefin framework', () => { + it('should render a simple cynefin diagram with all five domains', () => { + imgSnapshotTest( + `cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" + ` + ); + }); + + it('should render a cynefin diagram with transitions', () => { + imgSnapshotTest( + `cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" + ` + ); + }); + + it('should render an empty cynefin framework', () => { + imgSnapshotTest( + `cynefin-beta + title Empty Framework + + complex + complicated + clear + chaotic + ` + ); + }); + + it('should render cynefin with many items per domain', () => { + imgSnapshotTest( + `cynefin-beta + title Software Delivery + + complex + "New product discovery" + "User behavior analysis" + "A/B testing strategy" + + complicated + "Performance optimization" + "Security audit" + "Database migration" + + clear + "Deploy to staging" + "Run test suite" + "Merge pull request" + + chaotic + "Production outage" + "Data breach response" + ` + ); + }); + + it('should render cynefin with config override', () => { + imgSnapshotTest( + `cynefin-beta + complex + "Adaptive work" + clear + "Standard work" + `, + { cynefin: { width: 1000, height: 700, boundaryAmplitude: 15 } } + ); + }); + + it('should render cynefin without domain descriptions', () => { + imgSnapshotTest( + `cynefin-beta + title Minimal Labels + + complex + "Item A" + clear + "Item B" + `, + { cynefin: { showDomainDescriptions: false } } + ); + }); + + it('should render cynefin with theme override', () => { + imgSnapshotTest( + `cynefin-beta + complex + "Test item" + clear + "Standard" + `, + { + theme: 'base', + themeVariables: { + cynefin: { + complexBg: '#FFE4B5', + clearBg: '#E6E6FA', + boundaryColor: '#FF0000', + }, + }, + } + ); + }); + + it('should render cynefin with straight boundaries when amplitude is zero', () => { + imgSnapshotTest( + `cynefin-beta + title Straight Boundaries + + complex + "Item A" + complicated + "Item B" + clear + "Item C" + chaotic + "Item D" + `, + { cynefin: { boundaryAmplitude: 0 } } + ); + }); + + it('should render cynefin with accessibility directives', () => { + imgSnapshotTest( + `cynefin-beta + accTitle: Cynefin framework for software delivery + accDescr: A Cynefin map categorizing software tasks by complexity domain + + complex + "Feature discovery" + complicated + "Refactoring" + clear + "Hotfix" + chaotic + "Incident" + ` + ); + }); +}); diff --git a/docs/syntax/cynefin.md b/docs/syntax/cynefin.md new file mode 100644 index 00000000000..0a2215aaeb0 --- /dev/null +++ b/docs/syntax/cynefin.md @@ -0,0 +1,274 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/syntax/cynefin.md](../../packages/mermaid/src/docs/syntax/cynefin.md). + +# Cynefin Framework Diagram (v\+) + +> The Cynefin framework is a sense-making framework created by [Dave Snowden](https://en.wikipedia.org/wiki/Dave_Snowden) that categorizes problems into five complexity domains. It helps teams match their response to the nature of the situation they are facing. The name is Welsh for "place" or "habitat". + +You can read more at [The Cynefin Co](https://thecynefin.co/about-us/about-cynefin-framework). + +## Introduction + +The Cynefin framework divides the world into five domains, each with its own decision-making approach: + +- **Clear** (formerly Obvious/Simple): Cause and effect are obvious. Sense → Categorise → Respond. Apply **best practices**. +- **Complicated**: Cause and effect require analysis or expertise. Sense → Analyse → Respond. Apply **good practices**. +- **Complex**: Cause and effect can only be deduced in retrospect. Probe → Sense → Respond. Apply **emergent practices**. +- **Chaotic**: No perceivable cause and effect. Act → Sense → Respond. Apply **novel practices**. +- **Confusion** (or Disorder): You do not know which domain you are in. The goal is to move items out of this state into one of the other four. + +The signature visual feature is the wavy, organic boundary between the ordered (Clear, Complicated) and unordered (Complex, Chaotic) halves, and the "cliff" between Clear and Chaotic representing the risk of complacency leading to crisis. + +## Syntax + +A Cynefin diagram is declared with `cynefin-beta` followed by domain blocks and optional transitions. + +```md +cynefin-beta +title Optional Diagram Title + +complex +"Item label" +"Another item" + +complicated +"Expert analysis needed" + +clear +"Known procedure" + +chaotic +"Crisis response" + +confusion +"Item of unknown domain" + +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +``` + +### Keywords + +| Keyword | Meaning | +| -------------- | ---------------------------------------------------------------------- | +| `cynefin-beta` | Diagram type declaration (required, first line after any front matter) | +| `title` | Optional diagram title | +| `complex` | Opens the Complex domain block | +| `complicated` | Opens the Complicated domain block | +| `clear` | Opens the Clear domain block | +| `chaotic` | Opens the Chaotic domain block | +| `confusion` | Opens the Confusion / Disorder domain block | +| `-->` | Declares a transition from one domain to another | + +### Items + +Items are quoted string labels placed on their own lines inside a domain block. Each item renders as a text badge within the domain region. + +``` +complex + "Investigate root cause" + "Run chaos experiment" +``` + +### Transitions + +Transitions represent movement of items between domains over time. They are declared at the top level using `-->` between two domain names, with an optional label. + +``` +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +chaotic --> complex : "Stabilized" +``` + +Common transitions: + +- **Complex → Complicated**: An emerging pattern has become understood and can now be analyzed. +- **Chaotic → Complex**: A crisis has been stabilized enough to begin probing. +- **Clear → Chaotic**: Complacency or over-constraint has led to collapse (the "cliff"). +- **Complicated → Clear**: Analysis has codified a solution into a standard practice. + +## Examples + +### Basic example + +```mermaid-example +cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" +``` + +```mermaid +cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" +``` + +### With transitions + +```mermaid-example +cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" +``` + +```mermaid +cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" +``` + +### Empty framework + +The domains themselves render even with no items, useful as a teaching or worksheet template. + +```mermaid-example +cynefin-beta + title Cynefin Framework + + complex + complicated + clear + chaotic +``` + +```mermaid +cynefin-beta + title Cynefin Framework + + complex + complicated + clear + chaotic +``` + +## Configuration + +Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: + +| Option | Type | Default | Description | +| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | +| `width` | number | `800` | Width of the diagram in pixels | +| `height` | number | `600` | Height of the diagram in pixels | +| `padding` | number | `40` | Padding around the diagram | +| `showDomainDescriptions` | boolean | `true` | Show decision model and practice type subtitles per domain | +| `boundaryAmplitude` | number | `8` | Waviness amplitude of domain boundaries in pixels (set to `0` for straight lines) | + +Example: + +``` +%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% +cynefin-beta + complex + "Adaptive work" +``` + +## Theming + +Cynefin diagrams use the following theme variables, which can be overridden via `themeVariables.cynefin`: + +| Variable | Description | +| ---------------- | ------------------------------------------------ | +| `complexBg` | Background color for the Complex domain | +| `complicatedBg` | Background color for the Complicated domain | +| `clearBg` | Background color for the Clear domain | +| `chaoticBg` | Background color for the Chaotic domain | +| `confusionBg` | Background color for the Confusion center region | +| `boundaryColor` | Color of the wavy domain boundaries | +| `boundaryWidth` | Stroke width of the boundaries | +| `cliffColor` | Color of the Clear/Chaotic cliff | +| `cliffWidth` | Stroke width of the cliff | +| `arrowColor` | Color of transition arrows | +| `arrowWidth` | Stroke width of transition arrows | +| `labelColor` | Color of domain name labels | +| `textColor` | Color of item and subtitle text | +| `domainFontSize` | Font size of domain name labels | +| `itemFontSize` | Font size of item badges and subtitles | + +## Notes + +- Domain names are fixed keywords. Only `complex`, `complicated`, `clear`, `chaotic`, and `confusion` are recognized. +- Domains can be declared in any order; their position in the diagram is always the same (Complex top-left, Complicated top-right, Chaotic bottom-left, Clear bottom-right, Confusion center). +- Handdrawn mode is not currently supported. +- The wavy boundary rendering is deterministic: the same input always produces the same diagram, so diffs are stable across builds. + +## Accessibility + +Cynefin diagrams support the standard mermaid accessibility directives: + +``` +cynefin-beta + accTitle: Cynefin framework for software delivery decisions + accDescr: A Cynefin map categorizing software tasks by complexity domain + + complex + "New feature discovery" +``` diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts index a46eb6a1ec0..9a0885dba3e 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import type { DomainBlock, Transition } from '@mermaid-js/parser'; import { db } from './cynefinDb.js'; import { seededRandom, @@ -9,6 +10,14 @@ import { generateConfusionPath, } from './cynefinBoundaries.js'; +/** Test helper: build a partial DomainBlock with just the fields the DB reads. */ +const block = (domain: string, items: { label: string }[] = []): DomainBlock => + ({ domain, items: items.map((i) => ({ label: i.label })) }) as unknown as DomainBlock; + +/** Test helper: build a partial Transition with just the fields the DB reads. */ +const tx = (from: string, to: string, label?: string): Transition => + ({ from, to, label: label ?? '' }) as unknown as Transition; + describe('Cynefin Database', () => { beforeEach(() => db.clear()); @@ -18,17 +27,14 @@ describe('Cynefin Database', () => { }); it('should set domains from AST blocks', () => { - db.setDomains([ - { domain: 'complex', items: [{ label: 'Test' }] }, - { domain: 'clear', items: [] }, - ]); + db.setDomains([block('complex', [{ label: 'Test' }]), block('clear')]); expect(db.getDomains().size).toBe(2); expect(db.getDomains().get('complex')!.items).toHaveLength(1); expect(db.getDomains().get('clear')!.items).toHaveLength(0); }); it('should set transitions', () => { - db.setTransitions([{ from: 'complex', to: 'complicated', label: 'Pattern found' }]); + db.setTransitions([tx('complex', 'complicated', 'Pattern found')]); const transitions = db.getTransitions(); expect(transitions).toHaveLength(1); expect(transitions[0].from).toBe('complex'); @@ -37,18 +43,18 @@ describe('Cynefin Database', () => { }); it('should set transitions without labels', () => { - db.setTransitions([{ from: 'chaotic', to: 'complex' }]); + db.setTransitions([tx('chaotic', 'complex')]); expect(db.getTransitions()[0].label).toBeUndefined(); }); it('should handle null/undefined blocks', () => { - expect(() => db.setDomains(null as any)).not.toThrow(); - expect(() => db.setTransitions(null as any)).not.toThrow(); + expect(() => db.setDomains(null as unknown as DomainBlock[])).not.toThrow(); + expect(() => db.setTransitions(null as unknown as Transition[])).not.toThrow(); }); it('should clear all data', () => { - db.setDomains([{ domain: 'complex', items: [{ label: 'A' }] }]); - db.setTransitions([{ from: 'complex', to: 'chaotic' }]); + db.setDomains([block('complex', [{ label: 'A' }])]); + db.setTransitions([tx('complex', 'chaotic')]); db.clear(); expect(db.getDomains().size).toBe(0); expect(db.getTransitions().length).toBe(0); @@ -56,21 +62,18 @@ describe('Cynefin Database', () => { it('should handle multiple domains', () => { db.setDomains([ - { domain: 'complex', items: [] }, - { domain: 'complicated', items: [] }, - { domain: 'clear', items: [] }, - { domain: 'chaotic', items: [] }, - { domain: 'confusion', items: [] }, + block('complex'), + block('complicated'), + block('clear'), + block('chaotic'), + block('confusion'), ]); expect(db.getDomains().size).toBe(5); }); it('should handle domain with multiple items', () => { db.setDomains([ - { - domain: 'complex', - items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }, { label: 'D' }], - }, + block('complex', [{ label: 'A' }, { label: 'B' }, { label: 'C' }, { label: 'D' }]), ]); expect(db.getDomains().get('complex')!.items).toHaveLength(4); }); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts b/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts index 9f11fea5f9d..eb7b77309b2 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts @@ -28,11 +28,17 @@ export function hashString(str: string): number { * @param width - diagram width * @param height - diagram height * @param seed - deterministic seed for variation + * @param amplitudeOverride - optional amplitude in pixels; falls back to 1.5% of width * @returns SVG path d attribute string */ -export function generateFoldPath(width: number, height: number, seed: number): string { +export function generateFoldPath( + width: number, + height: number, + seed: number, + amplitudeOverride?: number +): string { const cx = width / 2; - const amplitude = width * 0.015; + const amplitude = amplitudeOverride ?? width * 0.015; const segments = 7; const segHeight = height / segments; const points: { x: number; y: number }[] = []; @@ -64,11 +70,17 @@ export function generateFoldPath(width: number, height: number, seed: number): s * @param width - diagram width * @param height - diagram height * @param seed - deterministic seed for variation + * @param amplitudeOverride - optional amplitude in pixels; falls back to 1.5% of height * @returns SVG path d attribute string */ -export function generateHorizontalBoundary(width: number, height: number, seed: number): string { +export function generateHorizontalBoundary( + width: number, + height: number, + seed: number, + amplitudeOverride?: number +): string { const cy = height / 2; - const amplitude = height * 0.015; + const amplitude = amplitudeOverride ?? height * 0.015; const segments = 7; const segWidth = width / segments; const points: { x: number; y: number }[] = []; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts index 473281e8cc8..4785c6c7ad5 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts @@ -1,4 +1,6 @@ +import type { DomainBlock, Transition } from '@mermaid-js/parser'; import { getConfig as commonGetConfig } from '../../config.js'; +import type { CynefinDiagramConfig } from '../../config.type.js'; import DEFAULT_CONFIG from '../../defaultConfig.js'; import { cleanAndMerge } from '../../utils.js'; import { @@ -28,13 +30,13 @@ const getDomains = (): Map => data.domains; const getTransitions = (): CynefinTransition[] => data.transitions; -const setDomains = (blocks: any[]) => { +const setDomains = (blocks: DomainBlock[]) => { if (!blocks) { return; } for (const block of blocks) { const domainName = block.domain as DomainName; - const items = (block.items ?? []).map((item: { label: string }) => ({ + const items = (block.items ?? []).map((item) => ({ label: item.label, })); data.domains.set(domainName, { @@ -44,23 +46,21 @@ const setDomains = (blocks: any[]) => { } }; -const setTransitions = (transitions: any[]) => { +const setTransitions = (transitions: Transition[]) => { if (!transitions) { return; } data.transitions = transitions.map((t) => ({ from: t.from as DomainName, to: t.to as DomainName, - label: t.label, + label: t.label || undefined, })); }; -const getConfig = (): Record => { - const defaultCynefinConfig = (DEFAULT_CONFIG as any).cynefin ?? {}; - const currentConfig = (commonGetConfig() as any).cynefin ?? {}; +const getConfig = (): Required => { return cleanAndMerge({ - ...defaultCynefinConfig, - ...currentConfig, + ...DEFAULT_CONFIG.cynefin, + ...commonGetConfig().cynefin, }); }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts index c346d0c90d5..690d495fdbb 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts @@ -3,6 +3,9 @@ import type { DiagramRenderer, DrawDefinition, SVG } from '../../diagram-api/typ import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; import { configureSvgSize } from '../../setupGraphViewbox.js'; import { log } from '../../logger.js'; +import { getConfig as getConfigAPI } from '../../config.js'; +import { getThemeVariables } from '../../themes/theme-default.js'; +import { cleanAndMerge } from '../../utils.js'; import type { CynefinDB, CynefinDomain, CynefinTransition, DomainName } from './types.js'; import { generateFoldPath, @@ -46,12 +49,29 @@ const getDomainLayouts = (width: number, height: number): Record = { - complex: '#6a9fb5', - complicated: '#8ab87a', - clear: '#c4a35a', - chaotic: '#b56a6a', - confusion: '#9b8ec4', +/** Resolve cynefin theme variables from the mermaid theme system. */ +const getCynefinTheme = () => { + const defaultThemeVariables = getThemeVariables(); + const currentConfig = getConfigAPI(); + const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (themeVariables as any).cynefin as { + domainFontSize: number; + itemFontSize: number; + boundaryColor: string; + boundaryWidth: number; + cliffColor: string; + cliffWidth: number; + arrowColor: string; + arrowWidth: number; + complexBg: string; + complicatedBg: string; + chaoticBg: string; + clearBg: string; + confusionBg: string; + textColor: string; + labelColor: string; + }; }; const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { @@ -61,15 +81,27 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const title = db.getDiagramTitle(); const accTitle = db.getAccTitle(); const accDescription = db.getAccDescription(); + const config = db.getConfig(); + const theme = getCynefinTheme(); log.debug('Rendering Cynefin diagram'); - const width = 800; - const height = 600; - const padding = 40; + const width = config.width; + const height = config.height; + const padding = config.padding; + const showDomainDescriptions = config.showDomainDescriptions; + const boundaryAmplitude = config.boundaryAmplitude; const totalWidth = width + padding * 2; const totalHeight = height + padding * 2; + const domainBg: Record = { + complex: theme.complexBg, + complicated: theme.complicatedBg, + clear: theme.clearBg, + chaotic: theme.chaoticBg, + confusion: theme.confusionBg, + }; + const svg: SVG = selectSvgElement(id); configureSvgSize(svg, totalHeight, totalWidth, true); @@ -100,8 +132,8 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', layout.y) .attr('width', layout.w) .attr('height', layout.h) - .attr('fill', DOMAIN_COLORS[domainName]) - .attr('fill-opacity', 0.3) + .attr('fill', domainBg[domainName]) + .attr('fill-opacity', 0.4) .attr('stroke', 'none'); } @@ -112,22 +144,28 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { boundaryGroup .append('path') .attr('class', 'cynefinBoundary') - .attr('d', generateFoldPath(width, height, seed)) - .attr('fill', 'none'); + .attr('d', generateFoldPath(width, height, seed, boundaryAmplitude)) + .attr('fill', 'none') + .attr('stroke', theme.boundaryColor) + .attr('stroke-width', theme.boundaryWidth); // Horizontal boundary boundaryGroup .append('path') .attr('class', 'cynefinBoundary') - .attr('d', generateHorizontalBoundary(width, height, seed + 100)) - .attr('fill', 'none'); + .attr('d', generateHorizontalBoundary(width, height, seed + 100, boundaryAmplitude)) + .attr('fill', 'none') + .attr('stroke', theme.boundaryColor) + .attr('stroke-width', theme.boundaryWidth); // 3. The cliff (thicker, between Clear and Chaotic at bottom-center) boundaryGroup .append('path') .attr('class', 'cynefinCliff') .attr('d', generateCliffPath(width, height)) - .attr('fill', 'none'); + .attr('fill', 'none') + .attr('stroke', theme.cliffColor) + .attr('stroke-width', theme.cliffWidth); // 4. Confusion ellipse (center overlay) const confusionRx = width * 0.1; @@ -136,10 +174,11 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .append('path') .attr('class', 'cynefinConfusion') .attr('d', generateConfusionPath(width / 2, height / 2, confusionRx, confusionRy)) - .attr('fill', DOMAIN_COLORS.confusion) - .attr('fill-opacity', 0.35) - .attr('stroke', DOMAIN_COLORS.confusion) - .attr('stroke-width', 1.5); + .attr('fill', domainBg.confusion) + .attr('fill-opacity', 0.5) + .attr('stroke', theme.boundaryColor) + .attr('stroke-width', 1.5) + .attr('stroke-dasharray', '4 2'); // 5. Domain name labels const labelGroup = root.append('g').attr('class', 'cynefin-labels'); @@ -149,9 +188,12 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .append('text') .attr('class', 'cynefinDomainLabel') .attr('x', layout.cx) - .attr('y', layout.cy - 30) + .attr('y', showDomainDescriptions ? layout.cy - 30 : layout.cy) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') + .attr('font-size', theme.domainFontSize) + .attr('font-weight', 'bold') + .attr('fill', theme.labelColor) .text(domainName.charAt(0).toUpperCase() + domainName.slice(1)); } @@ -160,46 +202,60 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .append('text') .attr('class', 'cynefinDomainLabel') .attr('x', width / 2) - .attr('y', height / 2 - 10) + .attr('y', showDomainDescriptions ? height / 2 - 10 : height / 2) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') + .attr('font-size', theme.domainFontSize) + .attr('font-weight', 'bold') + .attr('fill', theme.labelColor) .text('Confusion'); // 6. Domain description subtitles (decision model + practice type) - const subtitleGroup = root.append('g').attr('class', 'cynefin-subtitles'); - for (const domainName of quadrantDomains) { - const layout = layouts[domainName]; - const meta = DOMAIN_META[domainName]; - - subtitleGroup - .append('text') - .attr('class', 'cynefinSubtitle') - .attr('x', layout.cx) - .attr('y', layout.cy - 10) - .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') - .text(meta.model); + if (showDomainDescriptions) { + const subtitleGroup = root.append('g').attr('class', 'cynefin-subtitles'); + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + const meta = DOMAIN_META[domainName]; + + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', layout.cx) + .attr('y', layout.cy - 10) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', theme.itemFontSize - 1) + .attr('font-style', 'italic') + .attr('fill', theme.textColor) + .text(meta.model); + + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', layout.cx) + .attr('y', layout.cy + 5) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', theme.itemFontSize - 1) + .attr('font-style', 'italic') + .attr('fill', theme.textColor) + .text(meta.practice); + } + // Confusion subtitle subtitleGroup .append('text') .attr('class', 'cynefinSubtitle') - .attr('x', layout.cx) - .attr('y', layout.cy + 5) + .attr('x', width / 2) + .attr('y', height / 2 + 8) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .text(meta.practice); + .attr('font-size', theme.itemFontSize - 1) + .attr('font-style', 'italic') + .attr('fill', theme.textColor) + .text(DOMAIN_META.confusion.practice); } - // Confusion subtitle - subtitleGroup - .append('text') - .attr('class', 'cynefinSubtitle') - .attr('x', width / 2) - .attr('y', height / 2 + 8) - .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') - .text(DOMAIN_META.confusion.practice); - // 7. Items as text badges within each domain const itemGroup = root.append('g').attr('class', 'cynefin-items'); const itemHeight = 26; @@ -213,7 +269,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { } const layout = layouts[domainName]; - const startY = layout.cy + 25; + const startY = layout.cy + (showDomainDescriptions ? 25 : 15); domain.items.forEach((item, idx) => { const itemY = startY + idx * (itemHeight + 4); @@ -230,15 +286,16 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('height', itemHeight) .attr('rx', 4) .attr('ry', 4) - .attr('fill', '#ffffff') - .attr('stroke', '#cccccc') + .attr('fill', domainBg[domainName]) + .attr('stroke', theme.boundaryColor) .attr('stroke-width', 1) - .attr('fill-opacity', 0.9); + .attr('fill-opacity', 0.95); g.append('text') .attr('x', textLen / 2) .attr('y', itemHeight / 2) - .attr('fill', '#333333') + .attr('fill', theme.textColor) + .attr('font-size', theme.itemFontSize) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') .text(item.label); @@ -261,7 +318,8 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('orient', 'auto-start-reverse') .append('path') .attr('d', 'M 0 0 L 10 5 L 0 10 z') - .attr('class', 'cynefinArrowHead'); + .attr('class', 'cynefinArrowHead') + .attr('fill', theme.arrowColor); const arrowGroup = root.append('g').attr('class', 'cynefin-arrows'); @@ -297,6 +355,9 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { 'd', `M${fromLayout.cx},${fromLayout.cy} Q${cpx},${cpy} ${toLayout.cx},${toLayout.cy}` ) + .attr('fill', 'none') + .attr('stroke', theme.arrowColor) + .attr('stroke-width', theme.arrowWidth) .attr('marker-end', `url(#${markerId})`); // Optional label @@ -308,6 +369,8 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', cpy - 6) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'auto') + .attr('font-size', theme.itemFontSize - 1) + .attr('fill', theme.textColor) .text(transition.label); } }); @@ -317,11 +380,14 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { if (title) { root .append('text') - .attr('class', 'cynefinDomainLabel') + .attr('class', 'cynefinTitle') .attr('x', width / 2) .attr('y', -padding / 2) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') + .attr('font-size', theme.domainFontSize + 2) + .attr('font-weight', 'bold') + .attr('fill', theme.labelColor) .text(title); } }; diff --git a/packages/mermaid/src/diagrams/cynefin/types.ts b/packages/mermaid/src/diagrams/cynefin/types.ts index 3290b6cf577..abeaf493209 100644 --- a/packages/mermaid/src/diagrams/cynefin/types.ts +++ b/packages/mermaid/src/diagrams/cynefin/types.ts @@ -1,3 +1,6 @@ +import type { DomainBlock, Transition } from '@mermaid-js/parser'; +import type { CynefinDiagramConfig } from '../../config.type.js'; + export type DomainName = 'complex' | 'complicated' | 'clear' | 'chaotic' | 'confusion'; export interface CynefinItem { @@ -25,7 +28,7 @@ export interface CynefinDB { setAccDescription(description: string): void; getDomains(): Map; getTransitions(): CynefinTransition[]; - setDomains(blocks: any[]): void; - setTransitions(transitions: any[]): void; - getConfig(): Record; + setDomains(blocks: DomainBlock[]): void; + setTransitions(transitions: Transition[]): void; + getConfig(): Required; } diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index 6c2846b6b6d..d33c662398d 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -193,6 +193,7 @@ function sidebarSyntax() { { text: 'Venn 🔥', link: '/syntax/venn' }, { text: 'Ishikawa 🔥', link: '/syntax/ishikawa' }, { text: 'Wardley 🔥', link: '/syntax/wardley' }, + { text: 'Cynefin 🔥', link: '/syntax/cynefin' }, { text: 'TreeView 🔥', link: '/syntax/treeView' }, { text: 'Other Examples', link: '/syntax/examples' }, ], diff --git a/packages/mermaid/src/docs/syntax/cynefin.md b/packages/mermaid/src/docs/syntax/cynefin.md new file mode 100644 index 00000000000..1ba7520116c --- /dev/null +++ b/packages/mermaid/src/docs/syntax/cynefin.md @@ -0,0 +1,213 @@ +# Cynefin Framework Diagram (v+) + +> The Cynefin framework is a sense-making framework created by [Dave Snowden](https://en.wikipedia.org/wiki/Dave_Snowden) that categorizes problems into five complexity domains. It helps teams match their response to the nature of the situation they are facing. The name is Welsh for "place" or "habitat". + +You can read more at [The Cynefin Co](https://thecynefin.co/about-us/about-cynefin-framework). + +## Introduction + +The Cynefin framework divides the world into five domains, each with its own decision-making approach: + +- **Clear** (formerly Obvious/Simple): Cause and effect are obvious. Sense → Categorise → Respond. Apply **best practices**. +- **Complicated**: Cause and effect require analysis or expertise. Sense → Analyse → Respond. Apply **good practices**. +- **Complex**: Cause and effect can only be deduced in retrospect. Probe → Sense → Respond. Apply **emergent practices**. +- **Chaotic**: No perceivable cause and effect. Act → Sense → Respond. Apply **novel practices**. +- **Confusion** (or Disorder): You do not know which domain you are in. The goal is to move items out of this state into one of the other four. + +The signature visual feature is the wavy, organic boundary between the ordered (Clear, Complicated) and unordered (Complex, Chaotic) halves, and the "cliff" between Clear and Chaotic representing the risk of complacency leading to crisis. + +## Syntax + +A Cynefin diagram is declared with `cynefin-beta` followed by domain blocks and optional transitions. + +```md +cynefin-beta +title Optional Diagram Title + +complex +"Item label" +"Another item" + +complicated +"Expert analysis needed" + +clear +"Known procedure" + +chaotic +"Crisis response" + +confusion +"Item of unknown domain" + +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +``` + +### Keywords + +| Keyword | Meaning | +| -------------- | ---------------------------------------------------------------------- | +| `cynefin-beta` | Diagram type declaration (required, first line after any front matter) | +| `title` | Optional diagram title | +| `complex` | Opens the Complex domain block | +| `complicated` | Opens the Complicated domain block | +| `clear` | Opens the Clear domain block | +| `chaotic` | Opens the Chaotic domain block | +| `confusion` | Opens the Confusion / Disorder domain block | +| `-->` | Declares a transition from one domain to another | + +### Items + +Items are quoted string labels placed on their own lines inside a domain block. Each item renders as a text badge within the domain region. + +``` +complex + "Investigate root cause" + "Run chaos experiment" +``` + +### Transitions + +Transitions represent movement of items between domains over time. They are declared at the top level using `-->` between two domain names, with an optional label. + +``` +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +chaotic --> complex : "Stabilized" +``` + +Common transitions: + +- **Complex → Complicated**: An emerging pattern has become understood and can now be analyzed. +- **Chaotic → Complex**: A crisis has been stabilized enough to begin probing. +- **Clear → Chaotic**: Complacency or over-constraint has led to collapse (the "cliff"). +- **Complicated → Clear**: Analysis has codified a solution into a standard practice. + +## Examples + +### Basic example + +```mermaid-example +cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" +``` + +### With transitions + +```mermaid-example +cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" +``` + +### Empty framework + +The domains themselves render even with no items, useful as a teaching or worksheet template. + +```mermaid-example +cynefin-beta + title Cynefin Framework + + complex + complicated + clear + chaotic +``` + +## Configuration + +Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: + +| Option | Type | Default | Description | +| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | +| `width` | number | `800` | Width of the diagram in pixels | +| `height` | number | `600` | Height of the diagram in pixels | +| `padding` | number | `40` | Padding around the diagram | +| `showDomainDescriptions` | boolean | `true` | Show decision model and practice type subtitles per domain | +| `boundaryAmplitude` | number | `8` | Waviness amplitude of domain boundaries in pixels (set to `0` for straight lines) | + +Example: + +``` +%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% +cynefin-beta + complex + "Adaptive work" +``` + +## Theming + +Cynefin diagrams use the following theme variables, which can be overridden via `themeVariables.cynefin`: + +| Variable | Description | +| ---------------- | ------------------------------------------------ | +| `complexBg` | Background color for the Complex domain | +| `complicatedBg` | Background color for the Complicated domain | +| `clearBg` | Background color for the Clear domain | +| `chaoticBg` | Background color for the Chaotic domain | +| `confusionBg` | Background color for the Confusion center region | +| `boundaryColor` | Color of the wavy domain boundaries | +| `boundaryWidth` | Stroke width of the boundaries | +| `cliffColor` | Color of the Clear/Chaotic cliff | +| `cliffWidth` | Stroke width of the cliff | +| `arrowColor` | Color of transition arrows | +| `arrowWidth` | Stroke width of transition arrows | +| `labelColor` | Color of domain name labels | +| `textColor` | Color of item and subtitle text | +| `domainFontSize` | Font size of domain name labels | +| `itemFontSize` | Font size of item badges and subtitles | + +## Notes + +- Domain names are fixed keywords. Only `complex`, `complicated`, `clear`, `chaotic`, and `confusion` are recognized. +- Domains can be declared in any order; their position in the diagram is always the same (Complex top-left, Complicated top-right, Chaotic bottom-left, Clear bottom-right, Confusion center). +- Handdrawn mode is not currently supported. +- The wavy boundary rendering is deterministic: the same input always produces the same diagram, so diffs are stable across builds. + +## Accessibility + +Cynefin diagrams support the standard mermaid accessibility directives: + +``` +cynefin-beta + accTitle: Cynefin framework for software delivery decisions + accDescr: A Cynefin map categorizing software tasks by complexity domain + + complex + "New feature discovery" +``` diff --git a/packages/mermaid/test-cynefin.html b/packages/mermaid/test-cynefin.html deleted file mode 100644 index 99fd88485a0..00000000000 --- a/packages/mermaid/test-cynefin.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - Cynefin Framework - Test Suite - - - -
-

Cynefin Framework - Test Suite

- - - -
-

1. Basic Domains with Items

-
-cynefin-beta
-  title Incident Response Framework
-
-  complex
-    "Investigate root cause"
-    "Run chaos experiment"
-    "Monitor emergent patterns"
-
-  complicated
-    "Analyze performance data"
-    "Expert review needed"
-
-  clear
-    "Restart service"
-    "Apply known fix"
-    "Follow runbook"
-
-  chaotic
-    "Page on-call immediately"
-    "Restore from backup"
-
-  confusion
-    "Unknown failure mode"
-        
-
- - -
-

2. Domains with Transitions

-
-cynefin-beta
-  title Strategy Categorization
-
-  complex
-    "Market research"
-
-  complicated
-    "Competitive analysis"
-
-  clear
-    "Standard pricing"
-
-  chaotic
-    "Crisis management"
-
-  complex --> complicated : "Pattern identified"
-  complicated --> clear : "Best practice codified"
-  clear --> chaotic : "Complacency"
-  chaotic --> complex : "Stabilized"
-        
-
- - -
-

3. Minimal (Empty Domains)

-
-cynefin-beta
-  title Empty Framework
-
-  complex
-  complicated
-  clear
-  chaotic
-        
-
- - -
-

4. Many Items Per Domain

-
-cynefin-beta
-  title Software Delivery
-
-  complex
-    "New product discovery"
-    "User behavior analysis"
-    "A/B testing strategy"
-    "Platform architecture"
-
-  complicated
-    "Performance optimization"
-    "Security audit"
-    "Database migration"
-    "API design review"
-
-  clear
-    "Deploy to staging"
-    "Run test suite"
-    "Merge pull request"
-    "Update documentation"
-
-  chaotic
-    "Production outage"
-    "Data breach response"
-    "Vendor failure"
-        
-
-
- - - - From 8a5dc9570f2cbc5bb29aad6f294d3a83b3c38b83 Mon Sep 17 00:00:00 2001 From: ragelink Date: Thu, 16 Apr 2026 06:52:10 -0600 Subject: [PATCH 5/9] fix(cynefin): address round 2 review feedback - Self-loop transitions now rejected in setTransitions() with log.warn; renderer has defensive guard - Badge width uses getBBox() for accurate measurement, falls back to char-count heuristic for SSR - Confusion domain radii increased to 0.15; items capped at MAX_CONFUSION_ITEMS=3 with +N more overflow badge - Renderer switched to CSS-classes-only model; styles.ts reads from cynefin theme block directly - Detector regex anchored with (?:[\s:]|$) to avoid false matches - Grammar third alternative removed, comment added - aria-label replaced with child element per W3C SVG recommendation - as any cast removed from getCynefinTheme --- cypress/integration/rendering/cynefin.spec.js | 31 +++ docs/syntax/cynefin.md | 2 + .../cynefin/cynefin.integration.spec.ts | 30 +++ .../src/diagrams/cynefin/cynefin.spec.ts | 17 ++ .../mermaid/src/diagrams/cynefin/cynefinDb.ts | 21 +- .../src/diagrams/cynefin/cynefinDetector.ts | 2 +- .../src/diagrams/cynefin/cynefinRenderer.ts | 226 +++++++++++------- .../mermaid/src/diagrams/cynefin/styles.ts | 117 ++++----- packages/mermaid/src/docs/syntax/cynefin.md | 2 + .../src/language/cynefin/cynefin.langium | 3 +- 10 files changed, 288 insertions(+), 163 deletions(-) diff --git a/cypress/integration/rendering/cynefin.spec.js b/cypress/integration/rendering/cynefin.spec.js index eb17f8656f3..4ac7bfd0275 100644 --- a/cypress/integration/rendering/cynefin.spec.js +++ b/cypress/integration/rendering/cynefin.spec.js @@ -157,6 +157,37 @@ describe('cynefin framework', () => { ); }); + it('should render cynefin with confusion domain items without overflow', () => { + imgSnapshotTest( + `cynefin-beta + title Confusion Items + + confusion + "Unknown A" + "Unknown B" + "Unknown C" + "Unknown D" + "Unknown E" + ` + ); + }); + + it('should render cynefin with self-loop transitions silently dropped', () => { + imgSnapshotTest( + `cynefin-beta + title Self-loop Handling + + complex + "Emergent work" + complicated + "Expert work" + + complex --> complicated : "Pattern found" + complex --> complex : "Self-loop (dropped)" + ` + ); + }); + it('should render cynefin with accessibility directives', () => { imgSnapshotTest( `cynefin-beta diff --git a/docs/syntax/cynefin.md b/docs/syntax/cynefin.md index 0a2215aaeb0..ff13b4f626e 100644 --- a/docs/syntax/cynefin.md +++ b/docs/syntax/cynefin.md @@ -257,6 +257,8 @@ Cynefin diagrams use the following theme variables, which can be overridden via - Domain names are fixed keywords. Only `complex`, `complicated`, `clear`, `chaotic`, and `confusion` are recognized. - Domains can be declared in any order; their position in the diagram is always the same (Complex top-left, Complicated top-right, Chaotic bottom-left, Clear bottom-right, Confusion center). +- The `confusion` domain has a compact center ellipse. Up to 3 items are shown inside it; if more are provided a `+N more` overflow badge is displayed. In practice, the confusion domain should contain very few items — its purpose is to surface unknowns so they can be moved to one of the four main domains. +- Self-loop transitions (e.g. `complex --> complex`) are silently ignored. Transitions must connect two different domains. - Handdrawn mode is not currently supported. - The wavy boundary rendering is deterministic: the same input always produces the same diagram, so diffs are stable across builds. diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts index c30f016dfb3..aafdab0f436 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts @@ -212,6 +212,36 @@ describe('Cynefin Parsing - Complex diagram', () => { }); }); +describe('Cynefin Parsing - Self-loop transitions', () => { + beforeEach(() => db.clear()); + + it('should drop self-loop transitions and keep valid ones', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + complex --> complex : "Self-reflection" + complex --> complicated : "Pattern found" + complicated --> complicated +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBe('Pattern found'); + }); + + it('should produce zero transitions when all are self-loops', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complex --> complex +`); + expect(db.getTransitions()).toHaveLength(0); + }); +}); + describe('Cynefin Parsing - Re-parse after clear', () => { beforeEach(() => db.clear()); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts index 9a0885dba3e..4c1bc97d52c 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts @@ -47,6 +47,23 @@ describe('Cynefin Database', () => { expect(db.getTransitions()[0].label).toBeUndefined(); }); + it('should filter out self-loop transitions', () => { + db.setTransitions([ + tx('complex', 'complex'), + tx('complex', 'complicated', 'Pattern found'), + tx('clear', 'clear'), + ]); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + }); + + it('should handle a list of only self-loops gracefully', () => { + db.setTransitions([tx('complex', 'complex'), tx('chaotic', 'chaotic')]); + expect(db.getTransitions()).toHaveLength(0); + }); + it('should handle null/undefined blocks', () => { expect(() => db.setDomains(null as unknown as DomainBlock[])).not.toThrow(); expect(() => db.setTransitions(null as unknown as Transition[])).not.toThrow(); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts index 4785c6c7ad5..d4613e1e507 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts @@ -2,6 +2,7 @@ import type { DomainBlock, Transition } from '@mermaid-js/parser'; import { getConfig as commonGetConfig } from '../../config.js'; import type { CynefinDiagramConfig } from '../../config.type.js'; import DEFAULT_CONFIG from '../../defaultConfig.js'; +import { log } from '../../logger.js'; import { cleanAndMerge } from '../../utils.js'; import { clear as commonClear, @@ -50,11 +51,21 @@ const setTransitions = (transitions: Transition[]) => { if (!transitions) { return; } - data.transitions = transitions.map((t) => ({ - from: t.from as DomainName, - to: t.to as DomainName, - label: t.label || undefined, - })); + data.transitions = transitions + .filter((t) => { + if (t.from === t.to) { + log.warn( + `Cynefin: self-loop transition on domain "${t.from}" is not meaningful and will be skipped.` + ); + return false; + } + return true; + }) + .map((t) => ({ + from: t.from as DomainName, + to: t.to as DomainName, + label: t.label || undefined, + })); }; const getConfig = (): Required<CynefinDiagramConfig> => { diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts index b621cce3f48..45fa1fe4395 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts @@ -7,7 +7,7 @@ import type { const id = 'cynefin'; const detector: DiagramDetector = (txt) => { - return /^\s*cynefin-beta/.test(txt); + return /^\s*cynefin-beta(?:[\s:]|$)/.test(txt); }; const loader: DiagramLoader = async () => { diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts index 690d495fdbb..b21555d6317 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts @@ -49,31 +49,25 @@ const getDomainLayouts = (width: number, height: number): Record<DomainName, Dom }; }; -/** Resolve cynefin theme variables from the mermaid theme system. */ -const getCynefinTheme = () => { +interface CynefinDomainColors { + complexBg: string; + complicatedBg: string; + chaoticBg: string; + clearBg: string; + confusionBg: string; +} + +/** Resolve only the domain background colors from the cynefin theme block. */ +const getCynefinDomainColors = (): CynefinDomainColors => { const defaultThemeVariables = getThemeVariables(); const currentConfig = getConfigAPI(); const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (themeVariables as any).cynefin as { - domainFontSize: number; - itemFontSize: number; - boundaryColor: string; - boundaryWidth: number; - cliffColor: string; - cliffWidth: number; - arrowColor: string; - arrowWidth: number; - complexBg: string; - complicatedBg: string; - chaoticBg: string; - clearBg: string; - confusionBg: string; - textColor: string; - labelColor: string; - }; + return themeVariables.cynefin as CynefinDomainColors; }; +/** Maximum items rendered inside the confusion ellipse before overflow badge is shown. */ +const MAX_CONFUSION_ITEMS = 3; + const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const db = diagram.db as CynefinDB; const domains = db.getDomains(); @@ -82,7 +76,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const accTitle = db.getAccTitle(); const accDescription = db.getAccDescription(); const config = db.getConfig(); - const theme = getCynefinTheme(); + const domainColors = getCynefinDomainColors(); log.debug('Rendering Cynefin diagram'); @@ -95,11 +89,11 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const totalHeight = height + padding * 2; const domainBg: Record<DomainName, string> = { - complex: theme.complexBg, - complicated: theme.complicatedBg, - clear: theme.clearBg, - chaotic: theme.chaoticBg, - confusion: theme.confusionBg, + complex: domainColors.complexBg, + complicated: domainColors.complicatedBg, + clear: domainColors.clearBg, + chaotic: domainColors.chaoticBg, + confusion: domainColors.confusionBg, }; const svg: SVG = selectSvgElement(id); @@ -107,9 +101,9 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { configureSvgSize(svg, totalHeight, totalWidth, true); svg.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`); - // Accessibility + // Accessibility: use <title> element (W3C recommendation for SVG) if (accTitle) { - svg.attr('aria-label', accTitle); + svg.append('title').text(accTitle); } if (accDescription) { svg.append('desc').text(accDescription); @@ -137,50 +131,40 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('stroke', 'none'); } - // 2. Wavy boundaries + // 2. Wavy boundaries — stroke handled by .cynefinBoundary CSS class const boundaryGroup = root.append('g').attr('class', 'cynefin-boundaries'); - // Vertical fold boundaryGroup .append('path') .attr('class', 'cynefinBoundary') .attr('d', generateFoldPath(width, height, seed, boundaryAmplitude)) - .attr('fill', 'none') - .attr('stroke', theme.boundaryColor) - .attr('stroke-width', theme.boundaryWidth); + .attr('fill', 'none'); - // Horizontal boundary boundaryGroup .append('path') .attr('class', 'cynefinBoundary') .attr('d', generateHorizontalBoundary(width, height, seed + 100, boundaryAmplitude)) - .attr('fill', 'none') - .attr('stroke', theme.boundaryColor) - .attr('stroke-width', theme.boundaryWidth); + .attr('fill', 'none'); - // 3. The cliff (thicker, between Clear and Chaotic at bottom-center) + // 3. The cliff (thicker, between Clear and Chaotic) — stroke handled by .cynefinCliff CSS class boundaryGroup .append('path') .attr('class', 'cynefinCliff') .attr('d', generateCliffPath(width, height)) - .attr('fill', 'none') - .attr('stroke', theme.cliffColor) - .attr('stroke-width', theme.cliffWidth); + .attr('fill', 'none'); - // 4. Confusion ellipse (center overlay) - const confusionRx = width * 0.1; - const confusionRy = height * 0.1; + // 4. Confusion ellipse (center overlay) — stroke handled by .cynefinConfusion CSS class + // Using width*0.15 and height*0.15 gives enough room for up to ~3 item badges + const confusionRx = width * 0.15; + const confusionRy = height * 0.15; root .append('path') .attr('class', 'cynefinConfusion') .attr('d', generateConfusionPath(width / 2, height / 2, confusionRx, confusionRy)) .attr('fill', domainBg.confusion) - .attr('fill-opacity', 0.5) - .attr('stroke', theme.boundaryColor) - .attr('stroke-width', 1.5) - .attr('stroke-dasharray', '4 2'); + .attr('fill-opacity', 0.5); - // 5. Domain name labels + // 5. Domain name labels — text styling handled by .cynefinDomainLabel CSS class const labelGroup = root.append('g').attr('class', 'cynefin-labels'); for (const domainName of quadrantDomains) { const layout = layouts[domainName]; @@ -191,9 +175,6 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', showDomainDescriptions ? layout.cy - 30 : layout.cy) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', theme.domainFontSize) - .attr('font-weight', 'bold') - .attr('fill', theme.labelColor) .text(domainName.charAt(0).toUpperCase() + domainName.slice(1)); } @@ -205,12 +186,9 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', showDomainDescriptions ? height / 2 - 10 : height / 2) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', theme.domainFontSize) - .attr('font-weight', 'bold') - .attr('fill', theme.labelColor) .text('Confusion'); - // 6. Domain description subtitles (decision model + practice type) + // 6. Domain description subtitles — text styling handled by .cynefinSubtitle CSS class if (showDomainDescriptions) { const subtitleGroup = root.append('g').attr('class', 'cynefin-subtitles'); for (const domainName of quadrantDomains) { @@ -224,9 +202,6 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', layout.cy - 10) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', theme.itemFontSize - 1) - .attr('font-style', 'italic') - .attr('fill', theme.textColor) .text(meta.model); subtitleGroup @@ -236,9 +211,6 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', layout.cy + 5) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', theme.itemFontSize - 1) - .attr('font-style', 'italic') - .attr('fill', theme.textColor) .text(meta.practice); } @@ -250,9 +222,6 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', height / 2 + 8) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', theme.itemFontSize - 1) - .attr('font-style', 'italic') - .attr('fill', theme.textColor) .text(DOMAIN_META.confusion.practice); } @@ -269,37 +238,114 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { } const layout = layouts[domainName]; - const startY = layout.cy + (showDomainDescriptions ? 25 : 15); + const isConfusion = domainName === 'confusion'; + + // For confusion: cap items and center the block around the ellipse center. + // For quadrant domains: start below the label/subtitle area. + let itemsToRender = domain.items; + let overflowCount = 0; + if (isConfusion && domain.items.length > MAX_CONFUSION_ITEMS) { + overflowCount = domain.items.length - MAX_CONFUSION_ITEMS; + itemsToRender = domain.items.slice(0, MAX_CONFUSION_ITEMS); + } - domain.items.forEach((item, idx) => { + let startY: number; + if (isConfusion) { + // Center the item block below the label within the ellipse + const labelOffset = showDomainDescriptions ? 22 : 14; + startY = layout.cy + labelOffset; + } else { + startY = layout.cy + (showDomainDescriptions ? 25 : 15); + } + + // Render item badges using getBBox() for accurate width measurement + [...itemsToRender].forEach((item, idx) => { const itemY = startY + idx * (itemHeight + 4); - const textLen = item.label.length * 7 + itemPaddingX * 2; - const itemX = layout.cx - textLen / 2; + const g = itemGroup.append('g'); + + // Append text first to measure actual rendered width + const textEl = g + .append('text') + .attr('class', 'cynefinItemText') + .attr('x', 0) + .attr('y', itemHeight / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .text(item.label); + + // Measure rendered text width; fall back to character-count heuristic if unavailable + let measuredWidth = item.label.length * 7; + const textNode = textEl.node(); + if (textNode && typeof textNode.getBBox === 'function') { + const bbox = textNode.getBBox(); + if (bbox.width > 0) { + measuredWidth = bbox.width; + } + } + + const badgeWidth = measuredWidth + itemPaddingX * 2; + const itemX = layout.cx - badgeWidth / 2; - const g = itemGroup.append('g').attr('transform', `translate(${itemX}, ${itemY})`); + g.attr('transform', `translate(${itemX}, ${itemY})`); - g.append('rect') + // Insert rect behind the text, sized to measured badge width + g.insert('rect', 'text') .attr('class', 'cynefinItem') .attr('x', 0) .attr('y', 0) - .attr('width', textLen) + .attr('width', badgeWidth) .attr('height', itemHeight) .attr('rx', 4) .attr('ry', 4) .attr('fill', domainBg[domainName]) - .attr('stroke', theme.boundaryColor) - .attr('stroke-width', 1) .attr('fill-opacity', 0.95); - g.append('text') - .attr('x', textLen / 2) + // Centre text within badge + textEl.attr('x', badgeWidth / 2).attr('y', itemHeight / 2); + }); + + // Overflow badge: "+N more" when confusion has more items than MAX_CONFUSION_ITEMS + if (overflowCount > 0) { + const overflowY = startY + itemsToRender.length * (itemHeight + 4); + const overflowLabel = `+${overflowCount} more`; + const g = itemGroup.append('g'); + + const textEl = g + .append('text') + .attr('class', 'cynefinItemText') + .attr('x', 0) .attr('y', itemHeight / 2) - .attr('fill', theme.textColor) - .attr('font-size', theme.itemFontSize) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') - .text(item.label); - }); + .text(overflowLabel); + + let measuredWidth = overflowLabel.length * 7; + const textNode = textEl.node(); + if (textNode && typeof textNode.getBBox === 'function') { + const bbox = textNode.getBBox(); + if (bbox.width > 0) { + measuredWidth = bbox.width; + } + } + + const badgeWidth = measuredWidth + itemPaddingX * 2; + const itemX = layout.cx - badgeWidth / 2; + + g.attr('transform', `translate(${itemX}, ${overflowY})`); + + g.insert('rect', 'text') + .attr('class', 'cynefinItemOverflow') + .attr('x', 0) + .attr('y', 0) + .attr('width', badgeWidth) + .attr('height', itemHeight) + .attr('rx', 4) + .attr('ry', 4) + .attr('fill', domainBg[domainName]) + .attr('fill-opacity', 0.6); + + textEl.attr('x', badgeWidth / 2).attr('y', itemHeight / 2); + } } // 8. Transition arrows between domain centers @@ -318,8 +364,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('orient', 'auto-start-reverse') .append('path') .attr('d', 'M 0 0 L 10 5 L 0 10 z') - .attr('class', 'cynefinArrowHead') - .attr('fill', theme.arrowColor); + .attr('class', 'cynefinArrowHead'); const arrowGroup = root.append('g').attr('class', 'cynefin-arrows'); @@ -330,6 +375,12 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { return; } + // Self-loops are filtered at the DB level; guard here handles any edge case + if (transition.from === transition.to) { + log.warn(`Cynefin renderer: skipping self-loop on domain "${transition.from}"`); + return; + } + const x1 = fromLayout.cx + padding; const y1 = fromLayout.cy + padding; const x2 = toLayout.cx + padding; @@ -356,11 +407,9 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { `M${fromLayout.cx},${fromLayout.cy} Q${cpx},${cpy} ${toLayout.cx},${toLayout.cy}` ) .attr('fill', 'none') - .attr('stroke', theme.arrowColor) - .attr('stroke-width', theme.arrowWidth) .attr('marker-end', `url(#${markerId})`); - // Optional label + // Optional label — text styling handled by .cynefinArrowLabel CSS class if (transition.label) { arrowGroup .append('text') @@ -369,14 +418,12 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', cpy - 6) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'auto') - .attr('font-size', theme.itemFontSize - 1) - .attr('fill', theme.textColor) .text(transition.label); } }); } - // Title + // Title — text styling handled by .cynefinTitle CSS class if (title) { root .append('text') @@ -385,9 +432,6 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { .attr('y', -padding / 2) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', theme.domainFontSize + 2) - .attr('font-weight', 'bold') - .attr('fill', theme.labelColor) .text(title); } }; diff --git a/packages/mermaid/src/diagrams/cynefin/styles.ts b/packages/mermaid/src/diagrams/cynefin/styles.ts index 61b82f915d3..d0b7b48140f 100644 --- a/packages/mermaid/src/diagrams/cynefin/styles.ts +++ b/packages/mermaid/src/diagrams/cynefin/styles.ts @@ -3,104 +3,91 @@ import { cleanAndMerge } from '../../utils.js'; import { getThemeVariables } from '../../themes/theme-default.js'; import { getConfig as getConfigAPI } from '../../config.js'; -export interface CynefinStyleOptions { - domainOpacity?: number; - boundaryColor?: string; - boundaryStrokeWidth?: number; - cliffColor?: string; - cliffStrokeWidth?: number; - confusionOpacity?: number; - labelFontSize?: number; - subtitleFontSize?: number; - itemFontSize?: number; - itemFill?: string; - itemStroke?: string; - arrowColor?: string; - arrowStrokeWidth?: number; - arrowLabelFontSize?: number; -} - -export const buildCynefinStyleOptions = (cynefin?: CynefinStyleOptions) => { +/** + * Resolve the cynefin-specific theme block from the current mermaid theme. + * All presentation values (colors, font sizes, stroke widths) are sourced from + * here rather than duplicated as inline SVG attributes in the renderer. + */ +const getCynefinTheme = () => { const defaultThemeVariables = getThemeVariables(); const currentConfig = getConfigAPI(); - const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); - const cynefinOptions: CynefinStyleOptions = cleanAndMerge( - { - domainOpacity: 0.3, - boundaryColor: themeVariables.lineColor ?? '#333', - boundaryStrokeWidth: 1.5, - cliffColor: themeVariables.lineColor ?? '#222', - cliffStrokeWidth: 3, - confusionOpacity: 0.35, - labelFontSize: 18, - subtitleFontSize: 11, - itemFontSize: 12, - itemFill: themeVariables.mainBkg ?? '#fff', - itemStroke: themeVariables.border1 ?? '#ccc', - arrowColor: themeVariables.lineColor ?? '#555', - arrowStrokeWidth: 2, - arrowLabelFontSize: 11, - } as CynefinStyleOptions, - cynefin - ); - - return { themeVariables, cynefinOptions }; + return themeVariables.cynefin as { + domainFontSize: number; + itemFontSize: number; + boundaryColor: string; + boundaryWidth: number; + cliffColor: string; + cliffWidth: number; + arrowColor: string; + arrowWidth: number; + textColor: string; + labelColor: string; + }; }; -export const styles: DiagramStylesProvider = ({ - cynefin, -}: { cynefin?: CynefinStyleOptions } = {}) => { - const { themeVariables, cynefinOptions } = buildCynefinStyleOptions(cynefin); +export const styles: DiagramStylesProvider = () => { + const t = getCynefinTheme(); return ` .cynefinDomain { stroke: none; } .cynefinDomainLabel { - font-size: ${cynefinOptions.labelFontSize}px; + font-size: ${t.domainFontSize}px; font-weight: bold; - fill: ${themeVariables.primaryTextColor ?? '#222'}; - color: ${themeVariables.primaryTextColor ?? '#222'}; + fill: ${t.labelColor}; } .cynefinSubtitle { - font-size: ${cynefinOptions.subtitleFontSize}px; - fill: ${themeVariables.secondaryTextColor ?? '#555'}; - color: ${themeVariables.secondaryTextColor ?? '#555'}; + font-size: ${t.itemFontSize - 1}px; + fill: ${t.textColor}; font-style: italic; } - .cynefinItem rect { - fill: ${cynefinOptions.itemFill}; - stroke: ${cynefinOptions.itemStroke}; + .cynefinItem { + fill-opacity: 0.95; + stroke: ${t.boundaryColor}; stroke-width: 1; } - .cynefinItem text { - font-size: ${cynefinOptions.itemFontSize}px; - fill: ${themeVariables.primaryTextColor ?? '#222'}; + .cynefinItemText { + font-size: ${t.itemFontSize}px; + fill: ${t.textColor}; + } + .cynefinItemOverflow { + fill-opacity: 0.6; + stroke: ${t.boundaryColor}; + stroke-width: 1; + stroke-dasharray: 3 2; } .cynefinBoundary { - stroke: ${cynefinOptions.boundaryColor}; - stroke-width: ${cynefinOptions.boundaryStrokeWidth}; + stroke: ${t.boundaryColor}; + stroke-width: ${t.boundaryWidth}; stroke-dasharray: 6 3; } .cynefinCliff { - stroke: ${cynefinOptions.cliffColor}; - stroke-width: ${cynefinOptions.cliffStrokeWidth}; + stroke: ${t.cliffColor}; + stroke-width: ${t.cliffWidth}; } .cynefinConfusion { + stroke: ${t.boundaryColor}; + stroke-width: 1.5; stroke-dasharray: 4 2; } .cynefinArrowLine { - stroke: ${cynefinOptions.arrowColor}; - stroke-width: ${cynefinOptions.arrowStrokeWidth}; + stroke: ${t.arrowColor}; + stroke-width: ${t.arrowWidth}; fill: none; } .cynefinArrowHead { - fill: ${cynefinOptions.arrowColor}; + fill: ${t.arrowColor}; stroke: none; } .cynefinArrowLabel { - font-size: ${cynefinOptions.arrowLabelFontSize}px; - fill: ${themeVariables.primaryTextColor ?? '#333'}; + font-size: ${t.itemFontSize - 1}px; + fill: ${t.textColor}; + } + .cynefinTitle { + font-size: ${t.domainFontSize + 2}px; + font-weight: bold; + fill: ${t.labelColor}; } `; }; diff --git a/packages/mermaid/src/docs/syntax/cynefin.md b/packages/mermaid/src/docs/syntax/cynefin.md index 1ba7520116c..d952ff75c62 100644 --- a/packages/mermaid/src/docs/syntax/cynefin.md +++ b/packages/mermaid/src/docs/syntax/cynefin.md @@ -196,6 +196,8 @@ Cynefin diagrams use the following theme variables, which can be overridden via - Domain names are fixed keywords. Only `complex`, `complicated`, `clear`, `chaotic`, and `confusion` are recognized. - Domains can be declared in any order; their position in the diagram is always the same (Complex top-left, Complicated top-right, Chaotic bottom-left, Clear bottom-right, Confusion center). +- The `confusion` domain has a compact center ellipse. Up to 3 items are shown inside it; if more are provided a `+N more` overflow badge is displayed. In practice, the confusion domain should contain very few items — its purpose is to surface unknowns so they can be moved to one of the four main domains. +- Self-loop transitions (e.g. `complex --> complex`) are silently ignored. Transitions must connect two different domains. - Handdrawn mode is not currently supported. - The wavy boundary rendering is deterministic: the same input always produces the same diagram, so diffs are stable across builds. diff --git a/packages/parser/src/language/cynefin/cynefin.langium b/packages/parser/src/language/cynefin/cynefin.langium index 27b24cec504..b95d0115b1a 100644 --- a/packages/parser/src/language/cynefin/cynefin.langium +++ b/packages/parser/src/language/cynefin/cynefin.langium @@ -3,7 +3,8 @@ import "../common/common"; entry Cynefin: NEWLINE* - ('cynefin-beta' | 'cynefin-beta:' | 'cynefin-beta' ':') + // Accept bare keyword or colon-terminated form; both are equivalent + ('cynefin-beta' | 'cynefin-beta:') NEWLINE* ( TitleAndAccessibilities From dfa88d769b74253171509cc1a0e945399dcd0fe8 Mon Sep 17 00:00:00 2001 From: ragelink <ragelink@ragelink.com> Date: Fri, 17 Apr 2026 10:03:18 -0600 Subject: [PATCH 6/9] fix(cynefin): address round 3 review feedback - Honor `useMaxWidth` config in renderer (match radar reference pattern) - Rename config/detector/schema key to `cynefin-beta` to match keyword and align with the `wardley-beta` convention - Simplify transition control-point math into a single coordinate space - Document the expectation that quadrant item counts stay small --- .build/jsonSchema.ts | 2 +- cypress/integration/rendering/cynefin.spec.js | 6 +++--- .../setup/mermaid/interfaces/MermaidConfig.md | 4 ++-- docs/syntax/cynefin.md | 6 ++++-- packages/mermaid/src/config.type.ts | 2 +- packages/mermaid/src/defaultConfig.ts | 4 ++-- .../mermaid/src/diagrams/cynefin/cynefinDb.ts | 4 ++-- .../src/diagrams/cynefin/cynefinDetector.ts | 2 +- .../src/diagrams/cynefin/cynefinRenderer.ts | 21 ++++++++----------- packages/mermaid/src/docs/syntax/cynefin.md | 6 ++++-- .../multi-diagram-id-uniqueness.spec.ts | 2 +- .../mermaid/src/schemas/config.schema.yaml | 2 +- 12 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index bf12b38b461..68aa8b93f2d 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -32,7 +32,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'eventmodeling', 'radar', 'venn', - 'cynefin', + 'cynefin-beta', ] as const; /** diff --git a/cypress/integration/rendering/cynefin.spec.js b/cypress/integration/rendering/cynefin.spec.js index 4ac7bfd0275..b29f0ec0374 100644 --- a/cypress/integration/rendering/cynefin.spec.js +++ b/cypress/integration/rendering/cynefin.spec.js @@ -100,7 +100,7 @@ describe('cynefin framework', () => { clear "Standard work" `, - { cynefin: { width: 1000, height: 700, boundaryAmplitude: 15 } } + { 'cynefin-beta': { width: 1000, height: 700, boundaryAmplitude: 15 } } ); }); @@ -114,7 +114,7 @@ describe('cynefin framework', () => { clear "Item B" `, - { cynefin: { showDomainDescriptions: false } } + { 'cynefin-beta': { showDomainDescriptions: false } } ); }); @@ -153,7 +153,7 @@ describe('cynefin framework', () => { chaotic "Item D" `, - { cynefin: { boundaryAmplitude: 0 } } + { 'cynefin-beta': { boundaryAmplitude: 0 } } ); }); diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index 0df41633544..6dc65478923 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -65,9 +65,9 @@ Defined in: [packages/mermaid/src/config.type.ts:224](https://github.com/mermaid --- -### cynefin? +### cynefin-beta? -> `optional` **cynefin**: `CynefinDiagramConfig` +> `optional` **cynefin-beta**: `CynefinDiagramConfig` Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) diff --git a/docs/syntax/cynefin.md b/docs/syntax/cynefin.md index ff13b4f626e..c9a97658bee 100644 --- a/docs/syntax/cynefin.md +++ b/docs/syntax/cynefin.md @@ -73,6 +73,8 @@ complex "Run chaos experiment" ``` +Keep per-domain item lists short — the quadrants have fixed layout and long lists can visually overflow their boxes. The confusion ellipse caps at three items and shows a `+N more` badge; the four quadrant domains do not clip, so prefer a handful of items each. + ### Transitions Transitions represent movement of items between domains over time. They are declared at the top level using `-->` between two domain names, with an optional label. @@ -212,7 +214,7 @@ cynefin-beta ## Configuration -Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: +Cynefin diagrams accept the following configuration under the `cynefin-beta` key in the mermaid config: | Option | Type | Default | Description | | ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | @@ -225,7 +227,7 @@ Cynefin diagrams accept the following configuration under the `cynefin` key in t Example: ``` -%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% +%%{init: {'cynefin-beta': {'width': 1000, 'showDomainDescriptions': false}}}%% cynefin-beta complex "Adaptive work" diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 8bab6dd5aa3..a17686aeac9 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -242,7 +242,7 @@ export interface MermaidConfig { radar?: RadarDiagramConfig; venn?: VennDiagramConfig; 'wardley-beta'?: WardleyDiagramConfig; - cynefin?: CynefinDiagramConfig; + 'cynefin-beta'?: CynefinDiagramConfig; dompurifyConfig?: DOMPurifyConfiguration; wrap?: boolean; fontSize?: number; diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index c1e93c540be..30529f6f350 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -294,8 +294,8 @@ const config: RequiredDeep<MermaidConfig> = { venn: { ...defaultConfigJson.venn, }, - cynefin: { - ...defaultConfigJson.cynefin, + 'cynefin-beta': { + ...defaultConfigJson['cynefin-beta'], }, }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts index d4613e1e507..5aef8e785fe 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts @@ -70,8 +70,8 @@ const setTransitions = (transitions: Transition[]) => { const getConfig = (): Required<CynefinDiagramConfig> => { return cleanAndMerge({ - ...DEFAULT_CONFIG.cynefin, - ...commonGetConfig().cynefin, + ...DEFAULT_CONFIG['cynefin-beta'], + ...commonGetConfig()['cynefin-beta'], }); }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts index 45fa1fe4395..5b7ff22a480 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts @@ -4,7 +4,7 @@ import type { DiagramLoader, } from '../../diagram-api/types.js'; -const id = 'cynefin'; +const id = 'cynefin-beta'; const detector: DiagramDetector = (txt) => { return /^\s*cynefin-beta(?:[\s:]|$)/.test(txt); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts index b21555d6317..52e6288a101 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts @@ -98,7 +98,7 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const svg: SVG = selectSvgElement(id); - configureSvgSize(svg, totalHeight, totalWidth, true); + configureSvgSize(svg, totalHeight, totalWidth, config.useMaxWidth ?? true); svg.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`); // Accessibility: use <title> element (W3C recommendation for SVG) @@ -381,10 +381,11 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { return; } - const x1 = fromLayout.cx + padding; - const y1 = fromLayout.cy + padding; - const x2 = toLayout.cx + padding; - const y2 = toLayout.cy + padding; + // All math in root-local (unpadded) coordinates to match the <g> transform. + const x1 = fromLayout.cx; + const y1 = fromLayout.cy; + const x2 = toLayout.cx; + const y2 = toLayout.cy; // Quadratic bezier with perpendicular offset const mx = (x1 + x2) / 2; @@ -393,19 +394,15 @@ const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { const dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); const offsetAmount = len * 0.15; - // Perpendicular offset const nx = -dy / len; const ny = dx / len; - const cpx = mx + nx * offsetAmount - padding; - const cpy = my + ny * offsetAmount - padding; + const cpx = mx + nx * offsetAmount; + const cpy = my + ny * offsetAmount; arrowGroup .append('path') .attr('class', 'cynefinArrowLine') - .attr( - 'd', - `M${fromLayout.cx},${fromLayout.cy} Q${cpx},${cpy} ${toLayout.cx},${toLayout.cy}` - ) + .attr('d', `M${x1},${y1} Q${cpx},${cpy} ${x2},${y2}`) .attr('fill', 'none') .attr('marker-end', `url(#${markerId})`); diff --git a/packages/mermaid/src/docs/syntax/cynefin.md b/packages/mermaid/src/docs/syntax/cynefin.md index d952ff75c62..8c763f9b010 100644 --- a/packages/mermaid/src/docs/syntax/cynefin.md +++ b/packages/mermaid/src/docs/syntax/cynefin.md @@ -67,6 +67,8 @@ complex "Run chaos experiment" ``` +Keep per-domain item lists short — the quadrants have fixed layout and long lists can visually overflow their boxes. The confusion ellipse caps at three items and shows a `+N more` badge; the four quadrant domains do not clip, so prefer a handful of items each. + ### Transitions Transitions represent movement of items between domains over time. They are declared at the top level using `-->` between two domain names, with an optional label. @@ -151,7 +153,7 @@ cynefin-beta ## Configuration -Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: +Cynefin diagrams accept the following configuration under the `cynefin-beta` key in the mermaid config: | Option | Type | Default | Description | | ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | @@ -164,7 +166,7 @@ Cynefin diagrams accept the following configuration under the `cynefin` key in t Example: ``` -%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% +%%{init: {'cynefin-beta': {'width': 1000, 'showDomainDescriptions': false}}}%% cynefin-beta complex "Adaptive work" diff --git a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts index 198bec0c7e9..d13bbcbb51c 100644 --- a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts +++ b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts @@ -179,7 +179,7 @@ union A, B`, Electric Kettle -> Kettle Smart Kettle -> Kettle`, - cynefin: `cynefin-beta + 'cynefin-beta': `cynefin-beta title Incident Response complex "Investigate root cause" diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index afcbb592447..316372f2e8b 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -335,7 +335,7 @@ properties: $ref: '#/$defs/VennDiagramConfig' wardley-beta: $ref: '#/$defs/WardleyDiagramConfig' - cynefin: + cynefin-beta: $ref: '#/$defs/CynefinDiagramConfig' dompurifyConfig: title: DOM Purify Configuration From 100d8ced9d5b0a6faac5c2b8bcd8c2626685f7fe Mon Sep 17 00:00:00 2001 From: ragelink <ragelink@ragelink.com> Date: Mon, 27 Apr 2026 10:02:15 -0600 Subject: [PATCH 7/9] fix(cynefin): align e2e folder layout and example id with CI checks - Move cypress/integration/rendering/cynefin.spec.js into a cynefin/ subfolder so e2e-diagram-scope.spec.ts (every diagram folder must have a matching spec subfolder) passes. - Update packages/examples/src/examples/cynefin.ts id from 'cynefin' to 'cynefin-beta' to match the registered detector id, satisfying example.spec.ts (every registered diagram must have an example). --- cypress/integration/rendering/{ => cynefin}/cynefin.spec.js | 0 packages/examples/src/examples/cynefin.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename cypress/integration/rendering/{ => cynefin}/cynefin.spec.js (100%) diff --git a/cypress/integration/rendering/cynefin.spec.js b/cypress/integration/rendering/cynefin/cynefin.spec.js similarity index 100% rename from cypress/integration/rendering/cynefin.spec.js rename to cypress/integration/rendering/cynefin/cynefin.spec.js diff --git a/packages/examples/src/examples/cynefin.ts b/packages/examples/src/examples/cynefin.ts index df1c8965851..832678a5d50 100644 --- a/packages/examples/src/examples/cynefin.ts +++ b/packages/examples/src/examples/cynefin.ts @@ -1,7 +1,7 @@ import type { DiagramMetadata } from '../types.js'; export default { - id: 'cynefin', + id: 'cynefin-beta', name: 'Cynefin Framework', description: 'Decision-making framework for categorizing problems by complexity', examples: [ From 0323b9052292294597f3953f6c6c46dc8ff1556a Mon Sep 17 00:00:00 2001 From: ragelink <ragelink@ragelink.com> Date: Mon, 27 Apr 2026 10:11:48 -0600 Subject: [PATCH 8/9] refactor(cynefin): rename registered id and config key to 'cynefin' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns Cynefin with the radar diagram convention so that the diagram's internal identity matches the rest of the codebase. Previously cynefin diverged: the registered detector id and config key were 'cynefin-beta', which forced bracket-access (DEFAULT_CONFIG['cynefin-beta']) — the only such case in defaultConfig.ts. Radar by contrast uses id 'radar' / config key 'radar' with the '-beta' marker living only in the syntax keyword. Changes: - Detector id: 'cynefin-beta' -> 'cynefin' - Config schema key (cynefin-beta -> cynefin) in config.schema.yaml, defaultConfig.ts, .build/jsonSchema.ts, and the regenerated config.type.ts - Example id back to 'cynefin' (matches the registered diagram id) - Test/spec config-object keys updated (multi-diagram uniqueness spec, cypress cynefin spec) - Docs source updates for the config-key references; generated docs regenerated via pnpm --filter mermaid docs:build Unchanged: the syntax keyword 'cynefin-beta' remains the trigger token in the Langium grammar, parser tokenBuilder, detector regex, and all diagram code examples — exactly mirroring radar-beta. --- .build/jsonSchema.ts | 2 +- cypress/integration/rendering/cynefin/cynefin.spec.js | 6 +++--- docs/config/setup/mermaid/interfaces/MermaidConfig.md | 4 ++-- docs/syntax/cynefin.md | 4 ++-- packages/examples/src/examples/cynefin.ts | 2 +- packages/mermaid/src/config.type.ts | 2 +- packages/mermaid/src/defaultConfig.ts | 4 ++-- packages/mermaid/src/diagrams/cynefin/cynefinDb.ts | 4 ++-- packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts | 2 +- packages/mermaid/src/docs/syntax/cynefin.md | 4 ++-- .../src/rendering-util/multi-diagram-id-uniqueness.spec.ts | 2 +- packages/mermaid/src/schemas/config.schema.yaml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index 68aa8b93f2d..bf12b38b461 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -32,7 +32,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'eventmodeling', 'radar', 'venn', - 'cynefin-beta', + 'cynefin', ] as const; /** diff --git a/cypress/integration/rendering/cynefin/cynefin.spec.js b/cypress/integration/rendering/cynefin/cynefin.spec.js index b29f0ec0374..4ac7bfd0275 100644 --- a/cypress/integration/rendering/cynefin/cynefin.spec.js +++ b/cypress/integration/rendering/cynefin/cynefin.spec.js @@ -100,7 +100,7 @@ describe('cynefin framework', () => { clear "Standard work" `, - { 'cynefin-beta': { width: 1000, height: 700, boundaryAmplitude: 15 } } + { cynefin: { width: 1000, height: 700, boundaryAmplitude: 15 } } ); }); @@ -114,7 +114,7 @@ describe('cynefin framework', () => { clear "Item B" `, - { 'cynefin-beta': { showDomainDescriptions: false } } + { cynefin: { showDomainDescriptions: false } } ); }); @@ -153,7 +153,7 @@ describe('cynefin framework', () => { chaotic "Item D" `, - { 'cynefin-beta': { boundaryAmplitude: 0 } } + { cynefin: { boundaryAmplitude: 0 } } ); }); diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index 6dc65478923..0df41633544 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -65,9 +65,9 @@ Defined in: [packages/mermaid/src/config.type.ts:224](https://github.com/mermaid --- -### cynefin-beta? +### cynefin? -> `optional` **cynefin-beta**: `CynefinDiagramConfig` +> `optional` **cynefin**: `CynefinDiagramConfig` Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) diff --git a/docs/syntax/cynefin.md b/docs/syntax/cynefin.md index c9a97658bee..33d7d411efc 100644 --- a/docs/syntax/cynefin.md +++ b/docs/syntax/cynefin.md @@ -214,7 +214,7 @@ cynefin-beta ## Configuration -Cynefin diagrams accept the following configuration under the `cynefin-beta` key in the mermaid config: +Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: | Option | Type | Default | Description | | ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | @@ -227,7 +227,7 @@ Cynefin diagrams accept the following configuration under the `cynefin-beta` key Example: ``` -%%{init: {'cynefin-beta': {'width': 1000, 'showDomainDescriptions': false}}}%% +%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% cynefin-beta complex "Adaptive work" diff --git a/packages/examples/src/examples/cynefin.ts b/packages/examples/src/examples/cynefin.ts index 832678a5d50..df1c8965851 100644 --- a/packages/examples/src/examples/cynefin.ts +++ b/packages/examples/src/examples/cynefin.ts @@ -1,7 +1,7 @@ import type { DiagramMetadata } from '../types.js'; export default { - id: 'cynefin-beta', + id: 'cynefin', name: 'Cynefin Framework', description: 'Decision-making framework for categorizing problems by complexity', examples: [ diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 30d139cc6a6..9566b863db3 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -242,7 +242,7 @@ export interface MermaidConfig { radar?: RadarDiagramConfig; venn?: VennDiagramConfig; 'wardley-beta'?: WardleyDiagramConfig; - 'cynefin-beta'?: CynefinDiagramConfig; + cynefin?: CynefinDiagramConfig; dompurifyConfig?: DOMPurifyConfiguration; wrap?: boolean; fontSize?: number; diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 6c9ce8dc40e..87318154ce5 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -295,8 +295,8 @@ const config: RequiredDeep<MermaidConfig> = { venn: { ...defaultConfigJson.venn, }, - 'cynefin-beta': { - ...defaultConfigJson['cynefin-beta'], + cynefin: { + ...defaultConfigJson.cynefin, }, }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts index 5aef8e785fe..d4613e1e507 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts @@ -70,8 +70,8 @@ const setTransitions = (transitions: Transition[]) => { const getConfig = (): Required<CynefinDiagramConfig> => { return cleanAndMerge({ - ...DEFAULT_CONFIG['cynefin-beta'], - ...commonGetConfig()['cynefin-beta'], + ...DEFAULT_CONFIG.cynefin, + ...commonGetConfig().cynefin, }); }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts index 5b7ff22a480..45fa1fe4395 100644 --- a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts @@ -4,7 +4,7 @@ import type { DiagramLoader, } from '../../diagram-api/types.js'; -const id = 'cynefin-beta'; +const id = 'cynefin'; const detector: DiagramDetector = (txt) => { return /^\s*cynefin-beta(?:[\s:]|$)/.test(txt); diff --git a/packages/mermaid/src/docs/syntax/cynefin.md b/packages/mermaid/src/docs/syntax/cynefin.md index 8c763f9b010..61f3a495572 100644 --- a/packages/mermaid/src/docs/syntax/cynefin.md +++ b/packages/mermaid/src/docs/syntax/cynefin.md @@ -153,7 +153,7 @@ cynefin-beta ## Configuration -Cynefin diagrams accept the following configuration under the `cynefin-beta` key in the mermaid config: +Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: | Option | Type | Default | Description | | ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | @@ -166,7 +166,7 @@ Cynefin diagrams accept the following configuration under the `cynefin-beta` key Example: ``` -%%{init: {'cynefin-beta': {'width': 1000, 'showDomainDescriptions': false}}}%% +%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% cynefin-beta complex "Adaptive work" diff --git a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts index d13bbcbb51c..198bec0c7e9 100644 --- a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts +++ b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts @@ -179,7 +179,7 @@ union A, B`, Electric Kettle -> Kettle Smart Kettle -> Kettle`, - 'cynefin-beta': `cynefin-beta + cynefin: `cynefin-beta title Incident Response complex "Investigate root cause" diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index d63ad472627..cd413f7bd27 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -335,7 +335,7 @@ properties: $ref: '#/$defs/VennDiagramConfig' wardley-beta: $ref: '#/$defs/WardleyDiagramConfig' - cynefin-beta: + cynefin: $ref: '#/$defs/CynefinDiagramConfig' dompurifyConfig: title: DOM Purify Configuration From 241bd9a827c346743edaa8d902024679f2a2c251 Mon Sep 17 00:00:00 2001 From: ragelink <ragelink@ragelink.com> Date: Mon, 27 Apr 2026 20:04:35 -0600 Subject: [PATCH 9/9] fix(cynefin): correct cypress helpers import path after subfolder move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving cynefin.spec.js into cypress/integration/rendering/cynefin/ broke the relative import for ../../helpers/util — needs an extra level (../../../helpers/util) to match the radar and wardley specs. --- cypress/integration/rendering/cynefin/cynefin.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/rendering/cynefin/cynefin.spec.js b/cypress/integration/rendering/cynefin/cynefin.spec.js index 4ac7bfd0275..166b480aa00 100644 --- a/cypress/integration/rendering/cynefin/cynefin.spec.js +++ b/cypress/integration/rendering/cynefin/cynefin.spec.js @@ -1,4 +1,4 @@ -import { imgSnapshotTest } from '../../helpers/util'; +import { imgSnapshotTest } from '../../../helpers/util'; describe('cynefin framework', () => { it('should render a simple cynefin diagram with all five domains', () => {