← 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
M server.jsM src/color-isolate.js
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, '"')}"
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 →