[object Object]

← back to Wallco Ai

admin: draggable+popout modal kit (all 19 pages) + windowed /admin/workspace dashboard (add/move/resize grid panels, layout persists)

ef32cc70ed7b889c2df9ed0bee39ffc9cd5bdb25 · 2026-05-31 18:36:29 -0700 · Steve Abrams

Files touched

Diff

commit ef32cc70ed7b889c2df9ed0bee39ffc9cd5bdb25
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 31 18:36:29 2026 -0700

    admin: draggable+popout modal kit (all 19 pages) + windowed /admin/workspace dashboard (add/move/resize grid panels, layout persists)
---
 public/admin/admin-modal-kit.js             | 237 ++++++++++++++++++++++++++++
 public/admin/bg-tester.html                 |   1 +
 public/admin/bleed-review.html              |   1 +
 public/admin/botanical-wonders-curator.html |   1 +
 public/admin/cactus-curator.html            |   1 +
 public/admin/defect-registry.html           |   1 +
 public/admin/dogs-curator.html              |   1 +
 public/admin/drunk-curator.html             |   1 +
 public/admin/edges-review.html              |   1 +
 public/admin/elements.html                  |   1 +
 public/admin/etsy-bucket.html               |   1 +
 public/admin/etsy-setup.html                |   1 +
 public/admin/fliepaper-bugs.html            |   1 +
 public/admin/ghost-review.html              |   1 +
 public/admin/inspirations.html              |   1 +
 public/admin/mid-heal-preview.html          |   1 +
 public/admin/rooms.html                     |   1 +
 public/admin/seam-debug.html                |   1 +
 public/admin/status-browser.html            |   1 +
 public/admin/theme-gallery.html             |   1 +
 public/admin/workspace.html                 | 202 ++++++++++++++++++++++++
 src/admin.js                                |   5 +
 22 files changed, 463 insertions(+)

