[object Object]

← back to All Designerwallcoverings

viewer control standard: add Fields on/off menu to index.html (+Image toggle, mfr sorters) and list-toggle+Fields menu to vendors.html

7f2ae6c7e52339562bca706c1cc0e23dd1b63532 · 2026-08-11 14:21:21 -0700 · Steve

Files touched

Diff

commit 7f2ae6c7e52339562bca706c1cc0e23dd1b63532
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 11 14:21:21 2026 -0700

    viewer control standard: add Fields on/off menu to index.html (+Image toggle, mfr sorters) and list-toggle+Fields menu to vendors.html
---
 public/index.html   |  62 +++++++++++++++++++++-----
 public/vendors.html | 124 +++++++++++++++++++++++++++++++++++++++++++++++-----
 server.js           |   3 ++
 3 files changed, 165 insertions(+), 24 deletions(-)

diff --git a/public/index.html b/public/index.html
index 13f0c7c..95d9c36 100644
--- a/public/index.html
+++ b/public/index.html
@@ -230,8 +230,23 @@
   td.acts-td .intpanel { width:auto; margin-top:5px; }
   /* CARD FIELDS visibility toggles — all off = pure image-only texture wall */
   .grid.h-sku .sku, .grid.h-ttl .ttl, .grid.h-vt .vt, .grid.h-mfrn .mfrn, .grid.h-mfrname .mfrname, .grid.h-fac .fac, .grid.h-price .price, .grid.h-when .when, .grid.h-act .acts { display:none; }
+  .grid.h-img .thumb { display:none; }
   .grid.h-sku.h-ttl.h-vt.h-mfrn.h-mfrname.h-fac.h-price.h-when .meta { display:none; }
 
+  /* Fields ▾ control-bar menu — a second surface onto the SAME hiddenFields state as the
+     rail "Card fields" section (one source of truth; both stay in sync). */
+  .fieldmenu { position:relative; }
+  .fbtn { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:8px;
+    padding:7px 10px; font-size:13px; cursor:pointer; font-family:inherit; }
+  .fbtn:hover { border-color:var(--accent); color:var(--txt); }
+  .fpop { position:absolute; top:calc(100% + 6px); right:0; background:#0f0f13; border:1px solid var(--line);
+    border-radius:8px; box-shadow:0 14px 34px rgba(0,0,0,.5); padding:8px 4px; z-index:40; min-width:210px; }
+  .fpop[hidden] { display:none; }
+  .fpop label { display:flex; align-items:center; gap:9px; padding:6px 12px; font-size:12.5px; color:var(--txt);
+    cursor:pointer; white-space:nowrap; }
+  .fpop label:hover { background:var(--card); }
+  .fpop input { accent-color: var(--accent); }
+
   /* ── list view ── */
   .listwrap { padding:0 16px 30px; overflow-x:auto; }
   table { width:100%; border-collapse:collapse; font-size:12.5px; }
@@ -273,6 +288,8 @@
       <option value="type">Type</option>
       <option value="series">Book / Series</option>
       <option value="sku">SKU</option>
+      <option value="mfr">Mfr #</option>
+      <option value="mfrname">Mfr Name</option>
       <option value="pattern">Pattern</option>
       <option value="color">Color (family)</option>
       <option value="style">Style</option>
@@ -284,6 +301,7 @@
   </span>
   <span class="viewseg"><button id="vGrid">Grid</button><button id="vList">List</button></span>
   <span class="bar" id="densWrap">Density <input type="range" id="dens" min="2" max="20" value="6"> <span id="densN"></span></span>
+  <span class="fieldmenu"><button id="fieldsBtn" class="fbtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button><div class="fpop" id="fpop" hidden role="menu"></div></span>
   <span class="bar" id="fmBar" title="FileMaker Pro source-of-truth">
     <span id="fmStat" class="fmstat">FM …</span>
     <button id="fmMismatch" class="fmtoggle" type="button" title="Show only rows where FileMaker and Shopify mfr numbers disagree">⚠ Mismatches</button>
