@nextcommerce/campaigns-os 1.52.0 → 1.53.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.
- package/CHANGELOG.md +48 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +201 -12
- package/contracts/release-ledger.json +327 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +56 -15
- package/docs/campaign-build-brief.md +48 -17
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +2 -0
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/cli.mjs +160 -112
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +129 -14
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +3 -1
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +15 -3
- package/src/qa-browser.mjs +131 -84
- package/src/qa-node.mjs +14 -3
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +119 -26
- package/src/tooling-setup.mjs +1 -1
package/src/brand-theme.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import { contrastToolkit } from "./contrast.mjs";
|
|
3
4
|
import { shellToken } from "./shell-token.mjs";
|
|
4
5
|
import {
|
|
5
6
|
existsSync,
|
|
@@ -677,18 +678,15 @@ function mixColor(value, target, amount) {
|
|
|
677
678
|
});
|
|
678
679
|
}
|
|
679
680
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
686
|
-
}
|
|
681
|
+
// Luminance and ratio are the shared WCAG 2.x measurement Polish and QA use.
|
|
682
|
+
// Ratios are compared unrounded; only recorded values are rounded, for
|
|
683
|
+
// display.
|
|
684
|
+
const contrast = contrastToolkit();
|
|
685
|
+
const { contrastRatio, displayRatio } = contrast;
|
|
687
686
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return (lighter + 0.05) / (darker + 0.05);
|
|
687
|
+
// The 8-bit channels colorToRgb reads, as the [0,1] sRGB the helper takes.
|
|
688
|
+
function relativeLuminance({ r, g, b }) {
|
|
689
|
+
return contrast.relativeLuminance([r / 255, g / 255, b / 255]);
|
|
692
690
|
}
|
|
693
691
|
|
|
694
692
|
// Pick the foreground color (dark vs light) most legible on `bgValue`. A light
|
|
@@ -708,9 +706,11 @@ function readableForeground(bgValue, choices = {}) {
|
|
|
708
706
|
const darkContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(darkValue)));
|
|
709
707
|
const lightContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(lightValue)));
|
|
710
708
|
const useDark = darkContrast >= lightContrast;
|
|
709
|
+
const ratio = Math.max(darkContrast, lightContrast);
|
|
711
710
|
return {
|
|
712
711
|
value: useDark ? darkValue : lightValue,
|
|
713
|
-
|
|
712
|
+
ratio,
|
|
713
|
+
contrast: displayRatio(ratio),
|
|
714
714
|
on: useDark ? "dark" : "light",
|
|
715
715
|
};
|
|
716
716
|
}
|
|
@@ -732,11 +732,12 @@ function declaredForeground(bgValue, declaredValue) {
|
|
|
732
732
|
if (!bgRgb || !declared) return null;
|
|
733
733
|
const bgLuminance = relativeLuminance(bgRgb);
|
|
734
734
|
const declaredLuminance = relativeLuminance(colorToRgb(declared));
|
|
735
|
-
const
|
|
736
|
-
if (
|
|
735
|
+
const ratio = contrastRatio(bgLuminance, declaredLuminance);
|
|
736
|
+
if (ratio < DECLARED_CTA_FOREGROUND_MIN_CONTRAST) return null;
|
|
737
737
|
return {
|
|
738
738
|
value: declared,
|
|
739
|
-
|
|
739
|
+
ratio,
|
|
740
|
+
contrast: displayRatio(ratio),
|
|
740
741
|
on: declaredLuminance < bgLuminance ? "dark" : "light",
|
|
741
742
|
};
|
|
742
743
|
}
|
|
@@ -768,7 +769,7 @@ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForegroun
|
|
|
768
769
|
const declared = backgroundTarget === CTA_BACKGROUND_TARGET ? declaredForeground(backgroundValue, ctaForeground) : null;
|
|
769
770
|
const readable = declared || readableForeground(backgroundValue, choices);
|
|
770
771
|
if (!readable) continue;
|
|
771
|
-
if (minContrast && readable.
|
|
772
|
+
if (minContrast && readable.ratio < minContrast) {
|
|
772
773
|
warnings.push(issue(
|
|
773
774
|
"theme.foreground.low_contrast",
|
|
774
775
|
declared
|
|
@@ -779,7 +780,7 @@ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForegroun
|
|
|
779
780
|
}
|
|
780
781
|
// Scale confidence by the achieved contrast so a strong derivation reads as
|
|
781
782
|
// trustworthy as a direct mapping (AAA >= 7:1 high, AA >= 4.5:1 medium).
|
|
782
|
-
const confidence = readable.
|
|
783
|
+
const confidence = readable.ratio >= 7 ? "high" : readable.ratio >= 4.5 ? "medium" : "low";
|
|
783
784
|
mappings.push({
|
|
784
785
|
source: backgroundTarget,
|
|
785
786
|
target: foregroundTarget,
|
|
@@ -893,9 +894,9 @@ function darkestDeclaredBodyText(rootTokens, bodyBackground) {
|
|
|
893
894
|
if (!value) continue;
|
|
894
895
|
const luminance = relativeLuminance(colorToRgb(value));
|
|
895
896
|
if (luminance >= bgLuminance) continue;
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
898
|
-
if (!best || luminance < best.luminance) best = { name, value, luminance, contrast:
|
|
897
|
+
const ratio = contrastRatio(bgLuminance, luminance);
|
|
898
|
+
if (ratio < BODY_TEXT_MIN_CONTRAST) continue;
|
|
899
|
+
if (!best || luminance < best.luminance) best = { name, value, luminance, contrast: displayRatio(ratio) };
|
|
899
900
|
}
|
|
900
901
|
return best;
|
|
901
902
|
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// The campaign intent summary: a short, deterministic orientation rendered
|
|
2
|
+
// from the normalized Campaign Build Brief for the stage prompts and every
|
|
3
|
+
// `next` result. It is output only: nothing persists it and no routing,
|
|
4
|
+
// gate, price or commerce decision reads it.
|
|
5
|
+
//
|
|
6
|
+
// Every displayed brief value carries the marker its `_meta.field_sources`
|
|
7
|
+
// entry proves, and nothing stronger: an entry counts only when build-brief's
|
|
8
|
+
// fieldSourceEntryMatches accepts it (exactly {kind, value_fingerprint}, a
|
|
9
|
+
// recorded kind, and the fingerprint of the current value). Values are
|
|
10
|
+
// rendered as quoted data and never interpreted. A collection the summary
|
|
11
|
+
// cannot read as its expected shape is said to be not recorded, and a blank
|
|
12
|
+
// value reads not stated; neither is ever presented as complete.
|
|
13
|
+
//
|
|
14
|
+
// Pure: equal inputs give byte-identical output. No file, clock or
|
|
15
|
+
// environment access; a failure is handed to the caller's onError.
|
|
16
|
+
import { fieldSourceEntryMatches, SUMMARY_FIELDS } from "./build-brief.mjs";
|
|
17
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
18
|
+
|
|
19
|
+
export const SUMMARY_WORD_LIMIT = 150;
|
|
20
|
+
|
|
21
|
+
const NO_BRIEF_TEXT = "No Campaign Build Brief is recorded for this campaign. Ask the operator for purpose and audience before business-sensitive choices; never assume them.";
|
|
22
|
+
const NOT_SUMMARISED_TEXT = "The Campaign Build Brief could not be summarised.";
|
|
23
|
+
const COMMERCE_TEXT = "products, prices and offers come from CampaignSpec/API values; cart, checkout and post-purchase behaviour stay with the SDK.";
|
|
24
|
+
|
|
25
|
+
// The provenance-stamped fields, in the order build-brief exports them. The
|
|
26
|
+
// page-keyed field names a concrete page id in place of "<page>", joined by
|
|
27
|
+
// plain concatenation so no character of the id is interpreted.
|
|
28
|
+
const [AUDIENCE, CONVERSION_GOAL, TONE, PALETTE_SOURCE, PRIMARY_ACCENT, CTA_STYLE, AVOID, PAGE_SOURCE, BLOCK_PLACEHOLDERS] = SUMMARY_FIELDS;
|
|
29
|
+
const [PAGE_SOURCE_PREFIX, PAGE_SOURCE_SUFFIX, ...PAGE_SOURCE_REST] = PAGE_SOURCE.split("<page>");
|
|
30
|
+
if (PAGE_SOURCE_SUFFIX === undefined || PAGE_SOURCE_REST.length) {
|
|
31
|
+
throw new Error(`SUMMARY_FIELDS must name the page-keyed design authority field with exactly one "<page>" placeholder; got ${JSON.stringify(PAGE_SOURCE)}.`);
|
|
32
|
+
}
|
|
33
|
+
const pageSourceField = (pageId) => PAGE_SOURCE_PREFIX + pageId + PAGE_SOURCE_SUFFIX;
|
|
34
|
+
|
|
35
|
+
const NOT_RECORDED = "not_recorded";
|
|
36
|
+
// An active page whose id is missing or blank keeps its place in the journey.
|
|
37
|
+
const PAGE_ID_NOT_RECORDED = "(page id not recorded)";
|
|
38
|
+
const MARKERS = Object.freeze({ stated: "[stated]", source: "[from source]", default: "[default]", [NOT_RECORDED]: "[source not recorded]" });
|
|
39
|
+
// Weakest first: a value whose origin was not recorded claims the least of
|
|
40
|
+
// the marks; a part that reads not stated (null) claims less than any mark.
|
|
41
|
+
const STRENGTH = Object.freeze([NOT_RECORDED, "default", "source", "stated"]);
|
|
42
|
+
|
|
43
|
+
// design_authority.<page>.source values the generator writes; any other
|
|
44
|
+
// non-null value is shown verbatim in its own group.
|
|
45
|
+
const SUPPLIED_DESIGN = "provided_design_export";
|
|
46
|
+
const TEMPLATE = "template";
|
|
47
|
+
|
|
48
|
+
const VALUE_WORDS = 12;
|
|
49
|
+
const FINAL_CUT_WORDS = 8;
|
|
50
|
+
const LIST_CUT_LINES = Object.freeze([7, 4, 3]);
|
|
51
|
+
|
|
52
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
const own = (object, key) => (isPlainObject(object) && Object.hasOwn(object, key) ? object[key] : undefined);
|
|
54
|
+
const valueAt = (brief, path) => path.split(".").reduce((value, key) => own(value, key), brief);
|
|
55
|
+
const singleLine = (text) => text.replace(/\r\n|[\n\r\u2028\u2029]/g, " ");
|
|
56
|
+
// Whitespace only, counting NEL (U+0085), which String.prototype.trim keeps.
|
|
57
|
+
const isBlank = (value) => typeof value === "string" && !value.replace(/\u0085/g, "").trim();
|
|
58
|
+
const notStated = (value) => value == null || isBlank(value);
|
|
59
|
+
|
|
60
|
+
// C0, DEL and C1 control characters (ESC and NEL among them) written as \t or
|
|
61
|
+
// \uXXXX escapes, so none reaches a prompt or terminal raw.
|
|
62
|
+
const escapeControls = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => (char === "\t" ? "\\t" : `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`));
|
|
63
|
+
// Inside quotes, `\` and `"` are escaped first, so no value can close them.
|
|
64
|
+
const escapeQuoted = (text) => escapeControls(text.replace(/[\\"]/g, "\\$&"));
|
|
65
|
+
|
|
66
|
+
// stated | source | default | not_recorded, or null for an absent value.
|
|
67
|
+
function provenanceOf(brief, field, value) {
|
|
68
|
+
if (value == null) return null;
|
|
69
|
+
const entry = own(own(own(brief, "_meta"), "field_sources"), field);
|
|
70
|
+
if (!fieldSourceEntryMatches(entry, value)) return NOT_RECORDED;
|
|
71
|
+
return entry.kind;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The weakest mark among the parts a line or group shows; a part that reads
|
|
75
|
+
// not stated makes it null, so it never claims a mark that part lacks.
|
|
76
|
+
function weakest(provenances) {
|
|
77
|
+
if (!provenances.length || provenances.some((provenance) => provenance == null)) return null;
|
|
78
|
+
return STRENGTH[Math.min(...provenances.map((provenance) => STRENGTH.indexOf(provenance)))];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A brief value as quoted data: line breaks collapse to spaces, the value is
|
|
82
|
+
// trimmed and cut to `maxWords` words with "…", and escaped.
|
|
83
|
+
function quoted(value, maxWords, cut) {
|
|
84
|
+
const text = singleLine(typeof value === "string" ? value : canonicalJson(value)).trim();
|
|
85
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
86
|
+
if (words.length > maxWords) cut.happened = true;
|
|
87
|
+
const kept = words.length > maxWords ? `${words.slice(0, maxWords).join(" ")}…` : text;
|
|
88
|
+
return `"${escapeQuoted(kept)}"`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A list cut to its first k items plus "+N more", where N counts what the
|
|
92
|
+
// dropped items stand for (one each by default).
|
|
93
|
+
function listItems(items, k, render, cut, weight = () => 1) {
|
|
94
|
+
if (k == null || items.length <= k) return items.map(render);
|
|
95
|
+
cut.happened = true;
|
|
96
|
+
const more = items.slice(k).reduce((sum, item) => sum + weight(item), 0);
|
|
97
|
+
return [...items.slice(0, k).map(render), `+${more} more`];
|
|
98
|
+
}
|
|
99
|
+
// A page id as displayed: unquoted, with line breaks collapsed to spaces and
|
|
100
|
+
// control characters escaped.
|
|
101
|
+
const pageIdText = (id) => (id == null ? PAGE_ID_NOT_RECORDED : escapeControls(singleLine(id)));
|
|
102
|
+
|
|
103
|
+
function marked(value, provenance, maxWords, cut) {
|
|
104
|
+
return `${quoted(value, maxWords, cut)} ${MARKERS[provenance]}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A blank string reads not stated, like an absent value.
|
|
108
|
+
function field(brief, path) {
|
|
109
|
+
const raw = valueAt(brief, path);
|
|
110
|
+
const value = notStated(raw) ? null : raw;
|
|
111
|
+
return { value, provenance: provenanceOf(brief, path, value) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Line 1 and the line-5 palette clause have page-keyed generator values, so
|
|
115
|
+
// they show a value only when the brief file states it.
|
|
116
|
+
function statedOnly({ value, provenance }) {
|
|
117
|
+
return provenance === "stated" ? { value, provenance } : { value: null, provenance: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Line 4: each active page in exactly one group, in the fixed group order;
|
|
121
|
+
// each group carries the weakest marker among its pages. Authority and stamp
|
|
122
|
+
// are read with the page id exactly as recorded; a page without one, or with
|
|
123
|
+
// a blank source, reads not stated.
|
|
124
|
+
function authorityGroups(brief, pageIds) {
|
|
125
|
+
const authority = own(brief, "design_authority");
|
|
126
|
+
const supplied = { kind: "supplied", source: SUPPLIED_DESIGN, pages: [], provenances: [] };
|
|
127
|
+
const template = { kind: "template", source: TEMPLATE, pages: [], provenances: [] };
|
|
128
|
+
const others = new Map();
|
|
129
|
+
const unstated = { kind: "not_stated", source: null, pages: [], provenances: [] };
|
|
130
|
+
for (const pageId of pageIds) {
|
|
131
|
+
const recorded = pageId == null ? null : own(own(authority, pageId), "source");
|
|
132
|
+
const source = notStated(recorded) ? null : recorded;
|
|
133
|
+
const provenance = pageId == null ? null : provenanceOf(brief, pageSourceField(pageId), source);
|
|
134
|
+
let group = unstated;
|
|
135
|
+
if (source === SUPPLIED_DESIGN) group = supplied;
|
|
136
|
+
else if (source === TEMPLATE) group = template;
|
|
137
|
+
else if (source != null) {
|
|
138
|
+
const key = canonicalJson(source);
|
|
139
|
+
if (!others.has(key)) others.set(key, { kind: "other", source, pages: [], provenances: [] });
|
|
140
|
+
group = others.get(key);
|
|
141
|
+
}
|
|
142
|
+
group.pages.push(pageId);
|
|
143
|
+
group.provenances.push(provenance);
|
|
144
|
+
}
|
|
145
|
+
return [supplied, template, ...others.values(), unstated]
|
|
146
|
+
.filter((group) => group.pages.length)
|
|
147
|
+
.map(({ provenances, ...group }) => ({ ...group, provenance: weakest(provenances) }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readModel(brief, activePageIds) {
|
|
151
|
+
// Page ids stay as recorded and are escaped only when rendered; a missing,
|
|
152
|
+
// non-string or blank id is null and keeps its place.
|
|
153
|
+
const pageIds = (Array.isArray(activePageIds) ? activePageIds : []).map((id) => (typeof id === "string" && !isBlank(id) ? id : null));
|
|
154
|
+
const avoid = field(brief, AVOID);
|
|
155
|
+
const avoidItems = avoid.value == null ? [] : Array.isArray(avoid.value) ? avoid.value : [avoid.value];
|
|
156
|
+
// Questions not recorded as a list are null, never read as none.
|
|
157
|
+
const questions = own(brief, "questions");
|
|
158
|
+
return {
|
|
159
|
+
purpose: statedOnly(field(brief, CONVERSION_GOAL)),
|
|
160
|
+
audience: field(brief, AUDIENCE),
|
|
161
|
+
journey: pageIds,
|
|
162
|
+
authority: authorityGroups(brief, pageIds),
|
|
163
|
+
palette: statedOnly(field(brief, PALETTE_SOURCE)),
|
|
164
|
+
buttons: field(brief, CTA_STYLE),
|
|
165
|
+
accent: field(brief, PRIMARY_ACCENT),
|
|
166
|
+
tone: field(brief, TONE),
|
|
167
|
+
avoid: { ...avoid, items: avoidItems, itemNotStated: avoidItems.some(notStated) },
|
|
168
|
+
placeholders: field(brief, BLOCK_PLACEHOLDERS),
|
|
169
|
+
questions: Array.isArray(questions) ? questions.map((question) => (typeof question?.id === "string" && !isBlank(question.id) ? singleLine(question.id.trim()) : "unnamed question")) : null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The nine lines, with the line-3/4/7 lists cut to `cuts[line]` items and the
|
|
174
|
+
// line-1/2/5/6 values cut to `valueWords` words. Line 4 cut to k keeps its
|
|
175
|
+
// first k groups, each with its first k page ids; "+N more" counts pages.
|
|
176
|
+
function renderLines(model, cuts, valueWords) {
|
|
177
|
+
const cut = { happened: false };
|
|
178
|
+
const value = (item, fallback) => (item.value == null ? fallback : marked(item.value, item.provenance, valueWords, cut));
|
|
179
|
+
const group = (entry) => {
|
|
180
|
+
const ids = listItems(entry.pages, cuts[4], pageIdText, cut).join(", ");
|
|
181
|
+
if (entry.kind === "supplied") return `${ids} follow the supplied design ${MARKERS[entry.provenance]}`;
|
|
182
|
+
if (entry.kind === "template") return `${ids} follow the template ${MARKERS[entry.provenance]}`;
|
|
183
|
+
if (entry.kind === "other") return `${ids} use ${marked(entry.source, entry.provenance, VALUE_WORDS, cut)}`;
|
|
184
|
+
return `${ids} not stated`;
|
|
185
|
+
};
|
|
186
|
+
const avoid = model.avoid.value == null
|
|
187
|
+
? "avoid not stated"
|
|
188
|
+
: `avoid ${model.avoid.items.length ? listItems(model.avoid.items, cuts[7], (item) => (notStated(item) ? "not stated" : quoted(item, VALUE_WORDS, cut)), cut).join("; ") : "none"} ${MARKERS[model.avoid.provenance]}`;
|
|
189
|
+
const placeholders = model.placeholders.value == null
|
|
190
|
+
? "not stated"
|
|
191
|
+
: typeof model.placeholders.value === "boolean"
|
|
192
|
+
? `${model.placeholders.value ? "yes" : "no"} ${MARKERS[model.placeholders.provenance]}`
|
|
193
|
+
: marked(model.placeholders.value, model.placeholders.provenance, VALUE_WORDS, cut);
|
|
194
|
+
const lines = [
|
|
195
|
+
`Purpose: ${value(model.purpose, "not stated")}.`,
|
|
196
|
+
`Audience: ${value(model.audience, "not stated")}.`,
|
|
197
|
+
model.journey.length ? `Journey: ${listItems(model.journey, cuts[3], pageIdText, cut).join(" → ")} (CampaignSpec).` : "Journey: not stated.",
|
|
198
|
+
model.authority.length ? `Visual authority: ${listItems(model.authority, cuts[4], group, cut, (entry) => entry.pages.length).join("; ")}.` : "Visual authority: not stated.",
|
|
199
|
+
`Palette and buttons: ${model.palette.value == null ? "palette source not stated" : `palette from ${marked(model.palette.value, "stated", valueWords, cut)}`}; button style ${value(model.buttons, "not stated")}; accent ${value(model.accent, "not stated")}.`,
|
|
200
|
+
`Tone: ${value(model.tone, "not stated")}.`,
|
|
201
|
+
`Preserve: ${avoid}; template placeholders removed: ${placeholders}.`,
|
|
202
|
+
`Commerce: ${COMMERCE_TEXT}`,
|
|
203
|
+
`Open brief questions: ${model.questions == null ? "not recorded" : model.questions.length ? model.questions.map(escapeControls).join(", ") : "none"}.`,
|
|
204
|
+
];
|
|
205
|
+
const text = lines.join("\n");
|
|
206
|
+
return { text, wordCount: wordCount(text), cut: cut.happened };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function wordCount(text) {
|
|
210
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Within the word bound: cut one list at a time, in order, to its first k
|
|
214
|
+
// items (k from 5 down to 1) until the bound holds: line 7, then line 4, then
|
|
215
|
+
// line 3. Only if it still fails, cut the line-1/2/5/6 values to 8 words.
|
|
216
|
+
// Lines 8 and 9 are never cut, and nothing is padded.
|
|
217
|
+
function renderWithinBound(model) {
|
|
218
|
+
const cuts = {};
|
|
219
|
+
let rendered = renderLines(model, cuts, VALUE_WORDS);
|
|
220
|
+
for (const line of LIST_CUT_LINES) {
|
|
221
|
+
for (let k = 5; k >= 1 && rendered.wordCount > SUMMARY_WORD_LIMIT; k -= 1) {
|
|
222
|
+
cuts[line] = k;
|
|
223
|
+
rendered = renderLines(model, cuts, VALUE_WORDS);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (rendered.wordCount > SUMMARY_WORD_LIMIT) rendered = renderLines(model, cuts, FINAL_CUT_WORDS);
|
|
227
|
+
return rendered;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isPartial(model, cut) {
|
|
231
|
+
const values = [model.purpose, model.audience, model.palette, model.buttons, model.accent, model.tone, model.avoid, model.placeholders];
|
|
232
|
+
const missing = values.some((item) => item.value == null) || model.avoid.itemNotStated || !model.journey.length || model.journey.includes(null) || !model.authority.length;
|
|
233
|
+
const unrecorded = [...values, ...model.authority].some((item) => item.provenance === NOT_RECORDED);
|
|
234
|
+
const ungrouped = model.authority.some((group) => group.kind === "other" || group.kind === "not_stated");
|
|
235
|
+
return missing || unrecorded || ungrouped || model.questions == null || model.questions.length > 0 || cut;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const unavailable = (text) => ({ status: "unavailable", text, lines: [], word_count: wordCount(text), open_questions: [] });
|
|
239
|
+
|
|
240
|
+
// Never throws. No brief reads the fixed no-brief text. A brief the summary
|
|
241
|
+
// cannot be computed from (for example a value nested too deeply to
|
|
242
|
+
// fingerprint) reads unavailable with its own text, and the error is passed
|
|
243
|
+
// to `onError` so the caller can name it.
|
|
244
|
+
export function summarizeCampaignBrief({ brief, activePageIds = [], onError } = {}) {
|
|
245
|
+
if (!isPlainObject(brief)) return unavailable(NO_BRIEF_TEXT);
|
|
246
|
+
try {
|
|
247
|
+
return summarize(brief, activePageIds);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
onError?.(error);
|
|
250
|
+
return unavailable(NOT_SUMMARISED_TEXT);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function summarize(brief, activePageIds) {
|
|
255
|
+
const model = readModel(brief, activePageIds);
|
|
256
|
+
const rendered = renderWithinBound(model);
|
|
257
|
+
const line = (label, value, provenance) => ({ label, value, provenance });
|
|
258
|
+
return {
|
|
259
|
+
status: isPartial(model, rendered.cut) ? "partial" : "available",
|
|
260
|
+
text: rendered.text,
|
|
261
|
+
lines: [
|
|
262
|
+
line("Purpose", model.purpose.value, model.purpose.provenance),
|
|
263
|
+
line("Audience", model.audience.value, model.audience.provenance),
|
|
264
|
+
line("Journey", model.journey, null),
|
|
265
|
+
line("Visual authority", model.authority.map(({ pages, source, provenance }) => ({ page_ids: pages, source, provenance })), weakest(model.authority.map((group) => group.provenance))),
|
|
266
|
+
line("Palette and buttons", { commerce_palette_source: model.palette.value, cta_style: model.buttons.value, primary_accent: model.accent.value }, weakest([model.palette.provenance, model.buttons.provenance, model.accent.provenance])),
|
|
267
|
+
line("Tone", model.tone.value, model.tone.provenance),
|
|
268
|
+
line("Preserve", { avoid: model.avoid.value, block_placeholders: model.placeholders.value }, weakest([model.avoid.itemNotStated ? null : model.avoid.provenance, model.placeholders.provenance])),
|
|
269
|
+
line("Commerce", COMMERCE_TEXT, null),
|
|
270
|
+
line("Open brief questions", model.questions, null),
|
|
271
|
+
],
|
|
272
|
+
word_count: rendered.wordCount,
|
|
273
|
+
open_questions: model.questions ?? [],
|
|
274
|
+
};
|
|
275
|
+
}
|
package/src/build-brief.mjs
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { basename, extname, join, resolve } from "node:path";
|
|
3
4
|
import { parse as parseYaml } from "yaml";
|
|
4
5
|
|
|
6
|
+
import { cmd } from "./install-invocation.mjs";
|
|
7
|
+
import { canonicalJson } from "./polish-capture.mjs";
|
|
5
8
|
import { escapeRegExp } from "./repo-scan.mjs";
|
|
6
9
|
|
|
7
10
|
export const BUILD_BRIEF_SCHEMA = "campaigns-os-build-brief/v1";
|
|
11
|
+
// The brief value of a packet that carries no Campaign Build Brief, for both
|
|
12
|
+
// material partitions and for every stage stamp made against it.
|
|
13
|
+
export const BRIEF_ABSENT = "absent";
|
|
14
|
+
// The fields the campaign intent summary prints, in its order. `<page>` stands
|
|
15
|
+
// for each page id present in design_authority; _meta.field_sources keys use
|
|
16
|
+
// the concrete id (design_authority.checkout.source).
|
|
17
|
+
export const SUMMARY_FIELDS = Object.freeze(["campaign_intent.audience", "campaign_intent.conversion_goal", "campaign_intent.tone", "brand.commerce_palette_source", "brand.primary_accent", "brand.cta_style", "brand.avoid", "design_authority.<page>.source", "template_residue_policy.block_placeholders"]);
|
|
18
|
+
const BRIEF_MODE_SOURCES = Object.freeze({ FIELD: "field", LEGACY_REPORT: "legacy_report", GENERATED: "generated" });
|
|
19
|
+
// Codes of the errors normalization itself raises. They block in either mode;
|
|
20
|
+
// evaluation gates block only a prepared brief.
|
|
21
|
+
const NORMALIZATION_ERROR_CODES = Object.freeze(new Set(["build_brief.type", "build_brief.schema_version", "build_brief.brief_mode"]));
|
|
22
|
+
// Top-level keys that never count as material: normalization and evaluation
|
|
23
|
+
// output, and the declared mode. qa_policy.enforcement is fixed package text.
|
|
24
|
+
const NON_MATERIAL_BRIEF_KEYS = Object.freeze(new Set(["_meta", "status", "questions", "gates", "confidence", "schema_version", "brief_mode"]));
|
|
8
25
|
export const BUILD_BRIEF_NORMALIZED_REL_PATH = ".campaign-runtime/input/campaign-build-brief.normalized.json";
|
|
9
26
|
export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
|
|
10
27
|
"campaign-build-brief.yaml",
|
|
@@ -169,6 +186,32 @@ export function loadCampaignBuildBriefFile(path) {
|
|
|
169
186
|
return { path: resolvedPath, format, value: parsed };
|
|
170
187
|
}
|
|
171
188
|
|
|
189
|
+
// The mode a brief file declares, in rule order: brief_mode "prepared" or
|
|
190
|
+
// "guided"; without brief_mode, prepared only when the report this write
|
|
191
|
+
// replaces already recorded a prepared brief for the campaign (so it keeps the
|
|
192
|
+
// blocking it was built under); otherwise guided. Any other brief_mode value
|
|
193
|
+
// is a normalization error. The file's own _meta is never read.
|
|
194
|
+
function briefModeFor(loaded, boundReportMode, errors) {
|
|
195
|
+
if (!loaded) return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.GENERATED };
|
|
196
|
+
const declared = isObject(loaded.value) && Object.hasOwn(loaded.value, "brief_mode");
|
|
197
|
+
if (declared && loaded.value.brief_mode === "prepared") return { mode: "prepared", source: BRIEF_MODE_SOURCES.FIELD };
|
|
198
|
+
if (declared && loaded.value.brief_mode === "guided") return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.FIELD };
|
|
199
|
+
if (declared) {
|
|
200
|
+
errors.push({
|
|
201
|
+
code: "build_brief.brief_mode",
|
|
202
|
+
field: "brief_mode",
|
|
203
|
+
message: `Campaign Build Brief brief_mode must be "prepared" or "guided" (got ${JSON.stringify(loaded.value.brief_mode)}); omit it for a guided brief.`,
|
|
204
|
+
});
|
|
205
|
+
return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.FIELD };
|
|
206
|
+
}
|
|
207
|
+
if (boundReportMode === "prepared") return { mode: "prepared", source: BRIEF_MODE_SOURCES.LEGACY_REPORT };
|
|
208
|
+
return { mode: "guided_draft", source: BRIEF_MODE_SOURCES.GENERATED };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `boundReportMode`: build_brief.mode on the Assembly Report this write
|
|
212
|
+
// replaces, when that report belongs to the same campaign (mode rule 3).
|
|
213
|
+
// `previousNormalizedBrief`: the normalized brief on disk before this write,
|
|
214
|
+
// read for field provenance only.
|
|
172
215
|
export function createCampaignBuildBriefArtifact({
|
|
173
216
|
inputPath = null,
|
|
174
217
|
inputSource = null,
|
|
@@ -178,19 +221,24 @@ export function createCampaignBuildBriefArtifact({
|
|
|
178
221
|
templateFamily = null,
|
|
179
222
|
sourceAssetCrawl = null,
|
|
180
223
|
commerceZoneFindings = [],
|
|
224
|
+
boundReportMode = null,
|
|
225
|
+
previousNormalizedBrief = null,
|
|
181
226
|
} = {}) {
|
|
182
227
|
const loaded = inputPath ? loadCampaignBuildBriefFile(inputPath) : null;
|
|
183
|
-
const
|
|
228
|
+
const errors = [];
|
|
229
|
+
const { mode, source: modeSource } = briefModeFor(loaded, boundReportMode, errors);
|
|
184
230
|
const baseBrief = loaded
|
|
185
231
|
? cloneJsonOrEmpty(loaded.value)
|
|
186
232
|
: draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl });
|
|
187
233
|
|
|
188
|
-
const errors = [];
|
|
189
234
|
const normalized = normalizeCampaignBuildBrief(baseBrief, {
|
|
190
235
|
mode,
|
|
236
|
+
modeSource,
|
|
191
237
|
inputPath: loaded?.path || null,
|
|
192
238
|
inputFormat: loaded?.format || "generated",
|
|
193
239
|
inputSource,
|
|
240
|
+
fileValue: loaded ? loaded.value : null,
|
|
241
|
+
previousNormalizedBrief,
|
|
194
242
|
spec,
|
|
195
243
|
activePages,
|
|
196
244
|
pageMappings,
|
|
@@ -222,9 +270,11 @@ export function createCampaignBuildBriefArtifact({
|
|
|
222
270
|
normalized.questions = evaluation.questions;
|
|
223
271
|
normalized.gates = gates;
|
|
224
272
|
|
|
273
|
+
// A prepared brief blocks on every blocker gate; a guided one only on the
|
|
274
|
+
// gates its normalization errors produced (the first errors.length gates).
|
|
225
275
|
const blocking = mode === "prepared"
|
|
226
276
|
? blockerGates
|
|
227
|
-
:
|
|
277
|
+
: gates.slice(0, errors.length);
|
|
228
278
|
|
|
229
279
|
return {
|
|
230
280
|
mode,
|
|
@@ -278,24 +328,26 @@ export function validateCampaignBuildBriefArtifact(brief, { spec = null, normali
|
|
|
278
328
|
if (questions.length) {
|
|
279
329
|
errors.push({
|
|
280
330
|
code: "build_brief.questions_unanswered",
|
|
281
|
-
message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${describeOpenQuestions(questions)}. Set those fields in the brief file and
|
|
331
|
+
message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${describeOpenQuestions(questions)}. Set those fields in the brief file and save it with ${cmd("record")} brief --packet <packet>.`,
|
|
282
332
|
});
|
|
283
333
|
}
|
|
284
334
|
} else {
|
|
285
335
|
if (questions.length) {
|
|
286
336
|
// An answer given in conversation is not recorded until it is in a
|
|
287
|
-
// brief file
|
|
288
|
-
//
|
|
337
|
+
// brief file saved with record brief: the guided draft is regenerated
|
|
338
|
+
// on every intake, and a brief file replaces it whole.
|
|
289
339
|
warnings.push({
|
|
290
340
|
code: "build_brief.guided_questions",
|
|
291
|
-
message: `
|
|
292
|
-
+ `An answer counts only once it is
|
|
341
|
+
message: `Guided Campaign Build Brief has ${questions.length} high-impact business question(s) to confirm: ${describeOpenQuestions(questions)}. `
|
|
342
|
+
+ `An answer counts only once it is saved: copy ${normalizedPath} to campaign-build-brief.json in the target repo, set those fields, and run ${cmd("record")} brief --packet <packet> `
|
|
293
343
|
+ "(the file is found there automatically, or pass --brief <file>). The file replaces the draft, so start from the copy to keep the fields the draft already filled. "
|
|
294
|
-
+ "
|
|
344
|
+
+ "Saving keeps every stage's recorded evidence unless the brief's content changes.",
|
|
295
345
|
});
|
|
296
346
|
}
|
|
347
|
+
// Input errors block in both modes; evaluation gates stay warnings here.
|
|
297
348
|
for (const gate of blockerGates) {
|
|
298
|
-
|
|
349
|
+
const target = brief.status === "invalid" && NORMALIZATION_ERROR_CODES.has(gate.code) ? errors : warnings;
|
|
350
|
+
target.push({ code: gate.code || "build_brief.blocker", message: gate.message || "Guided Campaign Build Brief has an unresolved blocker." });
|
|
299
351
|
}
|
|
300
352
|
}
|
|
301
353
|
|
|
@@ -351,6 +403,7 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
|
|
|
351
403
|
brief._meta = {
|
|
352
404
|
generated_at: new Date().toISOString(),
|
|
353
405
|
mode: meta.mode,
|
|
406
|
+
mode_source: meta.modeSource,
|
|
354
407
|
input_path: meta.inputPath || null,
|
|
355
408
|
input_format: meta.inputFormat || null,
|
|
356
409
|
input_source: meta.inputSource || null,
|
|
@@ -368,20 +421,119 @@ function normalizeCampaignBuildBrief(value, meta, errors) {
|
|
|
368
421
|
brief.template_residue_policy = normalizeResiduePolicy(brief.template_residue_policy);
|
|
369
422
|
brief.qa_policy = normalizeQaPolicy(brief.qa_policy);
|
|
370
423
|
brief.confidence = objectOrEmpty(brief.confidence);
|
|
424
|
+
brief._meta.field_sources = summaryFieldSources(brief, meta);
|
|
371
425
|
|
|
372
426
|
return brief;
|
|
373
427
|
}
|
|
374
428
|
|
|
429
|
+
// The concrete summary field paths of a normalized brief: one
|
|
430
|
+
// design_authority.<page>.source per page the brief lists.
|
|
431
|
+
function summaryFieldPaths(brief) {
|
|
432
|
+
return SUMMARY_FIELDS.flatMap((path) => (path === "design_authority.<page>.source"
|
|
433
|
+
? Object.keys(objectOrEmpty(brief.design_authority)).map((page) => `design_authority.${page}.source`)
|
|
434
|
+
: [path]));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The value at a dotted path, reading the segment after `design_authority`
|
|
438
|
+
// as one page id (a page id may itself contain a dot).
|
|
439
|
+
function valueAtPath(value, path) {
|
|
440
|
+
const parts = path.startsWith("design_authority.") && path.endsWith(".source")
|
|
441
|
+
? ["design_authority", path.slice("design_authority.".length, -".source".length), "source"]
|
|
442
|
+
: path.split(".");
|
|
443
|
+
let node = value;
|
|
444
|
+
for (const part of parts) {
|
|
445
|
+
if (!isObject(node) || !Object.hasOwn(node, part)) return undefined;
|
|
446
|
+
node = node[part];
|
|
447
|
+
}
|
|
448
|
+
return node;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function valueFingerprint(value) {
|
|
452
|
+
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Whether `path` is one of the answer_fields of a question still open in the
|
|
456
|
+
// previous normalized brief (design_authority.<page_id>.source matches any page).
|
|
457
|
+
function answersOpenQuestion(path, previous) {
|
|
458
|
+
const questions = Array.isArray(previous?.questions) ? previous.questions : [];
|
|
459
|
+
return questions.some((question) => {
|
|
460
|
+
const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
|
|
461
|
+
return (entry?.answer_fields || []).some((field) => field === path
|
|
462
|
+
|| (field === "design_authority.<page_id>.source" && path.startsWith("design_authority.") && path.endsWith(".source")));
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// _meta.field_sources: {kind, value_fingerprint} per summary field. A
|
|
467
|
+
// generated draft stamps every non-null value `default` (no draft value is
|
|
468
|
+
// copied verbatim from a structured source field). From a file: a value absent
|
|
469
|
+
// or null in the file is `default` when normalization filled one; a value the
|
|
470
|
+
// file sets is `stated` when it answers an open question of the previous
|
|
471
|
+
// brief or differs from the previous value (or there is no previous brief),
|
|
472
|
+
// and otherwise keeps the previous entry. A null value gets no entry.
|
|
473
|
+
function summaryFieldSources(brief, meta) {
|
|
474
|
+
const previous = isObject(meta.previousNormalizedBrief) ? meta.previousNormalizedBrief : null;
|
|
475
|
+
const previousSources = isObject(previous?._meta?.field_sources) ? previous._meta.field_sources : {};
|
|
476
|
+
const fromFile = isObject(meta.fileValue);
|
|
477
|
+
const sources = {};
|
|
478
|
+
for (const path of summaryFieldPaths(brief)) {
|
|
479
|
+
const value = valueAtPath(brief, path);
|
|
480
|
+
if (value === undefined || value === null) continue;
|
|
481
|
+
const entry = (kind) => ({ kind, value_fingerprint: valueFingerprint(value) });
|
|
482
|
+
const inFile = fromFile ? valueAtPath(meta.fileValue, path) : undefined;
|
|
483
|
+
if (!fromFile || inFile === undefined || inFile === null) {
|
|
484
|
+
sources[path] = entry("default");
|
|
485
|
+
} else if (answersOpenQuestion(path, previous) || !previous || canonicalJson(valueAtPath(previous, path) ?? null) !== canonicalJson(value)) {
|
|
486
|
+
sources[path] = entry("stated");
|
|
487
|
+
} else if (Object.hasOwn(previousSources, path) && fieldSourceEntryMatches(previousSources[path], value)) {
|
|
488
|
+
sources[path] = { kind: previousSources[path].kind, value_fingerprint: previousSources[path].value_fingerprint };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return sources;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const FIELD_SOURCE_KINDS = Object.freeze(["stated", "source", "default"]);
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Whether a _meta.field_sources entry can be read for `value`: exactly
|
|
498
|
+
* {kind, value_fingerprint}, a known kind, and the fingerprint of `value`.
|
|
499
|
+
* Any other entry is ignored.
|
|
500
|
+
*/
|
|
501
|
+
export function fieldSourceEntryMatches(entry, value) {
|
|
502
|
+
return isObject(entry)
|
|
503
|
+
&& Object.keys(entry).length === 2
|
|
504
|
+
&& FIELD_SOURCE_KINDS.includes(entry.kind)
|
|
505
|
+
&& entry.value_fingerprint === valueFingerprint(value);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The brief's material content, from the normalized artifact: `qa_policy`
|
|
509
|
+
// (without its fixed enforcement note) and `presentation`, every other
|
|
510
|
+
// top-level key that is not normalization output or the declared mode,
|
|
511
|
+
// including keys the operator added.
|
|
512
|
+
export function briefMaterialProjection(normalized) {
|
|
513
|
+
const brief = isObject(normalized) ? normalized : {};
|
|
514
|
+
// Entry-wise copies, so an operator key named __proto__ stays data.
|
|
515
|
+
const presentation = Object.fromEntries(Object.entries(brief).filter(([key]) => !NON_MATERIAL_BRIEF_KEYS.has(key) && key !== "qa_policy"));
|
|
516
|
+
const qaPolicy = Object.fromEntries(Object.entries(objectOrEmpty(brief.qa_policy)).filter(([key]) => key !== "enforcement"));
|
|
517
|
+
return { presentation, qa_policy: qaPolicy };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Each partition as "sha256:<hex>" of its canonical JSON: key order,
|
|
521
|
+
// whitespace, an explicit default and _meta never change it.
|
|
522
|
+
export function briefMaterialFingerprint(normalized) {
|
|
523
|
+
const projection = briefMaterialProjection(normalized);
|
|
524
|
+
return { presentation: valueFingerprint(projection.presentation), qa_policy: valueFingerprint(projection.qa_policy) };
|
|
525
|
+
}
|
|
526
|
+
|
|
375
527
|
function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFamily, sourceAssetCrawl }) {
|
|
376
528
|
const pageMap = new Map((pageMappings || []).map((page) => [page.page_id, page]));
|
|
377
|
-
|
|
378
|
-
|
|
529
|
+
// Entry-wise, so a page id named __proto__ stays a page.
|
|
530
|
+
const designAuthority = Object.fromEntries((activePages || []).map((page) => {
|
|
379
531
|
const mapped = pageMap.get(page.id);
|
|
380
|
-
|
|
532
|
+
return [page.id, {
|
|
381
533
|
source: mapped?.path ? "provided_design_export" : "template",
|
|
382
534
|
reference: mapped?.path || mapped?.skip_reason || `${page.type || "page"} from selected template`,
|
|
383
|
-
};
|
|
384
|
-
}
|
|
535
|
+
}];
|
|
536
|
+
}));
|
|
385
537
|
|
|
386
538
|
const variantSignals = collectVariantSignals(spec, sourceAssetCrawl);
|
|
387
539
|
const paymentMethods = collectSpecPaymentMethods(spec);
|