@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -7,6 +7,7 @@ import {
7
7
  mkdirSync,
8
8
  readdirSync,
9
9
  readFileSync,
10
+ realpathSync,
10
11
  renameSync,
11
12
  rmSync,
12
13
  statSync,
@@ -14,6 +15,7 @@ import {
14
15
  } from "node:fs";
15
16
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
16
17
  import { fileURLToPath } from "node:url";
18
+ import { packetRuntimeRelPath } from "./campaign-workspace.mjs";
17
19
 
18
20
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
21
  const SOURCE_DEFAULTS_PATH = "contracts/brand-theme-source-defaults.figma-sections-export.v0.json";
@@ -521,6 +523,357 @@ function collectCtaLabelRules(content, rootTokens = {}) {
521
523
  return rules;
522
524
  }
523
525
 
526
+ // --- Typography -----------------------------------------------------------
527
+ // The design source's self-hosted brand faces, and which of them its
528
+ // headings, body copy and buttons use. next-core takes font family and button
529
+ // radius as custom properties (contracts/brand-theme-target-tokens.next-core.v0.json,
530
+ // `typography`), so commerce pages carry the design's type through the same
531
+ // token layer as its colours; the @font-face rules ship beside brand-theme.css
532
+ // in brand-fonts.css. A family is mapped only when the source ships a face for
533
+ // it: naming a font the page cannot load would only change the fallback.
534
+
535
+ const GENERIC_FONT_FAMILIES = new Set([
536
+ "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif",
537
+ "ui-monospace", "ui-rounded", "math", "emoji", "inherit", "initial", "unset", "revert", "-apple-system",
538
+ "blinkmacsystemfont",
539
+ ]);
540
+ const FALLBACK_GENERICS = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy"]);
541
+ const HEADING_ELEMENTS = new Set(["h1", "h2", "h3"]);
542
+ const HEADING_NAME_PARTS = new Set(["heading", "headline", "title"]);
543
+ const BODY_ELEMENTS = new Set(["html", "body", "p"]);
544
+ const FONT_FACE_DESCRIPTORS = new Set(["font-family", "font-style", "font-weight", "font-display", "font-stretch", "unicode-range", "src"]);
545
+ // brand-fonts.css is placed in the campaign's assets/css/ beside
546
+ // brand-theme.css, and build copies the source's assets/ tree into the
547
+ // campaign's, so face URLs are written relative to the source's assets/css/.
548
+ const FONTS_CSS_SOURCE_DIR = "assets/css";
549
+ export const BRAND_FONTS_FILENAME = "brand-fonts.css";
550
+
551
+ // A family name as read from source CSS, unquoted and stripped of anything
552
+ // that could not appear in a valid quoted family (control characters, braces,
553
+ // semicolons, at-signs, quotes and backslashes), so generated values stay
554
+ // valid by construction.
555
+ function unquoteFamily(name) {
556
+ return String(name || "").trim().replace(/^(["'])(.*)\1$/, "$2")
557
+ .replace(/[\u0000-\u001f\u007f{};@"'\\]/g, "").replace(/\s+/g, " ").trim();
558
+ }
559
+
560
+ // A value with its var() references resolved through the source's :root
561
+ // custom properties (Shopify themes route type through --font-body-family and
562
+ // friends). An unresolved var() takes its fallback, else stays unresolved.
563
+ function resolveCssVars(value, tokens = {}, depth = 0) {
564
+ const text = String(value || "");
565
+ if (depth > 5 || !/var\(/i.test(text)) return text;
566
+ const replaced = text.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (match, name, fallback) => {
567
+ if (isNonEmptyString(tokens[name])) return tokens[name];
568
+ return fallback !== undefined ? fallback : match;
569
+ });
570
+ return replaced === text ? text : resolveCssVars(replaced, tokens, depth + 1);
571
+ }
572
+
573
+ // The first named family in a font-family value and the generic it falls
574
+ // back to, if it names one. null for unresolved var() and generic-only stacks.
575
+ function fontStack(value, tokens = {}) {
576
+ const cleaned = resolveCssVars(value, tokens).replace(/!important/gi, "").trim();
577
+ if (!cleaned || /var\(/i.test(cleaned)) return null;
578
+ const names = cleaned.split(",").map(unquoteFamily).filter(Boolean);
579
+ const family = names.find((name) => !GENERIC_FONT_FAMILIES.has(name.toLowerCase()));
580
+ if (!family) return null;
581
+ const generic = names.map((name) => name.toLowerCase()).find((name) => FALLBACK_GENERICS.has(name)) || null;
582
+ return { family, generic };
583
+ }
584
+
585
+ export function extractFontFaces(content) {
586
+ const faces = [];
587
+ for (const match of stripCssComments(content).matchAll(/@font-face\s*\{([^{}]*)\}/gi)) {
588
+ const face = { family: null, descriptors: [], srcs: [] };
589
+ for (const [rawName, rawValue] of splitDeclarations(match[1])) {
590
+ const name = rawName.trim().toLowerCase();
591
+ const value = rawValue.trim();
592
+ if (!FONT_FACE_DESCRIPTORS.has(name) || !value) continue;
593
+ if (name === "font-family") face.family = unquoteFamily(value);
594
+ else if (name === "src") {
595
+ face.srcs = [...value.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)(?:\s*format\(\s*(["']?)([^"')]+)\3\s*\))?/gi)]
596
+ .map((src) => ({ url: src[2].trim(), format: src[4] ? src[4].trim() : null }));
597
+ } else face.descriptors.push([name, value]);
598
+ }
599
+ if (face.family && face.srcs.length) faces.push(face);
600
+ }
601
+ return faces;
602
+ }
603
+
604
+ // The class a stylesheet scopes itself under, such as the importer's
605
+ // `.cc-import`: the leading class of more than half the rules. A rule on that
606
+ // class alone sets the page's base type, as `body` would.
607
+ function scopeRootClass(content) {
608
+ const leading = new Map();
609
+ let rules = 0;
610
+ for (const match of stripCssComments(content).matchAll(/([^{};]+)\{[^{}]+\}/g)) {
611
+ const selector = match[1].trim();
612
+ if (!selector || selector.startsWith("@")) continue;
613
+ rules += 1;
614
+ const first = selector.match(/^\.([A-Za-z_][\w-]*)(?=[\s>+~,]|$)/);
615
+ if (first) leading.set(first[1], (leading.get(first[1]) || 0) + 1);
616
+ }
617
+ const top = topEntry(leading);
618
+ return top && rules >= 10 && top[1] > rules / 2 ? top[0] : null;
619
+ }
620
+
621
+ // Custom properties a stylesheet declares on its page root (html, body or its
622
+ // scope root class), where Shopify themes and the importer keep their type and
623
+ // radius variables, rather than on :root.
624
+ function pageRootCustomProperties(content) {
625
+ const scopeRoot = scopeRootClass(content);
626
+ const roots = new Set(["html", "body", ...(scopeRoot ? [`.${scopeRoot}`] : [])]);
627
+ const tokens = {};
628
+ for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
629
+ if (!match[1].split(",").some((part) => roots.has(part.trim()))) continue;
630
+ for (const [rawName, rawValue] of splitDeclarations(match[2])) {
631
+ const name = rawName.trim();
632
+ if (name.startsWith("--") && rawValue.trim()) tokens[name] = rawValue.trim();
633
+ }
634
+ }
635
+ return tokens;
636
+ }
637
+
638
+ // One compound selector that names a button: the element, a submit input, or
639
+ // a class starting btn/button/cta or with a cta part. Unlike isButtonSelector,
640
+ // a list qualifies when any of its selectors does, since type and radius rules
641
+ // group a button with siblings (`.button, .shopify-challenge__button`).
642
+ function buttonCompoundWeight(compound) {
643
+ if (compound.pseudos.length) return 0;
644
+ if (compound.type === "button") return compound.classes.length ? 1 : 3;
645
+ if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return 1;
646
+ if (compound.classes.some((name) => /^(?:btn|button)$/i.test(name))) return 3;
647
+ return compound.classes.some((name) => /^(?:btn|button|cta)/i.test(name) || name.toLowerCase().split(/[-_]+/).includes("cta")) ? 1 : 0;
648
+ }
649
+
650
+ // A border-radius value next-core's --radius-size--buttons can take: 0, or one
651
+ // to four lengths or percentages. Anything else (var() left unresolved,
652
+ // slash syntax, calc()) is reported, not mapped.
653
+ const RADIUS_VALUE = /^(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))(?:\s+(?:0|\d+(?:\.\d+)?(?:px|rem|em|%))){0,3}$/;
654
+
655
+ function newTypographyUsage() {
656
+ return { heading: new Map(), body: new Map(), button: new Map(), generics: new Map(), button_radius: new Map(), radius_rejected: new Set() };
657
+ }
658
+
659
+ // Rule-level font usage by role: { heading, body, button } as Maps of family
660
+ // to weighted rule count, generics as family -> generic, and button radii.
661
+ // An element selector (h1, p, button) weighs 3 against a class's 1, so a
662
+ // `.card-title` cannot outvote the h1 rule.
663
+ function collectTypographyRules(content, usage = null, tokens = {}) {
664
+ const out = usage || newTypographyUsage();
665
+ const bump = (map, key, by = 1) => map.set(key, (map.get(key) || 0) + by);
666
+ const scopeRoot = scopeRootClass(content);
667
+ for (const match of stripCssComments(content).matchAll(/([^{};]+)\{([^{}]+)\}/g)) {
668
+ const selector = match[1].trim();
669
+ if (!selector || selector.startsWith("@")) continue;
670
+ let stack = null;
671
+ let radius = null;
672
+ for (const [rawName, rawValue] of splitDeclarations(match[2])) {
673
+ const name = rawName.trim().toLowerCase();
674
+ if (name === "font-family") stack = fontStack(rawValue, tokens) || stack;
675
+ if (name === "border-radius") radius = resolveCssVars(rawValue, tokens).replace(/!important/gi, "").trim();
676
+ }
677
+ if (!stack && !radius) continue;
678
+ const compounds = rightmostCompounds(selector) || [];
679
+ const parts = (compound) => compound.classes.flatMap((name) => name.toLowerCase().split(/[-_]+/));
680
+ const headingWeight = compounds.some((compound) => HEADING_ELEMENTS.has(compound.type)) ? 3
681
+ : compounds.some((compound) => parts(compound).some((part) => HEADING_NAME_PARTS.has(part))) ? 1 : 0;
682
+ const isScopeRoot = Boolean(scopeRoot) && selector.split(",").some((part) => part.trim() === `.${scopeRoot}`);
683
+ const bodyWeight = isScopeRoot || compounds.some((compound) => BODY_ELEMENTS.has(compound.type) && !compound.classes.length && !compound.pseudos.length) ? 3 : 0;
684
+ const buttonWeight = Math.max(0, ...compounds.map(buttonCompoundWeight));
685
+ if (stack) {
686
+ if (stack.generic && !out.generics.has(stack.family.toLowerCase())) out.generics.set(stack.family.toLowerCase(), stack.generic);
687
+ if (headingWeight) bump(out.heading, stack.family, headingWeight);
688
+ if (bodyWeight) bump(out.body, stack.family, bodyWeight);
689
+ if (buttonWeight) bump(out.button, stack.family, buttonWeight);
690
+ }
691
+ if (radius && buttonWeight) {
692
+ if (RADIUS_VALUE.test(radius)) bump(out.button_radius, radius, buttonWeight);
693
+ else out.radius_rejected.add(radius);
694
+ }
695
+ }
696
+ return out;
697
+ }
698
+
699
+ function topEntry(map, accept = () => true) {
700
+ return [...map.entries()].filter(([key]) => accept(key)).sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))[0] || null;
701
+ }
702
+
703
+ // A face's src URLs rewritten for brand-fonts.css in the campaign's
704
+ // assets/css/. A remote, data: or root-relative URL, or a file outside the
705
+ // source root or missing from it, is dropped with a reason.
706
+ function relocateFaceSrcs(face, cssPath, sourceRoot) {
707
+ const kept = [];
708
+ const dropped = [];
709
+ const fontsCssDir = resolve(sourceRoot, FONTS_CSS_SOURCE_DIR);
710
+ for (const src of face.srcs) {
711
+ const bare = src.url.split(/[?#]/)[0];
712
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(bare)) {
713
+ dropped.push({ url: src.url, reason: "not a file the source ships (remote, data: or root-relative URL)" });
714
+ continue;
715
+ }
716
+ const file = resolve(dirname(cssPath), bare);
717
+ if (!existsSync(file)) {
718
+ dropped.push({ url: src.url, reason: "file missing from the source" });
719
+ continue;
720
+ }
721
+ // Containment is checked on real paths, so a symlink (or a symlinked
722
+ // directory) leading out of the source root is dropped, not shipped.
723
+ const inside = relative(realpathSync(sourceRoot), realpathSync(file));
724
+ if (!inside || inside.startsWith("..") || isAbsolute(inside)) {
725
+ dropped.push({ url: src.url, reason: "outside the source root" });
726
+ continue;
727
+ }
728
+ kept.push({ url: relative(fontsCssDir, file).split("\\").join("/"), format: src.format, source_file: inside.split("\\").join("/") });
729
+ }
730
+ return { kept, dropped };
731
+ }
732
+
733
+ // The typography the design source declares, from every CSS file the theme
734
+ // inspection read: the families its headings, body copy and buttons use that
735
+ // it also ships faces for, the button radius, and the faces to ship.
736
+ function inferTypography(candidates, sourceRoot) {
737
+ const usage = newTypographyUsage();
738
+ const faces = [];
739
+ const seenFiles = new Set();
740
+ const tokens = Object.assign({}, ...candidates.map((candidate) => candidate.root_tokens || {}));
741
+ for (const candidate of candidates) {
742
+ if (candidate.inline_block_index !== undefined || seenFiles.has(resolve(candidate.path))) continue;
743
+ seenFiles.add(resolve(candidate.path));
744
+ const content = readFileSync(candidate.path, "utf8");
745
+ collectTypographyRules(content, usage, { ...tokens, ...pageRootCustomProperties(content) });
746
+ for (const face of extractFontFaces(content)) faces.push({ ...face, css_path: candidate.path });
747
+ }
748
+ const warnings = [];
749
+ const shippable = new Map();
750
+ const droppedSrcs = [];
751
+ for (const face of faces) {
752
+ const { kept, dropped } = relocateFaceSrcs(face, face.css_path, sourceRoot);
753
+ droppedSrcs.push(...dropped.map((drop) => ({ ...drop, family: face.family })));
754
+ if (!kept.length) continue;
755
+ const descriptors = Object.fromEntries(face.descriptors);
756
+ const key = [face.family.toLowerCase(), descriptors["font-weight"] || "400", descriptors["font-style"] || "normal", descriptors["font-stretch"] || "normal", descriptors["unicode-range"] || ""].join("|");
757
+ const existing = shippable.get(key);
758
+ if (!existing) shippable.set(key, { family: face.family, descriptors: face.descriptors, srcs: kept, css_path: face.css_path });
759
+ else {
760
+ // The same face declared again (often one rule per format): keep every
761
+ // file, in source order, so browsers can pick the format they support.
762
+ for (const src of kept) if (!existing.srcs.some((other) => other.url === src.url)) existing.srcs.push(src);
763
+ }
764
+ }
765
+ const faceFamilies = new Set([...shippable.values()].map((face) => face.family.toLowerCase()));
766
+ const hasFace = (family) => faceFamilies.has(String(family).toLowerCase());
767
+ const pick = (role) => {
768
+ const entry = topEntry(usage[role], hasFace);
769
+ return entry ? { family: entry[0], rule_weight: entry[1] } : null;
770
+ };
771
+ const heading = pick("heading");
772
+ const button = pick("button");
773
+ const body = pick("body") || (button && (!heading || button.family !== heading.family) ? { ...button, inferred_from: "button" } : null);
774
+ const unavailable = [];
775
+ for (const role of ["heading", "body", "button"]) {
776
+ const used = topEntry(usage[role]);
777
+ if (used && !hasFace(used[0])) {
778
+ unavailable.push({ role, family: used[0] });
779
+ warnings.push(issue("theme.typography.face_unavailable", `The source's ${role} font "${used[0]}" has no @font-face the campaign can ship, so it is not mapped.`, { role, family: used[0] }));
780
+ }
781
+ }
782
+ const radius = topEntry(usage.button_radius);
783
+ const used = new Set([heading, body, button].filter(Boolean).map((entry) => entry.family.toLowerCase()));
784
+ const shipped = [...shippable.values()].filter((face) => used.has(face.family.toLowerCase()));
785
+ const usedAnywhere = new Set(["heading", "body", "button"].flatMap((role) => [...usage[role].keys()].map((family) => family.toLowerCase())));
786
+ for (const drop of droppedSrcs.filter((entry) => usedAnywhere.has(entry.family.toLowerCase()))) {
787
+ warnings.push(issue("theme.typography.face_src_dropped", `A @font-face src for "${drop.family}" was left out of ${BRAND_FONTS_FILENAME}: ${drop.reason}.`, { family: drop.family, url: drop.url }));
788
+ }
789
+ return {
790
+ heading,
791
+ body,
792
+ button,
793
+ button_radius: radius ? { value: radius[0], rule_count: radius[1] } : null,
794
+ generics: usage.generics,
795
+ faces: shipped,
796
+ face_css_paths: [...new Set(shipped.map((face) => face.css_path))],
797
+ unavailable,
798
+ radius_rejected: [...usage.radius_rejected],
799
+ warnings,
800
+ };
801
+ }
802
+
803
+ // The family followed directly by the generic the source declared for it, so
804
+ // a face that fails to load degrades the way the design asked.
805
+ function familyValue(family, generics) {
806
+ return `"${unquoteFamily(family)}", ${generics.get(String(family).toLowerCase()) || "sans-serif"}`;
807
+ }
808
+
809
+ // Typography mappings onto the contract's typography targets. Font families
810
+ // are mapped only with a shippable face; the button radius only alongside
811
+ // them, so a source with no brand faces maps exactly what it did before.
812
+ function mapTypography(typography, targetTokens) {
813
+ const targets = targetTokens.typography?.targets || {};
814
+ const mappings = [];
815
+ if (!typography.faces.length) return mappings;
816
+ const add = (role, target, value, derivation) => {
817
+ if (target && value) mappings.push({ source: `typography.${role}`, target, value, confidence: "high", derivation });
818
+ };
819
+ if (typography.heading) {
820
+ add("heading", targets.heading_family, familyValue(typography.heading.family, typography.generics), { method: "design-source-font-face", family: typography.heading.family, rule_weight: typography.heading.rule_weight });
821
+ }
822
+ if (typography.body) {
823
+ add("body", targets.body_family, familyValue(typography.body.family, typography.generics), { method: typography.body.inferred_from ? "design-source-font-face-from-button" : "design-source-font-face", family: typography.body.family, rule_weight: typography.body.rule_weight });
824
+ }
825
+ if (typography.button_radius) {
826
+ add("button_radius", targets.button_radius, typography.button_radius.value, { method: "design-source-button-radius", rule_count: typography.button_radius.rule_count });
827
+ }
828
+ return mappings;
829
+ }
830
+
831
+ function renderBrandFontsCss({ sourcePaths, faces }) {
832
+ const lines = [
833
+ "/*",
834
+ " * Generated by Campaigns OS brand-theme v0.",
835
+ ` * Source: ${sourcePaths.join(", ") || "unknown source"}`,
836
+ ` * The design's brand faces for commerce pages. Load beside ${"brand-theme.css"}; URLs are relative to assets/css/.`,
837
+ " */",
838
+ ];
839
+ for (const face of faces) {
840
+ const descriptors = new Map(face.descriptors);
841
+ // Shipping policy, not the source's: a block period on a large display
842
+ // face can hide commerce headings on a slow connection.
843
+ descriptors.set("font-display", "swap");
844
+ lines.push("@font-face {");
845
+ lines.push(` font-family: "${unquoteFamily(face.family)}";`);
846
+ for (const [name, value] of [...descriptors.entries()].sort((a, b) => a[0].localeCompare(b[0]))) lines.push(` ${name}: ${value};`);
847
+ lines.push(` src: ${face.srcs.map((src) => `url("${src.url}")${src.format ? ` format("${src.format}")` : ""}`).join(", ")};`);
848
+ lines.push("}");
849
+ }
850
+ lines.push("");
851
+ return lines.join("\n");
852
+ }
853
+
854
+ export function validateGeneratedFontsCss(css) {
855
+ const errors = [];
856
+ if (!isNonEmptyString(css)) return { ok: false, errors: [issue("theme.fonts_css.empty", "Generated brand-fonts.css is empty.")], warnings: [] };
857
+ if (/<\/?script\b|javascript:/i.test(css)) errors.push(issue("theme.fonts_css.script", "Generated brand-fonts.css contains script-like content."));
858
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
859
+ const blocks = [];
860
+ const residual = withoutComments.replace(/@font-face\s*\{([^{}]*)\}/g, (_match, body) => {
861
+ blocks.push(body);
862
+ return "";
863
+ }).trim();
864
+ if (residual) errors.push(issue("theme.fonts_css.selector", "Generated brand-fonts.css must contain only @font-face rules.", { residual }));
865
+ for (const body of blocks) {
866
+ for (const [rawName, rawValue] of splitDeclarations(body)) {
867
+ const name = rawName.trim().toLowerCase();
868
+ if (!FONT_FACE_DESCRIPTORS.has(name)) errors.push(issue("theme.fonts_css.descriptor", `Generated @font-face declares ${name}, which is not a font descriptor.`));
869
+ for (const url of String(rawValue).matchAll(/url\(\s*["']?([^"')]+)/gi)) {
870
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(url[1].trim())) errors.push(issue("theme.fonts_css.remote_url", "Generated brand-fonts.css must reference the campaign's own font files by relative URL.", { url: url[1] }));
871
+ }
872
+ }
873
+ }
874
+ return { ok: errors.length === 0, errors, warnings: [] };
875
+ }
876
+
524
877
  function extractDeclarationColor(value) {
525
878
  const cleaned = String(value || "").replace(/!important/gi, "").trim();
526
879
  const exact = normalizeColor(cleaned);
@@ -1063,7 +1416,7 @@ function statusFor({ policy, selected, confidence, warnings, errors }) {
1063
1416
  return "ready";
1064
1417
  }
1065
1418
 
1066
- function renderBrandThemeCss({ selected, mappings, confidence }) {
1419
+ function renderBrandThemeCss({ selected, mappings, confidence, bodyScoped = new Set() }) {
1067
1420
  const unique = [];
1068
1421
  const seen = new Set();
1069
1422
  for (const mapping of mappings) {
@@ -1081,10 +1434,20 @@ function renderBrandThemeCss({ selected, mappings, confidence }) {
1081
1434
  " */",
1082
1435
  ":root {",
1083
1436
  ];
1084
- for (const mapping of unique.sort((a, b) => a.target.localeCompare(b.target))) {
1437
+ const sorted = unique.sort((a, b) => a.target.localeCompare(b.target));
1438
+ for (const mapping of sorted.filter((entry) => !bodyScoped.has(entry.target))) {
1085
1439
  lines.push(` ${mapping.target}: ${mapping.value};`);
1086
1440
  }
1087
1441
  lines.push("}");
1442
+ const scoped = sorted.filter((entry) => bodyScoped.has(entry.target));
1443
+ if (scoped.length) {
1444
+ lines.push("");
1445
+ lines.push("/* next-core re-declares these on body at its 991/767/479px breakpoints; body keeps them at every width. */");
1446
+ lines.push(":root,");
1447
+ lines.push("body {");
1448
+ for (const mapping of scoped) lines.push(` ${mapping.target}: ${mapping.value};`);
1449
+ lines.push("}");
1450
+ }
1088
1451
  lines.push("");
1089
1452
  return lines.join("\n");
1090
1453
  }
@@ -1105,12 +1468,12 @@ export function validateGeneratedCss(css) {
1105
1468
 
1106
1469
  const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "").trim();
1107
1470
  const rootBlocks = [];
1108
- const residual = withoutComments.replace(/:root\s*\{([\s\S]*?)\}/g, (_match, body) => {
1471
+ const residual = withoutComments.replace(/:root(?:\s*,\s*body)?\s*\{([\s\S]*?)\}/g, (_match, body) => {
1109
1472
  rootBlocks.push(body);
1110
1473
  return "";
1111
1474
  }).trim();
1112
1475
  if (residual) {
1113
- errors.push(issue("theme.css.selector", "Generated CSS must contain only :root blocks with custom properties.", { residual }));
1476
+ errors.push(issue("theme.css.selector", "Generated CSS must contain only :root (or :root, body) blocks with custom properties.", { residual }));
1114
1477
  }
1115
1478
  if (rootBlocks.length === 0) {
1116
1479
  errors.push(issue("theme.css.root", "Generated CSS must include a :root block."));
@@ -1195,8 +1558,9 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
1195
1558
  const contracts = loadContracts(repoRoot);
1196
1559
  const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
1197
1560
  const targetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo) || dirname(resolve(packetPath));
1198
- const themeOutDir = outDir ? resolve(outDir) : resolve(targetRepo, ".campaign-runtime/theme");
1561
+ const themeOutDir = outDir ? resolve(outDir) : resolve(targetRepo, packetRuntimeRelPath(packet), "theme");
1199
1562
  const cssPath = resolve(themeOutDir, "brand-theme.css");
1563
+ const fontsCssPath = resolve(themeOutDir, BRAND_FONTS_FILENAME);
1200
1564
  const reportPath = resolve(themeOutDir, "theme-report.json");
1201
1565
 
1202
1566
  if (requestedPolicy === "off") {
@@ -1253,8 +1617,18 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
1253
1617
 
1254
1618
  const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens, { rootTokens: selected.root_tokens, ctaLabelRules: selected.cta_label_rules }) : { mappings: [], warnings: [] };
1255
1619
  warnings.push(...mapped.warnings);
1620
+ const typography = selected ? inferTypography(candidates, sourceRoot) : null;
1621
+ if (typography) {
1622
+ mapped.mappings.push(...mapTypography(typography, contracts.targetTokens));
1623
+ warnings.push(...typography.warnings);
1624
+ }
1625
+ const fontsCss = typography?.faces.length && mapped.mappings.some((mapping) => mapping.source.startsWith("typography."))
1626
+ ? renderBrandFontsCss({ sourcePaths: typography.face_css_paths.map((path) => relativeArtifactPath(targetRepo, path)), faces: typography.faces })
1627
+ : null;
1628
+ const fontsSafety = fontsCss ? validateGeneratedFontsCss(fontsCss) : null;
1629
+ if (fontsSafety && !fontsSafety.ok) errors.push(...fontsSafety.errors);
1256
1630
  const confidence = confidenceFor({ selected, defaultMatch, mappings: mapped.mappings, conflicts });
1257
- const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence }) : null;
1631
+ const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence, bodyScoped: new Set(contracts.targetTokens.typography?.body_scoped_tokens || []) }) : null;
1258
1632
  const safety = css ? validateGeneratedCss(css) : { ok: false, errors: [issue("theme.css.empty", "No generated CSS because no source tokens were selected.")], warnings: [] };
1259
1633
  if (css && !safety.ok) errors.push(...safety.errors);
1260
1634
 
@@ -1270,6 +1644,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
1270
1644
  const selectedSource = selected ? sourceFileEntry(selected, targetRepo) : null;
1271
1645
  const generated = {
1272
1646
  css_path: relativeArtifactPath(targetRepo, cssPath),
1647
+ ...(fontsCss ? { fonts_css_path: relativeArtifactPath(targetRepo, fontsCssPath) } : {}),
1273
1648
  report_path: relativeArtifactPath(targetRepo, reportPath),
1274
1649
  safety,
1275
1650
  stale,
@@ -1306,6 +1681,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
1306
1681
  producer_defaults: defaultMatch,
1307
1682
  conflicts,
1308
1683
  mappings: mapped.mappings,
1684
+ ...(fontsCss || typography?.unavailable.length || typography?.radius_rejected.length ? { typography: typographyReportBlock(typography, targetRepo) } : {}),
1309
1685
  generated,
1310
1686
  existing_brand_layers: findExistingBrandLayers(targetRepo, cssPath),
1311
1687
  warnings,
@@ -1313,7 +1689,24 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
1313
1689
  next_action: nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy: requestedPolicy }),
1314
1690
  };
1315
1691
 
1316
- return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings });
1692
+ return buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings, fontsCss, fontsCssPath });
1693
+ }
1694
+
1695
+ function typographyReportBlock(typography, targetRepo) {
1696
+ return {
1697
+ heading: typography.heading,
1698
+ body: typography.body,
1699
+ button: typography.button,
1700
+ button_radius: typography.button_radius,
1701
+ unavailable: typography.unavailable,
1702
+ radius_rejected: typography.radius_rejected,
1703
+ faces: typography.faces.map((face) => ({
1704
+ family: face.family,
1705
+ weight: Object.fromEntries(face.descriptors)["font-weight"] || "400",
1706
+ style: Object.fromEntries(face.descriptors)["font-style"] || "normal",
1707
+ files: face.srcs.map((src) => src.source_file),
1708
+ })),
1709
+ };
1317
1710
  }
1318
1711
 
1319
1712
  function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate, stale, policy }) {
@@ -1327,7 +1720,7 @@ function nextActionForTheme({ status, confidence, canGenerate, canAutoGenerate,
1327
1720
  return "Review theme-report.json and decide whether to skip or collect stronger brand inputs.";
1328
1721
  }
1329
1722
 
1330
- function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings }) {
1723
+ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, reportPath, contextTheme, report, css, errors, warnings, fontsCss = null, fontsCssPath = null }) {
1331
1724
  return {
1332
1725
  ok: errors.length === 0,
1333
1726
  status: contextTheme.status,
@@ -1343,12 +1736,14 @@ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, repo
1343
1736
  },
1344
1737
  absolute_paths: {
1345
1738
  css_path: cssPath,
1739
+ ...(fontsCss ? { fonts_css_path: fontsCssPath } : {}),
1346
1740
  report_path: reportPath,
1347
1741
  out_dir: dirname(cssPath),
1348
1742
  },
1349
1743
  context_theme: contextTheme,
1350
1744
  report,
1351
1745
  css,
1746
+ fonts_css: fontsCss,
1352
1747
  errors,
1353
1748
  warnings,
1354
1749
  ready: contextTheme.status === "ready" ? [`Brand theme artifact can be generated with confidence ${contextTheme.confidence}.`] : [],
@@ -1363,7 +1758,7 @@ function buildInspectionResponse({ packet, targetRepo, sourceRoot, cssPath, repo
1363
1758
 
1364
1759
  export function writeThemeArtifacts(inspection, { writeCss = false, writeReport = true, force = false, packetPath = "<campaign-runtime.build.json>" } = {}) {
1365
1760
  const errors = [...(inspection.errors || [])];
1366
- const wrote = { report: false, css: false };
1761
+ const wrote = { report: false, css: false, ...(inspection.fonts_css ? { fonts_css: false } : {}) };
1367
1762
  const alreadyCurrent = { css: false };
1368
1763
  const reportPath = resolvePathFromInspection(inspection, "report_path");
1369
1764
  const cssPath = resolvePathFromInspection(inspection, "css_path");
@@ -1394,6 +1789,17 @@ export function writeThemeArtifacts(inspection, { writeCss = false, writeReport
1394
1789
  replaceFileAtomically(cssPath, inspection.css);
1395
1790
  wrote.css = true;
1396
1791
  }
1792
+ if (!errors.length && inspection.fonts_css) {
1793
+ const fontsPath = resolvePathFromInspection(inspection, "fonts_css_path");
1794
+ const current = existsSync(fontsPath) ? readFileSync(fontsPath, "utf8") : null;
1795
+ if (current !== null && current !== inspection.fonts_css && !force) {
1796
+ const safeCommand = `campaigns-os theme generate --packet ${shellToken(packetPath)} --force`;
1797
+ errors.push(issue("theme.generate.exists", `${BRAND_FONTS_FILENAME} already exists and differs from the current generated output; rerun \`${safeCommand}\` or pass a new --out-dir to overwrite safely.`, { safe_commands: [safeCommand] }));
1798
+ } else if (current !== inspection.fonts_css) {
1799
+ replaceFileAtomically(fontsPath, inspection.fonts_css);
1800
+ wrote.fonts_css = true;
1801
+ }
1802
+ }
1397
1803
  }
1398
1804
 
1399
1805
  return {
@@ -43,6 +43,7 @@ const STRENGTH = Object.freeze([NOT_RECORDED, "default", "source", "stated"]);
43
43
  // design_authority.<page>.source values the generator writes; any other
44
44
  // non-null value is shown verbatim in its own group.
45
45
  const SUPPLIED_DESIGN = "provided_design_export";
46
+ const DESIGN_FILE = "design_file";
46
47
  const TEMPLATE = "template";
47
48
 
48
49
  const VALUE_WORDS = 12;
@@ -124,6 +125,7 @@ function statedOnly({ value, provenance }) {
124
125
  function authorityGroups(brief, pageIds) {
125
126
  const authority = own(brief, "design_authority");
126
127
  const supplied = { kind: "supplied", source: SUPPLIED_DESIGN, pages: [], provenances: [] };
128
+ const designFile = { kind: "design_file", source: DESIGN_FILE, pages: [], provenances: [] };
127
129
  const template = { kind: "template", source: TEMPLATE, pages: [], provenances: [] };
128
130
  const others = new Map();
129
131
  const unstated = { kind: "not_stated", source: null, pages: [], provenances: [] };
@@ -132,7 +134,8 @@ function authorityGroups(brief, pageIds) {
132
134
  const source = notStated(recorded) ? null : recorded;
133
135
  const provenance = pageId == null ? null : provenanceOf(brief, pageSourceField(pageId), source);
134
136
  let group = unstated;
135
- if (source === SUPPLIED_DESIGN) group = supplied;
137
+ if (source === DESIGN_FILE) group = designFile;
138
+ else if (source === SUPPLIED_DESIGN) group = supplied;
136
139
  else if (source === TEMPLATE) group = template;
137
140
  else if (source != null) {
138
141
  const key = canonicalJson(source);
@@ -142,7 +145,7 @@ function authorityGroups(brief, pageIds) {
142
145
  group.pages.push(pageId);
143
146
  group.provenances.push(provenance);
144
147
  }
145
- return [supplied, template, ...others.values(), unstated]
148
+ return [designFile, supplied, template, ...others.values(), unstated]
146
149
  .filter((group) => group.pages.length)
147
150
  .map(({ provenances, ...group }) => ({ ...group, provenance: weakest(provenances) }));
148
151
  }
@@ -178,6 +181,7 @@ function renderLines(model, cuts, valueWords) {
178
181
  const value = (item, fallback) => (item.value == null ? fallback : marked(item.value, item.provenance, valueWords, cut));
179
182
  const group = (entry) => {
180
183
  const ids = listItems(entry.pages, cuts[4], pageIdText, cut).join(", ");
184
+ if (entry.kind === "design_file") return `${ids} follow the design file, built from its HTML export ${MARKERS[entry.provenance]}`;
181
185
  if (entry.kind === "supplied") return `${ids} follow the supplied design ${MARKERS[entry.provenance]}`;
182
186
  if (entry.kind === "template") return `${ids} follow the template ${MARKERS[entry.provenance]}`;
183
187
  if (entry.kind === "other") return `${ids} use ${marked(entry.source, entry.provenance, VALUE_WORDS, cut)}`;
@@ -56,6 +56,7 @@ export async function launchPackageChromium({
56
56
  chromium: injectedChromium,
57
57
  onMissing,
58
58
  signal,
59
+ launchOptions = {},
59
60
  } = {}) {
60
61
  if (typeof onMissing !== "function") {
61
62
  throw new Error("Campaigns OS browser launch requires an onMissing(kind, error) handler.");
@@ -71,7 +72,7 @@ export async function launchPackageChromium({
71
72
  }
72
73
  if (signal?.aborted) throw signal.reason ?? new Error("Campaigns OS browser launch was abandoned before launch.");
73
74
  try {
74
- return await chromium.launch({ headless: headed !== true });
75
+ return await chromium.launch({ ...launchOptions, headless: headed !== true });
75
76
  } catch (error) {
76
77
  if (isMissingBrowserError(error)) throw onMissing(MISSING_BROWSER_KINDS.BROWSER, error);
77
78
  throw error;
@@ -219,6 +219,7 @@ export function createCampaignBuildBriefArtifact({
219
219
  spec = null,
220
220
  activePages = [],
221
221
  pageMappings = [],
222
+ designFiles = [],
222
223
  templateFamily = null,
223
224
  sourceAssetCrawl = null,
224
225
  commerceZoneFindings = [],
@@ -230,7 +231,7 @@ export function createCampaignBuildBriefArtifact({
230
231
  const { mode, source: modeSource } = briefModeFor(loaded, boundReportMode, errors);
231
232
  const baseBrief = loaded
232
233
  ? cloneJsonOrEmpty(loaded.value)
233
- : draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl });
234
+ : draftCampaignBuildBrief({ spec, activePages, pageMappings, designFiles, templateFamily, sourceAssetCrawl });
234
235
 
235
236
  const normalized = normalizeCampaignBuildBrief(baseBrief, {
236
237
  mode,
@@ -525,11 +526,17 @@ export function briefMaterialFingerprint(normalized) {
525
526
  return { presentation: valueFingerprint(projection.presentation), qa_policy: valueFingerprint(projection.qa_policy) };
526
527
  }
527
528
 
528
- function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl }) {
529
+ function draftCampaignBuildBrief({ spec, activePages, pageMappings, designFiles, templateFamily, sourceAssetCrawl }) {
529
530
  const pageMap = new Map((pageMappings || []).map((page) => [page.page_id, page]));
530
531
  // Entry-wise, so a page id named __proto__ stays a page.
531
532
  const designAuthority = Object.fromEntries((activePages || []).map((page) => {
532
533
  const mapped = pageMap.get(page.id);
534
+ const design = designFiles.find((entry) => entry.page_id === page.id)?.declaration;
535
+ if (mapped?.path && design) return [page.id, {
536
+ source: "design_file", reference: design.file_url,
537
+ frames: Object.fromEntries(Object.entries(design.frames).map(([viewport, frame]) => [viewport, frame.url])),
538
+ derived_artifact: { source: "provided_design_export", reference: mapped.path },
539
+ }];
533
540
  return [page.id, {
534
541
  source: mapped?.path ? "provided_design_export" : "template",
535
542
  reference: mapped?.path || mapped?.skip_reason || `${page.type || "page"} from selected template`,
@@ -625,8 +632,11 @@ function evaluateCampaignBuildBrief(brief, { spec, activePages, pageMappings, so
625
632
  const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
626
633
  const regulated = hasRegulatedSignals(spec);
627
634
  const pageIds = [...new Set((activePages || []).map((page) => page.id).filter(Boolean))];
635
+ // A page added from the repo's page tree after handoff (#721) has the repo
636
+ // page itself as its authority; the brief is not asked to name one.
637
+ const repoPages = new Set((pageMappings || []).filter((page) => page?.origin === "repo").map((page) => page.page_id));
628
638
 
629
- const missingDesignAuthority = pageIds.filter((pageId) => !isObject(brief.design_authority?.[pageId]) || !isNonEmptyString(brief.design_authority?.[pageId]?.source));
639
+ const missingDesignAuthority = pageIds.filter((pageId) => !repoPages.has(pageId) && (!isObject(brief.design_authority?.[pageId]) || !isNonEmptyString(brief.design_authority?.[pageId]?.source)));
630
640
  if (missingDesignAuthority.length) {
631
641
  addQuestion(questions, "page_design_authority", {
632
642
  detail: `Missing page authority for: ${missingDesignAuthority.join(", ")}.`,