mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
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:
@@ -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')
|
||||
}
|
||||
}
|
||||
Vendored
+4
-1
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user