[object Object]

← back to Wallco Ai

feat(design page): replace generic 14-hue tone wheel with 19 fashion-house palettes

3732ed9e398d78215fbd710208496e93c1b20e27 · 2026-05-25 00:29:59 -0700 · Steve Abrams

Pre-refactor the recolor strip on /design/:id was 14 generic Tailwind-y
hue buttons (Red/Orange/Yellow/Lime/Green/Teal/Cyan/Blue/Indigo/Violet/
Magenta/Pink/Neutral/Charcoal). Each fed a vague 'recolor into the X
family' prompt to gemini-2.5-flash-image — palette-disconnected output.

Now: 19 fashion-house buttons (Hermès, Bottega Veneta, Prada, Saint
Laurent, Dior, Chanel, Gucci, Celine, Loewe, Valentino, Toteme, Khaite,
Balenciaga, Burberry, Miu Miu, Jacquemus, Skims, Gymshark, Phoebe Philo).
Each carries the brand's actual product-palette hex stack + signature,
rendered as a 4-color swatch stripe ABOVE the brand name with the button
bg colored in the brand accent (auto-contrast text).

server.js — RECOLOR_BRANDS map built from scripts/fashion_palettes.js
(dw-fashion-templates skill canonical list). /api/design/:id/recolor
accepts { brand } OR legacy { hue }; brand mode builds a STRICT 'use
ONLY these hexes' prompt with the signature.

Tested — 200 + 322KB on /design/41509, 19 data-brand buttons rendered,
0 legacy data-hue buttons, endpoint validates good vs bad brand keys.

Files touched

Diff

commit 3732ed9e398d78215fbd710208496e93c1b20e27
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 25 00:29:59 2026 -0700

    feat(design page): replace generic 14-hue tone wheel with 19 fashion-house palettes
    
    Pre-refactor the recolor strip on /design/:id was 14 generic Tailwind-y
    hue buttons (Red/Orange/Yellow/Lime/Green/Teal/Cyan/Blue/Indigo/Violet/
    Magenta/Pink/Neutral/Charcoal). Each fed a vague 'recolor into the X
    family' prompt to gemini-2.5-flash-image — palette-disconnected output.
    
    Now: 19 fashion-house buttons (Hermès, Bottega Veneta, Prada, Saint
    Laurent, Dior, Chanel, Gucci, Celine, Loewe, Valentino, Toteme, Khaite,
    Balenciaga, Burberry, Miu Miu, Jacquemus, Skims, Gymshark, Phoebe Philo).
    Each carries the brand's actual product-palette hex stack + signature,
    rendered as a 4-color swatch stripe ABOVE the brand name with the button
    bg colored in the brand accent (auto-contrast text).
    
    server.js — RECOLOR_BRANDS map built from scripts/fashion_palettes.js
    (dw-fashion-templates skill canonical list). /api/design/:id/recolor
    accepts { brand } OR legacy { hue }; brand mode builds a STRICT 'use
    ONLY these hexes' prompt with the signature.
    
    Tested — 200 + 322KB on /design/41509, 19 data-brand buttons rendered,
    0 legacy data-hue buttons, endpoint validates good vs bad brand keys.
---
 server.js | 249 ++++++++++++++++++++++++++++++++++++++++++++++++++------------
 1 file changed, 202 insertions(+), 47 deletions(-)

diff --git a/server.js b/server.js
index 6071524..eb068ed 100644
--- a/server.js
+++ b/server.js
@@ -6786,6 +6786,23 @@ app.post('/api/design/:id/comment', express.json({ limit: '8kb' }), (req, res) =
   }
 });
 
