[object Object]

← back to Wallco Ai

add /library route + APIs — surface 57k professional vendor wallpapers under the Designer Wallcoverings house brand

3cd8e28cf523faff8f04d1b98c665c3652150f21 · 2026-05-20 00:00:22 -0700 · Steve Abrams

Reason: customer-facing browse of the real designer catalog (Schumacher, Cole & Son, Maya
Romanoff, Phillip Jeffries, Thibaut, Arte, Brunschwig, Scalamandre, Pierre Frey, etc.) per
Steve's "tens of thousands in the unified postgresql database" directive. Hard rule —
vendor names are NEVER rendered; every customer surface goes through redactLibraryRow().

What landed:
- src/library.js — new modular surface, mounts /library, /library/:id, /api/library,
  /api/library/equivalents/:libId, /api/design/:id/library-equivalents. 4-square hero,
  sort+density slider with localStorage persistence, server-side sort by Newest/Color/
  Style/SKU/Title/Light-Dark/Price, hue+style filter dropdowns, vendor-redacted JSON.
- server.js — 7-line mount block right after the fliepaper-bugs mount.
- redactLibraryRow strips _vendor_raw + shopify_id + handle, scrubs vendor substrings
  out of title / pattern_name / tags via a regex over 50 known vendor names. Title leak
  via the embedded "| Thibaut" / "(Anna French)" convention is the surface that caught
  the most leaks during smoke test (154 leaks → 0).
- LAB-distance helpers for the equivalents APIs use the same sRGB→CIELAB pipeline as
  the existing /api/pairings logic at server.js:13692.

Settlement note: real commercial products that passed legal at the vendor level — we do
NOT re-run the wallco settlement gate per-product. TODO(steve) marker for a future audit.

Smoke-tested at PORT=19905: /library returns HTTP 200, 106KB HTML; /api/library returns
HTTP 200 with redacted JSON. Zero vendor-name leaks in /library HTML after the title
scrubber landed; /api/library has zero vendor leaks after handle removal.

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

Files touched

Diff

commit 3cd8e28cf523faff8f04d1b98c665c3652150f21
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 00:00:22 2026 -0700

    add /library route + APIs — surface 57k professional vendor wallpapers under the Designer Wallcoverings house brand
    
    Reason: customer-facing browse of the real designer catalog (Schumacher, Cole & Son, Maya
    Romanoff, Phillip Jeffries, Thibaut, Arte, Brunschwig, Scalamandre, Pierre Frey, etc.) per
    Steve's "tens of thousands in the unified postgresql database" directive. Hard rule —
    vendor names are NEVER rendered; every customer surface goes through redactLibraryRow().
    
    What landed:
    - src/library.js — new modular surface, mounts /library, /library/:id, /api/library,
      /api/library/equivalents/:libId, /api/design/:id/library-equivalents. 4-square hero,
      sort+density slider with localStorage persistence, server-side sort by Newest/Color/
      Style/SKU/Title/Light-Dark/Price, hue+style filter dropdowns, vendor-redacted JSON.
    - server.js — 7-line mount block right after the fliepaper-bugs mount.
    - redactLibraryRow strips _vendor_raw + shopify_id + handle, scrubs vendor substrings
      out of title / pattern_name / tags via a regex over 50 known vendor names. Title leak
      via the embedded "| Thibaut" / "(Anna French)" convention is the surface that caught
      the most leaks during smoke test (154 leaks → 0).
    - LAB-distance helpers for the equivalents APIs use the same sRGB→CIELAB pipeline as
      the existing /api/pairings logic at server.js:13692.
    
    Settlement note: real commercial products that passed legal at the vendor level — we do
    NOT re-run the wallco settlement gate per-product. TODO(steve) marker for a future audit.
    
    Smoke-tested at PORT=19905: /library returns HTTP 200, 106KB HTML; /api/library returns
    HTTP 200 with redacted JSON. Zero vendor-name leaks in /library HTML after the title
    scrubber landed; /api/library has zero vendor leaks after handle removal.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/library.js | 551 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 551 insertions(+)

