@nextcommerce/campaigns-os 1.50.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 +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.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 +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- 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 +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: next-campaigns-qa
|
|
3
|
-
version: 1.3.
|
|
3
|
+
version: 1.3.33
|
|
4
4
|
description: Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Bundle revision: 1.
|
|
8
|
-
Run `npx --no-install campaigns-os tooling status --skills-revision 1.
|
|
7
|
+
Bundle revision: 1.53.0+skills.2
|
|
8
|
+
Run `npx --no-install campaigns-os tooling status --skills-revision 1.53.0+skills.2`
|
|
9
9
|
from the campaign's Page Kit folder, where it runs the project's pinned copy and
|
|
10
10
|
never installs one, at the start of each task. Start a fresh session if it
|
|
11
11
|
reports `mismatch`: this text is already in your context and is never re-read
|
|
@@ -70,6 +70,7 @@ Inputs:
|
|
|
70
70
|
- tested base URL (localhost dev URL, preview URL, or production URL)
|
|
71
71
|
- assembly report
|
|
72
72
|
- Test-order coverage choice (`common` default vs explicit paths vs topology-complete `full`) and SDK origin state (localhost is a Development domain; non-localhost origins need allowlist confirmation so the SDK loads)
|
|
73
|
+
- Read the normalized Campaign Build Brief at `.campaign-runtime/input/campaign-build-brief.normalized.json` and the recorded decisions (`adapter_decisions`, `decisions` and `theme` in `.campaign-runtime/assembly-report.json`, and `assembly.template_decision_notes` in the Build Packet) before working on the campaign. The campaign intent summary at the top of the setup, build, Polish and QA prompts and in every `next` result is orientation, never a source of prices or commerce behaviour.
|
|
73
74
|
|
|
74
75
|
Rules:
|
|
75
76
|
|
|
@@ -89,7 +90,7 @@ Rules:
|
|
|
89
90
|
- A typed-card path that fails is classified by **what it did to the store** before the runner decides what to do about it. A failure the runner can prove happened before submit (`not_created`) is **re-run once, if the creation budget has a slot no still-unrun planned path needs** — so a transient miss is not reported as a defect in the build, without an early path eating budget the last planned paths need. Under the default budget a path whose submit was *rejected* has already spent its own slot, so it is not re-run and records `evidence.order_creation.rerun_skipped` instead. When the re-run does happen, both attempts appear in `test_orders[]` and `evidence.retry` names the first attempt's error and ref id. A failure that happened **after** the order was created (`created` — most often a receipt that did not render) is **never resubmitted**: the runner reloads that order's receipt and re-runs only the read-only checks, and `evidence.recovery` carries the original failure, the checks re-run, and whether it cleared. Read `evidence.order_creation` for two separate counts: `submissions_reserved` (platform-side creation slots charged to this path — reserved before a submit click, or charged for a hosted-checkout redirect where no submit click happens — which stand even when the create then failed) and `orders_confirmed_created` (creates the platform was observed to accept) — a spent slot with no confirmed order is the ambiguous case, not an order to reconcile. Recovery clears only on persisted evidence it re-read on that pass: a failed or absent order read-back stops it honestly rather than re-deciding against the original attempt's numbers. An outcome it cannot prove either way (`ambiguous` — an unusable read-back, a lost create response, a network-failed create, a 4xx after an earlier 2xx) stops the path and names the operator check instead of buying again. A pass that only came back after recovery is never indistinguishable from a first-attempt pass, and a failure that survives recovery still blocks.
|
|
90
91
|
- Analytics correctness is two-phase in the same run: the campaign-root visit inventories declared providers/tags only, then the one canonical typed-card run proves Purchase for each topology-recognized receipt from the signals emitted across every page the path loaded after checkout, read after the full `--analytics-settle` window. The receipt qualifies the order; the journey is measured, because the SDK fires `dl_purchase` (and the outbound Purchase) on the first `?ref_id=` page — the upsell page when the funnel has one — and dedupes it on the receipt. It never places a second analytics order. The receipt document's own reading stays in evidence (`receipt_signals`, `fired_on`) as the diagnostic of which document fired.
|
|
91
92
|
- A missing or topology-unrecognized receipt is `MANUAL_REVIEW`/`WARN`; a recognized receipt with no dataLayer, outbound Meta, or outbound GA4 Purchase is `FAIL`/`BLOCKER`. Capture, unreadable-page, and settle-deadline errors on a recognized receipt are explicit non-waivable blockers. The `analytics-correctness:purchase-fires` waiver applies only to a genuine recognized-receipt/no-signal failure, and is recorded with `campaigns-os qa waive --assertion analytics-correctness:purchase-fires --reason "<why>"` (tier `C`: it overwrites the assembly report and doctor output; the lane is scoped to that one assertion and every other is refused).
|
|
92
|
-
- Keep QA in a tight sequence: install the Playwright browser, resolve topology, run browser QA plus typed-card proof with `--test-order common` by default.
|
|
93
|
+
- Keep QA in a tight sequence: install the Playwright browser, resolve topology, run browser QA plus typed-card proof with `--test-order common` by default. `qa run` has no permission flag: coverage is its only control. Test orders still land in the store as real orders that someone may have to cancel, so unless the operator has already said test orders are fine for this campaign, ask once, up front in your first turn with your other setup questions. When the operator has answered, at the start of this session or earlier in the build, follow that answer and do not pause QA to ask again. When nobody asked, ask once before the first test order rather than skip the question. Otherwise pause only for missing inputs, out-of-scope runtime pages that block checkout proof, or merchant-specific uncertainty.
|
|
93
94
|
- Use `--browser` for rendered browser evidence. Browser QA must use the package-owned Playwright flow, not external agent/browser skills.
|
|
94
95
|
- Saved-Map QA publishes to the QA portal under the existing consent and flag controls; report the portal link only when publication succeeds. Pass `--no-post-verdict` (or `--local-only`) to keep that verdict local. This stays tier `C`: served-page probes, requested orders, Run Telemetry and progress retain their own controls. See `docs/qa-and-test-orders.md` for the saved-Map publication policy.
|
|
95
96
|
- Local-spec QA always keeps verdicts and progress local. `qa run` suppresses portal publication even with `--post-verdict`; `qa publish` refuses local-spec packets with `local_spec`. Report the local verdict/sidecar as evidence, never a dashboard link. A matching route cannot replace a matching `local_spec_id`, and QA refuses a foreign or stale local Assembly Report. Run Telemetry still follows its consent controls.
|
|
@@ -116,11 +117,13 @@ Rules:
|
|
|
116
117
|
- A path with remaining actions may stop cleanly only at a terminal recognized in that selected topology. Missing accept/decline controls on any other page are blockers. A cross-origin handoff is a valid terminal navigation but is not receipt-rendering or persisted-receipt proof.
|
|
117
118
|
- Keep multi-funnel and `tiers:common` / `tiers:full` plans isolated to each selected checkout's own funnel graph, tiers, and recognized terminals; never borrow an unrelated funnel's receipt page.
|
|
118
119
|
- Accepted-upsell proof is valid only when the browser observes the order upsell API mutation and the final order evidence contains the selected upsell package. A checkout bump line marked `is_upsell` is not accepted-upsell proof.
|
|
119
|
-
- Test orders are
|
|
120
|
+
- Test orders are real orders in the store, but global test cards bypass the gateway: no charge, no transaction, and no merchant-specific sandbox routing to confirm. Localhost on any port is globally available as a Campaigns App Development domain and suppresses Campaigns analytics; non-localhost preview/production origins must be allowlisted for the campaign API key so the SDK loads — that is about SDK initialization, not test-order permission.
|
|
120
121
|
- Launch readiness is separate from Campaigns OS proof. If QA passes on local/preview, still surface production storefront URL, live payment methods, shipping markets, legal/support URLs, analytics expectations, and merchant-side configuration as real-shopper readiness items before launch.
|
|
121
122
|
- For multi-market campaigns, verify at least one non-default currency/country path: currency display, shipping method names/prices, available payment methods, and market-specific copy.
|
|
122
123
|
- Treat missing deploy URL, missing polish status, or unresolved doctor blockers as launch blockers.
|
|
123
124
|
- Report blockers, warnings, and residual risks.
|
|
125
|
+
- When a readability warning appears, report it with its colours, ratio, requirement and pages. Offer the operator options inside the brand palette (a darker or lighter shade of the same hue, the palette's text colour, or a larger, bold label where the design allows). Do not recolour the merchant's design until the operator chooses. Accept a warning only with the refs, reason and name the operator gives you at the QC handoff.
|
|
126
|
+
- At the end of QA, run `next` and present the QC handoff once. Ask the operator which open warnings to accept and why. Run `checkpoint accept` only with the refs, reason, and name the operator gave you in this conversation. Never write `qc_accepts`, `qc_results`, or QA verdict files by hand. The up-front test-order permission does not cover accepts.
|
|
124
127
|
- QA follows build and polish; it does not edit campaign code.
|
|
125
128
|
|
|
126
129
|
Canonical test-order flow:
|
package/skills.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "campaigns-os-skills",
|
|
3
3
|
"description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
|
|
4
|
-
"bundle_revision": "1.
|
|
4
|
+
"bundle_revision": "1.53.0+skills.2",
|
|
5
5
|
"homepage": "https://github.com/NextCommerceCo/campaigns-os",
|
|
6
6
|
"retired_skills": [
|
|
7
7
|
{
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"id": "next-campaigns-os",
|
|
18
18
|
"name": "Campaigns OS Lifecycle",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.49",
|
|
20
20
|
"path": "skills/next-campaigns-os/SKILL.md",
|
|
21
21
|
"domain": "campaigns",
|
|
22
22
|
"description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
{
|
|
25
25
|
"id": "next-campaigns-os-setup",
|
|
26
26
|
"name": "Campaigns OS Setup",
|
|
27
|
-
"version": "2.0.
|
|
27
|
+
"version": "2.0.32",
|
|
28
28
|
"path": "skills/next-campaigns-os-setup/SKILL.md",
|
|
29
29
|
"domain": "campaigns",
|
|
30
30
|
"description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
{
|
|
33
33
|
"id": "next-campaigns-build",
|
|
34
34
|
"name": "Campaign Build",
|
|
35
|
-
"version": "1.0.
|
|
35
|
+
"version": "1.0.34",
|
|
36
36
|
"path": "skills/next-campaigns-build/SKILL.md",
|
|
37
37
|
"domain": "campaigns",
|
|
38
38
|
"description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
{
|
|
41
41
|
"id": "next-campaigns-polish",
|
|
42
42
|
"name": "Campaign Polish",
|
|
43
|
-
"version": "1.1.
|
|
43
|
+
"version": "1.1.33",
|
|
44
44
|
"path": "skills/next-campaigns-polish/SKILL.md",
|
|
45
45
|
"domain": "campaigns",
|
|
46
46
|
"description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
{
|
|
49
49
|
"id": "next-campaigns-qa",
|
|
50
50
|
"name": "Campaign QA",
|
|
51
|
-
"version": "1.3.
|
|
51
|
+
"version": "1.3.33",
|
|
52
52
|
"path": "skills/next-campaigns-qa/SKILL.md",
|
|
53
53
|
"domain": "campaigns",
|
|
54
54
|
"description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
{
|
|
57
57
|
"id": "campaign-lifecycle-orientation",
|
|
58
58
|
"name": "Campaign Lifecycle Orientation",
|
|
59
|
-
"version": "1.0.
|
|
59
|
+
"version": "1.0.29",
|
|
60
60
|
"path": "skills/campaign-lifecycle-orientation/SKILL.md",
|
|
61
61
|
"domain": "campaigns",
|
|
62
62
|
"description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
{
|
|
65
65
|
"id": "campaign-run-evidence",
|
|
66
66
|
"name": "Campaign Run Evidence",
|
|
67
|
-
"version": "1.0.
|
|
67
|
+
"version": "1.0.29",
|
|
68
68
|
"path": "skills/campaign-run-evidence/SKILL.md",
|
|
69
69
|
"domain": "campaigns",
|
|
70
70
|
"description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
{
|
|
73
73
|
"id": "campaign-readback-classification",
|
|
74
74
|
"name": "Campaign Readback Classification",
|
|
75
|
-
"version": "1.0.
|
|
75
|
+
"version": "1.0.29",
|
|
76
76
|
"path": "skills/campaign-readback-classification/SKILL.md",
|
|
77
77
|
"domain": "campaigns",
|
|
78
78
|
"description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
{
|
|
81
81
|
"id": "contribution-intake",
|
|
82
82
|
"name": "Contribution Intake",
|
|
83
|
-
"version": "1.0.
|
|
83
|
+
"version": "1.0.29",
|
|
84
84
|
"path": "skills/contribution-intake/SKILL.md",
|
|
85
85
|
"domain": "campaigns",
|
|
86
86
|
"description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
|
|
@@ -317,7 +317,7 @@ function validateTemplateSlicePaths(copied, location, targetRepo, warnings, read
|
|
|
317
317
|
|
|
318
318
|
function checkEnum(value, allowed, code, warnings, addIssue) {
|
|
319
319
|
if (value == null) return;
|
|
320
|
-
if (!allowed.has(value)) addIssue(warnings, code, `${code} has unknown value
|
|
320
|
+
if (!allowed.has(value)) addIssue(warnings, code, `${code} has unknown value ${JSON.stringify(value)}; allowed values: ${[...allowed].join(", ")}.`);
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
function isObject(value) {
|
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,
|
|
@@ -88,6 +89,18 @@ function loadContracts(repoRoot = ROOT) {
|
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// A theme write error as the Assembly Report's theme.warnings[] carries it
|
|
93
|
+
// (schema $defs.themeIssue): detail, when present, must be an object, so an
|
|
94
|
+
// issue without one omits the key rather than writing null.
|
|
95
|
+
export function themeIssueForReport(error) {
|
|
96
|
+
const detail = error?.detail;
|
|
97
|
+
return {
|
|
98
|
+
code: error?.code,
|
|
99
|
+
message: error?.message,
|
|
100
|
+
...(detail && typeof detail === "object" && !Array.isArray(detail) ? { detail } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
function issue(code, message, detail = null) {
|
|
92
105
|
return detail ? { code, message, detail } : { code, message };
|
|
93
106
|
}
|
|
@@ -665,18 +678,15 @@ function mixColor(value, target, amount) {
|
|
|
665
678
|
});
|
|
666
679
|
}
|
|
667
680
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
674
|
-
}
|
|
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;
|
|
675
686
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
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]);
|
|
680
690
|
}
|
|
681
691
|
|
|
682
692
|
// Pick the foreground color (dark vs light) most legible on `bgValue`. A light
|
|
@@ -696,9 +706,11 @@ function readableForeground(bgValue, choices = {}) {
|
|
|
696
706
|
const darkContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(darkValue)));
|
|
697
707
|
const lightContrast = contrastRatio(bgLuminance, relativeLuminance(colorToRgb(lightValue)));
|
|
698
708
|
const useDark = darkContrast >= lightContrast;
|
|
709
|
+
const ratio = Math.max(darkContrast, lightContrast);
|
|
699
710
|
return {
|
|
700
711
|
value: useDark ? darkValue : lightValue,
|
|
701
|
-
|
|
712
|
+
ratio,
|
|
713
|
+
contrast: displayRatio(ratio),
|
|
702
714
|
on: useDark ? "dark" : "light",
|
|
703
715
|
};
|
|
704
716
|
}
|
|
@@ -720,11 +732,12 @@ function declaredForeground(bgValue, declaredValue) {
|
|
|
720
732
|
if (!bgRgb || !declared) return null;
|
|
721
733
|
const bgLuminance = relativeLuminance(bgRgb);
|
|
722
734
|
const declaredLuminance = relativeLuminance(colorToRgb(declared));
|
|
723
|
-
const
|
|
724
|
-
if (
|
|
735
|
+
const ratio = contrastRatio(bgLuminance, declaredLuminance);
|
|
736
|
+
if (ratio < DECLARED_CTA_FOREGROUND_MIN_CONTRAST) return null;
|
|
725
737
|
return {
|
|
726
738
|
value: declared,
|
|
727
|
-
|
|
739
|
+
ratio,
|
|
740
|
+
contrast: displayRatio(ratio),
|
|
728
741
|
on: declaredLuminance < bgLuminance ? "dark" : "light",
|
|
729
742
|
};
|
|
730
743
|
}
|
|
@@ -756,7 +769,7 @@ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForegroun
|
|
|
756
769
|
const declared = backgroundTarget === CTA_BACKGROUND_TARGET ? declaredForeground(backgroundValue, ctaForeground) : null;
|
|
757
770
|
const readable = declared || readableForeground(backgroundValue, choices);
|
|
758
771
|
if (!readable) continue;
|
|
759
|
-
if (minContrast && readable.
|
|
772
|
+
if (minContrast && readable.ratio < minContrast) {
|
|
760
773
|
warnings.push(issue(
|
|
761
774
|
"theme.foreground.low_contrast",
|
|
762
775
|
declared
|
|
@@ -767,7 +780,7 @@ function deriveForegroundMappings(existingMappings, targetTokens, { ctaForegroun
|
|
|
767
780
|
}
|
|
768
781
|
// Scale confidence by the achieved contrast so a strong derivation reads as
|
|
769
782
|
// trustworthy as a direct mapping (AAA >= 7:1 high, AA >= 4.5:1 medium).
|
|
770
|
-
const confidence = readable.
|
|
783
|
+
const confidence = readable.ratio >= 7 ? "high" : readable.ratio >= 4.5 ? "medium" : "low";
|
|
771
784
|
mappings.push({
|
|
772
785
|
source: backgroundTarget,
|
|
773
786
|
target: foregroundTarget,
|
|
@@ -881,9 +894,9 @@ function darkestDeclaredBodyText(rootTokens, bodyBackground) {
|
|
|
881
894
|
if (!value) continue;
|
|
882
895
|
const luminance = relativeLuminance(colorToRgb(value));
|
|
883
896
|
if (luminance >= bgLuminance) continue;
|
|
884
|
-
const
|
|
885
|
-
if (
|
|
886
|
-
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) };
|
|
887
900
|
}
|
|
888
901
|
return best;
|
|
889
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
|
+
}
|