From 914f6d6335001967481aeb6b473a802d8e2b037b Mon Sep 17 00:00:00 2001 From: Robby Cohen Date: Fri, 13 Nov 2026 14:14:58 -0600 Subject: [PATCH 1/8] Show markdown fences rendered with a Source toggle In the reading view, a fence tagged markdown or md shows its body rendered, with a Rendered / Source toggle in the block header. Each page load starts on Rendered. A choice made on the page survives live updates. The hidden source
 stays the anchor text model in both views, so
occurrence counting and the server's anchor resolution are unchanged.
Rendered elements carry body-relative UTF-16 offsets from nested
sourcepos. The browser maps a rendered selection to its source range,
and draws source highlights over the rendered text when Rendered shows.
Only fences at a unique AST code-block position qualify, like math.

Adds a demo library example with a seeded thread, the served Markdown
guide paragraph, and docs in SOURCE_COMMENTS.md.

Co-authored-by: Morty 
Co-Authored-By: Claude Opus 5.5 
---
 docs/DEMO_LIBRARY.md                          |   1 +
 docs/SOURCE_COMMENTS.md                       |  29 +++
 .../assets/stylesheets/coplan/markdown.css    |  96 ++++++++
 engine/app/helpers/coplan/markdown_helper.rb  | 113 +++++++++-
 .../app/helpers/coplan/slideshows_helper.rb   |   5 +-
 .../coplan/content_nav_controller.js          |   2 +
 .../coplan/markdown_block_controller.js       |  49 +++++
 .../coplan/text_selection_controller.js       |  33 ++-
 .../controllers/coplan/voice_controller.js    |  18 +-
 .../coplan/writing_provenance_controller.js   |   2 +-
 .../app/javascript/coplan/markdown_blocks.js  | 194 +++++++++++++++++
 .../services/coplan/plans/source_targets.rb   |  27 ++-
 .../guides/markdown.text.erb                  |   2 +
 engine/config/importmap.rb                    |   2 +
 engine/db/demo/content/markdown-blocks.md     |  53 +++++
 engine/lib/coplan/development_seed.rb         |  10 +
 spec/helpers/slideshows_helper_spec.rb        |  93 ++++++++
 spec/lib/development_seed_spec.rb             |  18 ++
 spec/system/markdown_blocks_spec.rb           | 206 ++++++++++++++++++
 19 files changed, 929 insertions(+), 24 deletions(-)
 create mode 100644 engine/app/javascript/controllers/coplan/markdown_block_controller.js
 create mode 100644 engine/app/javascript/coplan/markdown_blocks.js
 create mode 100644 engine/db/demo/content/markdown-blocks.md
 create mode 100644 spec/system/markdown_blocks_spec.rb

diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md
index b5c8f5f2..aeb5a79a 100644
--- a/docs/DEMO_LIBRARY.md
+++ b/docs/DEMO_LIBRARY.md
@@ -67,6 +67,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff.
 | image-resizing | Drop or paste files to upload and embed; duplicate drops reuse files; resize images, wrap prose, Shift+Enter beside, Enter below |
 | file-uploads | Drop the same image or file again: one attachment and stored copy, multiple embeds; renamed copies, Dual, and Undo |
 | callout-gallery | `> [!SUMMARY]`, `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` callouts, a custom label, nested list and code, plain and escaped quotes, and both deck themes; comment inside a callout, or edit one in Editor and check Raw |
+| markdown-blocks | `markdown` and `md` fences shown as rendered drafts with a Rendered / Source switch; a discussion on the draft shows in both views; select rendered text across a link, press C, then switch to Source |
 | math-gallery | KaTeX math: inline, display, and fenced formulas, math in lists and tables, a six-row slide equation, dollar amounts that stay text, and invalid math shown as source; comment on the text around a formula |
 
 Flowcharts currently support source-backed node and edge comments. Other
