← 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
M shopify/push-collection-fixes.shA shopify/push-grid-responsive-infinite.shM shopify/push-size-qty-layout.shM shopify/scripts/cadence/vendors.jsM shopify/theme-moq/push-section-to-live.py
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 →