[object Object]

← back to Designer Wallcoverings

auto-save: 2026-07-31T09:26:15 (3 files) — DW-Agents/vendor-command-center/server.js scripts/color-index-feature/color-index-service.js shopify/theme-LIVE-pull-20260728-colorbar/snippets/color-palette.liquid

11adafb929d0dd9d7f100bdd10f8d4924a91d5a3 · 2026-07-31 09:26:30 -0700 · Steve Abrams

Files touched

Diff

commit 11adafb929d0dd9d7f100bdd10f8d4924a91d5a3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 09:26:30 2026 -0700

    auto-save: 2026-07-31T09:26:15 (3 files) — DW-Agents/vendor-command-center/server.js scripts/color-index-feature/color-index-service.js shopify/theme-LIVE-pull-20260728-colorbar/snippets/color-palette.liquid
---
 DW-Agents/vendor-command-center/server.js          | 20 ++++-
 scripts/color-index-feature/color-index-service.js | 28 ++++++-
 .../snippets/color-palette.liquid                  | 85 ++++++++++++++++++++--
 3 files changed, 118 insertions(+), 15 deletions(-)

diff --git a/DW-Agents/vendor-command-center/server.js b/DW-Agents/vendor-command-center/server.js
index 0936de03..09f16455 100644
--- a/DW-Agents/vendor-command-center/server.js
+++ b/DW-Agents/vendor-command-center/server.js
@@ -2203,7 +2203,9 @@ async function syncVendorCounts() {
 
     for (const v of allVendors) {
       const code = v.vendor_code;
-      const catTable = v.catalog_table;
+      // catalog_table is freeform admin-editable text that gets interpolated into SQL —
+      // strip to identifier chars (same guard as the SKU-gen path at ~848)
+      const catTable = String(v.catalog_table || '').replace(/[^a-z0-9_]/gi, '') || null;
       let catalogCount = 0, catWithCost = 0, catWithImages = 0, catOnShopify = 0;
       let catWithWidth = 0, catWithRepeat = 0;
       let catWithAboutVendor = 0, catWithAllImages = 0, catWithSpecSheet = 0, catWithBodyHtml = 0;
@@ -2225,8 +2227,10 @@ async function syncVendorCounts() {
             const discCol = await resolveSharedDiscriminator(catTable);
             if (discCol) {
               const discVal = String((discCol === 'vendor_code' ? code : v.vendor_name) || '').replace(/'/g, "''");
-              vendorFilter = ` WHERE ${discCol} = '${discVal}'`;
-              vendorAnd = ` AND ${discCol} = '${discVal}'`;
+              // Case-fold both sides: catalog rows carry mixed-case codes (dwdp vs DWDP,
+              // grd vs GRD) and exact = silently dropped those rows to 0
+              vendorFilter = ` WHERE LOWER(${discCol}) = LOWER('${discVal}')`;
+              vendorAnd = ` AND LOWER(${discCol}) = LOWER('${discVal}')`;
             }
           }
 
@@ -2310,7 +2314,10 @@ async function syncVendorCounts() {
       const specTotal = specResult.total;
 
       // --- SECONDARY: Shopify products (what's published) ---
-      const patterns = VENDOR_NAME_MAP[code];
+      // Unmapped vendors fall back to their registry name as the Shopify vendor pattern;
+      // without this, any vendor absent from VENDOR_NAME_MAP reads 0 on the dashboard
+      // (carnegie 558 / newwall 433 live products showed as dead vendors)
+      const patterns = VENDOR_NAME_MAP[code] || (v.vendor_name ? [v.vendor_name + '%'] : null);
       if (patterns && patterns.length > 0) {
         try {
           const conditions = patterns.map((_, i) => `vendor ILIKE $${i + 1}`).join(' OR ');
@@ -2894,6 +2901,11 @@ app.get('/catalog-browse', (req, res) => {
   res.sendFile(__dirname + '/public/catalog-browse.html');
 });
 
+// Serve width gaps page (was orphaned — file existed with no route, TK-10084 tour)
+app.get('/width-gaps', (req, res) => {
+  res.sendFile(__dirname + '/public/width-gaps.html');
+});
+
 // Run sync on startup (after 5s delay to let DB connect) and every 5 minutes
 setTimeout(() => syncVendorCounts(), 5000);
 setInterval(() => syncVendorCounts(), 300000);
diff --git a/scripts/color-index-feature/color-index-service.js b/scripts/color-index-feature/color-index-service.js
index 55b13025..2df34599 100644
--- a/scripts/color-index-feature/color-index-service.js
+++ b/scripts/color-index-feature/color-index-service.js
@@ -62,12 +62,27 @@ function hexToLab(hex){
 // ── the catalog-wide color-tolerance query ───────────────────────────────────
 // Roll variant is resolved storefront-side (product handle → PDP), so we return handle
 // (never the $4.25 Sample). image/title/vendor come straight from the color index.
-async function colorIndex(hex, tol, limit, style){
+// Coordinate filters (Steve TK-10085): TYPE + USE buckets built from product_type.
+// The fragments are FIXED strings selected by a whitelist (never user-interpolated),
+// so there is no injection surface. USE 'residential' = the non-commercial complement.
+function ciTypeUseClause(type, use){
+  let c = '';
+  if(type==='wallcovering') c += ` AND (coalesce(product_type,'') ~* 'wallcover|wallpaper|mural')`;
+  else if(type==='fabric')  c += ` AND (coalesce(product_type,'') ~* 'fabric|upholst|drapery|multipurpose|pillow')`;
+  else if(type==='other')   c += ` AND (coalesce(product_type,'') !~* 'wallcover|wallpaper|mural|fabric|upholst|drapery|multipurpose|pillow')`;
+  if(use==='commercial')    c += ` AND (coalesce(product_type,'') ~* 'commercial|contract')`;
+  else if(use==='residential') c += ` AND (coalesce(product_type,'') !~* 'commercial|contract')`;
+  return c;
+}
+async function colorIndex(hex, tol, limit, style, type, use){
   const lab = hexToLab(hex);
   if(!lab) return { ok:false, err:'bad hex', results:[] };
   const params = [lab.L, lab.a, lab.b, tol, limit];
   let styleClause = '';
   if(style){ params.push(style.toLowerCase()); styleClause = ` AND (lower(coalesce(product_type,'')) LIKE '%'||$6||'%')`; }
+  const TYPE = ['wallcovering','fabric','other'].indexOf(type)>=0 ? type : '';
+  const USE  = ['commercial','residential'].indexOf(use)>=0 ? use : '';
+  const filterClause = ciTypeUseClause(TYPE, USE);
   const sql = `
     SELECT handle, title, vendor, hex, image_url,
            sqrt(power(lab_l-$1,2)+power(lab_a-$2,2)+power(lab_b-$3,2)) AS de
@@ -77,6 +92,7 @@ async function colorIndex(hex, tol, limit, style){
        AND image_url IS NOT NULL AND image_url<>''
        AND sqrt(power(lab_l-$1,2)+power(lab_a-$2,2)+power(lab_b-$3,2)) <= $4
        ${styleClause}
+       ${filterClause}
      ORDER BY de ASC
      LIMIT $5`;
   const { rows } = await pool.query(sql, params);
@@ -95,15 +111,17 @@ const server = http.createServer((req,res)=>{
   const u = new URL(req.url, 'http://127.0.0.1');
   if(u.pathname==='/healthz') return send(res,200,{ok:true, tol:DELTA_E_10PCT, max:MAX_RESULTS});
   if(u.pathname==='/color-index'){
-    const run = (hex, tol, limit, style)=> colorIndex(hex, tol, limit, style)
+    const run = (hex, tol, limit, style, type, use)=> colorIndex(hex, tol, limit, style, type, use)
       .then(r=>send(res,200,r)).catch(e=>send(res,500,{ok:false,err:String(e&&e.message||e),results:[]}));
     if(req.method==='GET'){
       const hex=(u.searchParams.get('hex')||'').slice(0,16);
       const tol=Math.max(1,Math.min(parseFloat(u.searchParams.get('tol'))||DELTA_E_10PCT,40));
       const limit=Math.max(1,Math.min(parseInt(u.searchParams.get('k'),10)||MAX_RESULTS,MAX_RESULTS));
       const style=(u.searchParams.get('style')||'').slice(0,64);
+      const type=(u.searchParams.get('type')||'').slice(0,16).toLowerCase();
+      const use=(u.searchParams.get('use')||'').slice(0,16).toLowerCase();
       if(!hex) return send(res,400,{ok:false,err:'hex required',results:[]});
-      return run(hex,tol,limit,style);
+      return run(hex,tol,limit,style,type,use);
     }
     if(req.method==='POST'){
       let body=''; req.on('data',c=>{ body+=c; if(body.length>4096) req.destroy(); });
@@ -113,8 +131,10 @@ const server = http.createServer((req,res)=>{
         const tol=Math.max(1,Math.min(parseFloat(p.tol)||DELTA_E_10PCT,40));
         const limit=Math.max(1,Math.min(parseInt(p.k,10)||MAX_RESULTS,MAX_RESULTS));
         const style=(typeof p.style==='string'?p.style:'').slice(0,64);
+        const type=(typeof p.type==='string'?p.type:'').slice(0,16).toLowerCase();
+        const use=(typeof p.use==='string'?p.use:'').slice(0,16).toLowerCase();
         if(!hex) return send(res,400,{ok:false,err:'hex required',results:[]});
-        run(hex,tol,limit,style);
+        run(hex,tol,limit,style,type,use);
       });
       return;
     }
diff --git a/shopify/theme-LIVE-pull-20260728-colorbar/snippets/color-palette.liquid b/shopify/theme-LIVE-pull-20260728-colorbar/snippets/color-palette.liquid
index d6f2f1e0..8660eccf 100644
--- a/shopify/theme-LIVE-pull-20260728-colorbar/snippets/color-palette.liquid
+++ b/shopify/theme-LIVE-pull-20260728-colorbar/snippets/color-palette.liquid
@@ -91,6 +91,24 @@
       <span class="dw-color-index__label" data-dw-ci-label aria-live="polite"></span>
       <button type="button" class="dw-color-index__close" data-dw-ci-close aria-label="Close color index">&times;</button>
     </div>
+    {%- comment -%} Coordinate filters (Steve TK-10085): narrow the color-matched
+      coordinates by product TYPE and USE. Single-select chips per group; the choice
+      re-queries the color-index endpoint and persists to localStorage. {%- endcomment -%}
+    <div class="dw-color-index__filters" data-dw-ci-filters>
+      <div class="dw-ci-fgroup" role="radiogroup" aria-label="Filter coordinates by product type">
+        <span class="dw-ci-flabel">Type</span>
+        <button type="button" class="dw-ci-chip is-active" data-dw-ci-type="" role="radio" aria-checked="true">All</button>
+        <button type="button" class="dw-ci-chip" data-dw-ci-type="wallcovering" role="radio" aria-checked="false">Wallcovering</button>
+        <button type="button" class="dw-ci-chip" data-dw-ci-type="fabric" role="radio" aria-checked="false">Fabric</button>
+        <button type="button" class="dw-ci-chip" data-dw-ci-type="other" role="radio" aria-checked="false">Other</button>
+      </div>
+      <div class="dw-ci-fgroup" role="radiogroup" aria-label="Filter coordinates by use">
+        <span class="dw-ci-flabel">Use</span>
+        <button type="button" class="dw-ci-chip is-active" data-dw-ci-use="" role="radio" aria-checked="true">All</button>
+        <button type="button" class="dw-ci-chip" data-dw-ci-use="commercial" role="radio" aria-checked="false">Commercial</button>
+        <button type="button" class="dw-ci-chip" data-dw-ci-use="residential" role="radio" aria-checked="false" title="All non-contract lines">Residential</button>
+      </div>
+    </div>
     <div class="dw-color-index__grid" data-dw-ci-grid aria-live="polite"></div>
     {%- comment -%} Steve 2026-07-28: reveal +2 more rows of coordinates per click (refresh). {%- endcomment -%}
     <button type="button" class="dw-color-index__more" data-dw-ci-more hidden aria-label="Show two more rows of coordinating wallcoverings">Show 2 more rows</button>
@@ -145,6 +163,16 @@
   .dw-color-index__close{flex:0 0 auto;background:none;border:1px solid #e0ded9;border-radius:50%;width:26px;height:26px;line-height:1;font-size:15px;color:#8a8577;cursor:pointer;padding:0;transition:color .12s ease,border-color .12s ease;}
   .dw-color-index__close:hover{color:#3D4246;border-color:#b8b3a8;}
   .dw-color-index__close:focus-visible{outline:2px solid #b8b3a8;outline-offset:2px;}
+  /* Coordinate filter chips (Steve TK-10085) — two single-select groups (Type / Use). */
+  .dw-color-index__filters{display:flex;flex-wrap:wrap;align-items:center;gap:8px 20px;margin:0 0 16px;}
+  .dw-ci-fgroup{display:flex;flex-wrap:wrap;align-items:center;gap:6px;}
+  .dw-ci-flabel{font-family:Lora,serif;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:#9a958a;margin-right:2px;}
+  .dw-ci-chip{background:#fff;border:1px solid #d8d4cc;border-radius:999px;padding:5px 13px;font-family:Lora,serif;font-size:12px;letter-spacing:.02em;color:#5c5f63;cursor:pointer;transition:border-color .12s ease,color .12s ease,background .12s ease;}
+  .dw-ci-chip:hover{border-color:#b8b3a8;color:#3D4246;}
+  .dw-ci-chip:focus-visible{outline:2px solid #b8b3a8;outline-offset:2px;}
+  .dw-ci-chip.is-active{background:#3D4246;border-color:#3D4246;color:#fff;}
+  .dw-ci-usetag{font-size:12px;color:#9a958a;font-style:italic;margin-left:4px;}
+  @media(max-width:600px){.dw-color-index__filters{gap:8px 12px;}.dw-ci-flabel{width:100%;margin-bottom:-2px;}}
   .dw-color-index__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));grid-template-rows:repeat(2,auto);grid-auto-rows:0;gap:16px;overflow:hidden;}
   @media(min-width:1000px){.dw-color-index__grid{grid-template-columns:repeat(8,1fr);}}
   /* "Show 2 more rows" — reveals the next 2 rows of coordinates each click (Steve 2026-07-28) */
@@ -272,15 +300,35 @@
     var CI = root.querySelector('[data-dw-color-index]');
     var CI_ENDPOINT = (CI && CI.dataset.endpoint) || 'https://photo.designerwallcoverings.com/apps/color-index';
     var CI_K = 48;                 // fetch up to ~6 rows; show 2, reveal +2 per "Show 2 more rows" click (Steve 2026-07-28)
-    var _ciCache = {};             // hex -> Promise<{results,total,tolerance_pct}>
+    var _ciCache = {};             // key(hex|type|use) -> Promise<{results,total,tolerance_pct}>
+    /* Coordinate filters (Steve TK-10085): TYPE (''|wallcovering|fabric|other) and
+       USE (''|commercial|residential); '' = All (the default). Both are sent to the
+       color-index endpoint (SERVER-side filter, so the 48-row fetch always comes back
+       already matching — client-side filtering would empty the visible rows) and
+       persist to localStorage so a shopper's choice survives reloads. */
+    var _ciType = '', _ciUse = '', _ciHex = '', _ciName = '';
+    try{ _ciType = localStorage.getItem('dw_ci_type') || ''; _ciUse = localStorage.getItem('dw_ci_use') || ''; }catch(e){}
+    if(['','wallcovering','fabric','other'].indexOf(_ciType)<0) _ciType='';
+    if(['','commercial','residential'].indexOf(_ciUse)<0) _ciUse='';
     function fetchColorIndex(hex){
-      if(_ciCache[hex]) return _ciCache[hex];
-      _ciCache[hex]=fetch(CI_ENDPOINT,{
+      var key = hex+'|'+_ciType+'|'+_ciUse;
+      if(_ciCache[key]) return _ciCache[key];
+      var payload = { hex:hex, k:CI_K };
+      if(_ciType) payload.type=_ciType;
+      if(_ciUse) payload.use=_ciUse;
+      _ciCache[key]=fetch(CI_ENDPOINT,{
         method:'POST', mode:'cors', headers:{'Content-Type':'application/json'},
-        body:JSON.stringify({hex:hex, k:CI_K})
+        body:JSON.stringify(payload)
       }).then(function(r){ return r.ok?r.json():{results:[]}; })
         .catch(function(){ return {results:[], _err:true}; });
-      return _ciCache[hex];
+      return _ciCache[key];
+    }
+    /* Drawer heading noun follows the TYPE filter; USE appends a small italic tag. */
+    function ciNoun(){ return _ciType==='wallcovering' ? 'Wallcoverings' : _ciType==='fabric' ? 'Fabrics' : 'Coordinates'; }
+    function ciLabelHTML(name){
+      var fam = familyFromHex(_ciHex)||'';
+      var use = _ciUse==='commercial' ? ' · Commercial' : _ciUse==='residential' ? ' · Residential' : '';
+      return ciNoun()+' in this color — <b>'+(name||fam||_ciHex)+'</b>'+(use?'<span class="dw-ci-usetag">'+use+'</span>':'');
     }
     function ciEl(sel){ return CI ? CI.querySelector(sel) : null; }
     function closeIndex(){ if(CI){ CI.classList.remove('is-open'); CI.hidden=true; } }
@@ -322,14 +370,37 @@
       if(_cx) _cx.addEventListener('click', closeIndex);
       // Esc closes the drawer when it (or something inside it) is focused (a11y).
       CI.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ closeIndex(); } });
+      /* Coordinate filter chips (Steve TK-10085): single-select per group. Changing a
+         filter persists the choice and, if the drawer is open, re-queries for the color
+         currently shown (fetchColorIndex is cache-keyed on hex|type|use, so switching
+         back is instant). Chips reflect the persisted choice on load. */
+      function ciSyncChips(){
+        var t=CI.querySelectorAll('[data-dw-ci-type]'), u=CI.querySelectorAll('[data-dw-ci-use]'), i, on;
+        for(i=0;i<t.length;i++){ on=(t[i].getAttribute('data-dw-ci-type')||'')===_ciType; t[i].classList.toggle('is-active',on); t[i].setAttribute('aria-checked',on?'true':'false'); }
+        for(i=0;i<u.length;i++){ on=(u[i].getAttribute('data-dw-ci-use')||'')===_ciUse; u[i].classList.toggle('is-active',on); u[i].setAttribute('aria-checked',on?'true':'false'); }
+      }
+      function ciOnFilter(kind,val){
+        if(kind==='type'){ if(val===_ciType) return; _ciType=val; try{localStorage.setItem('dw_ci_type',val);}catch(e){} }
+        else { if(val===_ciUse) return; _ciUse=val; try{localStorage.setItem('dw_ci_use',val);}catch(e){} }
+        ciSyncChips();
+        if(_ciHex) openIndex(_ciHex,_ciName);   // re-query the open color under the new filters
+      }
+      var _chips=CI.querySelectorAll('.dw-ci-chip');
+      for(var _c=0;_c<_chips.length;_c++){ (function(chip){
+        chip.addEventListener('click', function(){
+          if(chip.hasAttribute('data-dw-ci-type')) ciOnFilter('type', chip.getAttribute('data-dw-ci-type')||'');
+          else ciOnFilter('use', chip.getAttribute('data-dw-ci-use')||'');
+        });
+      })(_chips[_c]); }
+      ciSyncChips();   // reflect any persisted choice on load
     }
     /* Open the in-page index for a clicked hex. */
     function openIndex(hex, name){
       if(!CI) return;
+      _ciHex=hex; _ciName=(name||'');
       var sw=ciEl('[data-dw-ci-swatch]'), lbl=ciEl('[data-dw-ci-label]'), grid=ciEl('[data-dw-ci-grid]');
-      var fam=familyFromHex(hex)||'';
       if(sw) sw.style.background=hex;
-      if(lbl) lbl.innerHTML='Wallcoverings in this color — <b>'+(name||fam||hex)+'</b>';
+      if(lbl) lbl.innerHTML=ciLabelHTML(_ciName);
       if(grid) grid.innerHTML='<div class="dw-color-index__loading">Finding matches…</div>';
       CI.hidden=false;
       // retrigger the soft reveal even on a re-click (refine 2026-07-09)

← 03599d65 auto-save: 2026-07-31T08:55:43 (3 files) — DW-Agents/.gitign  ·  back to Designer Wallcoverings  ·  TK-10046: build-residual-plan --inputs-dir fallback to durab 1fa0b3ac →