[object Object]

← back to Designer Wallcoverings

security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy.

19c8a3775ae29ae930c0449a44444e0622eec6f3 · 2026-05-30 11:35:47 -0700 · Steve

Files touched

Diff

commit 19c8a3775ae29ae930c0449a44444e0622eec6f3
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat May 30 11:35:47 2026 -0700

    security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy.
---
 shopify/scripts/coleson-add-bolt-variants.js     | 182 ++++++++++++++++
 shopify/scripts/coleson-add-panel-variants.js    | 130 +++++++++++
 shopify/scripts/coleson-palm-jungle-refresh.js   | 265 +++++++++++++++++++++++
 shopify/scripts/coleson-panel-cleared.js         |  91 ++++++++
 shopify/scripts/coleson-retry-failures.js        | 163 ++++++++++++++
 shopify/scripts/coleson-seasonal-woods-panels.js |  99 +++++++++
 6 files changed, 930 insertions(+)

diff --git a/shopify/scripts/coleson-add-bolt-variants.js b/shopify/scripts/coleson-add-bolt-variants.js
new file mode 100644
index 00000000..739d93f9
--- /dev/null
+++ b/shopify/scripts/coleson-add-bolt-variants.js
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+// Add a "Sold Per Bolt (<width>in x 33ft)" variant to every Cole & Son
+// sample-only product on designer-laboratory-sandbox.myshopify.com.
+//
+// Source data: /tmp/cole-son-pricing/coleson_worklist.csv (826 rows)
+//   Built via PG join coleson_catalog × Kravet PriceAdj_April19.xlsx (NEW MAP).
+//
+// Per row:
+//   width_inches ∈ [19..22]  → bolt 20.5", weight 4.0 lb
+//   width_inches ∈ [26..29]  → bolt 27.5", weight 5.0 lb
+//   price = Kravet NEW MAP (USD/roll)
+//   sku   = `${base_dw_sku}-Sold Per Bolt (<width>in x 33ft)`  (base = dw_sku minus '-Sample')
+//   tracked = false  (matches sandberg + sample behavior)
+//
+// Safeguards:
+//   - --dry-run is the DEFAULT. Live writes require --live.
+//   - --limit N caps the run (sane for test batches).
+//   - Idempotency: skip product if any variant title !== 'Sample' already.
+//   - Per-product userErrors captured; full per-row JSON saved.
+//   - 150ms pacing matches sandberg script.
+
+const fs = require('fs');
+const path = require('path');
+
+const SHOP = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+
+if (!TOKEN) { console.error('SHOPIFY_ADMIN_TOKEN unset'); process.exit(1); }
+
+const ARGS = process.argv.slice(2);
+const LIVE = ARGS.includes('--live');
+const DRY = !LIVE;
+const LIMIT_IDX = ARGS.findIndex(a => a === '--limit');
+const LIMIT = LIMIT_IDX >= 0 ? parseInt(ARGS[LIMIT_IDX + 1], 10) : null;
+const INPUT = ARGS.find(a => a.startsWith('--input='))?.slice(8)
+            || '/tmp/cole-son-pricing/coleson_worklist.csv';
+
+function loadCSV(p) {
+  const txt = fs.readFileSync(p, 'utf8').replace(/\r/g, '');
+  const lines = txt.split('\n').filter(Boolean);
+  const header = lines[0].split(',');
+  const rows = [];
+  for (let i = 1; i < lines.length; i++) {
+    // Naive CSV parse: collection contains "&" but no commas inside fields per our export
+    const cols = [];
+    let cur = '', inQ = false;
+    for (const ch of lines[i]) {
+      if (ch === '"') inQ = !inQ;
+      else if (ch === ',' && !inQ) { cols.push(cur); cur = ''; }
+      else cur += ch;
+    }
+    cols.push(cur);
+    const rec = {};
+    header.forEach((h, j) => rec[h] = cols[j]);
+    rows.push(rec);
+  }
+  return rows;
+}
+
+async function gql(query, variables = {}) {
+  for (let attempt = 0; attempt < 3; attempt++) {
+    const res = await fetch(ENDPOINT, {
+      method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query, variables }),
+    });
+    const json = await res.json();
+    if (!json.errors) return json;
+    if (attempt === 2) throw new Error(JSON.stringify(json.errors).slice(0, 400));
+    await new Promise(r => setTimeout(r, 600 * (attempt + 1)));
+  }
+}
+
+const INSPECT_Q = `
+  query Inspect($id: ID!) {
+    product(id: $id) {
+      id title status vendor
+      options { id name values }
+      variants(first: 20) {
+        edges { node { id title sku selectedOptions { name value } } }
+      }
+    }
+  }`;
+
+const ADD_VARIANT = `
+  mutation Add($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
+    productVariantsBulkCreate(productId: $productId, variants: $variants) {
+      productVariants { id title sku price inventoryItem { id measurement { weight { value unit } } } }
+      userErrors { field message code }
+    }
+  }`;
+
+async function processOne(row) {
+  const productId = row.shopify_product_id; // already a gid
+  const { data } = await gql(INSPECT_Q, { id: productId });
+  if (!data || !data.product) return { row, status: 'missing-product' };
+  const variants = data.product.variants.edges.map(e => e.node);
+  const hasBolt = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+  if (hasBolt) return { row, status: 'skip-has-bolt' };
+
+  // Base DW SKU: strip the trailing "-Sample" from the sample variant if present
+  const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+  const baseSku = sampleVariant
+    ? (sampleVariant.sku || '').replace(/-Sample$/, '')
+    : (row.dw_sku || '').replace(/-Sample$/, '');
+  if (!baseSku) return { row, status: 'no-base-sku', detail: { sampleVariant, dwSkuFromCsv: row.dw_sku } };
+
+  const boltLabel = `Sold Per Bolt (${row.bolt_width}in x 33ft)`;
+  const newSku = `${baseSku}-${boltLabel}`;
+  const priceStr = Number(row.map_price).toFixed(2);
+  const weightVal = Number(row.bolt_weight_lb);
+
+  const variantInput = {
+    optionValues: [{ optionName: OPTION_NAME, name: boltLabel }],
+    price: priceStr,
+    inventoryItem: {
+      sku: newSku,
+      measurement: { weight: { value: weightVal, unit: 'POUNDS' } },
+      tracked: false,
+    },
+  };
+
+  if (DRY) return { row, status: 'DRY', baseSku, newSku, priceStr, weightVal, boltLabel };
+
+  const { data: d2 } = await gql(ADD_VARIANT, { productId, variants: [variantInput] });
+  const errs = d2.productVariantsBulkCreate.userErrors;
+  if (errs && errs.length) return { row, status: 'error', errors: errs, newSku, priceStr };
+  const created = d2.productVariantsBulkCreate.productVariants[0];
+  return {
+    row, status: 'ok',
+    newVariantId: created.id, sku: created.sku, price: created.price,
+    weight: created.inventoryItem?.measurement?.weight,
+  };
+}
+
+async function run() {
+  let rows = loadCSV(INPUT);
+  if (LIMIT) rows = rows.slice(0, LIMIT);
+  console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'}  input=${INPUT}  rows=${rows.length}`);
+  console.log(`  narrow=${rows.filter(r => r.bolt_width === '20.5').length}  wider=${rows.filter(r => r.bolt_width === '27.5').length}`);
+
+  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+  const outPath = `/tmp/cole-son-pricing/run-${LIVE ? 'live' : 'dry'}-${stamp}.json`;
+
+  const results = [];
+  for (let i = 0; i < rows.length; i++) {
+    let r;
+    try { r = await processOne(rows[i]); }
+    catch (e) { r = { row: rows[i], status: 'exception', error: String(e).slice(0, 400) }; }
+    results.push(r);
+    if (((i + 1) % 10) === 0 || i + 1 === rows.length) {
+      process.stdout.write(`\r  ${i + 1}/${rows.length}  `);
+    }
+    if (LIVE) await new Promise(r => setTimeout(r, 150));
+  }
+  process.stdout.write('\n');
+
+  const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+  console.log('Status:', JSON.stringify(byStatus));
+  fs.writeFileSync(outPath, JSON.stringify(results, null, 2));
+  console.log(`saved → ${outPath}`);
+
+  // Failure samples
+  const failed = results.filter(r => r.status === 'error' || r.status === 'exception');
+  if (failed.length) {
+    console.log(`\nFAILED ${failed.length} (first 5):`);
+    for (const f of failed.slice(0, 5)) {
+      console.log('  ', f.row.mfr_sku, f.status, JSON.stringify(f.errors || f.error).slice(0, 200));
+    }
+  }
+  if (DRY) {
+    const sample = results.filter(r => r.status === 'DRY').slice(0, 5);
+    console.log('\nFirst 5 planned variants:');
+    for (const r of sample) {
+      console.log(`  ${r.row.mfr_sku}  bolt=${r.boltLabel}  $${r.priceStr}  ${r.weightVal} lb  sku="${r.newSku}"`);
+    }
+  }
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/shopify/scripts/coleson-add-panel-variants.js b/shopify/scripts/coleson-add-panel-variants.js
new file mode 100644
index 00000000..748295ec
--- /dev/null
+++ b/shopify/scripts/coleson-add-panel-variants.js
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+// Add a "Sold Per Panel" variant to the 12 settlement-safe Cole & Son
+// panel/mural products on designer-laboratory-sandbox.myshopify.com.
+//
+// Spec:
+//   optionValue = "Sold Per Panel"
+//   sku         = `${base_dw_sku}-Sold Per Panel`
+//   price       = Kravet NEW MAP (per EACH, e.g. $616.50 for Nuvolette)
+//   weight      = 8 lb (panel-scale; bolts are 4-5 lb)
+//   tracked     = false
+//
+// Scope: 12 panels at 54-55.1" wide. All Fornasetti/Bullard/Royal Palaces/Gardens
+// non-foliage subjects (clouds, flying machines, monochrome florals, gates).
+// Skips Uccelli, Idyll Metallic, Verdure Tapestry, Seasonal Woods (settlement-flagged).
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+const PANEL_LABEL = 'Sold Per Panel';
+const PANEL_WEIGHT_LB = 8;
+const LIVE = process.argv.includes('--live');
+
+// 12 safe panels from PG worklist
+const WORKLIST = [
+  { mfr: "114/28057.CS.0", dw_base: "DWKK-133867", pid: "7421482336307", w: 54.0,  map: 616.5, pattern: "NUVOLETTE",        color: "SLATE BLUE"        },
+  { mfr: "114/28054.CS.0", dw_base: "DWKK-133864", pid: "7421482106931", w: 54.0,  map: 616.5, pattern: "NUVOLETTE",        color: "BLACK/WHITE"       },
+  { mfr: "114/28055.CS.0", dw_base: "DWKK-133865", pid: "7421482205235", w: 54.0,  map: 616.5, pattern: "NUVOLETTE",        color: "SOOT/SNOW"         },
+  { mfr: "114/28056.CS.0", dw_base: "DWKK-133866", pid: "7421482238003", w: 54.0,  map: 616.5, pattern: "NUVOLETTE",        color: "STONE"             },
+  { mfr: "114/2004.CS.0",  dw_base: "DWKK-133848", pid: "7421480927283", w: 54.0,  map: 616.5, pattern: "NUVOLETTE",        color: "GILVER & CHARCOAL" },
+  { mfr: "114/2005.CS.0",  dw_base: "DWKK-133850", pid: "7421481123891", w: 54.0,  map: 616.5, pattern: "NUVOLETTE",        color: "PEARL"             },
+  { mfr: "114/10020.CS.0", dw_base: "DWKK-133828", pid: "7421479419955", w: 54.0,  map: 658.5, pattern: "MACCHINE VOLANTI", color: "STONE/ROUGE/BLUE"  },
+  { mfr: "114/27053.CS.0", dw_base: "DWKK-133863", pid: "7421482041395", w: 54.0,  map: 658.5, pattern: "MACCHINE VOLANTI", color: "SOOT/SNOW"         },
+  { mfr: "113/4012.CS.0",  dw_base: "DWKK-133809", pid: "7421477814323", w: 54.0,  map: 889.5, pattern: "BAHIA",            color: "GOLD & STONE"      },
+  { mfr: "118/8017.CS.0",  dw_base: "DWKK-134038", pid: "7421496033331", w: 55.1,  map: 523.5, pattern: "TIJOU GATE",       color: "SPRING GREEN"      },
+  { mfr: "114/20040.CS.0", dw_base: "DWKK-133849", pid: "7421481025587", w: 55.1,  map: 540.0, pattern: "RIFLESSO",         color: "BLACK/WHITE"       },
+  { mfr: "120/3009.CS.0",  dw_base: "DWKK-134100", pid: "7421500784691", w: 55.1,  map: 616.5, pattern: "GRANDE FLEUR",     color: "PEACH BLUSH"       },
+];
+
+async function gql(query, variables = {}) {
+  for (let attempt = 0; attempt < 3; attempt++) {
+    const res = await fetch(ENDPOINT, {
+      method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query, variables }),
+    });
+    const j = await res.json();
+    if (!j.errors) return j;
+    if (attempt === 2) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+    await new Promise(r => setTimeout(r, 600 * (attempt + 1)));
+  }
+}
+
+const INSPECT_Q = `query($id:ID!){product(id:$id){id title status options{id name values} variants(first:20){edges{node{id title sku selectedOptions{name value}}}}}}`;
+
+const ADD_VARIANT = `mutation Add($productId:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$productId,variants:$variants,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id title sku price inventoryItem{measurement{weight{value unit}}}} userErrors{field message code}}}`;
+
+const CREATE_OPTION = `mutation CreateOption($pid:ID!,$values:[OptionValueCreateInput!]!){productOptionsCreate(productId:$pid, options:[{name:"Size", position:1, values:$values}]){product{id options{id name values}} userErrors{field message code}}}`;
+
+async function processOne(row) {
+  const productId = `gid://shopify/Product/${row.pid}`;
+  const { data } = await gql(INSPECT_Q, { id: productId });
+  if (!data || !data.product) return { row, status: 'missing-product' };
+
+  const product = data.product;
+  const variants = product.variants.edges.map(e => e.node);
+  const hasNonSample = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+  if (hasNonSample) return { row, status: 'skip-has-bolt-or-panel', productId };
+
+  const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+  const baseSku = sampleVariant ? (sampleVariant.sku || '').replace(/-Sample$/, '') : row.dw_base;
+  const newSku = `${baseSku}-${PANEL_LABEL}`;
+  const priceStr = Number(row.map).toFixed(2);
+  const sizeOption = product.options.find(o => o.name === OPTION_NAME);
+
+  if (!LIVE) return { row, status: 'DRY', productId, baseSku, newSku, priceStr, weight: PANEL_WEIGHT_LB, hasSizeOption: !!sizeOption };
+
+  // Create the option if not present (some legacy products only have "Title")
+  if (!sizeOption) {
+    const optRes = await gql(CREATE_OPTION, { pid: productId, values: [{ name: 'Sample' }, { name: PANEL_LABEL }] });
+    const errs = optRes.data.productOptionsCreate.userErrors;
+    if (errs && errs.length) {
+      // Fallback: try with just the panel value
+      const r2 = await gql(CREATE_OPTION, { pid: productId, values: [{ name: PANEL_LABEL }] });
+      const e2 = r2.data.productOptionsCreate.userErrors;
+      if (e2 && e2.length) return { row, status: 'option-create-failed', errors: e2 };
+    }
+  }
+
+  const variantInput = {
+    optionValues: [{ optionName: OPTION_NAME, name: PANEL_LABEL }],
+    price: priceStr,
+    inventoryItem: {
+      sku: newSku,
+      measurement: { weight: { value: PANEL_WEIGHT_LB, unit: 'POUNDS' } },
+      tracked: false,
+    },
+  };
+  const { data: d2 } = await gql(ADD_VARIANT, { productId, variants: [variantInput] });
+  const errs = d2.productVariantsBulkCreate.userErrors;
+  if (errs && errs.length) return { row, status: 'error', errors: errs, baseSku, newSku, priceStr };
+  const created = d2.productVariantsBulkCreate.productVariants[0];
+  return {
+    row, status: 'ok',
+    productId, baseSku,
+    variantId: created.id, sku: created.sku, price: created.price,
+    weight: created.inventoryItem?.measurement?.weight,
+  };
+}
+
+(async () => {
+  console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'}  rows=${WORKLIST.length}`);
+  const results = [];
+  for (let i = 0; i < WORKLIST.length; i++) {
+    try {
+      const r = await processOne(WORKLIST[i]);
+      results.push(r);
+      console.log(`  [${i+1}/${WORKLIST.length}] ${r.row.mfr}  ${r.row.pattern} ${r.row.color}  →  ${r.status}  ${r.sku || ''}  ${r.price ? '$'+r.price : ''}  ${r.weight ? r.weight.value+'lb' : ''}`);
+    } catch (e) {
+      results.push({ row: WORKLIST[i], status: 'exception', error: String(e).slice(0,400) });
+      console.log(`  [${i+1}/${WORKLIST.length}] ${WORKLIST[i].mfr}: EXCEPTION ${String(e).slice(0,160)}`);
+    }
+    if (LIVE) await new Promise(r => setTimeout(r, 200));
+  }
+  const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+  fs.writeFileSync(`/tmp/cole-son-pricing/panel-run-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+  const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+  console.log('\nStatus:', JSON.stringify(byStatus));
+})();
diff --git a/shopify/scripts/coleson-palm-jungle-refresh.js b/shopify/scripts/coleson-palm-jungle-refresh.js
new file mode 100644
index 00000000..09c30c42
--- /dev/null
+++ b/shopify/scripts/coleson-palm-jungle-refresh.js
@@ -0,0 +1,265 @@
+#!/usr/bin/env node
+// Refresh Cole & Son Palm Jungle product line on DW Shopify to match
+// cole-and-son.com's current 7 colorways.
+//
+// Plan:
+//   1) UPDATE 6 existing products (95/1002-95/1007):
+//        - Rename title to new colorway
+//        - Add new Cole & Son CDN image as primary
+//        - Update custom.color metafield
+//        - Add Bolt variant ($244.50, 4 lb, 20.5"×33ft) — NONE currently have one
+//   2) CREATE 1 new product (95/1001 Olive Green on White):
+//        - Sample variant ($4.25)
+//        - Bolt variant ($244.50, 4 lb, 20.5"×33ft)
+//        - Hero image from CS CDN
+//        - Title in DW format
+//        - Width + manufacturer_sku metafields
+//
+// Pricing: all from Kravet 2025 NEW MAP = $244.50/roll for SKUs 95/1001-95/1007.
+//
+// --dry-run is default. --live to actually write.
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const LIVE = process.argv.includes('--live');
+
+// The 7 Palm Jungle colorways with their CS SKU, new colorway name, and Kravet MAP.
+// 95/1001 has shopify_id=null (CREATE); others UPDATE.
+const PLAN = [
+  // 95/1001 already created at productId 7842516140083 — skipping create path
+  { kravet_sku: "95/1002.CS.0", new_color: "Teal & Viridian on Chalk",            shopify_id: "7421884497971", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1002.jpg" },
+  { kravet_sku: "95/1003.CS.0", new_color: "Viridian on Charcoal",                shopify_id: "7421884596275", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1003.jpg" },
+  { kravet_sku: "95/1004.CS.0", new_color: "Metallic Gilver on Charcoal",         shopify_id: "7421884661811", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1004.jpg" },
+  { kravet_sku: "95/1005.CS.0", new_color: "Hyacinth on White",                   shopify_id: "7421884694579", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1005.jpg" },
+  { kravet_sku: "95/1006.CS.0", new_color: "Metallic Silver & Linen on Hyacinth", shopify_id: "7421885251635", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1006.jpg" },
+  { kravet_sku: "95/1007.CS.0", new_color: "Heath Grey on Metallic Silver",       shopify_id: "7421885349939", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1007.jpg" },
+];
+
+const COLLECTION_TAG = "Cole & Son Contemporary Restyled";
+const BOLT_LABEL = "Sold Per Bolt (20.5in x 33ft)";
+const BOLT_WEIGHT_LB = 4;
+const WIDTH_INCHES = "20.5 Inches";
+const SAMPLE_PRICE = "4.25";
+
+async function gql(query, variables = {}) {
+  const r = await fetch(ENDPOINT, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+  return j.data;
+}
+
+const titleFor = (color) => `Palm Jungle - ${color} | Cole & Son Contemporary Restyled |Botanical & Floral Wallcovering`;
+const sampleSku = (baseDw) => `${baseDw}-Sample`;
+const boltSku = (baseDw) => `${baseDw}-${BOLT_LABEL}`;
+
+// ── UPDATE PATH ─────────────────────────────────────────────────────
+async function updateExisting(row) {
+  const productId = `gid://shopify/Product/${row.shopify_id}`;
+  // Fetch current state
+  const { product } = await gql(`query($id:ID!){product(id:$id){id title handle vendor status options{id name values} variants(first:10){edges{node{id title sku price selectedOptions{name value} inventoryItem{id}}}} metafields(first:30){edges{node{id namespace key value type}}} images(first:10){edges{node{id altText url}}}}}`, { id: productId });
+  if (!product) return { status: 'missing', row };
+
+  const newTitle = titleFor(row.new_color);
+  const currentSampleVariant = product.variants.edges.find(e => e.node.title.includes('Sample') || (e.node.sku||'').endsWith('-Sample'))?.node;
+  const baseDwSku = currentSampleVariant ? currentSampleVariant.sku.replace(/-Sample$/, '') : null;
+  if (!baseDwSku) return { status: 'no-base-sku', row };
+
+  const hasBolt = product.variants.edges.some(e => !(e.node.sku||'').endsWith('-Sample') && e.node.title !== 'Sample');
+  const sizeOption = product.options.find(o => o.name === 'Size');
+
+  const ops = [];
+
+  // 1) Title update
+  if (product.title !== newTitle) {
+    ops.push({ op: 'title', from: product.title, to: newTitle });
+  }
+  // 2) Color metafield (custom.color)
+  ops.push({ op: 'metafield.color', to: row.new_color });
+  // 3) Hero image refresh — append new CS image as additional image (don't delete old, just add)
+  ops.push({ op: 'image.append', src: row.image_url });
+  // 4) Bolt variant add (only if not already present)
+  if (!hasBolt) {
+    ops.push({ op: 'bolt.add', sku: boltSku(baseDwSku), price: row.map.toFixed(2), weight: BOLT_WEIGHT_LB });
+  } else {
+    ops.push({ op: 'bolt.skip', reason: 'already-has-non-Sample-variant' });
+  }
+
+  if (!LIVE) return { status: 'DRY', row, productId, baseDwSku, newTitle, ops };
+
+  // Execute
+  const results = {};
+  // (1) Title via productUpdate
+  if (ops.find(o => o.op === 'title')) {
+    const d = await gql(`mutation($p:ProductUpdateInput!){productUpdate(product:$p){product{id title} userErrors{field message}}}`, { p: { id: productId, title: newTitle } });
+    results.title = d.productUpdate.userErrors.length ? { errors: d.productUpdate.userErrors } : { ok: true };
+  }
+  // (2) color metafield via productUpdate(metafields)
+  const d2 = await gql(
+    `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){metafields{key value namespace} userErrors{field message}}}`,
+    { mfs: [
+      { ownerId: productId, namespace: "custom", key: "color", value: row.new_color, type: "single_line_text_field" },
+      { ownerId: productId, namespace: "custom", key: "real_color_name", value: row.new_color, type: "single_line_text_field" },
+      { ownerId: productId, namespace: "dwc",    key: "color", value: row.new_color, type: "single_line_text_field" },
+    ] }
+  );
+  results.metafields = d2.metafieldsSet.userErrors.length ? { errors: d2.metafieldsSet.userErrors } : { ok: true };
+
+  // (3) Append image
+  const d3 = await gql(`mutation($pid:ID!,$media:[CreateMediaInput!]!){productCreateMedia(productId:$pid, media:$media){media{...on MediaImage{id image{url altText}}} userErrors{field message}}}`, { pid: productId, media: [{ originalSource: row.image_url, mediaContentType: "IMAGE", alt: `Palm Jungle ${row.new_color} | Cole & Son` }] });
+  results.image = d3.productCreateMedia.userErrors.length ? { errors: d3.productCreateMedia.userErrors } : { ok: true, mediaId: d3.productCreateMedia.media?.[0]?.id };
+
+  // (4) Add bolt variant if missing
+  if (!hasBolt) {
+    const variantInput = {
+      optionValues: sizeOption
+        ? [{ optionName: 'Size', name: BOLT_LABEL }]
+        : [{ optionName: 'Title', name: BOLT_LABEL }], // fallback if no Size option
+      price: row.map.toFixed(2),
+      inventoryItem: {
+        sku: boltSku(baseDwSku),
+        measurement: { weight: { value: BOLT_WEIGHT_LB, unit: 'POUNDS' } },
+        tracked: false,
+      },
+    };
+    const d4 = await gql(`mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs){productVariants{id title sku price} userErrors{field message code}}}`, { pid: productId, vs: [variantInput] });
+    results.bolt = d4.productVariantsBulkCreate.userErrors.length ? { errors: d4.productVariantsBulkCreate.userErrors } : { ok: true, sku: d4.productVariantsBulkCreate.productVariants[0]?.sku };
+  }
+
+  return { status: 'ok-update', row, productId, baseDwSku, results };
+}
+
+// ── CREATE PATH ─────────────────────────────────────────────────────
+async function createNew(row) {
+  // Generate new DW SKU — scan recent Cole & Son products for max DWKK-NNNNN, then +1.
+  const skuQ2 = await gql(`{products(first:100, query:"vendor:'Cole & Son'", sortKey:CREATED_AT, reverse:true){edges{node{variants(first:2){edges{node{sku}}}}}}}`);
+  let maxNum = 0;
+  for (const e of skuQ2.products.edges) {
+    for (const ve of e.node.variants.edges) {
+      const m = (ve.node.sku || '').match(/^DWKK-(\d+)(-|$)/);
+      if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
+    }
+  }
+  const newDwSku = `DWKK-${maxNum + 1}`;
+  const newTitle = titleFor(row.new_color);
+
+  const productInput = {
+    title: newTitle,
+    vendor: "Cole & Son",
+    productType: "Wallcovering",
+    tags: [
+      "Cole & Son",
+      "Cole & Son Contemporary Restyled",
+      "Palm Jungle",
+      "Botanical",
+      "Floral",
+      "Foliage",
+      "Wallcovering",
+      "Colour_Green",
+      row.kravet_sku, // mfr SKU as tag
+    ],
+    status: "DRAFT",  // start as DRAFT — flip to ACTIVE after image + width metafields confirmed
+    productOptions: [{ name: "Size", values: [{ name: "Sample" }, { name: BOLT_LABEL }] }],
+  };
+
+  if (!LIVE) {
+    return {
+      status: 'DRY-create', row, newDwSku, newTitle, productInput,
+      planned_variants: [
+        { sku: sampleSku(newDwSku), title: 'Sample', price: SAMPLE_PRICE, weight: 0 },
+        { sku: boltSku(newDwSku),   title: BOLT_LABEL, price: row.map.toFixed(2), weight: BOLT_WEIGHT_LB },
+      ],
+      planned_image: row.image_url,
+      planned_metafields: { manufacturer_sku: row.kravet_sku, color: row.new_color, width: WIDTH_INCHES },
+    };
+  }
+
+  // (a) Create the base product with Sample variant
+  const d = await gql(
+    `mutation($p:ProductCreateInput!,$media:[CreateMediaInput!]){productCreate(product:$p, media:$media){product{id title handle status options{id name}} userErrors{field message}}}`,
+    { p: productInput, media: [{ originalSource: row.image_url, mediaContentType: "IMAGE", alt: `Palm Jungle ${row.new_color} | Cole & Son` }] }
+  );
+  const errs = d.productCreate.userErrors;
+  if (errs && errs.length) return { status: 'create-failed', row, errors: errs };
+  const newProductId = d.productCreate.product.id;
+
+  // (b) Add Sample + Bolt variants
+  const variants = [
+    {
+      optionValues: [{ optionName: 'Size', name: 'Sample' }],
+      price: SAMPLE_PRICE,
+      inventoryItem: { sku: sampleSku(newDwSku), measurement: { weight: { value: 0, unit: 'POUNDS' } }, tracked: false },
+    },
+    {
+      optionValues: [{ optionName: 'Size', name: BOLT_LABEL }],
+      price: row.map.toFixed(2),
+      inventoryItem: { sku: boltSku(newDwSku), measurement: { weight: { value: BOLT_WEIGHT_LB, unit: 'POUNDS' } }, tracked: false },
+    },
+  ];
+  const dv = await gql(
+    `mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id title sku price} userErrors{field message code}}}`,
+    { pid: newProductId, vs: variants }
+  );
+  const vErrs = dv.productVariantsBulkCreate.userErrors;
+  if (vErrs && vErrs.length) return { status: 'variants-failed', row, productId: newProductId, errors: vErrs };
+
+  // (c) Set metafields
+  const dmf = await gql(
+    `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){metafields{key value namespace} userErrors{field message}}}`,
+    { mfs: [
+      { ownerId: newProductId, namespace: "custom", key: "manufacturer_sku", value: row.kravet_sku, type: "single_line_text_field" },
+      { ownerId: newProductId, namespace: "dwc",    key: "manufacturer_sku", value: row.kravet_sku, type: "single_line_text_field" },
+      { ownerId: newProductId, namespace: "custom", key: "color",            value: row.new_color,  type: "single_line_text_field" },
+      { ownerId: newProductId, namespace: "custom", key: "real_color_name",  value: row.new_color,  type: "single_line_text_field" },
+      { ownerId: newProductId, namespace: "global", key: "width",            value: WIDTH_INCHES,   type: "single_line_text_field" },
+      { ownerId: newProductId, namespace: "global", key: "length",           value: "33 Feet",      type: "single_line_text_field" },
+      { ownerId: newProductId, namespace: "global", key: "unit_of_measure",  value: "Priced Per Single Roll", type: "single_line_text_field" },
+    ] }
+  );
+
+  // (d) Set product status to ACTIVE
+  const dup = await gql(
+    `mutation($p:ProductUpdateInput!){productUpdate(product:$p){product{id status} userErrors{field message}}}`,
+    { p: { id: newProductId, status: 'ACTIVE' } }
+  );
+
+  return {
+    status: 'ok-create',
+    row, newDwSku, productId: newProductId,
+    title: d.productCreate.product.title,
+    handle: d.productCreate.product.handle,
+    variants_created: dv.productVariantsBulkCreate.productVariants.map(v => ({ sku: v.sku, price: v.price })),
+    metafields_errors: dmf.metafieldsSet.userErrors,
+    activate_errors: dup.productUpdate.userErrors,
+  };
+}
+
+(async () => {
+  console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'}`);
+  const results = [];
+  for (const row of PLAN) {
+    try {
+      const r = row.shopify_id ? await updateExisting(row) : await createNew(row);
+      results.push(r);
+      console.log(`\n${row.kravet_sku}  ${row.new_color}  →  ${r.status}`);
+      if (r.ops) for (const o of r.ops) console.log(`   ${o.op}: ${JSON.stringify(o).slice(0,200)}`);
+      if (r.results) console.log(`   results: ${JSON.stringify(r.results).slice(0,400)}`);
+      if (r.planned_variants) {
+        console.log(`   create plan: dwSku=${r.newDwSku}, title="${r.newTitle}"`);
+        for (const v of r.planned_variants) console.log(`     variant: ${v.sku}  $${v.price}  ${v.weight} lb`);
+      }
+    } catch (e) {
+      results.push({ status: 'exception', row, error: String(e).slice(0,400) });
+      console.log(`\n${row.kravet_sku}  EXCEPTION: ${String(e).slice(0,300)}`);
+    }
+    if (LIVE) await new Promise(r => setTimeout(r, 200));
+  }
+  const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+  fs.writeFileSync(`/tmp/cole-son-pricing/palm-jungle-refresh-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+  console.log(`\nsaved per-row results.`);
+})();
diff --git a/shopify/scripts/coleson-panel-cleared.js b/shopify/scripts/coleson-panel-cleared.js
new file mode 100644
index 00000000..34b6d96d
--- /dev/null
+++ b/shopify/scripts/coleson-panel-cleared.js
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// Scaffold "Sold Per Panel" variants for the 5 settlement-cleared Cole & Son panels.
+// Spec matches coleson-add-panel-variants.js: optionValue="Sold Per Panel", weight=8 lb, tracked=false, price=Kravet NEW MAP.
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+const PANEL_LABEL = 'Sold Per Panel';
+const PANEL_WEIGHT_LB = 8;
+const LIVE = process.argv.includes('--live');
+
+const CLEARED = [
+  // Uccelli — OK (birds on branches, Part A not satisfied)
+  { mfr: "114/11022.CS.0", dw_base: "DWKK-133830", pid: "7421479551027", w: 41.0,  map: 460.5,  pattern: "UCCELLI",          color: "BALLET SLIPPER" },
+  { mfr: "114/11023.CS.0", dw_base: "DWKK-133831", pid: "7421479616563", w: 41.0,  map: 460.5,  pattern: "UCCELLI",          color: "HYACINTH"       },
+  // Idyll Metallic — OK (scenic mural-animal, peacocks at fountain)
+  { mfr: "120/1001M.CS.0", dw_base: "DWKK-134090", pid: "7421499867187", w: 192.9, map: 1354.5, pattern: "IDYLL METALLIC",   color: "BLUSH PEARL"    },
+  { mfr: "120/1002M.CS.0", dw_base: "DWKK-134092", pid: "7421500063795", w: 192.9, map: 1354.5, pattern: "IDYLL METALLIC",   color: "PLATINUM PEARL" },
+  // Verdure Tapestry — OK (scenic tapestry, no B elements, trunks present)
+  { mfr: "118/17038.CS.0", dw_base: "DWKK-134027", pid: "7421495148595", w: 82.6,  map: 925.5,  pattern: "VERDURE TAPESTRY", color: "V T I&C"        },
+];
+
+async function gql(query, variables = {}) {
+  for (let attempt = 0; attempt < 3; attempt++) {
+    const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+    const j = await res.json();
+    if (!j.errors) return j;
+    if (attempt === 2) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+    await new Promise(r => setTimeout(r, 600 * (attempt + 1)));
+  }
+}
+
+const INSPECT_Q = `query($id:ID!){product(id:$id){id title status options{id name values} variants(first:20){edges{node{id title sku}}}}}`;
+const ADD_VARIANT = `mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id title sku price inventoryItem{measurement{weight{value unit}}}} userErrors{field message code}}}`;
+const CREATE_OPTION = `mutation($pid:ID!,$values:[OptionValueCreateInput!]!){productOptionsCreate(productId:$pid, options:[{name:"Size", position:1, values:$values}]){product{id options{id name values}} userErrors{field message}}}`;
+
+async function processOne(row) {
+  const productId = `gid://shopify/Product/${row.pid}`;
+  const { data } = await gql(INSPECT_Q, { id: productId });
+  if (!data || !data.product) return { row, status: 'missing-product' };
+  const product = data.product;
+  const variants = product.variants.edges.map(e => e.node);
+  const hasNonSample = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+  if (hasNonSample) return { row, status: 'skip-has-variant', productId };
+  const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+  const baseSku = sampleVariant ? sampleVariant.sku.replace(/-Sample$/, '') : row.dw_base;
+  const newSku = `${baseSku}-${PANEL_LABEL}`;
+  const sizeOption = product.options.find(o => o.name === OPTION_NAME);
+  if (!LIVE) return { row, status: 'DRY', productId, baseSku, newSku, price: row.map.toFixed(2), weight: PANEL_WEIGHT_LB, hasSize: !!sizeOption };
+  if (!sizeOption) {
+    const optR = await gql(CREATE_OPTION, { pid: productId, values: [{ name: 'Sample' }, { name: PANEL_LABEL }] });
+    const e1 = optR.data.productOptionsCreate.userErrors;
+    if (e1 && e1.length) {
+      const r2 = await gql(CREATE_OPTION, { pid: productId, values: [{ name: PANEL_LABEL }] });
+      const e2 = r2.data.productOptionsCreate.userErrors;
+      if (e2 && e2.length) return { row, status: 'option-create-failed', errors: e2 };
+    }
+  }
+  const variantInput = {
+    optionValues: [{ optionName: OPTION_NAME, name: PANEL_LABEL }],
+    price: row.map.toFixed(2),
+    inventoryItem: { sku: newSku, measurement: { weight: { value: PANEL_WEIGHT_LB, unit: 'POUNDS' } }, tracked: false },
+  };
+  const { data: d2 } = await gql(ADD_VARIANT, { pid: productId, vs: [variantInput] });
+  const errs = d2.productVariantsBulkCreate.userErrors;
+  if (errs && errs.length) return { row, status: 'error', errors: errs, baseSku, newSku };
+  const created = d2.productVariantsBulkCreate.productVariants[0];
+  return { row, status: 'ok', productId, sku: created.sku, price: created.price, weight: created.inventoryItem?.measurement?.weight };
+}
+
+(async () => {
+  console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'}  rows=${CLEARED.length}`);
+  const results = [];
+  for (let i = 0; i < CLEARED.length; i++) {
+    try {
+      const r = await processOne(CLEARED[i]);
+      results.push(r);
+      console.log(`  [${i+1}/${CLEARED.length}] ${r.row.mfr}  ${r.row.pattern} ${r.row.color}  →  ${r.status}  ${r.sku || ''}  ${r.price ? '$'+r.price : ''}`);
+    } catch (e) {
+      results.push({ row: CLEARED[i], status: 'exception', error: String(e).slice(0,400) });
+      console.log(`  [${i+1}/${CLEARED.length}] ${CLEARED[i].mfr}: EXCEPTION ${String(e).slice(0,160)}`);
+    }
+    if (LIVE) await new Promise(r => setTimeout(r, 200));
+  }
+  const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+  fs.writeFileSync(`/tmp/cole-son-pricing/panel-cleared-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+  const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+  console.log('\nStatus:', JSON.stringify(byStatus));
+})();
diff --git a/shopify/scripts/coleson-retry-failures.js b/shopify/scripts/coleson-retry-failures.js
new file mode 100644
index 00000000..0aec30aa
--- /dev/null
+++ b/shopify/scripts/coleson-retry-failures.js
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+// Retry the 6 Cole & Son failures from run-live-2026-05-26T14-59-27-547Z.json.
+// Two failure modes:
+//   A) "Option does not exist" → product has no `Size` option yet. Create it first.
+//   B) Invalid $id → row had a bare integer or empty shopify_product_id.
+//      Re-resolve gid via PG mfr_sku lookup or Shopify SKU search.
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+
+async function gql(query, variables = {}) {
+  const res = await fetch(ENDPOINT, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await res.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0,400));
+  return j;
+}
+
+const INSPECT_Q = `
+  query Inspect($id: ID!) {
+    product(id: $id) {
+      id title status vendor
+      options { id name values position }
+      variants(first: 20) { edges { node { id title sku } } }
+    }
+  }`;
+
+// Find a product by SKU (sample SKU like DWKK-NNNN-Sample)
+const FIND_BY_SKU = `
+  query ProdBySku($q: String!) {
+    productVariants(first: 1, query: $q) {
+      edges { node { id sku product { id title } } }
+    }
+  }`;
+
+const CREATE_OPTION = `
+  mutation CreateOption($pid: ID!, $optionValues: [OptionValueCreateInput!]!) {
+    productOptionsCreate(productId: $pid, options: [{name: "Size", position: 1, values: $optionValues}]) {
+      product { id options { id name position values } }
+      userErrors { field message code }
+    }
+  }`;
+
+const ADD_VARIANT = `
+  mutation Add($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
+    productVariantsBulkCreate(productId: $productId, variants: $variants, strategy: REMOVE_STANDALONE_VARIANT) {
+      productVariants { id title sku price inventoryItem { measurement { weight { value unit } } } }
+      userErrors { field message code }
+    }
+  }`;
+
+function buildVariantInput(row, boltLabel, baseSku) {
+  return {
+    optionValues: [{ optionName: OPTION_NAME, name: boltLabel }],
+    price: Number(row.map_price).toFixed(2),
+    inventoryItem: {
+      sku: `${baseSku}-${boltLabel}`,
+      measurement: { weight: { value: Number(row.bolt_weight_lb), unit: 'POUNDS' } },
+      tracked: false,
+    },
+  };
+}
+
+async function resolveProductId(row) {
+  // If already a gid, return
+  if (row.shopify_product_id && row.shopify_product_id.startsWith('gid://shopify/Product/')) {
+    return row.shopify_product_id;
+  }
+  // Bare numeric → prefix
+  if (row.shopify_product_id && /^\d+$/.test(row.shopify_product_id)) {
+    return `gid://shopify/Product/${row.shopify_product_id}`;
+  }
+  // Empty → look up via dw_sku Sample variant SKU
+  if (!row.shopify_product_id && row.dw_sku) {
+    const q = `sku:"${row.dw_sku}"`;
+    const { data } = await gql(FIND_BY_SKU, { q });
+    const v = data?.productVariants?.edges?.[0]?.node;
+    if (v) return v.product.id;
+  }
+  return null;
+}
+
+async function processFailure(failureRow) {
+  const row = failureRow.row;
+  const productId = await resolveProductId(row);
+  if (!productId) return { row, status: 'unresolvable' };
+
+  const { data } = await gql(INSPECT_Q, { id: productId });
+  const product = data.product;
+  if (!product) return { row, status: 'product-missing' };
+
+  const variants = product.variants.edges.map(e => e.node);
+  const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+  const baseSku = sampleVariant ? (sampleVariant.sku || '').replace(/-Sample$/, '') : (row.dw_sku || '').replace(/-Sample$/, '');
+  if (!baseSku) return { row, status: 'no-base-sku' };
+
+  const hasBolt = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+  if (hasBolt) return { row, status: 'already-has-bolt', productId };
+
+  const boltLabel = `Sold Per Bolt (${row.bolt_width}in x 33ft)`;
+
+  // If `Size` option doesn't exist, create it first with the value we'll use
+  const hasSizeOption = product.options.some(o => o.name === OPTION_NAME);
+  if (!hasSizeOption) {
+    console.log(`  ${row.mfr_sku}: creating Size option ['Sample','${boltLabel}']`);
+    const { data: optD } = await gql(CREATE_OPTION, {
+      pid: productId,
+      optionValues: [{ name: 'Sample' }, { name: boltLabel }],
+    });
+    const optErrs = optD.productOptionsCreate.userErrors;
+    if (optErrs && optErrs.length) {
+      // Maybe only sample exists but unnamed — try with just the bolt label
+      console.log(`  option create returned errors, retrying with just bolt value:`, JSON.stringify(optErrs).slice(0,200));
+      const { data: optD2 } = await gql(CREATE_OPTION, {
+        pid: productId,
+        optionValues: [{ name: boltLabel }],
+      });
+      const optErrs2 = optD2.productOptionsCreate.userErrors;
+      if (optErrs2 && optErrs2.length) return { row, status: 'option-create-failed', errors: optErrs2 };
+    }
+  }
+
+  const variantInput = buildVariantInput(row, boltLabel, baseSku);
+  const { data: d2 } = await gql(ADD_VARIANT, { productId, variants: [variantInput] });
+  const errs = d2.productVariantsBulkCreate.userErrors;
+  if (errs && errs.length) return { row, status: 'add-failed', errors: errs, productId, baseSku, boltLabel };
+  const created = d2.productVariantsBulkCreate.productVariants[0];
+  return {
+    row, status: 'ok', productId, baseSku,
+    newVariantId: created.id, sku: created.sku, price: created.price,
+    weight: created.inventoryItem?.measurement?.weight,
+  };
+}
+
+async function run() {
+  const all = JSON.parse(fs.readFileSync('/tmp/cole-son-pricing/run-live-2026-05-26T14-59-27-547Z.json', 'utf8'));
+  const fails = all.filter(r => r.status === 'error' || r.status === 'exception');
+  console.log(`Retrying ${fails.length} failures`);
+
+  const results = [];
+  for (let i = 0; i < fails.length; i++) {
+    try {
+      const r = await processFailure(fails[i]);
+      results.push(r);
+      console.log(`  [${i+1}/${fails.length}] ${r.row.mfr_sku}: ${r.status}  ${r.sku || ''}  ${r.price ? '$'+r.price : ''}`);
+    } catch (e) {
+      results.push({ row: fails[i].row, status: 'exception', error: String(e).slice(0,300) });
+      console.log(`  [${i+1}/${fails.length}] ${fails[i].row.mfr_sku}: EXCEPTION ${String(e).slice(0,160)}`);
+    }
+    await new Promise(r => setTimeout(r, 200));
+  }
+  fs.writeFileSync('/tmp/cole-son-pricing/retry-result.json', JSON.stringify(results, null, 2));
+  const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+  console.log('\nRetry status:', JSON.stringify(byStatus));
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/shopify/scripts/coleson-seasonal-woods-panels.js b/shopify/scripts/coleson-seasonal-woods-panels.js
new file mode 100644
index 00000000..a7833d9c
--- /dev/null
+++ b/shopify/scripts/coleson-seasonal-woods-panels.js
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+// Scaffold Sold Per Panel variants for 9 Seasonal Woods colorways.
+//
+// SETTLEMENT AUTHORIZATION (recorded in chat 2026-05-26):
+//   Steve approved via AskUserQuestion option 1: "Approved — ship the 9 panel
+//   variants. Mural-animal + trunks + branches = within carve-out."
+//   Image-side gate detector outputs A1=T A2=T A3=T B=T Acceptable=T →
+//   binding decision table cell = NEEDS REVIEW → Steve confirmed in writing.
+//
+// Spec: optionValue="Sold Per Panel", weight=8 lb, tracked=false, price=$843
+// (Kravet 2025 NEW MAP per EACH).
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+const PANEL_LABEL = 'Sold Per Panel';
+const PANEL_WEIGHT_LB = 8;
+const LIVE = process.argv.includes('--live');
+
+const SEASONAL_WOODS = [
+  { mfr: "120/6018.CS.0",  dw_base: "DWKK-134111", pid: "7421501669427", map: 843, color: "CLEAR SKIES"       },
+  { mfr: "120/6020.CS.0",  dw_base: "DWKK-134113", pid: "7421501800499", map: 843, color: "JADE / SAGE"       },
+  { mfr: "120/6020M.CS.0", dw_base: "DWKK-134114", pid: "7421501833267", map: 843, color: "JADE / SAGE PEARL" },
+  { mfr: "120/6021.CS.0",  dw_base: "DWKK-134116", pid: "7421502029875", map: 843, color: "OLIVE"             },
+  { mfr: "120/6022M.CS.0", dw_base: "DWKK-134118", pid: "7421502193715", map: 843, color: "ROSE"              },
+  { mfr: "120/6023.CS.0",  dw_base: "DWKK-134120", pid: "7421502324787", map: 843, color: "PLATINUM"          },
+  { mfr: "120/6023M.CS.0", dw_base: "DWKK-134121", pid: "7421502423091", map: 843, color: "PLATINUM"          },
+  { mfr: "120/6024M.CS.0", dw_base: "DWKK-134123", pid: "7421502521395", map: 843, color: "GOLD PEARL"        },
+  { mfr: "120/6025.CS.0",  dw_base: "DWKK-134124", pid: "7421502586931", map: 843, color: "MIDNIGHT"          },
+];
+
+async function gql(q, v = {}) {
+  for (let i = 0; i < 3; i++) {
+    const r = await fetch(ENDPOINT, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+    const j = await r.json();
+    if (!j.errors) return j;
+    if (i === 2) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+    await new Promise(s => setTimeout(s, 600 * (i + 1)));
+  }
+}
+
+const INSPECT_Q = `query($id:ID!){product(id:$id){id title options{id name values} variants(first:20){edges{node{title sku}}}}}`;
+const ADD = `mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id sku price inventoryItem{measurement{weight{value unit}}}} userErrors{field message code}}}`;
+const CREATE_OPTION = `mutation($pid:ID!,$values:[OptionValueCreateInput!]!){productOptionsCreate(productId:$pid, options:[{name:"Size", position:1, values:$values}]){product{id} userErrors{field message}}}`;
+
+async function processOne(row) {
+  const productId = `gid://shopify/Product/${row.pid}`;
+  const { data } = await gql(INSPECT_Q, { id: productId });
+  if (!data?.product) return { row, status: 'missing-product' };
+  const p = data.product;
+  const variants = p.variants.edges.map(e => e.node);
+  const hasNonSample = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+  if (hasNonSample) return { row, status: 'skip-has-variant' };
+  const sample = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+  const baseSku = sample ? sample.sku.replace(/-Sample$/, '') : row.dw_base;
+  const newSku = `${baseSku}-${PANEL_LABEL}`;
+  const sizeOption = p.options.find(o => o.name === OPTION_NAME);
+  if (!LIVE) return { row, status: 'DRY', baseSku, newSku, price: row.map.toFixed(2), hasSize: !!sizeOption };
+  if (!sizeOption) {
+    const r = await gql(CREATE_OPTION, { pid: productId, values: [{ name: 'Sample' }, { name: PANEL_LABEL }] });
+    const e = r.data.productOptionsCreate.userErrors;
+    if (e?.length) {
+      const r2 = await gql(CREATE_OPTION, { pid: productId, values: [{ name: PANEL_LABEL }] });
+      const e2 = r2.data.productOptionsCreate.userErrors;
+      if (e2?.length) return { row, status: 'option-create-failed', errors: e2 };
+    }
+  }
+  const variantInput = {
+    optionValues: [{ optionName: OPTION_NAME, name: PANEL_LABEL }],
+    price: row.map.toFixed(2),
+    inventoryItem: { sku: newSku, measurement: { weight: { value: PANEL_WEIGHT_LB, unit: 'POUNDS' } }, tracked: false },
+  };
+  const { data: d2 } = await gql(ADD, { pid: productId, vs: [variantInput] });
+  const errs = d2.productVariantsBulkCreate.userErrors;
+  if (errs?.length) return { row, status: 'error', errors: errs };
+  const created = d2.productVariantsBulkCreate.productVariants[0];
+  return { row, status: 'ok', sku: created.sku, price: created.price, weight: created.inventoryItem?.measurement?.weight };
+}
+
+(async () => {
+  console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'}  rows=${SEASONAL_WOODS.length}`);
+  const results = [];
+  for (let i = 0; i < SEASONAL_WOODS.length; i++) {
+    try {
+      const r = await processOne(SEASONAL_WOODS[i]);
+      results.push(r);
+      console.log(`  [${i+1}/${SEASONAL_WOODS.length}] ${r.row.mfr}  ${r.row.color}  →  ${r.status}  ${r.sku || ''}  ${r.price ? '$'+r.price : ''}`);
+    } catch (e) {
+      results.push({ row: SEASONAL_WOODS[i], status: 'exception', error: String(e).slice(0,400) });
+      console.log(`  [${i+1}/${SEASONAL_WOODS.length}] ${SEASONAL_WOODS[i].mfr}: EXCEPTION ${String(e).slice(0,160)}`);
+    }
+    if (LIVE) await new Promise(s => setTimeout(s, 200));
+  }
+  const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+  fs.writeFileSync(`/tmp/cole-son-pricing/seasonal-woods-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+  console.log('\nStatus:', JSON.stringify(results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {})));
+})();

← a20c8005 security: strip hardcoded dw_admin DSN password -> env-first  ·  back to Designer Wallcoverings  ·  security: pickup DW-Agents strip of hardcoded DW2025secure D e9a4650a →