[object Object]

← back to Wallco Ai

bg-tester picker: switch to unified /api/bg-textures, hide vendor names

3a16b547af9df38c5079293d4bb45cb1bca07ac6 · 2026-05-25 00:33:41 -0700 · Steve Abrams

Combines two layers of work in one file:
1. Pre-existing Bulk Apply panel from a prior session (loadCategories,
   bulk-cat dropdown, bulk-fire button, pollBulk poller) — preserved
   verbatim with only the texture_name field swapped to drop vendor.
2. This session — picker rewired off the legacy vendor-leaking
   /api/dw-textures onto the new /api/bg-textures (~5k deduped DW-only
   textures):
   - Brand filter row + brand sort + prestige rank ALL removed. Steve's
     rule — "client does not need to know" the vendor.
   - Category filter (Style) chips updated to the 15 real material
     categories surfaced by the unified endpoint (added Mica, Madagascar,
     Burlap, Seagrass; dropped Bamboo, Suede, Woven which had 0 matches).
   - Sort dropdown: by Style (grouped) · by Color (LAB hue wheel) ·
     by Name · Shuffle. LAB hex→hue conversion inlined for client-side
     sort without an extra round trip.
   - Chip label: name + small color-dot + category (no brand).
   - Result card head: shows category in lieu of brand.
   - Auto-pick-4: now picks one each from grasscloth/linen/cork/mica
     instead of one each from 4 hardcoded vendor brands.

Files touched

Diff

commit 3a16b547af9df38c5079293d4bb45cb1bca07ac6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 25 00:33:41 2026 -0700

    bg-tester picker: switch to unified /api/bg-textures, hide vendor names
    
    Combines two layers of work in one file:
    1. Pre-existing Bulk Apply panel from a prior session (loadCategories,
       bulk-cat dropdown, bulk-fire button, pollBulk poller) — preserved
       verbatim with only the texture_name field swapped to drop vendor.
    2. This session — picker rewired off the legacy vendor-leaking
       /api/dw-textures onto the new /api/bg-textures (~5k deduped DW-only
       textures):
       - Brand filter row + brand sort + prestige rank ALL removed. Steve's
         rule — "client does not need to know" the vendor.
       - Category filter (Style) chips updated to the 15 real material
         categories surfaced by the unified endpoint (added Mica, Madagascar,
         Burlap, Seagrass; dropped Bamboo, Suede, Woven which had 0 matches).
       - Sort dropdown: by Style (grouped) · by Color (LAB hue wheel) ·
         by Name · Shuffle. LAB hex→hue conversion inlined for client-side
         sort without an extra round trip.
       - Chip label: name + small color-dot + category (no brand).
       - Result card head: shows category in lieu of brand.
       - Auto-pick-4: now picks one each from grasscloth/linen/cork/mica
         instead of one each from 4 hardcoded vendor brands.
---
 public/admin/bg-tester.html | 258 ++++++++++++++++++++++++++++++++++++++------
 1 file changed, 225 insertions(+), 33 deletions(-)

diff --git a/public/admin/bg-tester.html b/public/admin/bg-tester.html
index 43f87cb..3e1326a 100644
--- a/public/admin/bg-tester.html
+++ b/public/admin/bg-tester.html
@@ -169,23 +169,55 @@
         <button id="auto-pick" style="width:100%; padding:10px 12px; border:1px solid var(--gold); color:#7a5500; background:linear-gradient(180deg,#fff7e0,#fef0c8); border-radius:6px; cursor:pointer; font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase;">
           ✨ Try 4 Textures Now
         </button>