diff --git a/docs/SOURCE_COMMENTS.md b/docs/SOURCE_COMMENTS.md
index 9c43c5f6..70c27c60 100644
--- a/docs/SOURCE_COMMENTS.md
+++ b/docs/SOURCE_COMMENTS.md
@@ -67,6 +67,35 @@ Their source context uses the last successfully tracked revision. A stale
 browser selection is rejected; the form retains its draft and asks the
 reader to reopen the latest plan before posting.
 
+## Markdown blocks
+
+A `markdown` or `md` fence renders in the reading view as a Markdown block:
+the rendered draft, a Rendered / Source switch, and the literal source,
+hidden while Rendered shows. Each page load starts on Rendered. A live update
+keeps the view chosen on that page.
+
+Markdown block comments are ordinary text comments with no `anchor_kind`.
+The hidden source stays in the browser's anchor text model in both views, and
+the rendered pane stays out of it. This keeps occurrence counting the same as
+for any code block, on the client and in `MarkdownTextExtractor`.
+
+- The server marks each rendered element with the body range it came from
+  (`data-markdown-from` and `data-markdown-to`, character offsets into the
+  fence body). `coplan/markdown_blocks.js` finds each rendered text node in
+  its element's range. Link text maps to its label, not its URL. Footnote
+  numbers and other generated text do not map.
+- A selection in the rendered draft becomes the source range from its first
+  to its last mapped character. A selection across `**bold**` text or a link
+  therefore stores the Markdown source of that passage.
+- When Rendered shows, a highlight that falls in the hidden source is drawn
+  over the rendered characters that came from that range. A comment made in
+  either view shows in both.
+- A comment anchored only to text with no rendered form, such as part of a
+  URL, is visible in Source only.
+- Only a fence the parser produced becomes a block. Authored HTML imitating
+  one stays ordinary HTML. Fences inside slide decks, editor previews, and
+  comments stay code blocks.
+
 ## Supported syntax
 
 - Markdown table header and body cells, including repeated values, Unicode,
diff --git a/engine/app/assets/stylesheets/coplan/markdown.css b/engine/app/assets/stylesheets/coplan/markdown.css
index c97494f7..99029b8c 100644
--- a/engine/app/assets/stylesheets/coplan/markdown.css
+++ b/engine/app/assets/stylesheets/coplan/markdown.css
@@ -241,6 +241,102 @@
   user-select: none;
 }
 
