Skip to content
Open
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
18 changes: 18 additions & 0 deletions .changeset/c4-outline-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'mermaid': minor
---

feat(c4): render C4 elements in the c4model.com outline style - the element sits on the theme's
surface colour with its identity colour as the border and label text. The identity colour comes from
the existing per-element `<type>_bg_color` palette, shifted until it reads against that surface, so
dark themes get a light identity on a dark body rather than the other way round. `UpdateElementStyle`
overrides still apply and take precedence.

Two behaviour changes worth noting:

- **`<type>_border_color` no longer affects rendering.** The border is now the element's identity
colour derived from `<type>_bg_color`. The 40 `<type>_border_color` config keys still exist and
still validate, but setting one has no effect.
- Element bodies follow the theme instead of the previous solid palette fill, so existing C4 diagrams
change appearance: a light fill with a coloured outline in light themes, and a dark fill in dark
themes, where they were previously a solid colour with white text.
23 changes: 23 additions & 0 deletions cypress/integration/rendering/c4/c4.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,27 @@ C4Context
{}
);
});
// The outline look derives the border and label from the palette colour over the theme's
// surface. Without a dark-theme case the snapshots only ever show the light surface, so a
// dark identity colour on a dark body would not be visible here.
it('C4.9 should keep the outline style readable in the dark theme', () => {
imgSnapshotTest(
['default', 'dark'].map(
(theme) => `---
title: theme=${theme}
config:
theme: ${theme}
---
C4Context
Person(customerA, "Banking Customer A", "A customer of the bank.")
System(SystemAA, "Internet Banking System", "Allows customers to view information.")
System_Ext(SystemE, "Mainframe", "Stores all of the core banking information.")
ContainerDb(db, "Database", "SQL Database", "Stores user registration information.")
Rel(customerA, SystemAA, "Uses")
Rel(SystemAA, db, "Reads from and writes to", "JDBC")
`
),
{}
);
});
});
78 changes: 78 additions & 0 deletions packages/mermaid/src/diagrams/c4/c4Colors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @ts-expect-error Incorrect khroma types
import { luminance } from 'khroma';
import { describe, it, expect } from 'vitest';
import { readableOn } from './c4Colors.js';

const ratio = (one: string, other: string): number => {
const [brighter, darker] = [luminance(one), luminance(other)].sort(
(a: number, b: number) => b - a
);
return (brighter + 0.05) / (darker + 0.05);
};

// The shipped c4 palette, which is what the defaults actually put on screen.
const PALETTE = ['#08427B', '#686868', '#1168BD', '#999999', '#438DD5', '#B3B3B3', '#85BBF0'];

describe('readableOn', () => {
it('leaves a colour alone when it already reads on the background', () => {
// #08427B on white is about 10:1 - nothing to fix.
expect(readableOn('#08427B', '#ffffff')).toBe('#08427B');
});

it('darkens a pale colour on a light background', () => {
const readable = readableOn('#85BBF0', '#ffffff');

expect(readable).not.toBe('#85BBF0');
expect(luminance(readable)).toBeLessThan(luminance('#85BBF0'));
expect(ratio(readable, '#ffffff')).toBeGreaterThanOrEqual(4.5);
});

// The bug this exists to prevent: shifting in a fixed direction darkens the colour
// further into a dark background, which is how the outline look became unreadable
// in the dark theme.
it('lightens a dark colour on a dark background', () => {
const readable = readableOn('#08427B', '#333333');

expect(readable).not.toBe('#08427B');
expect(luminance(readable)).toBeGreaterThan(luminance('#08427B'));
expect(ratio(readable, '#333333')).toBeGreaterThanOrEqual(4.5);
});

it('reaches a readable contrast for every palette colour on every theme surface', () => {
// The surfaces the shipped themes actually use for `background`.
for (const surface of ['#ffffff', '#f4f4f4', '#333333']) {
for (const color of PALETTE) {
expect(ratio(readableOn(color, surface), surface)).toBeGreaterThanOrEqual(4.5);
}
}
});

// A mid-grey background is the case that breaks choosing the direction by whether the
// background is "dark": `#888888` counts as dark, so lightening is the obvious choice,
// yet lightening can never clear the target against it while darkening reaches ~4.9:1.
it('darkens against a mid-grey background, where lightening cannot reach the target', () => {
const readable = readableOn('#08427B', '#888888');

expect(luminance(readable)).toBeLessThan(luminance('#08427B'));
expect(ratio(readable, '#888888')).toBeGreaterThanOrEqual(4.5);
});

// The invariant that pins the whole class of bug: whatever it returns is never harder
// to read than what it was given.
it('never returns a colour less readable than the original', () => {
for (const surface of ['#ffffff', '#f4f4f4', '#333333', '#6f6f6f', '#888888', '#999999']) {
for (const color of PALETTE) {
expect(ratio(readableOn(color, surface), surface)).toBeGreaterThanOrEqual(
ratio(color, surface)
);
}
}
});

it('returns a value it cannot parse untouched', () => {
// An unusable config value reaches CSS as-is and is dropped there, rather than
// becoming NaN and painting nothing.
expect(readableOn('not-a-color', '#ffffff')).toBe('not-a-color');
expect(readableOn('#08427B', 'not-a-color')).toBe('#08427B');
});
});
64 changes: 64 additions & 0 deletions packages/mermaid/src/diagrams/c4/c4Colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @ts-expect-error Incorrect khroma types
import { darken, isValid, lighten, luminance } from 'khroma';

