From a8ab91ecd8f0e4aab5876d4a73125b7233084261 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 21 May 2026 20:29:41 +0200 Subject: [PATCH] dashboard: render SVG file previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG files in the side-panel file preview showed only raw source. Add a rendered/source tabbed view: 'rendered' (default) shows the image, 'source' shows the markup. The image loads via an data: URI — -loaded SVG runs in the browser's secure static mode (scripts + external fetches disabled), so an untrusted SVG from an agent's state dir can't execute code in the dashboard origin. Tabs reuse the existing diff-base-tab styling; a checkerboard backs the image so transparent regions read clearly. closes #188 --- docs/web-ui.md | 6 ++++- hive-c0re/assets/app.js | 44 ++++++++++++++++++++++++++++++---- hive-c0re/assets/dashboard.css | 12 ++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index f1bbea9..d5cf46d 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -68,7 +68,11 @@ node)` swaps the body; the JS builders for file previews, approval diffs, and journald logs all render into it. Markdown file previews (`.md` / `.markdown`) render through the vendored `marked` bundle (`GET /static/marked.js`) into a `.md` block; -other files stay raw in a `
`.
+SVG previews (`.svg`) get a `rendered` / `source` tabbed view —
+`rendered` shows the image via an `` `data:` URI (the
+browser's secure static mode, so an untrusted SVG can't run
+scripts), `source` shows the raw markup; other files stay raw
+in a `
`.
 
 Both bind their listeners with `SO_REUSEADDR` via
 `tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js
index dc291e0..c535c99 100644
--- a/hive-c0re/assets/app.js
+++ b/hive-c0re/assets/app.js
@@ -91,13 +91,47 @@
     if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
     return text;
   }
+  // SVG file preview: a "rendered" tab (default) showing the image and
+  // a "source" tab with the raw markup. The image is loaded via an
+  //  data: URI — -loaded SVG runs in the browser's secure
+  // static mode (no scripts, no external fetches), so an untrusted
+  // SVG from an agent's state dir can't execute code in the dashboard.
+  function buildSvgPanel(text) {
+    const tabs = el('div', { class: 'diff-base-tabs' });
+    const host = el('div', { class: 'svg-host' });
+    function show(mode) {
+      for (const b of tabs.children) {
+        b.classList.toggle('active', b.dataset.mode === mode);
+      }
+      if (mode === 'source') {
+        host.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
+        return;
+      }
+      const img = el('img', { class: 'svg-render', alt: 'SVG preview' });
+      img.addEventListener('error', () => {
+        host.replaceChildren(el('div', { class: 'meta' },
+          '(could not render — see the source tab)'));
+      });
+      img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
+      host.replaceChildren(img);
+    }
+    for (const [mode, label] of [['rendered', 'rendered'], ['source', 'source']]) {
+      const b = el('button',
+        { type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
+      b.addEventListener('click', () => show(mode));
+      tabs.append(b);
+    }
+    show('rendered');
+    return el('div', {}, tabs, host);
+  }
   // Lazy-load `path` from /api/state-file into the side panel.
-  // Markdown files render through `marked` into a `.md` block; every
-  // other file stays raw text in a 
.
+  // Markdown → `marked` into a `.md` block; SVG → rendered/source
+  // tabbed view; every other file stays raw text in a 
.
   async function openFilePanel(path) {
     const isMd = /\.(md|markdown)$/i.test(path);
-    const view = isMd
-      ? el('div', { class: 'md' })
+    const isSvg = /\.svg$/i.test(path);
+    const view = isMd ? el('div', { class: 'md' })
+      : isSvg ? el('div')
       : el('pre', { class: 'path-preview-body' });
     view.textContent = '(fetching…)';
     Panel.open('↳ ' + path, view);
@@ -106,6 +140,8 @@
       if (isMd && window.marked && typeof window.marked.parse === 'function') {
         marked.setOptions({ breaks: true, gfm: true });
         view.innerHTML = marked.parse(text);
+      } else if (isSvg) {
+        view.replaceChildren(buildSvgPanel(text));
       } else {
         view.textContent = text;
       }
diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css
index 0eca735..46c55f8 100644
--- a/hive-c0re/assets/dashboard.css
+++ b/hive-c0re/assets/dashboard.css
@@ -327,6 +327,18 @@ code {
   border-color: var(--purple);
   background: rgba(203, 166, 247, 0.08);
 }
+/* SVG file preview (issue #188) */
+.svg-host { margin-top: 0.5em; }
+.svg-render {
+  display: block;
+  max-width: 100%;
+  height: auto;
+  margin: 0 auto;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  /* checkerboard so transparent regions of the SVG read clearly */
+  background: repeating-conic-gradient(#313244 0% 25%, #1e1e2e 0% 50%) 50% / 18px 18px;
+}
 .approval-tabs {
   display: flex;
   gap: 0.4em;