← back to Designer Wallcoverings
enrich-local: add 503-overload-break (transient Gemini high-demand) + clean resume path; RESUME-ENRICH.md; Gemini recovered, resuming full run
7cb4cc6ec8004eee575cacc673998c4a4433c4ae · 2026-06-23 15:15:06 -0700 · Steve
Files touched
A .gitignore.append.tmpA audits/designtex-uom-fix/designtex-uom-drift-live-77.jsonA audits/designtex-uom-fix/flip-uom-live-77.mjsA shopify/apply-collection-render-heroes.pyA shopify/audit/collection-hero-render-progress.jsonM shopify/collection-hero-fix/dw-collection-hero.cssM shopify/collection-hero-fix/snippets_dw-collection-hero-bg.liquidA shopify/render-collection-heroes.pyM shopify/tres-tintas-room-backups/_regen.ledgerM vendor-scrapers/china-seas-refreshA vendor-scrapers/quadrille-house/RESUME-ENRICH.mdM vendor-scrapers/quadrille-house/enrich-local.jsA verify-collection-hero-fix.js
Diff
commit 7cb4cc6ec8004eee575cacc673998c4a4433c4ae
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 23 15:15:06 2026 -0700
enrich-local: add 503-overload-break (transient Gemini high-demand) + clean resume path; RESUME-ENRICH.md; Gemini recovered, resuming full run
---
.gitignore.append.tmp | 0
.../designtex-uom-drift-live-77.json | 541 +++++++++++++++++++++
audits/designtex-uom-fix/flip-uom-live-77.mjs | 68 +++
shopify/apply-collection-render-heroes.py | 256 ++++++++++
shopify/audit/collection-hero-render-progress.json | 155 ++++++
shopify/collection-hero-fix/dw-collection-hero.css | 12 +-
.../snippets_dw-collection-hero-bg.liquid | 48 +-
shopify/render-collection-heroes.py | 265 ++++++++++
shopify/tres-tintas-room-backups/_regen.ledger | 75 +++
vendor-scrapers/china-seas-refresh | 2 +-
vendor-scrapers/quadrille-house/RESUME-ENRICH.md | 31 ++
vendor-scrapers/quadrille-house/enrich-local.js | 13 +-
verify-collection-hero-fix.js | 91 ++++
13 files changed, 1520 insertions(+), 37 deletions(-)
diff --git a/.gitignore.append.tmp b/.gitignore.append.tmp
new file mode 100644
index 00000000..e69de29b
diff --git a/audits/designtex-uom-fix/designtex-uom-drift-live-77.json b/audits/designtex-uom-fix/designtex-uom-drift-live-77.json
new file mode 100644
index 00000000..4e728e9c
--- /dev/null
+++ b/audits/designtex-uom-fix/designtex-uom-drift-live-77.json
@@ -0,0 +1,541 @@
+[
+ {
+ "sku": "DWDX-220232",
+ "id": "gid://shopify/Product/7865534971955",
+ "title": "Cirrus, Serene Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220233",
+ "id": "gid://shopify/Product/7865535004723",
+ "title": "Grasscloth, Pearl Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220234",
+ "id": "gid://shopify/Product/7865535037491",
+ "title": "Grasscloth, Halva Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220235",
+ "id": "gid://shopify/Product/7865535070259",
+ "title": "Grasscloth, Seagull Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220236",
+ "id": "gid://shopify/Product/7865535103027",
+ "title": "Grasscloth, Sparrow Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220237",
+ "id": "gid://shopify/Product/7865535135795",
+ "title": "Grasscloth, Trinket Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220238",
+ "id": "gid://shopify/Product/7865535168563",
+ "title": "Grasscloth, Moonlight Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220239",
+ "id": "gid://shopify/Product/7865910853683",
+ "title": "Grasscloth, Selenium Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220240",
+ "id": "gid://shopify/Product/7865910886451",
+ "title": "Grasscloth, Nougat Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220241",
+ "id": "gid://shopify/Product/7865910919219",
+ "title": "Grasscloth, Umber Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220242",
+ "id": "gid://shopify/Product/7865910951987",
+ "title": "Grasscloth, Plume Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220243",
+ "id": "gid://shopify/Product/7865910984755",
+ "title": "Grasscloth, Mint Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220244",
+ "id": "gid://shopify/Product/7865911017523",
+ "title": "Grasscloth, Sprig Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220245",
+ "id": "gid://shopify/Product/7865911050291",
+ "title": "Grasscloth, Raincloud Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220246",
+ "id": "gid://shopify/Product/7865911083059",
+ "title": "Grasscloth, Sail Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220247",
+ "id": "gid://shopify/Product/7865974816819",
+ "title": "Grasscloth, Lakeside Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220248",
+ "id": "gid://shopify/Product/7865974849587",
+ "title": "Grasscloth, Indigo Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220249",
+ "id": "gid://shopify/Product/7865974882355",
+ "title": "Gilded, Plaster Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220250",
+ "id": "gid://shopify/Product/7865974915123",
+ "title": "Gilded, Pearl Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220251",
+ "id": "gid://shopify/Product/7865974947891",
+ "title": "Gilded, Ivory Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220252",
+ "id": "gid://shopify/Product/7865974980659",
+ "title": "Gilded, Oyster Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220253",
+ "id": "gid://shopify/Product/7865975013427",
+ "title": "Gilded, Beige Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220254",
+ "id": "gid://shopify/Product/7865975046195",
+ "title": "Gilded, Saddle Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220255",
+ "id": "gid://shopify/Product/7866018234419",
+ "title": "Gilded, Platinum Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220256",
+ "id": "gid://shopify/Product/7866018267187",
+ "title": "Gilded, Metal Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220257",
+ "id": "gid://shopify/Product/7866018299955",
+ "title": "Gilded, Beige Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220258",
+ "id": "gid://shopify/Product/7866018332723",
+ "title": "Gilded, Fawn Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220259",
+ "id": "gid://shopify/Product/7866018365491",
+ "title": "Gilded, Flaxen Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220260",
+ "id": "gid://shopify/Product/7866018398259",
+ "title": "Gilded, Chestnut Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220261",
+ "id": "gid://shopify/Product/7866018431027",
+ "title": "Gilded, Gold Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220262",
+ "id": "gid://shopify/Product/7866018463795",
+ "title": "Gilded, Mist Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220263",
+ "id": "gid://shopify/Product/7866040778803",
+ "title": "Gilded, Teal Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220264",
+ "id": "gid://shopify/Product/7866040811571",
+ "title": "Gilded, Raincloud Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220265",
+ "id": "gid://shopify/Product/7866040844339",
+ "title": "Cottage, Frost Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220266",
+ "id": "gid://shopify/Product/7866040877107",
+ "title": "Cottage, Swan Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220267",
+ "id": "gid://shopify/Product/7866040909875",
+ "title": "Cottage, Gull Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220268",
+ "id": "gid://shopify/Product/7866040942643",
+ "title": "Cottage, Sand Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220269",
+ "id": "gid://shopify/Product/7866040975411",
+ "title": "Cottage, Durum Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220270",
+ "id": "gid://shopify/Product/7866041008179",
+ "title": "Cottage, Ecru Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220271",
+ "id": "gid://shopify/Product/7866056048691",
+ "title": "Cottage, Gift Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220272",
+ "id": "gid://shopify/Product/7866056081459",
+ "title": "Cottage, Grain Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220273",
+ "id": "gid://shopify/Product/7866056114227",
+ "title": "Cottage, Chai Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220274",
+ "id": "gid://shopify/Product/7866056146995",
+ "title": "Cottage, Turmeric Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220275",
+ "id": "gid://shopify/Product/7866056179763",
+ "title": "Cottage, Feather Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220276",
+ "id": "gid://shopify/Product/7866056212531",
+ "title": "Cottage, Spa Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220277",
+ "id": "gid://shopify/Product/7866056245299",
+ "title": "Cottage, Drizzle Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220278",
+ "id": "gid://shopify/Product/7866056278067",
+ "title": "Cottage, Oceanic Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220279",
+ "id": "gid://shopify/Product/7866100121651",
+ "title": "Cottage, Lapis Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220280",
+ "id": "gid://shopify/Product/7866100154419",
+ "title": "Cottage, Rutabaga Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220281",
+ "id": "gid://shopify/Product/7866100187187",
+ "title": "Bristle, Alabaster Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220282",
+ "id": "gid://shopify/Product/7866100219955",
+ "title": "Bristle, Snow Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220283",
+ "id": "gid://shopify/Product/7866100252723",
+ "title": "Bristle, Linen Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220284",
+ "id": "gid://shopify/Product/7866100285491",
+ "title": "Bristle, Fossil Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220285",
+ "id": "gid://shopify/Product/7866100318259",
+ "title": "Bristle, Bedrock Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220286",
+ "id": "gid://shopify/Product/7866100351027",
+ "title": "Bristle, Quartz Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220287",
+ "id": "gid://shopify/Product/7866117193779",
+ "title": "Bristle, Cloud Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220288",
+ "id": "gid://shopify/Product/7866117226547",
+ "title": "Bristle, Mist Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220289",
+ "id": "gid://shopify/Product/7866117259315",
+ "title": "Bristle, Windy Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220290",
+ "id": "gid://shopify/Product/7866117292083",
+ "title": "Bristle, Storm Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220291",
+ "id": "gid://shopify/Product/7866117324851",
+ "title": "Bristle, Wheat Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220292",
+ "id": "gid://shopify/Product/7866117357619",
+ "title": "Bristle, Giftbox Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220293",
+ "id": "gid://shopify/Product/7866117390387",
+ "title": "Bristle, Yellow Ochre Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220294",
+ "id": "gid://shopify/Product/7866117423155",
+ "title": "Bristle, Dewdrop Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220295",
+ "id": "gid://shopify/Product/7866231128115",
+ "title": "Bristle, Agate Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220296",
+ "id": "gid://shopify/Product/7866231160883",
+ "title": "Bristle, Canal Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220297",
+ "id": "gid://shopify/Product/7866231193651",
+ "title": "Note, Cloudy Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220298",
+ "id": "gid://shopify/Product/7866231226419",
+ "title": "Note, Charcoal Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220299",
+ "id": "gid://shopify/Product/7866231291955",
+ "title": "Note, Birch Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220300",
+ "id": "gid://shopify/Product/7866231324723",
+ "title": "Note, Rain Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220301",
+ "id": "gid://shopify/Product/7866231357491",
+ "title": "Note, Marine Wallcoverings | Designtex",
+ "status": "ACTIVE",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220302",
+ "id": "gid://shopify/Product/7866231390259",
+ "title": "Pebble, Ecru Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220303",
+ "id": "gid://shopify/Product/7866861322291",
+ "title": "Pebble, Chalk Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220304",
+ "id": "gid://shopify/Product/7866861387827",
+ "title": "Pebble, Fancy Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220305",
+ "id": "gid://shopify/Product/7866861420595",
+ "title": "Pebble, Taupe Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220306",
+ "id": "gid://shopify/Product/7866886881331",
+ "title": "Pebble, Rocky Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220307",
+ "id": "gid://shopify/Product/7866886914099",
+ "title": "Pebble, Fog Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ },
+ {
+ "sku": "DWDX-220308",
+ "id": "gid://shopify/Product/7866886946867",
+ "title": "Pebble, Stone Wallcoverings | Designtex",
+ "status": "DRAFT",
+ "uom": "Priced Per Single Roll"
+ }
+]
\ No newline at end of file
diff --git a/audits/designtex-uom-fix/flip-uom-live-77.mjs b/audits/designtex-uom-fix/flip-uom-live-77.mjs
new file mode 100644
index 00000000..1d32d6ce
--- /dev/null
+++ b/audits/designtex-uom-fix/flip-uom-live-77.mjs
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+// Flip the 77 still-drifted live Designtex products: global.unit_of_measure -> 'YARD',
+// variant title "Single Roll" -> "Yard", and remove/replace the "Priced Per Single Roll" TAG.
+// Price UNTOUCHED. DTD 3/3 (relabel-only). GATED — dry-run by default, --execute to write.
+//
+// Reads designtex-uom-drift-live-77.json (the fresh 2026-06-23 live audit set).
+// Batches of 50, >=90s gap (DW bulk-push rule). $0 (Shopify API, no metered AI).
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const EXECUTE = process.argv.includes('--execute');
+const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+const tok = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
+const HERE = path.dirname(new URL(import.meta.url).pathname);
+
+const gql = (query, variables) => fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`, {
+ method: 'POST', headers: { 'X-Shopify-Access-Token': tok, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+}).then((r) => r.json());
+
+const rows = JSON.parse(fs.readFileSync(path.join(HERE, 'designtex-uom-drift-live-77.json'), 'utf8'));
+console.log(`Mode: ${EXECUTE ? 'EXECUTE' : 'DRY RUN'} | rows: ${rows.length}`);
+if (!EXECUTE) {
+ rows.slice(0, 5).forEach((r) => console.log(` ${r.sku} [${r.status}] uom '${r.uom}' -> 'YARD', tag fix`));
+ console.log('Re-run with --execute to apply.');
+ process.exit(0);
+}
+
+const mfMut = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
+const tagAdd = `mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id,tags:$tags){ userErrors{ message } } }`;
+const tagRm = `mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id,tags:$tags){ userErrors{ message } } }`;
+const varQ = `query($id:ID!){ product(id:$id){ variants(first:5){ edges{ node{ id title } } } } }`;
+const varMut = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid,variants:$variants){ userErrors{ message } } }`;
+
+const results = [];
+const BATCH = 50;
+for (let i = 0; i < rows.length; i += BATCH) {
+ for (const r of rows.slice(i, i + BATCH)) {
+ const id = r.id; // gid
+ const errs = [];
+ // 1. metafield
+ const m = await gql(mfMut, { mf: [{ ownerId: id, namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: 'YARD' }] });
+ (m.data?.metafieldsSet?.userErrors || []).forEach((e) => errs.push('mf:' + e.message));
+ // 2. tags: remove the roll tag, add a yard tag
+ const tr = await gql(tagRm, { id, tags: ['Priced Per Single Roll'] });
+ (tr.data?.tagsRemove?.userErrors || []).forEach((e) => errs.push('tagRm:' + e.message));
+ const ta = await gql(tagAdd, { id, tags: ['Priced Per Yard'] });
+ (ta.data?.tagsAdd?.userErrors || []).forEach((e) => errs.push('tagAdd:' + e.message));
+ // 3. variant title "Single Roll"/"Roll" -> "Yard" (skip the Sample variant)
+ const vq = await gql(varQ, { id });
+ const variants = (vq.data?.product?.variants?.edges || []).map((e) => e.node)
+ .filter((v) => !/Sample/i.test(v.title) && /Roll/i.test(v.title));
+ if (variants.length) {
+ const vm = await gql(varMut, { pid: id, variants: variants.map((v) => ({ id: v.id, optionValues: [{ optionName: 'Title', name: 'Yard' }] })) });
+ (vm.data?.productVariantsBulkUpdate?.userErrors || []).forEach((e) => errs.push('var:' + e.message));
+ }
+ results.push({ sku: r.sku, ok: errs.length === 0, errs });
+ if (errs.length) console.error('ERR', r.sku, errs.join('; '));
+ await new Promise((x) => setTimeout(x, 400));
+ }
+ console.log(`Batch ${i / BATCH + 1} done (${Math.min(i + BATCH, rows.length)}/${rows.length}).`);
+ if (i + BATCH < rows.length) { console.log('Sleeping 90s...'); await new Promise((x) => setTimeout(x, 90000)); }
+}
+const failed = results.filter((r) => !r.ok);
+console.log(`DONE. ${results.length - failed.length} ok, ${failed.length} failed.`);
+fs.writeFileSync(path.join(HERE, 'flip-uom-live-77-results.json'), JSON.stringify(results, null, 2));
diff --git a/shopify/apply-collection-render-heroes.py b/shopify/apply-collection-render-heroes.py
new file mode 100644
index 00000000..cc6ba113
--- /dev/null
+++ b/shopify/apply-collection-render-heroes.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""
+APPLY rendered room-setting hero images to the 218 swatch-only collections.
+ (Verdict A, Part 3 — STAGED, DRY-RUN BY DEFAULT. Steve gates the live write.)
+
+This is the apply half of the render batch (render-collection-heroes.py made the
+images on Kamatera). For each collection in the render plan that has a SUCCESSFUL
+render in the progress JSON, this script would:
+
+ 1. resolve the collection id from its handle (custom or smart),
+ 2. upload the rendered ROOM-SETTING JPEG to Shopify Files (GraphQL
+ stagedUploadsCreate -> PUT to GCS -> fileCreate) to get a cdn.shopify.com URL,
+ 3. PUT that CDN url as collection.image.src
+ custom -> PUT /custom_collections/{id}.json image.src
+ smart -> PUT /smart_collections/{id}.json image.src
+ with image ALT = the CLEAN collection title (NEVER a private-label / true-
+ vendor name; the plan's apply.alt is already the clean title).
+
+HARD SAFETY (matches the pending-approval memo)
+ * DRY-RUN by default — prints exactly what it WOULD do, uploads nothing,
+ writes nothing. --execute is REQUIRED to write and is Steve-gated.
+ * NEVER points collection.image at the flat pattern swatch — only at the
+ rendered room image keyed by handle in the progress JSON.
+ * NEVER unpublishes / retires / archives anything. The 51 empty-orphan
+ collections are untouched here — that retire stays separately gated.
+ * Sets image ALT to the clean collection title only.
+
+RENDER SOURCE
+ The rendered JPEGs live on the Kamatera box (default /root/hero-render/out).
+ In dry-run, this script reads the progress JSON (which it can pull or be given
+ locally) and reports the plan. In --execute, it expects the rendered files to
+ be reachable locally (--render-dir) — pull them first with:
+ rsync -av my-server:/root/hero-render/out/ ./audit/hero-renders/
+ scp my-server:/root/hero-render/audit/collection-hero-render-progress.json \
+ ./audit/
+
+USAGE:
+ python3 apply-collection-render-heroes.py # dry-run, all ok rows
+ python3 apply-collection-render-heroes.py --limit 5 # dry-run first 5
+ python3 apply-collection-render-heroes.py --execute \
+ --render-dir ./audit/hero-renders # LIVE (Steve only)
+"""
+import os
+import sys
+import json
+import time
+import argparse
+import urllib.request
+import urllib.error
+
+STORE = "designer-laboratory-sandbox.myshopify.com"
+API = "2024-10"
+HERE = os.path.dirname(os.path.abspath(__file__))
+SECRETS = os.path.expanduser("~/Projects/secrets-manager/.env")
+PLAN = os.path.join(HERE, "audit", "collection-hero-render-plan.json")
+PROGRESS = os.path.join(HERE, "audit", "collection-hero-render-progress.json")
+
+
+def load_token():
+ t = os.environ.get("SHOPIFY_ADMIN_TOKEN")
+ if t:
+ return t
+ if os.path.exists(SECRETS):
+ for line in open(SECRETS):
+ for k in ("SHOPIFY_ADMIN_TOKEN", "SHOPIFY_THEME_TOKEN"):
+ if line.startswith(k + "="):
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
+ sys.exit("No Shopify token.")
+
+
+TOK = load_token()
+H = {"X-Shopify-Access-Token": TOK, "Content-Type": "application/json"}
+
+
+def gql(query, variables=None):
+ url = f"https://{STORE}/admin/api/{API}/graphql.json"
+ body = json.dumps({"query": query, "variables": variables or {}}).encode()
+ req = urllib.request.Request(url, data=body, headers=H, method="POST")
+ return json.load(urllib.request.urlopen(req, timeout=60))
+
+
+def resolve_collection(handle, kind):
+ """Resolve handle -> (id, kind) using REST (REST id is the numeric we PUT to)."""
+ resource = "custom_collections" if kind == "custom" else "smart_collections"
+ url = (f"https://{STORE}/admin/api/{API}/{resource}.json"
+ f"?handle={urllib.parse.quote(handle)}&fields=id,handle,title")
+ req = urllib.request.Request(url, headers=H, method="GET")
+ out = json.load(urllib.request.urlopen(req, timeout=30))
+ arr = out.get(resource, [])
+ if arr:
+ return arr[0]["id"], kind
+ # fall back to the other kind in case the plan's kind drifted
+ other = "smart_collections" if kind == "custom" else "custom_collections"
+ url2 = (f"https://{STORE}/admin/api/{API}/{other}.json"
+ f"?handle={urllib.parse.quote(handle)}&fields=id,handle,title")
+ out2 = json.load(urllib.request.urlopen(
+ urllib.request.Request(url2, headers=H, method="GET"), timeout=30))
+ arr2 = out2.get(other, [])
+ if arr2:
+ return arr2[0]["id"], ("custom" if other == "custom_collections" else "smart")
+ return None, kind
+
+
+def upload_file_to_shopify(local_path, alt):
+ """stagedUploadsCreate -> PUT bytes -> fileCreate -> return cdn url. LIVE only."""
+ import mimetypes
+ fname = os.path.basename(local_path)
+ size = os.path.getsize(local_path)
+ mime = mimetypes.guess_type(fname)[0] or "image/png"
+ staged = gql("""
+ mutation($input:[StagedUploadInput!]!){
+ stagedUploadsCreate(input:$input){
+ stagedTargets{ url resourceUrl parameters{ name value } }
+ userErrors{ field message } } }""",
+ {"input": [{"filename": fname, "mimeType": mime,
+ "fileSize": str(size), "httpMethod": "POST",
+ "resource": "FILE"}]})
+ tgt = staged["data"]["stagedUploadsCreate"]["stagedTargets"][0]
+ # multipart POST to the staged GCS url
+ boundary = "----dwheroupload"
+ parts = []
+ for p in tgt["parameters"]:
+ parts.append(f"--{boundary}\r\nContent-Disposition: form-data; "
+ f'name="{p["name"]}"\r\n\r\n{p["value"]}\r\n')
+ head = "".join(parts).encode()
+ fhead = (f"--{boundary}\r\nContent-Disposition: form-data; "
+ f'name="file"; filename="{fname}"\r\n'
+ f"Content-Type: {mime}\r\n\r\n").encode()
+ tail = f"\r\n--{boundary}--\r\n".encode()
+ with open(local_path, "rb") as f:
+ fbytes = f.read()
+ payload = head + fhead + fbytes + tail
+ req = urllib.request.Request(tgt["url"], data=payload, method="POST",
+ headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
+ urllib.request.urlopen(req, timeout=120)
+ created = gql("""
+ mutation($files:[FileCreateInput!]!){
+ fileCreate(files:$files){
+ files{ ... on MediaImage { image{ url } } alt }
+ userErrors{ field message } } }""",
+ {"files": [{"originalSource": tgt["resourceUrl"], "alt": alt,
+ "contentType": "IMAGE"}]})
+ # the cdn url may take a tick to populate; poll the file node if null
+ f0 = created["data"]["fileCreate"]["files"][0]
+ img = (f0.get("image") or {}).get("url")
+ return img # may be None initially; caller can re-query
+
+
+def put_collection_image(kind, coll_id, src, alt):
+ resource = "custom_collections" if kind == "custom" else "smart_collections"
+ url = f"https://{STORE}/admin/api/{API}/{resource}/{coll_id}.json"
+ payload = {resource[:-1]: {"id": coll_id, "image": {"src": src, "alt": alt}}}
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(),
+ headers=H, method="PUT")
+ resp = urllib.request.urlopen(req, timeout=60)
+ return json.load(resp), resp.headers.get("x-shopify-shop-api-call-limit", "0/80")
+
+
+def main():
+ import urllib.parse # noqa: F401 (used in resolve)
+ global PROGRESS
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--execute", action="store_true",
+ help="LIVE: upload renders + set collection.image (Steve-gated)")
+ ap.add_argument("--plan", default=PLAN)
+ ap.add_argument("--progress", default=PROGRESS)
+ ap.add_argument("--render-dir", default=os.path.join(HERE, "audit", "hero-renders"),
+ help="local dir holding the rendered <handle>.jpg files (execute only)")
+ ap.add_argument("--limit", type=int, default=0)
+ args = ap.parse_args()
+
+ plan = json.load(open(args.plan))
+ plan_rows = {r["handle"]: r for r in plan["plan"]}
+
+ prog = {}
+ if os.path.exists(args.progress):
+ prog = json.load(open(args.progress)).get("rows", {})
+ else:
+ print(f"NOTE: progress file not found locally ({args.progress}); "
+ f"in dry-run this just means 'renders still in flight on Kamatera'.\n"
+ f" Pull it before --execute:\n"
+ f" scp my-server:/root/hero-render/audit/"
+ f"collection-hero-render-progress.json {args.progress}\n")
+
+ # rows we'd apply = plan rows whose render is ok (or, if no progress yet, ALL
+ # plan rows shown as PENDING so the dry-run still previews the full set)
+ rows = []
+ for handle, prow in plan_rows.items():
+ pr = prog.get(handle)
+ rows.append((handle, prow, pr))
+ if args.limit:
+ rows = rows[:args.limit]
+
+ mode = "EXECUTE (LIVE WRITE)" if args.execute else "DRY-RUN (no writes / no uploads)"
+ print(f"=== apply-collection-render-heroes [{mode}] {len(rows)} plan rows ===")
+ if not args.execute:
+ print("(pass --execute --render-dir <dir> to write — Steve-gated)\n")
+
+ wrote = 0
+ skipped = 0
+ for i, (handle, prow, pr) in enumerate(rows, 1):
+ alt = prow["apply"]["alt"] # clean collection title, no vendor
+ kind = prow["kind"]
+ ok = bool(pr and pr.get("ok"))
+ status = "RENDER-OK" if ok else ("RENDER-FAIL" if pr else "RENDER-PENDING")
+ local = os.path.join(args.render_dir, f"{handle}.jpg")
+
+ if not args.execute:
+ print(f"[{i}/{len(rows)}] {status:14s} {kind:6s} /{handle} "
+ f"alt={alt!r}")
+ continue
+
+ # ---- LIVE path (Steve only) ----
+ if not ok:
+ skipped += 1
+ print(f"[{i}/{len(rows)}] SKIP ({status}) /{handle}")
+ continue
+ if not os.path.exists(local):
+ skipped += 1
+ print(f"[{i}/{len(rows)}] SKIP (no local render {local}) /{handle}")
+ continue
+ try:
+ coll_id, real_kind = resolve_collection(handle, kind)
+ if not coll_id:
+ skipped += 1
+ print(f"[{i}/{len(rows)}] SKIP (handle not found) /{handle}")
+ continue
+ cdn = upload_file_to_shopify(local, alt)
+ if not cdn:
+ # fileCreate accepted but url not yet ready; brief poll
+ time.sleep(3)
+ cdn = upload_file_to_shopify(local, alt)
+ _, used = put_collection_image(real_kind, coll_id, cdn, alt)
+ wrote += 1
+ print(f"[{i}/{len(rows)}] OK ({used}) {real_kind:6s} /{handle} <- {cdn}")
+ time.sleep(0.4)
+ except urllib.error.HTTPError as e:
+ print(f"[{i}/{len(rows)}] FAIL http {e.code}: "
+ f"{e.read().decode()[:140]} /{handle}")
+ time.sleep(2)
+ except Exception as e:
+ print(f"[{i}/{len(rows)}] FAIL {e} /{handle}")
+ time.sleep(2)
+
+ if args.execute:
+ print(f"\n=== wrote {wrote}, skipped {skipped} of {len(rows)} ===")
+ else:
+ ok_n = sum(1 for _, _, pr in rows if pr and pr.get("ok"))
+ pend = sum(1 for _, _, pr in rows if not pr)
+ fail = sum(1 for _, _, pr in rows if pr and not pr.get("ok"))
+ print(f"\n=== dry-run: would apply {ok_n} ok renders, "
+ f"{pend} pending, {fail} failed; wrote 0 ===")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shopify/audit/collection-hero-render-progress.json b/shopify/audit/collection-hero-render-progress.json
new file mode 100644
index 00000000..51d6ef9d
--- /dev/null
+++ b/shopify/audit/collection-hero-render-progress.json
@@ -0,0 +1,155 @@
+{
+ "started_at": "2026-06-23T22:11:49.335670+00:00",
+ "updated_at": "2026-06-23T22:14:33.244152+00:00",
+ "model": "Gemini 2.0 Flash Exp Image",
+ "cost_per_render_usd": 0.039,
+ "rows": {
+ "wallpapersback": {
+ "ok": true,
+ "bytes": 2102534,
+ "sec": 8.0,
+ "path": "/root/hero-render/out/wallpapersback.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:11:57.316139+00:00"
+ },
+ "la-walls": {
+ "ok": true,
+ "bytes": 1997702,
+ "sec": 7.5,
+ "path": "/root/hero-render/out/la-walls.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:12:04.802817+00:00"
+ },
+ "malibu-walls": {
+ "ok": true,
+ "bytes": 2496501,
+ "sec": 9.4,
+ "path": "/root/hero-render/out/malibu-walls.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:12:14.257219+00:00"
+ },
+ "malibu-walls-coastal": {
+ "ok": true,
+ "bytes": 2172037,
+ "sec": 8.7,
+ "path": "/root/hero-render/out/malibu-walls-coastal.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:12:22.985004+00:00"
+ },
+ "malibu-walls-contemporary": {
+ "ok": true,
+ "bytes": 2476784,
+ "sec": 9.3,
+ "path": "/root/hero-render/out/malibu-walls-contemporary.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:12:32.303406+00:00"
+ },
+ "malibu-walls-geometric": {
+ "ok": true,
+ "bytes": 2045715,
+ "sec": 8.9,
+ "path": "/root/hero-render/out/malibu-walls-geometric.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:01.758440+00:00"
+ },
+ "malibu-walls-minimalist": {
+ "ok": true,
+ "bytes": 2430285,
+ "sec": 9.6,
+ "path": "/root/hero-render/out/malibu-walls-minimalist.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:11.350489+00:00"
+ },
+ "malibu-walls-scandinavian": {
+ "ok": true,
+ "bytes": 2524233,
+ "sec": 9.4,
+ "path": "/root/hero-render/out/malibu-walls-scandinavian.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:20.713047+00:00"
+ },
+ "malibu-walls-traditional": {
+ "ok": true,
+ "bytes": 2039024,
+ "sec": 8.7,
+ "path": "/root/hero-render/out/malibu-walls-traditional.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:29.373538+00:00"
+ },
+ "malibu-walls-transitional": {
+ "ok": true,
+ "bytes": 1999265,
+ "sec": 8.3,
+ "path": "/root/hero-render/out/malibu-walls-transitional.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:37.721742+00:00"
+ },
+ "bella-italiano": {
+ "ok": true,
+ "bytes": 2402812,
+ "sec": 8.4,
+ "path": "/root/hero-render/out/bella-italiano.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:46.097113+00:00"
+ },
+ "candice-olson-collection": {
+ "ok": true,
+ "bytes": 2341898,
+ "sec": 8.9,
+ "path": "/root/hero-render/out/candice-olson-collection.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:13:54.965743+00:00"
+ },
+ "designer-pulse-2026-03-07": {
+ "ok": true,
+ "bytes": 2119466,
+ "sec": 9.0,
+ "path": "/root/hero-render/out/designer-pulse-2026-03-07.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:14:03.939305+00:00"
+ },
+ "elements-ii-naturals": {
+ "ok": true,
+ "bytes": 2334329,
+ "sec": 11.6,
+ "path": "/root/hero-render/out/elements-ii-naturals.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:14:15.533803+00:00"
+ },
+ "gaston-nuevo-mundo": {
+ "ok": true,
+ "bytes": 2377518,
+ "sec": 8.1,
+ "path": "/root/hero-render/out/gaston-nuevo-mundo.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:14:23.687143+00:00"
+ },
+ "grasscloth-6": {
+ "ok": true,
+ "bytes": 2340021,
+ "sec": 9.5,
+ "path": "/root/hero-render/out/grasscloth-6.jpg",
+ "attempts": 1,
+ "model": "Gemini 2.0 Flash Exp Image",
+ "at": "2026-06-23T22:14:33.242613+00:00"
+ }
+ },
+ "ok_count": 16,
+ "fail_count": 0,
+ "cost_so_far_usd": 0.624
+}
\ No newline at end of file
diff --git a/shopify/collection-hero-fix/dw-collection-hero.css b/shopify/collection-hero-fix/dw-collection-hero.css
index f39453de..5cd61ab6 100644
--- a/shopify/collection-hero-fix/dw-collection-hero.css
+++ b/shopify/collection-hero-fix/dw-collection-hero.css
@@ -78,10 +78,12 @@ body.template-collection .boost-sd__product-filter-fallback .page-title {
font-weight: 300;
}
-/* Also neutralize Boost's default dark pill even outside our wrapper as a
- belt-and-suspenders: if for any reason the Boost header is NOT wrapped and
- has no image-inner, strip the naked pill so it never looks broken. */
-.boost-sd__collection-header:not(:has(.boost-sd__header-image-inner)) .boost-sd__header-title {
+/* Belt-and-suspenders: neutralize Boost's default dark pill whenever the
+ collection has NO hero. "No hero" = the snippet did NOT add .dw-has-hero
+ (set only when collection.image OR settings.collection_default_hero exists)
+ AND Boost rendered no native image-inner. Excludes .dw-has-hero so the
+ image case (painted by the snippet's inline <style>) is never stripped. */
+.boost-sd__collection-header:not(.dw-has-hero):not(:has(.boost-sd__header-image-inner)) .boost-sd__header-title {
background: transparent !important;
color: #1a1a1a !important;
border-radius: 0 !important;
@@ -91,7 +93,7 @@ body.template-collection .boost-sd__product-filter-fallback .page-title {
text-transform: uppercase;
font-weight: 300;
}
-.boost-sd__collection-header:not(:has(.boost-sd__header-image-inner)) {
+.boost-sd__collection-header:not(.dw-has-hero):not(:has(.boost-sd__header-image-inner)) {
background: #f4f2ee;
min-height: 200px;
display: flex;
diff --git a/shopify/collection-hero-fix/snippets_dw-collection-hero-bg.liquid b/shopify/collection-hero-fix/snippets_dw-collection-hero-bg.liquid
index 76aea1dd..92b5db9c 100644
--- a/shopify/collection-hero-fix/snippets_dw-collection-hero-bg.liquid
+++ b/shopify/collection-hero-fix/snippets_dw-collection-hero-bg.liquid
@@ -1,16 +1,15 @@
{%- comment -%}
- DW collection hero background (F1=B). Renders an inline <style> that paints the
- collection hero band with, in priority order:
+ DW collection hero background (F1=B). Paints the collection hero band with,
+ in priority order:
1. collection.image (the per-collection hero — Steve's HARD rule)
2. settings.collection_default_hero (one branded fallback, set in customizer)
- 3. nothing -> CSS soft branded band kicks in (clean centered type, no pill)
- Also exposes a body-level flag class so dw-collection-hero.css can switch the
- title between white-on-image pill and clean dark type.
-
- Targets BOTH the Boost header (.boost-sd__collection-header, live grid) and the
- theme pre-hydration header (.collection-header) so the hero is correct before
- AND after Boost hydrates. No layout reflow; pure background + class toggle.
- Owner: vp-dw-commerce · 2026-06-23. Remove by deleting the render call + file.
+ 3. nothing -> the soft branded band + clean centered dark type kicks in
+ (dw-collection-hero.css), so an imageless collection still looks polished.
+ Sets body.dw-collection-has-hero when (1) or (2) exists so the CSS can switch
+ the Boost + theme header titles to the white-on-image pill and never strip
+ the painted background. Targets BOTH the Boost header (live grid) and the
+ theme pre-hydration header so the hero is correct before AND after hydration.
+ Owner: vp-dw-commerce · 2026-06-23. Remove: delete the render call + this file.
{%- endcomment -%}
{%- liquid
assign hero_url = ''
@@ -20,27 +19,24 @@
assign hero_url = settings.collection_default_hero | image_url: width: 2000
endif
-%}
+{%- if hero_url != blank -%}
+<script>document.documentElement.classList.add('dw-coll-hero');document.addEventListener('DOMContentLoaded',function(){document.body&&document.body.classList.add('dw-collection-has-hero');});</script>
<style>
- /* wrap the collection header(s) into the hero band */
- .boost-sd__collection-header,
- .template-collection .collection-header {
- --dw-hero-applied: 1;
- }
- {%- if hero_url != blank -%}
- .boost-sd__collection-header,
- .template-collection .collection-header {
- --dw-hero-img: url("{{ hero_url }}");
+ body.dw-collection-has-hero .boost-sd__collection-header,
+ .dw-coll-hero .template-collection .collection-header,
+ html.dw-coll-hero.template-collection .collection-header {
background-image:
linear-gradient(180deg, rgba(0,0,0,0.18), rgba(0,0,0,0.34)),
- var(--dw-hero-img) !important;
+ url("{{ hero_url }}") !important;
background-size: cover !important;
background-position: center !important;
min-height: 240px;
display: flex; align-items: center; justify-content: center; text-align: center;
}
- /* image present -> white pill title */
- .boost-sd__collection-header .boost-sd__header-title,
- .template-collection .collection-header .page-title {
+ /* image present -> white pill title (high specificity to win over the
+ no-hero neutralizer) */
+ body.dw-collection-has-hero .boost-sd__collection-header .boost-sd__header-title,
+ .dw-coll-hero .template-collection .collection-header .page-title {
color: #fff !important;
background: rgba(0,0,0,0.42) !important;
border-radius: 4px !important;
@@ -48,7 +44,7 @@
display: inline-block !important;
text-shadow: 0 1px 18px rgba(0,0,0,0.45);
}
- .boost-sd__collection-header .boost-sd__header-description,
- .boost-sd__collection-header .boost-sd__header-description * { color: rgba(255,255,255,.92) !important; }
- {%- endif -%}
+ body.dw-collection-has-hero .boost-sd__collection-header .boost-sd__header-description,
+ body.dw-collection-has-hero .boost-sd__collection-header .boost-sd__header-description * { color: rgba(255,255,255,.92) !important; }
</style>
+{%- endif -%}
diff --git a/shopify/render-collection-heroes.py b/shopify/render-collection-heroes.py
new file mode 100644
index 00000000..089056e3
--- /dev/null
+++ b/shopify/render-collection-heroes.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""
+RENDER collection hero room-settings — Verdict A, Part 2 (the render batch).
+
+Reads audit/collection-hero-render-plan.json (218 swatch-only DW collections),
+and for each row:
+ 1. downloads the chosen member product's flat pattern image,
+ 2. POSTs it to the room-setting service (http://127.0.0.1:3076/api/generate-room)
+ to produce a photoreal ROOM-SETTING hero (never the flat swatch),
+ 3. saves the returned JPEG to OUT_DIR/<handle>.jpg,
+ 4. CHECKPOINTS the row result to a progress JSON (resumable — re-running skips
+ rows already marked ok),
+ 5. tracks running cost (per-render + batch total).
+
+DESIGN NOTES
+ * RUNS ON KAMATERA. The room service binds *:3076 on the box and is firewalled
+ from the public internet; :3075 is a stale tailscale forward (HTTP 000). So
+ this script must execute on the Kamatera host (via `ssh my-server`).
+ * Self-contained: stdlib only (urllib, json, base64, subprocess). No psycopg2,
+ no requests — the plan JSON already carries every render input we need, so
+ NO database access is required at render time.
+ * Per-row failure isolation: a row gets ONE retry; if it still fails it is
+ flagged (ok=false, err=...) and the batch CONTINUES. One bad row never kills
+ the run.
+ * Cost: $0.039 per render (model "Gemini 2.0 Flash Exp Image", matches the
+ pilot + the plan's projected_cost_usd). Logged per-render and as a batch
+ total to the progress JSON; the Mac2-side cost-tracker ledger entry is made
+ by the launcher after the run reads the final total.
+ * This script ONLY generates + hosts renders locally on the box. It does NOT
+ write collection.image to Shopify and does NOT unpublish anything — those
+ stay gated for Steve (see apply-collection-heroes.py --execute).
+
+USAGE (on Kamatera):
+ python3 render-collection-heroes.py # full batch, resumable
+ python3 render-collection-heroes.py --limit 5 # first 5 only (smoke)
+ python3 render-collection-heroes.py --only handle1,handle2 # specific rows
+ python3 render-collection-heroes.py --plan /path/plan.json --out /path/out \
+ --progress /path/progress.json
+"""
+import os
+import sys
+import json
+import time
+import base64
+import argparse
+import subprocess
+import urllib.request
+import urllib.error
+import ssl
+from datetime import datetime, timezone
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+ROOM_API = "http://127.0.0.1:3076/api/generate-room"
+COST_PER_RENDER = 0.039 # "Gemini 2.0 Flash Exp Image" — matches pilot + plan
+RENDER_MODEL = "Gemini 2.0 Flash Exp Image"
+MAX_RETRIES = 1 # one retry after the first attempt
+MIN_OK_BYTES = 50_000 # a real room render is >>50KB; smaller = treat as fail
+
+_ssl = ssl.create_default_context()
+_ssl.check_hostname = False
+_ssl.verify_mode = ssl.CERT_NONE
+
+
+def log(msg):
+ ts = datetime.now().strftime("%H:%M:%S")
+ print(f"[{ts}] {msg}", flush=True)
+
+
+def now_iso():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def load_progress(path):
+ if os.path.exists(path):
+ try:
+ return json.load(open(path))
+ except Exception:
+ log(f"WARN progress file unreadable, starting fresh: {path}")
+ return {
+ "started_at": now_iso(),
+ "updated_at": now_iso(),
+ "model": RENDER_MODEL,
+ "cost_per_render_usd": COST_PER_RENDER,
+ "rows": {}, # handle -> {ok,bytes,sec,path,attempts,err,at}
+ "ok_count": 0,
+ "fail_count": 0,
+ "cost_so_far_usd": 0.0,
+ }
+
+
+def save_progress(path, prog):
+ prog["updated_at"] = now_iso()
+ ok = [r for r in prog["rows"].values() if r.get("ok")]
+ prog["ok_count"] = len(ok)
+ prog["fail_count"] = sum(1 for r in prog["rows"].values() if not r.get("ok"))
+ # cost counts every render call that actually returned an image (ok rows)
+ prog["cost_so_far_usd"] = round(len(ok) * COST_PER_RENDER, 4)
+ tmp = path + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(prog, f, indent=2)
+ os.replace(tmp, path)
+
+
+def download_pattern(src):
+ """Download flat pattern image; resize >2MB down to 1500px to keep payload fast.
+ Returns base64 string or None."""
+ try:
+ req = urllib.request.Request(src, headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36"
+ })
+ with urllib.request.urlopen(req, timeout=60, context=_ssl) as resp:
+ data = resp.read()
+ if len(data) < 1000:
+ return None
+ if len(data) > 2_000_000:
+ ti, to = "/tmp/_hero_dl_in.jpg", "/tmp/_hero_dl_out.jpg"
+ with open(ti, "wb") as f:
+ f.write(data)
+ try:
+ subprocess.run(
+ ["convert", ti, "-resize", "1500x1500>", "-quality", "85", to],
+ check=True, timeout=20)
+ with open(to, "rb") as f:
+ data = f.read()
+ except Exception:
+ pass # fall back to the original bytes if convert is missing
+ return base64.b64encode(data).decode("utf-8")
+ except Exception as e:
+ log(f" download failed: {e}")
+ return None
+
+
+def call_room(pattern_b64, spec):
+ """POST to the room service; return (image_b64|None, err|None)."""
+ payload = json.dumps({
+ "patternBase64": pattern_b64,
+ "roomType": spec.get("roomType", "living_room"),
+ "angle": spec.get("angle", "straight_on"),
+ "cameraDistance": spec.get("cameraDistance", 10),
+ "patternWidth": spec.get("patternWidth", 27),
+ "patternHeight": spec.get("patternHeight", 27),
+ "patternType": spec.get("patternType", "wallcovering"),
+ "wallWidth": 14,
+ "wallHeight": 9,
+ }).encode()
+ req = urllib.request.Request(
+ ROOM_API, data=payload,
+ headers={"Content-Type": "application/json"}, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=150) as resp:
+ result = json.loads(resp.read().decode())
+ if result.get("success") and result.get("image"):
+ return result["image"], None
+ return None, result.get("error", "no image in response")
+ except urllib.error.HTTPError as e:
+ return None, f"http {e.code}: {e.read().decode()[:140]}"
+ except Exception as e:
+ return None, str(e)
+
+
+def render_row(row, out_dir):
+ """Render a single plan row. Returns a result dict (ok/err/bytes/sec/path)."""
+ handle = row["handle"]
+ src = row.get("render_input_pattern_src")
+ spec = row.get("render_spec", {})
+ if not src:
+ return {"ok": False, "err": "no render_input_pattern_src",
+ "attempts": 0, "at": now_iso()}
+
+ out_path = os.path.join(out_dir, f"{handle}.jpg")
+ last_err = None
+ for attempt in range(1, MAX_RETRIES + 2): # 1 try + MAX_RETRIES
+ t0 = time.time()
+ b64 = download_pattern(src)
+ if not b64:
+ last_err = "pattern download failed"
+ time.sleep(1.5)
+ continue
+ img_b64, err = call_room(b64, spec)
+ sec = round(time.time() - t0, 1)
+ if img_b64:
+ raw = base64.b64decode(
+ img_b64.split(",", 1)[1] if img_b64.startswith("data:") else img_b64)
+ if len(raw) < MIN_OK_BYTES:
+ last_err = f"render too small ({len(raw)}b)"
+ time.sleep(1.5)
+ continue
+ with open(out_path, "wb") as f:
+ f.write(raw)
+ return {"ok": True, "bytes": len(raw), "sec": sec,
+ "path": out_path, "attempts": attempt,
+ "model": RENDER_MODEL, "at": now_iso()}
+ last_err = err
+ log(f" attempt {attempt} failed: {err}")
+ time.sleep(2)
+ return {"ok": False, "err": last_err, "attempts": MAX_RETRIES + 1,
+ "at": now_iso()}
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--plan", default=os.path.join(
+ HERE, "audit", "collection-hero-render-plan.json"))
+ ap.add_argument("--out", default="/tmp/hero-renders")
+ ap.add_argument("--progress", default=os.path.join(
+ HERE, "audit", "collection-hero-render-progress.json"))
+ ap.add_argument("--limit", type=int, default=0)
+ ap.add_argument("--only", default="", help="comma-separated handles")
+ ap.add_argument("--force", action="store_true",
+ help="re-render rows already marked ok")
+ args = ap.parse_args()
+
+ plan = json.load(open(args.plan))
+ rows = plan["plan"] if isinstance(plan, dict) else plan
+ if args.only:
+ wanted = {h.strip() for h in args.only.split(",") if h.strip()}
+ rows = [r for r in rows if r["handle"] in wanted]
+ if args.limit:
+ rows = rows[:args.limit]
+
+ os.makedirs(args.out, exist_ok=True)
+ prog = load_progress(args.progress)
+
+ total = len(rows)
+ log(f"=== render-collection-heroes {total} rows model={RENDER_MODEL} "
+ f"${COST_PER_RENDER}/render ===")
+ log(f" api={ROOM_API} out={args.out} progress={args.progress}")
+
+ done_already = sum(1 for r in rows
+ if prog["rows"].get(r["handle"], {}).get("ok") and not args.force)
+ if done_already:
+ log(f" resuming — {done_already}/{total} already ok, skipping them")
+
+ for i, row in enumerate(rows, 1):
+ handle = row["handle"]
+ prev = prog["rows"].get(handle)
+ if prev and prev.get("ok") and not args.force:
+ continue
+ res = render_row(row, args.out)
+ prog["rows"][handle] = res
+ save_progress(args.progress, prog)
+ if res["ok"]:
+ running = prog["cost_so_far_usd"]
+ log(f"[{i}/{total}] OK /{handle} {res['bytes']:,}b {res['sec']}s "
+ f"(try {res['attempts']}) | render ${COST_PER_RENDER:.3f} "
+ f"batch ${running:.3f}")
+ else:
+ log(f"[{i}/{total}] FAIL /{handle} {res.get('err')} "
+ f"(flagged, continuing)")
+
+ ok = prog["ok_count"]
+ fail = prog["fail_count"]
+ cost = prog["cost_so_far_usd"]
+ log(f"=== done. ok={ok} fail={fail} | total cost ${cost:.2f} "
+ f"(projected full-218 ${218*COST_PER_RENDER:.2f}) ===")
+ # machine-readable tail for the launcher
+ print("BATCH_SUMMARY " + json.dumps({
+ "ok": ok, "fail": fail, "cost_usd": cost,
+ "progress": args.progress, "out": args.out}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shopify/tres-tintas-room-backups/_regen.ledger b/shopify/tres-tintas-room-backups/_regen.ledger
index 3e96e503..75db0198 100644
--- a/shopify/tres-tintas-room-backups/_regen.ledger
+++ b/shopify/tres-tintas-room-backups/_regen.ledger
@@ -439,3 +439,78 @@
7866353975347
7866354008115
7866354040883
+7866354073651
+7866354106419
+7866354139187
+7866354171955
+7866354237491
+7866354270259
+7866354303027
+7866354335795
+7866354368563
+7866354401331
+7866354434099
+7866354466867
+7866354499635
+7866354532403
+7866354565171
+7866354597939
+7866354630707
+7866354663475
+7866354696243
+7866354729011
+7866354761779
+7866354794547
+7866354827315
+7866354860083
+7866354892851
+7866354925619
+7866354958387
+7866354991155
+7866355023923
+7866355056691
+7866355089459
+7866355122227
+7866355154995
+7866355220531
+7866355286067
+7866355318835
+7866355351603
+7866355384371
+7866355417139
+7866355449907
+7866355482675
+7866355515443
+7866355548211
+7866355580979
+7866355613747
+7866355646515
+7866355679283
+7866355712051
+7866355744819
+7866355777587
+7866355810355
+7866355843123
+7866355875891
+7866355908659
+7866355941427
+7866355974195
+7866356006963
+7866356039731
+7866356072499
+7866356105267
+7866356138035
+7866356170803
+7866356203571
+7866356236339
+7866356269107
+7866356301875
+7866356334643
+7866356367411
+7866356400179
+7866356432947
+7866356465715
+7866356498483
+7866356531251
+7866356564019
+7866356596787
diff --git a/vendor-scrapers/china-seas-refresh b/vendor-scrapers/china-seas-refresh
index d5251c9b..92108ab4 160000
--- a/vendor-scrapers/china-seas-refresh
+++ b/vendor-scrapers/china-seas-refresh
@@ -1 +1 @@
-Subproject commit d5251c9b4253f26c4418440169d925324b9c84b9
+Subproject commit 92108ab45e85cd11589da9f778f1abbab323df6f
diff --git a/vendor-scrapers/quadrille-house/RESUME-ENRICH.md b/vendor-scrapers/quadrille-house/RESUME-ENRICH.md
new file mode 100644
index 00000000..7082ae6c
--- /dev/null
+++ b/vendor-scrapers/quadrille-house/RESUME-ENRICH.md
@@ -0,0 +1,31 @@
+# Resume enrichment (if interrupted)
+
+The Quadrille-house net-new enrichment (Gemini vision: descriptions + colors + tags)
+is the only resumable in-flight piece. It is crash-safe and idempotent.
+
+## How to resume
+```sh
+cd ~/Projects/Designer-Wallcoverings/vendor-scrapers/quadrille-house
+node enrich-local.js --budget 5
+```
+It auto-selects ONLY rows with NULL/empty ai_tags, so re-running picks up exactly
+where it left off. Free-tier gemini-2.5-flash-lite => ~$0 actual (paid ceiling ~$1.17
+for all 1,942). It has 429-quota-break (6 streak), 503-overload-break (40 streak, the
+transient "high demand" failure), and per-model failover built in.
+
+## Check progress
+```sh
+/opt/homebrew/opt/postgresql@14/bin/psql -d dw_unified -tA -c \
+"SELECT COUNT(*) FILTER (WHERE ai_tags IS NOT NULL AND ai_tags::text NOT IN ('','[]','null')) enriched, COUNT(*) total FROM quadrille_house_catalog;"
+```
+
+## If Gemini is overloaded (503 streak of 's')
+That's transient (model high-demand), NOT a budget/quota issue. The OVERLOAD_BREAK
+stops the run cleanly within ~40 skipped rows. Just re-run later — recovered rows
+fill in, skipped rows stay NULL and get retried.
+
+## Everything else is DONE (no resume needed)
+- Registry prefixes applied (commit 2908741a)
+- China Seas: 0 live writes (DTD verdict A x2 — premises were false); mirror refreshed
+- 1,942 dw_sku assigned, HELD structurally (not in vendors.js + cost=0)
+- 26 China Seas net-new flagged NET_NEW_HELD_NO_COST
diff --git a/vendor-scrapers/quadrille-house/enrich-local.js b/vendor-scrapers/quadrille-house/enrich-local.js
index 75c42834..02844bf3 100644
--- a/vendor-scrapers/quadrille-house/enrich-local.js
+++ b/vendor-scrapers/quadrille-house/enrich-local.js
@@ -121,7 +121,7 @@ function parseAllImages(v) {
return arr.map(x => typeof x === 'string' ? x : (x && (x.url || x.src || x.image_url || x.image))).filter(Boolean);
}
-const MAX_429 = 3, MAX_503 = 4, QUOTA_BREAK = 6;
+const MAX_429 = 3, MAX_503 = 4, QUOTA_BREAK = 6, OVERLOAD_BREAK = 40;
let consecutive429 = 0;
function geminiAnalyze(candidatesIn, attempt = 0) {
return (async () => {
@@ -219,12 +219,15 @@ async function main() {
WHERE image_url IS NOT NULL AND image_url<>'' ${cond} ORDER BY id ${lim};`)
.split('\n').filter(Boolean).map(l => { const [id, brand, mfr_sku, image_url, all_images] = l.split('\t'); return { id, brand, mfr_sku, image_url, all_images }; });
console.log(`Enrich ${rows.length} rows (model ${MODEL}, dry=${DRY}, force=${FORCE}, budget $${BUDGET})`);
- let done = 0, skip = 0, err = 0, spend = 0;
+ let done = 0, skip = 0, err = 0, spend = 0, retryStreak = 0;
for (const r of rows) {
- if (consecutive429 >= QUOTA_BREAK) { console.log(`QUOTA BREAK at ${done}/${rows.length} — resume later`); break; }
- if (spend >= BUDGET) { console.log(`BUDGET CAP $${BUDGET} hit at ${done}`); break; }
+ if (consecutive429 >= QUOTA_BREAK) { console.log(`\nQUOTA BREAK at ${done}/${rows.length} — resume later`); break; }
+ if (retryStreak >= OVERLOAD_BREAK) { console.log(`\nOVERLOAD BREAK at ${done}/${rows.length} — Gemini 503 high-demand streak; resume later (rows stay NULL ai_tags)`); break; }
+ if (spend >= BUDGET) { console.log(`\nBUDGET CAP $${BUDGET} hit at ${done}`); break; }
const a = await geminiAnalyze([r.image_url, ...parseAllImages(r.all_images)]);
- if (!a || a._skip || a._429 || a._retry) { skip++; process.stdout.write('s'); continue; }
+ if (a && a._retry) { retryStreak++; skip++; process.stdout.write('s'); continue; }
+ if (!a || a._skip || a._429) { skip++; process.stdout.write('s'); continue; }
+ retryStreak = 0;
const cols = mapToCols(a);
spend += PER_CALL;
if (DRY) { console.log(`\n[DRY ${r.id} ${r.brand} ${r.mfr_sku}] tags=${JSON.parse(cols.ai_tags).slice(0,6).join(',')} | hex=${cols.color_hex} | "${(cols.ai_description||'').slice(0,90)}"`); done++; continue; }
diff --git a/verify-collection-hero-fix.js b/verify-collection-hero-fix.js
new file mode 100644
index 00000000..fddcddc1
--- /dev/null
+++ b/verify-collection-hero-fix.js
@@ -0,0 +1,91 @@
+// Verify the staged collection-hero fix by applying the EXACT staged CSS + the
+// snippet's inline <style> output to the real live /collections/all DOM, both
+// viewports, BEFORE/AFTER screenshots. Read-only network; $0 local.
+const { chromium } = require('playwright');
+const fs = require('fs');
+
+const CSS = fs.readFileSync(__dirname + '/shopify/collection-hero-fix/dw-collection-hero.css', 'utf8');
+const URL = 'https://www.designerwallcoverings.com/collections/all';
+const SHOTS = '/tmp/dwshots';
+
+// Simulate the snippet output for the NO-IMAGE case (collection.image blank,
+// settings.collection_default_hero blank): the snippet emits only the wrapper
+// flag, no --dw-hero-img -> soft band + clean dark type. We also test the
+// IMAGE-PRESENT case by injecting a sample hero url.
+const SNIPPET_NOIMG = `
+ .boost-sd__collection-header,.template-collection .collection-header{--dw-hero-applied:1;}
+`;
+const SAMPLE_IMG = 'https://www.designerwallcoverings.com/cdn/shop/files/marble.jpg';
+const SNIPPET_IMG = `
+ body.dw-collection-has-hero .boost-sd__collection-header{
+ background-image:linear-gradient(180deg,rgba(0,0,0,.18),rgba(0,0,0,.34)),url("${SAMPLE_IMG}") !important;
+ background-size:cover !important;background-position:center !important;min-height:240px;
+ display:flex;align-items:center;justify-content:center;text-align:center;}
+ body.dw-collection-has-hero .boost-sd__collection-header .boost-sd__header-title{color:#fff !important;background:rgba(0,0,0,.42) !important;border-radius:4px !important;padding:12px 28px !important;display:inline-block !important;}
+`;
+
+async function probeTitle(page) {
+ return page.evaluate(() => {
+ const pick = (sel) => { const el = document.querySelector(sel); if (!el) return null; const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return { sel, bg: cs.backgroundColor, color: cs.color, radius: cs.borderRadius, w: Math.round(r.width), h: Math.round(r.height), visible: cs.display !== 'none' && r.height > 0 }; };
+ return {
+ boostTitle: pick('.boost-sd__header-title'),
+ themeTitle: pick('.page-title'),
+ boostHeader: pick('.boost-sd__collection-header'),
+ };
+ });
+}
+
+(async () => {
+ const browser = await chromium.launch();
+
+ for (const [name, vp, mobile] of [
+ ['desktop', { width: 1440, height: 900 }, false],
+ ['mobile', { width: 390, height: 844 }, true],
+ ]) {
+ const ctx = await browser.newContext({ viewport: vp, isMobile: mobile, deviceScaleFactor: mobile ? 3 : 1,
+ userAgent: mobile ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' : undefined });
+ const page = await ctx.newPage();
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
+ await page.waitForTimeout(3500);
+
+ // BEFORE
+ await page.screenshot({ path: `${SHOTS}/dev-${name}-BEFORE.png`, fullPage: false });
+ const before = await probeTitle(page);
+
+ // APPLY staged CSS + snippet (no-image case = the real /collections/all state)
+ await page.addStyleTag({ content: CSS });
+ await page.addStyleTag({ content: SNIPPET_NOIMG });
+ // tag wrapper class the snippet/JS would add for has-image detection (none here)
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: `${SHOTS}/dev-${name}-AFTER-noimage.png`, fullPage: false });
+ const afterNoImg = await probeTitle(page);
+
+ // Now simulate IMAGE-PRESENT case (snippet sets body.dw-collection-has-hero)
+ await page.addStyleTag({ content: SNIPPET_IMG });
+ await page.evaluate(() => { document.body.classList.add('dw-collection-has-hero'); document.documentElement.classList.add('dw-coll-hero'); });
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: `${SHOTS}/dev-${name}-AFTER-withimage.png`, fullPage: false });
+ const afterImg = await probeTitle(page);
+
+ console.log(`\n=== ${name.toUpperCase()} ===`);
+ console.log('BEFORE boostTitle bg:', before.boostTitle && before.boostTitle.bg, '| themeTitle visible:', before.themeTitle && before.themeTitle.visible);
+ console.log('AFTER-noimg boostTitle bg:', afterNoImg.boostTitle && afterNoImg.boostTitle.bg, 'color:', afterNoImg.boostTitle && afterNoImg.boostTitle.color, '| themeTitle visible:', afterNoImg.themeTitle && afterNoImg.themeTitle.visible);
+ console.log('AFTER-img boostTitle bg:', afterImg.boostTitle && afterImg.boostTitle.bg, 'color:', afterImg.boostTitle && afterImg.boostTitle.color, '| header bg-image applied:', afterImg.boostHeader && afterImg.boostHeader.bg);
+
+ if (mobile) {
+ const gap = await page.evaluate(() => {
+ const cart = document.querySelector('.cart-count');
+ const brand = document.querySelector('.header-branding-desktop, .header-branding');
+ const g = (cart && brand) ? Math.round(brand.getBoundingClientRect().top - cart.getBoundingClientRect().bottom) : null;
+ const bH = brand ? Math.round(brand.getBoundingClientRect().height) : null;
+ const mh = document.querySelector('.main-header');
+ return { cartToBrandGap: g, brandingHeight: bH, headerHeight: mh ? Math.round(mh.getBoundingClientRect().height) : null };
+ });
+ console.log('MOBILE F3 after-fix:', JSON.stringify(gap), '(BEFORE was gap~11, brandingH 118, headerH 160)');
+ }
+
+ await ctx.close();
+ }
+ await browser.close();
+ console.log('\nScreenshots in', SHOTS);
+})();
← 8d9e17ae Designtex: live gap audit + fill-in plan from BigCommerce cr
·
back to Designer Wallcoverings
·
Designtex: comprehensive gated memo + live-77 UOM flip scrip 06ab4e14 →