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=>'
, 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