@runwell/shopify-toolkit 0.18.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/bin/runwell-shopify +10 -1
  2. package/lib/list.js +22 -9
  3. package/lib/qa-bundles.js +117 -0
  4. package/lib/qa.js +147 -13
  5. package/modules/INDEX.md +14 -5
  6. package/modules/bundle-builder/README.md +6 -1
  7. package/modules/bundle-builder/module.json +5 -1
  8. package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +16 -0
  9. package/modules/runwell-bundle-system/README.md +35 -0
  10. package/modules/runwell-bundle-system/admin-metafields.json +46 -0
  11. package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +861 -0
  12. package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +287 -0
  13. package/modules/runwell-bundle-system/module.json +126 -0
  14. package/modules/runwell-bundle-system/qa/mobile-checklist.md +105 -0
  15. package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +59 -0
  16. package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +121 -0
  17. package/modules/runwell-bundle-system/sections/runwell-bundle-home-stacks.liquid +77 -0
  18. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +50 -0
  19. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +72 -0
  20. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +105 -0
  21. package/modules/runwell-bundle-system/settings.json +25 -0
  22. package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +70 -0
  23. package/modules/runwell-bundle-system/snippets/runwell-bundle-cross-supplier.liquid +18 -0
  24. package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +67 -0
  25. package/modules/runwell-bundle-system/snippets/runwell-bundle-fomo.liquid +32 -0
  26. package/modules/runwell-bundle-system/snippets/runwell-bundle-free-gift.liquid +34 -0
  27. package/modules/runwell-bundle-system/snippets/runwell-bundle-multi-product.liquid +86 -0
  28. package/modules/runwell-bundle-system/snippets/runwell-bundle-pricing.liquid +30 -0
  29. package/modules/runwell-bundle-system/snippets/runwell-bundle-quantity-tiers.liquid +73 -0
  30. package/package.json +1 -1
@@ -8,6 +8,7 @@ import { remove } from '../lib/remove.js';
8
8
  import { doctor } from '../lib/doctor.js';
9
9
  import { validate } from '../lib/validate.js';
10
10
  import { qa } from '../lib/qa.js';
11
+ import { qaBundles } from '../lib/qa-bundles.js';
11
12
  import { init } from '../lib/init.js';
12
13
  import { upgradeBaseline } from '../lib/upgrade-baseline.js';
13
14
  import { diffBaseline } from '../lib/diff-baseline.js';
@@ -55,6 +56,9 @@ Commands:
55
56
  qa Code-first QA pipeline (validate-config, doctor,
56
57
  template-integrity, orphan-assets, theme-check).
57
58
  Run before any visual QA or "done" declaration.
59
+ Add --bundles to run the bundle-system check
60
+ (admin metafields, cross-supplier disclosure,
61
+ customizer contract) instead of the full pipeline.
58
62
  init <client> Scaffold a new tenant theme directory pinned to
59
63
  the dawn-runwell baseline + toolkit. Use --baseline
60
64
  to override the default pin.
@@ -98,7 +102,12 @@ flags.toolkitRoot = TOOLKIT_ROOT;
98
102
  await validate(flags);
99
103
  break;
100
104
  case 'qa':
101
- await qa(flags);
105
+ if (flags.bundles) {
106
+ flags.toolkitRoot = flags.toolkitRoot || TOOLKIT_ROOT;
107
+ await qaBundles(flags);
108
+ } else {
109
+ await qa(flags);
110
+ }
102
111
  break;
103
112
  case 'init':
104
113
  await init(flags);
