← back to Thedesignerlibrary
agent-review refinements: GPU disposal + tap-to-flip + XSS escaping + SEO surface (robots/sitemap/OG/JSON-LD/SSR browse) + perf caches + luxury type scale
5808dabd221fcfb63205fa6054241b3f6216b676 · 2026-08-07 08:15:10 -0700 · Steve Abrams
Files touched
M public/bookshelf.jsM public/browse.htmlA public/favicon.svgM public/index.htmlA public/og-image.jpgR100 public/vendor/three.module.js public/vendor/three-0.160.0.module.jsM server.js
Diff
commit 5808dabd221fcfb63205fa6054241b3f6216b676
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 08:15:10 2026 -0700
agent-review refinements: GPU disposal + tap-to-flip + XSS escaping + SEO surface (robots/sitemap/OG/JSON-LD/SSR browse) + perf caches + luxury type scale
---
public/bookshelf.js | 71 +++++++--
public/browse.html | 105 +++++++++-----
public/favicon.svg | 8 +
public/index.html | 97 +++++++++++--
public/og-image.jpg | Bin 0 -> 51797 bytes
.../{three.module.js => three-0.160.0.module.js} | 0
server.js | 161 +++++++++++++++------
7 files changed, 343 insertions(+), 99 deletions(-)
diff --git a/public/bookshelf.js b/public/bookshelf.js
index 3b02d44..c440f55 100644
--- a/public/bookshelf.js
+++ b/public/bookshelf.js
@@ -49,6 +49,7 @@ export class BookshelfCatalog {
accent: '#c9a227',
autoRotateHint: true, // gentle idle drift on the shelf
onOpen: null, // (book, index) => void
+ onClose: null, // (book) => void ; fires when the book is reshelved
onPageChange: null, // (book, pageIndex) => void
onLinkClick: null, // (page, book) => void ; return false to prevent navigation
}, opts);
@@ -154,19 +155,30 @@ export class BookshelfCatalog {
return new THREE.Color().setHSL(hue / 360, 0.42, 0.42).getStyle();
}
+ // spine/cover text ink chosen by luminance — white titles vanish on the
+ // White/Cream and Greige hue-books, so light spines get dark walnut ink
+ _inkFor(spineColor) {
+ const { r, g, b } = spineColor;
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ return lum > 0.6
+ ? { main: '#2b2118', dim: 'rgba(43,33,24,0.75)', band: 'rgba(43,33,24,0.18)' }
+ : { main: '#fff', dim: 'rgba(255,255,255,0.82)', band: 'rgba(255,255,255,0.10)' };
+ }
+
_spineTexture(book, spineColor) {
const cw = 256, ch = 1024;
const c = document.createElement('canvas'); c.width = cw; c.height = ch;
const g = c.getContext('2d');
+ const ink = this._inkFor(spineColor);
g.fillStyle = spineColor.getStyle(); g.fillRect(0, 0, cw, ch);
// subtle top/bottom bands
- g.fillStyle = 'rgba(255,255,255,0.10)'; g.fillRect(0, 40, cw, 6); g.fillRect(0, ch - 46, cw, 6);
+ g.fillStyle = ink.band; g.fillRect(0, 40, cw, 6); g.fillRect(0, ch - 46, cw, 6);
// vertical title
g.save(); g.translate(cw / 2, ch / 2); g.rotate(-Math.PI / 2);
- g.fillStyle = '#fff'; g.textAlign = 'center'; g.textBaseline = 'middle';
+ g.fillStyle = ink.main; g.textAlign = 'center'; g.textBaseline = 'middle';
g.font = '600 62px Georgia, serif';
g.fillText(this._fit(g, book.title || 'Untitled', ch - 120), 0, -18);
- if (book.subtitle) { g.font = '400 34px Georgia, serif'; g.fillStyle = 'rgba(255,255,255,0.82)'; g.fillText(this._fit(g, book.subtitle, ch - 160), 0, 46); }
+ if (book.subtitle) { g.font = '400 34px Georgia, serif'; g.fillStyle = ink.dim; g.fillText(this._fit(g, book.subtitle, ch - 160), 0, 46); }
g.restore();
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4; return tex;
}
@@ -179,11 +191,13 @@ export class BookshelfCatalog {
grad.addColorStop(0, spineColor.clone().offsetHSL(0, 0, 0.08).getStyle());
grad.addColorStop(1, spineColor.clone().offsetHSL(0, 0, -0.10).getStyle());
g.fillStyle = grad; g.fillRect(0, 0, cw, ch);
- g.strokeStyle = 'rgba(255,255,255,0.35)'; g.lineWidth = 3; g.strokeRect(24, 24, cw - 48, ch - 48);
- g.fillStyle = '#fff'; g.textAlign = 'center';
+ const ink = this._inkFor(spineColor);
+ g.strokeStyle = ink.main === '#fff' ? 'rgba(255,255,255,0.35)' : 'rgba(43,33,24,0.35)';
+ g.lineWidth = 3; g.strokeRect(24, 24, cw - 48, ch - 48);
+ g.fillStyle = ink.main; g.textAlign = 'center';
g.font = '700 52px Georgia, serif';
this._wrap(g, book.title || 'Untitled', cw / 2, 220, cw - 100, 60);
- if (book.subtitle) { g.font = '400 30px Georgia, serif'; g.fillStyle = 'rgba(255,255,255,0.85)'; g.fillText(this._fit(g, book.subtitle, cw - 100), cw / 2, ch - 90); }
+ if (book.subtitle) { g.font = '400 30px Georgia, serif'; g.fillStyle = ink.dim; g.fillText(this._fit(g, book.subtitle, cw - 100), cw / 2, ch - 90); }
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4; return tex;
}
@@ -204,10 +218,13 @@ export class BookshelfCatalog {
g.fillText(`${pageNo} / ${count}`, cw - 44, ch - 34);
if (page.href) { g.fillStyle = this.opts.accent; g.textAlign = 'left'; g.font = '600 22px Georgia, serif'; g.fillText('View ›', 44, ch - 40); }
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4;
- // async swap in the real image on top of the placeholder, if provided
+ // async swap in the real image on top of the placeholder, if provided.
+ // Guard on _destroyed: the load can outlive a shelf-tab switch, and writing
+ // to a disposed texture is a WebGL error waiting to happen.
if (page.image) {
const img = new Image(); img.crossOrigin = 'anonymous';
img.onload = () => {
+ if (this._destroyed) return;
const r = Math.min((cw - 80) / img.width, 420 / img.height);
const dw = img.width * r, dh = img.height * r;
g.fillStyle = '#e8e3d6'; g.fillRect(40, 40, cw - 80, 420);
@@ -287,6 +304,9 @@ export class BookshelfCatalog {
dragging = false;
if (moved < 5) this._handleClick(e); // treat as a click, not a drag
});
+ // browser-cancelled pointer (two-finger scroll etc.) must clear the drag
+ // state or the next tap gets misread as a drag
+ el.addEventListener('pointercancel', () => { dragging = false; });
// keyboard: arrows page/scroll, Esc closes (stored so destroy() can unbind —
// this instance is rebuilt on every shelf-tab switch)
@@ -316,11 +336,16 @@ export class BookshelfCatalog {
const hits = this.raycaster.intersectObjects(this._bookGroups.map(g => g.userData.body), false);
if (hits.length) { this.openBook(hits[0].object.userData.index); return; }
} else if (this.state === 'reading') {
- // click right half -> next, left half -> prev, backdrop -> close
+ // right page: tap flips forward; ONLY the "View ›" strip (bottom-left of
+ // the page texture) opens the store link — otherwise a touch user can
+ // never see page 2 because every page has an href. left page: back.
+ // backdrop: close.
const hits = this.raycaster.intersectObjects([this._leftPage, this._rightPage], false);
if (hits.length) {
const page = this.books[this.activeIndex].pages?.[this.pageIndex];
- if (hits[0].object === this._rightPage && page && page.href) {
+ const uv = hits[0].uv;
+ const onViewStrip = uv && uv.y < 0.14 && uv.x < 0.5;
+ if (hits[0].object === this._rightPage && page && page.href && onViewStrip) {
const ok = this.opts.onLinkClick ? this.opts.onLinkClick(page, this.books[this.activeIndex]) : true;
if (ok !== false) window.open(page.href, '_blank', 'noopener,noreferrer');
return;
@@ -339,6 +364,7 @@ export class BookshelfCatalog {
const g = this._bookGroups[index];
g.userData.body.visible = false; // hide the shelved spine
const open = this._ensureOpenBook(); open.visible = true;
+ open.scale.setScalar(this._openBookScale());
this._paintSpread(this.books[index]);
// animate: book flies from shelf to reading position + opens flat
open.position.set(g.position.x - this._scrollXCurrent, 0.4, 0.5);
@@ -352,10 +378,21 @@ export class BookshelfCatalog {
if (this.opts.onOpen) this.opts.onOpen(this.books[index], index);
}
+ // scale the open spread down on narrow (portrait) viewports so both pages
+ // stay inside the frame — on a phone the full-size spread crops both edges
+ _openBookScale() {
+ const dist = 11 - 6.2; // camera z - reading z
+ const visH = 2 * dist * Math.tan((this.camera.fov / 2) * Math.PI / 180);
+ const visW = visH * this.camera.aspect;
+ const spreadW = BOOK_W * 2 + 0.1;
+ return clamp((visW * 0.94) / spreadW, 0.3, 1);
+ }
+
closeBook() {
if (this.state !== 'reading') return;
this.state = 'closing';
const open = this._openBook; const g = this._bookGroups[this.activeIndex];
+ const closedBook = this.books[this.activeIndex];
this._tween(this._leftPage.rotation, 'y', 0, 0.9, 400, easeInOutCubic);
this._tween(this._rightPage.rotation, 'y', 0, -0.9, 400, easeInOutCubic);
this._tween(open.position, 'z', open.position.z, 0.5, 520, easeInOutCubic);
@@ -363,6 +400,7 @@ export class BookshelfCatalog {
this._tween(open.rotation, 'y', 0, -0.9, 520, easeInOutCubic, () => {
open.visible = false; g.userData.body.visible = true;
this.state = 'shelf'; this.activeIndex = -1;
+ if (this.opts.onClose) this.opts.onClose(closedBook);
});
}
@@ -383,8 +421,7 @@ export class BookshelfCatalog {
const from = page.rotation.y, mid = dir > 0 ? -Math.PI * 0.5 : Math.PI * 0.5;
this._tween(page.rotation, 'y', from, mid, 160, easeInOutCubic, () => {
this._paintSpread(this.books[this.activeIndex]);
- this._tween(page.rotation, 'y', mid, dir > 0 ? 0 : 0, 160, easeInOutCubic);
- page.rotation.y = 0;
+ this._tween(page.rotation, 'y', mid, 0, 160, easeInOutCubic);
});
}
@@ -426,12 +463,24 @@ export class BookshelfCatalog {
const w = this.container.clientWidth || 1, h = this.container.clientHeight || 1;
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
+ if (this._openBook && this._openBook.visible) this._openBook.scale.setScalar(this._openBookScale());
}
destroy() {
+ this._destroyed = true;
cancelAnimationFrame(this._raf);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('keydown', this._onKeydown);
+ // renderer.dispose() alone leaves every geometry/material/CanvasTexture on
+ // the GPU — this instance is rebuilt per shelf-tab switch, so without the
+ // traversal 4-5 switches visibly stutter a mobile GPU
+ this.scene.traverse((obj) => {
+ if (obj.geometry) obj.geometry.dispose();
+ if (obj.material) {
+ const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
+ for (const m of mats) { if (m.map) m.map.dispose(); m.dispose(); }
+ }
+ });
this.renderer.dispose();
if (this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
diff --git a/public/browse.html b/public/browse.html
index d2e736d..03dd7b9 100644
--- a/public/browse.html
+++ b/public/browse.html
@@ -4,25 +4,45 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browse the Catalog — The Designer Library</title>
-<meta name="description" content="Browse 84,000+ designer wallcoverings by brand, collection, style and hue.">
+<meta name="description" content="Browse 84,000+ designer wallcoverings from Phillipe Romano, Malibu Wallpaper, Rebel Walls, Clarke & Clarke, Thibaut and 40+ more brands — filtered by hue, style, collection or keyword. The full Designer Wallcoverings catalog, organized.">
+<link rel="canonical" href="https://thedesignerlibrary.com/browse">
+<link rel="icon" href="/favicon.svg" type="image/svg+xml">
+<meta property="og:type" content="website">
+<meta property="og:url" content="https://thedesignerlibrary.com/browse">
+<meta property="og:title" content="Browse the Catalog — The Designer Library">
+<meta property="og:description" content="Browse 84,000+ designer wallcoverings by brand, collection, style and hue.">
+<meta property="og:image" content="https://thedesignerlibrary.com/og-image.jpg">
+<meta property="og:site_name" content="The Designer Library">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="Browse the Catalog — The Designer Library">
+<meta name="twitter:description" content="Browse 84,000+ designer wallcoverings by brand, collection, style and hue.">
+<meta name="twitter:image" content="https://thedesignerlibrary.com/og-image.jpg">
<style>
:root { --bg:#171310; --ink:#efe8db; --dim:#a99e8d; --brass:#c9a227; --cols:5; }
* { box-sizing:border-box; }
- body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 Georgia, serif; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 Georgia, serif;
+ padding-bottom:90px; /* keep the last row + status clear of the DW-network footer-strip ad */ }
header { display:flex; align-items:center; justify-content:space-between; padding:16px 22px; }
- .wordmark { text-decoration:none; color:var(--ink); letter-spacing:.16em; font-size:18px; font-weight:600; text-transform:uppercase; }
+ .wordmark { text-decoration:none; color:var(--ink); letter-spacing:.14em;
+ font-size:clamp(26px, 2.8vw, 42px); font-weight:600; text-transform:uppercase; white-space:nowrap; }
.wordmark em { color:var(--brass); font-style:normal; }
+ .tagline { font-style:italic; color:var(--dim); font-size:13px; margin-left:14px; letter-spacing:.04em; }
.controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:0 22px 14px; }
select,input[type=search] { background:#211b16; color:var(--ink); border:1px solid rgba(201,162,39,.3);
border-radius:8px; padding:7px 10px; font:13px Georgia, serif; }
- .density { display:flex; align-items:center; gap:6px; color:var(--dim); font-size:12px; }
+ .density { display:flex; align-items:center; gap:6px; color:var(--dim); font-size:12px;
+ border:1px solid rgba(201,162,39,.3); border-radius:8px; padding:5px 10px; }
+ #density { accent-color:var(--brass); }
.chips { display:flex; gap:6px; flex-wrap:wrap; padding:0 22px 12px; }
.chip { color:var(--dim); border:1px solid rgba(255,255,255,.15); border-radius:999px; padding:3px 12px;
font-size:12px; cursor:pointer; text-decoration:none; }
.chip.on { color:#171310; background:var(--brass); border-color:var(--brass); }
#grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:14px; padding:0 22px 40px; }
- .card { background:#211b16; border:1px solid rgba(255,255,255,.06); border-radius:8px; overflow:hidden; }
+ .card { background:#211b16; border:1px solid rgba(201,162,39,.12); border-radius:4px; overflow:hidden;
+ transition:border-color .15s, box-shadow .15s; }
+ .card:hover { border-color:rgba(201,162,39,.4); box-shadow:0 4px 20px rgba(0,0,0,.4); }
.card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; cursor:pointer; }
+ @media (max-width:640px){ .tagline{display:none} .wordmark{font-size:17px} }
.card .meta { padding:8px 10px 10px; }
.card a.t { color:var(--ink); text-decoration:none; font-size:13px; display:block; }
.card a.t:hover { color:var(--brass); }
@@ -35,7 +55,7 @@
</head>
<body>
<header>
- <a class="wordmark" href="/">The Designer <em>Library</em></a>
+ <div><a class="wordmark" href="/">The Designer <em>Library</em></a><span class="tagline">Every collection, shelved.</span></div>
<a class="chip" href="/">← back to the shelves</a>
</header>
<div class="controls">
@@ -44,6 +64,7 @@
<option value="newest">Newest</option>
<option value="title">Title A→Z</option>
<option value="vendor">Brand A→Z</option>
+ <option value="collection">Collection A→Z</option>
<option value="light-dark">Light → Dark</option>
<option value="dark-light">Dark → Light</option>
<option value="wheel">Color Wheel</option>
@@ -65,9 +86,15 @@ const state = {
vendor: P.get('vendor') || '', hue: P.get('hue') || '', style: P.get('style') || '',
collection: P.get('collection') || '', page: 1, busy: false, done: false, total: 0,
};
+const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
$('#sort').value = state.sort; $('#q').value = state.q;
-$('#density').value = localStorage.tdl_cols || 5;
-document.documentElement.style.setProperty('--cols', $('#density').value);
+// first visit on a phone: 5 columns = 58px cards. Default by viewport, and
+// clamp a desktop-saved value so a synced localStorage can't wreck mobile.
+const defCols = window.innerWidth < 640 ? 2 : 5;
+let cols = parseInt(localStorage.tdl_cols) || defCols;
+if (window.innerWidth < 640) cols = Math.min(cols, 3);
+$('#density').value = cols;
+document.documentElement.style.setProperty('--cols', cols);
function syncUrl() {
const p = new URLSearchParams();
@@ -82,38 +109,47 @@ function syncUrl() {
async function loadFacets() {
const f = await fetch('/api/facets').then(r => r.json());
- $('#fVendor').innerHTML += f.vendors.map(v => `<option value="${v.value}">${v.value} (${v.n.toLocaleString()})</option>`).join('');
- $('#fHue').innerHTML += f.hues.map(v => `<option value="${v.value}">${v.value} (${v.n.toLocaleString()})</option>`).join('');
- $('#fStyle').innerHTML += f.styles.map(v => `<option value="${v.value}">${v.value} (${v.n.toLocaleString()})</option>`).join('');
+ const fill = (sel, list) => sel.append(...list.map(v => new Option(`${v.value} (${v.n.toLocaleString()})`, v.value)));
+ fill($('#fVendor'), f.vendors); fill($('#fHue'), f.hues); fill($('#fStyle'), f.styles);
$('#fVendor').value = state.vendor; $('#fHue').value = state.hue; $('#fStyle').value = state.style;
}
async function loadPage() {
if (state.busy || state.done) return;
state.busy = true;
- const p = new URLSearchParams({ sort: state.sort, page: state.page, limit: 48 });
- for (const k of ['q', 'vendor', 'hue', 'style', 'collection']) if (state[k]) p.set(k, state[k]);
- const data = await fetch('/api/products?' + p).then(r => r.json());
- state.total = data.total;
- const frag = document.createDocumentFragment();
- for (const pr of data.products) {
- const d = document.createElement('div'); d.className = 'card';
- d.innerHTML = `
- <img loading="lazy" src="${pr.image}" alt="${pr.title.replace(/"/g, '"')}">
- <div class="meta">
- <a class="t" href="${pr.url}" target="_blank" rel="nofollow sponsored noopener">${pr.hex ? `<span class="dot" style="background:${pr.hex}" title="more in this hue"></span>` : ''}${pr.title}</a>
- <div class="v"><a href="/browse?vendor=${encodeURIComponent(pr.vendor)}">${pr.vendor}</a>${pr.collection ? ` · <a href="/browse?vendor=${encodeURIComponent(pr.vendor)}&collection=${encodeURIComponent(pr.collection)}">${pr.collection}</a>` : ''}</div>
- </div>`;
- d.querySelector('img').onclick = () => window.open(pr.url, '_blank', 'noopener');
- if (pr.hue) d.querySelector('.dot')?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); state.hue = pr.hue; reset(); });
- frag.appendChild(d);
+ // without the try/finally, one failed fetch left busy=true forever and
+ // silently killed infinite scroll for the rest of the session
+ try {
+ const p = new URLSearchParams({ sort: state.sort, page: state.page, limit: 48 });
+ for (const k of ['q', 'vendor', 'hue', 'style', 'collection']) if (state[k]) p.set(k, state[k]);
+ const r = await fetch('/api/products?' + p);
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ const data = await r.json();
+ state.total = data.total;
+ const frag = document.createDocumentFragment();
+ for (const pr of data.products) {
+ const d = document.createElement('div'); d.className = 'card';
+ d.innerHTML = `
+ <img loading="lazy" src="${esc(pr.image)}" alt="${esc(pr.title)}">
+ <div class="meta">
+ <a class="t" href="${esc(pr.url)}" target="_blank" rel="nofollow sponsored noopener">${pr.hex ? `<span class="dot" style="background:${esc(pr.hex)}" title="more in this hue"></span>` : ''}${esc(pr.title)}</a>
+ <div class="v"><a href="/browse?vendor=${encodeURIComponent(pr.vendor)}">${esc(pr.vendor)}</a>${pr.collection ? ` · <a href="/browse?vendor=${encodeURIComponent(pr.vendor)}&collection=${encodeURIComponent(pr.collection)}">${esc(pr.collection)}</a>` : ''}</div>
+ </div>`;
+ d.querySelector('img').onclick = () => window.open(pr.url, '_blank', 'noopener');
+ if (pr.hue) d.querySelector('.dot')?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); state.hue = pr.hue; reset(); });
+ frag.appendChild(d);
+ }
+ $('#grid').appendChild(frag);
+ state.page++;
+ state.done = (state.page - 1) * 48 >= data.total;
+ $('#status').textContent = state.done
+ ? `${state.total.toLocaleString()} designs`
+ : `${Math.min((state.page - 1) * 48, state.total).toLocaleString()} of ${state.total.toLocaleString()} designs — scroll for more`;
+ } catch {
+ $('#status').textContent = 'trouble loading — scroll to retry';
+ } finally {
+ state.busy = false;
}
- $('#grid').appendChild(frag);
- state.page++; state.busy = false;
- state.done = (state.page - 1) * 48 >= data.total;
- $('#status').textContent = state.done
- ? `${state.total.toLocaleString()} designs`
- : `${Math.min((state.page - 1) * 48, state.total).toLocaleString()} of ${state.total.toLocaleString()} designs — scroll for more`;
}
function reset() { $('#grid').innerHTML = ''; state.page = 1; state.done = false; syncUrl(); loadPage(); }
@@ -127,6 +163,9 @@ $('#density').oninput = (e) => { localStorage.tdl_cols = e.target.value; documen
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY > document.body.offsetHeight - 900) loadPage();
});
+// the server SSRs the first 48 cards for crawlers — clear them before the
+// client render so the same products don't appear twice
+$('#grid').innerHTML = '';
loadFacets().then(() => { syncUrl(); loadPage(); });
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LPY7H5RZ20"></script>
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..38ab4f7
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+ <rect width="64" height="64" rx="12" fill="#171310"/>
+ <!-- three shelved book spines, middle one pulled up — brass on walnut -->
+ <rect x="14" y="18" width="9" height="30" rx="1.5" fill="#c9a227"/>
+ <rect x="27" y="12" width="9" height="36" rx="1.5" fill="#efe8db"/>
+ <rect x="40" y="18" width="9" height="30" rx="1.5" fill="#8a6f1d"/>
+ <rect x="12" y="50" width="40" height="3" rx="1.5" fill="#3a2e24"/>
+</svg>
diff --git a/public/index.html b/public/index.html
index 9a54bdc..7e69f9a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6,7 +6,46 @@
<title>The Designer Library — Wallcovering Collections, Brands, Styles & Hues</title>
<meta name="description" content="A 3D library of designer wallcoverings — browse 84,000+ designs as books on shelves, organized by hue, brand, collection and style. Pull a book, flip through, click into the store.">
<link rel="canonical" href="https://thedesignerlibrary.com/">
-<script type="importmap">{ "imports": { "three": "/vendor/three.module.js" } }</script>
+<link rel="icon" href="/favicon.svg" type="image/svg+xml">
+<meta property="og:type" content="website">
+<meta property="og:url" content="https://thedesignerlibrary.com/">
+<meta property="og:title" content="The Designer Library — Wallcovering Collections, Brands, Styles & Hues">
+<meta property="og:description" content="A 3D library of designer wallcoverings — browse 84,000+ designs as books on shelves, organized by hue, brand, collection and style.">
+<meta property="og:image" content="https://thedesignerlibrary.com/og-image.jpg">
+<meta property="og:image:width" content="1200">
+<meta property="og:image:height" content="630">
+<meta property="og:site_name" content="The Designer Library">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="The Designer Library — Wallcovering Collections, Brands, Styles & Hues">
+<meta name="twitter:description" content="A 3D library of designer wallcoverings — 84,000+ designs organized by hue, brand, collection and style.">
+<meta name="twitter:image" content="https://thedesignerlibrary.com/og-image.jpg">
+<script type="application/ld+json">
+{
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "WebSite",
+ "@id": "https://thedesignerlibrary.com/#website",
+ "url": "https://thedesignerlibrary.com/",
+ "name": "The Designer Library",
+ "description": "A 3D library of designer wallcoverings — 84,000+ designs organized by hue, brand, collection and style.",
+ "potentialAction": {
+ "@type": "SearchAction",
+ "target": { "@type": "EntryPoint", "urlTemplate": "https://thedesignerlibrary.com/browse?q={search_term_string}" },
+ "query-input": "required name=search_term_string"
+ }
+ },
+ {
+ "@type": "Organization",
+ "@id": "https://thedesignerlibrary.com/#organization",
+ "name": "The Designer Library",
+ "url": "https://thedesignerlibrary.com/",
+ "sameAs": ["https://www.designerwallcoverings.com"]
+ }
+ ]
+}
+</script>
+<script type="importmap">{ "imports": { "three": "/vendor/three-0.160.0.module.js" } }</script>
<style>
:root { --bg:#171310; --ink:#efe8db; --dim:#a99e8d; --brass:#c9a227; --walnut:#3a2e24; }
* { box-sizing:border-box; }
@@ -15,11 +54,14 @@
#shelf { position:fixed; inset:0; }
header { position:fixed; top:0; left:0; right:0; z-index:10; display:flex; align-items:center;
justify-content:space-between; padding:16px 22px; pointer-events:none; }
- .wordmark { pointer-events:auto; text-decoration:none; color:var(--ink); letter-spacing:.16em;
- font-size:19px; font-weight:600; text-transform:uppercase; }
+ .wordmark { pointer-events:auto; text-decoration:none; color:var(--ink); letter-spacing:.14em;
+ font-size:clamp(30px, 3.2vw, 48px); font-weight:600; text-transform:uppercase; white-space:nowrap; }
.wordmark em { color:var(--brass); font-style:normal; }
- .tagline { font-style:italic; color:var(--dim); font-size:13px; margin-left:12px; letter-spacing:.04em; }
- #burger { pointer-events:auto; background:none; border:0; color:var(--ink); font-size:24px; cursor:pointer; padding:6px 10px; }
+ .tagline { font-style:italic; color:var(--dim); font-size:13px; margin-left:14px; letter-spacing:.04em; }
+ #burger { pointer-events:auto; background:none; border:1px solid rgba(201,162,39,.35); border-radius:50%;
+ width:42px; height:42px; display:flex; align-items:center; justify-content:center;
+ color:var(--ink); font-size:18px; cursor:pointer; }
+ #burger:hover { border-color:var(--brass); color:var(--brass); }
nav#menu { position:fixed; top:0; right:0; bottom:0; width:270px; background:#211b16; z-index:30;
transform:translateX(100%); transition:transform .25s ease; padding:70px 26px; }
nav#menu.open { transform:none; }
@@ -27,7 +69,7 @@
border-bottom:1px solid rgba(255,255,255,.07); }
nav#menu a:hover { color:var(--brass); }
#menuClose { position:absolute; top:16px; right:18px; background:none; border:0; color:var(--dim); font-size:22px; cursor:pointer; }
- .bar { position:fixed; top:62px; left:0; right:0; z-index:9; display:flex; gap:8px; justify-content:center;
+ .bar { position:fixed; top:96px; left:0; right:0; z-index:9; display:flex; gap:8px; justify-content:center;
align-items:center; flex-wrap:wrap; padding:0 16px; }
.tab { pointer-events:auto; background:rgba(33,27,22,.85); color:var(--dim); border:1px solid rgba(201,162,39,.25);
border-radius:999px; padding:7px 18px; font:600 12px Georgia, serif; letter-spacing:.14em; cursor:pointer; }
@@ -39,15 +81,25 @@
input#search { background:rgba(33,27,22,.85); color:var(--ink); border:1px solid rgba(255,255,255,.14);
border-radius:999px; padding:7px 14px; font:13px Georgia, serif; width:180px; }
input#search::placeholder { color:var(--dim); }
- footer { position:fixed; bottom:0; left:0; right:0; z-index:8; display:flex; justify-content:space-between;
- align-items:center; padding:10px 22px; color:var(--dim); font-size:12px; font-style:italic; pointer-events:none; }
+ /* footer content lives LEFT so the DW-network ad card (bottom-right) never
+ covers the count or the interaction hints */
+ footer { position:fixed; bottom:0; left:0; right:340px; z-index:8; display:flex; justify-content:flex-start;
+ gap:18px; align-items:center; padding:10px 22px; color:var(--dim); font-size:11px; font-style:italic;
+ letter-spacing:.06em; opacity:.8; pointer-events:none; }
footer a { pointer-events:auto; color:var(--dim); }
#count { pointer-events:none; }
#reader { position:fixed; bottom:44px; left:0; right:0; z-index:8; text-align:center; color:var(--dim);
font-size:13px; display:none; }
#loading { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; z-index:5;
color:var(--dim); font-style:italic; letter-spacing:.08em; }
- @media (max-width:640px){ .tagline{display:none} input#search{width:120px} }
+ @media (max-width:640px){
+ .tagline{display:none} input#search{width:120px}
+ .wordmark{font-size:17px; letter-spacing:.14em;}
+ #burger{width:36px; height:36px; font-size:15px;}
+ .bar{top:58px;}
+ footer{bottom:96px; right:0; flex-wrap:wrap; gap:6px;}
+ #reader{bottom:140px;}
+ }
</style>
</head>
<body>
@@ -77,7 +129,7 @@
<div id="loading">fetching the library…</div>
<div id="reader"></div>
<footer>
- <span>scroll / drag the shelf · click a spine to open · ←/→ flip · Esc reshelves</span>
+ <span id="hints">scroll / drag the shelf · click a spine to open · ←/→ flip · Esc reshelves</span>
<span id="count"></span>
</footer>
@@ -88,9 +140,11 @@ const $ = (s) => document.querySelector(s);
const shelfEl = $('#shelf'), loading = $('#loading'), reader = $('#reader'), countEl = $('#count');
let shelf = null, currentBooks = [], currentShelf = null;
+const TOUCH = matchMedia('(pointer: coarse)').matches;
const THEME = {
background: '#171310', shelfColor: '#3a2e24', accent: '#c9a227',
onOpen(book) { reader.style.display = 'block'; reader.textContent = `${book.title} — ${book.subtitle || ''}`; },
+ onClose() { reader.style.display = 'none'; },
onPageChange(book, i) {
const p = book.pages[i];
reader.textContent = p ? `${book.title} · ${p.title}` : book.title;
@@ -109,7 +163,7 @@ async function loadShelf(name, brand) {
if (name === 'collections' && !brand) {
// first entry into collections: populate the brand picker, show the first brand
const pick = $('#brandPick');
- pick.innerHTML = data.brands.map(b => `<option>${b}</option>`).join('');
+ pick.replaceChildren(...data.brands.map(b => new Option(b, b)));
pick.classList.add('show');
return loadShelf('collections', data.brands[0]);
}
@@ -121,9 +175,16 @@ async function loadShelf(name, brand) {
reader.style.display = 'none';
if (currentBooks.length) shelf = new BookshelfCatalog(shelfEl, currentBooks, THEME);
loading.style.display = 'none';
- const total = currentBooks.reduce((n, b) => n + (parseInt((b.subtitle || '').replace(/,/g, '')) || 0), 0);
+ // design count = the LAST number in the subtitle — collections subtitles are
+ // "Brand · 12", hues/brands are "23,799 designs"; a leading parseInt gets NaN
+ const total = currentBooks.reduce((n, b) => {
+ const nums = (b.subtitle || '').match(/[\d,]+/g);
+ return n + (nums ? parseInt(nums[nums.length - 1].replace(/,/g, ''), 10) || 0 : 0);
+ }, 0);
countEl.textContent = `${currentBooks.length} books` + (total ? ` · ${total.toLocaleString()} designs` : '');
- $('#bookNames').innerHTML = currentBooks.map(b => `<option value="${b.title.replace(/"/g, '"')}">`).join('');
+ $('#bookNames').replaceChildren(...currentBooks.map(b => {
+ const o = document.createElement('option'); o.value = b.title; return o;
+ }));
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.dataset.shelf === name));
if (window.gtag) gtag('event', 'shelf_view', { shelf: name, brand: brand || '' });
history.replaceState(null, '', name === 'hues' ? '/' : `/#${name}${brand ? ':' + encodeURIComponent(brand) : ''}`);
@@ -132,9 +193,17 @@ async function loadShelf(name, brand) {
document.querySelectorAll('.tab').forEach(t => t.onclick = () => loadShelf(t.dataset.shelf));
$('#brandPick').onchange = (e) => loadShelf('collections', e.target.value);
$('#search').addEventListener('change', (e) => {
- const i = currentBooks.findIndex(b => b.title.toLowerCase() === e.target.value.toLowerCase());
+ const term = e.target.value.trim().toLowerCase();
+ if (!term) return;
+ // exact, then prefix, then substring against the current shelf's books;
+ // no book match → fall through to a full catalog search on /browse
+ let i = currentBooks.findIndex(b => b.title.toLowerCase() === term);
+ if (i < 0) i = currentBooks.findIndex(b => b.title.toLowerCase().startsWith(term));
+ if (i < 0) i = currentBooks.findIndex(b => b.title.toLowerCase().includes(term));
if (i >= 0 && shelf) { shelf.goToBook(i); setTimeout(() => shelf.openBook(i), 600); }
+ else location.href = '/browse?q=' + encodeURIComponent(e.target.value.trim());
});
+if (TOUCH) $('#hints').textContent = 'drag the shelf · tap a spine to open · tap the page to flip · tap outside the book to reshelve';
$('#burger').onclick = () => $('#menu').classList.add('open');
$('#menuClose').onclick = () => $('#menu').classList.remove('open');
document.querySelectorAll('#menu a[data-shelf]').forEach(a => a.onclick = (e) => {
diff --git a/public/og-image.jpg b/public/og-image.jpg
new file mode 100644
index 0000000..a4e2e2f
Binary files /dev/null and b/public/og-image.jpg differ
diff --git a/public/vendor/three.module.js b/public/vendor/three-0.160.0.module.js
similarity index 100%
rename from public/vendor/three.module.js
rename to public/vendor/three-0.160.0.module.js
diff --git a/server.js b/server.js
index ca671b6..4afa30d 100644
--- a/server.js
+++ b/server.js
@@ -7,13 +7,38 @@ const path = require('path');
const CFG = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
const PORT = process.env.PORT || CFG.port || 9807;
+const ORIGIN = 'https://thedesignerlibrary.com';
let LIB = { brands: [], collectionsByBrand: {}, styles: [], hues: [], counts: {}, builtAt: null };
let GRID = [];
+let BROWSE_HTML = '';
+let FACETS_CACHE = null;
+const SORTED_CACHE = {};
+
+function hexToHsl(hex) {
+ if (!hex) return null;
+ const h6 = hex.replace('#', '');
+ const r = parseInt(h6.slice(0, 2), 16) / 255, g = parseInt(h6.slice(2, 4), 16) / 255, b = parseInt(h6.slice(4, 6), 16) / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
+ let h = 0;
+ if (d) {
+ if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
+ h = (h * 60 + 360) % 360;
+ }
+ const l = (max + min) / 2;
+ return { h, s: d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)), l };
+}
+
function loadData() {
try {
LIB = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'library.json'), 'utf8'));
GRID = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+ // precompute HSL once — the color sorts would otherwise re-parse hex
+ // strings ~1.4M times per unfiltered request (85k rows × log n compares)
+ for (const p of GRID) p._hsl = hexToHsl(p.x);
+ FACETS_CACHE = null;
+ for (const k of Object.keys(SORTED_CACHE)) delete SORTED_CACHE[k];
+ BROWSE_HTML = fs.readFileSync(path.join(__dirname, 'public', 'browse.html'), 'utf8');
console.log(`library loaded: ${GRID.length} products, built ${LIB.builtAt}`);
} catch (e) { console.error('data load failed:', e.message); }
}
@@ -22,11 +47,15 @@ loadData();
const app = express();
app.disable('x-powered-by');
app.use((req, res, next) => { res.setHeader('X-Frame-Options', 'SAMEORIGIN'); next(); });
+// raw JSON in a search index helps no one — keep crawlers on the HTML pages
+app.use('/api', (req, res, next) => { res.setHeader('X-Robots-Tag', 'noindex, nofollow'); next(); });
app.get('/api/health', (_req, res) => res.json({ ok: true, products: GRID.length, builtAt: LIB.builtAt }));
-// per-shelf datasets — the landing view fetches only its own shelf
+// per-shelf datasets — the landing view fetches only its own shelf.
+// Data is build-time static, so let Cloudflare edge-cache it (s-maxage).
app.get('/api/shelf/:name', (req, res) => {
+ res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=3600');
const n = req.params.name;
if (n === 'brands') return res.json({ books: LIB.brands });
if (n === 'styles') return res.json({ books: LIB.styles });
@@ -40,63 +69,113 @@ app.get('/api/shelf/:name', (req, res) => {
});
// ---------- /browse grid API ----------
-function hexToHsl(hex) {
- if (!hex) return null;
- const h6 = hex.replace('#', '');
- const r = parseInt(h6.slice(0, 2), 16) / 255, g = parseInt(h6.slice(2, 4), 16) / 255, b = parseInt(h6.slice(4, 6), 16) / 255;
- const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
- let h = 0;
- if (d) {
- if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
- h = (h * 60 + 360) % 360;
- }
- const l = (max + min) / 2;
- return { h, s: d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)), l };
-}
const SORTS = {
newest: (a, b) => (b.d || '').localeCompare(a.d || ''),
title: (a, b) => a.t.localeCompare(b.t),
vendor: (a, b) => a.v.localeCompare(b.v) || a.t.localeCompare(b.t),
- 'light-dark': (a, b) => (hexToHsl(b.x)?.l ?? -1) - (hexToHsl(a.x)?.l ?? -1),
- 'dark-light': (a, b) => (hexToHsl(a.x)?.l ?? 2) - (hexToHsl(b.x)?.l ?? 2),
- wheel: (a, b) => (hexToHsl(a.x)?.h ?? 999) - (hexToHsl(b.x)?.h ?? 999),
+ collection: (a, b) => (a.c || '').localeCompare(b.c || '') || a.t.localeCompare(b.t),
+ 'light-dark': (a, b) => (b._hsl?.l ?? -1) - (a._hsl?.l ?? -1),
+ 'dark-light': (a, b) => (a._hsl?.l ?? 2) - (b._hsl?.l ?? 2),
+ wheel: (a, b) => (a._hsl?.h ?? 999) - (b._hsl?.h ?? 999),
};
-app.get('/api/products', (req, res) => {
- const { vendor, collection, style, hue, q } = req.query;
- const sort = SORTS[req.query.sort] ? req.query.sort : 'newest';
- const page = Math.max(1, parseInt(req.query.page) || 1);
- const limit = Math.min(96, Math.max(1, parseInt(req.query.limit) || 48));
- let list = GRID;
- if (vendor) list = list.filter(p => p.v === vendor);
- if (collection) list = list.filter(p => p.c === collection);
- if (style) list = list.filter(p => p.st === style);
- if (hue) list = list.filter(p => p.hu === hue);
- if (q) {
- const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
- list = list.filter(p => { const hay = (p.t + ' ' + p.v + ' ' + (p.c || '')).toLowerCase(); return terms.every(t => hay.includes(t)); });
+// unfiltered sorted views are identical for every visitor — sort once, slice forever
+function getSorted(sort) {
+ if (!SORTED_CACHE[sort]) SORTED_CACHE[sort] = [...GRID].sort(SORTS[sort]);
+ return SORTED_CACHE[sort];
+}
+
+function queryProducts(q) {
+ const { vendor, collection, style, hue } = q;
+ const sort = SORTS[q.sort] ? q.sort : 'newest';
+ const page = Math.max(1, parseInt(q.page) || 1);
+ const limit = Math.min(96, Math.max(1, parseInt(q.limit) || 48));
+ const term = (q.q || '').trim();
+ const filtered = vendor || collection || style || hue || term;
+ let sorted;
+ if (!filtered) {
+ sorted = getSorted(sort);
+ } else {
+ let list = GRID;
+ if (vendor) list = list.filter(p => p.v === vendor);
+ if (collection) list = list.filter(p => p.c === collection);
+ if (style) list = list.filter(p => p.st === style);
+ if (hue) list = list.filter(p => p.hu === hue);
+ if (term) {
+ const terms = term.toLowerCase().split(/\s+/).filter(Boolean);
+ list = list.filter(p => { const hay = (p.t + ' ' + p.v + ' ' + (p.c || '')).toLowerCase(); return terms.every(t => hay.includes(t)); });
+ }
+ sorted = [...list].sort(SORTS[sort]);
}
- const sorted = [...list].sort(SORTS[sort]);
const start = (page - 1) * limit;
- res.json({
+ return {
total: sorted.length, page, limit,
products: sorted.slice(start, start + limit).map(p => ({
title: p.t, vendor: p.v, handle: p.h, image: p.i, hex: p.x, hue: p.hu, style: p.st, collection: p.c,
url: `${CFG.storeBase}/products/${p.h}`,
})),
- });
+ };
+}
+
+app.get('/api/products', (req, res) => {
+ res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600');
+ res.json(queryProducts(req.query));
});
app.get('/api/facets', (req, res) => {
- const count = (key) => {
- const m = new Map();
- for (const p of GRID) { const v = p[key]; if (v) m.set(v, (m.get(v) || 0) + 1); }
- return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, n }));
- };
- res.json({ vendors: count('v').slice(0, 60), hues: count('hu'), styles: count('st') });
+ if (!FACETS_CACHE) {
+ const count = (key) => {
+ const m = new Map();
+ for (const p of GRID) { const v = p[key]; if (v) m.set(v, (m.get(v) || 0) + 1); }
+ return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, n }));
+ };
+ FACETS_CACHE = { vendors: count('v').slice(0, 60), hues: count('hu'), styles: count('st') };
+ }
+ res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=3600');
+ res.json(FACETS_CACHE);
+});
+
+// ---------- SEO surface ----------
+app.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').send(`User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: ${ORIGIN}/sitemap.xml\n`);
+});
+
+app.get('/sitemap.xml', (_req, res) => {
+ // just the two real pages — filter URLs all serve the same client-rendered
+ // shell, so listing them would hand Google thousands of duplicate pages
+ const urls = [
+ { loc: `${ORIGIN}/`, changefreq: 'weekly', priority: '1.0' },
+ { loc: `${ORIGIN}/browse`, changefreq: 'daily', priority: '0.9' },
+ ];
+ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.map(u => `<url><loc>${u.loc}</loc><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`).join('\n')}
+</urlset>`);
+});
+
+const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+// /browse with the first 48 products server-rendered — the only indexable
+// product content on the site (the 3D landing is pixels to a crawler).
+// The client script re-renders the grid on load, so hydration is a wash.
+app.get('/browse', (req, res) => {
+ res.setHeader('Cache-Control', 'no-cache');
+ let html = BROWSE_HTML;
+ try {
+ const first = queryProducts({ sort: 'newest', limit: 48 });
+ const cards = first.products.map(p => `<div class="card"><a href="${escHtml(p.url)}" target="_blank" rel="nofollow sponsored noopener"><img loading="lazy" src="${escHtml(p.image)}" alt="${escHtml(p.title)}" width="300" height="300"></a><div class="meta"><a class="t" href="${escHtml(p.url)}" target="_blank" rel="nofollow sponsored noopener">${escHtml(p.title)}</a><div class="v">${escHtml(p.vendor)}${p.collection ? ' · ' + escHtml(p.collection) : ''}</div></div></div>`).join('');
+ html = html.replace('<div id="grid"></div>', `<div id="grid">${cards}</div>`);
+ } catch { /* fall through to the empty shell */ }
+ res.send(html);
});
-app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h', extensions: ['html'] }));
-app.get('/browse', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'browse.html')));
+app.use(express.static(path.join(__dirname, 'public'), {
+ extensions: ['html'],
+ setHeaders(res, filePath) {
+ if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); // deploys visible immediately
+ else if (filePath.includes(`${path.sep}vendor${path.sep}`)) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // versioned filenames
+ else res.setHeader('Cache-Control', 'public, max-age=86400');
+ },
+}));
app.listen(PORT, () => console.log(`The Designer Library on :${PORT}`));
← fff9204 pixel: switch to the DW NETWORK pixel 1431180262113856 (Stev
·
back to Thedesignerlibrary
·
contrarian fixes: hex format guard on style attr, SSR cards 122f00a →