← 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
M public/css/site.cssM server.js
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 →