-        <div style="font:11px var(--sans); color:var(--faint); margin-top:6px">picks one each: Phillipe Romano · Holly Hunt · Carlisle · Cork — fires all 4 in parallel</div>
+        <div style="font:11px var(--sans); color:var(--faint); margin-top:6px">picks one each: Grasscloth · Linen · Cork · Mica — fires all 4 in parallel</div>
+      </div>
+      <div class="panel" style="margin-top:14px">
+        <h3>Bulk Apply — entire category</h3>
+        <div style="display:flex; gap:6px; align-items:center; margin-bottom:8px">
+          <label style="font:11px var(--sans); color:var(--faint)">Category</label>
+          <select id="bulk-cat" style="flex:1; padding:5px 8px; border:1px solid var(--line); border-radius:4px; font:12px var(--sans);">
+            <option value="">— pick category —</option>
+          </select>
+          <label style="font:11px var(--sans); color:var(--faint)">Limit</label>
+          <input id="bulk-limit" type="number" value="100" min="1" max="5000" style="width:60px; padding:5px 6px; border:1px solid var(--line); border-radius:4px; font:12px ui-monospace,Menlo,monospace;">
+        </div>
+        <button id="bulk-fire" disabled style="width:100%; padding:10px 12px; border:1px solid var(--red); color:var(--red); background:#fff; border-radius:6px; cursor:pointer; font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase; opacity:.5;">
+          🚀 Click a texture chip first
+        </button>
+        <div id="bulk-status" style="display:none; margin-top:8px; padding:8px 10px; background:#fef0c8; border-left:3px solid var(--gold); border-radius:0 4px 4px 0; font:11.5px var(--sans);"></div>
       </div>
     </div>
     <div>
       <div class="panel">
-        <h3>Texture Library — click any chip to apply</h3>
-        <div class="filters" id="filters">
-          <button class="active" data-f="all">All</button>
-          <button data-f="phillipe romano">Phillipe Romano</button>
-          <button data-f="holly hunt">Holly Hunt</button>
-          <button data-f="carlisle">Carlisle &amp; Co</button>
-          <button data-f="grasscloth">Grasscloth</button>
-          <button data-f="linen">Linen</button>
-          <button data-f="raffia">Raffia</button>
-          <button data-f="silk">Silk</button>
-          <button data-f="cork">Cork</button>
-          <button data-f="sisal">Sisal</button>
+        <h3>Texture Library — click any chip to apply
+          <span style="margin-left:14px; font:11px var(--sans); color:var(--faint); text-transform:none; letter-spacing:0;">
+            Sort:
+            <select id="tex-sort" style="margin-left:4px; padding:2px 6px; font:11px var(--sans); border:1px solid var(--line); border-radius:3px;">
+              <option value="category">by Style (grouped)</option>
+              <option value="color">by Color (hue wheel)</option>
+              <option value="name">by Name A→Z</option>
+              <option value="random">Shuffle</option>
+            </select>
+          </span>
+          <span id="tex-count" style="margin-left:10px; font:11px ui-monospace,Menlo,monospace; color:var(--faint);">— textures</span>
+        </h3>
+        <div class="filters" id="filters-mat">
+          <span style="font:9px var(--sans); color:var(--faint); letter-spacing:.1em; text-transform:uppercase; align-self:center; margin-right:4px;">Style</span>
+          <button class="active" data-f="all" data-type="material">All</button>
+          <button data-f="grasscloth" data-type="material">Grasscloth</button>
+          <button data-f="linen" data-type="material">Linen</button>
+          <button data-f="silk" data-type="material">Silk</button>
+          <button data-f="sisal" data-type="material">Sisal</button>
+          <button data-f="hemp" data-type="material">Hemp</button>
+          <button data-f="raffia" data-type="material">Raffia</button>
+          <button data-f="paperweave" data-type="material">Paperweave</button>
+          <button data-f="cork" data-type="material">Cork</button>
+          <button data-f="mica" data-type="material">Mica</button>
+          <button data-f="abaca" data-type="material">Abaca</button>
+          <button data-f="jute" data-type="material">Jute</button>
+          <button data-f="madagascar" data-type="material">Madagascar</button>
+          <button data-f="burlap" data-type="material">Burlap</button>
+          <button data-f="seagrass" data-type="material">Seagrass</button>
         </div>
         <div class="tex-grid" id="tex-grid"></div>
       </div>
