[object Object]

← back to Designerlookbooks

Replace pagination with infinite scrolling

81dde46a48f043277deab953de458f2b99969517 · 2026-06-23 09:35:27 -0700 · Claude

- Remove prev/next pagination nav (2 occurrences)
- Add scroll sentinel at bottom
- Load next lookbook page dynamically on scroll
- Append articles incrementally to grid

Complies with infinite scrolling requirement.

Files touched

Diff

commit 81dde46a48f043277deab953de458f2b99969517
Author: Claude <claude@anthropic.com>
Date:   Tue Jun 23 09:35:27 2026 -0700

    Replace pagination with infinite scrolling
    
    - Remove prev/next pagination nav (2 occurrences)
    - Add scroll sentinel at bottom
    - Load next lookbook page dynamically on scroll
    - Append articles incrementally to grid
    
    Complies with infinite scrolling requirement.
---
 server.js     | 154 ++++++++--
 server.js.tmp | 974 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 1104 insertions(+), 24 deletions(-)

diff --git a/server.js b/server.js
index 22601b8..d1f03f1 100644
--- a/server.js
+++ b/server.js
@@ -750,18 +750,7 @@ app.get('/lookbook/:slug', (req, res) => {
           </div>
         </article>
       `).join('')}</div>`}
-      ${pages > 1 ? `<nav class="pager">
-        ${cur > 1 ? `<a href="?${new URLSearchParams({ ...req.query, page: cur - 1 }).toString()}">‹ Prev</a>` : ''}
-        ${[...Array(Math.min(pages, 7)).keys()].map(i => {
-          let p;
-          if (pages <= 7) p = i + 1;
-          else if (cur <= 4) p = i + 1;
-          else if (cur >= pages - 3) p = pages - 6 + i;
-          else p = cur - 3 + i;
-          return p === cur ? `<span class="cur">${p}</span>` : `<a href="?${new URLSearchParams({ ...req.query, page: p }).toString()}">${p}</a>`;
-        }).join('')}
-        ${cur < pages ? `<a href="?${new URLSearchParams({ ...req.query, page: cur + 1 }).toString()}">Next ›</a>` : ''}
-      </nav>` : ''}
+      ${pages > 1 ? `<div id="scroll-sentinel" style="padding:20px;text-align:center;color:var(--ink-faint);font-size:13px;"><span id="loading-status">Loading more…</span></div>` : ''}
     </main>
     <script>
       // Hydrate sort + density preferences from localStorage when the URL
@@ -788,6 +777,70 @@ app.get('/lookbook/:slug', (req, res) => {
           }
         } catch (e) {}
       })();
+
+      // Infinite scroll
+      (function(){
+        const grid = document.querySelector('.grid');
+        const sentinel = document.getElementById('scroll-sentinel');
+        if (!grid || !sentinel) return;
+
+        let currentPage = ${cur};
+        let totalPages = ${pages};
+        let isLoading = false;
+
+        const scrollObserver = new IntersectionObserver(entries => {
+          for (const e of entries) {
+            if (e.isIntersecting && currentPage < totalPages && !isLoading) {
+              loadNextPage();
+            }
+          }
+        }, { rootMargin: '400px 0px' });
+
+        scrollObserver.observe(sentinel);
+
+        function loadNextPage() {
+          if (isLoading || currentPage >= totalPages) return;
+          isLoading = true;
+          const nextPage = currentPage + 1;
+          const url = new URL(location.href);
+          url.searchParams.set('page', nextPage);
+
+          fetch(url.toString())
+            .then(r => r.text())
+            .then(html => {
+              const parser = new DOMParser();
+              const doc = parser.parseFromString(html, 'text/html');
+              const nextRows = doc.querySelectorAll('.grid > article');
+
+              if (nextRows.length === 0) {
+                currentPage = totalPages;
+                document.getElementById('loading-status').textContent = 'All lookbooks loaded';
+                sentinel.style.color = '#999';
+                return;
+              }
+
+              nextRows.forEach(row => {
+                grid.appendChild(row.cloneNode(true));
+              });
+
+              currentPage = nextPage;
+              if (currentPage >= totalPages) {
+                document.getElementById('loading-status').textContent = 'All lookbooks loaded';
+                sentinel.style.color = '#999';
+              } else {
+                document.getElementById('loading-status').textContent = 'Loading more…';
+              }
+
+              isLoading = false;
+            })
+            .catch(err => {
+              console.error('Failed to load more:', err);
+              document.getElementById('loading-status').textContent = 'Error loading more';
+              sentinel.style.color = '#d84315';
+              isLoading = false;
+            });
+        }
+      })();
     </script>
   `;
   res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
@@ -894,18 +947,7 @@ app.get('/search', (req, res) => {
           </div>
         </article>
       `).join('')}</div>`}
-      ${pages > 1 ? `<nav class="pager">
-        ${cur > 1 ? `<a href="?${new URLSearchParams({ ...req.query, page: cur - 1 }).toString()}">‹ Prev</a>` : ''}
-        ${[...Array(Math.min(pages, 7)).keys()].map(i => {
-          let p;
-          if (pages <= 7) p = i + 1;
-          else if (cur <= 4) p = i + 1;
-          else if (cur >= pages - 3) p = pages - 6 + i;
-          else p = cur - 3 + i;
-          return p === cur ? `<span class="cur">${p}</span>` : `<a href="?${new URLSearchParams({ ...req.query, page: p }).toString()}">${p}</a>`;
-        }).join('')}
-        ${cur < pages ? `<a href="?${new URLSearchParams({ ...req.query, page: cur + 1 }).toString()}">Next ›</a>` : ''}
-      </nav>` : ''}
+      ${pages > 1 ? `<div id="scroll-sentinel" style="padding:20px;text-align:center;color:var(--ink-faint);font-size:13px;"><span id="loading-status">Loading more…</span></div>` : ''}
     </main>
     <script>
       // Hydrate sort + density from localStorage when the URL doesn't
@@ -931,6 +973,70 @@ app.get('/search', (req, res) => {
           }
         } catch (e) {}
       })();
+
+      // Infinite scroll
+      (function(){
+        const grid = document.querySelector('.grid');
+        const sentinel = document.getElementById('scroll-sentinel');
+        if (!grid || !sentinel) return;
+
+        let currentPage = ${cur};
+        let totalPages = ${pages};
+        let isLoading = false;
+
+        const scrollObserver = new IntersectionObserver(entries => {
+          for (const e of entries) {
+            if (e.isIntersecting && currentPage < totalPages && !isLoading) {
+              loadNextPage();
+            }
+          }
+        }, { rootMargin: '400px 0px' });
+
+        scrollObserver.observe(sentinel);
+
+        function loadNextPage() {
+          if (isLoading || currentPage >= totalPages) return;
+          isLoading = true;
+          const nextPage = currentPage + 1;
+          const url = new URL(location.href);
+          url.searchParams.set('page', nextPage);
+
+          fetch(url.toString())
+            .then(r => r.text())
+            .then(html => {
+              const parser = new DOMParser();
+              const doc = parser.parseFromString(html, 'text/html');
+              const nextRows = doc.querySelectorAll('.grid > article');
+
+              if (nextRows.length === 0) {
+                currentPage = totalPages;
+                document.getElementById('loading-status').textContent = 'All lookbooks loaded';
+                sentinel.style.color = '#999';
+                return;
+              }
+
+              nextRows.forEach(row => {
+                grid.appendChild(row.cloneNode(true));
+              });
+
+              currentPage = nextPage;
+              if (currentPage >= totalPages) {
+                document.getElementById('loading-status').textContent = 'All lookbooks loaded';
+                sentinel.style.color = '#999';
+              } else {
+                document.getElementById('loading-status').textContent = 'Loading more…';
+              }
+
+              isLoading = false;
+            })
+            .catch(err => {
+              console.error('Failed to load more:', err);
+              document.getElementById('loading-status').textContent = 'Error loading more';
+              sentinel.style.color = '#d84315';
+              isLoading = false;
+            });
+        }
+      })();
     </script>
   `;
   res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
