[object Object]

← 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

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 →