From d9d0d207826e16f00fa0ec763a676d2ee7aca27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 13:23:03 +0800 Subject: [PATCH] fix: repair desktop theme rendering for markdown #636 Dark-mode Markdown text was still inheriting Tailwind Typography's default prose color, so normal and italic text could render dark while strong text used the app theme token. Map markdown prose variables to the desktop theme tokens, move code-viewer hover and line-number styling onto theme tokens, and make Mermaid/Diff rendering follow the active app theme. Write/Edit/tool result and workspace render paths were audited; their core code and diff surfaces already use theme tokens, with the DiffViewer theme handoff tightened here. Constraint: Mermaid theme customization requires the base theme and concrete color values, so CSS theme tokens are resolved to hex before rendering. Rejected: Add per-element prose utility overrides only | misses Tailwind Typography defaults for quotes, counters, tables, and future prose elements Confidence: high Scope-risk: moderate Directive: Keep markdown, code, diff, and diagram colors routed through shared desktop theme tokens; do not reintroduce fixed light backgrounds in chat renderers. Tested: cd desktop && bun run test -- --run src/theme/globals.test.ts src/components/markdown/MarkdownRenderer.test.tsx src/components/chat/CodeViewer.test.tsx src/components/chat/MermaidRenderer.test.tsx src/components/chat/DiffViewer.test.tsx src/components/chat/chatBlocks.test.tsx src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: git diff --check Not-tested: Full Tauri live desktop session with real provider output; browser broad-audit screenshot capture timed out, but computed colors were captured for all three themes. --- .../src/components/chat/DiffViewer.test.tsx | 43 +++++++++ desktop/src/components/chat/DiffViewer.tsx | 4 +- .../components/chat/MermaidRenderer.test.tsx | 18 ++++ .../src/components/chat/MermaidRenderer.tsx | 89 +++++++++++++++++-- desktop/src/theme/globals.css | 24 ++++- desktop/src/theme/globals.test.ts | 19 ++++ 6 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 desktop/src/components/chat/DiffViewer.test.tsx 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);') + }) })