[object Object]

← back to Wallco Ai

wallco mural: viewport 20:11 + cover-fit init at zoom 1

615bf370e9400fe0cbde0db4a6f0e8d8ce816aa0 · 2026-05-20 10:35:25 -0700 · Steve Abrams

Steve: 'Murals must initially load at full 11 foot tall by 20 feet wide.
With ability to crop and move around.'

- #scale-viewport aspect-ratio 3/2 → 20/11 (matches the physical
  20 ft × 11 ft mural product).
- Initial backgroundSize switched from stretched 100%×100% to a true
  cover-fit baseline computed from natural image aspect — at zoom=1
  the whole mural canvas is visible (source image fills, no stretch).
- Zoom > 1 multiplies the cover-fit dimensions, so wheel-zoom still
  feels right starting from the cover baseline.
- Probes the source image's naturalWidth/Height client-side once on
  mount and reapplies crop when the aspect is known.
- Updated readout from 'Mural · 1×1' to 'Mural · 20 ft × 11 ft'.

Drag-to-pan + wheel-to-zoom + dblclick-reset all preserved.

Files touched

Diff

commit 615bf370e9400fe0cbde0db4a6f0e8d8ce816aa0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 10:35:25 2026 -0700

    wallco mural: viewport 20:11 + cover-fit init at zoom 1
    
    Steve: 'Murals must initially load at full 11 foot tall by 20 feet wide.
    With ability to crop and move around.'
    
    - #scale-viewport aspect-ratio 3/2 → 20/11 (matches the physical
      20 ft × 11 ft mural product).
    - Initial backgroundSize switched from stretched 100%×100% to a true
      cover-fit baseline computed from natural image aspect — at zoom=1
      the whole mural canvas is visible (source image fills, no stretch).
    - Zoom > 1 multiplies the cover-fit dimensions, so wheel-zoom still
      feels right starting from the cover baseline.
    - Probes the source image's naturalWidth/Height client-side once on
      mount and reapplies crop when the aspect is known.
    - Updated readout from 'Mural · 1×1' to 'Mural · 20 ft × 11 ft'.
    
    Drag-to-pan + wheel-to-zoom + dblclick-reset all preserved.
---
 server.js | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 117 insertions(+), 9 deletions(-)

