From 27401ad4387008f015fb6a549ebf0428eefa82d8 Mon Sep 17 00:00:00 2001 From: LzSkyline Date: Tue, 10 Nov 2026 01:19:12 +0800 Subject: [PATCH] fix(markdown): stop inline Mermaid diagrams rendering black (#2322) On a dark theme Mermaid diagrams painted near-black: node fills and borders came from the editor surface and chrome border, class-diagram labels used nodeBorder, and with darkMode Mermaid darkened every derived series colour by 75%, turning pies black. Fills now use a lifted surface, strokes and labels use the foreground, series colours are passed in explicitly, Gantt task labels stay readable against their bars, and diagrams use the classic look (the neo look hard-codes a black start state). Applies to the Markdown preview and agent chat, which share the theme helper; covered for flowchart, sequence, class, state, pie and Gantt in light and dark. Co-authored-by: lzskyline <9845170+lzskyline@users.noreply.github.com> --- e2e/agentMarkdown.spec.ts | 65 ++++++++- src/features/viewers/markdown/mermaid.test.ts | 50 ++++++- src/features/viewers/markdown/mermaid.ts | 131 ++++++++++++++++-- 3 files changed, 230 insertions(+), 16 deletions(-) diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts index 112260457..f0bbec081 100644 --- a/e2e/agentMarkdown.spec.ts +++ b/e2e/agentMarkdown.spec.ts @@ -48,11 +48,12 @@ const REPLY = [ "```", ].join("\n"); -async function openReply(page: Page, text: string, imageData?: string) { +async function openReply(page: Page, text: string, imageData?: string, theme?: "dark" | "light") { await page.goto("/"); await page.getByRole("tree", { name: "Project" }).waitFor(); await page.evaluate( - async ({ task, text, imageData }) => { + async ({ task, text, imageData, theme }) => { + if (theme) document.documentElement.dataset.theme = theme; if (imageData) { const routePath = "/src/platform/tauriCore.ts"; const { setInvokeRoute } = await import(routePath); @@ -80,7 +81,7 @@ async function openReply(page: Page, text: string, imageData?: string) { }); store.dockTask(task.id); }, - { task: TASK, text, imageData }, + { task: TASK, text, imageData, theme }, ); const reply = page.locator("[class*='_markdown_']").first(); await reply.locator("h1, p").first().waitFor(); @@ -387,3 +388,61 @@ test("keeps mermaid flowchart nodes and sequence control blocks off solid black" expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)"); expect(await css(reply, ".labelBox", "fill")).not.toBe("rgb(0, 0, 0)"); }); + +const DIAGRAMS = [ + "flowchart LR\n subgraph Client\n A[Local agent] --> B[Voice input]\n end", + "sequenceDiagram\n loop every minute\n A->>B: ping\n end", + "classDiagram\n class Agent {\n +String name\n +run()\n }", + "stateDiagram-v2\n [*] --> Ready\n Ready --> Running\n Running --> [*]", + 'pie title Work\n "Ready" : 40\n "Running" : 35\n "Done" : 25', + "gantt\n dateFormat YYYY-MM-DD\n section Delivery\n Shipped :done, shipped, 2026-01-01, 5d\n Building :active, building, after shipped, 5d\n Next :next, after building, 5d\n Critical :crit, critical, after next, 5d", +] + .map((source) => `\`\`\`mermaid\n${source}\n\`\`\``) + .join("\n\n"); + +for (const theme of ["dark", "light"] as const) { + test(`keeps Mermaid shapes, labels and chart series readable in ${theme} theme`, async ({ page }) => { + const reply = await openReply(page, `Diagrams:\n\n${DIAGRAMS}`, undefined, theme); + const svgs = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg"); + await expect(svgs).toHaveCount(6); + for (const [index, selector] of [ + [0, ".node rect"], + [1, ".labelBox"], + [2, ".outer-path path"], + [3, ".node rect"], + ] as const) { + expect(await css(svgs.nth(index), selector, "fill")).not.toBe("rgb(0, 0, 0)"); + expect(await css(svgs.nth(index), selector, "stroke")).not.toContain("url("); + } + expect(await css(svgs.nth(2), ".nodeLabel", "color")).not.toBe(await css(svgs.nth(2), ".outer-path path", "fill")); + const slices = await svgs + .nth(4) + .locator(".pieCircle") + .evaluateAll((els) => els.map((el) => getComputedStyle(el).fill)); + expect(new Set(slices).size).toBe(3); + expect(slices).not.toContain("rgb(0, 0, 0)"); + const contrasts = await svgs + .nth(5) + .locator("text.taskText") + .evaluateAll((els) => { + const luminance = (color: string) => { + const [r, g, b] = color + .match(/\d+/g)! + .map(Number) + .map((v) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; + }; + return els.map((el) => { + const bar = document.getElementById(el.id.replace(/-text$/, ""))!; + const fg = luminance(getComputedStyle(el).fill); + const bg = luminance(getComputedStyle(bar).fill); + return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) }; + }); + }); + expect(contrasts).toHaveLength(4); + for (const { text, ratio } of contrasts) expect(ratio, `${theme}: ${text}`).toBeGreaterThanOrEqual(4.5); + }); +} diff --git a/src/features/viewers/markdown/mermaid.test.ts b/src/features/viewers/markdown/mermaid.test.ts index 77c4f0789..9cfc4e5a1 100644 --- a/src/features/viewers/markdown/mermaid.test.ts +++ b/src/features/viewers/markdown/mermaid.test.ts @@ -77,7 +77,7 @@ describe("mermaid in the markdown preview", () => { await renderMermaidIn(root).done; expect(m.loads).toBe(1); expect(m.initialize).toHaveBeenCalledWith( - expect.objectContaining({ startOnLoad: false, securityLevel: "strict", theme: "base" }), + expect.objectContaining({ startOnLoad: false, securityLevel: "strict", theme: "base", look: "classic" }), ); const block = root.querySelector("[data-mermaid]")!; expect(block.getAttribute("data-mermaid-state")).toBe("ok"); @@ -282,6 +282,54 @@ describe("mermaid in the markdown preview", () => { } }); + it("lifts dark-theme shapes off the editor background and paints labels with the foreground", async () => { + const root = document.documentElement; + const previous = root.dataset.theme; + root.dataset.theme = "dark"; + const values: Record = { + "--bg-editor": "#191a1c", + "--bg-elev": "#212326", + "--bg-floating-toolbar": "#33353b", + "--bg-selected": "#2a4371", + "--border-strong": "#40434a", + "--fg-strong": "#dfe1e5", + "--fg": "#d1d3d9", + "--fg-secondary": "#9fa2a8", + "--accent": "#3871e1", + "--chart-series": "#e07a7c", + "--danger": "#f57e84", + }; + const computed = vi + .spyOn(globalThis, "getComputedStyle") + .mockImplementation( + () => ({ getPropertyValue: (n: string) => values[n] ?? "" }) as unknown as CSSStyleDeclaration, + ); + try { + await renderMermaidIn(preview(SEQ)).done; + expect(m.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + look: "classic", + themeVariables: expect.objectContaining({ + darkMode: true, + useGradient: false, + background: "#191a1c", + mainBkg: "#33353b", + nodeBorder: "#dfe1e5", + primaryBorderColor: "#dfe1e5", + primaryColor: "#3871e1", + cScale0: "#3871e1", + lineColor: "#9fa2a8", + classText: "#dfe1e5", + }), + }), + ); + } finally { + computed.mockRestore(); + if (previous === undefined) delete root.dataset.theme; + else root.dataset.theme = previous; + } + }); + it("passes colours a theme writes as hsl() or color-mix() to mermaid as RGB", async () => { const values: Record = { "--bg-editor": "hsl(220, 13%, 18%)", diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index bd9ccbbbd..1e88ff5e4 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -33,6 +33,12 @@ function loadMermaid(): Promise { /** * Diagram colours from the IDE tokens. Mermaid parses only hex and `rgb()`, so * tokens a theme writes as `hsl()`, `color-mix()` or `var()` are resolved to RGB. + * + * Shape fills cannot be the editor surface: on a dark theme that is nearly the + * preview background, so nodes, actors and slices paint as black. Class-diagram + * text is `nodeBorder`, so that has to be the foreground, not a chrome border. + * `darkMode` then darkens every `cScale*` by 75%; series colours are passed + * explicitly because a later override restores them. */ function themeVariables(): Record { const root = document.documentElement; @@ -46,21 +52,119 @@ function themeVariables(): Record { } return undefined; }; + const dark = root.dataset.theme !== "light"; + const canvas = token("--bg-editor", "--bg-app"); + const surface = dark ? token("--bg-floating-toolbar", "--bg-popup", "--bg-elev") : token("--bg-elev", "--bg-popup"); + const cluster = dark + ? token("--bg-selected", "--bg-floating-toolbar", "--bg-elev") + : token("--bg-hover", "--bg-elev"); + const ink = token("--fg-strong", "--fg"); + const line = token("--fg-secondary", "--fg"); + const text = token("--fg"); + const series = [ + "--accent", + "--chart-series", + "--success", + "--warning", + "--info", + "--danger", + "--link", + "--vcs-modified", + ] + .map((name) => token(name)) + .filter((color): color is string => Boolean(color)); const vars: Record = { - background: token("--bg-editor", "--bg-app"), - mainBkg: token("--bg-elev"), - primaryColor: token("--bg-elev"), - primaryBorderColor: token("--border-strong"), - primaryTextColor: token("--fg-strong", "--fg"), - secondaryColor: token("--bg-popup"), - tertiaryColor: token("--bg-hover"), - lineColor: token("--fg-secondary"), - textColor: token("--fg"), - edgeLabelBackground: token("--bg-editor", "--bg-app"), - errorBkgColor: token("--bg-elev"), + background: canvas, + mainBkg: surface, + secondBkg: surface, + primaryColor: series[0] ?? surface, + secondaryColor: series[1] ?? surface, + tertiaryColor: series[2] ?? surface, + primaryBorderColor: ink, + secondaryBorderColor: line, + tertiaryBorderColor: line, + nodeBorder: ink, + primaryTextColor: ink, + secondaryTextColor: ink, + tertiaryTextColor: ink, + nodeTextColor: ink, + textColor: text, + titleColor: ink, + classText: ink, + lineColor: line, + defaultLinkColor: line, + edgeLabelBackground: canvas, + clusterBkg: cluster, + clusterBorder: line, + actorBkg: surface, + actorBorder: line, + actorTextColor: ink, + actorLineColor: line, + signalColor: text, + signalTextColor: text, + labelBoxBkgColor: surface, + labelBoxBorderColor: line, + labelTextColor: ink, + loopTextColor: ink, + activationBkgColor: cluster, + activationBorderColor: line, + sequenceNumberColor: ink, + sectionBkgColor: cluster, + sectionBkgColor2: surface, + altSectionBkgColor: canvas, + excludeBkgColor: surface, + taskBkgColor: surface, + taskBorderColor: ink, + activeTaskBkgColor: cluster, + activeTaskBorderColor: token("--accent"), + doneTaskBkgColor: surface, + doneTaskBorderColor: line, + critBkgColor: surface, + critBorderColor: token("--danger"), + taskTextColor: ink, + taskTextDarkColor: ink, + taskTextOutsideColor: text, + taskTextClickableColor: token("--link", "--accent"), + gridColor: line, + todayLineColor: token("--danger"), + vertLineColor: token("--accent"), + labelBackgroundColor: surface, + compositeBackground: canvas, + compositeTitleBackground: surface, + compositeBorder: line, + innerEndBackground: canvas, + altBackground: cluster, + pieStrokeColor: line, + pieOuterStrokeColor: line, + pieTitleTextColor: ink, + pieSectionTextColor: text, + pieLegendTextColor: ink, + branchLabelColor: ink, + tagLabelColor: ink, + tagLabelBackground: surface, + commitLabelColor: ink, + commitLabelBackground: surface, + archEdgeColor: line, + archEdgeArrowColor: line, + archGroupBorderColor: line, + errorBkgColor: surface, errorTextColor: token("--danger"), + attributeBackgroundColorOdd: surface, + attributeBackgroundColorEven: cluster, }; - const out: Record = { darkMode: document.documentElement.dataset.theme !== "light" }; + for (let i = 0; i < 12 && series.length; i++) { + const color = series[i % series.length]!; + vars[`cScale${i}`] = color; + vars[`cScaleInv${i}`] = ink; + vars[`cScaleLabel${i}`] = ink; + vars[`pie${i + 1}`] = color; + if (i < 8) { + vars[`git${i}`] = color; + vars[`gitBranchLabel${i}`] = ink; + } + } + // `none` drops the built-in light-grey halo, which reads as a grey smudge on dark surfaces. + const out: Record = { darkMode: dark, useGradient: false, dropShadow: "none" }; for (const [k, v] of Object.entries(vars)) if (v) out[k] = v; const font = css.getPropertyValue("--font-ui").trim(); if (font) out.fontFamily = font; @@ -74,6 +178,9 @@ function configure(mermaid: Mermaid, key: string) { securityLevel: "strict", suppressErrorRendering: true, theme: "base", + // Each diagram type defaults to "neo": the start state is hard-coded + // black, and strokes become a gradient of the border colour. + look: "classic", themeVariables: themeVariables(), }); configured = key;