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);') + }) })