[object Object]

← back to Designer Wallcoverings

Add dw-boost-filter-topbar section: horizontal dropdown filter toolbar (DTD verdict B) driving Boost pf_* params, per-field ON/OFF toggle, ported new-import-viewer UX

a5a75bec3c6af667a55a341a7c727a1fbcd72ce7 · 2026-06-25 16:05:46 -0700 · Steve Abrams

Files touched

Diff

commit a5a75bec3c6af667a55a341a7c727a1fbcd72ce7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 25 16:05:46 2026 -0700

    Add dw-boost-filter-topbar section: horizontal dropdown filter toolbar (DTD verdict B) driving Boost pf_* params, per-field ON/OFF toggle, ported new-import-viewer UX
---
 shopify/sections/dw-boost-filter-topbar.liquid | 459 +++++++++++++++++++++++++
 1 file changed, 459 insertions(+)

diff --git a/shopify/sections/dw-boost-filter-topbar.liquid b/shopify/sections/dw-boost-filter-topbar.liquid
new file mode 100644
index 00000000..5240e534
--- /dev/null
+++ b/shopify/sections/dw-boost-filter-topbar.liquid
@@ -0,0 +1,459 @@
+{%- comment -%}
+  DW Boost Filter Topbar — DTD verdict B (officer-yolo 2026-06-25)
+
+  A CUSTOM horizontal DROPDOWN filter toolbar that sits ABOVE Boost's grid and
+  drives Boost's pf_* URL params + sort_by. Ported UX from the left-hand filters
+  of new.designerwallcoverings.com (new-import-viewer): toggleable controls,
+  sort + density + search, all persisted to localStorage.
+
+  ── HARD ARCHITECTURE RULES (do not violate) ──
+  1. This bar READS Boost's filter-tree DOM (option labels/counts) + the current
+     URL's pf_* params, and WRITES pf_* / sort_by params then NAVIGATES.
+     It NEVER mutates Boost's result grid DOM, never removes a Boost node, never
+     restyles Boost's React internals. Zero grid-break surface (see memory
+     reference_dw_theme_grid_init_abort: dropping a Boost DOM hook silently breaks
+     every grid — this bar only ADDS a sibling node, queries are read-only).
+  2. Boost is the filter ENGINE. The bar is UX-only. If Boost re-renders 1000x/day
+     the bar is independent — on each Boost mutation it re-reads option values
+     (debounced) but only to repopulate its OWN dropdowns.
+  3. Each filter field is individually toggleable ON/OFF by the user; the choice
+     persists in localStorage (dw_topbar_fields), same convention as sort/density.
+  4. NO private-label vendor names ever surface (Brewster/York/WallQuest/Command54
+     etc.) — the Brand/Vendor dropdown is built from Boost's emitted option list,
+     which already carries house-line names; a deny-list scrub is applied as a
+     belt-and-suspenders guard before any option is rendered.
+
+  ── Boost param contract (verified on the live page 2026-06-25) ──
+    pf_t_<tag>      tag-backed facets   → product_type, color, style, durability,
+                                          historical_era  (extensible: material,
+                                          process, pattern, contents = pf_t_*)
+    pf_v_brand      vendor/brand facet
+    pf_p_price      price range  (pf_p_price=<min>:<max>)
+    sort_by         Boost/Shopify sort  (created-descending, price-ascending, …)
+    q               search term
+
+  Each FIELD below maps to one Boost param. Fields whose Boost facet is not yet
+  configured in the Boost SD dashboard render their dropdown EMPTY with a
+  "configure in Boost" hint (see the accompanying runbook) — they do not break.
+
+  Revision: 2026-06-25 (officer-yolo / vp-dw-commerce)
+{%- endcomment -%}
+
+{%- liquid
+  assign accent = settings.color_accent | default: '#111111'
+-%}
+
+<div class="dw-tbf" data-section-id="{{ section.id }}" data-dw-topbar>
+  <style>
+    .dw-tbf{width:100%;padding:14px 0;margin-bottom:18px;border-bottom:1px solid var(--border-color,rgba(0,0,0,.1));
+      font-family:inherit;--dw-acc:{{ accent }}}
+    .dw-tbf__row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+    .dw-tbf__label{font-size:10px;letter-spacing:.3em;text-transform:uppercase;font-weight:700;
+      color:var(--text-muted,rgba(0,0,0,.55));margin-right:2px;white-space:nowrap}
+    /* one dropdown unit */
+    .dw-dd{position:relative}
+    .dw-dd__btn{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 13px;
+      font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:600;
+      color:var(--text-color,inherit);background:transparent;border:1px solid var(--border-color,rgba(0,0,0,.14));
+      border-radius:20px;cursor:pointer;white-space:nowrap;transition:border-color .15s,background .15s;line-height:1}
+    .dw-dd__btn:hover{border-color:var(--dw-acc)}
+    .dw-dd__btn[aria-expanded="true"]{border-color:var(--dw-acc);background:var(--bg-hover,rgba(0,0,0,.03))}
+    .dw-dd__btn .dw-caret{font-size:8px;opacity:.6;transition:transform .15s}
+    .dw-dd__btn[aria-expanded="true"] .dw-caret{transform:rotate(180deg)}
+    .dw-dd__btn .dw-cnt{font-variant-numeric:tabular-nums;background:var(--dw-acc);color:#fff;
+      border-radius:999px;font-size:9px;min-width:15px;height:15px;display:inline-flex;align-items:center;
+      justify-content:center;padding:0 4px}
+    /* active (has a selection) */
+    .dw-dd.has-sel .dw-dd__btn{background:var(--dw-acc);color:#fff;border-color:var(--dw-acc)}
+    .dw-dd.has-sel .dw-dd__btn .dw-cnt{background:#fff;color:var(--dw-acc)}
+    /* panel */
+    .dw-dd__panel{position:absolute;top:calc(100% + 6px);left:0;z-index:40;min-width:220px;max-width:300px;
+      max-height:340px;overflow:auto;background:var(--bg-color,#fff);border:1px solid var(--border-color,rgba(0,0,0,.14));
+      border-radius:12px;box-shadow:0 12px 34px rgba(0,0,0,.16);padding:8px;display:none}
+    .dw-dd.open .dw-dd__panel{display:block}
+    .dw-opt{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:8px;cursor:pointer;
+      font-size:12.5px;color:var(--text-color,inherit);user-select:none}
+    .dw-opt:hover{background:var(--bg-hover,rgba(0,0,0,.04))}
+    .dw-opt input{width:15px;height:15px;accent-color:var(--dw-acc);margin:0;cursor:pointer;flex:0 0 auto}
+    .dw-opt .dw-sw{width:15px;height:15px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:0 0 auto}
+    .dw-opt .dw-on{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+    .dw-opt .dw-oc{font-size:10.5px;color:var(--text-muted,rgba(0,0,0,.45));font-variant-numeric:tabular-nums}
+    .dw-dd__empty{padding:14px 10px;font-size:11.5px;color:var(--text-muted,rgba(0,0,0,.5));line-height:1.4}
+    .dw-dd__price{display:flex;gap:8px;align-items:center;padding:10px 9px}
+    .dw-dd__price input{width:74px;height:30px;border:1px solid var(--border-color,rgba(0,0,0,.16));
+      border-radius:8px;padding:0 8px;font:inherit;font-size:12px}
+    .dw-dd__apply{margin-top:6px;width:100%;height:32px;border:0;border-radius:8px;background:var(--dw-acc);
+      color:#fff;font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700;cursor:pointer}
+    /* sort + density + search reuse the same chip vocabulary */
+    .dw-tbf__sort{height:34px;padding:0 11px;border:1px solid var(--border-color,rgba(0,0,0,.14));border-radius:20px;
+      background:transparent;font:inherit;font-size:11px;letter-spacing:.1em;text-transform:uppercase;font-weight:600;cursor:pointer;color:inherit}
+    .dw-tbf__sort:hover{border-color:var(--dw-acc)}
+    .dw-density{display:inline-flex;align-items:center;gap:9px;height:34px}
+    .dw-density input[type=range]{width:96px;accent-color:var(--dw-acc);cursor:pointer}
+    .dw-density .dw-dlabel{font-size:10px;letter-spacing:.16em;text-transform:uppercase;font-weight:600;
+      color:var(--text-muted,rgba(0,0,0,.55));min-width:42px;text-align:right}
+    .dw-search{display:inline-flex;align-items:center;gap:7px;height:34px;border:1px solid var(--border-color,rgba(0,0,0,.14));
+      border-radius:20px;padding:0 12px;min-width:160px}
+    .dw-search input{flex:1;border:0;background:transparent;font:inherit;font-size:12px;outline:none;color:inherit}
+    .dw-search svg{width:13px;height:13px;stroke:var(--text-muted,rgba(0,0,0,.4));fill:none;stroke-width:1.6;flex:0 0 auto}
+    .dw-clear{margin-left:auto;font-size:10px;letter-spacing:.16em;text-transform:uppercase;font-weight:700;
+      color:var(--dw-acc);background:transparent;border:0;cursor:pointer;white-space:nowrap}
+    .dw-clear:disabled{opacity:.35;cursor:default}
+    /* the "which filters are shown" gear menu (per-field ON/OFF) */
+    .dw-gear{position:relative}
+    .dw-gear__btn{height:34px;width:34px;display:inline-flex;align-items:center;justify-content:center;
+      border:1px solid var(--border-color,rgba(0,0,0,.14));border-radius:50%;background:transparent;cursor:pointer;font-size:14px}
+    .dw-gear__btn:hover{border-color:var(--dw-acc)}
+    .dw-gear__panel{position:absolute;top:calc(100% + 6px);right:0;z-index:41;min-width:200px;background:var(--bg-color,#fff);
+      border:1px solid var(--border-color,rgba(0,0,0,.14));border-radius:12px;box-shadow:0 12px 34px rgba(0,0,0,.16);padding:8px;display:none}
+    .dw-gear.open .dw-gear__panel{display:block}
+    .dw-gear__panel .dw-gtitle{font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;font-weight:700;
+      color:var(--text-muted,rgba(0,0,0,.5));padding:5px 9px 7px}
+    @media (max-width:720px){
+      .dw-tbf__label{display:none}
+      .dw-density{display:none}
+      .dw-search{min-width:0;flex:1 1 100%;order:5}
+      .dw-dd__panel{max-width:84vw}
+    }
+  </style>
+
+  <div class="dw-tbf__row" data-tbf-row>
+    <span class="dw-tbf__label">Filter</span>
+    <span data-tbf-fields></span>
+
+    {%- comment -%} sort {%- endcomment -%}
+    <span class="dw-tbf__label" style="margin-left:6px">Sort</span>
+    <select class="dw-tbf__sort" data-tbf-sort aria-label="Sort products">
+      <option value="">Newest</option>
+      <option value="created-descending">Created · Newest</option>
+      <option value="created-ascending">Created · Oldest</option>
+      <option value="title-ascending">Title A→Z</option>
+      <option value="title-descending">Title Z→A</option>
+      <option value="price-ascending">Price ↑</option>
+      <option value="price-descending">Price ↓</option>
+      <option value="best-selling">Best selling</option>
+    </select>
+
+    {%- comment -%} density {%- endcomment -%}
+    <span class="dw-density" data-tbf-density-wrap>
+      <span class="dw-tbf__label">Grid</span>
+      <input type="range" min="2" max="8" value="4" data-tbf-density aria-label="Grid density">
+      <span class="dw-dlabel"><span data-tbf-dlabel>4</span> col</span>
+    </span>
+
+    {%- comment -%} search {%- endcomment -%}
+    <span class="dw-search" data-tbf-search-wrap>
+      <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
+      <input type="text" data-tbf-search placeholder="Search pattern / sku / color…" aria-label="Search">
+    </span>
+
+    <button class="dw-clear" data-tbf-clear disabled>Clear all</button>
+
+    {%- comment -%} per-field ON/OFF gear {%- endcomment -%}
+    <span class="dw-gear" data-tbf-gear>
+      <button class="dw-gear__btn" data-tbf-gear-btn title="Choose which filters to show" aria-haspopup="true" aria-expanded="false">⚙</button>
+      <div class="dw-gear__panel" data-tbf-gear-panel>
+        <div class="dw-gtitle">Show filters</div>
+        <span data-tbf-gear-list></span>
+      </div>
+    </span>
+  </div>
+</div>
+
+<script>
+(function(){
+  'use strict';
+  var root = document.currentScript.closest('[data-dw-topbar]') || document.querySelector('[data-dw-topbar]');
+  if(!root || root.__dwTbfInit) return; root.__dwTbfInit = true;
+
+  // ── FIELD MODEL ──────────────────────────────────────────────────────────
+  // key  = stable id (localStorage)
+  // label= dropdown button text
+  // param= Boost URL param this field writes (the contract)
+  // kind = 'tag' (pf_t_*) | 'vendor' (pf_v_brand) | 'price' (pf_p_price)
+  // swatch=render a color dot per option (color/swatch field)
+  // The order here is the default left→right order in the bar.
+  var FIELDS = [
+    { key:'color',    label:'Color',    param:'pf_t_color',         kind:'tag', swatch:true },
+    { key:'style',    label:'Style',    param:'pf_t_style',         kind:'tag' },
+    { key:'material', label:'Material', param:'pf_t_material',      kind:'tag' },
+    { key:'brand',    label:'Brand',    param:'pf_v_brand',         kind:'vendor' },
+    { key:'process',  label:'Process',  param:'pf_t_process',       kind:'tag' },
+    { key:'vendor',   label:'Vendor',   param:'pf_v_brand',         kind:'vendor' },
+    { key:'swatch',   label:'Swatch',   param:'pf_t_color',         kind:'tag', swatch:true },
+    { key:'contents', label:'Contents', param:'pf_t_contents',      kind:'tag' },
+    { key:'pattern',  label:'Pattern',  param:'pf_t_pattern',       kind:'tag' },
+    { key:'sku',      label:'SKU',      param:'pf_t_sku',           kind:'tag' },
+    { key:'durability',label:'Durability',param:'pf_t_durability',  kind:'tag' },
+    { key:'era',      label:'Era',      param:'pf_t_historical_era',kind:'tag' },
+    { key:'price',    label:'Price',    param:'pf_p_price',         kind:'price' }
+  ];
+  // Which fields are ON by default (the common five + price/vendor/created via sort).
+  var DEFAULT_ON = ['color','style','material','brand','process','price'];
+
+  // Private-label deny-list — these upstream names must NEVER reach a shopper.
+  var DENY = [/brewster/i,/\byork\b/i,/wallquest/i,/chesapeake/i,/nextwall/i,/seabrook/i,/command\s*54/i,/desima/i,/carlsten/i,/nicolette\s*mayer/i];
+  function denied(s){ s=String(s||''); return DENY.some(function(r){return r.test(s);}); }
+
+  var LS = {
+    fields:'dw_topbar_fields', density:'dw_collection_density',
+    sort:'dw_collection_sort', search:'dw_collection_search'
+  };
+  function lsGet(k,d){ try{var v=localStorage.getItem(k); return v==null?d:v;}catch(e){return d;} }
+  function lsSet(k,v){ try{localStorage.setItem(k,v);}catch(e){} }
+
+  function curParams(){ return new URLSearchParams(window.location.search); }
+  function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];}); }
+
+  // ── read which fields are enabled ────────────────────────────────────────
+  function enabledSet(){
+    var saved = lsGet(LS.fields,null);
+    if(saved){ try{ var a=JSON.parse(saved); if(Array.isArray(a)) return new Set(a); }catch(e){} }
+    return new Set(DEFAULT_ON);
+  }
+
+  // ── read Boost's emitted option values for a tag/vendor param ────────────
+  // STRICTLY READ-ONLY: we query Boost's filter-tree DOM for labels+counts.
+  // We never remove/restyle those nodes. If Boost hasn't rendered them yet
+  // (client API still loading) we fall back to the value already in the URL so
+  // the active selection is always reflected. Never throws → never breaks grid.
+  function boostOptions(field){
+    var out=[], seen={};
+    var param = field.param;
+    // 1) values already active in the URL (always show these so state is honest)
+    curParams().getAll(param).forEach(function(v){
+      v=decodeURIComponent(v); if(!seen[v]&&!denied(v)){ seen[v]=1; out.push({value:v,label:v,count:null,checked:true}); }
+    });
+    // 2) options Boost has rendered in its (possibly-hidden) native tree.
+    //    Boost emits filter options as anchors/inputs carrying the pf_* param in
+    //    href or data attributes. We scan defensively across known shapes.
+    try{
+      var tree = document.querySelector('.boost-sd__filter-tree') || document.querySelector('.boost-sd__sidebar');
+      if(tree){
+        tree.querySelectorAll('a[href],input[type="checkbox"]').forEach(function(el){
+          var href = el.getAttribute('href')||'';
+          var match=null;
+          if(href.indexOf(param+'=')>-1){
+            var u; try{ u=new URL(href, window.location.origin); }catch(e){ return; }
+            (u.searchParams.getAll(param)||[]).forEach(function(v){ match=v; });
+          }
+          if(!match && el.getAttribute('data-filter-value') && (el.getAttribute('data-filter-type')||'').toLowerCase().indexOf(field.key)>-1){
+            match = el.getAttribute('data-filter-value');
+          }
+          if(!match) return;
+          var lbl = (el.textContent||match).replace(/\s+\(\d[\d,]*\)\s*$/,'').trim() || match;
+          var cm = (el.textContent||'').match(/\((\d[\d,]*)\)\s*$/);
+          if(seen[match]||denied(match)||denied(lbl)) return;
+          seen[match]=1; out.push({value:match,label:lbl,count:cm?cm[1]:null,checked:false});
+        });
+      }
+    }catch(e){ /* read-only scan; ignore */ }
+    return out;
+  }
+
+  // ── swatch hex guess from a color name (cheap, named-color map) ──────────
+  var NAMED={black:'#1a1a1a',white:'#f4f4f4',grey:'#9aa0a6',gray:'#9aa0a6',silver:'#c8ccd1',red:'#c0392b',
+    pink:'#e84393',orange:'#e67e22',yellow:'#f1c40f',gold:'#caa53d',green:'#27ae60',olive:'#7d8c3e',
+    teal:'#16a085',blue:'#2980b9',navy:'#27408b',cyan:'#36c5d6',purple:'#8e44ad',violet:'#7c4dbb',
+    brown:'#8d6748',beige:'#d8c7a8',cream:'#efe6cf',taupe:'#b3a394',ivory:'#f1ebda',charcoal:'#36393f',
+    bronze:'#9c6b3c',copper:'#b87333',natural:'#d6c8af',neutral:'#cfc6b8'};
+  function swatchHex(name){
+    var n=String(name||'').toLowerCase();
+    for(var k in NAMED){ if(n.indexOf(k)>-1) return NAMED[k]; }
+    return '#cfc6b8';
+  }
+
+  // ── build a single dropdown for a field ──────────────────────────────────
+  function buildDropdown(field){
+    var opts = (field.kind==='price') ? [] : boostOptions(field);
+    var active = curParams().getAll(field.param).map(decodeURIComponent).filter(function(v){return !denied(v);});
+    var dd=document.createElement('span');
+    dd.className='dw-dd'+(active.length?' has-sel':'');
+    dd.setAttribute('data-dd', field.key);
+
+    var btn=document.createElement('button');
+    btn.type='button'; btn.className='dw-dd__btn'; btn.setAttribute('aria-expanded','false'); btn.setAttribute('aria-haspopup','true');
+    btn.innerHTML = esc(field.label) + (active.length?(' <span class="dw-cnt">'+active.length+'</span>'):'') + ' <span class="dw-caret">▾</span>';
+    dd.appendChild(btn);
+
+    var panel=document.createElement('div'); panel.className='dw-dd__panel';
+
+    if(field.kind==='price'){
+      var cur=curParams().get('pf_p_price')||''; var mn='',mx='';
+      if(cur.indexOf(':')>-1){ var pr=cur.split(':'); mn=pr[0]; mx=pr[1]; }
+      panel.innerHTML =
+        '<div class="dw-dd__price">'+
+          '<input type="number" min="0" step="1" placeholder="Min" data-pmin value="'+esc(mn)+'">'+
+          '<span style="opacity:.5">–</span>'+
+          '<input type="number" min="0" step="1" placeholder="Max" data-pmax value="'+esc(mx)+'">'+
+        '</div>'+
+        '<button class="dw-dd__apply" data-papply>Apply price</button>';
+      panel.querySelector('[data-papply]').addEventListener('click',function(){
+        var lo=panel.querySelector('[data-pmin]').value.trim(), hi=panel.querySelector('[data-pmax]').value.trim();
+        var p=curParams();
+        if(lo||hi){ p.set('pf_p_price',(lo||'0')+':'+(hi||'999999')); } else { p.delete('pf_p_price'); }
+        navTo(p);
+      });
+    } else if(!opts.length){
+      panel.innerHTML='<div class="dw-dd__empty">No <b>'+esc(field.label)+'</b> facet is configured in Boost yet.<br>Add it in the Boost SD dashboard (param <code>'+esc(field.param)+'</code>) — see the runbook. Active selections still work.</div>';
+    } else {
+      var html=opts.map(function(o){
+        var sw = field.swatch ? '<span class="dw-sw" style="background:'+swatchHex(o.label)+'"></span>' : '';
+        return '<label class="dw-opt"><input type="checkbox" data-val="'+esc(o.value)+'" '+(o.checked?'checked':'')+'>'+
+               sw+'<span class="dw-on">'+esc(o.label)+'</span>'+(o.count?('<span class="dw-oc">'+esc(o.count)+'</span>'):'')+'</label>';
+      }).join('');
+      panel.innerHTML=html;
+      panel.querySelectorAll('input[type="checkbox"]').forEach(function(cb){
+        cb.addEventListener('change',function(){
+          var p=curParams(); var val=cb.getAttribute('data-val');
+          var have=p.getAll(field.param).map(decodeURIComponent);
+          p.delete(field.param);
+          if(cb.checked){ if(have.indexOf(val)<0) have.push(val); }
+          else { have=have.filter(function(v){return v!==val;}); }
+          have.forEach(function(v){ p.append(field.param,v); });
+          navTo(p);
+        });
+      });
+    }
+    dd.appendChild(panel);
+
+    btn.addEventListener('click',function(e){
+      e.stopPropagation();
+      var wasOpen=dd.classList.contains('open');
+      closeAll();
+      if(!wasOpen){ dd.classList.add('open'); btn.setAttribute('aria-expanded','true'); }
+    });
+    return dd;
+  }
+
+  // ── navigation: write params + go (resets paging so Boost re-queries) ─────
+  function navTo(params){
+    params.delete('page');            // jump back to page 1 on any filter change
+    var qs=params.toString();
+    window.location.href = window.location.pathname + (qs?('?'+qs):'');
+  }
+
+  function closeAll(){
+    root.querySelectorAll('.dw-dd.open').forEach(function(d){ d.classList.remove('open'); var b=d.querySelector('.dw-dd__btn'); if(b)b.setAttribute('aria-expanded','false'); });
+    var g=root.querySelector('[data-tbf-gear]'); if(g)g.classList.remove('open');
+  }
+  document.addEventListener('click',function(ev){ if(!root.contains(ev.target)) closeAll(); });
+
+  // ── render the field dropdowns according to enabled set ──────────────────
+  function renderFields(){
+    var host=root.querySelector('[data-tbf-fields]'); if(!host) return;
+    var en=enabledSet();
+    host.innerHTML='';
+    FIELDS.forEach(function(f){ if(en.has(f.key)) host.appendChild(buildDropdown(f)); });
+    // clear-all enabled when any pf_*/q is present
+    var anyActive=false; curParams().forEach(function(v,k){ if(k.indexOf('pf_')===0||k==='q') anyActive=true; });
+    var clr=root.querySelector('[data-tbf-clear]'); if(clr) clr.disabled=!anyActive;
+  }
+
+  // ── gear menu: per-field ON/OFF, persisted ───────────────────────────────
+  function renderGear(){
+    var list=root.querySelector('[data-tbf-gear-list]'); if(!list) return;
+    var en=enabledSet();
+    list.innerHTML=FIELDS.map(function(f){
+      return '<label class="dw-opt"><input type="checkbox" data-fkey="'+f.key+'" '+(en.has(f.key)?'checked':'')+'><span class="dw-on">'+esc(f.label)+'</span></label>';
+    }).join('');
+    list.querySelectorAll('input[data-fkey]').forEach(function(cb){
+      cb.addEventListener('change',function(){
+        var s=enabledSet();
+        if(cb.checked) s.add(cb.getAttribute('data-fkey')); else s.delete(cb.getAttribute('data-fkey'));
+        lsSet(LS.fields, JSON.stringify(Array.from(s)));
+        renderFields();
+      });
+    });
+  }
+  var gearBtn=root.querySelector('[data-tbf-gear-btn]'), gear=root.querySelector('[data-tbf-gear]');
+  if(gearBtn&&gear){ gearBtn.addEventListener('click',function(e){ e.stopPropagation(); var o=gear.classList.contains('open'); closeAll(); if(!o){gear.classList.add('open');gearBtn.setAttribute('aria-expanded','true');} else gearBtn.setAttribute('aria-expanded','false'); }); }
+
+  // ── sort ─────────────────────────────────────────────────────────────────
+  var sortSel=root.querySelector('[data-tbf-sort]');
+  if(sortSel){
+    var curSort=curParams().get('sort_by')||lsGet(LS.sort,'');
+    if(curSort){ var opt=sortSel.querySelector('option[value="'+curSort+'"]'); if(opt) sortSel.value=curSort; }
+    sortSel.addEventListener('change',function(){
+      lsSet(LS.sort,this.value); var p=curParams();
+      if(this.value) p.set('sort_by',this.value); else p.delete('sort_by');
+      navTo(p);
+    });
+  }
+
+  // ── density (drives Boost's grid var ONLY, never the grid nodes) ──────────
+  // We set the CSS custom property --dw-grid-cols that the live theme's Boost
+  // override already binds to .boost-sd__product-list. We do NOT touch list
+  // children, never set grid-template on the list directly here unless the var
+  // isn't wired — purely additive, desktop-only.
+  var dens=root.querySelector('[data-tbf-density]'), dlabel=root.querySelector('[data-tbf-dlabel]');
+  function applyDensity(n){
+    document.documentElement.style.setProperty('--dw-grid-cols', n);
+    var list=document.querySelector('.boost-sd__product-list');
+    if(list && !getComputedStyle(list).getPropertyValue('--dw-grid-cols')){
+      // fallback only if the theme var isn't bound — still never removes nodes
+      list.style.gridTemplateColumns='repeat('+n+',minmax(0,1fr))';
+    }
+    if(dlabel) dlabel.textContent=n;
+  }
+  if(dens){
+    var savedD=lsGet(LS.density,'4'); dens.value=savedD; applyDensity(parseInt(savedD,10)||4);
+    dens.addEventListener('input',function(){ var n=parseInt(this.value,10)||4; lsSet(LS.density,n); applyDensity(n); });
+  }
+
+  // ── search ────────────────────────────────────────────────────────────────
+  var srch=root.querySelector('[data-tbf-search]');
+  if(srch){
+    srch.value=curParams().get('q')||'';
+    var t; srch.addEventListener('input',function(){ clearTimeout(t); var self=this; t=setTimeout(function(){
+      lsSet(LS.search,self.value); var p=curParams();
+      if(self.value.trim()) p.set('q',self.value.trim()); else p.delete('q');
+      navTo(p);
+    },450); });
+    srch.addEventListener('keydown',function(e){ if(e.key==='Enter'){ clearTimeout(t); var p=curParams(); if(this.value.trim())p.set('q',this.value.trim());else p.delete('q'); navTo(p);} });
+  }
+
+  // ── clear all ──────────────────────────────────────────────────────────────
+  var clr=root.querySelector('[data-tbf-clear]');
+  if(clr) clr.addEventListener('click',function(){
+    var p=curParams(); var del=[];
+    p.forEach(function(v,k){ if(k.indexOf('pf_')===0||k==='q'||k==='page') del.push(k); });
+    del.forEach(function(k){ p.delete(k); });
+    navTo(p);
+  });
+
+  // ── re-read Boost options when Boost re-renders (debounced, read-only) ────
+  // Only repopulates OUR dropdowns; never writes to Boost's tree.
+  var ro; var target=document.querySelector('.boost-sd__filter-tree')||document.querySelector('.boost-sd__sidebar');
+  if(target){
+    var mo=new MutationObserver(function(){ clearTimeout(ro); ro=setTimeout(renderFields,300); });
+    mo.observe(target,{childList:true,subtree:true});
+  }
+
+  // ── boot ──────────────────────────────────────────────────────────────────
+  renderFields(); renderGear();
+})();
+</script>
+
+{% schema %}
+{
+  "name": "DW Boost Filter Topbar",
+  "tag": "section",
+  "class": "dw-boost-filter-topbar",
+  "settings": [
+    {
+      "type": "header",
+      "content": "Horizontal dropdown filter toolbar that drives Boost's pf_* params. Per-field show/hide is controlled by the shopper via the ⚙ menu (persisted in their browser); these defaults only seed first-visit."
+    },
+    {
+      "type": "color",
+      "id": "color_accent",
+      "label": "Accent color",
+      "default": "#111111"
+    }
+  ],
+  "presets": [
+    { "name": "DW Boost Filter Topbar" }
+  ]
+}
+{% endschema %}

← b360daed auto-save: 2026-06-25T16:05:06 (8 files) — DW-Agents/vendor-  ·  back to Designer Wallcoverings  ·  Add field->Boost mapping + data-layer spec README for dw-boo 3ebc8cd9 →