+/* A `markdown` or `md` fence renders as a Markdown block: the same
+   terminal-style header as a code block, with a Rendered / Source switch,
+   over either the rendered body or the literal source. The header is real
+   DOM (it holds buttons), kept out of the anchor text model by
+   data-markdown-block-chrome. */
+.markdown-rendered .markdown-block {
+  border: var(--code-block-border, 1px solid var(--color-border));
+  border-radius: var(--code-block-radius, var(--radius));
+  margin-bottom: var(--space-md);
+  min-width: 0;
+}
+
+.markdown-rendered .markdown-block [hidden] {
+  display: none !important;
+}
+
+.markdown-block__bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-sm);
+  padding: 0.3em var(--space-sm) 0.3em 4.6em;
+  background-color: var(--code-label-bg, var(--code-header-bg));
+  background-image: var(--code-label-dots,
+    radial-gradient(circle at 1.1em 50%, #ff5f57 0.3em, transparent 0.36em),
+    radial-gradient(circle at 2.2em 50%, #febc2e 0.3em, transparent 0.36em),
+    radial-gradient(circle at 3.3em 50%, #28c840 0.3em, transparent 0.36em));
+  border-bottom: var(--code-label-border, 1px solid var(--color-border));
+  border-radius: var(--code-label-radius, calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0);
+  color: var(--code-header-text);
+  font-family: var(--code-label-font, var(--font-mono));
+  font-size: 0.75rem;
+  line-height: var(--code-label-leading, 1.2);
+  letter-spacing: var(--code-label-tracking, 0.04em);
+  user-select: none;
+}
+
+.markdown-block__views {
+  display: inline-flex;
+  gap: 2px;
+  padding: 2px;
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  background: var(--code-block-bg);
+}
+
+.markdown-block__view {
+  padding: 0.2em 0.65em;
+  border: 0;
+  border-radius: calc(var(--radius) - 2px);
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  letter-spacing: inherit;
+  cursor: pointer;
+}
+
+.markdown-block__view:hover {
+  color: var(--color-text);
+}
+
+.markdown-block__view[aria-pressed="true"] {
+  background: var(--color-surface, var(--color-bg));
+  color: var(--color-text);
+  box-shadow: 0 0 0 1px var(--color-border);
+}
+
+.markdown-block__view:focus-visible {
+  outline: 2px solid var(--color-primary);
+  outline-offset: 1px;
+}
+
+.markdown-block__rendered {
+  padding: var(--space-md) var(--space-lg);
+  min-width: 0;
+  overflow-x: auto;
+}
+
+.markdown-block__rendered > :first-child {
+  margin-top: 0;
+}
+
+.markdown-block__rendered > :last-child {
+  margin-bottom: 0;
+}
+
+.markdown-rendered .markdown-block > pre {
+  margin: 0;
+  border: 0;
+  border-radius: 0 0 var(--code-block-radius, var(--radius)) var(--code-block-radius, var(--radius));
+}
+
+.markdown-rendered .markdown-block > pre[lang]::before {
+  content: none;
+}
+
 .markdown-rendered code {
   font-family: var(--font-mono);
   font-size: 0.875em;
diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb
index 178b18c5..59bc778e 100644
--- a/engine/app/helpers/coplan/markdown_helper.rb
+++ b/engine/app/helpers/coplan/markdown_helper.rb
@@ -30,6 +30,9 @@ module MarkdownHelper
 
     CALLOUT_TYPES = %w[note tip important warning caution summary].freeze
 
+    # Fence languages the reading view shows rendered, with a Source toggle.
+    MARKDOWN_BLOCK_LANGUAGES = %w[markdown md].freeze
+
     # The only elements and values comrak emits for math. Anything else
     # carrying the attribute (authored HTML) loses it during rendering.
     MATH_ELEMENTS = %w[span code pre].freeze
@@ -72,7 +75,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes:
       # and tells parser-generated math from authored HTML that imitates it;
       # it is stripped after generating trusted interaction metadata.
       source = content.to_s.encode("UTF-8")
-      render_options[:sourcepos] = true if interactive || source.include?(ContentRegions::Iframe::PREFIX) || source.include?("$") || source.include?("math")
+      render_options[:sourcepos] = true if interactive || retain_sourcepos || source.include?(ContentRegions::Iframe::PREFIX) || source.include?("$") || source.include?("math")
       html = CoPlan::Markdown.to_html(source, options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil })
       with_chips = transform_mention_anchors(html)
       with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?)
@@ -84,7 +87,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes:
       sanitized = render_iframe_blocks(sanitized, content.to_s)
       sanitized = render_callouts(sanitized)
       result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset, source_comments: source_comments, retain_sourcepos: retain_sourcepos) : sanitized
-      unless interactive
+      unless interactive || retain_sourcepos
         fragment = Nokogiri::HTML::DocumentFragment.parse(result)
         fragment.css("[data-sourcepos]").each { |node| node.remove_attribute("data-sourcepos") }
         result = fragment.to_html
@@ -216,11 +219,12 @@ def select_footnotes(html, mode)
       return html if mode == :inline
 
       doc = Nokogiri::HTML::DocumentFragment.parse(html)
-      sections = doc.css("section[data-footnotes]")
+      # A rendered Markdown block keeps its own footnotes next to its body.
+      sections = doc.css("section[data-footnotes]").reject { |section| section.ancestors("[data-markdown-block-rendered]").any? }
       if mode == :only
         sections.map(&:to_html).join
       elsif mode == :exclude
-        sections.remove
+        sections.each(&:remove)
         doc.to_html
       else
         raise ArgumentError, "unknown footnote mode: #{mode.inspect}"
@@ -284,7 +288,10 @@ def math_positions(source)
     def make_checkboxes_interactive(html, content, line_offset: 0, source_comments: false, retain_sourcepos: false)
       doc = Nokogiri::HTML::DocumentFragment.parse(html)
       source_lines = content.to_s.each_line.map(&:rstrip)
-      Plans::SourceTargets.new(content).annotate(doc) if source_comments && line_offset.zero?
+      if source_comments && line_offset.zero?
+        Plans::SourceTargets.new(content).annotate(doc)
+        render_markdown_blocks(doc, content)
+      end
 
       doc.css('input[type="checkbox"]').each do |cb|
         li = cb.ancestors("li").first
@@ -415,7 +422,8 @@ def render_callouts(html)
     # rendered text — see exactly what they saw before.
     def wrap_data_tables(html)
       doc = Nokogiri::HTML::DocumentFragment.parse(html)
-      tables = doc.css("table").reject { |table| table.ancestors("table").any? }
+      # A Markdown block's rendered pane already wrapped its own tables.
+      tables = doc.css("table").reject { |table| table.ancestors("table, [data-markdown-block-rendered]").any? }
       return html if tables.empty?
 
       tables.each do |table|
@@ -442,7 +450,7 @@ def wrap_data_tables(html)
     # sits inside the link.
     def wrap_expandable_images(html)
       doc = Nokogiri::HTML::DocumentFragment.parse(html)
-      images = doc.css("img")
+      images = doc.css("img").reject { |image| image.ancestors("[data-markdown-block-rendered]").any? }
       return html if images.empty?
 
       images.each do |image|
@@ -464,6 +472,97 @@ def wrap_expandable_images(html)
       doc.to_html
     end
 
+    # In the reading view, a `markdown` or `md` fence shows its body
+    # rendered, with a toggle back to the literal source. The source 
+    # stays in the DOM, hidden, and remains the text that comment anchors
+    # count and match, so the document's anchor text model (and the
+    # server's, MarkdownTextExtractor) is the same whichever view shows.
+    # The rendered pane is kept out of that model; its block elements carry
+    # data-markdown-from/to, body-relative character offsets that let the
+    # browser map a rendered selection or highlight to the source.
+    #
+    # Like math, only a fence the parser produced qualifies: authored HTML
+    # can imitate the lang and sourcepos, so the 
 must sit at the
+    # unique position of a markdown code block in the AST.
+    def render_markdown_blocks(doc, content)
+      pres = doc.css("pre[lang][data-sourcepos]").select do |pre|
+        MARKDOWN_BLOCK_LANGUAGES.include?(pre["lang"].downcase) && pre.ancestors(".deck-region, [data-markdown-block]").none?
+      end
+      return doc if pres.empty?
+
+      positions = markdown_block_positions(content)
+      counts = pres.map { |pre| pre["data-sourcepos"] }.tally
+      pres.each_with_index do |pre, index|
+        next unless positions.include?(pre["data-sourcepos"]) && counts[pre["data-sourcepos"]] == 1
+
+        code = pre.element_children.find { |child| child.name == "code" }
+        next unless code
+
+        block = doc.document.create_element("div", class: "markdown-block", "data-markdown-block" => "",
+          "data-controller" => "coplan--markdown-block")
+        bar = doc.document.create_element("div", class: "markdown-block__bar", "data-markdown-block-chrome" => "")
+        bar.add_child(doc.document.create_element("span", pre["lang"], class: "markdown-block__label"))
+        views = doc.document.create_element("div", class: "markdown-block__views", role: "group", "aria-label" => "Markdown view")
+        { "rendered" => "Rendered", "source" => "Source" }.each do |view, label|
+          views.add_child(doc.document.create_element("button", label, type: "button", class: "markdown-block__view",
+            "aria-pressed" => (view == "rendered").to_s, "data-view" => view,
+            "data-coplan--markdown-block-target" => "button", "data-action" => "coplan--markdown-block#show"))
+        end
+        bar.add_child(views)
+        rendered = doc.document.create_element("div", class: "markdown-block__rendered", "data-markdown-block-rendered" => "",
+          "data-coplan--markdown-block-target" => "rendered")
+        rendered.inner_html = render_markdown_block_body(code.text, index + 1)
+
+        pre.add_previous_sibling(block)
+        pre["hidden"] = ""
+        pre["data-markdown-block-source"] = ""
+        pre["data-coplan--markdown-block-target"] = "source"
+        block.add_child(bar)
+        block.add_child(rendered)
+        block.add_child(pre)
+      end
+      doc
+    end
+
+    # Source positions of the fences that render as Markdown blocks.
+    def markdown_block_positions(content)
+      positions = Set.new
+      CoPlan::Markdown.parse(content.to_s.encode("UTF-8"), options: { extension: EXTENSION_OPTIONS }).walk do |node|
+        next unless node.type == :code_block && MARKDOWN_BLOCK_LANGUAGES.include?(node.fence_info.to_s.split.first&.downcase)
+
+        p = node.source_position
+        positions << "#{p[:start_line]}:#{p[:start_column]}-#{p[:end_line]}:#{p[:end_column]}"
+      end
+      positions
+    end
+
+    # A block's body renders read-only: no task toggles (their lines are
+    # not document lines), no outline headings or heading ids (they would
+    # collide with the document's), and footnotes scoped to the block.
+    def render_markdown_block_body(body, number)
+      html = render_markdown(body, interactive: false, retain_sourcepos: true, footnote_prefix: "markdown-block-#{number}")
+      wrapper = Nokogiri::HTML::DocumentFragment.parse(html).element_children.find { |node| node.name == "div" && node.classes.include?("markdown-rendered") }
+      inner = Nokogiri::HTML::DocumentFragment.parse(wrapper ? wrapper.inner_html : html)
+      inner.css(%w[h1 h2 h3 h4 h5 h6].map { |heading| "#{heading} a.anchor[id]" }.join(", ")).each do |anchor|
+        anchor.remove if anchor.text.strip.empty?
+      end
+      lines = body.lines
+      offsets = [ 0 ]
+      lines.each { |line| offsets << offsets.last + line.length }
+      # Browsers index strings in UTF-16 code units, Ruby in characters.
+      # They differ after a character outside the BMP, such as an emoji.
+      astral = body.match?(/[^\u0000-\uFFFF]/)
+      utf16 = ->(offset) { astral ? body[0, offset].encode("UTF-16LE").bytesize / 2 : offset }
+      inner.css("[data-sourcepos]").each do |element|
+        range = Plans::SourceTargets.char_range(element["data-sourcepos"], lines, offsets)
+        element.remove_attribute("data-sourcepos")
+        next unless range
+
+        element["data-markdown-from"], element["data-markdown-to"] = range.map { |offset| utf16.call(offset).to_s }
+      end
+      inner.to_html
+    end
+
     # Rewrites commonmarker's per-document footnote ids (#fn-N / #fnref-N)
     # and the hrefs that point at them so multiple fragments can coexist in
     # one DOM. Only ids/fragments with the fn-/fnref- shape are touched —
diff --git a/engine/app/helpers/coplan/slideshows_helper.rb b/engine/app/helpers/coplan/slideshows_helper.rb
index d86c3c55..00a6252b 100644
--- a/engine/app/helpers/coplan/slideshows_helper.rb
+++ b/engine/app/helpers/coplan/slideshows_helper.rb
@@ -107,7 +107,10 @@ def render_content_regions(content, interactive: true)
       rendered = Nokogiri::HTML::DocumentFragment.parse(safe_join(fragments))
       document = Nokogiri::HTML::DocumentFragment.parse(render_markdown(result.canonical_source, interactive: false, data_tables: false))
       reconcile_references(rendered, document)
-      Plans::SourceTargets.new(content).annotate(rendered) if interactive
+      if interactive
+        Plans::SourceTargets.new(content).annotate(rendered)
+        render_markdown_blocks(rendered, content)
+      end
       rendered.css("[data-sourcepos]").each { |element| element.remove_attribute("data-sourcepos") }
       rendered.to_html.html_safe
     end
diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js
index 8cc6a746..e5c945ef 100644
--- a/engine/app/javascript/controllers/coplan/content_nav_controller.js
+++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js
@@ -51,7 +51,9 @@ export default class extends Controller {
     // A document renders one .markdown-rendered; a deck renders one per
     // slide. Scan the whole content column either way — scanning the first
     // rendered block gave a deck an outline of slide 1 and nothing else.
+    // A rendered Markdown block is quoted content, not part of the outline.
     const headings = Array.from(documentRoot.querySelectorAll(".markdown-rendered h1, .markdown-rendered h2, .markdown-rendered h3"))
+      .filter(heading => !heading.closest("[data-markdown-block-rendered]"))
     // The title is part of the document and is a navigable heading too.
     this._headings = headings
 
diff --git a/engine/app/javascript/controllers/coplan/markdown_block_controller.js b/engine/app/javascript/controllers/coplan/markdown_block_controller.js
new file mode 100644
index 00000000..17af9cd4
--- /dev/null
+++ b/engine/app/javascript/controllers/coplan/markdown_block_controller.js
@@ -0,0 +1,49 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Switches a Markdown block between its rendered pane and its source
+// (MarkdownHelper#render_markdown_blocks). Each page load starts rendered.
+// A live update re-renders the document, so the choice made on this page
+// is remembered until the next navigation. A block someone switched to
+// Source must not snap back while they read.
+const choices = new WeakMap()
+
+export default class extends Controller {
+  static targets = ["rendered", "source", "button"]
+
+  connect() {
+    const view = choices.get(document.body)?.get(this.key)
+    if (view && view !== this.view) this.apply(view)
+  }
+
+  show(event) {
+    const view = event.currentTarget.dataset.view
+    if (view === this.view) return
+
+    this.apply(view)
+    if (!choices.has(document.body)) choices.set(document.body, new Map())
+    choices.get(document.body).set(this.key, view)
+  }
+
+  get view() {
+    return this.sourceTarget.hidden ? "rendered" : "source"
+  }
+
+  // A live update can add or remove blocks above this one, so the key is
+  // the block's source plus which copy of that source it is.
+  get key() {
+    const source = this.sourceTarget.textContent
+    const copies = Array.from(document.querySelectorAll("[data-markdown-block]"))
+      .filter(block => block.querySelector(":scope > pre[data-markdown-block-source]")?.textContent === source)
+    return `${copies.indexOf(this.element)}:${source}`
+  }
+
+  apply(view) {
+    const source = view === "source"
+    this.sourceTarget.hidden = !source
+    this.renderedTarget.hidden = source
+    this.buttonTargets.forEach(button => button.setAttribute("aria-pressed", String(button.dataset.view === view)))
+    // Comment highlights live in whichever view shows. Ask the text
+    // selection controller to draw them again.
+    this.element.dispatchEvent(new CustomEvent("coplan:highlight-settled", { bubbles: true }))
+  }
+}
diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js
index d195d094..f180c370 100644
--- a/engine/app/javascript/controllers/coplan/text_selection_controller.js
+++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js
@@ -1,6 +1,7 @@
 import { Controller } from "@hotwired/stimulus"
 import { commentWindowTop } from "coplan/comment_window"
 import { revealSections } from "coplan/collapsed_sections"
+import { CHROME_SELECTOR, RENDERED_SELECTOR, markRendered, renderedBlockForSource, sourceOffset, sourceRangeFor } from "coplan/markdown_blocks"
 
 // Elements whose text is never rendered but still appears in textContent —
 // e.g. the