← back to Wallco Ai
REFACTOR-1b/c/d: extract lib/color.js, lib/http.js, lib/parse.js
b940514484bb48846d49f3d50a7a30b26bb3fcc0 · 2026-05-23 11:59:19 -0700 · Steve Abrams
Continues the 13-commit refactor plan from the architect agent's
report. Behavior-preserving extractions:
lib/color.js (130 LOC):
- _hexToHsl, _hueBucket, _isNeonHex — hue-bucket sort + neon-aesthetic gate
- _hexToLab, _dE — CIELAB ΔE distance for "more like this" rails
- hexToHSL, hexSaturation, hueName + HUE_NAMES table — snapshot-style title gen
Two HSL variants intentionally kept distinct: _hexToHsl returns {h,s,l}
with s/l as percent (0..100); hexToHSL returns [h,s,l] with s/l as
0..1. Merging them would silently break ~15 call sites' threshold
comparisons.
lib/http.js (27 LOC):
- maybe304 — conditional GET / If-Modified-Since negotiation
lib/parse.js (28 LOC):
- parseDesignId(req) — wraps the 30+ duplicated parseInt + Number.isFinite
+ >=1 guard pattern. Available for new code; existing callers
untouched in this commit (per the agent's "no sweep required" note).
- parseIdLike(v) — same for non-req inputs.
server.js shrinks 17031 → 16928 (-103 LOC net after imports).
Cumulative refactor progress (REFACTOR-1a+b+c+d): ~150 LOC out of
server.js into 4 focused lib/ modules. Pattern proven for the bigger
extractions ahead (render helpers, route bucket extraction, fat
template extraction).
node --check passes for all 5 files. Behavior identical; same function
bodies, same exports at module scope.
Files touched
A lib/color.jsA lib/http.jsA lib/parse.jsM server.js
Diff
commit b940514484bb48846d49f3d50a7a30b26bb3fcc0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 23 11:59:19 2026 -0700
REFACTOR-1b/c/d: extract lib/color.js, lib/http.js, lib/parse.js
Continues the 13-commit refactor plan from the architect agent's
report. Behavior-preserving extractions:
lib/color.js (130 LOC):
- _hexToHsl, _hueBucket, _isNeonHex — hue-bucket sort + neon-aesthetic gate
- _hexToLab, _dE — CIELAB ΔE distance for "more like this" rails
- hexToHSL, hexSaturation, hueName + HUE_NAMES table — snapshot-style title gen
Two HSL variants intentionally kept distinct: _hexToHsl returns {h,s,l}
with s/l as percent (0..100); hexToHSL returns [h,s,l] with s/l as
0..1. Merging them would silently break ~15 call sites' threshold
comparisons.
lib/http.js (27 LOC):
- maybe304 — conditional GET / If-Modified-Since negotiation
lib/parse.js (28 LOC):
- parseDesignId(req) — wraps the 30+ duplicated parseInt + Number.isFinite
+ >=1 guard pattern. Available for new code; existing callers
untouched in this commit (per the agent's "no sweep required" note).
- parseIdLike(v) — same for non-req inputs.
server.js shrinks 17031 → 16928 (-103 LOC net after imports).
Cumulative refactor progress (REFACTOR-1a+b+c+d): ~150 LOC out of
server.js into 4 focused lib/ modules. Pattern proven for the bigger
extractions ahead (render helpers, route bucket extraction, fat
template extraction).
node --check passes for all 5 files. Behavior identical; same function
bodies, same exports at module scope.
---
lib/color.js | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/http.js | 27 +++++++++++++
lib/parse.js | 28 +++++++++++++
server.js | 123 +++++--------------------------------------------------
4 files changed, 195 insertions(+), 113 deletions(-)
diff --git a/lib/color.js b/lib/color.js
new file mode 100644
index 0000000..12d632a
--- /dev/null
+++ b/lib/color.js
@@ -0,0 +1,130 @@
+// lib/color.js — color-space helpers used across server.js for sort/filter,
+// "more like this", neon-aesthetic gates, and snapshot-style title generation.
+//
+// REFACTOR-1b (2026-05-23): extracted verbatim from server.js. No behavior
+// change — same return shapes ({h,s,l} object vs [h,s,l] array), same
+// hue-bucket cutoffs, same neon thresholds.
+//
+// Two HSL variants exist on purpose:
+// _hexToHsl → {h, s, l} in degrees / percent / percent — used by the
+// bucket / neon helpers.
+// hexToHSL → [h, s, l] in degrees / 0..1 / 0..1 — used by the
+// sort path (matches the snapshot-py port).
+// They cannot be merged without auditing every caller — the .s scale differs
+// (0..100 vs 0..1). Left distinct.
+
+'use strict';
+
+// ── HSL variant A: object shape, s/l in percent (0..100).
+function _hexToHsl(hex) {
+ if (!hex || typeof hex !== 'string') return null;
+ const m = hex.trim().match(/^#?([0-9a-f]{6})$/i);
+ if (!m) return null;
+ const n = parseInt(m[1], 16);
+ const r = ((n >> 16) & 0xff) / 255;
+ const g = ((n >> 8) & 0xff) / 255;
+ const b = ( n & 0xff) / 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
+ const l = (mx + mn) / 2;
+ let h = 0, s = 0;
+ if (mx !== mn) {
+ const d = mx - mn;
+ s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+ switch (mx) {
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+ case g: h = (b - r) / d + 2; break;
+ case b: h = (r - g) / d + 4; break;
+ }
+ h *= 60;
+ }
+ return { h, s: s * 100, l: l * 100 };
+}
+
+// Coarse 12-bucket hue classifier. Returns 'neutral' for low-sat or near-black/white.
+function _hueBucket(hex) {
+ const hsl = _hexToHsl(hex);
+ if (!hsl) return 'unknown';
+ if (hsl.s < 12 || hsl.l < 10 || hsl.l > 90) return 'neutral';
+ const h = hsl.h;
+ if (h < 15) return 'red'; if (h < 45) return 'orange'; if (h < 65) return 'yellow';
+ if (h < 90) return 'chartreuse'; if (h < 150) return 'green'; if (h < 180) return 'teal';
+ if (h < 210) return 'cyan'; if (h < 250) return 'blue'; if (h < 280) return 'indigo';
+ if (h < 310) return 'violet'; if (h < 340) return 'magenta'; return 'red';
+}
+
+// Neon-aesthetic flag: high saturation + mid lightness + (optional) palette percentage.
+function _isNeonHex(hex, pct) {
+ const hsl = _hexToHsl(hex);
+ if (!hsl) return false;
+ return hsl.s >= 70 && hsl.l >= 45 && hsl.l <= 75 && (pct == null || pct >= 5);
+}
+
+// CIELAB conversion for ΔE color-distance ("more like this in this palette").
+function _hexToLab(hex) {
+ let h = String(hex || '').replace('#','').trim();
+ if (h.length === 3) h = h.split('').map(c => c+c).join('');
+ if (!/^[0-9a-f]{6}$/i.test(h)) return null;
+ let r = parseInt(h.slice(0,2),16)/255, g = parseInt(h.slice(2,4),16)/255, b = parseInt(h.slice(4,6),16)/255;
+ // sRGB → linear
+ [r,g,b] = [r,g,b].map(v => v <= 0.04045 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4));
+ // linear → XYZ (D65)
+ const X = (r*0.4124564 + g*0.3575761 + b*0.1804375) / 0.95047;
+ const Y = (r*0.2126729 + g*0.7151522 + b*0.0721750) / 1.00000;
+ const Z = (r*0.0193339 + g*0.1191920 + b*0.9503041) / 1.08883;
+ // XYZ → Lab
+ const f = (t) => t > 0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
+ const fx = f(X), fy = f(Y), fz = f(Z);
+ return [ 116*fy - 16, 500*(fx-fy), 200*(fy-fz) ];
+}
+
+// Plain CIE76 ΔE (Euclidean). Good enough for ranking; not CIEDE2000.
+function _dE(a, b) {
+ const dL = a[0]-b[0], dA = a[1]-b[1], dB = a[2]-b[2];
+ return Math.sqrt(dL*dL + dA*dA + dB*dB);
+}
+
+// ── HSL variant B: array shape, s/l in 0..1. Mirrors refresh_designs_snapshot.py.
+function hexToHSL(hex) {
+ if (!hex) return [0, 0, 0.5];
+ const h = hex.replace('#', '');
+ 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 hue = 0, sat = 0;
+ if (max !== min) {
+ const d = max - min;
+ sat = 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 *= 60;
+ }
+ return [hue, sat, l];
+}
+
+function hexSaturation(hex) { return Math.round(hexToHSL(hex)[1] * 1000) / 1000; }
+
+// HUE_NAMES table used by hueName + snapshot-style title generator.
+const HUE_NAMES = [
+ [ 0, 18, 'Crimson'], [ 18, 38, 'Amber'], [ 38, 58, 'Honey'],
+ [ 58, 78, 'Olive'], [ 78,160, 'Sage'], [160,200, 'Teal'],
+ [200,255, 'Sapphire'],[255,295, 'Violet'], [295,335, 'Rose'],
+ [335,360, 'Crimson']
+];
+
+function hueName(hex) {
+ if (!hex) return 'Bone';
+ const [h, s] = hexToHSL(hex);
+ if (s < 0.06) return 'Noir';
+ for (const r of HUE_NAMES) if (h >= r[0] && h < r[1]) return r[2];
+ return 'Bone';
+}
+
+module.exports = {
+ _hexToHsl, _hueBucket, _isNeonHex,
+ _hexToLab, _dE,
+ hexToHSL, hexSaturation, hueName,
+ HUE_NAMES,
+};
diff --git a/lib/http.js b/lib/http.js
new file mode 100644
index 0000000..aff1cc1
--- /dev/null
+++ b/lib/http.js
@@ -0,0 +1,27 @@
+// lib/http.js — HTTP helpers (304 negotiation, etc.).
+//
+// REFACTOR-1c (2026-05-23): extracted verbatim from server.js. Pure function,
+// no state.
+
+'use strict';
+
+// Conditional-GET helper. Sets Last-Modified, checks If-Modified-Since, and
+// 304s when the client's copy is current. Returns true if it sent the 304
+// (caller should `return` after calling this).
+function maybe304(req, res, lastModifiedDate) {
+ if (!lastModifiedDate || isNaN(lastModifiedDate.getTime())) return false;
+ // HTTP-date truncates to 1s; floor both sides to match.
+ const lastMs = Math.floor(lastModifiedDate.getTime() / 1000) * 1000;
+ res.setHeader('Last-Modified', new Date(lastMs).toUTCString());
+ const ims = req.headers['if-modified-since'];
+ if (ims) {
+ const imsMs = Date.parse(ims);
+ if (!isNaN(imsMs) && imsMs >= lastMs) {
+ res.status(304).end();
+ return true;
+ }
+ }
+ return false;
+}
+
+module.exports = { maybe304 };
diff --git a/lib/parse.js b/lib/parse.js
new file mode 100644
index 0000000..f958ca1
--- /dev/null
+++ b/lib/parse.js
@@ -0,0 +1,28 @@
+// lib/parse.js — small parsing helpers shared across routes.
+//
+// REFACTOR-1d (2026-05-23): new module. parseDesignId is a wrap of the
+// 30+ duplicated `parseInt(req.params.id, 10) + Number.isFinite + >=1`
+// guard pattern. Adopt opportunistically — no sweep required in this commit.
+
+'use strict';
+
+// Parse req.params.id as a positive integer design ID.
+// Returns the integer on success, or null if it's missing / NaN / non-positive.
+// Callers typically:
+// const id = parseDesignId(req);
+// if (id == null) return res.status(400).json({ error: 'bad id' });
+function parseDesignId(req) {
+ const raw = req && req.params && req.params.id;
+ const n = parseInt(raw, 10);
+ if (!Number.isFinite(n) || n < 1) return null;
+ return n;
+}
+
+// Same shape for req.query.id (or any string-like).
+function parseIdLike(v) {
+ const n = parseInt(v, 10);
+ if (!Number.isFinite(n) || n < 1) return null;
+ return n;
+}
+
+module.exports = { parseDesignId, parseIdLike };
diff --git a/server.js b/server.js
index 04b7e0e..54e4ff2 100644
--- a/server.js
+++ b/server.js
@@ -258,44 +258,8 @@ function loadBlankIds() {
// - design where dominant_hex alone is neon (single-color fallback for
// rows without a full palette)
// - design where prompt / motifs / tags match the neon/rainbow regex
-function _hexToHsl(hex) {
- if (!hex || typeof hex !== 'string') return null;
- const m = hex.trim().match(/^#?([0-9a-f]{6})$/i);
- if (!m) return null;
- const n = parseInt(m[1], 16);
- const r = ((n >> 16) & 0xff) / 255;
- const g = ((n >> 8) & 0xff) / 255;
- const b = ( n & 0xff) / 255;
- const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
- const l = (mx + mn) / 2;
- let h = 0, s = 0;
- if (mx !== mn) {
- const d = mx - mn;
- s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
- switch (mx) {
- case r: h = (g - b) / d + (g < b ? 6 : 0); break;
- case g: h = (b - r) / d + 2; break;
- case b: h = (r - g) / d + 4; break;
- }
- h *= 60;
- }
- return { h, s: s * 100, l: l * 100 };
-}
-function _hueBucket(hex) {
- const hsl = _hexToHsl(hex);
- if (!hsl) return 'unknown';
- if (hsl.s < 12 || hsl.l < 10 || hsl.l > 90) return 'neutral';
- const h = hsl.h;
- if (h < 15) return 'red'; if (h < 45) return 'orange'; if (h < 65) return 'yellow';
- if (h < 90) return 'chartreuse'; if (h < 150) return 'green'; if (h < 180) return 'teal';
- if (h < 210) return 'cyan'; if (h < 250) return 'blue'; if (h < 280) return 'indigo';
- if (h < 310) return 'violet'; if (h < 340) return 'magenta'; return 'red';
-}
-function _isNeonHex(hex, pct) {
- const hsl = _hexToHsl(hex);
- if (!hsl) return false;
- return hsl.s >= 70 && hsl.l >= 45 && hsl.l <= 75 && (pct == null || pct >= 5);
-}
+// REFACTOR-1b (2026-05-23): color helpers extracted to lib/color.js.
+const { _hexToHsl, _hueBucket, _isNeonHex, _hexToLab, _dE, hexToHSL, hexSaturation, hueName, HUE_NAMES } = require('./lib/color');
const _AESTHETIC_GATE_RE = /\b(neon|fluorescent|electric|rainbow|day[- ]?glo|highlighter|fluoro)\b/i;
function passesAestheticGate(d) {
if (!d) return false;
@@ -349,21 +313,10 @@ loadDesigns();
// precision since HTTP-date drops millis), emit 304 with empty body. Crawlers like
// Googlebot/Bingbot use this to skip full-body downloads on repeat visits.
// Returns true if a 304 was sent (caller should bail). HEAD requests handled too.
-function maybe304(req, res, lastModifiedDate) {
- if (!lastModifiedDate || isNaN(lastModifiedDate.getTime())) return false;
- // HTTP-date truncates to 1s; floor both sides to match.
- const lastMs = Math.floor(lastModifiedDate.getTime() / 1000) * 1000;
- res.setHeader('Last-Modified', new Date(lastMs).toUTCString());
- const ims = req.headers['if-modified-since'];
- if (ims) {
- const imsMs = Date.parse(ims);
- if (!isNaN(imsMs) && imsMs >= lastMs) {
- res.status(304).end();
- return true;
- }
- }
- return false;
-}
+// REFACTOR-1c: maybe304 extracted to lib/http.js.
+// REFACTOR-1d: parseDesignId / parseIdLike available via lib/parse.js for new code.
+const { maybe304 } = require('./lib/http');
+const { parseDesignId, parseIdLike } = require('./lib/parse');
// ── Levenshtein distance (early-exit at cap). Used for did-you-mean suggestions.
function _lev(a, b, cap) {
@@ -539,26 +492,7 @@ app.get('/api/designs/search', (req, res) => {
// ── /api/designs/by-color?hex=%23c16d74&n=12&exclude=72
// Returns N closest-in-color designs to a target hex via CIELAB ΔE distance.
// Useful for "more like this in this palette" rails on the PDP. Read-only, no DB.
-function _hexToLab(hex) {
- let h = String(hex || '').replace('#','').trim();
- if (h.length === 3) h = h.split('').map(c => c+c).join('');
- if (!/^[0-9a-f]{6}$/i.test(h)) return null;
- let r = parseInt(h.slice(0,2),16)/255, g = parseInt(h.slice(2,4),16)/255, b = parseInt(h.slice(4,6),16)/255;
- // sRGB → linear
- [r,g,b] = [r,g,b].map(v => v <= 0.04045 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4));
- // linear → XYZ (D65)
- const X = (r*0.4124564 + g*0.3575761 + b*0.1804375) / 0.95047;
- const Y = (r*0.2126729 + g*0.7151522 + b*0.0721750) / 1.00000;
- const Z = (r*0.0193339 + g*0.1191920 + b*0.9503041) / 1.08883;
- // XYZ → Lab
- const f = (t) => t > 0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
- const fx = f(X), fy = f(Y), fz = f(Z);
- return [ 116*fy - 16, 500*(fx-fy), 200*(fy-fz) ];
-}
-function _dE(a, b) {
- const dL = a[0]-b[0], dA = a[1]-b[1], dB = a[2]-b[2];
- return Math.sqrt(dL*dL + dA*dA + dB*dB);
-}
+// REFACTOR-1b: _hexToLab + _dE moved to lib/color.js (imported above).
// In-memory TTL cache for /api/designs/by-color. The Lab conversion + ΔE sort
// runs over every DESIGN on every call — the same PDP rail hex + size combo
// gets hammered as users browse, so cache the JSON body for the same 5min
@@ -700,39 +634,8 @@ app.get('/api/designs/random', (req, res) => {
});
});
-// ── Color sort helpers
-function hexToHSL(hex) {
- if (!hex) return [0, 0, 0.5];
- const h = hex.replace('#', '');
- 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 hue = 0, sat = 0;
- if (max !== min) {
- const d = max - min;
- sat = 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 *= 60;
- }
- return [hue, sat, l];
-}
-
-// Saturation (0..1) from hex — used by /design/:id PG fallback.
-function hexSaturation(hex) { return Math.round(hexToHSL(hex)[1] * 1000) / 1000; }
-
-// ── Snapshot-style title generator (mirror of refresh_designs_snapshot.py).
-// Used by /design/:id PG fallback so freshly-baked designs land with proper
-// "Honey Garden No.83"-style titles, not the raw prompt prefix.
-const HUE_NAMES = [
- [ 0, 18, 'Crimson'], [ 18, 38, 'Amber'], [ 38, 58, 'Honey'],
- [ 58, 78, 'Olive'], [ 78,160, 'Sage'], [160,200, 'Teal'],
- [200,255, 'Sapphire'],[255,295, 'Violet'], [295,335, 'Rose'],
- [335,360, 'Crimson']
-];
+// REFACTOR-1b: hexToHSL, hexSaturation, hueName, HUE_NAMES moved to lib/color.js.
+// NOUNS + titleFor remain here — they're category-aware naming, not pure color.
const NOUNS = {
floral: ['Bouquet','Blossom','Garden','Floret','Botanical','Bloom'],
geometric: ['Lattice','Tessellation','Geometry','Meridian','Compass','Mosaic'],
@@ -747,13 +650,7 @@ const NOUNS = {
// Panoramic scenic — luxury hand-painted scenic tradition.
'mural-scenic': ['Scenic','Panorama','Vista','Frieze','Tapestry','Folly','Allée','Belvedere','Promenade','Pavillon']
};
-function hueName(hex) {
- if (!hex) return 'Bone';
- const [h, s] = hexToHSL(hex);
- if (s < 0.06) return 'Noir';
- for (const r of HUE_NAMES) if (h >= r[0] && h < r[1]) return r[2];
- return 'Bone';
-}
+// REFACTOR-1b: hueName moved to lib/color.js (imported above).
function titleFor(cat, hex, id) {
const pool = NOUNS[(cat || 'mixed').toLowerCase()] || NOUNS.mixed;
return `${hueName(hex)} ${pool[id % pool.length]} No.${id}`;
← 3b6954e REFACTOR-1a: extract lib/db.js (psqlQuery, pgEsc, psqlExecLo
·
back to Wallco Ai
·
REFACTOR-2: extract lib/gemini.js wrapper for 6 Gemini call 550c260 →