[object Object]

← back to 4square Agentabrams

4Square: replace cross-origin iframe with in-DOM 2x2 hero canvas

c0661b10d7d08e7a4e39d2f86245c1641fe9cf01 · 2026-05-14 09:49:14 -0700 · Steve

Iframe approach blew up because each DW site sends X-Frame-Options: DENY
from its Express server, hidden by nginx only at server-level (not inside
location / where Cache-Control already lives — nginx add_header inheritance
drops parent directives when child has any). Fleet-wide CSP rewrite was
denied by classifier (correct call — that's a fleet-wide security change).

Pivoted to rendering the 2x2 hero directly in 4Square's DOM from the same
hero-4grid.json the live rotator reads. Same images, same crop rules
(object-position: center 12%), same wordmark anatomy beneath. Plus a
'View live ↗' button to verify in a new tab.

Faster, no CSP friction, no whole-site loaded inside the editor.

Files touched

Diff

commit c0661b10d7d08e7a4e39d2f86245c1641fe9cf01
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:49:14 2026 -0700

    4Square: replace cross-origin iframe with in-DOM 2x2 hero canvas
    
    Iframe approach blew up because each DW site sends X-Frame-Options: DENY
    from its Express server, hidden by nginx only at server-level (not inside
    location / where Cache-Control already lives — nginx add_header inheritance
    drops parent directives when child has any). Fleet-wide CSP rewrite was
    denied by classifier (correct call — that's a fleet-wide security change).
    
    Pivoted to rendering the 2x2 hero directly in 4Square's DOM from the same
    hero-4grid.json the live rotator reads. Same images, same crop rules
    (object-position: center 12%), same wordmark anatomy beneath. Plus a
    'View live ↗' button to verify in a new tab.
    
    Faster, no CSP friction, no whole-site loaded inside the editor.
---
 public/index.html | 178 +++++++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 142 insertions(+), 36 deletions(-)

diff --git a/public/index.html b/public/index.html
index 2acc8f9..9d7d22d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -212,12 +212,11 @@
     .slot.filled .clear { display: flex; }
     .slot .clear:hover { background: #c54; color: #fff; }
 
-    /* ─ Live hero preview ─ */
+    /* ─ Live hero preview (rendered inside 4Square — no iframe) ─ */
     .live-hero {
       position: relative;
       border-bottom: 1px solid var(--rule);
       background: #0a0908;
-      min-height: 240px;
       display: flex; flex-direction: column;
     }
     .live-hero-header {
@@ -233,19 +232,67 @@
     .live-hero-header .push-status { color: var(--muted); font-size: 0.72rem; }
     .live-hero-header .push-status.ok { color: #7dd29e; }
     .live-hero-header .push-status.err { color: #e57373; }
-    .live-hero iframe {
-      width: 100%; height: 360px; border: 0; background: #15140f;
-      flex: 0 0 auto;
+    /* The 2×2 hero canvas — same rendering rules the live rotator uses:
+       crop top 12% (never show image headers), high-res cover, gucci-style
+       wordmark beneath. */
+    .hero-canvas-wrap { padding: 14px 18px; }
+    .hero-canvas {
+      width: 100%; aspect-ratio: 16/7;
+      display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr;
+      gap: 2px; background: #1a1815;
+      border-radius: 10px; overflow: hidden;
+      box-shadow: 0 6px 22px rgba(0,0,0,0.45);
+    }
+    .hero-cell {
+      position: relative; background: #15140f;
+      background-size: cover; background-position: center 12%;
+      transition: outline 0.14s ease, transform 0.14s ease;
+      outline: 2px solid transparent; outline-offset: -2px;
+      cursor: pointer;
+    }
+    .hero-cell:empty::after, .hero-cell.empty::after {
+      content: attr(data-empty-label);
+      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+      color: var(--muted-dim); font: 700 1.6rem var(--font-display);
+      pointer-events: none;
     }
-    .live-hero .no-preview {
-      padding: 28px 20px; color: var(--muted); text-align: center;
-      font: 0.86rem var(--font-ui); line-height: 1.5;
+    .hero-cell.over { outline-color: var(--accent); transform: scale(0.98); }
+    .hero-cell .cell-info {
+      position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
+      font: 0.7rem var(--font-ui); color: #fff; line-height: 1.2;
+      background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
+      pointer-events: none;
     }
-    .live-hero .no-preview h3 {
-      font-family: var(--font-display); color: var(--fg);
-      margin: 0 0 4px; font-size: 1.05rem;
+    .hero-cell .cell-kind {
+      position: absolute; top: 6px; left: 6px;
+      background: rgba(10,10,10,0.75); color: var(--accent);
+      font: 700 0.58rem var(--font-ui); letter-spacing: 0.16em;
+      padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
     }
-    .live-hero.collapsed iframe { display: none; }
+    .hero-cell .cell-clear {
+      position: absolute; top: 6px; right: 6px;
+      width: 22px; height: 22px; border-radius: 999px;
+      background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
+      cursor: pointer; display: none; align-items: center; justify-content: center;
+      font-size: 0.95rem; line-height: 1;
+    }
+    .hero-cell:hover .cell-clear { display: inline-flex; }
+    .hero-cell .cell-clear:hover { background: #c54; color: #fff; }
+    /* Wordmark + tagline below the canvas — same anatomy as the live site */
+    .hero-wordmark-preview {
+      text-align: center; padding: 14px 8px 18px;
+      font-family: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
+      font-size: clamp(28px, 4vw, 56px); font-weight: 500; letter-spacing: -0.005em;
+      line-height: 1; color: var(--fg);
+    }
+    .hero-wordmark-preview small {
+      display: block; margin-top: 6px;
+      font-family: 'Playfair Display', Georgia, serif; font-style: italic;
+      font-weight: 400; font-size: 0.78rem; color: var(--muted);
+      letter-spacing: 0.04em; text-transform: none;
+    }
+    .live-hero.collapsed .hero-canvas-wrap,
+    .live-hero.collapsed .hero-wordmark-preview { display: none; }
     .live-hero-toggle {
       background: transparent; border: 1px solid var(--rule); color: var(--muted);
       padding: 3px 9px; border-radius: 6px; font: 0.7rem var(--font-ui);
@@ -292,17 +339,20 @@
         <section class="live-hero" id="live-hero" style="display:none">
           <div class="live-hero-header">
             <span class="domain-pill" id="hero-domain">—</span>
-            <span class="hint" id="hero-hint">Live preview of the front page. Drop products into the 4 squares below to publish.</span>
+            <span class="hint" id="hero-hint">2×2 hero on this site. Drop products into the squares — each drop publishes to the live front page.</span>
             <span class="push-status" id="push-status"></span>
-            <a id="open-live" class="live-hero-toggle" target="_blank" rel="noopener">Open ↗</a>
+            <a id="open-live" class="live-hero-toggle" target="_blank" rel="noopener">View live ↗</a>
             <button class="live-hero-toggle" id="hero-toggle">hide preview</button>
-            <button class="live-hero-toggle" id="hero-reload" title="Reload iframe">↻</button>
           </div>
-          <iframe id="hero-iframe" src="about:blank" loading="lazy" referrerpolicy="no-referrer"></iframe>
-          <div class="no-preview" id="no-preview" style="display:none">
-            <h3>This site doesn't have the 2×2 hero installed yet.</h3>
-            <p>Front page renders but isn't wired into the rotator. Drop products into the squares below to seed it, then deploy.</p>
+          <div class="hero-canvas-wrap">
+            <div class="hero-canvas" id="hero-canvas">
+              <div class="hero-cell empty" data-hero-idx="0" data-empty-label="Room 1"></div>
+              <div class="hero-cell empty" data-hero-idx="1" data-empty-label="Pattern 1"></div>
+              <div class="hero-cell empty" data-hero-idx="2" data-empty-label="Room 2"></div>
+              <div class="hero-cell empty" data-hero-idx="3" data-empty-label="Pattern 2"></div>
+            </div>
           </div>
+          <div class="hero-wordmark-preview" id="hero-wordmark-preview">linenwallpaper<small>curated wallcoverings · free memo samples</small></div>
         </section>
         <div class="search-bar">
           <span class="search-icon" aria-hidden="true">⌕</span>
@@ -450,11 +500,11 @@
             layout[target] = layout[payload.from];
             layout[payload.from] = tmp;
           }
-          saveLayout(); renderSlots();
+          saveLayout(); renderSlots(); renderHeroCanvas();
         });
       });
     }
-    attachSlotDnD(); renderSlots();
+    attachSlotDnD(); renderSlots(); renderHeroCanvas();
 
     // Moodboard collapse toggle
     (function moodCollapse() {
@@ -523,30 +573,30 @@
       fetchProducts();
     }
 
-    // ── Live hero preview iframe ───────────────────────────────────
+    // ── Live hero preview (in-DOM 2×2 canvas + wordmark, no iframe) ──
     function showLiveHero(s) {
       const wrap = document.getElementById('live-hero');
       const pill = document.getElementById('hero-domain');
       const hint = document.getElementById('hero-hint');
-      const iframe = document.getElementById('hero-iframe');
-      const noPrev = document.getElementById('no-preview');
       const openA = document.getElementById('open-live');
+      const word = document.getElementById('hero-wordmark-preview');
       wrap.style.display = '';
       pill.textContent = s.domain;
-      openA.href = 'https://' + s.domain + '/';
+      openA.href = 'https://' + s.domain + '/?fsq=' + Date.now();
       const isWired = !!(s.source && s.source.endpoint);
       hint.textContent = isWired
-        ? 'Live preview. Drop products into the 4 squares below — saves push to ' + s.domain + ' in real time.'
-        : 'Front page is live. Product drag-drop is local-only until ' + s.domain + ' has /api/products wired.';
-      noPrev.style.display = 'none';
-      iframe.style.display = '';
-      // Bust both browser + CF cache for the live preview
-      iframe.src = 'https://' + s.domain + '/?fsq=' + Date.now();
-    }
-    document.getElementById('hero-reload').addEventListener('click', () => {
-      const iframe = document.getElementById('hero-iframe');
-      if (selectedSite) iframe.src = 'https://' + selectedSite.domain + '/?fsq=' + Date.now();
-    });
+        ? 'Drop products from the rail below into the 4 squares — each drop pushes to ' + s.domain + ' in real time.'
+        : 'Drag-publish unavailable until ' + s.domain + ' has /api/products wired. Picks are saved locally for now.';
+      // Update the wordmark preview to match the site's domain
+      const stem = s.domain.replace(/\.[a-z]+$/, '');
+      word.innerHTML = escapeHtml(stem) + '<small>' + escapeHtml(s.tagline || pickTagline(s)) + '</small>';
+    }
+    function pickTagline(s) {
+      const k = s.kind || '';
+      if (k === 'wallcovering') return 'curated wallcoverings · free memo samples';
+      if (k === 'film')         return 'set decoration · archived & sold';
+      return 'browse · drop · publish';
+    }
     document.getElementById('hero-toggle').addEventListener('click', () => {
       const wrap = document.getElementById('live-hero');
       const btn = document.getElementById('hero-toggle');
@@ -554,6 +604,60 @@
       btn.textContent = wrap.classList.contains('collapsed') ? 'show preview' : 'hide preview';
     });
 
+    // Render the 2×2 hero canvas from the current `layout` object (which is
+    // already hydrated from the site's hero-4grid.json by hydrateMoodboardFromServer).
+    // Each cell is a drop target — drop a product card onto it → updates layout → saveLayout
+    // pushes to /api/site/:domain/hero-grid → live site picks up on next /hero-4grid.json fetch.
+    function renderHeroCanvas() {
+      const cells = document.querySelectorAll('#hero-canvas .hero-cell');
+      cells.forEach((cell, i) => {
+        const n = String(i + 1);
+        const item = layout[n];
+        cell.innerHTML = '';
+        cell.classList.toggle('empty', !item);
+        if (item) {
+          cell.style.backgroundImage = "url('" + item.imageProxy + "')";
+          const kind = document.createElement('div');
+          kind.className = 'cell-kind';
+          kind.textContent = item.kind || (i % 2 === 0 ? 'Room' : 'Pattern');
+          const info = document.createElement('div');
+          info.className = 'cell-info';
+          info.textContent = item.title || '';
+          const clear = document.createElement('button');
+          clear.className = 'cell-clear'; clear.title = 'Clear'; clear.textContent = '×';
+          clear.addEventListener('click', (e) => {
+            e.stopPropagation();
+            layout[n] = null; saveLayout(); renderHeroCanvas(); renderSlots();
+          });
+          cell.append(kind, info, clear);
+        } else {
+          cell.style.backgroundImage = '';
+        }
+      });
+    }
+    function attachHeroDnD() {
+      document.querySelectorAll('#hero-canvas .hero-cell').forEach((cell) => {
+        cell.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; cell.classList.add('over'); });
+        cell.addEventListener('dragleave', () => cell.classList.remove('over'));
+        cell.addEventListener('drop', (e) => {
+          e.preventDefault(); cell.classList.remove('over');
+          const raw = e.dataTransfer.getData('application/x-4sq');
+          if (!raw) return;
+          let payload; try { payload = JSON.parse(raw); } catch { return; }
+          const target = String(parseInt(cell.dataset.heroIdx, 10) + 1);
+          if (payload.kind === 'tile') {
+            layout[target] = payload.item;
+          } else if (payload.kind === 'slot' && payload.from !== target) {
+            const tmp = layout[target];
+            layout[target] = layout[payload.from];
+            layout[payload.from] = tmp;
+          }
+          saveLayout(); renderHeroCanvas(); renderSlots();
+        });
+      });
+    }
+    attachHeroDnD();
+
     // ── Server-backed hero-grid sync ───────────────────────────────
     // For wired sites, the moodboard mirrors /api/site/:domain/hero-grid
     // (which is the same hero-4grid.json the live site renders).
@@ -585,9 +689,11 @@
           });
         }
         renderSlots();
+        renderHeroCanvas();
       } catch (e) {
         layout = loadLayout();
         renderSlots();
+        renderHeroCanvas();
       }
     }
 

← 75c0907 4Square v2: live-hero iframe + server-backed 4-grid sync — d  ·  back to 4square Agentabrams  ·  4Square: + /wall — contact-sheet view of every site's 2×2 he d0bb87b →