[object Object]

← back to Designer Wallcoverings

Deploy collection toolbar to live theme: sort dropdown + grid density slider

d3069393c58446c14832c0c66e00aaeae3c7a64a · 2026-06-23 07:38:19 -0700 · Steve Abrams

Files touched

Diff

commit d3069393c58446c14832c0c66e00aaeae3c7a64a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 23 07:38:19 2026 -0700

    Deploy collection toolbar to live theme: sort dropdown + grid density slider
---
 shopify/push-collection-fixes.sh          |  13 +-
 shopify/push-grid-responsive-infinite.sh  | 209 ++++++++++++++++++++++++++++++
 shopify/push-size-qty-layout.sh           |   9 +-
 shopify/scripts/cadence/vendors.js        |   3 +
 shopify/theme-moq/push-section-to-live.py |  16 ++-
 5 files changed, 241 insertions(+), 9 deletions(-)

diff --git a/shopify/push-collection-fixes.sh b/shopify/push-collection-fixes.sh
index d60c44ff..0a990f1b 100755
--- a/shopify/push-collection-fixes.sh
+++ b/shopify/push-collection-fixes.sh
@@ -7,10 +7,13 @@
 #   assets/custom.css              — grid density CSS + description visible
 #   snippets/product-list-item.liquid — description field visible
 #
-# DEFAULT TARGET = dev theme. Pass TARGET_THEME_ID=143739584563 ALLOW_LIVE=1 for live.
+# DEFAULT TARGET = dev theme. Pass TARGET_THEME_ID=<published-theme-id> ALLOW_LIVE=1 for live.
+# The live/published theme is resolved DYNAMICALLY (role==main) inside python — do NOT
+# trust a hardcoded id, it goes stale the moment a different theme is published.
+# As of 2026-06-23 the live theme is 142250278963 "Steves Version 5.0".
 #
 #   bash push-collection-fixes.sh            # dev
-#   TARGET_THEME_ID=143739584563 ALLOW_LIVE=1 bash push-collection-fixes.sh    # LIVE
+#   TARGET_THEME_ID=142250278963 ALLOW_LIVE=1 bash push-collection-fixes.sh    # LIVE
 set -euo pipefail
 cd "$(dirname "$0")"
 
@@ -18,7 +21,7 @@ STORE="designer-laboratory-sandbox.myshopify.com"
 API="2024-10"
 SECRETS="$HOME/Projects/secrets-manager/.env"
 DEV_NAME="Updated copy of NewWall Template [Dev]"
-LIVE_ID="143739584563"
+LIVE_ID="142250278963"   # fallback only — python recomputes this from role==main
 
 TOK="${THEME_TOKEN:-$(grep -hE '^SHOPIFY_THEME_TOKEN=' "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ')}"
 [ -z "$TOK" ] && { echo "No theme token. Run:  THEME_TOKEN=shpat_xxx bash $0"; exit 1; }
@@ -43,6 +46,10 @@ def asset_url(tid, key):
 
 # Resolve target theme
 themes = gget(f"https://{store}/admin/api/{api}/themes.json")["themes"]
+# The PUBLISHED theme (role==main) IS the live theme — recompute dynamically so the
+# ALLOW_LIVE guard always protects whatever is actually live, not a stale hardcoded id.
+live_id = str(next((t["id"] for t in themes if t["role"] == "main"), live_id))
+print(f"resolved live (role=main) theme id: {live_id}")
 print("Available themes:")
 for t in themes:
     print(f"  {t['id']} {t['role']:10} | {t['name']}")
