← back to Dw Contact Us Pages
verify.mjs (PASS/FAIL/NOT-MEASURED) + README with order of operations, rollback, residual risks
b61f5af0ef518de0374b623f6770ac3a65ad36cb · 2026-09-19 10:11:07 -0700 · Claude (TK-11925)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki
Files touched
A README.mdA scripts/verify.mjs
Diff
commit b61f5af0ef518de0374b623f6770ac3a65ad36cb
Author: Claude (TK-11925) <steve@designerwallcoverings.com>
Date: Sat Sep 19 10:11:07 2026 -0700
verify.mjs (PASS/FAIL/NOT-MEASURED) + README with order of operations, rollback, residual risks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163KBzeE1R39RSbxNmAjbki
---
README.md | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++++
scripts/verify.mjs | 120 ++++++++++++++++++++++++++++++++++++
2 files changed, 296 insertions(+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1780a78
--- /dev/null
+++ b/README.md
@@ -0,0 +1,176 @@
+# dw-contact-us-pages — TK-11925
+
+Turn every **ACTIVE** product of three to-the-trade vendors into a **contact-us page**:
+no price, no add-to-cart, no sample button — a contact block directly under the product
+image instead.
+
+| Vendor | ACTIVE products |
+|---|---|
+| Designers Guild | 568 |
+| Ralph Lauren | 140 |
+| Christian Lacroix Europe | 77 |
+| **total** | **785** |
+
+Store: `designer-laboratory-sandbox.myshopify.com` — **this IS the live production
+Designer Wallcoverings store** despite the handle. Live theme: `145556635699`
+("DW Sample-Shipping DEV", role `main`). Admin API `2024-10`.
+
+Quadrille is explicitly **not** in scope.
+
+## Hard rails baked into every script
+
+* **Dry-run is the default.** No script writes anything without an explicit `--apply`.
+* `push-theme.mjs` **refuses** a theme whose role is `main` (and refuses theme id
+ `145556635699` by id) unless `--allow-main` is *also* passed.
+* Every write script captures a **preimage first** and has a `--rollback`.
+* Every applied run appends to `~/.claude/yolo-queue/executed-reversible/ledger.jsonl`
+ with a concrete undo command.
+* The **$4.25 Sample variant is never touched** — 785 of them, all titled `Sample`.
+ The swatch stays orderable, which is the whole point of keeping the PDP live.
+
+## How the "no price / no cart / no sample" actually works
+
+`snippets/product-form-content.liquid` renders the buy box from
+`{% for block in section.blocks %}`: the `price` block prints the price, the `form`
+block prints the variant selector, the sample buttons and add-to-cart. So a product
+template **without those two blocks renders none of it**. That is the primary
+mechanism — CSS is only a backstop.
+
+1. **`theme/templates/product.contact-us.json`** — a copy of the live
+ `templates/product.json` with the `price` and `form` blocks removed, the
+ `description` block enabled, and the tag-gated `contact_for_price` section dropped.
+ `recommendations` and the `div.product-section` wrapper are kept.
+ *(Written as strict JSON with no `/* */` comment header — Shopify documents comment
+ support in JSON templates but it was not verifiable from here, so the zero-risk
+ form was chosen and the rationale lives in this README instead.)*
+2. **`theme/snippets/dw-contact-us-block.liquid`** — the contact block. Self-gates on
+ `template.suffix == 'contact-us'`. Heading, vendor/pattern/base-SKU line, the
+ "available through our showroom" lede, a `tel:` and a prefilled `mailto:`, and an
+ inline inquiry form that POSTs the **same JSON body to the same live endpoint**
+ as `sections/contact-for-price.liquid`
+ (`https://api.designerwallcoverings.com/api/sku-inquiry`; fields `productId
+ productTitle sku name email phone company role quantity unit projectName city
+ timeline budget notes`), with inline success/error states. Self-contained CSS under
+ `.dw-cu*`, Lora headings / `#3D4246` text to match the house look. No external deps.
+ It also carries the belt-and-braces CSS under `body.template-suffix-contact-us`
+ hiding `.product__price .product__form .product-price .cfp-section .dw-quote-section
+ .dw-sample-banner .add-to-cart .dl-sample-btn .dl-second-sample-btn .product-options
+ .mm_quantity .shopify-payment-button .product-details .money` — the class inventory
+ measured out of `product-form-content.liquid`.
+3. **`theme/snippets/product.liquid`** — the live file, one additive block: right after
+ `{% render 'product-gallery' %}` it renders the contact block, so it sits directly
+ **below the image column** and above `.product-details-wrapper`.
+4. **`theme/assets/dw-contact-us-cards.js`** + **`theme/snippets/hide-browse-hidden.liquid`**
+ — Boost SD renders browse/search grids client-side. The asset sweeps
+ `.boost-sd__product-item`, identifies the cohort by the card's
+ `[class*="product-vendor"]` text, and swaps `.boost-sd__product-price` /
+ `.boost-sd__format-currency` for **"Contact us for pricing"**. Vendor list comes from
+ `window.DW_CONTACT_US_VENDORS`, emitted by liquid from
+ `shop.metafields.custom.contact_us_vendors` → `settings.contact_us_vendors` → the
+ literal three names (mirrors the `showroom_vendors` pattern). An optional
+ `window.DW_CONTACT_US_IDS` id-set backstop is honoured if ever emitted.
+5. **`theme/snippets/product-list-item.liquid`** — the Liquid-rendered card path. Where
+ `item.vendor == 'Newmor Wallcoverings'` prints "Showroom inquiry", a branch keyed on
+ `item.template_suffix == 'contact-us'` now prints "Contact us for pricing".
+
+## Order of operations
+
+```sh
+cd ~/Projects/dw-contact-us-pages
+
+node scripts/enumerate.mjs # READ ONLY -> data/targets.json
+
+# 1. theme — push to a DUPLICATE theme first and eyeball a PDP
+node scripts/push-theme.mjs --theme <dup-id>
+node scripts/push-theme.mjs --theme <dup-id> --apply
+# live main theme (Steve-gated):
+node scripts/push-theme.mjs --theme 145556635699 --apply --allow-main
+
+# 2. template assignment (785 products, templateSuffix null/'' -> contact-us)
+node scripts/assign-template.mjs
+node scripts/assign-template.mjs --apply
+
+# 3. hide + harden (737 non-sample variants; 785 Sample variants untouched)
+node scripts/harden-variants.mjs
+node scripts/harden-variants.mjs --apply
+
+# 4. channels (2,355 unpublish ops over 785 products)
+node scripts/unpublish-channels.mjs
+node scripts/unpublish-channels.mjs --apply
+
+# 5. verify
+node scripts/verify.mjs
+```
+
+Steps 2–4 are order-independent of each other, but the **theme must be pushed before the
+template is assigned** — otherwise `templateSuffix=contact-us` points at a template that
+does not exist yet and the PDP 404s the template.
+
+## Rollback
+
+```sh
+node scripts/rollback-theme.mjs --theme <id> --apply # restores byte-for-byte; deletes assets that were new
+node scripts/assign-template.mjs --rollback --apply # restores the old templateSuffix
+node scripts/harden-variants.mjs --rollback --apply # policy -> quantities -> tracked:false LAST
+node scripts/unpublish-channels.mjs --rollback --apply # re-publishes only what was published before
+```
+
+Preimages: `data/theme-preimage/<themeId>/**` (+ `manifest.json`),
+`data/ledger-template.jsonl`, `data/ledger-variants.jsonl`, `data/ledger-channels.jsonl`.
+Rollback always reads the **first** ledger record per id, so a re-run of an apply cannot
+overwrite the true preimage.
+
+## Inventory hardening — why the order matters
+
+Measured on the live cohort (785 products / 1,522 variants):
+
+* 737 non-sample variants, **all** `inventoryPolicy = CONTINUE`.
+* **598 of them have `inventoryItem.tracked = false`** (DG 521 + CL 77). An untracked
+ variant is *always* available no matter the policy, and `inventorySetQuantities`
+ **fails** on an untracked item. So the forward order is
+ **`inventoryItemUpdate tracked:true` → `inventorySetQuantities on_hand 0` → `policy DENY`**,
+ and the rollback order is the reverse with `tracked:false` restored **last**.
+* 139 tracked variants (Ralph Lauren) have `qty > 0`; their per-location on-hand is in
+ the ledger and is restored exactly.
+* Exactly **one** location: `gid://shopify/Location/5795643504` ("15442 Ventura Blvd.").
+* **48 products** (DG 47, RL 1) are Sample-only with no sellable variant — hardening has
+ nothing to do for them; template assignment and unpublishing still apply. `verify.mjs`
+ reports those as **NOT-MEASURED (0 of 0)**, never green.
+
+## Channels
+
+Unpublished from **Google & YouTube** (`29646651457`), **Shop** (`44317507635`) and
+**Buy Button** (`22497296496`) only — 785 × 3 = **2,355** operations, all three currently
+published for all 785. **Online Store stays published**: the PDP *is* the contact-us page.
+Facebook & Instagram, Pinterest, TikTok, Houzz, Fabricut, POS, Inbox, Rakuten and
+DWAutoPostBlog are deliberately untouched.
+
+## Residual risks
+
+1. **`/products/<handle>.json` and the storefront product JSON still expose variant price
+ strings** even when `available:false`. Shopify offers no way to suppress that for a
+ published product. Price is hidden from the *page*, not from the public JSON endpoint.
+ Same for `oembed`/structured-data consumers that read that JSON.
+2. **The Boost grid treatment depends on client-side JS.** No-JS crawlers and the moment
+ before the sweep runs will still show the Boost price. The Liquid card path
+ (`product-list-item.liquid`) has no such gap, but Boost renders most grids.
+3. **The `body.template-suffix-contact-us` hook** comes from `layout/theme.liquid`
+ (~line 798). If that body class is ever removed, the belt-and-braces CSS silently
+ stops applying — the template block removal still holds, which is why the CSS is the
+ backstop and not the mechanism.
+4. **Boost vendor text is the card key.** If the Boost card layout is configured to hide
+ the vendor line, `[class*="product-vendor"]` disappears and the sweep misses; the
+ `window.DW_CONTACT_US_IDS` backstop exists for that case but is not emitted today.
+5. **Theme editor drift.** Someone re-adding a `price`/`form` block to
+ `product.contact-us.json` in the theme editor re-introduces the buy box; only the CSS
+ backstop catches it.
+6. **`templateSuffix` restore is `''`, not `null`.** 645 products read `null` and 140 read
+ `''` today; Shopify's write API has no `null`, so rollback sets `''` for both. The two
+ are behaviourally identical (no suffix).
+7. **Sample-only products (48)** show a contact block *and* an orderable $4.25 swatch —
+ intended, but worth an eyeball on one of them before the full run.
+
+## Cost
+
+All operations are Shopify Admin API calls against an existing plan — **$0 incremental
+spend**. Local scripting: `$0 (local)`.
diff --git a/scripts/verify.mjs b/scripts/verify.mjs
new file mode 100644
index 0000000..f2794a7
--- /dev/null
+++ b/scripts/verify.mjs
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// verify.mjs — READ ONLY post-apply verification. TK-11925.
+// node scripts/verify.mjs [--n 5] [--storefront https://www.designerwallcoverings.com]
+//
+// Per CLAUDE.md TK-11431: an UNMEASURED input is never PASS. Every check reports
+// MEASURED-GOOD / MEASURED-BAD / NOT-MEASURED, carries population beside observed,
+// and 0-of-0 is WARN, never green.
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { ROOT, parseArgs, gql, loadTargets, VENDORS, TARGET_PUBLICATIONS, isSampleVariant } from './lib.mjs';
+
+const a = parseArgs();
+const N = Number(a.n || 5);
+const STORE = String(a.storefront || 'https://www.designerwallcoverings.com').replace(/\/$/, '');
+
+const targets = loadTargets();
+const sample = [];
+for (const v of VENDORS) sample.push(...targets.filter((p) => p.vendor === v).slice(0, N));
+if (!sample.length) { console.error('WARN: population 0 — nothing sampled. NOT-MEASURED, not PASS.'); process.exit(1); }
+
+const Q = `query V($ids: [ID!]!) {
+ nodes(ids: $ids) { ... on Product {
+ id handle vendor templateSuffix
+ resourcePublicationsV2(first: 30) { nodes { isPublished publication { id name } } }
+ variants(first: 100) { nodes { id sku title inventoryPolicy inventoryQuantity inventoryItem { tracked } } }
+ } } }`;
+
+const state = new Map();
+for (let i = 0; i < sample.length; i += 20) {
+ const d = await gql(Q, { ids: sample.slice(i, i + 20).map((p) => p.id) });
+ for (const n of d.nodes) if (n) state.set(n.id, n);
+}
+
+const TARGET_PUB_IDS = new Set(TARGET_PUBLICATIONS.map((p) => p.id));
+const rows = [];
+let anyFail = 0, anyUnmeasured = 0;
+
+for (const p of sample) {
+ const r = { handle: p.handle, vendor: p.vendor, checks: {} };
+ const set = (k, verdict, detail) => { r.checks[k] = { verdict, detail }; if (verdict === 'FAIL') anyFail++; if (verdict === 'NOT-MEASURED') anyUnmeasured++; };
+
+ // -- 1. admin state
+ const s = state.get(p.id);
+ if (!s) set('templateSuffix', 'NOT-MEASURED', 'product not returned by admin API');
+ else {
+ set('templateSuffix', s.templateSuffix === 'contact-us' ? 'PASS' : 'FAIL', `suffix=${JSON.stringify(s.templateSuffix)}`);
+ const ns = s.variants.nodes.filter((v) => !isSampleVariant(v));
+ const bad = ns.filter((v) => !(v.inventoryPolicy === 'DENY' && v.inventoryItem?.tracked === true && (v.inventoryQuantity || 0) <= 0));
+ set('variants_hardened', ns.length === 0 ? 'NOT-MEASURED' : (bad.length ? 'FAIL' : 'PASS'),
+ `${ns.length - bad.length}/${ns.length} non-sample DENY+tracked+qty<=0` + (ns.length === 0 ? ' (sample-only product: 0 of 0)' : ''));
+ const smp = s.variants.nodes.filter((v) => isSampleVariant(v));
+ set('sample_untouched', smp.length === 0 ? 'NOT-MEASURED' : 'PASS', `${smp.length} sample variant(s) present`);
+ const stillOn = s.resourcePublicationsV2.nodes.filter((x) => TARGET_PUB_IDS.has(x.publication.id) && x.isPublished);
+ set('channels_unpublished', stillOn.length ? 'FAIL' : 'PASS',
+ stillOn.length ? 'still on: ' + stillOn.map((x) => x.publication.name).join(', ') : 'off G&YT / Shop / Buy Button');
+ const os = s.resourcePublicationsV2.nodes.find((x) => x.publication.name === 'Online Store');
+ set('online_store_kept', !os ? 'NOT-MEASURED' : (os.isPublished ? 'PASS' : 'FAIL'), os ? `published=${os.isPublished}` : 'no Online Store publication row');
+ }
+
+ // -- 2. live PDP HTML
+ let html = null;
+ try {
+ const res = await fetch(`${STORE}/products/${p.handle}`, { headers: { 'User-Agent': 'dw-contact-us-verify/1.0' } });
+ if (res.ok) html = await res.text(); else set('pdp_fetch', 'NOT-MEASURED', `HTTP ${res.status}`);
+ } catch (e) { set('pdp_fetch', 'NOT-MEASURED', String(e).slice(0, 80)); }
+
+ if (html === null) {
+ for (const k of ['contact_block', 'no_add_to_cart', 'no_price_markup', 'no_sample_button', 'body_class'])
+ set(k, 'NOT-MEASURED', 'PDP HTML unavailable');
+ } else {
+ set('contact_block', /class="dw-cu"|dw-cu__h/.test(html) ? 'PASS' : 'FAIL', 'dw-cu block in HTML');
+ const cart = /Add to cart|class="add-to-cart|name="add"/i.test(html);
+ set('no_add_to_cart', cart ? 'FAIL' : 'PASS', cart ? 'add-to-cart markup present' : 'none');
+ const details = (html.match(/<div class="product-details-wrapper">[\s\S]*?<\/div>\s*<\/div>/) || [''])[0] || '';
+ const priced = /class="product__price"/.test(html) || /<span class="money">/.test(details);
+ set('no_price_markup', priced ? 'FAIL' : 'PASS', priced ? 'price markup in product details' : 'none');
+ const smpBtn = /dl-sample-btn|dl-second-sample-btn|Complimentary Sample/.test(html);
+ set('no_sample_button', smpBtn ? 'FAIL' : 'PASS', smpBtn ? 'sample UI present' : 'none');
+ set('body_class', /template-suffix-contact-us/.test(html) ? 'PASS' : 'FAIL', 'body.template-suffix-contact-us hook');
+ }
+
+ // -- 3. public product json
+ try {
+ const res = await fetch(`${STORE}/products/${p.handle}.json`);
+ if (!res.ok) set('json_unavailable', 'NOT-MEASURED', `HTTP ${res.status}`);
+ else {
+ const j = await res.json();
+ const ns = (j.product?.variants || []).filter((v) => !isSampleVariant(v));
+ const avail = ns.filter((v) => v.available);
+ set('json_rolls_unavailable', ns.length === 0 ? 'NOT-MEASURED' : (avail.length ? 'FAIL' : 'PASS'),
+ `${ns.length - avail.length}/${ns.length} non-sample variants available:false`);
+ }
+ } catch (e) { set('json_unavailable', 'NOT-MEASURED', String(e).slice(0, 80)); }
+
+ rows.push(r);
+}
+
+// ---- table
+const cols = [...new Set(rows.flatMap((r) => Object.keys(r.checks)))];
+const pad = (s, n) => String(s).padEnd(n);
+console.log('\nTK-11925 verify · sampled ' + rows.length + ' products (' + N + ' per vendor) · storefront ' + STORE + '\n');
+console.log(pad('handle', 42) + cols.map((c) => pad(c.slice(0, 13), 15)).join(''));
+for (const r of rows) {
+ console.log(pad(r.handle.slice(0, 40), 42) + cols.map((c) => {
+ const v = r.checks[c]?.verdict || '-';
+ const mark = v === 'PASS' ? 'PASS' : v === 'FAIL' ? 'FAIL' : v === '-' ? '-' : 'NOT-MEAS';
+ return pad(mark, 15);
+ }).join(''));
+}
+for (const r of rows) for (const [k, v] of Object.entries(r.checks)) if (v.verdict !== 'PASS') console.log(` ! ${r.handle} ${k}: ${v.verdict} — ${v.detail}`);
+
+const verdict = anyFail ? 'FAIL' : anyUnmeasured ? 'WARN' : 'PASS';
+console.log(`\nVERDICT: ${verdict} (fail=${anyFail}, not-measured=${anyUnmeasured}, population=${targets.length}, observed=${rows.length})`);
+console.log('NOT MEASURED BY THIS SCRIPT: the Boost grid "Contact us for pricing" swap is client-side JS —');
+console.log(' it cannot be asserted from raw HTML. Check a collection page in a browser.');
+mkdirSync(`${ROOT}/data`, { recursive: true });
+writeFileSync(`${ROOT}/data/verify-latest.json`, JSON.stringify({
+ ts: new Date().toISOString(), verdict, fail: anyFail, not_measured: anyUnmeasured,
+ population: targets.length, observed: rows.length, storefront: STORE, rows,
+}, null, 2));
+process.exit(verdict === 'FAIL' ? 1 : 0);
← 63acba0 scripts: assign-template, harden-variants (tracked-first), u
·
back to Dw Contact Us Pages
·
layout: contact block sits under the image via a scoped 2-co 5d1534b →