From d95f77c10ed9e10980e34b42512c9d7b02802cc0 Mon Sep 17 00:00:00 2001 From: Rustem Date: Tue, 9 Jun 2026 11:40:53 +0300 Subject: [PATCH 1/6] fix(web): render previews on a light background The shared wrapper's `@media (prefers-color-scheme: dark)` block made preview text light-grey on the forced-white block background under a dark OS theme. Strip that block from preview output only (JS previews before `srcdoc`; server-rendered template preview via `stripDarkModeCSS`). Sent emails are unaffected. --- internal/web/handlers/templates.go | 8 ++++++++ internal/web/views/block_form.html | 1 + internal/web/views/block_view.html | 1 + internal/web/views/template_view.html | 4 +--- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go index 802ae45..7dc7422 100644 --- a/internal/web/handlers/templates.go +++ b/internal/web/handlers/templates.go @@ -471,6 +471,8 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) { } } + html = stripDarkModeCSS(html) + data := map[string]any{ "Title": "Preview: " + t.Name, "Active": "templates", @@ -483,6 +485,12 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) { h.render(w, "template_preview", data) } +var darkModeBlockRe = regexp.MustCompile(`(?s)@media\s*\(prefers-color-scheme:\s*dark\)\s*\{.*?\}\s*\}`) + +func stripDarkModeCSS(html string) string { + return darkModeBlockRe.ReplaceAllString(html, "") +} + // TemplateExportData represents template data for export/import type TemplateExportData struct { Name string `json:"name"` diff --git a/internal/web/views/block_form.html b/internal/web/views/block_form.html index 98f6f9f..01ae6ae 100644 --- a/internal/web/views/block_form.html +++ b/internal/web/views/block_form.html @@ -321,6 +321,7 @@

Template Variables

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(html) { + html = html.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, ''); frame.srcdoc = html; status.textContent = 'OK — block rendered with the JSON above.'; status.style.color = 'var(--text-muted)'; diff --git a/internal/web/views/block_view.html b/internal/web/views/block_view.html index 5318bb7..4028f3e 100644 --- a/internal/web/views/block_view.html +++ b/internal/web/views/block_view.html @@ -171,6 +171,7 @@

Appearance

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(html) { + html = html.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, ''); frame.srcdoc = html; status.textContent = 'OK'; status.style.color = 'var(--text-muted)'; diff --git a/internal/web/views/template_view.html b/internal/web/views/template_view.html index 29b1be2..beb30a0 100644 --- a/internal/web/views/template_view.html +++ b/internal/web/views/template_view.html @@ -94,9 +94,6 @@

Deployment Status

- sample payload so Subject macros, range and if blocks expand the - same way they will when the API serves a real recipient. The width - buttons resize the surrounding shell so the wrapper @media rules

Preview

@@ -229,6 +226,7 @@

