← 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
A lib/hex-colorname.jsM server.js
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 →