diff --git a/public/admin/admin-modal-kit.js b/public/admin/admin-modal-kit.js
new file mode 100644
index 0000000..282802c
--- /dev/null
+++ b/public/admin/admin-modal-kit.js
@@ -0,0 +1,237 @@
+/* admin-modal-kit.js — Steve 2026-05-31
+ * Makes EVERY admin modal drag-by-titlebar + popout (un-dim so you can work
+ * around it) + remember its position across opens. Drop-in: just
+ *   <script src="/admin/admin-modal-kit.js" defer></script>
+ * on any admin page. No markup changes required — it auto-detects modal panels
+ * by a selector list and enhances each the first time it becomes visible.
+ *
+ * Design notes:
+ *  - We enhance the modal *panel* (the content box), not the full-screen scrim.
+ *  - A MutationObserver catches modals that are injected/shown after load.
+ *  - Position persists in localStorage keyed by pathname + panel identity.
+ *  - Defensive throughout: a bad page must never throw and break the modal.
+ *  - Opt out per element with data-no-drag; mark a custom handle with
+ *    data-amk-handle.
+ */
+(function () {
+  'use strict';
+  if (window.__amkLoaded) return; window.__amkLoaded = true;
+
+  // Candidate "panel" selectors (the movable content box). Order matters:
+  // most-specific first so we don't grab a full-screen scrim by accident.
+  var PANEL_SEL = [
+    '.modal-box', '.modal-inner', '.modal-side', '.modal-card', '.modal-content',
+    '.nsm', '.dialog', '[role="dialog"]', '.popup', '.sheet', '.amk-window'
+  ];
+  // Bare ".modal" / ".overlay" are usually the SCRIM. Only treat them as the
+  // panel if they have no inner panel child (some pages style .modal as the box).
+  var MAYBE_PANEL_SEL = ['.modal', '.overlay', '.backdrop'];
+
+  var STORE = 'amk:' + location.pathname.replace(/\/+$/, '');
+
+  function load() { try { return JSON.parse(localStorage.getItem(STORE) || '{}'); } catch (e) { return {}; } }
+  function save(o) { try { localStorage.setItem(STORE, JSON.stringify(o)); } catch (e) {} }
+
+  function keyFor(el, idx) {
+    if (el.id) return '#' + el.id;
+    if (el.dataset.amkKey) return el.dataset.amkKey;
+    var k = (el.className || 'panel').toString().trim().split(/\s+/).slice(0, 2).join('.') + ':' + idx;
+    el.dataset.amkKey = k;
+    return k;
+  }
+
+  function visible(el) {
+    if (!el || !el.isConnected) return false;
+    var cs = getComputedStyle(el);
+    if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return false;
+    var r = el.getBoundingClientRect();
+    return r.width > 120 && r.height > 80;   // real panel, not a sliver
+  }
+
+  // Is this element a full-screen scrim (cover the viewport, semi-transparent)?
+  function looksLikeScrim(el) {
+    var r = el.getBoundingClientRect();
+    return r.width > innerWidth * 0.9 && r.height > innerHeight * 0.9;
+  }
+
+  function findScrim(panel) {
+    // Walk up: the first fixed/absolute full-screen ancestor is the backdrop.
+    var p = panel.parentElement, hop = 0;
+    while (p && hop++ < 6) {
+      var cs = getComputedStyle(p);
+      if ((cs.position === 'fixed' || cs.position === 'absolute') && looksLikeScrim(p)) return p;
+      p = p.parentElement;
+    }
+    return null;
+  }
+
+  function pickHandle(panel) {
+    if (panel.dataset.amkHandle) {
+      var h = panel.querySelector(panel.dataset.amkHandle);
+      if (h) return h;
+    }
+    // existing header-ish element
+    var head = panel.querySelector('.modal-head, .modal-title, .modal-header, header, h1, h2, h3');
+    if (head && head.offsetParent !== null) return head;
+    return null;
+  }
+
+  function enhance(panel, idx) {
+    if (panel.__amk) return;
+    if (panel.closest('[data-no-drag]') || panel.hasAttribute('data-no-drag')) return;
+    panel.__amk = true;
+
+    var store = load();
+    var key = keyFor(panel, idx);
+
+    // Build the title bar (grip + title + popout + close).
+    var bar = document.createElement('div');
+    bar.className = 'amk-bar';
+    var existingHandle = pickHandle(panel);
+    var titleTxt = (existingHandle && existingHandle.textContent.trim().slice(0, 60)) ||
+                   panel.getAttribute('aria-label') || 'Move';
+    bar.innerHTML =
+      '<span class="amk-grip" title="Drag to move">⠿</span>' +
+      '<span class="amk-title"></span>' +
+      '<span class="amk-spacer"></span>' +
+      '<button type="button" class="amk-btn amk-pop" title="Pop out (un-dim, float free)">⤢</button>' +
+      '<button type="button" class="amk-btn amk-reset" title="Reset position">⟲</button>' +
+      '<button type="button" class="amk-btn amk-close" title="Close">✕</button>';
+    bar.querySelector('.amk-title').textContent = existingHandle ? '' : titleTxt;
+
+    // Make the panel positionable.
+    var cs = getComputedStyle(panel);
+    if (cs.position === 'static') panel.style.position = 'relative';
+    panel.classList.add('amk-panel');
+    panel.insertBefore(bar, panel.firstChild);
+
+    // Restore saved geometry.
+    var saved = store[key];
+    function applyFixed(left, top) {
+      panel.style.position = 'fixed';
+      panel.style.margin = '0';
+      panel.style.left = Math.round(left) + 'px';
+      panel.style.top = Math.round(top) + 'px';
+      panel.style.right = 'auto'; panel.style.bottom = 'auto';
+      panel.style.transform = 'none';
+    }
+    if (saved && typeof saved.left === 'number') {
+      applyFixed(saved.left, saved.top);
+      if (saved.popped) setPopped(true);
+    }
+
+    // ---- Drag ----
+    var dragging = false, sx = 0, sy = 0, ol = 0, ot = 0;
+    function onDown(e) {
+      if (e.target.closest('.amk-btn')) return;          // buttons aren't drag
+      var r = panel.getBoundingClientRect();
+      applyFixed(r.left, r.top);                          // pin where it currently is
+      dragging = true; sx = e.clientX; sy = e.clientY; ol = r.left; ot = r.top;
+      bar.setPointerCapture && bar.setPointerCapture(e.pointerId);
+      document.body.style.userSelect = 'none';
+      e.preventDefault();
+    }
+    function onMove(e) {
+      if (!dragging) return;
+      var nl = ol + (e.clientX - sx), nt = ot + (e.clientY - sy);
+      var r = panel.getBoundingClientRect();
+      nl = Math.max(8 - r.width + 60, Math.min(nl, innerWidth - 60));  // keep grabbable
+      nt = Math.max(0, Math.min(nt, innerHeight - 36));
+      panel.style.left = nl + 'px'; panel.style.top = nt + 'px';
+    }
+    function onUp() {
+      if (!dragging) return;
+      dragging = false; document.body.style.userSelect = '';
+      persist();
+    }
+    bar.addEventListener('pointerdown', onDown);
+    addEventListener('pointermove', onMove);
+    addEventListener('pointerup', onUp);
+
+    // ---- Popout (un-dim the scrim, float free, click-through behind) ----
+    var scrim = findScrim(panel);
+    var popped = false;
+    function setPopped(on) {
+      popped = on;
+      panel.classList.toggle('amk-popped', on);
+      bar.querySelector('.amk-pop').classList.toggle('on', on);
+      if (scrim) {
+        scrim.style.background = on ? 'transparent' : '';
+        scrim.style.pointerEvents = on ? 'none' : '';   // clicks pass through to page
+        panel.style.pointerEvents = 'auto';
+      }
+      if (on && getComputedStyle(panel).position !== 'fixed') {
+        var r = panel.getBoundingClientRect(); applyFixed(r.left, r.top);
+      }
+    }
+    bar.querySelector('.amk-pop').addEventListener('click', function () { setPopped(!popped); persist(); });
+
+    // ---- Reset ----
+    bar.querySelector('.amk-reset').addEventListener('click', function () {
+      panel.style.cssText = panel.style.cssText
+        .replace(/(left|top|right|bottom|position|margin|transform)\s*:[^;]*;?/g, '');
+      panel.classList.remove('amk-popped');
+      if (scrim) { scrim.style.background = ''; scrim.style.pointerEvents = ''; }
+      popped = false; bar.querySelector('.amk-pop').classList.remove('on');
+      var s = load(); delete s[key]; save(s);
+    });
+
+    // ---- Close (delegate to whatever the page already wires) ----
+    bar.querySelector('.amk-close').addEventListener('click', function () {
+      // reset scrim so the page's own close logic isn't fighting transparency
+      if (scrim) { scrim.style.background = ''; scrim.style.pointerEvents = ''; }
+      var btn = panel.querySelector('.modal-close, [data-close], .close, .nsm-close');
+      if (btn) { btn.click(); return; }
+      if (scrim) { scrim.click(); return; }       // many pages close on scrim click
+      document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    });
+
+    function persist() {
+      var s = load(); var r = panel.getBoundingClientRect();
+      s[key] = { left: r.left, top: r.top, popped: popped };
+      save(s);
+    }
+  }
+
+  function scan() {
+    var sel = PANEL_SEL.join(',');
+    document.querySelectorAll(sel).forEach(function (el, i) {
+      if (visible(el) && !el.__amk) enhance(el, i);
+    });
+    // bare .modal/.overlay only if it has no inner panel and isn't itself a scrim wrapper of one
+    document.querySelectorAll(MAYBE_PANEL_SEL.join(',')).forEach(function (el, i) {
+      if (el.__amk) return;
+      if (!visible(el)) return;
+      if (el.querySelector(PANEL_SEL.join(','))) return;  // it's a scrim around a real panel
+      if (looksLikeScrim(el) && el.children.length) return; // pure scrim
+      enhance(el, 100 + i);
+    });
+  }
+
+  // Styles
+  var css = document.createElement('style');
+  css.textContent =
+    '.amk-panel{box-shadow:0 18px 60px rgba(0,0,0,.32)!important}' +
+    '.amk-bar{display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;' +
+      'padding:6px 8px;margin:-1px -1px 6px;background:linear-gradient(#1c2030,#141826);' +
+      'color:#e8eaf2;border-radius:10px 10px 0 0;font:600 12px -apple-system,system-ui,sans-serif}' +
+    '.amk-bar:active{cursor:grabbing}' +
+    '.amk-grip{opacity:.7;font-size:14px;letter-spacing:-2px}' +
+    '.amk-title{opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60%}' +
+    '.amk-spacer{flex:1}' +
+    '.amk-btn{border:0;background:rgba(255,255,255,.10);color:#e8eaf2;cursor:pointer;' +
+      'width:24px;height:22px;border-radius:6px;font-size:12px;line-height:1;padding:0}' +
+    '.amk-btn:hover{background:rgba(255,255,255,.22)}' +
+    '.amk-pop.on{background:#34cbf4;color:#06202a}' +
+    '.amk-popped{z-index:2147483000!important}';
+  (document.head || document.documentElement).appendChild(css);
+
+  var mo = new MutationObserver(function () { clearTimeout(window.__amkT); window.__amkT = setTimeout(scan, 60); });
+  function start() {
+    scan();
+    mo.observe(document.body, { childList: true, subtree: true, attributes: true,
+      attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'] });
+  }
+  if (document.readyState === 'loading') addEventListener('DOMContentLoaded', start);
+  else start();
+})();
diff --git a/public/admin/bg-tester.html b/public/admin/bg-tester.html
index a594fac..ed1f10a 100644
--- a/public/admin/bg-tester.html
+++ b/public/admin/bg-tester.html
@@ -592,5 +592,6 @@
   });
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/bleed-review.html b/public/admin/bleed-review.html
index d22cf9a..7a8c9c8 100644
--- a/public/admin/bleed-review.html
+++ b/public/admin/bleed-review.html
@@ -441,5 +441,6 @@
   load();
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/botanical-wonders-curator.html b/public/admin/botanical-wonders-curator.html
index ec437bc..f8206b0 100644
--- a/public/admin/botanical-wonders-curator.html
+++ b/public/admin/botanical-wonders-curator.html
@@ -213,5 +213,6 @@ $('#density').value=cols; $('#densval').textContent=cols;
 document.documentElement.style.setProperty('--cols',cols);
 load();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 0cae7a4..a43133a 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -1575,5 +1575,6 @@ load();
   });
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/defect-registry.html b/public/admin/defect-registry.html
index df900c4..c23273f 100644
--- a/public/admin/defect-registry.html
+++ b/public/admin/defect-registry.html
@@ -149,5 +149,6 @@ $('#density').oninput = e => applyDensity(e.target.value);
   load();
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/dogs-curator.html b/public/admin/dogs-curator.html
index 4058822..7e32688 100644
--- a/public/admin/dogs-curator.html
+++ b/public/admin/dogs-curator.html
@@ -205,5 +205,6 @@ $('#density').value=cols; $('#densval').textContent=cols;
 document.documentElement.style.setProperty('--cols',cols);
 load();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/drunk-curator.html b/public/admin/drunk-curator.html