/** WCAG AA for normal text. */
const CONTRAST_TARGET = 4.5;

/**
* WCAG relative-contrast ratio. khroma's own `contrast` omits the `+ 0.05` terms and
* clamps the result to 10, which saturates for most real colour pairs, so it cannot
* tell an adequate pairing from a comfortable one.
*/
const contrastRatio = (one: string, other: string): number => {
const [brighter, darker] = [luminance(one), luminance(other)].sort((a, b) => b - a);
return (brighter + 0.05) / (darker + 0.05);
};

/**
* `color` shifted one way until it reads against `background`, or as far as shifting
* that way gets. Bounded at 12 steps of 5%, and stops early on a colour that has run
* into black or white.
*/
const shiftUntilReadable = (
color: string,
background: string,
shift: (color: string, amount: number) => string
): string => {
let candidate = color;
for (let step = 0; step < 12 && contrastRatio(candidate, background) < CONTRAST_TARGET; step++) {
const next = shift(candidate, 5);
if (next === candidate) {
break;
}
candidate = next;
}
return candidate;
};

/**
* A palette colour shifted until it reads against `background`, so the C4 outline look
* stays legible on a dark theme as well as a light one.
*
* Both directions are tried and the better result wins, rather than picking one from
* whether the background counts as dark. That test pivots at half luminance, while the
* point where lightening stops beating darkening sits nearer a fifth of it - so on a
* mid-grey background, choosing by darkness lightens a dark colour towards the grey and
* ends up worse than leaving it alone. Trying both cannot regress: the winner is at
* least as readable as the input.
*
* Colours that cannot be parsed are returned untouched, so an unusable config value
* reaches CSS as-is and is dropped there rather than becoming `NaN`.
*/
export const readableOn = (color: string, background: string): string => {
if (!isValid(color) || !isValid(background)) {
return color;
}
if (contrastRatio(color, background) >= CONTRAST_TARGET) {
return color;
}
const lightened = shiftUntilReadable(color, background, lighten);
const darkened = shiftUntilReadable(color, background, darken);
return contrastRatio(lightened, background) >= contrastRatio(darkened, background)
? lightened
: darkened;
};
4 changes: 2 additions & 2 deletions packages/mermaid/src/diagrams/c4/c4Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export const drawC4ShapeArray = async function (
// (e.g. anything with opacity) painting at the stale origin.
await Promise.all(
c4Shapes.map(async (c4Shape) => {
const node = buildC4Node(c4Shape, conf, conf.c4ShapePadding, look, conf.width);
const node = buildC4Node(c4Shape, conf.c4ShapePadding, look, conf.width);
node.domId = `${diagramId}-${node.id}`;
const measured = await shapeHandlerFor(node)(diagram, node, renderOptions);
c4Shape.width = node.width ?? conf.width;
Expand All @@ -307,7 +307,7 @@ export const drawC4ShapeArray = async function (
// (unified shapes are centred at the origin; legacy x/y is the top-left corner).
await Promise.all(
c4Shapes.map(async (c4Shape) => {
const node = buildC4Node(c4Shape, conf, conf.c4ShapePadding, look, conf.width);
const node = buildC4Node(c4Shape, conf.c4ShapePadding, look, conf.width);
node.domId = `${diagramId}-${node.id}`;
// Needed to properly calculate the intersection points.
node.x = c4Shape.x + c4Shape.width / 2;
Expand Down
42 changes: 17 additions & 25 deletions packages/mermaid/src/diagrams/c4/c4ShapeAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { C4DiagramConfig } from '../../config.type.js';
import type { ShapeID } from '../../rendering-util/rendering-elements/shapes.js';
import type { NonClusterNode } from '../../rendering-util/types.js';

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

const C4_ELEMENT_TYPE_SET = new Set<string>(C4_ELEMENT_TYPES);

const isC4ElementType = (value: string): value is (typeof C4_ELEMENT_TYPES)[number] =>
C4_ELEMENT_TYPE_SET.has(value);

/**
* Element colours: the per-element `<type>_bg_color`/`<type>_border_color` config
* palette drives the fill and border, with white text. An explicit per-element
* colour (UpdateElementStyle: $bgColor/$borderColor/$fontColor) overrides it.
* Only the explicit per-element colours from `UpdateElementStyle`
* (`$bgColor`/`$borderColor`/`$fontColor`). The outline look itself - the light fill and
* the palette colour as border and text - comes from the stylesheet, which is where the
* theme variables are available; these are emitted inline so they override it.
*/
const elementCssStyles = (shape: C4ShapeLike, config: C4DiagramConfig): string[] => {
const elementType = shape.typeC4Shape.text;
const fill = shape.bgColor ?? (isC4ElementType(elementType) && config[`${elementType}_bg_color`]);
const stroke =
shape.borderColor ?? (isC4ElementType(elementType) && config[`${elementType}_border_color`]);
const elementCssStyles = (shape: C4ShapeLike): string[] => {
const styles: string[] = [];
if (fill) {
styles.push(`fill:${fill}`);
if (shape.bgColor) {
styles.push(`fill:${shape.bgColor}`);
}
if (shape.borderColor) {
styles.push(`stroke:${shape.borderColor}`);
}
if (stroke) {
styles.push(`stroke:${stroke}`);
if (shape.fontColor) {
styles.push(`color:${shape.fontColor}`);
}
styles.push(`color:${shape.fontColor ?? '#FFFFFF'}`);
return styles;
};

/**
* Converts a legacy C4 shape into a unified-renderer Node. `config` is the c4
* diagram config, whose `<type>_bg_color`/`<type>_border_color` palette drives the fill and border.
* `elementWidth` is the target shape width (`c4.width`); the label helper
* derives its own text-wrapping width from it.
* Converts a legacy C4 shape into a unified-renderer Node. Element colours come from the
* stylesheet, keyed on the `c4-<type>` class this sets; only `UpdateElementStyle`
* overrides travel inline. `elementWidth` is the target shape width (`c4.width`); the
* label helper derives its own text-wrapping width from it.
*/
export const buildC4Node = (
shape: C4ShapeLike,
config: C4DiagramConfig,
padding: number,
look: string,
elementWidth: number
Expand All @@ -174,7 +166,7 @@ export const buildC4Node = (
cssClasses.push('c4-external');
}
const nodeShape = resolveNodeShape(shape);
const cssStyles = elementCssStyles(shape, config);
const cssStyles = elementCssStyles(shape);
if (nodeShape === 'rounded' || nodeShape === 'fr-rect') {
// Inline so it wins over the shape's default corner radius.
cssStyles.push('rx:12px', 'ry:12px');
Expand Down
55 changes: 40 additions & 15 deletions packages/mermaid/src/diagrams/c4/styles.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { C4_ELEMENT_TYPES } from './c4ShapeAdapter.js';
import { readableOn } from './c4Colors.js';

// The elements each C4 shape draws; `person` contributes both a rect and a circle.
const SHAPE_PARTS = ['rect', 'path', 'circle', 'ellipse', 'line'];

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

const getStyles = (options) =>
`.person {
stroke: ${options.personBorder};
fill: ${options.personBkg};
// The c4model.com outline look: each element type's palette colour becomes its border
// and text - its identity - over the theme's surface colour. Built through the CSSOM for
// the same reason as the font rules, and here rather than inline on the node because the
// theme variables only exist at style-generation time. An `UpdateElementStyle` colour is
// still emitted inline by the shape adapter, which outranks any of this.
const elementColorStyles = (options) => {
const c4 = getConfig().c4 ?? {};
const surface = options.background;
const sheet = new CSSStyleSheet();
for (const type of C4_ELEMENT_TYPES) {
const paletteColor = c4[`${type}_bg_color`];
if (!paletteColor) {
continue;
}
const identity = readableOn(paletteColor, surface);
const parts = SHAPE_PARTS.map((part) => `.c4-shape.c4-${type} ${part}`).join(', ');
const strokeRule = sheet.cssRules[sheet.insertRule(`${parts} {}`, sheet.cssRules.length)];
strokeRule.style.setProperty('stroke', identity);
// Set on the group so the label inherits it and `fill: currentColor` picks it up.
const colorRule =
sheet.cssRules[sheet.insertRule(`.c4-shape.c4-${type} {}`, sheet.cssRules.length)];
colorRule.style.setProperty('color', identity);
}
${elementFontStyles()}
return [...sheet.cssRules]
.filter((rule) => rule.style.length > 0)
.map((rule) => ` ${rule.cssText}`)
.join('\n');
};

const getStyles = (options) =>
`${elementFontStyles()}
${elementColorStyles(options)}

/* The element font colour is set inline per element (default white); the
label text takes it via currentColor. */
${SHAPE_PARTS.map((part) => `.c4-shape ${part}`).join(',\n ')} {
fill: ${options.background};
stroke-width: 2px;
}
/* The identity colour is set on the element group above; the label follows it. */
.c4-shape .label,
.c4-shape .label text {
color: inherit;
Expand All @@ -57,14 +90,6 @@ ${elementFontStyles()}
.c4-shape .label .c4-descr {
font-size: 0.82em;
}
.c4-shape .basic,
.c4-shape rect,
.c4-shape path,
.c4-shape circle,
.c4-shape ellipse,
.c4-shape line {
stroke-width: 2px;
}
`;

export default getStyles;
Loading
Loading