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/docs/syntax/usecase.md b/docs/syntax/usecase.md index 4178872df47..3aa07bbe034 100644 --- a/docs/syntax/usecase.md +++ b/docs/syntax/usecase.md @@ -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: @@ -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 @@ -540,6 +662,7 @@ config: nodeSpacing: 60 rankSpacing: 70 diagramPadding: 24 + colorScheme: role useMaxWidth: false --- usecase-beta @@ -562,6 +685,7 @@ config: nodeSpacing: 60 rankSpacing: 70 diagramPadding: 24 + colorScheme: role useMaxWidth: false --- usecase-beta 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/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 3afc433e008..3bea0a715e0 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -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. diff --git a/packages/mermaid/src/config.usecase.spec.ts b/packages/mermaid/src/config.usecase.spec.ts index 84063ddac29..923df41ee02 100644 --- a/packages/mermaid/src/config.usecase.spec.ts +++ b/packages/mermaid/src/config.usecase.spec.ts @@ -32,6 +32,7 @@ const supportedConfig = { nodeSpacing: 50, rankSpacing: 50, diagramPadding: 20, + colorScheme: 'role', useMaxWidth: true, } satisfies UsecaseDiagramConfig; @@ -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 }); @@ -82,6 +86,7 @@ describe('usecase configuration', () => { 'nodeSpacing', 'rankSpacing', 'diagramPadding', + 'colorScheme', ]); }); diff --git a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts index b294e63b4bc..e57128a2ce0 100644 --- a/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts +++ b/packages/mermaid/src/diagrams/paletteCssGeneration.spec.ts @@ -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; @@ -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; @@ -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); }; @@ -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 @@ -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)); }); @@ -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)); }); @@ -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=""]`, + * 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/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..c31d8aa5494 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecase-colorIndex.spec.ts @@ -0,0 +1,124 @@ +/** + * `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 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/usecaseDb.ts b/packages/mermaid/src/diagrams/usecase/usecaseDb.ts index 2849cc4cea4..b0f4c6f747e 100644 --- a/packages/mermaid/src/diagrams/usecase/usecaseDb.ts +++ b/packages/mermaid/src/diagrams/usecase/usecaseDb.ts @@ -310,6 +310,30 @@ const getData = (): UsecaseLayoutData => { 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/docs/syntax/usecase.md b/packages/mermaid/src/docs/syntax/usecase.md index 5f5072baf0f..85e72c5de03 100644 --- a/packages/mermaid/src/docs/syntax/usecase.md +++ b/packages/mermaid/src/docs/syntax/usecase.md @@ -308,6 +308,90 @@ 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 +``` + +### 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 +``` + +`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: @@ -323,6 +407,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 @@ -338,6 +423,7 @@ config: nodeSpacing: 60 rankSpacing: 70 diagramPadding: 24 + colorScheme: role useMaxWidth: false --- usecase-beta diff --git a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js index 5d0722e1b66..b171a52b3d6 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js @@ -651,8 +651,8 @@ export const getUsecaseSystemBoundaryGeometry = (node, labelBBox) => { const usecaseSystemBoundary = async (parent, node) => { log.info('Creating usecase system boundary for ', node.id, node); const siteConfig = getConfig(); - const { themeVariables, handDrawnSeed } = siteConfig; - const { clusterBkg, clusterBorder } = themeVariables; + const { theme, themeVariables, handDrawnSeed } = siteConfig; + const { clusterBkg, clusterBorder, borderColorArray } = themeVariables; const { labelStyles, nodeStyles } = styles2String(node); const { stylesMap } = compileStyles(node); @@ -667,6 +667,10 @@ const usecaseSystemBoundary = async (parent, node) => { .attr('data-boundary-type', boundaryType) .attr('data-look', node.look); + // Per-container colour slot, as in `rect` above. A boundary is a container, but unlike a + // class namespace it is painted -- `usecase/styles.ts` defines the matching rules. + stampColorSlot(shapeSvg, node.colorIndex, theme, borderColorArray); + const useHtmlLabels = getEffectiveHtmlLabels(siteConfig); const labelEl = shapeSvg.insert('g').attr('class', 'cluster-label system-boundary-title'); const text = await createText(labelEl, node.label, { diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.spec.ts new file mode 100644 index 00000000000..b5b0954e023 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.spec.ts @@ -0,0 +1,91 @@ +import { select } from 'd3'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import * as configApi from '../../../config.js'; +import type { Node } from '../../types.js'; +import { squareRect } from './squareRect.js'; + +const measurementStubs = { + getBBox: () => ({ x: 0, y: 0, width: 40, height: 16 }), + getComputedTextLength: () => 40, +} as const; + +const originalDescriptors = Object.fromEntries( + Object.keys(measurementStubs).map((name) => [ + name, + Object.getOwnPropertyDescriptor(SVGElement.prototype, name), + ]) +); + +beforeAll(() => { + for (const [name, value] of Object.entries(measurementStubs)) { + Object.defineProperty(SVGElement.prototype, name, { configurable: true, value }); + } +}); + +afterAll(() => { + for (const [name, descriptor] of Object.entries(originalDescriptors)) { + if (descriptor) { + Object.defineProperty(SVGElement.prototype, name, descriptor); + } else { + Reflect.deleteProperty(SVGElement.prototype, name); + } + } +}); + +const svg = () => select(document.querySelector('svg')!); + +const rectNode = (extra: Partial = {}) => + ({ + id: 'n1', + domId: 'diagram-n1', + label: 'A node', + labelType: 'text', + shape: 'rect', + isGroup: false, + padding: 8, + cssClasses: 'default', + look: 'classic', + x: 0, + y: 0, + ...extra, + }) as Node; + +beforeEach(() => { + document.body.innerHTML = ''; + configApi.reset(); + configApi.setConfig({ + htmlLabels: false, + flowchart: { htmlLabels: false }, + theme: 'redux-color', + }); +}); + +describe('squareRect colour slot', () => { + /** + * `squareRect` backs the plain `rect` shape for the whole library — notes, JSON tables and + * `classDb`'s synthetic interface node all render through it. Stamping a slot on a node that + * was never assigned one hands every one of them `color-0`, which is inert only for as long + * as no stylesheet emitting `[data-color-id] … rect` rules happens to render a bare rect. + * `er/styles.ts` already emits exactly that selector shape, so keep the stamp keyed to a slot + * the diagram actually assigned. + */ + it('leaves a node with no assigned slot unstamped on a palette theme', async () => { + const shapeSvg = await squareRect(svg(), rectNode()); + + expect(shapeSvg.attr('data-color-id')).toBeNull(); + }); + + it('stamps the assigned slot on a palette theme', async () => { + const shapeSvg = await squareRect(svg(), rectNode({ colorIndex: 3 })); + + expect(shapeSvg.attr('data-color-id')).toBe('color-3'); + }); + + it('stays unstamped on a theme without a palette', async () => { + configApi.setConfig({ theme: 'default' }); + + const shapeSvg = await squareRect(svg(), rectNode({ colorIndex: 3 })); + + expect(shapeSvg.attr('data-color-id')).toBeNull(); + }); +}); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts index 21c6efe4439..6189f182f62 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/squareRect.ts @@ -1,3 +1,5 @@ +import { getConfig } from '../../../diagram-api/diagramAPI.js'; +import { stampColorSlot } from '../../../diagrams/common/colorThemeGate.js'; import type { Node, RectOptions } from '../../types.js'; import type { D3Selection } from '../../../types.js'; import { drawRect } from './drawRect.js'; @@ -14,5 +16,18 @@ export async function squareRect(parent: D3Selecti labelPaddingX: node.labelPaddingX ?? labelPaddingX, labelPaddingY: labelPaddingY, } as RectOptions; - return drawRect(parent, node, options); + const shapeSvg = await drawRect(parent, node, options); + + // Per-item colour slot, for a use case written with the `[Rect]` form. Unlike the + // containers `clusters.js` stamps, this shape backs the plain `rect` for the whole library + // -- notes, JSON tables and `classDb`'s synthetic interface node all reach it -- so stamp + // only where a diagram actually assigned a slot. Stamping unconditionally would hand all of + // those `color-0`, inert only for as long as no stylesheet emitting `[data-color-id] ... rect` + // rules happens to render a bare rect; `er/styles.ts` already emits that selector shape. + if (node.colorIndex !== undefined) { + const { theme, themeVariables } = getConfig(); + stampColorSlot(shapeSvg, node.colorIndex, theme, themeVariables.borderColorArray); + } + + return shapeSvg; } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts index 431aafaada2..818f19c77cb 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts @@ -1,4 +1,6 @@ import rough from 'roughjs'; +import { getConfig } from '../../../diagram-api/diagramAPI.js'; +import { stampColorSlot } from '../../../diagrams/common/colorThemeGate.js'; import type { D3Selection } from '../../../types.js'; import type { Node } from '../../types.js'; import intersect from '../intersect/index.js'; @@ -187,6 +189,10 @@ export async function renderUsecaseActor( label.attr('class', 'label actor-label usecase-actor-label'); + // Per-item colour slot, stamped once here so all four actor variants share it. + const { theme, themeVariables } = getConfig(); + stampColorSlot(shapeSvg, node.colorIndex, theme, themeVariables.borderColorArray); + let stereotypeLabel: D3Selection | undefined; let stereotypeBox: MeasuredBox | undefined; if (node.stereotype) { diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseBusiness.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseBusiness.ts index 1581c36551a..79a9e962b96 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseBusiness.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseBusiness.ts @@ -1,4 +1,6 @@ import rough from 'roughjs'; +import { getConfig } from '../../../diagram-api/diagramAPI.js'; +import { stampColorSlot } from '../../../diagrams/common/colorThemeGate.js'; import type { Bounds, D3Selection, Point } from '../../../types.js'; import type { Node } from '../../types.js'; import intersect from '../intersect/index.js'; @@ -45,6 +47,10 @@ export async function usecaseBusiness( } = await labelHelper(parent, labelNode, getNodeClasses(node, 'usecase-business-shape')); label.attr('class', 'label usecase-label'); + + // Per-item colour slot -- see `usecaseEllipse`. + const { theme, themeVariables } = getConfig(); + stampColorSlot(shapeSvg, node.colorIndex, theme, themeVariables.borderColorArray); let stereotypeLabel: D3Selection | undefined; let stereotypeBox: MeasuredBox | undefined; if (businessNode.stereotype) { diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseEllipse.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseEllipse.ts index c9bcd387a9a..4e577a5d7f3 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseEllipse.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseEllipse.ts @@ -1,4 +1,6 @@ import rough from 'roughjs'; +import { getConfig } from '../../../diagram-api/diagramAPI.js'; +import { stampColorSlot } from '../../../diagrams/common/colorThemeGate.js'; import type { Bounds, D3Selection, Point } from '../../../types.js'; import type { Node } from '../../types.js'; import intersect from '../intersect/index.js'; @@ -13,6 +15,11 @@ export async function usecaseEllipse( node.labelStyle = labelStyles; const { shapeSvg, bbox, halfPadding } = await labelHelper(parent, node, getNodeClasses(node)); + // Per-item colour slot. A no-op unless the active theme carries a palette; `usecase/styles.ts` + // defines the matching `[data-color-id]` rules. + const { theme, themeVariables } = getConfig(); + stampColorSlot(shapeSvg, node.colorIndex, theme, themeVariables.borderColorArray); + // Calculate ellipse dimensions with padding const padding = halfPadding ?? 10; const radiusX = bbox.width / 2 + padding * 2; diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 5bc12e5d902..7f3912e17e5 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -2875,6 +2875,31 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) type: number minimum: 0 default: 20 + colorScheme: + description: | + 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. + type: string + enum: + - role + - rotate + default: role VennDiagramConfig: title: Venn Diagram Config allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] diff --git a/packages/mermaid/src/themes/theme-redux-color.js b/packages/mermaid/src/themes/theme-redux-color.js index 12620ab3b71..06569fd426d 100644 --- a/packages/mermaid/src/themes/theme-redux-color.js +++ b/packages/mermaid/src/themes/theme-redux-color.js @@ -86,6 +86,21 @@ class Theme { '#FFF1F2', //Rose-50 ]; + /* Usecase Diagram variables. + + One colour per kind of element -- see `usecase.colorScheme`. Chosen from this theme's + own palette so the diagram stays on-brand, and kept to three well-separated hues that + can be checked for contrast once rather than per instance. The boundary frame is a + large area, so it takes the lightest treatment and leans on its border. */ + this.usecaseActorBorder = '#A78BFA'; // Violet-400 + this.usecaseActorBkg = '#F5F3FF'; // Violet-50 + this.usecaseBorder = '#2DD4BF'; // Teal-400 + this.usecaseBkg = '#F0FDFA'; // Teal-50 + this.usecaseBoundaryBorder = '#BDBCCC'; + this.usecaseBoundaryBkg = '#FAFAFC'; + this.usecaseIncludeLine = '#38BDF8'; // Sky-400 + this.usecaseExtendLine = '#FB923C'; // Orange-400 + this.filterColor = '#000000'; } updateColors() { diff --git a/packages/mermaid/src/themes/theme-redux-dark-color.js b/packages/mermaid/src/themes/theme-redux-dark-color.js index 0f2f36038ff..a07fb47ee8f 100644 --- a/packages/mermaid/src/themes/theme-redux-dark-color.js +++ b/packages/mermaid/src/themes/theme-redux-dark-color.js @@ -88,6 +88,21 @@ class Theme { this.bkgColorArray = []; + /* Usecase Diagram variables -- the dark counterpart of the light theme's role tokens. + + Borders only. This theme already ships a border palette and an *empty* background + palette, because saturated fills behind light text lose contrast fast in dark mode -- + so the role tokens follow the same rule and set no fills at all. Leaving them unset + lets each one fall back to the value the rest of the theme already uses: `mainBkg` + for actors and use cases, `clusterBkg` for boundaries. Pinning them to one colour + instead flattened the three kinds into a single tone and made a use case body + indistinguishable from the boundary containing it. */ + this.usecaseActorBorder = '#A78BFA'; // Violet-400 + this.usecaseBorder = '#2DD4BF'; // Teal-400 + this.usecaseBoundaryBorder = '#BDBCCC'; + this.usecaseIncludeLine = '#38BDF8'; // Sky-400 + this.usecaseExtendLine = '#FB923C'; // Orange-400 + this.filterColor = '#FFFFFF'; } updateColors() {