← 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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 & 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 “${esc(qRaw)}”.</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 “${esc(qRaw)}”</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 →