From a875bcbb9eed3b83fe207291446a38af6ac10d73 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 14 Jun 2026 05:52:40 +0000 Subject: [PATCH] feat(ux): enlarge Mermaid diagrams in chat (#4075) + workspace CSV table preview (#4025) Combined light-UX release NW. Both rebased onto current master (were 96 and 247 behind; net 3-dot diffs applied, byte-identical to PR heads). Both reuse existing renderers (chat image lightbox / chat CSV table renderer). Review fix (#4025, Codex): the CSV table preview previously stripped the workspace Edit affordance .csv had when it fell through to the code preview. Restored: csv mode is editable, renderCsvPreviewContent caches raw content for the edit textarea, and saving an edited CSV re-renders the table (not markdown). Verified the full edit round-trip live + regression test. Co-authored-by: Rod Boev --- CHANGELOG.md | 7 ++ static/style.css | 3 +- static/ui.js | 113 ++++++++++++++---- static/workspace.js | 90 ++++++++++---- tests/test_csv_table_rendering.py | 63 +++++++++- ...t_issue3337_workspace_preview_highlight.py | 20 +++- tests/test_issue4075_mermaid_lightbox.py | 82 +++++++++++++ 7 files changed, 317 insertions(+), 61 deletions(-) create mode 100644 tests/test_issue4075_mermaid_lightbox.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a13d66fc..fed95a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.410] — 2026-06-14 — Release NW (chat Mermaid lightbox + workspace CSV table preview, #4075/#4025) + +### Added + +- **Rendered Mermaid diagrams can now be enlarged in chat (#4075).** Clicking a Mermaid diagram opens it in the existing fullscreen lightbox so larger graphs are readable without leaving the conversation. (#4075) +- **CSV files now preview as formatted tables in the workspace (#4025).** The workspace file preview reuses the existing chat CSV table renderer (with a 256 KB cap and clear error states for oversized/empty/malformed files) instead of showing raw comma-separated text, matching how CSVs already render inline in messages. (#4025) + ## [v0.51.409] — 2026-06-14 — Release NV (provider-gate title-gen reasoning extra_body, #4161/#2083) ### Fixed diff --git a/static/style.css b/static/style.css index 23285629..bfd2aa59 100644 --- a/static/style.css +++ b/static/style.css @@ -1741,6 +1741,7 @@ .img-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:lb-in .15s ease;} @keyframes lb-in{from{opacity:0}to{opacity:1}} .img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;} + .img-lightbox .mermaid-lightbox-svg{max-width:90vw;max-height:90vh;width:auto;height:auto;display:block;background:var(--code-bg);box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;} .img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;} .img-lightbox-close:hover{background:rgba(255,255,255,.22);} .img-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,opacity .15s;z-index:1;opacity:.6;} @@ -4559,7 +4560,7 @@ main.main > #mainPlugin{display:none;} /* ── Mermaid diagrams ── */ .mermaid-block{background:var(--code-bg);border-radius:8px;padding:16px;margin:8px 0;overflow-x:auto;} .mermaid-rendered{background:transparent;padding:8px 0;} -.mermaid-rendered svg{max-width:100%;height:auto;} +.mermaid-rendered svg{max-width:100%;height:auto;cursor:zoom-in;} /* ── Session projects ── */ .session-source-tabs{display:flex;gap:4px;padding:4px 10px 8px;flex-shrink:0;} diff --git a/static/ui.js b/static/ui.js index 37a7e6e0..8b801696 100644 --- a/static/ui.js +++ b/static/ui.js @@ -675,10 +675,70 @@ function _openImgLightbox(imgEl) { } _openImgLightboxWithNav(src, alt, allImages, startIndex); } +function _openMermaidLightbox(svgEl) { + if(!svgEl) return; + const lb = document.createElement('div'); + lb.className = 'img-lightbox'; + lb.setAttribute('role', 'dialog'); + lb.setAttribute('aria-modal', 'true'); + lb.setAttribute('aria-label', 'Mermaid diagram'); + const clone = svgEl.cloneNode(true); + const idMap = new Map(); + const idPrefix = 'mermaid-lightbox-'+Math.random().toString(36).slice(2,10)+'-'; + const idNodes = [clone, ...clone.querySelectorAll('[id]')].filter(el => el.id); + idNodes.forEach(el => { + const nextId = idPrefix + el.id; + idMap.set(el.id, nextId); + el.id = nextId; + }); + if(idMap.size){ + const refAttrs = ['href','xlink:href','fill','stroke','filter','clip-path','mask','marker-start','marker-mid','marker-end','aria-labelledby','aria-describedby']; + [clone, ...clone.querySelectorAll('*')].forEach(el => { + refAttrs.forEach(attr => { + const value = el.getAttribute(attr); + if(!value) return; + let nextValue = value.replace(/url\(#([^)]+)\)/g, (match, refId) => idMap.has(refId) ? `url(#${idMap.get(refId)})` : match); + if(nextValue.startsWith('#') && idMap.has(nextValue.slice(1))){ + nextValue = '#'+idMap.get(nextValue.slice(1)); + } + if(nextValue !== value){ + el.setAttribute(attr, nextValue); + } + }); + }); + clone.querySelectorAll('style').forEach(styleEl => { + let styleText = styleEl.textContent || ''; + idMap.forEach((nextId, originalId) => { + const escapedId = originalId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + styleText = styleText.replace(new RegExp(`url\\(#${escapedId}\\)`, 'g'), `url(#${nextId})`); + styleText = styleText.replace(new RegExp(`(^|[^\\w-])#${escapedId}(?=$|[^\\w-])`, 'g'), (match, prefix) => `${prefix}#${nextId}`); + }); + styleEl.textContent = styleText; + }); + } + clone.classList.add('mermaid-lightbox-svg'); + clone.removeAttribute('width'); + clone.removeAttribute('height'); + clone.onclick = e => e.stopPropagation(); + const cls = document.createElement('button'); + cls.className = 'img-lightbox-close'; + cls.setAttribute('aria-label', 'Close'); + cls.textContent = '×'; + cls.onclick = () => _closeImgLightbox(lb); + lb.appendChild(clone); + lb.appendChild(cls); + lb.onclick = () => _closeImgLightbox(lb); + lb._keyHandler = e => { + if(e.key==='Escape') _closeImgLightbox(lb); + }; + document.body.appendChild(lb); + document.addEventListener('keydown', lb._keyHandler); +} function _openImgLightboxWithNav(src, alt, images, index) { const lb = document.createElement('div'); lb.className = 'img-lightbox'; lb.setAttribute('role', 'dialog'); + lb.setAttribute('aria-modal', 'true'); lb.setAttribute('aria-label', alt || 'Image'); const img = document.createElement('img'); img.src = src; @@ -772,6 +832,8 @@ document.addEventListener('click', e => { // Message-attached images (already wired since v0.50.x). let img = e.target.closest('.msg-media-img'); if(img){ _openImgLightbox(img); return; } + const mermaidSvg = e.target.closest('.mermaid-rendered svg'); + if(mermaidSvg){ _openMermaidLightbox(mermaidSvg); return; } // Composer attach-tray image thumbnails — click any pasted/dropped image // chip to lightbox-zoom it before sending. Excludes audio/video chips, // which keep their inline media controls. SVG thumbnails (.attach-thumb--svg) @@ -11195,8 +11257,33 @@ function loadDiffInline(container){ }); } +const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering + +function buildCsvTablePreview(path, text){ + if(typeof text!=='string') return {errorKey:'csv_error'}; + if(text.length>CSV_MAX_SIZE) return {errorKey:'csv_too_large'}; + const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim()); + if(rows.length<2) return {errorKey:'csv_no_data'}; + // Auto-detect separator (comma, semicolon, tab) + // Heuristic: uses the first separator found in the header row. Edge case: + // quoted fields containing commas without non-quoted commas in the header + // could cause misdetection — acceptable trade-off for a preview renderer. + const firstLine=rows[0]; + const separators=[',',';','\t']; + const sep=separators.find(s=>firstLine.includes(s))||','; + const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,'')); + const bodyRows=rows.slice(1).map(r=>''+r.split(sep).map(c=>`${esc(c.trim().replace(/^["']|["']$/g,''))}`).join('')+'').join(''); + const headerRow=headers.map(h=>`${esc(h)}`).join(''); + return { + html:`
${esc(path.split('/').pop())} ${t('csv_header_note')}
${headerRow}${bodyRows}
`, + }; +} + +function _csvPreviewErrorHtml(path, errorKey){ + return `
${esc(path.split('/').pop())}
${t(errorKey)}
`; +} + function loadCsvInline(container){ - const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering const root=container||document; root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); @@ -11204,29 +11291,11 @@ function loadCsvInline(container){ fetch('api/media?path='+encodeURIComponent(path)) .then(r=>{if(!r.ok) throw new Error(r.status);return r.text();}) .then(text=>{ - if(text.length>CSV_MAX_SIZE){ - el.outerHTML=`
${esc(path.split('/').pop())}
${t('csv_too_large')}
`; - return; - } - const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim()); - if(rows.length<2){ - el.outerHTML=`
${esc(path.split('/').pop())}
${t('csv_no_data')}
`; - return; - } - // Auto-detect separator (comma, semicolon, tab) - // Heuristic: uses the first separator found in the header row. Edge case: - // quoted fields containing commas without non-quoted commas in the header - // could cause misdetection — acceptable trade-off for a preview renderer. - const firstLine=rows[0]; - const separators=[',',';','\t']; - let sep=separators.find(s=>firstLine.includes(s))||','; - const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,'')); - const bodyRows=rows.slice(1).map(r=>''+r.split(sep).map(c=>`${esc(c.trim().replace(/^["']|["']$/g,''))}`).join('')+'').join(''); - const headerRow=headers.map(h=>`${esc(h)}`).join(''); - el.outerHTML=`
${esc(path.split('/').pop())} ${t('csv_header_note')}
${headerRow}${bodyRows}
`; + const preview=buildCsvTablePreview(path, text); + el.outerHTML=preview.html||_csvPreviewErrorHtml(path, preview.errorKey||'csv_error'); }) .catch(()=>{ - el.outerHTML=`
${esc(path.split('/').pop())}
${t('csv_error')}
`; + el.outerHTML=_csvPreviewErrorHtml(path, 'csv_error'); }); }); } diff --git a/static/workspace.js b/static/workspace.js index cbfcbde5..35be58ce 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -507,6 +507,51 @@ function renderMarkdownPreviewContent(data){ requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); } +function renderCodePreviewContent(path, content){ + showPreview('code'); + const codeEl=document.createElement('code'); + codeEl.textContent=content; + const lang=_prismLanguageForPath(path); + if(lang) codeEl.className='language-'+lang; + const pre=$('previewCode'); + pre.textContent=''; + // Prism.highlightElement() propagates the language-* class onto the + // parent
, so a previously-previewed code file leaves e.g.
+  // "language-css" on #previewCode. A subsequent plain-text file builds a
+  // class-less , and Prism walks up to that stale ancestor class and
+  // mis-highlights prose. Strip any inherited language-* token from the
+  // 
 before each render so highlighting never leaks across files.
+  pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
+  pre.appendChild(codeEl);
+  // Only invoke Prism when we actually assigned a language; otherwise the
+  // class-less  would inherit any ancestor language-* class.
+  if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
+    Prism.highlightElement(codeEl);
+  }
+}
+
+function renderCsvPreviewContent(path, content){
+  if(typeof buildCsvTablePreview!=='function') return false;
+  const preview=buildCsvTablePreview(path, content);
+  if(!preview) return false;
+  showPreview('csv');
+  // Preserve the raw CSV text so the Edit flow can repopulate the textarea and
+  // a save can re-render the table from the edited source (#4025 review, Codex).
+  if(typeof content==='string'){
+    _previewRawContent = content;
+    _previewRawContentPath = path;
+  }
+  if(preview.html){
+    $('previewMd').innerHTML=preview.html;
+    return true;
+  }
+  if(preview.errorKey&&typeof _csvPreviewErrorHtml==='function'){
+    $('previewMd').innerHTML=_csvPreviewErrorHtml(path, preview.errorKey);
+    return true;
+  }
+  return false;
+}
+
 function forceRenderMarkdownPreview(){
   // #3378 review (Codex): don't force-render from a dirty/open editor — the
   // cached raw content would not reflect the unsaved edit. Require a saved,
@@ -518,21 +563,21 @@ function forceRenderMarkdownPreview(){
 }
 
 let _previewCurrentPath = '';  // relative path of currently previewed file
-let _previewCurrentMode = '';  // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
+let _previewCurrentMode = '';  // 'code' | 'csv' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
 let _previewDirty = false;     // true when edits are unsaved
 
 function showPreview(mode){
-  // mode: 'code' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
+  // mode: 'code' | 'csv' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
   $('previewCode').style.display     = mode==='code'  ? '' : 'none';
   $('previewImgWrap').style.display  = mode==='image' ? '' : 'none';
   const mediaWrap=$('previewMediaWrap'); if(mediaWrap) mediaWrap.style.display = (mode==='audio'||mode==='video') ? '' : 'none';
   const pdfWrap=$('previewPdfWrap'); if(pdfWrap) pdfWrap.style.display = mode==='pdf' ? '' : 'none';
-  $('previewMd').style.display       = mode==='md'    ? '' : 'none';
+  $('previewMd').style.display       = (mode==='md'||mode==='csv') ? '' : 'none';
   $('previewHtmlWrap').style.display = mode==='html'  ? '' : 'none';
   $('previewEditArea').style.display = 'none';  // start in read-only
   const badge=$('previewBadge');
   badge.className='preview-badge '+mode;
-  badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
+  badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='csv'?'csv':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
   _previewCurrentMode = mode;
   _previewDirty = false;
   updateEditBtn();
@@ -545,7 +590,7 @@ function showPreview(mode){
 function updateEditBtn(){
   const btn=$('btnEditFile');
   if(!btn)return;
-  const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
+  const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv';
   btn.style.display = editable?'':'none';
   const editing = $('previewEditArea').style.display!=='none';
   btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
@@ -571,6 +616,7 @@ async function toggleEditMode(){
       _previewRawContent = content;
       _previewRawContentPath = _previewCurrentPath;
       if(_previewCurrentMode==='code') $('previewCode').textContent=content;
+      else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, content);
       else renderMarkdownPreviewContent({content});
       $('previewEditArea').style.display='none';
       if(_previewCurrentMode==='code') $('previewCode').style.display='';
@@ -725,6 +771,18 @@ async function openFile(path, opts={}){
       iframe.src=''; // clear first to avoid stale content
       iframe.src=url;
     }
+  } else if(ext==='.csv'){
+    try{
+      const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
+      if(data.binary){
+        downloadFile(path);
+        return;
+      }
+      if(renderCsvPreviewContent(path, data.content)) return;
+      renderCodePreviewContent(path, data.content);
+    }catch(e){
+      downloadFile(path);
+    }
   } else {
     // Plain code / text -- but fall back to download if server signals binary
     try{
@@ -734,27 +792,7 @@ async function openFile(path, opts={}){
         downloadFile(path);
         return;
       }
-      showPreview('code');
-      // Syntax highlighting with Prism.js (already loaded on the page).
-      const codeEl=document.createElement('code');
-      codeEl.textContent=data.content;
-      const lang=_prismLanguageForPath(path);
-      if(lang) codeEl.className='language-'+lang;
-      const pre=$('previewCode');
-      pre.textContent='';
-      // Prism.highlightElement() propagates the language-* class onto the
-      // parent 
, so a previously-previewed code file leaves e.g.
-      // "language-css" on #previewCode. A subsequent plain-text file builds a
-      // class-less , and Prism walks up to that stale ancestor class and
-      // mis-highlights prose. Strip any inherited language-* token from the
-      // 
 before each render so highlighting never leaks across files.
-      pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
-      pre.appendChild(codeEl);
-      // Only invoke Prism when we actually assigned a language; otherwise the
-      // class-less  would inherit any ancestor language-* class.
-      if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
-        Prism.highlightElement(codeEl);
-      }
+      renderCodePreviewContent(path, data.content);
     }catch(e){
       // If it's a 400/too-large error, offer download instead
       downloadFile(path);
diff --git a/tests/test_csv_table_rendering.py b/tests/test_csv_table_rendering.py
index 1d208c9f..a2afc3da 100644
--- a/tests/test_csv_table_rendering.py
+++ b/tests/test_csv_table_rendering.py
@@ -1,5 +1,8 @@
 """Test: CSV table rendering (#485)"""
 import re
+from pathlib import Path
+
+WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
 
 
 def test_csv_extension_regex():
@@ -47,6 +50,12 @@ def test_csv_media_file_handler():
         src = f.read()
     assert 'csv-inline-load' in src, "Missing csv-inline-load class for MEDIA: CSV"
     assert 'csv_loading' in src, "Missing csv_loading i18n key usage"
+    open_file = WORKSPACE_JS[WORKSPACE_JS.index("async function openFile(path, opts={}){"):WORKSPACE_JS.index("\nfunction downloadFile")]
+    csv_pos = open_file.find("} else if(ext==='.csv'){")
+    generic_pos = open_file.find("} else {\n    // Plain code / text -- but fall back to download if server signals binary")
+    assert csv_pos != -1, "openFile() should handle .csv before the generic code branch"
+    assert generic_pos != -1, "generic code branch missing from openFile()"
+    assert csv_pos < generic_pos
 
 
 def test_loadCsvInline_function():
@@ -54,22 +63,23 @@ def test_loadCsvInline_function():
     with open('static/ui.js') as f:
         src = f.read()
     assert 'function loadCsvInline' in src, "Missing loadCsvInline function"
+    assert 'function buildCsvTablePreview(path, text)' in src, "Missing shared CSV preview helper"
 
 
 def test_csv_inline_max_size():
     """Verify CSV inline rendering has a size cap."""
     with open('static/ui.js') as f:
         src = f.read()
-    csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
-    assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
-    assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
+    assert 'const CSV_MAX_SIZE=256*1024' in src, "Should have CSV_MAX_SIZE constant"
+    helper_section = src[src.find('function buildCsvTablePreview'):src.find('function buildCsvTablePreview') + 2000]
+    assert 'csv_too_large' in helper_section, "Should use csv_too_large i18n for oversized files"
 
 
 def test_csv_auto_detect_separator():
     """Verify CSV handler auto-detects separator."""
     with open('static/ui.js') as f:
         src = f.read()
-    csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
+    csv_section = src[src.find('function buildCsvTablePreview'):src.find('function buildCsvTablePreview') + 2000]
     assert 'separators' in csv_section, "Should have separator detection"
     assert ';' in csv_section, "Should detect semicolon separator"
     assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
@@ -86,9 +96,14 @@ def test_csv_error_handling():
     """Verify CSV error and empty data handling."""
     with open('static/ui.js') as f:
         src = f.read()
-    csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2500]
+    csv_section = src[src.find('function buildCsvTablePreview'):src.find('function loadCsvInline') + 1000]
     assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
     assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
+    helper_start = WORKSPACE_JS.index("function renderCsvPreviewContent(path, content){")
+    helper_end = WORKSPACE_JS.index("\nfunction forceRenderMarkdownPreview", helper_start)
+    helper_body = WORKSPACE_JS[helper_start:helper_end]
+    assert "if(preview.errorKey&&typeof _csvPreviewErrorHtml==='function'){" in helper_body
+    assert "$('previewMd').innerHTML=_csvPreviewErrorHtml(path, preview.errorKey);" in helper_body
 
 
 def test_csv_loadCsvInline_called_after_render():
@@ -99,13 +114,28 @@ def test_csv_loadCsvInline_called_after_render():
     idx = src.find('function postProcessRenderedMessages')
     body = src[idx:idx + 500]
     assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render"
+    load_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 1200]
+    assert 'buildCsvTablePreview(path, text)' in load_section, "Inline loader should reuse the shared helper"
+    open_file = WORKSPACE_JS[WORKSPACE_JS.index("async function openFile(path, opts={}){"):WORKSPACE_JS.index("\nfunction downloadFile")]
+    csv_pos = open_file.find("} else if(ext==='.csv'){")
+    generic_pos = open_file.find("} else {\n    // Plain code / text -- but fall back to download if server signals binary")
+    branch = open_file[csv_pos:generic_pos]
+    assert "if(renderCsvPreviewContent(path, data.content)) return;" in branch
+    assert "renderCodePreviewContent(path, data.content);" in branch
+    assert "showPreview('csv');" in WORKSPACE_JS
+    assert "$('previewMd').innerHTML=preview.html;" in WORKSPACE_JS
+    assert "(mode==='md'||mode==='csv')" in WORKSPACE_JS
+    assert "mode==='csv'?'csv'" in WORKSPACE_JS
+    # csv files keep the workspace Edit affordance (regression #4025: previously
+    # csv fell through to the code preview which exposed the Edit button).
+    assert "_previewCurrentMode==='md'||_previewCurrentMode==='csv'" in WORKSPACE_JS
 
 
 def test_csv_line_ending_normalization():
     """Verify CSV handler normalizes line endings."""
     with open('static/ui.js') as f:
         src = f.read()
-    csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
+    csv_section = src[src.find('function buildCsvTablePreview'):src.find('function buildCsvTablePreview') + 2000]
     assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
     assert '\\r' in csv_section, "Should handle \\r line endings"
 
@@ -139,3 +169,24 @@ def test_csv_not_matched_by_image_exts():
     assert match
     exts = match.group(1)
     assert 'csv' not in exts.lower(), ".csv should NOT be in _IMAGE_EXTS"
+
+
+def test_csv_preview_preserves_edit_flow():
+    """Regression (#4025 review): the CSV table preview must not strip the
+    workspace Edit affordance that .csv had when it fell through to the code
+    preview. csv mode must be editable, the preview must cache raw content for
+    the textarea, and a save must re-render the table (not markdown)."""
+    with open('static/workspace.js') as f:
+        src = f.read()
+    # csv mode is editable
+    assert "_previewCurrentMode==='csv'" in src, "csv mode should be editable / handled in workspace edit flow"
+    edit_btn = src[src.find('function updateEditBtn'):src.find('function updateEditBtn') + 400]
+    assert "==='csv'" in edit_btn, "updateEditBtn must allow editing csv mode"
+    # renderCsvPreviewContent caches the raw text for the edit textarea
+    csv_render = src[src.find('function renderCsvPreviewContent'):src.find('function renderCsvPreviewContent') + 700]
+    assert '_previewRawContent = content' in csv_render or '_previewRawContent=content' in csv_render, \
+        "renderCsvPreviewContent must cache raw CSV content for the edit flow"
+    # save path re-renders the CSV table for csv mode
+    save_section = src[src.find('async function toggleEditMode'):src.find('async function toggleEditMode') + 1200]
+    assert "renderCsvPreviewContent(_previewCurrentPath, content)" in save_section, \
+        "saving an edited CSV must re-render the table, not markdown"
diff --git a/tests/test_issue3337_workspace_preview_highlight.py b/tests/test_issue3337_workspace_preview_highlight.py
index 1729d805..918b00b3 100644
--- a/tests/test_issue3337_workspace_preview_highlight.py
+++ b/tests/test_issue3337_workspace_preview_highlight.py
@@ -18,11 +18,19 @@ def _open_file_body() -> str:
     return WORKSPACE_JS[start:start + 8000]
 
 
+def _code_preview_helper() -> str:
+    start = WORKSPACE_JS.index("function renderCodePreviewContent(path, content){")
+    end = WORKSPACE_JS.index("\nfunction renderCsvPreviewContent(path, content){", start)
+    return WORKSPACE_JS[start:end]
+
+
 def test_workspace_preview_assigns_prism_language_class():
     body = _open_file_body()
-    assert "_prismLanguageForPath(path)" in body
-    assert "codeEl.className='language-'+lang" in body
-    assert "Prism.highlightElement(codeEl)" in body
+    helper = _code_preview_helper()
+    assert "renderCodePreviewContent(path, data.content);" in body
+    assert "_prismLanguageForPath(path)" in helper
+    assert "codeEl.className='language-'+lang" in helper
+    assert "Prism.highlightElement(codeEl)" in helper
 
 
 def test_prism_language_map_covers_common_extensions():
@@ -61,12 +69,12 @@ def test_plain_text_files_do_not_inherit_prior_file_highlighting():
     2. Prism.highlightElement is only called when a non-empty language was
        assigned, so a class-less  never walks up to an ancestor class.
     """
-    body = _open_file_body()
+    helper = _code_preview_helper()
     # Guard 1: stale language-* token stripped from the 
 before append.
-    assert "pre.className=pre.className.replace(/\\blanguage-\\S+/g,'')" in body, (
+    assert "pre.className=pre.className.replace(/\\blanguage-\\S+/g,'')" in helper, (
         "previewCode 
 must have stale language-* classes stripped each render"
     )
     # Guard 2: highlightElement gated on a truthy lang.
-    assert "if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function')" in body, (
+    assert "if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function')" in helper, (
         "Prism.highlightElement must only run when a language was assigned"
     )
diff --git a/tests/test_issue4075_mermaid_lightbox.py b/tests/test_issue4075_mermaid_lightbox.py
new file mode 100644
index 00000000..8913e6fb
--- /dev/null
+++ b/tests/test_issue4075_mermaid_lightbox.py
@@ -0,0 +1,82 @@
+"""Static regression coverage for Mermaid diagram lightbox wiring."""
+
+from pathlib import Path
+import re
+
+
+ROOT = Path(__file__).resolve().parent.parent
+UI = ROOT / "static" / "ui.js"
+STYLE = ROOT / "static" / "style.css"
+
+
+def _ui_js() -> str:
+    return UI.read_text(encoding="utf-8")
+
+
+def _style_css() -> str:
+    return STYLE.read_text(encoding="utf-8")
+
+
+class TestMermaidLightboxHelper:
+    def test_mermaid_lightbox_has_dedicated_helper(self):
+        src = _ui_js()
+        assert re.search(r"function\s+_openMermaidLightbox\(svgEl\)\s*\{", src)
+        assert "svgEl.cloneNode(true)" in src
+        assert "mermaid-lightbox-svg" in src
+
+    def test_mermaid_lightbox_reuses_existing_modal_chrome(self):
+        src = _ui_js()
+        assert "img-lightbox" in src
+        assert "img-lightbox-close" in src
+        assert "_closeImgLightbox(lb)" in src
+
+    def test_mermaid_lightbox_rewrites_cloned_svg_ids(self):
+        src = _ui_js()
+        assert "const idMap = new Map();" in src
+        assert "const idPrefix = 'mermaid-lightbox-'" in src
+        assert "replace(/url\\(#([^)]+)\\)/g" in src
+
+    def test_mermaid_lightbox_rewrites_embedded_style_selectors(self):
+        src = _ui_js()
+        assert "clone.querySelectorAll('style').forEach(styleEl => {" in src
+        assert "styleText = styleText.replace(new RegExp(`url\\\\(#${escapedId}\\\\)`" in src
+        assert "styleText = styleText.replace(new RegExp(`(^|[^\\\\w-])#${escapedId}(?=$|[^\\\\w-])`" in src
+
+
+class TestDocumentClickDelegate:
+    def test_delegate_routes_rendered_mermaid_svgs_before_attach_thumb(self):
+        src = _ui_js()
+        mermaid_branch = (
+            "  const mermaidSvg = e.target.closest('.mermaid-rendered svg');\n"
+            "  if(mermaidSvg){ _openMermaidLightbox(mermaidSvg); return; }\n"
+        )
+        attach_branch = (
+            "  img = e.target.closest('.attach-thumb');\n"
+            "  if(img && img.tagName === 'IMG'){\n"
+        )
+        assert mermaid_branch in src
+        assert attach_branch in src
+        assert src.index(mermaid_branch) < src.index(attach_branch)
+
+    def test_delegate_still_handles_message_images(self):
+        src = _ui_js()
+        msg_branch = "let img = e.target.closest('.msg-media-img');\n  if(img){ _openImgLightbox(img); return; }"
+        assert msg_branch in src
+
+
+class TestMermaidLightboxCss:
+    def test_rendered_mermaid_svg_advertises_zoom(self):
+        src = _style_css()
+        assert ".mermaid-rendered svg{max-width:100%;height:auto;cursor:zoom-in;}" in src
+
+    def test_lightbox_svg_uses_modal_viewport_limits(self):
+        src = _style_css()
+        rule = ".img-lightbox .mermaid-lightbox-svg{max-width:90vw;max-height:90vh;"
+        assert rule in src
+        assert "background:var(--code-bg);" in src
+
+
+class TestLightboxAria:
+    def test_lightboxes_set_aria_modal(self):
+        src = _ui_js()
+        assert src.count("setAttribute('aria-modal', 'true')") >= 2