[object Object]

← back to 4square Admin

feat: 12-chip color filter strip (right-aligned in source bar)

198d58c21d17534b9d863e0f1a878428821b4ad8 · 2026-05-14 09:40:58 -0700 · Steve

- 12 hue buckets: black, white, grey, brown, beige, yellow, orange, red, pink, purple, blue, green
- hexToBucket() js HSL conversion — handles low-saturation rows (b/w/grey) before hue, narrow brown/beige bands at low L / mid-low S
- rowBucket() per kind: wallco→dominant_hex, patternbank→colors_hex[0], shopify/vintage→tag-match 'color-X'
- click chip to filter; click × or active chip to clear; persists in component state for current session
- grid + stat counts update reactively; load-more retains filter context note
- gracefully empty-states when no loaded row matches the picked hue with hint to load more

Files touched

Diff

commit 198d58c21d17534b9d863e0f1a878428821b4ad8
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:40:58 2026 -0700

    feat: 12-chip color filter strip (right-aligned in source bar)
    
    - 12 hue buckets: black, white, grey, brown, beige, yellow, orange, red, pink, purple, blue, green
    - hexToBucket() js HSL conversion — handles low-saturation rows (b/w/grey) before hue, narrow brown/beige bands at low L / mid-low S
    - rowBucket() per kind: wallco→dominant_hex, patternbank→colors_hex[0], shopify/vintage→tag-match 'color-X'
    - click chip to filter; click × or active chip to clear; persists in component state for current session
    - grid + stat counts update reactively; load-more retains filter context note
    - gracefully empty-states when no loaded row matches the picked hue with hint to load more
---
 index.html | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 102 insertions(+), 9 deletions(-)