package/lib/list.js CHANGED
@@ -59,33 +59,37 @@ function listTenants(flags) {
59
59
 
60
60
  console.log(`Runwell Shopify tenants (registry: ${registryPath})\n`);
61
61
  console.log(`Toolkit current: ${toolkitCurrent}\n`);
62
- console.log(' Tenant Store Baseline pin Config pin Synced ver Modules Drift Status');
63
- console.log(' --------- ------------------------------------------- --------------------------------- ------------ ----------- ------- ----- ----------');
62
+ console.log(' Tenant Store Config pin Synced ver Modules Brand CSS Drift Status');
63
+ console.log(' --------- ------------------------------------------- ------------ ----------- ------- --------- ----- ----------');
64
64
 
65
65
  let drifters = 0;
66
+ let exposed = 0;
66
67
 
67
68
  for (const [slug, t] of tenants.sort((a, b) => a[0].localeCompare(b[0]))) {
68
69
  const store = (t.store || '?').padEnd(43);
69
70
  const tenantConfig = readTenantConfig(t.theme_repo_local);
70
- const basePin = (tenantConfig?.baseline || t.baseline || '?').padEnd(33);
71
71
  const configPin = (tenantConfig?.toolkit_version || '?').padEnd(12);
72
72
  const syncedVer = (readManifestToolkit(t.theme_repo_local) || '?').padEnd(11);
73
73
  const moduleCount = (t.modules_enabled || []).length.toString().padEnd(7);
74
+ const brandCss = hasTenantBrandCss(slug, t.theme_repo_local) ? 'present ' : 'MISSING ';
75
+ if (brandCss.trim() === 'MISSING') exposed++;
74
76
  const drift = computeDrift(tenantConfig?.toolkit_version, toolkitCurrent);
75
77
  if (drift !== 'ok') drifters++;
76
78
  const driftStr = drift.padEnd(5);
77
79
  const status = (t.status || '?');
78
- console.log(` ${slug.padEnd(9)} ${store} ${basePin} ${configPin} ${syncedVer} ${moduleCount} ${driftStr} ${status}`);
80
+ console.log(` ${slug.padEnd(9)} ${store} ${configPin} ${syncedVer} ${moduleCount} ${brandCss} ${driftStr} ${status}`);
79
81
  }
80
82
 
81
- console.log(`\nTotal: ${tenants.length} tenants. Drift: ${drifters} of ${tenants.length} pinned below toolkit current (${toolkitCurrent}).`);
83
+ console.log(`\nTotal: ${tenants.length} tenants.`);
84
+ console.log(` Drift: ${drifters} of ${tenants.length} pinned below toolkit current (${toolkitCurrent}).`);
85
+ console.log(` Exposed: ${exposed} of ${tenants.length} missing tenant brand CSS (will inherit toolkit token changes raw).`);
82
86
 
83
87
  if (flags.verbose) {
84
88
  console.log('\nLegend:');
85
- console.log(' Baseline pin: from runwell.config.json baseline field (truth source).');
86
- console.log(' Config pin: from runwell.config.json toolkit_version (truth source). ^X.Y.Z accepts forward minor bumps.');
87
- console.log(' Synced ver: actual toolkit version recorded by the last `runwell-shopify sync` (from runwell-manifest.json).');
88
- console.log(' Drift: "ok" if config-pin minor >= toolkit-current minor; "stale" if behind. "stale" with synced=current means the files are current but the pin lies.');
89
+ console.log(' Config pin: from runwell.config.json toolkit_version (truth source). ^X.Y.Z accepts forward minor bumps.');
90
+ console.log(' Synced ver: actual toolkit version recorded by the last `runwell-shopify sync` (from runwell-manifest.json).');
91
+ console.log(' Brand CSS: "present" if assets/<tenant>-brand.css exists, "MISSING" if not. Per docs/CUSTOMIZATION-LEVELS.md, every tenant should ship one to insulate the storefront from upstream token tuning.');
92
+ console.log(' Drift: "ok" if config-pin minor >= toolkit-current minor; "stale" if behind. "stale" with synced=current means the files are current but the pin lies.');
89
93
  }
90
94
  }
91
95
 
@@ -135,6 +139,15 @@ function readTenantConfig(themeRepoLocal) {
135
139
  }
136
140
  }
137
141
 
