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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 13:23:03 +08:00
parent f559ed31dc
commit d9d0d20782
6 changed files with 185 additions and 12 deletions

View File

@ -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 <div data-testid="diff-viewer" data-dark-theme={String(props.useDarkTheme)} />
},
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(
<DiffViewer filePath="src/example.ts" oldString="const a = 1" newString="const a = 2" />,
)
expect(screen.getByTestId('diff-viewer')).toHaveAttribute('data-dark-theme', 'false')
act(() => {
useUIStore.setState({ theme: 'dark' })
})
rerender(<DiffViewer filePath="src/example.ts" oldString="const a = 1" newString="const a = 2" />)
expect(screen.getByTestId('diff-viewer')).toHaveAttribute('data-dark-theme', 'true')
expect(diffViewerMock).toHaveBeenLastCalledWith(expect.objectContaining({ useDarkTheme: true }))
})
})

View File

@ -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'}
/>
</div>
</div>

View File

@ -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(<MermaidRenderer code={'graph TB\nA-->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(<MermaidRenderer code={'graph TB\nA-->B'} />)

View File

@ -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<HTMLDivElement>(null)
const previewViewportRef = useRef<HTMLDivElement>(null)
const previewContentRef = useRef<HTMLDivElement>(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 */}
<div
ref={containerRef}
className="flex items-center justify-center overflow-auto bg-white p-4 cursor-pointer"
data-testid="mermaid-diagram-surface"
className="flex items-center justify-center overflow-auto bg-[var(--color-surface-container-lowest)] p-4 cursor-pointer"
style={{ maxHeight: 400 }}
onClick={handlePreview}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
@ -329,7 +400,7 @@ export function MermaidRenderer({ code }: Props) {
<div
ref={previewViewportRef}
data-testid="mermaid-preview-viewport"
className="overflow-auto rounded-xl bg-white"
className="overflow-auto rounded-xl bg-[var(--color-surface-container-lowest)]"
style={{
maxHeight: '75vh',
cursor: isDraggingPreview ? 'grabbing' : 'grab',

View File

@ -82,13 +82,13 @@
.code-viewer-area .line:hover,
.code-viewer-area .token-line:hover {
background: rgba(0, 0, 0, 0.03);
background: var(--color-surface-hover);
}
.code-viewer-area .has-line-numbers,
.code-viewer-area .rs-has-line-numbers {
counter-reset: line-number calc(var(--line-start, 1) - 1);
--line-numbers-foreground: rgba(135, 115, 109, 0.5);
--line-numbers-foreground: var(--color-text-tertiary);
--line-numbers-width: 2.5ch;
--line-numbers-padding-left: 0;
--line-numbers-padding-right: 0;
@ -1208,6 +1208,26 @@ button, input, textarea, select, a, [role="button"] {
border-spacing: 0;
}
.markdown-prose {
--tw-prose-body: var(--color-text-primary);
--tw-prose-headings: var(--color-text-primary);
--tw-prose-lead: var(--color-text-secondary);
--tw-prose-links: var(--color-text-accent);
--tw-prose-bold: var(--color-text-primary);
--tw-prose-counters: var(--color-text-tertiary);
--tw-prose-bullets: var(--color-text-tertiary);
--tw-prose-hr: var(--color-border);
--tw-prose-quotes: var(--color-text-primary);
--tw-prose-quote-borders: var(--color-outline-variant);
--tw-prose-captions: var(--color-text-secondary);
--tw-prose-kbd: var(--color-text-secondary);
--tw-prose-code: var(--color-code-fg);
--tw-prose-pre-code: var(--color-code-fg);
--tw-prose-pre-bg: var(--color-code-bg);
--tw-prose-th-borders: var(--color-border);
--tw-prose-td-borders: var(--color-border);
}
.markdown-prose .md-table-wrap tbody tr:last-child td {
border-bottom: 0;
}

View File

@ -91,4 +91,23 @@ describe('desktop theme tokens', () => {
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);')
})
})