[object Object]

← back to Wallco Ai

murals: add sort dropdown + density slider (Steve standing rule — every product grid needs sort + density)

8a16967ff5638796146342b5a19c873416cf77a2 · 2026-06-01 17:16:49 -0700 · Steve Abrams

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

Files touched

Diff

commit 8a16967ff5638796146342b5a19c873416cf77a2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 17:16:49 2026 -0700

    murals: add sort dropdown + density slider (Steve standing rule — every product grid needs sort + density)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 77 insertions(+), 3 deletions(-)

diff --git a/server.js b/server.js
index abd487a..7f85409 100644
--- a/server.js
+++ b/server.js
@@ -533,10 +533,36 @@ app.get('/design/:id/tabs', (req, res) => {
 //   V8 spec      — wine-list spec sheet
 //   V9 config    — configurator-first
 //   V10 stack    — single-column card stack
+// Theme variants are standalone HTML. Inject the standard site chrome — login
+// header + nav + footer — so every theme has the login and the same info as the
+// rest of the site (Steve 2026-06-01). One injection point covers all variants.
+const _themeVariantCache = {};
 function themeVariantSend(req, res, file) {
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) return res.status(404).type('html').send('<h1>404</h1>');
-  res.sendFile(path.join(__dirname, 'public', 'theme-variants', file));
+  try {
+    let html = _themeVariantCache[file];
+    if (html === undefined) {
+      html = fs.readFileSync(path.join(__dirname, 'public', 'theme-variants', file), 'utf8');
+      // site.css FIRST in <head> so the variant's own inline :root still wins on
+      // palette, while header/nav/footer pick up their standard styling.
+      if (!/css\/site\.css/.test(html)) {
+        html = html.replace(/<head>/i, '<head>\n<link rel="stylesheet" href="/css/site.css">');
+      }
+      // standard header (Sign In / login) right after <body>
+      if (!/site-header/.test(html)) {
+        html = html.replace(/<body[^>]*>/i, (m) => `${m}\n${htmlHeader('')}`);
+      }
+      // standard footer + nav script before </body>
+      if (!/site-footer/.test(html)) {
+        html = html.replace(/<\/body>/i, `\n${FOOTER}\n<script src="/corner-nav.js" defer></script>\n</body>`);
+      }
+      _themeVariantCache[file] = html;
+    }
+    res.type('html').send(html);
+  } catch (e) {
+    res.status(404).type('html').send('<h1>404</h1>');
+  }
 }
 app.get('/design/:id/hero',      (req, res) => themeVariantSend(req, res, 'v4-hero.html'));
 app.get('/design/:id/vertical',  (req, res) => themeVariantSend(req, res, 'v5-vertical.html'));
@@ -19397,6 +19423,7 @@ ${designs.map(tearsheet).join('')}
 // ── MURALS
 app.get('/murals', (req, res) => {
   const q = String(req.query.q || '').toLowerCase().trim();
+  const sort = req.query.sort || 'newest';
   const murals = DESIGNS.filter(d => d.kind === 'mural' || d.kind === 'mural_panel');
   let all = murals.length === 0 ? DESIGNS : murals; // fallback: show all if no mural-kind yet
   // Server-side ?q= filter — mirrors the search endpoint's logic so the
@@ -19413,6 +19440,9 @@ app.get('/murals', (req, res) => {
       return terms.every(t => hay.includes(t));
     });
   }
+  // Server-side sort — same helper + keys as /designs (Steve standing rule:
+  // every product grid MUST have sort + density).
+  all = sortDesigns(all, sort);
 
   const cards = all.map(d => {
     const roomKey = (d.room_mockups || []).includes('living_room') ? 'living_room'
@@ -19451,17 +19481,61 @@ ${htmlHeader('/murals')}
   </div>
   <div class="catalog-toolbar" style="padding:18px 40px 0">
     <form method="get" action="/murals" class="filter-form">
-      <div class="toolbar-row">
+      <input type="hidden" name="sort" id="murals-sort-hidden" value="${sort.replace(/[^a-z-]/g,'')}">
+      <div class="toolbar-row" style="display:flex;gap:14px;flex-wrap:wrap;align-items:center">
         <div class="search-wrap" style="position:relative;flex:1;min-width:200px">
           <input type="search" name="q" value="${q.replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]))}" placeholder="Search murals…" class="search-input" id="murals-search-input" autocomplete="off" aria-autocomplete="list" aria-controls="murals-search-suggest" aria-expanded="false" role="combobox" style="width:100%">
           <div id="murals-search-suggest" role="listbox" aria-label="Search suggestions" style="display:none;position:absolute;left:0;right:0;top:100%;margin-top:4px;background:var(--bg,#fff);border:1px solid var(--line,#ddd);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.12);z-index:50;max-height:60vh;overflow-y:auto"></div>
         </div>
+        <div style="display:flex;align-items:center;gap:6px">
+          <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint)">Sort</span>
+          <select aria-label="Sort murals" class="filter-select" onchange="document.getElementById('murals-sort-hidden').value=this.value;this.form.submit()">
+            <option value="newest"      ${sort==='newest'?'selected':''}>Newest first</option>
+            <option value="oldest"      ${sort==='oldest'?'selected':''}>Oldest first</option>
+            <option value="title"       ${sort==='title'?'selected':''}>Title A → Z</option>
+            <option value="title-desc"  ${sort==='title-desc'?'selected':''}>Title Z → A</option>
+            <option value="color"       ${sort==='color'?'selected':''}>Color (group similar hues)</option>
+            <option value="light-dark"  ${sort==='light-dark'?'selected':''}>Light → Dark</option>
+            <option value="dark-light"  ${sort==='dark-light'?'selected':''}>Dark → Light</option>
+            <option value="color-wheel" ${sort==='color-wheel'?'selected':''}>Color Wheel (warm → cool)</option>
+            <option value="vivid"       ${sort==='vivid'?'selected':''}>Most Vivid</option>
+            <option value="muted"       ${sort==='muted'?'selected':''}>Most Muted</option>
+            <option value="style"       ${sort==='style'?'selected':''}>Style (grouped)</option>
+          </select>
+        </div>
+        <div class="density-wrap" style="display:flex;align-items:center;gap:7px">
+          <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint)" title="Grid density">Density</span>
+          <input type="range" id="murals-density-slider" min="2" max="8" step="1" value="4"
+            aria-label="Grid density (columns)" aria-valuemin="2" aria-valuemax="8" aria-valuenow="4">
+          <span id="murals-density-value" style="font:11px var(--sans);color:var(--ink-faint);min-width:1ch">4</span>
+        </div>
       </div>
     </form>
   </div>
-  <div class="design-grid catalog-grid" style="padding:24px 40px">
+  <div class="design-grid catalog-grid" id="murals-grid" style="padding:24px 40px">
     ${cards || '<p class="empty-state">No murals match that search.</p>'}
   </div>
+  <script>
+  (function(){
+    var slider = document.getElementById('murals-density-slider');
+    var grid   = document.getElementById('murals-grid');
+    var label  = document.getElementById('murals-density-value');
+    if (!slider || !grid) return;
+    var saved = localStorage.getItem('wallco-density');   // shared with /designs
+    if (saved) slider.value = saved;
+    function applyCols(v){
+      var w = window.innerWidth, n = parseInt(v, 10);
+      if (w <= 600)      n = Math.min(n, 2);
+      else if (w <= 900) n = Math.min(n, 3);
+      grid.style.setProperty('--cols', String(n));
+      if (label) label.textContent = String(n);
+      slider.setAttribute('aria-valuenow', String(n));
+    }
+    applyCols(slider.value);
+    slider.addEventListener('input', function(){ applyCols(this.value); localStorage.setItem('wallco-density', this.value); });
+    window.addEventListener('resize', function(){ applyCols(slider.value); });
+  })();
+  </script>
 </section>
 </main>
 ${FOOTER}

← 271f231 studio/search: fix dead pattern thumbnails (generator + stud  ·  back to Wallco Ai  ·  docs: convergence migration plan — make prod spoon_all_desig 031802d →