@runwell/shopify-toolkit 0.17.4 → 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 (51) hide show
  1. package/bin/runwell-shopify +14 -2
  2. package/lib/list.js +133 -0
  3. package/lib/qa-bundles.js +117 -0
  4. package/lib/qa.js +147 -13
  5. package/modules/INDEX.md +65 -23
  6. package/modules/_shared/css-tokens/assets/runwell-tokens.css +24 -4
  7. package/modules/_shared/css-tokens/module.json +2 -2
  8. package/modules/_shared/css-typography/assets/runwell-typography.css +14 -6
  9. package/modules/_shared/css-typography/module.json +2 -2
  10. package/modules/bundle-builder/README.md +6 -1
  11. package/modules/bundle-builder/module.json +5 -1
  12. package/modules/care-coaching-medvi/README.md +46 -0
  13. package/modules/care-coaching-medvi/assets/runwell-care-coaching-medvi.css +241 -0
  14. package/modules/care-coaching-medvi/module.json +80 -0
  15. package/modules/care-coaching-medvi/sections/runwell-care-coaching-medvi.liquid +274 -0
  16. package/modules/care-coaching-medvi/snippets/runwell-care-coaching-medvi-circular-text.liquid +25 -0
  17. package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +16 -0
  18. package/modules/collection-block-medvi/README.md +50 -0
  19. package/modules/collection-block-medvi/assets/runwell-collection-block-medvi.css +242 -0
  20. package/modules/collection-block-medvi/module.json +83 -0
  21. package/modules/collection-block-medvi/sections/runwell-collection-block-medvi.liquid +355 -0
  22. package/modules/product-trio-medvi/README.md +35 -0
  23. package/modules/product-trio-medvi/assets/runwell-product-trio-medvi.css +119 -0
  24. package/modules/product-trio-medvi/module.json +48 -0
  25. package/modules/product-trio-medvi/sections/runwell-product-trio-medvi.liquid +188 -0
  26. package/modules/runwell-bundle-system/README.md +35 -0
  27. package/modules/runwell-bundle-system/admin-metafields.json +46 -0
  28. package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +861 -0
  29. package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +287 -0
  30. package/modules/runwell-bundle-system/module.json +126 -0
  31. package/modules/runwell-bundle-system/qa/mobile-checklist.md +105 -0
  32. package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +59 -0
  33. package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +121 -0
  34. package/modules/runwell-bundle-system/sections/runwell-bundle-home-stacks.liquid +77 -0
  35. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +50 -0
  36. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +72 -0
  37. package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +105 -0
  38. package/modules/runwell-bundle-system/settings.json +25 -0
  39. package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +70 -0
  40. package/modules/runwell-bundle-system/snippets/runwell-bundle-cross-supplier.liquid +18 -0
  41. package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +67 -0
  42. package/modules/runwell-bundle-system/snippets/runwell-bundle-fomo.liquid +32 -0
  43. package/modules/runwell-bundle-system/snippets/runwell-bundle-free-gift.liquid +34 -0
  44. package/modules/runwell-bundle-system/snippets/runwell-bundle-multi-product.liquid +86 -0
  45. package/modules/runwell-bundle-system/snippets/runwell-bundle-pricing.liquid +30 -0
  46. package/modules/runwell-bundle-system/snippets/runwell-bundle-quantity-tiers.liquid +73 -0
  47. package/modules/testimonials-medvi/README.md +44 -0
  48. package/modules/testimonials-medvi/assets/runwell-testimonials-medvi.css +239 -0
  49. package/modules/testimonials-medvi/module.json +68 -0
  50. package/modules/testimonials-medvi/sections/runwell-testimonials-medvi.liquid +355 -0
  51. package/package.json +2 -2
@@ -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';
@@ -44,7 +45,10 @@ function help() {
44
45
 
45
46
  Commands:
46
47
  sync Apply runwell.config.json to the target theme
47
- list List available + installed modules
48
+ list List available + installed modules.
49
+ --tenants prints a cross-tenant matrix
50
+ (tenants x baseline pin x toolkit pin x
51
+ drift) read from shopify-toolkit-deployments.json.
48
52
  add <module> Enable a module in runwell.config.json
49
53
  remove <module> Disable + clean a module's files
50
54
  doctor Detect drift between manifest and theme
@@ -52,6 +56,9 @@ Commands:
52
56
  qa Code-first QA pipeline (validate-config, doctor,
53
57
  template-integrity, orphan-assets, theme-check).
54
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.
55
62
  init <client> Scaffold a new tenant theme directory pinned to
56
63
  the dawn-runwell baseline + toolkit. Use --baseline
57
64
  to override the default pin.
@@ -95,7 +102,12 @@ flags.toolkitRoot = TOOLKIT_ROOT;
95
102
  await validate(flags);
96
103
  break;
97
104
  case 'qa':
98
- 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
+ }
99
111
  break;
100
112
  case 'init':
101
113
  await init(flags);
package/lib/list.js CHANGED
@@ -1,8 +1,16 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import { listAvailableModules, loadModuleManifest, loadConfig } from './config-loader.js';
4
5
 
