← back to Wallco Ai
lib/hex-colorname.js
167 lines
'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 };