← back to Wallco Ai
YOLO R2 — ΔE closeness bands in src/color-isolate. closenessBand(de, kind) returns {band, note} for any Lab ΔE; kind='sw' yields 'no SW analogue — paint physics ceiling' at the weak end, kind='design' yields 'different color family'. Wired into nearestSW (sw.closeness) and coordinatingDesigns (per-result closeness). API /api/isolate returns closeness on palette / dominant_sw / coordinating_hexes / coordinating_designs. Rendered as colored chips on the iso modal palette + coord-designs grid. +12 unit tests (27/27 green). Mirrored to ~/Projects/_shared.
957e20b8b9f0e8ca85975c362bff6e6b3ceee737 · 2026-05-12 00:49:57 -0700 · Steve Abrams
Files touched
M YOLO_BACKLOG.mdM server.jsM src/color-isolate.jsM tests/unit/color-isolate.test.js
Diff
commit 957e20b8b9f0e8ca85975c362bff6e6b3ceee737
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 00:49:57 2026 -0700
YOLO R2 — ΔE closeness bands in src/color-isolate. closenessBand(de, kind) returns {band, note} for any Lab ΔE; kind='sw' yields 'no SW analogue — paint physics ceiling' at the weak end, kind='design' yields 'different color family'. Wired into nearestSW (sw.closeness) and coordinatingDesigns (per-result closeness). API /api/isolate returns closeness on palette / dominant_sw / coordinating_hexes / coordinating_designs. Rendered as colored chips on the iso modal palette + coord-designs grid. +12 unit tests (27/27 green). Mirrored to ~/Projects/_shared.
---
YOLO_BACKLOG.md | 5 +--
server.js | 24 ++++++++++++---
src/color-isolate.js | 23 ++++++++++++--
tests/unit/color-isolate.test.js | 66 +++++++++++++++++++++++++++++++++++++++-
4 files changed, 109 insertions(+), 9 deletions(-)
diff --git a/YOLO_BACKLOG.md b/YOLO_BACKLOG.md
index 8b95a1a..fd09c4a 100644
--- a/YOLO_BACKLOG.md
+++ b/YOLO_BACKLOG.md
@@ -27,7 +27,7 @@ User ask verbatim: "show final patterns and 1 room setting on https://wallco.ai/
### Loop opener — visual QA of the isolate feature (just shipped, never seen)
- [x] **R1 · Screenshot the isolate modal** — 2026-05-12 00:14 · 6 screenshots saved to tmp/isolate-qa/ · works: palette+SW labels render correctly · issues found: modal bottom cut at 900vh, review-card image-wrap aspect ratio off · *no commit (screenshots only)*
-- [ ] **R2 · ΔE closeness bands** — `/age-themes` and `/api/isolate` response both expose raw ΔE numbers. Add a human-readable closeness band per swatch: ΔE 0-5 "exact", 5-15 "close", 15-30 "approximate", 30+ "not in SW catalog (paint physics ceiling)". Render in both `/age-themes` and the isolate modal.
+- [x] **R2 · ΔE closeness bands** — 2026-05-12 00:58 · added `closenessBand(deltaE, kind)` to shared color-isolate.js. Bands: 0-5 exact / 5-15 close / 15-30 approximate / 30+ weak (kind='sw' → "no SW analogue", kind='design' → "different color family"). Wired into `nearestSW` (sw.closeness) + `coordinatingDesigns` (each result.closeness). API `/api/isolate/:id` returns closeness on all 4 surfaces: palette[].sw, dominant_sw, coordinating_hexes[].sw, coordinating_designs[]. Rendered as colored chips on wallco-ai (/designs iso modal) AND thesetdecorator (/best-sellers/:id). +12 unit tests (was 15, now 27 all green). `/age-themes` doesn't use ΔE (uses APCA Lc) so no change there — architect's mention was stale. Cross-project: shared canonical, mirrored to wallco-ai/src + thesetdecorator/lib + _shared.
- [x] **R3 · Adult band diff-chip clarity** — 2026-05-12 00:48 · resolved differently than architect spec'd. Adult band IS visually subtle (only body 13→15 changes) but renders TWO swatches because the body delta is real. Added explicit `diff-bar` chip strip above every band's compare grid showing each changed prop in `key from → to` format (e.g. Adult: "1 change: body 13 → 15"; Teen: "3 changes: font/body/h"). Diff detection auto-derived from `Object.keys(b.best).filter(k => b.current[k] !== b.best[k])` — single source of truth, no hardcoded annotations. Bands with zero diffs (none today) would render "no change recommended" placeholder. Verified live for all 8 bands.
- [x] **R4 · Isolate keyboard shortcuts** — 2026-05-12 00:52 · added scoped keydown handler inside iso-modal (fires only when modal is open). W=wallpaper mode, P=paint mode (both require Isolate button to have been clicked first, else toast nudges user). C=copy primary hex (palette[0].hex via navigator.clipboard with success/fail toast). ? toast shows full shortcut list. Doesn't conflict with global K/R/P card handler because that one early-returns when any modal is open. Verified handler is in served HTML.
- [ ] **R5 · Isolate modal: source-design SW label** — currently the palette[0] hex shows its SW match, but the design's stored `dominant_hex` (which may differ from extraction) isn't labeled. Show both: "Design's `dominant_hex` = X (SW Y)" + "Extracted palette[0] = Z (SW W)".
@@ -70,4 +70,5 @@ TICK · TIMESTAMP · ITEM · STATUS · COMMIT
- T8 · 2026-05-12 00:35 · R11 dominant_sw · done · /api/isolate response now annotates dominant_hex with SW match
- T9 · 2026-05-12 00:42 · R9 AI-language scrub · done · 9 public surfaces · admin banner kept per validation-loop · 9faa35b
- T10 · 2026-05-12 00:48 · R3 Adult diff-chip · done · auto-detect changed props per band, render diff-bar above compare grid · 04c43b4
-- T11 · 2026-05-12 00:52 · R4 isolate W/P/C shortcuts · done · scoped keydown handler, toast feedback, clipboard for primary hex
+- T11 · 2026-05-12 00:52 · R4 isolate W/P/C shortcuts · done · scoped keydown handler, toast feedback, clipboard for primary hex · b204703
+- T12 · 2026-05-12 00:58 · R2 ΔE closeness bands · done · 4 bands · 4 surfaces in API · chips on both wallco + tsd · 12 new unit tests
diff --git a/server.js b/server.js
index e7f08d4..44be79a 100644
--- a/server.js
+++ b/server.js
@@ -1315,6 +1315,15 @@ ${_isAdmin ? `
.iso-sw .sw-code { font-size:10px; color:#d2b15c; text-align:center; letter-spacing:.04em; }
.iso-sw .sw-name { font-size:10px; color:#888; text-align:center; line-height:1.2; }
.iso-sw .sw-empty { font-size:10px; color:#555; text-align:center; font-style:italic; }
+ .iso-sw .sw-closeness { display:inline-block; margin-top:4px; padding:1px 6px; border-radius:8px; font:9px/1.2 ui-monospace, Menlo, monospace; text-transform:uppercase; letter-spacing:.05em; }
+ .sw-close-exact { background:#1f3a1f; color:#5fbf6e; }
+ .sw-close-close { background:#1f2f3a; color:#5fa8bf; }
+ .sw-close-approximate { background:#3a2e1f; color:#d2b15c; }
+ .sw-close-weak { background:#3a1f1f; color:#d2554a; }
+ .iso-grid-wallpaper .iso-de.iso-de-exact { background:rgba(95,191,110,.85); color:#0a1a0d; }
+ .iso-grid-wallpaper .iso-de.iso-de-close { background:rgba(95,168,191,.85); color:#0d1a1f; }
+ .iso-grid-wallpaper .iso-de.iso-de-approximate { background:rgba(210,177,92,.85); color:#1f1a0d; }
+ .iso-grid-wallpaper .iso-de.iso-de-weak { background:rgba(210,85,74,.75); color:#1f0d0d; }
.iso-action { display:flex; gap:10px; align-items:center; }
.iso-btn-isolate { flex:1; padding:10px 16px; background:#d2b15c; color:#0f0e0c; border:none; cursor:pointer; font:13px/1.2 -apple-system, system-ui, sans-serif; font-weight:600; border-radius:3px; letter-spacing:.04em; text-transform:uppercase; }
.iso-btn-isolate:hover { background:#e8c46d; }
@@ -1827,11 +1836,15 @@ ${_isAdmin ? `
$('#iso-palette').innerHTML = palette.map(p => {
const hex = p.hex || '#888';
const sw = p.sw;
+ const closenessChip = (sw && sw.closeness)
+ ? '<div class="sw-closeness sw-close-' + sw.closeness.band + '" title="ΔE ' + sw.deltaE + ' — ' + escapeAttr(sw.closeness.note) + '">' + escapeHTML(sw.closeness.band) + '</div>'
+ : '';
const swBlock = sw
? '<div class="sw-code">' + escapeHTML(sw.code) + '</div>' +
- '<div class="sw-name">' + escapeHTML(sw.name) + '</div>'
+ '<div class="sw-name">' + escapeHTML(sw.name) + '</div>' +
+ closenessChip
: '<div class="sw-empty">—</div>';
- return '<div class="iso-sw" title="' + escapeAttr(hex) + (sw ? ' · ' + escapeAttr(sw.code + ' ' + sw.name) : '') + '">' +
+ return '<div class="iso-sw" title="' + escapeAttr(hex) + (sw ? ' · ' + escapeAttr(sw.code + ' ' + sw.name + ' · ΔE ' + sw.deltaE) : '') + '">' +
'<div class="dot" style="background:' + escapeAttr(hex) + '"></div>' +
'<div class="hex">' + escapeHTML(hex) + '</div>' +
swBlock +
@@ -1847,9 +1860,11 @@ ${_isAdmin ? `
}
grid.innerHTML = designs.map(d => {
const img = (d.image_url || '').replace(/'/g, '');
- const t = escapeAttr((d.title || '#' + d.id) + ' · ΔE ' + (d.deltaE != null ? d.deltaE : '?'));
+ const closenessTxt = d.closeness ? ' (' + d.closeness.band + ')' : '';
+ const t = escapeAttr((d.title || '#' + d.id) + ' · ΔE ' + (d.deltaE != null ? d.deltaE : '?') + closenessTxt);
+ const bandClass = d.closeness ? ' iso-de-' + d.closeness.band : '';
return '<a href="/design/' + d.id + '" target="_blank" title="' + t + '" style="background-image:url(' + img + ')">' +
- '<span class="iso-de">ΔE ' + (d.deltaE != null ? d.deltaE.toFixed(1) : '?') + '</span>' +
+ '<span class="iso-de' + bandClass + '">ΔE ' + (d.deltaE != null ? d.deltaE.toFixed(1) : '?') + '</span>' +
'</a>';
}).join('');
}
@@ -6423,6 +6438,7 @@ app.get('/api/isolate/:designId', requireAdmin, (req, res) => {
dominant_hex: d.dominant_hex,
category: d.category,
deltaE: d.deltaE,
+ closeness: d.closeness,
}));
// R11 — annotate the design's stored dominant_hex with its nearest SW so
diff --git a/src/color-isolate.js b/src/color-isolate.js
index 404a44d..f8ad760 100644
--- a/src/color-isolate.js
+++ b/src/color-isolate.js
@@ -137,7 +137,22 @@ function loadSW() {
return _swCache;
}
-// Returns { code, name, hex, deltaE } for the closest SW color to `hex`,
+// Human-readable closeness band for a Lab ΔE value.
+// Bands chosen to align with industry intuition (ΔE 2-3 = JND for trained eyes;
+// 5 = casual eyes can't tell; 15 = same color family; 30 = different colors).
+// Returns { band: 'exact'|'close'|'approximate'|'weak', note: string }.
+function closenessBand(de, kind = 'sw') {
+ const n = +de;
+ if (!isFinite(n) || n < 0) return { band: 'weak', note: '?' };
+ if (n <= 5) return { band: 'exact', note: 'exact match' };
+ if (n <= 15) return { band: 'close', note: 'close match' };
+ if (n <= 30) return { band: 'approximate', note: 'approximate match' };
+ return kind === 'sw'
+ ? { band: 'weak', note: 'no SW analogue — paint physics ceiling' }
+ : { band: 'weak', note: 'weak match — different color family' };
+}
+
+// Returns { code, name, hex, deltaE, closeness } for the closest SW color to `hex`,
// or null if hex is bad or SW DB missing.
function nearestSW(hex) {
const rgb = hexToRgb(hex);
@@ -150,6 +165,7 @@ function nearestSW(hex) {
const d = deltaE(lab, sw._lab);
if (!best || d < best.deltaE) best = { code: sw.code, name: sw.name, hex: sw.hex, deltaE: +d.toFixed(2) };
}
+ if (best) best.closeness = closenessBand(best.deltaE, 'sw');
return best;
}
@@ -192,7 +208,8 @@ function coordinatingDesigns(primaryHex, designs, opts = {}) {
if (!lab) return [];
const scored = (designs || []).filter(d => d.dominant_hex).map(d => {
const dlab = rgbToLab(hexToRgb(d.dominant_hex));
- return { ...d, deltaE: dlab ? +deltaE(lab, dlab).toFixed(2) : 999 };
+ const dE = dlab ? +deltaE(lab, dlab).toFixed(2) : 999;
+ return { ...d, deltaE: dE, closeness: closenessBand(dE, 'design') };
}).sort((a, b) => a.deltaE - b.deltaE);
return scored.slice(0, n);
}
@@ -200,6 +217,8 @@ function coordinatingDesigns(primaryHex, designs, opts = {}) {
module.exports = {
// hex/color math
expandHex, hexToRgb, rgbToHex, rgbToLab, deltaE, rgbToHsl, hslToRgb,
+ // closeness bands (R2 — human-readable interpretation of raw ΔE)
+ closenessBand,
// palette + SW
extractPalette, nearestSW, annotatePalette, loadSW,
// coordination
diff --git a/tests/unit/color-isolate.test.js b/tests/unit/color-isolate.test.js
index fb7c410..b7f4038 100644
--- a/tests/unit/color-isolate.test.js
+++ b/tests/unit/color-isolate.test.js
@@ -15,7 +15,7 @@ const mod = require('../../src/color-isolate.js');
const {
expandHex, hexToRgb, rgbToHex, deltaE, rgbToLab,
extractPalette, annotatePalette, coordinatingHexes, coordinatingDesigns,
- loadSW,
+ loadSW, closenessBand, nearestSW,
} = mod;
// ── expandHex ────────────────────────────────────────────────────────────
@@ -156,4 +156,68 @@ describe('coordinatingDesigns', () => {
assert.equal(result.length, 1);
assert.equal(result[0].id, 1);
});
+
+ test('attaches closeness band to each result', () => {
+ const designs = [
+ { id: 1, dominant_hex: '#ff0000' }, // ΔE 0
+ { id: 2, dominant_hex: '#ff1010' }, // ΔE small
+ { id: 3, dominant_hex: '#0000ff' }, // ΔE huge
+ ];
+ const result = coordinatingDesigns('#ff0000', designs, { limit: 3 });
+ for (const r of result) {
+ assert.ok(r.closeness, 'closeness present');
+ assert.ok(['exact','close','approximate','weak'].includes(r.closeness.band));
+ assert.equal(typeof r.closeness.note, 'string');
+ }
+ assert.equal(result[0].closeness.band, 'exact', 'identical hex → exact band');
+ });
+});
+
+// ── closenessBand ────────────────────────────────────────────────────────
+describe('closenessBand', () => {
+ test('ΔE 0 → exact', () => {
+ assert.equal(closenessBand(0).band, 'exact');
+ });
+ test('ΔE 5 (boundary) → exact', () => {
+ assert.equal(closenessBand(5).band, 'exact');
+ });
+ test('ΔE 5.01 → close', () => {
+ assert.equal(closenessBand(5.01).band, 'close');
+ });
+ test('ΔE 15 (boundary) → close', () => {
+ assert.equal(closenessBand(15).band, 'close');
+ });
+ test('ΔE 15.01 → approximate', () => {
+ assert.equal(closenessBand(15.01).band, 'approximate');
+ });
+ test('ΔE 30 (boundary) → approximate', () => {
+ assert.equal(closenessBand(30).band, 'approximate');
+ });
+ test('ΔE 30.01 → weak', () => {
+ assert.equal(closenessBand(30.01).band, 'weak');
+ });
+ test('kind=sw uses paint-physics ceiling note at weak band', () => {
+ const r = closenessBand(50, 'sw');
+ assert.equal(r.band, 'weak');
+ assert.match(r.note, /paint physics|no SW/i);
+ });
+ test('kind=design uses color-family note at weak band', () => {
+ const r = closenessBand(50, 'design');
+ assert.equal(r.band, 'weak');
+ assert.match(r.note, /different color family|weak/i);
+ });
+ test('non-finite ΔE → weak fallback', () => {
+ assert.equal(closenessBand(NaN).band, 'weak');
+ assert.equal(closenessBand(-1).band, 'weak');
+ });
+});
+
+// ── nearestSW closeness wiring ───────────────────────────────────────────
+describe('nearestSW with closeness', () => {
+ test('attaches closeness band when DB present', () => {
+ const r = nearestSW('#ff0000');
+ if (!r) return; // SW DB missing
+ assert.ok(r.closeness, 'closeness present');
+ assert.ok(['exact','close','approximate','weak'].includes(r.closeness.band));
+ });
});
← 55f8ac2 YOLO R4 — keyboard shortcuts on isolate modal (W=wallpaper,
·
back to Wallco Ai
·
YOLO R5 — surface design's stored dominant_hex vs extracted a9a18ac →