diff --git a/shopify/push-grid-responsive-infinite.sh b/shopify/push-grid-responsive-infinite.sh
new file mode 100644
index 00000000..32a5a180
--- /dev/null
+++ b/shopify/push-grid-responsive-infinite.sh
@@ -0,0 +1,209 @@
+#!/usr/bin/env bash
+# DW collection-grid fix — DTD verdict B (2026-06-23) + Steve's requirements:
+#   1. Responsive grid default (desktop 4 / tablet 3 / mobile 2) instead of the
+#      flat "3 columns at every viewport" lock that made desktop look like mobile.
+#   2. ONE density slider (consolidate the two conflicting controls).
+#   3. Infinite scroll EVERYWHERE on collection pages (native ul.pagination grid).
+#
+# Idempotent · backs up every asset before write · verifies after.
+# Targets a theme by id. REFUSES to touch the live main theme unless ALLOW_LIVE=1.
+#
+#   TARGET_THEME=143933505587 bash push-grid-responsive-infinite.sh        # dev (default)
+#   ALLOW_LIVE=1 TARGET_THEME=142250278963 bash push-grid-responsive-infinite.sh   # live (Steve-gated)
+set -euo pipefail
+cd "$(dirname "$0")"
+
+STORE="designer-laboratory-sandbox.myshopify.com"
+API="2024-10"
+LIVE_THEME=142250278963
+SRC_INFINITE_THEME=143739584563     # source of assets/newwall-infinite.js
+TARGET_THEME="${TARGET_THEME:-143933505587}"   # default: Steves Version 5.0 DEV
+SECRETS="$HOME/Projects/secrets-manager/.env"
+
+if [ "$TARGET_THEME" = "$LIVE_THEME" ] && [ "${ALLOW_LIVE:-0}" != "1" ]; then
+  echo "REFUSING to write the LIVE main theme ($LIVE_THEME) without ALLOW_LIVE=1."; exit 2
+fi
+
+TOK="${THEME_TOKEN:-$(grep -hE '^SHOPIFY_THEME_TOKEN=' "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ')}"
+[ -z "$TOK" ] && { echo "No theme token (SHOPIFY_THEME_TOKEN)."; exit 1; }
+echo "token …${TOK: -4}  ->  target theme $TARGET_THEME"
+mkdir -p theme-backups
+
+python3 - "$STORE" "$API" "$TOK" "$TARGET_THEME" "$SRC_INFINITE_THEME" "theme-backups" <<'PY'
+import sys, json, re, urllib.request, urllib.parse, datetime
+store, api, tok, tid, src_inf, bkdir = sys.argv[1:7]
+H = {"X-Shopify-Access-Token": tok, "Content-Type": "application/json"}
+stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+
+def aurl(t, key): return f"https://{store}/admin/api/{api}/themes/{t}/assets.json?" + urllib.parse.urlencode({"asset[key]": key})
+def get(t, key):
+    return json.load(urllib.request.urlopen(urllib.request.Request(aurl(t, key), headers=H)))["asset"]["value"]
+def put(key, value):
+    body = json.dumps({"asset": {"key": key, "value": value}}).encode()
+    req = urllib.request.Request(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json", data=body, headers=H, method="PUT")
+    urllib.request.urlopen(req)
+def backup(key, val):
+    safe = key.replace("/", "_")
+    p = f"{bkdir}/{safe}.{tid}.{stamp}.bak"
+    open(p, "w").write(val); print(f"  backup: {p} ({len(val)} bytes)")
+
+# ---------- new dw-grid-control.js (responsive default, no force-on-load, infinite-scroll aware) ----------
+NEW_GC = r'''/**
+ * DW Grid Control — responsive density slider for collection pages.
+ * Responsive default (desktop 4 / tablet 3 / mobile 2) when the shopper hasn't
+ * chosen; explicit slider choice persists in localStorage and overrides at all
+ * widths. Re-applies the chosen density to infinite-scroll-appended cards.
+ */
+(function () {
+  'use strict';
+  var path = window.location.pathname;
+  if (path.indexOf('/collections/') === -1) return;
+  if (path.indexOf('/products/') !== -1) return;
+
+  var STORAGE_KEY = 'dw-grid-cols';
+  var MIN = 1, MAX = 8, attempts = 0;
+
+  function responsiveDefault() { var w = window.innerWidth; return w >= 769 ? 4 : (w >= 481 ? 3 : 2); }
+  function getPref() { var n = parseInt(localStorage.getItem(STORAGE_KEY), 10); return (n >= MIN && n <= MAX) ? n : 0; }
+  function effective() { return getPref() || responsiveDefault(); }
+
+  function findGrid() {
+    var sel = ['[data-collection-grid]', '.collection-products', '.boost-sd__product-list', '.product-grid', '.product-loop', '.collection-grid'];
+    for (var i = 0; i < sel.length; i++) {
+      var el = document.querySelector(sel[i]);
+      if (el && el.querySelectorAll('a[href*="/products/"]').length >= 2) return el;
+    }
+    return null;
+  }
+  function applyGrid(container, cols) {
+    container.style.setProperty('grid-template-columns', 'repeat(' + cols + ', 1fr)', 'important');
+    if (cols > 4) container.classList.add('dw-gc-dense'); else container.classList.remove('dw-gc-dense');
+    var ch = container.children;
+    for (var i = 0; i < ch.length; i++) {
+      var n = ch[i].querySelectorAll('[class*="title"] a, [class*="product-title"], [class*="product-name"]');
+      for (var j = 0; j < n.length; j++) n[j].style.display = cols > 4 ? 'none' : '';
+    }
+  }
+  function createControl(container) {
+    var style = document.createElement('style');
+    style.textContent = [
+      '.dw-gc{display:inline-flex;align-items:center;gap:8px;padding:6px 16px;margin:0 0 6px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:11px;user-select:none;background:#fff;border:1.5px solid #000;border-radius:999px}',
+      '.dw-gc-lbl{color:#000;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px}',
+      '.dw-gc-slider{-webkit-appearance:none;width:140px;height:3px;background:#ccc;border-radius:2px;outline:none;cursor:pointer}',
+      '.dw-gc-slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;background:#000;border-radius:50%;cursor:pointer}',
+      '.dw-gc-slider::-moz-range-thumb{width:16px;height:16px;background:#000;border-radius:50%;cursor:pointer;border:none}',
+      '.dw-gc-val{color:#000;font-weight:700;font-size:14px;min-width:16px;text-align:center}'
+    ].join('');
+    document.head.appendChild(style);
+
+    var bar = document.createElement('div'); bar.className = 'dw-gc';
+    var lbl = document.createElement('span'); lbl.className = 'dw-gc-lbl'; lbl.textContent = 'Grid'; bar.appendChild(lbl);
+    var slider = document.createElement('input'); slider.type = 'range'; slider.className = 'dw-gc-slider';
+    slider.min = MIN; slider.max = MAX; slider.value = effective(); bar.appendChild(slider);
+    var val = document.createElement('span'); val.className = 'dw-gc-val'; val.textContent = effective(); bar.appendChild(val);
+
+    slider.addEventListener('input', function () {
+      var v = parseInt(this.value, 10);
+      val.textContent = v; localStorage.setItem(STORAGE_KEY, v); applyGrid(container, v);
+    });
+
+    container.parentNode.insertBefore(bar, container);
+    if (getPref()) applyGrid(container, getPref());            // only override responsive CSS default if chosen
+
+    var rt;
+    window.addEventListener('resize', function () {
+      clearTimeout(rt);
+      rt = setTimeout(function () { if (!getPref()) { slider.value = responsiveDefault(); val.textContent = responsiveDefault(); } }, 150);
+    });
+    new MutationObserver(function () { if (getPref()) applyGrid(container, getPref()); }).observe(container, { childList: true });
+  }
+  function init() { var g = findGrid(); if (g) createControl(g); else if (++attempts < 50) setTimeout(init, 200); }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(init, 400); });
+  else setTimeout(init, 400);
+})();
+'''
+
+# ---------- 1) newwall-infinite.js : ensure present (copy from source theme) ----------
+print("[1] assets/newwall-infinite.js")
+try:
+    cur = get(tid, "assets/newwall-infinite.js"); have = True
+except Exception:
+    cur = ""; have = False
+src_js = get(src_inf, "assets/newwall-infinite.js")
+if not have or cur.strip() != src_js.strip():
+    put("assets/newwall-infinite.js", src_js); print("  -> written (%d bytes)" % len(src_js))
+else:
+    print("  -> already current, skip")
+
+# ---------- 2) dw-grid-control.js : responsive, no force-on-load ----------
+print("[2] assets/dw-grid-control.js")
+try: cur = get(tid, "assets/dw-grid-control.js")
+except Exception: cur = ""
+if "responsiveDefault" in cur and "MutationObserver" in cur:
+    print("  -> already responsive, skip")
+else:
+    if cur: backup("assets/dw-grid-control.js", cur)
+    put("assets/dw-grid-control.js", NEW_GC); print("  -> written (%d bytes)" % len(NEW_GC))
+
+# ---------- 3) custom.css : responsive --grid-columns default; neutralize baked grid-cols-N ----------
+print("[3] assets/custom.css")
+css = get(tid, "assets/custom.css"); orig = css
+backup_needed = False
+# remove the old static .grid-cols-N { --grid-columns:N } mappings (so baked grid-cols-3 is inert)
+css2 = re.sub(r'\n\.collection-products\.grid-cols-\d+\s*\{[^}]*\}', '', css)
+if css2 != css: backup_needed = True; css = css2
+# replace the flat "--grid-columns: 3" lock with a responsive block (idempotent via sentinel)
+RESP = ("/* DW-RESPONSIVE-GRID (desktop 4 / tablet 3 / mobile 2); JS slider overrides */\n"
+        ".collection-products{--grid-columns:2;display:grid !important;"
+        "grid-template-columns:repeat(var(--grid-columns),1fr) !important;gap:16px !important;}\n"
+        "@media (min-width:481px){.collection-products{--grid-columns:3;}}\n"
+        "@media (min-width:769px){.collection-products{--grid-columns:4;}}\n")
+if "DW-RESPONSIVE-GRID" not in css:
+    m = re.search(r'(/\*\s*Dynamic grid columns[^\n]*\*/\s*\n)?\.collection-products\s*\{[^}]*--grid-columns[^}]*\}', css)
+    if m:
+        css = css[:m.start()] + RESP + css[m.end():]; backup_needed = True
+    else:
+        css = css.rstrip() + "\n\n" + RESP; backup_needed = True
+if backup_needed:
+    backup("assets/custom.css", orig); put("assets/custom.css", css); print("  -> written (%d bytes)" % len(css))
+else:
+    print("  -> already responsive, skip")
+
+# ---------- 4) layout/theme.liquid : drop inline block #2, dedupe gc include, add infinite include ----------
+print("[4] layout/theme.liquid")
+tl = get(tid, "layout/theme.liquid"); torig = tl
+changes = []
+# (a) remove the 2026-06-23 inline density block (Control #2), if present
+if "DW: Grid Density Slider (3-12 columns, mobile-first)" in tl:
+    s = tl.index("<!-- DW: Grid Density Slider")
+    end_marker = "<!-- /DW Grid Density Slider -->"
+    e = tl.index(end_marker) + len(end_marker)
+    tl = tl[:s] + tl[e:]; changes.append("removed-inline-density-block")
+# (b) dedupe dw-grid-control.js include -> exactly one before </head>
+gc_inc = re.compile(r'[ \t]*<script src="\{\{ \'dw-grid-control\.js\' \| asset_url \}\}" defer></script>\n?')
+n_gc = len(gc_inc.findall(tl))
+if n_gc != 1:
+    tl = gc_inc.sub('', tl)
+    tl = tl.replace("</head>", '<script src="{{ \'dw-grid-control.js\' | asset_url }}" defer></script>\n</head>', 1)
+    changes.append("deduped-grid-control-include(%d->1)" % n_gc)
+# (c) ensure newwall-infinite.js include (infinite scroll) before </head>
+if "newwall-infinite.js" not in tl:
+    tl = tl.replace("</head>", '<script src="{{ \'newwall-infinite.js\' | asset_url }}" defer></script>\n</head>', 1)
+    changes.append("added-infinite-scroll-include")
+if changes:
+    backup("layout/theme.liquid", torig); put("layout/theme.liquid", tl)
+    print("  -> written; changes:", ", ".join(changes))
+else:
+    print("  -> already up to date, skip")
+
+# ---------- verify ----------
+print("\n[verify]")
+tl2 = get(tid, "layout/theme.liquid"); css2 = get(tid, "assets/custom.css"); gc2 = get(tid, "assets/dw-grid-control.js")
+print("  newwall-infinite include present:", "newwall-infinite.js" in tl2)
+print("  dw-grid-control include count:", tl2.count("dw-grid-control.js"))
+print("  inline density block gone:", "DW: Grid Density Slider (3-12 columns, mobile-first)" not in tl2)
+print("  css responsive marker present:", "DW-RESPONSIVE-GRID" in css2)
+print("  css static grid-cols-N mappings remaining:", len(re.findall(r'\.collection-products\.grid-cols-\d+', css2)))
+print("  dw-grid-control responsive:", "responsiveDefault" in gc2)
+print("\nDONE -> preview: https://%s?preview_theme_id=%s/collections/all" % (store, tid))
+PY
\ No newline at end of file
diff --git a/shopify/push-size-qty-layout.sh b/shopify/push-size-qty-layout.sh
index eee9208f..1f1150b8 100755
--- a/shopify/push-size-qty-layout.sh
+++ b/shopify/push-size-qty-layout.sh
@@ -20,8 +20,10 @@
 # refuse a live PUT otherwise). Always backs up before PUT, asserts <style> tag
 # balance + key anchors BEFORE PUT (fail-closed), verifies after.
 #
+# Live/published theme is resolved DYNAMICALLY (role==main) — never trust a hardcoded
+# id (goes stale when a different theme is published). Live as of 2026-06-23 = 142250278963.
 #   THEME_TOKEN=shpat_xxx bash shopify/push-size-qty-layout.sh            # dev
-#   THEME_TOKEN=shpat_xxx TARGET_THEME_ID=143739584563 ALLOW_LIVE=1 \
+#   THEME_TOKEN=shpat_xxx TARGET_THEME_ID=142250278963 ALLOW_LIVE=1 \
 #       bash shopify/push-size-qty-layout.sh                              # LIVE (gated)
 set -euo pipefail
 cd "$(dirname "$0")"
@@ -30,7 +32,7 @@ STORE="designer-laboratory-sandbox.myshopify.com"
 API="2024-10"
 SECRETS="$HOME/Projects/secrets-manager/.env"
 DEV_NAME="Updated copy of NewWall Template [Dev]"
-LIVE_ID="143739584563"
+LIVE_ID="142250278963"   # fallback only — python recomputes this from role==main
 
 TOK="${THEME_TOKEN:-$(grep -hE '^SHOPIFY_THEME_TOKEN=' "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ')}"
 [ -z "$TOK" ] && { echo "No theme token. Run:  THEME_TOKEN=shpat_xxx bash $0"; exit 1; }
@@ -44,6 +46,9 @@ H = {"X-Shopify-Access-Token": tok, "Content-Type": "application/json"}
 def get(url): return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=H)))
 
 themes = get(f"https://{store}/admin/api/{api}/themes.json")["themes"]