142
+ function hasTenantBrandCss(slug, themeRepoLocal) {
143
+ if (!themeRepoLocal) return false;
144
+ const candidates = [
145
+ path.join(themeRepoLocal, 'assets', `${slug}-brand.css`),
146
+ path.join(themeRepoLocal, 'assets', 'tenant-brand.css'),
147
+ ];
148
+ return candidates.some(p => fs.existsSync(p));
149
+ }
150
+
138
151
  function computeDrift(pin, current) {
139
152
  if (!pin || !current) return '?';
140
153
  const pinMinor = extractMinor(pin);
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /* qa-bundles: cross-supplier and bundle metafield consistency check.
5
+
6
+ Invoked via `runwell-shopify qa --bundles`. Two layers:
7
+
8
+ A. Local checks (this file): manifest + contract integrity.
9
+ B. Store checks (deferred): per-bundle Shopify Admin GraphQL reads to
10
+ verify bundle_cross_supplier matches actual Carro supplier count.
11
+ Store checks land in BS-15 (Lushi integration) once the bundle
12
+ module is enabled on staging and a real bundle_index exists.
13
+
14
+ Local stages:
15
+ 1. admin-metafields-defined the 19 product metafields are present
16
+ in modules/runwell-bundle-system/admin-metafields.json
17
+ 2. disclosure-snippet-exists the cross-supplier disclosure renders
18
+ guard `bundle_cross_supplier && bundle_supplier_count > 1`
19
+ 3. settings-contract the cross_supplier_disclosure customizer
20
+ key is wired in settings.json with the
21
+ {n} placeholder
22
+
23
+ The Shopify Admin API stage (store-bundle-audit) is stubbed and reports
24
+ "deferred" until BS-15 wires the live check. */
25
+
26
+ export async function qaBundles(flags) {
27
+ const toolkitRoot = path.resolve(flags.toolkitRoot || process.cwd());
28
+ const moduleDir = path.join(toolkitRoot, 'modules', 'runwell-bundle-system');
29
+
30
+ const findings = { errors: [], warnings: [], info: [] };
31
+ const log = (level, stage, msg) => {
32
+ findings[level].push({ stage, msg });
33
+ const tag = level === 'errors' ? 'FAIL' : level === 'warnings' ? 'WARN' : 'OK ';
34
+ console.log(` [${tag}] ${stage}: ${msg}`);
35
+ };
36
+
37
+ console.log(`runwell-shopify qa --bundles @ ${toolkitRoot}`);
38
+ console.log('');
39
+
40
+ // 1. admin-metafields-defined
41
+ console.log('1/4 admin-metafields-defined');
42
+ const adminPath = path.join(moduleDir, 'admin-metafields.json');
43
+ if (!fs.existsSync(adminPath)) {
44
+ log('errors', 'admin-metafields-defined', 'admin-metafields.json missing');
45
+ } else {
46
+ try {
47
+ const admin = JSON.parse(fs.readFileSync(adminPath, 'utf8'));
48
+ const defs = admin.product_metafields?.definitions || [];
49
+ const required = [
50
+ 'bundle_mode', 'bundle_pricing_model', 'bundle_pricing_value',
51
+ 'bundle_components', 'bundle_quantity_tiers', 'bundle_cross_supplier',
52
+ 'bundle_supplier_count'
53
+ ];
54
+ const present = new Set(defs.map(d => d.key));
55
+ const missing = required.filter(k => !present.has(k));
56
+ if (missing.length > 0) {
57
+ log('errors', 'admin-metafields-defined', `missing required keys: ${missing.join(', ')}`);
58
+ } else {
59
+ log('info', 'admin-metafields-defined', `${defs.length} definitions present, all required keys covered`);
60
+ }
61
+ } catch (e) {
62
+ log('errors', 'admin-metafields-defined', `admin-metafields.json invalid JSON: ${e.message}`);
63
+ }
64
+ }
65
+ console.log('');
66
+
67
+ // 2. disclosure-snippet-exists
68
+ console.log('2/4 disclosure-snippet-exists');
69
+ const snippetPath = path.join(moduleDir, 'snippets', 'runwell-bundle-cross-supplier.liquid');
70
+ if (!fs.existsSync(snippetPath)) {
71
+ log('errors', 'disclosure-snippet-exists', 'runwell-bundle-cross-supplier.liquid missing');
72
+ } else {
73
+ const snippet = fs.readFileSync(snippetPath, 'utf8');
74
+ if (!snippet.includes('bundle_supplier_count')) {
75
+ log('warnings', 'disclosure-snippet-exists', 'snippet does not reference bundle_supplier_count');
76
+ } else {
77
+ log('info', 'disclosure-snippet-exists', 'snippet present and references supplier_count');
78
+ }
79
+ }
80
+ console.log('');
81
+
82
+ // 3. settings-contract
83
+ console.log('3/4 settings-contract');
84
+ const settingsPath = path.join(moduleDir, 'settings.json');
85
+ if (!fs.existsSync(settingsPath)) {
86
+ log('errors', 'settings-contract', 'settings.json missing');
87
+ } else {
88
+ try {
89
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
90
+ const ids = (settings.settings || []).map(s => s.id);
91
+ if (!ids.includes('bundle_system__cross_supplier_disclosure')) {
92
+ log('errors', 'settings-contract', 'bundle_system__cross_supplier_disclosure missing from settings.json');
93
+ } else {
94
+ const setting = settings.settings.find(s => s.id === 'bundle_system__cross_supplier_disclosure');
95
+ if (!(setting.default || '').includes('{n}')) {
96
+ log('warnings', 'settings-contract', 'cross_supplier_disclosure default lacks {n} placeholder');
97
+ } else {
98
+ log('info', 'settings-contract', 'cross_supplier_disclosure wired with {n} placeholder');
99
+ }
100
+ }
101
+ } catch (e) {
102
+ log('errors', 'settings-contract', `settings.json invalid JSON: ${e.message}`);
103
+ }
104
+ }
105
+ console.log('');
106
+
107
+ // 4. store-bundle-audit (deferred until BS-15)
108
+ console.log('4/4 store-bundle-audit');
109
+ log('info', 'store-bundle-audit',
110
+ 'deferred until BS-15. Will read each bundle product via Shopify Admin ' +
111
+ 'GraphQL, count distinct Carro suppliers via product.metafields, and ' +
112
+ 'flag mismatches with bundle_cross_supplier / bundle_supplier_count.');
113
+ console.log('');
114
+
115
+ console.log(`summary: ${findings.errors.length} errors, ${findings.warnings.length} warnings, ${findings.info.length} info`);
116
+ return findings;
117
+ }
package/lib/qa.js CHANGED
@@ -10,16 +10,19 @@ import { loadConfig, loadModuleManifest, resolveVariant } from './config-loader.
10
10
  if the theme cannot be inspected at all (no manifest, no config).
11
11
 
12
12
  Stages:
13
- 1. validate-config runwell.config.json shape + module references
14
- 2. doctor drift between runwell-manifest and synced files
15
- 3. template-integrity templates/*.json sections referenced in order
16
- 4. orphan-assets runwell-* files with no matching enabled module
17
- 5. theme-check shopify CLI theme-check (lint), if available
13
+ 1. validate-config runwell.config.json shape + module references
14
+ 2. doctor drift between runwell-manifest and synced files
15
+ 3. template-integrity templates/*.json sections referenced in order
16
+ 4. orphan-assets runwell-* files with no matching enabled module
17
+ 5. brand-fields {{brand.X}} references defined in tenant config
18
+ 6. shared-token-impact shared-token VALUE diffs between synced + toolkit-current
19
+ cross-checked against tenant brand CSS coverage
20
+ 7. theme-check shopify CLI theme-check (lint), if available
18
21
 
19
22
  Caller can skip stages with --skip <name,name>.
20
23
  */
21
24
 
22
- const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'brand-fields', 'theme-check'];
25
+ const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'brand-fields', 'shared-token-impact', 'theme-check'];
23
26
 