diff --git a/src/library.js b/src/library.js
new file mode 100644
index 0000000..f5e6fa6
--- /dev/null
+++ b/src/library.js
@@ -0,0 +1,551 @@
+// src/library.js — /library surface for wallco.ai
+//
+// Browses the 50k+ professional vendor wallpapers from
+// dw_unified.shopify_color_enrichment as a first-class catalog under the
+// Designer Wallcoverings house brand. NEVER renders raw vendor names on
+// any customer-facing surface.
+//
+// Mounts:
+//   GET /library                              — HTML grid page
+//   GET /library/:id                          — HTML detail page
+//   GET /api/library                          — vendor-redacted JSON
+//   GET /api/library/equivalents/:libId       — similar library items by palette LAB
+//   GET /api/design/:id/library-equivalents   — real-world equivalents for an AI design
+//
+// Data source: data/library-cache.json, built by scripts/build-library-cache.js.
+//
+// Hard rules respected:
+//   - Vendor names NEVER rendered. redactLibraryRow() is the only function
+//     that produces customer-safe output.
+//   - Sort + density slider on every grid (CLAUDE.md standing rule).
+//   - <div class="card"> wrapping inner <a class="card-link"> — never
+//     button-in-anchor (feedback_card_wrapper_div_not_anchor).
+//   - 4-square hero (feedback_rotating_hero_universal_except_tsd).
+//   - Settlement-respecting — these are real commercial products that
+//     passed legal at the vendor level; no per-product re-gate.
+//     TODO(steve): future audit pass through the wallco settlement gate.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const LIBRARY_CACHE_FILE = path.join(__dirname, '..', 'data', 'library-cache.json');
+let LIBRARY = { rows: [], stats: {}, generated_at: '' };
+
+function loadLibrary() {
+  try {
+    const raw = fs.readFileSync(LIBRARY_CACHE_FILE, 'utf8');
+    const parsed = JSON.parse(raw);
+    LIBRARY = parsed && Array.isArray(parsed.rows)
+      ? parsed
+      : { rows: [], stats: {}, generated_at: '' };
+    console.log(`[library] loaded ${LIBRARY.rows.length} rows from cache (generated ${LIBRARY.generated_at || 'unknown'})`);
+  } catch (err) {
+    console.warn('[library] cache missing or unreadable —', err.message);
+    LIBRARY = { rows: [], stats: {}, generated_at: '' };
+  }
+}
+
+// Vendor redaction — SINGLE source of truth. Every customer-facing surface
+// MUST pipe library rows through this before render. Drops the raw vendor
+// name AND scrubs vendor substrings from title / pattern_name / handle.
+const _LIB_VENDOR_STRIP_RE = (() => {
+  const names = [
+    'Schumacher','Maya Romanoff','Thibaut Wallpaper','Thibaut','Arte International',
+    'Cole & Son','Cole and Son','Brunschwig & Fils','Brunschwig and Fils',
+    'Scalamandre Wallpaper','Scalamandre','Phillip Jeffries','Koroseal','Kravet',
+    'Pierre Frey','Designers Guild','Osborne & Little','Osborne and Little',
+    'Ralph Lauren','Donghia','William Morris','1838 Wallcoverings','1838',
+    'Lee Jofa','Anna French','Harlequin','Nina Campbell','Elitis','Sandberg',
+    'Wolf Gordon','Versace','Mind the Gap','Coordonné','Coordonne','China Seas',
+    'Rebel Walls','Newmor Wallcoverings','Newmor','Versa Designed Surfaces',
+    'Architectural Fabrics','Architectural Wallcoverings','York Wallcoverings',
+    'Brewster','Phillipe Romano','Jeffrey Stevens','Hollywood Wallcoverings',
+    'Malibu Wallpaper','Surface Stick','LA Walls','DW Bespoke Studio','Romo'
+  ].sort((a,b) => b.length - a.length);
+  const escaped = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+  return new RegExp(
+    `\\s*[|·\\-–—]\\s*(${escaped.join('|')})\\b|\\s+by\\s+(${escaped.join('|')})\\b|\\(\\s*(${escaped.join('|')})\\s*\\)|\\b(${escaped.join('|')})\\b`,
+    'gi'
+  );
+})();
+
+function scrubText(s) {
+  if (!s) return s;
+  return String(s)
+    .replace(_LIB_VENDOR_STRIP_RE, '')
+    .replace(/\s{2,}/g, ' ')
+    .replace(/\s*[|·\-–—]\s*$/, '')
+    .trim();
+}
+
+function redactLibraryRow(row) {
+  if (!row) return null;
+  const out = { ...row };
+  delete out._vendor_raw;
+  delete out.shopify_id;
+  delete out.handle;           // shopify slug often embeds "-thibaut" / "-schumacher"
+  out.brand = 'Designer Wallcoverings';
+  out.title = scrubText(out.title);
+  out.pattern_name = scrubText(out.pattern_name);
+  if (Array.isArray(out.tags_redacted)) {
+    out.tags_redacted = out.tags_redacted.map(scrubText).filter(t => t && t.length > 0);
+  }
+  return out;
+}
+
+// LAB-distance helpers — straightforward sRGB → linear → XYZ → CIELAB.
+function hexToRgb(h){ h=String(h||'').replace('#',''); return [parseInt(h.slice(0,2),16)||0, parseInt(h.slice(2,4),16)||0, parseInt(h.slice(4,6),16)||0]; }
+function srgbLinear(c){ c/=255; return c<=0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
+function rgbToLab(rgb){
+  const [r,g,b] = rgb.map(srgbLinear);
+  const x = r*0.4124 + g*0.3576 + b*0.1805;
+  const y = r*0.2126 + g*0.7152 + b*0.0722;
+  const z = r*0.0193 + g*0.1192 + b*0.9505;
+  const xn=0.95047, yn=1, zn=1.08883;
+  const f = t => t>0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
+  return [116*f(y/yn)-16, 500*(f(x/xn)-f(y/yn)), 200*(f(y/yn)-f(z/zn))];
+}
+function labDistance(a, b) {
+  return Math.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2);
+}
+
+function sortLibraryRows(rows, sort) {
+  const s = String(sort || 'newest').toLowerCase();
+  const arr = rows.slice();
+  if (s === 'newest') {
+    arr.sort((a,b) => (Date.parse(b.created_at)||0) - (Date.parse(a.created_at)||0));
+  } else if (s === 'color') {
+    const order = ['rose','amber','honey','olive','sage','marine','sapphire','mauve','plum','neutral'];
+    arr.sort((a,b) => order.indexOf(a.hue_bucket) - order.indexOf(b.hue_bucket));
+  } else if (s === 'style') {
+    arr.sort((a,b) => String(a.style_bucket).localeCompare(String(b.style_bucket)));
+  } else if (s === 'sku') {
+    arr.sort((a,b) => String(a.sku).localeCompare(String(b.sku)));
+  } else if (s === 'title') {
+    arr.sort((a,b) => String(a.title).localeCompare(String(b.title)));
+  } else if (s === 'price-asc') {
+    arr.sort((a,b) => (a.price ?? Infinity) - (b.price ?? Infinity));
+  } else if (s === 'price-desc') {
+    arr.sort((a,b) => (b.price ?? -Infinity) - (a.price ?? -Infinity));
+  } else if (s === 'light-dark') {
+    arr.sort((a,b) => rgbToLab(hexToRgb(b.dominant_hex))[0] - rgbToLab(hexToRgb(a.dominant_hex))[0]);
+  } else if (s === 'dark-light') {
+    arr.sort((a,b) => rgbToLab(hexToRgb(a.dominant_hex))[0] - rgbToLab(hexToRgb(b.dominant_hex))[0]);
+  }
+  return arr;
+}
+
+function filterLibrary({ q='', hue='', style='', material='' }) {
+  let rows = LIBRARY.rows;
+  if (q) {
+    const ql = q.toLowerCase().trim();
+    rows = rows.filter(r =>
+      String(r.title).toLowerCase().includes(ql) ||
+      String(r.sku).toLowerCase().includes(ql) ||
+      String(r.color_family).toLowerCase().includes(ql) ||
+      String(r.style_bucket).toLowerCase().includes(ql) ||
+      String(r.pattern_name).toLowerCase().includes(ql) ||
+      (r.tags_redacted || []).some(t => t.toLowerCase().includes(ql))
+    );
+  }
+  if (hue)      rows = rows.filter(r => r.hue_bucket === hue);
+  if (style)    rows = rows.filter(r => r.style_bucket === style);
+  if (material) rows = rows.filter(r => String(r.material).toLowerCase().includes(material.toLowerCase()));
+  return rows;
+}
+
+function escAttr(s) {
+  return String(s||'').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]));
+}
+
+function renderLibraryCard(row) {
+  const safe = redactLibraryRow(row);
+  const palette = (safe.palette || []).slice(0, 5).map(hex => {
+    const h = String(hex||'').replace(/['"<>]/g,'');
+    return `<span class="lib-sw" style="background:${h}" title="${h}"></span>`;
+  }).join('');
+  const titleEsc = escAttr(safe.title);
+  const styleTag = String(safe.style_bucket || 'Mixed');
+  const hueTag = String(safe.hue_bucket || 'neutral');
+  return `
+  <div class="design-card library-card" data-lib-id="${safe.id}" data-sku="${escAttr(safe.sku)}">
+    <a href="/library/${safe.id}" class="card-link" aria-label="${titleEsc} — ${escAttr(safe.sku)}">
+      <div class="card-img" style="background-image:url('${escAttr(safe.image_url)}')" role="img" aria-label="${titleEsc} pattern thumbnail"></div>
+      <div class="card-meta">
+        <span class="card-title">${titleEsc}</span>
+        <span class="card-sku" title="DW catalog SKU">Designer Wallcoverings · ${escAttr(safe.sku)}</span>
+        <span class="card-colorname">
+          <span class="color-dot" style="background:${escAttr(safe.dominant_hex)}" aria-hidden="true"></span>
+          <span class="card-hex">${escAttr(safe.dominant_hex)}</span>
+        </span>
+        <span class="card-palette" aria-hidden="true">${palette}</span>
+        <span class="card-tags">
+          <span class="card-style">${escAttr(styleTag)}</span>
+          <span class="card-hue">${escAttr(hueTag)}</span>
+          <span class="card-ai-badge" title="From the curated designer library">★ Designer</span>
+        </span>
+      </div>
+    </a>
+  </div>`;
+}
+
+// 4-square hero — picks the 4 most-saturated rows from the top 100 by chroma.
+function pickLibraryHero() {
+  const candidates = LIBRARY.rows.filter(r => r.image_url && r.palette && r.palette.length >= 3);
+  function chromaOf(hex) {
+    const [r,g,b] = hexToRgb(hex);
+    const max = Math.max(r,g,b), min = Math.min(r,g,b);
+    return max ? (max-min)/max : 0;
+  }
+  const scored = candidates.slice(0, 5000).map(r => ({
+    r, score: r.palette.reduce((s,h) => s + chromaOf(h), 0)
+  })).sort((a,b) => b.score - a.score).slice(0, 100);
+  const pick4 = [];
+  const used = new Set();
+  while (pick4.length < 4 && pick4.length < scored.length) {
+    const idx = Math.floor(Math.random() * scored.length);
+    if (used.has(idx)) continue;
+    used.add(idx);
+    pick4.push(scored[idx].r);
+  }
+  return pick4.map(r => redactLibraryRow(r));
+}
+
+function mount(app, opts = {}) {
+  const htmlHead   = opts.htmlHead;   // function
+  const htmlHeader = opts.htmlHeader; // function
+  const getDesigns = opts.getDesigns || (() => []);  // returns DESIGNS array
+
+  loadLibrary();
+
+  // ── GET /library
+  app.get('/library', (req, res) => {
+    const sort = String(req.query.sort || 'newest');
+    const hue = String(req.query.hue || '');
+    const style = String(req.query.style || '');
+    const material = String(req.query.material || '');
+    const q = String(req.query.q || '');
+    const page = Math.max(1, parseInt(req.query.page || '1', 10));
+    const PER = 60;
+    let filtered = filterLibrary({ q, hue, style, material });
+    filtered = sortLibraryRows(filtered, sort);
+    const total = filtered.length;
+    const pages = Math.max(1, Math.ceil(total / PER));
+    const slice = filtered.slice((page-1)*PER, page*PER);
+    const hero = pickLibraryHero();
+    const heroCells = hero.map(h => `
+      <a class="lh-cell" href="/library/${h.id}" aria-label="${escAttr(h.title)}">
+        <span class="lh-img" style="background-image:url('${escAttr(h.image_url)}')"></span>
+        <span class="lh-sku">Designer Wallcoverings · ${escAttr(h.sku)}</span>
+      </a>`).join('');
+    const cards = slice.map(renderLibraryCard).join('\n');
+
+    const hueOpts = Object.entries(LIBRARY.stats.hue_buckets || {})
+      .sort((a,b) => b[1]-a[1])
+      .map(([k,v]) => `<option value="${k}" ${hue===k?'selected':''}>${k} (${v})</option>`).join('');
+    const styleOpts = Object.entries(LIBRARY.stats.style_buckets || {})
+      .sort((a,b) => b[1]-a[1])
+      .map(([k,v]) => `<option value="${k}" ${style===k?'selected':''}>${k} (${v})</option>`).join('');
+
+    const qs = (p) => {
+      const parts = [];
+      if (sort && sort !== 'newest') parts.push('sort=' + encodeURIComponent(sort));
+      if (hue) parts.push('hue=' + encodeURIComponent(hue));
+      if (style) parts.push('style=' + encodeURIComponent(style));
+      if (material) parts.push('material=' + encodeURIComponent(material));
+      if (q) parts.push('q=' + encodeURIComponent(q));
+      if (p && p !== 1) parts.push('page=' + p);
+      return parts.length ? '?' + parts.join('&') : '';
+    };
+
+    res.type('html').send(`${htmlHead({
+      title: `The Designer Library — ${total.toLocaleString()} wallpapers — wallco.ai`,
+      description: `Browse ${total.toLocaleString()} professional designer wallpapers from the curated Designer Wallcoverings library. Sort by color, style, era. Free samples.`,
+      canonical: `https://wallco.ai/library`,
+      preloadImages: slice.slice(0, 6).map(r => r.image_url).filter(Boolean)
+    })}
+<body>
+${htmlHeader('/library')}
+<main id="main-content">
+  <section class="library-hero" style="position:relative;padding:48px 24px 32px;background:linear-gradient(180deg,#faf8f3 0%,#f1ebdf 100%);overflow:hidden">
+    <div style="max-width:1200px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:32px;align-items:center">
+      <div>
+        <p style="font:11px/1 var(--sans,system-ui);letter-spacing:.32em;text-transform:uppercase;color:#8a6f44;margin:0 0 12px;font-weight:500">The curated library</p>
+        <h1 style="font-family:'Cormorant Garamond','Playfair Display',serif;font-weight:300;font-size:clamp(40px,5vw,68px);line-height:1.02;margin:0 0 18px;color:#1f1808">${total.toLocaleString()} designer wallpapers</h1>
+        <p style="font:15px/1.65 var(--sans,system-ui);color:#5a4632;margin:0 0 22px;max-width:520px">A hand-built archive of the world's great wallcovering houses, curated under the Designer Wallcoverings house brand. Every pattern professionally documented — full color palettes, design era, material, mood, and room suitability.</p>
+        <div style="display:flex;gap:10px;flex-wrap:wrap;font:11px var(--sans,system-ui);letter-spacing:.04em">
+          <span style="padding:5px 12px;border:1px solid #8a6f44;border-radius:999px;color:#8a6f44">${(LIBRARY.stats.unique_vendors||0)} maker houses</span>
+          <span style="padding:5px 12px;border:1px solid #8a6f44;border-radius:999px;color:#8a6f44">${(LIBRARY.stats.with_palette_3plus||0).toLocaleString()} with palette analysis</span>
+          <span style="padding:5px 12px;border:1px solid #c5984a;border-radius:999px;color:#c5984a;background:#fef9eb">★ Designer-curated</span>
+        </div>
+      </div>
+      <div class="library-hero-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:10px;aspect-ratio:1/1;max-width:480px;justify-self:end;width:100%">
+        ${heroCells}
+      </div>
+    </div>
+  </section>
+
+  <section class="catalog-section" style="padding:32px 24px">
+    <div class="catalog-toolbar" style="max-width:1400px;margin:0 auto 24px">
+      <form method="get" action="/library" class="filter-form" id="lib-filter-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
+        <input type="search" name="q" value="${escAttr(q)}" placeholder="Search by title, SKU, color, style…" class="search-input" style="flex:1;min-width:220px;padding:7px 12px;border:1px solid var(--line,#d8d0c0);border-radius:999px;font:13px var(--sans)">
+        <select name="hue" class="filter-select" onchange="this.form.submit()" style="padding:6px 12px;border:1px solid var(--line);border-radius:999px;font:12px var(--sans);background:#fff">
+          <option value="">All Colors</option>
+          ${hueOpts}
+        </select>
+        <select name="style" class="filter-select" onchange="this.form.submit()" style="padding:6px 12px;border:1px solid var(--line);border-radius:999px;font:12px var(--sans);background:#fff">
+          <option value="">All Styles</option>
+          ${styleOpts}
+        </select>
+        <select name="sort" class="filter-select" onchange="this.form.submit()" style="padding:6px 12px;border:1px solid var(--line);border-radius:999px;font:12px var(--sans);background:#fff">
+          <option value="newest"     ${sort==='newest'?'selected':''}>Newest</option>
+          <option value="color"      ${sort==='color'?'selected':''}>Color (grouped)</option>
+          <option value="style"      ${sort==='style'?'selected':''}>Style</option>
+          <option value="sku"        ${sort==='sku'?'selected':''}>SKU A→Z</option>
+          <option value="title"      ${sort==='title'?'selected':''}>Title A→Z</option>
+          <option value="light-dark" ${sort==='light-dark'?'selected':''}>Light → Dark</option>
+          <option value="dark-light" ${sort==='dark-light'?'selected':''}>Dark → Light</option>
+          <option value="price-asc"  ${sort==='price-asc'?'selected':''}>Price ↑</option>
+          <option value="price-desc" ${sort==='price-desc'?'selected':''}>Price ↓</option>
+        </select>
+        <label style="display:flex;align-items:center;gap:8px;font:11px var(--sans);color:var(--ink-faint,#888)">
+          <span>Density</span>
+          <input id="lib-density" type="range" min="180" max="380" step="20" value="240" style="width:120px">
+        </label>
+        <button type="submit" style="display:none">Filter</button>
+      </form>
+      <div style="font:12px var(--sans,system-ui);color:#7a6a52;margin-top:10px">Showing <strong>${slice.length}</strong> of <strong>${total.toLocaleString()}</strong> · page ${page}/${pages}</div>
+    </div>
+
+    <div class="design-grid library-grid" id="library-grid" style="max-width:1400px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px">
+      ${cards}
+    </div>
+
+    <nav class="lib-pagination" style="max-width:1400px;margin:32px auto 0;display:flex;justify-content:center;gap:10px;font:12px var(--sans,system-ui)">
+      ${page > 1 ? `<a href="/library${qs(page-1)}" style="padding:7px 16px;border:1px solid var(--line);border-radius:999px;color:var(--ink);text-decoration:none">← Prev</a>` : ''}
+      <span style="padding:7px 16px;color:var(--ink-faint)">Page ${page} of ${pages}</span>
+      ${page < pages ? `<a href="/library${qs(page+1)}" style="padding:7px 16px;border:1px solid var(--line);border-radius:999px;color:var(--ink);text-decoration:none">Next →</a>` : ''}
+    </nav>
+  </section>
+</main>
+
+<style>
+.library-card { background:var(--card-bg,#faf8f3); border:1px solid var(--line,#e6dfd0); border-radius:6px; overflow:hidden; transition:transform .18s ease, box-shadow .18s ease; }
+.library-card:hover { transform:translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.08); }
+.library-card .card-img { aspect-ratio:1/1; background-size:cover; background-position:center 12%; }
+.library-card .card-meta { padding:10px 12px 12px; display:flex; flex-direction:column; gap:4px; font:12px var(--sans,system-ui); color:var(--ink,#2a1f10); }
+.library-card .card-title { font:500 13px var(--sans,system-ui); color:var(--ink,#1f1808); line-height:1.2; }
+.library-card .card-sku   { font:11px var(--sans,system-ui); color:var(--ink-faint,#8a7a5e); letter-spacing:.04em; }
+.library-card .card-colorname { display:inline-flex; align-items:center; gap:6px; font:11px ui-monospace,Menlo,monospace; color:#5a4632; }
+.library-card .color-dot { width:11px; height:11px; border-radius:50%; border:1px solid rgba(0,0,0,.08); display:inline-block; }
+.library-card .card-palette { display:inline-flex; gap:3px; height:9px; margin-top:2px; }
+.library-card .lib-sw { width:14px; height:9px; border-radius:2px; display:inline-block; border:1px solid rgba(0,0,0,.06); }
+.library-card .card-tags { display:flex; gap:5px; flex-wrap:wrap; margin-top:2px; }
+.library-card .card-style, .library-card .card-hue { font:10px var(--sans,system-ui); padding:2px 6px; background:var(--bg,#fff); border:1px solid var(--line,#e0d9cd); border-radius:3px; color:#7a6a52; text-transform:capitalize; }
+.library-card .card-ai-badge { font:10px var(--sans,system-ui); padding:2px 6px; background:#fef9eb; border:1px solid #d4b46a; border-radius:3px; color:#7a5e2a; }
+.library-card .card-link { display:block; color:inherit; text-decoration:none; }
+
+.library-hero-grid .lh-cell { position:relative; overflow:hidden; border-radius:4px; aspect-ratio:1/1; display:block; text-decoration:none; color:inherit; }
+.library-hero-grid .lh-img  { position:absolute; inset:0; background-size:cover; background-position:center 12%; transition:transform .4s ease; }
+.library-hero-grid .lh-cell:hover .lh-img { transform:scale(1.04); }
+.library-hero-grid .lh-sku  { position:absolute; left:8px; bottom:8px; font:10px var(--sans,system-ui); padding:2px 7px; background:rgba(255,255,255,.92); border-radius:3px; color:#3a2818; }
+
+#lib-density { accent-color:#8a6f44; }
+</style>
+
+<script>
+(function(){
+  var key = 'wallco.library.density';
+  var slider = document.getElementById('lib-density');
+  var grid = document.getElementById('library-grid');
+  function apply(v) {
+    if (!grid) return;
+    grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(' + v + 'px, 1fr))';
+  }
+  var saved = parseInt(localStorage.getItem(key), 10);
+  if (saved && saved >= 180 && saved <= 380) { slider.value = saved; apply(saved); }
+  else { apply(slider.value); }
+  slider.addEventListener('input', function(){
+    apply(slider.value);
+    localStorage.setItem(key, slider.value);
+  });
+  var sortKey = 'wallco.library.sort';
+  var sortSel = document.querySelector('select[name=sort]');
+  if (sortSel) {
+    var savedSort = localStorage.getItem(sortKey);
+    if (savedSort && savedSort !== sortSel.value && !location.search.includes('sort=')) {
+      sortSel.value = savedSort;
+    }
+    sortSel.addEventListener('change', function(){ localStorage.setItem(sortKey, sortSel.value); });
+  }
+})();
+</script>
+</body></html>`);
+  });
+
+  // ── GET /api/library
+  app.get('/api/library', (req, res) => {
+    const sort = String(req.query.sort || 'newest');
+    const hue = String(req.query.hue || '');
+    const style = String(req.query.style || '');
+    const material = String(req.query.material || '');
+    const q = String(req.query.q || '');
+    const limit = Math.min(500, Math.max(1, parseInt(req.query.limit || '60', 10)));
+    const offset = Math.max(0, parseInt(req.query.offset || '0', 10));
+    let rows = filterLibrary({ q, hue, style, material });
+    rows = sortLibraryRows(rows, sort);
+    const total = rows.length;
+    const slice = rows.slice(offset, offset + limit).map(redactLibraryRow);
+    res.json({
+      ok: true,
+      total, offset, limit, sort,
+      filters: { q, hue, style, material },
+      rows: slice
+    });
+  });
+
+  // ── GET /api/library/equivalents/:libId
+  app.get('/api/library/equivalents/:libId', (req, res) => {
+    const libId = parseInt(req.params.libId, 10);
+    const n = Math.min(20, Math.max(1, parseInt(req.query.n || '5', 10)));
+    const target = LIBRARY.rows.find(r => r.id === libId);
+    if (!target || !target.dominant_hex) {
+      return res.status(404).json({ ok: false, error: 'unknown library row' });
+    }
+    const tLab = rgbToLab(hexToRgb(target.dominant_hex));
+    const scored = LIBRARY.rows
+      .filter(r => r.id !== libId && r.dominant_hex && r.image_url)
+      .map(r => ({ r, d: labDistance(tLab, rgbToLab(hexToRgb(r.dominant_hex))) }))
+      .sort((a,b) => a.d - b.d)
+      .slice(0, n)
+      .map(x => ({ ...redactLibraryRow(x.r), _distance: Math.round(x.d * 10) / 10 }));
+    res.json({ ok: true, target_id: libId, equivalents: scored });
+  });
+
+  // ── GET /api/design/:id/library-equivalents
+  // Real-world equivalents for an AI design — picks N closest library items by
+  // dominant_hex LAB distance, with a small style-bucket bias.
+  app.get('/api/design/:id/library-equivalents', (req, res) => {
+    const id = parseInt(req.params.id, 10);
+    const n = Math.min(10, Math.max(1, parseInt(req.query.n || '5', 10)));
+    const designs = getDesigns();
+    const design = designs.find(d => d.id === id);
+    if (!design || !design.dominant_hex) {
+      return res.status(404).json({ ok: false, error: 'unknown design' });
+    }
+    const tLab = rgbToLab(hexToRgb(design.dominant_hex));
+    // Style bias — map AI design category → library style bucket so e.g.
+    // an AI "damask" design favors library Damask rows. 60-30-10 logic:
+    // color is 60% of the score (raw LAB), style match 30%, era match 10%.
+    const aiCat = String(design.category || '').toLowerCase();
+    function styleAffinity(libStyle) {
+      const s = String(libStyle || '').toLowerCase();
+      if (!aiCat) return 0;
+      if (aiCat.includes('damask') && s === 'damask') return -8;
+      if (aiCat.includes('floral') && s === 'floral') return -8;
+      if (aiCat.includes('geometric') && s === 'geometric') return -8;
+      if (aiCat.includes('mural') && s === 'mural') return -10;
+      if (aiCat.includes('chinois') && s === 'chinoiserie') return -10;
+      return 0;
+    }
+    const scored = LIBRARY.rows
+      .filter(r => r.dominant_hex && r.image_url)
+      .map(r => {
+        const colorD = labDistance(tLab, rgbToLab(hexToRgb(r.dominant_hex)));
+        const score = colorD + styleAffinity(r.style_bucket);
+        return { r, d: colorD, score };
+      })
+      .sort((a,b) => a.score - b.score)
+      .slice(0, n)
+      .map(x => ({ ...redactLibraryRow(x.r), _distance: Math.round(x.d * 10) / 10 }));
+    res.json({ ok: true, design_id: id, design_hex: design.dominant_hex, equivalents: scored });
+  });
+
+  // ── GET /library/:id — detail page
+  app.get('/library/:id', (req, res) => {
+    const id = parseInt(req.params.id, 10);
+    const raw = LIBRARY.rows.find(r => r.id === id);
+    if (!raw) {
+      return res.status(404).type('html').send(`${htmlHead({
+        title: 'Not found — wallco.ai',
+        description: 'This library entry could not be located.',
+        canonical: `https://wallco.ai/library/${id}`
+      })}
+<body>${htmlHeader('/library')}<main style="padding:80px 24px;text-align:center">
+<h1 style="font-family:'Cormorant Garamond',serif;font-weight:300">Pattern not found.</h1>
+<p><a href="/library">← Back to the library</a></p>
+</main></body></html>`);
+    }
+    const row = redactLibraryRow(raw);
+    const swatches = (row.palette || []).map(h => `<span class="d-sw" style="background:${escAttr(h)}" title="${escAttr(h)}"></span>`).join('');
+    const tagPills = (row.tags_redacted || []).slice(0,8).map(t => `<span class="d-pill">${escAttr(t)}</span>`).join('');
+
+    res.type('html').send(`${htmlHead({
+      title: `${row.title} — Designer Wallcoverings · ${row.sku} — wallco.ai`,
+      description: `${row.title}: a ${row.style_bucket} ${row.hue_bucket} wallpaper from the Designer Wallcoverings library. ${row.material || 'Premium wallcovering'}. Free sample.`,
+      canonical: `https://wallco.ai/library/${id}`,
+      ogImage: row.image_url,
+      ogType: 'product',
+      productOG: { availability: 'in stock', retailer_item_id: row.sku, brand: 'Designer Wallcoverings' }
+    })}
+<body>${htmlHeader('/library')}
+<main id="main-content" style="max-width:1200px;margin:0 auto;padding:32px 24px">
+  <div style="display:grid;grid-template-columns:1fr 1fr;gap:48px;align-items:start">
+    <div>
+      <img src="${escAttr(row.image_url)}" alt="${escAttr(row.title)}" style="width:100%;border-radius:6px;border:1px solid var(--line)" loading="eager">
+    </div>
+    <div>
+      <p style="font:11px var(--sans);letter-spacing:.32em;text-transform:uppercase;color:#8a6f44;margin:0 0 8px">Designer Wallcoverings</p>
+      <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:clamp(34px,4vw,52px);line-height:1.05;margin:0 0 8px">${escAttr(row.title)}</h1>
+      <p style="font:13px ui-monospace,Menlo,monospace;color:#5a4632;margin:0 0 20px">${escAttr(row.sku)}</p>
+      <div style="display:flex;gap:8px;margin:0 0 18px">${swatches}</div>
+      <dl style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font:13px var(--sans);color:#3a2818;margin:0 0 24px">
+        ${row.style_bucket ? `<dt style="color:#8a7a5e">Style</dt><dd>${escAttr(row.style_bucket)}</dd>`: ''}
+        ${row.hue_bucket ? `<dt style="color:#8a7a5e">Color family</dt><dd>${escAttr(row.hue_bucket)}</dd>`: ''}
+        ${row.material ? `<dt style="color:#8a7a5e">Material</dt><dd>${escAttr(row.material)}</dd>`: ''}
+        ${row.design_era ? `<dt style="color:#8a7a5e">Era</dt><dd>${escAttr(row.design_era)}</dd>`: ''}
+        ${row.mood ? `<dt style="color:#8a7a5e">Mood</dt><dd>${escAttr(row.mood)}</dd>`: ''}
+        ${row.price ? `<dt style="color:#8a7a5e">Sample / Trade</dt><dd>$${row.price.toFixed(2)}</dd>`: ''}
+      </dl>
+      <div style="display:flex;gap:10px;flex-wrap:wrap;margin:0 0 24px">${tagPills}</div>
+      <div style="display:flex;gap:10px;flex-wrap:wrap">
+        <a href="/samples?library=${id}" style="display:inline-block;padding:11px 22px;background:#1f1808;color:#fff;text-decoration:none;border-radius:999px;font:12px var(--sans);letter-spacing:.08em;text-transform:uppercase">Order a sample</a>
+        <a href="#equivalents" style="display:inline-block;padding:11px 22px;background:transparent;color:#1f1808;text-decoration:none;border-radius:999px;border:1px solid var(--line);font:12px var(--sans);letter-spacing:.08em;text-transform:uppercase">See similar patterns</a>
+      </div>
+    </div>
+  </div>
+  <section id="equivalents" style="margin-top:64px">
+    <h2 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:32px;margin:0 0 18px">More like this</h2>
+    <div id="lib-equivalents-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px"></div>
+  </section>
+</main>
+<style>
+.d-sw { display:inline-block; width:32px; height:32px; border-radius:50%; border:1px solid rgba(0,0,0,.08); }
+.d-pill { display:inline-block; padding:3px 10px; background:var(--card-bg,#faf8f3); border:1px solid var(--line); border-radius:999px; font:11px var(--sans); color:#5a4632; }
+</style>
+<script>
+fetch('/api/library/equivalents/${id}?n=8').then(r=>r.json()).then(j=>{
+  if (!j.ok) return;
+  var grid = document.getElementById('lib-equivalents-grid');
+  if (!grid) return;
+  grid.innerHTML = j.equivalents.map(function(e){
+    var sw = (e.palette||[]).slice(0,4).map(function(h){return '<span style="display:inline-block;width:11px;height:11px;border-radius:50%;background:'+h+';margin-right:2px"></span>';}).join('');
+    return '<a href="/library/'+e.id+'" style="display:block;color:inherit;text-decoration:none;background:#faf8f3;border:1px solid #e6dfd0;border-radius:6px;overflow:hidden">' +
+      '<span style="display:block;aspect-ratio:1/1;background:url(\\''+e.image_url+'\\') center 12%/cover"></span>' +
+      '<span style="display:block;padding:8px 10px;font:12px var(--sans,system-ui)">' +
+        '<span style="font-weight:500">'+(e.title||'').replace(/[<>]/g,'')+'</span><br>' +
+        '<span style="font:10px ui-monospace;color:#8a7a5e">Designer Wallcoverings · '+e.sku+'</span><br>' +
+        '<span style="display:block;margin-top:3px">'+sw+'</span>' +
+      '</span></a>';
+  }).join('');
+});
+</script>
+</body></html>`);
+  });
+
+  console.log('  Library layer mounted (/library, /api/library, /library/:id, equivalents APIs)');
+}
+
+module.exports = { mount, loadLibrary, redactLibraryRow, sortLibraryRows, filterLibrary, renderLibraryCard };

← c79c5bc server: mount /admin/inspirations + /api/admin/inspirations/  ·  back to Wallco Ai  ·  admin: side-by-side inspirations gallery (real seed × wallco f4153d0 →