From cb5bfe0d40e57e318f3022ac056cf2c6df3cd9bd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 29 Mar 2026 22:35:02 -0400 Subject: [PATCH 1/7] docs(penpal): add P/E-PENPAL-HIGHLIGHT-MEDIA requirements and test mapping Define the product requirement for expanding highlights to encompass entire images and mermaid diagrams when they intersect with text, and the corresponding engineering requirement for the rehype plugin behavior. Co-Authored-By: Claude Opus 4.6 --- apps/penpal/ERD.md | 3 +++ apps/penpal/PRODUCT.md | 2 ++ apps/penpal/TESTING.md | 1 + 3 files changed, 6 insertions(+) diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md index 8eb06d17..669ee710 100644 --- a/apps/penpal/ERD.md +++ b/apps/penpal/ERD.md @@ -136,6 +136,9 @@ see-also: - **E-PENPAL-HIGHLIGHT-CROSS**: The rehype plugin splits and wraps `` elements across inline formatting boundaries (bold, italic, code, links) and across block boundaries between code blocks and prose. Each contiguous segment of highlighted text gets its own `` wrapper so the highlight renders correctly regardless of intervening HTML structure. ← [P-PENPAL-HIGHLIGHT-CROSS](PRODUCT.md#P-PENPAL-HIGHLIGHT-CROSS) +- **E-PENPAL-HIGHLIGHT-MEDIA**: When a matched highlight range partially intersects an `` or mermaid `
` element while also spanning non-image/diagram text, the rehype plugin expands the highlight to encompass the entire media element. Inline images sandwiched between `` elements with the same `threadId` are wrapped in a matching ``. Block-level image paragraphs and mermaid `
` blocks encountered during cross-element continuation are wrapped in `` without consuming text from the remaining match.
+  ← [P-PENPAL-HIGHLIGHT-MEDIA](PRODUCT.md#P-PENPAL-HIGHLIGHT-MEDIA)
+
 ---
 
 ## Mermaid Diagram Anchoring
diff --git a/apps/penpal/PRODUCT.md b/apps/penpal/PRODUCT.md
index 4e4a831f..0f027467 100644
--- a/apps/penpal/PRODUCT.md
+++ b/apps/penpal/PRODUCT.md
@@ -236,6 +236,8 @@ Global views aggregate content across all projects. They appear as top-level ite
 
 - **P-PENPAL-HIGHLIGHT-CROSS**: A single highlight can span across formatting boundaries — bold, italic, code, links, and other inline markup — as well as cross between code blocks and surrounding prose.
 
+- **P-PENPAL-HIGHLIGHT-MEDIA**: A highlight that intersects an image or mermaid diagram while also containing other text expands to encompass the entire image or diagram. A highlight cannot contain only part of an image or diagram when it also includes non-image/diagram content.
+
 ---
 
 ## Mermaid Diagram Comments
diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md
index 64fb1c1c..b9fee617 100644
--- a/apps/penpal/TESTING.md
+++ b/apps/penpal/TESTING.md
@@ -75,6 +75,7 @@ see-also:
 | Anchor Resolution (P-PENPAL-ANCHOR-RESOLVE) | comments_test.go (implicit via round-trip) | rehypeCommentHighlights.test.ts | — | — |
 | Comment Highlights (P-PENPAL-HIGHLIGHT) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | review-workflow.spec.ts |
 | Cross-Element Highlights (P-PENPAL-HIGHLIGHT-CROSS) | — | rehypeCommentHighlights.test.ts | — | — |
+| Media Highlight Expansion (P-PENPAL-HIGHLIGHT-MEDIA) | — | rehypeCommentHighlights.test.ts | — | — |
 | Mermaid Diagram Comments (P-PENPAL-DIAGRAM-SELECT) | — | — | — | mermaid-comments.spec.ts |
 | Comment Threads (P-PENPAL-THREAD-PANEL, REPLY, STATES) | comments_test.go | CommentsPanel.test.tsx | api_threads_test.go | review-workflow.spec.ts |
 | Comment Ordering (E-PENPAL-COMMENT-ORDER) | ordering_test.go | utils/comments.test.ts | — | — |

From b4043de52ae639a3996c9ea74659adc4615cf13a Mon Sep 17 00:00:00 2001
From: Logan Johnson 
Date: Sun, 29 Mar 2026 22:35:09 -0400
Subject: [PATCH 2/7] feat(penpal): highlight expansion for images and mermaid
 diagrams (E-PENPAL-HIGHLIGHT-MEDIA)

Expand highlight marks to encompass entire images and mermaid diagrams
when the highlight also spans adjacent text. Mermaid blocks use annotation
(dataMermaidHighlight on ) + CSS classes instead of  wrapping
to avoid breaking imperative SVG rendering. Adds mermaidCrossed flag to
continuation state so post-mermaid text matching handles SVG-polluted
remaining text from sel.toString().

Co-Authored-By: Claude Opus 4.6 
---
 .../src/components/MarkdownViewer.tsx         |  21 +-
 .../rehypeCommentHighlights.test.ts           | 306 ++++++++++++++++++
 .../src/components/rehypeCommentHighlights.ts | 147 ++++++++-
 apps/penpal/frontend/src/index.css            |  21 ++
 4 files changed, 488 insertions(+), 7 deletions(-)

diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.tsx
index dfe5852f..e25623d3 100644
--- a/apps/penpal/frontend/src/components/MarkdownViewer.tsx
+++ b/apps/penpal/frontend/src/components/MarkdownViewer.tsx
@@ -230,12 +230,31 @@ const MarkdownViewer = forwardRef(
             // Use AST node position to set data-source-line at render time,
             // matching how the Go template sets it server-side.
             const sourceLine = node?.position?.start?.line;
+
+            // E-PENPAL-HIGHLIGHT-MEDIA: read highlight annotation from rehype plugin.
+            // Applied as className (not a  wrapper) to avoid changing tree
+            // structure, which would cause React to recreate the DOM node and lose
+            // the imperatively-rendered mermaid SVG.
+            let highlightClass = 'mermaid-container';
+            let highlightThreadId: string | undefined;
+            const mermaidHighlightRaw = node?.properties?.dataMermaidHighlight;
+            if (typeof mermaidHighlightRaw === 'string') {
+              try {
+                const parsed = JSON.parse(mermaidHighlightRaw) as { threadId: string; pending?: boolean };
+                highlightClass += parsed.pending
+                  ? ' comment-highlight pending-highlight'
+                  : ' comment-highlight';
+                highlightThreadId = parsed.threadId;
+              } catch { /* ignore malformed */ }
+            }
+
             return (
               
                   {children}
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
index a10c7a6a..a10c4d58 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
@@ -60,6 +60,44 @@ function makeParagraph(line: number, text: string): Element {
   } as Element;
 }
 
+/** Build a 

block-level image paragraph */ +function makeImgParagraph(line: number, src: string): Element { + return { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'element', + tagName: 'img', + properties: { src }, + children: [], + } as Element, + ], + position: { start: { line, column: 1, offset: 0 }, end: { line, column: 1, offset: 0 } }, + } as Element; +} + +/** Build a

text text

with inline image */ +function makeParagraphWithImg(line: number, textBefore: string, imgSrc: string, textAfter: string): Element { + return { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { type: 'text', value: textBefore } as Text, + { + type: 'element', + tagName: 'img', + properties: { src: imgSrc }, + children: [], + } as Element, + { type: 'text', value: textAfter } as Text, + ], + position: { start: { line, column: 1, offset: 0 }, end: { line, column: 1 + textBefore.length + textAfter.length, offset: textBefore.length + textAfter.length } }, + } as Element; +} + // E-PENPAL-HIGHLIGHT-REHYPE: verifies injection, pending class, line matching, and text splitting. describe('rehypeCommentHighlights', () => { it('wraps matching text in a with comment-highlight class', () => { @@ -481,6 +519,274 @@ describe('rehypeCommentHighlights', () => { }); }); + // E-PENPAL-HIGHLIGHT-MEDIA: media wrapping tests + describe('media highlight wrapping', () => { + it('inline image wrapped when between marks with same threadId', () => { + //

Hello world

— highlight covers "Hello" and "world" + const tree: Root = { + type: 'root', + children: [makeParagraphWithImg(1, 'Hello ', 'test.png', ' world')], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ threadId: 't1', selectedText: 'Hello world', startLine: 1 }], + }); + transform(tree); + + const marks = findMarks(tree); + // Should have 3 marks: "Hello ", wrapper, " world" + expect(marks).toHaveLength(3); + // The middle mark should wrap the img + const imgMark = marks.find(m => + m.children.some(c => c.type === 'element' && (c as Element).tagName === 'img') + ); + expect(imgMark).toBeDefined(); + expect((imgMark!.properties as Record).dataThreadId).toBe('t1'); + }); + + it('block-level image wrapped during continuation', () => { + //

First

Last

+ const tree: Root = { + type: 'root', + children: [ + makeParagraph(1, 'First paragraph text'), + makeImgParagraph(3, 'diagram.png'), + makeParagraph(5, 'Last paragraph text'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ + threadId: 't1', + selectedText: 'First paragraph text Last paragraph text', + startLine: 1, + }], + }); + transform(tree); + + // The image paragraph should be wrapped in a + const rootChildren = tree.children; + const imgContainer = rootChildren[1]; + expect(imgContainer.type).toBe('element'); + expect((imgContainer as Element).tagName).toBe('mark'); + expect((imgContainer as Element).properties?.dataThreadId).toBe('t1'); + // The

should be inside the mark + const innerP = (imgContainer as Element).children[0] as Element; + expect(innerP.tagName).toBe('p'); + expect((innerP.children[0] as Element).tagName).toBe('img'); + + // First and last paragraphs should also have marks + const marks = findMarks(tree); + expect(marks.length).toBeGreaterThanOrEqual(2); + }); + + it('mermaid block annotated (not wrapped) during continuation', () => { + //

Before

...

After

+ // Mermaid blocks can't be wrapped in because it changes tree structure, + // causing React to recreate DOM nodes and lose imperatively-rendered SVG. + // Instead, the element gets a dataMermaidHighlight annotation. + const tree: Root = { + type: 'root', + children: [ + makeParagraph(1, 'Before mermaid text'), + makePreCode(3, 'mermaid', 'graph TD\n A-->B\n'), + makeParagraph(7, 'After mermaid text'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ + threadId: 't1', + selectedText: 'Before mermaid text After mermaid text', + startLine: 1, + }], + }); + transform(tree); + + // The mermaid
 should NOT be wrapped — tree structure unchanged
+      const pre = tree.children[1] as Element;
+      expect(pre.tagName).toBe('pre');
+
+      // The  element should have dataMermaidHighlight annotation
+      const code = pre.children[0] as Element;
+      const raw = code.properties?.dataMermaidHighlight;
+      expect(raw).toBeDefined();
+      const parsed = JSON.parse(String(raw)) as { threadId: string; pending?: boolean };
+      expect(parsed.threadId).toBe('t1');
+
+      // Before and after paragraphs should also have marks
+      const proseMarks = findMarks(tree).filter(m =>
+        m.children.some(c => c.type === 'text')
+      );
+      expect(proseMarks.length).toBeGreaterThanOrEqual(2);
+    });
+
+    it('image NOT wrapped when highlight is only on one side', () => {
+      // 

Hello world

— highlight covers only "Hello" + const tree: Root = { + type: 'root', + children: [makeParagraphWithImg(1, 'Hello ', 'test.png', ' world')], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ threadId: 't1', selectedText: 'Hello', startLine: 1 }], + }); + transform(tree); + + const marks = findMarks(tree); + // Should have 1 mark for "Hello" only — img should NOT be wrapped + expect(marks).toHaveLength(1); + expect((marks[0].children[0] as Text).value).toBe('Hello'); + + // The img should still be a direct child of the

, not wrapped in + const p = tree.children[0] as Element; + const hasDirectImg = p.children.some( + c => c.type === 'element' && (c as Element).tagName === 'img' + ); + expect(hasDirectImg).toBe(true); + }); + + it('mermaid annotation carries pending flag', () => { + const tree: Root = { + type: 'root', + children: [ + makeParagraph(1, 'Before text'), + makePreCode(3, 'mermaid', 'graph TD\n A-->B\n'), + makeParagraph(7, 'After text'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ + threadId: 't1', + selectedText: 'Before text After text', + startLine: 1, + pending: true, + }], + }); + transform(tree); + + const pre = tree.children[1] as Element; + const code = pre.children[0] as Element; + const parsed = JSON.parse(String(code.properties?.dataMermaidHighlight)) as { threadId: string; pending?: boolean }; + expect(parsed.pending).toBe(true); + }); + + it('continuation works past mermaid when selectedText has SVG text', () => { + // Real scenario: user selects from prose through a mermaid diagram to prose + // after. Selection captures SVG node labels (not mermaid source), so the + // remaining after matching the first paragraph contains junk SVG text + // before the post-mermaid paragraph text. + const tree: Root = { + type: 'root', + children: [ + makeParagraph(1, 'Before mermaid text'), + makePreCode(3, 'mermaid', 'graph TD\n A[Start]-->B[End]\n'), + makeParagraph(7, 'After mermaid text here'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ + threadId: 't1', + // Simulates sel.toString(): prose + SVG labels + post-prose + selectedText: 'Before mermaid text Start End After mermaid text here', + startLine: 1, + }], + }); + transform(tree); + + // First paragraph should be highlighted + const firstPMarks = findMarks(tree.children[0] as Element); + expect(firstPMarks.length).toBeGreaterThanOrEqual(1); + + // Mermaid block should be annotated + const pre = tree.children[1] as Element; + const code = pre.children[0] as Element; + expect(code.properties?.dataMermaidHighlight).toBeDefined(); + + // Last paragraph should also be highlighted via continuation + const lastP = tree.children[2]; + // lastP might be wrapped in (block-level) or have inner marks + const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark' + ? [lastP as Element] + : findMarks(lastP as Element); + expect(lastPMarks.length).toBeGreaterThanOrEqual(1); + }); + + it('short text after mermaid is highlighted via mermaidCrossed flag', () => { + // When SVG text is long relative to post-mermaid text, the normal + // Strategy 3 thresholds reject the match. mermaidCrossed lowers them. + const tree: Root = { + type: 'root', + children: [ + makeParagraph(1, 'Intro text'), + makePreCode(3, 'mermaid', 'graph TD\n A[Login]-->B[Dashboard]-->C[Settings]-->D[Profile]\n'), + makeParagraph(8, 'Done'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ + threadId: 't1', + // SVG labels are long, post-mermaid text is short + selectedText: 'Intro text Login Dashboard Settings Profile Done', + startLine: 1, + }], + }); + transform(tree); + + const lastP = tree.children[2]; + const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark' + ? [lastP as Element] + : findMarks(lastP as Element); + expect(lastPMarks.length).toBeGreaterThanOrEqual(1); + }); + + it('mermaid block NOT annotated when no continuation is active', () => { + // Mermaid block as first element — no highlight spans into it + const tree: Root = { + type: 'root', + children: [ + makePreCode(1, 'mermaid', 'graph TD\n A-->B\n'), + makeParagraph(5, 'Some text after'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ threadId: 't1', selectedText: 'Some text after', startLine: 5 }], + }); + transform(tree); + + // Mermaid should NOT have annotation + const pre = tree.children[0] as Element; + expect(pre.tagName).toBe('pre'); + const code = pre.children[0] as Element; + expect(code.properties?.dataMermaidHighlight).toBeUndefined(); + + // Paragraph should have a mark + const marks = findMarks(tree); + expect(marks).toHaveLength(1); + }); + + it('pending class propagated to media wrapping marks', () => { + const tree: Root = { + type: 'root', + children: [ + makeParagraph(1, 'Before text'), + makeImgParagraph(3, 'photo.png'), + makeParagraph(5, 'After text'), + ], + }; + const transform = rehypeCommentHighlights({ + highlights: [{ + threadId: 't1', + selectedText: 'Before text After text', + startLine: 1, + pending: true, + }], + }); + transform(tree); + + // The image paragraph wrapper should have pending class + const imgContainer = tree.children[1] as Element; + expect(imgContainer.tagName).toBe('mark'); + expect((imgContainer.properties?.className as string[])).toContain('pending-highlight'); + }); + }); + describe('nthIndexOf', () => { it('returns first occurrence for index 0', () => { expect(nthIndexOf('foo bar foo baz foo', 'foo', 0)).toBe(0); diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts index 7b73beba..7b77563e 100644 --- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts +++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts @@ -39,7 +39,7 @@ export default function rehypeCommentHighlights(options: Options) { // Track which highlights have been started to avoid double-applying const applied = new Set(); // Cross-element highlights needing continuation: threadId → remaining normalized text - const continuing = new Map(); + const continuing = new Map(); visit(tree, 'element', (node: Element, index, parent) => { if (index === undefined || !parent) return; @@ -52,6 +52,38 @@ export default function rehypeCommentHighlights(options: Options) { // string), so handleCodeBlock stores match info as data attributes instead. // Language-less

 falls through to normal mark insertion.
       if (node.tagName === 'pre') {
+        // E-PENPAL-HIGHLIGHT-MEDIA: annotate mermaid blocks during continuation.
+        // We can't wrap 
 in  because mermaid rendering uses imperative
+        // DOM mutations (innerHTML = svg). Wrapping changes the tree structure,
+        // causing React to recreate DOM nodes and lose the rendered SVG.
+        // Instead, annotate the  element so MarkdownViewer can add highlight
+        // classes to the mermaid container div (props-only change, no DOM recreation).
+        const codeChild = node.children.find(
+          (c): c is Element => c.type === 'element' && c.tagName === 'code'
+        );
+        const isMermaid = codeChild && Array.isArray(codeChild.properties?.className) &&
+          (codeChild.properties.className as string[]).some(c => c === 'language-mermaid');
+        if (isMermaid && continuing.size > 0) {
+          const sourceLine = node.position?.start?.line;
+          if (sourceLine) {
+            for (const [, state] of continuing) {
+              if (sourceLine > state.highlight.startLine) {
+                codeChild.properties = codeChild.properties || {};
+                codeChild.properties.dataMermaidHighlight = JSON.stringify({
+                  threadId: state.highlight.threadId,
+                  pending: state.highlight.pending,
+                });
+                // Mark that this continuation crossed a mermaid block, so
+                // post-mermaid matching is lenient (remaining contains SVG
+                // text from sel.toString() that won't match any HAST element).
+                state.mermaidCrossed = true;
+                break;
+              }
+            }
+          }
+          return SKIP;
+        }
+
         if (handleCodeBlock(node, continuing, byLine, applied)) {
           return SKIP;
         }
@@ -60,13 +92,24 @@ export default function rehypeCommentHighlights(options: Options) {
       const sourceLine = node.position?.start?.line;
       if (!sourceLine) return;
 
+      // E-PENPAL-HIGHLIGHT-MEDIA: wrap block-level images during continuation
+      if (isMediaOnlyBlock(node) && continuing.size > 0) {
+        for (const [, state] of continuing) {
+          if (sourceLine > state.highlight.startLine) {
+            wrapNodeInMark(node, index!, parent! as Element | Root, state.highlight);
+            break;
+          }
+        }
+        return SKIP;
+      }
+
       // Continue cross-element highlights into subsequent elements.
       // Only try elements on lines AFTER the highlight's startLine to avoid
       // double-matching in child elements of the start element (whose text
       // was already covered by collectTextNodes on the parent).
       for (const [threadId, state] of continuing) {
         if (sourceLine <= state.highlight.startLine) continue;
-        const matched = applyContinuation(node, state.highlight, state.remaining);
+        const matched = applyContinuation(node, state.highlight, state.remaining, state.mermaidCrossed);
         if (matched > 0) {
           const newRemaining = state.remaining.slice(matched).trim();
           if (newRemaining.length === 0) {
@@ -76,6 +119,8 @@ export default function rehypeCommentHighlights(options: Options) {
           }
         }
       }
+      // E-PENPAL-HIGHLIGHT-MEDIA: wrap inline images after continuation marks
+      wrapInlineMedia(node);
 
       // Start new highlights at or near this line.
       // Check nearby lines (0-3 offset) because startLine may point to an empty
@@ -98,6 +143,8 @@ export default function rehypeCommentHighlights(options: Options) {
               continuing.set(highlight.threadId, { highlight, remaining: normSelected });
             }
           }
+          // E-PENPAL-HIGHLIGHT-MEDIA: wrap inline images after highlight marks
+          if (result.matched) wrapInlineMedia(node);
         }
       }
     });
@@ -332,7 +379,7 @@ const BLOCK_TAGS = new Set([
   'tr', 'td', 'th', 'section', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
 ]);
 
-function applyContinuation(element: Element, highlight: ThreadHighlight, remaining: string): number {
+function applyContinuation(element: Element, highlight: ThreadHighlight, remaining: string, mermaidCrossed?: boolean): number {
   // Skip container elements with block children — let individual children handle
   // the continuation to avoid matching the wrong child's text when collectTextNodes
   // concatenates all descendants without separators.
@@ -383,13 +430,19 @@ function applyContinuation(element: Element, highlight: ThreadHighlight, remaini
   // with a suffix of remaining (e.g. remaining="s Observability..." and
   // element text starts with "Observability...").
   // Thresholds are proportional to content length to avoid edge cases with
-  // very short or very long text.
-  const minOverlapElement = Math.max(3, Math.min(10, Math.floor(remaining.length / 3)));
+  // very short or very long text. When mermaidCrossed, remaining is polluted
+  // with SVG text from sel.toString() that doesn't appear in HAST, so we
+  // lower thresholds to accept shorter matches at any position.
+  const minOverlapElement = mermaidCrossed ? 3 : Math.max(3, Math.min(10, Math.floor(remaining.length / 3)));
   const probeWindow = Math.max(5, Math.floor(remaining.length / 2));
   if (matchIndex === -1 && normalizedText.length >= minOverlapElement) {
     const probe = normalizedText.slice(0, Math.min(probeWindow, normalizedText.length));
     const overlapIdx = remaining.indexOf(probe);
-    if (overlapIdx > 0 && overlapIdx < probeWindow) {
+    // Accept the overlap if it's within the probe window, OR if the probe
+    // is long enough (≥8 chars) to be specific regardless of position.
+    // After crossing a mermaid block, accept any position with ≥3 char probe
+    // since the SVG text may push the real match arbitrarily far into remaining.
+    if (overlapIdx > 0 && (mermaidCrossed ? probe.length >= 3 : (probe.length >= 8 || overlapIdx < probeWindow))) {
       matchIndex = 0;
       matchLength = Math.min(normalizedText.length, remaining.length - overlapIdx);
       skippedChars = overlapIdx;
@@ -407,6 +460,88 @@ function applyContinuation(element: Element, highlight: ThreadHighlight, remaini
   return skippedChars + matchLength;
 }
 
+// ── Media wrapping ──────────────────────────────────────────────────
+
+// E-PENPAL-HIGHLIGHT-MEDIA: helpers for wrapping images and mermaid diagrams in highlights.
+
+/** Returns true if the element is a block containing only  elements and whitespace text. */
+function isMediaOnlyBlock(node: Element): boolean {
+  if (!node.children || node.children.length === 0) return false;
+  let hasImg = false;
+  for (const child of node.children) {
+    if (child.type === 'element' && (child as Element).tagName === 'img') {
+      hasImg = true;
+    } else if (child.type === 'text' && !(child as Text).value.trim()) {
+      // whitespace text node — ok
+    } else {
+      return false;
+    }
+  }
+  return hasImg;
+}
+
+/** Wrap a node in a  element by replacing it in the parent's children array. */
+function wrapNodeInMark(
+  node: Element, index: number, parent: Element | Root, highlight: ThreadHighlight
+): void {
+  const mark: Element = {
+    type: 'element',
+    tagName: 'mark',
+    properties: {
+      className: highlight.pending
+        ? ['comment-highlight', 'pending-highlight']
+        : ['comment-highlight'],
+      dataThreadId: highlight.threadId,
+    },
+    children: [node],
+  };
+  (parent as Element).children[index] = mark;
+}
+
+/** Find the nearest  element searching from `fromIndex` in `direction`, skipping whitespace text. */
+function findNearestMark(children: ElementContent[], fromIndex: number, direction: -1 | 1): Element | null {
+  for (let i = fromIndex + direction; i >= 0 && i < children.length; i += direction) {
+    const c = children[i];
+    if (c.type === 'element' && (c as Element).tagName === 'mark') return c as Element;
+    if (c.type === 'text' && !(c as Text).value.trim()) continue;
+    break;
+  }
+  return null;
+}
+
+/**
+ * After mark insertion, scan an element's children for  elements
+ * sandwiched between  elements with the same threadId and wrap them.
+ */
+function wrapInlineMedia(element: Element): void {
+  const children = element.children;
+  for (let i = children.length - 1; i >= 0; i--) {
+    const child = children[i];
+    if (child.type !== 'element') continue;
+    const el = child as Element;
+    if (el.tagName !== 'img') continue;
+
+    const markBefore = findNearestMark(children, i, -1);
+    const markAfter = findNearestMark(children, i, 1);
+
+    if (markBefore && markAfter &&
+        markBefore.properties?.dataThreadId === markAfter.properties?.dataThreadId) {
+      const threadId = markBefore.properties?.dataThreadId as string;
+      const pending = (markBefore.properties?.className as string[])?.includes('pending-highlight');
+      const mark: Element = {
+        type: 'element',
+        tagName: 'mark',
+        properties: {
+          className: pending ? ['comment-highlight', 'pending-highlight'] : ['comment-highlight'],
+          dataThreadId: threadId,
+        },
+        children: [el],
+      };
+      children[i] = mark;
+    }
+  }
+}
+
 // ── Code block bridging ─────────────────────────────────────────────
 
 // E-PENPAL-HIGHLIGHT-CROSS: Handle syntax-highlighted code blocks for cross-boundary highlights.
diff --git a/apps/penpal/frontend/src/index.css b/apps/penpal/frontend/src/index.css
index b0283fea..76b00d39 100644
--- a/apps/penpal/frontend/src/index.css
+++ b/apps/penpal/frontend/src/index.css
@@ -1364,6 +1364,27 @@ a:hover { text-decoration: underline; }
   box-shadow: 0 0 0 2px rgba(163, 134, 0, 0.4);
   color: #e0e0e0;
 }
+/* E-PENPAL-HIGHLIGHT-MEDIA: mermaid containers use a prominent border + shadow
+   instead of background, since the SVG covers the highlight bg. */
+.mermaid-container.comment-highlight {
+  border: 2px solid #f5c518;
+  box-shadow: 0 0 0 2px rgba(245, 197, 24, 0.35);
+  border-bottom: 2px solid #f5c518;
+  background: var(--bg-surface);
+}
+[data-theme="dark"] .mermaid-container.comment-highlight {
+  border-color: #a38600;
+  box-shadow: 0 0 0 2px rgba(163, 134, 0, 0.35);
+  background: var(--bg-surface);
+}
+.mermaid-container.comment-highlight:hover {
+  border-color: #e8b400;
+  box-shadow: 0 0 0 3px rgba(245, 197, 24, 0.45);
+}
+[data-theme="dark"] .mermaid-container.comment-highlight:hover {
+  border-color: #c79f00;
+  box-shadow: 0 0 0 3px rgba(163, 134, 0, 0.45);
+}
 
 /* Review banner */
 .review-banner {

From f731e7618f71937bd2c8c4f6b5292cdd25f6c3e4 Mon Sep 17 00:00:00 2001
From: Logan Johnson 
Date: Sun, 29 Mar 2026 22:44:52 -0400
Subject: [PATCH 3/7] docs(penpal): update HIGHLIGHT-MEDIA and SVG-DRAG
 requirements for mermaid selection behaviors

Broaden P-PENPAL-HIGHLIGHT-MEDIA to cover selections starting at or
dragging out of diagrams. Update E-PENPAL-HIGHLIGHT-MEDIA with rehype
plugin mermaid-start handling and MermaidSelection drag-escape. Cross-ref
E-PENPAL-SVG-DRAG with the escape-to-text transition.

Co-Authored-By: Claude Opus 4.6 
---
 apps/penpal/ERD.md     | 4 ++--
 apps/penpal/PRODUCT.md | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md
index 669ee710..1089932b 100644
--- a/apps/penpal/ERD.md
+++ b/apps/penpal/ERD.md
@@ -136,14 +136,14 @@ see-also:
 - **E-PENPAL-HIGHLIGHT-CROSS**: The rehype plugin splits and wraps `` elements across inline formatting boundaries (bold, italic, code, links) and across block boundaries between code blocks and prose. Each contiguous segment of highlighted text gets its own `` wrapper so the highlight renders correctly regardless of intervening HTML structure.
   ← [P-PENPAL-HIGHLIGHT-CROSS](PRODUCT.md#P-PENPAL-HIGHLIGHT-CROSS)
 
-- **E-PENPAL-HIGHLIGHT-MEDIA**: When a matched highlight range partially intersects an `` or mermaid `
` element while also spanning non-image/diagram text, the rehype plugin expands the highlight to encompass the entire media element. Inline images sandwiched between `` elements with the same `threadId` are wrapped in a matching ``. Block-level image paragraphs and mermaid `
` blocks encountered during cross-element continuation are wrapped in `` without consuming text from the remaining match.
+- **E-PENPAL-HIGHLIGHT-MEDIA**: The rehype plugin expands highlights to encompass entire media elements. Inline images sandwiched between `` elements with the same `threadId` are wrapped in a matching ``. Block-level image paragraphs are wrapped during continuation. Mermaid `
` blocks are annotated (not wrapped) via `dataMermaidHighlight` on the `` element — MarkdownViewer reads this to add CSS highlight classes without changing tree structure, preserving the imperatively-rendered SVG. When a highlight's `startLine` falls on a mermaid block, the plugin annotates the mermaid and schedules the full `selectedText` for continuation with `mermaidCrossed: true`, which relaxes Strategy 3 matching thresholds so post-mermaid text is found despite SVG label pollution in `remaining`. Highlighted mermaid containers render with a yellow `border` + `box-shadow` instead of a background overlay. `MermaidSelection` detects when a drag leaves the container bounds and transitions from SVG rect mode to a programmatic text selection (via `Selection.setBaseAndExtent` + `Selection.extend`) that includes the whole diagram, handing off to `SelectionToolbar` on mouseup.
   ← [P-PENPAL-HIGHLIGHT-MEDIA](PRODUCT.md#P-PENPAL-HIGHLIGHT-MEDIA)
 
 ---
 
 ## Mermaid Diagram Anchoring
 
-- **E-PENPAL-SVG-DRAG**: `MermaidSelection` handles drag on `.mermaid-container` elements. After a 5px movement threshold, a live `.penpal-pending-svg-highlight` rect tracks the selection. On mouseup, SVG coordinates are computed.
+- **E-PENPAL-SVG-DRAG**: `MermaidSelection` handles drag on `.mermaid-container` elements. After a 5px movement threshold, a live `.penpal-pending-svg-highlight` rect tracks the selection. On mouseup, SVG coordinates are computed. If the mouse leaves the container bounds during drag, SVG rect mode is cancelled and the drag transitions to a text selection that includes the whole diagram (see [E-PENPAL-HIGHLIGHT-MEDIA](#E-PENPAL-HIGHLIGHT-MEDIA)).
   ← [P-PENPAL-DIAGRAM-SELECT](PRODUCT.md#P-PENPAL-DIAGRAM-SELECT)
 
 - **E-PENPAL-SVG-EXTRACT**: The SVG snippet is extracted by cloning the SVG, setting a cropped `viewBox`, scaling to max 300x200px, and re-IDing all elements with a random prefix to prevent DOM ID collisions. CSS `url(#id)` and `href="#id"` references are rewritten.
diff --git a/apps/penpal/PRODUCT.md b/apps/penpal/PRODUCT.md
index 0f027467..5f132a7a 100644
--- a/apps/penpal/PRODUCT.md
+++ b/apps/penpal/PRODUCT.md
@@ -236,7 +236,7 @@ Global views aggregate content across all projects. They appear as top-level ite
 
 - **P-PENPAL-HIGHLIGHT-CROSS**: A single highlight can span across formatting boundaries — bold, italic, code, links, and other inline markup — as well as cross between code blocks and surrounding prose.
 
-- **P-PENPAL-HIGHLIGHT-MEDIA**: A highlight that intersects an image or mermaid diagram while also containing other text expands to encompass the entire image or diagram. A highlight cannot contain only part of an image or diagram when it also includes non-image/diagram content.
+- **P-PENPAL-HIGHLIGHT-MEDIA**: A highlight that intersects an image or mermaid diagram expands to encompass the entire media element. Selecting text that spans into, through, or starting within a diagram highlights the diagram with a visible yellow border and highlights the adjacent text normally. Dragging a selection out of a mermaid diagram transitions from rectangle selection to text selection, including the whole diagram in the resulting highlight.
 
 ---
 

From f1b97bce4942fdd46bcd6ccf7639d442432e6d84 Mon Sep 17 00:00:00 2001
From: Logan Johnson 
Date: Sun, 29 Mar 2026 22:44:58 -0400
Subject: [PATCH 4/7] fix(penpal): mermaid highlight for selection starting
 at/within diagram (E-PENPAL-HIGHLIGHT-MEDIA)

Two fixes for mermaid + highlight interactions:

1. Rehype plugin: highlights whose startLine falls on a mermaid block now
   annotate the diagram and schedule continuation with mermaidCrossed,
   so text before/after the diagram is also highlighted. Previously the
   mermaid handler SKIP'd before reaching the "start new highlights" path.

2. MermaidSelection: dragging outside the container cancels SVG rect mode
   and transitions to a text selection (via setBaseAndExtent + extend)
   that includes the whole diagram, handing off to SelectionToolbar.

Co-Authored-By: Claude Opus 4.6 
---
 .../src/components/MermaidSelection.tsx       | 68 +++++++++++++++++++
 .../rehypeCommentHighlights.test.ts           | 64 +++++++++++++++++
 .../src/components/rehypeCommentHighlights.ts | 43 +++++++++++-
 3 files changed, 173 insertions(+), 2 deletions(-)

diff --git a/apps/penpal/frontend/src/components/MermaidSelection.tsx b/apps/penpal/frontend/src/components/MermaidSelection.tsx
index bcce63db..f69cb571 100644
--- a/apps/penpal/frontend/src/components/MermaidSelection.tsx
+++ b/apps/penpal/frontend/src/components/MermaidSelection.tsx
@@ -189,6 +189,74 @@ export default function MermaidSelection({
           const dy = e2.clientY - startY;
           if (!dragging && Math.sqrt(dx * dx + dy * dy) < 5) return;
 
+          // E-PENPAL-HIGHLIGHT-MEDIA: when mouse leaves the mermaid container
+          // during drag, cancel SVG rect mode and switch to text selection
+          // that includes the whole diagram plus whatever text the user drags to.
+          const containerRect = containerEl.getBoundingClientRect();
+          const outsideContainer =
+            e2.clientX < containerRect.left || e2.clientX > containerRect.right ||
+            e2.clientY < containerRect.top || e2.clientY > containerRect.bottom;
+
+          if (outsideContainer) {
+            if (!dragging) {
+              // Never entered SVG mode — just cancel our tracking and let browser handle
+              document.removeEventListener('mousemove', onMouseMove);
+              document.removeEventListener('mouseup', onMouseUp);
+              dragActiveRef.current = false;
+              if (draggingRef) draggingRef.current = false;
+              return;
+            }
+
+            // Was in SVG mode — switch to text selection including the whole diagram
+            if (overlayRect) { overlayRect.remove(); overlayRect = null; }
+            containerEl.style.userSelect = '';
+            dragging = false;
+
+            // Create a text selection from the mermaid container to the mouse position
+            const sel = window.getSelection();
+            sel?.removeAllRanges();
+            const caretRange = document.caretRangeFromPoint(e2.clientX, e2.clientY);
+
+            if (sel && caretRange) {
+              const isAbove = e2.clientY < containerRect.top;
+              if (isAbove) {
+                // Dragging up: anchor at end of container, focus at mouse position
+                sel.setBaseAndExtent(
+                  containerEl, containerEl.childNodes.length,
+                  caretRange.startContainer, caretRange.startOffset,
+                );
+              } else {
+                // Dragging down/sideways: anchor at start of container, focus at mouse position
+                sel.setBaseAndExtent(
+                  containerEl, 0,
+                  caretRange.startContainer, caretRange.startOffset,
+                );
+              }
+            }
+
+            // Replace SVG handlers with text-extension handlers
+            document.removeEventListener('mousemove', onMouseMove);
+            document.removeEventListener('mouseup', onMouseUp);
+
+            const onTextMove = (e3: MouseEvent) => {
+              const cr = document.caretRangeFromPoint(e3.clientX, e3.clientY);
+              if (cr && sel) {
+                try { sel.extend(cr.startContainer, cr.startOffset); } catch { /* ignore */ }
+              }
+            };
+            const onTextUp = () => {
+              document.removeEventListener('mousemove', onTextMove);
+              document.removeEventListener('mouseup', onTextUp);
+              dragActiveRef.current = false;
+              if (draggingRef) draggingRef.current = false;
+              // SelectionToolbar's mouseup handler on contentEl will fire and
+              // show the comment button for the text+diagram selection.
+            };
+            document.addEventListener('mousemove', onTextMove);
+            document.addEventListener('mouseup', onTextUp);
+            return;
+          }
+
           if (!dragging) {
             dragging = true;
             dragActiveRef.current = true;
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
index a10c4d58..f8792fdd 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
@@ -736,6 +736,70 @@ describe('rehypeCommentHighlights', () => {
       expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
     });
 
+    it('highlight starting at mermaid annotates it and continues to text after', () => {
+      // User drags selection from text below UP into a preceding mermaid diagram.
+      // startLine lands on the mermaid block. The rehype plugin must annotate
+      // the mermaid and set up continuation so the text after is also highlighted.
+      const tree: Root = {
+        type: 'root',
+        children: [
+          makePreCode(1, 'mermaid', 'graph TD\n  A[Start]-->B[End]\n'),
+          makeParagraph(5, 'Text after the diagram'),
+        ],
+      };
+      const transform = rehypeCommentHighlights({
+        highlights: [{
+          threadId: 't1',
+          // sel.toString() captures SVG labels + text after
+          selectedText: 'Start End Text after the diagram',
+          startLine: 1,
+        }],
+      });
+      transform(tree);
+
+      // Mermaid block should be annotated
+      const pre = tree.children[0] as Element;
+      const code = pre.children[0] as Element;
+      expect(code.properties?.dataMermaidHighlight).toBeDefined();
+      const parsed = JSON.parse(String(code.properties?.dataMermaidHighlight));
+      expect(parsed.threadId).toBe('t1');
+
+      // Text after should also be highlighted via continuation
+      const lastP = tree.children[1];
+      const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark'
+        ? [lastP as Element]
+        : findMarks(lastP as Element);
+      expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
+    });
+
+    it('highlight starting at mermaid with no text after only annotates diagram', () => {
+      // Selection entirely within mermaid — only SVG labels captured
+      const tree: Root = {
+        type: 'root',
+        children: [
+          makeParagraph(1, 'Before the diagram'),
+          makePreCode(3, 'mermaid', 'graph TD\n  A[Login]-->B[Dashboard]\n'),
+        ],
+      };
+      const transform = rehypeCommentHighlights({
+        highlights: [{
+          threadId: 't1',
+          selectedText: 'Login Dashboard',
+          startLine: 3,
+        }],
+      });
+      transform(tree);
+
+      // Mermaid should be annotated
+      const pre = tree.children[1] as Element;
+      const code = pre.children[0] as Element;
+      expect(code.properties?.dataMermaidHighlight).toBeDefined();
+
+      // Before paragraph should NOT be highlighted (highlight starts at mermaid, not before)
+      const beforeMarks = findMarks(tree.children[0] as Element);
+      expect(beforeMarks).toHaveLength(0);
+    });
+
     it('mermaid block NOT annotated when no continuation is active', () => {
       // Mermaid block as first element — no highlight spans into it
       const tree: Root = {
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
index 7b77563e..2e4c514a 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
@@ -63,9 +63,12 @@ export default function rehypeCommentHighlights(options: Options) {
         );
         const isMermaid = codeChild && Array.isArray(codeChild.properties?.className) &&
           (codeChild.properties.className as string[]).some(c => c === 'language-mermaid');
-        if (isMermaid && continuing.size > 0) {
+        if (isMermaid) {
           const sourceLine = node.position?.start?.line;
-          if (sourceLine) {
+          let annotated = false;
+
+          // Annotate mermaid during cross-element continuation
+          if (continuing.size > 0 && sourceLine) {
             for (const [, state] of continuing) {
               if (sourceLine > state.highlight.startLine) {
                 codeChild.properties = codeChild.properties || {};
@@ -77,10 +80,46 @@ export default function rehypeCommentHighlights(options: Options) {
                 // post-mermaid matching is lenient (remaining contains SVG
                 // text from sel.toString() that won't match any HAST element).
                 state.mermaidCrossed = true;
+                annotated = true;
                 break;
               }
             }
           }
+
+          // E-PENPAL-HIGHLIGHT-MEDIA: start highlights whose startLine falls on
+          // this mermaid block. The selectedText from sel.toString() contains SVG
+          // labels (not mermaid source), so we can't text-match — just annotate
+          // the mermaid and schedule the full selectedText for continuation so
+          // adjacent prose elements get highlighted.
+          if (sourceLine) {
+            for (let lineOffset = 0; lineOffset <= 3; lineOffset++) {
+              const lineHighlights = byLine.get(sourceLine - lineOffset);
+              if (!lineHighlights) continue;
+              for (const highlight of lineHighlights) {
+                if (applied.has(highlight.threadId)) continue;
+                applied.add(highlight.threadId);
+                if (!annotated) {
+                  codeChild.properties = codeChild.properties || {};
+                  codeChild.properties.dataMermaidHighlight = JSON.stringify({
+                    threadId: highlight.threadId,
+                    pending: highlight.pending,
+                  });
+                  annotated = true;
+                }
+                // Schedule continuation with the full text — mermaidCrossed
+                // ensures lenient matching past the SVG text in remaining.
+                const normSelected = normalizeSelected(highlight.selectedText);
+                if (normSelected.length >= 3) {
+                  continuing.set(highlight.threadId, {
+                    highlight,
+                    remaining: normSelected,
+                    mermaidCrossed: true,
+                  });
+                }
+              }
+            }
+          }
+
           return SKIP;
         }
 

From 3b7747458bf045df24d0594ed50d8be12f0b9ae5 Mon Sep 17 00:00:00 2001
From: Logan Johnson 
Date: Sun, 29 Mar 2026 22:58:34 -0400
Subject: [PATCH 5/7] =?UTF-8?q?fix(penpal):=20review=20findings=20?=
 =?UTF-8?q?=E2=80=94=20dead=20code,=20guarded=20SKIP,=20test=20coverage=20?=
 =?UTF-8?q?(E-PENPAL-HIGHLIGHT-MEDIA)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Remove dead isMermaid check in handleCodeBlock (caller already SKIPs)
- Media-only block: only SKIP when a wrap actually happened
- Guard wrapInlineMedia behind continuationMatched flag
- Add MarkdownViewer integration test for mermaid highlight class
- Add rehype test for startLine lineOffset resolving to mermaid
- Update TESTING.md with MarkdownViewer coverage and e2e drag-out gap

Co-Authored-By: Claude Opus 4.6 
---
 apps/penpal/TESTING.md                        |  2 +-
 .../src/components/MarkdownViewer.test.tsx    | 14 ++++++++
 .../rehypeCommentHighlights.test.ts           | 34 +++++++++++++++++++
 .../src/components/rehypeCommentHighlights.ts | 20 +++++++----
 4 files changed, 62 insertions(+), 8 deletions(-)

diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md
index b9fee617..cff30315 100644
--- a/apps/penpal/TESTING.md
+++ b/apps/penpal/TESTING.md
@@ -75,7 +75,7 @@ see-also:
 | Anchor Resolution (P-PENPAL-ANCHOR-RESOLVE) | comments_test.go (implicit via round-trip) | rehypeCommentHighlights.test.ts | — | — |
 | Comment Highlights (P-PENPAL-HIGHLIGHT) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | review-workflow.spec.ts |
 | Cross-Element Highlights (P-PENPAL-HIGHLIGHT-CROSS) | — | rehypeCommentHighlights.test.ts | — | — |
-| Media Highlight Expansion (P-PENPAL-HIGHLIGHT-MEDIA) | — | rehypeCommentHighlights.test.ts | — | — |
+| Media Highlight Expansion (P-PENPAL-HIGHLIGHT-MEDIA) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | mermaid-comments.spec.ts (drag-out) |
 | Mermaid Diagram Comments (P-PENPAL-DIAGRAM-SELECT) | — | — | — | mermaid-comments.spec.ts |
 | Comment Threads (P-PENPAL-THREAD-PANEL, REPLY, STATES) | comments_test.go | CommentsPanel.test.tsx | api_threads_test.go | review-workflow.spec.ts |
 | Comment Ordering (E-PENPAL-COMMENT-ORDER) | ordering_test.go | utils/comments.test.ts | — | — |
diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
index 773a2fc5..d4772f68 100644
--- a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
+++ b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
@@ -164,6 +164,20 @@ describe('MarkdownViewer', () => {
     expect(mermaidAfter).toBe(mermaidBefore);
   });
 
+  // E-PENPAL-HIGHLIGHT-MEDIA: mermaid container gets highlight class via annotation
+  it('applies comment-highlight class to mermaid container when highlight spans into it', () => {
+    const md = 'Before text\n\n```mermaid\ngraph TD\n  A --> B\n```';
+    const highlights = [
+      { threadId: 't-media', selectedText: 'Before text A B', startLine: 1 },
+    ];
+    const { container } = render(
+      ,
+    );
+    const mermaidContainer = container.querySelector('.mermaid-container.comment-highlight');
+    expect(mermaidContainer).not.toBeNull();
+    expect(mermaidContainer?.getAttribute('data-thread-id')).toBe('t-media');
+  });
+
   it('renders pending highlights with pending-highlight class', () => {
     const md = 'Hello world';
     const highlights = [
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
index f8792fdd..7665504b 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
@@ -772,6 +772,40 @@ describe('rehypeCommentHighlights', () => {
       expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
     });
 
+    it('highlight starting near mermaid via lineOffset annotates it', () => {
+      // startLine is 1 line before the mermaid fence (e.g. empty line or
+      // thematic break). The 0-3 lineOffset loop should still find the match.
+      const tree: Root = {
+        type: 'root',
+        children: [
+          makePreCode(4, 'mermaid', 'graph TD\n  A[Start]-->B[End]\n'),
+          makeParagraph(8, 'After text'),
+        ],
+      };
+      const transform = rehypeCommentHighlights({
+        highlights: [{
+          threadId: 't1',
+          selectedText: 'Start End After text',
+          startLine: 3, // 1 line before the mermaid fence at line 4
+        }],
+      });
+      transform(tree);
+
+      // Mermaid should be annotated via lineOffset match
+      const pre = tree.children[0] as Element;
+      const code = pre.children[0] as Element;
+      expect(code.properties?.dataMermaidHighlight).toBeDefined();
+      const parsed = JSON.parse(String(code.properties?.dataMermaidHighlight));
+      expect(parsed.threadId).toBe('t1');
+
+      // Text after should be highlighted via continuation
+      const lastP = tree.children[1];
+      const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark'
+        ? [lastP as Element]
+        : findMarks(lastP as Element);
+      expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
+    });
+
     it('highlight starting at mermaid with no text after only annotates diagram', () => {
       // Selection entirely within mermaid — only SVG labels captured
       const tree: Root = {
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
index 2e4c514a..40c19f85 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
@@ -131,25 +131,32 @@ export default function rehypeCommentHighlights(options: Options) {
       const sourceLine = node.position?.start?.line;
       if (!sourceLine) return;
 
-      // E-PENPAL-HIGHLIGHT-MEDIA: wrap block-level images during continuation
+      // E-PENPAL-HIGHLIGHT-MEDIA: wrap block-level images during continuation.
+      // Images have no text content, so wrapping doesn't consume from remaining —
+      // the next text element will continue matching. Only SKIP if we wrapped,
+      // to avoid falling through to text-matching logic on an image-only block.
       if (isMediaOnlyBlock(node) && continuing.size > 0) {
+        let wrapped = false;
         for (const [, state] of continuing) {
           if (sourceLine > state.highlight.startLine) {
             wrapNodeInMark(node, index!, parent! as Element | Root, state.highlight);
+            wrapped = true;
             break;
           }
         }
-        return SKIP;
+        if (wrapped) return SKIP;
       }
 
       // Continue cross-element highlights into subsequent elements.
       // Only try elements on lines AFTER the highlight's startLine to avoid
       // double-matching in child elements of the start element (whose text
       // was already covered by collectTextNodes on the parent).
+      let continuationMatched = false;
       for (const [threadId, state] of continuing) {
         if (sourceLine <= state.highlight.startLine) continue;
         const matched = applyContinuation(node, state.highlight, state.remaining, state.mermaidCrossed);
         if (matched > 0) {
+          continuationMatched = true;
           const newRemaining = state.remaining.slice(matched).trim();
           if (newRemaining.length === 0) {
             continuing.delete(threadId);
@@ -159,7 +166,7 @@ export default function rehypeCommentHighlights(options: Options) {
         }
       }
       // E-PENPAL-HIGHLIGHT-MEDIA: wrap inline images after continuation marks
-      wrapInlineMedia(node);
+      if (continuationMatched) wrapInlineMedia(node);
 
       // Start new highlights at or near this line.
       // Check nearby lines (0-3 offset) because startLine may point to an empty
@@ -599,14 +606,13 @@ function handleCodeBlock(
   );
   if (!codeChild) return false;
 
+  // Mermaid 
 blocks are handled by the caller (annotated as media)
+  // before handleCodeBlock is reached, so only syntax-highlighted and
+  // language-less code blocks arrive here.
   const classes = Array.isArray(codeChild.properties?.className)
     ? (codeChild.properties.className as string[]) : [];
-  const isMermaid = classes.some(c => c === 'language-mermaid');
   const hasLanguage = classes.some(c => /^language-/.test(String(c)));
 
-  // Mermaid: skip for now (Phase 2 handles wrapping as media)
-  if (isMermaid) return true;
-
   // Language-less code: fall through to normal mark insertion
   // (SyntaxHighlighter is not used, so  elements render fine)
   if (!hasLanguage) return false;

From ca4977412ba6bde61dc4cdbe26312d80d4789265 Mon Sep 17 00:00:00 2001
From: Logan Johnson 
Date: Sun, 29 Mar 2026 23:03:46 -0400
Subject: [PATCH 6/7] test(penpal): e2e test for mermaid
 drag-out-to-text-selection (E-PENPAL-HIGHLIGHT-MEDIA)

Closes the e2e coverage gap: verifies that dragging from inside a
mermaid diagram to outside cancels the SVG rect and transitions to
a text selection with the SelectionToolbar visible.

Co-Authored-By: Claude Opus 4.6 
---
 apps/penpal/TESTING.md                        |  2 +-
 .../penpal/e2e/tests/mermaid-comments.spec.ts | 55 +++++++++++++++++++
 2 files changed, 56 insertions(+), 1 deletion(-)

diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md
index cff30315..a184ebaf 100644
--- a/apps/penpal/TESTING.md
+++ b/apps/penpal/TESTING.md
@@ -75,7 +75,7 @@ see-also:
 | Anchor Resolution (P-PENPAL-ANCHOR-RESOLVE) | comments_test.go (implicit via round-trip) | rehypeCommentHighlights.test.ts | — | — |
 | Comment Highlights (P-PENPAL-HIGHLIGHT) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | review-workflow.spec.ts |
 | Cross-Element Highlights (P-PENPAL-HIGHLIGHT-CROSS) | — | rehypeCommentHighlights.test.ts | — | — |
-| Media Highlight Expansion (P-PENPAL-HIGHLIGHT-MEDIA) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | mermaid-comments.spec.ts (drag-out) |
+| Media Highlight Expansion (P-PENPAL-HIGHLIGHT-MEDIA) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | mermaid-comments.spec.ts |
 | Mermaid Diagram Comments (P-PENPAL-DIAGRAM-SELECT) | — | — | — | mermaid-comments.spec.ts |
 | Comment Threads (P-PENPAL-THREAD-PANEL, REPLY, STATES) | comments_test.go | CommentsPanel.test.tsx | api_threads_test.go | review-workflow.spec.ts |
 | Comment Ordering (E-PENPAL-COMMENT-ORDER) | ordering_test.go | utils/comments.test.ts | — | — |
diff --git a/apps/penpal/e2e/tests/mermaid-comments.spec.ts b/apps/penpal/e2e/tests/mermaid-comments.spec.ts
index a817444d..dd46809e 100644
--- a/apps/penpal/e2e/tests/mermaid-comments.spec.ts
+++ b/apps/penpal/e2e/tests/mermaid-comments.spec.ts
@@ -217,6 +217,61 @@ test.describe('mermaid diagram commenting', () => {
     await expect(highlight).not.toBeAttached({ timeout: 5000 });
   });
 
+  // E-PENPAL-HIGHLIGHT-MEDIA: verifies dragging out of a mermaid diagram cancels SVG rect
+  // and transitions to text selection that includes the whole diagram.
+  test('dragging out of mermaid diagram switches to text selection', async ({ page }) => {
+    await blockPendingNavigation(page);
+    await page.goto(`/file/${projectName}/${filePath}`);
+
+    // Wait for mermaid to render
+    const container = page.locator('.mermaid-container');
+    await expect(container).toBeVisible({ timeout: 10000 });
+    const svg = container.locator('svg');
+    await expect(svg).toBeVisible();
+
+    const svgBox = await svg.boundingBox();
+    expect(svgBox).toBeTruthy();
+
+    // Find the "Text after" paragraph below the diagram
+    const afterText = page.locator('p', { hasText: 'Text after the diagram' });
+    await expect(afterText).toBeVisible();
+    const afterBox = await afterText.boundingBox();
+    expect(afterBox).toBeTruthy();
+
+    // Start drag inside the SVG (center area)
+    const startX = svgBox!.x + svgBox!.width * 0.5;
+    const startY = svgBox!.y + svgBox!.height * 0.5;
+    await page.mouse.move(startX, startY);
+    await page.mouse.down();
+
+    // Move past the 5px threshold (still inside SVG) to enter SVG rect mode
+    await page.mouse.move(startX + 10, startY + 10, { steps: 2 });
+
+    // Verify we entered SVG rect mode — pending rect should exist
+    const pendingRect = svg.locator('.penpal-pending-svg-highlight');
+    await expect(pendingRect).toBeAttached();
+
+    // Now drag below the container — this should trigger escape to text selection
+    const endX = afterBox!.x + afterBox!.width * 0.5;
+    const endY = afterBox!.y + afterBox!.height * 0.5;
+    await page.mouse.move(endX, endY, { steps: 5 });
+
+    // The SVG pending rect should be gone (cancelled on escape)
+    await expect(pendingRect).not.toBeAttached({ timeout: 2000 });
+
+    // Release the mouse on the content area to trigger SelectionToolbar
+    await page.mouse.up();
+
+    // A text selection should exist that includes the mermaid content
+    const selectionText = await page.evaluate(() => window.getSelection()?.toString().trim() ?? '');
+    expect(selectionText.length).toBeGreaterThan(0);
+
+    // The selection toolbar should appear
+    const toolbar = page.locator('.selection-toolbar');
+    await expect(toolbar).toBeVisible({ timeout: 5000 });
+    await expect(toolbar.locator('button', { hasText: 'Comment' })).toBeVisible();
+  });
+
   // E-PENPAL-MD-RENDER: verifies normal text selection toolbar works alongside mermaid diagrams.
   test('normal text selection still works alongside diagrams', async ({ page }) => {
     await blockPendingNavigation(page);

From 00d9a890211895ddf9c7da4e40d586cab6025bf4 Mon Sep 17 00:00:00 2001
From: Logan Johnson 
Date: Sun, 29 Mar 2026 23:08:00 -0400
Subject: [PATCH 7/7] =?UTF-8?q?test(penpal):=20close=20mermaid=20highlight?=
 =?UTF-8?q?=20test=20gaps=20=E2=80=94=20pending=20class=20+=20upward=20dra?=
 =?UTF-8?q?g-out?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- MarkdownViewer: verify pending-highlight class applied to mermaid
  container div (not just prose marks)
- e2e: verify dragging upward out of mermaid into preceding text
  cancels SVG rect and creates text selection (isAbove branch)

Co-Authored-By: Claude Opus 4.6 
---
 .../penpal/e2e/tests/mermaid-comments.spec.ts | 50 +++++++++++++++++++
 .../src/components/MarkdownViewer.test.tsx    | 14 ++++++
 2 files changed, 64 insertions(+)

diff --git a/apps/penpal/e2e/tests/mermaid-comments.spec.ts b/apps/penpal/e2e/tests/mermaid-comments.spec.ts
index dd46809e..59c4a8ba 100644
--- a/apps/penpal/e2e/tests/mermaid-comments.spec.ts
+++ b/apps/penpal/e2e/tests/mermaid-comments.spec.ts
@@ -272,6 +272,56 @@ test.describe('mermaid diagram commenting', () => {
     await expect(toolbar.locator('button', { hasText: 'Comment' })).toBeVisible();
   });
 
+  // E-PENPAL-HIGHLIGHT-MEDIA: verifies dragging upward out of a mermaid diagram
+  // creates a text selection anchored at the end of the container.
+  test('dragging upward out of mermaid diagram switches to text selection', async ({ page }) => {
+    await blockPendingNavigation(page);
+    await page.goto(`/file/${projectName}/${filePath}`);
+
+    const container = page.locator('.mermaid-container');
+    await expect(container).toBeVisible({ timeout: 10000 });
+    const svg = container.locator('svg');
+    await expect(svg).toBeVisible();
+
+    const svgBox = await svg.boundingBox();
+    expect(svgBox).toBeTruthy();
+
+    // Find the "intro text" paragraph above the diagram
+    const beforeText = page.locator('p', { hasText: 'intro text before' });
+    await expect(beforeText).toBeVisible();
+    const beforeBox = await beforeText.boundingBox();
+    expect(beforeBox).toBeTruthy();
+
+    // Start drag inside the SVG (center area)
+    const startX = svgBox!.x + svgBox!.width * 0.5;
+    const startY = svgBox!.y + svgBox!.height * 0.5;
+    await page.mouse.move(startX, startY);
+    await page.mouse.down();
+
+    // Move past 5px threshold to enter SVG rect mode
+    await page.mouse.move(startX - 10, startY - 10, { steps: 2 });
+    const pendingRect = svg.locator('.penpal-pending-svg-highlight');
+    await expect(pendingRect).toBeAttached();
+
+    // Drag upward above the container into the text before
+    const endX = beforeBox!.x + beforeBox!.width * 0.5;
+    const endY = beforeBox!.y + beforeBox!.height * 0.5;
+    await page.mouse.move(endX, endY, { steps: 5 });
+
+    // SVG rect should be cancelled
+    await expect(pendingRect).not.toBeAttached({ timeout: 2000 });
+
+    await page.mouse.up();
+
+    // Text selection should exist
+    const selectionText = await page.evaluate(() => window.getSelection()?.toString().trim() ?? '');
+    expect(selectionText.length).toBeGreaterThan(0);
+
+    // SelectionToolbar should appear
+    const toolbar = page.locator('.selection-toolbar');
+    await expect(toolbar).toBeVisible({ timeout: 5000 });
+  });
+
   // E-PENPAL-MD-RENDER: verifies normal text selection toolbar works alongside mermaid diagrams.
   test('normal text selection still works alongside diagrams', async ({ page }) => {
     await blockPendingNavigation(page);
diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
index d4772f68..b376b1c5 100644
--- a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
+++ b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
@@ -178,6 +178,20 @@ describe('MarkdownViewer', () => {
     expect(mermaidContainer?.getAttribute('data-thread-id')).toBe('t-media');
   });
 
+  // E-PENPAL-HIGHLIGHT-MEDIA: pending mermaid container gets pending-highlight class
+  it('applies pending-highlight class to mermaid container for pending highlights', () => {
+    const md = 'Before text\n\n```mermaid\ngraph TD\n  A --> B\n```';
+    const highlights = [
+      { threadId: 'pending', selectedText: 'Before text A B', startLine: 1, pending: true },
+    ];
+    const { container } = render(
+      ,
+    );
+    const mermaidContainer = container.querySelector('.mermaid-container.pending-highlight');
+    expect(mermaidContainer).not.toBeNull();
+    expect(mermaidContainer?.classList.contains('comment-highlight')).toBe(true);
+  });
+
   it('renders pending highlights with pending-highlight class', () => {
     const md = 'Hello world';
     const highlights = [