[object Object]

← back to Wallco Ai

Add universal search: /search page + /api/search aggregator + global header search box

4dbc0e268c0db9fd29cfaaa6d643020fc6c46a9c · 2026-06-01 07:26:58 -0700 · Steve Abrams

- /api/search?q= aggregates designs (scored), categories (live count+thumb),
  and navigable pages (keyword aliases) into one payload; did-you-mean fallback
- /search?q= server-rendered grouped results page (Categories/Pages/Designs)
  with sort+density controls on the design grid (CLAUDE.md rule), browse landing
  on empty query, vendor-denylist + no-FLIEPAPER guards
- global search box added to htmlHeader nav-panel -> reachable from every page

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

Files touched

Diff

commit 4dbc0e268c0db9fd29cfaaa6d643020fc6c46a9c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 07:26:58 2026 -0700

    Add universal search: /search page + /api/search aggregator + global header search box
    
    - /api/search?q= aggregates designs (scored), categories (live count+thumb),
      and navigable pages (keyword aliases) into one payload; did-you-mean fallback
    - /search?q= server-rendered grouped results page (Categories/Pages/Designs)
      with sort+density controls on the design grid (CLAUDE.md rule), browse landing
      on empty query, vendor-denylist + no-FLIEPAPER guards
    - global search box added to htmlHeader nav-panel -> reachable from every page
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 357 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 357 insertions(+)

diff --git a/server.js b/server.js
index 86e450d..4287b22 100644
--- a/server.js
+++ b/server.js
@@ -646,6 +646,167 @@ app.get('/api/designs/search', (req, res) => {
   });
 });
 
