[object Object]

← back to Wallco Ai

wall-room-viewer: add Wall HEIGHT slider matching the Wall WIDTH layout — independent room aspect, true px-per-inch on both axes, V-ruler counts to wall height, badge shows ↔N × ↕M repeats. Defaults 9' (108"), range 7'-15' (84-180"), persists via localStorage wrv_wall_h

b471c32e68a5204ac727983304b8d2c18eb7571d · 2026-05-28 10:57:16 -0700 · Steve Abrams

Files touched

Diff

commit b471c32e68a5204ac727983304b8d2c18eb7571d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 10:57:16 2026 -0700

    wall-room-viewer: add Wall HEIGHT slider matching the Wall WIDTH layout — independent room aspect, true px-per-inch on both axes, V-ruler counts to wall height, badge shows ↔N × ↕M repeats. Defaults 9' (108"), range 7'-15' (84-180"), persists via localStorage wrv_wall_h
---
 public/wall-room-viewer.html | 41 +++++++++++++++++++--------
 server.js                    | 66 ++++++++++++++++++++++++++++++++++++++------
 2 files changed, 86 insertions(+), 21 deletions(-)

diff --git a/public/wall-room-viewer.html b/public/wall-room-viewer.html
index 2dbe4a2..66ac61c 100644
--- a/public/wall-room-viewer.html
+++ b/public/wall-room-viewer.html
@@ -100,8 +100,12 @@
         <span class="lab">Wall width <span class="val" id="wallval">13.5′</span></span>
         <input type="range" id="wallSlider" min="96" max="240" step="6" value="162">
       </div>
+      <div class="ctl" style="min-width:230px">
+        <span class="lab">Wall height <span class="val" id="wallvalH">9′</span></span>
+        <input type="range" id="wallSliderH" min="84" max="180" step="6" value="108">
+      </div>
       <div class="info">
-        <b id="repCount">—</b> repeats across · pattern tile <b id="repInches">36</b>″ wide
+        <b id="repCount">—</b> repeats across · <b id="repCountV">—</b> tall · tile <b id="repInches">36</b>″
       </div>
     </div>
 
@@ -149,6 +153,7 @@ const SCALE_WIDTHS = [24, 36, 54];   // standard wallpaper / panel widths
 let scaleInches  = parseInt(localStorage.getItem('wrv_scale') || '36', 10);
 if (!SCALE_WIDTHS.includes(scaleInches)) scaleInches = 36;
 let wallInches   = Math.min(240, Math.max(96, parseInt(localStorage.getItem('wrv_wall') || '162', 10)));
