@nextcommerce/campaigns-os 1.43.2 → 1.46.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 (72) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +648 -5103
  3. package/README.md +32 -11
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  14. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  15. package/contracts/effects.v1.json +1176 -113
  16. package/contracts/orientation-reason-codes.v1.json +7 -0
  17. package/contracts/release-ledger.json +2345 -6087
  18. package/contracts/supported-surface.json +7 -4
  19. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  20. package/docs/brand-theme-bridge.md +81 -0
  21. package/docs/build-packet.md +158 -21
  22. package/docs/campaigns-os-build-flow.md +3 -3
  23. package/docs/design-source-package.md +73 -0
  24. package/docs/effects.md +50 -8
  25. package/docs/gateway-login.md +3 -0
  26. package/docs/local-setup.md +1 -1
  27. package/docs/orientation-contract-reference.md +42 -2
  28. package/docs/polish-evidence.md +74 -0
  29. package/docs/qa-and-test-orders.md +99 -13
  30. package/docs/release-ledger-authoring-guide.md +64 -4
  31. package/docs/runtime-readiness.md +1 -1
  32. package/docs/sdk-storage-compatibility.md +1 -1
  33. package/docs/skills-revision.md +10 -10
  34. package/docs/supported-surface.md +2 -2
  35. package/docs/versioning.md +4 -1
  36. package/package.json +1 -1
  37. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  38. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  39. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  40. package/skills/campaign-readback-classification/SKILL.md +3 -3
  41. package/skills/campaign-run-evidence/SKILL.md +7 -6
  42. package/skills/contribution-intake/SKILL.md +3 -3
  43. package/skills/next-campaigns-build/SKILL.md +7 -6
  44. package/skills/next-campaigns-os/SKILL.md +7 -7
  45. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  46. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  47. package/skills/next-campaigns-polish/SKILL.md +28 -9
  48. package/skills/next-campaigns-qa/SKILL.md +7 -4
  49. package/skills.json +10 -10
  50. package/src/brand-theme.mjs +320 -20
  51. package/src/built-site-scope.mjs +16 -4
  52. package/src/cli.mjs +280 -46
  53. package/src/commercial-parity.mjs +48 -2
  54. package/src/deviation.mjs +13 -1
  55. package/src/diagnostic.mjs +5 -2
  56. package/src/doctor/checks.mjs +320 -81
  57. package/src/doctor/inspect.mjs +55 -13
  58. package/src/doctor/source-provenance.mjs +184 -0
  59. package/src/invocation.mjs +4 -0
  60. package/src/live-campaign-refs.mjs +466 -0
  61. package/src/login.mjs +2 -2
  62. package/src/page-kit-store-profile.mjs +69 -12
  63. package/src/page-kit-sync.mjs +31 -12
  64. package/src/progress-node.mjs +3 -1
  65. package/src/qa-browser.mjs +538 -28
  66. package/src/qa-commercial-parity.mjs +48 -5
  67. package/src/qa-node.mjs +122 -7
  68. package/src/qa-test-order-topology.mjs +148 -0
  69. package/src/sdk-markup.mjs +72 -8
  70. package/src/source-html-intake.mjs +116 -0
  71. package/src/stage-record.mjs +551 -0
  72. package/src/upsell-selector-scope.mjs +112 -2
@@ -169,16 +169,21 @@ function extractStyleBlocks(content) {
169
169
  return blocks;
170
170
  }
171
171
 
