[object Object]

← 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

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,'&quot;')}" data-category="${g.key}"
+                 style="background-image:url('${t.image_url}')"
+                 title="${(t.full_title || t.title || '').replace(/"/g,'&quot;')}">
+              <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 →