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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/usecase-role-colors.md
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 124 additions & 0 deletions docs/syntax/usecase.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,127 @@ 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,
cycling in declaration order the way entity relationship and class diagrams are coloured.
This buys per-element variety at the cost of the stability described above: inserting an
actor or a use case shifts the colour of every actor and use case declared after it.
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:
Expand All @@ -525,6 +646,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
Expand All @@ -540,6 +662,7 @@ config:
nodeSpacing: 60
rankSpacing: 70
diagramPadding: 24
colorScheme: role
useMaxWidth: false
---
usecase-beta
Expand All @@ -562,6 +685,7 @@ config:
nodeSpacing: 60
rankSpacing: 70
diagramPadding: 24
colorScheme: role
useMaxWidth: false
---
usecase-beta
Expand Down
27 changes: 26 additions & 1 deletion e2e/rendering/usecase/usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,12 +480,37 @@ 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,
usecase: { diagramPadding: 24, useMaxWidth: true },
});
});
}

// 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' },
});
});
}
});
22 changes: 22 additions & 0 deletions packages/mermaid/src/config.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2111,6 +2111,28 @@ 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`), cycling in
* declaration order, the way ER entities and class boxes are coloured. This buys
* per-instance variety at the cost of that stability: inserting an element shifts the
* colour of everything declared after it. 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.
Expand Down
5 changes: 5 additions & 0 deletions packages/mermaid/src/config.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const supportedConfig = {
nodeSpacing: 50,
rankSpacing: 50,
diagramPadding: 20,
colorScheme: 'role',
useMaxWidth: true,
} satisfies UsecaseDiagramConfig;

Expand Down Expand Up @@ -69,6 +70,9 @@ 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 });
Expand All @@ -82,6 +86,7 @@ describe('usecase configuration', () => {
'nodeSpacing',
'rankSpacing',
'diagramPadding',
'colorScheme',
]);
});

Expand Down
77 changes: 70 additions & 7 deletions packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,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;
Expand All @@ -47,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;
Expand Down Expand Up @@ -85,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);
};

Expand Down Expand Up @@ -143,8 +170,8 @@ describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => {
// 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 count.
// 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
Expand All @@ -156,8 +183,8 @@ describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => {
// palette length, so they cannot disagree; `colorThemeGate.spec.ts` pins that.
const blocks = paletteBlocks(css);
const strokes = strokesIn(blocks);
expect(blocks).toHaveLength(borderColorArray.length * 2);
expect(strokes).toHaveLength(borderColorArray.length * 2);
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));
});

Expand All @@ -168,7 +195,7 @@ describe.each(SLOT_STYLESHEETS)('%s stylesheet slot rules', (name) => {
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 * 2);
expect(strokes).toHaveLength(borderColorArray.length * RULES_PER_SLOT[name]);
expect(new Set(strokes)).toEqual(new Set(borderColorArray));
});

Expand Down Expand Up @@ -216,3 +243,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="<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');
});
});
Loading
Loading