diff --git a/index.html b/index.html
index 0a6e5c1..7d2703c 100644
--- a/index.html
+++ b/index.html
@@ -76,11 +76,20 @@
 
   .source-bar {
     padding:10px 22px; border-bottom:1px solid var(--line); background:#0a0908;
-    font-size:11px; color:#888; display:flex; align-items:center; gap:12px;
+    font-size:11px; color:#888; display:flex; align-items:center; gap:12px; flex-wrap:wrap;
   }
   .source-bar .lic { color:#d2b15c; font-style:italic }
   .source-bar .pill { padding:2px 9px; border-radius:11px; border:1px solid var(--line); color:#bba; font-size:10.5px }
   .source-bar .pill.ro { color:#e08070; border-color:#4a2a26 }
+  .source-bar .colorbar { margin-left:auto; display:flex; gap:5px; align-items:center }
+  .source-bar .colorbar .clbl { color:#888; font:500 10.5px/1 -apple-system; text-transform:uppercase; letter-spacing:.1em; margin-right:4px }
+  .color-chip {
+    width:20px; height:20px; border-radius:50%; cursor:pointer; border:2px solid transparent;
+    transition:border-color .12s, transform .12s;
+  }
+  .color-chip:hover { border-color:#888; transform:scale(1.1) }
+  .color-chip.active { border-color:var(--gold); transform:scale(1.12); box-shadow:0 0 8px rgba(210,177,92,.5) }
+  .color-chip.clear { background:transparent !important; border:1px dashed #555; font:600 11px/1 -apple-system; color:#888; display:flex; align-items:center; justify-content:center }
   .grid-wrap { overflow-y:auto; padding:18px 22px 80px; flex:1 }
   .grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap:10px }
   .card {
@@ -226,6 +235,66 @@ let SOURCES = [];
 let CURRENT = { id: localStorage.getItem(LS.src) || 'wallco:fliepaper-bugs', kind:null, rows:[], total:0, spec:null, readonly:false, has_more:false, offset:0 };
 let selected = new Set();
 let searchDebounce = null;
+let colorFilter = null;  // null | 'red' | 'blue' | ...
+
+const COLOR_BUCKETS = [
+  ['black',  '#0a0908'], ['white',  '#f3f0ea'], ['grey',   '#7a7672'],
+  ['brown',  '#6b4a2a'], ['beige',  '#d8c6a0'], ['yellow', '#e6c948'],
+  ['orange', '#d97a2a'], ['red',    '#c43c3c'], ['pink',   '#e0a5c0'],
+  ['purple', '#7a5eb0'], ['blue',   '#4f7bc4'], ['green',  '#5a8e5a'],
+];
+
+function hexToBucket(hex) {
+  if (!hex) return null;
+  const m = /#?([0-9a-f]{6})/i.exec(hex);
+  if (!m) return null;
+  const r = parseInt(m[1].slice(0,2),16)/255,
+        g = parseInt(m[1].slice(2,4),16)/255,
+        b = parseInt(m[1].slice(4,6),16)/255;
+  const max = Math.max(r,g,b), min = Math.min(r,g,b), L = (max+min)/2;
+  const d = max - min;
+  if (d < 0.05) {
+    if (L < 0.18) return 'black';
+    if (L > 0.85) return 'white';
+    return 'grey';
+  }
+  const S = L > 0.5 ? d/(2-max-min) : d/(max+min);
+  let H;
+  if (max === r) H = ((g-b)/d + (g<b?6:0))*60;
+  else if (max === g) H = ((b-r)/d + 2)*60;
+  else H = ((r-g)/d + 4)*60;
+  if (S < 0.18) return L < 0.2 ? 'black' : L > 0.85 ? 'white' : 'grey';
+  // brown/beige: low-saturation oranges in narrow band
+  if (H >= 15 && H < 50 && L < 0.4) return 'brown';
+  if (H >= 15 && H < 60 && S < 0.45 && L > 0.55) return 'beige';
+  if (H < 15 || H >= 345) return 'red';
+  if (H < 40)  return 'orange';
+  if (H < 65)  return 'yellow';
+  if (H < 170) return 'green';
+  if (H < 250) return 'blue';
+  if (H < 290) return 'purple';
+  return 'pink';
+}
+
+function rowBucket(r) {
+  if (CURRENT.kind === 'wallco') return hexToBucket(r.dominant_hex);
+  if (CURRENT.kind === 'patternbank' && Array.isArray(r.colors_hex)) {
+    // pick the most-saturated swatch
+    let best = null, bestS = -1;
+    for (const h of r.colors_hex) {
+      const b = hexToBucket(h);
+      if (b) { return b; }  // first non-null is fine; could be improved
+    }
+    return null;
+  }
+  // shopify/vintage: parse tags for "color-X" pattern if present
+  const tags = Array.isArray(r.tags) ? r.tags : (typeof r.tags === 'string' ? r.tags.split(',') : []);
+  for (const t of tags) {
+    const m = /color[-_:](\w+)/i.exec(String(t).trim());
+    if (m && COLOR_BUCKETS.find(b => b[0] === m[1].toLowerCase())) return m[1].toLowerCase();
+  }
+  return null;
+}
 
 const SORT_WALLCO = [
   ['newest','Newest'],['oldest','Oldest'],
@@ -353,8 +422,11 @@ function populateRecolor(spec) {
 }
 
 function renderControls() {
-  const showing = CURRENT.rows.length;
-  $('#stat').textContent = `${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()}`;
+  const visibleRows = visibleRowSet();
+  const showing = visibleRows.length;
+  $('#stat').textContent = colorFilter
+    ? `${showing.toLocaleString()} matching "${colorFilter}" of ${CURRENT.rows.length.toLocaleString()} loaded`
+    : `${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()}`;
   document.querySelectorAll('#bulk-actions button').forEach(b => b.disabled = CURRENT.readonly || selected.size === 0);
   $('#bulk-actions').style.opacity = CURRENT.readonly ? .35 : 1;
   $('#bulk-actions').title = CURRENT.readonly ? 'Read-only source' : '';
@@ -364,7 +436,24 @@ function renderControls() {
   const bar = $('#src-bar');
   const lic = CURRENT.license_note ? `<span class="lic">${esc(CURRENT.license_note)}</span>` : '';
   const ro  = CURRENT.readonly ? '<span class="pill ro">read-only</span>' : '<span class="pill" style="color:#7dd87d;border-color:#2a4a2a">editable</span>';
-  bar.innerHTML = `<strong style="color:var(--gold);font-weight:500">${esc(CURRENT.id)}</strong> ${ro} · ${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()} ${lic}`;
+  const chips = `<span class="colorbar">
+    <span class="clbl">Hue</span>
+    ${COLOR_BUCKETS.map(([name, hex]) =>
+      `<span class="color-chip ${colorFilter===name?'active':''}" style="background:${hex}" title="${name}" onclick="toggleColor('${name}')"></span>`).join('')}
+    <span class="color-chip clear ${colorFilter?'':'active'}" title="clear" onclick="toggleColor(null)">×</span>
+  </span>`;
+  bar.innerHTML = `<strong style="color:var(--gold);font-weight:500">${esc(CURRENT.id)}</strong> ${ro} · ${showing.toLocaleString()} of ${CURRENT.total.toLocaleString()} ${lic}${chips}`;
+}
+
+function visibleRowSet() {
+  if (!colorFilter) return CURRENT.rows;
+  return CURRENT.rows.filter(r => rowBucket(r) === colorFilter);
+}
+
+function toggleColor(c) {
+  colorFilter = (c === colorFilter) ? null : c;
+  renderGrid();   // re-render with filter applied
+  renderControls();
 }
 
 async function exportCSV() {
@@ -386,16 +475,20 @@ function renderGrid() {
   const wrap = $('#grid-wrap');
   if (CURRENT.rows.length === 0) { wrap.innerHTML = '<div class="empty-state">no products in this source</div>'; return; }
 
-  // Matrix layout for fliepaper-bugs (no pagination needed — small fixed set)
   if (CURRENT.spec && CURRENT.spec.patterns && CURRENT.spec.palette) {
     return renderMatrix(wrap);
   }
-  // Flat grid for everything else
-  const cardsHtml = CURRENT.rows.map(r => cardHtml(r)).join('');
+  const vis = visibleRowSet();
+  if (vis.length === 0) {
+    wrap.innerHTML = `<div class="empty-state">No "${esc(colorFilter)}" cards in loaded set. Hint: load more first, or pick a different hue.</div>`;
+    return;
+  }
+  const cardsHtml = vis.map(r => cardHtml(r)).join('');
   const remaining = CURRENT.total - CURRENT.rows.length;
+  const filtNote = colorFilter ? ` <span style="color:var(--gold);font-style:italic">(hue: ${esc(colorFilter)})</span>` : '';
   const more = CURRENT.has_more
-    ? `<div style="text-align:center; padding:32px 0 16px"><button class="small" id="load-more" onclick="loadMore()" style="padding:10px 24px; font-size:13px">Load more (${remaining.toLocaleString()} remaining)</button></div>`
-    : (CURRENT.total > CURRENT.rows.length ? '' : `<div style="text-align:center; padding:24px 0 16px; color:#555; font-size:11px; font-style:italic">end · ${CURRENT.rows.length.toLocaleString()} of ${CURRENT.total.toLocaleString()}</div>`);
+    ? `<div style="text-align:center; padding:32px 0 16px"><button class="small" id="load-more" onclick="loadMore()" style="padding:10px 24px; font-size:13px">Load more (${remaining.toLocaleString()} remaining)${filtNote}</button></div>`
+    : (CURRENT.total > CURRENT.rows.length ? '' : `<div style="text-align:center; padding:24px 0 16px; color:#555; font-size:11px; font-style:italic">end · ${CURRENT.rows.length.toLocaleString()} of ${CURRENT.total.toLocaleString()}${filtNote}</div>`);
   wrap.innerHTML = `<div class="grid">${cardsHtml}</div>${more}`;
   attachCardHandlers();
 }

← 0598058 feat: source-context header bar + CSV export of selected row  ·  back to 4square Admin  ·  feat: bulk tag editor for wallco rows b358ef8 →