Actions

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(rendered) { + rendered = rendered.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, ''); frame.srcdoc = rendered; if (status) { status.textContent = 'OK'; status.style.color = 'var(--text-muted)'; } }).catch(function(err) { From 31eb13c2cb187d79ddf3246f022131f556e90510 Mon Sep 17 00:00:00 2001 From: Rustem Date: Tue, 9 Jun 2026 11:40:53 +0300 Subject: [PATCH 2/6] fix(builder): allow editing text next to inline icons/links Inline editing was blocked for elements with a non-text child, so text sharing a `

` with an icon or link wasn't editable. - `replaceTextVars` now rewrites placeholders only in text content, so `href="{{.Var}}"` is no longer corrupted (neutralized to `#` via `replaceHrefVars`). - ``/`` no longer block editing: surrounding text is wrapped in editable spans, link text is edited separately. Listener wiring moved to `bindEditable`. --- internal/web/static/js/builder.js | 69 +++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js index a8bc06d..c179ba0 100644 --- a/internal/web/static/js/builder.js +++ b/internal/web/static/js/builder.js @@ -375,8 +375,13 @@ }); } + function replaceHrefVars(html) { + return html.replace(/(href=")(\{\{\.\w+\}\})(")/gi, '$1#$3'); + } + function replaceTextVars(html) { - return html.replace(/\{\{\.(\w+)\}\}/g, function(match, varName) { + return html.replace(/<[^>]*>|\{\{\.(\w+)\}\}/g, function(match, varName) { + if (match[0] === '<') return match; // a tag — leave untouched return '' + varName + ''; }); } @@ -407,6 +412,7 @@ function previewHTML(html) { html = applyTestValues(html); html = replaceImageVars(html); + html = replaceHrefVars(html); html = replaceTextVars(html); return html; } @@ -432,39 +438,66 @@ }, true); } + function bindEditable(el, original, item, previewEl) { + el.setAttribute('contenteditable', 'true'); + el.setAttribute('spellcheck', 'false'); + el.dataset.originalText = original; + el.addEventListener('mousedown', function(e) { e.stopPropagation(); }); + el.addEventListener('focus', function() { + var blockEl = el.closest('.builder-block'); + if (blockEl) blockEl.draggable = false; + }); + el.addEventListener('input', function() { scheduleInlineSave(item, previewEl); }); + el.addEventListener('blur', function() { + var blockEl = el.closest('.builder-block'); + if (blockEl) blockEl.draggable = true; + scheduleInlineSave(item, previewEl, true); + }); + el.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); el.blur(); } + }); + } + function enableInlineEdit(previewEl, item) { previewEl.querySelectorAll('td, th, span, p, h1, h2, h3, h4, h5, h6, li, a, b, strong, i, em').forEach(function(node) { if (node.dataset && node.dataset.placeholder) return; + if (node.dataset && node.dataset.inlineText) return; // already-wrapped fragment + var mixed = false; var blocksParent = false; for (var c = 0; c < node.children.length; c++) { var ch = node.children[c]; if (ch.tagName === 'BR') continue; + if (ch.tagName === 'IMG' || ch.tagName === 'A') { mixed = true; continue; } if (ch.dataset && ch.dataset.placeholder) continue; blocksParent = true; break; } if (blocksParent) return; + + if (mixed) { + var textNodes = []; + node.childNodes.forEach(function(c) { + if (c.nodeType === 3 && c.nodeValue && c.nodeValue.trim()) textNodes.push(c); + }); + textNodes.forEach(function(tn) { + var raw = tn.nodeValue; + var trimmed = raw.trim(); + if (!trimmed) return; + if (item.html.indexOf(trimmed) === -1) return; + var span = document.createElement('span'); + span.dataset.inlineText = '1'; + span.textContent = raw; + tn.parentNode.replaceChild(span, tn); + bindEditable(span, trimmed, item, previewEl); + }); + return; + } + var canonical = canonicalText(node); var trimmed = canonical.trim(); if (!trimmed) return; if (item.html.indexOf(trimmed) === -1) return; - node.setAttribute('contenteditable', 'true'); - node.setAttribute('spellcheck', 'false'); - node.dataset.originalText = trimmed; - node.addEventListener('mousedown', function(e) { e.stopPropagation(); }); - node.addEventListener('focus', function() { - var blockEl = node.closest('.builder-block'); - if (blockEl) blockEl.draggable = false; - }); - node.addEventListener('input', function() { scheduleInlineSave(item, previewEl); }); - node.addEventListener('blur', function() { - var blockEl = node.closest('.builder-block'); - if (blockEl) blockEl.draggable = true; - scheduleInlineSave(item, previewEl, true); - }); - node.addEventListener('keydown', function(e) { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); node.blur(); } - }); + bindEditable(node, trimmed, item, previewEl); }); } From 5e1958cdd27848f426cbb0aedfd8a21d6bd4ae45 Mon Sep 17 00:00:00 2001 From: Rustem Date: Mon, 15 Jun 2026 14:02:47 +0300 Subject: [PATCH 3/6] fix(builder): map inline edits by source position indexOf(original) hit the first match, so editing a repeated short fragment rewrote the wrong one. Record each fragment's source offset at wrap time and apply edits by offset; fall back to indexOf if it drifts. --- internal/web/static/js/builder.js | 42 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js index c179ba0..a0057db 100644 --- a/internal/web/static/js/builder.js +++ b/internal/web/static/js/builder.js @@ -438,10 +438,11 @@ }, true); } - function bindEditable(el, original, item, previewEl) { + function bindEditable(el, original, item, previewEl, srcIndex) { el.setAttribute('contenteditable', 'true'); el.setAttribute('spellcheck', 'false'); el.dataset.originalText = original; + if (srcIndex !== undefined && srcIndex >= 0) el.dataset.sourceIndex = String(srcIndex); el.addEventListener('mousedown', function(e) { e.stopPropagation(); }); el.addEventListener('focus', function() { var blockEl = el.closest('.builder-block'); @@ -459,6 +460,13 @@ } function enableInlineEdit(previewEl, item) { + var cursor = 0; + var locate = function(text) { + var at = item.html.indexOf(text, cursor); + if (at === -1) return -1; + cursor = at + text.length; + return at; + }; previewEl.querySelectorAll('td, th, span, p, h1, h2, h3, h4, h5, h6, li, a, b, strong, i, em').forEach(function(node) { if (node.dataset && node.dataset.placeholder) return; if (node.dataset && node.dataset.inlineText) return; // already-wrapped fragment @@ -483,12 +491,13 @@ var raw = tn.nodeValue; var trimmed = raw.trim(); if (!trimmed) return; - if (item.html.indexOf(trimmed) === -1) return; + var at = locate(trimmed); + if (at === -1) return; var span = document.createElement('span'); span.dataset.inlineText = '1'; span.textContent = raw; tn.parentNode.replaceChild(span, tn); - bindEditable(span, trimmed, item, previewEl); + bindEditable(span, trimmed, item, previewEl, at); }); return; } @@ -496,8 +505,9 @@ var canonical = canonicalText(node); var trimmed = canonical.trim(); if (!trimmed) return; - if (item.html.indexOf(trimmed) === -1) return; - bindEditable(node, trimmed, item, previewEl); + var at = locate(trimmed); + if (at === -1) return; + bindEditable(node, trimmed, item, previewEl, at); }); } @@ -512,6 +522,8 @@ var newSourceHTML = item.html; var changes = 0; var diagnostics = []; + + var edits = []; previewEl.querySelectorAll('[contenteditable="true"]').forEach(function(node) { var original = node.dataset.originalText; var current = canonicalText(node).trim(); @@ -522,13 +534,27 @@ diagnostics.push('placeholder count mismatch in ' + JSON.stringify(original.slice(0, 30))); return; } - var idx = newSourceHTML.indexOf(original); + var idx = -1; + if (node.dataset.sourceIndex !== undefined) { + var si = parseInt(node.dataset.sourceIndex, 10); + if (si >= 0 && item.html.substr(si, original.length) === original) idx = si; + } + if (idx === -1) idx = item.html.indexOf(original); if (idx === -1) { diagnostics.push('original text not found in source: ' + JSON.stringify(original.slice(0, 30))); return; } - newSourceHTML = newSourceHTML.slice(0, idx) + current + newSourceHTML.slice(idx + original.length); - node.dataset.originalText = current; + edits.push({ idx: idx, original: original, current: current, node: node }); + }); + + edits.sort(function(a, b) { return a.idx - b.idx; }); + var offset = 0; + edits.forEach(function(e) { + var pos = e.idx + offset; + newSourceHTML = newSourceHTML.slice(0, pos) + e.current + newSourceHTML.slice(pos + e.original.length); + offset += e.current.length - e.original.length; + e.node.dataset.originalText = e.current; + if (e.node.dataset.sourceIndex !== undefined) e.node.dataset.sourceIndex = String(e.idx); changes++; }); console.log('[inline-edit] save called', { From 0ecc3df02d2710810da8f83cc1bda5e5a8dd8e7b Mon Sep 17 00:00:00 2001 From: Rustem Date: Sun, 28 Jun 2026 23:30:16 +0300 Subject: [PATCH 4/6] fix(builder): keep inline-edit positions valid across repeated saves --- internal/web/static/js/builder.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js index a0057db..f901d3d 100644 --- a/internal/web/static/js/builder.js +++ b/internal/web/static/js/builder.js @@ -554,9 +554,21 @@ newSourceHTML = newSourceHTML.slice(0, pos) + e.current + newSourceHTML.slice(pos + e.original.length); offset += e.current.length - e.original.length; e.node.dataset.originalText = e.current; - if (e.node.dataset.sourceIndex !== undefined) e.node.dataset.sourceIndex = String(e.idx); changes++; }); + + if (changes > 0) { + var recur = 0; + previewEl.querySelectorAll('[contenteditable="true"]').forEach(function(node) { + if (node.dataset.sourceIndex === undefined) return; + var t = node.dataset.originalText; + if (t === undefined) return; + var at = newSourceHTML.indexOf(t, recur); + if (at === -1) return; + node.dataset.sourceIndex = String(at); + recur = at + t.length; + }); + } console.log('[inline-edit] save called', { blockId: item.blockId, sourceLen: item.html.length, From 509968e163754f4356a1d937c25fa165ef767156 Mon Sep 17 00:00:00 2001 From: Rustem Date: Sun, 28 Jun 2026 23:42:04 +0300 Subject: [PATCH 5/6] fix(builder): keep inline-edit positions correct for mixed nodes and repeated saves --- internal/web/static/js/builder.js | 38 ++++++++++++++++++------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js index f901d3d..76778ba 100644 --- a/internal/web/static/js/builder.js +++ b/internal/web/static/js/builder.js @@ -469,7 +469,8 @@ }; previewEl.querySelectorAll('td, th, span, p, h1, h2, h3, h4, h5, h6, li, a, b, strong, i, em').forEach(function(node) { if (node.dataset && node.dataset.placeholder) return; - if (node.dataset && node.dataset.inlineText) return; // already-wrapped fragment + if (node.dataset && node.dataset.inlineText) return; + if (node.dataset && node.dataset.inlineDone) return; var mixed = false; var blocksParent = false; for (var c = 0; c < node.children.length; c++) { @@ -483,21 +484,26 @@ if (blocksParent) return; if (mixed) { - var textNodes = []; - node.childNodes.forEach(function(c) { - if (c.nodeType === 3 && c.nodeValue && c.nodeValue.trim()) textNodes.push(c); - }); - textNodes.forEach(function(tn) { - var raw = tn.nodeValue; - var trimmed = raw.trim(); - if (!trimmed) return; - var at = locate(trimmed); - if (at === -1) return; - var span = document.createElement('span'); - span.dataset.inlineText = '1'; - span.textContent = raw; - tn.parentNode.replaceChild(span, tn); - bindEditable(span, trimmed, item, previewEl, at); + Array.prototype.slice.call(node.childNodes).forEach(function(ch) { + if (ch.nodeType === 3) { + var raw = ch.nodeValue; + var trimmed = raw && raw.trim(); + if (!trimmed) return; + var at = locate(trimmed); + if (at === -1) return; + var span = document.createElement('span'); + span.dataset.inlineText = '1'; + span.textContent = raw; + ch.parentNode.replaceChild(span, ch); + bindEditable(span, trimmed, item, previewEl, at); + } else if (ch.nodeType === 1 && ch.tagName === 'A') { + var aText = canonicalText(ch).trim(); + if (!aText) return; + var aAt = locate(aText); + if (aAt === -1) return; + bindEditable(ch, aText, item, previewEl, aAt); + ch.dataset.inlineDone = '1'; + } }); return; } From 524e84ff4127decdfcaf7e021ecf0b6c5144c1c3 Mon Sep 17 00:00:00 2001 From: Rustem Date: Sun, 28 Jun 2026 23:59:44 +0300 Subject: [PATCH 6/6] fix(builder): scope dark-mode stripping to wrapper, widen href var match --- internal/web/handlers/templates.go | 7 ++++++- internal/web/static/js/builder.js | 2 +- internal/web/views/block_form.html | 2 +- internal/web/views/block_view.html | 2 +- internal/web/views/template_view.html | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go index 7dc7422..c22d830 100644 --- a/internal/web/handlers/templates.go +++ b/internal/web/handlers/templates.go @@ -488,7 +488,12 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) { var darkModeBlockRe = regexp.MustCompile(`(?s)@media\s*\(prefers-color-scheme:\s*dark\)\s*\{.*?\}\s*\}`) func stripDarkModeCSS(html string) string { - return darkModeBlockRe.ReplaceAllString(html, "") + return darkModeBlockRe.ReplaceAllStringFunc(html, func(m string) string { + if strings.Contains(m, ".force-page-bg") { + return "" + } + return m + }) } // TemplateExportData represents template data for export/import diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js index 76778ba..69b3465 100644 --- a/internal/web/static/js/builder.js +++ b/internal/web/static/js/builder.js @@ -376,7 +376,7 @@ } function replaceHrefVars(html) { - return html.replace(/(href=")(\{\{\.\w+\}\})(")/gi, '$1#$3'); + return html.replace(/href\s*=\s*(['"])\s*\{\{\s*\.\w+(?:\.\w+)*\s*\}\}\s*\1/gi, 'href=$1#$1'); } function replaceTextVars(html) { diff --git a/internal/web/views/block_form.html b/internal/web/views/block_form.html index 01ae6ae..75a4d6e 100644 --- a/internal/web/views/block_form.html +++ b/internal/web/views/block_form.html @@ -321,7 +321,7 @@

Template Variables

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(html) { - html = html.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, ''); + html = html.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, function(m){ return m.indexOf('.force-page-bg') !== -1 ? '' : m; }); frame.srcdoc = html; status.textContent = 'OK — block rendered with the JSON above.'; status.style.color = 'var(--text-muted)'; diff --git a/internal/web/views/block_view.html b/internal/web/views/block_view.html index 4028f3e..bff58b4 100644 --- a/internal/web/views/block_view.html +++ b/internal/web/views/block_view.html @@ -171,7 +171,7 @@

Appearance

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(html) { - html = html.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, ''); + html = html.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, function(m){ return m.indexOf('.force-page-bg') !== -1 ? '' : m; }); frame.srcdoc = html; status.textContent = 'OK'; status.style.color = 'var(--text-muted)'; diff --git a/internal/web/views/template_view.html b/internal/web/views/template_view.html index beb30a0..d7fb14a 100644 --- a/internal/web/views/template_view.html +++ b/internal/web/views/template_view.html @@ -226,7 +226,7 @@

Actions

if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); return r.text(); }).then(function(rendered) { - rendered = rendered.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, ''); + rendered = rendered.replace(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g, function(m){ return m.indexOf('.force-page-bg') !== -1 ? '' : m; }); frame.srcdoc = rendered; if (status) { status.textContent = 'OK'; status.style.color = 'var(--text-muted)'; } }).catch(function(err) {