172
+ // A commented-out declaration is not part of the design (#535): every token
173
+ // and rule extraction below reads the CSS with comments removed first.
174
+ function stripCssComments(content) {
175
+ return String(content || "").replace(/\/\*[\s\S]*?\*\//g, "");
176
+ }
177
+
172
178
  export function parseRootCustomProperties(content) {
173
179
  const tokens = {};
174
180
  const warnings = [];
175
- for (const block of extractRootBlocks(content)) {
181
+ for (const block of extractRootBlocks(stripCssComments(content))) {
176
182
  const declarationPattern = /(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+);/g;
177
183
  for (const match of block.body.matchAll(declarationPattern)) {
178
184
  tokens[match[1].trim()] = match[2].trim();
179
185
  }
180
186
  const bodyWithoutMatches = block.body
181
- .replace(/\/\*[\s\S]*?\*\//g, "")
182
187
  .replace(declarationPattern, "")
183
188
  .trim();
184
189
  if (bodyWithoutMatches) {
@@ -207,10 +212,17 @@ function isSurfaceBgToken(parts) {
207
212
  || hasTokenSequence(parts, ["bg"], ["page", "body", "site"]);
208
213
  }
209
214
 
210
- function isTextInverseToken(parts) {
211
- return hasTokenSequence(parts, ["text", "foreground"], ["inverse"])
212
- || hasTokenSequence(parts, ["inverse"], ["text", "foreground"])
213
- || hasTokenSequence(parts, ["on"], ["primary", "cta", "brand", "accent"]);
215
+ // Inverse / on-colour label tokens, whatever the word order (#535). The name
216
+ // needs a text or foreground part, plus either "inverse" or "on" followed by a
217
+ // coloured or dark background word: --text-inverse, --inverse-text,
218
+ // --text-color-inverse, --on-primary-text, --text-on-dark, --foreground-on-cta.
219
+ // --border-on-primary and --overlay-on-dark are not text; --text-on-light is
220
+ // ordinary dark copy. The one rule for inverse text: token inference, the
221
+ // declared CTA label and body text all read it.
222
+ function isTextLabelToken(parts) {
223
+ if (!hasTokenPart(parts, ["text", "foreground"])) return false;
224
+ return hasTokenPart(parts, ["inverse"])
225
+ || hasTokenSequence(parts, ["on"], ["primary", "cta", "brand", "accent", "dark"]);
214
226
  }
215
227
 
216
228
  function isTargetContractToken(name) {
@@ -251,13 +263,13 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
251
263
  }
252
264
  if (hasTokenPart(parts, ["text"]) && hasTokenPart(parts, ["primary", "main"])) addToken("--text-primary", color);
253
265
  if (hasTokenPart(parts, ["text", "foreground"]) && hasTokenPart(parts, ["secondary", "muted", "subtle"])) addToken("--text-secondary", color);
254
- if (isTextInverseToken(parts)) addToken("--text-inverse", color);
266
+ if (isTextLabelToken(parts)) addToken("--text-inverse", color);
255
267
  if (hasTokenPart(parts, ["border", "outline", "stroke", "ring"])) addToken("--border-default", color);
256
268
  if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
257
269
  }
258
270
 
259
271
  const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
260
- for (const match of content.matchAll(rulePattern)) {
272
+ for (const match of stripCssComments(content).matchAll(rulePattern)) {
261
273
  const selector = match[1] || "";
262
274
  for (const declaration of match[2].split(";")) {
263
275
  const [rawName, ...rawValueParts] = declaration.split(":");
@@ -298,6 +310,168 @@ function isCtaLikeSelector(selector) {
298
310
  return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
299
311
  }
300
312
 
313
+ // Functional pseudo-classes whose arguments are other selectors. They are
314
+ // read past whole: `button:not(.order-summary)` is still the button, and
315
+ // `.cart:has(.button)` is still the cart.
316
+ const SELECTOR_ARGUMENT_PSEUDOS = new Set(["not", "is", "where", "has"]);
317
+
318
+ // Tokenises a selector list into the rightmost compound selector of each
319
+ // selector, as { type, classes, attributes, pseudos } (#535). Strings,
320
+ // escapes, [attribute] selectors and pseudo-class arguments are consumed
321
+ // whole, so class-like text inside them (`[data-target=".btn"]`, `:not(.btn)`)
322
+ // is never read as the compound's own class. null when the list is empty or
323
+ // has anything this reader does not understand.
324
+ function rightmostCompounds(selectorList) {
325
+ const text = String(selectorList || "");
326
+ const compounds = [];
327
+ const fresh = () => ({ type: null, classes: [], attributes: [], pseudos: [] });
328
+ const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.attributes.length && !compound.pseudos.length;
329
+ let current = fresh();
330
+ let afterCombinator = false;
331
+ let i = 0;
332
+ const readName = () => {
333
+ const start = i;
334
+ while (i < text.length && (/[A-Za-z0-9_\- -￿]/.test(text[i]) || text[i] === "\\")) i += text[i] === "\\" ? 2 : 1;
335
+ return text.slice(start, i);
336
+ };
337
+ // Consumes a balanced [...] or (...) group from text[i], strings included.
338
+ const readGroup = (open, close) => {
339
+ const start = i;
340
+ let depth = 0;
341
+ while (i < text.length) {
342
+ const ch = text[i];
343
+ if (ch === "\\") { i += 2; continue; }
344
+ if (ch === "\"" || ch === "'") {
345
+ i += 1;
346
+ while (i < text.length && text[i] !== ch) i += text[i] === "\\" ? 2 : 1;
347
+ i += 1;
348
+ continue;
349
+ }
350
+ if (ch === open) depth += 1;
351
+ if (ch === close && --depth === 0) return text.slice(start + 1, i++);
352
+ i += 1;
353
+ }
354
+ return null;
355
+ };
356
+ while (i < text.length) {
357
+ const ch = text[i];
358
+ if (/[\s>+~]/.test(ch)) { afterCombinator = true; i += 1; continue; }
359
+ if (ch === ",") {
360
+ if (isEmpty(current)) return null;
361
+ compounds.push(current);
362
+ current = fresh();
363
+ afterCombinator = false;
364
+ i += 1;
365
+ continue;
366
+ }
367
+ if (afterCombinator) { current = fresh(); afterCombinator = false; }
368
+ if (ch === "." || ch === "#") {
369
+ i += 1;
370
+ const name = readName();
371
+ if (!name) return null;
372
+ if (ch === ".") current.classes.push(name);
373
+ } else if (ch === "[") {
374
+ const attribute = readGroup("[", "]");
375
+ if (attribute === null) return null;
376
+ current.attributes.push(attribute);
377
+ } else if (ch === ":") {
378
+ i += text[i + 1] === ":" ? 2 : 1;
379
+ const name = readName().toLowerCase();
380
+ if (!name) return null;
381
+ const hasArguments = text[i] === "(";
382
+ if (hasArguments && readGroup("(", ")") === null) return null;
383
+ if (!(hasArguments && SELECTOR_ARGUMENT_PSEUDOS.has(name))) current.pseudos.push(name);
384
+ } else if (ch === "*") {
385
+ i += 1;
386
+ current.type = "*";
387
+ } else {
388
+ const name = readName();
389
+ if (!name) return null;
390
+ current.type = name.toLowerCase();
391
+ }
392
+ }
393
+ if (isEmpty(current)) return null;
394
+ compounds.push(current);
395
+ return compounds;
396
+ }
397
+
398
+ // A selector that is unambiguously a button or CTA, for reading the CTA label
399
+ // colour (#535). isCtaLikeSelector is too broad for that (.order-summary,
400
+ // .cart-count). Every selector in the list must qualify, judged on its
401
+ // rightmost compound selector's own parts (rightmostCompounds): it carries no
402
+ // state pseudo-class or pseudo-element (:hover, :disabled, ::before) and is
403
+ // one of: the `button` element; `input[type=submit]`; or a class that
404
+ // starts with `btn`, `button` or `cta`, or has a `cta` part (.cta, .hero-cta,
405
+ // .cta-primary, .ctaButton). An attribute alone never qualifies:
406
+ // `[type=submit]`, `div[type=submit]` and `.order-summary[type=submit]` are
407
+ // not buttons.
408
+ function isButtonSelector(selector) {
409
+ const compounds = rightmostCompounds(selector);
410
+ if (!compounds) return false;
411
+ return compounds.every((compound) => {
412
+ if (compound.pseudos.length) return false;
413
+ if (compound.type === "button") return true;
414
+ if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return true;
415
+ return compound.classes.some((name) => (
416
+ /^(?:btn|button|cta)/i.test(name) || name.toLowerCase().split(/[-_]+/).includes("cta")
417
+ ));
418
+ });
419
+ }
420
+
421
+ // A rule body's declarations as [name, value] pairs. A `;` or `:` inside a
422
+ // string or parentheses belongs to the value, so
423
+ // `background: url("data:image/png;base64,...") #dd4249` stays one
424
+ // declaration.
425
+ function splitDeclarations(body) {
426
+ const declarations = [];
427
+ let depth = 0;
428
+ let quote = null;
429
+ let start = 0;
430
+ let colon = -1;
431
+ const text = String(body || "");
432
+ const push = (end) => {
433
+ if (colon !== -1) declarations.push([text.slice(start, colon), text.slice(colon + 1, end)]);
434
+ };
435
+ for (let i = 0; i < text.length; i += 1) {
436
+ const ch = text[i];
437
+ if (ch === "\\") { i += 1; continue; }
438
+ if (quote) { if (ch === quote) quote = null; continue; }
439
+ if (ch === "\"" || ch === "'") quote = ch;
440
+ else if (ch === "(") depth += 1;
441
+ else if (ch === ")") depth = Math.max(0, depth - 1);
442
+ else if (ch === ":" && depth === 0 && colon === -1) colon = i;
443
+ else if (ch === ";" && depth === 0) {
444
+ push(i);
445
+ start = i + 1;
446
+ colon = -1;
447
+ }
448
+ }
449
+ push(text.length);
450
+ return declarations;
451
+ }
452
+
453
+ // The colour rules that can supply the CTA label (#535): each button-selector
454
+ // rule that declares `color:`, with the background it declares beside it, if
455
+ // any. The CTA background is known only after mapping, so pairing happens in
456
+ // declaredCtaForegroundValue.
457
+ function collectCtaLabelRules(content, rootTokens = {}) {
458
+ const rules = [];
459
+ for (const match of stripCssComments(content).matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
460
+ if (!isButtonSelector(match[1])) continue;
461
+ let color = null;
462
+ let background = null;
463
+ for (const [rawName, rawValue] of splitDeclarations(match[2])) {
464
+ const name = rawName.trim().toLowerCase();
465
+ const value = resolveDeclarationColor(rawValue, rootTokens);
466
+ if (!value) continue;
467
+ if (name === "color") color = value;
468
+ if (["background", "background-color"].includes(name)) background = value;
469
+ }
470
+ if (color) rules.push({ color, background });
471
+ }
472
+ return rules;
473
+ }
474
+
301
475
  function extractDeclarationColor(value) {
302
476
  const cleaned = String(value || "").replace(/!important/gi, "").trim();
303
477
  const exact = normalizeColor(cleaned);
@@ -336,6 +510,8 @@ function candidateFromFile(path, role, source = "css_file", referencedBy = []) {
336
510
  role,
337
511
  hash: sha256File(path),
338
512
  tokens: { ...inferred, ...parsed.tokens },
513
+ root_tokens: parsed.tokens,
514
+ cta_label_rules: collectCtaLabelRules(content, parsed.tokens),
339
515
  warnings: parsed.warnings,
340
516
  referenced_by: referencedBy,
341
517
  };
@@ -345,10 +521,16 @@ function inlineCandidatesFromHtml(path, role) {
345
521
  const content = readFileSync(path, "utf8");
346
522
  const candidates = [];
347
523
  for (const styleBlock of extractStyleBlocks(content)) {
524
+ const styleBody = stripCssComments(styleBlock.body);
525
+ // Blocks are read from the raw style body so each candidate's index and
526
+ // hash match earlier releases (an unchanged source is not reported stale);
527
+ // a :root block that starts inside a comment is skipped.
528
+ const comments = [...styleBlock.body.matchAll(/\/\*[\s\S]*?\*\//g)].map((match) => [match.index, match.index + match[0].length]);
348
529
  for (const block of extractRootBlocks(styleBlock.body)) {
530
+ if (comments.some(([start, end]) => block.offset >= start && block.offset < end)) continue;
349
531
  const parsed = parseRootCustomProperties(`:root {${block.body}}`);
350
532
  if (Object.keys(parsed.tokens).length === 0) continue;
351
- const inferred = inferDesignIntentTokens(styleBlock.body, parsed.tokens);
533
+ const inferred = inferDesignIntentTokens(styleBody, parsed.tokens);
352
534
  candidates.push({
353
535
  source: "html_inline_root",
354
536
  path,
@@ -356,6 +538,8 @@ function inlineCandidatesFromHtml(path, role) {
356
538
  hash: sha256(`${path}:${styleBlock.index}:${block.index}:${block.body}`),
357
539
  inline_block_index: styleBlock.index,
358
540
  tokens: { ...inferred, ...parsed.tokens },
541
+ root_tokens: parsed.tokens,
542
+ cta_label_rules: collectCtaLabelRules(styleBody, parsed.tokens),
359
543
  warnings: parsed.warnings,
360
544
  referenced_by: [],
361
545
  });
@@ -488,7 +672,8 @@ function contrastRatio(luminanceA, luminanceB) {
488
672
  // brand background (yellow/white/pastel) yields a dark foreground; a dark or
489
673
  // saturated background yields a light one. This is the fix for white-on-light
490
674
  // CTA text: never trust a copied source --text-inverse (which defaults to
491
- // white), always derive from the background's luminance.
675
+ // white) unless it is readable on the CTA (declaredForeground below);
676
+ // otherwise derive from the background's luminance.
492
677
  function readableForeground(bgValue, choices = {}) {
493
678
  const bgRgb = colorToRgb(bgValue);
494
679
  if (!bgRgb) return null;
@@ -507,12 +692,39 @@ function readableForeground(bgValue, choices = {}) {
507
692
  };
508
693
  }
509
694
 
695
+ // The CTA label colour the source declares (#535). A light scaffold default
696
+ // (white --text-inverse on a yellow CTA) must still lose to the luminance pick,
697
+ // so the declared colour is used only when it clears WCAG AA for large text
698
+ // (3:1) on the CTA background. CTA labels are short, semibold button text, and
699
+ // the design's own pairing is the stronger signal than a marginally higher
700
+ // black/white score (white on #dd4249 is 4.24:1, black 4.67:1). Anything under
701
+ // the contract's normal-text min_contrast_ratio still gets the low-contrast
702
+ // warning below.
703
+ const DECLARED_CTA_FOREGROUND_MIN_CONTRAST = 3;
704
+ const CTA_BACKGROUND_TARGET = "--brand--color--cta-primary";
705
+
706
+ function declaredForeground(bgValue, declaredValue) {
707
+ const bgRgb = colorToRgb(bgValue);
708
+ const declared = normalizeColor(declaredValue);
709
+ if (!bgRgb || !declared) return null;
710
+ const bgLuminance = relativeLuminance(bgRgb);
711
+ const declaredLuminance = relativeLuminance(colorToRgb(declared));
712
+ const contrast = contrastRatio(bgLuminance, declaredLuminance);
713
+ if (contrast < DECLARED_CTA_FOREGROUND_MIN_CONTRAST) return null;
714
+ return {
715
+ value: declared,
716
+ contrast: Math.round(contrast * 100) / 100,
717
+ on: declaredLuminance < bgLuminance ? "dark" : "light",
718
+ };
719
+ }
720
+
510
721
  // Emit foreground/on-color tokens derived from the luminance of the background
511
722
  // each sits on. Pairing comes from the contract's foreground_derivations so the
512
723
  // generator stays data-driven. A foreground target already mapped from a source
513
724
  // token is left untouched; one whose paired backgrounds are all unmapped is
514
- // skipped (no background to read).
515
- function deriveForegroundMappings(existingMappings, targetTokens) {
725
+ // skipped (no background to read). A foreground paired with the CTA background
726
+ // takes the source's declared CTA foreground when it is readable there.
727
+ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForeground = null } = {}) {
516
728
  const config = targetTokens.foreground_derivations;
517
729
  if (!isObject(config) || !isObject(config.derivations)) return { mappings: [], warnings: [] };
518
730
  const allowedTargets = new Set(targetTokens.tokens || []);
@@ -530,12 +742,15 @@ function deriveForegroundMappings(existingMappings, targetTokens) {
530
742
  const backgroundTarget = candidates.find((target) => valueByTarget.has(target));
531
743
  if (!backgroundTarget) continue;
532
744
  const backgroundValue = valueByTarget.get(backgroundTarget);
533
- const readable = readableForeground(backgroundValue, choices);
745
+ const declared = backgroundTarget === CTA_BACKGROUND_TARGET ? declaredForeground(backgroundValue, ctaForeground) : null;
746
+ const readable = declared || readableForeground(backgroundValue, choices);
534
747
  if (!readable) continue;
535
748
  if (minContrast && readable.contrast < minContrast) {
536
749
  warnings.push(issue(
537
750
  "theme.foreground.low_contrast",
538
- `Derived ${foregroundTarget} on ${backgroundTarget} (${backgroundValue}) only reaches ${readable.contrast}:1 contrast (< ${minContrast}:1). Confirm the brand background or supply an explicit foreground.`,
751
+ declared
752
+ ? `Declared CTA foreground ${declared.value} for ${foregroundTarget} on ${backgroundTarget} (${backgroundValue}) reaches ${readable.contrast}:1 contrast: it clears ${DECLARED_CTA_FOREGROUND_MIN_CONTRAST}:1 for large text but not ${minContrast}:1 for normal text. Confirm the CTA label is large or bold text.`
753
+ : `Derived ${foregroundTarget} on ${backgroundTarget} (${backgroundValue}) only reaches ${readable.contrast}:1 contrast (< ${minContrast}:1). Confirm the brand background or supply an explicit foreground.`,
539
754
  { foreground: foregroundTarget, background: backgroundTarget, background_value: backgroundValue, contrast: readable.contrast },
540
755
  ));
541
756
  }
@@ -547,7 +762,9 @@ function deriveForegroundMappings(existingMappings, targetTokens) {
547
762
  target: foregroundTarget,
548
763
  value: readable.value,
549
764
  confidence,
550
- derivation: { method: "foreground-from-luminance", background: backgroundTarget, background_value: backgroundValue, on: readable.on, contrast: readable.contrast },
765
+ derivation: declared
766
+ ? { method: "declared-cta-foreground", background: backgroundTarget, background_value: backgroundValue, declared_value: declared.value, on: readable.on, contrast: readable.contrast }
767
+ : { method: "foreground-from-luminance", background: backgroundTarget, background_value: backgroundValue, on: readable.on, contrast: readable.contrast },
551
768
  });
552
769
  }
553
770
  return { mappings, warnings };
@@ -618,7 +835,69 @@ function collectTokenConflicts(candidates) {
618
835
  return conflicts;
619
836
  }
620
837
 
621
- function mapTokens(tokens, targetTokens) {
838
+ // A declared text token (#535) is a :root custom property in the selected
839
+ // source whose name has a "text" part and whose value is a solid colour,
840
+ // minus the names with another job: inverse / on-colour labels, secondary,
841
+ // muted or state copy (links, status colours, placeholders, selections),
842
+ // CTA/button labels, and text shadows, borders and backgrounds.
843
+ const BODY_TEXT_MIN_CONTRAST = 4.5;
844
+
845
+ function isDeclaredTextToken(name) {
846
+ if (isTargetContractToken(name)) return false;
847
+ const parts = tokenNameParts(name);
848
+ if (!hasTokenPart(parts, ["text"]) || isTextLabelToken(parts)) return false;
849
+ return !hasTokenPart(parts, [
850
+ "secondary", "muted", "subtle", "cta", "button", "btn",
851
+ "link", "error", "danger", "success", "warning", "info", "highlight", "accent",
852
+ "placeholder", "disabled", "selection",
853
+ "shadow", "border", "outline", "stroke", "bg", "background",
854
+ ]);
855
+ }
856
+
857
+ // Body text takes the darkest declared text token that is darker than the body
858
+ // background and reaches WCAG AA for normal text (4.5:1) on it, whether or not
859
+ // a --text-primary was otherwise found. With no solid body background, or no
860
+ // token that qualifies, the caller keeps its existing --text-primary pick (or
861
+ // none).
862
+ function darkestDeclaredBodyText(rootTokens, bodyBackground) {
863
+ const bgRgb = colorToRgb(bodyBackground);
864
+ if (!bgRgb) return null;
865
+ const bgLuminance = relativeLuminance(bgRgb);
866
+ let best = null;
867
+ for (const [name, rawValue] of Object.entries(rootTokens || {})) {
868
+ if (!isDeclaredTextToken(name)) continue;
869
+ const value = normalizeColor(rawValue);
870
+ if (!value) continue;
871
+ const luminance = relativeLuminance(colorToRgb(value));
872
+ if (luminance >= bgLuminance) continue;
873
+ const contrast = contrastRatio(bgLuminance, luminance);
874
+ if (contrast < BODY_TEXT_MIN_CONTRAST) continue;
875
+ if (!best || luminance < best.luminance) best = { name, value, luminance, contrast: Math.round(contrast * 100) / 100 };
876
+ }
877
+ return best;
878
+ }
879
+
880
+ // The CTA label colour the source declares (#535), in this order: the colour
881
+ // every button-selector rule on the CTA background agrees on (the design's own
882
+ // pairing); a :root inverse / on-colour text token (--text-inverse first); the
883
+ // colour every other button-selector rule agrees on. Button rules that
884
+ // disagree declare nothing at their step. null when none applies, so the
885
+ // luminance pick stands.
886
+ function declaredCtaForegroundValue({ rootTokens = {}, ctaLabelRules = [], ctaBackground = null }) {
887
+ const background = normalizeColor(ctaBackground);
888
+ if (!background) return null;
889
+ const paired = [...new Set(ctaLabelRules.filter((rule) => rule.background === background).map((rule) => rule.color))];
890
+ if (paired.length === 1) return paired[0];
891
+ const labelNames = Object.keys(rootTokens || {}).filter((name) => (
892
+ !isTargetContractToken(name) && isTextLabelToken(tokenNameParts(name)) && normalizeColor(rootTokens[name])
893
+ ));
894
+ const labelName = labelNames.includes("--text-inverse") ? "--text-inverse" : labelNames[0];
895
+ if (labelName) return normalizeColor(rootTokens[labelName]);
896
+ const unpaired = [...new Set(ctaLabelRules.filter((rule) => !rule.background).map((rule) => rule.color))];
897
+ return unpaired.length === 1 ? unpaired[0] : null;
898
+ }
899
+
900
+ function mapTokens(tokens, targetTokens, { rootTokens = {}, ctaLabelRules = [] } = {}) {
622
901
  const allowedTargets = new Set(targetTokens.tokens || []);
623
902
  const mappings = [];
624
903
  const warnings = [];
@@ -632,9 +911,28 @@ function mapTokens(tokens, targetTokens) {
632
911
  mappings.push({ source, target, value, confidence, derivation });
633
912
  }
634
913
 
914
+ // Applies whether or not the source yields a --text-primary; a --text-primary
915
+ // of the same colour keeps its plain mapping.
916
+ const textPrimary = isNonEmptyString(tokens["--text-primary"]) ? tokens["--text-primary"].trim() : null;
917
+ const textPrimaryColor = normalizeColor(textPrimary);
918
+ const bodyText = darkestDeclaredBodyText(rootTokens, tokens["--surface-bg"]);
919
+ const replaceBodyText = Boolean(bodyText) && bodyText.value !== textPrimaryColor;
920
+
635
921
  for (const [source, targets] of Object.entries(sourceMappings)) {
636
- if (!isNonEmptyString(tokens[source])) continue;
637
- for (const target of targets) addMapping(source, target, tokens[source].trim());
922
+ const declaredBodyText = source === "--text-primary" && replaceBodyText;
923
+ if (!declaredBodyText && !isNonEmptyString(tokens[source])) continue;
924
+ for (const target of targets) {
925
+ if (declaredBodyText) {
926
+ addMapping(bodyText.name, target, bodyText.value, "high", {
927
+ method: "darkest-declared-text-token",
928
+ replaced_value: textPrimary,
929
+ background_value: normalizeColor(tokens["--surface-bg"]),
930
+ contrast: bodyText.contrast,
931
+ });
932
+ } else {
933
+ addMapping(source, target, tokens[source].trim());
934
+ }
935
+ }
638
936
  }
639
937
 
640
938
  const derived = targetTokens.derived_mappings?.["--brand-primary"] || {};
@@ -671,7 +969,9 @@ function mapTokens(tokens, targetTokens) {
671
969
 
672
970
  // Foreground/on-color tokens derive from the luminance of the background they
673
971
  // sit on (CTA, primary, accent), after all backgrounds are mapped above.
674
- const foreground = deriveForegroundMappings(mappings, targetTokens);
972
+ const ctaBackground = mappings.find((mapping) => mapping.target === CTA_BACKGROUND_TARGET)?.value;
973
+ const ctaForeground = declaredCtaForegroundValue({ rootTokens, ctaLabelRules, ctaBackground });
974
+ const foreground = deriveForegroundMappings(mappings, targetTokens, { ctaForeground });
675
975
  for (const mapping of foreground.mappings) {
676
976
  addMapping(mapping.source, mapping.target, mapping.value, mapping.confidence, mapping.derivation);
677
977
  }
@@ -891,7 +1191,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
891
1191
  warnings.push(issue("theme.source_tokens.unresolved", `Source token ${token} uses an unresolved value; it cannot be compared to producer defaults.`));
892
1192
  }
893
1193
 
894
- const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens) : { mappings: [], warnings: [] };
1194
+ const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens, { rootTokens: selected.root_tokens, ctaLabelRules: selected.cta_label_rules }) : { mappings: [], warnings: [] };
895
1195
  warnings.push(...mapped.warnings);
896
1196
  const confidence = confidenceFor({ selected, defaultMatch, mappings: mapped.mappings, conflicts });
897
1197
  const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence }) : null;
@@ -18,6 +18,18 @@ import { basename, join, relative, sep } from "node:path";
18
18
 
19
19
  const HTML_EXT = ".html";
20
20
 
21
+ // The route tokens inferPageType reads for the funnel roles, exported so a
22
+ // caller can tell which token produced a guess (#529: an explicit "upsell"
23
+ // word is a stronger signal than "oto"). Tested against the lower-cased,
24
+ // trimmed route.
25
+ export const ROUTE_TOKENS = Object.freeze({
26
+ downsell: /down[\s_/-]*sell/,
27
+ upsell: /up[\s_/-]*sell/,
28
+ one_time_offer: /(^|[\s_/-])oto([\s_/-]|\d|$)|one[\s_/-]*time[\s_/-]*offer/,
29
+ receipt: /thank|receipt|confirm(ation)?|order[\s_/-]*complete/,
30
+ checkout: /checkout|\bcart\b|\border\b/,
31
+ });
32
+
21
33
  // Funnel page-type inference from a built route or filename. Order matters:
22
34
  // downsell is tested before upsell, and the broad fallbacks (landing/page) run
23
35
  // last. Returns one of the page types QA understands; "page" for generic
@@ -25,10 +37,10 @@ const HTML_EXT = ".html";
25
37
  export function inferPageType(routeOrName) {
26
38
  const value = String(routeOrName || "").toLowerCase().trim();
27
39
  if (value === "" || value === "/" || value === "index") return "landing";
28
- if (/down[\s_/-]*sell/.test(value)) return "downsell";
29
- if (/up[\s_/-]*sell|(^|[\s_/-])oto([\s_/-]|\d|$)|one[\s_/-]*time[\s_/-]*offer/.test(value)) return "upsell";
30
- if (/thank|receipt|confirm(ation)?|order[\s_/-]*complete/.test(value)) return "receipt";
31
- if (/checkout|\bcart\b|\border\b/.test(value)) return "checkout";
40
+ if (ROUTE_TOKENS.downsell.test(value)) return "downsell";
41
+ if (ROUTE_TOKENS.upsell.test(value) || ROUTE_TOKENS.one_time_offer.test(value)) return "upsell";
42
+ if (ROUTE_TOKENS.receipt.test(value)) return "receipt";
43
+ if (ROUTE_TOKENS.checkout.test(value)) return "checkout";
32
44
  // The two-step bundle-selection step. Deliberately narrow, and anchored on
33
45
  // BOTH ends: the route must *be* about choosing a bundle, not merely contain
34
46
  // the words. An editorial "/our-choose-bundle-guide/" stays generic rather