diff --git a/server.js.tmp b/server.js.tmp
new file mode 100644
index 0000000..22601b8
--- /dev/null
+++ b/server.js.tmp
@@ -0,0 +1,974 @@
+/**
+ * designerlookbooks.com — visual lookbook hub.
+ *
+ * Groups the DW-Shopify catalog (8,227 active SKUs, snapshot under
+ * data/products.json) into aesthetic clusters via title/type keyword
+ * rules, then renders each cluster as a magazine-style lookbook with
+ * hero, masthead, and editorial grid. Each cluster page carries the
+ * mandated sort + density slider, dark/light toggle, GA4 gtag, and a
+ * compliant footer.
+ *
+ * Memo-sample CTAs hand the buyer back to the canonical DW product
+ * page on Shopify (#sample anchor), so all order-flow + payment lives
+ * upstream. This site is a pure storefront/discovery surface.
+ */
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+
+const PORT = process.env.PORT || 9923;
+const BIND = process.env.BIND || '127.0.0.1';
+const GA_ID = 'G-SDQQKBE3GY';
+const DW_SHOPIFY = 'https://designerwallcoverings.com';
+const DATA = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+
+const app = express();
+
+
+app.get(['/favicon.ico','/favicon.svg'], (req, res) => res.sendFile(require('path').join(__dirname, 'public', req.path.slice(1))));
+app.use((_req, res, next) => {
+  res.set('X-Content-Type-Options', 'nosniff');
+  res.set('X-Frame-Options', 'SAMEORIGIN');
+  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+  // Storefront is regenerated from a static JSON snapshot, but the
+  // memory rule says no-store for HTML behind CF — let nginx/CF set
+  // that header in production. We keep an internal short cache here
+  // so dev reloads stay snappy without overriding the prod policy.
+  res.set('Cache-Control', 'public, max-age=300');
+  next();
+});
+
+// Snapshot/backup files should never be reachable from any static path.
+// Even though public/ is currently empty, sweep up any future
+// `.bak` / `.bak.*` / `.pre-*` / `.orig` filenames before they can be
+// served. Standing rule across the DW fleet.
+app.use((req, res, next) => {
+  if (/\.(bak|orig)(\.|$)|\.pre-/i.test(req.path)) return res.status(404).end();
+  next();
+});
+app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
+
+// ---------------------------------------------------------------------------
+// Aesthetic clusters
+// ---------------------------------------------------------------------------
+// Each cluster carries a slug, an editorial title + tagline, an accent
+// color used for the magazine-style cover, and a rule function that
+// classifies products. Rules read the product title + product_type
+// (case-insensitive substring matches). A product can appear in
+// multiple clusters; we de-duplicate inside each cluster but allow
+// cross-cluster overlap so the lookbooks each feel curated.
+const CLUSTERS = [
+  {
+    slug: 'soft-modernism',
+    title: 'Soft Modernism',
+    tagline: 'Plaster textures, paper-stocks, and quiet linens for rooms that breathe.',
+    accent: '#a89878',
+    keywords: ['linen', 'plaster', 'paperweave', 'paper weave', 'natural', 'grasscloth', 'sisal', 'jute', 'raffia', 'cork'],
+  },
+  {
+    slug: 'maximalist-flora',
+    title: 'Maximalist Flora',
+    tagline: 'Botanicals turned up loud — chinoiserie, garden murals, hothouse motifs.',
+    accent: '#5a7a3c',
+    keywords: ['floral', 'flower', 'chinoiserie', 'garden', 'botanical', 'foliage', 'leaf', 'palm', 'fern', 'orchid', 'peony', 'rose', 'bloom', 'aviary', 'bird'],
+  },
+  {
+    slug: 'architectural-line',
+    title: 'Architectural Line',
+    tagline: 'Stripes, lattices, herringbones — geometry as the only ornament needed.',
+    accent: '#1c2638',
+    keywords: ['stripe', 'pinstripe', 'lattice', 'herringbone', 'chevron', 'grid', 'geometric', 'fret', 'trellis', 'tile', 'diamond', 'hexagon', 'plaid', 'check'],
+  },
+  {
+    slug: 'global-craft',
+    title: 'Global Craft',
+    tagline: 'Block-printed, ikat, suzani — handwork translated to the wall.',
+    accent: '#a04015',
+    keywords: ['ikat', 'suzani', 'kilim', 'paisley', 'medallion', 'moroccan', 'persian', 'block print', 'block-print', 'tribal', 'batik', 'kasuri', 'shibori', 'mudcloth'],
+  },
+  {
+    slug: 'metallic-light',
+    title: 'Metallic Light',
+    tagline: 'Mica, glass-bead, gold-leaf, and silver grounds that catch every lumen.',
+    accent: '#8a7228',
+    keywords: ['gold', 'silver', 'metallic', 'mica', 'foil', 'glass bead', 'glass-bead', 'glassbeaded', 'gilded', 'pearlescent', 'shimmer', 'bronze', 'copper', 'platinum'],
+  },
+  {
+    slug: 'velvet-noir',
+    title: 'Velvet Noir',
+    tagline: 'Damasks, brocades, and saturated jewel tones for the after-dark room.',
+    accent: '#3a1530',
+    keywords: ['damask', 'brocade', 'velvet', 'jacquard', 'baroque', 'fleur', 'scroll', 'arabesque', 'noir', 'midnight', 'oxblood', 'aubergine', 'plum', 'wine', 'merlot'],
+  },
+  {
+    slug: 'coastal-lab',
+    title: 'Coastal Lab',
+    tagline: 'Sea-glass blues, sand, salt-bleached neutrals — California-modern light.',
+    accent: '#3d6e80',
+    keywords: ['ocean', 'sea', 'coral', 'wave', 'tide', 'seafoam', 'sand', 'shore', 'beach', 'aqua', 'turquoise', 'aegean', 'sky', 'driftwood', 'shell'],
+  },
+  {
+    slug: 'monochrome-study',
+    title: 'Monochrome Study',
+    tagline: 'Grayscale, black-on-white, and ink-on-paper — discipline in a single tone.',
+    accent: '#222222',
+    keywords: ['black', 'white', 'ivory', 'cream', 'charcoal', 'graphite', 'ash', 'smoke', 'monochrome', 'noir blanc'],
+  },
+];
+
+const NORM_TYPE = (t) => {
+  if (!t) return 'Other';
+  const x = t.replace(/s$/i, '').trim();
+  if (/wallcovering/i.test(x)) return 'Wallcovering';
+  if (/natural/i.test(x)) return 'Natural';
+  if (/drapery/i.test(x)) return 'Drapery';
+  if (/fabric/i.test(x)) return 'Fabric';
+  return x;
+};
+
+function matchesCluster(product, cluster) {
+  const hay = `${product.title || ''} ${product.product_type || ''}`.toLowerCase();
+  return cluster.keywords.some(k => hay.includes(k));
+}
+
+// Pre-compute cluster membership so route handlers don't re-scan 8k
+// rows on every request. Each cluster owns a snapshot of products
+// that pass its keyword rule, in the order the snapshot was produced
+// (which preserves the upstream "newest first" ordering).
+const CLUSTER_INDEX = Object.fromEntries(
+  CLUSTERS.map(c => [c.slug, { ...c, products: DATA.filter(p => matchesCluster(p, c)) }])
+);
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+
+function paginate(arr, page, perPage = 60) {
+  const total = arr.length;
+  const pages = Math.max(1, Math.ceil(total / perPage));
+  const p = Math.min(Math.max(1, page), pages);
+  return { items: arr.slice((p - 1) * perPage, p * perPage), page: p, pages, total };
+}
+
+function shopifyUrl(handle) { return handle ? `${DW_SHOPIFY}/products/${handle}` : DW_SHOPIFY; }
+function memoSampleUrl(handle) { return handle ? `${DW_SHOPIFY}/products/${handle}#sample` : DW_SHOPIFY; }
+
+// Sort modes — implemented server-side so deep-linked URLs reproduce.
+// "Newest" = snapshot order; "Color"/"Style" sort by tag-bucket name to
+// pull similar hues together; the rest are straight string/number sorts.
+const COLOR_BUCKETS = [
+  ['black', /\b(black|noir|onyx|jet|midnight)\b/i],
+  ['white', /\b(white|ivory|cream|alabaster|chalk)\b/i],
+  ['gray',  /\b(gray|grey|charcoal|graphite|ash|smoke|silver)\b/i],
+  ['red',   /\b(red|crimson|scarlet|ruby|brick|oxblood|cinnabar)\b/i],
+  ['orange',/\b(orange|terracotta|copper|rust|amber|tangerine)\b/i],
+  ['yellow',/\b(yellow|gold|ochre|saffron|honey|mustard|citrine)\b/i],
+  ['green', /\b(green|sage|olive|moss|emerald|jade|fern|forest|seafoam)\b/i],
+  ['blue',  /\b(blue|navy|cobalt|indigo|sapphire|aqua|teal|turquoise|aegean|sky)\b/i],
+  ['purple',/\b(purple|violet|lavender|plum|aubergine|mauve|lilac)\b/i],
+  ['pink',  /\b(pink|rose|blush|coral|fuchsia|magenta)\b/i],
+  ['brown', /\b(brown|tan|taupe|sand|khaki|camel|chocolate|caramel|sepia)\b/i],
+];
+function colorBucket(p) {
+  const hay = `${p.title || ''}`.toLowerCase();
+  for (const [name, rx] of COLOR_BUCKETS) if (rx.test(hay)) return name;
+  return 'zz';
+}
+const STYLE_BUCKETS = [
+  ['Floral',     /\b(floral|flower|botanical|peony|rose|garden|aviary|bird|chinoiserie)\b/i],
+  ['Geometric',  /\b(stripe|geometric|lattice|herringbone|chevron|grid|fret|trellis|diamond|hex)\b/i],
+  ['Damask',     /\b(damask|brocade|fleur|scroll|arabesque|baroque|jacquard)\b/i],
+  ['Texture',    /\b(linen|grasscloth|sisal|cork|paperweave|paper weave|plaster|raffia|jute)\b/i],
+  ['Metallic',   /\b(metallic|gold|silver|mica|foil|gilded|shimmer|bead)\b/i],
+  ['Global',     /\b(ikat|suzani|kilim|paisley|medallion|moroccan|persian|block.?print|tribal)\b/i],
+];
+function styleBucket(p) {
+  const hay = `${p.title || ''} ${p.product_type || ''}`.toLowerCase();
+  for (const [name, rx] of STYLE_BUCKETS) if (rx.test(hay)) return name;
+  return 'Other';
+}
+function sortProducts(arr, mode) {
+  const a = arr.slice();
+  switch (mode) {
+    case 'color':    return a.sort((x, y) => colorBucket(x).localeCompare(colorBucket(y)) || (x.title || '').localeCompare(y.title || ''));
+    case 'style':    return a.sort((x, y) => styleBucket(x).localeCompare(styleBucket(y)) || (x.title || '').localeCompare(y.title || ''));
+    case 'sku':      return a.sort((x, y) => (x.dw_sku || x.handle || '').localeCompare(y.dw_sku || y.handle || ''));
+    case 'title':    return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
+    case 'price-up':   return a.sort((x, y) => (parseFloat(x.retail_price) || Infinity) - (parseFloat(y.retail_price) || Infinity));
+    case 'price-down': return a.sort((x, y) => (parseFloat(y.retail_price) || -Infinity) - (parseFloat(x.retail_price) || -Infinity));
+    default: return a;
+  }
+}
+
+function pickPreviewImages(products, n = 4) {
+  const out = [];
+  for (const p of products) {
+    if (p.image_url && !out.includes(p.image_url)) out.push(p.image_url);
+    if (out.length >= n) break;
+  }
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// layout (vanilla HTML — no template engine, easier to audit + ship)
+// ---------------------------------------------------------------------------
+function layout({ title, description, body, og }) {
+  const pageTitle = `${title} — Designer Lookbooks`;
+  const desc = description || 'A visual lookbook of modern wallcovering aesthetics — curated clusters of texture, color, and pattern from the Designer Wallcoverings catalog.';
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(pageTitle)}</title>
+<meta name="description" content="${esc(desc)}">
+<meta property="og:title" content="${esc(pageTitle)}">
+<meta property="og:description" content="${esc(desc)}">
+<meta property="og:type" content="website">
+${og && og.image ? `<meta property="og:image" content="${esc(og.image)}">` : ''}
+<!-- Inline anti-flash theme bootstrap — sets the data-theme before paint. -->
+<script>
+  (function() {
+    try {
+      var saved = localStorage.getItem('dl-theme');
+      var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+      var theme = saved || (prefersDark ? 'dark' : 'light');
+      document.documentElement.setAttribute('data-theme', theme);
+    } catch (e) {}
+  })();
+</script>
+<!-- GA4 gtag (auto) -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=${GA_ID}"></script>
+<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA_ID}');</script>
+<!-- /GA4 gtag -->
+<link rel="preconnect" href="https://cdn.shopify.com">
+<style>
+:root[data-theme="light"] {
+  --bg:#f8f5ee; --fg:#181614; --muted:#7a7167; --rule:#d8cec0; --card:#ffffff;
+  --accent:#7d3c00; --accent-soft:#b8865c; --shadow:0 6px 24px rgba(0,0,0,.06);
+}
+:root[data-theme="dark"] {
+  --bg:#13110f; --fg:#ebe6dc; --muted:#8e857a; --rule:#2c2823; --card:#1c1916;
+  --accent:#cf964a; --accent-soft:#7d6238; --shadow:0 6px 24px rgba(0,0,0,.5);
+}
+* { box-sizing: border-box; margin: 0; padding: 0; }
+html, body {
+  font-family: -apple-system, 'Inter', 'Segoe UI', system-ui, sans-serif;
+  background: var(--bg); color: var(--fg); line-height: 1.55;
+  -webkit-font-smoothing: antialiased;
+}
+a { color: inherit; text-decoration: none; }
+img { max-width: 100%; display: block; }
+
+/* ---- masthead ---- */
+header.mast {
+  border-bottom: 1px solid var(--rule);
+  background: var(--bg);
+  position: sticky; top: 0; z-index: 50;
+  backdrop-filter: saturate(140%) blur(8px);
+}
+.mast-inner {
+  max-width: 1480px; margin: 0 auto; padding: 18px 32px;
+  display: flex; align-items: center; gap: 32px;
+}
+.brand {
+  font-family: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
+  font-weight: 600; font-size: 28px; letter-spacing: .02em;
+  white-space: nowrap;
+}
+.brand small {
+  display: block; font-family: inherit; font-size: 10px; letter-spacing: .26em;
+  font-weight: 500; color: var(--muted); margin-top: 2px;
+  font-family: -apple-system, 'Inter', sans-serif;
+}
+nav.types {
+  display: flex; gap: 18px; flex: 1; flex-wrap: wrap; align-items: center;
+}
+nav.types a {
+  font-size: 12px; font-weight: 500; color: var(--muted); padding: 4px 0;
+  border-bottom: 2px solid transparent; letter-spacing: .08em; text-transform: uppercase;
+}
+nav.types a:hover { color: var(--fg); }
+nav.types a.active { color: var(--accent); border-bottom-color: var(--accent); }
+form.search {
+  display: flex; gap: 6px; align-items: center;
+}
+form.search input {
+  border: 1px solid var(--rule); background: var(--card); padding: 8px 12px;
+  border-radius: 0; min-width: 220px; font: inherit; font-size: 13px;
+  outline: none; color: var(--fg);
+}
+form.search input:focus { border-color: var(--accent); }
+form.search button {
+  border: 1px solid var(--accent); background: var(--accent); color: #fff;
+  padding: 8px 16px; font: inherit; font-size: 12px; text-transform: uppercase;
+  letter-spacing: .1em; font-weight: 500; cursor: pointer;
+}
+button.theme-toggle {
+  border: 1px solid var(--rule); background: var(--card); color: var(--fg);
+  width: 36px; height: 36px; cursor: pointer; font-size: 16px;
+  display: inline-flex; align-items: center; justify-content: center;
+}
+button.theme-toggle:hover { border-color: var(--accent); }
+
+/* ---- magazine cover (home) ---- */
+.cover {
+  max-width: 1480px; margin: 0 auto; padding: 56px 32px 24px;
+}
+.cover h1 {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: clamp(40px, 8vw, 96px); font-weight: 500; line-height: .98;
+  letter-spacing: -.01em; max-width: 980px;
+}
+.cover .lede {
+  margin-top: 24px; max-width: 740px; font-size: 17px; color: var(--muted);
+}
+.cover .issue {
+  font-size: 11px; letter-spacing: .3em; text-transform: uppercase;
+  color: var(--muted); margin-bottom: 18px;
+}
+.divider {
+  border: 0; border-top: 1px solid var(--rule);
+  max-width: 1480px; margin: 36px auto 0; padding: 0 32px;
+}
+
+/* ---- editor's letter (home) ---- */
+.letter {
+  max-width: 1480px; margin: 0 auto; padding: 56px 32px 24px;
+}
+.letter-inner {
+  display: grid; grid-template-columns: 280px 1fr; gap: 56px;
+  border-top: 1px solid var(--rule); padding-top: 36px;
+}
+@media (max-width: 880px) {
+  .letter-inner { grid-template-columns: 1fr; gap: 18px; }
+}
+.letter-meta .kicker {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 26px; font-weight: 500; line-height: 1.1;
+  color: var(--accent); margin-bottom: 6px;
+}
+.letter-meta .byline {
+  font-size: 11px; letter-spacing: .22em; text-transform: uppercase;
+  color: var(--muted);
+}
+.letter-body p {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 20px; line-height: 1.55; color: var(--fg);
+  max-width: 640px; margin-bottom: 18px;
+}
+.letter-body p:first-child::first-letter {
+  font-size: 64px; line-height: .9; float: left;
+  padding: 6px 12px 0 0; font-weight: 500; color: var(--accent);
+}
+
+/* ---- cluster index (home) ---- */
+.spread {
+  max-width: 1480px; margin: 0 auto; padding: 48px 32px 80px;
+  display: grid; gap: 48px;
+  grid-template-columns: repeat(2, 1fr);
+}
+@media (max-width: 980px) { .spread { grid-template-columns: 1fr; } }
+.feature {
+  display: grid; grid-template-rows: auto auto auto;
+  border-top: 1px solid var(--rule); padding-top: 24px;
+}
+.feature .num {
+  font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
+  color: var(--muted); margin-bottom: 8px; font-variant-numeric: tabular-nums;
+}
+.feature h2 {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-weight: 500; font-size: 38px; line-height: 1.06; margin-bottom: 8px;
+}
+.feature .accent-bar { width: 48px; height: 3px; background: var(--accent-color, var(--accent)); margin-bottom: 14px; }
+.feature .tag { color: var(--muted); font-size: 14px; max-width: 520px; margin-bottom: 18px; }
+.feature .preview {
+  display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 16px;
+}
+.feature .preview a {
+  aspect-ratio: 1/1; background: var(--rule); overflow: hidden; display: block;
+}
+.feature .preview img {
+  width: 100%; height: 100%; object-fit: cover;
+  transition: transform .4s ease;
+}
+.feature .preview a:hover img { transform: scale(1.06); }
+.feature .cta {
+  display: inline-flex; align-items: center; gap: 8px; font-size: 12px;
+  letter-spacing: .14em; text-transform: uppercase; color: var(--accent);
+  font-weight: 600; border-bottom: 1px solid var(--accent); padding-bottom: 4px;
+  align-self: start;
+}
+.feature .cta:hover { color: var(--fg); border-color: var(--fg); }
+
+/* ---- cluster page ---- */
+.cluster-hero {
+  max-width: 1480px; margin: 0 auto; padding: 56px 32px 24px;
+  display: grid; grid-template-columns: 1.05fr 1fr; gap: 48px;
+}
+@media (max-width: 980px) { .cluster-hero { grid-template-columns: 1fr; } }
+.cluster-hero .copy h1 {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: clamp(40px, 6vw, 72px); font-weight: 500; line-height: 1.02;
+  letter-spacing: -.01em;
+}
+.cluster-hero .copy .crumb {
+  font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
+  color: var(--muted); margin-bottom: 16px;
+}
+.cluster-hero .copy .accent-bar { width: 56px; height: 4px; background: var(--accent-color); margin: 22px 0; }
+.cluster-hero .copy p { color: var(--muted); font-size: 17px; max-width: 540px; }
+.cluster-hero .copy .stat { margin-top: 28px; font-size: 13px; color: var(--muted); letter-spacing: .06em; text-transform: uppercase; }
+.cluster-hero .collage {
+  display: grid; grid-template-columns: 2fr 1fr 1fr; grid-template-rows: 1fr 1fr;
+  gap: 4px; aspect-ratio: 4/3;
+}
+.cluster-hero .collage a {
+  background: var(--rule); overflow: hidden; display: block;
+}
+.cluster-hero .collage a:first-child { grid-row: 1 / 3; }
+.cluster-hero .collage img { width: 100%; height: 100%; object-fit: cover; }
+
+/* ---- toolbar (sort + density) ---- */
+.toolbar {
+  max-width: 1480px; margin: 0 auto; padding: 24px 32px;
+  border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
+  display: flex; gap: 24px; align-items: center; flex-wrap: wrap;
+  background: var(--bg); position: sticky; top: 73px; z-index: 40;
+}
+.toolbar label {
+  font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
+  color: var(--muted); font-weight: 500;
+}
+.toolbar select, .toolbar input[type="range"] {
+  font: inherit; border: 1px solid var(--rule); background: var(--card);
+  color: var(--fg); padding: 6px 10px;
+}
+.toolbar select { font-size: 13px; }
+.toolbar .group { display: flex; align-items: center; gap: 10px; }
+.toolbar .meta { margin-left: auto; font-size: 12px; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; }
+
+/* ---- product grid ---- */
+main.grid-wrap { max-width: 1480px; margin: 0 auto; padding: 32px; }
+.grid {
+  display: grid; gap: 22px;
+  grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 240px), 1fr));
+}
+.card {
+  background: var(--card); border: 1px solid var(--rule);
+  transition: box-shadow .15s, transform .12s;
+}
+.card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
+.card a.thumb { display: block; aspect-ratio: 1/1; background: var(--rule); overflow: hidden; }
+.card a.thumb img { width: 100%; height: 100%; object-fit: cover; }
+.card .info { padding: 14px 14px 14px; }
+.card .info .t {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 17px; line-height: 1.22; margin-bottom: 4px;
+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+.card .info .sub { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
+.card .actions { display: flex; border-top: 1px solid var(--rule); }
+.card .actions a { flex: 1; padding: 10px 12px; text-align: center; font-size: 11px;
+  text-transform: uppercase; letter-spacing: .08em; font-weight: 500; color: var(--muted); transition: all .12s; }
+.card .actions a + a { border-left: 1px solid var(--rule); }
+.card .actions a:hover { background: var(--bg); color: var(--fg); }
+.card .actions a.cta { color: var(--accent); font-weight: 600; }
+.card .actions a.cta:hover { background: var(--accent); color: #fff; }
+.empty { padding: 80px 20px; text-align: center; color: var(--muted); }
+.pager { display: flex; justify-content: center; gap: 8px; margin-top: 48px; }
+.pager a, .pager span { padding: 8px 14px; border: 1px solid var(--rule); background: var(--card); font-size: 13px; min-width: 44px; text-align: center; }
+.pager a:hover { border-color: var(--accent); color: var(--accent); }
+.pager .cur { background: var(--accent); color: #fff; border-color: var(--accent); }
+
+/* ---- product detail ---- */
+.detail { display: grid; grid-template-columns: 1.2fr 1fr; gap: 48px; padding: 48px 32px;
+  max-width: 1480px; margin: 0 auto; }
+.detail .hero { background: var(--rule); aspect-ratio: 1/1; }
+.detail .hero img { width: 100%; height: 100%; object-fit: cover; }
+.detail h1 { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 48px; font-weight: 500; line-height: 1.05; margin-bottom: 8px; }
+.detail .crumb { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin-bottom: 18px; }
+.detail .pt { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin-bottom: 32px; }
+.detail .row { display: flex; gap: 24px; padding: 16px 0; border-bottom: 1px solid var(--rule); font-size: 13px; }
+.detail .row .k { flex: 0 0 140px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; font-weight: 500; }
+.detail .actions { display: flex; gap: 12px; margin-top: 32px; flex-wrap: wrap; }
+.detail .btn { padding: 14px 28px; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; font-weight: 600; border: 1px solid var(--fg); display: inline-block; }
+.detail .btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.detail .btn-primary:hover { background: #5e2d00; }
+.detail .btn-secondary:hover { background: var(--fg); color: var(--bg); }
+@media (max-width: 780px) { .detail { grid-template-columns: 1fr; padding: 24px; } }
+
+footer.fp {
+  border-top: 1px solid var(--rule);
+  background: var(--card); padding: 56px 32px;
+  display: grid; gap: 28px; grid-template-columns: 2fr 1fr 1fr 1fr;
+}
+@media (max-width: 880px) { footer.fp { grid-template-columns: 1fr 1fr; } }
+footer.fp h4 { font-size: 11px; letter-spacing: .22em; text-transform: uppercase; color: var(--muted); margin-bottom: 12px; font-weight: 500; }
+footer.fp p, footer.fp a { font-size: 13px; color: var(--fg); line-height: 1.7; }
+footer.fp .col-brand .name { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 22px; font-weight: 500; margin-bottom: 8px; }
+footer.fp ul { list-style: none; }
+footer.fp ul li { margin-bottom: 6px; }
+footer.fp .legal {
+  grid-column: 1 / -1; padding-top: 28px; border-top: 1px solid var(--rule);
+  font-size: 11px; color: var(--muted); letter-spacing: .04em;
+  display: flex; gap: 24px; flex-wrap: wrap;
+}
+</style>
+</head>
+<body>
+<header class="mast">
+  <div class="mast-inner">
+    <a href="/" class="brand">Designer Lookbooks<small>EDITORIAL · WALLCOVERINGS</small></a>
+    <nav class="types">
+      ${CLUSTERS.slice(0, 6).map(c => `<a href="/lookbook/${c.slug}">${esc(c.title)}</a>`).join('')}
+      <a href="/lookbooks">All Lookbooks</a>
+    </nav>
+    <form class="search" method="GET" action="/search">
+      <input name="q" placeholder="Search 8,200+ wallcoverings…" aria-label="Search">
+      <button>Go</button>
+    </form>
+    <button class="theme-toggle" onclick="(function(){var r=document.documentElement;var t=r.getAttribute('data-theme')==='dark'?'light':'dark';r.setAttribute('data-theme',t);try{localStorage.setItem('dl-theme',t);}catch(e){}})()" aria-label="Toggle theme" title="Toggle theme">
+      <span style="display:none">[t]</span>
+      <span class="theme-icon-light" style="display:inline">☀</span>
+      <span class="theme-icon-dark"  style="display:none">☾</span>
+    </button>
+  </div>
+</header>
+<style>
+  :root[data-theme="dark"] .theme-icon-light { display:none !important; }
+  :root[data-theme="dark"] .theme-icon-dark  { display:inline !important; }
+</style>
+${body}
+<footer class="fp">
+  <div class="col-brand">
+    <div class="name">Designer Lookbooks</div>
+    <p>An editorial visual hub for wallcoverings, organized by aesthetic cluster. Curated from the Designer Wallcoverings catalog.</p>
+  </div>
+  <div>
+    <h4>Lookbooks</h4>
+    <ul>${CLUSTERS.slice(0, 4).map(c => `<li><a href="/lookbook/${c.slug}">${esc(c.title)}</a></li>`).join('')}</ul>
+  </div>
+  <div>
+    <h4>More</h4>
+    <ul>
+      <li><a href="/lookbooks">All Lookbooks</a></li>
+      <li><a href="${DW_SHOPIFY}" target="_blank" rel="noopener noreferrer">Shop on DW</a></li>
+      <li><a href="/about">About</a></li>
+    </ul>
+  </div>
+  <div>
+    <h4>Contact</h4>
+    <p>
+      <a href="mailto:info@designerlookbooks.com">info@designerlookbooks.com</a><br>
+      <a href="tel:+18883734564">888-373-4564</a><br>
+      Designer Wallcoverings<br>
+      15442 Ventura Bl #102<br>
+      Sherman Oaks CA 91403
+    </p>
+  </div>
+  <div class="legal">
+    <span>© ${new Date().getFullYear()} Designer Wallcoverings. All rights reserved.</span>
+    <span>Memo samples are always free.</span>
+  </div>
+</footer>
+</body>
+</html>`;
+}
+
+// ---------------------------------------------------------------------------
+// routes
+// ---------------------------------------------------------------------------
+app.get('/', (_req, res) => {
+  const features = CLUSTERS.map((c, i) => {
+    const cluster = CLUSTER_INDEX[c.slug];
+    const previews = pickPreviewImages(cluster.products, 4);
+    return `
+      <article class="feature" style="--accent-color:${c.accent}">
+        <div class="num">No. ${String(i + 1).padStart(2, '0')} · ${cluster.products.length} works</div>
+        <h2>${esc(c.title)}</h2>
+        <div class="accent-bar"></div>
+        <p class="tag">${esc(c.tagline)}</p>
+        <div class="preview">
+          ${previews.map(u => `<a href="/lookbook/${c.slug}"><img loading="lazy" src="${esc(u)}" alt=""></a>`).join('')}
+        </div>
+        <a class="cta" href="/lookbook/${c.slug}">Open Lookbook →</a>
+      </article>
+    `;
+  }).join('');
+
+  const body = `
+    <section class="cover">
+      <div class="issue">Issue 01 · Spring 2026</div>
+      <h1>Wallpaper, organized the way <em>designers</em> actually shop it &mdash; <span style="color:var(--accent)">by mood.</span></h1>
+      <p class="lede">
+        Eight aesthetic clusters drawn from over eight thousand wallcoverings.
+        Skip the SKU lists; open a lookbook, read the spread, request memo
+        samples free.
+      </p>
+    </section>
+    <hr class="divider">
+    <section class="letter">
+      <div class="letter-inner">
+        <div class="letter-meta">
+          <div class="kicker">Editor's Letter</div>
+          <div class="byline">Spring 2026 &middot; Designer Lookbooks</div>
+        </div>
+        <div class="letter-body">
+          <p>
+            Most wallpaper catalogs are organized for the warehouse &mdash; by
+            vendor, by SKU, by collection name no one outside the trade
+            recognizes. That's fine if you already know what you want. It is
+            not how a room actually gets imagined.
+          </p>
+          <p>
+            Designer Lookbooks reorganizes the same eight thousand patterns
+            around the way clients describe a brief: "soft and modern," "loud
+            florals," "lots of metallic light," "something a little noir."
+            Each cluster reads like a magazine feature &mdash; a hero, a
+            tagline, an editorial spread &mdash; so you can browse the
+            <em>feeling</em> first and the specs second. Memo samples are
+            always free.
+          </p>
+        </div>
+      </div>
+    </section>
+    <hr class="divider">
+    <section class="spread">
+      ${features}
+    </section>
+  `;
+  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
+    title: 'Visual Lookbooks for Wallcoverings',
+    description: 'Eight aesthetic clusters drawn from 8,000+ wallcoverings — a magazine-style index of texture, color, and pattern. Free memo samples.',
+    body,
+  }));
+});
+
+app.get('/lookbooks', (_req, res) => {
+  const cards = CLUSTERS.map((c, i) => {
+    const cluster = CLUSTER_INDEX[c.slug];
+    const previews = pickPreviewImages(cluster.products, 4);
+    return `
+      <article class="feature" style="--accent-color:${c.accent}">
+        <div class="num">No. ${String(i + 1).padStart(2, '0')} · ${cluster.products.length} works</div>
+        <h2>${esc(c.title)}</h2>
+        <div class="accent-bar"></div>
+        <p class="tag">${esc(c.tagline)}</p>
+        <div class="preview">
+          ${previews.map(u => `<a href="/lookbook/${c.slug}"><img loading="lazy" src="${esc(u)}" alt=""></a>`).join('')}
+        </div>
+        <a class="cta" href="/lookbook/${c.slug}">Open Lookbook →</a>
+      </article>
+    `;
+  }).join('');
+
+  const body = `
+    <section class="cover">
+      <div class="issue">Index · All Lookbooks</div>
+      <h1>The full directory.</h1>
+      <p class="lede">Every aesthetic cluster, side by side.</p>
+    </section>
+    <hr class="divider">
+    <section class="spread">${cards}</section>
+  `;
+  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
+    title: 'All Lookbooks',
+    body,
+  }));
+});
+
+app.get('/lookbook/:slug', (req, res) => {
+  const cluster = CLUSTER_INDEX[req.params.slug];
+  if (!cluster) return res.status(404).set('Content-Type', 'text/html').send(layout({
+    title: 'Lookbook not found',
+    body: '<main class="grid-wrap"><div class="empty">That lookbook does not exist. <a href="/lookbooks">See all lookbooks →</a></div></main>'
+  }));
+
+  const sort = (req.query.sort || 'newest').toString();
+  const cardMin = parseInt(req.query.cm) || 240;
+  const page = parseInt(req.query.page) || 1;
+
+  const sorted = sortProducts(cluster.products, sort);
+  const { items, page: cur, pages, total } = paginate(sorted, page, 60);
+
+  const collage = pickPreviewImages(cluster.products, 5);
+
+  const body = `
+    <section class="cluster-hero" style="--accent-color:${cluster.accent}">
+      <div class="copy">
+        <div class="crumb"><a href="/">Designer Lookbooks</a> / Lookbook</div>
+        <h1>${esc(cluster.title)}</h1>
+        <div class="accent-bar"></div>
+        <p>${esc(cluster.tagline)}</p>
+        <div class="stat">${cluster.products.length.toLocaleString()} works · ${new Set(cluster.products.map(p => NORM_TYPE(p.product_type))).size} categories</div>
+      </div>
+      <div class="collage">
+        ${collage.slice(0, 5).map(u => `<a href="#grid"><img loading="lazy" src="${esc(u)}" alt=""></a>`).join('')}
+      </div>
+    </section>
+    <div class="toolbar" id="grid">
+      <div class="group">
+        <label for="sort">Sort</label>
+        <select id="sort" onchange="(function(s){try{localStorage.setItem('dl-sort',s.value);}catch(e){}var u=new URL(location);u.searchParams.set('sort',s.value);u.searchParams.delete('page');location=u.toString();})(this)">
+          ${[
+            ['newest', 'Newest'],
+            ['color', 'Color'],
+            ['style', 'Style'],
+            ['sku', 'SKU A→Z'],
+            ['title', 'Title A→Z'],
+            ['price-up', 'Price ↑'],
+            ['price-down', 'Price ↓'],
+          ].map(([v, label]) => `<option value="${v}" ${sort === v ? 'selected' : ''}>${label}</option>`).join('')}
+        </select>
+      </div>
+      <div class="group">
+        <label for="cm">Density</label>
+        <input type="range" id="cm" min="160" max="420" step="20" value="${cardMin}"
+          oninput="document.documentElement.style.setProperty('--card-min', this.value+'px'); try{localStorage.setItem('dl-cm', this.value);}catch(e){}"
+          onchange="(function(v){var u=new URL(location);u.searchParams.set('cm',v);location=u.toString();})(this.value)">
+      </div>
+      <div class="meta">${total.toLocaleString()} works · page ${cur}/${pages}</div>
+    </div>
+    <main class="grid-wrap">
+      ${items.length === 0 ? '<div class="empty">No products in this lookbook yet.</div>' : `<div class="grid" style="--card-min:${cardMin}px">${items.map(p => `
+        <article class="card">
+          <a class="thumb" href="/p/${esc(p.handle || '')}">
+            ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}
+          </a>
+          <div class="info">
+            <a href="/p/${esc(p.handle || '')}">
+              <div class="t">${esc(p.title)}</div>
+              <div class="sub">${esc(p.dw_sku || '')} · ${esc(NORM_TYPE(p.product_type))}</div>
+            </a>
+          </div>
+          <div class="actions">
+            <a href="/p/${esc(p.handle || '')}">View</a>
+            <a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo</a>
+          </div>
+        </article>
+      `).join('')}</div>`}
+      ${pages > 1 ? `<nav class="pager">
+        ${cur > 1 ? `<a href="?${new URLSearchParams({ ...req.query, page: cur - 1 }).toString()}">‹ Prev</a>` : ''}
+        ${[...Array(Math.min(pages, 7)).keys()].map(i => {
+          let p;
+          if (pages <= 7) p = i + 1;
+          else if (cur <= 4) p = i + 1;
+          else if (cur >= pages - 3) p = pages - 6 + i;
+          else p = cur - 3 + i;
+          return p === cur ? `<span class="cur">${p}</span>` : `<a href="?${new URLSearchParams({ ...req.query, page: p }).toString()}">${p}</a>`;
+        }).join('')}
+        ${cur < pages ? `<a href="?${new URLSearchParams({ ...req.query, page: cur + 1 }).toString()}">Next ›</a>` : ''}
+      </nav>` : ''}
+    </main>
+    <script>
+      // Hydrate sort + density preferences from localStorage when the URL
+      // doesn't already specify them. Sort needs a server round-trip
+      // because the grid is server-rendered — redirect once with the
+      // saved value so the page renders in the user's chosen order.
+      (function() {
+        try {
+          var url = new URL(location);
+          var qs = url.searchParams;
+          var savedSort = localStorage.getItem('dl-sort');
+          var validSorts = ['newest','color','style','sku','title','price-up','price-down'];
+          if (savedSort && validSorts.indexOf(savedSort) >= 0 && !qs.get('sort') && savedSort !== 'newest') {
+            qs.set('sort', savedSort);
+            location.replace(url.toString());
+            return;
+          }
+          var savedCm = localStorage.getItem('dl-cm');
+          if (savedCm && !qs.get('cm')) {
+            document.documentElement.style.setProperty('--card-min', savedCm + 'px');
+            var grid = document.querySelector('.grid');
+            if (grid) grid.style.setProperty('--card-min', savedCm + 'px');
+            var el = document.getElementById('cm'); if (el) el.value = savedCm;
+          }
+        } catch (e) {}
+      })();
+    </script>
+  `;
+  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
+    title: cluster.title,
+    description: cluster.tagline,
+    body,
+    og: { image: collage[0] },
+  }));
+});
+
+app.get('/p/:handle', (req, res) => {
+  const p = DATA.find(x => x.handle === req.params.handle);
+  if (!p) return res.status(404).set('Content-Type', 'text/html').send(layout({
+    title: 'Not found',
+    body: '<main class="grid-wrap"><div class="empty">That product is not in this lookbook. <a href="/">Back to home →</a></div></main>',
+  }));
+
+  // Find every cluster this product belongs to so we can credit it.
+  const inClusters = CLUSTERS.filter(c => matchesCluster(p, c));
+  const primaryCluster = inClusters[0];
+
+  const body = `
+    <div class="detail">
+      <div class="hero">${p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}</div>
+      <div>
+        <div class="crumb"><a href="/">Designer Lookbooks</a>${primaryCluster ? ` / <a href="/lookbook/${primaryCluster.slug}">${esc(primaryCluster.title)}</a>` : ''}</div>
+        ${primaryCluster ? `<div class="pt">${esc(primaryCluster.title)}</div>` : `<div class="pt">${esc(NORM_TYPE(p.product_type))}</div>`}
+        <h1>${esc(p.title)}</h1>
+        <div class="row"><div class="k">SKU</div><div>${esc(p.dw_sku || '—')}</div></div>
+        ${p.mfr_sku ? `<div class="row"><div class="k">Mfr SKU</div><div>${esc(p.mfr_sku)}</div></div>` : ''}
+        <div class="row"><div class="k">Type</div><div>${esc(p.product_type || '—')}</div></div>
+        ${p.retail_price ? `<div class="row"><div class="k">Retail</div><div>$${parseFloat(p.retail_price).toFixed(2)} /yd</div></div>` : ''}
+        ${inClusters.length > 1 ? `<div class="row"><div class="k">Also in</div><div>${inClusters.slice(1).map(c => `<a href="/lookbook/${c.slug}" style="color:var(--accent)">${esc(c.title)}</a>`).join(', ')}</div></div>` : ''}
+        <div class="actions">
+          <a class="btn btn-primary" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo Sample (free)</a>
+          <a class="btn btn-secondary" href="${esc(shopifyUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Shop on DW</a>
+        </div>
+      </div>
+    </div>
+  `;
+  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
+    title: p.title,
+    description: `${p.title} — ${primaryCluster ? primaryCluster.title : 'Designer Lookbooks'}. Order a free memo sample.`,
+    body,
+    og: { image: p.image_url },
+  }));
+});
+
+app.get('/search', (req, res) => {
+  const q = (req.query.q || '').toString().toLowerCase().trim();
+  const sort = (req.query.sort || 'newest').toString();
+  const cardMin = parseInt(req.query.cm) || 240;
+  const page = parseInt(req.query.page) || 1;
+
+  let filtered = DATA;
+  if (q) {
+    const tokens = q.split(/\s+/);
+    filtered = filtered.filter(p => {
+      const hay = `${p.title} ${p.dw_sku || ''} ${p.mfr_sku || ''} ${p.product_type || ''}`.toLowerCase();
+      return tokens.every(t => hay.includes(t));
+    });
+  }
+  const sorted = sortProducts(filtered, sort);
+  const { items, page: cur, pages, total } = paginate(sorted, page, 60);
+
+  const body = `
+    <section class="cover">
+      <div class="issue">Search</div>
+      <h1>${q ? `Results for &ldquo;${esc(q)}&rdquo;` : 'Search the catalog'}</h1>
+      <p class="lede">${total.toLocaleString()} ${total === 1 ? 'work' : 'works'}.</p>
+    </section>
+    <div class="toolbar">
+      <div class="group">
+        <label for="sort">Sort</label>
+        <select id="sort" onchange="(function(s){try{localStorage.setItem('dl-sort',s.value);}catch(e){}var u=new URL(location);u.searchParams.set('sort',s.value);u.searchParams.delete('page');location=u.toString();})(this)">
+          ${[
+            ['newest', 'Newest'],
+            ['color', 'Color'],
+            ['style', 'Style'],
+            ['sku', 'SKU A→Z'],
+            ['title', 'Title A→Z'],
+          ].map(([v, label]) => `<option value="${v}" ${sort === v ? 'selected' : ''}>${label}</option>`).join('')}
+        </select>
+      </div>
+      <div class="group">
+        <label for="cm">Density</label>
+        <input type="range" id="cm" min="160" max="420" step="20" value="${cardMin}"
+          oninput="document.documentElement.style.setProperty('--card-min', this.value+'px'); try{localStorage.setItem('dl-cm', this.value);}catch(e){}"
+          onchange="(function(v){var u=new URL(location);u.searchParams.set('cm',v);location=u.toString();})(this.value)">
+      </div>
+      <div class="meta">${total.toLocaleString()} works · page ${cur}/${pages}</div>
+    </div>
+    <main class="grid-wrap">
+      ${items.length === 0 ? '<div class="empty">Nothing matched. Try a broader term or <a href="/lookbooks">browse the lookbooks →</a></div>' : `<div class="grid" style="--card-min:${cardMin}px">${items.map(p => `
+        <article class="card">
+          <a class="thumb" href="/p/${esc(p.handle || '')}">${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : ''}</a>
+          <div class="info"><a href="/p/${esc(p.handle || '')}">
+            <div class="t">${esc(p.title)}</div>
+            <div class="sub">${esc(p.dw_sku || '')} · ${esc(NORM_TYPE(p.product_type))}</div>
+          </a></div>
+          <div class="actions">
+            <a href="/p/${esc(p.handle || '')}">View</a>
+            <a class="cta" href="${esc(memoSampleUrl(p.handle))}" target="_blank" rel="noopener noreferrer">Order Memo</a>
+          </div>
+        </article>
+      `).join('')}</div>`}
+      ${pages > 1 ? `<nav class="pager">
+        ${cur > 1 ? `<a href="?${new URLSearchParams({ ...req.query, page: cur - 1 }).toString()}">‹ Prev</a>` : ''}
+        ${[...Array(Math.min(pages, 7)).keys()].map(i => {
+          let p;
+          if (pages <= 7) p = i + 1;
+          else if (cur <= 4) p = i + 1;
+          else if (cur >= pages - 3) p = pages - 6 + i;
+          else p = cur - 3 + i;
+          return p === cur ? `<span class="cur">${p}</span>` : `<a href="?${new URLSearchParams({ ...req.query, page: p }).toString()}">${p}</a>`;
+        }).join('')}
+        ${cur < pages ? `<a href="?${new URLSearchParams({ ...req.query, page: cur + 1 }).toString()}">Next ›</a>` : ''}
+      </nav>` : ''}
+    </main>
+    <script>
+      // Hydrate sort + density from localStorage when the URL doesn't
+      // already specify them. Mirrors the lookbook page so user choice
+      // sticks across the whole storefront.
+      (function() {
+        try {
+          var url = new URL(location);
+          var qs = url.searchParams;
+          var savedSort = localStorage.getItem('dl-sort');
+          var validSorts = ['newest','color','style','sku','title'];
+          if (savedSort && validSorts.indexOf(savedSort) >= 0 && !qs.get('sort') && savedSort !== 'newest') {
+            qs.set('sort', savedSort);
+            location.replace(url.toString());
+            return;
+          }
+          var savedCm = localStorage.getItem('dl-cm');
+          if (savedCm && !qs.get('cm')) {
+            document.documentElement.style.setProperty('--card-min', savedCm + 'px');
+            var grid = document.querySelector('.grid');
+            if (grid) grid.style.setProperty('--card-min', savedCm + 'px');
+            var el = document.getElementById('cm'); if (el) el.value = savedCm;
+          }
+        } catch (e) {}
+      })();
+    </script>
+  `;
+  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
+    title: q ? `Search: ${q}` : 'Search',
+    body,
+  }));
+});
+
+app.get('/about', (_req, res) => {
+  const body = `
+    <section class="cover">
+      <div class="issue">About</div>
+      <h1>An editorial index for the wallcovering world.</h1>
+      <p class="lede">
+        Designer Lookbooks groups thousands of wallcoverings into eight aesthetic clusters —
+        Soft Modernism, Maximalist Flora, Architectural Line, Global Craft, Metallic Light,
+        Velvet Noir, Coastal Lab, and Monochrome Study. Each cluster reads like a feature
+        spread: a hero image, a tagline, and the full grid of works that fit the mood.
+      </p>
+      <p class="lede" style="margin-top:18px">
+        Memo samples are always free and ship from
+        <a href="${DW_SHOPIFY}" style="color:var(--accent);text-decoration:underline">Designer Wallcoverings</a>.
+        Questions? <a href="mailto:info@designerlookbooks.com" style="color:var(--accent);text-decoration:underline">info@designerlookbooks.com</a>.
+      </p>
+    </section>
+  `;
+  res.set('Content-Type', 'text/html; charset=utf-8').send(layout({
+    title: 'About',
+    body,
+  }));
+});
+
+app.get('/health', (_req, res) => res.json({
+  ok: true,
+  products: DATA.length,
+  clusters: CLUSTERS.map(c => ({ slug: c.slug, count: CLUSTER_INDEX[c.slug].products.length })),
+}));
+
+app.listen(PORT, BIND, () => {
+  console.log(`Designer Lookbooks on http://${BIND}:${PORT} · ${DATA.length} products · ${CLUSTERS.length} clusters`);
+});

← 57f08bb Scrub residual vendor names from products.json (title/vendor  ·  back to Designerlookbooks  ·  (newest)