[object Object]

← 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

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 →