+// ── Universal search index of customer-facing pages (non-design surfaces).
+// These are the navigable destinations a shopper might be searching for by
+// name ("how do I order a sample", "murals", "play"). Each carries keyword
+// aliases so a fuzzy query still lands. Admin surfaces are deliberately absent.
+const UNIVERSAL_PAGES = [
+  { path: '/designs',         title: 'All Designs',       blurb: 'Browse the full catalog of curated-original wallpaper patterns.', keywords: ['designs','catalog','browse','patterns','wallpaper','shop','all','gallery','collection'] },
+  { path: '/murals',          title: 'Murals',            blurb: 'Custom-sized scenic murals, panelized for your wall.',            keywords: ['mural','murals','scenic','panel','wall','large','custom','panorama'] },
+  { path: '/samples',         title: 'Free Samples',      blurb: 'Request a free physical sample swatch before you order.',         keywords: ['sample','samples','swatch','free','order','request','memo','try'] },
+  { path: '/designer-studio', title: 'Designer Studio',   blurb: 'Compose and customize your own pattern board.',                   keywords: ['designer','studio','compose','custom','create','board','moodboard','build'] },
+  { path: '/generator',       title: 'Pattern Generator', blurb: 'Generate a brand-new original pattern from a prompt.',            keywords: ['generator','generate','create','ai','prompt','make','new','original'] },
+  { path: '/play',            title: 'Play',              blurb: 'Explore and vote on patterns, hot-or-not style.',                 keywords: ['play','vote','game','hot','explore','swipe','discover','fun'] },
+  { path: '/marketplace',     title: 'Marketplace',       blurb: 'Designs available for trade and licensing.',                      keywords: ['marketplace','market','trade','license','buy','sell','shop'] },
+  { path: '/designers',       title: 'Designers',         blurb: 'The designers and collaborators behind the collections.',         keywords: ['designer','designers','artist','artists','collaborator','people','team'] },
+  { path: '/patterns',        title: 'Patterns',          blurb: 'Pattern families and motif categories.',                          keywords: ['pattern','patterns','motif','style','family','families','category'] },
+  { path: '/basket',          title: 'Basket',            blurb: 'Your saved sample basket.',                                       keywords: ['basket','cart','bag','saved','checkout','order'] },
+  { path: '/about',           title: 'About wallco.ai',   blurb: 'How every pattern is generated, curated-original, never licensed.', keywords: ['about','how','works','company','story','info','faq','curated','original'] },
+];
+
+// universalSearch(qRaw, limit) — UNIVERSAL search across the whole site.
+// Aggregates three result groups into one payload so a single box can find
+// anything a shopper means: designs (scored exactly like /api/designs/search),
+// categories (matched by display name + slug, carrying a live count + thumb),
+// and navigable pages (matched by title + keyword aliases). Pure read-only,
+// in-memory — no DB. Falls back to a did-you-mean over the motif/category
+// vocabulary when nothing matched, and to a browse payload when q is empty.
+// Used by both GET /api/search (JSON) and GET /search (server-rendered page).
+function universalSearch(qRaw, limit) {
+  qRaw = String(qRaw || '').toLowerCase().trim();
+  limit = Math.max(1, Math.min(50, parseInt(limit || '24', 10)));
+  const terms = qRaw.split(/\s+/).filter(Boolean);
+  // Vendor names must never surface in a customer-facing result (CLAUDE.md hard rule).
+  const VENDOR_DENYLIST = /\b(thibaut|koroseal|schumacher|maya[ -]romanoff|scalamandre|arte[ -]international|cole[ -]?(and|&)[ -]?son|phillip[ -]jeffries|osborne[ -]?(and|&)?[ -]?little|de[ -]gournay|fromental|sister[ -]parish|dedar|brewster|mind[ -]the[ -]gap|hygge|designtex|wolf[ -]gordon|carnegie|coordonn|daisy[ -]bennett|china[ -]seas|york|marburg|romo|fabricut|stout|knoll|arteriors|timorous[ -]beasties|hangups)\b/i;
+
+  // ── Group: categories (built fresh each call — cheap; bounded category count).
+  const catAgg = Object.create(null); // slug -> { count, thumb, sat }
+  for (const d of DESIGNS) {
+    if (BLANK_IDS.has(d.id)) continue;
+    const c = d.category;
+    if (!c || VENDOR_DENYLIST.test(c)) continue;
+    let a = catAgg[c];
+    if (!a) a = catAgg[c] = { count: 0, thumb: null, sat: -1 };
+    a.count++;
+    // Keep the most-saturated design's image as the category thumb (most striking).
+    const s = d.saturation || 0;
+    if (s > a.sat && d.image_url) { a.sat = s; a.thumb = d.image_url; }
+  }
+  const categories = [];
+  for (const slug of Object.keys(catAgg)) {
+    const disp = categoryDisplay(slug);
+    const hayDisp = disp.toLowerCase();
+    const haySlug = slug.toLowerCase().replace(/-/g, ' ');
+    let score = 0;
+    for (const t of terms) {
+      if (haySlug === t || hayDisp === t)         score += 6;
+      else if (hayDisp.includes(t) || haySlug.includes(t)) score += 3;
+    }
+    if (qRaw && score === 0) continue;
+    categories.push({
+      slug, title: disp, count: catAgg[slug].count,
+      thumb: catAgg[slug].thumb,
+      url: `/designs?cat=${encodeURIComponent(slug)}`,
+      score,
+    });
+  }
+  categories.sort((a, b) => b.score - a.score || b.count - a.count);
+
+  // ── Group: pages (title + keyword alias match).
+  const pages = [];
+  for (const p of UNIVERSAL_PAGES) {
+    const hayTitle = p.title.toLowerCase();
+    let score = 0;
+    for (const t of terms) {
+      if (p.keywords.includes(t))        score += 5;
+      else if (hayTitle.includes(t))     score += 4;
+      else if (p.keywords.some(k => k.includes(t) && t.length >= 3)) score += 2;
+      else if (p.blurb.toLowerCase().includes(t)) score += 1;
+    }
+    if (qRaw && score === 0) continue;
+    pages.push({ ...p, score });
+  }
+  pages.sort((a, b) => b.score - a.score);
+
+  // ── Group: designs — same weighting as /api/designs/search.
+  const designs = [];
+  if (qRaw) {
+    for (const d of DESIGNS) {
+      if (BLANK_IDS.has(d.id)) continue;
+      const title = (d.title || '').toLowerCase();
+      const category = (d.category || '').toLowerCase();
+      const handle = (d.handle || '').toLowerCase();
+      const motifs = (d.motifs || []).map(m => String(m).toLowerCase());
+      let score = 0;
+      for (const t of terms) {
+        if (motifs.some(m => m === t))            score += 5;
+        else if (motifs.some(m => m.includes(t))) score += 3;
+        if (title.includes(t))    score += 2;
+        if (category === t)       score += 2;
+        else if (category.includes(t)) score += 1;
+        if (handle.includes(t))   score += 1;
+      }
+      if (score > 0) {
+        designs.push({
+          id: d.id, title: d.title, category: d.category, handle: d.handle,
+          image_url: d.image_url, dominant_hex: d.dominant_hex,
+          room_mockups: d.room_mockups || [],
+          score, saturation: d.saturation || 0, url: `/design/${d.id}`,
+        });
+      }
+    }
+    designs.sort((a, b) => b.score - a.score || b.saturation - a.saturation);
+  }
+
+  // ── Did-you-mean (only when the whole search came up empty).
+  let didYouMean = [];
+  const totalHits = designs.length + categories.length + pages.length;
+  if (qRaw && totalHits === 0) {
+    const vocab = new Set();
+    for (const d of DESIGNS) {
+      (d.motifs || []).forEach(m => { const v = String(m).toLowerCase().trim(); if (v.length >= 3) vocab.add(v); });
+      if (d.category && !VENDOR_DENYLIST.test(d.category)) vocab.add(String(d.category).toLowerCase().trim());
+    }
+    const candidates = new Map();
+    const cap = 2;
+    for (const t of terms) {
+      if (t.length < 3) continue;
+      for (const w of vocab) {
+        const dist = _lev(t, w, cap);
+        if (dist <= cap && w !== t) {
+          const prev = candidates.get(w);
+          if (prev === undefined || dist < prev) candidates.set(w, dist);
+        }
+      }
+    }
+    didYouMean = Array.from(candidates.entries())
+      .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
+      .slice(0, 5).map(([word, distance]) => ({ word, distance }));
+  }
+
+  return {
+    q: qRaw,
+    limit,
+    total: totalHits,
+    counts: { designs: designs.length, categories: categories.length, pages: pages.length },
+    results: {
+      categories: categories.slice(0, 12),
+      pages: pages.slice(0, 12),
+      designs: designs.slice(0, limit),
+    },
+    did_you_mean: didYouMean,
+  };
+}
+
+app.get('/api/search', (req, res) => {
+  const payload = universalSearch(req.query.q, req.query.limit);
+  if (payload.q && payload.q.length >= 2) {
+    logEvent(req, 'universal_search', { q: payload.q, ...payload.counts });
+  }
+  res.setHeader('Cache-Control', 'public, max-age=120, must-revalidate');
+  res.json(payload);
+});
+
 // ── /api/designs/by-color?hex=%23c16d74&n=12&exclude=72
 // Returns N closest-in-color designs to a target hex via CIELAB ΔE distance.
 // Useful for "more like this in this palette" rails on the PDP. Read-only, no DB.
