Skip to content

Commit 2db6952

Browse files
committed
feat(c4): render C4 elements in the c4model.com outline style
An element sits on the theme's surface colour with its identity colour as the border and the label text, as on c4model.com, rather than a solid palette fill with white text. The identity colour is the element's `<type>_bg_color` palette entry shifted until it reads against that surface, so a dark theme gets a light identity on a dark body instead of a dark one on a dark one. The palette-derived colours are emitted by the stylesheet, keyed on the `c4-<type>` class the shape adapter sets, because that is where the theme variables are available. `UpdateElementStyle`'s `$bgColor`, `$borderColor` and `$fontColor` are still emitted inline on the node and so continue to take precedence over all of it. `<type>_border_color` no longer affects rendering: the border is the identity colour derived from `<type>_bg_color`.
1 parent 19563d8 commit 2db6952

8 files changed

Lines changed: 279 additions & 42 deletions

File tree

.changeset/c4-outline-theme.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'mermaid': minor
3+
---
4+
5+
feat(c4): render C4 elements in the c4model.com outline style - the element sits on the theme's
6+
surface colour with its identity colour as the border and label text. The identity colour comes from
7+
the existing per-element `<type>_bg_color` palette, shifted until it reads against that surface, so
8+
dark themes get a light identity on a dark body rather than the other way round. `UpdateElementStyle`
9+
overrides still apply and take precedence.
10+
11+
Two behaviour changes worth noting:
12+
13+
- **`<type>_border_color` no longer affects rendering.** The border is now the element's identity
14+
colour derived from `<type>_bg_color`. The 40 `<type>_border_color` config keys still exist and
15+
still validate, but setting one has no effect.
16+
- Element bodies follow the theme instead of the previous solid palette fill, so existing C4 diagrams
17+
change appearance: a light fill with a coloured outline in light themes, and a dark fill in dark
18+
themes, where they were previously a solid colour with white text.

