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