[object Object]

← back to Wallco Ai

feat(design page): mural pan UI + on-screen arrow controls

ea1d852801b2609310a9b9fa4f70395954b47f94 · 2026-05-24 01:17:06 -0700 · Steve Abrams

When design.kind is 'mural' or 'mural_panel', the /design/:id hero
preview now:
  - zooms the PNG to 180% inside an overflow:hidden container so panning
    actually reveals detail (a 1024×1024 mural displayed at 100% in a 600px
    box is just "see whole thing tiny" — that's not a mural viewer)
  - shows 4 on-screen arrow buttons (↑↓←→) + a reset (⟲) pill
  - accepts drag-to-pan via pointer events (mouse + touch)
  - accepts keyboard arrow keys when the wrap has focus (tabIndex set)
  - persists pan position to localStorage per design-id so reload keeps
    the user's current view

Pan is implemented as two CSS variables --pan-x / --pan-y in percent of
container, clamped to [-80, 0] (the overflow band of a 180% scale).
Non-mural designs are unchanged — the data-mural="0" attribute on the
wrap keeps the default fit-to-frame layout from site.css.

This complements 8584a8a (server-side mural-scale fix for /api/room) —
the viewer now matches the underlying scale model: murals are
wall-width single images, not tile repeats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ea1d852801b2609310a9b9fa4f70395954b47f94
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 01:17:06 2026 -0700

    feat(design page): mural pan UI + on-screen arrow controls
    
    When design.kind is 'mural' or 'mural_panel', the /design/:id hero
    preview now:
      - zooms the PNG to 180% inside an overflow:hidden container so panning
        actually reveals detail (a 1024×1024 mural displayed at 100% in a 600px
        box is just "see whole thing tiny" — that's not a mural viewer)
      - shows 4 on-screen arrow buttons (↑↓←→) + a reset (⟲) pill
      - accepts drag-to-pan via pointer events (mouse + touch)
      - accepts keyboard arrow keys when the wrap has focus (tabIndex set)
      - persists pan position to localStorage per design-id so reload keeps
        the user's current view
    
    Pan is implemented as two CSS variables --pan-x / --pan-y in percent of
    container, clamped to [-80, 0] (the overflow band of a 180% scale).
    Non-mural designs are unchanged — the data-mural="0" attribute on the
    wrap keeps the default fit-to-frame layout from site.css.
    
    This complements 8584a8a (server-side mural-scale fix for /api/room) —
    the viewer now matches the underlying scale model: murals are
    wall-width single images, not tile repeats.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/css/site.css | 62 ++++++++++++++++++++++++++++++++++
 server.js           | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 158 insertions(+), 1 deletion(-)

diff --git a/public/css/site.css b/public/css/site.css
index 30b36c4..d3b541a 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -643,6 +643,68 @@ main { padding-top: 73px; }
   border-radius: 2px;
   background: var(--line);
 }
+
+/* Mural pan UI — only active when data-mural="1" on the wrap.
+   Zooms the image to 180% inside the container so dragging actually reveals
+   detail, with overflow:hidden + transform-based pan + HUD arrow buttons.
+   Non-mural designs use the default .detail-img-wrap layout above. */
+.detail-img-wrap[data-mural="1"] {
+  overflow: hidden;
+  cursor: grab;
+  user-select: none;
+  -webkit-user-select: none;
+  touch-action: none;
+  aspect-ratio: 1;
+}
+.detail-img-wrap[data-mural="1"]:active { cursor: grabbing; }
+.detail-img-wrap[data-mural="1"] .detail-img {
+  position: absolute;
+  width: 180%;
+  height: auto;
+  max-width: none;
+  left: 0; top: 0;
+  transform: translate(var(--pan-x, -22%), var(--pan-y, -22%));
+  transition: transform .12s ease-out;
+  pointer-events: none;
+  user-select: none;
+  -webkit-user-select: none;
+}
+.mural-pan-hud {
+  position: absolute;
+  inset: 8px;
+  pointer-events: none;
+  z-index: 2;
+}
+.mural-arrow {
+  position: absolute;
+  pointer-events: auto;
+  background: rgba(20,16,8,.62);
+  color: #fff;
+  border: 1px solid rgba(255,255,255,.18);
+  border-radius: 999px;
+  width: 38px; height: 38px;
+  font-size: 17px;
+  line-height: 1;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  backdrop-filter: blur(6px);
+  -webkit-backdrop-filter: blur(6px);
+  transition: background .12s ease, transform .12s ease;
+}
+.mural-arrow:hover  { background: rgba(20,16,8,.85); }
+.mural-arrow:active { transform: scale(.92); }
+.mural-arrow.mp-up    { top: 8px;    left: 50%; transform: translateX(-50%); }
+.mural-arrow.mp-down  { bottom: 8px; left: 50%; transform: translateX(-50%); }
+.mural-arrow.mp-left  { left: 8px;   top: 50%;  transform: translateY(-50%); }
+.mural-arrow.mp-right { right: 8px;  top: 50%;  transform: translateY(-50%); }
+.mural-arrow.mp-reset { right: 8px;  bottom: 8px; width: 30px; height: 30px; font-size: 14px; opacity:.7; }
+.mural-arrow.mp-reset:hover { opacity: 1; }
+.mural-arrow.mp-up:hover    { transform: translateX(-50%) translateY(-2px); }
+.mural-arrow.mp-down:hover  { transform: translateX(-50%) translateY(2px); }
+.mural-arrow.mp-left:hover  { transform: translateY(-50%) translateX(-2px); }
+.mural-arrow.mp-right:hover { transform: translateY(-50%) translateX(2px); }
 .tiled-preview {
   margin-top: 12px;
   position: relative;
diff --git a/server.js b/server.js
index 5f73acf..9c77866 100644
--- a/server.js
+++ b/server.js
@@ -8070,8 +8070,103 @@ ${htmlHeader('/designs')}
         }
       </style>
 
-      <div class="detail-img-wrap">
+      <div class="detail-img-wrap" data-mural="${(design.kind === 'mural' || design.kind === 'mural_panel') ? '1' : '0'}">
         <img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" fetchpriority="high" decoding="async" crossorigin="anonymous">
+        ${(design.kind === 'mural' || design.kind === 'mural_panel') ? `
+        <!-- Mural pan controls — drag to pan, arrow keys + on-screen arrows for keyboard / touch users -->
+        <div class="mural-pan-hud" aria-hidden="false">
+          <button class="mural-arrow mp-up"    type="button" aria-label="Pan up"    data-dir="up">↑</button>
+          <button class="mural-arrow mp-down"  type="button" aria-label="Pan down"  data-dir="down">↓</button>
+          <button class="mural-arrow mp-left"  type="button" aria-label="Pan left"  data-dir="left">←</button>
+          <button class="mural-arrow mp-right" type="button" aria-label="Pan right" data-dir="right">→</button>
+          <button class="mural-arrow mp-reset" type="button" aria-label="Reset view" data-dir="reset" title="Reset view">⟲</button>
+        </div>
+        <script>
+        (function(){
+          // Mural pan: image is scaled to 180% inside the container; we set
+          // --pan-x and --pan-y CSS vars (percent of container width/height,
+          // negative values shift the image up/left). At rest the image is
+          // centered: -22% covers the (180-100)/2 = 40% overflow / 2 ≈ 22%.
+          var wrap = document.querySelector('.detail-img-wrap[data-mural="1"]');
+          if (!wrap) return;
+          var SCALE = 1.80;   // must match CSS width: 180%
+          var OVERFLOW = (SCALE - 1) * 100;        // = 80 percent total
+          var CENTER = -OVERFLOW / 2;              // = -40 (% of container)
+          // Pan bounds: pan-x ∈ [-OVERFLOW, 0]. Same for Y.
+          var STEP = 12;                            // percent per arrow click
+          var panX = parseFloat(localStorage.getItem('mural-pan-x-' + ${design.id})) ;
+          var panY = parseFloat(localStorage.getItem('mural-pan-y-' + ${design.id})) ;
+          if (!isFinite(panX)) panX = CENTER;
+          if (!isFinite(panY)) panY = CENTER;
+          function clamp(v) { return Math.max(-OVERFLOW, Math.min(0, v)); }
+          function apply() {
+            panX = clamp(panX);
+            panY = clamp(panY);
+            wrap.style.setProperty('--pan-x', panX + '%');
+            wrap.style.setProperty('--pan-y', panY + '%');
+            try {
+              localStorage.setItem('mural-pan-x-' + ${design.id}, String(panX));
+              localStorage.setItem('mural-pan-y-' + ${design.id}, String(panY));
+            } catch (_) {}
+          }
+          apply();
+
+          // On-screen arrow buttons
+          wrap.querySelectorAll('.mural-arrow').forEach(function(btn){
+            btn.addEventListener('click', function(){
+              var d = btn.dataset.dir;
+              if      (d === 'up')    panY += STEP;
+              else if (d === 'down')  panY -= STEP;
+              else if (d === 'left')  panX += STEP;
+              else if (d === 'right') panX -= STEP;
+              else if (d === 'reset') { panX = CENTER; panY = CENTER; }
+              apply();
+            });
+          });
+
+          // Keyboard arrows when the wrap is focused / hovered
+          wrap.tabIndex = 0;
+          wrap.addEventListener('keydown', function(e){
+            var handled = true;
+            if      (e.key === 'ArrowUp')    panY += STEP;
+            else if (e.key === 'ArrowDown')  panY -= STEP;
+            else if (e.key === 'ArrowLeft')  panX += STEP;
+            else if (e.key === 'ArrowRight') panX -= STEP;
+            else if (e.key === 'Home' || e.key === '0') { panX = CENTER; panY = CENTER; }
+            else handled = false;
+            if (handled) { e.preventDefault(); apply(); }
+          });
+
+          // Drag-to-pan (pointer events for mouse + touch). Mode A: free pan
+          // in pixels. We convert pixel delta to percent of container size.
+          var dragging = false, lastX = 0, lastY = 0;
+          wrap.addEventListener('pointerdown', function(e){
+            if (e.target.closest('.mural-arrow')) return;  // arrows handle themselves
+            dragging = true;
+            lastX = e.clientX; lastY = e.clientY;
+            wrap.setPointerCapture(e.pointerId);
+            wrap.querySelector('.detail-img').style.transition = 'none';
+          });
+          wrap.addEventListener('pointermove', function(e){
+            if (!dragging) return;
+            var rect = wrap.getBoundingClientRect();
+            var dx = e.clientX - lastX, dy = e.clientY - lastY;
+            lastX = e.clientX; lastY = e.clientY;
+            panX += (dx / rect.width)  * 100;
+            panY += (dy / rect.height) * 100;
+            apply();
+          });
+          function endDrag(e){
+            if (!dragging) return;
+            dragging = false;
+            try { wrap.releasePointerCapture(e.pointerId); } catch(_) {}
+            wrap.querySelector('.detail-img').style.transition = '';
+          }
+          wrap.addEventListener('pointerup',     endDrag);
+          wrap.addEventListener('pointercancel', endDrag);
+          wrap.addEventListener('pointerleave',  endDrag);
+        })();
+        </script>` : ''}
         <canvas id="detail-canvas" style="display:none"></canvas>
 
         <!-- ── COLOR STORY — palette dots + Sherwin-Williams + Dunn-Edwards rows

← 8584a8a fix(api/room): pass full mural width to upstream renderer fo  ·  back to Wallco Ai  ·  ghost-scan flagged.jsonl: gate on unanimous 3/3, not confide e110dad →