5
6
  export async function list(flags) {
7
+ if (flags.tenants) {
8
+ return listTenants(flags);
9
+ }
10
+ return listModules(flags);
11
+ }
12
+
13
+ function listModules(flags) {
6
14
  const available = listAvailableModules(flags.toolkitRoot);
7
15
 
8
16
  let installed = new Set();
@@ -29,3 +37,128 @@ export async function list(flags) {
29
37
 
30
38
  console.log(`\nTotal: ${available.length} modules. Installed in current config: ${installed.size}.`);
31
39
  }
40
+
41
+ function listTenants(flags) {
42
+ const registryPath = resolveRegistryPath(flags);
43
+ if (!registryPath) {
44
+ console.error('Could not locate shopify-toolkit-deployments.json.');
45
+ console.error('Pass --registry <path>, set RUNWELL_DEPLOYMENT_REGISTRY env var, or place the file at ~/Documents/Code/claude-PM/infrastructure/config/shopify-toolkit-deployments.json');
46
+ process.exit(1);
47
+ }
48
+
49
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
50
+ const deployments = registry.deployments || {};
51
+ const tenants = Object.entries(deployments);
52
+
53
+ if (tenants.length === 0) {
54
+ console.log('No tenants in registry.');
55
+ return;
56
+ }
57
+
58
+ const toolkitCurrent = readToolkitVersion(flags.toolkitRoot);
59
+
60
+ console.log(`Runwell Shopify tenants (registry: ${registryPath})\n`);
61
+ console.log(`Toolkit current: ${toolkitCurrent}\n`);
62
+ console.log(' Tenant Store Config pin Synced ver Modules Brand CSS Drift Status');
63
+ console.log(' --------- ------------------------------------------- ------------ ----------- ------- --------- ----- ----------');
64
+
65
+ let drifters = 0;
66
+ let exposed = 0;
67
+
68
+ for (const [slug, t] of tenants.sort((a, b) => a[0].localeCompare(b[0]))) {
69
+ const store = (t.store || '?').padEnd(43);
70
+ const tenantConfig = readTenantConfig(t.theme_repo_local);
71
+ const configPin = (tenantConfig?.toolkit_version || '?').padEnd(12);
72
+ const syncedVer = (readManifestToolkit(t.theme_repo_local) || '?').padEnd(11);
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++;
76
+ const drift = computeDrift(tenantConfig?.toolkit_version, toolkitCurrent);
77
+ if (drift !== 'ok') drifters++;
78
+ const driftStr = drift.padEnd(5);
79
+ const status = (t.status || '?');
80
+ console.log(` ${slug.padEnd(9)} ${store} ${configPin} ${syncedVer} ${moduleCount} ${brandCss} ${driftStr} ${status}`);
81
+ }
82
+
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).`);
86
+
87
+ if (flags.verbose) {
88
+ console.log('\nLegend:');
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.');
93
+ }
94
+ }
95
+
96
+ function resolveRegistryPath(flags) {
97
+ if (flags.registry) {
98
+ return path.resolve(flags.registry);
99
+ }
100
+ if (process.env.RUNWELL_DEPLOYMENT_REGISTRY) {
101
+ return path.resolve(process.env.RUNWELL_DEPLOYMENT_REGISTRY);
102
+ }
103
+ const guesses = [
104
+ path.join(os.homedir(), 'Documents/Code/claude-PM/infrastructure/config/shopify-toolkit-deployments.json'),
105
+ path.resolve(flags.toolkitRoot, '../claude-PM/infrastructure/config/shopify-toolkit-deployments.json'),
106
+ ];
107
+ return guesses.find(p => fs.existsSync(p));
108
+ }
109
+
110
+ function readToolkitVersion(toolkitRoot) {
111
+ try {
112
+ const pkg = JSON.parse(fs.readFileSync(path.join(toolkitRoot, 'package.json'), 'utf-8'));
113
+ return pkg.version;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function readManifestToolkit(themeRepoLocal) {
120
+ if (!themeRepoLocal) return null;
121
+ const manifestPath = path.join(themeRepoLocal, 'runwell-manifest.json');
122
+ if (!fs.existsSync(manifestPath)) return null;
123
+ try {
124
+ const m = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
125
+ return m.toolkit_version || null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function readTenantConfig(themeRepoLocal) {
132
+ if (!themeRepoLocal) return null;
133
+ const configPath = path.join(themeRepoLocal, 'runwell.config.json');
134
+ if (!fs.existsSync(configPath)) return null;
135
+ try {
136
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
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
+
151
+ function computeDrift(pin, current) {
152
+ if (!pin || !current) return '?';
153
+ const pinMinor = extractMinor(pin);
154
+ const curMinor = extractMinor(current);
155
+ if (pinMinor == null || curMinor == null) return '?';
156
+ if (pinMinor >= curMinor) return 'ok';
157
+ return 'stale';
158
+ }
159
+
160
+ function extractMinor(version) {
161
+ const m = String(version).match(/(\d+)\.(\d+)/);
162
+ if (!m) return null;
163
+ return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
164
+ }
@@ -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']