[object Object]

← back to Wallsandfabrics

snapshot before hamburger nav rollout

7aff7d823ed04954f57243edde188b4083ed5bfa · 2026-05-10 21:40:17 -0700 · Steve Abrams

Files touched

Diff

commit 7aff7d823ed04954f57243edde188b4083ed5bfa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 21:40:17 2026 -0700

    snapshot before hamburger nav rollout
---
 public/index.html.pre-sort-frontend | 267 ++++++++++++++++++++++++++++++++++++
 server.js.pre-sort-skill            | 159 +++++++++++++++++++++
 2 files changed, 426 insertions(+)

diff --git a/public/index.html.pre-sort-frontend b/public/index.html.pre-sort-frontend
new file mode 100644
index 0000000..c7b8848
--- /dev/null
+++ b/public/index.html.pre-sort-frontend
@@ -0,0 +1,267 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Walls &amp; Fabrics — wallcoverings + fabrics from Designer Wallcoverings</title>
+<meta name="description" content="One unified search across wallcoverings, fabrics, murals and trims. Memo samples shipped fast. Trade-friendly pricing from Designer Wallcoverings.">
+<link rel="icon" href="/favicon.svg" type="image/svg+xml">
+
+<!-- anti-flash theme — must run before paint -->
+<script>
+(function(){try{var t=localStorage.getItem('waf-theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
+</script>
+
+<!-- GA4 gtag (auto) -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=G-429EVNMHVY"></script>
+<script>
+window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
+gtag('js', new Date());
+gtag('config','G-429EVNMHVY');
+</script>
+<!-- /GA4 gtag -->
+
+<style>
+:root{
+  --bg:#F4EFE6; --fg:#0E1116; --muted:#5b5042; --line:#d8cfbf;
+  --paper:#ffffff; --accent:#9A6E3A; --accent2:#46606D;
+  --shadow:0 1px 0 rgba(0,0,0,.05),0 12px 28px -16px rgba(0,0,0,.18);
+  --cols:5;
+}
+:root[data-theme="dark"]{
+  --bg:#0E1116; --fg:#F0EBE0; --muted:#9aa1ae; --line:#2a3038;
+  --paper:#171b22; --accent:#C99463; --accent2:#7BA3B3;
+  --shadow:0 1px 0 rgba(0,0,0,.4),0 14px 30px -14px rgba(0,0,0,.6);
+}
+*{box-sizing:border-box}
+html,body{margin:0;background:var(--bg);color:var(--fg);font-family:Georgia,'Times New Roman',serif}
+body{font-size:16px;line-height:1.5}
+a{color:var(--accent);text-decoration:none}
+a:hover{text-decoration:underline}
+.wrap{max-width:1320px;margin:0 auto;padding:0 22px}
+header.site{position:sticky;top:0;z-index:20;background:var(--bg);border-bottom:1px solid var(--line)}
+header.site .row{display:flex;align-items:center;gap:18px;padding:14px 0}
+.brand{font-family:'Bodoni Moda','Playfair Display',Georgia,serif;font-weight:700;font-size:22px;letter-spacing:.02em}
+.brand .amp{color:var(--accent);font-style:italic;padding:0 .12em}
+nav.top{display:flex;gap:18px;margin-left:auto;align-items:center;font-size:14px}
+nav.top a{color:var(--fg);opacity:.78}
+nav.top a:hover{opacity:1}
+.theme-btn{background:none;border:1px solid var(--line);color:var(--fg);width:38px;height:38px;border-radius:50%;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:16px}
+.theme-btn:hover{border-color:var(--accent)}
+
+.hero{padding:64px 0 28px;border-bottom:1px solid var(--line)}
+.hero h1{font-family:'Bodoni Moda','Playfair Display',Georgia,serif;font-size:clamp(40px,6vw,72px);line-height:1;margin:0 0 16px;letter-spacing:-.01em}
+.hero h1 em{font-style:italic;color:var(--accent)}
+.hero p.lede{font-size:18px;color:var(--muted);max-width:62ch;margin:0 0 20px}
+.hero .meta{font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.16em;margin-bottom:14px}
+.surface-tabs{display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 0}
+.surface-tab{padding:10px 16px;border:1px solid var(--line);background:var(--paper);color:var(--fg);border-radius:999px;cursor:pointer;font:inherit;font-size:13px;letter-spacing:.05em;text-transform:uppercase}
+.surface-tab[aria-pressed="true"]{background:var(--fg);color:var(--bg);border-color:var(--fg)}
+.surface-tab .count{opacity:.6;margin-left:6px;font-variant-numeric:tabular-nums}
+
+.toolbar{display:flex;flex-wrap:wrap;gap:14px;align-items:center;padding:18px 0;border-bottom:1px solid var(--line);margin-bottom:24px}
+.toolbar .grow{flex:1;min-width:200px}
+.toolbar input[type="search"]{width:100%;padding:11px 14px;font:inherit;border:1px solid var(--line);background:var(--paper);color:var(--fg);border-radius:8px}
+.toolbar label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
+.toolbar select{padding:10px 12px;font:inherit;border:1px solid var(--line);background:var(--paper);color:var(--fg);border-radius:8px}
+.toolbar input[type="range"]{accent-color:var(--accent);width:140px}
+.toolbar .group{display:flex;align-items:center;gap:8px}
+
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:18px;padding:8px 0 60px}
+@media (max-width:1100px){.grid{grid-template-columns:repeat(min(var(--cols),4),1fr)}}
+@media (max-width:820px){.grid{grid-template-columns:repeat(min(var(--cols),3),1fr)}}
+@media (max-width:560px){.grid{grid-template-columns:repeat(min(var(--cols),2),1fr)}}
+.card{background:var(--paper);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:transform .15s,box-shadow .15s}
+.card:hover{transform:translateY(-2px);box-shadow:var(--shadow)}
+.card .img{aspect-ratio:1/1;background:#0001;display:block;background-size:cover;background-position:center}
+.card .body{padding:11px 12px 12px}
+.card .surface{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);font-weight:700}
+.card .ttl{font-size:14px;line-height:1.35;margin:4px 0 6px;color:var(--fg);min-height:2.6em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.card .meta{font-size:12px;color:var(--muted);display:flex;justify-content:space-between;gap:8px}
+.card a.smp{display:inline-block;margin-top:8px;font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--accent2);border-bottom:1px solid var(--accent2);padding-bottom:1px}
+
+.pager{display:flex;justify-content:center;gap:10px;margin:30px 0 60px}
+.pager button{padding:10px 18px;border:1px solid var(--line);background:var(--paper);color:var(--fg);border-radius:6px;cursor:pointer;font:inherit;font-size:14px}
+.pager button:disabled{opacity:.4;cursor:not-allowed}
+.pager .info{align-self:center;color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums}
+
+.empty{padding:80px 20px;text-align:center;color:var(--muted)}
+.empty h2{color:var(--fg);font-family:'Bodoni Moda',Georgia,serif;font-weight:400;margin-bottom:8px}
+
+footer.site{border-top:1px solid var(--line);padding:36px 0;font-size:13px;color:var(--muted);background:var(--paper)}
+footer.site .grid-foot{display:grid;grid-template-columns:2fr 1fr 1fr;gap:30px}
+@media (max-width:720px){footer.site .grid-foot{grid-template-columns:1fr}}
+footer.site h4{font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:var(--fg);margin:0 0 10px}
+footer.site a{color:var(--accent2)}
+.legal{margin-top:18px;font-size:12px;color:var(--muted)}
+</style>
+</head>
+<body>
+
+<header class="site">
+  <div class="wrap row">
+    <div class="brand">Walls <span class="amp">&amp;</span> Fabrics</div>
+    <nav class="top">
+      <a href="#grid" data-surface="all">All</a>
+      <a href="#grid" data-surface="wallcovering">Walls</a>
+      <a href="#grid" data-surface="fabric">Fabrics</a>
+      <a href="#grid" data-surface="mural">Murals</a>
+      <a href="#grid" data-surface="trim">Trim</a>
+      <button id="themeBtn" class="theme-btn" type="button" aria-label="Toggle dark / light">☼</button>
+    </nav>
+  </div>
+</header>
+
+<section class="hero wrap" id="hero">
+  <div class="meta">A Designer Wallcoverings property</div>
+  <h1>Walls <em>&amp;</em> Fabrics.</h1>
+  <p class="lede">One unified search across the Designer Wallcoverings catalog of wallcoverings, fabrics, murals, and trims. Sort by color, style, or surface — request memo samples in a few clicks.</p>
+
+  <div class="surface-tabs" id="surfaceTabs"></div>
+</section>
+
+<section class="wrap" id="grid">
+  <div class="toolbar">
+    <div class="grow"><input id="q" type="search" placeholder="Search by name, color, vendor, surface…" autocomplete="off"></div>
+    <div class="group"><label for="sort">Sort</label>
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="color">Color</option>
+        <option value="style">Style</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="title">Title A→Z</option>
+        <option value="price-asc">Price ↑</option>
+        <option value="price-desc">Price ↓</option>
+      </select>
+    </div>
+    <div class="group"><label for="cols">Density</label>
+      <input id="cols" type="range" min="3" max="8" step="1" value="5">
+    </div>
+  </div>
+
+  <div id="results" class="grid"></div>
+
+  <div class="pager">
+    <button id="prev" disabled>← Previous</button>
+    <span class="info" id="info">—</span>
+    <button id="next" disabled>Next →</button>
+  </div>
+</section>
+
+<footer class="site">
+  <div class="wrap grid-foot">
+    <div>
+      <h4>Walls &amp; Fabrics</h4>
+      <p>A Designer Wallcoverings sister site. Trade orders, memo samples, and quantity quotes available — same catalog, narrower lens.</p>
+      <p class="legal">© <span id="yr"></span> Designer Wallcoverings · 15442 Ventura Bl #102, Sherman Oaks CA 91403</p>
+    </div>
+    <div>
+      <h4>Contact</h4>
+      <p><a href="mailto:info@wallsandfabrics.com">info@wallsandfabrics.com</a></p>
+      <p><a href="tel:+18883734564">888-373-4564</a></p>
+    </div>
+    <div>
+      <h4>Browse</h4>
+      <p><a href="#" data-surface="wallcovering">Wallcoverings</a></p>
+      <p><a href="#" data-surface="fabric">Fabrics</a></p>
+      <p><a href="#" data-surface="mural">Murals</a></p>
+      <p><a href="#" data-surface="trim">Trim</a></p>
+    </div>
+  </div>
+</footer>
+
+<script>
+(function(){
+  // theme toggle
+  var btn=document.getElementById('themeBtn');
+  function setTheme(t){document.documentElement.setAttribute('data-theme',t);try{localStorage.setItem('waf-theme',t);}catch(e){}btn.textContent=(t==='dark')?'☾':'☼';}
+  setTheme(document.documentElement.getAttribute('data-theme')||'light');
+  btn.addEventListener('click',function(){setTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark');});
+
+  // density slider
+  var sl=document.getElementById('cols');
+  function applyCols(v){document.documentElement.style.setProperty('--cols',String(v));try{localStorage.setItem('waf-cols',String(v));}catch(e){}}
+  var savedCols=Number(localStorage.getItem('waf-cols'))||5;
+  sl.value=String(savedCols); applyCols(savedCols);
+  sl.addEventListener('input',function(){applyCols(Number(sl.value));});
+
+  // sort persistence
+  var sortSel=document.getElementById('sort');
+  var savedSort=localStorage.getItem('waf-sort')||'newest';
+  sortSel.value=savedSort;
+  sortSel.addEventListener('change',function(){try{localStorage.setItem('waf-sort',sortSel.value);}catch(e){}reload(1);});
+
+  // search debounce
+  var q=document.getElementById('q'); var qTimer=null;
+  q.addEventListener('input',function(){clearTimeout(qTimer);qTimer=setTimeout(function(){reload(1);},250);});
+
+  // surface tabs
+  var SURFACE_LABELS={all:'All',wallcovering:'Walls',fabric:'Fabrics',mural:'Murals',trim:'Trim'};
+  var state={surface:'all',page:1};
+  var tabs=document.getElementById('surfaceTabs');
+  function buildTabs(counts){
+    var totalAll=Object.values(counts).reduce(function(a,b){return a+b;},0);
+    var keys=['all','wallcovering','fabric','mural','trim'];
+    tabs.innerHTML=keys.map(function(k){
+      var n=k==='all'?totalAll:(counts[k]||0);
+      var label=SURFACE_LABELS[k]||k;
+      return '<button class="surface-tab" data-surface="'+k+'" aria-pressed="'+(state.surface===k?'true':'false')+'">'+label+'<span class="count">'+n.toLocaleString()+'</span></button>';
+    }).join('');
+    Array.from(tabs.querySelectorAll('.surface-tab')).forEach(function(b){
+      b.addEventListener('click',function(){state.surface=b.dataset.surface;buildTabs(counts);reload(1);});
+    });
+  }
+  // nav links + footer browse links also drive surface
+  Array.from(document.querySelectorAll('a[data-surface]')).forEach(function(a){
+    a.addEventListener('click',function(e){e.preventDefault();state.surface=a.dataset.surface;reload(1);document.getElementById('grid').scrollIntoView({behavior:'smooth',block:'start'});});
+  });
+
+  // pager
+  var prev=document.getElementById('prev'),next=document.getElementById('next'),info=document.getElementById('info');
+  prev.addEventListener('click',function(){if(state.page>1){reload(state.page-1);}});
+  next.addEventListener('click',function(){reload(state.page+1);});
+
+  function fmt$(p){if(!p||!p.max_price)return '';var n=Number(p.max_price);return isFinite(n)?'$'+n.toFixed(0):'';}
+  function card(p){
+    var img='<span class="img" style="background-image:url(\\''+(p.image_url||'').replace(/'/g,'%27')+'\\')"></span>';
+    var url=p.product_url||'https://designerwallcoverings.com/products/'+encodeURIComponent(p.handle||p.sku||'');
+    var surface=(p.__surface||'wallcovering');
+    var label=SURFACE_LABELS[surface]||surface;
+    var ttl=(p.title||'(untitled)').replace(/&/g,'&amp;').replace(/</g,'&lt;');
+    var vendor=(p.vendor||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');
+    return '<a href="'+url+'" target="_blank" rel="noopener" class="card" data-sku="'+(p.sku||p.handle||'')+'">'
+      +img
+      +'<div class="body"><div class="surface">'+label+'</div><div class="ttl">'+ttl+'</div>'
+      +'<div class="meta"><span>'+vendor+'</span><span>'+fmt$(p)+'</span></div>'
+      +'<span class="smp">Request memo →</span></div></a>';
+  }
+
+  function renderEmpty(){
+    document.getElementById('results').innerHTML='<div class="empty"><h2>No matches</h2><p>Try widening your search or selecting a different surface.</p></div>';
+    info.textContent='0 results';
+    prev.disabled=true; next.disabled=true;
+  }
+
+  function reload(page){
+    state.page=page||1;
+    var params=new URLSearchParams({sort:sortSel.value,page:String(state.page),limit:'24'});
+    if(q.value.trim()) params.set('q',q.value.trim());
+    if(state.surface && state.surface!=='all') params.set('surface',state.surface);
+    info.textContent='Loading…';
+    fetch('/api/products?'+params.toString()).then(function(r){return r.json();}).then(function(j){
+      buildTabs(j.surfaces||{});
+      if(!j.items || !j.items.length){renderEmpty();return;}
+      document.getElementById('results').innerHTML=j.items.map(card).join('');
+      info.textContent=(j.total.toLocaleString())+' results · page '+j.page+' of '+j.pages;
+      prev.disabled=j.page<=1;
+      next.disabled=j.page>=j.pages;
+    }).catch(function(e){info.textContent='Error loading.';console.error(e);});
+  }
+
+  document.getElementById('yr').textContent=new Date().getFullYear();
+  reload(1);
+})();
+</script>
+
+</body>
+</html>
diff --git a/server.js.pre-sort-skill b/server.js.pre-sort-skill
new file mode 100644
index 0000000..5df1a4b
--- /dev/null
+++ b/server.js.pre-sort-skill
@@ -0,0 +1,159 @@
+/**
+ * Walls & Fabrics — combo storefront for DW wallcoverings + fabrics + murals + trims.
+ * Curated slice of the live Designer Wallcoverings catalog (dw_unified).
+ */
+const express = require('express');
+const helmet = require('helmet');
+const path = require('path');
+const fs = require('fs');
+
+const PORT = process.env.PORT || 9934;
+const DW_SHOPIFY = 'https://designerwallcoverings.com';
+const __SITE = path.basename(__dirname);
+
+let DATA_RAW;
+try {
+  DATA_RAW = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+  if (!Array.isArray(DATA_RAW)) throw new Error('products.json must be an array');
+} catch (e) {
+  console.error(`[${__SITE}] FATAL: could not load products.json — ${e.message}`);
+  DATA_RAW = [];
+}
+
+// Normalize the multiple shapes that ship across DW family datasets:
+//   - cork shape: image_url + max_price + product_url
+//   - dw_unified shape: images[] + price + dw_url
+function normalize(p) {
+  const out = { ...p };
+  if (!out.image_url) {
+    if (Array.isArray(out.images) && out.images.length) {
+      out.image_url = typeof out.images[0] === 'string' ? out.images[0] : (out.images[0] && out.images[0].src) || '';
+    }
+  }
+  if (out.max_price == null && out.price != null) out.max_price = Number(out.price);
+  if (!out.product_url && out.dw_url) out.product_url = out.dw_url;
+  if (!out.sku && out.handle) out.sku = out.handle;
+  return out;
+}
+
+function isJunk(p) {
+  if (!p.image_url || !String(p.image_url).trim()) return true;
+  if (!p.handle && !p.sku) return true;
+  const t = p.title || '';
+  if (/lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine/i.test(t)) return true;
+  if (/visual.{0,3}merchandiser/i.test(t)) return true;
+  if (/(?:^|\W)image[ _-]?4(?:\W|$)/i.test(t)) return true;
+  return false;
+}
+
+const PRODUCTS = DATA_RAW.map(normalize).filter(p => !isJunk(p));
+console.log(`[${__SITE}] Loaded ${DATA_RAW.length}, kept ${PRODUCTS.length}, dropped ${DATA_RAW.length - PRODUCTS.length}`);
+
+// Surface tagging — derive a canonical surface bucket per product.
+function surfaceOf(p) {
+  const pt = String(p.product_type || '').toLowerCase();
+  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
+  if (pt.includes('fabric')) return 'fabric';
+  if (pt.includes('mural') || /mural/.test(blob)) return 'mural';
+  if (pt.includes('trim') || /trim|tassel|braid|fringe/.test(blob)) return 'trim';
+  return 'wallcovering';
+}
+for (const p of PRODUCTS) p.__surface = surfaceOf(p);
+
+const SURFACE_COUNTS = PRODUCTS.reduce((acc, p) => { acc[p.__surface] = (acc[p.__surface] || 0) + 1; return acc; }, {});
+console.log(`[${__SITE}] Surfaces:`, SURFACE_COUNTS);
+
+const app = express();
+app.use(helmet({ contentSecurityPolicy: false }));
+app.use(express.json({ limit: '256kb' }));
+
+require('./_universal-contact')(app, { siteName: 'Walls & Fabrics', zdColor: '#9A6E3A', zdPosition: 'right' });
+require('./_universal-auth')(app, { siteName: __SITE });
+
+app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
+
+// DW STANDARD: sort by color/style/sku/title — see ~/.claude/CLAUDE.md.
+const COLOR_RANK = {
+  black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,khaki:9,copper:10,
+  red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
+  yellow:30,gold:31,mustard:32,olive:33,
+  green:40,lime:41,mint:42,teal:50,aqua:51,turquoise:52,cyan:53,
+  blue:60,navy:61,indigo:62,periwinkle:63,
+  purple:70,violet:71,lavender:72,plum:73,magenta:74,fuchsia:75,
+};
+function colorRank(p) {
+  const tags = (p.tags || []).map(t => String(t).toLowerCase());
+  for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
+  return 999;
+}
+const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','mid century','rustic','industrial','farmhouse','bohemian','boho','geometric','floral','botanical','damask','toile','grasscloth','silk','linen','vinyl','natural'];
+function styleKey(p) {
+  const tags = (p.tags || []).map(t => String(t).toLowerCase());
+  for (const s of STYLE_TAGS) if (tags.some(t => t.includes(s))) return s;
+  return 'zzz';
+}
+function sortProducts(list, mode) {
+  if (mode === 'sku') return [...list].sort((a,b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
+  if (mode === 'title') return [...list].sort((a,b) => String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title || '').localeCompare(String(b.title || '')));
+  if (mode === 'price-asc')  return [...list].sort((a,b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
+  if (mode === 'price-desc') return [...list].sort((a,b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
+  return list; // newest
+}
+
+app.get('/api/products', (req, res) => {
+  const { q, surface, vendor, sort = 'newest', page = 1, limit = 24 } = req.query;
+  let list = PRODUCTS;
+  if (q) {
+    const needle = q.toLowerCase();
+    list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)) || (p.vendor || '').toLowerCase().includes(needle));
+  }
+  if (surface && surface !== 'all') list = list.filter(p => p.__surface === surface);
+  if (vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
+  list = sortProducts(list, sort);
+  const total = list.length;
+  const pageNum = Math.max(1, parseInt(page) || 1);
+  const lim = Math.min(60, parseInt(limit) || 24);
+  const start = (pageNum - 1) * lim;
+  res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total / lim), sort, surfaces: SURFACE_COUNTS, items: list.slice(start, start + lim) });
+});
+
+app.get('/api/facets', (req, res) => {
+  const vendors = {};
+  for (const p of PRODUCTS) vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
+  res.json({ surfaces: SURFACE_COUNTS, vendors, total: PRODUCTS.length });
+});
+
+app.get('/api/sliders', (req, res) => {
+  const out = [];
+  for (const surface of ['wallcovering','fabric','mural','trim']) {
+    const items = PRODUCTS.filter(p => p.__surface === surface).slice(0, 12);
+    if (items.length >= 4) out.push({ surface, items });
+  }
+  res.json({ rails: out });
+});
+
+app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS.length, surfaces: SURFACE_COUNTS }));
+
+app.get('/sample/:handle', (req, res) => {
+  const p = PRODUCTS.find(x => x.handle === req.params.handle || x.sku === req.params.handle);
+  if (!p) return res.status(404).send('Not found');
+  res.redirect(302, p.product_url || `${DW_SHOPIFY}/products/${encodeURIComponent(p.handle)}#sample`);
+});
+
+app.get('/robots.txt', (req, res) => {
+  res.type('text/plain').send(`User-agent: *\nAllow: /\nSitemap: https://wallsandfabrics.com/sitemap.xml\n`);
+});
+app.get('/sitemap.xml', (req, res) => {
+  const urls = ['/'];
+  const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.map(u => `  <url><loc>https://wallsandfabrics.com${u}</loc><changefreq>weekly</changefreq></url>`).join('\n')}
+</urlset>`;
+  res.type('application/xml').send(xml);
+});
+
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[${__SITE}] listening on http://127.0.0.1:${PORT}`);
+});

← 9c75cd7 sort-skill UI: add Light→Dark / Dark→Light / Color Wheel opt  ·  back to Wallsandfabrics  ·  hamburger nav: apply canonical pattern from sister-site flee 29c57e9 →