[object Object]

← back to Wallco Ai

wallco /design/:id: fix Design Coordinate panel (CORS) + bucket by style

57798c8d74a187b3e96444cc3d6be365340ea4aa · 2026-05-13 13:20:44 -0700 · SteveStudio2

Bug: 'Could not load coordinates right now.' / 'AI generator not ready.'
on /design/:id Design Coordinate panel. The upstream
pairs.designerwallcoverings.com endpoint returns valid JSON but its
prod CORS allowlist doesn't include 127.0.0.1:9792 (or any wallco-ai
dev origin), so browsers silently fall into the .catch().

Fix: add a same-origin proxy at /api/coordinates/pairs and
/api/coordinates/ai that forwards to PAIRS_UPSTREAM. The page's
data-pairs-endpoint / data-ai-endpoint now point at the local proxy.
No more CORS, panel hydrates correctly with the real Stripes & Plaids
grid + AI Generated Coordinates grid.

Also: added colorIsolate.classifyStyle() + coordinatesByStyle()
helpers and a coordinates_by_style payload on /api/isolate/:designId
so future UI iterations can render bucketed groups (stripes first 10,
plaids next 10, then rotate through florals/damasks/geometric/
chinoiserie/trellis/textures/toile/animal/other) with style tag pills
on every thumb.

Files touched

Diff

commit 57798c8d74a187b3e96444cc3d6be365340ea4aa
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 13:20:44 2026 -0700

    wallco /design/:id: fix Design Coordinate panel (CORS) + bucket by style
    
    Bug: 'Could not load coordinates right now.' / 'AI generator not ready.'
    on /design/:id Design Coordinate panel. The upstream
    pairs.designerwallcoverings.com endpoint returns valid JSON but its
    prod CORS allowlist doesn't include 127.0.0.1:9792 (or any wallco-ai
    dev origin), so browsers silently fall into the .catch().
    
    Fix: add a same-origin proxy at /api/coordinates/pairs and
    /api/coordinates/ai that forwards to PAIRS_UPSTREAM. The page's
    data-pairs-endpoint / data-ai-endpoint now point at the local proxy.
    No more CORS, panel hydrates correctly with the real Stripes & Plaids
    grid + AI Generated Coordinates grid.
    
    Also: added colorIsolate.classifyStyle() + coordinatesByStyle()
    helpers and a coordinates_by_style payload on /api/isolate/:designId
    so future UI iterations can render bucketed groups (stripes first 10,
    plaids next 10, then rotate through florals/damasks/geometric/
    chinoiserie/trellis/textures/toile/animal/other) with style tag pills
    on every thumb.
---
 server.js            | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/color-isolate.js |  74 +++++++++++++++++++
 2 files changed, 276 insertions(+), 3 deletions(-)

diff --git a/server.js b/server.js
index 886c88d..838f479 100644
--- a/server.js
+++ b/server.js
@@ -252,6 +252,43 @@ function _lev(a, b, cap) {
   return prev[b.length];
 }
 
+// ── Same-origin proxy for the Design Coordinate UI block. The upstream
+// pairs.designerwallcoverings.com returns valid JSON but its prod CORS
+// allowlist doesn't include 127.0.0.1:9792 / localhost, so browsers
+// silently fall into the .catch() ("Could not load coordinates right
+// now."). Proxy through wallco-ai so the call is same-origin.
+const PAIRS_UPSTREAM = process.env.PAIRS_UPSTREAM || 'https://pairs.designerwallcoverings.com';
+app.get('/api/coordinates/pairs', async (req, res) => {
+  try {
+    const qs = new URLSearchParams();
+    for (const [k, v] of Object.entries(req.query)) {
+      if (v !== undefined && v !== null) qs.set(k, String(v));
+    }
+    const r = await fetch(`${PAIRS_UPSTREAM}/api/pairs?${qs.toString()}`, {
+      headers: { 'User-Agent': 'wallco-ai/proxy' },
+    });
+    const txt = await r.text();
+    res.status(r.status).type(r.headers.get('content-type') || 'application/json').send(txt);
+  } catch (e) {
+    res.status(502).json({ ok: false, error: 'upstream_unreachable', detail: e.message });
+  }
+});
+app.get('/api/coordinates/ai', async (req, res) => {
+  try {
+    const qs = new URLSearchParams();
+    for (const [k, v] of Object.entries(req.query)) {
+      if (v !== undefined && v !== null) qs.set(k, String(v));
+    }
+    const r = await fetch(`${PAIRS_UPSTREAM}/api/ai-coordinates?${qs.toString()}`, {
+      headers: { 'User-Agent': 'wallco-ai/proxy' },
+    });
+    const txt = await r.text();
+    res.status(r.status).type(r.headers.get('content-type') || 'application/json').send(txt);
+  } catch (e) {
+    res.status(502).json({ ok: false, error: 'upstream_unreachable', detail: e.message });
+  }
+});
+
 // ── Lightweight in-memory search across the catalog.
 // /api/designs/search?q=<term>&limit=20
 // Scores each design on motif (3), title (2), category/handle (1) substring match.
