← back to Wallco Ai
add Path B surgical color-name backfill (re-derive title color word from dominant_hex; category preserved per /dtd option B)
46878b013d6875f8cf35864cdc9e57dec7ee60fe · 2026-06-01 16:44:31 -0700 · Steve Abrams
Files touched
A scripts/backfill-colorname-drift.py
Diff
commit 46878b013d6875f8cf35864cdc9e57dec7ee60fe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 1 16:44:31 2026 -0700
add Path B surgical color-name backfill (re-derive title color word from dominant_hex; category preserved per /dtd option B)
---
scripts/backfill-colorname-drift.py | 249 ++++++++++++++++++++++++++++++++++++
1 file changed, 249 insertions(+)
diff --git a/scripts/backfill-colorname-drift.py b/scripts/backfill-colorname-drift.py
new file mode 100644
index 0000000..fade141
--- /dev/null
+++ b/scripts/backfill-colorname-drift.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python3
+"""backfill-colorname-drift.py — Path B surgical backfill for the color-name vs
+dominant-hex drift Steve flagged on /design/10171.
+
+Re-derives the TITLE color word from the row's dominant_hex via the SAME
+colorway_name(hex) used by refresh_designs_snapshot.py, and swaps the wrong
+color word in-place while preserving the title shape:
+
+ "<Color> <Suffix> No.<id>" → "<NewColor> <Suffix> No.<id>"
+ "... in <Color>" → "... in <NewColor>"
+
+Category " · <token>" handling: per /dtd verdict (2026-06-01, Claude+Codex 2/0
+for option B), the category color token is rewritten ONLY when its leading
+color word chromatically DISAGREES with the dominant_hex. The curated luxe
+slugs (antique-tan, bordeaux-velvet, midnight-navy …) are color-faithful, so
+this is a no-op on the current live data — preserving the de Gournay naming.
+
+This script operates on a data/designs.json snapshot file (the surface
+server.js reads title/category from for /api/design/:id and /api/designs). It
+is a SURGICAL backfill — NOT a full snapshot rebuild. It does NOT touch
+is_published, user_removed, or any non-drifted row. NO catalog id-space churn.
+
+Usage:
+ # dry-run report (no write):
+ python3 scripts/backfill-colorname-drift.py --in data/designs.json --dry
+
+ # write a corrected snapshot to a new file:
+ python3 scripts/backfill-colorname-drift.py --in IN.json --out OUT.json
+
+The >50% shrink guard does NOT apply (row count is invariant — this only
+rewrites title strings on a subset of rows, never adds/removes rows; the script
+asserts the row count is unchanged).
+"""
+import sys, json, re, colorsys, argparse
+
+# ── colorway logic (verbatim from refresh_designs_snapshot.py @ 2026-06-01) ──
+COLORWAYS = {
+ 'neutral': {'vlight': 'Alabaster', 'light': 'Oyster', 'mid': 'Greige',
+ 'dark': 'Pewter', 'vdark': 'Charcoal'},
+ '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'}),
+ ],
+}
+
+def hex_to_hls(hex_):
+ if not hex_ or not hex_.startswith('#'):
+ return None
+ h = hex_.lstrip('#')
+ if len(h) == 3:
+ h = ''.join(c * 2 for c in h)
+ if len(h) < 6 or any(c not in '0123456789abcdefABCDEF' for c in h[:6]):
+ return None
+ r, g, b = int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255
+ return colorsys.rgb_to_hls(r, g, b)
+
+def colorway_name(hex_):
+ hls = hex_to_hls(hex_)
+ if hls is None:
+ return None
+ h, l, s = hls
+ if s < 0.10:
+ n = COLORWAYS['neutral']
+ if l > 0.85: return n['vlight']
+ if l > 0.65: return n['light']
+ if l > 0.42: return n['mid']
+ if l > 0.22: return n['dark']
+ return n['vdark']
+ deg = h * 360
+ for maxd, shades in COLORWAYS['bands']:
+ if deg < maxd:
+ return shades['light' if l > 0.62 else 'dark' if l < 0.34 else 'mid']
+ return 'Rose'
+
+def zone(hex_):
+ hls = hex_to_hls(hex_)
+ if hls is None:
+ return None
+ h, l, s = hls
+ if s < 0.12: return 'Neutral'
+ d = h * 360
+ if d >= 345 or d < 15: return 'Red'
+ if d < 60: return 'Warm'
+ if d < 170: return 'Green'
+ if d < 260: return 'Blue'
+ if d < 290: return 'Violet'
+ return 'Pink'
+
+# full vocabulary of color words colorway_name can emit (single + multi-word)
+COLOR_VOCAB = set()
+for _maxd, _sh in COLORWAYS['bands']:
+ COLOR_VOCAB.update(_sh.values())
+COLOR_VOCAB.update(COLORWAYS['neutral'].values())
+# longest-first so "Bottle Green"/"Eau de Nil" match before "Green"/"Nil"
+COLOR_VOCAB_RE = '|'.join(re.escape(c) for c in sorted(COLOR_VOCAB, key=len, reverse=True))
+
+SUFFIXES = (r'Studio|Atelier|Folio|Salon|Reverie|House|Manor|Origin|Drift|'
+ r'Daydream|Halcyon|Vespers|Idyll|Repose|Slumber|Haze|Lullaby')
+# "<Color> <Suffix> No.<id>" — color is the LEADING token (may be 2-3 words)
+TITLE_LEAD_RE = re.compile(r'^(' + COLOR_VOCAB_RE + r')\s+(' + SUFFIXES + r')\s+No\.(\d+)\s*$', re.I)
+# "... in <Color>" — color is the TRAILING token
+TITLE_IN_RE = re.compile(r'^(.*\bin\s+)(' + COLOR_VOCAB_RE + r')\s*$', re.I)
+
+# ── Category color-token disagreement test (per /dtd verdict, option B) ──────
+# To avoid zone()-boundary artifacts that flagged color-FAITHFUL curated slugs
+# (greenhouse-glass on a teal hex, deep-plum on a purple hex), the token and the
+# hex are judged on the SAME scale: the colorway_name BAND family. A token only
+# counts as a genuine mismatch when its literal color word maps to a band family
+# that differs from the family colorway_name(hex) lands in. Non-color luxe
+# modifiers ("deep","greenhouse","antique","manor","smoke" …) are intentionally
+# NOT colors and never trigger — they qualify the real color word, they aren't it.
+_WORD_TO_NAME = { # curated/common color word → a canonical colorway_name word
+ 'crimson':'Oxblood','scarlet':'Oxblood','oxblood':'Oxblood','garnet':'Garnet',
+ 'claret':'Claret','rosewood':'Rosewood','bordeaux':'Oxblood','ruby':'Garnet',
+ 'terracotta':'Terracotta','amber':'Amber','apricot':'Apricot','ochre':'Ochre',
+ 'honey':'Honey','chamois':'Chamois','saffron':'Ochre','mustard':'Ochre',
+ 'olive':'Olive','loden':'Loden','chartreuse':'Chartreuse','sage':'Sage',
+ 'celadon':'Celadon','forest':'Bottle Green','moss':'Sage','emerald':'Bottle Green',
+ 'verdigris':'Verdigris','teal':'Teal','delft':'Delft','prussian':'Prussian',
+ 'sapphire':'Sapphire','navy':'Prussian','indigo':'Prussian','cobalt':'Sapphire',
+ 'aubergine':'Aubergine','amethyst':'Amethyst','wisteria':'Wisteria',
+ 'damson':'Damson','plum':'Plum','mauve':'Mauve','violet':'Amethyst',
+ 'rose':'Rose','blush':'Blush',
+ 'alabaster':'Alabaster','oyster':'Oyster','greige':'Greige','pewter':'Pewter',
+ 'charcoal':'Charcoal',
+}
+def _band_family(name):
+ """Return the band key (max_deg) a colorway_name word belongs to, or
+ 'neutral'; None if unknown. Lets token-color and hex-color be compared on
+ one scale, eliminating coarse-zone boundary artifacts.
+
+ The two RED bands straddle the 360°/0° hue wraparound — band 15
+ (Oxblood/Claret/Blush) and band 361 (Garnet/Rosewood/Rose) are perceptually
+ ONE red family. They are merged to key 15 so a curated dark-red slug
+ ("bordeaux-velvet") is NOT flagged as disagreeing with a colorway_name that
+ merely landed on the other side of the seam ("Garnet")."""
+ if not name:
+ return None
+ nl = name.strip()
+ if nl in COLORWAYS['neutral'].values():
+ return 'neutral'
+ for maxd, shades in COLORWAYS['bands']:
+ if nl in shades.values():
+ return 15 if maxd == 361 else maxd # merge the wraparound red band
+ return None
+
+def fix_title(title, hexd):
+ """Return (new_title, old_color, new_color) or None if no change."""
+ new_color = colorway_name(hexd)
+ if not new_color or not title:
+ return None
+ t = title.strip()
+ m = TITLE_LEAD_RE.match(t)
+ if m:
+ old = m.group(1)
+ if old.lower() == new_color.lower():
+ return None
+ return (f"{new_color} {m.group(2)} No.{m.group(3)}", old, new_color)
+ m = TITLE_IN_RE.match(t)
+ if m:
+ old = m.group(2)
+ if old.lower() == new_color.lower():
+ return None
+ return (f"{m.group(1)}{new_color}", old, new_color)
+ return None
+
+def fix_category(cat, hexd):
+ """Per /dtd option B: rewrite the ' · <token>' color token ONLY when the
+ token's literal color word lands in a DIFFERENT colorway band family than
+ colorway_name(hex). Curated, color-faithful slugs are preserved. Returns
+ (new_cat, old_token, new_token) or None."""
+ if not cat or ' · ' not in cat:
+ return None
+ prefix, tok = cat.split(' · ', 1)
+ words = [w for w in tok.strip().lower().split('-') if w]
+ color_word = next((w for w in words if w in _WORD_TO_NAME), None)
+ if color_word is None:
+ return None # no literal color in the slug → preserve as-is
+ hex_name = colorway_name(hexd)
+ tok_fam = _band_family(_WORD_TO_NAME[color_word])
+ hex_fam = _band_family(hex_name)
+ if tok_fam is None or hex_fam is None:
+ return None
+ if tok_fam == 'neutral' or hex_fam == 'neutral' or tok_fam == hex_fam:
+ return None # same family (or neutral) → genuine agreement, preserve
+ new_tok = hex_name.lower().replace(' ', '-')
+ return (f"{prefix} · {new_tok}", tok.strip(), new_tok)
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--in', dest='inp', required=True)
+ ap.add_argument('--out', dest='out')
+ ap.add_argument('--dry', action='store_true')
+ args = ap.parse_args()
+
+ data = json.load(open(args.inp))
+ if isinstance(data, dict):
+ sys.exit("ERROR: expected a JSON array of design rows, got an object")
+ n0 = len(data)
+
+ title_changed = cat_changed = 0
+ examples = []
+ for d in data:
+ # ONLY touch live-facing published, non-removed rows (the audited set).
+ if not d.get('is_published') or d.get('user_removed'):
+ continue
+ hexd = d.get('dominant_hex') or ''
+ ft = fix_title(d.get('title') or '', hexd)
+ if ft:
+ new_title, old_c, new_c = ft
+ if not args.dry:
+ d['title'] = new_title
+ title_changed += 1
+ if len(examples) < 20:
+ examples.append((d['id'], hexd, old_c, new_c))
+ fc = fix_category(d.get('category') or '', hexd)
+ if fc:
+ new_cat, _ot, _nt = fc
+ if not args.dry:
+ d['category'] = new_cat
+ cat_changed += 1
+
+ assert len(data) == n0, "row count changed — backfill must be row-invariant"
+
+ print(f"rows in snapshot: {n0}")
+ print(f"TITLE color words re-derived: {title_changed}")
+ print(f"CATEGORY color tokens re-derived: {cat_changed} (option B: only on chromatic disagreement)")
+ print("sample title fixes (id | hex | old -> new):")
+ for e in examples:
+ print(f" {e[0]:>6} | {e[1]:<9} | {e[2]} -> {e[3]}")
+
+ if args.dry:
+ print("\n[DRY RUN — no file written]")
+ return
+ if not args.out:
+ sys.exit("ERROR: --out required when not --dry")
+ json.dump(data, open(args.out, 'w'))
+ print(f"\nwrote corrected snapshot → {args.out}")
+
+if __name__ == '__main__':
+ main()
← 06a0e7c generator: add Texture component picker (15 real DW natural-
·
back to Wallco Ai
·
generator: replace dead DW-orders palette with style-guide p dafdb2d →