mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
f559ed31dc
commit
d9d0d20782
43
desktop/src/components/chat/DiffViewer.test.tsx
Normal file
43
desktop/src/components/chat/DiffViewer.test.tsx
Normal 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 }))
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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'} />)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);')
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user