← 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 →