← back to Wallco Ai
marketplace: real-product texture picker + stripe/plaid generator
ebf7abdeb75c692bab2fdb999ce3053584505e97 · 2026-05-13 15:12:25 -0700 · SteveStudio2
New: /api/marketplace/textures — pulls Natural Wallcoverings from
shopify_products, buckets into 11 categories (grasscloth, sisal, jute,
silk, cork, raffia, linen, glass-bead, flocked, seagrass, paperweave),
in-process 10-min cache. 304 textures across 11 categories on first
load.
ai/generate-colorways now accepts textureSku or textureImageUrl. When
provided, the texture image is included as a 2nd Gemini inline_data
part with explicit instructions to ground the recolored motif on that
real-material surface. Falls back cleanly if the fetch fails.
ai/generate-stripe-plaid (new) — body { colors[], plaidTypes[],
textureSku? }. One Gemini call per requested type (gingham, tartan,
buffalo, windowpane, houndstooth, madras, glen, argyle, tattersall,
pinstripe) with the user-selected colors as the literal yarn list.
Persists each as a colorway row (ai_generated=true). Cost-tracked.
UI: pattern.html grows two new public sections — texture picker (per-
category horizontal-scroll strips with click-to-select) and plaid
generator (color-dot multi-select from existing colorway palettes +
plaid-type chip multi-select with Select-all / Clear). Both available
to all users, no login required.
Tests: ai-colorways.test.js adds 5 new cases (textures shape,
mock-with-textureSku, plaid validation x2, plaid mock fan-out).
10/10 cheap tests pass; verified one real plaid Gemini call writes
PNG, persists DB row, logs cost.
Files touched
M public/marketplace/pattern.htmlM src/marketplace/ai.jsM src/marketplace/index.jsA src/marketplace/textures.jsM tests/marketplace/ai-colorways.test.js
Diff
commit ebf7abdeb75c692bab2fdb999ce3053584505e97
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 15:12:25 2026 -0700
marketplace: real-product texture picker + stripe/plaid generator
New: /api/marketplace/textures — pulls Natural Wallcoverings from
shopify_products, buckets into 11 categories (grasscloth, sisal, jute,
silk, cork, raffia, linen, glass-bead, flocked, seagrass, paperweave),
in-process 10-min cache. 304 textures across 11 categories on first
load.
ai/generate-colorways now accepts textureSku or textureImageUrl. When
provided, the texture image is included as a 2nd Gemini inline_data
part with explicit instructions to ground the recolored motif on that
real-material surface. Falls back cleanly if the fetch fails.
ai/generate-stripe-plaid (new) — body { colors[], plaidTypes[],
textureSku? }. One Gemini call per requested type (gingham, tartan,
buffalo, windowpane, houndstooth, madras, glen, argyle, tattersall,
pinstripe) with the user-selected colors as the literal yarn list.
Persists each as a colorway row (ai_generated=true). Cost-tracked.
UI: pattern.html grows two new public sections — texture picker (per-
category horizontal-scroll strips with click-to-select) and plaid
generator (color-dot multi-select from existing colorway palettes +
plaid-type chip multi-select with Select-all / Clear). Both available
to all users, no login required.
Tests: ai-colorways.test.js adds 5 new cases (textures shape,
mock-with-textureSku, plaid validation x2, plaid mock fan-out).
10/10 cheap tests pass; verified one real plaid Gemini call writes
PNG, persists DB row, logs cost.
---
public/marketplace/pattern.html | 301 ++++++++++++++++++++++++++++++++-
src/marketplace/ai.js | 197 ++++++++++++++++++++-
src/marketplace/index.js | 3 +
src/marketplace/textures.js | 160 ++++++++++++++++++
tests/marketplace/ai-colorways.test.js | 59 +++++++
5 files changed, 702 insertions(+), 18 deletions(-)
diff --git a/public/marketplace/pattern.html b/public/marketplace/pattern.html
index b553c50..89d40e0 100644
--- a/public/marketplace/pattern.html
+++ b/public/marketplace/pattern.html
@@ -16,6 +16,43 @@
.colorway-card .name { font-weight: 600; margin-bottom: 6px; }
.specs { display:grid; grid-template-columns: 130px 1fr; gap: 4px 18px; font-size: 14px; color: var(--mp-muted); margin: 14px 0 22px; }
.specs .k { color: #222; }
+
+ /* Texture picker */
+ .tex-section { margin-top: 36px; padding: 22px; background: rgba(255,255,255,.5); border: 1px solid var(--mp-border); border-radius: 18px; }
+ .tex-section h3 { margin: 0 0 4px; }
+ .tex-section p.help { color: var(--mp-muted); margin: 0 0 14px; font-size: 14px; }
+ .tex-category { margin-top: 14px; }
+ .tex-category .cat-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
+ .tex-category .cat-label { font-weight: 600; }
+ .tex-category .cat-blurb { color: var(--mp-muted); font-size: 13px; }
+ .tex-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scroll-snap-type: x mandatory; }
+ .tex-strip::-webkit-scrollbar { height: 6px; }
+ .tex-strip::-webkit-scrollbar-thumb { background: rgba(0,0,0,.15); border-radius: 3px; }
+ .tex-chip { flex: 0 0 96px; aspect-ratio: 1/1; border-radius: 10px; background-size: cover; background-position: center; cursor: pointer; border: 2px solid transparent; scroll-snap-align: start; position: relative; transition: transform .12s ease, border-color .12s ease; }
+ .tex-chip:hover { transform: scale(1.04); }
+ .tex-chip.selected { border-color: #000; box-shadow: 0 0 0 3px rgba(0,0,0,.06); }
+ .tex-chip .tex-title { position: absolute; bottom: 0; left: 0; right: 0; padding: 4px 6px; font-size: 10px; color: #fff; background: linear-gradient(to top, rgba(0,0,0,.65), transparent); border-radius: 0 0 8px 8px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .tex-selected-banner { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: rgba(0,0,0,.04); border-radius: 12px; margin-top: 14px; font-size: 14px; }
+ .tex-selected-banner .pip { width: 28px; height: 28px; border-radius: 6px; background-size: cover; }
+ .tex-clear { color: var(--mp-muted); cursor: pointer; text-decoration: underline; font-size: 13px; }
+
+ /* Plaid generator */
+ .pl-section { margin-top: 36px; padding: 22px; background: rgba(255,255,255,.5); border: 1px solid var(--mp-border); border-radius: 18px; }
+ .pl-section h3 { margin: 0 0 4px; }
+ .pl-section p.help { color: var(--mp-muted); margin: 0 0 14px; font-size: 14px; }
+ .pl-row { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; }
+ .pl-block { flex: 1 1 240px; }
+ .pl-block label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 6px; }
+ .color-dots { display: flex; flex-wrap: wrap; gap: 8px; }
+ .color-dot { width: 32px; height: 32px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; box-shadow: 0 0 0 1px rgba(0,0,0,.12) inset; transition: transform .1s ease; position: relative; }
+ .color-dot:hover { transform: scale(1.1); }
+ .color-dot.selected { border-color: #000; box-shadow: 0 0 0 2px #fff inset, 0 0 0 4px rgba(0,0,0,.12); }
+ .color-dot.selected::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: #fff; font-size: 16px; text-shadow: 0 0 2px rgba(0,0,0,.6); font-weight: 700; }
+ .pl-tip { font-size: 12px; color: var(--mp-muted); margin-top: 6px; }
+ .pl-actions { margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+ .pl-multi { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; }
+ .pl-chip { font-size: 13px; padding: 6px 10px; background: rgba(0,0,0,.05); border: 1px solid var(--mp-border); border-radius: 999px; cursor: pointer; user-select: none; }
+ .pl-chip.selected { background: #111; color: #fff; border-color: #111; }
</style>
</head>
<body>
@@ -23,11 +60,51 @@
<script>
const slug = location.pathname.split('/').filter(Boolean).pop();
+
+// ── pattern-page state ────────────────────────────────────────────────────
+const state = {
+ pattern: null,
+ selectedTexture: null, // { sku, title, image_url, category } | null
+ patternColors: [], // extracted hex strings from existing colorways
+ pickedColors: new Set(), // user-selected hex colors for stripe/plaid
+ pickedPlaidTypes: new Set(), // user-selected plaid type keys
+};
+
+// 10 canonical plaid + stripe families.
+const PLAID_TYPES = [
+ { key: 'gingham', label: 'Gingham', blurb: 'Even-check 2-color woven.' },
+ { key: 'tartan', label: 'Tartan', blurb: 'Scottish multi-stripe twill.' },
+ { key: 'buffalo', label: 'Buffalo', blurb: 'Bold 2-color large check.' },
+ { key: 'windowpane', label: 'Windowpane', blurb: 'Thin grid on solid ground.' },
+ { key: 'houndstooth',label: 'Houndstooth', blurb: 'Broken-check geometric.' },
+ { key: 'madras', label: 'Madras', blurb: 'Lightweight bright cotton check.' },
+ { key: 'glen', label: 'Glen Plaid', blurb: 'Prince of Wales — woven crowfoot.' },
+ { key: 'argyle', label: 'Argyle', blurb: 'Diamond-on-diamond with overlay.' },
+ { key: 'tattersall', label: 'Tattersall', blurb: 'Two-color thin-line grid on light ground.' },
+ { key: 'pinstripe', label: 'Pinstripe', blurb: 'Vertical thin parallel stripes.' },
+];
+
+// ── page render ────────────────────────────────────────────────────────────
async function load() {
const j = await fetch('/api/marketplace/patterns/' + encodeURIComponent(slug)).then(r => r.json());
const root = document.getElementById('root');
if (!j.pattern) { root.innerHTML = `<div class="mp-wrap"><h2>Pattern not found</h2></div>`; return; }
const p = j.pattern;
+ state.pattern = p;
+
+ // Extract a deduped color list from existing colorways to seed the plaid picker
+ const colorSet = new Set();
+ (j.colorways || []).forEach(c => {
+ let pal = c.hex_palette;
+ if (typeof pal === 'string') { try { pal = JSON.parse(pal); } catch { pal = []; } }
+ (pal || []).forEach(h => { if (/^#[0-9a-f]{6}$/i.test(h)) colorSet.add(h.toLowerCase()); });
+ });
+ // Fall back to the 6 editorial palettes if no colorways exist yet
+ if (colorSet.size < 4) {
+ ['#B46A55','#E8C7A9','#69785A','#2F3A2D','#1F2E46','#7E9AB8','#C98986','#80514F','#BFA76A','#151515','#F4EFE8','#FFF8F5'].forEach(h => colorSet.add(h));
+ }
+ state.patternColors = Array.from(colorSet);
+
root.innerHTML = `
<main class="mp-wrap">
<div class="pat-grid">
@@ -55,39 +132,244 @@ async function load() {
<button class="mp-btn" onclick="orderSample()">Order sample · $${(p.sample_price||5).toFixed(2)}</button>
<button class="mp-btn ghost" onclick="orderRoll()">Order full roll · $${(p.base_price||248).toFixed(2)}</button>
<a class="mp-btn ghost" href="/licensing/${p.slug}">License artwork</a>
- <button class="mp-btn ghost" onclick="aiColorways()">Generate AI colorways</button>
+ <button class="mp-btn ghost" id="btn-ai-colorways" onclick="aiColorways()">Generate AI colorways</button>
</div>
<h3 style="margin-top: 30px">Colorways</h3>
<div class="mp-grid" id="cw"></div>
</div>
</div>
+
+ <!-- ── Real-product texture picker ─────────────────────────────────── -->
+ <section class="tex-section" id="texSection">
+ <h3>Choose a real-product background texture</h3>
+ <p class="help">Pick any natural wallcovering we sell — your colorways will be regenerated on that exact texture (grasscloth, sisal, cork, silk, raffia, linen, glass-bead, flocked, jute, seagrass, paperweave). Available to all users, no login required.</p>
+ <div id="texCategories">Loading textures…</div>
+ <div id="texSelectedBanner" style="display:none;"></div>
+ </section>
+
+ <!-- ── Stripe / plaid generator ────────────────────────────────────── -->
+ <section class="pl-section" id="plSection">
+ <h3>Create a stripe or plaid in these colors</h3>
+ <p class="help">Click any color dot to include it. Pick one or all the plaid/stripe types — we'll generate a seamless variant for each type you select.</p>
+
+ <div class="pl-row">
+ <div class="pl-block">
+ <label>Colors <span style="font-weight:400;color:var(--mp-muted)" id="plColorCount">(0 selected)</span></label>
+ <div class="color-dots" id="colorDots"></div>
+ <div class="pl-tip">Tap a dot to toggle it. We use these as the literal yarns in the woven plaid.</div>
+ </div>
+ <div class="pl-block">
+ <label>Plaid / stripe types <span style="font-weight:400;color:var(--mp-muted)" id="plTypeCount">(0 selected)</span></label>
+ <div class="pl-multi" id="plTypes"></div>
+ <div class="pl-actions">
+ <a class="tex-clear" onclick="selectAllPlaids()">Select all</a>
+ <span style="color:var(--mp-muted)">·</span>
+ <a class="tex-clear" onclick="clearPlaids()">Clear</a>
+ </div>
+ </div>
+ </div>
+
+ <div class="pl-actions" style="margin-top:18px">
+ <button class="mp-btn" id="btn-gen-plaid" onclick="generatePlaids()">Generate selected stripe/plaid variants</button>
+ <span style="color:var(--mp-muted);font-size:13px" id="plSummary"></span>
+ </div>
+
+ <div class="mp-grid" id="plResults" style="margin-top:18px"></div>
+ </section>
</main>`;
+ // initial render — existing DB colorways
const cw = document.getElementById('cw');
(j.colorways || []).forEach(c => {
const palette = Array.isArray(c.hex_palette) ? c.hex_palette : (c.hex_palette ? JSON.parse(c.hex_palette) : []);
- const card = document.createElement('div'); card.className='colorway-card';
- card.innerHTML = `<div class="name">${c.name}${c.is_primary ? ' · primary' : ''}</div>
+ const card = document.createElement('div'); card.className = 'colorway-card';
+ const imgHtml = c.image_url ? `<div style="aspect-ratio:1/1;border-radius:12px;background:#eee url('${c.image_url}') center/cover;margin-bottom:10px;"></div>` : '';
+ card.innerHTML = imgHtml +
+ `<div class="name">${c.name}${c.is_primary ? ' · primary' : ''}</div>
<div class="swatch-row">${palette.map(h => `<div class="swatch" style="background:${h}" title="${h}"></div>`).join('')}</div>`;
cw.appendChild(card);
});
+
+ renderColorDots();
+ renderPlaidTypes();
+ loadTextures();
+}
+
+// ── Texture picker ─────────────────────────────────────────────────────────
+async function loadTextures() {
+ try {
+ const r = await fetch('/api/marketplace/textures');
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || 'failed to load');
+ const root = document.getElementById('texCategories');
+ if (!j.groups || !j.groups.length) { root.textContent = 'No textures available.'; return; }
+ root.innerHTML = j.groups.map(g => `
+ <div class="tex-category">
+ <div class="cat-head">
+ <span class="cat-label">${g.label} <span style="color:var(--mp-muted);font-weight:400;">· ${g.count}</span></span>
+ <span class="cat-blurb">${g.blurb || ''}</span>
+ </div>
+ <div class="tex-strip" data-cat="${g.key}">
+ ${g.textures.map(t => `
+ <div class="tex-chip" data-sku="${t.sku}" data-img="${t.image_url}" data-title="${(t.title||'').replace(/"/g,'"')}" data-category="${g.key}"
+ style="background-image:url('${t.image_url}')"
+ title="${(t.full_title || t.title || '').replace(/"/g,'"')}">
+ <div class="tex-title">${t.title || 'Untitled'}</div>
+ </div>
+ `).join('')}
+ </div>
+ </div>
+ `).join('');
+ root.querySelectorAll('.tex-chip').forEach(el => {
+ el.addEventListener('click', () => selectTexture({
+ sku: el.dataset.sku,
+ image_url: el.dataset.img,
+ title: el.dataset.title,
+ category: el.dataset.category,
+ }, el));
+ });
+ restoreTextureFromStorage();
+ } catch (err) {
+ document.getElementById('texCategories').textContent = 'Could not load textures: ' + err.message;
+ }
}
+
+const TEX_LS_KEY = 'wallco.mp.selectedTexture';
+
+function selectTexture(t, el) {
+ state.selectedTexture = t;
+ document.querySelectorAll('.tex-chip.selected').forEach(x => x.classList.remove('selected'));
+ if (el) el.classList.add('selected');
+ const banner = document.getElementById('texSelectedBanner');
+ banner.style.display = 'flex';
+ banner.className = 'tex-selected-banner';
+ banner.innerHTML = `
+ <div class="pip" style="background-image:url('${t.image_url}')"></div>
+ <div><strong>${t.title}</strong> <span style="color:var(--mp-muted);">· ${t.category}</span></div>
+ <a class="tex-clear" onclick="clearTexture()" style="margin-left:auto">Remove</a>
+ `;
+ try { localStorage.setItem(TEX_LS_KEY, JSON.stringify(t)); } catch (_) {}
+}
+window.clearTexture = function() {
+ state.selectedTexture = null;
+ document.querySelectorAll('.tex-chip.selected').forEach(x => x.classList.remove('selected'));
+ document.getElementById('texSelectedBanner').style.display = 'none';
+ try { localStorage.removeItem(TEX_LS_KEY); } catch (_) {}
+};
+
+// Re-select the user's last-used texture if it's still in the catalog.
+function restoreTextureFromStorage() {
+ let saved = null;
+ try { saved = JSON.parse(localStorage.getItem(TEX_LS_KEY) || 'null'); } catch (_) {}
+ if (!saved || !saved.sku) return;
+ const chip = document.querySelector(`.tex-chip[data-sku="${CSS.escape(saved.sku)}"]`);
+ if (chip) {
+ selectTexture({
+ sku: chip.dataset.sku,
+ image_url: chip.dataset.img,
+ title: chip.dataset.title,
+ category: chip.dataset.category,
+ }, chip);
+ // Scroll the strip so the selected chip is visible.
+ try { chip.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'center' }); } catch (_) {}
+ }
+}
+
+// ── Plaid / stripe color picker + type chooser ─────────────────────────────
+function renderColorDots() {
+ const root = document.getElementById('colorDots');
+ root.innerHTML = state.patternColors.map(h => `
+ <div class="color-dot" data-hex="${h}" style="background:${h}" title="${h}"></div>
+ `).join('');
+ root.querySelectorAll('.color-dot').forEach(el => {
+ el.addEventListener('click', () => {
+ const hex = el.dataset.hex;
+ if (state.pickedColors.has(hex)) { state.pickedColors.delete(hex); el.classList.remove('selected'); }
+ else { state.pickedColors.add(hex); el.classList.add('selected'); }
+ document.getElementById('plColorCount').textContent = `(${state.pickedColors.size} selected)`;
+ });
+ });
+}
+
+function renderPlaidTypes() {
+ const root = document.getElementById('plTypes');
+ root.innerHTML = PLAID_TYPES.map(t => `
+ <span class="pl-chip" data-key="${t.key}" title="${t.blurb}">${t.label}</span>
+ `).join('');
+ root.querySelectorAll('.pl-chip').forEach(el => {
+ el.addEventListener('click', () => {
+ const k = el.dataset.key;
+ if (state.pickedPlaidTypes.has(k)) { state.pickedPlaidTypes.delete(k); el.classList.remove('selected'); }
+ else { state.pickedPlaidTypes.add(k); el.classList.add('selected'); }
+ document.getElementById('plTypeCount').textContent = `(${state.pickedPlaidTypes.size} selected)`;
+ });
+ });
+}
+
+window.selectAllPlaids = function() {
+ state.pickedPlaidTypes = new Set(PLAID_TYPES.map(t => t.key));
+ document.querySelectorAll('#plTypes .pl-chip').forEach(el => el.classList.add('selected'));
+ document.getElementById('plTypeCount').textContent = `(${state.pickedPlaidTypes.size} selected)`;
+};
+window.clearPlaids = function() {
+ state.pickedPlaidTypes.clear();
+ document.querySelectorAll('#plTypes .pl-chip').forEach(el => el.classList.remove('selected'));
+ document.getElementById('plTypeCount').textContent = '(0 selected)';
+};
+
+window.generatePlaids = async function() {
+ if (!state.pickedColors.size) { alert('Pick at least one color.'); return; }
+ if (!state.pickedPlaidTypes.size) { alert('Pick at least one plaid/stripe type.'); return; }
+ const colors = Array.from(state.pickedColors);
+ const types = Array.from(state.pickedPlaidTypes);
+ const btn = document.getElementById('btn-gen-plaid');
+ const summary = document.getElementById('plSummary');
+ const results = document.getElementById('plResults');
+ btn.disabled = true;
+ btn.textContent = `Generating ${types.length} variant${types.length>1?'s':''}…`;
+ summary.textContent = `${colors.length} color${colors.length>1?'s':''} · ${types.length} variant${types.length>1?'s':''} · ~${Math.ceil(types.length * 10)}s`;
+ try {
+ const r = await fetch('/api/marketplace/ai/generate-stripe-plaid', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ patternSlug: slug, colors, plaidTypes: types, textureSku: state.selectedTexture?.sku || null }),
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
+ (j.variants || []).forEach(v => {
+ const card = document.createElement('div'); card.className = 'colorway-card';
+ const imgHtml = v.image_url ? `<div style="aspect-ratio:1/1;border-radius:12px;background:#eee url('${v.image_url}') center/cover;margin-bottom:10px;"></div>` : '';
+ card.innerHTML = imgHtml +
+ `<div class="name">${v.label || v.type} <span style="color:var(--mp-muted);font-weight:400;">· AI</span></div>` +
+ `<div class="swatch-row">${(v.hex_palette||[]).map(h => `<div class="swatch" style="background:${h}" title="${h}"></div>`).join('')}</div>`;
+ results.appendChild(card);
+ });
+ btn.textContent = `Generated ${j.variants?.length || 0}/${types.length}`;
+ if (j.errors && j.errors.length) summary.textContent += ` · ${j.errors.length} failed`;
+ } catch (err) {
+ alert('Stripe/plaid generation failed: ' + err.message);
+ btn.disabled = false;
+ btn.textContent = 'Generate selected stripe/plaid variants';
+ }
+};
+
+// ── colorway generator (texture-aware) ─────────────────────────────────────
window.orderSample = function() { alert('Sample order — wires to Designer Wallcoverings fulfillment ($5 ships in 3–5 days).'); };
window.orderRoll = function() { alert('Full-roll order — wires to Shopify checkout.'); };
window.aiColorways = async function() {
- const btn = event && event.target;
+ const btn = document.getElementById('btn-ai-colorways');
const cwEl = document.getElementById('cw');
- if (btn) { btn.disabled = true; btn.textContent = 'Generating 6 colorways… (~30s)'; }
+ const hasTexture = !!state.selectedTexture;
+ if (btn) { btn.disabled = true; btn.textContent = hasTexture ? `Generating on ${state.selectedTexture.title}… (~30s)` : 'Generating 6 colorways… (~30s)'; }
try {
const r = await fetch('/api/marketplace/ai/generate-colorways', {
method: 'POST',
headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ patternSlug: slug }),
+ body: JSON.stringify({ patternSlug: slug, textureSku: state.selectedTexture?.sku || null }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
- // Render the freshly-generated colorways into the existing #cw grid
if (cwEl && Array.isArray(j.colorways)) {
j.colorways.forEach(c => {
const palette = Array.isArray(c.hex_palette) ? c.hex_palette : [];
@@ -95,13 +377,14 @@ window.aiColorways = async function() {
const imgHtml = c.image_url
? `<div style="aspect-ratio:1/1;border-radius:12px;background:#eee url('${c.image_url}') center/cover;margin-bottom:10px;"></div>`
: '';
+ const texLabel = hasTexture ? ` <span style="color:var(--mp-muted);font-weight:400;">· on ${state.selectedTexture.category}</span>` : '';
card.innerHTML = imgHtml +
- `<div class="name">${c.name} <span style="color:var(--mp-muted);font-weight:400;">· AI</span></div>` +
+ `<div class="name">${c.name} <span style="color:var(--mp-muted);font-weight:400;">· AI</span>${texLabel}</div>` +
`<div class="swatch-row">${palette.map(h => `<div class="swatch" style="background:${h}" title="${h}"></div>`).join('')}</div>`;
cwEl.appendChild(card);
});
}
- if (btn) btn.textContent = `Generated ${j.colorways?.length || 0} colorways`;
+ if (btn) btn.textContent = `Generated ${j.colorways?.length || 0} colorways${hasTexture ? ' on ' + state.selectedTexture.category : ''}`;
} catch (err) {
alert('Colorway generation failed: ' + err.message);
if (btn) { btn.disabled = false; btn.textContent = 'Generate AI colorways'; }
diff --git a/src/marketplace/ai.js b/src/marketplace/ai.js
index 9259240..62e2050 100644
--- a/src/marketplace/ai.js
+++ b/src/marketplace/ai.js
@@ -26,6 +26,7 @@ const { spawnSync } = require('child_process');
const { q, one } = require('./db');
const { shortToken } = require('./util');
const { ollamaChat, extractJson, cleanStringArray, safePromptField } = require('./ollama');
+const { findTextureBySku } = require('./textures');
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'marketplace', 'uploads');
const IMG_DIR = path.join(__dirname, '..', '..', 'data', 'generated');
@@ -102,17 +103,28 @@ function detectMime(buf, hintUrl) {
return 'image/jpeg';
}
-async function callGemini({ srcB64, mime, prompt, app, note }) {
+async function callGemini({ srcB64, mime, prompt, app, note, textureB64, textureMime }) {
const KEY = process.env.GEMINI_API_KEY;
if (!KEY) throw new Error('GEMINI_API_KEY missing');
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`;
+ // When a texture reference is provided, include it as a second inline_data
+ // part and prepend an explicit instruction so Gemini grounds the pattern on
+ // the user-selected real-material surface.
+ const parts = [
+ { inline_data: { mime_type: mime, data: srcB64 } },
+ ];
+ if (textureB64) {
+ parts.push({ inline_data: { mime_type: textureMime || 'image/jpeg', data: textureB64 } });
+ parts.push({ text:
+ 'Use the SECOND image as the BACKGROUND TEXTURE / GROUND material for the recolored wallcovering. ' +
+ 'Keep the visible weave, grain, and tactile material of that second image as the ground surface. ' +
+ 'The motif from the FIRST image should sit on top of that ground, recolored per the instructions below. ' +
+ prompt + ' ' + SHARED_TAIL });
+ } else {
+ parts.push({ text: prompt + ' ' + SHARED_TAIL });
+ }
const body = {
- contents: [{
- parts: [
- { inline_data: { mime_type: mime, data: srcB64 } },
- { text: prompt + ' ' + SHARED_TAIL },
- ],
- }],
+ contents: [{ parts }],
generationConfig: { responseModalities: ['IMAGE'] },
};
const r = await fetch(url, {
@@ -158,11 +170,12 @@ print(json.dumps(['#{:02x}{:02x}{:02x}'.format(*rgb) for rgb,_ in cnt.most_commo
return fallback || [];
}
-async function generateOneColorway({ patternId, srcB64, mime, def, persist }) {
+async function generateOneColorway({ patternId, srcB64, mime, def, persist, textureB64, textureMime, textureSku }) {
const buf = await callGemini({
srcB64, mime, prompt: def.prompt,
+ textureB64, textureMime,
app: 'wallco-ai',
- note: `marketplace-colorway pattern=${patternId || 'n/a'} ${def.name}`,
+ note: `marketplace-colorway pattern=${patternId || 'n/a'} ${def.name}${textureSku ? ' tex=' + textureSku : ''}`,
});
const filename = `colorway-${Date.now()}-${shortToken(5)}.png`;
const outPath = path.join(UPLOAD_DIR, filename);
@@ -398,6 +411,27 @@ function mount(app) {
const mime = detectMime(buf, imageUrl);
const srcB64 = buf.toString('base64');
+ // Optional: a real-product texture chosen by the user to act as the
+ // background ground. Resolved either by SKU (from /api/marketplace/textures)
+ // or directly by URL. Falls back to no-texture if the fetch fails — never
+ // bricks the whole call.
+ let textureB64 = null, textureMime = null, texture = null;
+ let textureUrl = body.textureImageUrl || null;
+ if (body.textureSku) {
+ texture = await findTextureBySku(body.textureSku).catch(() => null);
+ if (texture) textureUrl = texture.image_url;
+ }
+ if (textureUrl) {
+ try {
+ const tSrc = resolveSource(textureUrl);
+ const tBuf = await loadSourceBuffer(tSrc);
+ textureMime = detectMime(tBuf, textureUrl);
+ textureB64 = tBuf.toString('base64');
+ } catch (e) {
+ console.warn('[ai/generate-colorways] texture fetch failed, falling back:', e.message);
+ }
+ }
+
const persist = !!pattern && body.skipPersist !== true && body.skipPersist !== 'true';
// Run all 6 calls in parallel — each takes ~5-15s, so this lands ~1×latency
@@ -407,6 +441,7 @@ function mount(app) {
COLORWAYS.map(def => generateOneColorway({
patternId: pattern?.id || null,
srcB64, mime, def, persist,
+ textureB64, textureMime, textureSku: body.textureSku || null,
}))
);
const colorways = results
@@ -422,6 +457,7 @@ function mount(app) {
res.json({
status: 'ok',
pattern: pattern ? { id: pattern.id, slug: pattern.slug } : null,
+ texture: texture || (textureB64 && textureUrl ? { image_url: textureUrl, sku: body.textureSku || null } : null),
colorways,
errors: errors.length ? errors : undefined,
});
@@ -518,6 +554,149 @@ function mount(app) {
note: 'Wire to ComfyUI / Replicate / Gemini Image once API key exists; this stub returns shape only.'
});
});
+
+ // /api/marketplace/ai/generate-stripe-plaid
+ // Body: { patternSlug?|patternId?, colors: ["#aabbcc", ...], plaidTypes: ["gingham","tartan",...], textureSku? }
+ // For each requested plaidType, fires one Gemini text→image call that
+ // composes a seamless tile in the chosen colors, optionally rendered ON the
+ // selected real-product texture. Persists each variant as a colorway row
+ // (ai_generated=true) so they show up on the pattern detail page alongside
+ // the recolor variants.
+ app.post('/api/marketplace/ai/generate-stripe-plaid', async (req, res) => {
+ const body = req.body || {};
+ const colors = Array.isArray(body.colors) ? body.colors.filter(c => /^#[0-9a-f]{6}$/i.test(c)).slice(0, 12) : [];
+ const plaidTypes = Array.isArray(body.plaidTypes) ? body.plaidTypes.map(String).slice(0, 12) : [];
+ if (!colors.length) return res.status(400).json({ error: 'colors[] required (at least one hex)' });
+ if (!plaidTypes.length) return res.status(400).json({ error: 'plaidTypes[] required (at least one type)' });
+ if (body.mock === true || body.mock === 'true') {
+ return res.json({
+ status: 'mock',
+ variants: plaidTypes.map(t => ({
+ type: t, label: t,
+ image_url: null, ai_generated: false,
+ hex_palette: colors.slice(0, 5),
+ })),
+ });
+ }
+ if (!process.env.GEMINI_API_KEY) return res.status(500).json({ error: 'GEMINI_API_KEY not configured' });
+
+ // Plaid-specific recolor prompts. The Gemini call here is text→image (no
+ // source image) since we're inventing a brand-new tile rather than
+ // recoloring an existing one. The user-selected colors are passed as the
+ // literal yarn list and shoul read as distinct stripes/checks.
+ const PLAID_PROMPTS = {
+ gingham: 'a seamless GINGHAM check — even-sized squares in a 2-color weave with woven-thread micro-detail',
+ tartan: 'a seamless TARTAN — multi-stripe twill plaid with overlapping vertical and horizontal bands of equal width',
+ buffalo: 'a seamless BUFFALO check — large 2-tone bold squares (~2" each), heavyweight woven flannel feel',
+ windowpane: 'a seamless WINDOWPANE — thin clean grid lines on a solid ground, generous spacing, suiting fabric look',
+ houndstooth:'a seamless HOUNDSTOOTH — broken-check geometric, classic 4-pointed star shapes, two-color',
+ madras: 'a seamless MADRAS plaid — lightweight cotton, irregular bright bands in multiple colors, slightly faded',
+ glen: 'a seamless GLEN PLAID (Prince of Wales) — woven crowfoot houndstooth with overcheck of complementary lines',
+ argyle: 'a seamless ARGYLE — repeating diamond grid with overlay line motif, knit-sweater feel',
+ tattersall: 'a seamless TATTERSALL — two-color thin grid lines forming small even squares on a light ground',
+ pinstripe: 'a seamless PINSTRIPE — narrow even vertical parallel stripes on solid ground, classic suiting',
+ };
+
+ try {
+ // Resolve pattern (optional — only needed if we want to persist as colorway)
+ let pattern = null;
+ if (body.patternId) {
+ pattern = await one(`SELECT id, slug, original_image_url FROM mp_patterns WHERE id=$1`, [parseInt(body.patternId, 10)]);
+ } else if (body.patternSlug) {
+ pattern = await one(`SELECT id, slug, original_image_url FROM mp_patterns WHERE slug=$1`, [body.patternSlug]);
+ }
+ // Optional texture (real-product ground material)
+ let textureB64 = null, textureMime = null;
+ let textureUrl = body.textureImageUrl || null;
+ if (body.textureSku) {
+ const tex = await findTextureBySku(body.textureSku).catch(() => null);
+ if (tex) textureUrl = tex.image_url;
+ }
+ if (textureUrl) {
+ try {
+ const tSrc = resolveSource(textureUrl);
+ const tBuf = await loadSourceBuffer(tSrc);
+ textureMime = detectMime(tBuf, textureUrl);
+ textureB64 = tBuf.toString('base64');
+ } catch (e) {
+ console.warn('[ai/generate-stripe-plaid] texture fetch failed:', e.message);
+ }
+ }
+
+ const KEY = process.env.GEMINI_API_KEY;
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`;
+ const persist = !!pattern && body.skipPersist !== true && body.skipPersist !== 'true';
+
+ const results = await Promise.allSettled(plaidTypes.map(async (typeKey) => {
+ const family = PLAID_PROMPTS[typeKey];
+ if (!family) throw new Error('unknown plaid type: ' + typeKey);
+ const colorList = colors.map(c => c.toUpperCase()).join(', ');
+ const promptText = (textureB64
+ ? 'Use the provided reference image as the BACKGROUND TEXTURE / GROUND MATERIAL. Render the new plaid woven into that visible material. '
+ : '') +
+ `Generate ${family}, using ONLY these colors as the yarns: ${colorList}. ` +
+ 'Create a perfectly seamless tile that repeats with no edge artifacts. ' +
+ SHARED_TAIL;
+
+ const parts = [];
+ if (textureB64) parts.push({ inline_data: { mime_type: textureMime || 'image/jpeg', data: textureB64 } });
+ parts.push({ text: promptText });
+
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts }],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ }),
+ });
+ if (!r.ok) throw new Error(`gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
+ const j = await r.json();
+ try {
+ const { logGemini } = require(path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
+ logGemini(j, { app: 'wallco-ai', note: `marketplace-plaid ${typeKey} pattern=${pattern?.id || 'n/a'}${body.textureSku ? ' tex=' + body.textureSku : ''}`, model: 'gemini-2.5-flash-image' });
+ } catch (_) {}
+ const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ const data = part?.inline_data?.data || part?.inlineData?.data;
+ if (!data) {
+ const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text;
+ throw new Error(`gemini returned no image (${j.candidates?.[0]?.finishReason || 'unknown'}): ${(text || '').slice(0, 150)}`);
+ }
+ const filename = `plaid-${typeKey}-${Date.now()}-${shortToken(4)}.png`;
+ const outPath = path.join(UPLOAD_DIR, filename);
+ fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+ const rel = '/marketplace/uploads/' + filename;
+ const palette = extractPalette(outPath, colors);
+
+ let row = null;
+ if (persist && pattern) {
+ const label = typeKey.charAt(0).toUpperCase() + typeKey.slice(1);
+ row = await one(
+ `INSERT INTO mp_pattern_colorways (pattern_id, name, image_url, hex_palette, is_primary, ai_generated)
+ VALUES ($1,$2,$3,$4::jsonb,false,true)
+ RETURNING id`,
+ [pattern.id, `${label} · ${colors.length} colors`, rel, JSON.stringify(palette)]
+ );
+ }
+ return {
+ id: row?.id || null,
+ type: typeKey,
+ label: typeKey.charAt(0).toUpperCase() + typeKey.slice(1),
+ image_url: rel,
+ hex_palette: palette,
+ ai_generated: true,
+ };
+ }));
+
+ const variants = results.filter(r => r.status === 'fulfilled').map(r => r.value);
+ const errors = results.filter(r => r.status === 'rejected').map(r => String(r.reason?.message || r.reason).slice(0, 200));
+ if (!variants.length) return res.status(502).json({ error: 'all gemini calls failed', details: errors });
+ res.json({ status: 'ok', pattern: pattern ? { id: pattern.id, slug: pattern.slug } : null, variants, errors: errors.length ? errors : undefined });
+ } catch (err) {
+ console.error('[ai/generate-stripe-plaid]', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
}
module.exports = {
diff --git a/src/marketplace/index.js b/src/marketplace/index.js
index 496879a..070f46f 100644
--- a/src/marketplace/index.js
+++ b/src/marketplace/index.js
@@ -774,6 +774,7 @@ function mount(app) {
// ── AI stubs
require('./ai').mount(app);
+ require('./textures').mount(app);
// ── Designer follow toggle + trade project board listing (split out to avoid index bloat).
require('./follow-projects').mount(app, { requireDesigner, verifyDesigner, readDesignerCookie });
@@ -858,6 +859,8 @@ function mount(app) {
app.get('/marketplace/leaderboard', sendPage('leaderboard.html'));
app.get('/marketplace/challenges', sendPage('challenges.html'));
app.get('/marketplace/challenges/:id', sendPage('challenge.html'));
+ app.get('/marketplace/projects', sendPage('projects.html'));
+ app.get('/trade/projects', sendPage('projects.html'));
// ── /sitemap-marketplace.xml — every approved designer + pattern URL.
// Separate from /sitemap.xml so marketplace ops can ship/refresh it without
diff --git a/src/marketplace/textures.js b/src/marketplace/textures.js
new file mode 100644
index 0000000..621d7ba
--- /dev/null
+++ b/src/marketplace/textures.js
@@ -0,0 +1,160 @@
+// Real-product texture catalog for the colorway generator UI.
+//
+// Pulls Natural-Wallcovering-class products out of dw_unified.shopify_products
+// and buckets them into a fixed set of texture categories so users can pick a
+// real ground material (grasscloth, sisal, cork, silk, raffia, linen,
+// glass-bead, flocked, jute, seagrass, paperweave). The picked texture is
+// passed to /api/marketplace/ai/generate-colorways as either textureSku (we
+// look it up server-side) or textureImageUrl (used directly).
+//
+// /api/marketplace/textures — full catalog grouped by category
+// /api/marketplace/textures/:sku — single texture lookup (used internally
+// by generate-colorways but also handy
+// for the UI to refresh a chip)
+
+const { many, one } = require('./db');
+
+// Order matters — first match wins. The earlier a category appears here, the
+// more specific it is, so we sort the regex list from most-specific to most-
+// generic ("Grasscloth" is the catch-all bucket and lives last).
+const CATEGORIES = [
+ { key: 'glass-bead', label: 'Glass Bead', match: /\bglass\s*bead|beaded\b/i, blurb: 'Hand-applied glass beads on paper — light-catching, jewelbox-luxe.' },
+ { key: 'flocked', label: 'Flocked', match: /\bflock(ed)?\b/i, blurb: 'Velvet-soft raised fibers — old-world damask drama.' },
+ { key: 'cork', label: 'Cork', match: /\bcork\b/i, blurb: 'Warm, organic bark texture with natural figuring.' },
+ { key: 'raffia', label: 'Raffia', match: /\braffia\b/i, blurb: 'Loose-weave palm fiber — beach-house casual.' },
+ { key: 'silk', label: 'Silk', match: /\bsilk\b/i, blurb: 'Lustrous silk threads with subtle sheen.' },
+ { key: 'sisal', label: 'Sisal', match: /\bsisal\b/i, blurb: 'Crisp agave-fiber weave with directional ribbing.' },
+ { key: 'jute', label: 'Jute', match: /\bjute\b/i, blurb: 'Coarse natural jute — substantial, tactile.' },
+ { key: 'seagrass', label: 'Seagrass', match: /\bseagrass\b/i, blurb: 'Smooth marine reed with subtle green-gold cast.' },
+ { key: 'paperweave', label: 'Paperweave', match: /\bpaper\s*weave\b/i, blurb: 'Tightly-woven paper threads — clean, contemporary.' },
+ { key: 'linen', label: 'Linen', match: /\blinen\b/i, blurb: 'Soft linen weave — quiet, residential.' },
+ { key: 'grasscloth', label: 'Grasscloth', match: /\bgrass\s*cloth\b/i, blurb: 'Classic Asian grass-and-paper backing — the natural-wallcovering archetype.' },
+];
+
+function categoryOf(title, tags) {
+ const haystack = `${title || ''} ${tags || ''}`;
+ for (const c of CATEGORIES) {
+ if (c.match.test(haystack)) return c.key;
+ }
+ return null; // skip; we never bucket non-natural products
+}
+
+// Strip vendor suffix from titles — most Shopify titles are
+// "Pattern Name | Vendor Name". Drop the vendor tail for cleaner display.
+// Also collapse whitespace and dump anything past the first newline so
+// embedded spec blocks ("Repeat: 18\"…") don't leak into the chip.
+function shortTitle(t) {
+ if (!t) return 'Untitled';
+ let s = String(t).split('\n')[0].replace(/\s+/g, ' ').trim();
+ const i = s.indexOf('|');
+ if (i > 0) {
+ const head = s.slice(0, i).trim();
+ if (head.length >= 3) s = head;
+ } else if (i === 0) {
+ s = s.slice(1).trim();
+ }
+ return s || 'Untitled';
+}
+
+// Simple in-process cache so the catalog query doesn't run on every page load.
+// The corpus changes maybe weekly, so a 10-minute TTL is plenty.
+let _cache = null;
+let _cacheTs = 0;
+const CACHE_TTL_MS = 10 * 60 * 1000;
+
+// Returns a flat list of texture rows. Caller groups them by category.
+async function loadAllTextures(limitPerCategory = 30) {
+ if (_cache && (Date.now() - _cacheTs) < CACHE_TTL_MS) return _cache;
+ // Scope the corpus to wallcovering products (~5K rows) instead of the full
+ // shopify_products (~30K) so the ILIKE-ANY scan is cheap. Cap to 600 — that's
+ // 6× the per-category limit × 11 categories with buffer.
+ const rows = await many(`
+ SELECT id, sku, title, image_url, tags, product_type, vendor
+ FROM shopify_products
+ WHERE image_url IS NOT NULL
+ AND image_url <> ''
+ AND product_type ILIKE '%wallcovering%'
+ AND (
+ title ILIKE ANY (ARRAY['%cork%','%silk%','%raffia%','%sisal%','%jute%','%seagrass%','%linen%','%flock%','%glass bead%','%beaded%','%paperweave%','%paper weave%','%grasscloth%','%grass cloth%'])
+ OR tags ILIKE ANY (ARRAY['%cork%','%silk%','%raffia%','%sisal%','%jute%','%seagrass%','%linen%','%flocked%','%glass bead%','%beaded%','%paperweave%','%grasscloth%','%natural wallcovering%'])
+ )
+ ORDER BY title
+ LIMIT 600
+ `);
+
+ // Bucket + cap per category
+ const seenSku = new Set();
+ const byCategory = Object.fromEntries(CATEGORIES.map(c => [c.key, []]));
+ for (const r of rows) {
+ if (r.sku && seenSku.has(r.sku)) continue;
+ if (r.sku) seenSku.add(r.sku);
+ const cat = categoryOf(r.title, r.tags);
+ if (!cat) continue;
+ const bucket = byCategory[cat];
+ if (bucket.length >= limitPerCategory) continue;
+ bucket.push({
+ sku: r.sku || `id-${r.id}`,
+ title: shortTitle(r.title),
+ full_title: r.title,
+ vendor: r.vendor || null,
+ image_url: r.image_url,
+ category: cat,
+ });
+ }
+
+ const result = CATEGORIES.map(c => ({
+ key: c.key,
+ label: c.label,
+ blurb: c.blurb,
+ count: byCategory[c.key].length,
+ textures: byCategory[c.key],
+ })).filter(g => g.count > 0);
+ _cache = result;
+ _cacheTs = Date.now();
+ return result;
+}
+
+async function findTextureBySku(sku) {
+ if (!sku) return null;
+ const r = await one(
+ `SELECT id, sku, title, image_url, tags, vendor
+ FROM shopify_products
+ WHERE sku = $1 AND image_url IS NOT NULL AND image_url <> ''
+ LIMIT 1`,
+ [sku]
+ );
+ if (!r) return null;
+ return {
+ sku: r.sku,
+ title: shortTitle(r.title),
+ full_title: r.title,
+ vendor: r.vendor || null,
+ image_url: r.image_url,
+ category: categoryOf(r.title, r.tags),
+ };
+}
+
+function mount(app) {
+ app.get('/api/marketplace/textures', async (_req, res) => {
+ try {
+ const groups = await loadAllTextures();
+ const total = groups.reduce((sum, g) => sum + g.count, 0);
+ res.json({ ok: true, total, groups });
+ } catch (err) {
+ console.error('[textures]', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ app.get('/api/marketplace/textures/:sku', async (req, res) => {
+ try {
+ const row = await findTextureBySku(req.params.sku);
+ if (!row) return res.status(404).json({ error: 'texture not found' });
+ res.json({ texture: row });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+ });
+}
+
+module.exports = { mount, loadAllTextures, findTextureBySku, CATEGORIES };
diff --git a/tests/marketplace/ai-colorways.test.js b/tests/marketplace/ai-colorways.test.js
index 86a7cec..50e636c 100644
--- a/tests/marketplace/ai-colorways.test.js
+++ b/tests/marketplace/ai-colorways.test.js
@@ -93,6 +93,65 @@ t('mp_pattern_colorways table has ai_generated column', async () => {
assert.ok(Array.isArray(r.json.colorways), 'colorways[] expected');
});
+// ── Real-product texture catalog ────────────────────────────────────────────
+t('GET /api/marketplace/textures returns grouped natural-wallcovering catalog', async () => {
+ const r = await req('GET', '/api/marketplace/textures');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.json.ok, 'expected ok=true');
+ assert.ok(Number(r.json.total) > 0, 'expected total>0');
+ assert.ok(Array.isArray(r.json.groups) && r.json.groups.length >= 3,
+ `expected ≥3 categories, got ${r.json.groups?.length || 0}`);
+ // Each group has shape { key, label, blurb, count, textures:[{sku,title,image_url,category}] }
+ for (const g of r.json.groups) {
+ assert.ok(g.key && g.label && typeof g.count === 'number');
+ assert.ok(Array.isArray(g.textures) && g.textures.length === g.count);
+ if (g.textures[0]) {
+ const tx = g.textures[0];
+ assert.ok(tx.title && tx.image_url, 'texture row needs title + image_url');
+ assert.ok(/^https?:\/\//i.test(tx.image_url) || tx.image_url.startsWith('/'),
+ 'image_url should be absolute http(s) or root-relative path');
+ }
+ }
+});
+
+t('mock=true generate-colorways with textureSku returns same shape', async () => {
+ // Even with a textureSku passed, mock=true should short-circuit before the
+ // texture fetch — proves the route accepts the new param without exploding.
+ const r = await req('POST', '/api/marketplace/ai/generate-colorways', {
+ patternSlug: TEST_SLUG, textureSku: 'NOSUCHSKU', mock: true,
+ });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.colorways.length, 6);
+});
+
+// ── Stripe / plaid generator (validation + mock) ────────────────────────────
+t('generate-stripe-plaid: empty body → 400 (colors required)', async () => {
+ const r = await req('POST', '/api/marketplace/ai/generate-stripe-plaid', {});
+ assert.strictEqual(r.status, 400);
+ assert.ok(/colors/.test(r.json.error || ''));
+});
+
+t('generate-stripe-plaid: missing plaidTypes → 400', async () => {
+ const r = await req('POST', '/api/marketplace/ai/generate-stripe-plaid', {
+ patternSlug: TEST_SLUG, colors: ['#112233'],
+ });
+ assert.strictEqual(r.status, 400);
+ assert.ok(/plaidType/i.test(r.json.error || ''));
+});
+
+t('generate-stripe-plaid mock=true returns one variant per requested type', async () => {
+ const types = ['gingham', 'tartan', 'buffalo'];
+ const r = await req('POST', '/api/marketplace/ai/generate-stripe-plaid', {
+ patternSlug: TEST_SLUG, colors: ['#1F2E46', '#E8C7A9', '#69785A'],
+ plaidTypes: types, mock: true,
+ });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.status, 'mock');
+ assert.ok(Array.isArray(r.json.variants) && r.json.variants.length === types.length);
+ const returned = r.json.variants.map(v => v.type).sort();
+ assert.deepStrictEqual(returned, [...types].sort());
+});
+
// ── Real Gemini call — opt-in only (costs ~$0.006 + ~30s) ───────────────────
if (RUN_REAL) {
t('REAL Gemini: generates ≥1 ai_generated colorway, writes PNG to uploads/, persists row', async () => {
← 9ea61af redact vendor name on /design/:id pairs-well cards + studio
·
back to Wallco Ai
·
marketplace: tests for /api/marketplace/textures (9/9 pass l d6036b8 →