← 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
M public/admin/bg-tester.html
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 & 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 →