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;