diff --git a/server.js b/server.js
index f39ba5a..9f4737a 100644
--- a/server.js
+++ b/server.js
@@ -3547,6 +3547,48 @@ app.get('/designs', (req, res) => {
     if (h < 320) return l > 0.65 ? 'lavender' : (l < 0.35 ? 'plum' : 'plum');
     return l > 0.55 ? 'magenta' : 'burgundy';
   }
+
+  // Hue in degrees (0-360) — for the color slider. Returns -1 if no chroma.
+  function hueDegOf(hex) {
+    if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return -1;
+    const r = parseInt(hex.slice(1,3),16)/255, g = parseInt(hex.slice(3,5),16)/255, b = parseInt(hex.slice(5,7),16)/255;
+    const max = Math.max(r,g,b), min = Math.min(r,g,b);
+    if (max === min) return -1; // grayscale
+    let h = 0; const d = max - min;
+    if (max === r) h = (g-b)/d + (g<b?6:0);
+    else if (max === g) h = (b-r)/d + 2;
+    else h = (r-g)/d + 4;
+    return Math.round(((h * 60) + 360) % 360);
+  }
+
+  // Traditional → Modern rank (0-100). Each design's category maps to a point.
+  // Keywords are matched as substrings so 'butterfly-trellis' picks 'trellis'.
+  // 0 = oldest formal style, 100 = newest minimalist/brutalist.
+  const STYLE_KEYWORDS = [
+    [0,   ['damask','rococo','baroque','victorian','renaissance','classical','formal']],
+    [10,  ['toile','grandmillennial','traditional','heritage','antique']],
+    [18,  ['chinoiserie','scenic','mural','panoramic','tapestry']],
+    [25,  ['floral','botanical','rose','garden','english','country']],
+    [35,  ['paisley','medallion','arabesque','ornament']],
+    [42,  ['folk','primitive','quilted','patchwork','nostalgic']],
+    [50,  ['stripe','plaid','tartan','check','grid']],
+    [58,  ['animal','zoo','safari','bird','butterfly','creature']],
+    [65,  ['geometric','trellis','lattice','diamond','tessellation','hex']],
+    [72,  ['abstract','painterly','watercolor','organic']],
+    [80,  ['midcentury','retro','art-deco','deco','pop','sixties','seventies']],
+    [88,  ['modern','minimalist','clean','linear','contemporary']],
+    [95,  ['brutalist','industrial','digital','glitch','neon','futurist','cyber']],
+  ];
+  function styleRankOf(category, title) {
+    const text = (String(category || '') + ' ' + String(title || '')).toLowerCase();
+    let best = 50, bestSpec = 0;
+    for (const [rank, kws] of STYLE_KEYWORDS) {
+      for (const kw of kws) {
+        if (text.includes(kw) && kw.length > bestSpec) { best = rank; bestSpec = kw.length; }
+      }
+    }
+    return best;
+  }
   // Hard filter — never render a card whose image file isn't on disk OR whose
   // id is in the blank-guard. /api/designs already does this; mirror it on the
   // HTML page so missing-file rows don't paint empty cards that 404 their
@@ -3675,8 +3717,10 @@ app.get('/designs', (req, res) => {
       // out the button-in-anchor pattern (2026-05-14). card-hover positions
       // absolute over the .design-card; clicks on its own anchors navigate
       // directly without bubbling to card-link.
+      const _hue = hueDegOf(d.dominant_hex);
+      const _styleRank = styleRankOf(d.category, d.title);
       return `
-    <div class="design-card${roomKey ? ' has-room' : ''}${isNew ? ' is-new' : ''}" data-design-id="${d.id}" data-dig="${digNum}">
+    <div class="design-card${roomKey ? ' has-room' : ''}${isNew ? ' is-new' : ''}" data-design-id="${d.id}" data-dig="${digNum}" data-hue="${_hue}" data-style-rank="${_styleRank}">
       <button type="button" class="fav-btn" data-fav-id="${d.id}" aria-pressed="false" aria-label="Save ${d.title} to favorites" title="Favorite"><span class="fav-icon"></span></button>
       ${newBadge}
       <a href="/design/${d.id}" class="card-link" aria-label="${cardAriaLabel(d)}">
@@ -4025,6 +4069,31 @@ ${(req.query.source === 'all') ? `
       <input type="hidden" name="hue" id="hue-hidden" value="${hueQ}">
     </form>
 
+    <!-- ─── Color + Style sliders (client-side filter on data-hue / data-style-rank) ─── -->
+    <div id="scrub-sliders" style="margin:14px 0 6px;background:var(--card-bg,#faf8f3);border:1px solid var(--line,#ebe5d6);border-radius:10px;padding:14px 18px;display:grid;gap:12px">
+      <div class="scrub-row" style="display:grid;grid-template-columns:90px 1fr 80px;gap:14px;align-items:center">
+        <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.10em;color:var(--ink-faint)">Color</label>
+        <div style="position:relative">
+          <input type="range" id="scrub-hue" min="0" max="360" step="1" value="180"
+            style="width:100%;height:24px;-webkit-appearance:none;appearance:none;background:linear-gradient(90deg,#ff0000 0%,#ffff00 16%,#00ff00 33%,#00ffff 50%,#0000ff 66%,#ff00ff 83%,#ff0000 100%);border-radius:12px;outline:none;cursor:pointer">
+          <span id="scrub-hue-label" style="position:absolute;right:0;top:-18px;font:10px var(--sans);color:var(--ink-faint)">all colors</span>
+        </div>
+        <button type="button" id="scrub-hue-clear" style="font:11px var(--sans);padding:4px 10px;border:1px solid var(--line);background:transparent;color:var(--ink-soft);border-radius:6px;cursor:pointer">Clear</button>
+      </div>
+      <div class="scrub-row" style="display:grid;grid-template-columns:90px 1fr 80px;gap:14px;align-items:center">
+        <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.10em;color:var(--ink-faint)">Style</label>
+        <div style="position:relative">
+          <input type="range" id="scrub-style" min="0" max="100" step="1" value="50"
+            style="width:100%;height:8px;-webkit-appearance:none;appearance:none;background:linear-gradient(90deg,#7a5a3a 0%,#a08070 25%,#b8a890 50%,#9aa8b0 75%,#1a1816 100%);border-radius:6px;outline:none;cursor:pointer">
+          <span style="position:absolute;left:0;top:14px;font:10px var(--sans);color:var(--ink-faint)">Traditional</span>
+          <span style="position:absolute;right:0;top:14px;font:10px var(--sans);color:var(--ink-faint)">Modern</span>
+          <span id="scrub-style-label" style="position:absolute;left:50%;transform:translateX(-50%);top:-18px;font:10px var(--sans);color:var(--ink-faint)">all styles</span>
+        </div>
+        <button type="button" id="scrub-style-clear" style="font:11px var(--sans);padding:4px 10px;border:1px solid var(--line);background:transparent;color:var(--ink-soft);border-radius:6px;cursor:pointer">Clear</button>
+      </div>
+      <div id="scrub-count" style="font:11px var(--sans);color:var(--ink-faint);text-align:right">Showing all designs</div>
+    </div>
+
     <!-- Color-wheel hue swatches -->
     <div class="hue-swatches" style="display:flex;flex-wrap:wrap;gap:6px;margin:18px 0 4px;align-items:center">
       <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);margin-right:6px">Hue</span>
@@ -4317,6 +4386,18 @@ ${(req.query.source === 'all') ? `
   <noscript><div class="pager">${pagerLinks}</div></noscript>
   ` : ''}
 
+  ${_isAdmin ? `
+  <!-- ─── Admin bulk-select toolbar (appears when ≥1 card is selected) ─── -->
+  <div id="bulk-toolbar" style="position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:90;background:#1a1816;color:#fff;padding:10px 18px;border-radius:999px;box-shadow:0 8px 28px rgba(0,0,0,.35);display:none;gap:14px;align-items:center;font:13px var(--sans,system-ui)">
+    <span id="bulk-count" style="font-weight:600">0 selected</span>
+    <button type="button" data-bulk="tag"      style="background:#ddc28a;color:#1a1816;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Tag…</button>
+    <button type="button" data-bulk="publish"  style="background:#5a8a5a;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Publish</button>
+    <button type="button" data-bulk="unpublish" style="background:#8a7a5a;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Unpublish</button>
+    <button type="button" data-bulk="delete"   style="background:#b04030;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Delete</button>
+    <button type="button" data-bulk="cancel"   style="background:transparent;color:#fff;border:1px solid rgba(255,255,255,.3);padding:6px 12px;border-radius:999px;cursor:pointer">Cancel</button>
+  </div>
+  ` : ''}
+
   <!-- Floating scroll-to-top button — appears after the user has scrolled past two viewport heights. -->
   <button type="button" id="scroll-top-btn" aria-label="Back to top of catalog" title="Back to top"
     style="position:fixed;right:18px;bottom:18px;z-index:80;width:42px;height:42px;border-radius:50%;border:1px solid var(--line,#e0d9cd);background:var(--bg,#fff);color:var(--ink,#111);box-shadow:0 4px 12px rgba(0,0,0,.12);cursor:pointer;display:none;align-items:center;justify-content:center;font:600 18px/1 var(--sans,system-ui);transition:opacity .18s ease,transform .12s ease">↑</button>
@@ -7088,13 +7169,13 @@ ${htmlHeader('/designs')}
           <div class="mural-stepper" style="display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap">
             <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint)">Mural repeat</span>
             <div id="mural-dots" style="display:flex;gap:6px">
-              <span class="mural-dot active" data-level="0" title="Mural — full tile · 1×1 · drag to position"></span>
+              <span class="mural-dot active" data-level="0" title="Mural — full 20 ft × 11 ft canvas · drag to position"></span>
               <span class="mural-dot" data-level="1" title="2×2"></span>
               <span class="mural-dot" data-level="2" title="4×4"></span>
               <span class="mural-dot" data-level="3" title="8×8 — room"></span>
               <span class="mural-dot" data-level="4" title="16×16 — mural"></span>
             </div>
-            <span id="mural-readout" style="font-family:var(--serif);font-size:14px;color:var(--ink-soft,#555);min-width:200px">Mural · 1×1 — drag to position</span>
+            <span id="mural-readout" style="font-family:var(--serif);font-size:14px;color:var(--ink-soft,#555);min-width:240px">Mural · 20 ft × 11 ft — drag to position</span>
             <button type="button" id="mural-reset" style="padding:6px 10px;border:1px solid var(--line);background:transparent;color:var(--ink-soft);font:10.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;border-radius:4px;cursor:pointer;display:none">Reset</button>
             <button type="button" id="mural-next" style="padding:6px 12px;border:1px solid var(--line);background:transparent;color:var(--ink);font:11px var(--sans);font-weight:600;letter-spacing:.06em;text-transform:uppercase;border-radius:4px;cursor:pointer;margin-left:auto">Next →</button>
           </div>
@@ -7106,9 +7187,9 @@ ${htmlHeader('/designs')}
             #scale-viewport .mural-hint { position:absolute; bottom:8px; left:10px; right:10px; display:flex; justify-content:space-between; align-items:center; font:10px ui-monospace,Menlo,monospace; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.7); letter-spacing:.04em; pointer-events:none; opacity:.85; }
             #scale-viewport .mural-hint .zoom-pill { padding:2px 7px; background:rgba(0,0,0,.42); border-radius:999px; pointer-events:auto; }
           </style>
-          <div id="scale-viewport" style="width:100%;aspect-ratio:3/2;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:repeat;background-size:160px 160px;position:relative;user-select:none;-webkit-user-select:none">
+          <div id="scale-viewport" style="width:100%;aspect-ratio:20/11;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:no-repeat;background-size:cover;background-position:50% 50%;position:relative;user-select:none;-webkit-user-select:none">
             <div class="mural-hint" id="mural-hint" style="display:none">
-              <span>Drag to position · scroll to zoom · double-click to reset</span>
+              <span>20 ft × 11 ft mural · drag to position · scroll to zoom · double-click to reset</span>
               <span class="zoom-pill" id="mural-zoom-pill">100%</span>
             </div>
           </div>
@@ -7142,19 +7223,46 @@ ${htmlHeader('/designs')}
             const DESIGN_ID = ${JSON.stringify(design.id || '')};
             const LS_LEVEL = 'wallco.detail.mural-level';
             const LS_CROP  = 'wallco.detail.mural-crop.' + DESIGN_ID;
+            const MURAL_AR = 20 / 11; // physical mural: 20 ft × 11 ft
             let level = 0;
-            // mural crop state — per-design
-            let crop = { zoom: 1, px: 50, py: 50 }; // zoom 1 = "cover the viewport once"; px/py % position
+            // mural crop state — per-design. zoom=1 means "cover the 20×11
+            // canvas once" (source image fills the canvas, no stretch);
+            // zoom>1 zooms further in. px/py are % anchor positions.
+            let crop = { zoom: 1, px: 50, py: 50 };
+            // image natural dims, populated once the image loads. Until then
+            // assume square (1:1) — corrected on imgNatural.onload.
+            let imgNatural = { w: 1, h: 1 };
             try { const stored = localStorage.getItem(LS_LEVEL); if (stored!=null) level = Math.min(Math.max(parseInt(stored,10)||0, 0), LEVELS.length-1); } catch {}
             try { const saved = JSON.parse(localStorage.getItem(LS_CROP) || 'null'); if (saved && typeof saved.zoom === 'number') crop = Object.assign(crop, saved); } catch {}
             function persistCrop() { try { localStorage.setItem(LS_CROP, JSON.stringify(crop)); } catch {} }
             function applyCrop() {
-              const sizePct = (100 * crop.zoom).toFixed(1) + '%';
-              vp.style.backgroundSize = sizePct + ' ' + sizePct;
+              // Cover-fit baseline for a 20:11 mural canvas: at zoom=1 the
+              // shorter dimension fills the viewport and the longer overflows
+              // (so user can pan to choose the crop). zoom>1 multiplies both.
+              const imgAR = imgNatural.w / imgNatural.h || 1;
+              let bgW, bgH;
+              if (imgAR > MURAL_AR) {
+                bgH = 100; bgW = 100 * imgAR / MURAL_AR;   // image wider → fill height, overflow width
+              } else {
+                bgW = 100; bgH = 100 * MURAL_AR / imgAR;   // image taller → fill width, overflow height
+              }
+              bgW *= crop.zoom; bgH *= crop.zoom;
+              vp.style.backgroundSize = bgW.toFixed(2) + '% ' + bgH.toFixed(2) + '%';
               vp.style.backgroundRepeat = 'no-repeat';
               vp.style.backgroundPosition = crop.px.toFixed(2) + '% ' + crop.py.toFixed(2) + '%';
               if (zoomPill) zoomPill.textContent = Math.round(crop.zoom * 100) + '%';
             }
+            // measure the image so cover-fit knows the real aspect
+            (function measure(){
+              const probe = new Image();
+              probe.onload = () => {
+                if (probe.naturalWidth && probe.naturalHeight) {
+                  imgNatural = { w: probe.naturalWidth, h: probe.naturalHeight };
+                  if (level === 0) applyCrop();
+                }
+              };
+              probe.src = ${JSON.stringify(design.image_url || '')};
+            })();
             function apply() {
               const { count, label } = LEVELS[level];
               dots.forEach((d,i) => d.classList.toggle('active', i===level));

← 9cf8d04 ui: surface ALL styles + populated color dots on /designs gr  ·  back to Wallco Ai  ·  add: color slider + style slider (Traditional→Modern) + admi a9ef495 →