fix(mermaid): keep flowchart labels readable on custom node fills (#34452)

Mermaid draws every flowchart label in the theme's text colour, so a
`style` or `classDef` fill can hide it: light text on a pale node in dark
mode, dark text on a deep one in light mode. After rendering, labels that
fall below 4.5:1 on their node's fill switch to whichever theme colour
reads better, on desktop previews, chat and the phone.

Co-authored-by: Nico Hu <haoyu.hu@lexmount.com>
This commit is contained in:
Kelvin Amoaba
2026-10-27 16:38:00 +00:00
committed by GitHub
co-authored by Nico Hu
parent 53e6a7d4a9
commit 3ddaa8df6f
4 changed files with 145 additions and 2 deletions
@@ -1,6 +1,7 @@
import { memo, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { readableMermaidFlowchartSvg } from '../../../../src/shared/mermaid-flowchart-contrast'
import { MERMAID_DIAGRAM_CONFIG } from './mermaid-diagram-config'
import { loadPageMermaid } from './mermaid-page-engine'
@@ -37,7 +38,7 @@ export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Mer
return
}
// Only mermaid sanitizes its serialization; strict mode already made returned SVG safe to parse.
host.innerHTML = svg
host.innerHTML = readableMermaidFlowchartSvg(svg, colors.textPrimary, colors.bgRaised)
} catch {
if (!disposed) {
setFailedSource(source)
@@ -2,6 +2,7 @@ import React, { useEffect, useId, useRef, useState } from 'react'
import { Maximize2 } from 'lucide-react'
import type mermaidNamespace from 'mermaid'
import DOMPurify from 'dompurify'
import { readableMermaidFlowchartSvg } from '../../../../shared/mermaid-flowchart-contrast'
import { getMermaidConfig } from './mermaid-config'
import { readMermaidThemeColors } from './mermaid-theme-colors'
import { translate } from '@/i18n/i18n'
@@ -82,10 +83,13 @@ export default function MermaidBlock({
const { svg } = await mermaid.render(`mermaid-${id}`, content)
if (!cancelled) {
// Sanitize explicitly against XSS even if Mermaid’s internal sanitization changes.
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })
setRendered({
content,
result: {
svg: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }),
svg: colors
? readableMermaidFlowchartSvg(sanitized, colors.foreground, colors.background)
: sanitized,
background: colors?.background
}
})
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { readableMermaidFlowchartSvg } from './mermaid-flowchart-contrast'
function flowchart(fill: string, label: string): string {
return (
'<svg aria-roledescription="flowchart-v2"><g class="node">' +
`<rect class="label-container" style="fill:${fill}"></rect>` +
`<g class="label"><text style="fill:${label}">Node</text></g>` +
'</g></svg>'
)
}
function labelFill(svg: string): string {
const host = document.createElement('div')
host.innerHTML = svg
return host.querySelector('text')!.style.getPropertyValue('fill')
}
describe('readableMermaidFlowchartSvg', () => {
it('recolours a light theme label on a pale custom fill', () => {
const svg = readableMermaidFlowchartSvg(flowchart('#fde68a', '#e0e0e0'), '#e0e0e0', '#111111')
expect(labelFill(svg)).toBe('#111111')
})
it('reads the painted path when the label container is a group', () => {
const stadium = flowchart('#1e3a8a', '#e0e0e0').replace(
'<rect class="label-container" style="fill:#1e3a8a"></rect>',
// The group inherits the diagram's text colour; only its path is painted.
'<g class="label-container" style="fill:#e0e0e0"><path style="fill:#1e3a8a"></path></g>'
)
expect(labelFill(readableMermaidFlowchartSvg(stadium, '#e0e0e0', '#111111'))).toBe('#e0e0e0')
})
it('leaves a label that already reads on its fill', () => {
const svg = readableMermaidFlowchartSvg(flowchart('#1e3a8a', '#e0e0e0'), '#e0e0e0', '#111111')
expect(labelFill(svg)).toBe('#e0e0e0')
})
})
+99
View File
@@ -0,0 +1,99 @@
const READABLE_CONTRAST = 4.5
/** WCAG relative luminance of a `#rrggbb` or opaque `rgb()` colour; null for anything else. */
function luminance(color: string): number | null {
const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color)
const rgb = /^rgba?\(([^)]+)\)$/i.exec(color)
const channels = hex
? hex.slice(1).map((channel) => Number.parseInt(channel, 16))
: rgb
? rgb[1].split(/[\s,/]+/).map(Number)
: null
if (!channels || (channels.length === 4 && channels.pop() !== 1) || channels.length !== 3) {
return null
}
const [r, g, b] = channels.map((channel) => {
const value = channel / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function contrast(a: number, b: number): number {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
/**
* Mermaid draws every flowchart label in the theme's text colour, so a custom fill from `style`
* or `classDef` can hide it (light text on a pale node in dark mode). Recolours those labels to
* whichever of `foreground` and `background` reads better on the fill. Browser-only: the fill
* comes from computed styles, so `svg` is parsed into the document and must already be safe
* (sanitized, or from mermaid's strict mode).
*/
export function readableMermaidFlowchartSvg(
svg: string,
foreground: string,
background: string
): string {
const fgLum = luminance(foreground)
const bgLum = luminance(background)
if (!svg.includes('flowchart') || fgLum === null || bgLum === null) {
return svg
}
const host = document.createElement('div')
host.style.display = 'none'
host.innerHTML = svg
document.body.append(host)
try {
const nodes = host.querySelectorAll(
'svg[aria-roledescription^="flowchart"] .node, svg[aria-roledescription^="flowchart"] .cluster'
)
for (const node of nodes) {
// Only a node's label container sits behind its label (a brace draws beside it); stadium and
// hand-drawn shapes make it a group around the painted shapes. A subgraph is a plain rect.
const shape = node.classList.contains('cluster')
? node.querySelector('rect')
: node.querySelector('.label-container:not(g), .label-container :not(g)')
if (!shape) {
continue
}
const shapeStyle = getComputedStyle(shape)
const fill = luminance(shapeStyle.fill)
// A translucent fill blends with whatever is behind it, so its colour is unknown.
if (
fill === null ||
Number.parseFloat(shapeStyle.fillOpacity) < 1 ||
Number.parseFloat(shapeStyle.opacity) < 1
) {
continue
}
const fgRatio = contrast(fgLum, fill)
const bgRatio = contrast(bgLum, fill)
const best =
fgRatio >= bgRatio
? { color: foreground, ratio: fgRatio }
: { color: background, ratio: bgRatio }
for (const label of node.querySelectorAll<SVGElement | HTMLElement>(
'text, tspan, .nodeLabel, .nodeLabel *'
)) {
if (label.closest('.node, .cluster') !== node) {
continue
}
const svgText = label.localName === 'text' || label.localName === 'tspan'
const labelStyle = getComputedStyle(label)
// HTML that paints its own background (`<mark>`) was styled against that, not the fill.
if (!svgText && luminance(labelStyle.backgroundColor) !== null) {
continue
}
const current = luminance(svgText ? labelStyle.fill : labelStyle.color)
const ratio = current === null ? 0 : contrast(current, fill)
if (ratio < READABLE_CONTRAST && best.ratio > ratio) {
label.style.setProperty(svgText ? 'fill' : 'color', best.color, 'important')
}
}
}
return host.innerHTML
} finally {
host.remove()
}
}