← 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 };