diff --git a/.changeset/agentflow-redux-colors.md b/.changeset/agentflow-redux-colors.md new file mode 100644 index 00000000000..df6f6543597 --- /dev/null +++ b/.changeset/agentflow-redux-colors.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat(agentflow): take the redux colour palette. Under `redux-color` and `redux-dark-color`, every agentflow node kind — tool, task, decision, input, refdoc, connector, action — gets its own colour from a fixed palette slot, so colour says what an element _is_ and stays put when the diagram is edited around it. Containers cycle a counter in declaration order, the way flowchart subgraphs do, from the slots above the kind range so a container frame never matches a node inside it. A collapsed container keeps its slot. `redux-dark-color` carries borders but no fills, so nodes there take palette strokes over the theme's own background. Agentflow also takes `redux-color` and `neo` as its own per-diagram defaults, so an agentflow diagram that sets neither renders with the palette; anything set in front matter, a directive or `initialize()` still wins. diff --git a/.changeset/block-redux-color-palette.md b/.changeset/block-redux-color-palette.md new file mode 100644 index 00000000000..1ececde7eb1 --- /dev/null +++ b/.changeset/block-redux-color-palette.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix(block): apply the redux colour palette to composite blocks. Composites now take a per-container colour under `redux-color` and `redux-dark-color`, matching how flowchart subgraphs are coloured — one counter over containers, in declaration order, with the plain shapes left on the flat theme colour. The palette is opt-in: block does not default to `redux-color` the way flowchart and several other diagram types do, so set `theme: redux-color` (or `redux-dark-color`) explicitly to see it. `classDef`/`style` still win. diff --git a/.changeset/dark-theme-drift-and-palette-indexing.md b/.changeset/dark-theme-drift-and-palette-indexing.md new file mode 100644 index 00000000000..13e42246528 --- /dev/null +++ b/.changeset/dark-theme-drift-and-palette-indexing.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix(themes): `redux-dark`, `redux-dark-color` and `neo-dark` shipped `secondBkg` as the literal string `calculated`, so railroad rendered the invalid `fill: calculated`; it is now computed as `theme-dark` does. The same three themes had gantt done-task labels at 1.07:1 contrast — a light fill under their light task ink — now 5.7:1 or better. The ER and requirement stylesheets also validate `look` before interpolating it into a CSS selector. diff --git a/.changeset/er-requirement-palette-css.md b/.changeset/er-requirement-palette-css.md index 5d881d03f26..e035997b11e 100644 --- a/.changeset/er-requirement-palette-css.md +++ b/.changeset/er-requirement-palette-css.md @@ -2,9 +2,11 @@ 'mermaid': patch --- -fix(er, requirement, timeline): stop the ER, requirement and timeline stylesheets emitting invalid CSS for the colour themes. +fix(er, requirement, timeline): stop the ER, requirement and timeline stylesheets emitting CSS the browser discards for the colour themes. -All three generate one rule per palette slot, looping to `THEME_COLOR_LIMIT` and indexing the palette by the loop counter. Indexing raw means a palette with fewer entries than that limit emits `stroke: undefined` for the overflow slots; all three now wrap at the palette length, and bail before the loop for an empty palette — wrapping alone is not enough there, because `i % 0` is `NaN` and `[][NaN]` is `undefined`. +All three generate one rule per palette slot. ER and requirement looped to `THEME_COLOR_LIMIT` and indexed the palette by the loop counter, which goes wrong in both directions: a palette shorter than the limit emitted `stroke: undefined` for the overflow slots, and a palette _longer_ than it left entities stamped with a slot that had no rule at all, rendering unstyled beside coloured neighbours. Both now take the loop bound from the palette itself, which is the same length the boxes stamp with — so the rules emitted and the slots stamped cannot disagree. Both also bail before the loop for an empty palette, since wrapping alone is not enough there: `i % 0` is `NaN` and `[][NaN]` is `undefined`. + +Timeline keeps looping to `THEME_COLOR_LIMIT` and wrapping, because it numbers `.section-N` classes rather than palette slots — nothing stamps those, so the palette cycles across however many sections exist. `requirement` also emitted `fill: ;` — a property with no value, which is invalid — whenever there was no background palette. That is the live case for `redux-dark-color`, which ships a border palette and no background palette so that it colours outlines only. The declaration is now omitted instead. diff --git a/.changeset/per-diagram-appearance-defaults.md b/.changeset/per-diagram-appearance-defaults.md new file mode 100644 index 00000000000..59e73ffe113 --- /dev/null +++ b/.changeset/per-diagram-appearance-defaults.md @@ -0,0 +1,7 @@ +--- +'mermaid': minor +--- + +**`theme`, `look` and `layout` can now be set per diagram type**, in each diagram's config section — `mermaid.initialize({ look: 'classic', flowchart: { look: 'handDrawn' } })`, or the same under `config` in front matter. The schema uses the same mechanism to give a diagram type its own default. Resolution, highest first: front matter or directive, `initialize()`, the diagram type's default, the global default; a diagram-scoped value beats a global one set in the same layer. + +Swimlanes take `layout: swimlane` from that schema default instead of having it forced by their `init` hook, so `swimlane: { layout: ... }` and a `layout` in front matter are now honoured. An unregistered layout also always falls back to `dagre` with a warning rather than throwing — state diagrams skipped that fallback, and mindmaps threw when `cose-bilkent` was absent, as it is in `@mermaid-js/tiny`. diff --git a/.changeset/redux-color-becomes-default-theme.md b/.changeset/redux-color-becomes-default-theme.md new file mode 100644 index 00000000000..c99540e2589 --- /dev/null +++ b/.changeset/redux-color-becomes-default-theme.md @@ -0,0 +1,7 @@ +--- +'mermaid': major +--- + +**`redux-color` is now the default theme and `neo` the default look for ten diagram types** — flowchart, swimlane, class, ER, requirement, sequence, state, use case, Venn and agentflow. Those rendered without an explicit `theme` and `look` change appearance; every other diagram type keeps `default` and `classic`. To keep the previous appearance, set both explicitly — `mermaid.initialize({ theme: 'default', look: 'classic' })`, or the same two keys under `config` in front matter. + +An unrecognised `theme` name now resolves to the default theme in name as well as in variables; previously the invalid name stayed in place while the default's variables were loaded, and every palette-aware stylesheet gates on the name. `theme: 'null'`, the documented way to disable the pre-defined themes, is unaffected. Note that `neo` paints node strokes with a gradient when the theme sets `useGradient`, which `base` does; setting a custom `nodeBorder` on `base` turns the gradient off. diff --git a/.changeset/redux-color-state-composites.md b/.changeset/redux-color-state-composites.md new file mode 100644 index 00000000000..b341e68ee74 --- /dev/null +++ b/.changeset/redux-color-state-composites.md @@ -0,0 +1,11 @@ +--- +'mermaid': minor +--- + +feat(themes): composite states now take a per-container colour under the `redux-color` and `redux-dark-color` themes, as flowchart subgraphs already do. Each composite gets the palette's border colour on its outline and its background tint behind the title strip; nested composites each take the next colour, so depth stays readable. The body keeps the theme's own `compositeBackground`. `redux-dark-color` colours the outlines only, matching how it treats ER, requirement and sequence. + +The concurrency regions produced by a `--` divider share the colour of the composite they split, rather than taking one of their own — the author wrote a single composite, so it is drawn as one thing in parts. Adding a `--` therefore leaves every other composite's colour untouched. + +Under the `handDrawn` look, concurrency regions are now filled solid rather than hatched, so that they can carry the palette tint the same way every other look does. + +States inside a composite stay uniform. A composite carrying its own `classDef` or `style` keeps those colours and takes no palette slot, and neither do its concurrency regions; the slot is still spent, so styling one composite does not shift the colours of the ones after it. Note that this opt-out is all-or-nothing: a `classDef` that sets only text properties, such as `font-weight`, still takes that composite out of the palette. diff --git a/.changeset/redux-color-swimlane-lanes.md b/.changeset/redux-color-swimlane-lanes.md new file mode 100644 index 00000000000..b2a4ae075f2 --- /dev/null +++ b/.changeset/redux-color-swimlane-lanes.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat(themes): swimlane lanes take a per-lane colour under the `redux-color` and `redux-dark-color` themes, cycling every 12 as flowchart subgraphs do. The lane holding ungrouped nodes takes its own slot and now follows the diagram's `look`. diff --git a/.changeset/redux-color-venn.md b/.changeset/redux-color-venn.md new file mode 100644 index 00000000000..5f0270fcc91 --- /dev/null +++ b/.changeset/redux-color-venn.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix(themes): venn circles follow the `redux-color` and `redux-dark-color` palettes. Neither theme defined any `venn*` variable, so every circle rendered in one flat colour. diff --git a/.changeset/sequence-neo-redux-fixes.md b/.changeset/sequence-neo-redux-fixes.md new file mode 100644 index 00000000000..ac54b5849e9 --- /dev/null +++ b/.changeset/sequence-neo-redux-fixes.md @@ -0,0 +1,10 @@ +--- +'mermaid': patch +--- + +fix: sequence diagram actors under the `neo` look and `redux` themes. Notes no longer render bold, +a `rect` section band is no longer drawn white on white, and participants follow one vertical +model: every lifeline starts and ends on a shared line, all participant glyphs are one size with +their feet on a common edge, single-line labels share one baseline in the header and in the +mirrored footer, and a multiline label grows away from the lifeline instead of moving it. The +`classic` look is unchanged. diff --git a/.changeset/usecase-role-colors.md b/.changeset/usecase-role-colors.md new file mode 100644 index 00000000000..cdd691c1b9d --- /dev/null +++ b/.changeset/usecase-role-colors.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat(usecase): use case diagrams now take colour by role — actors, use cases and system boundaries each get their own colour from the new `usecaseActorBkg`/`usecaseActorBorder`, `usecaseBkg`/`usecaseBorder` and `usecaseBoundaryBkg`/`usecaseBoundaryBorder` theme variables, with `usecaseIncludeLine`/`usecaseExtendLine` separating the two dashed relationship kinds by hue. `redux-color` and `redux-dark-color` set them; every other theme is unchanged. Colour is keyed to the kind of element, so editing a diagram never recolours the elements around the edit. Set `usecase.colorScheme: 'rotate'` for the per-element palette cycle instead, and `classDef`/`style` still wins over both. diff --git a/.esbuild/dev-explorer/diagram-viewer.ts b/.esbuild/dev-explorer/diagram-viewer.ts index ad1285b1034..15a14bf2427 100644 --- a/.esbuild/dev-explorer/diagram-viewer.ts +++ b/.esbuild/dev-explorer/diagram-viewer.ts @@ -103,9 +103,12 @@ type MermaidTheme = | 'forest' | 'neutral' | 'base' + | 'neo' + | 'neo-dark' | 'redux' | 'redux-dark' - | 'redux-color'; + | 'redux-color' + | 'redux-dark-color'; type MermaidLayout = 'dagre' | 'elk' | 'domus' | 'hola' | 'swimlane'; type MermaidLook = 'classic' | 'handDrawn' | 'neo'; type MermaidLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -292,9 +295,12 @@ function isTheme(v: unknown): v is MermaidTheme { v === 'forest' || v === 'neutral' || v === 'base' || + v === 'neo' || + v === 'neo-dark' || v === 'redux' || v === 'redux-dark' || - v === 'redux-color' + v === 'redux-color' || + v === 'redux-dark-color' ); } @@ -1245,9 +1251,12 @@ export class DevDiagramViewer extends LitElement { forest neutral base + neo + neo-dark redux redux-dark redux-color + redux-dark-color diff --git a/.esbuild/dev-explorer/public/styles.css b/.esbuild/dev-explorer/public/styles.css index 83984a90838..4b7d20db9d3 100644 --- a/.esbuild/dev-explorer/public/styles.css +++ b/.esbuild/dev-explorer/public/styles.css @@ -366,7 +366,9 @@ dev-explorer-app .diagram-inner > svg { } dev-explorer-app .diagram-inner[data-theme='dark'], -dev-explorer-app .diagram-inner[data-theme='redux-dark'] { +dev-explorer-app .diagram-inner[data-theme='neo-dark'], +dev-explorer-app .diagram-inner[data-theme='redux-dark'], +dev-explorer-app .diagram-inner[data-theme='redux-dark-color'] { background: #0b1020; color: #e8eefc; } diff --git a/docs/config/setup/config/README.md b/docs/config/setup/config/README.md index e84f71a9e8c..6957ba5dd92 100644 --- a/docs/config/setup/config/README.md +++ b/docs/config/setup/config/README.md @@ -26,5 +26,6 @@ - [sanitize](functions/sanitize.md) - [saveConfigFromInitialize](functions/saveConfigFromInitialize.md) - [~~setConfig~~](functions/setConfig.md) +- [setDiagramConfigScope](functions/setDiagramConfigScope.md) - [setSiteConfig](functions/setSiteConfig.md) - [updateSiteConfig](functions/updateSiteConfig.md) diff --git a/docs/config/setup/config/functions/addDirective.md b/docs/config/setup/config/functions/addDirective.md index 804f797b3fa..9b4616db1f7 100644 --- a/docs/config/setup/config/functions/addDirective.md +++ b/docs/config/setup/config/functions/addDirective.md @@ -12,7 +12,7 @@ > **addDirective**(`directive`): `void` -Defined in: [packages/mermaid/src/config.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L173) +Defined in: [packages/mermaid/src/config.ts:277](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L277) Pushes in a directive to the configuration diff --git a/docs/config/setup/config/functions/evaluate.md b/docs/config/setup/config/functions/evaluate.md index 2d886c3e3ee..b40b6de8b61 100644 --- a/docs/config/setup/config/functions/evaluate.md +++ b/docs/config/setup/config/functions/evaluate.md @@ -12,7 +12,7 @@ > **evaluate**(`val?`): `boolean` -Defined in: [packages/mermaid/src/config.ts:16](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L16) +Defined in: [packages/mermaid/src/config.ts:62](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L62) Converts a string/boolean into a boolean diff --git a/docs/config/setup/config/functions/getConfig.md b/docs/config/setup/config/functions/getConfig.md index bb76243817d..7bfd5d5add3 100644 --- a/docs/config/setup/config/functions/getConfig.md +++ b/docs/config/setup/config/functions/getConfig.md @@ -12,7 +12,7 @@ > **getConfig**(): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:120](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L120) +Defined in: [packages/mermaid/src/config.ts:224](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L224) Returns a copy of the `currentConfig`. diff --git a/docs/config/setup/config/functions/getEffectiveHtmlLabels.md b/docs/config/setup/config/functions/getEffectiveHtmlLabels.md index eb1534ae7a9..42944fcd919 100644 --- a/docs/config/setup/config/functions/getEffectiveHtmlLabels.md +++ b/docs/config/setup/config/functions/getEffectiveHtmlLabels.md @@ -12,7 +12,7 @@ > **getEffectiveHtmlLabels**(`config`): `boolean` -Defined in: [packages/mermaid/src/config.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L246) +Defined in: [packages/mermaid/src/config.ts:352](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L352) Helper function to handle deprecated flowchart.htmlLabels diff --git a/docs/config/setup/config/functions/getSiteConfig.md b/docs/config/setup/config/functions/getSiteConfig.md index 4c740905835..ceed814fa5f 100644 --- a/docs/config/setup/config/functions/getSiteConfig.md +++ b/docs/config/setup/config/functions/getSiteConfig.md @@ -12,7 +12,7 @@ > **getSiteConfig**(): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:94](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L94) +Defined in: [packages/mermaid/src/config.ts:198](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L198) Returns a copy of the current `siteConfig` base configuration. diff --git a/docs/config/setup/config/functions/getUserDefinedConfig.md b/docs/config/setup/config/functions/getUserDefinedConfig.md index 73e8094592c..9b3e20f1167 100644 --- a/docs/config/setup/config/functions/getUserDefinedConfig.md +++ b/docs/config/setup/config/functions/getUserDefinedConfig.md @@ -12,7 +12,7 @@ > **getUserDefinedConfig**(): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:227](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L227) +Defined in: [packages/mermaid/src/config.ts:333](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L333) ## Returns diff --git a/docs/config/setup/config/functions/reset.md b/docs/config/setup/config/functions/reset.md index 9873e47bfc5..6433ae1eb09 100644 --- a/docs/config/setup/config/functions/reset.md +++ b/docs/config/setup/config/functions/reset.md @@ -12,7 +12,7 @@ > **reset**(`config`): `void` -Defined in: [packages/mermaid/src/config.ts:194](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L194) +Defined in: [packages/mermaid/src/config.ts:298](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L298) Resets the current config and applied directives to the provided config. diff --git a/docs/config/setup/config/functions/sanitize.md b/docs/config/setup/config/functions/sanitize.md index 24e49d3c943..1915ed847bb 100644 --- a/docs/config/setup/config/functions/sanitize.md +++ b/docs/config/setup/config/functions/sanitize.md @@ -12,7 +12,7 @@ > **sanitize**(`options`): `void` -Defined in: [packages/mermaid/src/config.ts:131](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L131) +Defined in: [packages/mermaid/src/config.ts:235](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L235) Ensures options parameter does not attempt to override `siteConfig` secure keys. diff --git a/docs/config/setup/config/functions/saveConfigFromInitialize.md b/docs/config/setup/config/functions/saveConfigFromInitialize.md index 209bbabb63f..2595da06f20 100644 --- a/docs/config/setup/config/functions/saveConfigFromInitialize.md +++ b/docs/config/setup/config/functions/saveConfigFromInitialize.md @@ -12,7 +12,7 @@ > **saveConfigFromInitialize**(`conf`): `void` -Defined in: [packages/mermaid/src/config.ts:78](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L78) +Defined in: [packages/mermaid/src/config.ts:181](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L181) ## Parameters diff --git a/docs/config/setup/config/functions/setConfig.md b/docs/config/setup/config/functions/setConfig.md index 348954dc645..4dba1581080 100644 --- a/docs/config/setup/config/functions/setConfig.md +++ b/docs/config/setup/config/functions/setConfig.md @@ -12,7 +12,7 @@ > **setConfig**(`conf`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:106](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L106) +Defined in: [packages/mermaid/src/config.ts:210](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L210) Updates the `currentConfig` with the provided `conf` after sanitization. diff --git a/docs/config/setup/config/functions/setDiagramConfigScope.md b/docs/config/setup/config/functions/setDiagramConfigScope.md new file mode 100644 index 00000000000..9e3cb4f0610 --- /dev/null +++ b/docs/config/setup/config/functions/setDiagramConfigScope.md @@ -0,0 +1,29 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/config/setup/config/functions/setDiagramConfigScope.md](../../../../../packages/mermaid/src/docs/config/setup/config/functions/setDiagramConfigScope.md). + +[**mermaid**](../../README.md) + +--- + +# Function: setDiagramConfigScope() + +> **setDiagramConfigScope**(`diagramType?`): `void` + +Defined in: [packages/mermaid/src/config.ts:150](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L150) + +Names the diagram type being parsed or rendered, so its own appearance defaults apply. + +## Parameters + +### diagramType? + +`string` + +The type `detectType` returned, e.g. `flowchart-v2`; `undefined` to leave scope. + +## Returns + +`void` diff --git a/docs/config/setup/config/functions/setSiteConfig.md b/docs/config/setup/config/functions/setSiteConfig.md index 1ea582ec2d1..39c64220614 100644 --- a/docs/config/setup/config/functions/setSiteConfig.md +++ b/docs/config/setup/config/functions/setSiteConfig.md @@ -12,7 +12,7 @@ > **setSiteConfig**(`conf`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:64](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L64) +Defined in: [packages/mermaid/src/config.ts:164](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L164) Sets the `siteConfig` to the desired values. diff --git a/docs/config/setup/config/functions/updateSiteConfig.md b/docs/config/setup/config/functions/updateSiteConfig.md index 865beb7c801..3ee515e24f9 100644 --- a/docs/config/setup/config/functions/updateSiteConfig.md +++ b/docs/config/setup/config/functions/updateSiteConfig.md @@ -12,7 +12,7 @@ > **updateSiteConfig**(`conf`): [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:82](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L82) +Defined in: [packages/mermaid/src/config.ts:185](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L185) ## Parameters diff --git a/docs/config/setup/config/variables/defaultConfig.md b/docs/config/setup/config/variables/defaultConfig.md index 3b08411fd9d..c351c83d350 100644 --- a/docs/config/setup/config/variables/defaultConfig.md +++ b/docs/config/setup/config/variables/defaultConfig.md @@ -12,4 +12,4 @@ > `const` **defaultConfig**: [`MermaidConfig`](../../mermaid/interfaces/MermaidConfig.md) -Defined in: [packages/mermaid/src/config.ts:8](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L8) +Defined in: [packages/mermaid/src/config.ts:9](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.ts#L9) diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 1e2a647b22f..49a59f48597 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:354](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L354) +Defined in: [packages/mermaid/src/defaultConfig.ts:358](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L358) diff --git a/docs/config/setup/mermaid/interfaces/Mermaid.md b/docs/config/setup/mermaid/interfaces/Mermaid.md index a99094d7ca7..d6bacb631c3 100644 --- a/docs/config/setup/mermaid/interfaces/Mermaid.md +++ b/docs/config/setup/mermaid/interfaces/Mermaid.md @@ -344,6 +344,10 @@ Defined in: [packages/mermaid/src/mermaid.ts:466](https://github.com/mermaid-js/ Defined in: [packages/mermaid/src/mermaid.ts:460](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaid.ts#L460) +Renders the diagram, holding the diagram config scope for exactly as long as the render. +Leaving it set would make `getConfig()` report the last diagram's appearance as the +global answer for every caller between renders. + #### Parameters ##### id @@ -362,12 +366,6 @@ Defined in: [packages/mermaid/src/mermaid.ts:460](https://github.com/mermaid-js/ `Promise`<[`RenderResult`](RenderResult.md)> -#### Deprecated - -- use the `mermaid.render` function instead of `mermaid.mermaidAPI.render` - -Deprecated for external use. - --- ### run() diff --git a/docs/config/theming.md b/docs/config/theming.md index d79f3cce7e7..b5051f1fa2d 100644 --- a/docs/config/theming.md +++ b/docs/config/theming.md @@ -12,15 +12,104 @@ Themes can now be customized at the site-wide level, or on individual Mermaid di ## Available Themes -1. [**default**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-default.js) - This is the default theme for all diagrams. +1. [**redux-color**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-redux-color.js) - The default theme for the diagram types listed under [Per-diagram defaults](#per-diagram-defaults). It pairs the `redux` geometry and typography with a categorical colour palette, so entities, actors, branches, classes, subgraph containers and chart series each get their own colour. -2. [**neutral**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-neutral.js) - This theme is great for black and white documents that will be printed. +2. [**redux-dark-color**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-redux-dark-color.js) - The dark counterpart of `redux-color`. -3. [**dark**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-dark.js) - This theme goes well with dark-colored elements or dark-mode. To use the dark theme (which changes the theme of the schema itself) with dark-mode (which sets the background), set `darkMode` to `true` in your config. +3. [**redux**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-redux.js) - The same geometry and typography as `redux-color`, but monochrome. Use this when you want the colour to carry meaning you assign yourself rather than being cycled per item. -4. [**forest**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-forest.js) - This theme contains shades of green. +4. [**redux-dark**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-redux-dark.js) - The dark counterpart of `redux`. -5. [**base**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-base.js) - This is the only theme that can be modified. Use this theme as the base for customizations. +5. [**default**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-default.js) - The long-standing Mermaid look, and still the default for every diagram type not listed under [Per-diagram defaults](#per-diagram-defaults). + +6. [**neutral**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-neutral.js) - This theme is great for black-and-white documents that will be printed. + +7. [**dark**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-dark.js) - This theme goes well with dark-colored elements or dark-mode. To use the dark theme (which changes the theme of the schema itself) with dark-mode (which sets the background), set `darkMode` to `true` in your config. + +8. [**forest**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-forest.js) - This theme contains shades of green. + +9. [**neo**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-neo.js) - A flatter, softer look, intended to be paired with `look: neo`. + +10. [**neo-dark**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-neo-dark.js) - The dark counterpart of `neo`. + +11. [**base**](https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/themes/theme-base.js) - This is the only theme that can be modified. Use this theme as the base for customizations. + +## Per-diagram defaults + +Not every diagram type defaults to the same theme and look. Since v\ +these do, to the `redux-color` theme and the `neo` look. The name on the left is the **config key** — what you write to +scope a setting to that diagram, which is not always the keyword the diagram starts with: + +| Config key | Diagram | +| ------------- | -------------------- | +| `agentflow` | `agentflow-beta` | +| `flowchart` | `flowchart` | +| `swimlane` | `swimlane-beta` | +| `class` | `classDiagram` | +| `er` | `erDiagram` | +| `requirement` | `requirementDiagram` | +| `sequence` | `sequenceDiagram` | +| `state` | `stateDiagram` | +| `usecase` | `usecase-beta` | +| `venn` | `venn-beta` | + +Every other diagram type defaults to the `default` theme and the `classic` look. + +`layout` works the same way. Only `swimlane` overrides it, to `swimlane`; everything else +uses the global default, `dagre`. + +These are only defaults, and the most specific thing you say wins. Highest priority first: + +1. The diagram's own frontmatter or `%%{init}%%` directive. +2. What you passed to `mermaid.initialize()`. +3. The diagram type's default, above. +4. The global default (`theme: default`, `look: classic`, `layout: dagre`). + +A layout that is not registered in the running build falls back to `dagre`, with a warning +in the console. `elk` ships as a separate package you register yourself, and `cose-bilkent` +is only bundled into builds that include the large features, so naming either as a default +does not require every build to carry it. + +Within each of the first two you can also scope a value to one diagram type, and the +scoped value wins over the global one you set alongside it. `theme`, `look` and `layout` +can all be set this way: + +```javascript +mermaid.initialize({ + look: 'classic', // everything is classic... + flowchart: { look: 'handDrawn' }, // ...except flowcharts + er: { theme: 'neutral' }, +}); +``` + +The same works in frontmatter, for one diagram: + +```yaml +--- +config: + flowchart: + look: handDrawn +--- +``` + +### Going back to the previous appearance + +The nine types above changed appearance when these became their defaults. To draw one the +way Mermaid drew it before, name the previous theme and look in its front matter: + +```yaml +--- +config: + theme: default + look: classic +--- +``` + +For every diagram on a page, pass the same two keys to `mermaid.initialize()`: + +```javascript +mermaid.initialize({ theme: 'default', look: 'classic' }); +``` ## Site-wide Theme diff --git a/docs/intro/syntax-reference.md b/docs/intro/syntax-reference.md index ffa5fe50347..13ef3c44d20 100644 --- a/docs/intro/syntax-reference.md +++ b/docs/intro/syntax-reference.md @@ -134,12 +134,15 @@ We've restructured how Mermaid renders diagrams, enabling new features like sele ### Selecting Diagram Looks -Mermaid offers a variety of styles or “looks” for your diagrams, allowing you to tailor the visual appearance to match your specific needs or preferences. Whether you prefer a hand-drawn or classic style, you can easily customize your diagrams. +Mermaid offers a variety of styles or “looks” for your diagrams, allowing you to tailor the visual appearance to match your specific needs or preferences. **Available Looks:** +- Neo Look: A flatter, softer style with rounded corners and subtle shadows, designed to pair with the `redux-color` theme family. It is the default for the diagram types listed under [Per-diagram defaults](../config/theming.md#per-diagram-defaults). - Hand-Drawn Look: For a more personal, creative touch, the hand-drawn look brings a sketch-like quality to your diagrams. This style is perfect for informal settings or when you want to add a bit of personality to your diagrams. -- Classic Look: If you prefer the traditional Mermaid style, the classic look maintains the original appearance that many users are familiar with. It’s great for consistency across projects or when you want to keep the familiar aesthetic. +- Classic Look: If you prefer the traditional Mermaid style, the classic look maintains the original appearance that many users are familiar with. It’s great for consistency across projects or when you want to keep the familiar aesthetic. It is the default for every other diagram type. + +Note that the `neo` look paints node strokes with a gradient when the active theme sets `useGradient`, which `base` does by default. Setting a custom `nodeBorder` on `base` turns the gradient off so your colour is what shows; set `useGradient: true` alongside it if you want to keep the gradient. **How to Select a Look:** diff --git a/docs/syntax/agentflow.md b/docs/syntax/agentflow.md index 6536bb5cc94..2c14c28c72f 100644 --- a/docs/syntax/agentflow.md +++ b/docs/syntax/agentflow.md @@ -46,6 +46,119 @@ agentflow-beta TB end ``` +## Default theme and look (v\+) + +Agentflow diagrams use the `redux-color` theme and the `neo` look by default. Not every +diagram type does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for +the list and for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +agentflow-beta TB + brief["Release brief"]@{ shape: input } + flow writer["Drafting Agent"] + draft["Draft the notes"]@{ shape: task } + lookup["changelog_search"]@{ shape: tool } + guide["Tone of voice"]@{ shape: refdoc } + draft --> lookup + draft -.- guide + end + flow reviewer["Review Agent"] + check["Check the claims"]@{ shape: task } + ok["Accurate?"]@{ shape: decision } + check --> ok + end + publish["Publish"]@{ shape: action } + brief --> writer + writer --> reviewer + ok --> publish +``` + +```mermaid +agentflow-beta TB + brief["Release brief"]@{ shape: input } + flow writer["Drafting Agent"] + draft["Draft the notes"]@{ shape: task } + lookup["changelog_search"]@{ shape: tool } + guide["Tone of voice"]@{ shape: refdoc } + draft --> lookup + draft -.- guide + end + flow reviewer["Review Agent"] + check["Check the claims"]@{ shape: task } + ok["Accurate?"]@{ shape: decision } + check --> ok + end + publish["Publish"]@{ shape: action } + brief --> writer + writer --> reviewer + ok --> publish +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +agentflow-beta TB + brief["Release brief"]@{ shape: input } + flow writer["Drafting Agent"] + draft["Draft the notes"]@{ shape: task } + lookup["changelog_search"]@{ shape: tool } + guide["Tone of voice"]@{ shape: refdoc } + draft --> lookup + draft -.- guide + end + flow reviewer["Review Agent"] + check["Check the claims"]@{ shape: task } + ok["Accurate?"]@{ shape: decision } + check --> ok + end + publish["Publish"]@{ shape: action } + brief --> writer + writer --> reviewer + ok --> publish +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +agentflow-beta TB + brief["Release brief"]@{ shape: input } + flow writer["Drafting Agent"] + draft["Draft the notes"]@{ shape: task } + lookup["changelog_search"]@{ shape: tool } + guide["Tone of voice"]@{ shape: refdoc } + draft --> lookup + draft -.- guide + end + flow reviewer["Review Agent"] + check["Check the claims"]@{ shape: task } + ok["Accurate?"]@{ shape: decision } + check --> ok + end + publish["Publish"]@{ shape: action } + brief --> writer + writer --> reviewer + ok --> publish +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ agentflow: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Declaring a diagram Every diagram starts with the `agentflow-beta` keyword, optionally followed by a direction — `TB`, `TD`, `BT`, `LR`, or `RL`. diff --git a/docs/syntax/classDiagram.md b/docs/syntax/classDiagram.md index 2906ce034ba..e62f5e4fe03 100644 --- a/docs/syntax/classDiagram.md +++ b/docs/syntax/classDiagram.md @@ -72,6 +72,127 @@ classDiagram } ``` +## Default theme and look (v\+) + +Class diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +classDiagram + class Customer { + +String name + +String email + } + class Order { + +String id + +Date placedAt + +total() Money + } + class LineItem { + +int quantity + } + class Payment { + <> + +authorise() bool + } + Customer "1" --> "*" Order : places + Order "1" *-- "*" LineItem : contains + Order --> Payment : settled by +``` + +```mermaid +classDiagram + class Customer { + +String name + +String email + } + class Order { + +String id + +Date placedAt + +total() Money + } + class LineItem { + +int quantity + } + class Payment { + <> + +authorise() bool + } + Customer "1" --> "*" Order : places + Order "1" *-- "*" LineItem : contains + Order --> Payment : settled by +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +classDiagram + class Customer { + +String name + +String email + } + class Order { + +String id + +Date placedAt + +total() Money + } + class LineItem { + +int quantity + } + class Payment { + <> + +authorise() bool + } + Customer "1" --> "*" Order : places + Order "1" *-- "*" LineItem : contains + Order --> Payment : settled by +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +classDiagram + class Customer { + +String name + +String email + } + class Order { + +String id + +Date placedAt + +total() Money + } + class LineItem { + +int quantity + } + class Payment { + <> + +authorise() bool + } + Customer "1" --> "*" Order : places + Order "1" *-- "*" LineItem : contains + Order --> Payment : settled by +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ class: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Syntax ### Class diff --git a/docs/syntax/entityRelationshipDiagram.md b/docs/syntax/entityRelationshipDiagram.md index 18b6dd78084..5798307e0cf 100644 --- a/docs/syntax/entityRelationshipDiagram.md +++ b/docs/syntax/entityRelationshipDiagram.md @@ -80,6 +80,127 @@ erDiagram When including attributes on ER diagrams, you must decide whether to include foreign keys as attributes. This probably depends on how closely you are trying to represent relational table structures. If your diagram is a _logical_ model which is not meant to imply a relational implementation, then it is better to leave these out because the associative relationships already convey the way that entities are associated. For example, a JSON data structure can implement a one-to-many relationship without the need for foreign key properties, using arrays. Similarly an object-oriented programming language may use pointers or references to collections. Even for models that are intended for relational implementation, you might decide that inclusion of foreign key attributes duplicates information already portrayed by the relationships, and does not add meaning to entities. Ultimately, it's your choice. +## Default theme and look (v\+) + +Entity relationship diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "appears in" + CUSTOMER { + string name + string email + } + ORDER { + int id + date placedAt + } + LINE_ITEM { + int quantity + float price + } + PRODUCT { + string sku + string title + } +``` + +```mermaid +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "appears in" + CUSTOMER { + string name + string email + } + ORDER { + int id + date placedAt + } + LINE_ITEM { + int quantity + float price + } + PRODUCT { + string sku + string title + } +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "appears in" + CUSTOMER { + string name + string email + } + ORDER { + int id + date placedAt + } + LINE_ITEM { + int quantity + float price + } + PRODUCT { + string sku + string title + } +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "appears in" + CUSTOMER { + string name + string email + } + ORDER { + int id + date placedAt + } + LINE_ITEM { + int quantity + float price + } + PRODUCT { + string sku + string title + } +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ er: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Syntax ### Entities and Relationships diff --git a/docs/syntax/flowchart.md b/docs/syntax/flowchart.md index aaad8d83b14..3432bb0caaa 100644 --- a/docs/syntax/flowchart.md +++ b/docs/syntax/flowchart.md @@ -144,6 +144,123 @@ Possible FlowChart orientations are: - RL - Right to left - LR - Left to right +## Default theme and look (v\+) + +Flowcharts use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +flowchart LR + subgraph Client + UI[Web app] + Cache[(Local cache)] + end + subgraph Services + API[API gateway] + Auth[Auth service] + Orders[Order service] + end + subgraph Storage + DB[(Orders DB)] + end + UI --> API + UI --> Cache + API --> Auth + API --> Orders + Orders --> DB + Auth -. token .-> UI +``` + +```mermaid +flowchart LR + subgraph Client + UI[Web app] + Cache[(Local cache)] + end + subgraph Services + API[API gateway] + Auth[Auth service] + Orders[Order service] + end + subgraph Storage + DB[(Orders DB)] + end + UI --> API + UI --> Cache + API --> Auth + API --> Orders + Orders --> DB + Auth -. token .-> UI +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +flowchart LR + subgraph Client + UI[Web app] + Cache[(Local cache)] + end + subgraph Services + API[API gateway] + Auth[Auth service] + Orders[Order service] + end + subgraph Storage + DB[(Orders DB)] + end + UI --> API + UI --> Cache + API --> Auth + API --> Orders + Orders --> DB + Auth -. token .-> UI +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +flowchart LR + subgraph Client + UI[Web app] + Cache[(Local cache)] + end + subgraph Services + API[API gateway] + Auth[Auth service] + Orders[Order service] + end + subgraph Storage + DB[(Orders DB)] + end + UI --> API + UI --> Cache + API --> Auth + API --> Orders + Orders --> DB + Auth -. token .-> UI +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ flowchart: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Node shapes ### A node with round edges diff --git a/docs/syntax/gitgraph.md b/docs/syntax/gitgraph.md index ffd8df3c3df..11e7564cfb9 100644 --- a/docs/syntax/gitgraph.md +++ b/docs/syntax/gitgraph.md @@ -1130,14 +1130,20 @@ Mermaid supports a bunch of pre-defined themes which you can use to find the rig The following are the different pre-defined theme options: -- `base` -- `forest` -- `dark` +- `redux-color` (the default) +- `redux-dark-color` +- `redux` +- `redux-dark` - `default` - `neutral` +- `dark` +- `forest` +- `neo` +- `neo-dark` +- `base` **NOTE**: To change theme you can either use the `initialize` call or _directives_. Learn more about [directives](../config/directives.md) -Let's put them to use, and see how our sample diagram looks in different themes: +Let's put a few of them to use, and see how our sample diagram looks in different themes: ### Base Theme @@ -1343,7 +1349,7 @@ config: merge release ``` -### Default Theme +### The `default` Theme ```mermaid-example --- @@ -1655,7 +1661,7 @@ Mermaid allows you to customize your diagram using theme variables which govern For understanding let us take a sample diagram with theme `default`, the default values of the theme variables is picked automatically from the theme. Later on we will see how to override the default values of the theme variables. -See how the default theme is used to set the colors for the branches: +See how the `default` theme is used to set the colors for the branches: ```mermaid-example --- @@ -1778,7 +1784,7 @@ See how the branch colors are changed to the values specified in the theme varia You can customize the branch label colors using the `gitBranchLabel0` to `gitBranchLabel7` theme variables. Mermaid allows you to set the colors for up-to 8 branches, where `gitBranchLabel0` variable will drive the value of the first branch label, `gitBranchLabel1` will drive the value of the second branch label and so on. -Lets see how the default theme is used to set the colors for the branch labels: +Lets see how the `default` theme is used to set the colors for the branch labels: Now let's override the default values for the `gitBranchLabel0` to `gitBranchLabel2` variables: diff --git a/docs/syntax/requirementDiagram.md b/docs/syntax/requirementDiagram.md index 36420d9b9e2..0c68cd816cb 100644 --- a/docs/syntax/requirementDiagram.md +++ b/docs/syntax/requirementDiagram.md @@ -44,6 +44,119 @@ Rendering requirements is straightforward. test_entity - satisfies -> test_req ``` +## Default theme and look (v\+) + +Requirement diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +requirementDiagram + requirement checkout_req { + id: 1 + text: Orders must be payable online. + risk: high + verifymethod: test + } + functionalRequirement payment_req { + id: 1.1 + text: Card payments must be authorised. + risk: high + verifymethod: test + } + element checkout_service { + type: service + } + checkout_req - contains -> payment_req + checkout_service - satisfies -> payment_req +``` + +```mermaid +requirementDiagram + requirement checkout_req { + id: 1 + text: Orders must be payable online. + risk: high + verifymethod: test + } + functionalRequirement payment_req { + id: 1.1 + text: Card payments must be authorised. + risk: high + verifymethod: test + } + element checkout_service { + type: service + } + checkout_req - contains -> payment_req + checkout_service - satisfies -> payment_req +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +requirementDiagram + requirement checkout_req { + id: 1 + text: Orders must be payable online. + risk: high + verifymethod: test + } + functionalRequirement payment_req { + id: 1.1 + text: Card payments must be authorised. + risk: high + verifymethod: test + } + element checkout_service { + type: service + } + checkout_req - contains -> payment_req + checkout_service - satisfies -> payment_req +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +requirementDiagram + requirement checkout_req { + id: 1 + text: Orders must be payable online. + risk: high + verifymethod: test + } + functionalRequirement payment_req { + id: 1.1 + text: Card payments must be authorised. + risk: high + verifymethod: test + } + element checkout_service { + type: service + } + checkout_req - contains -> payment_req + checkout_service - satisfies -> payment_req +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ requirement: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Syntax There are three types of components to a requirement diagram: requirement, element, and relationship. diff --git a/docs/syntax/sequenceDiagram.md b/docs/syntax/sequenceDiagram.md index 47ce7ba21d5..6b31852d49b 100644 --- a/docs/syntax/sequenceDiagram.md +++ b/docs/syntax/sequenceDiagram.md @@ -29,6 +29,107 @@ sequenceDiagram > > If unavoidable, one must use parentheses(), quotation marks "", or brackets {},\[], to enclose the word "end". i.e : (end), \[end], {end}. +## Default theme and look (v\+) + +Sequence diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +sequenceDiagram + autonumber + actor Customer + participant Web as Web app + participant API as API gateway + participant Bank + Customer->>Web: Place order + Web->>API: POST /orders + activate API + API->>Bank: Authorise payment + Bank-->>API: Approved + API-->>Web: 201 Created + deactivate API + Web-->>Customer: Order confirmed + Note over Customer,Bank: One order, one transaction +``` + +```mermaid +sequenceDiagram + autonumber + actor Customer + participant Web as Web app + participant API as API gateway + participant Bank + Customer->>Web: Place order + Web->>API: POST /orders + activate API + API->>Bank: Authorise payment + Bank-->>API: Approved + API-->>Web: 201 Created + deactivate API + Web-->>Customer: Order confirmed + Note over Customer,Bank: One order, one transaction +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +sequenceDiagram + autonumber + actor Customer + participant Web as Web app + participant API as API gateway + participant Bank + Customer->>Web: Place order + Web->>API: POST /orders + activate API + API->>Bank: Authorise payment + Bank-->>API: Approved + API-->>Web: 201 Created + deactivate API + Web-->>Customer: Order confirmed + Note over Customer,Bank: One order, one transaction +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +sequenceDiagram + autonumber + actor Customer + participant Web as Web app + participant API as API gateway + participant Bank + Customer->>Web: Place order + Web->>API: POST /orders + activate API + API->>Bank: Authorise payment + Bank-->>API: Approved + API-->>Web: 201 Created + deactivate API + Web-->>Customer: Order confirmed + Note over Customer,Bank: One order, one transaction +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ sequence: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Syntax ### Participants diff --git a/docs/syntax/stateDiagram.md b/docs/syntax/stateDiagram.md index 6bec01cbb64..c11aa5f1b62 100644 --- a/docs/syntax/stateDiagram.md +++ b/docs/syntax/stateDiagram.md @@ -70,6 +70,91 @@ a _transition._ The example diagram above shows three states: **Still**, **Movin **Still** state. From **Still** you can change to the **Moving** state. From **Moving** you can change either back to the **Still** state or to the **Crash** state. There is no transition from **Still** to **Crash**. (You can't crash if you're still.) +## Default theme and look (v\+) + +State diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +stateDiagram-v2 + [*] --> Draft + Draft --> Submitted : submit + state Review { + [*] --> Screening + Screening --> Decision + } + Submitted --> Review + Review --> Published : approved + Review --> Draft : rejected + Published --> [*] +``` + +```mermaid +stateDiagram-v2 + [*] --> Draft + Draft --> Submitted : submit + state Review { + [*] --> Screening + Screening --> Decision + } + Submitted --> Review + Review --> Published : approved + Review --> Draft : rejected + Published --> [*] +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +stateDiagram-v2 + [*] --> Draft + Draft --> Submitted : submit + state Review { + [*] --> Screening + Screening --> Decision + } + Submitted --> Review + Review --> Published : approved + Review --> Draft : rejected + Published --> [*] +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +stateDiagram-v2 + [*] --> Draft + Draft --> Submitted : submit + state Review { + [*] --> Screening + Screening --> Decision + } + Submitted --> Review + Review --> Published : approved + Review --> Draft : rejected + Published --> [*] +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ state: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## States A state can be declared in multiple ways. The simplest way is to define a state with just an id: diff --git a/docs/syntax/swimlanes.md b/docs/syntax/swimlanes.md index 6a9177c2462..15b4b8458d1 100644 --- a/docs/syntax/swimlanes.md +++ b/docs/syntax/swimlanes.md @@ -13,9 +13,112 @@ A swimlane diagram shows a process divided by responsibility. Each lane represen Use swimlane diagrams when the most important question is not only "what happens next?" but also "who owns this step?" They are useful for approval flows, support processes, delivery workflows, and any process where work crosses teams or systems. -## Basic Example +## Default theme and look (v\+) + +Swimlane diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +swimlane-beta LR + subgraph Customer + Browse[Browse catalogue] + Pay[Pay] + end + subgraph Warehouse + Pick[Pick items] + Ship[Ship order] + end + subgraph Finance + Invoice[Raise invoice] + end + Browse --> Pay + Pay --> Pick + Pick --> Ship + Pay --> Invoice +``` + +```mermaid +swimlane-beta LR + subgraph Customer + Browse[Browse catalogue] + Pay[Pay] + end + subgraph Warehouse + Pick[Pick items] + Ship[Ship order] + end + subgraph Finance + Invoice[Raise invoice] + end + Browse --> Pay + Pay --> Pick + Pick --> Ship + Pay --> Invoice +``` -> The rendered examples on this page use the **Neo** look and the **Redux** theme. Out of the box, swimlanes use your configured default look and theme. +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +swimlane-beta LR + subgraph Customer + Browse[Browse catalogue] + Pay[Pay] + end + subgraph Warehouse + Pick[Pick items] + Ship[Ship order] + end + subgraph Finance + Invoice[Raise invoice] + end + Browse --> Pay + Pay --> Pick + Pick --> Ship + Pay --> Invoice +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +swimlane-beta LR + subgraph Customer + Browse[Browse catalogue] + Pay[Pay] + end + subgraph Warehouse + Pick[Pick items] + Ship[Ship order] + end + subgraph Finance + Invoice[Raise invoice] + end + Browse --> Pay + Pay --> Pick + Pick --> Ship + Pay --> Invoice +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ swimlane: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + +## Basic Example ```mermaid-example swimlane-beta LR diff --git a/docs/syntax/timeline.md b/docs/syntax/timeline.md index edb0bccb3ab..6191d7d9b56 100644 --- a/docs/syntax/timeline.md +++ b/docs/syntax/timeline.md @@ -367,14 +367,20 @@ Mermaid supports a bunch of pre-defined themes which you can use to find the rig The following are the different pre-defined theme options: -- `base` -- `forest` -- `dark` +- `redux-color` (the default) +- `redux-dark-color` +- `redux` +- `redux-dark` - `default` - `neutral` +- `dark` +- `forest` +- `neo` +- `neo-dark` +- `base` **NOTE**: To change theme you can either use the `initialize` call or _directives_. Learn more about [directives](../config/directives.md) -Let's put them to use, and see how our sample diagram looks in different themes: +Let's put a few of them to use, and see how our sample diagram looks in different themes: ### Base Theme @@ -484,7 +490,7 @@ config: 2010 : Pinterest ``` -### Default Theme +### The `default` Theme ```mermaid-example --- diff --git a/docs/syntax/usecase.md b/docs/syntax/usecase.md index 4178872df47..ca09adb57b2 100644 --- a/docs/syntax/usecase.md +++ b/docs/syntax/usecase.md @@ -32,6 +32,111 @@ Customer --> Checkout Use `direction` with `TD`, `TB`, `BT`, `LR`, or `RL` to choose the layout direction. +## Default theme and look (v\+) + +Use case diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +usecase-beta +direction LR +actor Customer +actor Support +systemBoundary Storefront + Browse("Browse catalogue") + Checkout("Checkout") +end +systemBoundary Fulfilment + Track("Track delivery") +end +Customer --> Browse +Customer --> Checkout +Customer --> Track +Support --> Track +Checkout ..> : include Browse +``` + +```mermaid +usecase-beta +direction LR +actor Customer +actor Support +systemBoundary Storefront + Browse("Browse catalogue") + Checkout("Checkout") +end +systemBoundary Fulfilment + Track("Track delivery") +end +Customer --> Browse +Customer --> Checkout +Customer --> Track +Support --> Track +Checkout ..> : include Browse +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +usecase-beta +direction LR +actor Customer +actor Support +systemBoundary Storefront + Browse("Browse catalogue") + Checkout("Checkout") +end +systemBoundary Fulfilment + Track("Track delivery") +end +Customer --> Browse +Customer --> Checkout +Customer --> Track +Support --> Track +Checkout ..> : include Browse +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +usecase-beta +direction LR +actor Customer +actor Support +systemBoundary Storefront + Browse("Browse catalogue") + Checkout("Checkout") +end +systemBoundary Fulfilment + Track("Track delivery") +end +Customer --> Browse +Customer --> Checkout +Customer --> Track +Support --> Track +Checkout ..> : include Browse +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ usecase: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Actors and use cases Actor and use case identifiers match `[A-Za-z0-9_]+`, so they may start with a digit — `1`, `1mg`, and `3rd` are all valid. Identifiers are diagram-wide and are shared by actors, use cases, boundaries, JSON nodes, and explicit edges. @@ -510,6 +615,129 @@ Backgrounds come from the active theme, so a hardcoded `fill` is tied to the the Actor metadata is typed and is not a style map. `fillColor`, `strokeColor`, `strokeWidth`, and arbitrary actor metadata keys are errors. Use `classDef`, `class`, or `style` instead. +## Colors + +Colour is keyed to the kind of element, not to the order elements are declared. Actors take +one colour, use cases another, and system boundaries a third, so the colour of an element +says what it is and does not change when you edit the diagram around it. Adding a use case +in the middle of a document leaves every other element exactly as it was, which keeps +diffs, documentation screenshots, and visual baselines stable. + +Each role reads a pair of theme variables. A theme that sets none of them renders as it +always did, and you can override any of them through `themeVariables`. + +| Theme variable | Applies to | +| ----------------------- | ----------------------------------------- | +| `usecaseActorBkg` | Actor glyph fill | +| `usecaseActorBorder` | Actor glyph stroke | +| `usecaseBkg` | Use case body fill | +| `usecaseBorder` | Use case body stroke | +| `usecaseBoundaryBkg` | System boundary fill, when not numbered | +| `usecaseBoundaryBorder` | System boundary stroke, when not numbered | +| `usecaseIncludeLine` | `include` relationship stroke | +| `usecaseExtendLine` | `extend` relationship stroke | + +`include` and `extend` are both dashed, which is hard to tell apart at small sizes, so the +colour themes give them separate hues as well. + +System boundaries are the exception to the rule above: they are numbered rather than given +one shared colour. The first boundary in the document takes the first colour of the theme's +palette, the second takes the second, and so on, exactly as flowchart subgraphs are +numbered. For a container the number means something — it says which group an element +belongs to — and it stays put as long as the order of the boundaries does, so adding an +actor or a use case anywhere leaves it alone. `usecaseBoundaryBkg` and +`usecaseBoundaryBorder` are the fallback for themes that carry no palette to number with. + +```mermaid-example +--- +config: + theme: redux-color +--- +usecase-beta +direction LR +actor Customer +systemBoundary Catalogue + Browse("Browse catalogue") +end +systemBoundary Payment + Checkout("Checkout") +end +Customer --> Browse +Browse --> Checkout +Checkout ..> : include Browse +``` + +```mermaid +--- +config: + theme: redux-color +--- +usecase-beta +direction LR +actor Customer +systemBoundary Catalogue + Browse("Browse catalogue") +end +systemBoundary Payment + Checkout("Checkout") +end +Customer --> Browse +Browse --> Checkout +Checkout ..> : include Browse +``` + +### Per-element colour rotation + +Set `colorScheme: rotate` to extend the numbering from the boundaries to the actors and use +cases as well, so every element takes its own slot from the theme's categorical palette, the +way entity relationship and class diagrams are coloured. Actors and use cases share one +cycle, with the actors numbered first and the use cases after them, each in declaration +order. An `actor A`, `usecase U`, `actor B` written in that order is therefore numbered A, +B, U rather than A, U, B. This buys per-element variety at the cost of the stability +described above: inserting an element shifts the colour of every later element of its own +kind, and inserting an actor shifts the use cases too. Boundaries keep their own numbering +either way. Only the colour themes (`redux-color` and `redux-dark-color`) carry a palette, +so on any other theme the two settings render identically. + +```mermaid-example +--- +config: + theme: redux-color + usecase: + colorScheme: rotate +--- +usecase-beta +direction LR +actor Customer +actor Auditor +Browse("Browse catalogue") +Checkout("Checkout") +Customer --> Browse +Browse --> Checkout +Auditor --> Checkout +``` + +```mermaid +--- +config: + theme: redux-color + usecase: + colorScheme: rotate +--- +usecase-beta +direction LR +actor Customer +actor Auditor +Browse("Browse catalogue") +Checkout("Checkout") +Customer --> Browse +Browse --> Checkout +Auditor --> Checkout +``` + +`classDef` and `style` override both schemes, so per-element semantic colour stays +available whichever one is active. See [Styling](#styling). + ## Configuration Use case diagrams accept these diagram configuration keys: @@ -525,6 +753,7 @@ Use case diagrams accept these diagram configuration keys: | `nodeSpacing` | `50` | Spacing between nodes on the same level | | `rankSpacing` | `50` | Spacing between layout ranks | | `diagramPadding` | `20` | Padding around the diagram | +| `colorScheme` | `role` | How the diagram takes colour from the theme | | `useMaxWidth` | `true` | Whether the SVG scales to the available width | ```mermaid-example @@ -540,6 +769,7 @@ config: nodeSpacing: 60 rankSpacing: 70 diagramPadding: 24 + colorScheme: role useMaxWidth: false --- usecase-beta @@ -562,6 +792,7 @@ config: nodeSpacing: 60 rankSpacing: 70 diagramPadding: 24 + colorScheme: role useMaxWidth: false --- usecase-beta diff --git a/docs/syntax/venn.md b/docs/syntax/venn.md index 518f561dea4..38211b79198 100644 --- a/docs/syntax/venn.md +++ b/docs/syntax/venn.md @@ -11,6 +11,83 @@ Venn diagrams show relationships between sets using overlapping circles. > **Warning** > This is a new diagram type in Mermaid. Its syntax may evolve in future versions. +## Default theme and look (v\+) + +Venn diagrams use the `redux-color` theme and the `neo` look by default. Not every diagram type +does — see [Per-diagram defaults](../config/theming.md#per-diagram-defaults) for the list and +for the order in which Mermaid decides. + +The same diagram, drawn both ways: + +### With the defaults + +```mermaid-example +venn-beta + title What makes a good feature + set Desirable + set Feasible + set Viable + union Desirable,Feasible["Buildable"] + union Feasible,Viable["Sustainable"] + union Desirable,Viable["Marketable"] + union Desirable,Feasible,Viable["Ship it"] +``` + +```mermaid +venn-beta + title What makes a good feature + set Desirable + set Feasible + set Viable + union Desirable,Feasible["Buildable"] + union Feasible,Viable["Sustainable"] + union Desirable,Viable["Marketable"] + union Desirable,Feasible,Viable["Ship it"] +``` + +### The previous appearance + +Both are only defaults, so anything you set yourself wins. Naming the previous theme and look +in a diagram's front matter draws it the way Mermaid did before: + +```mermaid-example +--- +config: + theme: default + look: classic +--- +venn-beta + title What makes a good feature + set Desirable + set Feasible + set Viable + union Desirable,Feasible["Buildable"] + union Feasible,Viable["Sustainable"] + union Desirable,Viable["Marketable"] + union Desirable,Feasible,Viable["Ship it"] +``` + +```mermaid +--- +config: + theme: default + look: classic +--- +venn-beta + title What makes a good feature + set Desirable + set Feasible + set Viable + union Desirable,Feasible["Buildable"] + union Feasible,Viable["Sustainable"] + union Desirable,Viable["Marketable"] + union Desirable,Feasible,Viable["Ship it"] +``` + +Passing the same two keys to `mermaid.initialize()` does it for every diagram on the page, +and scoping them to one diagram type — `mermaid.initialize({ venn: { theme: 'default', look: 'classic' } })` — +does it for that type alone. + ## Syntax - Start with `venn-beta`. diff --git a/e2e/diagrams/sequence/should-align-multiline-actor-labels-on-the-datum-neo.mmd b/e2e/diagrams/sequence/should-align-multiline-actor-labels-on-the-datum-neo.mmd new file mode 100644 index 00000000000..76dcfa643b5 --- /dev/null +++ b/e2e/diagrams/sequence/should-align-multiline-actor-labels-on-the-datum-neo.mmd @@ -0,0 +1,13 @@ +--- +config: + theme: redux-color + look: neo +--- +sequenceDiagram + actor U as Multi
line
actor + participant P as Plain + participant B@{ "type" : "boundary" } as Single + participant C@{ "type" : "control" } as Two
lines + participant DB@{ "type" : "database" } as Database + U ->> P: the lifelines should share one start + P ->> DB: and one end diff --git a/e2e/diagrams/sequence/should-render-actor-and-database-aligned-on-neo.mmd b/e2e/diagrams/sequence/should-render-actor-and-database-aligned-on-neo.mmd new file mode 100644 index 00000000000..4bc63f852fb --- /dev/null +++ b/e2e/diagrams/sequence/should-render-actor-and-database-aligned-on-neo.mmd @@ -0,0 +1,19 @@ +--- +config: + theme: redux-color + look: neo +--- +sequenceDiagram + actor User + participant API + participant DB@{ "type" : "database" } as Database + Note over User: A note on the actor + rect + User ->> API: request + API ->> DB: query + end + Note over API,DB: A wider note + rect rgb(200, 230, 255) + DB -->> API: rows + end + API -->> User: response diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/01-basic-example.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/01-basic-example.mmd new file mode 100644 index 00000000000..52b188c295e --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/01-basic-example.mmd @@ -0,0 +1,9 @@ +agentflow-beta TB + flow reviewer["Review Agent"] + changes["Gather changes"]@{ shape: input } + analyse["Analyse diff"]@{ shape: task } + lint["run_linter"]@{ shape: tool } + ok["Clean?"]@{ shape: decision } + + changes --> analyse --> lint --> ok + end diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/02-nodes-and-shapes.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/02-nodes-and-shapes.mmd new file mode 100644 index 00000000000..5d2ab6346aa --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/02-nodes-and-shapes.mmd @@ -0,0 +1,9 @@ +agentflow-beta LR + brief["Brief"]@{ shape: input } + draft["Draft copy"]@{ shape: task } + spellcheck["spell_check"]@{ shape: tool } + guide["Style guide"]@{ shape: refdoc } + publish["Publish"]@{ shape: action } + + brief --> draft --> spellcheck --> publish + draft -.- guide diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/03-edges.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/03-edges.mmd new file mode 100644 index 00000000000..5053012127c --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/03-edges.mmd @@ -0,0 +1,10 @@ +agentflow-beta TB + check["Tests pass?"]@{ shape: decision } + ship["Ship it"]@{ shape: action } + fix["Fix the build"]@{ shape: task } + logs["Build logs"]@{ shape: refdoc } + + check -- yes --> ship + check -- no --> fix + fix --x check + fix -.- logs diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/04-containers.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/04-containers.mmd new file mode 100644 index 00000000000..2168102ba31 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/04-containers.mmd @@ -0,0 +1,12 @@ +agentflow-beta TB + flow team["Content Team"] + flow researcher["Researcher"] + gather["Gather sources"]@{ shape: task } + end + + flow writer["Writer"] + compose["Compose draft"]@{ shape: task } + end + + researcher --> writer + end diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/05-the-global-block.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/05-the-global-block.mmd new file mode 100644 index 00000000000..679ec35466f --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/05-the-global-block.mmd @@ -0,0 +1,14 @@ +agentflow-beta TB + global + corpus["Shared corpus"]@{ shape: refdoc } + end + + flow summariser["Summariser"] + summarise["Summarise"]@{ shape: task } + summarise -.- corpus + end + + flow indexer["Indexer"] + index["Build index"]@{ shape: task } + index -.- corpus + end diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/06-collapsing-a-container.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/06-collapsing-a-container.mmd new file mode 100644 index 00000000000..5b4eb9fc45b --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/06-collapsing-a-container.mmd @@ -0,0 +1,18 @@ +agentflow-beta TB + flow intake["Intake"] + receive["Receive request"]@{ shape: input } + end + + flow processing["Processing"] + validate["Validate"]@{ shape: task } + enrich["Enrich"]@{ shape: task } + validate --> enrich + end + processing@{ view: "collapsed" } + + flow output["Output"] + publish["Publish"]@{ shape: action } + end + + receive --> validate + enrich --> publish diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/07-connectors.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/07-connectors.mmd new file mode 100644 index 00000000000..8d2c826ec0c --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/07-connectors.mmd @@ -0,0 +1,8 @@ +agentflow-beta LR + connector github["GitHub API"] + github@{ protocol: "http", endpoint: "https://api.github.com" } + + title["Issue title"]@{ shape: input } + create["create_issue"]@{ shape: tool, connectorRef: "github.create_issue" } + + title --> create diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/08-a-worked-example.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/08-a-worked-example.mmd new file mode 100644 index 00000000000..f4e7fe532eb --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/08-a-worked-example.mmd @@ -0,0 +1,22 @@ +agentflow-beta TB + connector llm["LLM API"] + llm@{ protocol: "http", endpoint: "https://api.example.com/chat" } + + flow coffee_team["Coffee Team"] + city["city"]@{ shape: input, value: "Stockholm" } + + flow researcher["Researcher"] + research["research_location"]@{ shape: tool, params: "city :: String", returns: "Report" } + write["write_copy"]@{ shape: tool, connectorRef: "llm.chat", returns: "CoffeeCopy" } + city --> research --> write + end + researcher@{ instruction: "Research the city and draft English coffee copy citing sources." } + + flow designer["Designer"] + render["generate_html"]@{ shape: tool, connectorRef: "llm.chat", returns: "String" } + brand["Nordic Brand Guide"]@{ shape: refdoc } + render -.- brand + end + + researcher --> designer + end diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/09-render-the-v08-node-shapes.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/09-render-the-v08-node-shapes.mmd new file mode 100644 index 00000000000..6cf997e9ac5 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/09-render-the-v08-node-shapes.mmd @@ -0,0 +1,18 @@ +agentflow-beta TB + city["City Input"] + research["research_location"] + brief["Research Brief"] + style_ref["Nordic Design"] + check["Quality OK?"] + post["post_results"] + + city --> research --> brief + research -.- style_ref + brief --> check + check -- yes --> post + + city@{ shape: input } + research@{ shape: tool } + style_ref@{ shape: refdoc } + check@{ shape: decision } + post@{ shape: action } diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/10-every-container-kind.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/10-every-container-kind.mmd new file mode 100644 index 00000000000..ecae56ae0ab --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/10-every-container-kind.mmd @@ -0,0 +1,22 @@ +agentflow-beta TB + flow orchestrator["Orchestrator"] + gather["Gather Input"]@{ shape: input } + plan["Plan"]@{ shape: task } + decide["Ready?"]@{ shape: decision } + gather --> plan --> decide + end + + flow workers["Workers"] + search["web_search"]@{ shape: tool } + notes["Notes"]@{ shape: refdoc } + search -.- notes + end + + flow archive["Archive"] + store["Store"] + end + archive@{ view: "collapsed" } + + decide --> search + notes --> archive + decide --x archive diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/11-redirect-cross-boundary-edges-to-the-collapsed-parent.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/11-redirect-cross-boundary-edges-to-the-collapsed-parent.mmd new file mode 100644 index 00000000000..0ebf331ba60 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/11-redirect-cross-boundary-edges-to-the-collapsed-parent.mmd @@ -0,0 +1,19 @@ +%% Edges crossing a collapsed container's boundary must terminate at the +%% collapsed node rather than being dropped. +agentflow-beta TB + flow researcher["Research Agent"] + patterns["Pattern Research"]@{ shape: refdoc } + end + + flow reviewer["Review Agent"] + validate["validate_patterns"]@{ shape: tool } + reviewed["Reviewed Patterns"] + patterns --> validate --> reviewed + end + + flow reporter["Report Agent"] + compile["compile_report"]@{ shape: tool } + reviewed --> compile + end + + reviewer@{ view: "collapsed" } diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/12-keep-a-global-scoped-node-outside-the-flow-container.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/12-keep-a-global-scoped-node-outside-the-flow-container.mmd new file mode 100644 index 00000000000..5ae7e11d7eb --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/12-keep-a-global-scoped-node-outside-the-flow-container.mmd @@ -0,0 +1,11 @@ +%% Without the `global` block, referencing A inside the flow would pull it +%% into the container. Declaring it global anchors it at root level. +agentflow-beta TB + +global + A["Shared input"] +end + +flow pipeline["Pipeline"] + A --> B["Process"] --> C["Publish"] +end diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/13-render-the-input-value-pattern.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/13-render-the-input-value-pattern.mmd new file mode 100644 index 00000000000..9ba82632066 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/13-render-the-input-value-pattern.mmd @@ -0,0 +1,10 @@ +agentflow-beta TB + file_path["file_path"] + file_path@{ shape: input, description: "Path to the file in the GitHub repository to visualize", value: "src/HelloWorld.java" } + + read_file["read_file"] + read_file@{ shape: tool, params: "path :: String", returns: "String" } + + flow runner["Runner"] + file_path --> read_file + end diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/14-render-multiple-connector-declarations.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/14-render-multiple-connector-declarations.mmd new file mode 100644 index 00000000000..24f0a7be277 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/14-render-multiple-connector-declarations.mmd @@ -0,0 +1,11 @@ +agentflow-beta LR + connector github["GitHub API"] + github@{ protocol: "http", endpoint: "https://api.github.com" } + connector slack["Slack API"] + slack@{ protocol: "http", endpoint: "https://slack.com/api" } + + notify["notify_channel"] + notify@{ shape: action, connectorRef: "slack.post_message" } + create_issue["create_issue"] + create_issue@{ shape: tool, connectorRef: "github.create_issue" } + create_issue --> notify diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/15-support-triage.mmd b/e2e/platform/dev-diagrams/diagrams/agentflow/15-support-triage.mmd new file mode 100644 index 00000000000..4d6d701638f --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/15-support-triage.mmd @@ -0,0 +1,41 @@ +agentflow-beta TB + connector llm["LLM API"] + llm@{ protocol: "http", endpoint: "https://api.example.com/chat" } + connector crm["CRM"] + crm@{ protocol: "http", endpoint: "https://crm.example.com/v2" } + + global + policy["Support Policy"]@{ shape: refdoc } + end + + flow triage["Triage Agent"] + ticket["ticket"]@{ shape: input, value: "INC-4417" } + classify["classify_intent"]@{ shape: tool, connectorRef: "llm.chat", returns: "Intent" } + severity["Severity?"]@{ shape: decision } + ticket --> classify --> severity + end + triage@{ instruction: "Classify the ticket and decide how urgent it is." } + + flow resolve["Resolution Agent"] + lookup["fetch_account"]@{ shape: tool, connectorRef: "crm.get", params: "id :: String" } + draft["draft_reply"]@{ shape: task } + send["send_reply"]@{ shape: tool, connectorRef: "crm.post" } + lookup --> draft --> send + end + + flow escalate["Escalation Agent"] + page["page_oncall"]@{ shape: tool } + handoff["Handoff Notes"]@{ shape: refdoc } + page -.- handoff + end + + flow audit["Audit"] + record["record_outcome"]@{ shape: task } + end + audit@{ view: "collapsed" } + + severity -->|"low"| resolve + severity -->|"high"| escalate + classify -.- policy + resolve --> audit + escalate --x audit diff --git a/e2e/platform/dev-diagrams/diagrams/agentflow/README.md b/e2e/platform/dev-diagrams/diagrams/agentflow/README.md new file mode 100644 index 00000000000..39c81d66b71 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/agentflow/README.md @@ -0,0 +1,23 @@ +# Agentflow fixtures + +Working set for the redux-colour work on agentflow. + +None of these pin a theme or look. They do not need to: agentflow declares `redux-color` +and `neo` as its own defaults in `config.schema.yaml`, so opening any of them shows the +palette straight away. Leaving the front matter out also means the Dev Explorer's own +theme and look controls still govern, so the same file can be compared across themes — +that is why the copied spec fixture had its `theme: default` block removed. + +| Fixtures | Source | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01` … `08` | every `mermaid-example` block in `src/docs/syntax/agentflow.md`, in document order | +| `09` … `14` | the e2e fixtures under `e2e/diagrams/agentflow/` covering ground the docs do not: all v0.8 node shapes, every container kind, collapsed containers with cross-boundary edges, the `global` block, the input-value pattern, and multiple connectors | +| `15-support-triage` | written for this work — four containers (one collapsed), two connectors, a `global` refdoc, labelled decision branches, and all seven node kinds in one picture | + +`15-support-triage` is the one to judge colour on: it is the only fixture where sibling +containers and every node kind appear together, so colouring per container and colouring +per node kind look obviously different on it. + +`e2e/rendering/agentflow/agentflow-fixtures.spec.ts` sweeps this directory from the +filesystem, so a fixture dropped in here is snapshot-tested without touching the spec — +the same arrangement `e2e/rendering/usecase/usecase.spec.ts` uses for `diagrams/use-case`. diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/01-actor-vs-database.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/01-actor-vs-database.mmd new file mode 100644 index 00000000000..9cc25db867e --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/01-actor-vs-database.mmd @@ -0,0 +1,5 @@ +sequenceDiagram + actor User + participant DB@{ "type" : "database" } as Database + User ->> DB: query + DB -->> User: rows diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/02-actor-among-all-participant-types.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/02-actor-among-all-participant-types.mmd new file mode 100644 index 00000000000..e89095245b4 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/02-actor-among-all-participant-types.mmd @@ -0,0 +1,17 @@ +sequenceDiagram + actor User + participant Plain as Participant + participant Bound@{ "type" : "boundary" } as Boundary + participant Ctrl@{ "type" : "control" } as Control + participant Ent@{ "type" : "entity" } as Entity + participant DB@{ "type" : "database" } as Database + participant Q@{ "type" : "queue" } as Queue + participant Coll@{ "type" : "collections" } as Collections + User ->> Plain: start + Plain ->> Bound: in + Bound ->> Ctrl: handle + Ctrl ->> Ent: load + Ent ->> DB: read + DB ->> Q: enqueue + Q ->> Coll: fan out + Coll -->> User: done diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/03-note-font-weight.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/03-note-font-weight.mmd new file mode 100644 index 00000000000..5f91f62d38e --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/03-note-font-weight.mmd @@ -0,0 +1,12 @@ +sequenceDiagram + actor User + participant API + participant DB@{ "type" : "database" } as Database + Note over User: Note over a single actor + User ->> API: request + Note right of API: Note to the right + API ->> DB: query + Note over API,DB: Note spanning two participants + DB -->> API: rows + Note left of User: Note to the left + API -->> User: response diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/04-rect-section-background.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/04-rect-section-background.mmd new file mode 100644 index 00000000000..7cd40024853 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/04-rect-section-background.mmd @@ -0,0 +1,18 @@ +sequenceDiagram + actor User + participant API + participant DB@{ "type" : "database" } as Database + rect + User ->> API: inside a bare rect, theme default fill + API ->> DB: query + end + API -->> User: outside any rect + rect rgb(200, 230, 255) + User ->> API: inside a rect with an explicit colour + end + rect + User ->> API: outer bare rect + rect rgb(255, 230, 200) + API ->> DB: nested rect with an explicit colour + end + end diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/05-all-three-regressions.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/05-all-three-regressions.mmd new file mode 100644 index 00000000000..618e7c66828 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/05-all-three-regressions.mmd @@ -0,0 +1,14 @@ +sequenceDiagram + actor User + participant API + participant DB@{ "type" : "database" } as Database + Note over User: bold note text was fix 1 + rect + User ->> API: the band behind this was invisible, fix 3 + API ->> DB: query + end + Note over API,DB: a wider note + rect rgb(200, 230, 255) + DB -->> API: an explicitly coloured band always worked + end + API -->> User: the stick figure size was fix 2 diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/06-loops-and-labels.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/06-loops-and-labels.mmd new file mode 100644 index 00000000000..c6a5f0ffbaf --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/06-loops-and-labels.mmd @@ -0,0 +1,14 @@ +sequenceDiagram + actor User + participant DB@{ "type" : "database" } as Database + loop every retry + User ->> DB: attempt + end + alt found + DB -->> User: rows + else missing + DB -->> User: empty + end + opt cache + User ->> DB: warm + end diff --git a/e2e/platform/dev-diagrams/diagrams/sequence-fixes/07-multiline-labels.mmd b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/07-multiline-labels.mmd new file mode 100644 index 00000000000..abb1708ccc6 --- /dev/null +++ b/e2e/platform/dev-diagrams/diagrams/sequence-fixes/07-multiline-labels.mmd @@ -0,0 +1,8 @@ +sequenceDiagram + actor U as Multi
line
actor + participant P as Plain + participant B@{ "type" : "boundary" } as Single + participant C@{ "type" : "control" } as Two
lines + participant DB@{ "type" : "database" } as Database + U ->> P: the lifelines should share one start + P ->> DB: and one end diff --git a/e2e/rendering/agentflow/agentflow-fixtures.spec.ts b/e2e/rendering/agentflow/agentflow-fixtures.spec.ts new file mode 100644 index 00000000000..12cb1d73db7 --- /dev/null +++ b/e2e/rendering/agentflow/agentflow-fixtures.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { imgSnapshotTest } from '../../helpers/util.ts'; + +const AGENTFLOW_FIXTURE_DIR = 'e2e/platform/dev-diagrams/diagrams/agentflow'; + +// Derived from the filesystem so a newly-added agentflow fixture is swept +// automatically — the same arrangement the use-case suite uses. A hardcoded +// list drifts silently the moment someone drops a file in the directory. +const AGENTFLOW_FIXTURES = readdirSync(AGENTFLOW_FIXTURE_DIR) + .filter((file) => file.endsWith('.mmd')) + .sort(); + +// viewer.js injects the diagram source with innerHTML, so raw `&`, `<`, and `>` +// in fixture files must be entity-escaped to survive the round trip. +const asMermaidElementSource = (source: string): string => + source.replace(/&/g, '&').replace(//g, '>'); + +/** + * Every fixture, on every theme the colour work targets plus the two defaults it must not + * disturb. `redux-color` is the default theme, so the no-theme case goes through it too. + */ +const THEMES = ['redux-color', 'redux-dark-color', 'default', 'dark'] as const; + +test.describe('Agentflow diagram', () => { + test.describe('dev fixture coverage', () => { + test('covers every agentflow dev fixture', () => { + expect(AGENTFLOW_FIXTURES.length, 'generated agentflow fixture inventory').toBeGreaterThan(0); + }); + + AGENTFLOW_FIXTURES.forEach((fixture) => { + test(`renders ${fixture} end to end`, async ({ page }, testInfo) => { + const source = readFileSync(`${AGENTFLOW_FIXTURE_DIR}/${fixture}`, 'utf8'); + expect(source, 'fixture should declare the agentflow diagram type').toMatch( + /(?:^|\n)agentflow-beta(?:\s|$)/ + ); + await imgSnapshotTest(page, testInfo, asMermaidElementSource(source)); + await expect(page.locator('svg .error-icon')).toHaveCount(0); + }); + }); + }); + + test.describe('themes', () => { + for (const theme of THEMES) { + // One representative fixture per theme rather than the full cross-product: 15 + // fixtures times 4 themes is 60 screenshots for a colour change that shows up on + // any diagram carrying every node kind. `15-support-triage` is that diagram. + test(`renders the support-triage sample on ${theme}`, async ({ page }, testInfo) => { + const source = readFileSync(`${AGENTFLOW_FIXTURE_DIR}/15-support-triage.mmd`, 'utf8'); + await imgSnapshotTest(page, testInfo, asMermaidElementSource(source), { theme }); + await expect(page.locator('svg .error-icon')).toHaveCount(0); + }); + } + }); +}); diff --git a/e2e/rendering/agentflow/agentflow-redux-colors.spec.ts b/e2e/rendering/agentflow/agentflow-redux-colors.spec.ts new file mode 100644 index 00000000000..f1d3a4845d8 --- /dev/null +++ b/e2e/rendering/agentflow/agentflow-redux-colors.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; +/** + * Colour is asserted from COMPUTED STYLE, not from the stylesheet text. + * + * Checking that the emitted CSS contains a selector passes even when the selector matches + * nothing — which is exactly how three separate bugs survived here: a hook wired to a + * function the renderer never calls, a descendant combinator where the attribute and the + * class sit on the same element, and a kind read off the shape when two kinds share one + * shape. Only reading back the painted stroke catches those. + */ +import { readFileSync } from 'node:fs'; +import { renderGraph } from '../../helpers/util.ts'; +const src = readFileSync( + 'e2e/platform/dev-diagrams/diagrams/agentflow/15-support-triage.mmd', + 'utf8' +); +const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + +const probe = (page: any) => + page.evaluate(() => { + const svg = document.querySelector('svg[aria-roledescription]')!; + const rows: any[] = []; + for (const el of svg.querySelectorAll('[class*="af-kind-"],[data-color-id]')) { + const tag = el.querySelector('rect,path,polygon'); + if (!tag) continue; + const kind = [...el.classList].find((c) => c.startsWith('af-kind-')); + rows.push({ + marker: kind ?? `slot:${el.getAttribute('data-color-id')}`, + stroke: getComputedStyle(tag).stroke, + }); + } + return rows; + }); + +test('redux-color paints every kind and every container distinctly', async ({ page }, testInfo) => { + await renderGraph(page, testInfo, esc(src), { theme: 'redux-color', look: 'neo' }); + const by = new Map(); + for (const r of await probe(page)) by.set(r.marker, r.stroke); + // Seven kinds and four containers, each its own colour: a single shared colour would + // look exactly like the bug this fixes. + expect(by.size, 'markers found').toBeGreaterThan(6); + expect(new Set(by.values()).size, 'every marker its own colour').toBe(by.size); +}); + +test('default theme leaves the markers inert', async ({ page }, testInfo) => { + await renderGraph(page, testInfo, esc(src), { theme: 'default', look: 'neo' }); + const n = await page.evaluate(() => { + const svg = document.querySelector('svg[aria-roledescription]')!; + const el = svg.querySelector('[class*="af-kind-"]'); + const tag = el?.querySelector('rect,path,polygon'); + return { + count: svg.querySelectorAll('[class*="af-kind-"]').length, + stroke: tag ? getComputedStyle(tag).stroke : 'n/a', + slots: svg.querySelectorAll('[data-color-id]').length, + } as any; + }); + // The markers are inert rather than absent: `getData()` runs before the diagram's theme + // is settled, so the class cannot be gated there. `genColor` emits no rules off-palette, + // which is the gate that matters. + expect(n.slots, 'no container slots stamped off-palette').toBe(0); + expect(n.stroke, 'nodes keep the theme colour').not.toBe('rgb(232, 121, 249)'); +}); + +test('applies redux-color and neo by default, with nothing set', async ({ page }, testInfo) => { + // Agentflow declares `theme: redux-color` and `look: neo` as its own defaults in + // `config.schema.yaml`, so a diagram that sets neither still gets the palette. Rendered + // with no options at all — if those defaults stop resolving, the strokes collapse to one + // colour and this fails. + await renderGraph(page, testInfo, esc(src), {}); + + const seen = await page.evaluate(() => { + const svg = document.querySelector('svg[aria-roledescription]')!; + const looks = new Set( + [...svg.querySelectorAll('[data-look]')].map((e) => e.getAttribute('data-look')) + ); + const strokes = new Set(); + for (const el of svg.querySelectorAll('[class*="af-kind-"],[data-color-id]')) { + const tag = el.querySelector('rect,path,polygon'); + if (tag) { + strokes.add(getComputedStyle(tag).stroke); + } + } + return { looks: [...looks], strokes: strokes.size }; + }); + + expect(seen.looks, 'the neo look is the agentflow default').toContain('neo'); + expect(seen.strokes, 'the palette is live without asking for it').toBeGreaterThan(6); +}); diff --git a/e2e/rendering/block/block-redux-color.spec.ts b/e2e/rendering/block/block-redux-color.spec.ts new file mode 100644 index 00000000000..a092c76e869 --- /dev/null +++ b/e2e/rendering/block/block-redux-color.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from '@playwright/test'; +import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts'; + +/** + * Composite blocks take a per-container colour under the redux colour themes, the same + * way flowchart subgraphs do — one counter over containers, nothing on the plain shapes. + * Block is not one of the diagram types that default to a colour theme, so every test here + * names one explicitly. + * + * The unit tests pin the two halves separately — that `blockDB` hands out slots, and that + * the stylesheet emits rules — but only a render proves the stamped `data-color-id` + * actually meets the emitted selector on the element. That is exactly where this was + * broken: both halves can be right while nothing on screen changes colour. + */ +const reduxThemes = ['redux', 'redux-color', 'redux-dark', 'redux-dark-color'] as const; + +/** Three containers, so the ordering is unambiguous and a reversed cycle would show. */ +const composites = ` + block-beta + columns 1 + block:ingest + columns 2 + a["Fetch"] b["Validate"] + end + block:transform + columns 2 + c["Normalise"] d["Enrich"] + end + block:store + e["Warehouse"] + end +`; + +/** Nesting, to show the palette applying at more than one depth. */ +const nested = ` + block-beta + columns 1 + block:outer + columns 1 + block:inner1 + a["one"] b["two"] + end + block:inner2 + c["three"] + end + end + block:sibling + d["four"] + end +`; + +/** + * The plain shapes are deliberately left alone, exactly as a flowchart leaves its nodes + * alone. Every shape a block diagram can draw is here, and none of them should pick up a + * palette colour — only the container around them. + */ +const shapesInsideAContainer = ` + block-beta + columns 1 + block:shapes + columns 4 + sq["Square"] rn(("Circle")) di{"Diamond"} hx{{"Hexagon"}} + st(["Stadium"]) sr[["Subroutine"]] lr[/"Lean"/] tr[/"Trapezoid"\\] + end +`; + +/** A flat diagram has no containers, so nothing takes a palette colour. */ +const flat = ` + block-beta + columns 3 + a["One"] b["Two"] c["Three"] +`; + +/** + * Explicit user styling keeps winning over the palette: `style` becomes an inline + * `style` attribute and none of the palette rules are `!important`. + */ +const userStyled = ` + block-beta + columns 1 + block:palette + a["Palette"] + end + block:mine + b["Mine"] + end + style mine fill:#00ff00,stroke:#0000ff +`; + +const diagrams = { + composites, + nested, + 'shapes-inside-a-container': shapesInsideAContainer, + flat, + 'user-styled': userStyled, +} as const; + +test.describe('Block - Redux colour themes', () => { + for (const theme of reduxThemes) { + test.describe(`Theme: ${theme}`, () => { + for (const [name, diagram] of Object.entries(diagrams)) { + test(`should render ${name}`, async ({ page }, testInfo) => { + await imgSnapshotTest(page, testInfo, diagram, { theme, look: 'neo' }); + }); + } + }); + } + + test('stamps a palette slot that the stylesheet actually matches', async ({ page }, testInfo) => { + await renderGraph(page, testInfo, composites, { theme: 'redux-color', look: 'neo' }); + + const { stamped, matched } = await page.evaluate(() => { + const svg = document.querySelector('svg[aria-roledescription]')!; + const slots = [...svg.querySelectorAll('[data-color-id]')].map( + (el) => el.getAttribute('data-color-id')! + ); + const css = [...svg.querySelectorAll('style')].map((s) => s.textContent ?? '').join('\n'); + return { + stamped: [...new Set(slots)], + // A slot with no rule renders uncoloured — the failure this whole test exists for. + matched: [...new Set(slots)].filter((slot) => css.includes(`[data-color-id="${slot}"]`)), + }; + }); + + expect(stamped.length).toBeGreaterThan(0); + expect(matched).toEqual(stamped); + }); + + test('gives adjacent containers different colours', async ({ page }, testInfo) => { + // The point of the palette. One slot for everything would satisfy the test above + // while looking exactly like the bug being fixed. + await renderGraph(page, testInfo, composites, { theme: 'redux-color', look: 'neo' }); + + const distinct = await page.evaluate(() => { + const svg = document.querySelector('svg[aria-roledescription]')!; + return new Set( + [...svg.querySelectorAll('[data-color-id]')].map((el) => el.getAttribute('data-color-id')) + ).size; + }); + + expect(distinct).toBeGreaterThan(1); + }); + + test('leaves the plain shapes without a slot', async ({ page }, testInfo) => { + // Parity with the flowchart, which colours its subgraphs and never its nodes. Without + // this, widening the selectors later would go unnoticed. + await renderGraph(page, testInfo, shapesInsideAContainer, { + theme: 'redux-color', + look: 'neo', + }); + + const stampedIds = await page.evaluate(() => { + const svg = document.querySelector('svg[aria-roledescription]')!; + return [...svg.querySelectorAll('[data-color-id]')].map((el) => el.id || ''); + }); + + // One container, eight shapes inside it: exactly one element carries a slot. + expect(stampedIds).toHaveLength(1); + }); +}); diff --git a/e2e/rendering/flowchart/flowchart-elk.spec.js b/e2e/rendering/flowchart/flowchart-elk.spec.js index 573e9d3ba3c..506bf6a6e3f 100644 --- a/e2e/rendering/flowchart/flowchart-elk.spec.js +++ b/e2e/rendering/flowchart/flowchart-elk.spec.js @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { imgSnapshotTest, renderGraph, verifyNumber } from '../../helpers/util.ts'; +import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts'; test.describe('Flowchart ELK', () => { test('1-elk: should render a simple flowchart', async ({ page }, testInfo) => { @@ -49,7 +49,14 @@ test.describe('Flowchart ELK', () => { const style = await svg.getAttribute('style'); expect(style).toMatch(/^max-width: [\d.]+px;$/); const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join('')); - verifyNumber(maxWidthValue, 380, 15); + // `useMaxWidth` sets max-width to the diagram's own width, so assert it against the + // viewBox rather than a hardcoded pixel figure. The natural width depends on the + // default look, theme and font -- none of which this test is about -- so an absolute + // expectation breaks whenever any of those change. + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const viewBoxWidth = parseFloat(viewBox.split(/\s+/)[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + expect(maxWidthValue).toBeCloseTo(viewBoxWidth, 1); }); test('8-elk: should render a flowchart when useMaxWidth is false', async ({ page }, testInfo) => { await renderGraph( @@ -66,7 +73,12 @@ test.describe('Flowchart ELK', () => { ); const svg = page.locator('svg'); const width = parseFloat((await svg.getAttribute('width')) ?? '0'); - verifyNumber(width, 380, 15); + // Same reasoning as the useMaxWidth:true case: the width attribute carries the + // diagram's own width, which the viewBox already states. + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const viewBoxWidth = parseFloat(viewBox.split(/\s+/)[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + expect(width).toBeCloseTo(viewBoxWidth, 1); await expect(svg).not.toHaveAttribute('style'); }); diff --git a/e2e/rendering/flowchart/flowchart-v2.spec.js b/e2e/rendering/flowchart/flowchart-v2.spec.js index f21d789a015..8d9c7dd49b3 100644 --- a/e2e/rendering/flowchart/flowchart-v2.spec.js +++ b/e2e/rendering/flowchart/flowchart-v2.spec.js @@ -22,8 +22,14 @@ test.describe('Flowchart v2', () => { const style = await svg.getAttribute('style'); expect(style).toMatch(/^max-width: [\d.]+px;$/); const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join('')); - expect(maxWidthValue).toBeGreaterThanOrEqual(440 * 0.95); - expect(maxWidthValue).toBeLessThanOrEqual(440 * 1.05); + // `useMaxWidth` sets max-width to the diagram's own width, so assert it against the + // viewBox rather than a hardcoded pixel figure. The natural width depends on the + // default look, theme and font -- none of which this test is about -- so an absolute + // expectation breaks whenever any of those change. + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const viewBoxWidth = parseFloat(viewBox.split(/\s+/)[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + expect(maxWidthValue).toBeCloseTo(viewBoxWidth, 1); }); test('8: should render a flowchart when useMaxWidth is false', async ({ page }, testInfo) => { await renderGraph( @@ -40,8 +46,12 @@ test.describe('Flowchart v2', () => { ); const svg = page.locator('svg'); const width = parseFloat((await svg.getAttribute('width')) ?? '0'); - expect(width).toBeGreaterThanOrEqual(440 * 0.95); - expect(width).toBeLessThanOrEqual(440 * 1.05); + // Same reasoning as the useMaxWidth:true case: the width attribute carries the + // diagram's own width, which the viewBox already states. + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const viewBoxWidth = parseFloat(viewBox.split(/\s+/)[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + expect(width).toBeCloseTo(viewBoxWidth, 1); await expect(svg).not.toHaveAttribute('style'); }); diff --git a/e2e/rendering/flowchart/flowchart.spec.js b/e2e/rendering/flowchart/flowchart.spec.js index 21067a689c7..f9df56f51d1 100644 --- a/e2e/rendering/flowchart/flowchart.spec.js +++ b/e2e/rendering/flowchart/flowchart.spec.js @@ -22,8 +22,14 @@ test.describe('Graph', () => { const style = await svg.getAttribute('style'); expect(style).toMatch(/^max-width: [\d.]+px;$/); const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join('')); - expect(maxWidthValue).toBeGreaterThanOrEqual(446 * 0.9); - expect(maxWidthValue).toBeLessThanOrEqual(446 * 1.1); + // `useMaxWidth` sets max-width to the diagram's own width, so assert it against the + // viewBox rather than a hardcoded pixel figure. The natural width depends on the + // default look, theme and font -- none of which this test is about -- so an absolute + // expectation breaks whenever any of those change. + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const viewBoxWidth = parseFloat(viewBox.split(/\s+/)[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + expect(maxWidthValue).toBeCloseTo(viewBoxWidth, 1); }); test('39: should render a flowchart when useMaxWidth is false', async ({ page }, testInfo) => { await renderGraph( @@ -40,8 +46,12 @@ test.describe('Graph', () => { ); const svg = page.locator('svg'); const width = parseFloat((await svg.getAttribute('width')) ?? '0'); - expect(width).toBeGreaterThanOrEqual(446 * 0.9); - expect(width).toBeLessThanOrEqual(446 * 1.1); + // Same reasoning as the useMaxWidth:true case: the width attribute carries the + // diagram's own width, which the viewBox already states. + const viewBox = (await svg.getAttribute('viewBox')) ?? ''; + const viewBoxWidth = parseFloat(viewBox.split(/\s+/)[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + expect(width).toBeCloseTo(viewBoxWidth, 1); await expect(svg).not.toHaveAttribute('style'); }); test('40: should add edge animation', async ({ page }, testInfo) => { diff --git a/e2e/rendering/per-diagram-appearance.spec.ts b/e2e/rendering/per-diagram-appearance.spec.ts new file mode 100644 index 00000000000..70f3676fdf7 --- /dev/null +++ b/e2e/rendering/per-diagram-appearance.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page } from '@playwright/test'; +import { renderGraph } from '../helpers/util.ts'; + +/** + * Two diagrams on one page must resolve their appearance independently: `er` opts in to + * `redux-color`/`neo`, `block` does not. The unit tests assert the resolved config object, + * which cannot see whether the resolved theme actually reached the stylesheet — that is + * `createUserStyles`, and it runs per render, from the config in scope at the time. + * + * `data-look` and `data-color-id` are the two markers the renderers stamp, the second only + * under a colour theme, so between them they pin both halves without hardcoding a palette. + */ +const listed = `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains +`; + +const unlisted = `block-beta + columns 1 + block:group + A["one"] + B["two"] + end +`; + +/** Per-SVG appearance markers, in the order the diagrams appear on the page. */ +const appearanceOf = (page: Page) => + page.evaluate(() => + [...document.querySelectorAll('svg[aria-roledescription]')].map((svg) => ({ + role: svg.getAttribute('aria-roledescription'), + looks: [ + ...new Set( + [...svg.querySelectorAll('[data-look]')].map((el) => el.getAttribute('data-look')) + ), + ], + colorSlots: svg.querySelectorAll('[data-color-id]').length, + })) + ); + +test.describe('Per-diagram appearance defaults', () => { + test('gives each diagram on the page its own default theme and look', async ({ + page, + }, testInfo) => { + await renderGraph(page, testInfo, [listed, unlisted], { + screenshot: false, + logLevel: 0, + name: 'per-diagram-appearance-defaults', + }); + + const [er, block] = await appearanceOf(page); + + // `er` opted in: neo look, and a palette slot on every entity box. + expect(er.looks).toEqual(['neo']); + expect(er.colorSlots).toBeGreaterThan(0); + + // `block` did not: classic look, and no palette at all under the `default` theme. + expect(block.looks).toEqual(['classic']); + expect(block.colorSlots).toBe(0); + }); + + test('lets a global look from initialize() override the diagram default', async ({ + page, + }, testInfo) => { + await renderGraph(page, testInfo, [listed, unlisted], { + screenshot: false, + logLevel: 0, + name: 'per-diagram-appearance-global-look', + look: 'classic', + }); + + const [er, block] = await appearanceOf(page); + expect(er.looks).toEqual(['classic']); + expect(block.looks).toEqual(['classic']); + }); + + test('scopes a look from initialize() to one diagram type', async ({ page }, testInfo) => { + await renderGraph(page, testInfo, [listed, unlisted], { + screenshot: false, + logLevel: 0, + name: 'per-diagram-appearance-scoped-look', + look: 'classic', + er: { look: 'handDrawn' }, + }); + + const [er, block] = await appearanceOf(page); + // The more specific of the two things the user said wins, for that diagram only. + expect(er.looks).toEqual(['handDrawn']); + expect(block.looks).toEqual(['classic']); + }); + + test('lets one diagram set its own theme in frontmatter without moving the other', async ({ + page, + }, testInfo) => { + await renderGraph( + page, + testInfo, + [`---\nconfig:\n theme: default\n---\n${listed}`, unlisted], + { screenshot: false, logLevel: 0, name: 'per-diagram-appearance-frontmatter' } + ); + + const [er, block] = await appearanceOf(page); + // Frontmatter outranks the diagram default, so the palette goes away for `er` alone. + expect(er.colorSlots).toBe(0); + expect(block.colorSlots).toBe(0); + expect(block.looks).toEqual(['classic']); + }); +}); diff --git a/e2e/rendering/state/stateDiagram-redux-color-composites.spec.ts b/e2e/rendering/state/stateDiagram-redux-color-composites.spec.ts new file mode 100644 index 00000000000..5d1f949c0ac --- /dev/null +++ b/e2e/rendering/state/stateDiagram-redux-color-composites.spec.ts @@ -0,0 +1,165 @@ +import { test } from '@playwright/test'; +import { imgSnapshotTest } from '../../helpers/util.ts'; + +/** + * Composite states and concurrency regions take a per-container colour under the redux + * colour themes. The unit tests cover the two halves separately -- `dataFetcher` hands out + * the slots, `state/styles.js` emits the rules -- but only a render proves the stamped + * `data-color-id` actually meets the emitted selector on the element, which is where a + * container-shaped change is most likely to come apart: state composites are drawn by + * `roundedWithTitle` and `divider` rather than by the plain `rect` cluster the other + * diagrams use. + * + * The monochrome pair is included deliberately. `redux` and `redux-dark` carry no palette, + * so they must stay exactly as they render today -- these snapshots are what would catch + * the gate leaking colour into a theme that never asked for it. + */ +const reduxThemes = ['redux', 'redux-color', 'redux-dark', 'redux-dark-color'] as const; + +/** Three depths plus a sibling, so a cycle that failed to advance would be obvious. */ +const nested = ` + stateDiagram-v2 + [*] --> Boot + state Boot { + [*] --> Firmware + state Kernel { + [*] --> Sched + state Drivers { + [*] --> Probe + } + } + } + Boot --> Running + state Running { + [*] --> Serving + } + Running --> [*] +`; + +/** + * Three concurrency regions in one composite. Three rather than two: with two, a rule that + * paired them by declaration order rather than by parent would still look right. + */ +const concurrency = ` + stateDiagram-v2 + [*] --> Active + state Active { + [*] --> NumOff + NumOff --> NumOn + -- + [*] --> CapsOff + CapsOff --> CapsOn + -- + [*] --> ScrollOff + ScrollOff --> ScrollOn + } + Active --> [*] +`; + +/** + * The two rules pulling against each other in one diagram: the regions of `Concurrent` + * must match each other, while `Machine`, `Concurrent`, `Finish` and `Deep` must all + * differ. + */ +const regionsInsideNesting = ` + stateDiagram-v2 + state Machine { + state Concurrent { + [*] --> Left + -- + [*] --> Right + } + Concurrent --> Finish + state Finish { + [*] --> Flush + state Deep { + [*] --> Done + } + } + } +`; + +/** + * A composite the author has styled keeps its own colours and takes no palette slot at + * all. Half-and-half is the failure this guards: a state `classDef` compiles to + * `.name > * { ... }`, which reaches the body rect but not the title strip, so a composite + * that stayed in the cycle would show the author's fill under a palette-coloured title. + */ +const userStyled = ` + stateDiagram-v2 + classDef pinned fill:#111827,stroke:#F59E0B,color:#F9FAFB + state Outer { + [*] --> Step + state Pinned { + [*] --> Held + } + state Plain { + [*] --> Free + } + } + class Pinned pinned +`; + +/** + * A styled composite that is also divided. The regions render in a sibling layer, out of + * reach of the author's \`.pinned \> *\` rule, so if they kept a palette slot the composite + * would be painted by the author and its own regions from the palette. \`Neighbour\` is + * there to show the opt-out does not shift the colours around it. + */ +const userStyledWithRegions = ` + stateDiagram-v2 + classDef pinned fill:#111827,stroke:#F59E0B,color:#F9FAFB + state Split { + [*] --> Left + -- + [*] --> Right + } + Split --> Neighbour + state Neighbour { + [*] --> After + } + class Split pinned +`; + +const diagrams = { + nested, + concurrency, + 'regions inside nesting': regionsInsideNesting, + 'user-styled': userStyled, + 'user-styled with regions': userStyledWithRegions, +} as const; + +test.describe('State diagram - Redux colour theme composites', () => { + for (const theme of reduxThemes) { + test.describe(`Theme: ${theme}`, () => { + for (const [name, diagram] of Object.entries(diagrams)) { + test(`should render ${name} composite containers`, async ({ page }, testInfo) => { + await imgSnapshotTest(page, testInfo, diagram, { theme }); + }); + } + }); + } +}); + +/** + * `handDrawn` draws these containers as roughjs shapes rather than rects, so it is served + * by a separate set of rules in `state/styles.js` -- and those were the only part of this + * feature with no render behind them. A bare `path` selector tinted the composite body as + * well as the title strip, which is what these snapshots now hold still. + * + * Only the two colour themes and two fixtures, rather than the full matrix: the rules under + * test are the `outer` / `inner` / `divider` ones, and `nested` plus `concurrency` reach all + * three. `redux-dark-color` is worth keeping because it ships no background palette, so it + * exercises the branch where the tint is omitted entirely. + */ +test.describe('State diagram - Redux colour theme composites, handDrawn', () => { + for (const theme of ['redux-color', 'redux-dark-color'] as const) { + for (const [name, diagram] of Object.entries({ nested, concurrency })) { + test(`should render ${name} composite containers for ${theme}`, async ({ + page, + }, testInfo) => { + await imgSnapshotTest(page, testInfo, diagram, { theme, look: 'handDrawn' }); + }); + } + } +}); diff --git a/e2e/rendering/swimlanes/swimlanes.spec.ts b/e2e/rendering/swimlanes/swimlanes.spec.ts index 860ea5df078..563f411b8be 100644 --- a/e2e/rendering/swimlanes/swimlanes.spec.ts +++ b/e2e/rendering/swimlanes/swimlanes.spec.ts @@ -225,6 +225,200 @@ test.describe('Swimlanes diagram', () => { await expect(shape).toHaveCSS('stroke-width', '4px'); }); + /** Only a render proves the stamped slot meets the emitted selector. */ + test.describe('redux colour theme lanes', () => { + const fiveLanes = `swimlane-beta TD + subgraph Intake + A[Request] + end + subgraph Review + B[Check] + end + subgraph Build + C[Assemble] + end + subgraph Ship + D[Deliver] + end + subgraph Support + E[Follow up] + end + A --> B --> C --> D --> E + `; + + const laneStrokes = (page: Page, half: 'title' | 'body') => + page + .locator(`g.cluster.swimlane rect.swimlane-${half}`) + .evaluateAll((rects) => rects.map((rect) => getComputedStyle(rect).stroke)); + + for (const theme of ['redux-color', 'redux-dark-color'] as const) { + test(`gives every lane its own colour under ${theme}`, async ({ page }, testInfo) => { + await renderSwimlanes(page, testInfo, fiveLanes, `swimlanes-${theme}-lanes`, { theme }); + + await assertStandaloneSwimlanesRendered(page); + + const slots = await page + .locator('g.cluster.swimlane') + .evaluateAll((lanes) => lanes.map((lane) => lane.getAttribute('data-color-id'))); + expect(slots).toHaveLength(5); + expect(slots.filter(Boolean)).toHaveLength(5); + expect(new Set(slots).size).toBe(5); + + // One lane's two halves match; across lanes they differ. + const titles = await laneStrokes(page, 'title'); + const bodies = await laneStrokes(page, 'body'); + expect(titles).toEqual(bodies); + expect(new Set(titles).size).toBe(5); + // `none` would mean the palette rule never landed. + expect(titles.filter((stroke) => stroke === 'none')).toEqual([]); + }); + } + + /** roughjs's emission order, which no unit test can confirm. */ + test.describe('handDrawn', () => { + const lanePaths = (page: Page, half: 'title' | 'body', nth: 1 | 2) => + page + .locator(`g.cluster.swimlane .swimlane-${half} path:nth-of-type(${nth})`) + .evaluateAll((paths) => paths.map((path) => getComputedStyle(path).stroke)); + + test('paints the outline path of both halves per lane', async ({ page }, testInfo) => { + await renderSwimlanes(page, testInfo, fiveLanes, 'swimlanes-handdrawn-outlines', { + theme: 'redux-color', + look: 'handDrawn', + }); + + await expect(page.locator('g.cluster.swimlane rect.swimlane-body')).toHaveCount(0); + + for (const half of ['title', 'body'] as const) { + const outlines = await lanePaths(page, half, 2); + expect(outlines, `${half} outlines`).toHaveLength(5); + expect(new Set(outlines).size, `${half} outlines are distinct`).toBe(5); + expect(outlines.filter((stroke) => stroke === 'none')).toEqual([]); + } + }); + + /** roughjs draws a fill as lines, so the hachure path's stroke is the lane fill. */ + test('fills both halves where the theme ships a background palette', async ({ + page, + }, testInfo) => { + await renderSwimlanes(page, testInfo, fiveLanes, 'swimlanes-handdrawn-fill', { + theme: 'redux-color', + look: 'handDrawn', + }); + + for (const half of ['title', 'body'] as const) { + const fills = await lanePaths(page, half, 1); + expect(fills, `${half} fills`).toHaveLength(5); + expect(new Set(fills).size, `${half} fills are distinct`).toBe(5); + expect(fills.filter((stroke) => stroke === 'none')).toEqual([]); + } + }); + + test('leaves the body hachure unpainted without a background palette', async ({ + page, + }, testInfo) => { + await renderSwimlanes(page, testInfo, fiveLanes, 'swimlanes-handdrawn-no-fill', { + theme: 'redux-dark-color', + look: 'handDrawn', + }); + + const fills = await lanePaths(page, 'body', 1); + expect(fills).toHaveLength(5); + expect(new Set(fills)).toEqual(new Set(['none'])); + // The outline is still palette-coloured; only the fill is absent. + expect(new Set(await lanePaths(page, 'body', 2)).size).toBe(5); + }); + }); + + test('paints the lane body fill only where the theme ships one', async ({ page }, testInfo) => { + await renderSwimlanes(page, testInfo, fiveLanes, 'swimlanes-lane-fill', { + theme: 'redux-color', + }); + + const fills = await page + .locator('g.cluster.swimlane rect.swimlane-body') + .evaluateAll((rects) => rects.map((rect) => getComputedStyle(rect).fill)); + expect(new Set(fills).size).toBe(5); + }); + + test('keeps an explicit lane style ahead of the palette', async ({ page }, testInfo) => { + await renderSwimlanes( + page, + testInfo, + `swimlane-beta TD + subgraph Palette + A[Slot colour] + end + subgraph Styled + B[Own colour] + end + A --> B + style Styled fill:#00ff00,stroke:#0000ff + `, + 'swimlanes-lane-user-style', + { theme: 'redux-color' } + ); + + const styled = page.locator('g.cluster.swimlane[data-id="Styled"] rect.swimlane-body'); + await expect(styled).toHaveCSS('stroke', 'rgb(0, 0, 255)'); + await expect(styled).toHaveCSS('fill', 'rgb(0, 255, 0)'); + }); + + /** The synthetic lane gets no `look` or colour slot from upstream. */ + test('colours the synthetic default lane distinctly', async ({ page }, testInfo) => { + await renderSwimlanes( + page, + testInfo, + `swimlane-beta TD + subgraph OwnedLane + A[Owned node] + end + Loose[Loose node] --> A + `, + 'swimlanes-default-lane-colour', + { theme: 'redux-color' } + ); + + const slots = await page + .locator('g.cluster.swimlane') + .evaluateAll((lanes) => lanes.map((lane) => lane.getAttribute('data-color-id'))); + expect(slots).toHaveLength(2); + expect(new Set(slots).size).toBe(2); + expect(slots.filter(Boolean)).toHaveLength(2); + }); + + test('draws the synthetic default lane in the diagram look', async ({ page }, testInfo) => { + await renderSwimlanes( + page, + testInfo, + `swimlane-beta TD + subgraph OwnedLane + A[Owned node] + end + Loose[Loose node] --> A + `, + 'swimlanes-default-lane-handdrawn', + { theme: 'redux-color', look: 'handDrawn' } + ); + + const defaultLane = page.locator('g.cluster.swimlane[data-id="__swimlane_default__"]'); + await expect(defaultLane).toHaveAttribute('data-look', 'handDrawn'); + // A `rect` here would mean the classic branch ran instead. + await expect(defaultLane.locator('rect.swimlane-body')).toHaveCount(0); + await expect(defaultLane.locator('.swimlane-body path')).not.toHaveCount(0); + }); + + for (const theme of ['redux-color', 'redux-dark-color'] as const) { + test(`renders coloured lanes under ${theme}`, async ({ page }, testInfo) => { + await snapshotSwimlanes(page, testInfo, fiveLanes, { theme }); + }); + + test(`renders coloured handdrawn lanes under ${theme}`, async ({ page }, testInfo) => { + await snapshotSwimlanes(page, testInfo, fiveLanes, { theme, look: 'handDrawn' }); + }); + } + }); + test('puts nodes without an explicit subgraph into a default swimlane', async ({ page, }, testInfo) => { diff --git a/e2e/rendering/usecase/usecase.spec.ts b/e2e/rendering/usecase/usecase.spec.ts index 8ec0cc1de1a..c91433480ce 100644 --- a/e2e/rendering/usecase/usecase.spec.ts +++ b/e2e/rendering/usecase/usecase.spec.ts @@ -480,7 +480,20 @@ test.describe('Usecase diagram', () => { // THEMED_DIAGRAM deliberately carries no classDef/style, so every colour on screen comes // from a theme variable. clusterBkg (system boundary), noteBkgColor/noteBorderColor (note), // and the actor/use-case fills are the ones most likely to regress on a dark background. - for (const theme of ['default', 'dark', 'forest', 'neutral', 'base'] as const) { + // + // The two colour themes are in the list because they are the only ones that set the + // `usecase*` role variables: on them an actor, a use case and a boundary each render in + // their own colour, and `include` and `extend` separate by hue rather than by dash alone. + // Every other theme leaves those variables unset and must render exactly as before. + for (const theme of [ + 'default', + 'dark', + 'forest', + 'neutral', + 'base', + 'redux-color', + 'redux-dark-color', + ] as const) { test(`renders every themed element on the ${theme} theme`, async ({ page }, testInfo) => { await imgSnapshotTest(page, testInfo, THEMED_DIAGRAM, { theme, @@ -488,4 +501,16 @@ test.describe('Usecase diagram', () => { }); }); } + + // The opt-in scheme: every actor, use case and boundary takes its own slot from the + // theme's categorical palette instead of its role colour. Only the colour themes carry a + // palette, so those are the only two where this differs from the default. + for (const theme of ['redux-color', 'redux-dark-color'] as const) { + test(`rotates the palette per element on the ${theme} theme`, async ({ page }, testInfo) => { + await imgSnapshotTest(page, testInfo, THEMED_DIAGRAM, { + theme, + usecase: { diagramPadding: 24, useMaxWidth: true, colorScheme: 'rotate' }, + }); + }); + } }); diff --git a/e2e/rendering/user-journey/journey.spec.js b/e2e/rendering/user-journey/journey.spec.js index 2a45f1312d9..0f0c16e4c06 100644 --- a/e2e/rendering/user-journey/journey.spec.js +++ b/e2e/rendering/user-journey/journey.spec.js @@ -181,7 +181,7 @@ section Checkout from website { journey: { useMaxWidth: true } } ); - const { diagramStartX, maxLineWidth, lineCount } = await page.evaluate(() => { + const { diagramStartX, maxLineWidth, legendLines } = await page.evaluate(() => { const diagram = [...document.querySelectorAll('foreignobject')].find((el) => el.textContent?.includes('Sign Up') ); @@ -200,10 +200,43 @@ section Checkout from website return { diagramStartX: parseFloat(diagram.getAttribute('x') ?? '0'), maxLineWidth: maxWidth, - lineCount: lines.length, + legendLines: lines.map((line) => line.textContent?.trim() ?? ''), }; }); - expect(lineCount).toBe(9); + // This test is about wrapping mechanics and margins -- not about how many lines a + // particular typeface needs. An exact line count silently encoded the default theme's + // font: these labels wrap into 9 lines in Trebuchet and 6 in Recursive, so the + // assertion used to break on a theme change that had nothing to do with wrapping. + // + // A total-vs-label-count comparison is not the answer either, because it is not a + // per-label check: with three labels, one wrapping into four lines while the other two + // stay on a single line each gives four lines in total and passes -- the very case the + // check is meant to exclude. The legend lines are flat siblings with no per-label + // grouping in the DOM, so walk them in order and consume as many as each label needs. + const LONG_LABELS = [ + 'This is a long label that will be split into multiple lines to test the wrapping functionality', + 'This is another long label that will be split into multiple lines to test the wrapping functionality', + 'This is yet another long label that will be split into multiple lines to test the wrapping functionality', + ]; + const remaining = [...legendLines]; + for (const label of LONG_LABELS) { + const consumed = []; + while (remaining.length > 0 && consumed.join(' ') !== label) { + consumed.push(remaining.shift()); + } + // Reassembling the label also catches text being dropped or reordered by wrapping, + // and gives the per-label count its meaning: without this, a run of unrelated lines + // could satisfy the length check below. + expect(consumed.join(' '), `legend lines did not reassemble into: ${label}`).toBe(label); + // No exact count, so a different typeface is free to need a different number -- + // only that this label, on its own, did not fit on one line. + expect(consumed.length, `label did not wrap onto multiple lines: ${label}`).toBeGreaterThan( + 1 + ); + } + // Nothing left over: an extra legend entry would mean the fixture and LONG_LABELS have + // drifted apart, which would quietly weaken every assertion above. + expect(remaining).toEqual([]); expect(Math.abs(diagramStartX - maxLineWidth - 150)).toBeLessThanOrEqual(2); }); diff --git a/e2e/rendering/venn/venn-redux-color.spec.ts b/e2e/rendering/venn/venn-redux-color.spec.ts new file mode 100644 index 00000000000..d24901f8312 --- /dev/null +++ b/e2e/rendering/venn/venn-redux-color.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type Page } from '@playwright/test'; +import { renderGraph } from '../../helpers/util.ts'; + +/** + * What a screenshot cannot state: the circles differ from each other, rather than all + * landing on the single `primaryColor` fallback. + */ +const threeSets = `venn-beta + title Innovation + set Desirable + set Feasible + set Viable + union Desirable,Feasible,Viable["Innovation"] +`; + +const circleFills = (page: Page) => + page + .locator('.venn-circle path') + .evaluateAll((paths) => paths.map((path) => getComputedStyle(path).fill)); + +test.describe('Venn - redux colour themes', () => { + for (const theme of ['redux-color', 'redux-dark-color'] as const) { + test(`paints each set its own colour under ${theme}`, async ({ page }, testInfo) => { + await renderGraph(page, testInfo, threeSets, { + screenshot: false, + logLevel: 0, + name: `venn-${theme}`, + theme, + }); + + const fills = await circleFills(page); + expect(fills).toHaveLength(3); + expect(new Set(fills).size).toBe(3); + expect(fills.filter((fill) => fill === 'none' || fill === '')).toEqual([]); + }); + } + + test('keeps an explicit set style ahead of the palette', async ({ page }, testInfo) => { + await renderGraph( + page, + testInfo, + `venn-beta + set A + set B + union A, B + style A fill:#00ff00 + `, + { screenshot: false, logLevel: 0, name: 'venn-user-style', theme: 'redux-color' } + ); + + const fills = await circleFills(page); + expect(fills).toContain('rgb(0, 255, 0)'); + }); +}); diff --git a/packages/mermaid/src/config.appearance.spec.ts b/packages/mermaid/src/config.appearance.spec.ts new file mode 100644 index 00000000000..0dcf6fb96d8 --- /dev/null +++ b/packages/mermaid/src/config.appearance.spec.ts @@ -0,0 +1,302 @@ +/** + * `theme`, `look` and `layout` resolve per diagram type. Checks the order the four sources + * are consulted in, and that the theme variables follow the theme that actually won. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + defaultConfig, + getConfig, + saveConfigFromInitialize, + setDiagramConfigScope, + setSiteConfig, + reset, +} from './config.js'; +import { addDiagrams } from './diagram-api/diagram-orchestration.js'; +import { getDiagramConfigKey } from './diagram-api/diagramConfigKeys.js'; +import { mermaidAPI } from './mermaidAPI.js'; +import theme from './themes/index.js'; +// @ts-expect-error This file is generated by a custom Vite plugin +import defaultConfigJson from './schemas/config.schema.yaml?only-defaults=true'; +// @ts-expect-error Vite's JSON Schema plugin supplies this module during tests. +import configSchema from './schemas/config.schema.yaml'; + +/** The diagram types that opt in to the colour theme and the neo look. */ +const REDESIGNED_DIAGRAMS = { + flowchart: 'flowchart TD\n A --> B', + swimlane: 'swimlane-beta TD\n A --> B', + class: 'classDiagram\n class Duck', + state: 'stateDiagram-v2\n [*] --> Still', + er: 'erDiagram\n CUSTOMER ||--o{ ORDER : places', + requirement: + 'requirementDiagram\n requirement test_req {\n id: 1\n text: the test text\n risk: high\n verifymethod: test\n }', + sequence: 'sequenceDiagram\n Alice->>John: Hello John', + usecase: 'usecase-beta\n actor User1("Customer")', + venn: 'venn-beta\n set A\n set B', + agentflow: 'agentflow-beta TB\n a["A"]\n b["B"]\n a --> b', +} as const; + +/** Types deliberately left on whatever develop already shipped. */ +const UNCHANGED_DIAGRAMS = { + pie: 'pie\n "Dogs" : 40\n "Cats" : 60', + gantt: 'gantt\n title A\n section S\n Task :a1, 2014-01-01, 30d', + mindmap: 'mindmap\n root((mindmap))\n A', + journey: 'journey\n title My day\n section Go to work\n Make tea: 5: Me', +} as const; + +/** + * The config the renderers are handed for `text`. Scope is bounded to the parse, so it is + * re-established here over the same directives to read back the resolution it performed. + */ +const configFor = async (text: string) => { + const { diagramType } = await mermaidAPI.parse(text); + setDiagramConfigScope(diagramType); + const config = getConfig(); + setDiagramConfigScope(undefined); + return config; +}; + +const resetConfig = () => { + saveConfigFromInitialize({}); + setSiteConfig({}); + reset(); +}; + +describe('per-diagram appearance defaults', () => { + beforeEach(() => { + addDiagrams(); + resetConfig(); + }); + + describe('with nothing configured by the user', () => { + it.each(Object.entries(REDESIGNED_DIAGRAMS))( + '%s takes redux-color and the neo look', + async (_type, text) => { + const config = await configFor(text); + expect(config.theme).toBe('redux-color'); + expect(config.look).toBe('neo'); + } + ); + + it.each(Object.entries(UNCHANGED_DIAGRAMS))( + '%s keeps the global default theme and look', + async (_type, text) => { + const config = await configFor(text); + expect(config.theme).toBe('default'); + expect(config.look).toBe('classic'); + } + ); + + it('hands the renderer the theme variables of the theme it resolved', async () => { + const flowchart = await configFor(REDESIGNED_DIAGRAMS.flowchart); + expect(flowchart.themeVariables.primaryColor).toBe( + theme['redux-color'].getThemeVariables().primaryColor + ); + + resetConfig(); + + const pie = await configFor(UNCHANGED_DIAGRAMS.pie); + expect(pie.themeVariables.primaryColor).toBe(theme.default.getThemeVariables().primaryColor); + }); + + it('leaves the layout on dagre except where a diagram type names its own', async () => { + expect((await configFor(REDESIGNED_DIAGRAMS.class)).layout).toBe('dagre'); + expect((await configFor(UNCHANGED_DIAGRAMS.pie)).layout).toBe('dagre'); + // The one type that names its own -- see `swimlanesDiagram.spec.ts`. + expect((await configFor(REDESIGNED_DIAGRAMS.swimlane)).layout).toBe('swimlane'); + }); + }); + + describe('initialize() outranks the diagram type default', () => { + it('a global look applies to a diagram type that defaults to another one', async () => { + mermaidAPI.initialize({ look: 'classic' }); + expect((await configFor(REDESIGNED_DIAGRAMS.flowchart)).look).toBe('classic'); + }); + + it('a global theme applies, variables included', async () => { + mermaidAPI.initialize({ theme: 'dark' }); + const config = await configFor(REDESIGNED_DIAGRAMS.sequence); + expect(config.theme).toBe('dark'); + expect(config.themeVariables.primaryColor).toBe(theme.dark.getThemeVariables().primaryColor); + }); + + it('a global theme the user set to the schema default still counts as set', async () => { + mermaidAPI.initialize({ theme: 'default' }); + expect((await configFor(REDESIGNED_DIAGRAMS.er)).theme).toBe('default'); + }); + + it('a diagram-scoped value beats the global one the user set alongside it', async () => { + mermaidAPI.initialize({ look: 'classic', flowchart: { look: 'handDrawn' } }); + expect((await configFor(REDESIGNED_DIAGRAMS.flowchart)).look).toBe('handDrawn'); + expect((await configFor(REDESIGNED_DIAGRAMS.sequence)).look).toBe('classic'); + }); + + it('a diagram-scoped value reaches every renderer of that diagram', async () => { + mermaidAPI.initialize({ class: { theme: 'forest' } }); + // v1 and v2 parse into separate diagram types but share one config section. + expect((await configFor('classDiagram\n class Duck')).theme).toBe('forest'); + expect((await configFor('classDiagram-v2\n class Duck')).theme).toBe('forest'); + }); + + it('opts a diagram type into a layout', async () => { + mermaidAPI.initialize({ er: { layout: 'elk' } }); + expect((await configFor(REDESIGNED_DIAGRAMS.er)).layout).toBe('elk'); + expect((await configFor(REDESIGNED_DIAGRAMS.class)).layout).toBe('dagre'); + }); + }); + + describe('the diagram itself outranks everything', () => { + it('frontmatter beats a diagram type default', async () => { + const config = await configFor('---\nconfig:\n look: classic\n---\nflowchart TD\n A --> B'); + expect(config.look).toBe('classic'); + }); + + it('frontmatter beats initialize()', async () => { + mermaidAPI.initialize({ theme: 'dark' }); + const config = await configFor('---\nconfig:\n theme: forest\n---\nflowchart TD\n A --> B'); + expect(config.theme).toBe('forest'); + expect(config.themeVariables.primaryColor).toBe( + theme.forest.getThemeVariables().primaryColor + ); + }); + + it('a diagram-scoped frontmatter value beats a global one in the same frontmatter', async () => { + const config = await configFor( + '---\nconfig:\n look: classic\n flowchart:\n look: handDrawn\n---\nflowchart TD\n A --> B' + ); + expect(config.look).toBe('handDrawn'); + }); + + it('survives the `init` hook reconfiguring the diagram', async () => { + // Flowchart's `init` calls `setConfig`, which re-resolves from its own object alone. + const config = await configFor('---\nconfig:\n theme: dark\n---\nflowchart TD\n A --> B'); + expect(config.theme).toBe('dark'); + }); + + it('a directive beats a diagram type default', async () => { + const config = await configFor(`%%{init: {'look': 'classic'}}%%\nflowchart TD\n A --> B`); + expect(config.look).toBe('classic'); + }); + }); + + describe('scope', () => { + it('does not leak the previous diagram type into the next one', async () => { + expect((await configFor(REDESIGNED_DIAGRAMS.flowchart)).theme).toBe('redux-color'); + expect((await configFor(UNCHANGED_DIAGRAMS.pie)).theme).toBe('default'); + }); + + it('leaves the global defaults in charge once the parse is over', async () => { + // Scope lasts exactly as long as the parse or render, so an embedder reading + // `getConfig()` between them does not get the last diagram's appearance. + await mermaidAPI.parse(REDESIGNED_DIAGRAMS.flowchart); + expect(getConfig().theme).toBe('default'); + expect(getConfig().look).toBe('classic'); + }); + }); + + describe('rejects a value this build cannot render', () => { + // `initialize()` normalises an unknown top-level `theme`, so the diagram-scoped keys + // need the same guard: a name no stylesheet matches renders an unstyled diagram. + it('falls through an unknown diagram-scoped theme from initialize()', async () => { + mermaidAPI.initialize({ theme: 'dark', flowchart: { theme: 'totally-bogus' as never } }); + const config = await configFor(REDESIGNED_DIAGRAMS.flowchart); + expect(config.theme).toBe('dark'); + expect(config.themeVariables.primaryColor).toBe(theme.dark.getThemeVariables().primaryColor); + }); + + it('falls through an unknown diagram-scoped theme from frontmatter', async () => { + const config = await configFor( + '---\nconfig:\n flowchart:\n theme: totally-bogus\n---\nflowchart TD\n A --> B' + ); + expect(config.theme).toBe('redux-color'); + expect(config.themeVariables.primaryColor).toBe( + theme['redux-color'].getThemeVariables().primaryColor + ); + }); + + it('falls through an unknown diagram-scoped look', async () => { + mermaidAPI.initialize({ flowchart: { look: 'not-a-look' } as never }); + expect((await configFor(REDESIGNED_DIAGRAMS.flowchart)).look).toBe('neo'); + }); + + it.each(['__proto__', 'constructor', 'toString', 'valueOf'])( + 'falls through %s rather than reading it off the theme registry prototype', + async (inherited) => { + // These satisfy `in` on an object literal and then have no `getThemeVariables`. + const config = await configFor( + `---\nconfig:\n flowchart:\n theme: ${inherited}\n---\nflowchart TD\n A --> B` + ); + expect(config.theme).toBe('redux-color'); + expect(config.themeVariables.primaryColor).toBe( + theme['redux-color'].getThemeVariables().primaryColor + ); + } + ); + + it.each(['__proto__', 'constructor'])( + 'survives %s as a global theme, which never reached the diagram-scoped guard', + async (inherited) => { + // The global path predates this PR and threw a bare TypeError out of the render. + const config = await configFor( + `---\nconfig:\n theme: ${inherited}\n---\nflowchart TD\n A --> B` + ); + expect(config.themeVariables.primaryColor).toBeDefined(); + } + ); + + it("keeps the 'null' theme sentinel, which disables the pre-defined themes", async () => { + mermaidAPI.initialize({ flowchart: { theme: 'null' } }); + expect((await configFor(REDESIGNED_DIAGRAMS.flowchart)).theme).toBe('null'); + }); + + it('leaves an unknown layout alone -- the registry is extensible', async () => { + // Resolved at render time by `getRegisteredLayoutAlgorithm`, which falls back to dagre. + mermaidAPI.initialize({ er: { layout: 'elk' } }); + expect((await configFor(REDESIGNED_DIAGRAMS.er)).layout).toBe('elk'); + }); + }); + + describe('the schema is the single source of the defaults', () => { + it('every appearance default the schema declares reaches defaultConfig', () => { + // Sections `defaultConfig.ts` rebuilds by hand can silently lose these keys, leaving + // the diagram type on the global default. + const sections = Object.entries( + defaultConfigJson as Record | undefined> + ).filter(([, value]) => value !== null && typeof value === 'object'); + + for (const [section, declared] of sections) { + for (const key of ['theme', 'look', 'layout'] as const) { + if (declared?.[key] === undefined) { + continue; + } + expect( + (defaultConfig as Record | undefined>)[section]?.[key], + `defaultConfig.${section}.${key} dropped the schema default` + ).toBe(declared[key]); + } + } + }); + + it('validates looks against the enum the schema declares', () => { + const schema = configSchema as { + $defs: { BaseDiagramConfig: { properties: { look: { enum: string[] } } } }; + }; + // The runtime allow-list in `config.ts` is written out by hand; this is what stops it + // drifting from the schema and silently rejecting a newly added look. + expect(schema.$defs.BaseDiagramConfig.properties.look.enum).toEqual([ + 'classic', + 'handDrawn', + 'neo', + ]); + }); + + it('maps every diagram type to a config key that exists', () => { + for (const type of Object.keys(REDESIGNED_DIAGRAMS)) { + expect(defaultConfig).toHaveProperty(getDiagramConfigKey(type)); + } + expect(getDiagramConfigKey('flowchart-v2')).toBe('flowchart'); + expect(getDiagramConfigKey('flowchart-elk')).toBe('flowchart'); + expect(getDiagramConfigKey('classDiagram')).toBe('class'); + expect(getDiagramConfigKey('stateDiagram')).toBe('state'); + }); + }); +}); diff --git a/packages/mermaid/src/config.ts b/packages/mermaid/src/config.ts index 67b771a08a0..d45a206670d 100644 --- a/packages/mermaid/src/config.ts +++ b/packages/mermaid/src/config.ts @@ -2,11 +2,57 @@ import assignWithDepth from './assignWithDepth.js'; import { log } from './logger.js'; import theme from './themes/index.js'; import config from './defaultConfig.js'; -import type { MermaidConfig } from './config.type.js'; +import type { BaseDiagramConfig, MermaidConfig } from './config.type.js'; +import { getDiagramConfigKey } from './diagram-api/diagramConfigKeys.js'; import { sanitizeDirective } from './utils/sanitizeDirective.js'; export const defaultConfig: MermaidConfig = Object.freeze(config); +/** Settings a diagram type may default, and a user may set, for one diagram type alone. */ +const APPEARANCE_KEYS = ['theme', 'look', 'layout'] as const; + +type AppearanceKey = (typeof APPEARANCE_KEYS)[number]; + +type DiagramAppearance = Pick; + +/** Kept in step with the schema's `look` enum by `config.appearance.spec.ts`. */ +const LOOKS = new Set(['classic', 'handDrawn', 'neo']); + +/** + * Whether an appearance value is one this build answers to. `initialize()` normalises an + * unknown top-level `theme`, so the diagram-scoped keys need the same guard or they would + * carry a name no stylesheet matches. `layout` is exempt: its registry is extensible, and + * `getRegisteredLayoutAlgorithm` falls back at render time. + */ +const isUsableAppearance = (key: AppearanceKey, value: string) => { + if (key === 'theme') { + // `'null'` is the documented sentinel for disabling the pre-defined themes. + // `hasOwn`, not `in`: the registry is an object literal, so `__proto__` and + // `constructor` satisfy `in` and then have no `getThemeVariables`. + return value === 'null' || Object.hasOwn(theme, value); + } + return key !== 'look' || LOOKS.has(value); +}; + +/** + * Reads one appearance setting from one config layer, diagram-scoped value first. A value + * this build cannot render is passed over rather than winning, so a bogus `flowchart.theme` + * falls back to the `theme` set beside it before the next layer is tried. + */ +const readAppearance = ( + layer: MermaidConfig | undefined, + diagramConfigKey: string, + key: AppearanceKey +): DiagramAppearance[AppearanceKey] => { + if (!layer) { + return undefined; + } + const section = (layer as Record)[diagramConfigKey]; + return [section?.[key], layer[key]].find( + (value) => value !== undefined && isUsableAppearance(key, value) + ); +}; + /** * Converts a string/boolean into a boolean * @@ -18,8 +64,47 @@ export const evaluate = (val?: string | boolean | null): boolean => let siteConfig: MermaidConfig = assignWithDepth({}, defaultConfig); let configFromInitialize: MermaidConfig; +/** What the user handed to {@link setSiteConfig}, unmerged with the defaults so the + * appearance resolution can tell a user's value from the schema's. */ +let siteConfigDelta: MermaidConfig = {}; let directives: MermaidConfig[] = []; let currentConfig: MermaidConfig = assignWithDepth({}, defaultConfig); +/** Config section of the diagram in scope; `undefined` leaves the global defaults in charge. */ +let diagramConfigKey: string | undefined; + +/** + * Resolves `theme`, `look` and `layout` onto the top level of `cfg`, where renderers read + * them. Highest first: frontmatter/directive, `initialize()`, diagram default, global default. + */ +const resolveAppearance = (cfg: MermaidConfig, sumOfDirectives: MermaidConfig) => { + if (!diagramConfigKey) { + return; + } + const layers: MermaidConfig[] = [ + sumOfDirectives, + // `setConfig` passes only its own object as the directive list, so consult the real + // directives too, or a diagram default wins back over frontmatter. Later ones win. + ...[...directives].reverse(), + siteConfigDelta, + defaultConfig, + ]; + const section = (cfg as Record)[diagramConfigKey]; + for (const key of APPEARANCE_KEYS) { + for (const layer of layers) { + const value = readAppearance(layer, diagramConfigKey, key); + if (value === undefined) { + continue; + } + // Optional strings on both sides, but TS cannot see that through the key union. + (cfg as Record)[key] = value; + // Keep the section in step, so `getConfig().flowchart.look` cannot contradict `.look`. + if (section?.[key] !== undefined) { + (section as Record)[key] = value; + } + break; + } + } +}; const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: MermaidConfig[]) => { // start with config being the siteConfig @@ -36,15 +121,20 @@ const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: MermaidConfig[ cfg = assignWithDepth(cfg, sumOfDirectives); - if (sumOfDirectives.theme && sumOfDirectives.theme in theme) { + resolveAppearance(cfg, sumOfDirectives); + + // `cfg.themeVariables` were built from `siteCfg.theme`, and stylesheets gate their rules + // on the theme *name*, so a resolved theme of another name needs them rebuilt. + const themeWasOverridden = Boolean(sumOfDirectives.theme) || cfg.theme !== siteCfg.theme; + if (themeWasOverridden && cfg.theme && Object.hasOwn(theme, cfg.theme)) { + // Only `configFromInitialize` holds the variables as the user wrote them; the site + // config's are already derived, and feeding those back would override the new theme. const tmpConfigFromInitialize = assignWithDepth({}, configFromInitialize); const themeVariables = assignWithDepth( tmpConfigFromInitialize.themeVariables || {}, sumOfDirectives.themeVariables ); - if (cfg.theme && cfg.theme in theme) { - cfg.themeVariables = theme[cfg.theme as keyof typeof theme].getThemeVariables(themeVariables); - } + cfg.themeVariables = theme[cfg.theme as keyof typeof theme].getThemeVariables(themeVariables); } currentConfig = cfg; @@ -52,6 +142,16 @@ const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: MermaidConfig[ return currentConfig; }; +/** + * Names the diagram type being parsed or rendered, so its own appearance defaults apply. + * + * @param diagramType - The type `detectType` returned, e.g. `flowchart-v2`; `undefined` to leave scope. + */ +export const setDiagramConfigScope = (diagramType?: string) => { + diagramConfigKey = diagramType === undefined ? undefined : getDiagramConfigKey(diagramType); + updateCurrentConfig(siteConfig, directives); +}; + /** * Sets the `siteConfig` to the desired values. * @@ -64,11 +164,14 @@ const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: MermaidConfig[ export const setSiteConfig = (conf: MermaidConfig): MermaidConfig => { siteConfig = assignWithDepth({}, defaultConfig); siteConfig = assignWithDepth(siteConfig, conf); + siteConfigDelta = assignWithDepth({}, conf); - // @ts-ignore: TODO Fix ts errors - if (conf.theme && theme[conf.theme]) { - // @ts-ignore: TODO Fix ts errors - siteConfig.themeVariables = theme[conf.theme].getThemeVariables(conf.themeVariables); + // `hasOwn` rather than a truthiness check on `theme[conf.theme]`: `Object.prototype` + // members are truthy and carry no `getThemeVariables`. + if (conf.theme && Object.hasOwn(theme, conf.theme)) { + siteConfig.themeVariables = theme[conf.theme as keyof typeof theme].getThemeVariables( + conf.themeVariables + ); } updateCurrentConfig(siteConfig, directives); @@ -81,6 +184,7 @@ export const saveConfigFromInitialize = (conf: MermaidConfig): void => { export const updateSiteConfig = (conf: MermaidConfig): MermaidConfig => { siteConfig = assignWithDepth(siteConfig, conf); + siteConfigDelta = assignWithDepth(siteConfigDelta, conf); updateCurrentConfig(siteConfig, directives); return siteConfig; @@ -194,6 +298,8 @@ export const addDirective = (directive: MermaidConfig) => { export const reset = (config = siteConfig): void => { // Replace current config with siteConfig directives = []; + // Leave diagram scope too, or a stale type keeps applying its appearance defaults. + diagramConfigKey = undefined; updateCurrentConfig(config, directives); }; diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 3afc433e008..35e5234c5a4 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -366,6 +366,29 @@ export interface MermaidConfig { * via the `definition` "FlowchartDiagramConfig". */ export interface FlowchartDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * Margin top for the text over the diagram */ @@ -469,6 +492,34 @@ export interface BaseDiagramConfig { * */ useMaxWidth?: boolean; + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; + /** + * Defines which layout algorithm to use for rendering the diagram. + * + */ + layout?: string; } /** * The object containing configurations specific for the swimlanes diagram type. @@ -482,6 +533,34 @@ export interface BaseDiagramConfig { * via the `definition` "SwimlaneDiagramConfig". */ export interface SwimlaneDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; + /** + * Defines which layout algorithm to use for rendering the diagram. + * + */ + layout?: string; /** * Renders edge crossings as small arcs ("hops") or visible gaps so that * overlapping edges are easier to read. Set to `false` to disable. Edges @@ -521,6 +600,29 @@ export interface SwimlaneDiagramConfig extends BaseDiagramConfig { * via the `definition` "AgentflowDiagramConfig". */ export interface AgentflowDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * Margin top for the text over the diagram */ @@ -555,6 +657,29 @@ export interface AgentflowDiagramConfig extends BaseDiagramConfig { * via the `definition` "SequenceDiagramConfig". */ export interface SequenceDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; arrowMarkerAbsolute?: boolean; hideUnusedParticipants?: boolean; /** @@ -957,6 +1082,29 @@ export interface TimelineDiagramConfig extends BaseDiagramConfig { * via the `definition` "ClassDiagramConfig". */ export interface ClassDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * Margin top for the text over the diagram */ @@ -1002,6 +1150,29 @@ export interface ClassDiagramConfig extends BaseDiagramConfig { * via the `definition` "StateDiagramConfig". */ export interface StateDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * Margin top for the text over the diagram */ @@ -1043,6 +1214,29 @@ export interface StateDiagramConfig extends BaseDiagramConfig { * via the `definition` "ErDiagramConfig". */ export interface ErDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * Margin top for the text over the diagram */ @@ -1311,6 +1505,29 @@ export interface XYChartAxisConfig { * via the `definition` "RequirementDiagramConfig". */ export interface RequirementDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; rect_fill?: string; text_color?: string; rect_border_size?: string; @@ -2059,6 +2276,29 @@ export interface RadarDiagramConfig extends BaseDiagramConfig { * via the `definition` "UsecaseDiagramConfig". */ export interface UsecaseDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * Font size for actor labels */ @@ -2111,6 +2351,33 @@ export interface UsecaseDiagramConfig extends BaseDiagramConfig { * Padding around the entire diagram */ diagramPadding?: number; + /** + * How a use case diagram takes its colours from the active theme. + * + * `role` (the default) gives every element of a kind one colour, read from the + * `usecaseActorBkg` / `usecaseActorBorder`, `usecaseBkg` / `usecaseBorder`, and + * `usecaseBoundaryBkg` / `usecaseBoundaryBorder` theme variables. Colour then says + * what an element *is*, and it is invariant under insertion, reordering, and + * renaming -- adding one use case in the middle does not recolour the ones after it, + * so diffs, documentation screenshots, and visual baselines stay stable. + * + * `rotate` instead gives each actor, use case, and system boundary its own slot from + * the theme's categorical palette (`borderColorArray` / `bkgColorArray`), the way ER + * entities and class boxes are coloured. Actors and use cases share one cycle, and + * system boundaries run a second one from zero, so a boundary is never forced to + * match an element inside it. Within the shared cycle the actors are numbered first + * and the use cases after them, each in declaration order -- an interleaved + * `actor A`, `usecase U`, `actor B` is therefore numbered A, B, U rather than + * A, U, B. This buys per-instance variety at the cost of the stability `role` has: + * inserting an element shifts the colour of every later element of its own kind, and + * inserting an actor shifts the use cases too. Only the colour themes + * (`redux-color`, `redux-dark-color`) carry a palette, so on every other theme the + * two settings render identically. + * + * `classDef` and `style` keep overriding both, whichever is set. + * + */ + colorScheme?: 'role' | 'rotate'; } /** * The object containing configurations specific for Venn diagrams. @@ -2119,6 +2386,29 @@ export interface UsecaseDiagramConfig extends BaseDiagramConfig { * via the `definition` "VennDiagramConfig". */ export interface VennDiagramConfig extends BaseDiagramConfig { + /** + * Theme, the CSS style sheet. + * You may also use `themeCSS` to override this value. + * + */ + theme?: + | 'default' + | 'base' + | 'dark' + | 'forest' + | 'neutral' + | 'neo' + | 'neo-dark' + | 'redux' + | 'redux-dark' + | 'redux-color' + | 'redux-dark-color' + | 'null'; + /** + * Defines which main look to use for the diagram. + * + */ + look?: 'classic' | 'handDrawn' | 'neo'; /** * The width of the Venn diagram. */ diff --git a/packages/mermaid/src/config.usecase.spec.ts b/packages/mermaid/src/config.usecase.spec.ts index 84063ddac29..31b6725a002 100644 --- a/packages/mermaid/src/config.usecase.spec.ts +++ b/packages/mermaid/src/config.usecase.spec.ts @@ -18,11 +18,28 @@ interface ConfigSchema { $defs: Record; } +/** + * `BaseDiagramConfig` carries the shared `theme` definition, whose `meta:enum` is + * documentation for jsonschema2md rather than a validation keyword. Ajv's strict mode + * refuses to compile a schema containing a keyword it has never heard of, so declare it + * here the same way the schema build scripts do. + */ +const compileUsecaseSchema = () => { + const ajv = new Ajv2019({ allErrors: true, allowUnionTypes: true, strict: true }); + ajv.addKeyword({ keyword: 'meta:enum', errors: false }); + ajv.addKeyword({ keyword: 'tsType', errors: false }); + return ajv; +}; + const schema = configSchema as ConfigSchema; const usecaseDefinition = schema.$defs.UsecaseDiagramConfig; const baseDefinition = schema.$defs.BaseDiagramConfig; const supportedConfig = { + // The usecase diagram is one of the types that declares its own appearance + // defaults, so they are part of its runtime config surface. + theme: 'redux-color', + look: 'neo', actorFontSize: 14, actorFontFamily: '"Open Sans", sans-serif', actorFontWeight: 'normal', @@ -32,6 +49,7 @@ const supportedConfig = { nodeSpacing: 50, rankSpacing: 50, diagramPadding: 20, + colorScheme: 'role', useMaxWidth: true, } satisfies UsecaseDiagramConfig; @@ -60,6 +78,8 @@ describe('usecase configuration', () => { unevaluatedProperties: false, required: ['useMaxWidth'], properties: { + theme: { default: 'redux-color' }, + look: { default: 'neo' }, actorFontSize: { default: 14 }, actorFontFamily: { default: '"Open Sans", sans-serif' }, actorFontWeight: { default: 'normal' }, @@ -69,10 +89,15 @@ describe('usecase configuration', () => { nodeSpacing: { default: 50 }, rankSpacing: { default: 50 }, diagramPadding: { default: 20 }, + // `role` is the default deliberately: colour keyed to the kind of element is + // invariant under insertion and reordering, where the rotating palette is not. + colorScheme: { default: 'role' }, }, }); expect(baseDefinition.properties?.useMaxWidth).toMatchObject({ default: true }); expect(Object.keys(usecaseDefinition.properties ?? {})).toEqual([ + 'theme', + 'look', 'actorFontSize', 'actorFontFamily', 'actorFontWeight', @@ -82,11 +107,12 @@ describe('usecase configuration', () => { 'nodeSpacing', 'rankSpacing', 'diagramPadding', + 'colorScheme', ]); }); it.each(['actorMargin', 'usecaseMargin'])('rejects removed %s in the JSON Schema', (key) => { - const ajv = new Ajv2019({ allErrors: true, allowUnionTypes: true, strict: true }); + const ajv = compileUsecaseSchema(); const validate = ajv.compile({ $schema: schema.$schema, $defs: { BaseDiagramConfig: baseDefinition }, @@ -107,7 +133,7 @@ describe('usecase configuration', () => { // implicit guarantees of addDirective's sanitize() and setProperty(). describe('font pattern constraints', () => { const compile = () => { - const ajv = new Ajv2019({ allErrors: true, allowUnionTypes: true, strict: true }); + const ajv = compileUsecaseSchema(); return ajv.compile({ $schema: schema.$schema, $defs: { BaseDiagramConfig: baseDefinition }, diff --git a/packages/mermaid/src/defaultAppearanceDocs.spec.ts b/packages/mermaid/src/defaultAppearanceDocs.spec.ts new file mode 100644 index 00000000000..315caea97ce --- /dev/null +++ b/packages/mermaid/src/defaultAppearanceDocs.spec.ts @@ -0,0 +1,67 @@ +/** + * The ten pages that document the per-diagram defaults each show the same diagram twice -- + * once with the defaults and once pinned back to `default`/`classic`. They render on the + * public docs site, so a syntax slip in one shows up there as an error diagram. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it, beforeAll } from 'vitest'; +import { addDiagrams } from './diagram-api/diagram-orchestration.js'; +import { mermaidAPI } from './mermaidAPI.js'; + +const PAGES = [ + 'agentflow', + 'flowchart', + 'swimlanes', + 'classDiagram', + 'entityRelationshipDiagram', + 'requirementDiagram', + 'sequenceDiagram', + 'stateDiagram', + 'usecase', + 'venn', +]; + +const readPage = (page: string): string => { + const path = [ + resolve(process.cwd(), `packages/mermaid/src/docs/syntax/${page}.md`), + resolve(process.cwd(), `src/docs/syntax/${page}.md`), + ].find((candidate) => existsSync(candidate)); + if (!path) { + throw new Error(`Documentation page not found: ${page}.md`); + } + return readFileSync(path, 'utf8'); +}; + +/** The `mermaid-example` fences inside the "Default theme and look" section. */ +const appearanceExamples = (page: string): string[] => { + const section = /## Default theme and look[^\n]*\n[\S\s]*?(?=\n## )/.exec(readPage(page))?.[0]; + expect(section, `${page}.md has no "Default theme and look" section`).toBeDefined(); + return [...section!.matchAll(/^```mermaid-example[^\n]*\r?\n([\S\s]*?)\r?\n```$/gm)].map( + ([, source]) => source + ); +}; + +describe('per-diagram appearance documentation', () => { + beforeAll(() => { + addDiagrams(); + }); + + it.each(PAGES)('%s.md shows the diagram with the defaults and pinned back', async (page) => { + const examples = appearanceExamples(page); + expect(examples).toHaveLength(2); + + // `contributing.md` asks for a version marker on newly documented behaviour. + expect(readPage(page)).toContain('## Default theme and look (v+)'); + + const [withDefaults, pinnedBack] = examples; + // The pair has to be the same diagram, or the comparison teaches nothing. + expect(pinnedBack).toContain(withDefaults.trim()); + expect(pinnedBack).toContain('theme: default'); + expect(pinnedBack).toContain('look: classic'); + + for (const source of examples) { + await expect(mermaidAPI.parse(source), `${page}.md:\n${source}`).resolves.toBeTruthy(); + } + }); +}); diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index c52397f218b..8c4312a8dac 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -69,12 +69,16 @@ const config: RequiredDeep = { }, }, class: { + // Built from scratch rather than spread from the schema, so the appearance defaults + // have to be carried across by hand; the rest stay off, `padding` above all — the + // schema default of 5 would change class node dimensions on the unified renderer. + // Optional chaining: the docs scripts short-circuit `.schema.yaml` to `{}`. + theme: defaultConfigJson.class?.theme, + look: defaultConfigJson.class?.look, + layout: defaultConfigJson.class?.layout, defaultRenderer: 'dagre-wrapper', hideEmptyMembersBox: false, hierarchicalNamespaces: true, - // `padding` is intentionally left undefined so the unified (v2) renderer keeps - // its own node sizing — setting the schema default of 5 here would change class - // node dimensions. }, gantt: { ...defaultConfigJson.gantt, diff --git a/packages/mermaid/src/defaultTheme.spec.ts b/packages/mermaid/src/defaultTheme.spec.ts new file mode 100644 index 00000000000..1e0a744b998 --- /dev/null +++ b/packages/mermaid/src/defaultTheme.spec.ts @@ -0,0 +1,118 @@ +/** + * The theme name and the theme variables have to agree. If they drift nothing throws -- + * `theme` names one theme while `themeVariables` carries another's palette, and diagrams + * render in a mixture. Resolution order is `config.appearance.spec.ts`'s subject, not this + * file's. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as configApi from './config.js'; +import erStyles from './diagrams/er/styles.js'; +import { mermaidAPI } from './mermaidAPI.js'; +import themes from './themes/index.js'; + +/** What mermaid renders with when neither the user nor the diagram type says otherwise. */ +const GLOBAL_DEFAULT_THEME = 'default'; + +/** What the diagram types redesigned for it default to instead. */ +const DIAGRAM_DEFAULT_THEME = 'redux-color'; + +/** + * A variable only the colour themes define -- a cheap fingerprint for the palette. + * Returns the sentinel `'none'` rather than letting `JSON.stringify(undefined)` yield the + * *value* `undefined`, which would make a `.not.toBe('undefined')` assertion pass + * vacuously. + */ +const fingerprint = (variables: Record | undefined): string => + Array.isArray(variables?.borderColorArray) ? JSON.stringify(variables.borderColorArray) : 'none'; + +const fingerprintOf = (name: keyof typeof themes) => + fingerprint(themes[name].getThemeVariables({}) as unknown as Record); + +describe('default theme', () => { + beforeEach(() => { + configApi.saveConfigFromInitialize({}); + configApi.setSiteConfig({}); + configApi.reset(); + }); + + it(`is ${GLOBAL_DEFAULT_THEME} outside of any diagram`, () => { + expect(configApi.getConfig().theme).toBe(GLOBAL_DEFAULT_THEME); + }); + + it('ships themeVariables matching the theme it names', () => { + const config = configApi.getConfig(); + expect(fingerprint(config.themeVariables)).toBe(fingerprintOf(GLOBAL_DEFAULT_THEME)); + }); + + it('resolves themeVariables to the default theme when initialize is given no theme', () => { + mermaidAPI.initialize({}); + expect(fingerprint(configApi.getConfig().themeVariables)).toBe( + fingerprintOf(GLOBAL_DEFAULT_THEME) + ); + }); + + it('falls back to the default theme for an unrecognised theme name', () => { + // @ts-expect-error deliberately not a member of the theme union + mermaidAPI.initialize({ theme: 'not-a-real-theme' }); + const config = configApi.getConfig(); + expect(fingerprint(config.themeVariables)).toBe(fingerprintOf(GLOBAL_DEFAULT_THEME)); + // The name too, not just the variables: stylesheets gate their palette rules on it. + expect(config.theme).toBe(GLOBAL_DEFAULT_THEME); + }); + + it("preserves the 'null' sentinel, which disables the pre-defined themes", () => { + // Documented in the schema as "Can be set to disable any pre-defined mermaid theme". + // Normalising it to the default theme name would re-enable one, so the fallback must + // leave this value alone even though it is not a registered theme. + mermaidAPI.initialize({ theme: 'null' }); + expect(configApi.getConfig().theme).toBe('null'); + }); + + it('still honours an explicitly chosen theme', () => { + mermaidAPI.initialize({ theme: 'forest' }); + const config = configApi.getConfig(); + expect(config.theme).toBe('forest'); + // `forest` has no categorical colour arrays, so the fingerprint must go away. + expect(fingerprint(config.themeVariables)).toBe('none'); + }); + + describe('when a diagram type defaults to a different theme', () => { + /** + * The config the renderers are handed. Scope is bounded to the parse, so it is + * re-established here over the same directives to read back its resolution. + */ + const configFor = async (text: string) => { + const { diagramType } = await mermaidAPI.parse(text); + configApi.setDiagramConfigScope(diagramType); + const config = configApi.getConfig(); + configApi.setDiagramConfigScope(undefined); + return config; + }; + + it('carries that theme and its variables together', async () => { + const config = await configFor('erDiagram\n CUSTOMER ||--o{ ORDER : places'); + expect(config.theme).toBe(DIAGRAM_DEFAULT_THEME); + expect(fingerprint(config.themeVariables)).toBe(fingerprintOf(DIAGRAM_DEFAULT_THEME)); + expect(fingerprint(config.themeVariables)).not.toBe('none'); + }); + + it('emits palette CSS, not just palette variables', async () => { + // Where a name/variables disagreement would show: `er/styles.ts` gates on the name, + // so a stale one leaves the palette in the variables and absent from the CSS. + const config = await configFor('erDiagram\n CUSTOMER ||--o{ ORDER : places'); + const css = erStyles({ + ...(config.themeVariables as unknown as Record), + theme: config.theme, + look: config.look, + THEME_COLOR_LIMIT: 12, + } as never); + expect(css).toContain('[data-color-id="color-0"]'); + }); + + it('leaves the theme alone for a diagram type that did not opt in', async () => { + const config = await configFor('pie\n "Dogs" : 40'); + expect(config.theme).toBe(GLOBAL_DEFAULT_THEME); + expect(fingerprint(config.themeVariables)).toBe(fingerprintOf(GLOBAL_DEFAULT_THEME)); + }); + }); +}); diff --git a/packages/mermaid/src/diagram-api/diagramConfigKeys.ts b/packages/mermaid/src/diagram-api/diagramConfigKeys.ts new file mode 100644 index 00000000000..9aa4c57bdb9 --- /dev/null +++ b/packages/mermaid/src/diagram-api/diagramConfigKeys.ts @@ -0,0 +1,23 @@ +/** + * Diagram types whose id differs from the `MermaidConfig` key their configuration lives + * under. Several share one section on purpose: they are renderers or parser versions of + * one diagram, so a setting made under `flowchart`, `class` or `state` must reach any of them. + */ +const DIAGRAM_CONFIG_KEY_ALIASES: Record = { + 'flowchart-v2': 'flowchart', + 'flowchart-elk': 'flowchart', + classDiagram: 'class', + stateDiagram: 'state', + xychart: 'xyChart', + railroadAbnf: 'railroad', + railroadEbnf: 'railroad', + railroadPeg: 'railroad', +}; + +/** + * Returns the `MermaidConfig` key holding the configuration for `diagramType`. The key is + * not guaranteed to exist -- `info` and `error` have no section -- so treat a missing one + * as "nothing configured for this type". + */ +export const getDiagramConfigKey = (diagramType: string): string => + DIAGRAM_CONFIG_KEY_ALIASES[diagramType] ?? diagramType; diff --git a/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts b/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts index 924e777e6e0..d76da220cba 100644 --- a/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts +++ b/packages/mermaid/src/diagrams/agentflow/agentflowDb.ts @@ -36,9 +36,11 @@ import type { SemanticEdge, SemanticSubGraph, SemanticVertex, + VertexKind, } from './types.js'; import { AgentflowWarning } from './diagnostics.js'; import { normaliseNodeShapes, resolveShapeAlias } from './shapes.js'; +import { assignColorSlots } from './colorSlots.js'; import type { AgentflowDiagnostic, AgentflowDiagnosticContext, @@ -1903,6 +1905,61 @@ You have to call mermaid.initialize.` // returned `[]`. normaliseNodeShapes(nodes, this); + // Palette slots. Wired here rather than in `transformData` because this is the path the + // renderer actually takes -- `getData()` calls `normaliseNodeShapes` directly, so a hook + // added to `transformData` never runs on a real render. + // + // The kind comes from the vertex record, not from the resolved shape: a `connector` and a + // `task` are both `roundedRect`, so reading it off the shape paints every connector as a + // task. + // Declaration order for the container counter. Neither `nodes` nor `subGraphs` carries + // it: the loops above walk `subGraphs` in REVERSE, and `subGraphs` is itself in + // completion order, because the grammar reduces a container when it closes and a + // nested one therefore lands before its parent. Un-reversing would fix the flat case + // and still get nesting wrong. + // + // A pre-order walk of the containment forest recovers source order: roots complete in + // source order relative to each other, and a parent is always declared before the + // children it holds. `flowDb` builds its `declarationIndex` the same way. + const containerOrder = new Map(); + const childContainers = new Map(); + for (const sg of subGraphs) { + const parent = parentDB.get(sg.id); + if (parent !== undefined) { + childContainers.set(parent, [...(childContainers.get(parent) ?? []), sg.id]); + } + } + let nextContainer = 0; + const walkContainers = (id: string) => { + if (containerOrder.has(id)) { + return; // a containment cycle was refused above; do not loop on its remnant + } + containerOrder.set(id, nextContainer++); + for (const child of childContainers.get(id) ?? []) { + walkContainers(child); + } + }; + for (const sg of subGraphs) { + if (parentDB.get(sg.id) === undefined) { + walkContainers(sg.id); + } + } + + const connectorIds = new Set(this.getConnectors().map((c) => c.id)); + assignColorSlots( + nodes, + (id) => { + if (connectorIds.has(id)) { + return 'connector'; + } + const v = this.vertices.get(id); + return v + ? this.deriveVertexKind(v, resolveShapeAlias(v.type as string | undefined)) + : undefined; + }, + containerOrder + ); + return { nodes, edges, @@ -1928,6 +1985,33 @@ You have to call mermaid.initialize.` // arrow/stroke/label, subgraph membership, type/template declarations, // diagnostics) are kept. + /** + * The v0.8.1 §4 vertex kind for a parsed vertex. + * + * Extracted so the semantic model and `getData()`'s palette slots read the same rules + * from one place. Kind is NOT recoverable from the resolved shape alone — a tool and a + * task can both land on `roundedRect` — which is why this takes the vertex and not just + * its shape. + */ + private deriveVertexKind(v: FlowVertex, resolvedShape: string | undefined): VertexKind { + if (this.isToolDefinition(v)) { + return 'tool'; + } + if (resolvedShape === 'hexagon' || resolvedShape === 'hex') { + return 'action'; + } + if (resolvedShape === 'lean-right' || resolvedShape === 'lean_right') { + return 'input'; + } + if (resolvedShape === 'lin-doc' || resolvedShape === 'lined-document') { + return 'refdoc'; + } + if (resolvedShape === 'diamond') { + return 'decision'; + } + return 'task'; + } + public getSemanticModel(): AgentflowSemanticModel { // Run the post-parse validators so that the semantic export includes // up-to-date diagnostics. @@ -1973,19 +2057,7 @@ You have to call mermaid.initialize.` vertex.shape = resolvedShape; } // Derived vertex kind per v0.8.1 §4. - if (this.isToolDefinition(v)) { - vertex.vertexKind = 'tool'; - } else if (resolvedShape === 'hexagon' || resolvedShape === 'hex') { - vertex.vertexKind = 'action'; - } else if (resolvedShape === 'lean-right' || resolvedShape === 'lean_right') { - vertex.vertexKind = 'input'; - } else if (resolvedShape === 'lin-doc' || resolvedShape === 'lined-document') { - vertex.vertexKind = 'refdoc'; - } else if (resolvedShape === 'diamond') { - vertex.vertexKind = 'decision'; - } else { - vertex.vertexKind = 'task'; - } + vertex.vertexKind = this.deriveVertexKind(v, resolvedShape); if (v.metadata && Object.keys(v.metadata).length > 0) { // Strip presentation-only keys from metadata passthrough. const meta: Record = {}; diff --git a/packages/mermaid/src/diagrams/agentflow/colorSlots.spec.ts b/packages/mermaid/src/diagrams/agentflow/colorSlots.spec.ts new file mode 100644 index 00000000000..728b097a684 --- /dev/null +++ b/packages/mermaid/src/diagrams/agentflow/colorSlots.spec.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import * as configApi from '../../config.js'; +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { LayoutData } from '../../rendering-util/types.js'; +import getStyles from './styles.js'; +import { + KIND_COUNT, + KIND_SLOT, + assignColorSlots, + containerSlot, + containerSlotCount, +} from './colorSlots.js'; + +/** + * Two rules, and the point of the tests is that they stay apart: + * + * - a node's colour follows its KIND, from a fixed slot, so it does not move when the + * diagram is edited around it; + * - a container's colour follows a counter in declaration order, as flowchart subgraphs do. + * + * Both halves are pinned — the assignment and the rules that paint it — because either + * alone is silent. A class or slot with no rule renders uncoloured; a rule with nothing + * carrying it is dead CSS. Neither throws. + */ +const node = (id: string, kind: string) => ({ id, kind, isGroup: false }) as any; +/** Kind comes from the db in production; here it rides on the fixture. */ +const kindOf = (nodes: any[]) => (id: string) => nodes.find((n) => n.id === id)?.kind; +const group = (id: string) => ({ id, isGroup: true }) as any; +const layout = (nodes: any[]) => ({ nodes, edges: [], config: {} }) as unknown as LayoutData; +/** + * Declaration order. In production `getData()` derives this from a pre-order walk of the + * containment forest; these fixtures are already written in source order, so the array + * index is the same thing. + */ +const orderOf = (nodes: any[]) => + new Map( + nodes + .filter((n) => n.isGroup || n.shape === 'collapsedGroup') + .map((n, i) => [String(n.id), i] as const) + ); + +describe('agentflow colour slots', () => { + beforeEach(() => { + configApi.setSiteConfig({}); + configApi.reset(); + }); + + const withPalette = (n: number) => + configApi.setSiteConfig({ + themeVariables: { borderColorArray: Array.from({ length: n }, (_, i) => `#00000${i % 10}`) }, + } as any); + + describe('assignment', () => { + it('tags a node with the kind the db reports', () => { + withPalette(12); + const data = layout([node('a', 'tool'), node('b', 'decision'), node('c', 'task')]); + + assignColorSlots(data.nodes as any, kindOf(data.nodes as any), orderOf(data.nodes as any)); + + expect(data.nodes[0].cssClasses).toContain('af-kind-tool'); + expect(data.nodes[1].cssClasses).toContain('af-kind-decision'); + expect(data.nodes[2].cssClasses).toContain('af-kind-task'); + }); + + it('gives two nodes of one kind the same class, wherever they sit', () => { + // The whole point of colouring by kind: editing around a node does not recolour it. + withPalette(12); + const data = layout([node('a', 'tool'), node('b', 'decision'), node('c', 'tool')]); + + assignColorSlots(data.nodes as any, kindOf(data.nodes as any), orderOf(data.nodes as any)); + + expect(data.nodes[0].cssClasses).toBe(data.nodes[2].cssClasses); + }); + + it('keeps any classes the node already carried', () => { + withPalette(12); + const data = layout([{ ...node('a', 'decision'), cssClasses: 'mine' }]); + + assignColorSlots(data.nodes as any, kindOf(data.nodes as any), orderOf(data.nodes as any)); + + expect(data.nodes[0].cssClasses).toBe('mine af-kind-decision'); + }); + + it('numbers containers from the order it is given, above the kind slots', () => { + withPalette(12); + const data = layout([group('one'), node('a', 'decision'), group('two'), group('three')]); + + assignColorSlots(data.nodes as any, kindOf(data.nodes as any), orderOf(data.nodes as any)); + + expect(data.nodes[0].colorIndex).toBe(KIND_COUNT); + expect(data.nodes[2].colorIndex).toBe(KIND_COUNT + 1); + expect(data.nodes[3].colorIndex).toBe(KIND_COUNT + 2); + // A node takes no slot; its colour comes from its class. + expect(data.nodes[1].colorIndex).toBeUndefined(); + }); + + it('never folds a container back onto a kind slot', () => { + // `stampColorSlot` takes the modulo against the whole palette, so the wrap has to + // happen here — otherwise the container that runs off the end of the palette lands + // back on the `tool` colour. The effective length is read from config rather than + // assumed: `setSiteConfig` merges into the theme's own array. + withPalette(12); + const palette = + (getConfig().themeVariables as { borderColorArray?: string[] }).borderColorArray ?? []; + const data = layout(Array.from({ length: palette.length * 2 }, (_, i) => group(`g${i}`))); + + assignColorSlots(data.nodes as any, kindOf(data.nodes as any), orderOf(data.nodes as any)); + + for (const n of data.nodes) { + expect(n.colorIndex).toBeGreaterThanOrEqual(KIND_COUNT); + // What the stamp will actually resolve to. + expect(n.colorIndex! % palette.length).toBeGreaterThanOrEqual(KIND_COUNT); + } + }); + + it('stamps a slot the stylesheet actually names, on a palette shorter than the kinds', () => { + // The regression: `stampColorSlot` reduces the index modulo the palette length, so a + // 3-colour palette turned an assigned slot 7 into `color-1` while the stylesheet + // emitted a rule for `color-7` — every container silently unpainted. Both sides now + // go through `containerSlot`, so the assigned value IS the stamped value. + withPalette(3); + const data = layout([group('one'), group('two')]); + + assignColorSlots(data.nodes as any, kindOf(data.nodes as any), orderOf(data.nodes as any)); + + const palette = (getConfig().themeVariables as { borderColorArray?: string[] }) + .borderColorArray!; + for (const n of data.nodes) { + // What `stampColorSlot` will write, and what `genColor` will have named. + expect(n.colorIndex! % palette.length).toBe(n.colorIndex); + expect(n.colorIndex).toBe(containerSlot(0, palette.length)); + } + expect(containerSlotCount(3)).toBe(1); + }); + + it('agrees with the stylesheet at every palette length', () => { + // Cheap exhaustive check of the one invariant that matters: whatever the palette + // length, the slot the assignment produces is a slot `genColor` emits a rule for. + for (const len of [1, 3, 6, 7, 8, 12, 24]) { + for (let i = 0; i < containerSlotCount(len); i++) { + const slot = containerSlot(i, len); + expect(slot, `palette ${len}, container ${i}`).toBeLessThan(Math.max(1, len)); + expect(slot).toBe(slot % Math.max(1, len)); + } + } + }); + }); + + const paletteOptions = { + arrowheadColor: '#333', + border2: '#333', + clusterBkg: '#f4f4f4', + clusterBorder: '#ccc', + edgeLabelBackground: '#fff', + fontFamily: 'trebuchet ms', + lineColor: '#333', + mainBkg: '#eee', + nodeBorder: '#999', + nodeTextColor: '#333', + tertiaryColor: '#ffffde', + textColor: '#333', + titleColor: '#333', + theme: 'redux-color', + look: 'neo', + borderColorArray: Array.from({ length: 12 }, (_, i) => `#b0000${i.toString(16)}`), + bkgColorArray: Array.from({ length: 12 }, (_, i) => `#f0000${i.toString(16)}`), + } as any; + + describe('stylesheet', () => { + it('emits a rule for every kind, at that kind slot colour', () => { + const css = getStyles(paletteOptions); + + for (const [kind, slot] of KIND_SLOT) { + expect(css, `rule for ${kind}`).toContain(`.af-kind-${kind}`); + expect(css, `colour for ${kind}`).toContain(paletteOptions.borderColorArray[slot]); + } + }); + + it('paints containers from the slots above the kind range', () => { + const css = getStyles(paletteOptions); + const first = `[data-color-id="color-${KIND_COUNT}"]`; + + // The first container slot sits after the kinds, so its colour cannot collide with + // any node's. + expect(css).toContain(`${first}.cluster`); + expect(css).toContain(paletteOptions.borderColorArray[KIND_COUNT]); + // No container may use a kind's colour. + const containerRules = css.slice(css.indexOf(first)); + for (const [kind, slot] of KIND_SLOT) { + expect(containerRules, `container reusing the ${kind} colour`).not.toContain( + paletteOptions.borderColorArray[slot] + ); + } + }); + + it('names both forms of a container, so a collapsed one is not left grey', () => { + // A collapsed container is drawn as a `.node`, not a `.cluster`, but still holds a + // slot — `collapsedGroup.ts` stamps it. Naming only `.cluster` would leave it + // uncoloured beside its siblings. + const css = getStyles(paletteOptions); + const first = `[data-color-id="color-${KIND_COUNT}"]`; + + expect(css).toContain(`${first}.cluster`); + expect(css).toContain(`${first}.node`); + }); + + it('uses no container slot below the kind range', () => { + const css = getStyles(paletteOptions); + + for (let slot = 0; slot < KIND_COUNT; slot++) { + expect(css, `slot ${slot} belongs to a kind`).not.toContain( + `[data-color-id="color-${slot}"]` + ); + } + }); + + it('strokes without filling when the theme carries no background palette', () => { + // `redux-dark-color` is exactly this shape: 12 borders, no fills. + const css = getStyles({ ...paletteOptions, bkgColorArray: [] }); + + expect(css).toContain('.af-kind-tool'); + expect(css).toContain(paletteOptions.borderColorArray[0]); + expect(css).not.toContain(paletteOptions.bkgColorArray[0]); + }); + + it('emits nothing for a theme that carries no palette', () => { + const css = getStyles({ ...paletteOptions, theme: 'default' }); + + expect(css).not.toContain('af-kind-'); + expect(css).not.toContain('data-color-id'); + }); + + it('rejects a look that would break out of the selector', () => { + const css = getStyles({ ...paletteOptions, look: 'neo"] { fill: red } [x="' }); + + expect(css).not.toContain('fill: red'); + expect(css).toContain('[data-look="classic"]'); + }); + }); +}); diff --git a/packages/mermaid/src/diagrams/agentflow/colorSlots.ts b/packages/mermaid/src/diagrams/agentflow/colorSlots.ts new file mode 100644 index 00000000000..ae140651444 --- /dev/null +++ b/packages/mermaid/src/diagrams/agentflow/colorSlots.ts @@ -0,0 +1,119 @@ +/** + * Palette slots for agentflow, under the redux colour themes. + * + * Two different rules, because agentflow has two different things to colour: + * + * - **Nodes take a colour per KIND.** A `tool` is not a `task` is not a `decision` — the + * diagram type exists to say so — and colour that tracks kind is invariant under + * editing: inserting a node in the middle recolours nothing. Each kind is pinned to a + * fixed palette slot, so a tool is the same colour in every diagram. + * - **Containers cycle a counter**, exactly as flowchart subgraphs do: one counter across + * the whole diagram, in declaration order, so sibling containers differ and a nested one + * continues the cycle instead of restarting it. + * + * The two ranges are kept apart. Kinds own slots `0 .. KIND_COUNT-1` and containers cycle + * the slots above them, so a container frame can never land on the same colour as a node + * inside it — which would read as the node bleeding into its own container. + * + * Kind comes from the db's own `vertexKind`, never from the rendered shape. Shape is not + * one-to-one with kind — a `connector` and a `task` are both `roundedRect` — so inverting + * the shape map would silently paint every connector as a task. + */ +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { Node } from '../../rendering-util/types.js'; + +/** + * Fixed slot per kind. Declaration order of this map is the palette order, so `tool` is + * always the first palette colour, `task` the second, and so on. + */ +export const KIND_SLOT: ReadonlyMap = new Map([ + ['tool', 0], + ['task', 1], + ['decision', 2], + ['input', 3], + ['refdoc', 4], + ['connector', 5], + ['action', 6], +]); + +/** Slots reserved for kinds. Containers start above this. */ +export const KIND_COUNT = KIND_SLOT.size; + +/** Class marking a node's kind, matched by the rules `styles.ts` emits. */ +export const kindClass = (kind: string): string => `af-kind-${kind}`; + +/** Every kind, for the stylesheet to iterate. */ +export const KINDS: readonly string[] = [...KIND_SLOT.keys()]; + +/** + * How many slots containers may cycle through, given a palette length. + * + * At least one: a palette shorter than the kind range would otherwise yield a modulo by + * zero, and one repeated container colour beats a crash. + */ +export const containerSlotCount = (paletteLength: number): number => + Math.max(1, paletteLength - KIND_COUNT); + +/** + * The palette slot for the `n`th container, given the palette length. + * + * The single source of truth for both sides. `stampColorSlot` reduces whatever index it is + * given modulo the palette length, so the stylesheet has to name the slot the stamp will + * actually produce — otherwise a palette shorter than the kind range assigns slot 7, the + * stamp writes `color-1`, and the rule for `color-7` matches nothing. Measured before this + * was shared: a 3-colour palette left every container unpainted. + * + * On such a short palette a container will land on a kind's colour. That is unavoidable — + * seven kinds already collide among themselves below eight colours — and a shared colour + * beats an unpainted frame. + */ +export const containerSlot = (n: number, paletteLength: number): number => { + const slot = KIND_COUNT + (n % containerSlotCount(paletteLength)); + return paletteLength > 0 ? slot % paletteLength : slot; +}; + +/** + * Tag every node with its kind class, and give every container its slot. + * + * Nodes are tagged rather than stamped with `data-color-id` because agentflow draws + * through six different shapes — `subroutine`, `diamond`, `hexagon`, `lean-right`, + * `lin-doc`, `roundedRect` — and of the shared shapes only `squareRect` stamps for + * itself. A class travels through the shared pipeline on every one of them, and keeps this + * change inside agentflow instead of editing six files other diagrams also draw with. + * + * Containers use `colorIndex` and nothing else — the same mechanism flowchart subgraphs, + * block composites, state composites and usecase boundaries all use. Both forms are + * covered: `createContainerGroup` stamps the expanded frame and `collapsedGroup.ts` stamps + * the collapsed one, so collapsing a container keeps its colour. + * + * `containerOrder` carries declaration order, which the node array does not: `getData()` + * walks `subGraphs` in reverse, and `subGraphs` is itself in completion order, so neither + * it nor its reverse is the order the author wrote. The caller does that walk. + */ +export function assignColorSlots( + nodes: Node[], + kindOf: (id: string) => string | undefined, + containerOrder: ReadonlyMap +): void { + const palette = (getConfig().themeVariables as { borderColorArray?: unknown })?.borderColorArray; + const paletteLength = Array.isArray(palette) ? palette.length : 0; + + // Containers that the walk did not reach still need a slot, and they must not all take + // the same one. Counted after the declared ones so nothing shifts. + let fallbackOrdinal = containerOrder.size; + for (const node of nodes ?? []) { + // A collapsed container is drawn as a single node (`isGroup` is false) but it is still + // a container, and it takes its slot like one -- so collapsing a container does not + // reshuffle the colours of the ones after it. Flowchart does the same for a collapsed + // subgraph. + if (node.isGroup || node.shape === 'collapsedGroup') { + const n = containerOrder.get(String(node.id)) ?? fallbackOrdinal++; + node.colorIndex = containerSlot(n, paletteLength); + continue; + } + const kind = kindOf(String(node.id)); + if (kind && KIND_SLOT.has(kind)) { + node.cssClasses = `${node.cssClasses ?? ''} ${kindClass(kind)}`.replace(/\s+/g, ' ').trim(); + } + } +} diff --git a/packages/mermaid/src/diagrams/agentflow/containerOrder.spec.ts b/packages/mermaid/src/diagrams/agentflow/containerOrder.spec.ts new file mode 100644 index 00000000000..4ed4986c1da --- /dev/null +++ b/packages/mermaid/src/diagrams/agentflow/containerOrder.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import * as configApi from '../../config.js'; +import { Diagram } from '../../Diagram.js'; +import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; +import { setLogLevel } from '../../logger.js'; +import { KIND_COUNT } from './colorSlots.js'; + +/** + * Container palette slots follow the order the author declared containers in. + * + * Asserted through a real parse rather than against a hand-built node array, which is what + * let the bug through: `colorSlots.spec.ts` numbers the array it is given, so it passed + * while `getData()` handed over a reversed one. `getData()` walks `subGraphs` in reverse, + * and `subGraphs` is itself in completion order — a nested container closes before its + * parent — so neither that array nor its reverse is source order. + */ +const slotsById = async (text: string) => { + const db = (await Diagram.fromText(text)).db as unknown as { + getData: () => { nodes: { id: string; colorIndex?: number }[] }; + }; + const data = db.getData(); + return new Map( + data.nodes.filter((n) => n.colorIndex !== undefined).map((n) => [String(n.id), n.colorIndex!]) + ); +}; + +describe('agentflow container order', () => { + beforeEach(() => { + configApi.setSiteConfig({}); + configApi.reset(); + // A palette has to be active or every slot is inert and the order is unobservable. + configApi.setSiteConfig({ theme: 'redux-color' } as never); + addDiagrams(); + setLogLevel('fatal'); + }); + + it('gives the first declared container the first container slot', async () => { + const slots = await slotsById(`agentflow-beta TB + flow first["First"] + a["a"] + end + flow second["Second"] + b["b"] + end + flow third["Third"] + c["c"] + end + `); + + expect(slots.get('first')).toBe(KIND_COUNT); + expect(slots.get('second')).toBe(KIND_COUNT + 1); + expect(slots.get('third')).toBe(KIND_COUNT + 2); + }); + + it('numbers a parent before the container nested inside it', async () => { + // Completion order would put `inner` first, because it closes first. Source order is + // what a reader sees, so it is what the colours follow. + const slots = await slotsById(`agentflow-beta TB + flow outer["Outer"] + flow inner["Inner"] + a["a"] + end + end + flow sibling["Sibling"] + b["b"] + end + `); + + expect(slots.get('outer')).toBe(KIND_COUNT); + expect(slots.get('inner')).toBe(KIND_COUNT + 1); + expect(slots.get('sibling')).toBe(KIND_COUNT + 2); + }); + + it('keeps a collapsed container in the sequence', async () => { + // A collapsed container is drawn as a node but still holds its slot, so collapsing one + // does not reshuffle the colours of the containers after it. + const slots = await slotsById(`agentflow-beta TB + flow one["One"] + a["a"] + end + flow two["Two"] + b["b"] + end + two@{ view: "collapsed" } + flow three["Three"] + c["c"] + end + `); + + expect(slots.get('one')).toBe(KIND_COUNT); + expect(slots.get('two')).toBe(KIND_COUNT + 1); + expect(slots.get('three')).toBe(KIND_COUNT + 2); + }); +}); diff --git a/packages/mermaid/src/diagrams/agentflow/styles.ts b/packages/mermaid/src/diagrams/agentflow/styles.ts index 385dce25aae..72257edc7ea 100644 --- a/packages/mermaid/src/diagrams/agentflow/styles.ts +++ b/packages/mermaid/src/diagrams/agentflow/styles.ts @@ -1,5 +1,7 @@ import * as khroma from 'khroma'; import { getIconStyles } from '../globalStyles.js'; +import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js'; +import { KINDS, KIND_SLOT, containerSlot, containerSlotCount, kindClass } from './colorSlots.js'; /** Returns the styles given options */ export interface AgentflowStyleOptions { @@ -17,8 +19,93 @@ export interface AgentflowStyleOptions { tertiaryColor: string; textColor: string; titleColor: string; + /* Supplied by `createUserStyles`, which spreads `config.themeVariables` and adds the + theme name and look. Only the colour themes carry the palette arrays. */ + theme?: string; + look?: string; + borderColorArray?: string[]; + bkgColorArray?: string[]; + THEME_COLOR_LIMIT?: number; } +/** + * Palette rules. Two families, matching the two rules in `colorSlots.ts`: one colour per + * node KIND from a fixed slot, and a counter over containers from the slots above them. + * + * `redux-dark-color` carries 12 border colours and no background array, so `hasBkgColors` + * is false there and these rules stroke without filling — the node keeps the theme's own + * background. That is the palette telling us what it has, not a special case. + * + * Not `!important`: a node carrying `classDef` or `style` gets an inline `style` + * attribute, which has to keep winning over the theme palette. + */ +const genColor = (options: AgentflowStyleOptions) => { + const { theme, bkgColorArray, borderColorArray } = options; + if (!isColorTheme(theme, borderColorArray)) { + return ''; + } + const look = safeLook(options.look); + const hasBkgColors = hasPalette(bkgColorArray); + const paletteLength = colorSlotCount(options.THEME_COLOR_LIMIT, borderColorArray); + const border = (slot: number) => borderColorArray![slot % borderColorArray!.length]; + const fill = (slot: number) => + hasBkgColors ? `fill: ${bkgColorArray[slot % bkgColorArray.length]};` : ''; + + let sections = ''; + + /* One rule per kind. Every agentflow shape is drawn as a `path` except the rounded + `task`, which is a `rect`, so both are named for each kind rather than guessing. */ + for (const kind of KINDS) { + const slot = KIND_SLOT.get(kind)!; + /* Compound, not descendant: `insertNode` puts `data-look` on the very element that + carries the kind class, so a space would ask for the class on a CHILD and match + nothing. + * + * `.node` is named as well, and it is not decoration. The shared neo stylesheet emits + * `[data-look="neo"].node rect, … .node polygon`, which is (0,2,1) — exactly the + * specificity of `[data-look][class]` — and it is appended after the diagram's own + * styles, so an equal-specificity rule loses on order and the palette never appears. + * Adding `.node` makes this (0,3,1) and settles it by weight rather than by luck. */ + const sel = `[data-look="${look}"].node.${kindClass(kind)}`; + sections += ` + + ${sel} rect, + ${sel} path, + ${sel} polygon { + stroke: ${border(slot)}; + ${fill(slot)} + } +`; + } + + /* Containers cycle the slots above the kind range, so a frame never matches a node + inside it. Keyed on `data-color-id`, the same carrier every other diagram's containers + use — `createContainerGroup` stamps the expanded frame, `collapsedGroup` the collapsed + one. */ + for (let i = 0; i < containerSlotCount(paletteLength); i++) { + // The same arithmetic the assignment uses, so the selector always names the slot the + // stamp actually writes -- see `containerSlot`. + const slot = containerSlot(i, paletteLength); + /* Both forms of a container. An expanded one is a `.cluster`; a collapsed one is + drawn as a single `.node` and still holds its slot, so naming only `.cluster` would + leave collapsed containers grey beside their expanded siblings. Each suffix is + appended to both prefixes separately: a comma-joined prefix list would attach the + suffix to the last item only. */ + const expanded = `[data-look="${look}"][data-color-id="color-${slot}"].cluster`; + const collapsed = `[data-look="${look}"][data-color-id="color-${slot}"].node`; + const rule = (suffix: string) => `${expanded} ${suffix}, ${collapsed} ${suffix}`; + sections += ` + + ${rule('rect')}, + ${rule('path')} { + stroke: ${border(slot)}; + ${fill(slot)} + } +`; + } + return sections; +}; + const fade = (color: string, opacity: number) => { // @ts-ignore TODO: incorrect types from khroma const channel = khroma.channel; @@ -32,7 +119,8 @@ const fade = (color: string, opacity: number) => { }; const getStyles = (options: AgentflowStyleOptions) => - `.label { + `${genColor(options)} + .label { font-family: ${options.fontFamily}; color: ${options.nodeTextColor || options.textColor}; } diff --git a/packages/mermaid/src/diagrams/block/blockColorIndex.spec.ts b/packages/mermaid/src/diagrams/block/blockColorIndex.spec.ts new file mode 100644 index 00000000000..c9f806a6581 --- /dev/null +++ b/packages/mermaid/src/diagrams/block/blockColorIndex.spec.ts @@ -0,0 +1,160 @@ +// @ts-ignore: jison doesn't export types +import block from './parser/block.jison'; +import db from './blockDB.js'; +import * as configApi from '../../config.js'; +import getStyles from './styles.js'; + +/** + * Palette slots for composites, the same mechanism the flowchart uses for its subgraphs: + * the db hands each container a `colorIndex`, the renderer stamps it as `data-color-id`, + * and this stylesheet maps the slot to a border and a fill. + * + * Containers only, deliberately. `flowDb` builds its `declarationIndex` by walking + * `subGraphs` and nothing else, and every selector its stylesheet emits is a `.cluster` + * or a collapsed subgraph — a plain node never takes a slot. A block diagram's containers + * are its composites, so those are what take one here, and the simple shapes keep the + * flat theme colour. + * + * Both halves are pinned because either one alone is silent: a slot with no rule renders + * uncoloured, and a rule with no slot is dead CSS. Neither throws. + */ +describe('block colour slots', () => { + beforeEach(() => { + configApi.setSiteConfig({}); + configApi.reset(); + block.parser.yy = db; + block.parser.yy.clear(); + block.parser.yy.getLogger = () => console; + }); + + const indexOf = (id: string) => db.getBlock(id)?.colorIndex; + + it('numbers composites in declaration order', () => { + block.parse(`block-beta + block:first + a + end + block:second + b + end + block:third + c + end + `); + + expect([indexOf('first'), indexOf('second'), indexOf('third')]).toEqual([0, 1, 2]); + }); + + it('gives no slot to a simple block', () => { + // The flowchart colours containers and leaves its nodes alone; so does this. + block.parse(`block-beta + a + b + block:group + c + end + `); + + expect(indexOf('a')).toBeUndefined(); + expect(indexOf('b')).toBeUndefined(); + expect(indexOf('c')).toBeUndefined(); + expect(indexOf('group')).toBe(0); + }); + + it('numbers an outer composite before one nested inside it', () => { + // Source order, not completion order: a container is declared before its children, + // so it must take the lower slot even though it closes last. + block.parse(`block-beta + block:outer + block:inner + a + end + end + block:sibling + b + end + `); + + expect(indexOf('outer')).toBe(0); + expect(indexOf('inner')).toBe(1); + expect(indexOf('sibling')).toBe(2); + }); + + it('runs one counter across the whole diagram', () => { + // Not per container. Restarting the count inside each one would open every + // container's first child on the same colour as its cousins. + block.parse(`block-beta + block:a1 + block:a2 + x + end + end + block:b1 + block:b2 + y + end + end + `); + + expect([indexOf('a1'), indexOf('a2'), indexOf('b1'), indexOf('b2')]).toEqual([0, 1, 2, 3]); + }); + + // The base variables every block stylesheet reads, so these tests fail on the + // palette rather than on a missing colour somewhere unrelated. + const paletteOptions = { + arrowheadColor: '#333333', + border2: '#333333', + clusterBkg: '#f4f4f4', + clusterBorder: '#cccccc', + edgeLabelBackground: '#ffffff', + fontFamily: 'trebuchet ms', + lineColor: '#333333', + mainBkg: '#eeeeee', + nodeBorder: '#999999', + nodeTextColor: '#333333', + tertiaryColor: '#ffffde', + textColor: '#333333', + titleColor: '#333333', + theme: 'redux-color', + look: 'neo', + borderColorArray: ['#111111', '#222222'], + bkgColorArray: ['#eeeeee', '#dddddd'], + } as any; + + it('emits one composite rule per palette entry under a colour theme', () => { + const styles = getStyles(paletteOptions); + + expect(styles).toContain('[data-look="neo"][data-color-id="color-0"].node rect.composite'); + expect(styles).toContain('[data-look="neo"][data-color-id="color-1"].node rect.composite'); + expect(styles).toContain('#111111'); + expect(styles).toContain('#eeeeee'); + // Exactly as many slots as the palette has entries: a slot with no rule renders + // uncoloured, and a rule with no slot is dead CSS. + expect(styles).not.toContain('color-2'); + }); + + it('leaves the simple shapes to the flat theme colour', () => { + const styles = getStyles(paletteOptions); + const paletteRules = styles.slice(0, styles.indexOf('.label {')); + + // Every palette selector names `rect.composite`; nothing reaches a plain block. + for (const selector of paletteRules.match(/\[data-color-id="color-\d+"][^{]*/g) ?? []) { + expect(selector).toContain('rect.composite'); + } + }); + + it('emits nothing for a theme that carries no palette', () => { + const styles = getStyles({ ...paletteOptions, theme: 'default' }); + + expect(styles).not.toContain('data-color-id'); + }); + + it('rejects a look that would break out of the selector', () => { + // `look` is a top-level config key, so it is reachable from diagram frontmatter, + // and `config.sanitize` leaves braces and quotes intact. + const styles = getStyles({ ...paletteOptions, look: 'neo"] { fill: red } [x="' }); + + expect(styles).not.toContain('fill: red'); + expect(styles).toContain('[data-look="classic"]'); + }); +}); diff --git a/packages/mermaid/src/diagrams/block/blockDB.ts b/packages/mermaid/src/diagrams/block/blockDB.ts index 3ab269177e5..7daf07e5145 100644 --- a/packages/mermaid/src/diagrams/block/blockDB.ts +++ b/packages/mermaid/src/diagrams/block/blockDB.ts @@ -88,6 +88,20 @@ export const setCssClass = function (itemIds: string, cssClassName: string) { }); }; +/** + * Next palette slot to hand out. + * + * Composites only, and one counter across the whole parse -- exactly what the flowchart + * does for its subgraphs. There, `declarationIndex` is built by walking `subGraphs` and + * nothing else, and every palette selector it emits is a `.cluster` or a collapsed + * subgraph; a plain node never takes a slot. A block diagram's containers are its + * composites, so those are what take one here. + * + * A nested composite continues the cycle rather than restarting it, which is what keeps + * two sibling containers from opening on the same colour. + */ +let nextColorIndex = 0; + const populateBlockDatabase = (_blockList: Block[], parent: Block): void => { const blockList = _blockList.flat(); const children = []; @@ -141,6 +155,11 @@ const populateBlockDatabase = (_blockList: Block[], parent: Block): void => { const existingBlock = blockDatabase.get(block.id); if (existingBlock === undefined) { + // Assigned here, before the recursion into `block.children` below, so an outer + // composite takes a lower slot than any composite nested inside it. + if (block.type === 'composite') { + block.colorIndex = nextColorIndex++; + } blockDatabase.set(block.id, block); } else { // Add newer relevant data to aggregated node @@ -186,6 +205,7 @@ const clear = (): void => { edgeList = []; edgeCount = new Map(); diagramId = ''; + nextColorIndex = 0; }; export function typeStr2Type(typeStr: string) { diff --git a/packages/mermaid/src/diagrams/block/blockTypes.ts b/packages/mermaid/src/diagrams/block/blockTypes.ts index 8417d4855e0..cf56ba0e4bf 100644 --- a/packages/mermaid/src/diagrams/block/blockTypes.ts +++ b/packages/mermaid/src/diagrams/block/blockTypes.ts @@ -59,6 +59,12 @@ export interface Block { styles?: string[]; stylesStr?: string; widthInColumns?: number; + /** + * Palette slot, assigned in declaration order by `populateBlockDatabase`. Read by the + * renderer, stamped as `data-color-id`, and matched by the rules `styles.ts` emits. + * Undefined on a theme without a palette and on blocks that paint nothing. + */ + colorIndex?: number; } export interface ClassDef { diff --git a/packages/mermaid/src/diagrams/block/renderHelpers.ts b/packages/mermaid/src/diagrams/block/renderHelpers.ts index e8d77284541..e6067b5c818 100644 --- a/packages/mermaid/src/diagrams/block/renderHelpers.ts +++ b/packages/mermaid/src/diagrams/block/renderHelpers.ts @@ -6,6 +6,8 @@ import { positionEdgeLabel, } from '../../rendering-util/rendering-elements/edges.js'; import { insertNode, positionNode } from '../../rendering-util/rendering-elements/nodes.js'; +import { stampColorSlot } from '../common/colorThemeGate.js'; +import type { D3Selection } from '../../types.js'; import type { ShapeID } from '../../rendering-util/rendering-elements/shapes.js'; import { getStylesFromArray } from '../../utils.js'; import type { BlockDB } from './blockDB.js'; @@ -127,6 +129,8 @@ function getNodeFromBlock(block: Block, db: BlockDB, positioned = false) { intersect: undefined, padding: padding ?? getConfig()?.block?.padding ?? 0, widthInColumns: vertex.widthInColumns ?? 1, + colorIndex: vertex.colorIndex, + look: getConfig().look, }; return node; } @@ -157,7 +161,26 @@ export async function insertBlockPositioned(elem: any, block: Block, db: any) { const obj = db.getBlock(node.id); if (obj.type !== 'space') { const config = getConfig(); - await insertNode(elem, node, { config }); + const el = await insertNode(elem, node, { config }); + /* Only where a slot was actually assigned -- composites. `stampColorSlot` falls back + to `colorIndex ?? 0`, so calling it unconditionally would stamp every plain block + with slot 0 and paint the whole diagram one colour. `squareRect.ts` guards the same + way for the same reason. + + Stamped here rather than inside the shapes because `composite.ts` does not stamp + for itself, and doing it in the block renderer keeps the change inside the block + diagram: no other diagram routes through this call. */ + if (node.colorIndex !== undefined) { + stampColorSlot( + // `insertNode` returns an anchor instead of a group when the node carries a + // link, and both are `SVGGraphicsElement`s -- but the union of the two selections + // is not assignable to one instantiation of the generic, so it is narrowed here. + el as D3Selection, + node.colorIndex, + config.theme, + config.themeVariables?.borderColorArray + ); + } block.intersect = node?.intersect; positionNode(node); } diff --git a/packages/mermaid/src/diagrams/block/styles.ts b/packages/mermaid/src/diagrams/block/styles.ts index 1b0d09246cf..6107f7a38ae 100644 --- a/packages/mermaid/src/diagrams/block/styles.ts +++ b/packages/mermaid/src/diagrams/block/styles.ts @@ -1,5 +1,6 @@ import * as khroma from 'khroma'; import { getIconStyles } from '../globalStyles.js'; +import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js'; /** Returns the styles given options */ export interface BlockChartStyleOptions { @@ -16,8 +17,51 @@ export interface BlockChartStyleOptions { tertiaryColor: string; textColor: string; titleColor: string; + /* Supplied by `createUserStyles`, which spreads `config.themeVariables` and adds the + theme name and look. Only the colour themes carry the palette arrays. */ + theme?: string; + look?: string; + borderColorArray?: string[]; + bkgColorArray?: string[]; + THEME_COLOR_LIMIT?: number; } +/** + * Per-composite palette rules, matching what the flowchart does for its subgraphs: one + * counter over containers, and nothing on the plain shapes. + * + * A composite always draws a `rect.composite` inside a `.node` group -- it has no roughjs + * variant, so unlike the flowchart there is no `.rough-node` prefix to mirror and no + * hachure path to avoid painting. That one selector is the whole surface. + * + * Not `!important`: `composite.ts` puts a block's own `style` declarations in an inline + * `style` attribute, which has to keep winning over the theme palette. + */ +const genColor = (options: BlockChartStyleOptions) => { + const { theme, bkgColorArray, borderColorArray } = options; + if (!isColorTheme(theme, borderColorArray)) { + return ''; + } + const look = safeLook(options.look); + const hasBkgColors = hasPalette(bkgColorArray); + let sections = ''; + + for (let i = 0; i < colorSlotCount(options.THEME_COLOR_LIMIT, borderColorArray); i++) { + const borderColor = borderColorArray![i % borderColorArray!.length]; + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; + + sections += ` + + ${slot}.node rect.composite { + stroke: ${borderColor}; + ${fill} + } +`; + } + return sections; +}; + const fade = (color: string, opacity: number) => { // @ts-ignore TODO: incorrect types from khroma const channel = khroma.channel; @@ -31,7 +75,8 @@ const fade = (color: string, opacity: number) => { }; const getStyles = (options: BlockChartStyleOptions) => - `.label { + `${genColor(options)} + .label { font-family: ${options.fontFamily}; color: ${options.nodeTextColor || options.textColor}; } diff --git a/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts b/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts index 7c2adb06f56..59a9de7cfd3 100644 --- a/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts +++ b/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts @@ -7,10 +7,17 @@ * every theme outside the colour pair, must emit one rule per palette slot inside it, and * must never outrank a user's own styling. */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as configApi from '../../config.js'; +import { configKeys } from '../../defaultConfig.js'; import themes from '../../themes/index.js'; import classStyles from '../class/styles.js'; +import erStyles from '../er/styles.js'; import flowchartStyles from '../flowchart/styles.js'; +import requirementStyles from '../requirement/styles.js'; +import stateStyles from '../state/styles.js'; +import swimlanesStyles from '../swimlanes/styles.js'; +import timelineStyles from '../timeline/styles.js'; import { COLOR_THEMES, DEFAULT_COLOR_SLOTS, @@ -18,13 +25,29 @@ import { colorSlotCount, paletteSlotCount, safeLook, + stampColorSlot, } from './colorThemeGate.js'; +/** `swimlanes` appends its own lane rules to flowchart's, so it is listed separately. */ const STYLESHEETS = { class: classStyles, + er: erStyles, flowchart: flowchartStyles, + requirement: requirementStyles, + state: stateStyles, + swimlanes: swimlanesStyles, + timeline: timelineStyles, } as const; +/** + * Which stylesheets emit `[data-color-id]` slot rules. `timeline` is palette-aware but + * colours `.section-N` classes directly rather than stamping slots, so the slot-shaped + * assertions do not apply to it — only the crash-safety pass at the bottom does. + */ +const SLOT_STYLESHEETS = ( + ['class', 'er', 'flowchart', 'requirement', 'state', 'swimlanes'] as const +).filter((name) => name in STYLESHEETS); + const COLOUR_THEMES = [...COLOR_THEMES]; /** @@ -34,6 +57,12 @@ const COLOUR_THEMES = [...COLOR_THEMES]; */ const PLAIN_THEMES = Object.keys(themes).filter((name) => !COLOR_THEMES.has(name)); +/** + * Drives both channels. Most stylesheets read `theme`, `look` and the palette off the + * options they are handed; `requirement/styles.js` reads all three from `getConfig()` + * instead. Setting site config as well as passing options means one helper covers both, + * and the assertions do not have to know which stylesheet reads from where. + */ const render = ( name: keyof typeof STYLESHEETS, themeName: string, @@ -41,12 +70,24 @@ const render = ( overrides: Record = {} ) => { const themeVariables = themes[themeName as keyof typeof themes].getThemeVariables({}); - return STYLESHEETS[name]({ + const options = { ...(themeVariables as unknown as Record), theme: themeName, look, ...overrides, - } as never); + }; + configApi.reset(); + // The resolved options go into site config too, not just the theme name. Without that, + // `requirement/styles.js` -- which reads the palette from `getConfig()` -- never sees an + // `overrides` palette, so every slot-count assertion against it would quietly run + // against the shipped twelve-entry palette instead of the one under test and pass for + // the wrong reason. + configApi.setSiteConfig({ + theme: themeName as 'redux-color', + look: look as 'classic', + themeVariables: options, + }); + return STYLESHEETS[name](options as never); }; /** The distinct `color-N` slots a stylesheet emits rules for. */ @@ -57,9 +98,10 @@ it('covers every registered theme between the two lists', () => { expect([...PLAIN_THEMES, ...COLOUR_THEMES].sort()).toEqual(Object.keys(themes).sort()); }); -describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s stylesheet', (name) => { +describe.each(SLOT_STYLESHEETS)('%s stylesheet', (name) => { it.each(PLAIN_THEMES)('emits no per-item colour rules for %s', (themeName) => { - expect(render(name, themeName)).not.toContain('data-color-id'); + // The slot marker, not the bare attribute: `swimlanes` keys a rule off its absence. + expect(render(name, themeName)).not.toContain('data-color-id="color-'); }); it.each(COLOUR_THEMES)('emits one rule per palette slot for %s', (themeName) => { @@ -117,6 +159,80 @@ describe('safeLook', () => { }); }); +/** + * Unlike `look`, palette entries (`borderColorArray`/`bkgColorArray`) are interpolated + * straight into generated CSS by `er/styles.ts`, `requirement/styles.js`, `block/styles.ts` + * and their equivalents, with nothing analogous to `safeLook` validating an entry first. A + * hostile array entry — the same kind of `"]{a` payload `safeLook` guards against — would + * inject if it ever reached that code. + * + * It cannot, but not because the palette code defends itself, and not quite for the reason + * it looks like at a glance. `keyify` in `defaultConfig.ts` does skip any array-valued + * property while building `configKeys` -- but that branch is never reached for these two: + * `configKeys` is built by walking the *default* theme's variables + * (`theme.default.getThemeVariables()`), and the default theme does not define + * `borderColorArray`/`bkgColorArray` at all. Only `redux-color`/`redux-dark-color` do, and + * their variables never populate the object `keyify` walks. So today the property is simply + * absent, not present-and-filtered. + * + * `sanitizeDirective` strips any key that is not in `configKeys` -- the same mechanism that + * keeps a user from setting arbitrary config through front matter at all -- so either way, + * a directive-supplied `borderColorArray` cannot reach the palette code. The array-skip + * would become the operative defence, rather than a currently-unexercised one, if a future + * change ever gave the default theme its own `borderColorArray`/`bkgColorArray`. Pinning the + * outcome here, rather than the mechanism, is what keeps this test meaningful regardless of + * which of the two is actually doing the work. + */ +describe('palette arrays are excluded from configKeys', () => { + it.each(['borderColorArray', 'bkgColorArray'])('%s is not a recognised config key', (key) => { + expect(configKeys.has(key)).toBe(false); + }); +}); + +/** + * A palette-aware stylesheet must survive being handed theme variables that did not come + * from the configured theme. + * + * `timeline` is the one that reads the configured theme *name* from `getConfig()` while + * receiving its variables as a parameter, so it is the one where the two can disagree. It + * used to gate on the name alone and then index the palette regardless, which threw + * `Cannot read properties of undefined` for all eight themes that carry no palette. + * + * In production `mermaidAPI` passes the name and the variables from the same config + * object, so they always agree and no user hit this. It is still worth pinning: the + * failure mode is a crash rather than a cosmetic drift, and nothing else stops a caller + * from passing them separately. + */ +describe('mismatched theme name and theme variables', () => { + afterEach(() => { + configApi.reset(); + }); + + const paletteless = Object.keys(themes).filter((name) => !COLOR_THEMES.has(name)); + + describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])('%s', (name) => { + it.each(paletteless)( + 'does not throw for %s variables under a colour theme name', + (variablesFrom) => { + for (const configuredTheme of COLOUR_THEMES) { + // The configured theme claims a palette; the variables handed in have none. + configApi.setSiteConfig({ theme: configuredTheme as 'redux-color' }); + const themeVariables = themes[variablesFrom as keyof typeof themes].getThemeVariables( + {} + ) as unknown as Record; + expect(() => + STYLESHEETS[name]({ + ...themeVariables, + theme: configuredTheme, + look: 'classic', + } as never) + ).not.toThrow(); + } + } + ); + }); +}); + /** * `stampColorSlot` wraps at `palette.length`; the stylesheets emit one rule per slot up to * `colorSlotCount`. Those two counts have to agree, or an item gets stamped `color-N` with @@ -155,37 +271,34 @@ describe('colorSlotCount', () => { }); }); -describe.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])( - '%s stylesheet slot coverage', - (name) => { - it('emits a rule for every slot a palette longer than THEME_COLOR_LIMIT can stamp', () => { - const palette = Array.from({ length: 20 }, (_, i) => `#${(i + 16).toString(16)}0000`); - const slots = emittedSlots( - render(name, 'redux-color', 'classic', { - THEME_COLOR_LIMIT: 3, - borderColorArray: palette, - bkgColorArray: palette, - }) - ); - // Named rather than counted, so a failure says which slots lost their rule. - const missing = [...palette.keys()].filter((i) => !slots.has(i)); - expect(missing).toEqual([]); - }); +describe.each(SLOT_STYLESHEETS)('%s stylesheet slot coverage', (name) => { + it('emits a rule for every slot a palette longer than THEME_COLOR_LIMIT can stamp', () => { + const palette = Array.from({ length: 20 }, (_, i) => `#${(i + 16).toString(16)}0000`); + const slots = emittedSlots( + render(name, 'redux-color', 'classic', { + THEME_COLOR_LIMIT: 3, + borderColorArray: palette, + bkgColorArray: palette, + }) + ); + // Named rather than counted, so a failure says which slots lost their rule. + const missing = [...palette.keys()].filter((i) => !slots.has(i)); + expect(missing).toEqual([]); + }); - it('emits exactly the slots a shorter palette can stamp, and no dead rules', () => { - const slots = emittedSlots( - render(name, 'redux-color', 'classic', { - THEME_COLOR_LIMIT: 12, - borderColorArray: ['#ff0000', '#00ff00'], - bkgColorArray: ['#ffeeee', '#eeffee'], - }) - ); - // Changed from expecting the full limit: slots 2..11 could never be stamped, so - // they were rules nothing could match. - expect([...slots].sort((a, b) => a - b)).toEqual([0, 1]); - }); - } -); + it('emits exactly the slots a shorter palette can stamp, and no dead rules', () => { + const slots = emittedSlots( + render(name, 'redux-color', 'classic', { + THEME_COLOR_LIMIT: 12, + borderColorArray: ['#ff0000', '#00ff00'], + bkgColorArray: ['#ffeeee', '#eeffee'], + }) + ); + // Changed from expecting the full limit: slots 2..11 could never be stamped, so + // they were rules nothing could match. + expect([...slots].sort((a, b) => a - b)).toEqual([0, 1]); + }); +}); /** * Whatever the limit, the result is used directly as a `for` bound, so it has to be a value @@ -238,6 +351,31 @@ describe('colorSlotCount stays a usable loop bound', () => { * palette and any limit. Both now derive from the palette, so this holds by construction -- * and if anyone reintroduces a separate bound, it fails here rather than in review. */ +describe('stampColorSlot leaves an unnumbered element alone', () => { + const stamped = (colorIndex: number | undefined) => { + let attr: string | undefined; + const selection = { + attr: (_name: string, value: string) => { + attr = value; + return selection; + }, + } as unknown as Parameters[0]; + stampColorSlot(selection, colorIndex, 'redux-color', ['#a', '#b']); + return attr; + }; + + it('stamps nothing when there is no colorIndex', () => { + // Not `color-0`: an absent index means the element is outside the cycle, and painting + // it in the first palette colour is the opposite of that. State composites carrying + // the author's own `classDef` are the live case. + expect(stamped(undefined)).toBeUndefined(); + }); + + it('still stamps slot zero when the index really is zero', () => { + expect(stamped(0)).toBe('color-0'); + }); +}); + describe('emitted slots and stampable slots agree', () => { const paletteOf = (n: number) => Array.from({ length: n }, (_, i) => `#${i}`); @@ -264,23 +402,20 @@ describe('emitted slots and stampable slots agree', () => { expect([...emitted].sort((a, b) => a - b)).toEqual([...stampable].sort((a, b) => a - b)); }); - it.each(Object.keys(STYLESHEETS) as (keyof typeof STYLESHEETS)[])( - 'holds end to end for the %s stylesheet', - (name) => { - for (const paletteLength of [2, 12, MAX_COLOR_SLOTS + 50]) { - const palette = paletteOf(paletteLength); - const emitted = emittedSlots( - render(name, 'redux-color', 'classic', { - THEME_COLOR_LIMIT: 12, - borderColorArray: palette, - bkgColorArray: palette, - }) - ); - const stampable = new Set( - Array.from({ length: paletteLength + 200 }, (_, i) => i % paletteSlotCount(palette)) - ); - expect([...emitted].sort((a, b) => a - b)).toEqual([...stampable].sort((a, b) => a - b)); - } + it.each(SLOT_STYLESHEETS)('holds end to end for the %s stylesheet', (name) => { + for (const paletteLength of [2, 12, MAX_COLOR_SLOTS + 50]) { + const palette = paletteOf(paletteLength); + const emitted = emittedSlots( + render(name, 'redux-color', 'classic', { + THEME_COLOR_LIMIT: 12, + borderColorArray: palette, + bkgColorArray: palette, + }) + ); + const stampable = new Set( + Array.from({ length: paletteLength + 200 }, (_, i) => i % paletteSlotCount(palette)) + ); + expect([...emitted].sort((a, b) => a - b)).toEqual([...stampable].sort((a, b) => a - b)); } - ); + }); }); diff --git a/packages/mermaid/src/diagrams/common/colorThemeGate.ts b/packages/mermaid/src/diagrams/common/colorThemeGate.ts index 7bba4757ab9..66d356e532b 100644 --- a/packages/mermaid/src/diagrams/common/colorThemeGate.ts +++ b/packages/mermaid/src/diagrams/common/colorThemeGate.ts @@ -50,8 +50,15 @@ export const isColorTheme = (theme: string | undefined, palette: unknown): boole */ const SAFE_LOOK = /^[\w-]+$/; -export const safeLook = (look: string | undefined): string => - look != null && SAFE_LOOK.test(look) ? look : 'classic'; +export const safeLook = (look: unknown): string => { + // Only stringify types that actually describe themselves -- `String()` on a plain object + // or array would pass through as the meaningless `[object Object]`, which happens to + // still fail SAFE_LOOK today but would be a silent bug waiting for a future look-alike + // regex, and is exactly the kind of default-stringification mistake this function exists + // to guard against elsewhere. + const s = typeof look === 'string' || typeof look === 'number' ? String(look) : ''; + return SAFE_LOOK.test(s) ? s : 'classic'; +}; /** * Number of palette slots a stylesheet should emit. @@ -95,6 +102,14 @@ export const colorSlotCount = (themeColorLimit: unknown, palette?: unknown): num * * The slot wraps at the palette length rather than indexing raw, so a palette shorter * than the emitted slot count cannot produce `stroke: undefined`. + * + * An absent `colorIndex` means "this element is not part of the cycle", so nothing is + * stamped. It used to fall back to `colorIndex ?? 0`, which says the opposite -- an + * unnumbered element was stamped `color-0` and painted in the first palette colour. That + * was invisible while the only unnumbered containers were ones whose diagram emits no + * matching rules (class namespaces, block containers), and it stops being invisible the + * moment such a diagram gains palette rules: state's composites opt out of the cycle when + * the author has styled them, and would otherwise all come back as `color-0`. */ export const stampColorSlot = ( shapeSvg: D3Selection, @@ -102,9 +117,9 @@ export const stampColorSlot = ( theme: string | undefined, palette: unknown ): void => { - if (!isColorTheme(theme, palette)) { + if (colorIndex === undefined || !isColorTheme(theme, palette)) { return; } - const slot = (colorIndex ?? 0) % paletteSlotCount(palette); + const slot = colorIndex % paletteSlotCount(palette); shapeSvg.attr('data-color-id', `color-${slot}`); }; diff --git a/packages/mermaid/src/diagrams/er/styles.ts b/packages/mermaid/src/diagrams/er/styles.ts index 546cd20dc9c..1cd056afea1 100644 --- a/packages/mermaid/src/diagrams/er/styles.ts +++ b/packages/mermaid/src/diagrams/er/styles.ts @@ -1,5 +1,12 @@ import * as khroma from 'khroma'; import type { DiagramStylesProvider } from '../../diagram-api/types.js'; +import { + COLOR_THEMES, + hasPalette, + isColorTheme, + paletteSlotCount, + safeLook, +} from '../common/colorThemeGate.js'; const fade = (color: string, opacity: number) => { // @ts-ignore TODO: incorrect types from khroma @@ -12,26 +19,31 @@ const fade = (color: string, opacity: number) => { // @ts-ignore incorrect types from khroma return khroma.rgba(r, g, b, opacity); }; -const COLOR_THEMES = new Set(['redux-color', 'redux-dark-color']); - const genColor: DiagramStylesProvider = (options) => { - const { theme, look, bkgColorArray, borderColorArray } = options; - // Bail on an empty border palette as well as a non-colour theme. Without the second - // check `i % borderColorArray.length` is `i % 0` -> NaN, and `[][NaN]` is `undefined`, - // so every slot would emit `stroke: undefined` -- the exact symptom this guard exists to - // prevent. `requirement/styles.js` already gated on the palette; this brings ER into - // line. Reachable through a `themeVariables` override, not just in theory. - if (!COLOR_THEMES.has(theme) || !borderColorArray?.length) { + const { theme, bkgColorArray, borderColorArray } = options; + // `isColorTheme` covers both halves of this gate: the theme has to be a colour theme + // *and* the border palette has to be a non-empty array. The palette half matters on its + // own -- it is reachable through a `themeVariables` override -- because an empty palette + // would otherwise leave every slot emitting `stroke: undefined`. + if (!isColorTheme(theme, borderColorArray)) { return ''; } - const hasBkgColors = bkgColorArray?.length > 0; + // `look` is validated before it reaches the selector -- see `safeLook`. + const look = safeLook(options.look); + const hasBkgColors = hasPalette(bkgColorArray); let sections = ''; - for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { - // Wrap at the palette length rather than indexing raw. The loop runs to - // THEME_COLOR_LIMIT, so a palette with fewer entries than that emits - // `stroke: undefined` for the overflow slots. - const borderColor = borderColorArray[i % borderColorArray.length]; + // One rule per slot `erBox` can actually stamp. It stamps + // `colorIndex % borderColorArray.length`, so the ids it can produce are exactly + // `0 .. borderColorArray.length - 1` -- deriving the bound from the same length is what + // keeps the two from disagreeing. Looping to `THEME_COLOR_LIMIT` instead left a palette + // longer than the limit with stamped entities that had no rule to match, and a shorter + // one with dead rules. + for (let i = 0; i < paletteSlotCount(borderColorArray); i++) { + // `borderColorArray[i]` needs no wrap now the bound is its own length. The background + // palette is a separate array that may be shorter, so that one still wraps -- guarded + // by `hasBkgColors`, since `i % 0` is NaN and `[][NaN]` is `undefined`. + const borderColor = borderColorArray[i]; const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; sections += ` diff --git a/packages/mermaid/src/diagrams/flowchart/flowDiagram.spec.ts b/packages/mermaid/src/diagrams/flowchart/flowDiagram.spec.ts index 9bfe6ef1179..952ac98493a 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDiagram.spec.ts @@ -1,15 +1,15 @@ +/** + * `init` used to decide the layout; it no longer does, so that the schema resolution chain + * is the only authority. The chain itself is covered by `config.appearance.spec.ts` and + * `swimlanes/swimlanesDiagram.spec.ts`. + */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getUserDefinedConfig } from '../../config.js'; import { setConfig } from '../../diagram-api/diagramAPI.js'; import { createFlowDiagram } from './flowDiagram.js'; -// Spy getUserDefinedConfig + setConfig while keeping every other export real, so -// the renderer/parser imports that flowDiagram.ts pulls in still resolve. +// Spy setConfig while keeping every other export real, so the renderer/parser +// imports that flowDiagram.ts pulls in still resolve. // (vitest hoists vi.mock above the imports above.) -vi.mock('../../config.js', async (importOriginal) => { - const actual = await importOriginal>(); - return { ...actual, getUserDefinedConfig: vi.fn() }; -}); vi.mock('../../diagram-api/diagramAPI.js', async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, setConfig: vi.fn() }; @@ -21,30 +21,27 @@ function layoutSetByInit(): unknown { return call?.[0]?.layout; } -describe('createFlowDiagram init — layout precedence', () => { +describe('createFlowDiagram init', () => { beforeEach(() => { vi.mocked(setConfig).mockClear(); - vi.mocked(getUserDefinedConfig).mockReturnValue({} as never); }); - it('a user-defined (%%{init}%%) layout wins over defaultLayout and site config', () => { - vi.mocked(getUserDefinedConfig).mockReturnValue({ layout: 'elk' } as never); - createFlowDiagram({ defaultLayout: 'swimlane' }).init?.({ layout: 'dagre' } as never); - expect(layoutSetByInit()).toBe('elk'); - }); - - it('defaultLayout (e.g. swimlane) wins over the site-config layout when no user override is set', () => { - createFlowDiagram({ defaultLayout: 'swimlane' }).init?.({ layout: 'dagre' } as never); - expect(layoutSetByInit()).toBe('swimlane'); - }); - - it('falls back to the site-config layout when there is no user override and no defaultLayout', () => { - createFlowDiagram().init?.({ layout: 'elk' } as never); - expect(layoutSetByInit()).toBe('elk'); + it('does not force a layout, whatever the config already says', () => { + createFlowDiagram().init?.({ layout: 'dagre' } as never); + expect(layoutSetByInit()).toBeUndefined(); }); it('does not force a layout when none is set anywhere', () => { createFlowDiagram().init?.({} as never); expect(layoutSetByInit()).toBeUndefined(); }); + + it('still propagates arrowMarkerAbsolute into the flowchart config', () => { + const cnf = { arrowMarkerAbsolute: true } as never as { flowchart?: Record }; + createFlowDiagram().init?.(cnf as never); + expect(cnf.flowchart?.arrowMarkerAbsolute).toBe(true); + expect(vi.mocked(setConfig)).toHaveBeenCalledWith({ + flowchart: { arrowMarkerAbsolute: true }, + }); + }); }); diff --git a/packages/mermaid/src/diagrams/flowchart/flowDiagram.ts b/packages/mermaid/src/diagrams/flowchart/flowDiagram.ts index ca6361e8801..4c19d219a1a 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDiagram.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDiagram.ts @@ -1,5 +1,4 @@ import type { MermaidConfig } from '../../config.type.js'; -import { getUserDefinedConfig } from '../../config.js'; import { setConfig } from '../../diagram-api/diagramAPI.js'; import type { DiagramDefinition } from '../../diagram-api/types.js'; import { FlowDB } from './flowDb.js'; @@ -10,12 +9,10 @@ import flowParser from './parser/flowParser.ts'; import flowStyles from './styles.js'; interface FlowDiagramOptions { - defaultLayout?: string; styles?: typeof flowStyles; } export const createFlowDiagram = ({ - defaultLayout, styles = flowStyles, }: FlowDiagramOptions = {}): DiagramDefinition => ({ parser: flowParser, @@ -28,10 +25,8 @@ export const createFlowDiagram = ({ if (!cnf.flowchart) { cnf.flowchart = {}; } - const layout = getUserDefinedConfig().layout ?? defaultLayout ?? cnf.layout; - if (layout) { - setConfig({ layout }); - } + // The layout is not forced here: swimlanes declare `layout: swimlane` in the schema, + // which puts it below a user's `layout` or `swimlane.layout` instead of above both. cnf.flowchart.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute; setConfig({ flowchart: { arrowMarkerAbsolute: cnf.arrowMarkerAbsolute } }); }, diff --git a/packages/mermaid/src/diagrams/flowchart/styles.ts b/packages/mermaid/src/diagrams/flowchart/styles.ts index 69b7b43cf7b..e0286b7bbf8 100644 --- a/packages/mermaid/src/diagrams/flowchart/styles.ts +++ b/packages/mermaid/src/diagrams/flowchart/styles.ts @@ -41,6 +41,10 @@ export interface FlowChartStyleOptions { * inline `style` attribute, which has to keep winning over the theme palette. The * collapsed form's own colours are presentation attributes (`fill=` / `stroke=`), which * these rules correctly outrank while still losing to that inline style. + * + * Lanes are excluded by `:not(.swimlane)` and ruled separately below: a lane is two + * rectangles, and the generic `path` rule would paint the hachure roughjs emits for its + * unfilled body. Here, not in `swimlanes/styles.ts`, so `layout: swimlane` is covered too. */ const genColor = (options: FlowChartStyleOptions) => { const { theme, bkgColorArray, borderColorArray } = options; @@ -66,18 +70,42 @@ const genColor = (options: FlowChartStyleOptions) => { */ const collapsedRule = (suffix: string) => `${slot}.node ${suffix}, ${slot}.rough-node ${suffix}`; + /* Both halves of a lane. Each carries the whole prefix separately, as above. */ + const laneRule = (suffix: string) => + `${slot}.swimlane.cluster .swimlane-title${suffix}, ${slot}.swimlane.cluster .swimlane-body${suffix}`; sections += ` - ${slot}.cluster rect { + ${slot}.cluster:not(.swimlane) rect { + stroke: ${borderColor}; + ${fill} + } + + ${slot}.cluster:not(.swimlane) path { stroke: ${borderColor}; ${fill} } - ${slot}.cluster path { + /* Lane, classic and neo. */ + ${slot}.swimlane.cluster rect.swimlane-title, + ${slot}.swimlane.cluster rect.swimlane-body { stroke: ${borderColor}; ${fill} } + /* Lane, handDrawn: roughjs emits the hachure fill first, then the outline. */ + ${laneRule(' path:nth-of-type(2)')} { + stroke: ${borderColor}; + } +${ + hasBkgColors + ? ` + /* A roughjs fill is drawn as lines, so the lane fill is a stroke here. */ + ${laneRule(' path:first-of-type')} { + stroke: ${bkgColorArray[i % bkgColorArray.length]}; + } +` + : '' +} ${collapsedRule('.collapsed-group')}, ${collapsedRule('.collapsed-group path')} { stroke: ${borderColor}; diff --git a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts index 0e54ac66ecc..53cb6ffa3be 100644 --- a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts +++ b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts @@ -5,10 +5,14 @@ * discards, so the only symptom is a shape rendering unstyled. No screenshot test can * catch it: a diagram renders identically with or without a discarded declaration. * - * 1. Indexing raw means a palette shorter than `THEME_COLOR_LIMIT` yields - * `stroke: undefined` for the overflow slots. - * 2. Wrapping with `i % length` fixes that but reintroduces it for an *empty* palette: - * `i % 0` is `NaN` and `[][NaN]` is `undefined`. Both files bail early instead. + * 1. Indexing raw while looping to `THEME_COLOR_LIMIT` means a palette shorter than the + * limit yields `stroke: undefined` for the overflow slots. Both files now take the + * loop bound from the palette itself, which also fixes the reverse — a palette longer + * than the limit left stamped boxes with no rule emitted. + * 2. Wrapping with `i % length` fixes the first half but reintroduces it for an *empty* + * palette: `i % 0` is `NaN` and `[][NaN]` is `undefined`. Both files bail early + * instead. The background palette is a separate array and may still be shorter than + * the border one, so that one is still wrapped. * 3. `requirement` emitted `fill: ;` — an empty value, invalid CSS — whenever there was * no background palette. `redux-dark-color` is the live case: it ships a border * palette and no background palette, colouring outlines only. @@ -27,11 +31,13 @@ import themes from '../themes/index.js'; import erStyles from './er/styles.js'; import requirementStyles from './requirement/styles.js'; import timelineStyles from './timeline/styles.js'; +import usecaseStyles from './usecase/styles.js'; const STYLESHEETS = { er: erStyles, requirement: requirementStyles, timeline: timelineStyles, + usecase: usecaseStyles, } as const; type Stylesheet = keyof typeof STYLESHEETS; @@ -43,7 +49,23 @@ const ALL_STYLESHEETS = Object.keys(STYLESHEETS) as Stylesheet[]; * colours `.section-N` classes directly, so the slot-shaped assertions do not apply to * it — only the invalid-CSS ones do. */ -const SLOT_STYLESHEETS = ['er', 'requirement'] as const satisfies readonly Stylesheet[]; +const SLOT_STYLESHEETS = ['er', 'requirement', 'usecase'] as const satisfies readonly Stylesheet[]; + +/** + * How many rule blocks each stylesheet emits per slot, under the `classic` look these + * tests render with. ER and requirement emit a `path` rule and a `rect` rule; usecase + * emits five, because it paints three different kinds of element, reaches the business + * marker by name, and names the actor glyph's children as well as the group. + * + * Kept as a per-stylesheet number rather than a `greaterThan` bound: the point of the + * assertion is that *every* slot gets its rules, and a lower bound would still pass if + * only a couple of slots were emitted. + */ +const RULES_PER_SLOT = { + er: 2, + requirement: 2, + usecase: 5, +} as const satisfies Record<(typeof SLOT_STYLESHEETS)[number], number>; /** Matches `theme-base.js`; the palette rules are emitted one per slot up to this. */ const THEME_COLOR_LIMIT = 12; @@ -81,7 +103,16 @@ const render = ( ...overrides, }; configApi.reset(); - configApi.setSiteConfig({ theme, look: 'classic', themeVariables: options }); + configApi.setSiteConfig({ + theme, + look: 'classic', + themeVariables: options, + // The usecase stylesheet emits its slot rules only when asked to. Its default is the + // `role` scheme -- one colour per kind of element -- so without this every assertion + // below would be checking an empty string. `usecaseRoleScheme` covers the default. + ...(name === 'usecase' ? { usecase: { colorScheme: 'rotate' } } : {}), + ...(overrides.look ? { look: overrides.look as MermaidConfig['look'] } : {}), + }); return STYLESHEETS[name](options); }; @@ -131,20 +162,41 @@ describe.each(ALL_STYLESHEETS)('%s stylesheet', (name) => { describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => { it('resolves every slot from a palette shorter than THEME_COLOR_LIMIT', () => { + const borderColorArray = ['#ff0000', '#00ff00']; const css = render(name, 'redux-color', { - borderColorArray: ['#ff0000', '#00ff00'], + borderColorArray, bkgColorArray: ['#ffeeee', '#eeffee'], }); - // Every slot still gets a rule, and every rule names one of the two colours. Scoped to - // the palette blocks — the rest of the stylesheet has its own `stroke:` declarations. + // Every slot gets a rule, and every rule names one of the two colours. Scoped to the + // palette blocks — the rest of the stylesheet has its own `stroke:` declarations. // Counted exactly: a `greaterThan` bound would still pass if only a couple of slots - // were emitted, which is the regression this is here to catch. Each slot emits a - // `path` rule and a `rect` rule, hence twice the limit. + // were emitted, which is the regression this is here to catch. The per-slot rule count + // differs by stylesheet -- see `RULES_PER_SLOT`. + // + // The expected count is the palette length, changed from `THEME_COLOR_LIMIT`. Looping + // to the limit and wrapping the index did keep every declaration valid — which is what + // this file was written to check, and that half still holds — but `erBox` and + // `requirementBox` stamp `colorIndex % borderColorArray.length`, so a two-entry palette + // can only ever produce color-0 and color-1. Ten of the twelve rules matched nothing. + // The same drift in the other direction is the actual defect: a palette *longer* than + // the limit left stamped boxes with no rule at all. Both sides now derive from the + // palette length, so they cannot disagree; `colorThemeGate.spec.ts` pins that. const blocks = paletteBlocks(css); const strokes = strokesIn(blocks); - expect(blocks).toHaveLength(THEME_COLOR_LIMIT * 2); - expect(strokes).toHaveLength(THEME_COLOR_LIMIT * 2); - expect(new Set(strokes)).toEqual(new Set(['#ff0000', '#00ff00'])); + expect(blocks).toHaveLength(borderColorArray.length * RULES_PER_SLOT[name]); + expect(strokes).toHaveLength(borderColorArray.length * RULES_PER_SLOT[name]); + expect(new Set(strokes)).toEqual(new Set(borderColorArray)); + }); + + it('resolves every slot from a palette longer than THEME_COLOR_LIMIT', () => { + // The direction that actually broke: with the bound at THEME_COLOR_LIMIT, slots + // 12..19 were stamped by `erBox`/`requirementBox` and had no rule emitted, so those + // entities rendered unstyled next to coloured neighbours. + const borderColorArray = Array.from({ length: 20 }, (_, i) => `#${(i + 16).toString(16)}0000`); + const css = render(name, 'redux-color', { borderColorArray, bkgColorArray: [] }); + const strokes = strokesIn(paletteBlocks(css)); + expect(strokes).toHaveLength(borderColorArray.length * RULES_PER_SLOT[name]); + expect(new Set(strokes)).toEqual(new Set(borderColorArray)); }); it('emits no slot rules at all for an empty border palette', () => { @@ -160,6 +212,21 @@ describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => { expect(paletteBlocks(css).length).toBeGreaterThan(0); expect(paletteBlocks(css).every((block) => block.includes('stroke:'))).toBe(true); }); + + it('keeps a hostile look out of the selector', () => { + // `look` reaches getConfig() verbatim from an init directive -- the schema enum is not + // enforced at runtime, and config.sanitize only strips values containing `<`, `>` or + // `url(data:`. Braces and quotes survive, which is enough to close the attribute + // selector early and open a rule block of the author's choosing. `safeLook` is what + // stops that; this only exercises it here because er/requirement/usecase are the + // stylesheets that actually interpolate `look` into a slot selector (colorThemeGate.spec.ts + // pins `safeLook` itself). `timeline` never reads `options.look`, so it has nothing to + // protect and is deliberately excluded from this describe block. + const hostileLook = 'classic"]{a'; + const css = render(name, 'redux-color', { look: hostileLook as MermaidConfig['look'] }); + expect(css).not.toContain(hostileLook); + expect(css).toContain('[data-look="classic"]'); + }); }); describe('timeline section rules', () => { @@ -191,3 +258,39 @@ describe('timeline section rules', () => { expect(new Set(strokes).size).toBe(1); }); }); + +/** + * The handDrawn look is the one case where descending into a shape's paths is wrong. + * roughjs draws each shape as an outline path plus a hachure *fill* path whose colour is + * carried by its `stroke`, and the two are indistinguishable in CSS -- so a rule that + * strokes every path inside a shape repaints the fill lines in the border colour. A use + * case body collapses into a solid block and a hollow actor into a solid disc. + * + * `genColor` therefore omits its glyph-descendant rule under handDrawn. The whole + * stylesheet is regenerated per render and every rule is scoped to `[data-look=""]`, + * so gating on the look is exact rather than a heuristic. + */ +describe('usecase handDrawn palette rules', () => { + const glyphDescendantRules = (css: string) => + [...css.matchAll(/\.usecase-actor-glyph (?:path|circle)/g)].length; + + it('names the actor glyph children under a look that draws real elements', () => { + // `neo` ships `[data-look="neo"].node path { stroke }`, which hits the glyph's own + // paths -- a value set on the child beats one inherited from the group whatever the + // group rule's specificity. Without these rules every actor renders in the uniform + // border colour under the default look. + const css = render('usecase', 'redux-color', { look: 'neo' }); + expect(glyphDescendantRules(css)).toBeGreaterThan(0); + }); + + it('omits them under handDrawn, where they would fill a hollow actor', () => { + expect(glyphDescendantRules(render('usecase', 'redux-color', { look: 'handDrawn' }))).toBe(0); + }); + + it('still colours the actor glyph group under handDrawn', () => { + // The group rule stays: it is what roughjs-drawn actors inherit where they can, and + // dropping it would leave handDrawn actors with no palette rule at all. + const css = render('usecase', 'redux-color', { look: 'handDrawn' }); + expect(css).toContain('[data-look="handDrawn"][data-color-id="color-0"].usecase-actor'); + }); +}); diff --git a/packages/mermaid/src/diagrams/railroad/styles.spec.ts b/packages/mermaid/src/diagrams/railroad/styles.spec.ts index c6e5c0d9920..732f4402d1e 100644 --- a/packages/mermaid/src/diagrams/railroad/styles.spec.ts +++ b/packages/mermaid/src/diagrams/railroad/styles.spec.ts @@ -161,15 +161,24 @@ describe('Railroad Styles', () => { }); it('should fall back to safe defaults when css values are invalid', () => { - const styles = getStyles({ + const injected = { fontFamily: 'safe"} .railroad-terminal { display: none; } /*', terminalFill: '#fff; stroke: red;', - }); + }; + const styles = getStyles(injected); expect(styles).not.toContain('display: none'); expect(styles).not.toContain('stroke: red;'); expect(styles).toContain(`font-family: ${configApi.getConfig().themeVariables?.fontFamily}`); - expect(styles).toContain(`fill: ${configApi.getConfig().themeVariables?.secondBkg}`); + // `buildRailroadStyleOptions` layers theme-default underneath the active theme, so a + // variable the active theme never assigns still resolves -- `secondBkg` is unset + // across the base/neo/redux family, and railroad falls through to theme-default's + // value. Asserting that value directly keeps this independent of which theme is + // configured; reading `secondBkg` off `config.themeVariables` only agreed with the + // rendered output while the default theme happened to define it. Asserting against + // `buildRailroadStyleOptions` instead would be self-referential, since `getStyles` + // reads its values straight from it. + expect(styles).toContain(`fill: ${themes.default.getThemeVariables().secondBkg}`); }); it('should handle all options at once', () => { diff --git a/packages/mermaid/src/diagrams/requirement/styles.js b/packages/mermaid/src/diagrams/requirement/styles.js index d5328336635..2a7cc501f7e 100644 --- a/packages/mermaid/src/diagrams/requirement/styles.js +++ b/packages/mermaid/src/diagrams/requirement/styles.js @@ -1,25 +1,35 @@ import * as configApi from '../../config.js'; +import { hasPalette, isColorTheme, paletteSlotCount, safeLook } from '../common/colorThemeGate.js'; -const genColor = (options) => { +const genColor = () => { const config = configApi.getConfig(); - const { themeVariables, look } = config; + const { theme, themeVariables } = config; const { bkgColorArray, borderColorArray } = themeVariables; - if (!borderColorArray?.length) { + // Gates on the theme as well as the palette, matching every other stylesheet. This used + // to key off the array alone, which happened to give the same answer but left two + // different idioms in the codebase for the same decision. + if (!isColorTheme(theme, borderColorArray)) { return ''; } + // `look` is validated before it reaches the selector -- see `safeLook`. + const look = safeLook(config.look); let sections = ''; - const hasBkgColors = bkgColorArray?.length > 0; + const hasBkgColors = hasPalette(bkgColorArray); - for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + // One rule per slot `requirementBox` can actually stamp -- it stamps + // `colorIndex % borderColorArray.length`, so the ids it can produce are exactly + // `0 .. borderColorArray.length - 1`. Same reasoning as `er/styles.ts`. + for (let i = 0; i < paletteSlotCount(borderColorArray); i++) { // Omit the declaration when there is no fill palette, rather than emitting `fill: ;` // -- an empty value is invalid CSS. `redux-dark-color` is the live case: it ships a // border palette and no background palette, colouring outlines only. // - // Wrap at the palette length for the same reason as `er/styles.ts`: the loop runs to - // THEME_COLOR_LIMIT, so a shorter palette would emit `stroke: undefined`. - const borderColor = borderColorArray[i % borderColorArray.length]; + // The background palette is a separate array that may be shorter than the border one, + // so it still wraps; `borderColorArray[i]` does not need to, now the bound is its own + // length. + const borderColor = borderColorArray[i]; const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; sections += ` @@ -42,7 +52,7 @@ const getStyles = (options) => { const { look, themeVariables } = config; const { requirementEdgeLabelBackground } = themeVariables; return ` - ${genColor(options)} + ${genColor()} marker { fill: ${options.relationColor}; stroke: ${options.relationColor}; diff --git a/packages/mermaid/src/diagrams/sequence/actorBandModel.spec.ts b/packages/mermaid/src/diagrams/sequence/actorBandModel.spec.ts new file mode 100644 index 00000000000..19e4531de45 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/actorBandModel.spec.ts @@ -0,0 +1,232 @@ +/** + * Pipeline-level assertions for the band model: a real `mermaid.render`, all eight participant + * shapes on one row, header and footer. + * + * These deliberately do not call `drawActor` in isolation. The regressions this model exists for + * lived *between* the pieces -- shapes measuring themselves with `getBBox()` and feeding the + * result back into `actor.height`, which the footer then consumed -- so a test that stubs the + * measurement to a constant validates its own assumption and nothing else. Here `getBBox` is + * emulated from the geometry actually emitted, and text has a realistic nonzero height, so the + * feedback paths run for real. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import mermaid from '../../mermaid.js'; + +const TEXT_LINE_HEIGHT = 19; + +const num = (el: Element, n: string) => Number(el.getAttribute(n) ?? 0); + +const applyOwnTranslate = ( + el: Element, + box: { x: number; y: number; width: number; height: number } +) => { + const m = /translate\(\s*([\d.-]+)\s*[\s,]\s*([\d.-]+)\s*\)/.exec( + el.getAttribute('transform') ?? '' + ); + return m ? { ...box, x: box.x + Number(m[1]), y: box.y + Number(m[2]) } : box; +}; + +const bboxOf = (el: Element): { x: number; y: number; width: number; height: number } => { + const tag = el.tagName.toLowerCase(); + if (tag === 'circle') { + const r = num(el, 'r'); + return { x: num(el, 'cx') - r, y: num(el, 'cy') - r, width: 2 * r, height: 2 * r }; + } + if (tag === 'line') { + const [x1, x2, y1, y2] = [num(el, 'x1'), num(el, 'x2'), num(el, 'y1'), num(el, 'y2')]; + return { + x: Math.min(x1, x2), + y: Math.min(y1, y2), + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + }; + } + if (tag === 'rect') { + return { x: num(el, 'x'), y: num(el, 'y'), width: num(el, 'width'), height: num(el, 'height') }; + } + if (tag === 'path') { + const ys = [...(el.getAttribute('d') ?? '').matchAll(/([\d.-]+)[\s,]([\d.-]+)/g)] + .map((m) => Number(m[2])) + .filter(Number.isFinite); + if (!ys.length) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + return { x: 0, y: Math.min(...ys), width: 10, height: Math.max(...ys) - Math.min(...ys) }; + } + if (tag === 'text' || tag === 'tspan') { + const lines = Math.max(1, el.querySelectorAll('tspan').length); + return { + x: num(el, 'x'), + y: num(el, 'y'), + width: (el.textContent ?? '').length * 7, + height: TEXT_LINE_HEIGHT * lines, + }; + } + // Per the SVG spec, an element's getBBox excludes its own transform but includes its + // children's. The boundary shape hid a translate from three rounds of measurement because an + // earlier emulation skipped this. + const kids = [...el.children] + .map((child) => applyOwnTranslate(child, bboxOf(child))) + .filter((b) => b.width || b.height); + if (!kids.length) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + const x = Math.min(...kids.map((b) => b.x)); + const y = Math.min(...kids.map((b) => b.y)); + return { + x, + y, + width: Math.max(...kids.map((b) => b.x + b.width)) - x, + height: Math.max(...kids.map((b) => b.y + b.height)) - y, + }; +}; + +beforeAll(() => { + Object.defineProperty(SVGElement.prototype, 'getBBox', { + configurable: true, + value(this: SVGElement) { + return bboxOf(this); + }, + }); + Object.defineProperty(SVGElement.prototype, 'getComputedTextLength', { + configurable: true, + value(this: SVGElement) { + return (this.textContent ?? '').length * 7; + }, + }); +}); + +const MIXED_ROW = `sequenceDiagram + actor User + participant Plain as Plain + participant B@{ "type" : "boundary" } as Bound + participant C@{ "type" : "control" } as Ctrl + participant E@{ "type" : "entity" } as Ent + participant DB@{ "type" : "database" } as Database + participant Q@{ "type" : "queue" } as Que + participant Co@{ "type" : "collections" } as Coll + User ->> DB: query +`; + +/** Shapes that hang their label below a glyph; their labels share one baseline. */ +const OUTSIDE_LABELS = new Set(['User', 'Bound', 'Ctrl', 'Ent', 'Database']); + +const render = async (id: string, source: string) => { + mermaid.initialize({ theme: 'redux-color', look: 'neo', startOnLoad: false }); + const { svg } = await mermaid.render(id, source); + return new DOMParser().parseFromString(svg, 'image/svg+xml'); +}; + +const lifelines = (doc: Document) => + [...doc.querySelectorAll('line[data-et="life-line"]')].map((l) => ({ + id: l.getAttribute('data-id')!, + y1: Number(l.getAttribute('y1')), + y2: Number(l.getAttribute('y2')), + })); + +/** Header labels sit above the first datum, footer labels below the second. */ +const splitLabels = (doc: Document, datumTop: number) => { + const header = new Map(); + const footer = new Map(); + for (const t of doc.querySelectorAll('text.actor')) { + const y = Number(t.getAttribute('y')); + (y <= datumTop ? header : footer).set(t.textContent!, y); + } + return { header, footer }; +}; + +describe('actor band model (neo, real pipeline)', () => { + it('starts and ends every lifeline on the two datum lines', async () => { + const doc = await render('bm-datum', MIXED_ROW); + const lls = lifelines(doc); + + expect(lls).toHaveLength(8); + expect(new Set(lls.map((l) => l.y1)).size).toBe(1); + expect(new Set(lls.map((l) => l.y2)).size).toBe(1); + }); + + it('puts every outside label on one baseline, clear of the lifeline, header and footer', async () => { + const doc = await render('bm-labels', MIXED_ROW); + const [{ y1: datumTop }] = lifelines(doc); + const { header, footer } = splitLabels(doc, datumTop); + + const headerYs = [...OUTSIDE_LABELS].map((n) => header.get(n)); + expect(new Set(headerYs).size).toBe(1); + // The label's ink must end above the datum: centre + half the real text height, with room. + expect((headerYs[0] ?? Infinity) + TEXT_LINE_HEIGHT / 2).toBeLessThan(datumTop); + + const footerYs = [...OUTSIDE_LABELS].map((n) => footer.get(n)); + expect(new Set(footerYs).size).toBe(1); + }); + + it('keeps a multiline label on the datum, growing away from it', async () => { + const single = await render('bm-single', MIXED_ROW); + const multi = await render( + 'bm-multi', + MIXED_ROW.replace('actor User', 'actor User as First line
Second line') + ); + + const singleLifelines = lifelines(single); + const multiLifelines = lifelines(multi); + // The row grows, but stays one datum. + expect(new Set(multiLifelines.map((l) => l.y1)).size).toBe(1); + + const singleDatum = singleLifelines[0].y1; + const multiDatum = multiLifelines[0].y1; + expect(multiDatum).toBeGreaterThan(singleDatum); + + // Single-line neighbours keep their distance to the datum -- the extra line grew upward past + // them, it did not push them off the shared baseline. + const singleLabels = splitLabels(single, singleDatum).header; + const multiLabels = splitLabels(multi, multiDatum).header; + expect(multiDatum - multiLabels.get('Database')!).toBe( + singleDatum - singleLabels.get('Database')! + ); + }); + + it('renders every glyph inside the bands, transforms included', async () => { + // The assertion that finally catches what getBBox cannot: boundary and entity carried legacy + // `translate` nudges on their groups, so their local boxes matched control's while their + // rendered positions sat 21 and 6 lower. Measured here with each group's own transform + // applied, the way the browser paints it. + const doc = await render('bm-glyphs', MIXED_ROW); + const [{ y1: datumTop }] = lifelines(doc); + + const glyphBottoms = new Map(); + for (const g of doc.querySelectorAll('g[data-et="participant"]')) { + const box = applyOwnTranslate(g, bboxOf(g)); + const glyphs = [...g.querySelectorAll('circle, line, rect, path')].filter( + (el) => el.getAttribute('data-et') !== 'life-line' + ); + if (!glyphs.length) { + continue; + } + const bottoms = glyphs.map((el) => { + const b = applyOwnTranslate(el, bboxOf(el)); + return b.y + b.height + (box.y - bboxOf(g).y); + }); + glyphBottoms.set(g.getAttribute('data-id')!, Math.max(...bottoms)); + } + + // Nothing pokes below the datum... + for (const [, bottom] of glyphBottoms) { + expect(bottom).toBeLessThanOrEqual(datumTop + 0.5); + } + // ...and the icon family sits feet-on-one-line. A reintroduced translate breaks this. + const iconBottoms = ['User', 'B', 'C', 'E'].map((id) => glyphBottoms.get(id)); + expect(new Set(iconBottoms.map((b) => Math.round(b! * 10) / 10)).size).toBe(1); + }); + + it('reports one uniform actor height back into the pipeline', async () => { + // The old failure mode: shapes overwrote `actor.height` from their own bounding box with + // per-shape fudge terms, and the footer consumed the corrupted values. With the model, every + // consumer of heights sees the row height. The footer labels sharing a baseline (asserted + // above) is the visible consequence; this pins the datum gap between header and footer being + // identical for every shape, which fails if any shape's height drifts. + const doc = await render('bm-height', MIXED_ROW); + const lls = lifelines(doc); + const spans = new Set(lls.map((l) => l.y2 - l.y1)); + + expect(spans.size).toBe(1); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/actorBands.ts b/packages/mermaid/src/diagrams/sequence/actorBands.ts new file mode 100644 index 00000000000..3b2742dc4b4 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/actorBands.ts @@ -0,0 +1,120 @@ +/** + * The vertical band model for sequence participants under the `neo` look. + * + * Before this module, vertical geometry was the emergent output of each shape function: guess the + * lifeline position from the incoming height, draw the glyph at hardcoded offsets, measure itself + * with `getBBox()`, overwrite `actor.height` with the measurement plus a per-shape fudge + * (`labelBoxHeight` once for `boundary` and `entity`, twice for `control`), then draw the label + * with a mix of the old and new heights. The footer redrew with the corrupted heights and its own + * separate offsets. Fixing any one shape moved another; three rounds of that preceded this file. + * + * The model instead states the layout once, and everything draws from it: + * + * ``` + * rowTop (actor.starty) + * [ air, when this actor's stack is shorter than the row's ] + * [ glyph band, GLYPH_BAND_HEIGHT tall, glyphs bottom-aligned ] + * GLYPH_LABEL_GAP + * [ label block, measured text height, bottom-anchored ] + * LABEL_LIFELINE_GAP + * lifelineStart = rowTop + rowHeight <- one datum line for the whole row + * ... + * lifelineEnd (actor.stopy) <- one datum line for the whole row + * LABEL_LIFELINE_GAP + * [ glyph band ] + * GLYPH_LABEL_GAP + * [ label block, top-anchored, growing downward ] + * ``` + * + * Everything is anchored at the datum lines: with one line of text -- the usual case -- every + * outside-the-shape label sits on one baseline, and a multiline label grows away from the datum + * (upward in the header, downward in the footer) instead of pushing the lifeline around. The box + * shapes (`participant`, `queue`, `collections`) span the full row so their bottom edge sits on + * the datum and their label stays centred in the box. + * + * The `classic` look does not use this module; its per-shape legacy values are pinned in + * `lifelineStart.spec.ts` and must not move. + */ +import utils from '../../utils.js'; + +/** Height of the glyph band. The round icons set it: a 22 unit circle plus its underline. */ +export const GLYPH_BAND_HEIGHT = 44; + +/** Air between the bottom of the glyph and the top of the label block. */ +export const GLYPH_LABEL_GAP = 6; + +/** Air between the bottom of the label block and the lifeline datum. */ +export const LABEL_LIFELINE_GAP = 6; + +interface ActorLike { + description: string; + actorTextHeight?: number; +} + +interface ActorFontConf { + actorFontFamily?: string; + actorFontSize?: string | number; + actorFontWeight?: string | number; +} + +/** + * Measured height of the actor's label block. `calculateActorMargins` stashes the measurement it + * already takes; the fallback keeps isolated `drawActor` calls (tests, external callers) working. + */ +export const actorLabelHeight = (actor: ActorLike, conf: ActorFontConf): number => + actor.actorTextHeight ?? + utils.calculateTextDimensions(actor.description ?? '', { + fontFamily: conf.actorFontFamily, + fontSize: conf.actorFontSize, + fontWeight: conf.actorFontWeight, + } as Parameters[1]).height; + +/** The stack an icon-family actor needs above the datum: glyph, gap, label, gap. */ +export const actorStackHeight = (textHeight: number): number => + GLYPH_BAND_HEIGHT + GLYPH_LABEL_GAP + textHeight + LABEL_LIFELINE_GAP; + +export interface HeaderBands { + /** The datum: where this row's lifelines start. */ + lifelineStartY: number; + /** Bottom edge of the glyph band; glyphs are drawn ending here. */ + glyphBottomY: number; + /** Vertical centre of the label block. */ + labelCenterY: number; +} + +export interface FooterBands { + /** Top edge of the glyph band below the datum. */ + glyphTopY: number; + /** Bottom edge of the glyph band. */ + glyphBottomY: number; + /** Vertical centre of the label block. */ + labelCenterY: number; + /** Total height the footer stack occupies below the datum. */ + stackHeight: number; +} + +/** + * Header geometry for one actor. `rowHeight` is the row's shared height (`conf.height` after + * `calculateActorMargins`), which is what makes the datum one line rather than eight. + */ +export const headerBands = (actorY: number, rowHeight: number, textHeight: number): HeaderBands => { + const lifelineStartY = actorY + rowHeight; + const labelCenterY = lifelineStartY - LABEL_LIFELINE_GAP - textHeight / 2; + return { + lifelineStartY, + glyphBottomY: lifelineStartY - LABEL_LIFELINE_GAP - textHeight - GLYPH_LABEL_GAP, + labelCenterY, + }; +}; + +/** Footer geometry: the same stack, top-anchored at the datum, growing downward. */ +export const footerBands = (actorY: number, textHeight: number): FooterBands => { + const glyphTopY = actorY + LABEL_LIFELINE_GAP; + const glyphBottomY = glyphTopY + GLYPH_BAND_HEIGHT; + return { + glyphTopY, + glyphBottomY, + labelCenterY: glyphBottomY + GLYPH_LABEL_GAP + textHeight / 2, + stackHeight: actorStackHeight(textHeight), + }; +}; diff --git a/packages/mermaid/src/diagrams/sequence/actorSizing.spec.ts b/packages/mermaid/src/diagrams/sequence/actorSizing.spec.ts new file mode 100644 index 00000000000..945987c9b52 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/actorSizing.spec.ts @@ -0,0 +1,156 @@ +/** + * The stick figure under the band model (`actorBands.ts`), against `classic` as the invariant. + * + * History, because this spec has pinned three designs: the original `neo` halved the figure and + * let the scale leak into `actor.height` and the label; a first fix drew it full size; a second + * inset it at 0.8 with the label pinned. Both fixes still derived positions per shape, and the + * misalignments simply moved to the next seam -- lifelines, then footer stacks. The band model + * replaces all of that: the figure fills the shared glyph band, the label is bottom-anchored at + * the datum, and `classic` draws its legacy geometry untouched. + */ +import { select } from 'd3'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import defaultConfig from '../../defaultConfig.js'; +import themes from '../../themes/index.js'; +import svgDraw from './svgDraw.js'; +import { GLYPH_BAND_HEIGHT, LABEL_LIFELINE_GAP, actorLabelHeight } from './actorBands.js'; + +const originalGetBBox = Object.getOwnPropertyDescriptor(SVGElement.prototype, 'getBBox'); + +beforeAll(() => { + Object.defineProperty(SVGElement.prototype, 'getBBox', { + configurable: true, + // The stick figure reads its own bbox back. Report the union of the drawn primitives so the + // measurement tracks the glyph the code actually emitted. + value(this: SVGElement) { + const ys = [...this.querySelectorAll('line, circle')].flatMap((child) => { + if (child.tagName === 'circle') { + const cy = Number(child.getAttribute('cy') ?? 0); + const r = Number(child.getAttribute('r') ?? 0); + return [cy - r, cy + r]; + } + return [Number(child.getAttribute('y1') ?? 0), Number(child.getAttribute('y2') ?? 0)]; + }); + const height = ys.length ? Math.max(...ys) - Math.min(...ys) : 0; + return { x: 0, y: ys.length ? Math.min(...ys) : 0, width: 60, height } as DOMRect; + }, + }); +}); + +afterAll(() => { + if (originalGetBBox) { + Object.defineProperty(SVGElement.prototype, 'getBBox', originalGetBBox); + } else { + Reflect.deleteProperty(SVGElement.prototype, 'getBBox'); + } +}); + +const confFor = (look: string) => ({ + ...defaultConfig.sequence, + look, + theme: 'redux', + themeVariables: themes.redux.getThemeVariables(), + sequence: defaultConfig.sequence, +}); + +const participant = (name: string, type: string) => ({ + name, + description: name, + type, + x: 0, + y: 0, + starty: 100, + stopy: 400, + width: 150, + height: 65, + links: {}, + properties: {}, +}); + +const svg = () => select(document.querySelector('svg')!); + +/** Vertical extent of the drawn stick figure, ignoring the lifeline outside its group. */ +const glyphHeightOf = (figure: Element) => { + const circle = figure.querySelector('circle')!; + const top = Number(circle.getAttribute('cy')) - Number(circle.getAttribute('r')); + const feet = Math.max( + ...[...figure.querySelectorAll('line')].map((l) => Number(l.getAttribute('y2') ?? 0)) + ); + return feet - top; +}; + +/** y of the label `` a participant shape emitted. */ +const labelY = (root: Element) => Number(root.querySelector('text')?.getAttribute('y')); + +const drawOne = async (type: string, look: string) => { + document.body.innerHTML = ''; + const actor = participant(type, type); + const indexMap = new Map([[actor.name, 0]]); + await svgDraw.drawActor(svg(), actor, confFor(look), false, 'test-id', undefined, indexMap); + return { actor, root: document.querySelector('svg')! }; +}; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('stick-figure actor sizing', () => { + it.each(['classic', 'neo'])( + 'puts its label where a database puts its own, on %s', + async (look) => { + const stick = await drawOne('actor', look); + const database = await drawOne('database', look); + + expect(labelY(stick.root)).toBe(labelY(database.root)); + } + ); + + it('leaves the classic figure at the size it has always been', async () => { + // The default look renders a great many existing documents server-side, so its actor is not + // ours to resize. 15 is the radius `classic` has always drawn, and the glyph fills its box. + const { actor, root } = await drawOne('actor', 'classic'); + const figure = root.querySelector('.actor-man')!; + + expect(figure.querySelector('circle')?.getAttribute('r')).toBe('15'); + expect(glyphHeightOf(figure)).toBe(actor.height); + }); + + it('draws the figure smaller on neo than on classic, at the glyph band size', async () => { + const classic = await drawOne('actor', 'classic'); + const classicHead = Number(classic.root.querySelector('circle')!.getAttribute('r')); + + const neo = await drawOne('actor', 'neo'); + const neoHead = Number(neo.root.querySelector('circle')!.getAttribute('r')); + + expect(neoHead).toBeLessThan(classicHead); + // Same box height either way; the look changes the glyph, not the layout around it. + expect(neo.actor.height).toBe(classic.actor.height); + }); + + it('fills the shared glyph band on neo', async () => { + // The figure is the same size as the round icons beside it: it spans exactly the glyph band, + // feet on the band's bottom edge. + const { root } = await drawOne('actor', 'neo'); + const figure = root.querySelector('.actor-man')!; + + expect(glyphHeightOf(figure)).toBeCloseTo(GLYPH_BAND_HEIGHT, 5); + }); + + it('anchors the label at the datum on neo', async () => { + // Bottom-anchored: the label block's centre sits half its measured height plus the clearance + // above the lifeline datum, so single-line labels share a baseline across shapes and a second + // line grows upward rather than into the lifeline. + const { actor, root } = await drawOne('actor', 'neo'); + const textHeight = actorLabelHeight(actor as never, confFor('neo') as never); + const datum = 100 + actor.height; + + expect(labelY(root)).toBe(datum - LABEL_LIFELINE_GAP - textHeight / 2); + }); + + it('reports a height that is not shrunk by the look', async () => { + const classic = await drawOne('actor', 'classic'); + const neo = await drawOne('actor', 'neo'); + + expect(neo.actor.height).toBe(classic.actor.height); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/lifelineStart.spec.ts b/packages/mermaid/src/diagrams/sequence/lifelineStart.spec.ts new file mode 100644 index 00000000000..1ef22d57551 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/lifelineStart.spec.ts @@ -0,0 +1,168 @@ +/** + * Every participant shape used to decide for itself where its lifeline began, and they disagreed. + * On one row, with every shape sharing an `actorY` and an `actor.height`, that produced four + * different lifeline tops: `participant` measured from the box (65), `database` from the box plus + * twice `boxTextMargin` (75), `control` and `entity` used a hardcoded 75, and `boundary` and + * `actor` a hardcoded 80. An `actor` beside a `database` -- the most common pairing there is -- + * therefore had its lifeline start 5px lower for no reason either shape could state. + * + * `lifelineStartY` states the shared rule once: start below whatever the participant occupies. Most + * shapes centre their label inside the box and so end at the box bottom; `actor` and `database` + * hang theirs below it, so for those the label decides. Shapes sharing a label offset therefore + * share a lifeline top, which is what makes those two line up. + * + * Applied to `neo` only. The values are pinned for `classic` as well, because that look renders a + * great many existing documents and moving where a lifeline meets its shape would change all of + * them. + */ +import { select } from 'd3'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import defaultConfig from '../../defaultConfig.js'; +import themes from '../../themes/index.js'; +import svgDraw from './svgDraw.js'; + +const originalGetBBox = Object.getOwnPropertyDescriptor(SVGElement.prototype, 'getBBox'); + +beforeAll(() => { + Object.defineProperty(SVGElement.prototype, 'getBBox', { + configurable: true, + value: () => ({ x: 0, y: 0, width: 60, height: 65 }) as DOMRect, + }); +}); + +afterAll(() => { + if (originalGetBBox) { + Object.defineProperty(SVGElement.prototype, 'getBBox', originalGetBBox); + } else { + Reflect.deleteProperty(SVGElement.prototype, 'getBBox'); + } +}); + +const confFor = (look: string) => ({ + ...defaultConfig.sequence, + look, + theme: 'redux', + themeVariables: themes.redux.getThemeVariables(), + sequence: defaultConfig.sequence, +}); + +const ACTOR_Y = 100; + +/** y1 of the lifeline a participant shape emitted, relative to the top of its box. */ +const lifelineTop = async (type: string, look: string) => { + document.body.innerHTML = ''; + const actor = { + name: type, + description: type, + type, + x: 0, + y: 0, + starty: ACTOR_Y, + stopy: 400, + width: 150, + height: 65, + links: {}, + properties: {}, + }; + const svg = select(document.querySelector('svg')!); + await svgDraw.drawActor(svg, actor, confFor(look), false, 'id', undefined, new Map([[type, 0]])); + const line = document.querySelector('line[data-et="life-line"]')!; + return Number(line.getAttribute('y1')) - ACTOR_Y; +}; + +/** Distance from the bottom of the label text to the top of the lifeline. */ +const labelToLifelineGap = async (type: string, look: string) => { + const top = await lifelineTop(type, look); + const label = document.querySelector('text.actor')!; + const labelBottom = Number(label.getAttribute('y')) - ACTOR_Y + 7; + return top - labelBottom; +}; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('lifeline start', () => { + it('starts every icon-and-label-below shape at the same place on neo', async () => { + // `boundary`, `control`, `entity`, `database` and `actor` all hang their label below a glyph. + // They share one label offset, so they clear it at one height -- which is what puts an actor + // and a database, the most common pairing, on the same line. + const tops: number[] = []; + for (const type of ['boundary', 'control', 'entity', 'database', 'actor']) { + tops.push(await lifelineTop(type, 'neo')); + } + + expect(new Set(tops).size).toBe(1); + }); + + it('leaves the same gap between label and lifeline for all of them', async () => { + // The property that is actually visible: glyphs of different heights must not produce + // different amounts of air under the text. `control` had 3 and `boundary` 10.5. + const gaps: number[] = []; + for (const type of ['boundary', 'control', 'entity', 'database', 'actor']) { + gaps.push(await labelToLifelineGap(type, 'neo')); + } + + expect(new Set(gaps).size).toBe(1); + }); + + it('starts at the box bottom for shapes whose label sits inside the box', async () => { + // A different arrangement -- the label is centred in the box, nothing hangs below it, so there + // is nothing to clear and no gap to leave. + for (const type of ['participant', 'queue', 'collections']) { + expect(await lifelineTop(type, 'neo')).toBe(65); + } + }); + + it('ends the round icons at the same height on neo', async () => { + // `boundary`, `control` and `entity` draw the same 22 unit circle but were centred at 12, 32 + // and 25, so their glyphs ended 20 units apart. With one label baseline for the family that + // showed up as three different amounts of air between glyph and label. + const bottoms: number[] = []; + for (const type of ['boundary', 'control', 'entity']) { + await lifelineTop(type, 'neo'); + const circle = document.querySelector('svg circle')!; + bottoms.push(Number(circle.getAttribute('cy')) + Number(circle.getAttribute('r'))); + } + + expect(new Set(bottoms).size).toBe(1); + }); + + it('leaves the classic icon centres alone', async () => { + const centres: Record = {}; + for (const type of ['boundary', 'control', 'entity']) { + await lifelineTop(type, 'classic'); + centres[type] = Number(document.querySelector('svg circle')!.getAttribute('cy')) - ACTOR_Y; + } + + expect(centres).toEqual({ boundary: 12, control: 32, entity: 25 }); + }); + + it('leaves every classic lifeline exactly where it was', async () => { + // Sequentially: these share one document, so they cannot be measured in parallel. + const classic: Record = {}; + for (const type of [ + 'participant', + 'queue', + 'collections', + 'boundary', + 'control', + 'entity', + 'database', + 'actor', + ]) { + classic[type] = await lifelineTop(type, 'classic'); + } + + expect(classic).toEqual({ + participant: 65, + queue: 65, + collections: 65, + boundary: 80, + control: 75, + entity: 75, + database: 75, + actor: 80, + }); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/noteFontWeight.spec.ts b/packages/mermaid/src/diagrams/sequence/noteFontWeight.spec.ts new file mode 100644 index 00000000000..e56c7ec4438 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/noteFontWeight.spec.ts @@ -0,0 +1,41 @@ +/** + * Note text is drawn as `` wrapping one `` per line. The weight the + * user asked for — `sequence.noteFontWeight`, schema default 400 — is applied by `drawText` as an + * inline style on the `` element only; the tspan elements carry no weight of their own and inherit it. + * + * So a stylesheet rule matching `.noteText > tspan` beats the config outright: the inline style on + * the parent never reaches the tspan that actually renders the glyphs. That is how the redux themes + * came to force bold notes — they set the *theme variable* `noteFontWeight` to 600, which is a + * different value from the identically-named sequence config key, and the stylesheet emitted it at + * tspan level. + * + * The theme variable cannot simply be lowered: `git/styles.js` reads it as its bold-label weight + * under redux and neo (`useReduxGeometry` / `useNeoColorGen`), so changing it there would un-bold + * git branch and commit labels. The fix therefore belongs here — sequence stops emitting a weight + * into its stylesheet, and the documented config key governs note weight on every theme. + */ +import { describe, expect, it } from 'vitest'; +import themes from '../../themes/index.js'; +import getStyles from './styles.js'; + +const themeNames = Object.keys(themes) as (keyof typeof themes)[]; + +/** The declaration block for a selector, as emitted into the stylesheet. */ +const ruleFor = (css: string, selector: string): string | undefined => + new RegExp(`(?:^|\\})[^{}]*${selector.replaceAll('.', '\\.')}[^{}]*\\{([^}]*)\\}`).exec(css)?.[1]; + +describe('sequence note font weight', () => { + it.each(themeNames)('does not force a weight on note text under the %s theme', (themeName) => { + const css = getStyles(themes[themeName].getThemeVariables()); + + const noteTextRule = ruleFor(css, '.noteText'); + expect(noteTextRule).toBeDefined(); + expect(noteTextRule).not.toMatch(/font-weight/); + }); + + it('leaves the theme variable alone, because git depends on it', () => { + // Pinning the constraint that forced a sequence-scoped fix rather than a theme change. + expect(themes['redux-color'].getThemeVariables().noteFontWeight).toBe(600); + expect(themes.redux.getThemeVariables().noteFontWeight).toBe(600); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/rectSectionFill.spec.ts b/packages/mermaid/src/diagrams/sequence/rectSectionFill.spec.ts new file mode 100644 index 00000000000..9ee127e0208 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/rectSectionFill.spec.ts @@ -0,0 +1,51 @@ +/** + * A `rect` block shades a section of a sequence diagram. When the author gives no colour, the fill + * comes from the theme — `rectBkgColor`, falling back to `actorBkg` + * (`sequenceRenderer.ts` RECT_START). + * + * Every theme derives `rectBkgColor` from `tertiaryColor`, and the three modern light themes pin + * `tertiaryColor = '#ffffff'` against a `#ffffff` background. The band was therefore drawn white on + * white: present in the DOM, invisible on screen, and impossible to spot in a unit test that only + * asserts the rect exists. + * + * These assertions are about the resolved value rather than about any one theme, so a new theme + * that lands with the same collision fails here rather than shipping an invisible section. + */ +import { describe, expect, it } from 'vitest'; +import themes from '../../themes/index.js'; + +const themeNames = Object.keys(themes) as (keyof typeof themes)[]; + +/** The fill RECT_START resolves when the author gives no colour. */ +const resolvedRectFill = (theme: Record) => + theme.rectBkgColor || theme.actorBkg || 'rgba(128, 128, 128, 0.5)'; + +describe('sequence rect section fill', () => { + it.each(themeNames)('is distinguishable from the background on the %s theme', (themeName) => { + const theme = themes[themeName].getThemeVariables() as unknown as Record; + + expect(resolvedRectFill(theme).toLowerCase()).not.toBe(theme.background.toLowerCase()); + }); + + it.each(themeNames)( + 'stays distinguishable when the background is overridden to black, on the %s theme', + (themeName) => { + // darken() is a no-op at #000000, so a derivation that only darkens quietly reproduces the + // white-on-white bug at the other end of the range. Black-on-black hid from the bare + // getThemeVariables() assertions above, which never exercise an override. + const theme = themes[themeName].getThemeVariables({ + background: '#000000', + }) as unknown as Record; + + expect(resolvedRectFill(theme).toLowerCase()).not.toBe('#000000'); + } + ); + + it('is still overridable through themeVariables', () => { + const theme = themes.redux.getThemeVariables({ + rectBkgColor: '#abcdef', + }) as unknown as Record; + + expect(resolvedRectFill(theme)).toBe('#abcdef'); + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts b/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts index ca2678f8483..0601351a71c 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceRenderer.ts @@ -6,6 +6,7 @@ import common, { calculateMathMLDimensions, hasKatex } from '../common/common.js import { getUrl } from '../common/common.js'; import * as svgDrawCommon from '../common/svgDrawCommon.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; +import { actorStackHeight } from './actorBands.js'; import assignWithDepth from '../../assignWithDepth.js'; import utils from '../../utils.js'; import { configureSvgSize } from '../../setupGraphViewbox.js'; @@ -1110,7 +1111,7 @@ export const draw = async function (_text: string, id: string, _version: string, svgDraw.insertStickBottomArrowHead(diagram, id); if (look === 'neo') { - svgDraw.insertDropShadow(diagram, conf); + svgDraw.insertDropShadow(diagram, conf, id); } /** @@ -1633,7 +1634,15 @@ async function calculateActorMargins( ? conf.width : common.getMax(conf.width, actDims.width + 2 * conf.wrapPadding); - actor.height = actor.wrap ? common.getMax(actDims.height, conf.height) : conf.height; + if (getConfig().look === 'neo') { + // The band model: the row is as tall as its tallest glyph-gap-label-gap stack, and every + // shape anchors to the row's shared datum rather than measuring itself. The measured text + // height travels with the actor so the shapes place the label from the same number. + actor.actorTextHeight = actDims.height; + actor.height = common.getMax(actorStackHeight(actDims.height), conf.height); + } else { + actor.height = actor.wrap ? common.getMax(actDims.height, conf.height) : conf.height; + } maxHeight = common.getMax(maxHeight, actor.height); } diff --git a/packages/mermaid/src/diagrams/sequence/styles.js b/packages/mermaid/src/diagrams/sequence/styles.js index 859597c8189..d1705f1197d 100644 --- a/packages/mermaid/src/diagrams/sequence/styles.js +++ b/packages/mermaid/src/diagrams/sequence/styles.js @@ -103,10 +103,17 @@ const getStyles = (options) => { fill: ${options.noteBkgColor}; } + /* + * No font-weight here, deliberately. Note text renders inside tspan elements, and a weight emitted at + * tspan level outranks the inline style drawText puts on the parent text element from the + * sequence.noteFontWeight config key -- so that documented key would never reach the glyphs. + * The theme variable formerly emitted here is a different value from that config key, and + * git/styles.js reads it as its own bold-label weight under redux and neo, so it cannot be + * lowered there either. + */ .noteText, .noteText > tspan { fill: ${options.noteTextColor}; stroke: none; - ${options.noteFontWeight ? `font-weight: ${options.noteFontWeight};` : ''} } .activation0 { diff --git a/packages/mermaid/src/diagrams/sequence/svgDraw.js b/packages/mermaid/src/diagrams/sequence/svgDraw.js index 2ae3fa9b566..c6cb9d5ef5e 100644 --- a/packages/mermaid/src/diagrams/sequence/svgDraw.js +++ b/packages/mermaid/src/diagrams/sequence/svgDraw.js @@ -7,8 +7,37 @@ import common, { renderKatexSanitized, } from '../common/common.js'; import * as svgDrawCommon from '../common/svgDrawCommon.js'; +import { GLYPH_BAND_HEIGHT, actorLabelHeight, footerBands, headerBands } from './actorBands.js'; export const ACTOR_TYPE_WIDTH = 18 * 2; + +/** + * The stick figure's geometry, in unscaled units measured down from the top of its classic box: + * the head circle reaches `TOP` and the feet reach `BOTTOM`. Under `neo` the figure is scaled to + * fit the shared glyph band (see `actorBands.ts`); under `classic` it draws at these coordinates + * unchanged, as it always has. + */ +const ACTOR_GLYPH_TOP = -5; +const ACTOR_GLYPH_BOTTOM = 60; +const ACTOR_GLYPH_HEIGHT = ACTOR_GLYPH_BOTTOM - ACTOR_GLYPH_TOP; + +/** + * Band geometry for one participant under `neo`; null under every other look, which keeps each + * shape's legacy geometry byte-for-byte. See `actorBands.ts` for the model. `actor.height` is the + * row height here: `calculateActorMargins` gives each actor its own stack height and returns the + * max into `conf.height`, and `addActorRenderingData` (sequenceRenderer.ts) then raises every + * actor to that shared value via `getMax(actor.height || conf.height, conf.height)`. That second + * step is what makes the header datum `actorY + actor.height` one line across the row -- if it is + * ever refactored away, the datum splits per actor and the band model breaks. + */ +const neoBands = (actor, conf, isFooter, actorY) => { + if (conf.look !== 'neo') { + return null; + } + const textHeight = actorLabelHeight(actor, conf); + return isFooter ? footerBands(actorY, textHeight) : headerBands(actorY, actor.height, textHeight); +}; + const TOP_ACTOR_CLASS = 'actor-top'; const BOTTOM_ACTOR_CLASS = 'actor-bottom'; const ACTOR_BOX_CLASS = 'actor-box'; @@ -359,7 +388,7 @@ export const fixLifeLineHeights = (diagram, actors, actorKeys, conf) => { * @param {any} conf - DrawText implementation discriminator object * @param {boolean} isFooter - If the actor is the footer one */ -const drawActorTypeParticipant = function (elem, actor, conf, isFooter, actorIndexMap) { +const drawActorTypeParticipant = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; const centerY = actorY + actor.height; @@ -431,7 +460,7 @@ const drawActorTypeParticipant = function (elem, actor, conf, isFooter, actorInd rectElem.style('fill', paletteColor(bkgColorArray, actorCount)); } if (look === 'neo') { - rectElem.attr('filter', 'url(#drop-shadow)'); + rectElem.attr('filter', `url(#${dropShadowId(diagramId)})`); } actor.rectData = rect; @@ -467,8 +496,10 @@ const drawActorTypeParticipant = function (elem, actor, conf, isFooter, actorInd let height = actor.height; if (rectElem.node) { const bounds = rectElem.node().getBBox(); - actor.height = bounds.height; - height = bounds.height; + if (conf.look !== 'neo') { + actor.height = bounds.height; + height = bounds.height; + } } return height; @@ -482,7 +513,7 @@ const drawActorTypeParticipant = function (elem, actor, conf, isFooter, actorInd * @param {any} conf - DrawText implementation discriminator object * @param {boolean} isFooter - If the actor is the footer one */ -const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorIndexMap) { +const drawActorTypeCollections = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; const centerY = actorY + actor.height; @@ -542,6 +573,11 @@ const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorInd // DRAW STACKED RECTANGLES const offset = 6; + if (conf.look === 'neo') { + // The stack must not cross the datum. Both copies are drawn `offset` shorter, so the shadow + // copy's bottom edge lands exactly on the lifeline start instead of poking below it. + rect.height = actor.height - offset; + } const shadowRect = { ...rect, x: rect.x + (isFooter ? -offset : -offset), @@ -553,7 +589,7 @@ const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorInd actor.rectData = rect; if (look === 'neo') { - g.attr('filter', 'url(#drop-shadow)'); + g.attr('filter', `url(#${dropShadowId(diagramId)})`); } const actorCount = actorIndexMap.get(actor.name) ?? 0; @@ -587,8 +623,10 @@ const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorInd let height = actor.height; if (rectElem.node) { const bounds = rectElem.node().getBBox(); - actor.height = bounds.height; - height = bounds.height; + if (conf.look !== 'neo') { + actor.height = bounds.height; + height = bounds.height; + } } if (!isFooter) { @@ -600,7 +638,7 @@ const drawActorTypeCollections = function (elem, actor, conf, isFooter, actorInd return height; }; -const drawActorTypeQueue = function (elem, actor, conf, isFooter, actorIndexMap) { +const drawActorTypeQueue = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; const centerY = actorY + actor.height; @@ -688,7 +726,7 @@ const drawActorTypeQueue = function (elem, actor, conf, isFooter, actorIndexMap) actor.rectData = rect; if (look === 'neo') { - cylinderGroup.attr('filter', 'url(#drop-shadow)'); + cylinderGroup.attr('filter', `url(#${dropShadowId(diagramId)})`); } const actorCount = actorIndexMap.get(actor.name) ?? 0; @@ -725,8 +763,10 @@ const drawActorTypeQueue = function (elem, actor, conf, isFooter, actorIndexMap) const lastPath = cylinderGroup.select('path:last-child'); if (lastPath.node()) { const bounds = lastPath.node().getBBox(); - actor.height = bounds.height; - height = bounds.height; + if (conf.look !== 'neo') { + actor.height = bounds.height; + height = bounds.height; + } } if (!isFooter) { @@ -741,7 +781,8 @@ const drawActorTypeQueue = function (elem, actor, conf, isFooter, actorIndexMap) const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; - const centerY = actorY + 75; + const bands = neoBands(actor, conf, isFooter, actorY); + const centerY = bands ? bands.lifelineStartY : actorY + 75; const { look, theme, themeVariables } = conf; const { bkgColorArray, borderColorArray, actorBorder, actorBkg } = themeVariables; @@ -784,7 +825,7 @@ const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, a rect.class = 'actor'; const cx = actor.x + actor.width / 2; - const cy = actorY + 32; + const cy = bands ? bands.glyphBottomY - 22 : actorY + 32; const r = 22; actElem @@ -806,7 +847,7 @@ const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, a .attr('cx', cx) .attr('cy', cy) .attr('r', r) - .attr('filter', `${look === 'neo' ? 'url(#drop-shadow)' : ''}`); + .attr('filter', `${look === 'neo' ? `url(#${dropShadowId(diagramId)})` : ''}`); // Draw looping arrow as arc path actElem @@ -822,14 +863,16 @@ const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, a actElem.style('stroke', actorBorder); actElem.style('fill', actorBkg); } - const bounds = actElem.node().getBBox(); - actor.height = bounds.height + 2 * (conf?.sequence?.labelBoxHeight ?? 0); + if (!bands) { + const bounds = actElem.node().getBBox(); + actor.height = bounds.height + 2 * (conf?.sequence?.labelBoxHeight ?? 0); + } _drawTextCandidateFunc(conf, hasKatex(actor.description))( actor.description, actElem, rect.x, - rect.y + r + (!isFooter ? 12 : 5), + bands ? bands.labelCenterY - rect.height / 2 : rect.y + r + (!isFooter ? 12 : 5), rect.width, rect.height, { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, @@ -845,10 +888,11 @@ const drawActorTypeControl = function (elem, actor, conf, isFooter, diagramId, a return actor.height; }; -const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap) { +const drawActorTypeEntity = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; - const centerY = actorY + 75; + const bands = neoBands(actor, conf, isFooter, actorY); + const centerY = bands ? bands.lifelineStartY : actorY + 75; const { look, theme, themeVariables } = conf; const { bkgColorArray, borderColorArray } = themeVariables; @@ -873,7 +917,7 @@ const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap rect.class = 'actor'; const cx = actor.x + actor.width / 2; - const cy = actorY + (!isFooter ? 25 : 10); + const cy = bands ? bands.glyphBottomY - 22 : actorY + (!isFooter ? 25 : 10); const r = 22; actElem @@ -893,7 +937,7 @@ const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap .attr('stroke-width', 2); if (look === 'neo') { - actElem.attr('filter', 'url(#drop-shadow)'); + actElem.attr('filter', `url(#${dropShadowId(diagramId)})`); } const actorCount = actorIndexMap.get(actor.name) ?? 0; @@ -902,8 +946,10 @@ const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap actElem.style('fill', paletteColor(bkgColorArray, actorCount)); } - const bounds = actElem.node().getBBox(); - actor.height = bounds.height + (conf?.sequence?.labelBoxHeight ?? 0); + if (!bands) { + const bounds = actElem.node().getBBox(); + actor.height = bounds.height + (conf?.sequence?.labelBoxHeight ?? 0); + } if (!isFooter) { actorCnt++; @@ -928,29 +974,33 @@ const drawActorTypeEntity = function (elem, actor, conf, isFooter, actorIndexMap actor.description, actElem, rect.x, - rect.y + (!isFooter ? 30 : 15), + bands ? bands.labelCenterY - rect.height / 2 : rect.y + (!isFooter ? 30 : 15), rect.width, rect.height, { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, conf ); + if (!bands) { + // Legacy nudges. Under the band model the circle is placed exactly, so any leftover translate + // would reintroduce the stagger the model exists to remove -- getBBox does not see transforms, + // which is how these hid from every measurement while being plainly visible on screen. + actElem.attr('transform', `translate(${0}, ${isFooter ? r : r / 2 - 5})`); + } if (!isFooter) { - actElem.attr('transform', `translate(${0}, ${r / 2 - 5})`); actElem.attr('data-et', 'participant'); actElem.attr('data-type', 'entity'); actElem.attr('data-id', actor.name); - } else { - actElem.attr('transform', `translate(${0}, ${r})`); } return actor.height; }; -const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexMap) { +const drawActorTypeDatabase = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; - const centerY = actorY + actor.height + 2 * conf.boxTextMargin; + const bands = neoBands(actor, conf, isFooter, actorY); + const centerY = bands ? bands.lifelineStartY : actorY + actor.height + 2 * conf.boxTextMargin; const { theme, themeVariables, look } = conf; const { bkgColorArray, borderColorArray, actorBorder } = themeVariables; @@ -1008,20 +1058,25 @@ const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexM rect.class = cssclass; rect.name = actor.name; - // Cylinder dimensions + // Cylinder dimensions. The width stays proportional, but under the band model the height is + // fixed: `width / 3` let a long participant name make the icon taller, and the icon's height + // must not be able to move anything below it. rect.x = actor.x; rect.y = actorY; const w = rect.width / 3; - const h = rect.width / 3; const rx = w / 2; const ry = rx / (2.5 + w / 50); + const h = bands ? GLYPH_BAND_HEIGHT : rect.width / 3; + // Vertical anchor: the drawn cylinder spans cylinderY + ry .. cylinderY + h + ry after the + // translate below, so this puts its bottom on the glyph band's bottom edge. + const cylinderY = bands ? bands.glyphBottomY - h - ry : rect.y; // Cylinder base group const cylinderGroup = g.append('g'); cylinderGroup.attr('class', cssclass); const d = ` - M ${rect.x},${rect.y + ry} + M ${rect.x},${cylinderY + ry} a ${rx},${ry} 0 0 0 ${w},0 a ${rx},${ry} 0 0 0 -${w},0 l 0,${h - 2 * ry} @@ -1031,7 +1086,7 @@ const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexM // Draw the main cylinder body cylinderGroup.append('path').attr('d', d); if (look === 'neo') { - cylinderGroup.attr('filter', 'url(#drop-shadow)'); + cylinderGroup.attr('filter', `url(#${dropShadowId(diagramId)})`); } const actorCount = actorIndexMap.get(actor.name) ?? 0; if (COLOR_THEMES.has(theme)) { @@ -1048,17 +1103,19 @@ const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexM actor.description, g, rect.x, - rect.y + 35, + bands ? bands.labelCenterY - rect.height / 2 : rect.y + 35, rect.width, rect.height, { class: `actor ${ACTOR_BOX_CLASS}` }, conf ); - const lastPath = cylinderGroup.select('path:last-child'); - if (lastPath.node()) { - const bounds = lastPath.node().getBBox(); - actor.height = bounds.height + (conf.sequence.labelBoxHeight ?? 0); + if (!bands) { + const lastPath = cylinderGroup.select('path:last-child'); + if (lastPath.node()) { + const bounds = lastPath.node().getBBox(); + actor.height = bounds.height + (conf.sequence.labelBoxHeight ?? 0); + } } if (!isFooter) { @@ -1070,11 +1127,13 @@ const drawActorTypeDatabase = function (elem, actor, conf, isFooter, actorIndexM return actor.height; }; -const drawActorTypeBoundary = function (elem, actor, conf, isFooter, actorIndexMap) { +const drawActorTypeBoundary = function (elem, actor, conf, isFooter, diagramId, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; - const centerY = actorY + 80; + const bands = neoBands(actor, conf, isFooter, actorY); + const centerY = bands ? bands.lifelineStartY : actorY + 80; const radius = 22; + const iconCenterY = bands ? bands.glyphBottomY - radius : actorY + 12; const line = elem.append('g').lower(); const { look, theme, themeVariables } = conf; const { bkgColorArray, borderColorArray, actorBorder } = themeVariables; @@ -1119,26 +1178,26 @@ const drawActorTypeBoundary = function (elem, actor, conf, isFooter, actorIndexM .append('line') .attr('id', 'actor-man-torso' + actorCnt) .attr('x1', actor.x + actor.width / 2 - radius * 2.5) - .attr('y1', actorY + 12) + .attr('y1', iconCenterY) .attr('x2', actor.x + actor.width / 2 - 15) - .attr('y2', actorY + 12); + .attr('y2', iconCenterY); actElem .append('line') .attr('id', 'actor-man-arms' + actorCnt) .attr('x1', actor.x + actor.width / 2 - radius * 2.5) - .attr('y1', actorY + 2) // starting Y + .attr('y1', iconCenterY - 10) // starting Y .attr('x2', actor.x + actor.width / 2 - radius * 2.5) - .attr('y2', actorY + 22); // ending Y (26px long, adjust as needed) + .attr('y2', iconCenterY + 10); // ending Y actElem .append('circle') .attr('cx', actor.x + actor.width / 2) - .attr('cy', actorY + 12) + .attr('cy', iconCenterY) .attr('r', radius); if (look === 'neo') { - actElem.attr('filter', 'url(#drop-shadow)'); + actElem.attr('filter', `url(#${dropShadowId(diagramId)})`); } const actorCount = actorIndexMap.get(actor.name) ?? 0; @@ -1148,21 +1207,26 @@ const drawActorTypeBoundary = function (elem, actor, conf, isFooter, actorIndexM } else { actElem.style('stroke', actorBorder); } - const bounds = actElem.node().getBBox(); - actor.height = bounds.height + (conf.sequence.labelBoxHeight ?? 0); + if (!bands) { + const bounds = actElem.node().getBBox(); + actor.height = bounds.height + (conf.sequence.labelBoxHeight ?? 0); + } _drawTextCandidateFunc(conf, hasKatex(actor.description))( actor.description, actElem, rect.x, - rect.y + 15, + bands ? bands.labelCenterY - rect.height / 2 : rect.y + 15, rect.width, rect.height, { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, conf ); - actElem.attr('transform', `translate(0,${radius / 2 + 10})`); + if (!bands) { + // Legacy nudge; see the note in drawActorTypeEntity. + actElem.attr('transform', `translate(0,${radius / 2 + 10})`); + } if (!isFooter) { actElem.attr('data-et', 'participant'); @@ -1176,8 +1240,9 @@ const drawActorTypeBoundary = function (elem, actor, conf, isFooter, actorIndexM const drawActorTypeActor = function (elem, actor, conf, isFooter, actorIndexMap) { const actorY = isFooter ? actor.stopy : actor.starty; const center = actor.x + actor.width / 2; - const centerY = actorY + 80; - const { look, theme, themeVariables } = conf; + const bands = neoBands(actor, conf, isFooter, actorY); + const centerY = bands ? bands.lifelineStartY : actorY + 80; + const { theme, themeVariables } = conf; const { bkgColorArray, borderColorArray, actorBorder } = themeVariables; const line = elem.append('g').lower(); @@ -1214,58 +1279,59 @@ const drawActorTypeActor = function (elem, actor, conf, isFooter, actorIndexMap) actElem.attr('data-et', 'participant').attr('data-type', 'actor').attr('data-id', actor.name); } - // Scaling the stickman - const scale = look === 'neo' ? 0.5 : 1; - - // Adjusting stickman to maintain the same position - const adjustedActorY = look === 'neo' ? actorY + (1 - scale) * 30 : actorY; // Adjust for the torso and head shift + // Under the band model the figure is scaled to fill the shared glyph band, feet on its bottom + // edge, the same size as the round icons beside it. `classic` draws the legacy coordinates. + const glyphScale = bands ? GLYPH_BAND_HEIGHT / ACTOR_GLYPH_HEIGHT : 1; + const gy = (offset) => + bands ? bands.glyphBottomY - (ACTOR_GLYPH_BOTTOM - offset) * glyphScale : actorY + offset; + const gx = (offset) => center + offset * glyphScale; actElem .append('line') .attr('id', 'actor-man-torso' + actorCnt) .attr('x1', center) - .attr('y1', adjustedActorY + 25 * scale) + .attr('y1', gy(25)) .attr('x2', center) - .attr('y2', adjustedActorY + 45 * scale); + .attr('y2', gy(45)); actElem .append('line') .attr('id', 'actor-man-arms' + actorCnt) - .attr('x1', center - (ACTOR_TYPE_WIDTH / 2) * scale) - .attr('y1', adjustedActorY + 33 * scale) - .attr('x2', center + (ACTOR_TYPE_WIDTH / 2) * scale) - .attr('y2', adjustedActorY + 33 * scale); + .attr('x1', gx(-ACTOR_TYPE_WIDTH / 2)) + .attr('y1', gy(33)) + .attr('x2', gx(ACTOR_TYPE_WIDTH / 2)) + .attr('y2', gy(33)); actElem .append('line') - .attr('x1', center - (ACTOR_TYPE_WIDTH / 2) * scale) - .attr('y1', adjustedActorY + 60 * scale) + .attr('x1', gx(-ACTOR_TYPE_WIDTH / 2)) + .attr('y1', gy(60)) .attr('x2', center) - .attr('y2', adjustedActorY + 45 * scale); + .attr('y2', gy(45)); actElem .append('line') .attr('x1', center) - .attr('y1', adjustedActorY + 45 * scale) - .attr('x2', center + (ACTOR_TYPE_WIDTH / 2 - 2) * scale) - .attr('y2', adjustedActorY + 60 * scale); + .attr('y1', gy(45)) + .attr('x2', gx(ACTOR_TYPE_WIDTH / 2 - 2)) + .attr('y2', gy(60)); const circle = actElem.append('circle'); circle.attr('cx', actor.x + actor.width / 2); - circle.attr('cy', adjustedActorY + 10 * scale); - circle.attr('r', 15 * scale); - circle.attr('width', actor.width * scale); - circle.attr('height', actor.height * scale); + circle.attr('cy', gy(10)); + circle.attr('r', 15 * glyphScale); + circle.attr('width', actor.width); + circle.attr('height', actor.height); - // Get the bounds of the stickman after scaling - const bounds = actElem.node().getBBox(); - actor.height = bounds.height; + if (!bands) { + // Classic reports the glyph's fixed extent, as it always measured out to. + actor.height = ACTOR_GLYPH_HEIGHT; + } - // // Adjust the rect to match the scaled stickman const rect = svgDrawCommon.getNoteRect(); rect.x = actor.x; - rect.y = adjustedActorY; // Use adjustedActorY for proper alignment + rect.y = actorY; rect.fill = '#eaeaea'; - rect.width = actor.width; // Scale the width - rect.height = actor.height / scale; // Use the updated height from bounds + rect.width = actor.width; + rect.height = actor.height; rect.class = 'actor'; rect.rx = 3; rect.ry = 3; @@ -1282,7 +1348,7 @@ const drawActorTypeActor = function (elem, actor, conf, isFooter, actorIndexMap) actor.description, actElem, rect.x, - adjustedActorY + 35 * scale - (look === 'neo' ? 10 : 0), + bands ? bands.labelCenterY - rect.height / 2 : actorY + 35, rect.width, rect.height, { class: `actor ${ACTOR_MAN_FIGURE_CLASS}` }, @@ -1311,9 +1377,23 @@ export const drawActor = async function ( case 'actor': return await drawActorTypeActor(elem, actor, conf, isFooter, resolvedActorIndexMap); case 'participant': - return await drawActorTypeParticipant(elem, actor, conf, isFooter, resolvedActorIndexMap); + return await drawActorTypeParticipant( + elem, + actor, + conf, + isFooter, + diagramId, + resolvedActorIndexMap + ); case 'boundary': - return await drawActorTypeBoundary(elem, actor, conf, isFooter, resolvedActorIndexMap); + return await drawActorTypeBoundary( + elem, + actor, + conf, + isFooter, + diagramId, + resolvedActorIndexMap + ); case 'control': return await drawActorTypeControl( elem, @@ -1324,13 +1404,41 @@ export const drawActor = async function ( resolvedActorIndexMap ); case 'entity': - return await drawActorTypeEntity(elem, actor, conf, isFooter, resolvedActorIndexMap); + return await drawActorTypeEntity( + elem, + actor, + conf, + isFooter, + diagramId, + resolvedActorIndexMap + ); case 'database': - return await drawActorTypeDatabase(elem, actor, conf, isFooter, resolvedActorIndexMap); + return await drawActorTypeDatabase( + elem, + actor, + conf, + isFooter, + diagramId, + resolvedActorIndexMap + ); case 'collections': - return await drawActorTypeCollections(elem, actor, conf, isFooter, resolvedActorIndexMap); + return await drawActorTypeCollections( + elem, + actor, + conf, + isFooter, + diagramId, + resolvedActorIndexMap + ); case 'queue': - return await drawActorTypeQueue(elem, actor, conf, isFooter, resolvedActorIndexMap); + return await drawActorTypeQueue( + elem, + actor, + conf, + isFooter, + diagramId, + resolvedActorIndexMap + ); } }; @@ -1652,12 +1760,14 @@ export const insertArrowCrossHead = function (elem, id) { // this is actual shape for arrowhead }; -export const insertDropShadow = function (elem, conf) { +export const dropShadowId = (diagramId) => (diagramId ? `${diagramId}-drop-shadow` : 'drop-shadow'); + +export const insertDropShadow = function (elem, conf, diagramId) { const { theme } = conf; elem .append('defs') .append('filter') - .attr('id', 'drop-shadow') + .attr('id', dropShadowId(diagramId)) .attr('height', '130%') .attr('width', '130%') .append('feDropShadow') diff --git a/packages/mermaid/src/diagrams/state/dataFetcher.ts b/packages/mermaid/src/diagrams/state/dataFetcher.ts index 8b4bf27a358..d35ec952742 100644 --- a/packages/mermaid/src/diagrams/state/dataFetcher.ts +++ b/packages/mermaid/src/diagrams/state/dataFetcher.ts @@ -41,6 +41,58 @@ const nodeDb = new Map(); let graphItemCount = 0; // used to construct ids, etc. +// Next palette slot to hand out, and the slot each container ended up with. Both are +// per-render and cleared by `reset()` alongside `nodeDb`. +let nextColorIndex = 0; +const containerColorIndex = new Map(); + +/** + * Palette slot for a container. + * + * `dataFetcher` recurses depth-first and takes a slot as it inserts each container, so the + * numbering is a pre-order walk of the containment tree -- the same order flowchart gives + * its subgraphs, and the reason a nested composite never shares its parent's colour. + * + * Concurrency regions are the exception: they reuse their parent's slot rather than taking + * one. A `--` divider splits one composite into regions that `stateDb.docTranslator` models + * as sibling `divider` containers, and those are synthetic -- the author wrote one + * composite, and the trailing region does not even get a stable id. Giving them a colour of + * their own said there were several composites, and spent slots on containers nobody wrote, + * so adding a `--` silently recoloured every composite after it. Reusing the parent's slot + * says what is true: one composite, drawn in parts. + * + * It also carries the opt-out down for free. A container the author has styled resolves to + * `undefined`, and its regions now inherit that, so they stay unpainted with it. Left to + * take their own slot they were painted from the palette while the composite around them + * was painted by the author -- the same one-container-two-sources split `userStyled` exists + * to prevent, one level down, and out of reach of the author's `.name > *` rule because the + * regions render in a sibling layer. + * + * Only containers are numbered. Plain states keep the uniform look for the same reason + * flowchart leaves its nodes alone -- a state is a step, not a participant, and `classDef` + * / `style` is how colour carries meaning there. + */ +const colorSlotFor = ( + shape: string, + itemId: string, + parent: StateStmt | undefined, + userStyled: boolean +): number | undefined => { + // `has`, not a truthy check: a styled parent records `undefined` deliberately, and that + // is exactly the value its regions have to inherit. + if (shape === SHAPE_DIVIDER && parent?.id !== undefined && containerColorIndex.has(parent.id)) { + const inherited = containerColorIndex.get(parent.id); + containerColorIndex.set(itemId, inherited); + return inherited; + } + // Everything else takes the next slot. A `--` at the top level lands here too: there is + // no composite to belong to, so it is its own container. + const slot = nextColorIndex++; + const effective = userStyled ? undefined : slot; + containerColorIndex.set(itemId, effective); + return effective; +}; + /** * Create a standard string for the dom ID of an item. * If a type is given, insert that before the counter, preceded by the type spacer @@ -202,6 +254,18 @@ export const dataFetcher = ( const style = getStylesFromDbInfo(dbState); const config = getConfig(); + /** + * Whether the author has styled this state themselves, via `classDef`/`class` or a + * `style` statement. Such a container opts out of the palette entirely. + * + * It has to be all-or-nothing. A state `classDef` compiles to `.name > * { ... }` with + * `!important`, and the composite's title strip is *not* a direct child -- it sits inside + * an intermediate `g` -- so the author's rule reaches the body rect but not the title. + * Leaving the slot stamped therefore paints the two halves of one container from two + * different sources, which is worse than either on its own. + */ + const userStyled = classStr.trim() !== '' || style.length > 0; + log.info('dataFetcher parsedItem', parsedItem, dbState, style); if (itemId !== 'root') { @@ -272,6 +336,10 @@ export const dataFetcher = ( newNode.isGroup = true; newNode.dir = getDir(parsedItem); newNode.shape = parsedItem.type === DIVIDER_TYPE ? SHAPE_DIVIDER : SHAPE_GROUP; + // A styled container still spends its slot, so giving one composite a `classDef` does + // not shift the colour of every composite after it; it simply resolves to + // `undefined` and goes unstamped. See `colorSlotFor`. + newNode.colorIndex = colorSlotFor(newNode.shape, itemId, parent, userStyled); newNode.cssClasses = `${newNode.cssClasses} ${CSS_DIAGRAM_CLUSTER} ${altFlag ? CSS_DIAGRAM_CLUSTER_ALT : ''}`; } @@ -288,6 +356,7 @@ export const dataFetcher = ( domId: stateDomId(itemId, graphItemCount), type: newNode.type, isGroup: newNode.type === 'group', + colorIndex: newNode.colorIndex, padding: 8, rx: 10, ry: 10, @@ -404,4 +473,6 @@ export const dataFetcher = ( export const reset = () => { nodeDb.clear(); graphItemCount = 0; + nextColorIndex = 0; + containerColorIndex.clear(); }; diff --git a/packages/mermaid/src/diagrams/state/stateDb.ts b/packages/mermaid/src/diagrams/state/stateDb.ts index 8c3953ca61b..61368628612 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.ts +++ b/packages/mermaid/src/diagrams/state/stateDb.ts @@ -165,6 +165,8 @@ export interface NodeData { position?: string; description?: string | string[]; labelType?: string; + /** Palette slot for container shapes; see `nextColorSlot` in `dataFetcher.ts`. */ + colorIndex?: number; } export interface Edge { diff --git a/packages/mermaid/src/diagrams/state/stateDiagram-colorIndex.spec.ts b/packages/mermaid/src/diagrams/state/stateDiagram-colorIndex.spec.ts new file mode 100644 index 00000000000..eaeafa3284f --- /dev/null +++ b/packages/mermaid/src/diagrams/state/stateDiagram-colorIndex.spec.ts @@ -0,0 +1,247 @@ +/** + * Container colour slots for state diagrams: `dataFetcher` assigns the slot, `clusters.js` + * stamps it as `data-color-id`, and `state/styles.js` maps it to a border and a title tint. + * + * Two rules have to hold together, and they pull in opposite directions: + * + * 1. Nested composites must differ, or a machine nested three deep reads as one box. + * 2. The concurrency regions of a single composite must match, or one composite split by + * `--` reads as several composites sitting side by side. + * + * Both fail silently -- the diagram still renders, just with the wrong colours -- so pin + * the assignment here rather than relying on a screenshot to notice. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +// @ts-expect-error No types available for JISON +import stateDiagram, { parser } from './parser/stateDiagram.jison'; +import { StateDB } from './stateDb.js'; + +describe('state diagram colour slots', () => { + let stateDb: StateDB; + + beforeEach(() => { + stateDb = new StateDB(2); + parser.yy = stateDb; + stateDiagram.parser.yy = stateDb; + stateDiagram.parser.yy.clear(); + }); + + const parse = (diagram: string) => { + parser.parse(diagram); + return stateDb.getData().nodes; + }; + + const slots = (diagram: string) => + new Map(parse(diagram).map((node) => [node.id, node.colorIndex])); + + /** + * Concurrency regions are matched on shape, not on id. Only the regions that follow a + * `--` keep the `divider-id-N` name; `stateDb.docTranslator` gives the trailing one a + * random id, and an id prefix match would also pick up each region's `_start` child. + */ + const regionSlots = (nodes: { shape: string; parentId?: string; colorIndex?: number }[]) => + nodes.filter((node) => node.shape === 'divider'); + + it('gives each composite its own slot, in containment order', () => { + const byId = slots(`stateDiagram-v2 + [*] --> Boot + state Boot { + [*] --> Firmware + state Kernel { + [*] --> Scheduler + state Drivers { + [*] --> Probe + } + } + } + Boot --> Running + state Running { + [*] --> Serving + } + `); + // Pre-order: a composite is numbered before the composites it contains, so depth is + // what separates the colours rather than declaration order across the whole file. + expect(byId.get('Boot')).toBe(0); + expect(byId.get('Kernel')).toBe(1); + expect(byId.get('Drivers')).toBe(2); + expect(byId.get('Running')).toBe(3); + }); + + it('leaves plain states unslotted, so only containers are painted', () => { + const byId = slots(`stateDiagram-v2 + state Outer { + [*] --> Inner + Inner --> Done + } + `); + expect(byId.get('Outer')).toBe(0); + expect(byId.get('Inner')).toBeUndefined(); + expect(byId.get('Done')).toBeUndefined(); + }); + + it('gives every concurrency region of one composite the same slot', () => { + const nodes = parse(`stateDiagram-v2 + state Active { + [*] --> NumLockOff + -- + [*] --> CapsLockOff + -- + [*] --> ScrollLockOff + } + `); + const byId = new Map(nodes.map((node) => [node.id, node.colorIndex])); + const regions = regionSlots(nodes).map((node) => node.colorIndex); + + // Counted, because two `--` produce three regions: one per separator plus the trailing + // remainder. Asserting only on the distinct values would pass if a region went missing. + expect(regions).toHaveLength(3); + expect(new Set(regions).size).toBe(1); + // ...and it is the composite's own slot, not a fresh one. The regions are synthetic -- + // the author wrote one composite -- so they are drawn as parts of it rather than as + // something with a colour of its own. + expect(byId.get('Active')).toBe(0); + expect(regions[0]).toBe(0); + }); + + it('does not shift later composites when a divider is added', () => { + // The reason regions reuse the parent's slot rather than taking one. Spending slots on + // containers the author never wrote meant adding a `--` recoloured everything after it. + const withoutDivider = slots(`stateDiagram-v2 + state First { + [*] --> A + } + state Second { + [*] --> B + } + `); + stateDb = new StateDB(2); + parser.yy = stateDb; + stateDiagram.parser.yy = stateDb; + stateDiagram.parser.yy.clear(); + const withDivider = slots(`stateDiagram-v2 + state First { + [*] --> A + -- + [*] --> C + } + state Second { + [*] --> B + } + `); + + expect(withoutDivider.get('First')).toBe(withDivider.get('First')); + expect(withoutDivider.get('Second')).toBe(withDivider.get('Second')); + expect(withDivider.get('Second')).toBe(1); + }); + + it("carries a styled composite's opt-out into its concurrency regions", () => { + // The regions render in a sibling layer, so the author's `.pinned > *` rule cannot + // reach them. Were they to keep a palette slot, the composite would be painted by the + // author and its own regions from the palette -- one container, two sources, which is + // the split `userStyled` exists to prevent. + const nodes = parse(`stateDiagram-v2 + classDef pinned fill:#111827,stroke:#F59E0B + state Active { + [*] --> A + -- + [*] --> B + } + class Active pinned + `); + const byId = new Map(nodes.map((node) => [node.id, node.colorIndex])); + expect(byId.get('Active')).toBeUndefined(); + const regions = regionSlots(nodes); + expect(regions).toHaveLength(2); + expect(regions.every((region) => region.colorIndex === undefined)).toBe(true); + }); + + it('does not share one slot between two separately divided composites', () => { + // The shared slot is keyed by parent. Keying it globally, or by nothing at all, would + // paint every concurrency region in the diagram the same colour. + const nodes = parse(`stateDiagram-v2 + state First { + [*] --> A + -- + [*] --> B + } + state Second { + [*] --> C + -- + [*] --> D + } + `); + const byId = new Map(nodes.map((node) => [node.id, node.colorIndex])); + const regions = regionSlots(nodes); + + expect(byId.get('First')).not.toBe(byId.get('Second')); + expect(regions).toHaveLength(4); + // Two composites, two regions each: two distinct region colours, not one and not four. + expect(new Set(regions.map((node) => node.colorIndex)).size).toBe(2); + // And the pairing is by parent, not by declaration order -- each composite's regions + // carry that composite's own slot. + const byParent = new Map>(); + for (const region of regions) { + const key = region.parentId ?? 'root'; + byParent.set(key, (byParent.get(key) ?? new Set()).add(region.colorIndex)); + } + expect([...byParent.values()].map((set) => set.size)).toEqual([1, 1]); + expect(byParent.get('First')).toEqual(new Set([byId.get('First')])); + expect(byParent.get('Second')).toEqual(new Set([byId.get('Second')])); + }); + + it('leaves a composite with its own classDef unstamped, but still spends its slot', () => { + const byId = slots(`stateDiagram-v2 + classDef pinned fill:#111827,stroke:#F59E0B + state First { + [*] --> A + } + state Second { + [*] --> B + } + state Third { + [*] --> C + } + class Second pinned + `); + expect(byId.get('First')).toBe(0); + // Unstamped, so no `[data-color-id]` rule can match it and the author's class -- which + // is emitted `!important` -- is the only thing painting the container. + expect(byId.get('Second')).toBeUndefined(); + // The slot is still consumed: `Third` keeps the colour it would have had anyway, so + // styling one composite does not recolour every composite after it. + expect(byId.get('Third')).toBe(2); + }); + + it('leaves a composite with its own style statement unstamped', () => { + const byId = slots(`stateDiagram-v2 + state First { + [*] --> A + } + state Second { + [*] --> B + } + style Second fill:#111827 + `); + expect(byId.get('First')).toBe(0); + expect(byId.get('Second')).toBeUndefined(); + }); + + it('spends one slot on a composite named twice, so the cycle does not skip', () => { + // `dataFetcher` runs once per relation endpoint, so a composite on both sides of two + // transitions is visited more than once. Taking a slot each time would leave gaps in + // the cycle and shift every later container's colour. + const byId = slots(`stateDiagram-v2 + [*] --> Loop + state Loop { + [*] --> Spin + } + Loop --> Loop + Loop --> Exit + state Exit { + [*] --> Bye + } + `); + expect(byId.get('Loop')).toBe(0); + expect(byId.get('Exit')).toBe(1); + }); +}); diff --git a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js index f854d1703d3..54e6fd16842 100644 --- a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js +++ b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js @@ -19,6 +19,7 @@ vi.mock('../../diagram-api/diagramAPI.js', () => ({ })); vi.mock('../../rendering-util/render.js', () => ({ + getRegisteredLayoutAlgorithm: vi.fn((algorithm) => algorithm), render: vi.fn((data, svg) => { const layoutNode = data.nodes.find((node) => node.id === 'A') ?? data.nodes[0]; if (layoutNode) { diff --git a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts index 8621b6a422f..ea730b3c9ca 100644 --- a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts +++ b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts @@ -2,7 +2,7 @@ import { getConfig } from '../../diagram-api/diagramAPI.js'; import type { DiagramStyleClassDef } from '../../diagram-api/types.js'; import { log } from '../../logger.js'; import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js'; -import { render } from '../../rendering-util/render.js'; +import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js'; import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; import type { LayoutData } from '../../rendering-util/types.js'; import utils from '../../utils.js'; @@ -57,7 +57,9 @@ export const draw = async function (text: string, id: string, _version: string, const svg = getDiagramElement(id, securityLevel); data4Layout.type = diag.type; - data4Layout.layoutAlgorithm = layout; + // Resolve rather than assign: an unregistered layout would otherwise reach `render()` + // and throw, where every other unified renderer falls back to dagre. + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout); // TODO: Should we move these two to baseConfig? These types are not there in StateConfig. diff --git a/packages/mermaid/src/diagrams/state/styles.js b/packages/mermaid/src/diagrams/state/styles.js index 3e1efc738ec..1c69e94240d 100644 --- a/packages/mermaid/src/diagrams/state/styles.js +++ b/packages/mermaid/src/diagrams/state/styles.js @@ -1,5 +1,106 @@ +import { hasPalette, isColorTheme, paletteSlotCount, safeLook } from '../common/colorThemeGate.js'; + +/** + * Cycling per-container colour for composite states and concurrency regions. + * + * Only the containers are painted. A plain state is a step in the machine rather than a + * distinct participant, and `classDef` / `style` is already how colour carries meaning + * there -- the same line flowchart draws between its subgraphs and its nodes. + * + * The treatment follows the swimlane header: the palette's border colour on the outline, + * its background tint behind the title strip, and the theme's own `compositeBackground` + * left on the body. Tinting the body as well would stack tint on tint once composites + * nest, and nesting is exactly where the colours have to stay separable. + * + * `redux-dark-color` ships a border palette and an empty background palette, so on that + * theme the `fill` declarations are omitted entirely and only the outlines take colour -- + * the same outlines-only treatment it gives ER, requirement and sequence. + * + * Not `!important`: a state's own `classDef` / `style` has to keep winning over the theme. + */ +const genColor = (options) => { + const { theme, bkgColorArray, borderColorArray } = options; + if (!isColorTheme(theme, borderColorArray)) { + return ''; + } + // `look` is validated before it reaches the selector -- see `safeLook`. + const look = safeLook(options.look); + const hasBkgColors = hasPalette(bkgColorArray); + let sections = ''; + + // One rule per slot `dataFetcher` can hand out; `stampColorSlot` wraps at the palette + // length, so those are exactly `0 .. borderColorArray.length - 1`. + for (let i = 0; i < paletteSlotCount(borderColorArray); i++) { + const borderColor = borderColorArray[i]; + const tint = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; + sections += ` + + /* The title strip: \`rect.outer\` spans the whole composite and \`rect.inner\` covers + the body, so what stays visible of \`outer\` is the band behind the label. */ + ${slot}.statediagram-cluster rect.outer { + stroke: ${borderColor}; + ${tint} + } + + ${slot}.statediagram-cluster rect.inner { + stroke: ${borderColor}; + } + + /* Concurrency regions. Siblings of one composite share a slot, so a divided composite + reads as one thing split into parts rather than as several composites. */ + ${slot}.statediagram-cluster rect.divider { + stroke: ${borderColor}; + ${tint} + } + + /* handDrawn draws the same container as roughjs shapes rather than plain rects, so it + needs its own rules. \`roundedWithTitle\` and \`divider\` name those groups \`outer\`, + \`inner\` and \`divider\` to match the classic branch, which is what lets these + discriminate -- a bare \`.statediagram-cluster path\` rule reached the body as well and + tinted the whole composite, losing \`compositeBackground\` and diverging from what + classic and neo do. + + roughjs emits two paths per shape and marks them: the filled shape carries + \`stroke="none"\` and the sketched outline carries \`fill="none"\`. Splitting on that is + what keeps \`fill\` off the outline -- a rough outline is open squiggles, not a closed + region, so filling it produces smears -- and keeps \`stroke\` off the fill shape, which + would otherwise gain an edge it was drawn without. */ + ${slot}.statediagram-cluster .outer path[stroke='none'] { + ${tint} + } + + ${slot}.statediagram-cluster .outer path[fill='none'] { + stroke: ${borderColor}; + } + + /* No \`.inner\` rule on purpose. The body shape is left entirely alone under handDrawn, + where a rect's \`inner\` counterpart cannot be recoloured safely: roughjs draws a + hachure fill as *stroked* lines, so its fill paths carry \`fill="none"\` exactly like + the outline and no selector separates them. An \`.inner\` stroke rule therefore + repainted the hatching of every alt composite in the palette colour instead of + leaving it on \`altBackground\`. The container still reads as palette-coloured: the + \`outer\` shape spans the whole composite, so its outline already frames the body. */ + + /* Regions split the same way, which is why \`divider\` fills solid rather than taking + roughjs's default hachure -- see the note on that call. Hatched, both of its paths + carried \`fill="none"\` and these two rules degenerated: the tint matched nothing and + the border rule repainted the hatching. */ + ${slot}.statediagram-cluster .divider path[stroke='none'] { + ${tint} + } + + ${slot}.statediagram-cluster .divider path[fill='none'] { + stroke: ${borderColor}; + } + `; + } + return sections; +}; + const getStyles = (options) => ` +${genColor(options)} defs [id$="-barbEnd"] { fill: ${options.transitionColor}; stroke: ${options.transitionColor}; diff --git a/packages/mermaid/src/diagrams/swimlanes/lanePalette.spec.ts b/packages/mermaid/src/diagrams/swimlanes/lanePalette.spec.ts new file mode 100644 index 00000000000..ee75419a110 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlanes/lanePalette.spec.ts @@ -0,0 +1,114 @@ +/** + * Asserts the shape of the emitted CSS, because a lane renders identically whether a + * declaration was discarded, outranked, or never emitted. + */ +import { describe, expect, it } from 'vitest'; +import themes from '../../themes/index.js'; +import { COLOR_THEMES } from '../common/colorThemeGate.js'; +import swimlanesStyles from './styles.js'; + +const COLOUR_THEMES = [...COLOR_THEMES]; +const PLAIN_THEMES = Object.keys(themes).filter((name) => !COLOR_THEMES.has(name)); + +interface Palette { + borderColorArray?: string[]; + bkgColorArray?: string[]; + clusterBorder: string; +} + +const paletteOf = (themeName: string): Palette => + themes[themeName as keyof typeof themes].getThemeVariables({}) as unknown as Palette; + +const render = (themeName: string, look = 'neo'): string => + swimlanesStyles({ + ...(paletteOf(themeName) as unknown as Record), + theme: themeName, + look, + } as never); + +/** The declaration body of every rule whose selector matches `pattern`. */ +const bodiesMatching = (css: string, pattern: RegExp): string[] => + [...css.matchAll(/([^{}]+){([^{}]*)}/g)] + .filter(([, selector]) => pattern.test(selector)) + .map(([, , body]) => body); + +describe.each(COLOUR_THEMES)('%s lane palette', (themeName) => { + const { borderColorArray, bkgColorArray, clusterBorder } = paletteOf(themeName); + const slots = borderColorArray!.map((_, slot) => slot); + + it.each(slots)('paints both halves of the lane in slot %i', (slot) => { + const css = render(themeName); + const prefix = `\\[data-look="neo"\\]\\[data-color-id="color-${slot}"\\]\\.swimlane\\.cluster`; + + // One rule names both halves, each with the full prefix. + const laneRect = new RegExp( + `${prefix} rect\\.swimlane-title,\\s*${prefix} rect\\.swimlane-body \\{([^}]*)\\}` + ).exec(css); + expect(laneRect, `no lane rect rule for slot ${slot}`).not.toBeNull(); + expect(laneRect![1]).toContain(`stroke: ${borderColorArray![slot]};`); + if (bkgColorArray?.length) { + expect(laneRect![1]).toContain(`fill: ${bkgColorArray[slot]};`); + } + + // roughjs draws the hachure fill first, so the outline is the second path. + const laneOutline = new RegExp( + `${prefix} \\.swimlane-title path:nth-of-type\\(2\\), ` + + `${prefix} \\.swimlane-body path:nth-of-type\\(2\\) \\{([^}]*)\\}` + ).exec(css); + expect(laneOutline, `no lane outline rule for slot ${slot}`).not.toBeNull(); + expect(laneOutline![1]).toContain(`stroke: ${borderColorArray![slot]};`); + }); + + it('keeps the generic cluster palette rules away from lanes', () => { + const tails = [...render(themeName).matchAll(/\[data-color-id="color-\d+"]\.cluster([^,{]*)/g)] + .map((match) => match[1]) + .filter((tail) => !tail.startsWith('.swimlane')); + + expect(tails.length).toBeGreaterThan(0); + expect(tails.filter((tail) => !tail.startsWith(':not(.swimlane)'))).toEqual([]); + }); + + it('exempts palette lanes from the !important lane border', () => { + const css = render(themeName); + + expect(css).toContain(`.swimlane.cluster:not([data-color-id]) rect { + stroke: ${clusterBorder} !important; + }`); + // An unscoped form would outrank every lane palette rule. + expect(css).not.toContain('.swimlane.cluster rect {'); + }); + + it('never marks a lane palette rule !important', () => { + // A user's `style` reaches the lane inline and has to keep winning over the theme. + const bodies = bodiesMatching(render(themeName), /\[data-color-id="color-\d+"]\.swimlane/); + + expect(bodies.length).toBeGreaterThan(0); + expect(bodies.filter((body) => body.includes('!important'))).toEqual([]); + }); + + it('emits no empty or undefined declaration', () => { + const bodies = bodiesMatching(render(themeName), /data-color-id="color-\d+"/); + + expect(bodies.length).toBeGreaterThan(0); + for (const body of bodies) { + expect(body).not.toMatch(/:\s*(undefined|NaN)?\s*;/); + } + }); +}); + +/** `redux-dark-color` ships no background palette, so the rule must be absent there. */ +it('emits the handDrawn title fill rule only where a background palette exists', () => { + expect(paletteOf('redux-color').bkgColorArray?.length).toBeGreaterThan(0); + expect(paletteOf('redux-dark-color').bkgColorArray ?? []).toHaveLength(0); + + expect(render('redux-color')).toContain('.swimlane-title path:first-of-type'); + expect(render('redux-dark-color')).not.toContain('path:first-of-type'); +}); + +it.each(PLAIN_THEMES)('emits no lane palette rules for %s', (themeName) => { + const css = render(themeName); + + expect(css).not.toContain('data-color-id="color-'); + // The override still ships: nothing stamps the attribute its exemption keys on. + expect(css).toContain('.swimlane.cluster:not([data-color-id]) rect'); +}); diff --git a/packages/mermaid/src/diagrams/swimlanes/styles.ts b/packages/mermaid/src/diagrams/swimlanes/styles.ts index 441742eb5a1..8e6689f165f 100644 --- a/packages/mermaid/src/diagrams/swimlanes/styles.ts +++ b/packages/mermaid/src/diagrams/swimlanes/styles.ts @@ -11,10 +11,13 @@ import type { FlowChartStyleOptions } from '../flowchart/styles.js'; * The swimlane cluster shape draws its own lane border, so the generic * `.cluster rect` border is suppressed by matching its stroke to the cluster * background — theme-adaptive, rather than a hardcoded colour. + * + * The `!important` only outranks `[data-look="neo"].cluster rect`, which ties with it on + * specificity. Palette lanes are exempt because it would beat them too. */ const getStyles = (options: FlowChartStyleOptions): string => `${getFlowchartStyles(options)} - .swimlane.cluster rect { + .swimlane.cluster:not([data-color-id]) rect { stroke: ${options.clusterBorder} !important; } [data-look="neo"].cluster rect { diff --git a/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.spec.ts b/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.spec.ts index 3bdd1b65571..697ade39795 100644 --- a/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.spec.ts @@ -1,12 +1,20 @@ +/** + * Swimlanes differ from flowcharts only in the layout engine, so `layout: swimlane` is the + * diagram type. It is a schema default, not forced by `init`, so an override can reach it. + */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getConfig, - getUserDefinedConfig, reset, saveConfigFromInitialize, + setDiagramConfigScope, setSiteConfig, } from '../../config.js'; -import { diagram } from './swimlanesDiagram.js'; +import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; +import { mermaidAPI } from '../../mermaidAPI.js'; + +const SWIMLANE = 'swimlane-beta TD\n A --> B'; +const FLOWCHART = 'flowchart TD\n A --> B'; const resetConfig = () => { saveConfigFromInitialize({}); @@ -14,25 +22,42 @@ const resetConfig = () => { reset(); }; +const layoutFor = async (text: string) => { + const { diagramType } = await mermaidAPI.parse(text); + // Scope is bounded to the parse; re-establish it to read back the resolution it performed. + setDiagramConfigScope(diagramType); + const { layout } = getConfig(); + setDiagramConfigScope(undefined); + return layout; +}; + describe('swimlanesDiagram', () => { - beforeEach(resetConfig); + beforeEach(() => { + addDiagrams(); + resetConfig(); + }); afterEach(resetConfig); - it('defaults the shared flowchart renderer to the swimlane layout', () => { - expect(getUserDefinedConfig().layout).toBeUndefined(); - - diagram.init?.(getConfig()); + it('defaults the shared flowchart renderer to the swimlane layout', async () => { + expect(await layoutFor(SWIMLANE)).toBe('swimlane'); + }); - expect(getConfig().layout).toBe('swimlane'); + it('leaves plain flowcharts on the global default layout', async () => { + expect(await layoutFor(FLOWCHART)).toBe('dagre'); }); - it('keeps an explicit layout override', () => { - saveConfigFromInitialize({ layout: 'dagre' }); - setSiteConfig({ layout: 'dagre' }); - reset(); + it('keeps an explicit global layout override', async () => { + mermaidAPI.initialize({ layout: 'dagre' }); + expect(await layoutFor(SWIMLANE)).toBe('dagre'); + }); - diagram.init?.(getConfig()); + it('keeps a diagram-scoped layout override', async () => { + // `init` used to override this too, so swimlanes could not be moved off the engine. + mermaidAPI.initialize({ swimlane: { layout: 'dagre' } }); + expect(await layoutFor(SWIMLANE)).toBe('dagre'); + }); - expect(getConfig().layout).toBe('dagre'); + it('keeps a layout set in the diagram frontmatter', async () => { + expect(await layoutFor(`---\nconfig:\n layout: dagre\n---\n${SWIMLANE}`)).toBe('dagre'); }); }); diff --git a/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.ts b/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.ts index 6b754bcd91c..98b568f016a 100644 --- a/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.ts +++ b/packages/mermaid/src/diagrams/swimlanes/swimlanesDiagram.ts @@ -1,6 +1,6 @@ // Swimlanes is a "layout-variant diagram": it reuses the flowchart parser, DB, -// and renderer wholesale and only swaps in a different layout engine -// (`defaultLayout: 'swimlane'`) plus lane-specific styles. It therefore +// and renderer wholesale and only swaps in a different layout engine (the +// schema's `swimlane.layout` default) plus lane-specific styles. It therefore // deliberately consumes flowchart's public factory `createFlowDiagram` rather // than duplicating the entire flowchart plugin. This is the one sanctioned // exception to the cross-diagram isolation rule documented in diagrams/CLAUDE.md; @@ -8,4 +8,4 @@ import { createFlowDiagram } from '../flowchart/flowDiagram.js'; import swimlanesStyles from './styles.js'; -export const diagram = createFlowDiagram({ defaultLayout: 'swimlane', styles: swimlanesStyles }); +export const diagram = createFlowDiagram({ styles: swimlanesStyles }); diff --git a/packages/mermaid/src/diagrams/timeline/styles.js b/packages/mermaid/src/diagrams/timeline/styles.js index 10b075b7cc1..8c5795ab32d 100644 --- a/packages/mermaid/src/diagrams/timeline/styles.js +++ b/packages/mermaid/src/diagrams/timeline/styles.js @@ -1,16 +1,19 @@ import { darken, lighten, isDark } from 'khroma'; import { getConfig } from './../../config.js'; +import { colorSlotCount, isColorTheme as isPaletteTheme } from '../common/colorThemeGate.js'; const genReduxSections = (options) => { const { theme } = getConfig(); //Required to read the active theme at render time, // since options alone does not expose the theme name needed to switch between redux and classic section generators. const isDarkTheme = theme?.includes('dark'); - // Gate on the palette actually being present, not just on the theme name. `theme` comes - // from global config while `options` is passed in, so the two can disagree -- and with an - // empty palette `borderColorArray[i]` is `undefined`, emitting `stroke: undefined`. Same - // guard as `er/styles.ts` and `requirement/styles.js`. - const isColorTheme = theme?.includes('color') && options.borderColorArray?.length > 0; + // Use the shared gate rather than substring-matching the theme name. It checks both + // halves: that this is a colour theme, and that a palette is actually present. Both + // matter -- `includes('color')` would also match any future theme whose name merely + // contains "color", and it says nothing about the palette, so indexing + // `borderColorArray` off a name-only check threw when the configured theme and the + // passed-in `options` disagreed. + const isColorTheme = isPaletteTheme(theme, options.borderColorArray); const rawSvgId = options.svgId?.replace(/^#/, '') ?? ''; const scopedDropShadow = rawSvgId ? `url(#${rawSvgId}-drop-shadow)` @@ -18,10 +21,13 @@ const genReduxSections = (options) => { let sections = ''; - for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + for (let i = 0; i < colorSlotCount(options.THEME_COLOR_LIMIT); i++) { const sw = `${17 - 3 * i}`; - // Wrap at the palette length rather than indexing raw: the loop runs to - // THEME_COLOR_LIMIT, so a shorter palette would leave the overflow slots undefined. + // Unlike `er`/`requirement`, the bound here is the section count, not the palette + // length: timeline numbers `.section-N` classes, which are not `data-color-id` slots + // and so are not bounded by what anything stamps. The palette therefore wraps across + // however many sections exist -- indexing raw would leave the overflow sections + // undefined. const slot = isColorTheme ? options.borderColorArray[i % options.borderColorArray.length] : undefined; @@ -84,8 +90,13 @@ const genReduxSections = (options) => { const genSections = (options) => { let sections = ''; - for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { - options['lineColor' + i] = options['lineColor' + i] || options['cScaleInv' + i]; + // Bounded the same way as genReduxSections: these loops run on THEME_COLOR_LIMIT + // directly rather than a stamped slot count, and THEME_COLOR_LIMIT is reachable from + // front matter (including `.inf`, which parses to Infinity). See colorThemeGate.ts. + const colorLimit = colorSlotCount(options.THEME_COLOR_LIMIT); + + for (let i = 0; i < colorLimit; i++) { + options['lineColor' + i] = options['lineColor' + i] ?? options['cScaleInv' + i]; if (isDark(options['lineColor' + i])) { options['lineColor' + i] = lighten(options['lineColor' + i], 20); } else { @@ -93,7 +104,7 @@ const genSections = (options) => { } } - for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + for (let i = 0; i < colorLimit; i++) { const sw = '' + (17 - 3 * i); sections += ` .section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${ @@ -144,7 +155,9 @@ const getStyles = (options) => { let gradientSections = ''; // Don't apply gradient styling for neutral theme - it should maintain its grayscale color scheme if (options.useGradient && rawSvgId && options.THEME_COLOR_LIMIT && !isNeutralTheme) { - for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + // Same bound as genSections -- see the comment there. + const gradientColorLimit = colorSlotCount(options.THEME_COLOR_LIMIT); + for (let i = 0; i < gradientColorLimit; i++) { gradientSections += ` .section-${i - 1}[data-look="neo"] rect, .section-${i - 1}[data-look="neo"] path, diff --git a/packages/mermaid/src/diagrams/usecase/styles.ts b/packages/mermaid/src/diagrams/usecase/styles.ts index cea3380226a..f279f811036 100644 --- a/packages/mermaid/src/diagrams/usecase/styles.ts +++ b/packages/mermaid/src/diagrams/usecase/styles.ts @@ -1,3 +1,7 @@ +import * as configApi from '../../config.js'; +import type { DiagramStylesProvider } from '../../diagram-api/types.js'; +import { hasPalette, isColorTheme, paletteSlotCount, safeLook } from '../common/colorThemeGate.js'; + interface UsecaseStyleOptions { actorBkg?: string; actorBorder?: string; @@ -15,9 +19,181 @@ interface UsecaseStyleOptions { primaryColor: string; primaryTextColor: string; titleColor?: string; + theme?: string; + look?: string; + borderColorArray?: string[]; + bkgColorArray?: string[]; + usecaseActorBkg?: string; + usecaseActorBorder?: string; + usecaseBkg?: string; + usecaseBorder?: string; + usecaseBoundaryBkg?: string; + usecaseBoundaryBorder?: string; + usecaseIncludeLine?: string; + usecaseExtendLine?: string; } -const getStyles = (options: UsecaseStyleOptions) => ` +/** + * One colour per kind of element, rather than a slot per element. + * + * These are the values the `role` scheme paints with, and they are the default because + * colour that tracks *type* survives editing: inserting a use case in the middle of a + * diagram leaves every other element's colour alone, so diffs, documentation screenshots + * and visual baselines stay stable. In a use case diagram the shape already encodes the + * type -- stick figure, ellipse, frame -- so rotating hue across ellipses would add a + * second encoding that carries no information, and a fixed set of tokens can be tuned for + * contrast once per theme instead of being a per-instance lottery. + * + * Every fallback ends at the value the stylesheet used before these tokens existed, so a + * theme that sets none of them renders exactly as it did. A user `themeVariables` override + * lands on the options object whether or not the active theme declares the token. + */ +const roleColors = (options: UsecaseStyleOptions) => ({ + actorBkg: options.usecaseActorBkg ?? options.actorBkg ?? options.mainBkg, + actorBorder: options.usecaseActorBorder ?? options.actorBorder ?? options.primaryColor, + bkg: options.usecaseBkg ?? options.mainBkg, + border: options.usecaseBorder ?? options.nodeBorder ?? options.primaryColor, + boundaryBkg: options.usecaseBoundaryBkg ?? options.clusterBkg, + boundaryBorder: options.usecaseBoundaryBorder ?? options.clusterBorder, + includeLine: options.usecaseIncludeLine ?? options.lineColor, + extendLine: options.usecaseExtendLine ?? options.lineColor, +}); + +/** + * Cycling per-item colour under the `redux-color` / `redux-dark-color` themes. Actors, use + * cases and system boundaries all take a slot from one cycle assigned in `usecaseDb`; + * notes and JSON tables are never stamped, so they keep the theme's fixed colours. + * + * Every selector here is scoped to a `usecase-` class rather than a bare `.node`. `usecaseDb` + * hands a slot only to the three roles above, and the shapes stamp only what it assigned, so + * a note or JSON table carries no `data-color-id` to match -- the scoping keeps these rules + * narrow rather than being what makes them miss. + * + * Nothing is `!important`: the shapes put user `classDef` / `style` declarations in an + * inline `style` attribute, which has to keep winning over the theme palette. + */ +const genColor: DiagramStylesProvider = (options) => { + const { theme, bkgColorArray, borderColorArray } = options; + // Both halves of the gate: a colour theme *and* a non-empty palette. An empty palette is + // reachable through a `themeVariables` override, and would otherwise leave every slot + // emitting `stroke: undefined`. + if (!isColorTheme(theme, borderColorArray)) { + return ''; + } + // System boundaries take a palette slot under both schemes; actors and use cases only + // under `rotate`. Read from `getConfig()` rather than `options`, which carries theme + // variables and not the per-diagram config; `requirement` reads its palette the same way. + const rotate = configApi.getConfig().usecase?.colorScheme === 'rotate'; + // `look` is validated before it reaches the selector -- see `safeLook`. + const look = safeLook(options.look); + // Every rule below is scoped to `[data-look="${look}"]`, and one look applies to a whole + // diagram, so this flag decides the rules for exactly the nodes they can match. + const isHandDrawn = look === 'handDrawn'; + const hasBkgColors = hasPalette(bkgColorArray); + let sections = ''; + + // One rule per slot that can actually be stamped. `stampColorSlot` assigns + // `colorIndex % borderColorArray.length`, so deriving the bound from the same length is + // what keeps the emitted rules and the stamped ids from disagreeing. + for (let i = 0; i < paletteSlotCount(borderColorArray); i++) { + const borderColor = borderColorArray[i]; + // The background palette is a separate array that may be shorter, so it still wraps -- + // guarded by `hasBkgColors`, since `i % 0` is NaN and `[][NaN]` is `undefined`. + // `redux-dark-color` is the live no-background case: it colours outlines only. + const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; + + /* System boundaries, in both schemes. A boundary is a container, and numbering the + containers is the one place a counter carries information rather than noise: the slot + says which group a thing belongs to, and it stays put as long as the boundary order + does. Same reasoning, and the same declaration-index numbering, as flowchart + subgraphs. + + Descends into the handDrawn paths as flowchart does, which collapses a handDrawn + boundary into a solid hachure block -- a handDrawn flowchart subgraph under these + themes already renders that way, and diverging here would be the odder result. */ + sections += ` + + & ${slot}.system-boundary rect.boundary-body, + & ${slot}.system-boundary rect.boundary-tab, + & ${slot}.system-boundary .boundary-body path, + & ${slot}.system-boundary .boundary-tab path { + stroke: ${borderColor}; + ${fill} + } + `; + + if (!rotate) { + continue; + } + + sections += ` + + /* Use case bodies -- \`.usecase-element\` covers the ellipse form, the \`[Rect]\` form and + the business variant. + + Element selectors only, never a bare \`path\`. Under the handDrawn look roughjs draws + the body as a *pair* of paths, an outline stroked in the border colour and a hachure + fill stroked in the background colour, with no class to tell them apart. Stroking + both repaints the fill lines as border colour and the shape collapses into a solid + block -- which is what \`.usecase-element path\` did. So handDrawn bodies keep the + theme's uniform colours, exactly as handDrawn flowchart nodes do. */ + & ${slot}.usecase-element ellipse, + & ${slot}.usecase-element rect { + stroke: ${borderColor}; + ${fill} + } + + /* The business marker is a single classed path, so it can be reached safely by name -- + without it the marker keeps the uniform border beside a palette-coloured body. No + \`fill\`: the marker is drawn with \`fill="none"\` and has to stay that way. */ + & ${slot}.usecase-element .usecase-business-marker { + stroke: ${borderColor}; + } + + /* Actor glyphs, mirroring the uniform rule further down. The fill goes on the glyph + group, never on its children, so the hollow variant's own \`fill="none"\` keeps + winning and a hollow actor stays hollow. Same reason as above for not descending + into the handDrawn paths. */ + & ${slot}.usecase-actor .usecase-actor-shape, + & ${slot}.usecase-actor .usecase-actor-hollow, + & ${slot}.usecase-actor .usecase-actor-awesome, + & ${slot}.usecase-actor .usecase-actor-icon { + stroke: ${borderColor}; + ${fill} + } +${ + isHandDrawn + ? '' + : ` + /* The group rule above reaches the glyph by inheritance, which the neo look breaks: it + ships a \`[data-look="neo"].node path { stroke }\` rule that hits the glyph's own paths, + and a value set directly on the child always beats one inherited from the parent, + whatever the parent rule's specificity. So name the children too. + + Emitted for every look *except* handDrawn, where roughjs draws the glyph as an + outline path plus a hachure fill path stroked in the fill colour, indistinguishable + in CSS -- stroking both turns a hollow actor into a solid disc. Deliberately no + \`fill\` either way, so the hollow variant's own \`fill="none"\` keeps winning. */ + & ${slot}.usecase-actor .usecase-actor-glyph path, + & ${slot}.usecase-actor .usecase-actor-glyph circle { + stroke: ${borderColor}; + } +` +} + `; + } + return sections; +}; + +const getStyles: DiagramStylesProvider = (options: UsecaseStyleOptions) => { + const role = roleColors(options); + // Under handDrawn the glyph is a roughjs outline path plus a hachure fill path stroked in + // the fill colour, with nothing in CSS to tell them apart -- so the descendant rule below + // is emitted for every other look only. See `genColor` for the same split. + const isHandDrawn = safeLook(options.look) === 'handDrawn'; + return ` + ${genColor(options)} & .usecase-actor { color: ${options.actorTextColor ?? options.primaryTextColor}; } @@ -26,11 +202,32 @@ const getStyles = (options: UsecaseStyleOptions) => ` & .usecase-actor-hollow, & .usecase-actor-awesome, & .usecase-actor-icon { - fill: ${options.actorBkg ?? options.mainBkg}; - stroke: ${options.actorBorder ?? options.primaryColor}; + fill: ${role.actorBkg}; + stroke: ${role.actorBorder}; stroke-width: 2px; } +${ + isHandDrawn + ? '' + : ` + /* The rule above colours the glyph group and lets its children inherit, which the neo + look breaks: it ships a \`[data-look="neo"].node path { stroke }\` rule that lands on + the glyph's own paths, and a value set directly on a child always beats one inherited + from its parent, whatever the parent rule's specificity. Since neo is the default look, + without this every actor renders in the node border colour rather than the actor + colour the rule above asks for. + + \`.node\` is in the selector to outrank that neo rule rather than tie with it: both + would otherwise be one attribute plus one class plus one element, leaving the winner to + depend on which stylesheet is concatenated last. + Stroke only: the hollow variant's own \`fill="none"\` has to keep winning. */ + & .node.usecase-actor .usecase-actor-glyph path, + & .node.usecase-actor .usecase-actor-glyph circle { + stroke: ${role.actorBorder}; + } +` +} & .usecase-actor .nodeLabel, & .actor-label { color: ${options.actorTextColor ?? options.primaryTextColor}; @@ -44,11 +241,38 @@ const getStyles = (options: UsecaseStyleOptions) => ` & .usecase-element rect, & .usecase-business ellipse, & .usecase-business rect { - fill: ${options.mainBkg}; - stroke: ${options.nodeBorder ?? options.primaryColor}; + fill: ${role.bkg}; + stroke: ${role.border}; stroke-width: 2px; } +${ + isHandDrawn + ? '' + : ` + /* The same interception the actor glyph hits, one element down: neo ships + \`[data-look="neo"].node rect { stroke: nodeBorder }\`, which outranks the plain + \`.usecase-element rect\` above, so a use case written in the \`[Rect]\` form kept the node + border colour while its ellipse siblings took the role colour. An \`\` has no + equivalent neo rule and is already correct; restating it here costs nothing and means + the two forms cannot drift apart again. + + Qualified with \`[data-look]\` *and* \`.node\` to land strictly above that rule rather than + tie with it -- on a tie the later stylesheet would win, which is how neo took this in + the first place. Skipped under handDrawn, where roughjs draws paths and neither element + exists. */ + & [data-look="${safeLook(options.look)}"].node.usecase-element ellipse, + & [data-look="${safeLook(options.look)}"].node.usecase-element rect { + fill: ${role.bkg}; + stroke: ${role.border}; + } + /* The business marker is a \`\`, so it loses to \`[data-look="neo"].node path\` the same + way. No \`fill\`: the marker is drawn with \`fill="none"\` and has to stay that way. */ + & [data-look="${safeLook(options.look)}"].node.usecase-element .usecase-business-marker { + stroke: ${role.border}; + } +` +} & .usecase-element .nodeLabel, & .usecase-label { color: ${options.primaryTextColor}; @@ -62,14 +286,14 @@ const getStyles = (options: UsecaseStyleOptions) => ` & .usecase-business-marker { color: ${options.primaryTextColor}; fill: ${options.primaryTextColor}; - stroke: ${options.nodeBorder ?? options.primaryColor}; + stroke: ${role.border}; } & .system-boundary rect.boundary-body, & .system-boundary rect.boundary-tab, & .system-boundary-package-tab { - fill: ${options.clusterBkg}; - stroke: ${options.clusterBorder}; + fill: ${role.boundaryBkg}; + stroke: ${role.boundaryBorder}; stroke-width: 1px; } @@ -119,6 +343,16 @@ const getStyles = (options: UsecaseStyleOptions) => ` stroke-dasharray: 3; } + /* Include and extend are both dashed, which is a weak distinction at small sizes. The + tokens default to \`lineColor\`, so a theme that does not set them is unchanged. */ + & .relationship-include { + stroke: ${role.includeLine}; + } + + & .relationship-extend { + stroke: ${role.extendLine}; + } + & .relationship.edge-animation-fast, & .relationship.edge-animation-slow { stroke-linecap: round; @@ -160,5 +394,6 @@ const getStyles = (options: UsecaseStyleOptions) => ` stroke: ${options.lineColor}; } `; +}; export default getStyles; diff --git a/packages/mermaid/src/diagrams/usecase/usecase-colorIndex.spec.ts b/packages/mermaid/src/diagrams/usecase/usecase-colorIndex.spec.ts new file mode 100644 index 00000000000..4f229631ae7 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecase-colorIndex.spec.ts @@ -0,0 +1,140 @@ +/** + * `colorIndex` is what drives every palette slot under the `redux-color` / + * `redux-dark-color` themes: `usecaseDb` assigns it, the use case shapes and + * `usecaseSystemBoundary` stamp it as `data-color-id`, and `usecase/styles.ts` maps it to a + * border and fill. + * + * Two counters feed it, and they are not interchangeable. System boundaries are numbered + * from zero in declaration order and take a palette slot under *both* colour schemes, the + * way flowchart subgraphs do -- numbering the containers says which group a thing belongs + * to, which is real information. Actors and use cases share a second cycle that only the + * opt-in `usecase.colorScheme: 'rotate'` consumes; by default they take role colours, which + * `usecaseRoleColors.spec.ts` covers. + * + * The slot is assigned unconditionally, because `getData` has no business knowing which + * theme or scheme is active; the stamp and the rules are what the scheme gates. So these + * assertions hold whatever `colorScheme` is set to. + * + * The failure mode is silent. If the slots stop being assigned, or start being shared, + * every element falls back to `color-0` and a rotating diagram renders in one colour -- + * which looks like a theme problem, not a db problem. So pin the assignment here rather + * than relying on a screenshot to notice. + * + * Unlike class and ER, three different kinds share one cycle here, so the ordering between + * kinds is part of the contract and not just an implementation detail. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { Diagram } from '../../Diagram.js'; +import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; +import { db } from './usecaseDb.js'; + +beforeAll(async () => { + // Registers the use case diagram so `Diagram.fromText` can resolve it. + addDiagrams(); + await Diagram.fromText('usecase-beta\n actor TestActor'); +}); + +const colorIndexById = async (text: string) => { + await Diagram.fromText(text); + return new Map(db.getData().nodes.map((node) => [node.id, node.colorIndex])); +}; + +describe('usecase diagram colour slots', () => { + it('gives actors and use cases slots from one shared cycle', async () => { + const slots = await colorIndexById(`usecase-beta +actor User +actor Admin +Login("Sign in") +Logout("Sign out")`); + + // One counter across kinds: no two elements share a colour until the palette wraps. + expect(slots.get('User')).toBe(0); + expect(slots.get('Admin')).toBe(1); + expect(slots.get('Login')).toBe(2); + expect(slots.get('Logout')).toBe(3); + }); + + it('numbers the actors before the use cases, whatever order they were written in', async () => { + const slots = await colorIndexById(`usecase-beta +actor A +Middle("A use case between the two actors") +actor B`); + + // The two kinds live in separate maps, so the shared cycle runs over the actors first + // and the use cases after them rather than over the source order -- interleaving them + // would need the declaration index carried through the model. Written down because the + // grouping is the contract `usecase.colorScheme: 'rotate'` documents, and a future + // change that merged the two loops would silently recolour every interleaved diagram. + expect(slots.get('A')).toBe(0); + expect(slots.get('B')).toBe(1); + expect(slots.get('Middle')).toBe(2); + }); + + it('numbers system boundaries from zero, on their own counter', async () => { + const slots = await colorIndexById(`usecase-beta +systemBoundary sb1["Payment service"] + actor Clerk + Authorize("Authorize payment") +end +systemBoundary sb2["Shipping"] + Dispatch("Dispatch order") +end +Receipt("Create receipt")`); + + // Boundaries run on their own counter, the way `flowDb` numbers flowchart subgraphs: + // slot N means "the Nth group", which has to hold whatever else the diagram contains. + // Sharing the actor/use case cycle would make the first boundary's colour depend on how + // many actors happened to be declared, which is exactly the instability the counter is + // supposed to avoid here. + expect(slots.get('sb1')).toBe(0); + expect(slots.get('sb2')).toBe(1); + + // ...and the actors and use cases keep their own shared cycle, undisturbed by them. + expect(slots.get('Clerk')).toBe(0); + expect(slots.get('Authorize')).toBe(1); + expect(slots.get('Dispatch')).toBe(2); + expect(slots.get('Receipt')).toBe(3); + }); + + it('keeps boundary slots stable when an actor is inserted before them', async () => { + const withExtraActor = await colorIndexById(`usecase-beta +actor Extra +systemBoundary sb1["Payment service"] + actor Clerk +end +systemBoundary sb2["Shipping"] + Dispatch("Dispatch order") +end`); + + // The point of the separate counter: adding an actor must not recolour the groups. + expect(withExtraActor.get('sb1')).toBe(0); + expect(withExtraActor.get('sb2')).toBe(1); + }); + + it('does not spend a slot on a note', async () => { + const slots = await colorIndexById(`usecase-beta +actor User +note for User "Starts the workflow" +Login("Sign in")`); + + const noteEntry = [...slots.entries()].find(([id]) => id.startsWith('note')); + // A note carries the theme's fixed note colour, so it stays outside the cycle. + expect(noteEntry).toBeDefined(); + expect(noteEntry?.[1]).toBeUndefined(); + expect(slots.get('User')).toBe(0); + expect(slots.get('Login')).toBe(1); + }); + + it('does not spend a slot on a JSON table', async () => { + const slots = await colorIndexById(`usecase-beta +actor User +json Payload@{ "active": true } +Login("Sign in")`); + + // Same reasoning as a note: a JSON table is reference data, not a participant. + expect(slots.has('Payload')).toBe(true); + expect(slots.get('Payload')).toBeUndefined(); + expect(slots.get('User')).toBe(0); + expect(slots.get('Login')).toBe(1); + }); +}); diff --git a/packages/mermaid/src/diagrams/usecase/usecase.docs.spec.ts b/packages/mermaid/src/diagrams/usecase/usecase.docs.spec.ts index 8e1d1411813..8c047aec319 100644 --- a/packages/mermaid/src/diagrams/usecase/usecase.docs.spec.ts +++ b/packages/mermaid/src/diagrams/usecase/usecase.docs.spec.ts @@ -28,19 +28,26 @@ const extractMermaidExamples = (markdown: string): string[] => ); describe('usecase public documentation examples', () => { - jsdomIt('parses and renders every mermaid-example fence from the canonical page', async () => { - const examples = extractMermaidExamples(readDocumentation()); - expect(examples.length).toBeGreaterThan(0); + jsdomIt( + 'parses and renders every mermaid-example fence from the canonical page', + async () => { + const examples = extractMermaidExamples(readDocumentation()); + expect(examples.length).toBeGreaterThan(0); - for (const [index, source] of examples.entries()) { - const id = `usecase-doc-example-${index}`; - try { - const { svg } = await mermaidAPI.render(id, source); - expect(svg, `documentation example ${index + 1}`).toContain(' { const nodes: UsecaseLayoutData['nodes'] = []; const edges: UsecaseLayoutEdge[] = []; + /** + * Two counters, because the two kinds of slot mean different things. + * + * `boundaryColorIndex` numbers the system boundaries from zero in declaration order, the + * way `flowDb` numbers flowchart subgraphs. Here the counter carries real information -- + * which group a thing belongs to -- so boundary N takes palette slot N, and that holds + * under the default colour scheme as well as under `rotate`. It stays stable as long as + * the boundary order does, which is the property that makes a counter defensible for a + * container and not for a leaf. + * + * `colorIndex` numbers actors and use cases together, and is consumed only by the opt-in + * `usecase.colorScheme: 'rotate'`. Sharing one cycle between the two kinds is what stops + * an actor and a use case landing on the same colour and reading as linked. Notes and + * JSON tables are deliberately not counted: a note carries the theme's fixed note colour + * and a JSON table is reference data, so neither is a participant and neither may shift + * the cycle. + * + * Both are assigned unconditionally -- `stampColorSlot` is a no-op for every theme + * without a palette, and `getData` has no business knowing which theme or scheme is + * active. + */ + let colorIndex = 0; + let boundaryColorIndex = 0; + for (const actor of state.actors.values()) { nodes.push({ id: actor.id, @@ -319,6 +343,7 @@ const getData = (): UsecaseLayoutData => { isGroup: false, padding: 10, look: globalConfig.look, + colorIndex: colorIndex++, cssClasses: classNames( 'default', 'usecase-actor', @@ -345,6 +370,7 @@ const getData = (): UsecaseLayoutData => { isGroup: false, padding: useCase.shape === 'ellipse' ? 20 : 10, look: globalConfig.look, + colorIndex: colorIndex++, cssClasses: classNames( 'default', 'usecase-element', @@ -407,6 +433,7 @@ const getData = (): UsecaseLayoutData => { isGroup: true, padding: 20, look: globalConfig.look, + colorIndex: boundaryColorIndex++, cssClasses: classNames( 'default', 'system-boundary', diff --git a/packages/mermaid/src/diagrams/usecase/usecaseRoleColors.spec.ts b/packages/mermaid/src/diagrams/usecase/usecaseRoleColors.spec.ts new file mode 100644 index 00000000000..1a587fb836e --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseRoleColors.spec.ts @@ -0,0 +1,184 @@ +/** + * The use case diagram takes its colours by *role* -- one colour per kind of element -- + * rather than by handing each element a slot from the theme's categorical palette. + * + * That is the default for three reasons, and each has an assertion here: + * + * 1. Stability. A rotating counter binds colour to declaration order, so inserting one + * use case recolours every element after it, wrecking diffs, documentation + * screenshots and visual baselines. Role colour is invariant under insertion, + * reordering and renaming. + * 2. Colour should not compete with shape. The shape already carries the type -- stick + * figure, ellipse, frame -- so rotating hue across ellipses adds a second encoding + * that means nothing, and readers go looking for the meaning anyway. + * 3. Contrast can be tuned once per theme instead of being a per-instance lottery. + * + * `usecase.colorScheme: 'rotate'` is the escape hatch for anyone who wants the variety; + * `paletteCssGeneration.spec.ts` covers the rules it emits. `classDef` / `style` keep + * working under both, which is the real escape hatch for semantic colour. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import * as configApi from '../../config.js'; +import type { MermaidConfig } from '../../config.type.js'; +import themes from '../../themes/index.js'; +import getStyles from './styles.js'; + +const COLOUR_THEMES = [ + 'redux-color', + 'redux-dark-color', +] as const satisfies MermaidConfig['theme'][]; + +const render = ( + theme: MermaidConfig['theme'], + config: Partial = {}, + overrides: Record = {} +) => { + const themeVariables = themes[theme as keyof typeof themes].getThemeVariables({}); + const options = { + ...(themeVariables as unknown as Record), + theme, + look: 'classic', + ...overrides, + }; + configApi.reset(); + configApi.setSiteConfig({ theme, look: 'classic', themeVariables: options, ...config }); + return getStyles(options); +}; + +/** Every rule block keyed by a palette slot, whichever kind of element it targets. */ +const slotRules = (css: string) => [...css.matchAll(/\[data-color-id="color-\d+"]/g)].length; + +/** Slot rules that paint an actor or a use case -- the ones `role` must not emit. */ +const leafSlotRules = (css: string) => + [...css.matchAll(/\[data-color-id="color-\d+"]\.usecase-(?:actor|element)/g)].length; + +/** Slot rules that paint a system boundary -- emitted under both schemes. */ +const boundarySlotRules = (css: string) => + [...css.matchAll(/\[data-color-id="color-\d+"]\.system-boundary/g)].length; + +afterEach(() => { + configApi.reset(); +}); + +describe('usecase role colours', () => { + it.each(COLOUR_THEMES)('gives actors and use cases no palette slot for %s', (theme) => { + // The default is `role`, so a colour theme alone must not start rotating the leaves. + expect(leafSlotRules(render(theme))).toBe(0); + }); + + it.each(COLOUR_THEMES)('still numbers the system boundaries for %s', (theme) => { + // Boundaries are the exception, and the reason is that the counter means something for + // a container: slot N is "the Nth group". Flowchart subgraphs are numbered the same way. + expect(boundarySlotRules(render(theme))).toBeGreaterThan(0); + }); + + it.each(COLOUR_THEMES)('gives actors and use cases a slot for %s when rotating', (theme) => { + // Pinned alongside the assertion above so the two cannot both be satisfied by a + // stylesheet that simply never emits slot rules at all. + expect(leafSlotRules(render(theme, { usecase: { colorScheme: 'rotate' } }))).toBeGreaterThan(0); + }); + + it('emits no slot rules at all on a theme without a palette', () => { + // Including the boundary ones: with no palette there is nothing to number with. + expect(slotRules(render('neutral'))).toBe(0); + expect(slotRules(render('neutral', { usecase: { colorScheme: 'rotate' } }))).toBe(0); + }); + + it('gives actors, use cases and boundaries three distinct role colours', () => { + // The boundary token is the fallback for themes with no palette to number with, so it + // still has to be distinguishable from the other two. + const css = render('redux-color'); + const themeVariables = themes['redux-color'].getThemeVariables({}) as unknown as Record< + string, + string + >; + const roles = [ + themeVariables.usecaseActorBorder, + themeVariables.usecaseBorder, + themeVariables.usecaseBoundaryBorder, + ]; + // Distinct: the whole point of a role token is that the kind is legible from the + // colour, which fails if two kinds share one. + expect(new Set(roles).size).toBe(3); + for (const color of roles) { + expect(css).toContain(color); + } + }); + + it('falls back to the previous colours when a theme declares no role tokens', () => { + // Adding the tokens must not restyle the themes that do not set them. `neutral` is one + // of those, so its use case bodies still resolve to `mainBkg` / `nodeBorder`. + const themeVariables = themes.neutral.getThemeVariables({}) as unknown as Record< + string, + string + >; + const css = render('neutral'); + expect(themeVariables.usecaseBorder).toBeUndefined(); + expect(css).toContain(`fill: ${themeVariables.mainBkg};`); + expect(css).toContain(`stroke: ${themeVariables.nodeBorder};`); + }); + + it('separates include from extend by hue, not only by dash', () => { + const css = render('redux-color'); + const themeVariables = themes['redux-color'].getThemeVariables({}) as unknown as Record< + string, + string + >; + expect(themeVariables.usecaseIncludeLine).not.toBe(themeVariables.usecaseExtendLine); + expect(css).toMatch( + new RegExp(`\\.relationship-include \\{\\s*stroke: ${themeVariables.usecaseIncludeLine};`) + ); + expect(css).toMatch( + new RegExp(`\\.relationship-extend \\{\\s*stroke: ${themeVariables.usecaseExtendLine};`) + ); + }); + + /** + * The neo look -- the default -- ships rules like `[data-look="neo"].node rect` and + * `[data-look="neo"].node path` that land directly on the elements the role rules are + * trying to colour. A plain `.usecase-element rect` selector is one class short of them, + * and on a tie the later stylesheet wins, so the role colour silently lost: a `[Rect]` + * use case kept the node border colour while its ellipse siblings took the role colour. + * + * Asserted on the emitted selectors rather than on rendered pixels, because the failure + * is invisible in any theme where the two colours happen to be close. + */ + describe('outranking the neo look', () => { + const qualified = (css: string, suffix: string) => + css.includes(`[data-look="neo"].node.usecase-element ${suffix}`); + + it('qualifies the use case body rules under neo', () => { + const css = render('redux-color', {}, { look: 'neo' }); + expect(qualified(css, 'rect')).toBe(true); + expect(qualified(css, 'ellipse')).toBe(true); + }); + + it('qualifies the business marker rule under neo', () => { + expect(render('redux-color', {}, { look: 'neo' })).toContain( + '[data-look="neo"].node.usecase-element .usecase-business-marker' + ); + }); + + it('qualifies the actor glyph rule under neo', () => { + // Three classes deep, which already clears `[data-look="neo"].node path`. + expect(render('redux-color', {}, { look: 'neo' })).toContain( + '.node.usecase-actor .usecase-actor-glyph path' + ); + }); + + it('omits the element-qualified rules under handDrawn', () => { + // roughjs draws paths, so there is no `` or `` to qualify -- and the + // glyph rule must stay out entirely, or a hollow actor fills in. + const css = render('redux-color', {}, { look: 'handDrawn' }); + expect(qualified(css, 'rect')).toBe(false); + expect(css).not.toContain('.usecase-actor-glyph path'); + }); + }); + + it('honours a themeVariables override of a role token', () => { + // The tokens are reachable through `themeVariables` even on a theme that never declares + // them, which is what makes them a usable customisation point. + const css = render('neutral', {}, { usecaseBorder: '#123456' }); + expect(css).toContain('stroke: #123456;'); + }); +}); diff --git a/packages/mermaid/src/diagrams/venn/vennPalette.spec.ts b/packages/mermaid/src/diagrams/venn/vennPalette.spec.ts new file mode 100644 index 00000000000..f9a2bd311a3 --- /dev/null +++ b/packages/mermaid/src/diagrams/venn/vennPalette.spec.ts @@ -0,0 +1,117 @@ +/** + * A theme defining no `venn*` renders every circle in one flat `primaryColor`, which is + * what the redux colour themes shipped -- silently, since the fallback is a valid colour. + */ +import { describe, expect, it, vi } from 'vitest'; +import * as configModule from '../../config.js'; +import themes from '../../themes/index.js'; +import type { Diagram } from '../../Diagram.js'; +import { draw } from './vennRenderer.js'; + +/** How many the renderer reads. */ +const VENN_SLOTS = 8; + +const slots = Array.from({ length: VENN_SLOTS }, (_, i) => i); + +const themeVariablesOf = (name: string): Record => + themes[name as keyof typeof themes].getThemeVariables({}) as unknown as Record< + string, + string | string[] | undefined + >; + +const vennColorsOf = (name: string) => + slots.map((i) => themeVariablesOf(name)[`venn${i + 1}`] as string | undefined); + +/** + * Deliberately flat: their `cScale` is uniform grey in `redux` and near-black in the + * others. Listed, so a new theme without venn colours fails the check below. + */ +const NO_VENN_PALETTE = ['neo', 'neo-dark', 'redux', 'redux-dark']; + +const WITH_VENN_PALETTE = Object.keys(themes).filter((name) => !NO_VENN_PALETTE.includes(name)); + +it('accounts for every registered theme', () => { + expect([...NO_VENN_PALETTE, ...WITH_VENN_PALETTE].sort()).toEqual(Object.keys(themes).sort()); +}); + +describe.each(WITH_VENN_PALETTE)('%s venn colours', (name) => { + it('defines every slot the renderer reads', () => { + const colors = vennColorsOf(name); + + expect(colors.filter((color) => typeof color === 'string' && color.length > 0)).toHaveLength( + VENN_SLOTS + ); + }); +}); + +describe.each(NO_VENN_PALETTE)('%s venn colours', (name) => { + it('defines none, and so renders flat by design', () => { + expect(vennColorsOf(name).filter(Boolean)).toEqual([]); + }); +}); + +/** Asserted against the array rather than hex, so retuning the palette needs no edit. */ +describe.each(['redux-color', 'redux-dark-color'])('%s venn palette', (name) => { + it('takes the theme categorical palette', () => { + const palette = themeVariablesOf(name).borderColorArray as string[]; + + expect(palette.length).toBeGreaterThan(0); + expect(vennColorsOf(name)).toEqual(slots.map((i) => palette[i % palette.length])); + }); + + it('gives every slot a distinct colour', () => { + expect(new Set(vennColorsOf(name)).size).toBe(VENN_SLOTS); + }); +}); + +describe('renderer palette fallback', () => { + const createDiagram = () => + ({ + db: { + getConfig: () => ({ padding: 15, useDebugLayout: false }), + getDiagramTitle: () => undefined, + getSubsetData: () => [ + { sets: ['A'], size: 10, label: 'A' }, + { sets: ['B'], size: 10, label: 'B' }, + { sets: ['A', 'B'], size: 2.5, label: 'AB' }, + ], + getTextData: () => [], + getStyleData: () => [], + }, + }) as unknown as Diagram; + + const drawWithTheme = async (themeName: string) => { + document.body.innerHTML = ''; + const spy = vi.spyOn(configModule, 'getConfig'); + spy.mockReturnValue({ + ...configModule.getConfig(), + themeVariables: themeVariablesOf(themeName), + } as never); + + try { + await draw('', 'venn', '1.0', createDiagram()); + } finally { + spy.mockRestore(); + } + + return [...document.querySelectorAll('.venn-circle path')].map( + (path) => (path as SVGPathElement).style.fill + ); + }; + + it('paints each circle its own colour under a palette theme', async () => { + const fills = (await drawWithTheme('redux-color')).filter(Boolean); + + expect(fills.length).toBeGreaterThanOrEqual(2); + expect(new Set(fills).size).toBe(fills.length); + }); + + it('falls back to one colour, not to undefined, without a palette', async () => { + // Pins the fallback, not how it is reached: the renderer guard changes no output. + const fills = (await drawWithTheme('redux')).filter(Boolean); + + expect(fills.length).toBeGreaterThanOrEqual(2); + expect(new Set(fills).size).toBe(1); + expect(fills).not.toContain('undefined'); + }); +}); diff --git a/packages/mermaid/src/diagrams/venn/vennRenderer.ts b/packages/mermaid/src/diagrams/venn/vennRenderer.ts index 5c0575aa992..d6aac8f1435 100644 --- a/packages/mermaid/src/diagrams/venn/vennRenderer.ts +++ b/packages/mermaid/src/diagrams/venn/vennRenderer.ts @@ -123,8 +123,10 @@ export const draw: DrawDefinition = ( const data = d as VennData; const setsKey = stableSetsKey([...data.sets].sort()); const customStyle = styleByKey.get(setsKey); - const baseColor = - customStyle?.fill || themeColors[i % themeColors.length] || themeVariables.primaryColor; + // Empty for themes that define no `venn*`; the flat fallback is intended for those. + // Spelled out because the old route to it was `i % 0` being NaN. + const paletteColor = themeColors.length > 0 ? themeColors[i % themeColors.length] : undefined; + const baseColor = customStyle?.fill || paletteColor || themeVariables.primaryColor; group.classed(`venn-set-${i % 8}`, true); const fillOpacity = customStyle?.['fill-opacity'] ?? 0.1; const strokeColor = customStyle?.stroke || baseColor; diff --git a/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue b/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue index 03544f14b9c..efb206e53e2 100644 --- a/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue +++ b/packages/mermaid/src/docs/.vitepress/theme/Mermaid.vue @@ -17,6 +17,7 @@