[object Object]

← back to Designerwallcoverings

upstream fix (TK-10039): 8 more onboard generators write specs to metafields + prose-only body (knoll/stout/maharam/rigo/stroheim/muralsource/osborne/sanderson) via shared mapper; 0 tables, originals preserved

2ac6ccdbabc39f8702a8373d0e250aef1ea29b65 · 2026-07-29 07:31:01 -0700 · Steve Abrams

Files touched

Diff

commit 2ac6ccdbabc39f8702a8373d0e250aef1ea29b65
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 29 07:31:01 2026 -0700

    upstream fix (TK-10039): 8 more onboard generators write specs to metafields + prose-only body (knoll/stout/maharam/rigo/stroheim/muralsource/osborne/sanderson) via shared mapper; 0 tables, originals preserved
---
 scripts/knoll-onboard/build-payloads.mjs       | 17 +++++++++-----
 scripts/maharam-onboard/build-payloads.mjs     | 19 ++++++++++------
 scripts/muralsource-onboard/build-payloads.mjs | 24 ++++++++++++--------
 scripts/osborne-onboard/build-payloads.mjs     | 26 ++++++++++++---------
 scripts/rigo-onboard/build-payloads.mjs        | 20 +++++++++++------
 scripts/sanderson-onboard/build-payloads.mjs   | 31 +++++++++++++++-----------
 scripts/stout-onboard/build-payloads.mjs       | 24 ++++++++++++--------
 scripts/stroheim-onboard/build-payloads.mjs    | 22 +++++++++++-------
 8 files changed, 113 insertions(+), 70 deletions(-)

diff --git a/scripts/knoll-onboard/build-payloads.mjs b/scripts/knoll-onboard/build-payloads.mjs
index e29bd67..b0f341f 100644
--- a/scripts/knoll-onboard/build-payloads.mjs
+++ b/scripts/knoll-onboard/build-payloads.mjs
@@ -13,6 +13,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -53,20 +54,24 @@ function repeatStr(r) {
   if (!v && !h) return '';
   return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
 }
-function bodyHtml(r) {
-  const rowsHtml = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
     ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
     ['Finish', r.finish], ['Fire Rating', r.fire_rating],
     ['Manufacturer SKU', r.mfr_sku], ['Collection', /knoll\s*textiles/i.test(r.collection||'') ? '' : r.collection],
   ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+function bodyHtml(r) {
   const pat = titleCase(clean(r.pattern_name || r.mfr_sku));
-  return `<p>${pat} from Knoll Textiles — a high-performance commercial wallcovering. Priced per single roll; request a memo sample to see the material in hand.</p>`
-    + `<table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${String(v)}</td></tr>`).join('')}</table>`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<p>${pat} from Knoll Textiles — a high-performance commercial wallcovering. Priced per single roll; request a memo sample to see the material in hand.</p>`;
 }
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '') mf.push({ namespace: ns, key, type, value: String(value) }); };
+  const mf = [...specMetafields(specRows(r))];               // canonical spec metafields (Material/Width/Length/Repeat/Finish/Fire Rating/etc.)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
   S('custom', 'manufacturer_sku', r.mfr_sku);
   S('dwc', 'manufacturer_sku', r.mfr_sku);
   S('global', 'Brand', 'Knoll');
diff --git a/scripts/maharam-onboard/build-payloads.mjs b/scripts/maharam-onboard/build-payloads.mjs
index 239932a..ebd8abf 100644
--- a/scripts/maharam-onboard/build-payloads.mjs
+++ b/scripts/maharam-onboard/build-payloads.mjs
@@ -22,6 +22,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -69,19 +70,23 @@ function repeatStr(r) {
   if (!v && !h) return '';
   return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
 }
-function bodyHtml(r) {
-  const rowsHtml = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
     ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
     ['Finish', r.finish], ['Fire Rating', r.fire_rating], ['Application', r.application],
     ['Manufacturer SKU', r.mfr_sku], ['Collection', RE_BRAND.test(r.collection || '') ? '' : r.collection],
-  ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
-  return `<p>${patLabel(r)} from ${BRAND} — a high-performance commercial wallcovering. Request a $${SAMPLE_PRICE} memo sample to see the material in hand; <strong>call or email for a trade quote</strong> on full rolls.</p>`
-    + `<table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+function bodyHtml(r) {
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<p>${patLabel(r)} from ${BRAND} — a high-performance commercial wallcovering. Request a $${SAMPLE_PRICE} memo sample to see the material in hand; <strong>call or email for a trade quote</strong> on full rolls.</p>`;
 }
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+  const mf = [...specMetafields(specRows(r))];               // canonical spec metafields (theme-rendered)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value)) && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
   S('custom', 'manufacturer_sku', r.mfr_sku);
   S('dwc', 'manufacturer_sku', r.mfr_sku);
   S('global', 'Brand', BRAND);
