Theme Mermaid dark diagrams with the app's chart colors (#27398)

The legacy Mermaid dark theme drops color from flowcharts and uses light Gantt surfaces. Dark previews now use the `base` theme with palette values read from the app's CSS tokens. Chart tokens can resolve to oklch(), which Mermaid does not accept, so colors are converted to hex through a canvas. Tests cover Gantt label contrast and distinct pie colors.
This commit is contained in:
Jinjing
2026-10-11 11:16:41 -07:00
committed by GitHub
parent ac529511b9
commit 45de8d0791
5 changed files with 146 additions and 7 deletions
@@ -2,6 +2,7 @@ import React, { useEffect, useId, useState } from 'react'
import type mermaidNamespace from 'mermaid'
import DOMPurify from 'dompurify'
import { getMermaidConfig } from './mermaid-config'
import { readMermaidThemeColors } from './mermaid-theme-colors'
import { translate } from '@/i18n/i18n'
type MermaidApi = typeof mermaidNamespace
@@ -76,7 +77,9 @@ export default function MermaidBlock({
// MermaidBlock cannot overwrite htmlLabels/theme between initialize()
// and render(), which would make markdown preview fall back to the
// broken foreignObject label path again.
mermaid.initialize(getMermaidConfig(isDark, htmlLabels))
mermaid.initialize(
getMermaidConfig(isDark, htmlLabels, isDark ? readMermaidThemeColors() : undefined)
)
const { svg } = await mermaid.render(`mermaid-${id}`, content)
if (!cancelled) {
// Why: although mermaid uses DOMPurify internally, we add an explicit
@@ -1,7 +1,44 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import mermaid from 'mermaid'
import { getMermaidConfig } from './mermaid-config'
const darkColors = {
background: '#0a0a0a',
foreground: '#fafafa',
primary: '#193cb8',
secondary: '#1447e6',
tertiary: '#155dfc',
border: '#8ec5ff'
}
function luminance(color: string | boolean | undefined): number {
if (typeof color !== 'string') {
throw new Error('Expected a resolved Mermaid color')
}
const hex = color === 'lightgrey' ? '#d3d3d3' : color === 'white' ? '#ffffff' : color
if (!/^#[\da-f]{6}$/i.test(hex)) {
throw new Error(`Unsupported contrast-test color: ${color}`)
}
const channels = [1, 3, 5].map((offset) => {
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
}
function resolvedDarkTheme(): NonNullable<
ReturnType<typeof mermaid.mermaidAPI.getConfig>['themeVariables']
> {
mermaid.initialize(getMermaidConfig(true, false, darkColors))
const variables = mermaid.mermaidAPI.getConfig().themeVariables
if (!variables) {
throw new Error('Mermaid did not resolve the theme')
}
return variables
}
describe('getMermaidConfig', () => {
it('uses strict Mermaid rendering defaults', () => {
expect(getMermaidConfig(false)).toMatchObject({
@@ -21,10 +58,41 @@ describe('getMermaidConfig', () => {
})
})
it('switches to the dark mermaid theme when the preview is dark', () => {
expect(getMermaidConfig(true)).toMatchObject({
theme: 'dark',
it('uses the colored dark palette when the preview is dark', () => {
const colors = darkColors
expect(getMermaidConfig(true, false, colors)).toMatchObject({
theme: 'base',
darkMode: true,
themeVariables: {
darkMode: true,
primaryColor: colors.primary,
tertiaryColor: colors.tertiary,
primaryTextColor: colors.foreground,
tertiaryTextColor: colors.foreground,
primaryBorderColor: colors.border,
lineColor: colors.foreground,
edgeLabelBackground: colors.background
},
htmlLabels: false
})
})
it('keeps Gantt labels readable on completed and active tasks and alternate sections', () => {
const variables = resolvedDarkTheme()
for (const [text, background] of [
['taskTextDarkColor', 'doneTaskBkgColor'],
['taskTextDarkColor', 'activeTaskBkgColor'],
['taskTextOutsideColor', 'altSectionBkgColor']
]) {
const first = luminance(variables[text])
const second = luminance(variables[background])
const contrast = (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05)
expect(contrast, `${text} on ${background}`).toBeGreaterThanOrEqual(4.5)
}
})
it('gives the first three pie categories distinct resolved colors', () => {
const variables = resolvedDarkTheme()
expect(new Set([variables.pie1, variables.pie2, variables.pie3]).size).toBe(3)
})
})
@@ -1,14 +1,52 @@
import type mermaid from 'mermaid'
export type MermaidThemeColors = {
background: string
foreground: string
primary: string
secondary: string
tertiary: string
border: string
}
export function getMermaidConfig(
isDark: boolean,
htmlLabels = false
htmlLabels = false,
colors?: MermaidThemeColors
): Parameters<typeof mermaid.initialize>[0] {
return {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
theme: isDark ? 'dark' : 'default',
// The legacy dark theme removes color from flowcharts.
theme: isDark ? (colors ? 'base' : 'dark') : 'default',
darkMode: isDark,
...(isDark && colors
? {
themeVariables: {
darkMode: true,
background: colors.background,
primaryColor: colors.primary,
secondaryColor: colors.secondary,
tertiaryColor: colors.tertiary,
primaryTextColor: colors.foreground,
secondaryTextColor: colors.foreground,
tertiaryTextColor: colors.foreground,
primaryBorderColor: colors.border,
secondaryBorderColor: colors.border,
tertiaryBorderColor: colors.border,
lineColor: colors.foreground,
textColor: colors.foreground,
edgeLabelBackground: colors.background,
// Base defaults to light Gantt surfaces even when darkMode is enabled.
altSectionBkgColor: colors.background,
excludeBkgColor: colors.background,
doneTaskBkgColor: colors.background,
doneTaskBorderColor: colors.border,
activeTaskBkgColor: colors.secondary
}
}
: {}),
htmlLabels
}
}
@@ -0,0 +1,27 @@
import type { MermaidThemeColors } from './mermaid-config'
export function readMermaidThemeColors(): MermaidThemeColors | undefined {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 1
const context = canvas.getContext('2d')
if (!context) {
return undefined
}
const styles = getComputedStyle(document.documentElement)
const readColor = (token: string): string => {
// Mermaid needs hex colors; chart tokens can resolve to oklch().
context.clearRect(0, 0, 1, 1)
context.fillStyle = styles.getPropertyValue(token).trim()
context.fillRect(0, 0, 1, 1)
const pixel = context.getImageData(0, 0, 1, 1).data
return `#${Array.from(pixel.slice(0, 3), (channel) => channel.toString(16).padStart(2, '0')).join('')}`
}
return {
background: readColor('--background'),
foreground: readColor('--foreground'),
primary: readColor('--chart-5'),
secondary: readColor('--chart-4'),
tertiary: readColor('--chart-3'),
border: readColor('--chart-1')
}
}
+4 -1
View File
@@ -1,9 +1,11 @@
declare module 'mermaid' {
type MermaidTheme = 'default' | 'dark'
type MermaidTheme = 'default' | 'dark' | 'base'
type MermaidInitializeOptions = {
startOnLoad?: boolean
theme?: MermaidTheme
darkMode?: boolean
themeVariables?: Record<string, string | boolean>
htmlLabels?: boolean
securityLevel?: 'strict' | 'loose' | 'antiscript' | 'sandbox'
suppressErrorRendering?: boolean
@@ -16,6 +18,7 @@ declare module 'mermaid' {
type MermaidApi = {
initialize: (options: MermaidInitializeOptions) => void
mermaidAPI: { getConfig: () => MermaidInitializeOptions }
render: (id: string, text: string) => Promise<MermaidRenderResult>
}