+let wallInchesH  = Math.min(180, Math.max(84,  parseInt(localStorage.getItem('wrv_wall_h') || '108', 10)));   // 9′ default; 7′-15′ slider
 
 const $ = id => document.getElementById(id);
 const fmtFt = inches => {
@@ -157,22 +162,26 @@ const fmtFt = inches => {
 };
 
 function applyScale(){
-  // Set the room canvas dims (square): width follows the viewport; cap at 900.
+  // Room canvas: width follows the viewport; height follows wallInchesH at the
+  // SAME px-per-inch so the preview is true-to-life across both axes. Caps the
+  // pixel-width at 900 so wide walls don't blow out the layout.
   const stage = document.querySelector('.stage');
   const roomW = Math.min(900, stage.clientWidth - 80);
+  const ppi   = roomW / wallInches;
+  const roomH = Math.round(wallInchesH * ppi);
   const room = $('room');
   room.style.width  = roomW + 'px';
-  room.style.height = roomW + 'px';
+  room.style.height = roomH + 'px';
   room.style.backgroundImage = `url(/designs/img/by-id/${DESIGN_ID})`;
   // backgroundSize = repeat width / wall width × 100 (one pattern repeat as %
   // of the wall). Smaller repeat = smaller % = more repeats fitting on the wall.
   const sizePct = (scaleInches / wallInches * 100).toFixed(2);
   room.style.backgroundSize = sizePct + '% auto';
 
-  // Pinstripe lines at each repeat boundary
-  const ppi = roomW / wallInches;
+  // Pinstripe lines at each repeat boundary (horizontal repeat count)
   const repPx = scaleInches * ppi;
-  const nRep = wallInches / scaleInches;
+  const nRep  = wallInches  / scaleInches;
+  const nRepV = wallInchesH / scaleInches;
   const rep = $('repeats');
   let s = '';
   for (let x = 0; x <= roomW + 0.5; x += repPx) {
@@ -180,11 +189,13 @@ function applyScale(){
     s += `<div class="vline${edge ? ' edge' : ''}" style="left:${x.toFixed(1)}px"></div>`;
   }
   rep.innerHTML = s;
-  $('repbadge').textContent = `↔ ${nRep.toFixed(nRep % 1 ? 1 : 0)} × ${scaleInches}″`;
-  $('repCount').textContent = nRep.toFixed(nRep % 1 ? 1 : 0);
+  $('repbadge').textContent = `↔ ${nRep.toFixed(nRep % 1 ? 1 : 0)} × ↕ ${nRepV.toFixed(nRepV % 1 ? 1 : 0)} · ${scaleInches}″`;
+  $('repCount').textContent  = nRep.toFixed(nRep % 1 ? 1 : 0);
+  $('repCountV').textContent = nRepV.toFixed(nRepV % 1 ? 1 : 0);
   $('repInches').textContent = scaleInches;
-  $('roomtag').textContent = `${fmtFt(wallInches)} × ${fmtFt(wallInches)} wall · ${scaleInches}″ repeat`;
-  $('wallval').textContent = fmtFt(wallInches);
+  $('roomtag').textContent = `${fmtFt(wallInches)} × ${fmtFt(wallInchesH)} wall · ${scaleInches}″ repeat`;
+  $('wallval').textContent  = fmtFt(wallInches);
+  $('wallvalH').textContent = fmtFt(wallInchesH);
 
   // Pill highlight
   document.querySelectorAll('#scalepills button')
@@ -202,10 +213,10 @@ function applyScale(){
   }
   rh.innerHTML = rhs;
   const rv = $('roomRulerV');
-  rv.style.height = roomW + 'px';
+  rv.style.height = roomH + 'px';
   rv.style.top    = (room.offsetTop) + 'px';
   let rvs = '';
-  for (let inch = 0; inch <= wallInches; inch += 6) {
+  for (let inch = 0; inch <= wallInchesH; inch += 6) {
     const maj = inch % 12 === 0, y = (inch * ppi).toFixed(1);
     rvs += `<div class="rt${maj ? ' maj' : ''}" style="top:${y}px"></div>`;
     if (maj) rvs += `<div class="rl" style="top:${y}px">${fmtFt(inch)}</div>`;
@@ -227,6 +238,12 @@ $('wallSlider').addEventListener('input', e => {
   localStorage.setItem('wrv_wall', wallInches);
   applyScale();
 });
+$('wallSliderH').value = wallInchesH;
+$('wallSliderH').addEventListener('input', e => {
+  wallInchesH = Math.min(180, Math.max(84, parseInt(e.target.value, 10) || 108));
+  localStorage.setItem('wrv_wall_h', wallInchesH);
+  applyScale();
+});
 addEventListener('resize', applyScale);
 
 // ── Photoreal room upsell ──────────────────────────────────────────────────
diff --git a/server.js b/server.js
index 5d7b1af..1cc6d8e 100644
--- a/server.js
+++ b/server.js
@@ -10802,16 +10802,64 @@ try {
     else setStatus('smart-fix failed: ' + (j.error || j.reason || r.status), 'err');
   });
   onClick('atb-etsy', async function(b){
-    if (!confirm('Queue #' + b.dataset.id + ' in wallco_etsy_bucket (eligibility-checked, upscaled to 300 DPI)? Does NOT create a live Etsy listing.')) return;
-    setStatus('Queueing for Etsy bucket…');
-    var r = await fetch('/api/etsy-bucket/add', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id: parseInt(b.dataset.id, 10) }) });
-    var j = await r.json();
-    if (r.ok && j.ok) {
+    if (!confirm('Queue #' + b.dataset.id + ' in wallco_etsy_bucket (runs a seam scan first — ~30-60s)? Does NOT create a live Etsy listing.')) return;
+    // The eligibility scan takes ~30-60s. Give a heartbeat so the UI doesn't
+    // look frozen. The disabled-overlay above already gates clicks.
+    setStatus('🔍 Running seam scan… (~30-60s)');
+    var heartbeat = 0;
+    var pulse = setInterval(function(){ heartbeat++;
+      setStatus('🔍 Running seam scan… ' + heartbeat + 's elapsed'); }, 1000);
+    var _o = location.protocol + '//' + location.host;
+    async function callBucket(force){
+      return fetch(_o + '/api/etsy-bucket/add', { method:'POST', credentials:'include',
+        headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ id: parseInt(b.dataset.id, 10), force: !!force }) });
+    }
+    try {
+      var r = await callBucket(false);
+      var j = await r.json();
+      clearInterval(pulse);
+      if (!(r.ok && j.ok)) { setStatus('etsy queue failed: ' + (j.error || r.status), 'err'); return; }
       var first = (j.results && j.results[0]) || {};
-      if (first.ok && first.already_in) setStatus('already in bucket (' + first.already_in + ')', 'ok');
-      else if (first.ok) setStatus('✓ queued · ' + first.target_dpi + ' DPI · ' + first.upscale_px + 'px upscale', 'ok');
-      else setStatus('skipped: ' + (first.reason || 'not eligible'), 'err');
-    } else setStatus('etsy queue failed: ' + (j.error || r.status), 'err');
+      if (first.ok && first.already_in) {
+        setStatus('already in bucket (' + first.already_in + ')', 'ok'); return;
+      }
+      if (first.ok) {
+        setStatus('✓ queued · ' + first.target_dpi + ' DPI · ' + first.upscale_px + 'px upscale', 'ok'); return;
+      }
+      // Eligibility gate held — show the reason + an amber "Sell anyway" override.
+      // Digital downloads have a lower quality bar than wall printing, so an
+      // imperfect-seam design can still be Etsy-ready when Steve says so.
+      var sc = first.scores || {};
+      var detail = 'Gate held — ' + (first.reason || 'not eligible');
+      if (sc.v_mid != null) detail += ' [v_mid=' + sc.v_mid + ' h_mid=' + sc.h_mid + ' edges=' + sc.edges_max + ']';
+      setStatus(detail, 'err');
+      // Inject a "Sell anyway" button next to the status line if not already there.
+      if (!document.getElementById('atb-etsy-force')) {
+        var btn = document.createElement('button');
+        btn.id = 'atb-etsy-force';
+        btn.type = 'button';
+        btn.textContent = '🛒 Sell anyway →';
+        btn.style.cssText = 'margin-left:8px;padding:4px 10px;border:1px solid #c98a00;border-radius:5px;background:#3b2e08;color:#f0b400;font:600 11px var(--sans);cursor:pointer;letter-spacing:.04em';
+        btn.title = 'Force-add to the Etsy bucket regardless of seam-gate verdict. Digital downloads tolerate imperfect seams better than print.';
+        btn.addEventListener('click', async function(){
+          btn.disabled = true; setStatus('🔍 Force-adding…');
+          try {
+            var r2 = await callBucket(true);
+            var j2 = await r2.json();
+            var first2 = (j2.results && j2.results[0]) || {};
+            if (r2.ok && j2.ok && first2.ok) {
+              setStatus('✓ force-queued · ' + first2.target_dpi + ' DPI · ' + first2.upscale_px + 'px upscale', 'ok');
+              btn.remove();
+            } else {
+              setStatus('force-add failed: ' + (first2.reason || j2.error || r2.status), 'err');
+              btn.disabled = false;
+            }
+          } catch(e){ setStatus('force-add error: ' + e.message, 'err'); btn.disabled = false; }
+        });
+        stat.parentNode.insertBefore(btn, stat.nextSibling);
+      }
+    } catch(e){ clearInterval(pulse); setStatus('error: ' + e.message, 'err'); }
   });
 })();
 </script>

← a5228db design grid: universal hover-preview for the ~93% of cards w  ·  back to Wallco Ai  ·  /design/:id admin toolbar: 'Sell on Etsy' button — (1) absol d9d340a →