diff --git a/scripts/muralsource-onboard/build-payloads.mjs b/scripts/muralsource-onboard/build-payloads.mjs
index 752ac43..30e15b5 100644
--- a/scripts/muralsource-onboard/build-payloads.mjs
+++ b/scripts/muralsource-onboard/build-payloads.mjs
@@ -23,6 +23,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -77,22 +78,27 @@ function repeatStr(r) {
   if (!v && !h) return '';
   return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
 }
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
+    ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
+    ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
+    ['Finish', r.finish], ['Fire Rating', r.fire_rating], ['Application', r.application],
+    ['Manufacturer SKU', r.mfr_sku], ['Collection', new RegExp(BRAND, 'i').test(r.collection || '') ? '' : r.collection],
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
 function bodyHtml(r) {
   let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
   const intro = (r.ai_description && String(r.ai_description).trim().length >= 24)
     ? clean(r.ai_description)
     : `${pat} from ${BRAND} — a made-to-order scenic wallcovering mural. Request a $4.25 memo sample to see the material in hand before ordering the full mural set.`;
-  const rowsHtml = [
-    ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
-    ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
-    ['Finish', r.finish], ['Fire Rating', r.fire_rating], ['Application', r.application],
-    ['Manufacturer SKU', r.mfr_sku], ['Collection', new RegExp(BRAND, 'i').test(r.collection || '') ? '' : r.collection],
-  ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
-  return `<p>${intro}</p><table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<p>${intro}</p>`;
 }
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+  const mf = [...specMetafields(specRows(r))];               // canonical spec metafields
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
   S('global', 'Brand', BRAND);
   S('global', 'width', r.width);
   S('global', 'length', r.length);
diff --git a/scripts/osborne-onboard/build-payloads.mjs b/scripts/osborne-onboard/build-payloads.mjs
index 6e607cd..87cc0c0 100644
--- a/scripts/osborne-onboard/build-payloads.mjs
+++ b/scripts/osborne-onboard/build-payloads.mjs
@@ -19,6 +19,7 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -34,17 +35,19 @@ function load(file) {
   return fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
 }
 
-function bodyHtml(r, e) {
-  const story = e?.designStory ? `<p>${esc(e.designStory)}</p>` : `<p>${esc(r.title_core)} by Osborne &amp; Little.</p>`;
-  const specs = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r, e) {
+  return [
     ['Collection', r.collection],
     ['Material', MATERIAL],
     ['Brand', 'Osborne & Little'],
     ['Style', e?.style ? titleCase(e.style) : null],
-  ].filter(([, v]) => v);
-  const trs = specs.map(([k, v]) =>
-    `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
-  return `<div class="osborne-product">\n${story}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+function bodyHtml(r, e) {
+  const story = e?.designStory ? `<p>${esc(e.designStory)}</p>` : `<p>${esc(r.title_core)} by Osborne &amp; Little.</p>`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<div class="osborne-product">\n${story}\n</div>`;
 }
 
 function tags(r, e) {
@@ -60,9 +63,10 @@ function tags(r, e) {
 }
 
 function metafields(r, e) {
-  const mf = [];
-  const S = (ns, key, value) => { if (value != null && value !== '') mf.push({ namespace: ns, key, type: 'single_line_text_field', value: String(value) }); };
-  const N = (ns, key, value) => { if (value != null && value !== '' && !Number.isNaN(Number(value))) mf.push({ namespace: ns, key, type: 'number_decimal', value: String(Number(value)) }); };
+  const mf = [...specMetafields(specRows(r, e))];            // canonical spec metafields (theme renders them)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
+  const N = (ns, key, value) => { if (value != null && value !== '' && !Number.isNaN(Number(value)) && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type: 'number_decimal', value: String(Number(value)) }); have.add(ns + '.' + key); } };
   S('custom', 'manufacturer_sku', r.mfr_sku);
   S('custom', 'pattern_name', r.title_core);
   S('custom', 'background_color', e?.backgroundColor);
@@ -74,7 +78,7 @@ function metafields(r, e) {
   if (e?.enriched_at) { S('custom', 'ai_enriched_at', e.enriched_at); S('custom', 'ai_enriched_by', e.enriched_by); }
   const cols = (e?.colors || []).slice(0, 3);
   cols.forEach((c, i) => { S('custom', `color_${i+1}_name`, c.name); S('custom', `color_${i+1}_hex`, c.hex); N('custom', `color_${i+1}_percentage`, c.percentage); });
-  if (e?.colors?.length) mf.push({ namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(e.colors) });
+  if (e?.colors?.length && !have.has('custom.color_details')) { mf.push({ namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(e.colors) }); have.add('custom.color_details'); }
   return mf;
 }
 
diff --git a/scripts/rigo-onboard/build-payloads.mjs b/scripts/rigo-onboard/build-payloads.mjs
index 8400b42..bb52a6d 100644
--- a/scripts/rigo-onboard/build-payloads.mjs
+++ b/scripts/rigo-onboard/build-payloads.mjs
@@ -16,6 +16,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execFileSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -64,22 +65,27 @@ function tags(r) {
   return [...t].filter(Boolean).join(', ');
 }
 
-function bodyHtml(r) {
-  const spec = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', r.pr_pattern_name], ['Color', titleCase(r.color_name)], ['Collection', r.pr_collection_name],
     ['Material', r.content || '100% Vinyl'], ['Weight', r.weight_type], ['Backing', r.backing],
     ['Width', r.width], ['Match', r.match_type], ['Fire Rating', r.fire_rating],
-  ].filter(([, v]) => v && String(v).trim());
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+
+function bodyHtml(r) {
   const desc = r.ai_description ||
     `${r.pr_pattern_name} is a durable commercial-grade vinyl wallcovering from the ${r.pr_collection_name} collection.`;
-  return `<p>${desc} Priced by quote; order a memo sample to see the material in hand.</p>` +
-    `<table>${spec.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${String(v)}</td></tr>`).join('')}</table>`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<p>${desc} Priced by quote; order a memo sample to see the material in hand.</p>`;
 }
 
 function metafields(r) {
-  const mf = [];
+  const mf = [...specMetafields(specRows(r))];               // canonical spec metafields (Pattern/Color/Material/Width/Backing/Fire Rating/etc.)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
   const S = (ns, key, value, type = 'single_line_text_field') => {
-    if (value != null && String(value).trim() !== '') mf.push({ namespace: ns, key, type, value: String(value) });
+    if (value != null && String(value).trim() !== '' && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); }
   };
   S('custom', 'manufacturer_sku', r.mfr_sku);
   S('private_label', 'real_vendor_name', 'Rigo Wallcovering');
diff --git a/scripts/sanderson-onboard/build-payloads.mjs b/scripts/sanderson-onboard/build-payloads.mjs
index eca4e81..e3330e2 100644
--- a/scripts/sanderson-onboard/build-payloads.mjs
+++ b/scripts/sanderson-onboard/build-payloads.mjs
@@ -22,6 +22,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -77,12 +78,9 @@ function tags(r) {
   return [...t].filter(Boolean).join(', ');   // always ≥4 (base set) → satisfies 5-field ≥2 tags
 }
 
-function bodyHtml(r) {
-  const pat = titleCase(r.pattern_name) || titleCase(r.mfr_sku);
-  const story = !bad(r.ai_description)
-    ? `<p>${esc(clean(r.ai_description))}</p>`
-    : `<p>${esc(pat)}${!bad(r.color_name) ? ' in ' + esc(titleCase(r.color_name)) : ''} — a ${BRAND} wallcovering.</p>`;
-  const specRows = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', titleCase(r.pattern_name)], ['Color', bad(r.color_name) ? '' : titleCase(r.color_name)],
     ['Collection', bad(r.collection) ? '' : titleCase(r.collection)],
     ['Material', bad(r.material) ? '' : titleCase(r.material)],
@@ -90,15 +88,22 @@ function bodyHtml(r) {
     ['Pattern Repeat', bad(r.pattern_repeat) ? '' : r.pattern_repeat],
     ['Brand', BRAND], ['Manufacturer SKU', r.mfr_sku],
   ].filter(([, v]) => v != null && String(v).trim() !== '');
-  return `<div class="sanderson-product">\n${story}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n`
-    + specRows.map(([k, v]) => `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n')
-    + `\n</table>\n</div>`;
+}
+
+function bodyHtml(r) {
+  const pat = titleCase(r.pattern_name) || titleCase(r.mfr_sku);
+  const story = !bad(r.ai_description)
+    ? `<p>${esc(clean(r.ai_description))}</p>`
+    : `<p>${esc(pat)}${!bad(r.color_name) ? ' in ' + esc(titleCase(r.color_name)) : ''} — a ${BRAND} wallcovering.</p>`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<div class="sanderson-product">\n${story}\n</div>`;
 }
 
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (!bad(value)) mf.push({ namespace: ns, key, type, value: String(value).trim() }); };
-  const N = (ns, key, value) => { if (value != null && value !== '' && !Number.isNaN(Number(value))) mf.push({ namespace: ns, key, type: 'number_decimal', value: String(Number(value)) }); };
+  const mf = [...specMetafields(specRows(r))];   // canonical spec metafields (Pattern/Color/Material/Width/etc.)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (!bad(value) && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value).trim() }); have.add(ns + '.' + key); } };
+  const N = (ns, key, value) => { if (value != null && value !== '' && !Number.isNaN(Number(value)) && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type: 'number_decimal', value: String(Number(value)) }); have.add(ns + '.' + key); } };
   // DUAL manufacturer_sku (task spec: custom + dwc)
   S('custom', 'manufacturer_sku', r.mfr_sku);
   S('dwc', 'manufacturer_sku', r.mfr_sku);
@@ -112,7 +117,7 @@ function metafields(r) {
   S('mc-facebook', 'google_product_category', GOOGLE_CAT);
   const cols = arr(r.ai_colors).slice(0, 3);
   cols.forEach((c, i) => { const n = c && (c.name || c.color); S('custom', `color_${i+1}_name`, n); S('custom', `color_${i+1}_hex`, c && c.hex); N('custom', `color_${i+1}_percentage`, c && (c.percentage ?? c.pct)); });
-  if (arr(r.ai_colors).length) mf.push({ namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(arr(r.ai_colors)) });
+  if (arr(r.ai_colors).length && !have.has('custom.color_details')) mf.push({ namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(arr(r.ai_colors)) });
   const style0 = arr(r.ai_styles)[0] || arr(r.styles)[0];
   S('specs', 'style', typeof style0 === 'string' ? style0 : (style0 && style0.name));
   const pat0 = arr(r.ai_patterns)[0];
diff --git a/scripts/stout-onboard/build-payloads.mjs b/scripts/stout-onboard/build-payloads.mjs
index cd8a05d..4e05c44 100644
--- a/scripts/stout-onboard/build-payloads.mjs
+++ b/scripts/stout-onboard/build-payloads.mjs
@@ -26,6 +26,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -80,22 +81,27 @@ function repeatStr(r) {
   if (!v && !h) return '';
   return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
 }
-function bodyHtml(r) {
-  let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
-  const intro = (r.ai_description && String(r.ai_description).trim().length >= 24)
-    ? clean(r.ai_description)
-    : `${pat} from ${BRAND} — a designer wallcovering, priced and sold by the double roll. Request a $4.25 memo sample to see the material in hand before ordering full double rolls.`;
-  const rowsHtml = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
     ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
     ['Finish', r.finish], ['Fire Rating', r.fire_rating], ['Application', r.application],
     ['Manufacturer SKU', r.mfr_sku], ['Collection', new RegExp(BRAND, 'i').test(r.collection || '') ? '' : r.collection],
   ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
-  return `<p>${intro}</p><table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+}
+function bodyHtml(r) {
+  let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
+  const intro = (r.ai_description && String(r.ai_description).trim().length >= 24)
+    ? clean(r.ai_description)
+    : `${pat} from ${BRAND} — a designer wallcovering, priced and sold by the double roll. Request a $4.25 memo sample to see the material in hand before ordering full double rolls.`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<p>${intro}</p>`;
 }
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+  const mf = [...specMetafields(specRows(r))];               // canonical spec metafields (Material/Width/Length/Repeat/Finish/etc.)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value)) && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
   S('global', 'Brand', BRAND);
   S('global', 'width', r.width);
   S('global', 'length', r.length);
diff --git a/scripts/stroheim-onboard/build-payloads.mjs b/scripts/stroheim-onboard/build-payloads.mjs
index 6734004..4bcb548 100644
--- a/scripts/stroheim-onboard/build-payloads.mjs
+++ b/scripts/stroheim-onboard/build-payloads.mjs
@@ -22,6 +22,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execFileSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -70,19 +71,24 @@ function repeatStr(r) {
   if (!v && !h) return '';
   return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
 }
-function bodyHtml(r) {
-  let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
-  const intro = `${pat} from ${BRAND} — a designer wallcovering. Priced per single roll; request a $4.25 memo sample to see the material in hand before ordering.`;
-  const rowsHtml = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
     ['Width', r.width], ['Repeat', repeatStr(r)], ['Book', r.book],
     ['Manufacturer SKU', r.mfr_sku],
-  ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
-  return `<p>${intro}</p><table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+function bodyHtml(r) {
+  let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  const intro = `${pat} from ${BRAND} — a designer wallcovering. Priced per single roll; request a $4.25 memo sample to see the material in hand before ordering.`;
+  return `<p>${intro}</p>`;
 }
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+  const mf = [...specMetafields(specRows(r))];                      // canonical spec metafields (Pattern/Color/Material/Width/Repeat/Book/Mfr-SKU)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
   S('global', 'Brand', BRAND);
   S('global', 'width', r.width);                                    // REQUIRED per task
   S('global', 'repeat', repeatStr(r));

← 11f2c9d upstream fix: greenland-onboard writes specs to metafields (  ·  back to Designerwallcoverings  ·  auto-save: 2026-07-29T07:37:00 (8 files) — data/orphan-clean 695d29e →