[object Object]

← back to Wallco Ai

render-time color-name consistency on /api/design/:id + 404 guard for removed/dangling ids

0288f887de5da2775b090de859ce5ba6d1879566 · 2026-06-01 17:00:06 -0700 · Steve Abrams

Derive the displayed color word from the design's actual dominant_hex so a name
can never disagree with the color shown (a #FFCFD8 blush design no longer reads
'Midnight Navy'). Vocab gate ensures only real color tokens are rewritten;
motifs are left untouched. Also 404 missing/user_removed snapshot rows instead
of serving a title-less broken PDP (id reassignment leaves dangling ids).

Files touched

Diff

commit 0288f887de5da2775b090de859ce5ba6d1879566
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 17:00:06 2026 -0700

    render-time color-name consistency on /api/design/:id + 404 guard for removed/dangling ids
    
    Derive the displayed color word from the design's actual dominant_hex so a name
    can never disagree with the color shown (a #FFCFD8 blush design no longer reads
    'Midnight Navy'). Vocab gate ensures only real color tokens are rewritten;
    motifs are left untouched. Also 404 missing/user_removed snapshot rows instead
    of serving a title-less broken PDP (id reassignment leaves dangling ids).
---
 lib/hex-colorname.js | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js            |  12 +++-
 2 files changed, 177 insertions(+), 1 deletion(-)

