← back to Wallco Ai
feat(designs): add Color (group similar hues, H→L) + Style sort modes to browse grid
e04b3d1bb192c6bbb87a59fc5586c1a12f49bfbe · 2026-06-01 12:30:01 -0700 · Steve
- sortDesigns(): new 'color' case sorts chromatic designs by continuous hue then
lightness (finer than the 30-degree color-wheel buckets), pushes near-grays
into their own L-banded tail, and sends designs with no dominant_hex last in
natural order. New 'style' case groups by category with tags->motifs fallback.
- Surface both as Sort chips + dedicated dropdown optgroups; existing density
slider + sort localStorage persistence already satisfy the standing rule.
Files touched
Diff
commit e04b3d1bb192c6bbb87a59fc5586c1a12f49bfbe
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 1 12:30:01 2026 -0700
feat(designs): add Color (group similar hues, H→L) + Style sort modes to browse grid
- sortDesigns(): new 'color' case sorts chromatic designs by continuous hue then
lightness (finer than the 30-degree color-wheel buckets), pushes near-grays
into their own L-banded tail, and sends designs with no dominant_hex last in
natural order. New 'style' case groups by category with tags->motifs fallback.
- Surface both as Sort chips + dedicated dropdown optgroups; existing density
slider + sort localStorage persistence already satisfy the standing rule.
---
server.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 50 insertions(+), 4 deletions(-)
diff --git a/server.js b/server.js
index 9954577..5f07e71 100644
--- a/server.js
+++ b/server.js
@@ -1002,6 +1002,10 @@ function sortDesigns(designs, sort) {
};
// Saturation helper — HSL S channel; neutrals fall to the end.
const sat = x => { try { return (hexToHSL(x.dominant_hex)||[0,0,0])[1] || 0; } catch { return 0; } };
+ // Has-hex predicate — hexToHSL returns [0,0,0.5] for missing/empty, so a
+ // truthiness check on the raw field is the only reliable "no color" signal.
+ // Designs without a dominant_hex sort LAST in their natural (newest) order.
+ const hasHex = x => !!(x.dominant_hex && /^#?[0-9a-fA-F]{6}$/.test(String(x.dominant_hex).trim()));
switch (sort) {
case 'oldest': return d.sort((a,b) => (a.id||0) - (b.id||0));
case 'title': return d.sort(tie((a,b) => a.title.localeCompare(b.title)));
@@ -1029,6 +1033,44 @@ function sortDesigns(designs, sort) {
const bA = Math.floor(ha/30), bB = Math.floor(hb/30);
return bA !== bB ? bA-bB : la-lb;
}));
+ // 'color' — true continuous hue sort so visually-similar colors group
+ // tightly (finer than color-wheel's 30° buckets). Sort by hue, then by
+ // lightness within the same hue. Near-grays (S < 0.08) collapse to a hue
+ // of 0 but are pushed AFTER chromatic colors so the grays don't split the
+ // reds. Designs with NO dominant_hex go last, in natural (newest) order.
+ case 'color': {
+ const withHex = d.filter(hasHex);
+ const noHex = d.filter(x => !hasHex(x)).sort((a,b) => (b.id||0) - (a.id||0));
+ withHex.sort((a,b) => {
+ const [ha, sa, la] = hexToHSL(a.dominant_hex);
+ const [hb, sb, lb] = hexToHSL(b.dominant_hex);
+ const grayA = sa < 0.08 ? 1 : 0, grayB = sb < 0.08 ? 1 : 0;
+ if (grayA !== grayB) return grayA - grayB; // chromatic first
+ if (grayA === 1) return la - lb; // grays: light→dark band by L
+ if (Math.abs(ha - hb) > 0.5) return ha - hb; // primary key: hue
+ return la - lb; // tie-break: lightness
+ });
+ return withHex.concat(noHex);
+ }
+ // 'style' — group by visual style. Primary key is category; when category
+ // is missing/blank fall back to the first tag, then the first motif, so
+ // every design lands in SOME style bucket rather than an empty group.
+ // Within a bucket, newest first.
+ case 'style': {
+ const styleKey = x => {
+ const c = (x.category || '').trim().toLowerCase();
+ if (c) return c;
+ const t = Array.isArray(x.tags) && x.tags.length ? String(x.tags[0]).trim().toLowerCase() : '';
+ if (t) return t;
+ const m = Array.isArray(x.motifs) && x.motifs.length ? String(x.motifs[0]).trim().toLowerCase() : '';
+ if (m) return m;
+ return '~'; // unbucketable → sorts last (~ > all letters)
+ };
+ return d.sort(tie((a,b) => {
+ const ka = styleKey(a), kb = styleKey(b);
+ return ka !== kb ? ka.localeCompare(kb) : (b.id||0) - (a.id||0);
+ }));
+ }
// newest = strict highest id first. NO has-room tie-break — when a
// visitor explicitly asks for newest, hide-no-rooms-promote-rooms would
// sandbag the actual latest designs (the drunk-animals + stoned-animals
@@ -7418,9 +7460,9 @@ ${(req.query.source === 'all') ? `
<span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint);margin-right:4px">Sort</span>
${[
['newest', 'Newest'],
+ ['color', 'Color'],
+ ['style', 'Style'],
['title', 'Title A→Z'],
- ['category', 'Category'],
- ['color-wheel', 'Color Wheel'],
['random', 'Random'],
].map(([k, label]) => {
const on = sort === k;
@@ -7439,16 +7481,20 @@ ${(req.query.source === 'all') ? `
<optgroup label="Alphabetical">
<option value="title" ${sort==='title'?'selected':''}>Title A → Z</option>
<option value="title-desc" ${sort==='title-desc'?'selected':''}>Title Z → A</option>
- <option value="category" ${sort==='category'?'selected':''}>Category A → Z</option>
- <option value="category-desc"${sort==='category-desc'?'selected':''}>Category Z → A</option>
</optgroup>
<optgroup label="Color">
+ <option value="color" ${sort==='color'?'selected':''}>Color (group similar hues)</option>
<option value="light-dark" ${sort==='light-dark'?'selected':''}>Light → Dark</option>
<option value="dark-light" ${sort==='dark-light'?'selected':''}>Dark → Light</option>
<option value="color-wheel" ${sort==='color-wheel'?'selected':''}>Color Wheel (warm → cool)</option>
<option value="vivid" ${sort==='vivid'?'selected':''}>Most Vivid</option>
<option value="muted" ${sort==='muted'?'selected':''}>Most Muted</option>
</optgroup>
+ <optgroup label="Style">
+ <option value="style" ${sort==='style'?'selected':''}>Style (grouped)</option>
+ <option value="category" ${sort==='category'?'selected':''}>Category A → Z</option>
+ <option value="category-desc"${sort==='category-desc'?'selected':''}>Category Z → A</option>
+ </optgroup>
<optgroup label="Other">
<option value="has-room" ${sort==='has-room'?'selected':''}>In-Room previews first</option>
<option value="random" ${sort==='random'?'selected':''}>Random shuffle</option>
← 703d174 Guard /api/design and /api/design/ (missing id) → redirect h
·
back to Wallco Ai
·
batch-regen driver: 4-gate (seam+color+concept+settlement-OK 55c9073 →