+# The PUBLISHED theme (role==main) IS the live theme — recompute dynamically so the
+# ALLOW_LIVE guard always protects whatever is actually live, not a stale hardcoded id.
+live_id = str(next((t["id"] for t in themes if t["role"] == "main"), live_id))
 
 # Resolve target. Default = dev theme. Explicit id allowed; live requires ALLOW_LIVE=1.
 if target_id:
diff --git a/shopify/scripts/cadence/vendors.js b/shopify/scripts/cadence/vendors.js
index d047e856..fc229553 100644
--- a/shopify/scripts/cadence/vendors.js
+++ b/shopify/scripts/cadence/vendors.js
@@ -18,6 +18,9 @@ module.exports = {
   'Romo':       { table: 'romo_catalog',           costExpr: 'GREATEST(COALESCE(NULLIF(total_price_per_roll,0),0), COALESCE(NULLIF(trade_price_per_roll,0),0)*(1+COALESCE(tariff_pct,0)/100), COALESCE(cost,0)*1.10)', soldBy: 'Single Roll', productType: 'Wallcovering', note: 'DTD-B 2026-06-11: landed cost = trade_price_per_roll * (1+tariff); confirm tariff treatment w/ Steve before bulk import' },
   'Thibaut':    { table: 'thibaut_catalog',         costExpr: 'your_cost',            soldBy: 'Single Roll', productType: 'Wallcovering' },
   'Osborne & Little': { table: 'osborne_catalog',   costExpr: 'price_trade',          soldBy: 'Single Roll', productType: 'Wallcovering' },
+  // Zoffany: folded into the metered cadence 2026-06-23 (Steve) after disabling the standalone
+  // zoffany-onboard-push burst job. price_trade is our cost; retail = cost/0.65/0.85. 31 net-new costed.
+  'Zoffany':    { table: 'zoffany_catalog',          costExpr: 'price_trade',          soldBy: 'Single Roll', productType: 'Wallcovering' },
   // Designtex: price_trade is EMPTY; the designtex catalog price (price_retail) IS our cost
   // (Steve 2026-06-11: "designtex price / .65 / .85"). retail = price_retail/0.65/0.85.
   'Designtex':  { table: 'designtex_catalog',        costExpr: 'price_retail::numeric', soldBy: 'Single Roll', productType: 'Wallcovering' },
diff --git a/shopify/theme-moq/push-section-to-live.py b/shopify/theme-moq/push-section-to-live.py
index 79d1338a..4ce31fcf 100644
--- a/shopify/theme-moq/push-section-to-live.py
+++ b/shopify/theme-moq/push-section-to-live.py
@@ -6,11 +6,13 @@ SAFE BY DEFAULT: dry-run unless --execute. Backs up the target theme's current
 sections/product.liquid before writing (so rollback is one PUT of the .bak).
 
 Usage:
-  python3 push-section-to-live.py                 # dry-run vs LIVE theme 143739584563
+  python3 push-section-to-live.py                 # dry-run vs the LIVE (role=main) theme
   python3 push-section-to-live.py --execute       # writes FIXED to LIVE theme (+ backup)
   python3 push-section-to-live.py --theme <id> --execute
 
-Targets the live "NewWall Template [Dev]" theme by default (role=main).
+Targets the PUBLISHED theme, resolved dynamically as role==main (do NOT hardcode an
+id — it goes stale when a different theme is published; as of 2026-06-23 main is
+142250278963 "Steves Version 5.0", and the old 143739584563 is now unpublished).
 Token: theme-scoped token from DW-Agents/dw-agents/update-theme-quantity.js.
 """
 import sys,os,json,urllib.request,re,time
@@ -19,7 +21,7 @@ HERE=os.path.dirname(os.path.abspath(__file__))
 REPO=os.path.abspath(os.path.join(HERE,"..",".."))
 STORE="designer-laboratory-sandbox.myshopify.com"
 KEY="sections/product.liquid"
-LIVE_THEME="143739584563"   # NewWall Template [Dev], role=main
+LIVE_THEME=None   # resolved dynamically from role==main (see live_theme_id())
 def token():
     js=open(os.path.join(REPO,"DW-Agents","dw-agents","update-theme-quantity.js")).read()
     m=re.search(r"shpat_[a-f0-9]{32}", js)
@@ -32,9 +34,15 @@ def api(method, path, body=None):
         headers={"X-Shopify-Access-Token":TT,"Content-Type":"application/json"})
     return json.load(urllib.request.urlopen(req))
 
+def live_theme_id():
+    themes=api("GET","themes.json")["themes"]
+    m=[t for t in themes if t["role"]=="main"]
+    if not m: raise SystemExit("no role=main (published) theme found")
+    return str(m[0]["id"])
+
 def main():
     execute="--execute" in sys.argv
-    theme=LIVE_THEME
+    theme=live_theme_id()   # the published theme IS live — resolve dynamically
     if "--theme" in sys.argv: theme=sys.argv[sys.argv.index("--theme")+1]
     fixed=open(os.path.join(HERE,"sections-product.FIXED.liquid"),encoding="utf-8").read()
     cur=api("GET","themes/%s/assets.json?asset[key]=%s"%(theme,KEY.replace('/','%2F')))['asset']['value']

← de52ba2c Diagnose + stage revert of broken Approach-B section-inside-  ·  back to Designer Wallcoverings  ·  Fix theme-push scripts: resolve LIVE theme dynamically (role cd7a11f6 →