+// GET /api/design/:id/latest-child — polling endpoint for the /design/:id
+// admin UI to detect when a spawned regen/clean/recreate child has landed.
+// Returns { child_id } or { child_id: null }. Used by Regenerate-with-comment
+// to auto-redirect from parent → newest child when the async generation finishes.
+app.get('/api/design/:id/latest-child', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    const raw = psqlQuery(`SELECT id FROM spoon_all_designs
+      WHERE parent_design_id=${id} AND user_removed IS NOT TRUE
+      ORDER BY id DESC LIMIT 1;`);
+    const childId = parseInt(raw, 10);
+    res.json({ child_id: Number.isFinite(childId) && childId > 0 ? childId : null });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 // POST /api/design/:id/regenerate-with-comment — take parent design's prompt,
 // append admin comment as additional guidance, spawn a new generation. The
 // new design gets parent_design_id=<this id> so we can trace the lineage.
@@ -9855,8 +9872,8 @@ ${htmlHeader('/designs')}
         </script>
 
         ${_isAdmin ? `
-          <details class="admin-pane" open>
-            <summary>✎ Rate & Admin Review <span class="pane-hint">rate · comment · regen · edges scan · recreate · flag</span></summary>
+          <details class="admin-pane">
+            <summary>✎ Rate & Admin Review <span class="pane-hint">rate · comment · regen · edges scan · recreate · flag · 🧼 clean</span></summary>
             <div class="pane-body">
         <!-- ── USER RATING PANEL ─────────────────────────────────────────
              Feeds spoon_all_designs.user_color_good / style_good / scale_good /
@@ -9880,8 +9897,46 @@ ${htmlHeader('/designs')}
               <button id="rating-crop-fix" type="button"
                 style="background:#0a7c59;border:1px solid #0a7c59;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
                 title="Crop & Fix: drag a rectangle over the ghost-layer / overlap area; Gemini regenerates JUST that region in flat clean silhouette, preserving everything else">Crop &amp; Fix</button>
+              <button id="rating-clean-it" type="button"
+                style="background:#1a4ea3;border:1px solid #1a4ea3;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
+                title="Clean it (safe-fix): center-crop + opacity-snap to 4 solid colors. Pure PIL, no AI, preserves composition exactly. Lands new child in &lt;1s and redirects.">🧼 Clean it</button>
             </div>
           </div>
+          <script>
+          // 🧼 Clean it — calls /api/design/:id/fix (canonical safe-fix path).
+          // Pure PIL opacity-snap. New design lands instantly; redirect to it.
+          (function(){
+            var btn = document.getElementById('rating-clean-it');
+            if (!btn) return;
+            btn.addEventListener('click', function(){
+              if (!confirm('Clean design #${design.id}?\\n\\nThis applies center-crop + opacity-snap to 4 solid colors. The original stays as parent; a new child design is created.\\n\\nFast (<1s), no AI, preserves composition.')) return;
+              btn.disabled = true;
+              var orig = btn.textContent;
+              btn.textContent = '🧼 Cleaning…';
+              fetch('/api/design/${design.id}/fix', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ k: 4, crop: 1 })
+              })
+                .then(function(r){ return r.json(); })
+                .then(function(j){
+                  if (j.ok && j.new_id) {
+                    btn.textContent = '✓ #' + j.new_id + ' — redirecting';
+                    setTimeout(function(){ location.href = '/design/' + j.new_id; }, 400);
+                  } else {
+                    btn.textContent = orig;
+                    btn.disabled = false;
+                    alert('Clean failed: ' + (j.error || 'unknown'));
+                  }
+                })
+                .catch(function(e){
+                  btn.textContent = orig;
+                  btn.disabled = false;
+                  alert('Network error: ' + e.message);
+                });
+            });
+          })();
+          </script>
 
           <!-- Crop & Fix modal (admin) — drag-to-select rectangle over the
                source image, submit → Gemini image-edit regenerates the cropped
@@ -9959,15 +10014,44 @@ ${htmlHeader('/designs')}
             document.getElementById('ar-regen').addEventListener('click', function(){
               var c = (commentEl.value || '').trim();
               if (!c) { setStatus('Add a comment first — what should be different?', true); return; }
-              setStatus('Spawning regen — new design lands in ~30-90s. Refresh /designs after.', false);
+              var btn = document.getElementById('ar-regen');
+              btn.disabled = true;
+              var origLabel = btn.textContent;
+              btn.textContent = '⏳ Spawning regen…';
+              setStatus('Regen spawned — polling for new child every 4s (lands in 30-90s)…', false);
               fetch('/api/design/' + did + '/regenerate-with-comment', {
                 method: 'POST',
                 headers: { 'Content-Type': 'application/json' },
                 body: JSON.stringify({ comment: c })
               }).then(function(r){ return r.json(); }).then(function(j){
-                if (j.ok) { setStatus('OK — regen spawned. New design in ~60s.', false); }
-                else { setStatus('Error: ' + (j.error || 'unknown'), true); }
-              }).catch(function(e){ setStatus('Network error: ' + e.message, true); });
+                if (!j.ok) {
+                  setStatus('Error: ' + (j.error || 'unknown'), true);
+                  btn.disabled = false; btn.textContent = origLabel;
+                  return;
+                }
+                // Poll for the new child design (parent_design_id = did). Auto-redirect when it lands.
+                var ticks = 0;
+                var poll = setInterval(function(){
+                  ticks++;
+                  fetch('/api/design/' + did + '/latest-child').then(function(r){ return r.json(); }).then(function(p){
+                    if (p && p.child_id) {
+                      clearInterval(poll);
+                      btn.textContent = '✓ #' + p.child_id + ' — redirecting';
+                      setStatus('Child #' + p.child_id + ' landed — redirecting…', false);
+                      setTimeout(function(){ location.href = '/design/' + p.child_id; }, 500);
+                    } else if (ticks >= 45) {  // 45 * 4s = 180s timeout
+                      clearInterval(poll);
+                      setStatus('Timed out waiting for child (180s). Check /designs manually.', true);
+                      btn.disabled = false; btn.textContent = origLabel;
+                    } else {
+                      setStatus('Spawning regen — ' + (ticks * 4) + 's elapsed, polling…', false);
+                    }
+                  }).catch(function(){ /* transient — keep polling */ });
+                }, 4000);
+              }).catch(function(e){
+                setStatus('Network error: ' + e.message, true);
+                btn.disabled = false; btn.textContent = origLabel;
+              });
             });
             document.getElementById('ar-save').addEventListener('click', function(){
               var c = (commentEl.value || '').trim();
@@ -10706,24 +10790,45 @@ ${htmlHeader('/designs')}
                2026-05-13 ask: "Add buttons to admin to create in color
                wheel color. for tone on tone items." -->
           <div class="recolor-wheel" style="margin-top:22px;padding-top:18px;border-top:1px solid var(--line)">
-            <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:block;margin-bottom:8px">Admin · tone-on-tone color wheel</label>
-            <p style="font:11px var(--sans);color:var(--ink-faint);margin:0 0 10px">Click a hue to generate a tone-on-tone variant. Each takes ~5-15s &amp; costs ~$0.001 (gemini-image-edit).</p>
+            <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:block;margin-bottom:8px">Admin · tone-on-tone · fashion-house palettes</label>
+            <p style="font:11px var(--sans);color:var(--ink-faint);margin:0 0 10px">Click a brand to recolor this design in that house's actual product palette (sourced from the dw-fashion-templates skill). ~5-15s · ~$0.001 each (gemini-image-edit). Hover a swatch to see the hex stack.</p>
             <div id="recolor-buttons" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">
-              <button data-hue="red"      style="padding:6px 14px;background:#dc2626;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Red</button>
-              <button data-hue="orange"   style="padding:6px 14px;background:#ea580c;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Orange</button>
-              <button data-hue="yellow"   style="padding:6px 14px;background:#ca8a04;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Yellow</button>
-              <button data-hue="lime"     style="padding:6px 14px;background:#65a30d;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Lime</button>
-              <button data-hue="green"    style="padding:6px 14px;background:#16a34a;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Green</button>
-              <button data-hue="teal"     style="padding:6px 14px;background:#0d9488;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Teal</button>
-              <button data-hue="cyan"     style="padding:6px 14px;background:#0891b2;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Cyan</button>
-              <button data-hue="blue"     style="padding:6px 14px;background:#2563eb;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Blue</button>
-              <button data-hue="indigo"   style="padding:6px 14px;background:#4338ca;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Indigo</button>
-              <button data-hue="violet"   style="padding:6px 14px;background:#7c3aed;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Violet</button>
-              <button data-hue="magenta"  style="padding:6px 14px;background:#c026d3;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Magenta</button>
-              <button data-hue="pink"     style="padding:6px 14px;background:#db2777;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Pink</button>
-              <button data-hue="neutral"  style="padding:6px 14px;background:#a16207;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Neutral</button>
-              <button data-hue="charcoal" style="padding:6px 14px;background:#262626;color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em">Charcoal</button>
-              <button id="recolor-all" style="padding:6px 14px;background:linear-gradient(90deg,#dc2626,#ea580c,#ca8a04,#16a34a,#0891b2,#2563eb,#7c3aed,#db2777);color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em;flex-basis:100%;margin-top:4px">✦ Spin all 14 hues (parallel)</button>
+${(() => {
+  // Inline the fashion brands here so the buttons get the brand's actual
+  // accent color as their background and the signature as the title tooltip.
+  // Sourced from scripts/fashion_palettes.js (FASHION_PALETTES).
+  const brands = (() => {
+    try { return require('./scripts/fashion_palettes.js').FASHION_PALETTES; }
+    catch { return []; }
+  })();
+  // Pick best accent: first non-near-black, non-near-white hex; fallback first.
+  const pickAccent = (hex) => {
+    const isExtreme = (h) => {
+      const r = parseInt(h.slice(1,3),16), g = parseInt(h.slice(3,5),16), b = parseInt(h.slice(5,7),16);
+      const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
+      return lum < 0.08 || lum > 0.94;
+    };
+    return hex.find(h => !isExtreme(h)) || hex[0];
+  };
+  const textOn = (bg) => {
+    const r = parseInt(bg.slice(1,3),16), g = parseInt(bg.slice(3,5),16), b = parseInt(bg.slice(5,7),16);
+    const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
+    return lum > 0.6 ? '#1a1410' : '#ffffff';
+  };
+  return brands.map(p => {
+    const key = p.brand.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+    const bg = pickAccent(p.hex);
+    const fg = textOn(bg);
+    const title = `${p.brand} — ${p.hex.join(' · ')} — ${p.signature}`.replace(/"/g, '&quot;');
+    // Show a 4-color mini-stripe ABOVE the label so the actual palette is visible.
+    const stripe = p.hex.slice(0, 4).map(h => `<span style="display:inline-block;height:6px;width:14px;background:${h};border:1px solid rgba(0,0,0,.12)"></span>`).join('');
+    return `              <button data-brand="${key}" title="${title}" style="display:inline-flex;flex-direction:column;align-items:stretch;gap:3px;padding:5px 10px;background:${bg};color:${fg};border:1px solid rgba(0,0,0,.15);border-radius:6px;cursor:pointer;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.04em;min-width:96px">
+                <span style="display:flex;gap:1px;justify-content:center">${stripe}</span>
+                <span>${p.brand}</span>
+              </button>`;
+  }).join('\n');
+})()}
+              <button id="recolor-all" style="padding:6px 14px;background:linear-gradient(90deg,#FF4F00,#107A3B,#FF0000,#A98B5B,#006837,#D70000,#1B5E20,#F4D24F);color:#fff;border:0;border-radius:6px;cursor:pointer;font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.04em;flex-basis:100%;margin-top:4px">✦ Spin all houses (parallel)</button>
             </div>
             <div id="recolor-status" style="font:12px var(--sans);color:var(--ink-soft,#555);min-height:18px"></div>
             <div id="recolor-results" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:10px"></div>
@@ -10732,39 +10837,48 @@ ${htmlHeader('/designs')}
           (function(){
             const stat = document.getElementById('recolor-status');
             const results = document.getElementById('recolor-results');
-            const fireOne = async (hue, btn) => {
+            // Unified handler: reads either data-brand (new, fashion-house mode)
+            // or data-hue (legacy generic-color mode) off the button.
+            const fireOne = async (label, body, btn) => {
               if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }
-              const startTxt = '→ ' + hue + '…';
+              const startTxt = '→ ' + label + '…';
               stat.textContent = (stat.textContent ? stat.textContent + '  ' : '') + startTxt;
               try {
                 const r = await fetch('/api/design/${design.id}/recolor', {
                   method: 'POST', headers: { 'Content-Type': 'application/json' },
-                  body: JSON.stringify({ hue }),
+                  body: JSON.stringify(body),
                 });
                 const j = await r.json();
                 if (!j.ok) throw new Error(j.error || 'failed');
                 const card = document.createElement('div');
                 card.innerHTML = '<a href="/design/' + j.new_id + '" target="_blank" style="display:block;text-decoration:none;color:inherit">'
                   + '<img src="' + j.new_image_url + '" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;border:1px solid var(--line)">'
-                  + '<div style="font:10px var(--sans);text-align:center;margin-top:4px;color:var(--ink-soft,#555)">' + hue + ' · #' + j.new_id + '</div>'
+                  + '<div style="font:10px var(--sans);text-align:center;margin-top:4px;color:var(--ink-soft,#555)">' + label + ' · #' + j.new_id + '</div>'
                   + '</a>';
                 results.appendChild(card);
-                stat.textContent = stat.textContent.replace(startTxt, '✓ ' + hue);
+                stat.textContent = stat.textContent.replace(startTxt, '✓ ' + label);
               } catch (e) {
-                stat.textContent = stat.textContent.replace(startTxt, '✗ ' + hue + ':' + e.message);
+                stat.textContent = stat.textContent.replace(startTxt, '✗ ' + label + ':' + e.message);
               } finally {
                 if (btn) { btn.disabled = false; btn.style.opacity = '1'; }
               }
             };
-            document.querySelectorAll('#recolor-buttons button[data-hue]').forEach(b => {
-              b.addEventListener('click', () => fireOne(b.dataset.hue, b));
+            document.querySelectorAll('#recolor-buttons button[data-brand], #recolor-buttons button[data-hue]').forEach(b => {
+              b.addEventListener('click', () => {
+                if (b.dataset.brand) fireOne(b.dataset.brand, { brand: b.dataset.brand }, b);
+                else                 fireOne(b.dataset.hue,   { hue:   b.dataset.hue   }, b);
+              });
             });
             const allBtn = document.getElementById('recolor-all');
             if (allBtn) allBtn.addEventListener('click', async () => {
               allBtn.disabled = true; allBtn.style.opacity = '0.5';
-              const hues = Array.from(document.querySelectorAll('#recolor-buttons button[data-hue]')).map(b => b.dataset.hue);
-              stat.textContent = 'firing ' + hues.length + ' in parallel…';
-              await Promise.all(hues.map(h => fireOne(h, null)));
+              const targets = Array.from(document.querySelectorAll('#recolor-buttons button[data-brand], #recolor-buttons button[data-hue]'));
+              stat.textContent = 'firing ' + targets.length + ' in parallel…';
+              await Promise.all(targets.map(b => {
+                const body = b.dataset.brand ? { brand: b.dataset.brand } : { hue: b.dataset.hue };
+                const label = b.dataset.brand || b.dataset.hue;
+                return fireOne(label, body, null);
+              }));
               allBtn.disabled = false; allBtn.style.opacity = '1';
             });
           })();
@@ -18267,6 +18381,28 @@ app.post('/api/design/:id/furnish', __furnishLimiter, async (req, res) => {
 //
 // Steve's 2026-05-13 ask: "Add buttons to admin to create in color wheel
 // color. for tone on tone items." This endpoint backs those buttons.
+//
+// 2026-05-25: Fashion-brand mode added. Buttons on /design/:id now send
+// { brand: 'hermes' | 'gucci' | ... } and the prompt is built from the
+// brand's actual hex palette + signature (sourced from
+// scripts/fashion_palettes.js → dw-fashion-templates skill). The original
+// { hue: ... } mode is preserved for backward compatibility.
+const RECOLOR_BRANDS = (() => {
+  let entries = [];
+  try {
+    const { FASHION_PALETTES } = require('./scripts/fashion_palettes.js');
+    entries = FASHION_PALETTES;
+  } catch (e) {
+    console.warn('[recolor-brands] fashion_palettes.js load failed:', e.message);
+  }
+  const out = {};
+  for (const p of entries) {
+    const key = p.brand.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+    out[key] = { label: p.brand, hex: p.hex.slice(), signature: p.signature };
+  }
+  return out;
+})();
+
 const RECOLOR_HUES = {
   red:     { label: 'Red',      tail: 'crimson red and burgundy ground; deeper near-black-red motif; rich saturation' },
   orange:  { label: 'Orange',   tail: 'warm terracotta-orange ground; deeper burnt-orange motif; earthy saturation' },
@@ -18289,9 +18425,19 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
   try {
     const id = parseInt(req.params.id, 10);
     if (!Number.isFinite(id) || id < 1) return res.status(400).json({ ok: false, error: 'bad id' });
-    const hueKey = String((req.body && req.body.hue) || '').toLowerCase();
-    const hue = RECOLOR_HUES[hueKey];
-    if (!hue) return res.status(400).json({ ok: false, error: 'bad hue', valid: Object.keys(RECOLOR_HUES) });
+    const brandKey = String((req.body && req.body.brand) || '').toLowerCase();
+    const hueKey   = String((req.body && req.body.hue)   || '').toLowerCase();
+    const brand = brandKey ? RECOLOR_BRANDS[brandKey] : null;
+    const hue   = hueKey   ? RECOLOR_HUES[hueKey]     : null;
+    if (!brand && !hue) {
+      return res.status(400).json({
+        ok: false, error: 'bad brand and bad hue',
+        valid_brands: Object.keys(RECOLOR_BRANDS),
+        valid_hues:   Object.keys(RECOLOR_HUES),
+      });
+    }
+    const targetKey   = brandKey || hueKey;
+    const targetLabel = brand ? brand.label : hue.label;
 
     // Load source image bytes from local_path or by reading the file behind image_url
     const d = DESIGNS.find(x => x.id === id);
@@ -18301,11 +18447,19 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
     if (!srcPath || !fs.existsSync(srcPath)) return res.status(404).json({ ok: false, error: 'source file missing on disk', srcPath });
     const srcB64 = fs.readFileSync(srcPath).toString('base64');
 
-    const prompt = `Recolor everything into the ${hue.label.toUpperCase()} family ONLY. ${hue.tail}. `
-      + 'Tone-on-tone: strictly one hue family throughout — no contrasting hues anywhere. '
-      + 'Preserve the motif, composition, and pattern repeat exactly. '
-      + 'Seamless tile, no edge artifacts, no signature, no watermark, no text. '
-      + 'High detail, archival quality, fabric-pattern aesthetic.';
+    const prompt = brand
+      ? `Recolor everything into the ${brand.label.toUpperCase()} fashion-house palette ONLY: `
+        + `${brand.hex.join(', ')} (${brand.signature}). `
+        + `Use ONLY these exact hex colors — no other hues anywhere. Distribute them as a tone-on-tone story, `
+        + `with the darkest hex as the deepest motif silhouette, the lightest hex as the ground, and the mid-tones for fill. `
+        + `Preserve the motif, composition, and pattern repeat exactly. `
+        + `Seamless tile, no edge artifacts, no signature, no watermark, no text. `
+        + `High detail, archival quality, fabric-pattern aesthetic — as if printed in ${brand.label}'s own atelier.`
+      : `Recolor everything into the ${hue.label.toUpperCase()} family ONLY. ${hue.tail}. `
+        + 'Tone-on-tone: strictly one hue family throughout — no contrasting hues anywhere. '
+        + 'Preserve the motif, composition, and pattern repeat exactly. '
+        + 'Seamless tile, no edge artifacts, no signature, no watermark, no text. '
+        + 'High detail, archival quality, fabric-pattern aesthetic.';
 
     const t0 = Date.now();
     let gj;
@@ -18314,7 +18468,7 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
         model: 'gemini-2.5-flash-image',
         parts: [{ inline_data: { mime_type: 'image/png', data: srcB64 } }, { text: prompt }],
         generationConfig: { responseModalities: ['IMAGE'] },
-        note: 'recolor-' + hueKey + '-from-' + id,
+        note: 'recolor-' + targetKey + '-from-' + id,
       });
     } catch (e) {
       if (e.code === 'NO_KEY') return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
@@ -18326,15 +18480,16 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
     // Save PNG
     const crypto2 = require('crypto');
     const seed = crypto2.randomInt(1, 2 ** 31 - 1);
-    const filename = `recolor_${Date.now()}_p${id}_${hueKey}_${seed}.png`;
+    const filename = `recolor_${Date.now()}_p${id}_${targetKey}_${seed}.png`;
     const outPath = path.join(IMG_DIR, filename);
     fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
     const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
 
     // Insert into PG
     const parentTitle = (d.title || ('Design ' + id)).replace(/\s+No\.\d+$/, '');
-    const newTitle = `${parentTitle} (${hue.label} tonal)`;
-    const baseTags = ['recolor', 'tone-on-tone', hueKey];
+    const newTitle = `${parentTitle} (${targetLabel} tonal)`;
+    const baseTags = ['recolor', 'tone-on-tone', targetKey];
+    if (brand) baseTags.push('brand-' + targetKey);
     if (Array.isArray(d.motifs)) baseTags.push(...d.motifs.slice(0, 3));
     const tagSql = 'ARRAY[' + baseTags.map(t => "'" + String(t).replace(/'/g, "''") + "'").join(',') + ']::text[]';
     const motifsSql = Array.isArray(d.motifs) && d.motifs.length
@@ -18349,7 +18504,7 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
         '${outPath.replace(/'/g, "''")}', '/designs/img/${filename}',
         ${"'" + (d.category || 'recolor') + "'"},
         ${motifsSql}, ${tagSql}, TRUE,
-        '${("Tone-on-tone " + hue.label + " recolor of design " + id).replace(/'/g, "''")}')
+        '${("Tone-on-tone " + targetLabel + " recolor of design " + id).replace(/'/g, "''")}')
       RETURNING id;`;
     const newIdStr = psqlQuery(insertSql);
     const newId = parseInt(newIdStr, 10);

← 85c30fb feat(design page): 2-col admin toolbox + edges-agent button  ·  back to Wallco Ai  ·  fix(settlement): strip negative-prompt suffix before substri 117c9e5 →