← back to Wallco Ai
design page: Texture tab — 5,034 DW textures, sort + density slider, click-to-apply (admin)
705ca0632552db8f32ea698825cfc4447997890f · 2026-05-25 20:12:48 -0700 · Steve Abrams
Files touched
Diff
commit 705ca0632552db8f32ea698825cfc4447997890f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 25 20:12:48 2026 -0700
design page: Texture tab — 5,034 DW textures, sort + density slider, click-to-apply (admin)
---
server.js | 277 +++++++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 238 insertions(+), 39 deletions(-)
diff --git a/server.js b/server.js
index 270070f..7975e24 100644
--- a/server.js
+++ b/server.js
@@ -9444,6 +9444,7 @@ ${htmlHeader('/designs')}
<nav class="edit-tabs">
<button class="edit-tab active" data-tab="adjust">Adjust</button>
<button class="edit-tab" data-tab="color">Color</button>
+ <button class="edit-tab" data-tab="texture">Texture</button>
<button class="edit-tab" data-tab="info">Info</button>
<button class="edit-tab" data-tab="style">Style</button>
<button class="edit-tab" data-tab="save">Save</button>
@@ -9515,6 +9516,60 @@ ${htmlHeader('/designs')}
<div id="suggested-swatches" class="suggested-swatches"></div>
</section>
+ <!-- TEXTURE ─────────────────────────────────────────────────────
+ 5,034-item natural-fiber library (grasscloth/linen/silk/cork/
+ mica/raffia/sisal/madagascar/...). Click a chip to preview the
+ design rendered ON that texture. Apply requires admin — for
+ anonymous users the click acts as preview only (lightbox). -->
+ <section class="edit-tabpane" data-pane="texture">
+ <h3 class="pane-h">Texture library
+ <span style="margin-left:10px;font:11px var(--sans);color:var(--ink-faint,#7a6e5a);text-transform:none;letter-spacing:0">
+ click any chip to apply as background
+ </span>
+ </h3>
+
+ <!-- Sort + Density controls (Steve's standing rule for every grid) -->
+ <div class="tex-controls">
+ <label class="tex-control-lbl">Sort
+ <select id="tex-sort">
+ <option value="category">Style (grouped)</option>
+ <option value="color">Color (hue wheel)</option>
+ <option value="name">Name A→Z</option>
+ <option value="random">Shuffle</option>
+ </select>
+ </label>
+ <label class="tex-control-lbl">Density
+ <input id="tex-density" type="range" min="60" max="180" step="10" value="108">
+ </label>
+ <span id="tex-count" class="tex-count">0 textures</span>
+ </div>
+
+ <!-- Material filter chips — 17 natural-fiber categories -->
+ <div class="tex-filters" id="tex-filters">
+ <button class="tex-filter active" data-f="all">All</button>
+ <button class="tex-filter" data-f="grasscloth">Grasscloth</button>
+ <button class="tex-filter" data-f="linen">Linen</button>
+ <button class="tex-filter" data-f="silk">Silk</button>
+ <button class="tex-filter" data-f="sisal">Sisal</button>
+ <button class="tex-filter" data-f="hemp">Hemp</button>
+ <button class="tex-filter" data-f="raffia">Raffia</button>
+ <button class="tex-filter" data-f="paperweave">Paperweave</button>
+ <button class="tex-filter" data-f="cork">Cork</button>
+ <button class="tex-filter" data-f="abaca">Abaca</button>
+ <button class="tex-filter" data-f="mica">Mica</button>
+ <button class="tex-filter" data-f="jute">Jute</button>
+ <button class="tex-filter" data-f="madagascar">Madagascar</button>
+ <button class="tex-filter" data-f="burlap">Burlap</button>
+ <button class="tex-filter" data-f="seagrass">Seagrass</button>
+ <button class="tex-filter" data-f="vellum">Vellum</button>
+ <button class="tex-filter" data-f="rice-paper">Rice paper</button>
+ </div>
+
+ <div class="tex-grid" id="tex-grid">
+ <div class="tex-loading">Loading texture library…</div>
+ </div>
+ </section>
+
<!-- STYLE ─────────────────────────────────────────────────────── -->
<section class="edit-tabpane" data-pane="style">
<h3 class="pane-h">Style attribution</h3>
@@ -9608,6 +9663,29 @@ ${htmlHeader('/designs')}
.edit-modal-body { padding:18px 22px 22px; overflow-y:auto; flex:1; min-height:280px; }
.edit-tabpane { display:none; }
.edit-tabpane.active { display:block; }
+
+ /* Texture library tab */
+ .tex-controls { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
+ .tex-control-lbl { display:inline-flex; gap:6px; align-items:center; font:11px var(--sans,system-ui); color:#888; letter-spacing:.08em; text-transform:uppercase; }
+ .tex-control-lbl select { background:#1f1d1b; color:#e8e2d6; border:1px solid #3a3833; border-radius:4px; padding:4px 8px; font:12px var(--sans,system-ui); cursor:pointer; }
+ .tex-control-lbl input[type=range] { width:140px; cursor:pointer; }
+ .tex-count { margin-left:auto; font:11px ui-monospace,Menlo,monospace; color:#888; }
+ .tex-filters { display:flex; gap:5px; flex-wrap:wrap; margin-bottom:12px; }
+ .tex-filter { background:transparent; border:1px solid #3a3833; color:#888; border-radius:11px; padding:3px 9px; font:600 10px var(--sans,system-ui); letter-spacing:.04em; cursor:pointer; text-transform:uppercase; }
+ .tex-filter:hover { color:#e8e2d6; border-color:#5a5550; }
+ .tex-filter.active { background:#d2b15c; color:#1a1816; border-color:#d2b15c; }
+ .tex-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(var(--tex-cols,108px),1fr)); gap:8px; max-height:540px; overflow-y:auto; padding:2px; }
+ .tex-grid-section { grid-column:1/-1; font:600 11px var(--sans,system-ui); letter-spacing:.12em; text-transform:uppercase; color:#d2b15c; padding:10px 0 4px; border-bottom:1px solid #2a2a2a; margin-top:6px; }
+ .tex-chip { background:#15130f; border:2px solid #2a2a2a; border-radius:6px; overflow:hidden; cursor:pointer; transition:transform .1s, border-color .1s; position:relative; }
+ .tex-chip:hover { transform:translateY(-1px); border-color:#d2b15c; }
+ .tex-chip.active { border-color:#d2b15c; box-shadow:0 0 0 3px rgba(210,177,92,.3); }
+ .tex-chip .thumb { aspect-ratio:1; background:#1a1816; }
+ .tex-chip .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
+ .tex-chip .lbl { font:600 9.5px var(--sans,system-ui); padding:4px 6px; color:#e8e2d6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+ .tex-chip .brand { font:600 8.5px var(--sans,system-ui); color:#888; padding:0 6px 4px; text-transform:uppercase; letter-spacing:.06em; display:flex; align-items:center; gap:5px; }
+ .tex-chip .hex-dot { width:8px; height:8px; border-radius:50%; border:1px solid rgba(255,255,255,.2); flex-shrink:0; }
+ .tex-loading { grid-column:1/-1; padding:40px 0; text-align:center; color:#888; font:12px var(--sans,system-ui); }
+
.pane-h { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:#888; margin:0 0 10px; font-weight:500; }
.pane-text { font-size:13px; color:#bba; line-height:1.55; margin:0 0 14px; }
.pane-list { margin:0; padding-left:18px; font-size:13px; color:#bba; line-height:1.7; }
@@ -11204,53 +11282,41 @@ ${htmlHeader('/designs')}
</div>
${_isAdmin ? `
- <!-- Admin: tone-on-tone color-wheel recoloring. Per design click,
- spins up a Gemini image-edit in the target hue. Steve's
- 2026-05-13 ask: "Add buttons to admin to create in color
- wheel color. for tone on tone items." -->
+ <!-- Admin: tone-on-tone hex recoloring. 2026-05-25 DP-3 rewrite:
+ brand-house buttons replaced with a flat grid of UNIQUE hex
+ swatches aggregated from FASHION_PALETTES (no brand names
+ surfaced anywhere, per feedback_never_dw_vendors_in_public_ui)
+ + an HTML5 color wheel for arbitrary picks. Click any swatch
+ or wheel pick → POST {hex} → backend builds a tone-on-tone
+ prompt from that single hex (DP-4). -->
<div class="recolor-wheel" style="margin-top:22px;padding-top:18px;border-top:1px solid var(--line)">
- <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:block;margin-bottom:8px">Admin · tone-on-tone · fashion-house palettes</label>
- <p style="font:11px var(--sans);color:var(--ink-faint);margin:0 0 10px">Click a brand to recolor this design in that house's actual product palette (sourced from the dw-fashion-templates skill). ~5-15s · ~$0.001 each (gemini-image-edit). Hover a swatch to see the hex stack.</p>
- <div id="recolor-buttons" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">
+ <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:block;margin-bottom:8px">Admin · tone-on-tone · hex picker</label>
+ <p style="font:11px var(--sans);color:var(--ink-faint);margin:0 0 10px">Click any swatch to recolor this design as a tone-on-tone story in that single hue. Use the color wheel for arbitrary picks. ~5–15s · ~$0.001 each (gemini-image-edit).</p>
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;padding:8px 10px;background:#f6f0e0;border:1px solid var(--line);border-radius:6px">
+ <label for="recolor-wheel-input" style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-soft);cursor:pointer">Color wheel</label>
+ <input id="recolor-wheel-input" type="color" value="#c9a14b" style="width:42px;height:32px;padding:0;border:1px solid var(--line);border-radius:4px;cursor:pointer;background:transparent">
+ <code id="recolor-wheel-readout" style="font:12px ui-monospace,Menlo,monospace;color:var(--ink);background:#fff;padding:4px 8px;border:1px solid var(--line);border-radius:4px;flex:0 0 90px;text-align:center">#C9A14B</code>
+ <button id="recolor-wheel-run" type="button" style="padding:7px 14px;background:#1a1816;color:#f0eadc;border:0;border-radius:4px;font:600 11px var(--sans);letter-spacing:.06em;text-transform:uppercase;cursor:pointer">Recolor in this hue</button>
+ </div>
+ <div id="recolor-buttons" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(36px,1fr));gap:6px;margin-bottom:8px">
${(() => {
- // Inline the fashion brands here so the buttons get the brand's actual
- // accent color as their background and the signature as the title tooltip.
- // Sourced from scripts/fashion_palettes.js (FASHION_PALETTES).
const brands = (() => {
try { return require('./scripts/fashion_palettes.js').FASHION_PALETTES; }
catch { return []; }
})();
- // Pick best accent: first non-near-black, non-near-white hex; fallback first.
- const pickAccent = (hex) => {
- const isExtreme = (h) => {
- const r = parseInt(h.slice(1,3),16), g = parseInt(h.slice(3,5),16), b = parseInt(h.slice(5,7),16);
- const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
- return lum < 0.08 || lum > 0.94;
- };
- return hex.find(h => !isExtreme(h)) || hex[0];
- };
- const textOn = (bg) => {
- const r = parseInt(bg.slice(1,3),16), g = parseInt(bg.slice(3,5),16), b = parseInt(bg.slice(5,7),16);
- const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
- return lum > 0.6 ? '#1a1410' : '#ffffff';
- };
- return brands.map(p => {
- // Strip diacritics first ('Hermès' → 'Hermes') so the kebab key reads
- // cleanly. Both call sites (button render + RECOLOR_BRANDS map) use the
- // identical transform so the rendered data-brand and the server key match.
- const key = p.brand.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
- const bg = pickAccent(p.hex);
- const fg = textOn(bg);
- const title = `${p.brand} — ${p.hex.join(' · ')} — ${p.signature}`.replace(/"/g, '"');
- // Show a 4-color mini-stripe ABOVE the label so the actual palette is visible.
- const stripe = p.hex.slice(0, 4).map(h => `<span style="display:inline-block;height:6px;width:14px;background:${h};border:1px solid rgba(0,0,0,.12)"></span>`).join('');
- return ` <button data-brand="${key}" title="${title}" style="display:inline-flex;flex-direction:column;align-items:stretch;gap:3px;padding:5px 10px;background:${bg};color:${fg};border:1px solid rgba(0,0,0,.15);border-radius:6px;cursor:pointer;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.04em;min-width:96px">
- <span style="display:flex;gap:1px;justify-content:center">${stripe}</span>
- <span>${p.brand}</span>
- </button>`;
+ const seen = new Set();
+ const hexes = [];
+ for (const p of brands) for (const h of p.hex) {
+ const k = String(h).toUpperCase();
+ if (k.length === 7 && k.startsWith('#') && !seen.has(k)) { seen.add(k); hexes.push(k); }
+ }
+ return hexes.map(h => {
+ return ` <button data-hex="${h}" title="${h}" aria-label="Recolor in ${h}" style="width:100%;aspect-ratio:1;background:${h};border:1px solid rgba(0,0,0,.12);border-radius:50%;cursor:pointer;padding:0;transition:transform .12s,box-shadow .12s" onmouseover="this.style.transform='scale(1.15)';this.style.boxShadow='0 2px 8px rgba(0,0,0,.18)'" onmouseout="this.style.transform='';this.style.boxShadow=''"></button>`;
}).join('\n');
})()}
- <button id="recolor-all" style="padding:6px 14px;background:linear-gradient(90deg,#FF4F00,#107A3B,#FF0000,#A98B5B,#006837,#D70000,#1B5E20,#F4D24F);color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em;flex-basis:100%;margin-top:4px">✦ Spin all houses (parallel)</button>
+ </div>
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
+ <button id="recolor-all" style="flex:1;padding:6px 14px;background:linear-gradient(90deg,#FF4F00,#107A3B,#FF0000,#A98B5B,#006837,#D70000,#1B5E20,#F4D24F);color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">✦ Spin all hexes (parallel)</button>
</div>
<div id="recolor-status" style="font:12px var(--sans);color:var(--ink-soft,#555);min-height:18px"></div>
<div id="recolor-results" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:10px"></div>
@@ -13064,9 +13130,142 @@ ${(() => {
document.querySelectorAll('.edit-tabpane').forEach(function(p){
p.classList.toggle('active', p.getAttribute('data-pane') === key);
});
+ // Lazy-load texture library the first time the Texture tab opens
+ if (key === 'texture' && window.__loadTextureLib) window.__loadTextureLib();
});
});
+ // ── Texture library ──────────────────────────────────────────────────
+ // Mirrors the admin bg-tester pattern (/admin/bg-tester) but in-modal,
+ // public-facing. Sort + Density slider per Steve's standing rule for
+ // every product grid. Apply requires admin (replace-bg endpoint
+ // gates on adminRatingGate). Non-admins get a toast on click.
+ (function(){
+ var DESIGN_ID = ${id};
+ var IS_ADMIN = ${_isAdmin ? 'true' : 'false'};
+ var TEXTURES = [];
+ var MAT_FILTER = 'all';
+ var SORT_MODE = localStorage.getItem('wallco_tex_sort') || 'category';
+ var DENSITY = parseInt(localStorage.getItem('wallco_tex_density') || '108', 10);
+ var loaded = false;
+
+ function hexToLab(hex){
+ if (!hex) return null;
+ var m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return null;
+ var r = parseInt(m[1].slice(0,2),16)/255, g=parseInt(m[1].slice(2,4),16)/255, b=parseInt(m[1].slice(4,6),16)/255;
+ var lin = function(c){ return c<=0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055,2.4); };
+ var R=lin(r),G=lin(g),B=lin(b);
+ var X=(R*0.4124+G*0.3576+B*0.1805)/0.95047, Y=(R*0.2126+G*0.7152+B*0.0722), Z=(R*0.0193+G*0.1192+B*0.9505)/1.08883;
+ var f = function(t){ return t>0.008856 ? Math.cbrt(t) : (7.787*t+16/116); };
+ return [116*f(Y)-16, 500*(f(X)-f(Y)), 200*(f(Y)-f(Z))];
+ }
+ function passesFilter(t){
+ if (MAT_FILTER === 'all') return true;
+ var cat = (t.category || '').toLowerCase();
+ var nm = (t.name || '').toLowerCase();
+ return cat === MAT_FILTER || nm.indexOf(MAT_FILTER) !== -1;
+ }
+ function sortTextures(items){
+ var s = items.slice();
+ if (SORT_MODE === 'category') {
+ s.sort(function(a,b){ return (a.category||'').localeCompare(b.category||'') || (a.name||'').localeCompare(b.name||''); });
+ } else if (SORT_MODE === 'color') {
+ var scored = s.map(function(t){ var lab=hexToLab(t.hex); return {t:t, h:lab?Math.atan2(lab[2],lab[1]):999, L:lab?lab[0]:999}; });
+ scored.sort(function(a,b){ return (a.h-b.h) || (a.L-b.L) || (a.t.name||'').localeCompare(b.t.name||''); });
+ return scored.map(function(x){ return x.t; });
+ } else if (SORT_MODE === 'name') {
+ s.sort(function(a,b){ return (a.name||'').localeCompare(b.name||''); });
+ } else if (SORT_MODE === 'random') {
+ for (var i=s.length-1;i>0;i--){ var j=Math.floor(Math.random()*(i+1)); var x=s[i]; s[i]=s[j]; s[j]=x; }
+ }
+ return s;
+ }
+ function applyTexture(tex, chip){
+ if (!IS_ADMIN) {
+ alert('Applying a texture requires admin sign-in. The library is browseable to everyone, but background replacement uses paid AI image-editing — sign in to apply.');
+ return;
+ }
+ document.querySelectorAll('.tex-chip').forEach(function(c){ c.classList.remove('active'); });
+ chip.classList.add('active');
+ var preview = document.getElementById('edit-preview-img');
+ var readout = document.getElementById('edit-preview-readout');
+ if (readout) readout.textContent = 'Applying ' + tex.name + '… (~10–20s)';
+ fetch('/api/design/' + DESIGN_ID + '/replace-bg', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ texture_slug: tex.slug })
+ }).then(function(r){
+ if (!r.ok) return r.text().then(function(t){ throw new Error(r.status+': '+t.slice(0,160)); });
+ return r.json();
+ }).then(function(j){
+ if (j && j.new_id) {
+ if (preview) preview.src = '/designs/img/by-id/' + j.new_id + '?t=' + Date.now();
+ if (readout) readout.innerHTML = 'Applied — saved as <a href="/design/'+j.new_id+'" style="color:#d2b15c">#'+j.new_id+'</a>';
+ } else {
+ if (readout) readout.textContent = 'Done.';
+ }
+ }).catch(function(e){
+ if (readout) readout.textContent = 'Apply failed: ' + e.message;
+ });
+ }
+ function render(){
+ var grid = document.getElementById('tex-grid'); if (!grid) return;
+ var shown = sortTextures(TEXTURES.filter(passesFilter));
+ grid.style.setProperty('--tex-cols', DENSITY + 'px');
+ grid.innerHTML = '';
+ var frag = document.createDocumentFragment();
+ var section = null;
+ for (var i=0;i<shown.length;i++){
+ var t = shown[i];
+ if (SORT_MODE === 'category' && t.category !== section) {
+ section = t.category;
+ var hdr = document.createElement('div');
+ hdr.className = 'tex-grid-section';
+ var inSec = shown.filter(function(x){ return x.category === section; }).length;
+ hdr.textContent = (section || 'uncategorized') + ' (' + inSec + ')';
+ frag.appendChild(hdr);
+ }
+ var chip = document.createElement('div');
+ chip.className = 'tex-chip';
+ chip.title = t.name + (t.category ? ' · ' + t.category : '');
+ var dot = t.hex ? '<span class="hex-dot" style="background:'+t.hex+'"></span>' : '';
+ chip.innerHTML =
+ '<div class="thumb"><img loading="lazy" src="'+t.image_url+'" alt=""></div>' +
+ '<div class="lbl">'+(t.name||'').replace(/[<>]/g,'')+'</div>' +
+ '<div class="brand">'+dot+(t.category||'')+'</div>';
+ (function(tex, el){ el.addEventListener('click', function(){ applyTexture(tex, el); }); })(t, chip);
+ frag.appendChild(chip);
+ }
+ grid.appendChild(frag);
+ var cnt = document.getElementById('tex-count');
+ if (cnt) cnt.textContent = shown.length + ' of ' + TEXTURES.length + ' textures';
+ }
+ window.__loadTextureLib = function(){
+ if (loaded) return; loaded = true;
+ var s = document.getElementById('tex-sort'); if (s) s.value = SORT_MODE;
+ var d = document.getElementById('tex-density'); if (d) d.value = DENSITY;
+ fetch('/api/bg-textures').then(function(r){ return r.json(); }).then(function(j){
+ TEXTURES = (j && j.items) || [];
+ render();
+ }).catch(function(e){
+ var grid = document.getElementById('tex-grid');
+ if (grid) grid.innerHTML = '<div class="tex-loading">Failed to load: '+e.message+'</div>';
+ });
+ };
+ // Wire up controls (idempotent — safe even before lib loads)
+ document.querySelectorAll('#tex-filters .tex-filter').forEach(function(btn){
+ btn.addEventListener('click', function(){
+ document.querySelectorAll('#tex-filters .tex-filter').forEach(function(x){ x.classList.remove('active'); });
+ btn.classList.add('active');
+ MAT_FILTER = btn.getAttribute('data-f');
+ render();
+ });
+ });
+ var sortEl = document.getElementById('tex-sort');
+ if (sortEl) sortEl.addEventListener('change', function(){ SORT_MODE = sortEl.value; localStorage.setItem('wallco_tex_sort', SORT_MODE); render(); });
+ var denEl = document.getElementById('tex-density');
+ if (denEl) denEl.addEventListener('input', function(){ DENSITY = parseInt(denEl.value,10) || 108; localStorage.setItem('wallco_tex_density', String(DENSITY)); var g = document.getElementById('tex-grid'); if (g) g.style.setProperty('--tex-cols', DENSITY + 'px'); });
+ })();
+
// ── Curated Interior Designer panel (admin-only) ────────────────────────
(function(){
var params = new URLSearchParams(window.location.search);
← e0bff67 yolo-queue: DP-2 ✓ static 96x96 thumbs + stash + click-swap
·
back to Wallco Ai
·
yolo-queue: DP-3 + DP-4 ✓ hex swatches + wheel + backend hex df640d3 →