From 92fedc5e7ccae3c02c57974473398e8d91757119 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 13 Jul 2026 15:36:14 -0500 Subject: [PATCH 1/2] Add Mermaid diagrams to plan Markdown --- .../assets/stylesheets/coplan/application.css | 28 +++++ engine/app/helpers/coplan/markdown_helper.rb | 4 +- .../controllers/coplan/mermaid_controller.js | 110 ++++++++++++++++++ .../controllers/coplan/theme_controller.js | 1 + .../coplan/agent_instructions/show.text.erb | 14 +++ engine/config/importmap.rb | 1 + spec/helpers/markdown_helper_spec.rb | 8 ++ spec/requests/agent_instructions_spec.rb | 1 + spec/system/comment_ux_spec.rb | 38 ++++++ 9 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 engine/app/javascript/controllers/coplan/mermaid_controller.js diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index c1419a61..80c93ed0 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1132,6 +1132,34 @@ img.avatar { border-radius: 3px; } +.markdown-rendered .mermaid-diagram { + display: flex; + justify-content: center; + overflow-x: auto; + margin-bottom: var(--space-md); + padding: var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.markdown-rendered .mermaid-diagram svg { + max-width: 100%; + height: auto; +} + +.markdown-rendered .mermaid-diagram--error { + border-color: var(--color-danger); +} + +.markdown-rendered .mermaid-diagram__error-message { + display: block; + margin-bottom: var(--space-sm); + color: var(--color-danger); + font-family: var(--font-sans); + font-weight: 600; +} + .markdown-rendered blockquote { border-left: 3px solid var(--color-border); padding-left: var(--space-md); diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index f7029415..fba5b599 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -14,7 +14,7 @@ module MarkdownHelper details summary ].freeze - ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze + ALLOWED_ATTRIBUTES = %w[id class lang href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -27,7 +27,7 @@ def render_markdown(content, interactive: true) with_chips = transform_mention_anchors(html) sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized - tag.div(result.html_safe, class: "markdown-rendered") + tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid" }) end # Replaces `@username` produced by diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js new file mode 100644 index 00000000..defe06e8 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -0,0 +1,110 @@ +import { Controller } from "@hotwired/stimulus" + +let diagramId = 0 +let mermaidPromise +let configuredTheme + +export default class extends Controller { + connect() { + this.renderGeneration ||= 0 + this.boundThemeChange = () => this.renderDiagrams() + this.colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)") + window.addEventListener("coplan:theme-changed", this.boundThemeChange) + this.colorSchemeQuery.addEventListener("change", this.boundThemeChange) + this.renderDiagrams() + } + + disconnect() { + this.renderGeneration += 1 + window.removeEventListener("coplan:theme-changed", this.boundThemeChange) + this.colorSchemeQuery.removeEventListener("change", this.boundThemeChange) + } + + async renderDiagrams() { + const sources = [ + ...Array.from(this.element.querySelectorAll('pre[lang="mermaid"] > code'), block => ({ + container: block.parentElement, + source: block.textContent + })), + ...Array.from(this.element.querySelectorAll(".mermaid-diagram[data-mermaid-source]"), diagram => ({ + container: diagram, + source: diagram.dataset.mermaidSource + })) + ] + if (sources.length === 0) return + + let mermaid + try { + mermaid = await loadMermaid() + } catch { + sources.forEach(({ container }) => this.showError(container)) + return + } + + const theme = configureMermaid(mermaid) + const generation = ++this.renderGeneration + + for (const { container, source } of sources) { + await this.renderDiagram(mermaid, container, source, theme, generation) + } + } + + async renderDiagram(mermaid, sourceContainer, source, theme, generation) { + const id = `coplan-mermaid-${++diagramId}` + + try { + const { svg, bindFunctions } = await mermaid.render(id, source) + if (!this.element.isConnected || generation !== this.renderGeneration) return + + const diagram = document.createElement("div") + diagram.className = "mermaid-diagram" + diagram.setAttribute("role", "img") + diagram.setAttribute("aria-label", "Mermaid diagram") + diagram.dataset.mermaidSource = source + diagram.dataset.mermaidTheme = theme + diagram.innerHTML = svg + sourceContainer.replaceWith(diagram) + bindFunctions?.(diagram) + } catch { + document.getElementById(id)?.remove() + document.getElementById(`d${id}`)?.remove() + this.showError(sourceContainer) + } + } + + showError(sourceContainer) { + if (!sourceContainer.matches('pre[lang="mermaid"]')) return + + sourceContainer.classList.add("mermaid-diagram--error") + sourceContainer.removeAttribute("lang") + + const message = document.createElement("span") + message.className = "mermaid-diagram__error-message" + message.textContent = "Diagram could not be rendered." + sourceContainer.prepend(message) + } +} + +function loadMermaid() { + if (mermaidPromise) return mermaidPromise + + mermaidPromise = import("mermaid").then(({ default: mermaid }) => mermaid) + return mermaidPromise +} + +function configureMermaid(mermaid) { + const explicitTheme = document.documentElement.dataset.theme + const dark = explicitTheme === "dark" || + (!explicitTheme && window.matchMedia("(prefers-color-scheme: dark)").matches) + const theme = dark ? "dark" : "default" + if (theme === configuredTheme) return theme + + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme + }) + configuredTheme = theme + return theme +} diff --git a/engine/app/javascript/controllers/coplan/theme_controller.js b/engine/app/javascript/controllers/coplan/theme_controller.js index af794cb8..6f8e7794 100644 --- a/engine/app/javascript/controllers/coplan/theme_controller.js +++ b/engine/app/javascript/controllers/coplan/theme_controller.js @@ -15,6 +15,7 @@ export default class extends Controller { if (meta) { meta.content = theme === "system" ? "light dark" : theme } + window.dispatchEvent(new CustomEvent("coplan:theme-changed")) const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content fetch(this.urlValue, { diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index 42b19e1f..2800536a 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -46,6 +46,20 @@ Returns: plan metadata, `current_content`, `current_revision`, `comment_threads` Optional fields: `plan_type` (string) — the name of a plan type to use. See [Plan Types](#plan-types) below. +#### Diagrams + +Plans support Mermaid diagrams. When a diagram communicates architecture, data flow, sequence, or state more clearly than prose, write a fenced `mermaid` block in the plan Markdown: + +````markdown +```mermaid +flowchart LR + Client --> API + API --> Database +``` +```` + +The web UI renders these blocks as diagrams. Keep the Mermaid source in the plan so humans and agents can review and edit it alongside the surrounding Markdown. + ### Update Plan Update plan metadata (title, status, tags). Only fields included in the request body are changed. diff --git a/engine/config/importmap.rb b/engine/config/importmap.rb index 50041453..82290eef 100644 --- a/engine/config/importmap.rb +++ b/engine/config/importmap.rb @@ -1,3 +1,4 @@ pin "@rails/actioncable", to: "actioncable.esm.js" pin "coplan/web_push", to: "coplan/web_push.js" +pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.esm.min.mjs", preload: false pin_all_from CoPlan::Engine.root.join("app/javascript/controllers/coplan"), under: "controllers/coplan", preload: true diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index d286a056..8034891e 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -25,6 +25,14 @@ html = helper.render_markdown(nil) expect(html).to include("markdown-rendered") end + + it "marks rendered markdown for Mermaid enhancement" do + html = helper.render_markdown("```mermaid\ngraph LR\n A --> B\n```") + + expect(html).to include('data-controller="coplan--mermaid"') + expect(html).to include('
')
+      expect(html).to include("graph LR")
+    end
   end
 
   describe "#render_line_view" do
diff --git a/spec/requests/agent_instructions_spec.rb b/spec/requests/agent_instructions_spec.rb
index dc02d404..b7f2bbaf 100644
--- a/spec/requests/agent_instructions_spec.rb
+++ b/spec/requests/agent_instructions_spec.rb
@@ -7,6 +7,7 @@
       expect(response).to have_http_status(:success)
       expect(response.content_type).to include("text/markdown")
       expect(response.body).to include("# CoPlan API")
+      expect(response.body).to include("```mermaid")
     end
 
     it "includes plan types when they exist" do
diff --git a/spec/system/comment_ux_spec.rb b/spec/system/comment_ux_spec.rb
index c89e7ed8..b4765b0e 100644
--- a/spec/system/comment_ux_spec.rb
+++ b/spec/system/comment_ux_spec.rb
@@ -71,6 +71,44 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:)
       expect(page).to have_content("Architecture Overview")
       expect(page).to have_content("microservices architecture")
     end
+
+    it "renders Mermaid fences as diagrams" do
+      plan.current_plan_version.update!(content_markdown: <<~MARKDOWN)
+        # Request flow
+
+        ```mermaid
+        flowchart LR
+          Client --> API
+          API --> Database
+        ```
+      MARKDOWN
+
+      visit plan_path(plan)
+
+      expect(page).to have_css(".mermaid-diagram svg", wait: 10)
+      expect(page).not_to have_css('pre[lang="mermaid"]')
+
+      page.execute_script(<<~JS)
+        document.documentElement.dataset.theme = "dark"
+        window.dispatchEvent(new CustomEvent("coplan:theme-changed"))
+      JS
+      expect(page).to have_css('.mermaid-diagram[data-mermaid-theme="dark"]', wait: 10)
+    end
+
+    it "keeps invalid Mermaid source readable" do
+      plan.current_plan_version.update!(content_markdown: <<~MARKDOWN)
+        ```mermaid
+        not a valid diagram
+        ```
+      MARKDOWN
+
+      visit plan_path(plan)
+
+      expect(page).to have_css(".mermaid-diagram--error", wait: 10)
+      expect(page).to have_content("Diagram could not be rendered.")
+      expect(page).to have_content("not a valid diagram")
+      expect(page).not_to have_css('[id^="dcoplan-mermaid-"]', visible: :all)
+    end
   end
 
   describe "inline highlights" do

From 53d72048686886b01a0221c24caf743a46ad0154 Mon Sep 17 00:00:00 2001
From: Hampton Lintorn-Catlin 
Date: Mon, 13 Jul 2026 15:53:23 -0500
Subject: [PATCH 2/2] Improve Mermaid dark mode colors

---
 .../controllers/coplan/mermaid_controller.js  | 44 ++++++++++++++++++-
 1 file changed, 42 insertions(+), 2 deletions(-)

diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js
index defe06e8..fd4945be 100644
--- a/engine/app/javascript/controllers/coplan/mermaid_controller.js
+++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js
@@ -96,15 +96,55 @@ function configureMermaid(mermaid) {
   const explicitTheme = document.documentElement.dataset.theme
   const dark = explicitTheme === "dark" ||
     (!explicitTheme && window.matchMedia("(prefers-color-scheme: dark)").matches)
-  const theme = dark ? "dark" : "default"
+  const theme = dark ? "dark" : "light"
   if (theme === configuredTheme) return theme
 
   mermaid.initialize({
     startOnLoad: false,
     securityLevel: "strict",
     suppressErrorRendering: true,
-    theme
+    theme: dark ? "base" : "default",
+    ...(dark && { themeVariables: darkThemeVariables() })
   })
   configuredTheme = theme
   return theme
 }
+
+function darkThemeVariables() {
+  return {
+    darkMode: true,
+    background: "#0f172a",
+    primaryColor: "#1e3a5f",
+    primaryBorderColor: "#60a5fa",
+    primaryTextColor: "#eff6ff",
+    secondaryColor: "#3b255f",
+    secondaryBorderColor: "#a78bfa",
+    secondaryTextColor: "#f5f3ff",
+    tertiaryColor: "#123f3a",
+    tertiaryBorderColor: "#34d399",
+    tertiaryTextColor: "#ecfdf5",
+    lineColor: "#93c5fd",
+    textColor: "#e5eefc",
+    mainBkg: "#1e3a5f",
+    nodeBorder: "#60a5fa",
+    clusterBkg: "#172554",
+    clusterBorder: "#818cf8",
+    edgeLabelBackground: "#152033",
+    actorBkg: "#312e81",
+    actorBorder: "#a5b4fc",
+    actorTextColor: "#eef2ff",
+    actorLineColor: "#64748b",
+    signalColor: "#7dd3fc",
+    signalTextColor: "#e0f2fe",
+    labelBoxBkgColor: "#123f3a",
+    labelBoxBorderColor: "#34d399",
+    labelTextColor: "#ecfdf5",
+    activationBkgColor: "#164e63",
+    activationBorderColor: "#22d3ee",
+    noteBkgColor: "#713f12",
+    noteBorderColor: "#fbbf24",
+    noteTextColor: "#fef3c7",
+    labelColor: "#eff6ff",
+    altBackground: "#3b255f"
+  }
+}