index e1e7c7e..c7033b5 100644
--- a/public/admin/drunk-curator.html
+++ b/public/admin/drunk-curator.html
@@ -739,5 +739,6 @@ loadCollections();
   rebuildQueue();
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/edges-review.html b/public/admin/edges-review.html
index b474027..ef32225 100644
--- a/public/admin/edges-review.html
+++ b/public/admin/edges-review.html
@@ -547,5 +547,6 @@
   }).catch(() => {});
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/elements.html b/public/admin/elements.html
index cff0f03..ac71c66 100644
--- a/public/admin/elements.html
+++ b/public/admin/elements.html
@@ -550,5 +550,6 @@
     });
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/etsy-bucket.html b/public/admin/etsy-bucket.html
index 25228e2..a8bcd22 100644
--- a/public/admin/etsy-bucket.html
+++ b/public/admin/etsy-bucket.html
@@ -94,5 +94,6 @@ async function load(){
 }
 load();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/etsy-setup.html b/public/admin/etsy-setup.html
index 1187b49..6574008 100644
--- a/public/admin/etsy-setup.html
+++ b/public/admin/etsy-setup.html
@@ -239,5 +239,6 @@ $('testBtn').onclick = async () => {
 };
 refresh();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/fliepaper-bugs.html b/public/admin/fliepaper-bugs.html