diff --git a/lib/hex-colorname.js b/lib/hex-colorname.js
new file mode 100644
index 0000000..6bff6fe
--- /dev/null
+++ b/lib/hex-colorname.js
@@ -0,0 +1,166 @@
+'use strict';
+// hex-colorname.js — render-time color-name consistency for /api/design/:id.
+//
+// WHY: design titles/categories carry a human color word ("Crimson Studio",
+// "stripe · midnight-navy"). Recolor variants drift: the stored color word can
+// end up describing a hue the design no longer has (Steve, 2026-06-01: a
+// #FFCFD8 blush design displayed "Midnight Navy"). Rather than backfill 35k
+// snapshot rows (whose ids churn across regens), we re-derive the displayed
+// color word from the design's ACTUAL dominant_hex at response time, so the
+// name can never disagree with the color shown.
+//
+// SAFETY: the color VOCAB below is used ONLY to decide "is this token a color
+// word at all" — never to pick the new name. The new name ALWAYS comes from
+// colorwayName(hex). That's the key difference from the earlier broken backfill
+// (which used a vocab to match/derive and silently missed words like "Crimson",
+// fixing nothing). A title token that isn't a recognized color word (a motif
+// like "Capybaras") is left untouched.
+
+// ── hue/lightness model (ported verbatim from scripts/refresh_designs_snapshot.py) ──
+const BANDS = [
+  [15,  { dark: 'Oxblood',      mid: 'Claret',   light: 'Blush' }],
+  [35,  { dark: 'Terracotta',   mid: 'Amber',    light: 'Apricot' }],
+  [55,  { dark: 'Ochre',        mid: 'Honey',    light: 'Chamois' }],
+  [80,  { dark: 'Loden',        mid: 'Olive',    light: 'Chartreuse' }],
+  [155, { dark: 'Bottle Green', mid: 'Sage',     light: 'Celadon' }],
+  [200, { dark: 'Verdigris',    mid: 'Teal',     light: 'Eau de Nil' }],
+  [250, { dark: 'Prussian',     mid: 'Sapphire', light: 'Delft' }],
+  [290, { dark: 'Aubergine',    mid: 'Amethyst', light: 'Wisteria' }],
+  [330, { dark: 'Damson',       mid: 'Plum',     light: 'Mauve' }],
+  [361, { dark: 'Garnet',       mid: 'Rosewood', light: 'Rose' }],
+];
+const NEUTRAL = { vlight: 'Alabaster', light: 'Oyster', mid: 'Greige', dark: 'Pewter', vdark: 'Charcoal' };
+
+function hexToHls(hex) {
+  if (!hex || typeof hex !== 'string' || hex[0] !== '#') return null;
+  let h = hex.slice(1);
+  if (h.length === 3) h = h.split('').map(c => c + c).join('');
+  if (h.length < 6 || /[^0-9a-fA-F]/.test(h.slice(0, 6))) return null;
+  const r = parseInt(h.slice(0, 2), 16) / 255;
+  const g = parseInt(h.slice(2, 4), 16) / 255;
+  const b = parseInt(h.slice(4, 6), 16) / 255;
+  const max = Math.max(r, g, b), min = Math.min(r, g, b);
+  const l = (max + min) / 2;
+  let s = 0, hue = 0;
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+    if (max === r) hue = (g - b) / d + (g < b ? 6 : 0);
+    else if (max === g) hue = (b - r) / d + 2;
+    else hue = (r - g) / d + 4;
+    hue /= 6;
+  }
+  return [hue, l, s]; // (h, l, s) — matches Python colorsys.rgb_to_hls order
+}
+
+function colorwayName(hex) {
+  const hls = hexToHls(hex);
+  if (hls === null) return null;
+  const [h, l, s] = hls;
+  if (s < 0.10) {
+    if (l > 0.85) return NEUTRAL.vlight;
+    if (l > 0.65) return NEUTRAL.light;
+    if (l > 0.42) return NEUTRAL.mid;
+    if (l > 0.22) return NEUTRAL.dark;
+    return NEUTRAL.vdark;
+  }
+  const deg = h * 360;
+  for (const [maxd, sh] of BANDS) {
+    if (deg < maxd) return l > 0.62 ? sh.light : (l < 0.34 ? sh.dark : sh.mid);
+  }
+  return 'Rose';
+}
+
+// band family key for a colorway word → lets us compare a token color and the
+// hex color on one scale. The two red bands straddle the 360/0 hue seam (band
+// 15 Oxblood/Claret/Blush and band 361 Garnet/Rosewood/Rose are perceptually
+// one red family) so band 361 is merged to key 15.
+function bandFamilyOfName(name) {
+  if (!name) return null;
+  if (Object.values(NEUTRAL).includes(name)) return 'neutral';
+  for (const [maxd, sh] of BANDS) {
+    if (Object.values(sh).includes(name)) return maxd === 361 ? 15 : maxd;
+  }
+  return null;
+}
+
+// VOCAB (detection only): every colorway word the model can emit, plus common
+// curated/synonym color words that appear in stored titles/slugs. Maps each to
+// a canonical colorwayName word so we can find its band family. NOT used to
+// pick the new name.
+const WORD_TO_NAME = {
+  // band words
+  oxblood: 'Oxblood', claret: 'Claret', blush: 'Blush', terracotta: 'Terracotta',
+  amber: 'Amber', apricot: 'Apricot', ochre: 'Ochre', honey: 'Honey', chamois: 'Chamois',
+  loden: 'Loden', olive: 'Olive', chartreuse: 'Chartreuse', sage: 'Sage', celadon: 'Celadon',
+  verdigris: 'Verdigris', teal: 'Teal', prussian: 'Prussian', sapphire: 'Sapphire', delft: 'Delft',
+  aubergine: 'Aubergine', amethyst: 'Amethyst', wisteria: 'Wisteria', damson: 'Damson',
+  plum: 'Plum', mauve: 'Mauve', garnet: 'Garnet', rosewood: 'Rosewood', rose: 'Rose',
+  alabaster: 'Alabaster', oyster: 'Oyster', greige: 'Greige', pewter: 'Pewter', charcoal: 'Charcoal',
+  // synonyms found in stored names
+  crimson: 'Oxblood', scarlet: 'Oxblood', ruby: 'Garnet', bordeaux: 'Oxblood', burgundy: 'Oxblood',
+  saffron: 'Ochre', mustard: 'Ochre', gold: 'Ochre', moss: 'Sage', forest: 'Bottle Green',
+  emerald: 'Bottle Green', navy: 'Prussian', indigo: 'Prussian', cobalt: 'Sapphire', azure: 'Sapphire',
+  midnight: 'Prussian', violet: 'Amethyst', lavender: 'Wisteria', lilac: 'Wisteria', pink: 'Rose',
+  ivory: 'Alabaster', cream: 'Alabaster', greens: 'Sage', slate: 'Pewter', graphite: 'Charcoal',
+};
+const COLOR_WORDS = new Set(Object.keys(WORD_TO_NAME));
+
+const SUFFIXES = 'Studio|Atelier|Folio|Salon|Reverie|House|Manor|Origin|Drift|Daydream|Halcyon|Vespers|Idyll|Repose|Slumber|Haze|Lullaby';
+// "<Color> <Suffix> No.<id>" — leading color slot (1–2 words)
+const TITLE_LEAD = new RegExp('^([A-Za-z][A-Za-z ]{0,18}?)\\s+(' + SUFFIXES + ')\\s+No\\.(\\d+)\\s*$', 'i');
+// "... in <Color>" — trailing color slot
+const TITLE_IN = /^(.*\bin\s+)([A-Za-z][A-Za-z' -]{0,18}?)\s*$/i;
+
+// Replace a title's color word with the hex-derived name. Only fires when the
+// existing slot is a recognized color word (vocab gate). Returns the new title
+// or the original unchanged.
+function fixTitle(title, hex) {
+  const want = colorwayName(hex);
+  if (!want || !title) return title;
+  let m = title.match(TITLE_LEAD);
+  if (m) {
+    const old = m[1].trim();
+    if (!COLOR_WORDS.has(old.toLowerCase())) return title;   // not a color → leave motif alone
+    if (old.toLowerCase() === want.toLowerCase()) return title;
+    return `${want} ${m[2]} No.${m[3]}`;
+  }
+  m = title.match(TITLE_IN);
+  if (m) {
+    const old = m[2].trim();
+    if (!COLOR_WORDS.has(old.toLowerCase())) return title;
+    if (old.toLowerCase() === want.toLowerCase()) return title;
+    return `${m[1]}${want}`;
+  }
+  return title;
+}
+
+// Replace the category subtitle's trailing color token ("style · color-slug")
+// — but only when the token's color word lands in a DIFFERENT band family than
+// the hex (so curated, color-faithful slugs like a real navy "midnight-navy"
+// on a navy hex are preserved; a navy slug on a pink hex is corrected).
+function fixCategory(category, hex) {
+  if (!category || category.indexOf(' · ') === -1) return category;
+  const i = category.lastIndexOf(' · ');
+  const prefix = category.slice(0, i);
+  const tok = category.slice(i + 3);
+  const words = tok.toLowerCase().split('-').filter(Boolean);
+  const colorWord = words.find(w => COLOR_WORDS.has(w));
+  if (!colorWord) return category;                            // no color in slug → preserve
+  const want = colorwayName(hex);
+  if (!want) return category;
+  const tokFam = bandFamilyOfName(WORD_TO_NAME[colorWord]);
+  const hexFam = bandFamilyOfName(want);
+  if (tokFam === null || hexFam === null || tokFam === hexFam) return category; // agrees → keep
+  return `${prefix} · ${want.toLowerCase().replace(/\s+/g, '-')}`;
+}
+
+// Mutate a merged design object in place so title/category match dominant_hex.
+function applyHexConsistentNaming(d) {
+  if (!d || !d.dominant_hex) return d;
+  if (typeof d.title === 'string')    d.title = fixTitle(d.title, d.dominant_hex);
+  if (typeof d.category === 'string') d.category = fixCategory(d.category, d.dominant_hex);
+  return d;
+}
+
+module.exports = { colorwayName, fixTitle, fixCategory, applyHexConsistentNaming };
diff --git a/server.js b/server.js
index ebad3d5..e4e3a79 100644
--- a/server.js
+++ b/server.js
@@ -397,6 +397,7 @@ loadDesigns();
 // REFACTOR-1d: parseDesignId / parseIdLike available via lib/parse.js for new code.
 const { maybe304 } = require('./lib/http');
 const { parseDesignId, parseIdLike } = require('./lib/parse');
+const { applyHexConsistentNaming } = require('./lib/hex-colorname');
 const { geminiCall, extractText: geminiText, extractImageData: geminiImage, extractJson: geminiJson } = require('./lib/gemini');
 
 // ── Levenshtein distance (early-exit at cap). Used for did-you-mean suggestions.
@@ -476,14 +477,23 @@ app.get('/api/design/:id', (req, res) => {
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
   const snap = DESIGNS.find(d => d.id === id);
+  // Dangling-id guard: catalog regens reassign ids, so a once-valid id can point
+  // at a removed row (or be absent from DESIGNS entirely). title/category/hex
+  // come ONLY from the snapshot row — without one the PG-only response is a
+  // title-less, color-less blob that renders a BROKEN pdp. Treat missing /
+  // user_removed snapshot rows as not-found instead of serving the broken shell.
+  if (!snap || snap.user_removed) return res.status(404).json({ error: 'not found' });
   let pg = null;
   try {
     const raw = psqlQuery("SELECT row_to_json(t) FROM (SELECT palette, width_in, height_in, prompt, notes FROM spoon_all_designs WHERE id=" + id + ") t;");
     if (raw) pg = JSON.parse(raw);
   } catch (e) { /* PG hiccup — fall through with snapshot data only */ }
-  if (!snap && !pg) return res.status(404).json({ error: 'not found' });
   const merged = Object.assign({}, snap || {}, pg || {});
   delete merged.local_path;  // never leak Mac2 paths via public API
+  // Re-derive the displayed color word from the design's actual dominant_hex so
+  // the name can never disagree with the color shown (a #FFCFD8 blush design
+  // must not read "Midnight Navy"). See lib/hex-colorname.js. (Steve 2026-06-01)
+  applyHexConsistentNaming(merged);
   res.json({ ok: true, design: merged });
 });
 

← f9f8f89 Replicate fallback: sanctioned feather seam-heal + fix post-  ·  back to Wallco Ai  ·  add theme smoke-test selftest (meta-test) — proves the harne 6516c8d →