@@ -492,8 +510,16 @@ document.querySelectorAll('details.sec').forEach((d) => {
 });
 
 /* CARD FIELDS: toggle which card elements render — all off = image-only texture wall */
-const FIELD_DEFS = [['sku', 'SKU'], ['ttl', 'Title'], ['vt', 'Vendor · Type'], ['mfrn', 'Mfr #'], ['mfrname', 'Mfr Name'], ['fac', 'Color / Style / Material'], ['price', 'Price'], ['when', 'Active / Staged date'], ['act', 'Website / Internal buttons']];
+const FIELD_DEFS = [['img', 'Image'], ['sku', 'SKU'], ['ttl', 'Title'], ['vt', 'Vendor · Type'], ['mfrn', 'Mfr #'], ['mfrname', 'Mfr Name'], ['fac', 'Color / Style / Material'], ['price', 'Price'], ['when', 'Active / Staged date'], ['act', 'Website / Internal buttons']];
 let hiddenFields = loadSet('all_hidden_fields');
+// One field-toggle STATE (hiddenFields) drives TWO surfaces: the left-rail "Card fields"
+// section AND the control-bar "Fields ▾" popover. toggleField() flips the shared set and
+// re-renders both so they never drift.
+function toggleField(k) {
+  hiddenFields.has(k) ? hiddenFields.delete(k) : hiddenFields.add(k);
+  localStorage.setItem('all_hidden_fields', JSON.stringify([...hiddenFields]));
+  applyFieldClasses(); drawFieldToggles(); drawFieldMenu();
+}
 function drawFieldToggles() {
   const row = document.getElementById('fieldsRow'); row.innerHTML = '';
   FIELD_DEFS.forEach(([k, label]) => {
@@ -501,20 +527,32 @@ function drawFieldToggles() {
     const el = document.createElement('div');
     el.className = 'fitem' + (on ? ' on' : '');
     el.innerHTML = `<span class="box">${on ? '✓' : ''}</span><span class="lbl">${label}</span>`;
-    el.onclick = () => {
-      hiddenFields.has(k) ? hiddenFields.delete(k) : hiddenFields.add(k);
-      localStorage.setItem('all_hidden_fields', JSON.stringify([...hiddenFields]));
-      applyFieldClasses(); drawFieldToggles();
-    };
+    el.onclick = () => toggleField(k);
     row.appendChild(el);
   });
   document.getElementById('nFields').textContent = hiddenFields.size ? `${FIELD_DEFS.length - hiddenFields.size}/${FIELD_DEFS.length}` : '';
   document.getElementById('nFields').style.display = hiddenFields.size ? '' : 'none';
 }
+// Control-bar "Fields ▾" popover — a checkbox per card field, same state as the rail.
+function drawFieldMenu() {
+  const pop = document.getElementById('fpop'); if (!pop) return;
+  pop.innerHTML = FIELD_DEFS.map(([k, label]) =>
+    `<label><input type="checkbox" data-field="${k}"${hiddenFields.has(k) ? '' : ' checked'}> ${label}</label>`).join('');
+  pop.querySelectorAll('input').forEach((cb) => cb.addEventListener('change', () => toggleField(cb.dataset.field)));
+}
 function applyFieldClasses() {
   FIELD_DEFS.forEach(([k]) => grid.classList.toggle('h-' + k, hiddenFields.has(k)));
 }
-applyFieldClasses(); drawFieldToggles();
+applyFieldClasses(); drawFieldToggles(); drawFieldMenu();
+
+// Fields ▾ open/close (click-away closes).
+const fieldsBtn = document.getElementById('fieldsBtn'), fpop = document.getElementById('fpop');
+if (fieldsBtn && fpop) {
+  fieldsBtn.addEventListener('click', (e) => { e.stopPropagation();
+    const open = fpop.hidden; fpop.hidden = !open; fieldsBtn.setAttribute('aria-expanded', String(open)); });
+  document.addEventListener('click', (e) => { if (!fpop.hidden && !e.target.closest('.fieldmenu')) {
+    fpop.hidden = true; fieldsBtn.setAttribute('aria-expanded', 'false'); } });
+}
 
 const sortSel = document.getElementById('sort'), dirBtn = document.getElementById('dir');
 sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : state.sort;
@@ -1149,15 +1187,15 @@ function card(r) {
   const gcount = (r.image_count || (r.images ? r.images.length : (r.image ? 1 : 0)));
   const galBadge = gcount > 1 ? `<span class="imgcount" title="${gcount} images — hover to cycle">⧉ ${gcount}</span>` : '';
   const thumb = r.image
-    ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}${discoBadge}${galBadge}</div>`
-    : `<div class="thumb empty">${stBadge}${discoBadge}no image</div>`;
+    ? `<div class="thumb" data-f="img" style="background-image:url('${r.image}')">${stBadge}${discoBadge}${galBadge}</div>`
+    : `<div class="thumb empty" data-f="img">${stBadge}${discoBadge}no image</div>`;
   // Card data atoms → drill spans (href-to-deeper-data). The card itself is a click-to-open
   // element, so in-card atoms are <span class="drill"> (asSpan=true), NEVER nested <a>; a
   // delegated .meta handler filters + stopPropagation so the card's window.open never fires.
   const vt = [r.vendor && drill('vendor', r.vendor, r.vendor, true), r.type && drill('type', r.type, r.type, true)]
     .filter(Boolean).join(' · ');
   const mfrn = mfrHTML(r);
-  const mfrname = r.mfr_name ? `<div class="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
+  const mfrname = r.mfr_name ? `<div class="mfrname" data-f="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
   const fmspec = fmSpecsHTML(r);
   const facAtoms = [
     ...(r.colors || []).map((c) => drill('color', c, c, true)),
@@ -1165,8 +1203,8 @@ function card(r) {
     ...(r.materials || []).map((c) => drill('material', c, c, true)),
   ].filter(Boolean).slice(0, 4);
   const fac = facAtoms.join(' · ');
-  const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
-  el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${mfrn}${mfrname}${fmspec}${fac ? `<div class="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts">${actButtonsHTML(r)}</div>`;
+  const price = r.price ? `<span class="price" data-f="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
+  el.innerHTML = `${thumb}<div class="meta"><div class="sku" data-f="sku">${r.sku || '—'}</div><div class="ttl" data-f="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt" data-f="vt">${vt}</div>` : ''}${mfrn}${mfrname}${fmspec}${fac ? `<div class="fac" data-f="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts" data-f="act">${actButtonsHTML(r)}</div>`;
   const acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
   const meta = el.querySelector('.meta'); if (meta) meta.addEventListener('click', handleDrillClick);
   if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
diff --git a/public/vendors.html b/public/vendors.html
index 80b295e..fa44ad5 100644
--- a/public/vendors.html
+++ b/public/vendors.html
@@ -43,7 +43,34 @@
   .dens { display:flex; align-items:center; gap:10px; font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#7d766a; }
   .dens input { accent-color: var(--gold); width: 130px; }
 
+  /* Fields on/off menu — toggle any field on the card (down to image-only). */
+  .fieldmenu { position:relative; }
+  .fbtn { padding:9px 14px; border:1px solid var(--line); background:#fff; font-size:12px; letter-spacing:.1em;
+    text-transform:uppercase; color:#7d766a; cursor:pointer; border-radius:2px; }
+  .fbtn:hover { border-color:var(--gold); color:var(--ink); }
+  .fpop { position:absolute; top:calc(100% + 8px); right:0; background:#fff; border:1px solid var(--line);
+    border-radius:2px; box-shadow:0 14px 34px rgba(22,20,18,.16); padding:10px 6px; z-index:30; min-width:190px; }
+  .fpop[hidden] { display:none; }
+  .fpop label { display:flex; align-items:center; gap:10px; padding:7px 14px; font-size:12px; letter-spacing:.08em;
+    text-transform:uppercase; color:#4b463d; cursor:pointer; white-space:nowrap; }
+  .fpop label:hover { background:var(--paper); }
+  .fpop input { accent-color: var(--gold); }
+
   .grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 26px; padding: 34px; }
+
+  /* List view — cards become horizontal rows (thumbnail left, meta right). */
+  .grid.list { display:block; padding: 20px 34px; }
+  .grid.list .card { flex-direction:row; align-items:stretch; margin-bottom:14px; }
+  .grid.list .card:hover { transform:none; }
+  .grid.list .collage { flex:0 0 clamp(120px, 22vw, 210px); aspect-ratio:4/3; }
+  .grid.list .meta { flex:1; display:flex; flex-direction:column; justify-content:center; }
+
+  /* Per-field hide switches driven by body.hf-<field> classes (set from the Fields menu). */
+  body.hf-image  .collage { display:none; }
+  body.hf-name   [data-f="name"]   { display:none; }
+  body.hf-count  [data-f="count"]  { display:none; }
+  body.hf-status [data-f="status"] { display:none; }
+  body.hf-cta    [data-f="cta"]    { display:none; }
   .card { background:#fff; border:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column;
     transition: box-shadow .25s, transform .25s; }
   .card:hover { box-shadow: 0 14px 34px rgba(22,20,18,.14); transform: translateY(-3px); }
@@ -55,6 +82,8 @@
   .card .meta { padding: 16px 18px 18px; }
   .card h3 { font-family: Georgia, serif; font-weight:400; font-size:17px; letter-spacing:.03em; }
   .card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
+  .card .fline { margin-top:6px; font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#9a927f; }
+  .card .fline b { color:#6b6459; font-weight:600; }
   .card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
 
   .sentinel { height: 10px; }
@@ -93,11 +122,23 @@
     <button id="fAll">All Vendors</button>
   </div>
   <select id="sort" aria-label="Sort">
-    <option value="designs">Most Designs</option>
-    <option value="az">Name A→Z</option>
-    <option value="za">Name Z→A</option>
+    <option value="designs">Designs: Most</option>
+    <option value="designs_asc">Designs: Fewest</option>
+    <option value="az">Name: A→Z</option>
+    <option value="za">Name: Z→A</option>
+    <option value="status">Status: Private-Label First</option>
+    <option value="status_desc">Status: Standard First</option>
+    <option value="live">Available Now First</option>
   </select>
-  <label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"></label>
+  <label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"><span id="densN">4</span></label>
+  <div class="seg" role="tablist" aria-label="View">
+    <button id="vGrid" class="on">Grid</button>
+    <button id="vList">List</button>
+  </div>
+  <div class="fieldmenu">
+    <button id="fieldsBtn" class="fbtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
+    <div class="fpop" id="fpop" hidden role="menu"></div>
+  </div>
 </div>
 
 <main class="grid" id="grid"></main>
@@ -108,22 +149,59 @@
 <script>
 const grid = document.getElementById('grid'), sentinel = document.getElementById('sentinel');
 const menu = document.getElementById('menu');
+// Every field on a card is user-toggleable; [key, label, default-visible].
+const FIELDS = [
+  ['image',  'Image',      true],
+  ['name',   'Name',       true],
+  ['count',  'Designs',    true],
+  ['status', 'Status',     false],
+  ['cta',    'Shop link',  true],
+];
+function loadFields(){
+  const def = {}; FIELDS.forEach(([k,,d]) => def[k] = d);
+  try { return Object.assign(def, JSON.parse(localStorage.getItem('adw_fields') || '{}')); }
+  catch(e){ return def; }
+}
+
 const state = {
   all: [], view: [], rendered: 0, BATCH: 40,
   q: '', filter: localStorage.getItem('adw_filter') || 'live',
   sort: localStorage.getItem('adw_sort') || 'designs',
   cols: localStorage.getItem('adw_cols') || '4',
+  viewMode: localStorage.getItem('adw_view') || 'grid',
+  fields: loadFields(),
 };
 
 document.documentElement.style.setProperty('--cols', state.cols);
 density.value = state.cols;
+densN.textContent = state.cols;
 sort.value = state.sort;
 setSeg();
+setView();
+buildFieldMenu();
+applyFields();
 
 function setSeg(){
   fLive.classList.toggle('on', state.filter==='live');
   fAll.classList.toggle('on', state.filter==='all');
 }
+function setView(){
+  grid.classList.toggle('list', state.viewMode === 'list');
+  vGrid.classList.toggle('on', state.viewMode === 'grid');
+  vList.classList.toggle('on', state.viewMode === 'list');
+}
+function applyFields(){
+  FIELDS.forEach(([k]) => document.body.classList.toggle('hf-' + k, !state.fields[k]));
+}
+function buildFieldMenu(){
+  fpop.innerHTML = FIELDS.map(([k,label]) =>
+    `<label><input type="checkbox" data-field="${k}"${state.fields[k] ? ' checked' : ''}> ${label}</label>`).join('');
+  fpop.querySelectorAll('input').forEach(cb => cb.addEventListener('change', () => {
+    state.fields[cb.dataset.field] = cb.checked;
+    localStorage.setItem('adw_fields', JSON.stringify(state.fields));
+    applyFields();
+  }));
+}
 
 fetch('/api/vendors').then(r=>r.json()).then(d=>{
   state.all = d.vendors || [];
@@ -132,13 +210,25 @@ fetch('/api/vendors').then(r=>r.json()).then(d=>{
   apply();
 });
 
+// Every field on a vendor record gets a sorter. Ties break on name so order is stable.
+const nm = v => String(v.name || '');
+const ac = v => (v.active_count || 0);
+const priv = v => (v.is_private_label ? 1 : 0);
+const SORTERS = {
+  designs:     (a,b)=> (ac(b)-ac(a)) || nm(a).localeCompare(nm(b)),
+  designs_asc: (a,b)=> (ac(a)-ac(b)) || nm(a).localeCompare(nm(b)),
+  az:          (a,b)=> nm(a).localeCompare(nm(b)),
+  za:          (a,b)=> nm(b).localeCompare(nm(a)),
+  status:      (a,b)=> (priv(b)-priv(a)) || nm(a).localeCompare(nm(b)),
+  status_desc: (a,b)=> (priv(a)-priv(b)) || nm(a).localeCompare(nm(b)),
+  live:        (a,b)=> ((ac(b)>0)-(ac(a)>0)) || (ac(b)-ac(a)) || nm(a).localeCompare(nm(b)),
+};
+
 function apply(){
   let v = state.all;
   if (state.filter==='live') v = v.filter(x=>x.active_count>0);
   if (state.q) { const q=state.q.toLowerCase(); v = v.filter(x=>x.name.toLowerCase().includes(q)); }
-  if (state.sort==='az') v=[...v].sort((a,b)=>a.name.localeCompare(b.name));
-  else if (state.sort==='za') v=[...v].sort((a,b)=>b.name.localeCompare(a.name));
-  else v=[...v].sort((a,b)=>b.active_count-a.active_count || a.name.localeCompare(b.name));
+  v = [...v].sort(SORTERS[state.sort] || SORTERS.designs);
   state.view = v; state.rendered = 0; grid.innerHTML=''; renderMore();
 }
 
@@ -150,11 +240,12 @@ function card(v){
     ? imgs.map(u=>`<img loading="lazy" src="${u.replace(/"/g,'')}" alt="${v.name} design">`).join('')
     : `<div class="ph">${v.name.charAt(0)}</div>`;
   el.innerHTML = `
-    <div class="collage${imgs.length<=1?' single':''}">${cells}</div>
+    <div class="collage${imgs.length<=1?' single':''}" data-f="image">${cells}</div>
     <div class="meta">
-      <h3></h3>
-      <div class="sub">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
-      <div class="cta">Shop the Line →</div>
+      <h3 data-f="name"></h3>
+      <div class="sub" data-f="count">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
+      <div class="fline" data-f="status"><b>Status</b> ${v.is_private_label ? 'Private Label' : (v.active_count>0 ? 'Available Now' : 'By Inquiry')}</div>
+      <div class="cta" data-f="cta">Shop the Line →</div>
     </div>`;
   el.querySelector('h3').textContent = v.name;
   return el;
@@ -171,9 +262,18 @@ new IntersectionObserver(es=>{ if(es[0].isIntersecting) renderMore(); }, {rootMa
 q.addEventListener('input', e=>{ state.q=e.target.value.trim(); apply(); });
 sort.addEventListener('change', e=>{ state.sort=e.target.value; localStorage.setItem('adw_sort',state.sort); apply(); });
 density.addEventListener('input', e=>{ state.cols=e.target.value; localStorage.setItem('adw_cols',state.cols);
-  document.documentElement.style.setProperty('--cols', state.cols); });
+  densN.textContent=state.cols; document.documentElement.style.setProperty('--cols', state.cols); });
 fLive.onclick=()=>{ state.filter='live'; localStorage.setItem('adw_filter','live'); setSeg(); apply(); };
 fAll.onclick=()=>{ state.filter='all'; localStorage.setItem('adw_filter','all'); setSeg(); apply(); };
+
+vGrid.onclick=()=>{ state.viewMode='grid'; localStorage.setItem('adw_view','grid'); setView(); };
+vList.onclick=()=>{ state.viewMode='list'; localStorage.setItem('adw_view','list'); setView(); };
+
+// Fields menu open/close (click-away closes).
+fieldsBtn.addEventListener('click', (e)=>{ e.stopPropagation();
+  const open = fpop.hidden; fpop.hidden = !open; fieldsBtn.setAttribute('aria-expanded', String(open)); });
+document.addEventListener('click', (e)=>{ if (!fpop.hidden && !e.target.closest('.fieldmenu')) {
+  fpop.hidden = true; fieldsBtn.setAttribute('aria-expanded','false'); } });
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 8b71b5a..a82f0a3 100644
--- a/server.js
+++ b/server.js
@@ -930,6 +930,8 @@ const sorters = {
   type: (a, b) => str(a.type).localeCompare(str(b.type)) || str(a.title).localeCompare(str(b.title)),
   series: (a, b) => str(a.series).localeCompare(str(b.series)) || str(a.sku).localeCompare(str(b.sku)),
   sku: (a, b) => str(a.sku).localeCompare(str(b.sku)),
+  mfr: (a, b) => str(a.mfr_number).localeCompare(str(b.mfr_number)) || str(a.sku).localeCompare(str(b.sku)),
+  mfrname: (a, b) => str(a.mfr_name).localeCompare(str(b.mfr_name)) || str(a.sku).localeCompare(str(b.sku)),
   pattern: (a, b) => str(a.pattern).localeCompare(str(b.pattern)),
   status: (a, b) => str(a.status).localeCompare(str(b.status)),
   lifecycle: (a, b) => str(a.lifecycle).localeCompare(str(b.lifecycle)) || str(a.sku).localeCompare(str(b.sku)),
@@ -945,6 +947,7 @@ const sorters = {
 // per-sort "row actually has this value" — used to keep empties last on dir=desc
 const HAS_VALUE = {
   sku: (r) => r.sku != null && r.sku !== '', title: (r) => !!r.title, vendor: (r) => !!r.vendor,
+  mfr: (r) => !!r.mfr_number, mfrname: (r) => !!r.mfr_name,
   type: (r) => !!r.type, series: (r) => !!r.series, pattern: (r) => !!r.pattern, status: (r) => !!r.status, lifecycle: (r) => !!r.lifecycle,
   price: (r) => r.price != null, price_asc: (r) => r.price != null, price_desc: (r) => r.price != null,
   color: (r) => r.colors.length > 0, style: (r) => r.styles.length > 0, material: (r) => r.materials.length > 0,

← 95446fa microsites.html: add list/grid toggle + per-field on/off men  ·  back to All Designerwallcoverings  ·  nav-agent: universal grid-controls drop-in on internal viewe 3b584b2 →