@nextcommerce/campaigns-os 1.54.0 → 1.55.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 (53) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -12
  4. package/compatibility.json +1 -1
  5. package/contracts/release-ledger.json +441 -0
  6. package/contracts/supported-surface.json +2 -2
  7. package/docs/brand-theme-bridge.md +10 -0
  8. package/docs/build-packet.md +35 -19
  9. package/docs/campaigns-os-build-flow.md +1 -1
  10. package/docs/effects.md +2 -5
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/qa-and-test-orders.md +55 -11
  14. package/docs/runtime-readiness.md +1 -1
  15. package/docs/skills-revision.md +10 -10
  16. package/docs/supported-surface.md +1 -1
  17. package/docs/workflow-findings-sidecar.md +57 -2
  18. package/package.json +3 -1
  19. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +3 -3
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +4 -4
  29. package/skills.json +10 -10
  30. package/src/adapter-decision-contract.mjs +26 -12
  31. package/src/brand-theme.mjs +44 -8
  32. package/src/campaign-identity.mjs +33 -4
  33. package/src/cli.mjs +36 -59
  34. package/src/consent.mjs +3 -2
  35. package/src/design-source-publication.mjs +1 -1
  36. package/src/deviation.mjs +2 -1
  37. package/src/doctor/checks.mjs +16 -6
  38. package/src/doctor/next-step.mjs +16 -6
  39. package/src/invocation.mjs +4 -3
  40. package/src/lifecycle.mjs +88 -6
  41. package/src/local-preview-policy.mjs +6 -0
  42. package/src/login.mjs +2 -1
  43. package/src/polish-browser.mjs +8 -2
  44. package/src/polish-node.mjs +6 -1
  45. package/src/polish-readability.mjs +2 -2
  46. package/src/qa-browser.mjs +521 -92
  47. package/src/qa-cart-entry.mjs +29 -6
  48. package/src/qa-node.mjs +144 -39
  49. package/src/qa-verdict-publish.mjs +2 -1
  50. package/src/run-record.mjs +46 -34
  51. package/src/sdk-markup.mjs +41 -1
  52. package/src/stage-ledger.mjs +14 -2
  53. package/src/stage-record.mjs +50 -13
package/skills.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "campaigns-os-skills",
3
3
  "description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
4
- "bundle_revision": "1.54.0+skills.1",
4
+ "bundle_revision": "1.55.0+skills.1",
5
5
  "homepage": "https://github.com/NextCommerceCo/campaigns-os",