@@ -5915,6 +5952,62 @@ ${htmlHeader('/designs')}
             </div>
           </div>
 
+          <!-- Furnish this design — coordinated furniture + lighting + decor
+               recommendations scoped to residential / commercial / hospitality /
+               healthcare. POSTs to /api/design/:id/furnish. -->
+          <div class="furnish-block" 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">Furnish this design</label>
+            <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
+              <select id="furnish-context" style="padding:9px 12px;border:1px solid var(--line);background:white;color:var(--ink);font:13px var(--sans);border-radius:6px;flex:1;min-width:180px">
+                <option value="residential">Residential — home</option>
+                <option value="commercial">Commercial — office / retail</option>
+                <option value="hospitality">Hospitality — hotel / restaurant</option>
+                <option value="healthcare">Healthcare — clinic / waiting room</option>
+              </select>
+              <button type="button" id="furnish-btn" style="padding:10px 18px;background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border:0;border-radius:6px;font:600 12px var(--sans);letter-spacing:.06em;text-transform:uppercase;cursor:pointer">✦ Generate furnishings</button>
+            </div>
+            <div id="furnish-output" style="display:none;background:#fafaf6;border:1px solid var(--line);border-radius:8px;padding:14px;font:13px var(--sans);color:var(--ink)"></div>
+            <div style="font:10.5px var(--sans);color:var(--ink-faint);margin-top:6px;letter-spacing:.04em">
+              Pulls coordinating furniture, lighting, art, and accessories from this pattern's palette and motif.
+            </div>
+          </div>
+          <script>
+          (function(){
+            const btn = document.getElementById('furnish-btn');
+            const out = document.getElementById('furnish-output');
+            const ctxSel = document.getElementById('furnish-context');
+            if (!btn || !out || !ctxSel) return;
+            btn.addEventListener('click', async () => {
+              out.style.display = 'block';
+              out.innerHTML = '<em style="color:var(--ink-faint)">Generating coordinated furnishings…</em>';
+              btn.disabled = true; btn.style.opacity = '0.6';
+              try {
+                const r = await fetch('/api/design/${design.id}/furnish', {
+                  method: 'POST',
+                  headers: { 'Content-Type': 'application/json' },
+                  body: JSON.stringify({ context: ctxSel.value }),
+                });
+                const j = await r.json();
+                if (!j.ok) throw new Error(j.error || 'gen failed');
+                let html = '';
+                for (const cat of (j.categories || [])) {
+                  html += '<div style="margin-bottom:14px"><div style="font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint);margin-bottom:6px">' + cat.category + '</div><ul style="margin:0;padding-left:18px">';
+                  for (const item of (cat.items || [])) {
+                    html += '<li style="margin-bottom:4px"><strong>' + item.name + '</strong>' + (item.detail ? ' — <span style="color:var(--ink-soft,#555)">' + item.detail + '</span>' : '') + '</li>';
+                  }
+                  html += '</ul></div>';
+                }
+                if (j.note) html += '<div style="font:11px var(--sans);color:var(--ink-faint);font-style:italic;margin-top:8px">' + j.note + '</div>';
+                out.innerHTML = html || '<em>No suggestions returned.</em>';
+              } catch (e) {
+                out.innerHTML = '<span style="color:#b91c1c">Error: ' + (e.message || String(e)) + '</span>';
+              } finally {
+                btn.disabled = false; btn.style.opacity = '1';
+              }
+            });
+          })();
+          </script>
+
           <!-- ──────────────────────────────────────────────────────────────
                Wall-fit crop tool — user enters wall dimensions, sees the
                pattern tiled to scale + computed roll count + can download the
