diff --git a/desktop/src/components/chat/DiffViewer.test.tsx b/desktop/src/components/chat/DiffViewer.test.tsx
new file mode 100644
index 00000000..b6a42445
--- /dev/null
+++ b/desktop/src/components/chat/DiffViewer.test.tsx
@@ -0,0 +1,43 @@
+import { act, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import '@testing-library/jest-dom'
+
+const { diffViewerMock } = vi.hoisted(() => ({
+ diffViewerMock: vi.fn(),
+}))
+
+vi.mock('react-diff-viewer-continued', () => ({
+ default: (props: { useDarkTheme: boolean }) => {
+ diffViewerMock(props)
+ return
+ },
+ DiffMethod: {
+ WORDS: 'WORDS',
+ },
+}))
+
+import { useUIStore } from '../../stores/uiStore'
+import { DiffViewer } from './DiffViewer'
+
+describe('DiffViewer', () => {
+ beforeEach(() => {
+ diffViewerMock.mockReset()
+ useUIStore.setState({ theme: 'white' })
+ })
+
+ it('passes the current app theme to the underlying diff renderer', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ expect(screen.getByTestId('diff-viewer')).toHaveAttribute('data-dark-theme', 'false')
+
+ act(() => {
+ useUIStore.setState({ theme: 'dark' })
+ })
+ rerender()
+
+ expect(screen.getByTestId('diff-viewer')).toHaveAttribute('data-dark-theme', 'true')
+ expect(diffViewerMock).toHaveBeenLastCalledWith(expect.objectContaining({ useDarkTheme: true }))
+ })
+})
diff --git a/desktop/src/components/chat/DiffViewer.tsx b/desktop/src/components/chat/DiffViewer.tsx
index 50e179e0..07933c5c 100644
--- a/desktop/src/components/chat/DiffViewer.tsx
+++ b/desktop/src/components/chat/DiffViewer.tsx
@@ -1,6 +1,7 @@
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '../shared/CopyButton'
+import { useUIStore } from '../../stores/uiStore'
type Props = {
filePath: string
@@ -112,6 +113,7 @@ const diffStyles = {
}
export function DiffViewer({ filePath, oldString, newString }: Props) {
+ const theme = useUIStore((state) => state.theme)
const language = inferLanguage(filePath)
const oldLines = oldString.split('\n')
@@ -149,7 +151,7 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
renderContent={(str) => highlightSyntax(str, language)}
hideLineNumbers={false}
styles={diffStyles}
- useDarkTheme={document.documentElement.getAttribute('data-theme') === 'dark'}
+ useDarkTheme={theme === 'dark'}
/>
diff --git a/desktop/src/components/chat/MermaidRenderer.test.tsx b/desktop/src/components/chat/MermaidRenderer.test.tsx
index 3775e44a..c43b76fd 100644
--- a/desktop/src/components/chat/MermaidRenderer.test.tsx
+++ b/desktop/src/components/chat/MermaidRenderer.test.tsx
@@ -15,9 +15,11 @@ vi.mock('mermaid', () => ({
}))
import { MermaidRenderer } from './MermaidRenderer'
+import { useUIStore } from '../../stores/uiStore'
describe('MermaidRenderer', () => {
beforeEach(() => {
+ useUIStore.setState({ theme: 'white' })
initializeMock.mockReset()
renderMock.mockReset()
renderMock.mockResolvedValue({
@@ -31,8 +33,11 @@ describe('MermaidRenderer', () => {
const previewButton = await screen.findByRole('button', { name: /preview/i })
expect(previewButton).toBeInTheDocument()
expect(initializeMock).toHaveBeenCalledWith(expect.objectContaining({
+ theme: 'base',
+ themeVariables: expect.objectContaining({ darkMode: false }),
suppressErrorRendering: true,
}))
+ expect(screen.getByTestId('mermaid-diagram-surface').className).toContain('bg-[var(--color-surface-container-lowest)]')
fireEvent.click(previewButton)
@@ -56,6 +61,19 @@ describe('MermaidRenderer', () => {
})
})
+ it('uses dark Mermaid theme variables when the app is in dark mode', async () => {
+ useUIStore.setState({ theme: 'dark' })
+
+ render(B'} />)
+
+ await screen.findByRole('button', { name: /preview/i })
+
+ expect(initializeMock).toHaveBeenCalledWith(expect.objectContaining({
+ theme: 'base',
+ themeVariables: expect.objectContaining({ darkMode: true }),
+ }))
+ })
+
it('enters and exits dragging state while panning the preview viewport', async () => {
render(B'} />)
diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx
index ab5b87da..aeefe9d3 100644
--- a/desktop/src/components/chat/MermaidRenderer.tsx
+++ b/desktop/src/components/chat/MermaidRenderer.tsx
@@ -3,12 +3,13 @@ import DOMPurify from 'dompurify'
import mermaid from 'mermaid'
import { Modal } from '../shared/Modal'
import { CopyButton } from '../shared/CopyButton'
+import { useUIStore } from '../../stores/uiStore'
+import type { ThemeMode } from '../../types/settings'
type Props = {
code: string
}
-let mermaidInitialized = false
const MIN_PREVIEW_ZOOM = 0.5
const MAX_PREVIEW_ZOOM = 3
const PREVIEW_ZOOM_STEP = 0.25
@@ -26,16 +27,84 @@ type DragState = {
scrollTop: number
}
-function initMermaid() {
- if (mermaidInitialized) return
+function rgbToHex(color: string, fallback: string) {
+ const trimmed = color.trim()
+ if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed
+ const shortHex = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(trimmed)
+ if (shortHex) {
+ return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`
+ }
+
+ const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(trimmed)
+ if (!rgb) return fallback
+
+ return [rgb[1], rgb[2], rgb[3]]
+ .map((value) => {
+ const channel = Math.max(0, Math.min(255, Math.round(Number.parseFloat(value ?? '0'))))
+ return channel.toString(16).padStart(2, '0')
+ })
+ .join('')
+ .replace(/^/, '#')
+}
+
+function resolveThemeColor(token: string, fallback: string) {
+ if (typeof document === 'undefined') return fallback
+
+ const probe = document.createElement('span')
+ probe.style.color = `var(${token})`
+ probe.style.position = 'absolute'
+ probe.style.pointerEvents = 'none'
+ probe.style.opacity = '0'
+ document.body.appendChild(probe)
+ const resolved = getComputedStyle(probe).color
+ probe.remove()
+
+ return rgbToHex(resolved, fallback)
+}
+
+function initMermaid(theme: ThemeMode) {
+ const isDark = theme === 'dark'
+ const textColor = resolveThemeColor('--color-text-primary', isDark ? '#E5E2E1' : '#1B1C1A')
+ const mutedTextColor = resolveThemeColor('--color-text-secondary', isDark ? '#B7AAA5' : '#61514B')
+ const surfaceColor = resolveThemeColor('--color-surface-container-lowest', isDark ? '#0E0E0E' : '#FFFFFF')
+ const nodeColor = resolveThemeColor('--color-surface-container-low', isDark ? '#1C1B1B' : '#F4EFEA')
+ const accentColor = resolveThemeColor('--color-primary', isDark ? '#FFB59F' : '#8F482F')
+ const lineColor = resolveThemeColor('--color-outline', isDark ? '#8D7F7A' : '#87736D')
+
mermaid.initialize({
startOnLoad: false,
- theme: 'default',
+ theme: 'base',
+ themeVariables: {
+ darkMode: isDark,
+ background: surfaceColor,
+ mainBkg: nodeColor,
+ primaryColor: nodeColor,
+ primaryTextColor: textColor,
+ primaryBorderColor: lineColor,
+ secondaryColor: surfaceColor,
+ tertiaryColor: surfaceColor,
+ textColor,
+ lineColor,
+ edgeLabelBackground: surfaceColor,
+ clusterBkg: surfaceColor,
+ clusterBorder: lineColor,
+ titleColor: textColor,
+ labelTextColor: textColor,
+ nodeTextColor: textColor,
+ noteTextColor: textColor,
+ noteBkgColor: surfaceColor,
+ noteBorderColor: lineColor,
+ actorTextColor: textColor,
+ actorLineColor: lineColor,
+ signalTextColor: textColor,
+ signalColor: mutedTextColor,
+ activationBkgColor: nodeColor,
+ activationBorderColor: accentColor,
+ },
securityLevel: 'strict',
suppressErrorRendering: true,
fontFamily: 'var(--font-sans)',
})
- mermaidInitialized = true
}
let mermaidIdCounter = 0
@@ -92,6 +161,7 @@ function parseSvgMetrics(svg: string): SvgMetrics | null {
}
export function MermaidRenderer({ code }: Props) {
+ const theme = useUIStore((state) => state.theme)
const containerRef = useRef(null)
const previewViewportRef = useRef(null)
const previewContentRef = useRef(null)
@@ -106,7 +176,7 @@ export function MermaidRenderer({ code }: Props) {
useEffect(() => {
let cancelled = false
- initMermaid()
+ initMermaid(theme)
const id = `mermaid-${++mermaidIdCounter}`
@@ -126,7 +196,7 @@ export function MermaidRenderer({ code }: Props) {
)
return () => { cancelled = true }
- }, [code])
+ }, [code, theme])
const handlePreview = useCallback(() => setPreviewOpen(true), [])
const handlePreviewClose = useCallback(() => setPreviewOpen(false), [])
@@ -279,7 +349,8 @@ export function MermaidRenderer({ code }: Props) {
{/* Diagram */}
{
expect(css).toContain('--settings-zoom-thumb-border: rgba(255, 181, 159, 0.78);')
expect(css).toContain('box-shadow: var(--settings-zoom-thumb-shadow);')
})
+
+ it('maps markdown typography colors to theme tokens', () => {
+ const markdownProseStart = normalizedCss.indexOf('.markdown-prose {')
+ expect(markdownProseStart).toBeGreaterThanOrEqual(0)
+ const markdownProseEnd = normalizedCss.indexOf('}', markdownProseStart)
+ const markdownProseBlock = normalizedCss.slice(markdownProseStart, markdownProseEnd)
+
+ expect(markdownProseBlock).toContain('--tw-prose-body: var(--color-text-primary);')
+ expect(markdownProseBlock).toContain('--tw-prose-quotes: var(--color-text-primary);')
+ expect(markdownProseBlock).toContain('--tw-prose-bold: var(--color-text-primary);')
+ expect(markdownProseBlock).toContain('--tw-prose-code: var(--color-code-fg);')
+ expect(markdownProseBlock).toContain('--tw-prose-pre-bg: var(--color-code-bg);')
+ expect(markdownProseBlock).toContain('--tw-prose-td-borders: var(--color-border);')
+ })
+
+ it('keeps code viewer line hover and line numbers on theme tokens', () => {
+ expect(css).toContain('background: var(--color-surface-hover);')
+ expect(css).toContain('--line-numbers-foreground: var(--color-text-tertiary);')
+ })
})