diff --git a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx
index 726f6672660..a2a9254512f 100644
--- a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx
+++ b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx
@@ -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)
diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx
index 9015dfefb7a..4315bbe10a1 100644
--- a/src/renderer/src/components/editor/MermaidBlock.tsx
+++ b/src/renderer/src/components/editor/MermaidBlock.tsx
@@ -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
}
})
diff --git a/src/shared/mermaid-flowchart-contrast.test.ts b/src/shared/mermaid-flowchart-contrast.test.ts
new file mode 100644
index 00000000000..6f10748cab6
--- /dev/null
+++ b/src/shared/mermaid-flowchart-contrast.test.ts
@@ -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 (
+ ''
+ )
+}
+
+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(
+ '',
+ // The group inherits the diagram's text colour; only its path is painted.
+ ''
+ )
+ 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')
+ })
+})
diff --git a/src/shared/mermaid-flowchart-contrast.ts b/src/shared/mermaid-flowchart-contrast.ts
new file mode 100644
index 00000000000..3b2662d2c5a
--- /dev/null
+++ b/src/shared/mermaid-flowchart-contrast.ts
@@ -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(
+ '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 (``) 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()
+ }
+}