24
27
  /* Walk the toolkit modules tree and collect every {{brand.X}} reference.
25
28
  Returns the set of unique X identifiers (e.g. 'primary', 'rain-forrest',
@@ -43,6 +46,129 @@ function collectBrandFieldReferences(modulesDir) {
43
46
  return refs;
44
47
  }
45
48
 
49
+ /* Parse a CSS file for top-level :root --runwell-X declarations.
50
+ Returns Map<token-name, value-string>. Whitespace-collapsed values so
51
+ identical sizes match regardless of formatting.
52
+
53
+ Brace-balanced block extraction. Naive regex (`/:root\s*\{(.*?)\}/`)
54
+ breaks on `{{brand.X}}` Liquid placeholders inside the root block:
55
+ the `}}` terminates the lazy match prematurely. This walker counts
56
+ braces and stops at the actual matching close. */
57
+ function parseRunwellTokens(filePath) {
58
+ const out = new Map();
59
+ if (!fs.existsSync(filePath)) return out;
60
+ const content = fs.readFileSync(filePath, 'utf8');
61
+ const rootStart = content.search(/:root\s*\{/);
62
+ if (rootStart < 0) return out;
63
+ const openIdx = content.indexOf('{', rootStart);
64
+ let depth = 0;
65
+ let blockEnd = -1;
66
+ for (let i = openIdx; i < content.length; i++) {
67
+ const c = content[i];
68
+ if (c === '{') depth++;
69
+ else if (c === '}') {
70
+ depth--;
71
+ if (depth === 0) { blockEnd = i; break; }
72
+ }
73
+ }
74
+ if (blockEnd < 0) return out;
75
+ const block = content.slice(openIdx + 1, blockEnd);
76
+ // Strip Liquid placeholders so they don't break the value matcher
77
+ // (we treat the placeholder as the literal value; comparison logic
78
+ // upstream skips entries whose toolkit-current value starts with '{{').
79
+ const decl = /(--runwell-[a-z0-9-]+)\s*:\s*([^;]+);/gi;
80
+ let m;
81
+ while ((m = decl.exec(block)) !== null) {
82
+ out.set(m[1], m[2].replace(/\s+/g, ' ').trim());
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /* Read tenant brand CSS files (if any) and collect every --runwell-X token
88
+ the tenant overrides at any cascade scope. Used to determine whether a
89
+ shared-token value change will reach the storefront or be masked. */
90
+ function collectTenantTokenOverrides(targetDir, clientSlug) {
91
+ const overrides = new Set();
92
+ const candidates = [
93
+ path.join(targetDir, 'assets', `${clientSlug}-brand.css`),
94
+ path.join(targetDir, 'assets', 'tenant-brand.css'),
95
+ ];
96
+ for (const file of candidates) {
97
+ if (!fs.existsSync(file)) continue;
98
+ const content = fs.readFileSync(file, 'utf8');
99
+ const decl = /(--runwell-[a-z0-9-]+)\s*:/gi;
100
+ let m;
101
+ while ((m = decl.exec(content)) !== null) overrides.add(m[1]);
102
+ }
103
+ return overrides;
104
+ }
105
+
106
+ function runSharedTokenImpactProbe(targetDir, toolkitRoot, log) {
107
+ const sharedFiles = [
108
+ {
109
+ synced: path.join(targetDir, 'assets', 'runwell-tokens.css'),
110
+ current: path.join(toolkitRoot, 'modules', '_shared', 'css-tokens', 'assets', 'runwell-tokens.css'),
111
+ label: 'tokens',
112
+ },
113
+ {
114
+ synced: path.join(targetDir, 'assets', 'runwell-typography.css'),
115
+ current: path.join(toolkitRoot, 'modules', '_shared', 'css-typography', 'assets', 'runwell-typography.css'),
116
+ label: 'typography',
117
+ },
118
+ ];
119
+
120
+ let clientSlug = '';
121
+ try {
122
+ const cfg = JSON.parse(fs.readFileSync(path.join(targetDir, 'runwell.config.json'), 'utf8'));
123
+ clientSlug = cfg.client || '';
124
+ } catch { /* no config; probe still runs against synced vs current */ }
125
+
126
+ const tenantOverrides = clientSlug ? collectTenantTokenOverrides(targetDir, clientSlug) : new Set();
127
+ let totalDiffs = 0;
128
+ let exposedDiffs = 0;
129
+
130
+ for (const { synced, current, label } of sharedFiles) {
131
+ if (!fs.existsSync(synced)) {
132
+ log('warnings', 'shared-token-impact', `${label}: ${path.basename(synced)} missing in synced theme; cannot compare`);
133
+ continue;
134
+ }
135
+ if (!fs.existsSync(current)) {
136
+ log('warnings', 'shared-token-impact', `${label}: toolkit-current source not found at ${current}; cannot compare`);
137
+ continue;
138
+ }
139
+ const oldTokens = parseRunwellTokens(synced);
140
+ const newTokens = parseRunwellTokens(current);
141
+ const allKeys = new Set([...oldTokens.keys(), ...newTokens.keys()]);
142
+ for (const key of allKeys) {
143
+ const a = oldTokens.get(key);
144
+ const b = newTokens.get(key);
145
+ // Skip Liquid-interpolated tokens: brand colors get resolved from
146
+ // runwell.config.json during sync, so the toolkit-current placeholder
147
+ // is not directly comparable to the synced hex value. Only flag
148
+ // design-system tokens that ship with literal values.
149
+ if (b && b.includes('{{')) continue;
150
+ if (a && a.includes('{{')) continue;
151
+ if (a === b) continue;
152
+ totalDiffs++;
153
+ const insulated = tenantOverrides.has(key);
154
+ const verdict = insulated ? 'insulated' : 'EXPOSED';
155
+ const detail = a == null
156
+ ? `new token (will be added: ${b})`
157
+ : b == null
158
+ ? `token removed (was: ${a})`
159
+ : `${a} -> ${b}`;
160
+ const level = insulated ? 'info' : 'warnings';
161
+ if (!insulated) exposedDiffs++;
162
+ log(level, 'shared-token-impact', `${key}: ${detail} [${verdict}${insulated ? ' by tenant brand CSS' : '; tenant brand CSS does not override this token'}]`);
163
+ }
164
+ }
165
+ if (totalDiffs === 0) {
166
+ log('info', 'shared-token-impact', 'no shared-token value drift between synced theme and toolkit-current');
167
+ } else {
168
+ log('info', 'shared-token-impact', `${totalDiffs} shared tokens differ between synced and toolkit-current; ${exposedDiffs} would surface visually on next sync (no tenant override).`);
169
+ }
170
+ }
171
+
46
172
  export async function qa(flags) {
47
173
  const targetDir = path.resolve(flags.target || process.cwd());
48
174
  const skip = new Set((flags.skip || '').split(',').map(s => s.trim()).filter(Boolean));
@@ -59,7 +185,7 @@ export async function qa(flags) {
59
185
 
60
186
  // Stage 1: validate-config
61
187
  if (!skip.has('validate-config')) {
62
- console.log('1/6 validate-config');
188
+ console.log('1/7 validate-config');
63
189
  try {
64
190
  const { config } = loadConfig(flags.config || './runwell.config.json');
65
191
  if (!config.client) log('errors', 'validate-config', 'missing required field "client"');
@@ -82,7 +208,7 @@ export async function qa(flags) {
82
208
 
83
209
  // Stage 2: doctor (manifest drift)
84
210
  if (!skip.has('doctor')) {
85
- console.log('2/6 doctor (manifest drift)');
211
+ console.log('2/7 doctor (manifest drift)');
86
212
  const manifestPath = path.join(targetDir, 'runwell-manifest.json');
87
213
  if (!fs.existsSync(manifestPath)) {
88
214
  log('errors', 'doctor', 'no runwell-manifest.json (run "runwell-shopify sync" first)');
@@ -103,7 +229,7 @@ export async function qa(flags) {
103
229
 
104
230
  // Stage 3: template-integrity (every section in templates/*.json must exist)
105
231
  if (!skip.has('template-integrity')) {
106
- console.log('3/6 template-integrity');
232
+ console.log('3/7 template-integrity');
107
233
  const tmplDir = path.join(targetDir, 'templates');
108
234
  if (fs.existsSync(tmplDir)) {
109
235
  const jsons = fs.readdirSync(tmplDir).filter(f => f.endsWith('.json'));
@@ -146,7 +272,7 @@ export async function qa(flags) {
146
272
 
147
273
  // Stage 4: orphan-assets (runwell-* files in theme that aren't synced from any enabled module)
148
274
  if (!skip.has('orphan-assets')) {
149
- console.log('4/6 orphan-assets');
275
+ console.log('4/7 orphan-assets');
150
276
  const manifestPath = path.join(targetDir, 'runwell-manifest.json');
151
277
  if (fs.existsSync(manifestPath)) {
152
278
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
@@ -172,7 +298,7 @@ export async function qa(flags) {
172
298
 
173
299
  // Stage 5: brand-fields (every {{brand.X}} reference in toolkit modules must be defined in tenant config.brand)
174
300
  if (!skip.has('brand-fields')) {
175
- console.log('5/6 brand-fields');
301
+ console.log('5/7 brand-fields');
176
302
  try {
177
303
  const { config } = loadConfig(flags.config || './runwell.config.json');
178
304
  const brand = config.brand || {};
@@ -199,9 +325,17 @@ export async function qa(flags) {
199
325
  console.log('');
200
326
  }
201
327
 
202
- // Stage 6: theme-check via Shopify CLI (parse --output json for accuracy)
328
+ // Stage 6: shared-token-impact (compare synced shared tokens vs toolkit-current;
329
+ // surface any value diffs and whether the tenant brand CSS overrides them)
330
+ if (!skip.has('shared-token-impact')) {
331
+ console.log('6/7 shared-token-impact');
332
+ runSharedTokenImpactProbe(targetDir, flags.toolkitRoot, log);
333
+ console.log('');
334
+ }
335
+
336
+ // Stage 7: theme-check via Shopify CLI (parse --output json for accuracy)
203
337
  if (!skip.has('theme-check')) {
204
- console.log('6/6 theme-check');
338
+ console.log('7/7 theme-check');
205
339
  await new Promise(resolve => {
206
340
  const proc = spawn('shopify', ['theme', 'check', '--path', targetDir, '--output', 'json'], {
207
341
  stdio: ['ignore', 'pipe', 'pipe']
package/modules/INDEX.md CHANGED
@@ -3,12 +3,12 @@
3
3
  Auto-generated by `scripts/generate-index.mjs`. Do not edit by hand.
4
4
  Skills (shopify-storefront, shopify-cro, shopify-cli-ops, shopify-admin-browser) grep this file before writing custom Liquid.
5
5
 
6
- Total modules: 38.
6
+ Total modules: 39.
7
7
 
8
8
  ## By category
9
9
 
10
10
  - **branding**: care-coaching-medvi, product-trio-medvi
11
- - **catalog**: bundle-builder, quantity-breaks, subscriptions
11
+ - **catalog**: bundle-builder, quantity-breaks, runwell-bundle-system, subscriptions
12
12
  - **conversion**: cart-cross-sell, cart-freeship-progress, cart-usps, exit-intent, gift-with-purchase, post-purchase-upsell, quick-view, risk-reversal, shipping-bar, sticky-atc
13
13
  - **customer**: loyalty-tiers, wishlist
14
14
  - **pdp**: comparison-table, delivery-estimate, inventory-urgency, pdp-ingredients, pdp-journal-link, pdp-trust-checks, recently-viewed
@@ -19,7 +19,7 @@ Total modules: 38.
19
19
 
20
20
  | Name | Category | Replaces | Files | Config keys | Variants | Admin steps |
21
21
  |---|---|---|---|---|---|---|
22
- | `bundle-builder` | catalog | BYOB-style bundle apps | sections:1 assets:1 | heading, sale_prefix, show_rating, rating_score, rating_count, show_trust_badges, trust_1, trust_2, trust_3, fomo_mode, fomo_cycle_days, fomo_stock_count | (none) | (none) |
22
+ | `bundle-builder` | catalog | (native build) | sections:1 assets:1 | heading, sale_prefix, show_rating, rating_score, rating_count, show_trust_badges, trust_1, trust_2, trust_3, fomo_mode, fomo_cycle_days, fomo_stock_count | (none) | (none) |
23
23
  | `care-coaching-medvi` | branding | (native build) | sections:1 snippets:1 assets:1 | section_eyebrow, headline_pre, headline_post, headline_accent_color, block_heading, circular_text, circular_text_position, footer_link_label, layout, bg_band | (none) | (none) |
24
24
  | `cart-cross-sell` | conversion | Rebuy / OneClickUpsell pre-purchase upsell | snippets:1 | eyebrow, cta_label | (none) | (none) |
25
25
  | `cart-freeship-progress` | conversion | Bold Free Shipping Manager and similar app features | snippets:1 | threshold_cents, away_text, unlocked_message | (none) | (none) |
@@ -47,6 +47,7 @@ Total modules: 38.
47
47
  | `recently-viewed` | pdp | (native build) | sections:1 assets:2 | eyebrow, heading, background_color | (none) | (none) |
48
48
  | `reviews` | social-proof | (native build) | sections:1 assets:1 | heading | (none) | (none) |
49
49
  | `risk-reversal` | conversion | (native build) | sections:1 | icon, heading, body, link_label, link_url, background_color, text_color | (none) | (none) |
50
+ | `runwell-bundle-system` | catalog | the legacy bundle-builder module | sections:6 snippets:8 assets:2 | surface_1_collection_page_enabled, surface_2_pdp_pairs_with_enabled, surface_3_home_stacks_enabled, surface_4_cart_drawer_xsell_enabled, surface_5_pdp_banner_enabled, surface_6_collection_filter_enabled, surface_2_eyebrow, surface_2_heading, surface_3_eyebrow, surface_3_heading, surface_4_eyebrow, surface_4_cta, surface_5_copy_template, cross_supplier_disclosure, home_strip_position | (none) | install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index |
50
51
  | `scrolling-ticker` | social-proof | announcement-bar / scrolling-text apps | sections:1 assets:1 | (none) | (none) | (none) |
51
52
  | `shipping-bar` | conversion | (native build) | sections:1 | threshold_cents, message_below, message_qualified, message_default, background_color, text_color | (none) | (none) |
52
53
  | `social-proof-banner` | social-proof | fixed-text social-proof apps | sections:1 assets:1 | (none) | (none) | (none) |
@@ -63,8 +64,7 @@ Total modules: 38.
63
64
  ### bundle-builder
64
65
 
65
66
  - Category: catalog
66
- - Replaces: BYOB-style bundle apps
67
- - What: Build-your-own-bundle PDP section.
67
+ - What: DEPRECATED 2026-05-10 in favor of runwell-bundle-system (Mode A).
68
68
  - Files: sections:1 assets:1
69
69
  - Config: heading, sale_prefix, show_rating, rating_score, rating_count, show_trust_badges, trust_1, trust_2, trust_3, fomo_mode, fomo_cycle_days, fomo_stock_count
70
70
 
@@ -264,6 +264,15 @@ Total modules: 38.
264
264
  - Files: sections:1
265
265
  - Config: icon, heading, body, link_label, link_url, background_color, text_color
266
266
 
267
+ ### runwell-bundle-system
268
+
269
+ - Category: catalog
270
+ - Replaces: the legacy bundle-builder module
271
+ - What: Configurable bundles engine.
272
+ - Files: sections:6 snippets:8 assets:2
273
+ - Config: surface_1_collection_page_enabled, surface_2_pdp_pairs_with_enabled, surface_3_home_stacks_enabled, surface_4_cart_drawer_xsell_enabled, surface_5_pdp_banner_enabled, surface_6_collection_filter_enabled, surface_2_eyebrow, surface_2_heading, surface_3_eyebrow, surface_3_heading, surface_4_eyebrow, surface_4_cta, surface_5_copy_template, cross_supplier_disclosure, home_strip_position
274
+ - Admin steps: install-shopify-bundles + define-bundle-metafields + create-bundle-index-metaobject + create-first-bundle-product + configure-quantity-tier-discount-function + configure-free-gift-discount + create-bundles-collection-page-template + add-home-stacks-section + add-pdp-pairs-with-section + add-pdp-banner-section + verify-cart-drawer-coordination + rebuild-bundle-index
275
+
267
276
  ### scrolling-ticker
268
277
 
269
278
  - Category: social-proof
@@ -1,4 +1,9 @@
1
- # bundle-builder
1
+ # bundle-builder (deprecated)
2
+
3
+ > **Deprecated since toolkit 0.21.0.** Use `runwell-bundle-system` (Mode A: quantity tiers) instead.
4
+ > This module continues to function for tenants pinned to it; removal target version is 0.22.0.
5
+ >
6
+ > **Migration path:** set `modules.bundle-builder.enabled` to false in `runwell.config.json`, enable `runwell-bundle-system`, and set `bundle_mode: "quantity_tiers"` on the bundle product via the `runwell.bundle_mode` metafield. See `_clients/capital-v/lushi/specs/bundle-system/phase-plan.md` v1.1 for the full Lusha cutover plan.
2
7
 
3
8
  PDP section for build-your-own-bundle merchandising. Slideshow gallery + thumbnail nav + radio bundle picker (1x / 2x / 3x with savings, free-ship and free-gift badges) + sticky ATC + FOMO countdown + scarcity. Migrated from Lusha's bundle-selector.
4
9
 
@@ -2,7 +2,11 @@
2
2
  "name": "bundle-builder",
3
3
  "version": "0.1.0",
4
4
  "category": "catalog",
5
- "description": "Build-your-own-bundle PDP section. Slideshow gallery + thumbnail nav + radio bundle picker (1x / 2x / 3x with savings, free-ship and free-gift badges) + sticky ATC + FOMO countdown + scarcity. Migrated from Lusha (guabrasha-store/sections/bundle-selector.liquid). Replaces BYOB-style bundle apps.",
5
+ "deprecated": true,
6
+ "deprecated_in_favor_of": "runwell-bundle-system",
7
+ "deprecated_since": "0.21.0",
8
+ "removal_target_version": "0.22.0",
9
+ "description": "DEPRECATED 2026-05-10 in favor of runwell-bundle-system (Mode A). Build-your-own-bundle PDP section. Slideshow gallery + thumbnail nav + radio bundle picker (1x / 2x / 3x with savings, free-ship and free-gift badges) + sticky ATC + FOMO countdown + scarcity. Lusha migration to runwell-bundle-system Mode A happens in v1.1; module removed at toolkit 0.22.0.",
6
10
  "files": {
7
11
  "sections": ["sections/runwell-bundle-builder.liquid"],
8
12
  "assets": ["assets/runwell-bundle-builder.css"]
@@ -3,8 +3,15 @@
3
3
  already in cart. Replaces Rebuy/OneClickUpsell pre-purchase upsell
4
4
  display. Render inside cart-drawer.liquid via:
5
5
  {% render 'runwell-cart-xsell' %}
6
+
7
+ BS-6 coordination: runwell-bundle-system Surface 4 (cart drawer
8
+ bundle cross-sell) takes priority on partial-bundle matches. When
9
+ that surface dispatches the `runwell:bundle-xsell-active` event,
10
+ this fallback removes itself so customers do not see two competing
11
+ upsell cards.
6
12
  {%- endcomment -%}
7
13
 
14
+ <div data-runwell-cart-xsell-fallback>
8
15
  {%- if cart.item_count > 0 -%}
9
16
  {%- assign cart_handles = cart.items | map: 'handle' -%}
10
17
  {%- assign suggestion = blank -%}
@@ -38,3 +45,12 @@
38
45
  </div>
39
46
  {%- endif -%}
40
47
  {%- endif -%}
48
+ </div>
49
+ <script>
50
+ (function () {
51
+ document.addEventListener('runwell:bundle-xsell-active', function () {
52
+ var fallback = document.querySelector('[data-runwell-cart-xsell-fallback]');
53
+ if (fallback) fallback.remove();
54
+ });
55
+ })();
56
+ </script>
@@ -0,0 +1,35 @@
1
+ # runwell-bundle-system
2
+
3
+ Configurable bundles engine for Runwell Shopify tenants.
4
+
5
+ ## Modes
6
+ - Mode A: single-product quantity tiers (Lusha pattern).
7
+ - Mode B: curated multi-product bundles (Lushi pattern).
8
+ - Mode C: mix-and-match / build-your-own (v1.5).
9
+ - Mode D: subscription bundles (v2).
10
+
11
+ ## Display surfaces
12
+ 1. Dedicated /bundles collection page.
13
+ 2. PDP "Pairs well with" widget.
14
+ 3. Home page curated stacks.
15
+ 4. Cart drawer bundle cross-sell.
16
+ 5. PDP bundle banner.
17
+ 6. Collection page bundles filter.
18
+
19
+ ## Architecture
20
+ Sits on top of Shopify's free first-party Bundles app. Reads per-bundle config from product metafields (namespace: runwell). See spec.md for the data model.
21
+
22
+ ## Replaces
23
+ - bundle-builder (deprecated; Lusha migrates in v1.1).
24
+
25
+ ## Lineage
26
+ Net-new Runwell module. Spec: `_clients/capital-v/lushi/specs/bundle-system/spec.md`.
27
+
28
+ ## Files in this module
29
+
30
+ - `module.json`: module manifest, config schema, admin_steps array.
31
+ - `admin-metafields.json`: source of truth for the 19 product metafields and the `bundle_index` shop metaobject. Source for `runwell-shopify provision-metafields` and merchant manual setup.
32
+ - `settings.json`: theme customizer settings patch. Appended to tenant `config/settings_schema.json` by `runwell-shopify sync`.
33
+ - `sections/`: one Liquid section per display surface (placeholders until BS-2 onwards).
34
+ - `snippets/`: shared bundle UI partials (placeholders until BS-2 onwards).
35
+ - `assets/`: module CSS and JS (placeholders until BS-2 onwards).
@@ -0,0 +1,46 @@
1
+ {
2
+ "$comment": "Source of truth for runwell.bundle_* product metafield definitions. Mirrors spec.md section 2.1. Read by runwell-shopify provision-metafields (when implemented) and by admin-guide.md for manual setup.",
3
+ "spec_ref": "_clients/capital-v/lushi/specs/bundle-system/spec.md#21-per-bundle-product-metafields",
4
+ "product_metafields": {
5
+ "namespace": "runwell",
6
+ "owner_type": "PRODUCT",
7
+ "definitions": [
8
+ { "key": "bundle_mode", "name": "Bundle mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"quantity_tiers\",\"multi_product\",\"mix_match\",\"subscription\"]" }] },
9
+ { "key": "bundle_pricing_model", "name": "Bundle pricing model", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"tier_quantity\",\"fixed_price\",\"percent_off_subtotal\",\"dollar_off_subtotal\"]" }] },
10
+ { "key": "bundle_pricing_value", "name": "Bundle pricing value (JSON)", "type": "json", "description": "Shape varies by pricing_model. See spec.md section 2.2." },
11
+ { "key": "bundle_components", "name": "Bundle components (JSON)", "type": "json", "description": "Required for multi_product / mix_match. Array of {product_handle, qty}." },
12
+ { "key": "bundle_quantity_tiers", "name": "Bundle quantity tiers (JSON)", "type": "json", "description": "Required for quantity_tiers. Array of {qty, discount_pct}. Mirrors quantity_breaks schema." },
13
+ { "key": "bundle_show_in_catalog", "name": "Show in main catalog", "type": "boolean", "default": true },
14
+ { "key": "bundle_surfaces_enabled", "name": "Surfaces enabled (JSON)", "type": "json", "description": "Per-bundle surface allowlist. Array of 1..6. Absent = all enabled at tenant level." },
15
+ { "key": "bundle_copy", "name": "Per-surface copy (JSON)", "type": "json", "description": "Per-surface eyebrow/heading/cta overrides." },
16
+ { "key": "bundle_free_gift_enabled", "name": "Free gift enabled", "type": "boolean", "default": false },
17
+ { "key": "bundle_free_gift_handle", "name": "Free gift product handle", "type": "single_line_text_field" },
18
+ { "key": "bundle_fomo_mode", "name": "FOMO mode", "type": "single_line_text_field", "validations": [{ "name": "choices", "value": "[\"none\",\"discount\",\"scarcity\",\"both\"]" }] },
19
+ { "key": "bundle_fomo_cycle_days", "name": "FOMO cycle (days)", "type": "number_integer" },
20
+ { "key": "bundle_fomo_stock_count", "name": "FOMO stock count", "type": "number_integer" },
21
+ { "key": "bundle_cross_supplier", "name": "Cross-supplier", "type": "boolean", "default": false },
22
+ { "key": "bundle_supplier_count", "name": "Supplier count", "type": "number_integer" },
23
+ { "key": "bundle_savings_pct", "name": "Computed savings percent", "type": "number_decimal", "description": "Precomputed for fast banner / card render. Recomputed by runwell-shopify rebuild-bundle-index." },
24
+ { "key": "subscription_enabled", "name": "Subscription enabled (v2)", "type": "boolean", "default": false, "v2_only": true },
25
+ { "key": "subscription_interval", "name": "Subscription interval (JSON, v2)", "type": "json", "v2_only": true },
26
+ { "key": "subscription_discount_pct", "name": "Subscription discount percent (v2)", "type": "number_decimal", "v2_only": true },
27
+ { "key": "subscription_badge_copy", "name": "Subscription badge copy (v2)", "type": "single_line_text_field", "v2_only": true }
28
+ ]
29
+ },
30
+ "shop_metaobjects": [
31
+ {
32
+ "type": "bundle_index",
33
+ "name": "Bundle index",
34
+ "description": "Forward map (bundles -> components) and reverse map (products -> bundles) used by surfaces 2/4 to avoid N+1 reads. Single instance per shop. Rebuilt by runwell-shopify rebuild-bundle-index or by Shopify Flow on bundle product create/update.",
35
+ "field_definitions": [
36
+ { "key": "entries", "name": "Entries", "type": "json", "description": "{products: {handle: [bundle_handles]}, bundles: {handle: {components: {handle: qty}}}}" }
37
+ ]
38
+ }
39
+ ],
40
+ "cross_field_validation": [
41
+ { "rule": "bundle_quantity_tiers required when bundle_mode == 'quantity_tiers'", "enforced_by": "runwell-shopify qa --bundles" },
42
+ { "rule": "bundle_components required when bundle_mode in ['multi_product','mix_match']", "enforced_by": "runwell-shopify qa --bundles" },
43
+ { "rule": "bundle_pricing_value shape matches bundle_pricing_model (see spec.md section 2.2)", "enforced_by": "runwell-shopify qa --bundles" },
44
+ { "rule": "bundle_free_gift_handle required when bundle_free_gift_enabled == true", "enforced_by": "runwell-shopify qa --bundles" }
45
+ ]
46
+ }