@@ -200,9 +232,28 @@
 (() => {
   const $ = (id) => document.getElementById(id);
   let TEXTURES = [];
-  let FILTER = 'all';
+  let MAT_FILTER = 'all';
+  let SORT_MODE = 'category';
   let CURRENT_DID = null;
   let RESULT_COUNT = 0;
+  let SELECTED_TEXTURE = null;   // for bulk-apply
+
+  // sRGB hex → CIE-LAB. Used for "by Color (hue wheel)" sort.
+  function hexToLab(hex) {
+    if (!hex) return null;
+    const m = /^#?([0-9a-f]{6})$/i.exec(hex);
+    if (!m) return null;
+    const r = parseInt(m[1].slice(0,2), 16) / 255;
+    const g = parseInt(m[1].slice(2,4), 16) / 255;
+    const b = parseInt(m[1].slice(4,6), 16) / 255;
+    const lin = c => c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
+    const R = lin(r), G = lin(g), B = lin(b);
+    const X = (R*0.4124 + G*0.3576 + B*0.1805) / 0.95047;
+    const Y = (R*0.2126 + G*0.7152 + B*0.0722);
+    const Z = (R*0.0193 + G*0.1192 + B*0.9505) / 1.08883;
+    const f = t => 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 toast(msg) {
     const t = $('toast');
@@ -213,48 +264,87 @@
   }
 
   async function loadTextures() {
-    const r = await fetch('/api/dw-textures');
+    // Unified DW Textures pool: 5k+ vendor-stripped rows, deduped, with
+    // material category + dominant hex. Vendor names intentionally not exposed.
+    const r = await fetch('/api/bg-textures');
     const j = await r.json();
     TEXTURES = j.items || [];
     renderFilters();
   }
 
   function passesFilter(tex) {
-    if (FILTER === 'all') return true;
-    const blob = (tex.brand + ' ' + tex.material + ' ' + tex.title).toLowerCase();
-    return blob.includes(FILTER);
+    if (MAT_FILTER !== 'all') {
+      const cat = (tex.category || '').toLowerCase();
+      const name = (tex.name || '').toLowerCase();
+      if (cat !== MAT_FILTER && !name.includes(MAT_FILTER)) return false;
+    }
+    return true;
+  }
+  function sortTextures(items) {
+    const sorted = [...items];
+    if (SORT_MODE === 'category') {
+      sorted.sort((a,b) => (a.category || '').localeCompare(b.category || '') || (a.name || '').localeCompare(b.name || ''));
+    } else if (SORT_MODE === 'color') {
+      // LAB hue-wheel: angle in (a*, b*) plane, then lightness, then name
+      const scored = sorted.map(t => {
+        const lab = hexToLab(t.hex);
+        return { t, h: lab ? Math.atan2(lab[2], lab[1]) : 999, L: lab ? lab[0] : 999 };
+      });
+      scored.sort((a,b) => (a.h - b.h) || (a.L - b.L) || (a.t.name || '').localeCompare(b.t.name || ''));
+      return scored.map(s => s.t);
+    } else if (SORT_MODE === 'name') {
+      sorted.sort((a,b) => (a.name || '').localeCompare(b.name || ''));
+    } else if (SORT_MODE === 'random') {
+      for (let i = sorted.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        [sorted[i], sorted[j]] = [sorted[j], sorted[i]];
+      }
+    }
+    return sorted;
   }
   function renderFilters() {
     const grid = $('tex-grid');
     grid.innerHTML = '';
-    const shown = TEXTURES.filter(passesFilter);
+    const shown = sortTextures(TEXTURES.filter(passesFilter));
     const frag = document.createDocumentFragment();
+    let currentSection = null;
     for (const tex of shown) {
+      // Section dividers when grouped by category
+      if (SORT_MODE === 'category' && tex.category !== currentSection) {
+        currentSection = tex.category;
+        const hdr = document.createElement('div');
+        hdr.style.cssText = 'grid-column:1/-1; font:600 11px var(--sans); letter-spacing:.12em; text-transform:uppercase; color:var(--ink); padding:8px 0 4px; border-bottom:1px solid var(--line); margin-top:6px;';
+        hdr.textContent = `${currentSection || 'uncategorized'} (${shown.filter(x => x.category === currentSection).length})`;
+        frag.appendChild(hdr);
+      }
       const chip = document.createElement('div');
       chip.className = 'tex-chip';
-      chip.title = tex.title;
+      chip.title = tex.name;
       chip.dataset.url = tex.image_url;
-      chip.dataset.name = tex.name + ' (' + tex.brand + ')';
+      chip.dataset.name = tex.name;
+      const dot = tex.hex ? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${tex.hex};border:1px solid rgba(0,0,0,.15);margin-right:5px;vertical-align:middle"></span>` : '';
       chip.innerHTML = `
         <div class="thumb"><img loading="lazy" src="${tex.image_url}" alt=""></div>
         <div class="lbl">${tex.name}</div>
-        <div class="brand">${tex.brand}</div>
+        <div class="brand">${dot}${tex.category || ''}</div>
       `;
       chip.onclick = () => applyTexture(tex, chip);
       frag.appendChild(chip);
     }
     grid.appendChild(frag);
-    $('stats').textContent = `${shown.length} textures loaded · ${RESULT_COUNT} results`;
+    $('stats').textContent = `${shown.length} of ${TEXTURES.length} textures · ${RESULT_COUNT} results`;
+    $('tex-count').textContent = `${shown.length} textures`;
   }
 
-  document.querySelectorAll('#filters button').forEach(b => {
+  document.querySelectorAll('#filters-mat button').forEach(b => {
     b.onclick = () => {
-      document.querySelectorAll('#filters button').forEach(x => x.classList.remove('active'));
+      document.querySelectorAll('#filters-mat button').forEach(x => x.classList.remove('active'));
       b.classList.add('active');
-      FILTER = b.dataset.f;
+      MAT_FILTER = b.dataset.f;
       renderFilters();
     };
   });
+  $('tex-sort').onchange = (e) => { SORT_MODE = e.target.value; renderFilters(); };
 
   $('load-btn').onclick = loadSource;
   $('did-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadSource(); });
@@ -277,7 +367,11 @@
   }
 
   async function applyTexture(tex, chip) {
+    // Remember the last-clicked for bulk-apply
+    SELECTED_TEXTURE = tex;
+    updateBulkButton();
     if (!CURRENT_DID) { toast('load a source design first'); return; }
+    document.querySelectorAll('.tex-chip').forEach(c => c.classList.remove('active'));
     chip.classList.add('active');
     // Make a result card now (with loading state)
     const results = $('results');
@@ -286,7 +380,7 @@
     card.innerHTML = `
       <div class="head">
         <span class="tex-name">${tex.name}</span>
-        <span style="color:var(--faint);font:10px var(--sans);text-transform:uppercase;letter-spacing:.06em">${tex.brand}</span>
+        <span style="color:var(--faint);font:10px var(--sans);text-transform:uppercase;letter-spacing:.06em">${tex.category || ''}</span>
         <span class="elapsed" id="el-${RESULT_COUNT}">⏳…</span>
       </div>
       <div class="img-box loading" id="ib-${RESULT_COUNT}"></div>
@@ -304,7 +398,7 @@
         method:'POST', headers:{'Content-Type':'application/json'},
         body: JSON.stringify({
           texture_image_url: tex.image_url,
-          texture_name: tex.name + ' (' + tex.brand + ')',
+          texture_name: tex.name,
         }),
       });
       const j = await r.json();
@@ -353,18 +447,18 @@
     } catch (e) { console.warn('recent load failed:', e.message); }
   }
 
-  // Auto-pick 4 textures (one per brand)
+  // Auto-pick 4 textures — one each from 4 distinct categories
   $('auto-pick').onclick = async () => {
     if (!CURRENT_DID) { toast('load a source design first'); return; }
-    const wantedBrands = ['Phillipe Romano', 'Holly Hunt', 'Carlisle', 'Maya Romanoff'];
+    const wantedCats = ['grasscloth', 'linen', 'cork', 'mica'];
     const picks = [];
-    for (const brand of wantedBrands) {
-      const candidates = TEXTURES.filter(t => t.brand.toLowerCase().includes(brand.toLowerCase()));
+    for (const cat of wantedCats) {
+      const candidates = TEXTURES.filter(t => (t.category || '').toLowerCase() === cat);
       if (candidates.length) {
         picks.push(candidates[Math.floor(Math.random() * candidates.length)]);
       }
     }
-    // Fall back if some brands missing
+    // Fall back if some categories missing
     while (picks.length < 4 && TEXTURES.length > picks.length) {
       const rand = TEXTURES[Math.floor(Math.random() * TEXTURES.length)];
       if (!picks.includes(rand)) picks.push(rand);
@@ -376,9 +470,107 @@
     }
   };
 
+  // ── Bulk Apply ───────────────────────────────────────────────────────
+  function updateBulkButton() {
+    const btn = $('bulk-fire');
+    const cat = $('bulk-cat').value;
+    if (SELECTED_TEXTURE && cat) {
+      btn.disabled = false;
+      btn.style.opacity = '1';
+      btn.style.background = 'var(--red)';
+      btn.style.color = '#fff';
+      btn.textContent = `🚀 Apply ${SELECTED_TEXTURE.name} to ${cat} (${$('bulk-limit').value} designs)`;
+    } else if (SELECTED_TEXTURE && !cat) {
+      btn.disabled = true;
+      btn.style.opacity = '.5';
+      btn.style.background = '#fff';
+      btn.style.color = 'var(--red)';
+      btn.textContent = `🚀 Pick a category to bulk-apply ${SELECTED_TEXTURE.name}`;
+    } else {
+      btn.disabled = true;
+      btn.style.opacity = '.5';
+      btn.style.background = '#fff';
+      btn.style.color = 'var(--red)';
+      btn.textContent = '🚀 Click a texture chip first';
+    }
+  }
+  async function loadCategories() {
+    try {
+      const r = await fetch('/api/categories');
+      if (!r.ok) return;
+      const j = await r.json();
+      const sel = $('bulk-cat');
+      for (const c of (j.items || [])) {
+        const opt = document.createElement('option');
+        opt.value = c.category; opt.textContent = `${c.category} (${c.count})`;
+        sel.appendChild(opt);
+      }
+    } catch (e) {
+      // Fallback: hardcoded popular categories
+      const fallback = ['drunk-animals','muybridge-plate','designer-zoo-calm','face-skull-damask','cactus','damask','stripe','chinoiserie','designer-scenic'];
+      const sel = $('bulk-cat');
+      for (const c of fallback) {
+        const opt = document.createElement('option');
+        opt.value = c; opt.textContent = c;
+        sel.appendChild(opt);
+      }
+    }
+  }
+  $('bulk-cat').onchange = updateBulkButton;
+  $('bulk-limit').oninput = updateBulkButton;
+  $('bulk-fire').onclick = async () => {
+    if (!SELECTED_TEXTURE) { toast('pick a texture first'); return; }
+    const cat = $('bulk-cat').value;
+    if (!cat) { toast('pick a category'); return; }
+    const limit = parseInt($('bulk-limit').value, 10) || 100;
+    if (!confirm(`Apply "${SELECTED_TEXTURE.name}" to up to ${limit} designs in "${cat}"?\n\n~${(limit * 0.005).toFixed(2)} USD · ~${Math.ceil(limit * 3.3 / 60)} min wall clock at concurrency 3.`)) return;
+    const stat = $('bulk-status');
+    stat.style.display = '';
+    stat.textContent = '🚀 launching…';
+    try {
+      const r = await fetch('/api/bulk-bg-swap', {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          category: cat,
+          texture_image_url: SELECTED_TEXTURE.image_url,
+          texture_name: SELECTED_TEXTURE.name,
+          limit,
+        }),
+      });
+      const j = await r.json();
+      if (!j.ok) throw new Error(j.error || 'launch failed');
+      stat.innerHTML = `🚀 job <b>${j.job_id}</b> launched (pid ${j.pid}) · polling…`;
+      pollBulk(j.job_id);
+    } catch (e) {
+      stat.style.background = '#fce8e6';
+      stat.style.borderLeftColor = 'var(--red)';
+      stat.innerHTML = `✗ ${e.message}`;
+    }
+  };
+  async function pollBulk(jobId) {
+    const stat = $('bulk-status');
+    let tries = 0;
+    const interval = setInterval(async () => {
+      try {
+        const r = await fetch(`/api/bulk-bg-swap/${jobId}/tail`);
+        const j = await r.json();
+        if (j.ok) {
+          stat.innerHTML = `🚀 job <b>${jobId}</b> · <b>${j.ok_count}</b> ok · ${j.err_count} err ${j.done ? ' · <span style="color:var(--green)">✓ DONE</span>' : '· polling…'}`;
+          if (j.done) {
+            clearInterval(interval);
+            stat.style.background = '#eef7ed';
+            stat.style.borderLeftColor = 'var(--green)';
+          }
+        }
+      } catch {}
+      if (++tries > 1200) clearInterval(interval);
+    }, 5000);
+  }
+
   // Pre-load 39341 if nothing else specified
   loadTextures().then(() => {
     loadRecent();
+    loadCategories();
     const q = new URLSearchParams(location.search);
     const did = q.get('design_id') || q.get('id');
     if (did) {

← 117c9e5 fix(settlement): strip negative-prompt suffix before substri  ·  back to Wallco Ai  ·  feat(design page): split tabs into LEFT (public) + RIGHT (ad 60f4226 →