← 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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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 →