@@ -5538,6 +5699,12 @@ function htmlHeader(active) {
   </button>
 </header>
 <nav class="nav-panel" id="nav-panel" hidden>
+  <form class="nav-search" method="get" action="/search" role="search" style="display:flex;gap:8px;margin:0 14px 12px;align-items:center">
+    <input type="search" name="q" placeholder="Search wallco.ai…" autocomplete="off" aria-label="Search wallco.ai" style="flex:1;padding:10px 14px;font:14px var(--sans,system-ui);border:1px solid var(--line,#d8d0c0);border-radius:999px;background:var(--card-bg,#faf8f3);color:var(--ink,#1f1808);outline:none">
+    <button type="submit" aria-label="Search" style="flex:0 0 auto;width:38px;height:38px;border:0;border-radius:50%;background:var(--accent,#1a1a1a);color:var(--bg,#fff);cursor:pointer;display:inline-flex;align-items:center;justify-content:center">
+      <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
+    </button>
+  </form>
   ${navLinks.map(([href, label]) =>
     `<a href="${href}" class="${active === href ? 'active' : ''}"${href === '/basket' ? ' id="nav-basket-link"' : ''}>${label}${href === '/basket' ? '<span id="nav-basket-count" style="display:none;margin-left:6px;font:11px var(--sans,system-ui);background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);padding:1px 7px;border-radius:999px;vertical-align:middle"></span>' : ''}</a>`
   ).join('\n  ')}
@@ -18864,6 +19031,196 @@ ${HAMBURGER_JS}
 // sitemap entries pointing at the live About page instead of returning 404).
 app.get('/provenance', (req, res) => res.redirect(301, '/about'));
 
+// ── UNIVERSAL SEARCH PAGE — /search?q=<term>
+// Server-rendered grouped results (categories · pages · designs) from
+// universalSearch(). The global header search box + the corner-nav magnifier
+// both submit here. Designs render as a card grid with the standard sort +
+// density controls (CLAUDE.md hard rule). Empty query → a browse landing.
+app.get('/search', (req, res) => {
+  const esc = (s) => String(s == null ? '' : s).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
+  const qRaw = String(req.query.q || '').trim();
+  const data = universalSearch(qRaw, 48);
+  const { categories, pages, designs } = data.results;
+  const hasQ = qRaw.length > 0;
+
+  if (hasQ && qRaw.length >= 2) {
+    logEvent(req, 'universal_search_page', { q: qRaw.toLowerCase(), ...data.counts });
+  }
+
+  const catCard = (c) => `
+    <a class="us-cat" href="${esc(c.url)}">
+      <span class="us-cat-thumb" style="background-image:url('${esc(c.thumb || '')}')"></span>
+      <span class="us-cat-body">
+        <span class="us-cat-title">${esc(c.title)}</span>
+        <span class="us-cat-count">${c.count} design${c.count === 1 ? '' : 's'}</span>
+      </span>
+    </a>`;
+
+  const pageRow = (p) => `
+    <a class="us-page" href="${esc(p.path)}">
+      <span class="us-page-title">${esc(p.title)}</span>
+      <span class="us-page-blurb">${esc(p.blurb)}</span>
+    </a>`;
+
+  const designCard = (d) => {
+    const hue = (d.dominant_hex || '#efe9dd');
+    return `
+    <div class="us-card" data-title="${esc((d.title || '').toLowerCase())}" data-hex="${esc(hue)}">
+      <a class="us-card-link" href="${esc(d.url)}" aria-label="${esc(d.title || ('Design ' + d.id))}">
+        <span class="us-card-img" style="background-image:url('${esc(d.image_url || '')}');background-color:${esc(hue)}"></span>
+        <span class="us-card-meta">
+          <span class="us-card-title">${esc(d.title || ('Pattern No.' + d.id))}</span>
+          <span class="us-card-sku">${esc(categoryDisplay(d.category))} · No.${d.id}</span>
+        </span>
+      </a>
+    </div>`;
+  };
+
+  // Browse landing (no query) — surface ALL categories so the page is never empty.
+  let body;
+  if (!hasQ) {
+    const allCats = universalSearch('', 1).results.categories; // q='' → all categories, count-sorted
+    body = `
+      <div class="us-empty">
+        <p class="us-empty-lead">Search ${DESIGNS.length.toLocaleString()} curated-original patterns, categories &amp; pages.</p>
+        <p class="us-empty-sub">Try a motif (<em>rose</em>, <em>damask</em>, <em>palm</em>), a category, or a page name.</p>
+      </div>
+      ${allCats.length ? `<section class="us-section"><h2 class="us-h2">Browse categories</h2><div class="us-cat-grid">${allCats.map(catCard).join('')}</div></section>` : ''}`;
+  } else if (data.total === 0) {
+    const dym = (data.did_you_mean || []).map(d => `<a href="/search?q=${encodeURIComponent(d.word)}">${esc(d.word)}</a>`).join(' · ');
+    body = `
+      <div class="us-empty">
+        <p class="us-empty-lead">No matches for &ldquo;${esc(qRaw)}&rdquo;.</p>
+        ${dym ? `<p class="us-empty-sub">Did you mean: ${dym}</p>` : `<p class="us-empty-sub">Try a broader term, or <a href="/designs">browse all designs</a>.</p>`}
+      </div>`;
+  } else {
+    const dym = (data.did_you_mean || []).length
+      ? `<p class="us-dym">Did you mean: ${data.did_you_mean.map(d => `<a href="/search?q=${encodeURIComponent(d.word)}">${esc(d.word)}</a>`).join(' · ')}</p>` : '';
+    const catSec = categories.length
+      ? `<section class="us-section"><h2 class="us-h2">Categories <span class="us-n">${categories.length}</span></h2><div class="us-cat-grid">${categories.map(catCard).join('')}</div></section>` : '';
+    const pageSec = pages.length
+      ? `<section class="us-section"><h2 class="us-h2">Pages <span class="us-n">${pages.length}</span></h2><div class="us-page-list">${pages.map(pageRow).join('')}</div></section>` : '';
+    const designSec = designs.length
+      ? `<section class="us-section">
+          <div class="us-grid-toolbar">
+            <h2 class="us-h2" style="margin:0">Designs <span class="us-n">${data.counts.designs}</span></h2>
+            <div class="us-controls">
+              <label class="us-ctl">Sort
+                <select id="us-sort">
+                  <option value="relevance">Relevance</option>
+                  <option value="title">Title A→Z</option>
+                  <option value="color">Color</option>
+                </select>
+              </label>
+              <label class="us-ctl">Density
+                <input type="range" id="us-density" min="2" max="7" step="1" value="4">
+              </label>
+            </div>
+          </div>
+          <div class="us-grid" id="us-grid">${designs.map(designCard).join('')}</div>
+          ${data.counts.designs > designs.length ? `<p class="us-more"><a href="/designs?q=${encodeURIComponent(qRaw)}">See all ${data.counts.designs} design results →</a></p>` : ''}
+        </section>` : '';
+    body = `<p class="us-summary">${data.total} result${data.total === 1 ? '' : 's'} for &ldquo;${esc(qRaw)}&rdquo;</p>${dym}${catSec}${pageSec}${designSec}`;
+  }
+
+  res.type('html').send(`${htmlHead({
+    title: hasQ ? `Search: ${qRaw} — wallco.ai` : 'Search — wallco.ai',
+    description: 'Search wallco.ai — curated-original wallpaper patterns, categories and pages.',
+    canonical: 'https://wallco.ai/search'
+  })}
+<body>
+${htmlHeader('/search')}
+<style>
+  .us-main{max-width:1180px;margin:0 auto;padding:40px 24px 80px}
+  .us-searchbar{display:flex;gap:10px;max-width:680px;margin:8px auto 28px;width:100%}
+  .us-searchbar input[type=search]{flex:1;padding:14px 18px;font:400 16px var(--sans,system-ui);border:1px solid var(--line,#d8d0c0);border-radius:999px;background:var(--card-bg,#faf8f3);color:var(--ink,#1f1808);outline:none}
+  .us-searchbar input[type=search]:focus{border-color:var(--accent,#1a1a1a)}
+  .us-searchbar button{padding:14px 26px;font:500 14px var(--sans,system-ui);letter-spacing:.04em;border:0;border-radius:999px;background:var(--accent,#1a1a1a);color:var(--bg,#fff);cursor:pointer}
+  .us-summary{font:400 14px var(--sans);color:var(--ink-faint,#8a7a5e);margin:0 0 6px;text-align:center}
+  .us-dym,.us-empty-sub{font:400 14px var(--sans);color:var(--ink-soft,#6a5e48)}
+  .us-dym a,.us-empty-sub a{color:var(--accent,#1a1a1a)}
+  .us-section{margin:30px 0 0}
+  .us-h2{font:300 22px var(--serif,'Cormorant Garamond',Georgia,serif);color:var(--ink,#1f1808);margin:0 0 14px;letter-spacing:.01em}
+  .us-n{font:500 12px var(--sans);color:var(--ink-faint,#8a7a5e);vertical-align:middle;margin-left:4px}
+  .us-cat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
+  .us-cat{display:flex;align-items:center;gap:12px;padding:10px;border:1px solid var(--line,#e0d9cd);border-radius:10px;text-decoration:none;background:var(--card-bg,#fff);transition:box-shadow .15s,transform .15s}
+  .us-cat:hover{box-shadow:0 6px 20px rgba(0,0,0,.10);transform:translateY(-2px)}
+  .us-cat-thumb{width:56px;height:56px;flex:0 0 56px;border-radius:8px;background-size:cover;background-position:center 12%;background-color:var(--line,#e8e2d6)}
+  .us-cat-title{display:block;font:500 14px var(--sans);color:var(--ink,#1f1808)}
+  .us-cat-count{display:block;font:400 12px var(--sans);color:var(--ink-faint,#8a7a5e);margin-top:2px}
+  .us-page-list{display:flex;flex-direction:column;gap:2px}
+  .us-page{display:flex;flex-direction:column;padding:12px 14px;border-radius:8px;text-decoration:none;transition:background .12s}
+  .us-page:hover{background:var(--card-bg,#faf8f3)}
+  .us-page-title{font:500 15px var(--sans);color:var(--ink,#1f1808)}
+  .us-page-blurb{font:400 13px var(--sans);color:var(--ink-faint,#8a7a5e);margin-top:2px}
+  .us-grid-toolbar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:14px}
+  .us-controls{display:flex;gap:16px;align-items:center}
+  .us-ctl{font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#8a7a5e);display:flex;align-items:center;gap:7px}
+  .us-ctl select{font:13px var(--sans);padding:5px 9px;border:1px solid var(--line,#d8d0c0);border-radius:6px;background:transparent;color:var(--ink,#1f1808);text-transform:none;letter-spacing:0}
+  .us-ctl input[type=range]{width:110px}
+  .us-grid{display:grid;grid-template-columns:repeat(var(--us-cols,4),1fr);gap:16px}
+  .us-card{border:1px solid var(--line,#e8e2d6);border-radius:10px;overflow:hidden;background:var(--card-bg,#fff);transition:box-shadow .15s,transform .15s}
+  .us-card:hover{box-shadow:0 8px 24px rgba(0,0,0,.12);transform:translateY(-2px)}
+  .us-card-link{text-decoration:none;display:block}
+  .us-card-img{display:block;width:100%;aspect-ratio:1/1;background-size:cover;background-position:center 12%}
+  .us-card-meta{display:block;padding:9px 11px 11px}
+  .us-card-title{display:block;font:500 13px var(--sans);color:var(--ink,#1f1808);line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .us-card-sku{display:block;font:400 11px var(--sans);color:var(--ink-faint,#8a7a5e);margin-top:3px;letter-spacing:.03em}
+  .us-empty{text-align:center;padding:24px 0 8px}
+  .us-empty-lead{font:300 24px var(--serif,'Cormorant Garamond',Georgia,serif);color:var(--ink,#1f1808);margin:0 0 8px}
+  .us-more{margin:18px 0 0;text-align:center}
+  .us-more a{font:500 14px var(--sans);color:var(--accent,#1a1a1a)}
+  @media (max-width:640px){.us-grid{--us-cols:2 !important}.us-controls{width:100%;justify-content:space-between}}
+</style>
+<main class="us-main">
+  <h1 style="font:300 30px var(--serif,'Cormorant Garamond',Georgia,serif);text-align:center;color:var(--ink,#1f1808);margin:0 0 6px">Search</h1>
+  <form class="us-searchbar" method="get" action="/search" role="search">
+    <input type="search" name="q" value="${esc(qRaw)}" placeholder="Search patterns, categories, pages…" autocomplete="off" autofocus aria-label="Search wallco.ai">
+    <button type="submit">Search</button>
+  </form>
+  ${body}
+</main>
+${FOOTER}
+<script>
+(function(){
+  var grid = document.getElementById('us-grid');
+  var sortSel = document.getElementById('us-sort');
+  var dens = document.getElementById('us-density');
+  if (dens) {
+    var savedD = localStorage.getItem('us-density');
+    if (savedD) dens.value = savedD;
+    var applyD = function(){ document.querySelectorAll('.us-grid').forEach(function(g){ g.style.setProperty('--us-cols', dens.value); }); localStorage.setItem('us-density', dens.value); };
+    applyD();
+    dens.addEventListener('input', applyD);
+  }
+  function hexToHue(hex){
+    hex = String(hex||'').replace('#',''); if(hex.length===3) hex=hex.split('').map(function(c){return c+c;}).join('');
+    var r=parseInt(hex.substr(0,2),16)/255, g=parseInt(hex.substr(2,2),16)/255, b=parseInt(hex.substr(4,2),16)/255;
+    if(isNaN(r))return 999; var mx=Math.max(r,g,b),mn=Math.min(r,g,b),h=0,d=mx-mn;
+    if(d===0)h=0; else if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4;
+    h=Math.round(h*60); if(h<0)h+=360; return h;
+  }
+  if (sortSel && grid) {
+    var savedS = localStorage.getItem('us-sort'); if (savedS) sortSel.value = savedS;
+    var orig = Array.prototype.slice.call(grid.children);
+    var applyS = function(){
+      var v = sortSel.value; localStorage.setItem('us-sort', v);
+      var arr = Array.prototype.slice.call(grid.children);
+      if (v==='relevance'){ arr = orig.slice(); }
+      else if (v==='title'){ arr.sort(function(a,b){ return (a.dataset.title||'').localeCompare(b.dataset.title||''); }); }
+      else if (v==='color'){ arr.sort(function(a,b){ return hexToHue(a.dataset.hex)-hexToHue(b.dataset.hex); }); }
+      arr.forEach(function(el){ grid.appendChild(el); });
+    };
+    applyS();
+    sortSel.addEventListener('change', applyS);
+  }
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
 // ── ABOUT
 app.get('/about', (req, res) => {
   res.type('html').send(`${htmlHead({

← fef1177 gen-luxe: psql helper ON_ERROR_STOP=1 so a half-applied mult  ·  back to Wallco Ai  ·  TODO: mark BLEED_GHOST triage done (7875 cohort, 850 contrad 1c8294c →