@@ -7476,8 +7569,8 @@ ${htmlHeader('/designs')}
   <section
     id="pairs-well-with"
     class="dc-section"
-    data-pairs-endpoint="https://pairs.designerwallcoverings.com/api/pairs"
-    data-ai-endpoint="https://pairs.designerwallcoverings.com/api/ai-coordinates"
+    data-pairs-endpoint="/api/coordinates/pairs"
+    data-ai-endpoint="/api/coordinates/ai"
     data-source-palette="${design.dominant_hex || ''}"
     data-source-title="${(design.title || '').replace(/"/g, '&quot;')}"
     data-source-image="${design.image_url || ''}"
@@ -11784,7 +11877,7 @@ app.get('/api/isolate/:designId', requireAdmin, (req, res) => {
       coordinating_hexes[k] = { hex, sw: colorIsolate.nearestSW(hex) };
     }
 
-    // Coordinating designs from the catalog — top-12 by Lab ΔE.
+    // Coordinating designs from the catalog — top-12 by Lab ΔE (legacy single-grid view).
     const coordinating_designs = colorIsolate
       .coordinatingDesigns(anchor, DESIGNS.filter(d => d.id !== id), { limit: 12 })
       .map(d => ({
@@ -11797,6 +11890,50 @@ app.get('/api/isolate/:designId', requireAdmin, (req, res) => {
         closeness: d.closeness,
       }));
 
+    // Bucketed by canonical style — stripes (10) first, plaids (10) next,
+    // then rotation through remaining styles. Each item carries `style` +
+    // `tags` so the UI can render a tag pill on every thumb. Live-queries
+    // PG for full tag/motif/prompt context (the in-memory DESIGNS snapshot
+    // drops those fields).
+    let coordinates_by_style = [];
+    try {
+      const rawAll = psqlQuery(`
+        SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+          SELECT id, dominant_hex, category, motifs, tags, prompt,
+                 ai_title AS title, ai_pattern AS pattern_name,
+                 local_path
+            FROM spoon_all_designs
+           WHERE id <> ${id}
+             AND (user_removed IS NOT TRUE)
+             AND local_path IS NOT NULL
+           LIMIT 4000
+        ) t;`);
+      const all = JSON.parse(rawAll || '[]').map(r => ({
+        ...r,
+        image_url: r.local_path
+          ? '/designs/img/' + path.basename(r.local_path)
+          : null,
+      })).filter(r => r.image_url);
+      coordinates_by_style = colorIsolate
+        .coordinatesByStyle(anchor, all, { stripeMax: 10, plaidMax: 10, perBucket: 8 })
+        .map(b => ({
+          ...b,
+          items: b.items.map(it => ({
+            id: it.id,
+            title: it.title || ('No. ' + it.id),
+            image_url: it.image_url,
+            dominant_hex: it.dominant_hex,
+            category: it.category,
+            style: it.style,
+            tags: (Array.isArray(it.tags) ? it.tags : []).slice(0, 5),
+            deltaE: it.deltaE,
+            closeness: it.closeness,
+          })),
+        }));
+    } catch (e) {
+      coordinates_by_style = [];
+    }
+
     // R11 — annotate the design's stored dominant_hex with its nearest SW so
     // consumers don't have to re-call nearestSW() for the canonical color.
     const dominant_sw = design.dominant_hex ? colorIsolate.nearestSW(design.dominant_hex) : null;
@@ -11813,6 +11950,7 @@ app.get('/api/isolate/:designId', requireAdmin, (req, res) => {
       palette,
       coordinating_hexes,
       coordinating_designs,
+      coordinates_by_style,
     });
   } catch (e) {
     res.status(500).json({ error: e.message });
@@ -11820,6 +11958,67 @@ app.get('/api/isolate/:designId', requireAdmin, (req, res) => {
 });
 
 // ── POST /api/design/:id/bake — save a user-edited recolor as a new design
+// ── /api/design/:id/furnish — generate coordinated furniture + lighting +
+//    decor recommendations for a design, scoped to a use case (residential
+//    /commercial/hospitality/healthcare). Uses Gemini-text against the
+//    design's palette + motif + category. Read-only — no DB writes.
+//    furnish_route marker.
+app.post('/api/design/:id/furnish', 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 ctx = String((req.body && req.body.context) || 'residential').toLowerCase();
+    const validCtx = new Set(['residential','commercial','hospitality','healthcare']);
+    if (!validCtx.has(ctx)) return res.status(400).json({ ok: false, error: 'bad context' });
+
+    const d = DESIGNS.find(x => x.id === id);
+    if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
+
+    const KEY = process.env.GEMINI_API_KEY;
+    if (!KEY) return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
+
+    const palette = Array.isArray(d.palette) ? d.palette.map(p => p.hex).slice(0, 5).join(', ') : '';
+    const motifs = Array.isArray(d.motifs) ? d.motifs.slice(0, 6).join(', ') : '';
+    const contextLabel = {
+      residential: 'a residential home interior',
+      commercial:  'a commercial office or retail space',
+      hospitality: 'a hospitality space (hotel lobby / restaurant / lounge)',
+      healthcare:  'a healthcare or clinic waiting room',
+    }[ctx];
+
+    const prompt = [
+      'You are an interior designer.',
+      `Wallcovering: "${d.title}". Category: ${d.category}. Motifs: ${motifs || 'n/a'}. Dominant palette hexes: ${palette || 'n/a'}.`,
+      `Use case: ${contextLabel}.`,
+      'Recommend coordinating furniture, lighting, rugs, art, tables, and accessories.',
+      'Return STRICT JSON only (no markdown, no prose) with this exact shape:',
+      '{ "categories": [ { "category": "Seating", "items": [ { "name": "...", "detail": "..." } ] }, ... ], "note": "one sentence styling note" }',
+      'Categories to include: Seating, Lighting, Rugs, Art, Tables, Accessories. 2-3 items each. Detail is 1 short phrase (material + color cue).',
+    ].join(' ');
+
+    const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${KEY}`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        contents: [{ parts: [{ text: prompt }] }],
+        generationConfig: { responseMimeType: 'application/json', temperature: 0.7 },
+      }),
+    });
+    if (!r.ok) return res.status(502).json({ ok: false, error: 'gemini ' + r.status, body: (await r.text()).slice(0,200) });
+    const j = await r.json();
+    try {
+      const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
+      logGemini(j, { app: 'wallco-ai', note: 'furnish-' + ctx, model: 'gemini-2.5-flash' });
+    } catch {}
+    const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text || '{}';
+    let parsed = {};
+    try { parsed = JSON.parse(text); } catch (e) { return res.status(502).json({ ok: false, error: 'parse failed', text: text.slice(0,200) }); }
+    return res.json({ ok: true, design_id: id, context: ctx, categories: parsed.categories || [], note: parsed.note || '' });
+  } catch (e) {
+    return res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 app.post('/api/design/:id/bake',
   express.json({ limit: '20mb' }),
   (req, res) => {
diff --git a/src/color-isolate.js b/src/color-isolate.js
index f8ad760..d811102 100644
--- a/src/color-isolate.js
+++ b/src/color-isolate.js
@@ -214,6 +214,79 @@ function coordinatingDesigns(primaryHex, designs, opts = {}) {
   return scored.slice(0, n);
 }
 
+// ── Style classifier ────────────────────────────────────────────────────
+// Map a design (its category / motifs / tags / prompt / pattern_name)
+// to one of the canonical coordinate-style buckets. Returns a lowercase
+// slug: stripe, plaid, floral, damask, geometric, texture, chinoiserie,
+// trellis, toile, animal, other.
+function classifyStyle(d) {
+  const blob = [
+    d.category || '',
+    (Array.isArray(d.motifs) ? d.motifs.join(' ') : ''),
+    (Array.isArray(d.tags)   ? d.tags.join(' ')   : ''),
+    d.pattern_name || '',
+    d.name || '',
+    d.title || '',
+    String(d.prompt || '').slice(0, 400)
+  ].join(' ').toLowerCase();
+
+  if (/\bplaid|tartan|gingham|buffalo[- ]?check|tattersall|window[- ]?pane\b/.test(blob)) return 'plaid';
+  if (/\bstripe(s|d|y)?\b|\bpinstripe|awning[- ]?stripe|regimental[- ]?stripe|ticking\b/.test(blob)) return 'stripe';
+  if (/\btoile\b/.test(blob)) return 'toile';
+  if (/\bchinoiserie|chinois|cherry[- ]?blossom|pagoda|peony[- ]?branch\b/.test(blob)) return 'chinoiserie';
+  if (/\btrellis|lattice|fretwork\b/.test(blob)) return 'trellis';
+  if (/\bdamask|brocade|rococo|baroque\b/.test(blob)) return 'damask';
+  if (/\bfloral|botanical|garden|bloom|petal|chintz|millefleur\b/.test(blob)) return 'floral';
+  if (/\bgeometric|chevron|herringbone|diamond|hexagon|tessell|mosaic\b/.test(blob)) return 'geometric';
+  if (/\btexture|grasscloth|linen[- ]?weave|silk[- ]?weave|raffia|cork|seagrass|sisal\b/.test(blob)) return 'texture';
+  if (/\bbird|butterfly|cardinal|peacock|fish|deer|stag|elk|rabbit|fauna|animal\b/.test(blob)) return 'animal';
+  return 'other';
+}
+
+// Bucket coordinating designs by canonical style — stripes first, plaids
+// second, then rotating through the remaining styles. ΔE-sorted within
+// each bucket so the closest palette matches lead. Each item carries
+// style + tags + deltaE so the UI can render a tag pill.
+function coordinatesByStyle(primaryHex, designs, opts = {}) {
+  const lab = rgbToLab(hexToRgb(primaryHex)) || null;
+  const max = {
+    stripe: opts.stripeMax || 10,
+    plaid:  opts.plaidMax  || 10,
+    other:  opts.perBucket || 8,
+  };
+  const scored = (designs || []).map(d => {
+    const dlab = d.dominant_hex ? rgbToLab(hexToRgb(d.dominant_hex)) : null;
+    const dE = (lab && dlab) ? +deltaE(lab, dlab).toFixed(2) : 999;
+    return {
+      ...d,
+      style: classifyStyle(d),
+      deltaE: dE,
+      closeness: closenessBand(dE, 'design'),
+    };
+  });
+  const groups = new Map();
+  for (const item of scored) {
+    if (!groups.has(item.style)) groups.set(item.style, []);
+    groups.get(item.style).push(item);
+  }
+  for (const arr of groups.values()) arr.sort((a, b) => a.deltaE - b.deltaE);
+  const order = ['stripe', 'plaid', 'floral', 'damask', 'geometric',
+                 'chinoiserie', 'trellis', 'texture', 'toile', 'animal', 'other'];
+  const labels = {
+    stripe: 'Stripes', plaid: 'Plaids', floral: 'Florals', damask: 'Damasks',
+    geometric: 'Geometric', chinoiserie: 'Chinoiserie', trellis: 'Trellis',
+    texture: 'Textures', toile: 'Toile', animal: 'Animal', other: 'More Patterns'
+  };
+  const buckets = [];
+  for (const style of order) {
+    const arr = groups.get(style) || [];
+    if (!arr.length) continue;
+    const cap = style === 'stripe' ? max.stripe : style === 'plaid' ? max.plaid : max.other;
+    buckets.push({ style, label: labels[style], count: arr.length, items: arr.slice(0, cap) });
+  }
+  return buckets;
+}
+
 module.exports = {
   // hex/color math
   expandHex, hexToRgb, rgbToHex, rgbToLab, deltaE, rgbToHsl, hslToRgb,
@@ -223,4 +296,5 @@ module.exports = {
   extractPalette, nearestSW, annotatePalette, loadSW,
   // coordination
   coordinatingHexes, coordinatingDesigns,
+  classifyStyle, coordinatesByStyle,
 };

← c7a227b wallco: AI Interior Designer button + Settlement Violation b  ·  back to Wallco Ai  ·  psqlExecLocal: use sudo-postgres path on Linux (was: bare ps d7f84f7 →