From 134b7968598972574b885f01147109c0218747c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 15:57:03 +0200 Subject: [PATCH] fix(markdown): derive table and task list colours from the theme --- .../src/components/markdown/renderer.test.ts | 56 +++++++++++ .../app/src/components/markdown/renderer.tsx | 82 +-------------- .../app/src/components/markdown/styles.ts | 99 +++++++++++++++++++ 3 files changed, 158 insertions(+), 79 deletions(-) create mode 100644 packages/app/src/components/markdown/styles.ts diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 188ad4fedf..9183909162 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { darkTheme, lightTheme } from "@/styles/theme"; +import { createMarkdownStyles } from "./styles"; import { prepareMarkdownSource } from "./source"; describe("shared Markdown source adaptation", () => { @@ -139,3 +141,57 @@ it.each(['""', "''", "()"])("preserves an authored empty link title: %s", (title const source = `[Open](/repo/file.ts ${title})`; expect(prepareMarkdownSource(source, "/repo").render()).toBe(source); }); + +describe("shared Markdown theme colours", () => { + it.each([ + ["dark", darkTheme], + ["light", lightTheme], + ] as const)("uses %s theme colours for tables and task labels", (_name, theme) => { + const { markdownStyle: style } = createMarkdownStyles(theme); + expect(style.table).toMatchObject({ + color: theme.colors.foreground, + headerTextColor: theme.colors.foreground, + headerBackgroundColor: theme.colors.surface2, + rowEvenBackgroundColor: theme.colors.surface0, + rowOddBackgroundColor: theme.colors.surface1, + borderColor: theme.colors.border, + }); + expect(style.list?.color).toBe(theme.colors.foreground); + expect(style.taskList).toMatchObject({ + checkedTextColor: theme.colors.foregroundMuted, + checkedColor: theme.colors.accent, + checkmarkColor: theme.colors.accentForeground, + borderColor: theme.colors.foregroundMuted, + }); + }); +}); + +it.each([ + ["dark", darkTheme], + ["light", lightTheme], +] as const)( + "uses %s theme colours for quotes, admonitions, code, links and dividers", + (_name, theme) => { + const { markdownStyle: style } = createMarkdownStyles(theme); + expect(style.blockquote).toMatchObject({ + color: theme.colors.foregroundMuted, + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.border, + admonitions: { + note: { color: theme.colors.accentBright, backgroundColor: theme.colors.surface2 }, + tip: { color: theme.colors.statusSuccess, backgroundColor: theme.colors.surface2 }, + important: { color: theme.colors.statusMerged, backgroundColor: theme.colors.surface2 }, + warning: { color: theme.colors.statusWarning, backgroundColor: theme.colors.surface2 }, + caution: { color: theme.colors.statusDanger, backgroundColor: theme.colors.surface2 }, + }, + }); + expect(style.code).toMatchObject({ + color: theme.colors.foreground, + backgroundColor: theme.colors.surface2, + borderColor: "transparent", + }); + expect(style.link?.color).toBe(theme.colors.accent); + expect(style.linkVariants?.["^(?!https?://).+"]?.color).toBe(theme.colors.accent); + expect(style.thematicBreak?.color).toBe(theme.colors.border); + }, +); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 057eebd7e3..1b4bfa9518 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -3,7 +3,6 @@ import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/mark import { memo, useMemo, useState } from "react"; import { EnrichedMarkdownText, - type MarkdownStyle, type LinkPressEvent, type CodeBlockRendererProps, } from "react-native-enriched-markdown"; @@ -17,6 +16,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import type { Theme } from "@/styles/theme"; import { isWeb } from "@/constants/platform"; +import { createMarkdownStyles } from "./styles"; import { useMarkdownSource, type MarkdownImageContext } from "./images"; import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fence/mermaid"; @@ -24,84 +24,8 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen const ThemedMarkdown = withUnistyles(EnrichedMarkdownText); const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }; -function markdownStyle(theme: Theme, compact = false): { markdownStyle: MarkdownStyle } { - const fontSize = compact ? theme.fontSize.base : theme.fontSize.content; - const paragraph = { - fontSize, - fontFamily: theme.fontFamily.ui, - lineHeight: Math.round(fontSize * 1.4), - color: theme.colors.foreground, - marginTop: 0, - marginBottom: theme.spacing[3], - }; - const heading = (scale: number) => ({ - ...paragraph, - fontSize: Math.round(fontSize * scale), - lineHeight: Math.round(fontSize * scale * 1.3), - fontWeight: "bold", - marginTop: theme.spacing[3], - }); - return { - markdownStyle: { - paragraph, - h1: heading(1.6), - h2: heading(1.4), - h3: heading(1.2), - h4: heading(1.1), - h5: heading(1), - h6: heading(1), - list: { - ...paragraph, - bulletColor: theme.colors.foreground, - markerColor: theme.colors.foregroundMuted, - gapWidth: 7, - itemSpacing: 4, - marginLeft: 20, - }, - blockquote: { - ...paragraph, - color: theme.colors.foregroundMuted, - backgroundColor: theme.colors.surface2, - borderColor: theme.colors.border, - borderWidth: 3, - padding: 12, - gapWidth: 8, - }, - link: { color: theme.colors.accent, underline: false }, - linkVariants: { - "^(?!https?://).+": { - color: theme.colors.accent, - pill: { borderRadius: 4, paddingHorizontal: 3 }, - }, - }, - code: { - fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - backgroundColor: theme.colors.surface2, - borderColor: "transparent", - }, - codeBlock: { - ...paragraph, - fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, - fontSize: theme.fontSize.code, - backgroundColor: theme.colors.surface2, - borderColor: theme.colors.border, - borderRadius: 8, - padding: 12, - }, - table: { - ...paragraph, - borderColor: theme.colors.border, - headerBackgroundColor: theme.colors.surface2, - }, - image: { height: 320, resizeMode: "contain", borderRadius: 8 }, - thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 }, - }, - }; -} -const regularStyle = (theme: Theme) => markdownStyle(theme); -const compactStyle = (theme: Theme) => markdownStyle(theme, true); +const regularStyle = (theme: Theme) => createMarkdownStyles(theme); +const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true); const ThemedCodeBlock = withUnistyles(HighlightedCodeBlock); const codeStyle = (theme: Theme) => ({ inheritedStyles: { diff --git a/packages/app/src/components/markdown/styles.ts b/packages/app/src/components/markdown/styles.ts new file mode 100644 index 0000000000..a3dc51a73a --- /dev/null +++ b/packages/app/src/components/markdown/styles.ts @@ -0,0 +1,99 @@ +import type { MarkdownStyle } from "react-native-enriched-markdown"; +import type { Theme } from "@/styles/theme"; +import { isWeb } from "@/constants/platform"; + +export function createMarkdownStyles( + theme: Theme, + compact = false, +): { markdownStyle: MarkdownStyle } { + const fontSize = compact ? theme.fontSize.base : theme.fontSize.content; + const paragraph = { + fontSize, + fontFamily: theme.fontFamily.ui, + lineHeight: Math.round(fontSize * 1.4), + color: theme.colors.foreground, + marginTop: 0, + marginBottom: theme.spacing[3], + }; + const heading = (scale: number) => ({ + ...paragraph, + fontSize: Math.round(fontSize * scale), + lineHeight: Math.round(fontSize * scale * 1.3), + fontWeight: "bold", + marginTop: theme.spacing[3], + }); + return { + markdownStyle: { + paragraph, + h1: heading(1.6), + h2: heading(1.4), + h3: heading(1.2), + h4: heading(1.1), + h5: heading(1), + h6: heading(1), + list: { + ...paragraph, + bulletColor: theme.colors.foreground, + markerColor: theme.colors.foregroundMuted, + gapWidth: 7, + itemSpacing: 4, + marginLeft: 20, + }, + blockquote: { + ...paragraph, + color: theme.colors.foregroundMuted, + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.border, + borderWidth: 3, + padding: 12, + gapWidth: 8, + admonitions: { + note: { color: theme.colors.accentBright, backgroundColor: theme.colors.surface2 }, + tip: { color: theme.colors.statusSuccess, backgroundColor: theme.colors.surface2 }, + important: { color: theme.colors.statusMerged, backgroundColor: theme.colors.surface2 }, + warning: { color: theme.colors.statusWarning, backgroundColor: theme.colors.surface2 }, + caution: { color: theme.colors.statusDanger, backgroundColor: theme.colors.surface2 }, + }, + }, + link: { color: theme.colors.accent, underline: false }, + linkVariants: { + "^(?!https?://).+": { + color: theme.colors.accent, + pill: { borderRadius: 4, paddingHorizontal: 3 }, + }, + }, + code: { + fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, + fontSize: theme.fontSize.code, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface2, + borderColor: "transparent", + }, + codeBlock: { + ...paragraph, + fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, + fontSize: theme.fontSize.code, + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.border, + borderRadius: 8, + padding: 12, + }, + table: { + ...paragraph, + borderColor: theme.colors.border, + headerBackgroundColor: theme.colors.surface2, + headerTextColor: theme.colors.foreground, + rowEvenBackgroundColor: theme.colors.surface0, + rowOddBackgroundColor: theme.colors.surface1, + }, + taskList: { + checkedTextColor: theme.colors.foregroundMuted, + checkedColor: theme.colors.accent, + checkmarkColor: theme.colors.accentForeground, + borderColor: theme.colors.foregroundMuted, + }, + image: { height: 320, resizeMode: "contain", borderRadius: 8 }, + thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 }, + }, + }; +}