index 2ab7872..31b8a30 100644
--- a/public/admin/fliepaper-bugs.html
+++ b/public/admin/fliepaper-bugs.html
@@ -238,5 +238,6 @@ refresh();
 // 20s auto-refresh — admin grid should reflect yolo-runner progress live.
 setInterval(refresh, 20000);
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 54e4763..9510274 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -2073,5 +2073,6 @@
   boot().catch(e => { document.body.innerHTML = '<pre style="padding:40px;color:#b00">' + e.stack + '</pre>'; });
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/inspirations.html b/public/admin/inspirations.html
index 0a1d892..01a8b53 100644
--- a/public/admin/inspirations.html
+++ b/public/admin/inspirations.html
@@ -331,5 +331,6 @@
 })();
 </script>
 
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/mid-heal-preview.html b/public/admin/mid-heal-preview.html
index f7eb322..349ca4e 100644
--- a/public/admin/mid-heal-preview.html
+++ b/public/admin/mid-heal-preview.html
@@ -210,5 +210,6 @@ fetch('/api/admin/mid-heal-preview/sample')
       `<div class="loading">fetch failed: ${err.message}</div>`;
   });
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/rooms.html b/public/admin/rooms.html
index 74760bf..567aaad 100644
--- a/public/admin/rooms.html
+++ b/public/admin/rooms.html
@@ -89,5 +89,6 @@ function render(){
   }catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index e9f5396..2c21b52 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -297,5 +297,6 @@ $('healall').addEventListener('click',()=>healSelected((CUR?.boxes||[]).map((_,i
 $('regenbtn').addEventListener('click',regenerate);
 if(ID0) load();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/status-browser.html b/public/admin/status-browser.html
index 8f5ee0a..942c3b4 100644
--- a/public/admin/status-browser.html
+++ b/public/admin/status-browser.html
@@ -666,5 +666,6 @@
   loadCategories(); loadStats(); load();
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/theme-gallery.html b/public/admin/theme-gallery.html
index eddfe69..d0c16d3 100644
--- a/public/admin/theme-gallery.html
+++ b/public/admin/theme-gallery.html
@@ -318,5 +318,6 @@ window.addEventListener('resize', ()=>{ clearTimeout(window._rt); window._rt=set
   else newest();
 })();
 </script>
+  <script src="/admin/admin-modal-kit.js" defer></script>
 </body>
 </html>
diff --git a/public/admin/workspace.html b/public/admin/workspace.html
new file mode 100644
index 0000000..7067d92
--- /dev/null
+++ b/public/admin/workspace.html
@@ -0,0 +1,202 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Admin Workspace — wallco.ai</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<style>
+  :root{--bg:#0f1117;--ink:#e8eaf2;--mut:#8b93a7;--line:#262b3a;--card:#161a26;--accent:#34cbf4;}
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:
+    radial-gradient(1200px 600px at 20% -10%,#1a2030,transparent),var(--bg);
+    color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;overflow:hidden}
+  header.ws{position:fixed;top:0;left:0;right:0;height:52px;z-index:50;display:flex;align-items:center;
+    gap:12px;padding:0 16px;background:rgba(15,17,23,.92);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
+  h1{font-size:15px;margin:0;font-weight:650;letter-spacing:.2px}
+  .sub{color:var(--mut);font-size:12px}
+  button,select{font:inherit;color:var(--ink);background:#1c2030;border:1px solid var(--line);
+    border-radius:8px;padding:7px 12px;cursor:pointer}
+  button.primary{background:var(--accent);color:#06202a;border-color:var(--accent);font-weight:650}
+  button:hover{border-color:var(--accent)}
+  .spacer{flex:1}
+  main{position:fixed;inset:52px 0 0 0;overflow:hidden}
+  .empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
+    color:var(--mut);gap:14px;text-align:center;padding:24px}
+  .empty h2{color:var(--ink);font-weight:600;margin:0}
+  /* Windows */
+  .win{position:absolute;background:var(--card);border:1px solid var(--line);border-radius:12px;
+    box-shadow:0 22px 70px rgba(0,0,0,.5);display:flex;flex-direction:column;overflow:hidden;min-width:280px;min-height:180px}
+  .win.focus{border-color:var(--accent);box-shadow:0 22px 80px rgba(52,203,244,.18),0 22px 70px rgba(0,0,0,.5)}
+  .win-bar{display:flex;align-items:center;gap:8px;padding:7px 9px;cursor:grab;user-select:none;
+    background:linear-gradient(#1c2030,#141826);border-bottom:1px solid var(--line)}
+  .win-bar:active{cursor:grabbing}
+  .win-grip{opacity:.6;letter-spacing:-2px}
+  .win-title{font-weight:600;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
+  .win-btn{border:0;background:rgba(255,255,255,.10);color:var(--ink);width:24px;height:22px;border-radius:6px;
+    cursor:pointer;font-size:12px;line-height:1;padding:0}
+  .win-btn:hover{background:rgba(255,255,255,.22)}
+  .win-body{flex:1;position:relative;background:#fff}
+  .win-body iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff}
+  .win-resize{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;
+    background:linear-gradient(135deg,transparent 50%,var(--mut) 50%);border-radius:0 0 8px 0;opacity:.7}
+  .win.min .win-body,.win.min .win-resize{display:none}
+  .win.min{height:auto!important;min-height:0}
+  .chip{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:999px;padding:3px 9px}
+</style>
+</head>
+<body>
+<header class="ws">
+  <h1>🪟 Admin Workspace</h1>
+  <span class="sub" id="count">0 panels</span>
+  <span class="spacer"></span>
+  <select id="addSel" title="Pick a grid/page to open as a panel">
+    <option value="">+ Add panel…</option>
+  </select>
+  <button id="tileBtn" title="Auto-arrange all panels in a tidy grid">▦ Tile</button>
+  <button id="cascadeBtn" title="Cascade panels diagonally">▧ Cascade</button>
+  <button id="clearBtn" title="Close all panels">Clear</button>
+</header>
+<main id="stage">
+  <div class="empty" id="empty">
+    <h2>Empty workspace</h2>
+    <div class="sub">Use <strong>+ Add panel</strong> to open any admin grid as a movable, resizable window.<br>
+      Drag the title bar to move · drag the corner to resize · layout auto-saves.</div>
+  </div>
+</main>
+
+<script>
+// ---- Catalog of openable admin grids/pages ----
+const PAGES = [
+  ['Cactus Curator',        '/admin/cactus-curator'],
+  ['Drunk Curator',         '/admin/drunk-curator'],
+  ['Dogs Curator',          '/admin/dogs-curator'],
+  ['Botanical Curator',     '/admin/botanical-wonders-curator'],
+  ['Ghost Review',          '/admin/ghost-review'],
+  ['Edges Review',          '/admin/edges-review'],
+  ['Bleed Review',          '/admin/bleed-review'],
+  ['Defect Registry',       '/admin/defect-registry'],
+  ['Rooms Gallery',         '/admin/rooms'],
+  ['Theme Gallery',         '/admin/theme-gallery'],
+  ['Elements',              '/admin/elements'],
+  ['Inspirations',          '/admin/inspirations'],
+  ['Etsy Bucket',           '/admin/etsy-bucket'],
+  ['Status Browser',        '/admin/status-browser'],
+  ['Designs grid (public)', '/designs'],
+];
+const STORE = 'ws-admin-layout-v1';
+const $ = s => document.querySelector(s);
+const stage = $('#stage');
+let z = 10, wins = [];
+
+PAGES.forEach(([label,url]) => {
+  const o = document.createElement('option'); o.value = url; o.textContent = label; $('#addSel').appendChild(o);
+});
+
+function save(){
+  const data = wins.map(w => ({
+    url:w.dataset.url, title:w.dataset.title,
+    left:parseInt(w.style.left), top:parseInt(w.style.top),
+    w:parseInt(w.style.width), h:parseInt(w.style.height),
+    min:w.classList.contains('min'), z:+w.style.zIndex||10
+  }));
+  try{ localStorage.setItem(STORE, JSON.stringify(data)); }catch(e){}
+  $('#count').textContent = wins.length + (wins.length===1?' panel':' panels');
+  $('#empty').style.display = wins.length ? 'none' : '';
+}
+
+function focusWin(w){ wins.forEach(x=>x.classList.remove('focus')); w.classList.add('focus'); w.style.zIndex = ++z; }
+
+function makeWin(opt){
+  const w = document.createElement('div'); w.className='win';
+  w.dataset.url = opt.url; w.dataset.title = opt.title || labelFor(opt.url);
+  w.style.left=(opt.left??(40+wins.length*28))+'px';
+  w.style.top =(opt.top ??(40+wins.length*28))+'px';
+  w.style.width =(opt.w??560)+'px';
+  w.style.height=(opt.h??460)+'px';
+  w.style.zIndex = opt.z || (++z);
+  w.innerHTML =
+    '<div class="win-bar">'+
+      '<span class="win-grip">⠿</span>'+
+      '<span class="win-title"></span>'+
+      '<a class="win-btn" title="Open full page" target="_blank" rel="noopener" href="'+esc(opt.url)+'">↗</a>'+
+      '<button class="win-btn b-reload" title="Reload">⟳</button>'+
+      '<button class="win-btn b-min" title="Collapse">—</button>'+
+      '<button class="win-btn b-close" title="Close">✕</button>'+
+    '</div>'+
+    '<div class="win-body"><iframe src="'+esc(opt.url)+'" loading="lazy" referrerpolicy="same-origin"></iframe></div>'+
+    '<div class="win-resize" title="Drag to resize"></div>';
+  w.querySelector('.win-title').textContent = w.dataset.title;
+  if (opt.min) w.classList.add('min');
+  stage.appendChild(w); wins.push(w);
+
+  const bar = w.querySelector('.win-bar');
+  // drag
+  let dx,dy,ox,oy,drag=false;
+  bar.addEventListener('pointerdown', e=>{
+    if(e.target.closest('.win-btn')) return;
+    drag=true; focusWin(w); dx=e.clientX; dy=e.clientY; ox=parseInt(w.style.left); oy=parseInt(w.style.top);
+    bar.setPointerCapture(e.pointerId); e.preventDefault();
+  });
+  bar.addEventListener('pointermove', e=>{
+    if(!drag) return;
+    let nl=ox+(e.clientX-dx), nt=oy+(e.clientY-dy);
+    nl=Math.max(2-w.offsetWidth+80, Math.min(nl, innerWidth-60));
+    nt=Math.max(0, Math.min(nt, innerHeight-90));
+    w.style.left=nl+'px'; w.style.top=nt+'px';
+  });
+  bar.addEventListener('pointerup', ()=>{ if(drag){drag=false; save();} });
+  // resize
+  const rz=w.querySelector('.win-resize'); let rw,rh,rx,ry,res=false;
+  rz.addEventListener('pointerdown', e=>{
+    res=true; focusWin(w); rx=e.clientX; ry=e.clientY; rw=w.offsetWidth; rh=w.offsetHeight;
+    rz.setPointerCapture(e.pointerId); e.preventDefault();
+  });
+  rz.addEventListener('pointermove', e=>{
+    if(!res) return;
+    w.style.width =Math.max(280, rw+(e.clientX-rx))+'px';
+    w.style.height=Math.max(180, rh+(e.clientY-ry))+'px';
+  });
+  rz.addEventListener('pointerup', ()=>{ if(res){res=false; save();} });
+  // focus on any click
+  w.addEventListener('pointerdown', ()=>focusWin(w), true);
+  // buttons
+  w.querySelector('.b-reload').addEventListener('click', ()=>{ const f=w.querySelector('iframe'); f.src=f.src; });
+  w.querySelector('.b-min').addEventListener('click', ()=>{ w.classList.toggle('min'); save(); });
+  w.querySelector('.b-close').addEventListener('click', ()=>{ w.remove(); wins=wins.filter(x=>x!==w); save(); });
+
+  focusWin(w); save();
+  return w;
+}
+
+function labelFor(url){ const p = PAGES.find(x=>x[1]===url); return p?p[0]:url; }
+function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// tile / cascade
+function tile(){
+  const n=wins.length; if(!n) return;
+  const cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols);
+  const W=innerWidth, H=innerHeight-52, gw=W/cols, gh=H/rows;
+  wins.forEach((w,i)=>{ const c=i%cols, r=Math.floor(i/cols);
+    w.classList.remove('min');
+    w.style.left=Math.round(c*gw+4)+'px'; w.style.top=Math.round(r*gh+4)+'px';
+    w.style.width=Math.round(gw-8)+'px'; w.style.height=Math.round(gh-8)+'px'; });
+  save();
+}
+function cascade(){ wins.forEach((w,i)=>{ w.classList.remove('min');
+  w.style.left=(40+i*30)+'px'; w.style.top=(40+i*30)+'px'; w.style.width='560px'; w.style.height='460px';
+  w.style.zIndex=++z; }); save(); }
+
+$('#addSel').addEventListener('change', e=>{ const url=e.target.value; if(url){ makeWin({url}); e.target.value=''; } });
+$('#tileBtn').addEventListener('click', tile);
+$('#cascadeBtn').addEventListener('click', cascade);
+$('#clearBtn').addEventListener('click', ()=>{ if(wins.length && confirm('Close all panels?')){ wins.forEach(w=>w.remove()); wins=[]; save(); } });
+
+// restore
+(function init(){
+  let data=[]; try{ data=JSON.parse(localStorage.getItem(STORE)||'[]'); }catch(e){}
+  data.sort((a,b)=>(a.z||0)-(b.z||0)).forEach(makeWin);
+  save();
+})();
+</script>
+</body>
+</html>
diff --git a/src/admin.js b/src/admin.js
index e151bb0..3eb22fa 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -163,6 +163,7 @@ function adminHeader(active) {
     ['/admin/leads',        'Leads'],
     ['/admin/golive',       'Go-Live'],
     ['/admin/theme-gallery','Theme Gallery'],
+    ['/admin/workspace',    'Workspace'],
     ['/',                   'View site →']
   ];
   return `<header class="admin-header">
@@ -179,6 +180,10 @@ function mount(app) {
   app.get('/admin/theme-gallery', gate, (req, res) =>
     res.sendFile(require('path').join(__dirname, '..', 'public', 'admin', 'theme-gallery.html')));
 
+  // Workspace — windowed dashboard: open any admin grid as a movable/resizable panel.
+  app.get('/admin/workspace', gate, (req, res) =>
+    res.sendFile(require('path').join(__dirname, '..', 'public', 'admin', 'workspace.html')));
+
   ensureInspirationSchema();
 
   // ── Landing: vendor cards

← ebf027d Pattern generation always anchors on a REAL natural wallcove  ·  back to Wallco Ai  ·  harden generator_tick id-reconciliation: parse authoritative 0c8a15c →