← back to Designer Wallcoverings
Add idempotent size/qty layout fixer (strip-all-then-inject-one, fail-closed style-balance + anchor asserts, dev-default/live-gated)
77dea01c66eca768242ce121d99ce2cc45b031bc · 2026-06-22 13:03:10 -0700 · steve
Files touched
A shopify/push-size-qty-layout.sh
Diff
commit 77dea01c66eca768242ce121d99ce2cc45b031bc
Author: steve <steve@designerwallcoverings.com>
Date: Mon Jun 22 13:03:10 2026 -0700
Add idempotent size/qty layout fixer (strip-all-then-inject-one, fail-closed style-balance + anchor asserts, dev-default/live-gated)
---
shopify/push-size-qty-layout.sh | 162 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 162 insertions(+)
diff --git a/shopify/push-size-qty-layout.sh b/shopify/push-size-qty-layout.sh
new file mode 100755
index 00000000..eee9208f
--- /dev/null
+++ b/shopify/push-size-qty-layout.sh
@@ -0,0 +1,162 @@
+#!/usr/bin/env bash
+# IDEMPOTENT Size/Quantity layout fixer for the legacy PDP.
+#
+# THE BUG: prior runs APPENDED conflicting <style> blocks (duplicated
+# .product-options{display:flex} side-by-side blocks, runtime JS that wraps
+# Size+Quantity into a flex row, grid 1fr 1fr side-by-side blocks, malformed
+# unclosed <style> tags, and a "FORCE vertical" block targeting flex-PDP classes
+# OptionSelector/quantity that DON'T EXIST on the legacy template). On legacy the
+# display:flex blocks win → Quantity is jammed onto the variant row.
+#
+# THE FIX (converges, never re-chaos): strip EVERY DW size/qty block, then inject
+# exactly ONE clean grid block. `.product-options` is NATIVELY a CSS grid
+# (max-content minmax(220px,max-content)) in assets/theme.css — that already
+# stacks Quantity BELOW Size. This block just restores/forces that grid and slots
+# the dw-variant-btns into column 2, hiding the native select. Re-running this is
+# a no-op once converged.
+#
+# DEFAULT TARGET = the DEV theme (never live). To target live you must pass
+# TARGET_THEME_ID explicitly AND set ALLOW_LIVE=1 (Steve-gated; this script will
+# refuse a live PUT otherwise). Always backs up before PUT, asserts <style> tag
+# balance + key anchors BEFORE PUT (fail-closed), verifies after.
+#
+# THEME_TOKEN=shpat_xxx bash shopify/push-size-qty-layout.sh # dev
+# THEME_TOKEN=shpat_xxx TARGET_THEME_ID=143739584563 ALLOW_LIVE=1 \
+# bash shopify/push-size-qty-layout.sh # LIVE (gated)
+set -euo pipefail
+cd "$(dirname "$0")"
+
+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"
+
+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; }
+echo "token …${TOK: -4}"
+
+mkdir -p theme-backups
+python3 - "$STORE" "$API" "$TOK" "$DEV_NAME" "$LIVE_ID" "theme-backups" "${TARGET_THEME_ID:-}" "${ALLOW_LIVE:-0}" <<'PY'
+import sys, json, re, urllib.request, urllib.parse, datetime
+store, api, tok, dev_name, live_id, bkdir, target_id, allow_live = sys.argv[1:10]
+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"]
+
+# Resolve target. Default = dev theme. Explicit id allowed; live requires ALLOW_LIVE=1.
+if target_id:
+ match = [t for t in themes if str(t["id"]) == str(target_id)]
+ if not match: print(f"target theme id {target_id} not found"); sys.exit(1)
+ theme = match[0]
+ if str(theme["id"]) == live_id and allow_live != "1":
+ print("REFUSING live PUT: set ALLOW_LIVE=1 to target the main/live theme."); sys.exit(2)
+else:
+ match = [t for t in themes if t["name"] == dev_name and t["role"] != "main"]
+ if not match: print(f'dev theme "{dev_name}" not found'); sys.exit(1)
+ theme = match[0]
+tid = theme["id"]
+is_live = (str(tid) == live_id)
+print(f'=== target: {tid} "{theme["name"]}" (role={theme["role"]}, LIVE={is_live}) ===')
+
+key = "layout/theme.liquid"
+q = urllib.parse.urlencode({"asset[key]": key})
+cur = get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
+stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+tag = "LIVE-"+live_id if is_live else f"dev-{tid}"
+bk = f"{bkdir}/theme.liquid.{tag}.{stamp}.sizeqty.bak"
+open(bk, "w").write(cur); print(f"backup: {bk} ({len(cur)} bytes)")
+
+src = cur
+
+# 1) STRIP every prior DW size/qty injection. Match all our historical marker
+# comments and the malformed/duplicated style blocks between them.
+SIZEQTY_MARKERS = [
+ r"<!-- DW: size/qty[^>]*-->.*?<!-- /DW size/qty -->",
+ r"<!-- DW SIZE\+QUANTITY[^>]*-->.*?<!-- /DW SIZE\+QUANTITY -->",
+ r"<!-- DW size-qty layout[^>]*-->.*?<!-- /DW size-qty layout -->",
+]
+for pat in SIZEQTY_MARKERS:
+ src = re.sub(pat, "", src, flags=re.S|re.I)
+
+# 2) Belt-and-suspenders: remove any stray <style> block that mentions
+# .product-options AND (flex OR 1fr 1fr OR FORCE) — the chaos signatures —
+# but ONLY well-formed <style>..</style> pairs so we never eat unrelated CSS.
+def strip_chaos_styles(s):
+ out = []
+ i = 0
+ while True:
+ j = s.find("<style", i)
+ if j == -1:
+ out.append(s[i:]); break
+ k = s.find("</style>", j)
+ if k == -1:
+ # malformed unclosed <style> — drop from the open tag to next <style or EOF
+ nxt = s.find("<style", j+6)
+ out.append(s[i:j])
+ i = nxt if nxt != -1 else len(s)
+ continue
+ block = s[j:k+8]
+ chaos = (".product-options" in block and
+ ("display:flex" in block or "display: flex" in block
+ or "1fr 1fr" in block or "FORCE" in block.upper()))
+ if chaos:
+ out.append(s[i:j]) # drop the chaos block
+ else:
+ out.append(s[i:k+8]) # keep
+ i = k+8
+ return "".join(out)
+src = strip_chaos_styles(src)
+
+# 3) Remove any leftover runtime JS that wraps Size+Quantity into a flex row.
+src = re.sub(r"<script>[^<]*?(?:OptionSelector|wrap.*?Size.*?Quantity|flex.*?row)[^<]*?</script>",
+ "", src, flags=re.S|re.I)
+
+# 4) Inject ONE clean grid block. `.product-options` is natively grid; this just
+# forces it + slots dw-variant-btns into col 2 + hides the native last_variant
+# select when our buttons are present. Stacks Quantity BELOW Size.
+CLEAN = (
+"\n<!-- DW size-qty layout (idempotent, single block) — Quantity stacks BELOW Size -->\n"
+"<style>\n"
+" .product-options{ display:grid !important;\n"
+" grid-template-columns:max-content minmax(220px, max-content) !important;\n"
+" column-gap:12px; row-gap:10px; align-items:center; }\n"
+" .product-options .dw-variant-btns{ grid-column:2; align-self:center; }\n"
+" .product-options:has(.dw-variant-btns) .last_variant{ display:none; }\n"
+"</style>\n"
+"<!-- /DW size-qty layout -->\n"
+)
+# place just before </body> if present, else append.
+if "</body>" in src:
+ src = src.replace("</body>", CLEAN + "</body>", 1)
+else:
+ src = src + CLEAN
+
+# --- FAIL-CLOSED ASSERTIONS before any PUT ---
+opens = len(re.findall(r"<style\b", src))
+closes = src.count("</style>")
+assert opens == closes, f"ABORT: <style> tag imbalance after fix ({opens} open / {closes} close)"
+assert "<!-- /DW size-qty layout -->" in src, "ABORT: clean block anchor missing"
+assert src.count("<!-- DW size-qty layout") == 1, "ABORT: more than one DW size-qty block"
+# no chaos signatures should remain inside any product-options style block
+assert "1fr 1fr" not in src or ".product-options" not in src.split("1fr 1fr")[0][-400:], \
+ "ABORT: a side-by-side 1fr 1fr .product-options block survived"
+assert len(src) > 20000, f"ABORT: result implausibly small ({len(src)} bytes)"
+print(f"assertions OK — styles balanced {opens}={closes}, one clean block, no chaos. result {len(src)} bytes")
+
+if src == cur:
+ print("already converged (no change needed). done."); sys.exit(0)
+
+DRY = (allow_live != "1" and is_live) # extra guard, already handled above
+body = json.dumps({"asset": {"key": key, "value": src}}).encode()
+r = urllib.request.urlopen(urllib.request.Request(
+ f"https://{store}/admin/api/{api}/themes/{tid}/assets.json", data=body, method="PUT", headers=H))
+print("PUT HTTP", r.status, "OK", f"({len(src)} bytes)")
+
+ver = get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
+vo, vc = len(re.findall(r"<style\b", ver)), ver.count("</style>")
+print(f"verify: DW size-qty blocks x{ver.count('<!-- DW size-qty layout')} | styles {vo}/{vc} | dw-variant-btns x{ver.count('dw-variant-btns')}")
+print(f"PREVIEW: https://www.designerwallcoverings.com/products/bedford-drive-crocodile-animal-skin-vinyl-rew-6216" + ("" if is_live else f"?preview_theme_id={tid}"))
+print(f"revert: PUT {bk} back to {key} on theme {tid}")
+PY
← e3adc9cd revert: Reset to clean state before SIZE/QUANTITY layout exp
·
back to Designer Wallcoverings
·
Add bucketA-add-roll-variants.js: build sellable roll varian 4be7b7c4 →