6
6
  "retired_skills": [
7
7
  {
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "id": "next-campaigns-os",
18
18
  "name": "Campaigns OS Lifecycle",
19
- "version": "1.0.50",
19
+ "version": "1.0.52",
20
20
  "path": "skills/next-campaigns-os/SKILL.md",
21
21
  "domain": "campaigns",
22
22
  "description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "id": "next-campaigns-os-setup",
26
26
  "name": "Campaigns OS Setup",
27
- "version": "2.0.33",
27
+ "version": "2.0.35",
28
28
  "path": "skills/next-campaigns-os-setup/SKILL.md",
29
29
  "domain": "campaigns",
30
30
  "description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
@@ -32,7 +32,7 @@
32
32
  {
33
33
  "id": "next-campaigns-build",
34
34
  "name": "Campaign Build",
35
- "version": "1.0.35",
35
+ "version": "1.0.37",
36
36
  "path": "skills/next-campaigns-build/SKILL.md",
37
37
  "domain": "campaigns",
38
38
  "description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
@@ -40,7 +40,7 @@
40
40
  {
41
41
  "id": "next-campaigns-polish",
42
42
  "name": "Campaign Polish",
43
- "version": "1.1.34",
43
+ "version": "1.1.36",
44
44
  "path": "skills/next-campaigns-polish/SKILL.md",
45
45
  "domain": "campaigns",
46
46
  "description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
@@ -48,7 +48,7 @@
48
48
  {
49
49
  "id": "next-campaigns-qa",
50
50
  "name": "Campaign QA",
51
- "version": "1.3.34",
51
+ "version": "1.3.36",
52
52
  "path": "skills/next-campaigns-qa/SKILL.md",
53
53
  "domain": "campaigns",
54
54
  "description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
@@ -56,7 +56,7 @@
56
56
  {
57
57
  "id": "campaign-lifecycle-orientation",
58
58
  "name": "Campaign Lifecycle Orientation",
59
- "version": "1.0.30",
59
+ "version": "1.0.32",
60
60
  "path": "skills/campaign-lifecycle-orientation/SKILL.md",
61
61
  "domain": "campaigns",
62
62
  "description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
@@ -64,7 +64,7 @@
64
64
  {
65
65
  "id": "campaign-run-evidence",
66
66
  "name": "Campaign Run Evidence",
67
- "version": "1.0.30",
67
+ "version": "1.0.32",
68
68
  "path": "skills/campaign-run-evidence/SKILL.md",
69
69
  "domain": "campaigns",
70
70
  "description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
@@ -72,7 +72,7 @@
72
72
  {
73
73
  "id": "campaign-readback-classification",
74
74
  "name": "Campaign Readback Classification",
75
- "version": "1.0.30",
75
+ "version": "1.0.32",
76
76
  "path": "skills/campaign-readback-classification/SKILL.md",
77
77
  "domain": "campaigns",
78
78
  "description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
@@ -80,7 +80,7 @@
80
80
  {
81
81
  "id": "contribution-intake",
82
82
  "name": "Contribution Intake",
83
- "version": "1.0.30",
83
+ "version": "1.0.32",
84
84
  "path": "skills/contribution-intake/SKILL.md",
85
85
  "domain": "campaigns",
86
86
  "description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
@@ -66,6 +66,20 @@ const SCRIPT_STYLE_REFERENCE_POLICIES = new Set(["frontmatter_or_campaign_asset"
66
66
  const CTA_REWRITE_POLICIES = ROUTE_REWRITE_POLICIES;
67
67
  const LAYOUT_CHOICES = new Set(["campaign_layout", "page_layout", "raw_passthrough", "not_applicable", "unknown"]);
68
68
 
69
+ // The scalar choices record build can set; the object-valued template slice
70
+ // stays with its existing proof fields and doctor checks.
71
+ export const ADAPTER_DECISION_SCALAR_VALUES = Object.freeze(Object.assign(Object.create(null), {
72
+ raw_html_conversion_status: [...RAW_HTML_CONVERSION_STATUSES],
73
+ source_asset_strategy: [...SOURCE_ASSET_STRATEGIES],
74
+ commerce_shell_adoption: [...COMMERCE_SHELL_ADOPTIONS],
75
+ route_rewrite_policy: [...ROUTE_REWRITE_POLICIES],
76
+ config_script_strategy: [...CONFIG_SCRIPT_STRATEGIES],
77
+ frontmatter_policy: [...FRONTMATTER_POLICIES],
78
+ script_style_reference_policy: [...SCRIPT_STYLE_REFERENCE_POLICIES],
79
+ cta_rewrite_policy: [...CTA_REWRITE_POLICIES],
80
+ layout_choice: [...LAYOUT_CHOICES],
81
+ }));
82
+
69
83
  /**
70
84
  * Seeds the adapter decision record written onto a new Build Packet.
71
85
  *
@@ -170,7 +184,7 @@ export function validateAdapterSourceFiles({ decisions, sourceRoot, pages = [],
170
184
  addIssue(
171
185
  warnings,
172
186
  "source_html.raw_html_wrappers",
173
- `source_html.adapter_contract.raw_html_conversion_status is "${status}", but mapped source HTML still contains document wrapper tags: ${sample}${more}. Strip <!doctype>, <html>, <head>, and <body> before treating the page as page-kit-ready source.`
187
+ `Effective raw_html_conversion_status is "${status}", but mapped source HTML still contains document wrapper tags: ${sample}${more}. Strip <!doctype>, <html>, <head>, and <body> before treating the page as page-kit-ready source.`
174
188
  );
175
189
  }
176
190
 
@@ -184,32 +198,32 @@ export function validateAdapterDecisionGates({ decisions, location, specPages =
184
198
  addIssue(
185
199
  warnings,
186
200
  "adapter.raw_html_conversion_status",
187
- `Assembly is recorded complete, but ${location}.raw_html_conversion_status is "${decisions.raw_html_conversion_status}". Record completed/not_required after wrapper stripping, frontmatter, asset moves, script/style refs, CTA rewrites, route policy, and layout choice are settled.`
201
+ `Assembly is recorded complete, but ${location}.raw_html_conversion_status is "${decisions.raw_html_conversion_status}". Use record build --adapter-decision raw_html_conversion_status=<value> to record completed/not_required after wrapper stripping, frontmatter, asset moves, script/style refs, CTA rewrites, route policy, and layout choice are settled.`
188
202
  );
189
203
  }
190
204
  if (["raw_passthrough", "unknown"].includes(decisions.source_asset_strategy)) {
191
- addIssue(warnings, "adapter.source_asset_strategy", `Assembly is recorded complete with ${location}.source_asset_strategy="${decisions.source_asset_strategy}". Page-kit builds should normally use pagekit_campaign_asset_root so src/<slug>/assets/* publishes at /<slug>/*.`);
205
+ addIssue(warnings, "adapter.source_asset_strategy", `Assembly is recorded complete with ${location}.source_asset_strategy="${decisions.source_asset_strategy}". Use record build --adapter-decision source_asset_strategy=<value> to record pagekit_campaign_asset_root when src/<slug>/assets/* publishes at /<slug>/*.`);
192
206
  }
193
207
  if (["raw_passthrough", "unknown"].includes(decisions.route_rewrite_policy)) {
194
- addIssue(warnings, "adapter.route_rewrite_policy", `Assembly is recorded complete with ${location}.route_rewrite_policy="${decisions.route_rewrite_policy}". Record how CampaignSpec routes and CTA destinations were rewritten before QA.`);
208
+ addIssue(warnings, "adapter.route_rewrite_policy", `Assembly is recorded complete with ${location}.route_rewrite_policy="${decisions.route_rewrite_policy}". Use record build --adapter-decision route_rewrite_policy=<value> to record how CampaignSpec routes and CTA destinations were rewritten before QA.`);
195
209
  }
196
210
  if (decisions.config_script_strategy === "unknown") {
197
- addIssue(warnings, "adapter.config_script_strategy", `Assembly is recorded complete but ${location}.config_script_strategy is unknown. Record whether config scripts load via campaign assets, frontmatter scripts, inline config, or not_required.`);
211
+ addIssue(warnings, "adapter.config_script_strategy", `Assembly is recorded complete but ${location}.config_script_strategy is unknown. Use record build --adapter-decision config_script_strategy=<value> to record whether config scripts load via campaign assets, frontmatter scripts, inline config, or not_required.`);
198
212
  }
199
213
  if (["preserve_document_wrappers", "unknown"].includes(decisions.wrapper_policy)) {
200
- addIssue(warnings, "adapter.wrapper_policy", `Assembly is recorded complete with ${location}.wrapper_policy="${decisions.wrapper_policy}". Page-kit source should normally strip document wrappers unless preserving them is explicitly not_required.`);
214
+ addIssue(warnings, "adapter.wrapper_policy", `Assembly is recorded complete with ${location}.wrapper_policy="${decisions.wrapper_policy}". Select wrapper_policy at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; page-kit source should normally strip document wrappers unless preserving them is explicitly not_required.`);
201
215
  }
202
216
  if (["raw_passthrough", "unknown"].includes(decisions.frontmatter_policy)) {
203
- addIssue(warnings, "adapter.frontmatter_policy", `Assembly is recorded complete with ${location}.frontmatter_policy="${decisions.frontmatter_policy}". Record how Page Kit YAML frontmatter was created or why it is not_required.`);
217
+ addIssue(warnings, "adapter.frontmatter_policy", `Assembly is recorded complete with ${location}.frontmatter_policy="${decisions.frontmatter_policy}". Use record build --adapter-decision frontmatter_policy=<value> to record how Page Kit YAML frontmatter was created or why it is not_required.`);
204
218
  }
205
219
  if (["raw_passthrough", "unknown"].includes(decisions.script_style_reference_policy)) {
206
- addIssue(warnings, "adapter.script_style_reference_policy", `Assembly is recorded complete with ${location}.script_style_reference_policy="${decisions.script_style_reference_policy}". Record how scripts/styles load via frontmatter, campaign assets, inline blocks, or not_required.`);
220
+ addIssue(warnings, "adapter.script_style_reference_policy", `Assembly is recorded complete with ${location}.script_style_reference_policy="${decisions.script_style_reference_policy}". Use record build --adapter-decision script_style_reference_policy=<value> to record how scripts/styles load via frontmatter, campaign assets, inline blocks, or not_required.`);
207
221
  }
208
222
  if (["raw_passthrough", "unknown"].includes(decisions.cta_rewrite_policy)) {
209
- addIssue(warnings, "adapter.cta_rewrite_policy", `Assembly is recorded complete with ${location}.cta_rewrite_policy="${decisions.cta_rewrite_policy}". Record how CTA destinations were rewritten from CampaignSpec routes before QA.`);
223
+ addIssue(warnings, "adapter.cta_rewrite_policy", `Assembly is recorded complete with ${location}.cta_rewrite_policy="${decisions.cta_rewrite_policy}". Use record build --adapter-decision cta_rewrite_policy=<value> to record how CTA destinations were rewritten from CampaignSpec routes before QA.`);
210
224
  }
211
225
  if (["raw_passthrough", "unknown"].includes(decisions.layout_choice)) {
212
- addIssue(warnings, "adapter.layout_choice", `Assembly is recorded complete with ${location}.layout_choice="${decisions.layout_choice}". Record the Page Kit layout strategy before handoff.`);
226
+ addIssue(warnings, "adapter.layout_choice", `Assembly is recorded complete with ${location}.layout_choice="${decisions.layout_choice}". Use record build --adapter-decision layout_choice=<value> to record the Page Kit layout strategy before handoff.`);
213
227
  }
214
228
  }
215
229
 
@@ -219,13 +233,13 @@ export function validateAdapterDecisionGates({ decisions, location, specPages =
219
233
  addIssue(
220
234
  errors,
221
235
  "adapter.commerce_shell_adoption",
222
- `Runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}) are marked custom_html_experimental for template family "${family}". Use template_clone_first_verified or sdk_surfaces_preserved before treating checkout/upsell/downsell/receipt as build-ready.`
236
+ `Runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}) are marked custom_html_experimental for template family "${family}". Use record build --adapter-decision commerce_shell_adoption=<value> to record template_clone_first_verified or sdk_surfaces_preserved before treating checkout/upsell/downsell/receipt as build-ready.`
223
237
  );
224
238
  } else if (assemblyComplete && !["template_clone_first_verified", "sdk_surfaces_preserved"].includes(adoption)) {
225
239
  addIssue(
226
240
  warnings,
227
241
  "adapter.commerce_shell_adoption",
228
- `Assembly is recorded complete, but ${location}.commerce_shell_adoption is "${adoption || "missing"}" for runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}). Commerce pages should be template-clone-first, then styled, with SDK-owned surfaces preserved.`
242
+ `Assembly is recorded complete, but ${location}.commerce_shell_adoption is "${adoption || "missing"}" for runtime commerce pages (${runtimePages.map((page) => page.id).join(", ")}). Use record build --adapter-decision commerce_shell_adoption=<value> after commerce pages are template-clone-first, then styled, with SDK-owned surfaces preserved.`
229
243
  );
230
244
  }
231
245
  }
@@ -256,8 +256,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
256
256
  const lower = name.toLowerCase();
257
257
  if (isTargetContractToken(lower)) continue;
258
258
  const parts = tokenNameParts(lower);
259
+ const decorativeName = hasTokenPart(parts, DECORATIVE_NAME_PARTS);
260
+ const namedRole = hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
259
261
  const ctaName = hasTokenPart(parts, ["cta", "button", "btn"]);
260
- if (!ctaName && (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
262
+ if (!ctaName && (!decorativeName || namedRole)
263
+ && (hasTokenSequence(parts, ["brand"], ["primary", "main"]) || (hasTokenPart(parts, ["primary"]) && !hasTokenPart(parts, ["text", "foreground", "surface", "background", "bg", "border", "outline"])))) {
261
264
  addToken("--brand-primary", color);
262
265
  }
263
266
  if (ctaName) {
@@ -281,7 +284,7 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
281
284
  if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
282
285
  }
283
286
 
284
- const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
287
+ const rulePattern = /([^{};]+)\{([^{}]+)\}/g;
285
288
  for (const match of stripCssComments(content).matchAll(rulePattern)) {
286
289
  const selector = match[1] || "";
287
290
  for (const declaration of match[2].split(";")) {
@@ -291,9 +294,11 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
291
294
  const color = resolveDeclarationColor(rawValue, rootTokens);
292
295
  if (!color) continue;
293
296
  if (["background", "background-color"].includes(name)) {
294
- if (/(header|nav|announcement|brand|hero)/i.test(selector) && isStrongBrandColor(color)) {
297
+ if (hasPrimarySelectorRole(selector)
298
+ && isStrongBrandColor(color)) {
295
299
  addToken("--brand-primary", color);
296
- } else if (isCtaLikeSelector(selector) && isStrongBrandColor(color)) {
300
+ }
301
+ if (isCtaLikeSelector(eligibleSelectorText(selector)) && isStrongBrandColor(color)) {
297
302
  addToken("--button-primary-bg", color);
298
303
  addToken("--brand-cta", color);
299
304
  addToken("--brand-accent", color);
@@ -319,6 +324,32 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
319
324
  return tokens;
320
325
  }
321
326
 
327
+ const DECORATIVE_NAME_PARTS = ["sale", "discount", "badge", "rating", "star", "strike", "border", "announcement"];
328
+ const EXPLICIT_BRAND_PARTS = ["brand", "primary", "cta", "button", "btn"];
329
+
330
+ function selectorSubjectParts(compound) {
331
+ return [...compound.classes, ...compound.ids].flatMap(tokenNameParts);
332
+ }
333
+
334
+ function subjectHasDecorativeRole(compound) {
335
+ // A button or submit input is a control whatever its classes say.
336
+ if (compound.type === "button") return false;
337
+ if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return false;
338
+ const parts = selectorSubjectParts(compound);
339
+ return hasTokenPart(parts, DECORATIVE_NAME_PARTS) && !hasTokenPart(parts, EXPLICIT_BRAND_PARTS);
340
+ }
341
+
342
+ function eligibleSelectorText(selector) {
343
+ const compounds = rightmostCompounds(selector);
344
+ if (!compounds) return selector;
345
+ return compounds.filter((compound) => !subjectHasDecorativeRole(compound))
346
+ .map((compound) => compound.selectorText).join(", ");
347
+ }
348
+
349
+ function hasPrimarySelectorRole(selector) {
350
+ return /(header|nav|announcement|brand|hero)/i.test(eligibleSelectorText(selector));
351
+ }
352
+
322
353
  function isCtaLikeSelector(selector) {
323
354
  return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
324
355
  }
@@ -337,11 +368,12 @@ const SELECTOR_ARGUMENT_PSEUDOS = new Set(["not", "is", "where", "has"]);
337
368
  function rightmostCompounds(selectorList) {
338
369
  const text = String(selectorList || "");
339
370
  const compounds = [];
340
- const fresh = () => ({ type: null, classes: [], attributes: [], pseudos: [] });
341
- const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.attributes.length && !compound.pseudos.length;
371
+ const fresh = () => ({ type: null, classes: [], ids: [], attributes: [], pseudos: [] });
372
+ const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.ids.length && !compound.attributes.length && !compound.pseudos.length;
342
373
  let current = fresh();
343
374
  let afterCombinator = false;
344
375
  let i = 0;
376
+ let selectorStart = 0;
345
377
  const readName = () => {
346
378
  const start = i;
347
379
  while (i < text.length && (/[A-Za-z0-9_\- -￿]/.test(text[i]) || text[i] === "\\")) i += text[i] === "\\" ? 2 : 1;
@@ -371,10 +403,12 @@ function rightmostCompounds(selectorList) {
371
403
  if (/[\s>+~]/.test(ch)) { afterCombinator = true; i += 1; continue; }
372
404
  if (ch === ",") {
373
405
  if (isEmpty(current)) return null;
406
+ current.selectorText = text.slice(selectorStart, i);
374
407
  compounds.push(current);
375
408
  current = fresh();
376
409
  afterCombinator = false;
377
410
  i += 1;
411
+ selectorStart = i;
378
412
  continue;
379
413
  }
380
414
  if (afterCombinator) { current = fresh(); afterCombinator = false; }
@@ -383,6 +417,7 @@ function rightmostCompounds(selectorList) {
383
417
  const name = readName();
384
418
  if (!name) return null;
385
419
  if (ch === ".") current.classes.push(name);
420
+ else current.ids.push(name);
386
421
  } else if (ch === "[") {
387
422
  const attribute = readGroup("[", "]");
388
423
  if (attribute === null) return null;
@@ -404,6 +439,7 @@ function rightmostCompounds(selectorList) {
404
439
  }
405
440
  }
406
441
  if (isEmpty(current)) return null;
442
+ current.selectorText = text.slice(selectorStart);
407
443
  compounds.push(current);
408
444
  return compounds;
409
445
  }
@@ -469,7 +505,7 @@ function splitDeclarations(body) {
469
505
  // declaredCtaForegroundValue.
470
506
  function collectCtaLabelRules(content, rootTokens = {}) {
471
507
  const rules = [];
472
- for (const match of stripCssComments(content).matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
508
+ for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
473
509
  if (!isButtonSelector(match[1])) continue;
474
510
  let color = null;
475
511
  let background = null;
@@ -975,7 +1011,7 @@ function mapTokens(tokens, targetTokens, { rootTokens = {}, ctaLabelRules = [] }
975
1011
  const hasCta = mappings.some((mapping) => mapping.target === "--brand--color--cta-primary");
976
1012
  if (!hasCta && isNonEmptyString(tokens["--brand-accent"])) {
977
1013
  addMapping("--brand-accent", "--brand--color--cta-primary", tokens["--brand-accent"].trim(), "medium", { method: "cta-fallback-from-brand-accent" });
978
- warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-accent as --brand--color--cta-primary."));
1014
+ warnings.push(issue("theme.cta.fallback", "No explicit CTA token or button background was found; using --brand-accent as --brand--color--cta-primary. This may be a rating or decorative accent; confirm the CTA colour against the design."));
979
1015
  } else if (!hasCta && isNonEmptyString(tokens["--brand-primary"])) {
980
1016
  addMapping("--brand-primary", "--brand--color--cta-primary", tokens["--brand-primary"].trim(), "medium", { method: "cta-fallback-from-brand-primary" });
981
1017
  warnings.push(issue("theme.cta.fallback", "No explicit CTA token was found; using --brand-primary as --brand--color--cta-primary."));
@@ -45,6 +45,7 @@ export const CAMPAIGN_IDENTITY = "built_output.campaign_identity";
45
45
  // Finding kinds, each with its own issue code under the gate id.
46
46
  export const CAMPAIGN_IDENTITY_KINDS = Object.freeze({
47
47
  api_key_drift: `${CAMPAIGN_IDENTITY}.api_key_drift`,
48
+ api_key_mismatch: `${CAMPAIGN_IDENTITY}.api_key_mismatch`,
48
49
  funnel_drift: `${CAMPAIGN_IDENTITY}.funnel_drift`,
49
50
  funnel_missing: `${CAMPAIGN_IDENTITY}.funnel_missing`,
50
51
  attribution_drift: `${CAMPAIGN_IDENTITY}.attribution_drift`,
@@ -270,7 +271,7 @@ function gateBase(subject) {
270
271
  * file?: string|null, content: string, spec_page?: boolean,
271
272
  * scripts?: Array<{ src: string, file?: string|null, content: string }> }> }} input
272
273
  */
273
- export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
274
+ export function evaluateCampaignIdentity({ subject, pages = [], expectedApiKey = null } = {}) {
274
275
  const all = Array.isArray(pages) ? pages : [];
275
276
  const skipped = all
276
277
  .filter((page) => isParkedPage(page.route ?? page.file ?? page.page_id))
@@ -298,7 +299,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
298
299
  // value. Meta and config are compared together on purpose: a page whose
299
300
  // meta names one campaign while its config.js names another is drift
300
301
  // inside a single page, and the SDK's precedence rule only hides it.
301
- const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, ...key })));
302
+ const keyObservations = identities.flatMap((identity) => identity.api_keys.map((key) => ({ page_id: identity.page_id, page_file: identity.file || identity.page_id, ...key })));
302
303
  const firstKey = keyObservations[0] || null;
303
304
  for (const observation of keyObservations.slice(1)) {
304
305
  if (observation.value === firstKey.value) continue;
@@ -312,6 +313,32 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
312
313
  });
313
314
  break; // one finding per kind names the first pair; the rest follow from the same edit
314
315
  }
316
+ const wrongKeys = new Map();
317
+ if (expectedApiKey?.key) {
318
+ for (const observation of keyObservations) {
319
+ if (observation.value === expectedApiKey.key) continue;
320
+ if (!wrongKeys.has(observation.value)) wrongKeys.set(observation.value, new Map());
321
+ const sources = wrongKeys.get(observation.value);
322
+ const sourceId = `${observation.where}\u0000${observation.source}`;
323
+ if (!sources.has(sourceId)) sources.set(sourceId, { file: observation.where, source: observation.source, pages: new Set() });
324
+ sources.get(sourceId).pages.add(observation.page_file);
325
+ }
326
+ }
327
+ const wrongSourceFiles = [...new Set([...wrongKeys.values()].flatMap((sources) => [...sources.values()].map((source) => source.file)))];
328
+ if (wrongKeys.size) {
329
+ const firstWrong = keyObservations.find((observation) => observation.value !== expectedApiKey.key);
330
+ const wrongDetails = [...wrongKeys].map(([value, sources]) =>
331
+ `${[...sources.values()].map(({ file, source, pages }) => `${file} (${source}; used by ${[...pages].join(", ")})`).join(" and ")} has ${quote(value)}`,
332
+ ).join("; ");
333
+ findings.push({
334
+ kind: "api_key_mismatch",
335
+ code: CAMPAIGN_IDENTITY_KINDS.api_key_mismatch,
336
+ a: { page_id: null, file: expectedApiKey.source, value: expectedApiKey.key },
337
+ b: { page_id: firstWrong.page_id, file: firstWrong.where, source: firstWrong.source, value: firstWrong.value },
338
+ evidence: `Built API key in ${wrongDetails} differs from the Campaigns API key in ${expectedApiKey.source}.`,
339
+ message: `Built API key in ${wrongDetails} differs from the Campaigns API key ${quote(expectedApiKey.key)} in ${expectedApiKey.source}. Replace the built key in the named source with the campaign's own key, then rebuild.`,
340
+ });
341
+ }
315
342
 
316
343
  // 2. next-funnel: one value across pages, and present wherever the SDK is
317
344
  // bootstrapped (a page that declares next-page-type is SDK-bound).
@@ -422,7 +449,7 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
422
449
  // Short on purpose; the per-edit prose is findings[].message, which is
423
450
  // what each doctor error carries. A reason that concatenates them is
424
451
  // truncated by every single-line renderer.
425
- reason: `${findings.length} campaign identity drift finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the two files and two values of each.`,
452
+ reason: `${findings.length} campaign identity ${findings.every((finding) => finding.kind === "api_key_mismatch") ? "API key mismatch" : "drift"} finding(s) across ${scanned.length} built page(s) (${[...new Set(findings.map((finding) => finding.kind))].join(", ")}); see findings[] for the sources and values to repair.`,
426
453
  findings,
427
454
  pages_scanned: scanned.length,
428
455
  pages_skipped: skipped,
@@ -432,7 +459,9 @@ export function evaluateCampaignIdentity({ subject, pages = [] } = {}) {
432
459
  id: "repair_identity",
433
460
  kind: "edit",
434
461
  command: null,
435
- description: `Make every page name the same campaign: ${findings.map((finding) => `${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")})`).join("; ")}. Not waivable: two campaign identities on one funnel cannot both be intended.`,
462
+ description: findings.map((finding) => finding.kind === "api_key_mismatch"
463
+ ? `Replace the built key in ${wrongSourceFiles.join(", ")} with the campaign's key, then rebuild.`
464
+ : `Make every page name the same campaign: ${finding.b?.file || finding.a.file} (${finding.kind.replace(/_/g, " ")}).`).join(" "),
436
465
  },
437
466
  ],
438
467
  };