← 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
M public/wall-room-viewer.htmlM server.js
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 →