cypress/integration/rendering/c4/c4.spec.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,27 @@ C4Context
177177
{}
178178
);
179179
});
180+
// The outline look derives the border and label from the palette colour over the theme's
181+
// surface. Without a dark-theme case the snapshots only ever show the light surface, so a
182+
// dark identity colour on a dark body would not be visible here.
183+
it('C4.9 should keep the outline style readable in the dark theme', () => {
184+
imgSnapshotTest(
185+
['default', 'dark'].map(
186+
(theme) => `---
187+
title: theme=${theme}
188+
config:
189+
theme: ${theme}
190+
---
191+
C4Context
192+
Person(customerA, "Banking Customer A", "A customer of the bank.")
193+
System(SystemAA, "Internet Banking System", "Allows customers to view information.")
194+
System_Ext(SystemE, "Mainframe", "Stores all of the core banking information.")
195+
ContainerDb(db, "Database", "SQL Database", "Stores user registration information.")
196+
Rel(customerA, SystemAA, "Uses")
197+
Rel(SystemAA, db, "Reads from and writes to", "JDBC")
198+
`
199+
),
200+
{}
201+
);
202+
});
180203
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// @ts-expect-error Incorrect khroma types
2+
import { luminance } from 'khroma';
3+
import { describe, it, expect } from 'vitest';
4+
import { readableOn } from './c4Colors.js';
5+
6+
const ratio = (one: string, other: string): number => {
7+
const [brighter, darker] = [luminance(one), luminance(other)].sort(
8+
(a: number, b: number) => b - a
9+
);
10+
return (brighter + 0.05) / (darker + 0.05);
11+
};
12+
13+
// The shipped c4 palette, which is what the defaults actually put on screen.
14+
const PALETTE = ['#08427B', '#686868', '#1168BD', '#999999', '#438DD5', '#B3B3B3', '#85BBF0'];
15+
16+
describe('readableOn', () => {
17+
it('leaves a colour alone when it already reads on the background', () => {
18+
// #08427B on white is about 10:1 - nothing to fix.
19+
expect(readableOn('#08427B', '#ffffff')).toBe('#08427B');
20+
});
21+
22+
it('darkens a pale colour on a light background', () => {
23+
const readable = readableOn('#85BBF0', '#ffffff');
24+
25+
expect(readable).not.toBe('#85BBF0');
26+
expect(luminance(readable)).toBeLessThan(luminance('#85BBF0'));
27+
expect(ratio(readable, '#ffffff')).toBeGreaterThanOrEqual(4.5);
28+
});
29+
30+
// The bug this exists to prevent: shifting in a fixed direction darkens the colour
31+
// further into a dark background, which is how the outline look became unreadable
32+
// in the dark theme.
33+
it('lightens a dark colour on a dark background', () => {
34+
const readable = readableOn('#08427B', '#333333');
35+
36+
expect(readable).not.toBe('#08427B');
37+
expect(luminance(readable)).toBeGreaterThan(luminance('#08427B'));
38+
expect(ratio(readable, '#333333')).toBeGreaterThanOrEqual(4.5);
39+
});
40+
41+
it('reaches a readable contrast for every palette colour on both surfaces', () => {
42+
for (const surface of ['#ffffff', '#f4f4f4', '#333333']) {
43+
for (const color of PALETTE) {
44+
expect(ratio(readableOn(color, surface), surface)).toBeGreaterThanOrEqual(4.5);
45+
}
46+
}
47+
});
48+
49+
it('returns a value it cannot parse untouched', () => {
50+
// An unusable config value reaches CSS as-is and is dropped there, rather than
51+
// becoming NaN and painting nothing.
52+
expect(readableOn('not-a-color', '#ffffff')).toBe('not-a-color');
53+
expect(readableOn('#08427B', 'not-a-color')).toBe('#08427B');
54+
});
55+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// @ts-expect-error Incorrect khroma types
2+
import { darken, isDark, isValid, lighten, luminance } from 'khroma';
3+
4+
/** WCAG AA for normal text. */
5+
const CONTRAST_TARGET = 4.5;
6+
7+
/**
8+
* WCAG relative-contrast ratio. khroma's own `contrast` omits the `+ 0.05` terms and
9+
* clamps the result to 10, which saturates for most real colour pairs, so it cannot
10+
* tell an adequate pairing from a comfortable one.
11+
*/
12+
const contrastRatio = (one: string, other: string): number => {
13+
const [brighter, darker] = [luminance(one), luminance(other)].sort((a, b) => b - a);
14+
return (brighter + 0.05) / (darker + 0.05);
15+
};
16+
17+
/**
18+
* A palette colour shifted until it reads against `background`: darkened on a light
19+
* background, lightened on a dark one. Shifting in a fixed direction would push the
20+
* colour towards its own background on one of the two, which is what makes the C4
21+
* outline look legible in a dark theme as well as a light one.
22+
*
23+
* Colours that cannot be parsed are returned untouched, so an unusable config value
24+
* reaches CSS as-is and is dropped there rather than becoming `NaN`.
25+
*/
26+
export const readableOn = (color: string, background: string): string => {
27+
if (!isValid(color) || !isValid(background)) {
28+
return color;
29+
}
30+
const shift = isDark(background) ? lighten : darken;
31+
let readable = color;
32+
// Bounded: 12 steps of 5% covers the palette, and stops rather than looping on a
33+
// colour that has already run into black or white.
34+
for (let step = 0; step < 12 && contrastRatio(readable, background) < CONTRAST_TARGET; step++) {
35+
const next = shift(readable, 5);
36+
if (next === readable) {
37+
break;
38+
}
39+
readable = next;
40+
}
41+
return readable;
42+
};

packages/mermaid/src/diagrams/c4/c4Renderer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ export const drawC4ShapeArray = async function (
288288
// (e.g. anything with opacity) painting at the stale origin.
289289
await Promise.all(
290290
c4Shapes.map(async (c4Shape) => {
291-
const node = buildC4Node(c4Shape, conf, conf.c4ShapePadding, look, conf.width);
291+
const node = buildC4Node(c4Shape, conf.c4ShapePadding, look, conf.width);
292292
node.domId = `${diagramId}-${node.id}`;
293293
const measured = await shapeHandlerFor(node)(diagram, node, renderOptions);
294294
c4Shape.width = node.width ?? conf.width;
@@ -307,7 +307,7 @@ export const drawC4ShapeArray = async function (
307307
// (unified shapes are centred at the origin; legacy x/y is the top-left corner).
308308
await Promise.all(
309309
c4Shapes.map(async (c4Shape) => {
310-
const node = buildC4Node(c4Shape, conf, conf.c4ShapePadding, look, conf.width);
310+
const node = buildC4Node(c4Shape, conf.c4ShapePadding, look, conf.width);
311311
node.domId = `${diagramId}-${node.id}`;
312312
// Needed to properly calculate the intersection points.
313313
node.x = c4Shape.x + c4Shape.width / 2;

packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { C4DiagramConfig } from '../../config.type.js';
21
import type { ShapeID } from '../../rendering-util/rendering-elements/shapes.js';
32
import type { NonClusterNode } from '../../rendering-util/types.js';
43

@@ -129,41 +128,34 @@ export const C4_ELEMENT_TYPES = (
129128
] as const
130129
).flatMap((type) => [type, `external_${type}`] as const);
131130

132-
const C4_ELEMENT_TYPE_SET = new Set<string>(C4_ELEMENT_TYPES);
133-
134-
const isC4ElementType = (value: string): value is (typeof C4_ELEMENT_TYPES)[number] =>
135-
C4_ELEMENT_TYPE_SET.has(value);
136-
137131
/**
138-
* Element colours: the per-element `<type>_bg_color`/`<type>_border_color` config
139-
* palette drives the fill and border, with white text. An explicit per-element
140-
* colour (UpdateElementStyle: $bgColor/$borderColor/$fontColor) overrides it.
132+
* Only the explicit per-element colours from `UpdateElementStyle`
133+
* (`$bgColor`/`$borderColor`/`$fontColor`). The outline look itself - the light fill and
134+
* the palette colour as border and text - comes from the stylesheet, which is where the
135+
* theme variables are available; these are emitted inline so they override it.
141136
*/
142-
const elementCssStyles = (shape: C4ShapeLike, config: C4DiagramConfig): string[] => {
143-
const elementType = shape.typeC4Shape.text;
144-
const fill = shape.bgColor ?? (isC4ElementType(elementType) && config[`${elementType}_bg_color`]);
145-
const stroke =
146-
shape.borderColor ?? (isC4ElementType(elementType) && config[`${elementType}_border_color`]);
137+
const elementCssStyles = (shape: C4ShapeLike): string[] => {
147138
const styles: string[] = [];
148-
if (fill) {
149-
styles.push(`fill:${fill}`);
139+
if (shape.bgColor) {
140+
styles.push(`fill:${shape.bgColor}`);
141+
}
142+
if (shape.borderColor) {
143+
styles.push(`stroke:${shape.borderColor}`);
150144
}
151-
if (stroke) {
152-
styles.push(`stroke:${stroke}`);
145+
if (shape.fontColor) {
146+
styles.push(`color:${shape.fontColor}`);
153147
}
154-
styles.push(`color:${shape.fontColor ?? '#FFFFFF'}`);
155148
return styles;
156149
};
157150

158151
/**
159-
* Converts a legacy C4 shape into a unified-renderer Node. `config` is the c4
160-
* diagram config, whose `<type>_bg_color`/`<type>_border_color` palette drives the fill and border.
161-
* `elementWidth` is the target shape width (`c4.width`); the label helper
162-
* derives its own text-wrapping width from it.
152+
* Converts a legacy C4 shape into a unified-renderer Node. Element colours come from the
153+
* stylesheet, keyed on the `c4-<type>` class this sets; only `UpdateElementStyle`
154+
* overrides travel inline. `elementWidth` is the target shape width (`c4.width`); the
155+
* label helper derives its own text-wrapping width from it.
163156
*/
164157
export const buildC4Node = (
165158
shape: C4ShapeLike,
166-
config: C4DiagramConfig,
167159
padding: number,
168160
look: string,
169161
elementWidth: number
@@ -174,7 +166,7 @@ export const buildC4Node = (
174166
cssClasses.push('c4-external');
175167
}
176168
const nodeShape = resolveNodeShape(shape);
177-
const cssStyles = elementCssStyles(shape, config);
169+
const cssStyles = elementCssStyles(shape);
178170
if (nodeShape === 'rounded' || nodeShape === 'fr-rect') {
179171
// Inline so it wins over the shape's default corner radius.
180172
cssStyles.push('rx:12px', 'ry:12px');

packages/mermaid/src/diagrams/c4/styles.js

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { getConfig } from '../../diagram-api/diagramAPI.js';
22
import { C4_ELEMENT_TYPES } from './c4ShapeAdapter.js';
3+
import { readableOn } from './c4Colors.js';
4+
5+
// The elements each C4 shape draws; `person` contributes both a rect and a circle.
6+
const SHAPE_PARTS = ['rect', 'path', 'circle', 'ellipse', 'line'];
37

48
// Per-element-type font rules from the c4 config (personFontFamily and friends).
59
// Built through the CSSOM so config values are parsed as CSS values: a value
@@ -33,15 +37,44 @@ const elementFontStyles = () => {
3337
.join('\n');
3438
};
3539

36-
const getStyles = (options) =>
37-
`.person {
38-
stroke: ${options.personBorder};
39-
fill: ${options.personBkg};
40+
// The c4model.com outline look: each element type's palette colour becomes its border
41+
// and text - its identity - over the theme's surface colour. Built through the CSSOM for
42+
// the same reason as the font rules, and here rather than inline on the node because the
43+
// theme variables only exist at style-generation time. An `UpdateElementStyle` colour is
44+
// still emitted inline by the shape adapter, which outranks any of this.
45+
const elementColorStyles = (options) => {
46+
const c4 = getConfig().c4 ?? {};
47+
const surface = options.background;
48+
const sheet = new CSSStyleSheet();
49+
for (const type of C4_ELEMENT_TYPES) {
50+
const paletteColor = c4[`${type}_bg_color`];
51+
if (!paletteColor) {
52+
continue;
53+
}
54+
const identity = readableOn(paletteColor, surface);
55+
const parts = SHAPE_PARTS.map((part) => `.c4-shape.c4-${type} ${part}`).join(', ');
56+
const strokeRule = sheet.cssRules[sheet.insertRule(`${parts} {}`, sheet.cssRules.length)];
57+
strokeRule.style.setProperty('stroke', identity);
58+
// Set on the group so the label inherits it and `fill: currentColor` picks it up.
59+
const colorRule =
60+
sheet.cssRules[sheet.insertRule(`.c4-shape.c4-${type} {}`, sheet.cssRules.length)];
61+
colorRule.style.setProperty('color', identity);
4062
}
41-
${elementFontStyles()}
63+
return [...sheet.cssRules]
64+
.filter((rule) => rule.style.length > 0)
65+
.map((rule) => ` ${rule.cssText}`)
66+
.join('\n');
67+
};
68+
69+
const getStyles = (options) =>
70+
`${elementFontStyles()}
71+
${elementColorStyles(options)}
4272
43-
/* The element font colour is set inline per element (default white); the
44-
label text takes it via currentColor. */
73+
${SHAPE_PARTS.map((part) => `.c4-shape ${part}`).join(',\n ')} {
74+
fill: ${options.background};
75+
stroke-width: 2px;
76+
}
77+
/* The identity colour is set on the element group above; the label follows it. */
4578
.c4-shape .label,
4679
.c4-shape .label text {
4780
color: inherit;
@@ -57,14 +90,6 @@ ${elementFontStyles()}
5790
.c4-shape .label .c4-descr {
5891
font-size: 0.82em;
5992
}
60-
.c4-shape .basic,
61-
.c4-shape rect,
62-
.c4-shape path,
63-
.c4-shape circle,
64-
.c4-shape ellipse,
65-
.c4-shape line {
66-
stroke-width: 2px;
67-
}
6893
`;
6994

7095
export default getStyles;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// @ts-expect-error Incorrect khroma types
2+
import { luminance } from 'khroma';
3+
import { describe, it, expect } from 'vitest';
4+
import getStyles from './styles.js';
5+
6+
/** Comments sit above the rules they describe, so they would read as selector text. */
7+
const withoutComments = (css: string): string =>
8+
css
9+
.split('/*')
10+
.map((chunk, index) => (index === 0 ? chunk : chunk.slice(chunk.indexOf('*/') + 2)))
11+
.join('');
12+
13+
/** The generated stylesheet as selector/declaration pairs. */
14+
const rulesOf = (css: string): { selector: string; declarations: string }[] =>
15+
withoutComments(css)
16+
.split('}')
17+
.map((block) => block.split('{'))
18+
.filter((parts) => parts.length === 2)
19+
.map(([selector, declarations]) => ({ selector: selector.trim(), declarations }));
20+
21+
/**
22+
* The value a rule gives one property. `matches` takes the whole selector text, so a
23+
* rule for the element group can be told apart from the rule for its parts.
24+
*/
25+
const valueFor = (
26+
css: string,
27+
matches: (selector: string) => boolean,
28+
property: string
29+
): string => {
30+
const rule = rulesOf(css).find(({ selector }) => matches(selector));
31+
if (!rule) {
32+
throw new Error(`no matching rule in\n${css}`);
33+
}
34+
const declaration = rule.declarations
35+
.split(';')
36+
.map((one) => one.trim())
37+
.find((one) => one.startsWith(`${property}:`));
38+
if (!declaration) {
39+
throw new Error(`no "${property}" on "${rule.selector}"`);
40+
}
41+
return declaration.slice(property.length + 1).trim();
42+
};
43+
44+
const parts = (selectorStart: string) => (selector: string) => selector.startsWith(selectorStart);
45+
const group = (selectorText: string) => (selector: string) => selector === selectorText;
46+
47+
describe('c4 styles', () => {
48+
it('puts the element body on the theme surface', () => {
49+
expect(valueFor(getStyles({ background: '#ffffff' }), parts('.c4-shape rect'), 'fill')).toBe(
50+
'#ffffff'
51+
);
52+
});
53+
54+
it('derives each element type its own identity colour from the palette', () => {
55+
const css = getStyles({ background: '#ffffff' });
56+
57+
// person (#08427B) and container (#438DD5) are different palette entries, so the
58+
// rules must not collapse to a single colour.
59+
expect(valueFor(css, parts('.c4-shape.c4-person rect'), 'stroke')).not.toBe(
60+
valueFor(css, parts('.c4-shape.c4-container rect'), 'stroke')
61+
);
62+
});
63+
64+
// The regression the outline look risks: deriving the identity colour without regard to
65+
// the surface puts a dark border and dark label text on a dark body.
66+
it('lightens the identity colour on a dark surface instead of darkening it', () => {
67+
const person = parts('.c4-shape.c4-person rect');
68+
const onLight = valueFor(getStyles({ background: '#ffffff' }), person, 'stroke');
69+
const onDark = valueFor(getStyles({ background: '#333333' }), person, 'stroke');
70+
71+
expect(luminance(onDark)).toBeGreaterThan(luminance(onLight));
72+
});
73+
74+
it('carries the identity colour on the group so the label inherits it', () => {
75+
const css = getStyles({ background: '#ffffff' });
76+
77+
expect(valueFor(css, group('.c4-shape.c4-person'), 'color')).toBe(
78+
valueFor(css, parts('.c4-shape.c4-person rect'), 'stroke')
79+
);
80+
expect(valueFor(css, parts('.c4-shape .label'), 'fill')).toBe('currentColor');
81+
});
82+
});

0 commit comments

Comments
 (0)