@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
@@ -56,15 +56,29 @@ const ROUTING_HINT_FIELDS = Object.freeze({
56
56
  });
57
57
 
58
58
  // The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
59
- // everything else under the campaign root, `assets/` and `_data/` included,
60
- // is rendered when it is .html), plus the two no build reads. The CLI walker
61
- // prunes with this and pageRouteForFile filters with it, so the list lives
62
- // in one place.
59
+ // on 0.2.x everything else under the campaign root, `assets/` and `_data/`
60
+ // included, is rendered when it is .html), plus the two no build reads. The
61
+ // CLI walker prunes with this and pageRouteForFile filters with it, so the
62
+ // list lives in one place.
63
63
  export const PAGE_TREE_IGNORED_DIRS = Object.freeze(["_layouts", "_includes", "node_modules", ".git"]);
64
64
  export function isPageTreeIgnoredDir(name) {
65
65
  return PAGE_TREE_IGNORED_DIRS.includes(name);
66
66
  }
67
67
 
68
+ // Whether a campaign-relative file sits where page-kit renders no page. From
69
+ // 0.3.0 page-kit also skips the campaign's top-level `assets/` (its
70
+ // `*/assets/**` glob; a nested `offers/assets/` still renders), so the caller
71
+ // says which rule the target's installed page-kit follows. Backslashes and
72
+ // leading slashes read as pageRouteForFile reads them.
73
+ export function isPageTreeIgnoredPath(relativePath, { assetsHtmlIgnored = false } = {}) {
74
+ const dirs = normalizePageTreePath(relativePath).split("/").slice(0, -1);
75
+ return dirs.some(isPageTreeIgnoredDir) || (assetsHtmlIgnored && dirs[0] === "assets");
76
+ }
77
+
78
+ function normalizePageTreePath(relativePath) {
79
+ return String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
80
+ }
81
+
68
82
  function isPlainObject(value) {
69
83
  return value !== null && typeof value === "object" && !Array.isArray(value);
70
84
  }
@@ -114,13 +128,14 @@ function containerProblem(spec, path) {
114
128
  // `upsell/`, intermediate directories ignored with its NESTED_NO_PERMALINK
115
129
  // warning), and `index.html` is the entry route "". A nested `index.html`
116
130
  // would collide with the campaign root (page-kit's DUPLICATE_OUTPUT), so it
117
- // is not a page this reads. Files under `_layouts/` or `_includes/` return
118
- // null, as page-kit never renders them.
119
- export function pageRouteForFile(relativePath) {
120
- const path = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
131
+ // is not a page this reads. Files under `_layouts/` or `_includes/` (and,
132
+ // with assetsHtmlIgnored, the top-level `assets/`) return null, as page-kit
133
+ // never renders them.
134
+ export function pageRouteForFile(relativePath, { assetsHtmlIgnored = false } = {}) {
135
+ const path = normalizePageTreePath(relativePath);
121
136
  if (!path || !/\.html$/i.test(path)) return null;
122
137
  const segments = path.split("/");
123
- if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) return null;
138
+ if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) return null;
124
139
  const basename = segments[segments.length - 1].replace(/\.html$/i, "");
125
140
  if (basename === "index") return segments.length === 1 ? "" : null;
126
141
  return `${basename}/`;
@@ -11,7 +11,6 @@ import {
11
11
  NEXT_STAGE_CONTRACTS,
12
12
  NEXT_STAGE_OWNERS,
13
13
  stageIsBlocked,
14
- stageIsTerminal,
15
14
  } from "./orchestration-stage-contract.mjs";
16
15
 
17
16
  const PRODUCER_STAGES = new Set(["doctor", "qa"]);
@@ -200,6 +199,33 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
200
199
  return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
201
200
  }
202
201
 
202
+ /**
203
+ * #668: the Polish record a CampaignSpec change moved to `polish.history`,
204
+ * restored as the live record stamped with `stamps` (the current inputs), or
205
+ * null. It is the same review of the same pages, so it is restored only when
206
+ * the live record's input_change is a CampaignSpec change over exactly
207
+ * `fingerprint`, the newest history entry is the completed Polish that change
208
+ * archived (same build fingerprint and source package fingerprint), and the
209
+ * brief's presentation stamp is unchanged: a presentation change still owes a
210
+ * new review. The caller decides that the operator kept this output.
211
+ */
212
+ export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint = null, stamps = {}, completedStatuses }) {
213
+ const change = isPlainObject(polish) ? polish.input_change : null;
214
+ if (!isPlainObject(change) || change.reason !== "spec_material_changed" || change.superseded_build_fingerprint !== fingerprint) return null;
215
+ const history = Array.isArray(polish.history) ? polish.history : [];
216
+ const superseded = history[history.length - 1];
217
+ if (!isPlainObject(superseded) || superseded.archived_at !== change.at || !completedStatuses.includes(superseded.status)) return null;
218
+ if (superseded.source_build_fingerprint !== fingerprint) return null;
219
+ if ((superseded.source_package_material_fingerprint ?? null) !== (sourcePackageFingerprint ?? null)) return null;
220
+ if ((superseded.source_brief_material?.presentation ?? null) !== (stamps.source_brief_material?.presentation ?? null)) return null;
221
+ const restored = { ...polish };
222
+ for (const field of ["required_by", "required_for", "input_change", "source_brief_material", "source_spec_material_hash"]) delete restored[field];
223
+ for (const field of ["status", "completed_at", "recorded_by", "performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "outputs", "evidence"]) {
224
+ if (Object.hasOwn(superseded, field)) restored[field] = JSON.parse(JSON.stringify(superseded[field]));
225
+ }
226
+ return { ...restored, stage: "polish", ...stamps };
227
+ }
228
+
203
229
  /**
204
230
  * True when any stage of `report` carries a non-empty history: recorded
205
231
  * operator-visible state an intake must not silently drop.
@@ -23,12 +23,12 @@
23
23
  // stylesheets must also match the built output byte for byte).
24
24
  import { createHash } from "node:crypto";
25
25
  import { existsSync, readFileSync, statSync } from "node:fs";
26
- import { dirname, join, relative, resolve, sep } from "node:path";
26
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
 
29
29
  import Ajv2020 from "ajv/dist/2020.js";
30
30
 
31
- import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
31
+ import { BRAND_FONTS_FILENAME, BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
32
32
  import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
33
33
  import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
34
34
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
@@ -52,7 +52,7 @@ import {
52
52
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
53
53
  import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
54
54
  import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
55
- import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
55
+ import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
56
56
  import { withTargetLockSync } from "./target-lock.mjs";
57
57
  import { commerceScopeFromScope } from "./theme-gate.mjs";
58
58
 
@@ -127,7 +127,7 @@ function typeName(value) {
127
127
 
128
128
  // visual_review keys only `polish capture` writes. `record polish --evidence`
129
129
  // refuses them by name and carries the captured values forward.
130
- export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
130
+ export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability", "captures", "element_parity"]);
131
131
  export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
132
132
 
133
133
  export function refuseRecord(stage, problems) {
@@ -382,9 +382,15 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
382
382
  if (detected) {
383
383
  const cause = detected === "spec_material_changed" ? "spec" : "presentation";
384
384
  const demoted = demoteStages({ ...nextReport, stages: { ...nextReport.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
385
- return { report: demoted.report, context: null };
385
+ const kept = keptPolishFor(demoted.report.stages.polish, { assembly, fingerprint, sourcePackageFingerprint, stamps });
386
+ return { report: kept ? { ...demoted.report, stages: { ...demoted.report.stages, polish: kept } } : demoted.report, context: null };
386
387
  }
387
388
  const previousPolish = stageObject(report, "polish");
389
+ // The usual #668 path: intake (prepare-build --force or record spec) already
390
+ // recorded the change on assembly, so nothing is detected here, but the
391
+ // carried input_change makes this build a replay the operator kept.
392
+ const keptPolish = keptPolishFor(previousPolish, { assembly, fingerprint, sourcePackageFingerprint, stamps });
393
+ if (keptPolish) return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish: keptPolish, ...deploy } }, context: null };
388
394
  const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
389
395
  && optionalString(previousPolish.source_build_fingerprint) === fingerprint;
390
396
  const polish = polishStillCurrent
@@ -399,6 +405,16 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
399
405
  return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
400
406
  }
401
407
 
408
+ // #668: the Polish a CampaignSpec change superseded comes back when the
409
+ // operator's --deviation-reason kept the output it reviewed (stage-ledger's
410
+ // restorePolishKeptWithOutput decides from the archived record). A terminal
411
+ // Polish, or a build the operator did not keep, is left alone.
412
+ function keptPolishFor(polish, { assembly, fingerprint, sourcePackageFingerprint, stamps }) {
413
+ if (!isObject(assembly.unchanged_output_reason) || stageIsTerminal(String(polish?.status || ""))) return null;
414
+ const kept = restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint, stamps, completedStatuses: POLISH_COMPLETED_STATUSES });
415
+ return kept ? withoutKeys(kept, ["skip_reason"]) : null;
416
+ }
417
+
402
418
  // stages.deploy after a build of `fingerprint`, or null when it is kept: a
403
419
  // completed deploy whose source_build_fingerprint names other output goes
404
420
  // whole into history (archived_by "record build", reason_code
@@ -496,10 +512,24 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
496
512
  // family components, so it must load a brand layer (brand-theme.css or
497
513
  // checkout-brand.css) after it, and that file must be in the built output. A
498
514
  // page that loads neither renders the design's own markup and needs no brand
499
- // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). Any page
515
+ // layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). When
516
+ // theme generate wrote brand-fonts.css (the design's own faces), each styled
517
+ // page must load the campaign's copy too, or the families brand-theme.css
518
+ // names fall back to the system font. Where it was written is the Build
519
+ // Context's theme.generated.fonts_css_path (theme generate --out-dir moves
520
+ // it); the default path is read only when the context names none. Any page
500
521
  // that breaks the rule is a refusal naming the page.
501
- function brandLayerFacts(doctor) {
522
+ function brandLayerFacts(doctor, context = null) {
502
523
  const derived = doctor.derived || {};
524
+ const recordedFonts = context?.theme?.generated?.fonts_css_path;
525
+ const generatedFonts = typeof recordedFonts === "string" && recordedFonts
526
+ ? resolve(derived.target_repo, recordedFonts)
527
+ : (context?.theme?.generated ? null : resolve(derived.target_repo, ".campaign-runtime/theme", BRAND_FONTS_FILENAME));
528
+ const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
529
+ const insideSite = (file) => {
530
+ const rel = relative(site.site_root, file);
531
+ return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
532
+ };
503
533
  const commerce = commerceScopeFromScope(derived.scope);
504
534
  if (!commerce.all.length) {
505
535
  throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
@@ -550,6 +580,30 @@ function brandLayerFacts(doctor) {
550
580
  problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
551
581
  continue;
552
582
  }
583
+ // brand-fonts.css holds only @font-face rules, so unlike the brand layer
584
+ // its position relative to next-core.css does not matter.
585
+ if (fontsRequired) {
586
+ const fontsHref = hrefs.find((candidate) => hrefName(candidate) === BRAND_FONTS_FILENAME);
587
+ const bare = fontsHref ? fontsHref.split(/[?#]/)[0] : null;
588
+ const fontsFile = bare && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(bare)
589
+ ? (bare.startsWith("/") ? join(site.site_root, bare) : resolve(dirname(built.built_path), bare))
590
+ : null;
591
+ const generatedRel = relative(derived.target_repo, generatedFonts);
592
+ const problem = !fontsHref
593
+ ? `${label}: ${builtRel} links no ${BRAND_FONTS_FILENAME}. Theme generate wrote the design's font faces to ${generatedRel}; copy it into the campaign assets/css folder beside the brand layer, list it in the page's frontmatter styles and rebuild.`
594
+ : !fontsFile || !insideSite(fontsFile)
595
+ ? `${label}: ${builtRel} links ${fontsHref}, which is not a file in this campaign's built output; link the campaign's own copy of ${generatedRel}.`
596
+ : !existsSync(fontsFile)
597
+ ? `${label}: ${builtRel} links ${fontsHref}, which is not in the built output.`
598
+ : readFileSync(fontsFile, "utf8") !== readFileSync(generatedFonts, "utf8")
599
+ ? `${label}: ${builtRel} links ${fontsHref}, which differs from the generated ${generatedRel}; copy the generated file and rebuild.`
600
+ : null;
601
+ if (problem) {
602
+ problems.push(problem);
603
+ continue;
604
+ }
605
+ evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, fontsFile)}, identical to the generated ${generatedRel}.`);
606
+ }
553
607
  layers.push(file);
554
608
  styled.push(label);
555
609
  evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
@@ -849,7 +903,7 @@ function validateRecord(stage, { report, context, packet, fingerprint }) {
849
903
  // over the report this record would write. A blocked or skipped Polish is
850
904
  // not a pass the gate could grant; doctor keeps QA blocked on it.
851
905
  const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
852
- const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
906
+ const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint, newEvidence: true });
853
907
  if (gate.status === "blocked") {
854
908
  problems.push(`${gate.code}: ${gate.reason}`);
855
909
  for (const problem of gate.problems || []) problems.push(problem);
@@ -1164,7 +1218,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1164
1218
  }
1165
1219
  facts = doctorFacts(stage, doctor, report, packet);
1166
1220
  if (typeof afterDoctorRead === "function") afterDoctorRead();
1167
- if (stage === "theme") layer = brandLayerFacts(doctor);
1221
+ if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath));
1168
1222
  if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
1169
1223
  const next = stage === "setup"
1170
1224
  ? composeSetup(report, context, { now: timestamp, recordedBy })
@@ -166,13 +166,13 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
166
166
  id: "theme_generate",
167
167
  kind: "command",
168
168
  command: `campaigns-os theme generate --packet ${packetArg}`,
169
- description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens.",
169
+ description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens, and brand-fonts.css beside it when the design source ships the faces its headings, body copy or buttons use.",
170
170
  },
171
171
  {
172
172
  id: "apply_brand_layer",
173
173
  kind: "manual",
174
174
  command: null,
175
- description: `Copy brand-theme.css into the campaign assets/css folder, list it after next-core.css in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
175
+ description: `Copy brand-theme.css (and brand-fonts.css, when theme generate wrote one) into the campaign assets/css folder, list brand-theme.css after next-core.css, and brand-fonts.css anywhere, in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
176
176
  },
177
177
  {
178
178
  id: "waive_theme",
@@ -0,0 +1,296 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { boundedDimensions, VISUAL_MAX_DECODE_PIXELS } from "./png-header.mjs";
5
+
6
+ const own = (value, key) => value !== null && ["object", "function"].includes(typeof value) && Object.hasOwn(value, key) ? value[key] : undefined;
7
+ const boundedArea = boundedDimensions;
8
+ function geometry(image) {
9
+ boundedArea(own(image, "width"), own(image, "height"));
10
+ if (!ArrayBuffer.isView(image.data) || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
11
+ }
12
+ const track = (memory, data) => { memory?.acquire(data); return data; };
13
+ function release(image, memory) {
14
+ const data = image.data;
15
+ image.data = null;
16
+ if (data) memory?.release(data);
17
+ }
18
+ const allocate = (length, memory) => track(memory, Buffer.alloc(length));
19
+ function maskRects(rects) {
20
+ if (!Array.isArray(rects) || rects.some((r) => !r || ![r.x, r.y].every(Number.isFinite)
21
+ || ![r.width, r.height].every((n) => Number.isFinite(n) && n >= 0))) throw new RangeError("invalid_mask");
22
+ }
23
+
24
+ // Comparisons consume their RGBA inputs. Callbacks must finish encoding/writing
25
+ // artifacts synchronously; only metrics survive into the next comparison.
26
+ export function readRgba(source, memory) {
27
+ boundedArea(own(source, "width"), own(source, "height"));
28
+ if (typeof own(source, "load") !== "function") { geometry(source); return source; }
29
+ const image = source.load();
30
+ geometry(image);
31
+ if (image.width !== source.width || image.height !== source.height) throw new RangeError("invalid_image_geometry");
32
+ track(memory, image.data); return image;
33
+ }
34
+ export function releaseComparison(part, memory) {
35
+ for (const key of ["basis_crop", "build_crop", "heatmap"]) if (part?.[key]) {
36
+ release(part[key], memory); delete part[key];
37
+ }
38
+ delete part.mismatches; delete part.mask;
39
+ }
40
+
41
+ export const DIFF_OPTIONS = Object.freeze({ threshold: 0.1, include_aa: false, alpha: 0.1 });
42
+ // Neither package exports its version: read it from the package.json above the
43
+ // resolved entry whose name is the package's. Unknown is null, never a guess.
44
+ export function installedPackageVersion(name, { resolveEntry = (specifier) => import.meta.resolve(specifier) } = {}) {
45
+ try {
46
+ let directory = dirname(fileURLToPath(resolveEntry(name)));
47
+ for (;;) {
48
+ const file = join(directory, "package.json");
49
+ if (existsSync(file)) {
50
+ const manifest = JSON.parse(readFileSync(file, "utf8"));
51
+ if (manifest?.name === name) return typeof manifest.version === "string" ? manifest.version : null;
52
+ }
53
+ const parent = dirname(directory);
54
+ if (parent === directory) return null;
55
+ directory = parent;
56
+ }
57
+ } catch { return null; }
58
+ }
59
+ export async function loadVisualDependencies({ importer = (name) => import(name), versionOf = installedPackageVersion } = {}) {
60
+ async function load(name, type) {
61
+ try {
62
+ const module = await importer(name);
63
+ const api = own(module, "default") || module;
64
+ const result = { status: "available", version: own(module, "version") ?? own(api, "version") ?? versionOf(name), package: name };
65
+ if (type === "decoder") {
66
+ const PNG = own(module, "PNG") || own(api, "PNG");
67
+ const sync = own(PNG, "sync");
68
+ if (typeof own(sync, "read") !== "function" || typeof own(sync, "write") !== "function") throw new TypeError("Invalid PNG module");
69
+ result.decode = (bytes) => PNG.sync.read(bytes);
70
+ result.encode = (image) => PNG.sync.write(image);
71
+ } else {
72
+ if (typeof api !== "function") throw new TypeError("Invalid diff module");
73
+ result.compare = api;
74
+ }
75
+ return result;
76
+ } catch (error) {
77
+ // A missing transitive import is a broken installation, not this package being absent.
78
+ const missing = ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"].includes(error?.code)
79
+ && new RegExp(`(?:package|module) ['\"]${name}['\"]`).test(error.message || "");
80
+ return { status: missing ? "missing" : "failed", version: null, package: name,
81
+ install_hint: `Install ${name} where campaigns-os is installed (npm install ${name}).` };
82
+ }
83
+ }
84
+ const [decoder, diff] = await Promise.all([load("pngjs", "decoder"), load("pixelmatch", "diff")]);
85
+ return { decoder, diff };
86
+ }
87
+
88
+ export function padRgba(image, width, height, color = [255, 255, 255, 255], memory) {
89
+ boundedArea(width, height);
90
+ geometry(image);
91
+ if (width < image.width || height < image.height || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
92
+ const data = allocate(width * height * 4, memory);
93
+ for (let i = 0; i < data.length; i += 4) data.set(color, i);
94
+ for (let y = 0; y < image.height; y++) data.set(image.data.subarray(y * image.width * 4, (y + 1) * image.width * 4), y * width * 4);
95
+ return { width, height, data };
96
+ }
97
+
98
+ export function paintMaskUnion(images, rectsA = [], rectsB = [], color = [255, 0, 255, 255]) {
99
+ maskRects(rectsA); maskRects(rectsB);
100
+ if (typeof color === "string" && /^#[a-f0-9]{6}$/i.test(color)) color = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)).concat(255);
101
+ if (!images.length) return { masked_pixels: 0, mask: new Uint8Array() };
102
+ for (const image of images) geometry(image);
103
+ const { width, height } = images[0];
104
+ boundedArea(width, height);
105
+ if (images.some((image) => image.width !== width || image.height !== height)) throw new RangeError("mask_canvas_mismatch");
106
+ const mask = new Uint8Array(width * height);
107
+ let masked_pixels = 0;
108
+ for (const rect of [...rectsA, ...rectsB]) {
109
+ if (!rect.width || !rect.height) continue;
110
+ const x0 = Math.max(0, Math.floor(rect.x)), y0 = Math.max(0, Math.floor(rect.y));
111
+ const x1 = Math.min(width, Math.ceil(rect.x + rect.width)), y1 = Math.min(height, Math.ceil(rect.y + rect.height));
112
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
113
+ const index = y * width + x;
114
+ if (!mask[index]) { mask[index] = 1; masked_pixels++; }
115
+ for (const image of images) image.data.set(color, index * 4);
116
+ }
117
+ }
118
+ return { masked_pixels, mask };
119
+ }
120
+
121
+ export function compareImages(basis, build, { diff, rectsA = [], rectsB = [], dpr = 1, memory } = {}) {
122
+ geometry(basis); geometry(build);
123
+ maskRects(rectsA); maskRects(rectsB);
124
+ if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
125
+ const compare = typeof diff === "function" ? diff : own(diff, "compare");
126
+ if (typeof compare !== "function") throw new Error("dependency_missing");
127
+ const width = Math.max(basis.width, build.width), height = Math.max(basis.height, build.height);
128
+ boundedArea(width, height);
129
+ const originalA = { width: basis.width, height: basis.height }, originalB = { width: build.width, height: build.height };
130
+ track(memory, basis.data); track(memory, build.data);
131
+ const inputs = [{ ...basis }, { ...build }];
132
+ basis.data = null; build.data = null;
133
+ const consume = (image) => {
134
+ if (image.width === width && image.height === height) {
135
+ const result = { ...image }; image.data = null; return result;
136
+ }
137
+ const padded = padRgba(image, width, height, undefined, memory);
138
+ release(image, memory); return padded;
139
+ };
140
+ let a, b, heatmap, completed = false;
141
+ try {
142
+ if (inputs[0].data.buffer === inputs[1].data.buffer
143
+ && inputs[0].data.byteOffset < inputs[1].data.byteOffset + inputs[1].data.byteLength
144
+ && inputs[1].data.byteOffset < inputs[0].data.byteOffset + inputs[0].data.byteLength) {
145
+ const original = inputs[0].data;
146
+ inputs[0].data = allocate(original.length, memory);
147
+ inputs[0].data.set(original);
148
+ if (original !== inputs[1].data) memory?.release(original);
149
+ }
150
+ a = consume(inputs[0]); b = consume(inputs[1]);
151
+ const { mask, masked_pixels } = paintMaskUnion([a, b], rectsA, rectsB);
152
+ heatmap = { width, height, data: allocate(width * height * 4, memory) };
153
+ compare(a.data, b.data, heatmap.data, width, height,
154
+ { threshold: DIFF_OPTIONS.threshold, includeAA: DIFF_OPTIONS.include_aa, alpha: DIFF_OPTIONS.alpha });
155
+ const mismatches = new Uint8Array(width * height);
156
+ let mismatch_pixels = 0;
157
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
158
+ const i = y * width + x, p = i * 4;
159
+ const missing = (x >= originalA.width || y >= originalA.height) !== (x >= originalB.width || y >= originalB.height);
160
+ if (!mask[i] && (missing || (heatmap.data[p] === 255 && heatmap.data[p + 1] === 0 && heatmap.data[p + 2] === 0))) {
161
+ mismatches[i] = 1; mismatch_pixels++;
162
+ heatmap.data.set([255, 0, 0, 255], p);
163
+ }
164
+ }
165
+ const compared_pixels = width * height - masked_pixels;
166
+ const unpad = (image, original) => {
167
+ for (let y = 0; y < original.height; y++) image.data.copyWithin(y * original.width * 4, y * width * 4, (y * width + original.width) * 4);
168
+ return { ...original, data: image.data.subarray(0, original.width * original.height * 4) };
169
+ };
170
+ const result = { status: "compared", reason: null, width_delta_px: originalB.width - originalA.width,
171
+ height_delta_px: originalB.height - originalA.height, canvas: { width, height }, masked_pixels,
172
+ mismatch_pixels, compared_pixels, mismatch_ratio: compared_pixels ? mismatch_pixels / compared_pixels : 0,
173
+ heatmap, basis_crop: unpad(a, originalA), build_crop: unpad(b, originalB), mismatches, mask,
174
+ worst_tiles: worstTiles({ width, height, mismatches, mask }, { tile: Math.min(32 * dpr, Math.max(width, height)) }) };
175
+ completed = true; return result;
176
+ } finally {
177
+ if (!completed) for (const image of [a, b, heatmap, ...inputs]) if (image?.data) release(image, memory);
178
+ }
179
+ }
180
+
181
+ export function worstTiles({ width, height, mismatches, mask = [] }, { tile = 32, count = 3 } = {}) {
182
+ boundedArea(width, height);
183
+ if (!Number.isFinite(tile) || tile <= 0 || !Number.isSafeInteger(count) || count < 0) throw new RangeError("invalid_tile_options");
184
+ tile = Math.max(1, Math.round(tile));
185
+ count = Math.min(count, 3);
186
+ if (!mismatches || mismatches.length !== width * height || (mask.length && mask.length !== width * height)) throw new RangeError("invalid_tile_geometry");
187
+ const tiles = [];
188
+ for (let y = 0; y < height; y += tile) for (let x = 0; x < width; x += tile) {
189
+ const w = Math.min(tile, width - x), h = Math.min(tile, height - y);
190
+ let mismatch = 0, compared = 0;
191
+ for (let row = y; row < y + h; row++) for (let col = x; col < x + w; col++) {
192
+ const i = row * width + col;
193
+ if (!mask[i]) { compared++; mismatch += mismatches[i]; }
194
+ }
195
+ tiles.push({ x, y, width: w, height: h, mismatch_ratio: compared ? mismatch / compared : 0 });
196
+ tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x);
197
+ if (tiles.length > count) tiles.pop();
198
+ }
199
+ return tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x).slice(0, count);
200
+ }
201
+
202
+ export function encodeHeatmap(image, decoder) {
203
+ geometry(image?.heatmap || image);
204
+ return decoder.encode(image.heatmap || image);
205
+ }
206
+
207
+ export function pairCompatibility(build, basis, { reference = false } = {}) {
208
+ const fail = (reason) => ({ ok: false, reason });
209
+ if (!build || typeof build !== "object" || !basis || typeof basis !== "object") return fail("capture_unavailable");
210
+ if (reference) {
211
+ if (!build || basis.viewport !== build.viewport) return fail("viewport_not_captured");
212
+ if (basis.css_width == null) return fail(basis.dpr == null ? "dpr_unknown" : "width_unknown");
213
+ if (basis.dpr == null) return fail("dpr_unknown");
214
+ if (basis.css_width !== build.width) return fail("width_mismatch");
215
+ if (basis.dpr !== build.device_scale_factor) return fail("dpr_mismatch");
216
+ } else if ([build, basis].some((cell) => cell?.stabilized?.treatment?.mask_cap_reached)) return fail("mask_cap_reached");
217
+ return { ok: true, reason: null };
218
+ }
219
+ export function pageCompatibility(a, b) {
220
+ return a?.truncated || b?.truncated ? { ok: false, reason: "capture_truncated" } : { ok: true, reason: null };
221
+ }
222
+
223
+ function pixelBand(source, band, dpr) {
224
+ const top = Math.floor(band.top * dpr), bottom = Math.ceil((band.top + band.height) * dpr);
225
+ const left = Math.floor((band.left ?? 0) * dpr);
226
+ const right = Math.ceil(((band.left ?? 0) + (band.width ?? source.width / dpr)) * dpr);
227
+ return { top, left, width: right - left, height: bottom - top };
228
+ }
229
+ function insideCapture(source, band, dpr) {
230
+ const left = band.left ?? 0, width = band.width ?? source.width / dpr;
231
+ return band.top >= 0 && left >= 0
232
+ && (band.top + band.height) * dpr <= source.height && (left + width) * dpr <= source.width;
233
+ }
234
+ // A rectangle not wholly inside the decoded image is refused before any copy,
235
+ // so no zero-filled pixel is ever presented as image data.
236
+ export function crop(source, { top, left, width, height }, memory) {
237
+ boundedArea(width, height);
238
+ const image = readRgba(source, memory);
239
+ if (![top, left].every(Number.isSafeInteger) || top < 0 || left < 0
240
+ || left + width > image.width || top + height > image.height) {
241
+ release(image, memory);
242
+ throw new RangeError("invalid_image_geometry");
243
+ }
244
+ const data = allocate(width * height * 4, memory);
245
+ for (let row = 0; row < height; row++) data.set(image.data.subarray(((row + top) * image.width + left) * 4,
246
+ ((row + top) * image.width + left + width) * 4), row * width * 4);
247
+ release(image, memory);
248
+ return { width, height, data };
249
+ }
250
+ export function compareBands(basis, build, { basisSections, buildSections, rectsA = [], rectsB = [], dpr = 1, diff, cap = 40, memory, onComparison } = {}) {
251
+ boundedArea(own(basis, "width"), own(basis, "height")); boundedArea(own(build, "width"), own(build, "height"));
252
+ if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
253
+ if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError("invalid_band_cap");
254
+ cap = Math.min(cap, 40);
255
+ maskRects(rectsA); maskRects(rectsB);
256
+ const a = basisSections?.bands || [], b = buildSections?.bands || [];
257
+ if (!Array.isArray(a) || !Array.isArray(b) || [...a, ...b].some((band) => !band || typeof band !== "object")) throw new RangeError("invalid_band_geometry");
258
+ for (const [source, sections] of [[basis, a], [build, b]]) for (const band of sections) {
259
+ const width = band.width ?? source.width / dpr;
260
+ if (![band.top, band.height, band.left ?? 0, width].every(Number.isFinite)) throw new RangeError("invalid_band_geometry");
261
+ // Section boxes are signed, fractional CSS document coordinates. Only
262
+ // non-finite values and over-cap areas refuse the comparison (A-C1).
263
+ if (width > 0 && band.height > 0 && width * dpr * band.height * dpr > VISUAL_MAX_DECODE_PIXELS) throw new RangeError("image_too_large");
264
+ }
265
+ // Repeated bands read fresh images from caller-provided sources (normally
266
+ // capture files). No decoded originals or encoded snapshots are retained.
267
+ if ([basis, build].some((source) => typeof own(source, "load") !== "function" || source.data != null)) throw new RangeError("image_source_required");
268
+ const keys = [...new Set([...a, ...b].map((band) => band.key))].sort();
269
+ const bands = [], members = [];
270
+ const unsupported = (key, reason) => {
271
+ bands.push({ key, status: "unsupported", reason }); members.push({ key, result: "unexercised", reason_code: reason });
272
+ };
273
+ if (!keys.length) members.push({ key: "sections", result: "unexercised", reason_code: "no_section_keys" });
274
+ for (const [index, key] of keys.entries()) {
275
+ const aa = a.filter((band) => band.key === key), bb = b.filter((band) => band.key === key);
276
+ if (index >= cap) { unsupported(key, "band_cap_reached"); continue; }
277
+ if (aa.length > 1 || bb.length > 1 || [basisSections, buildSections].some((s) => s?.duplicated_keys?.includes(key))) { unsupported(key, "section_mapping_duplicate"); continue; }
278
+ if (!aa.length || !bb.length) { unsupported(key, "section_mapping_missing"); continue; }
279
+ const [sa] = aa, [sb] = bb;
280
+ if (sa.height <= 0 || sb.height <= 0 || sa.width <= 0 || sb.width <= 0) { unsupported(key, "section_not_rendered"); continue; }
281
+ if (!insideCapture(basis, sa, dpr) || !insideCapture(build, sb, dpr)) { unsupported(key, "beyond_capture_height"); continue; }
282
+ const pa = pixelBand(basis, sa, dpr), pb = pixelBand(build, sb, dpr);
283
+ if (pa.width <= 0 || pa.height <= 0 || pb.width <= 0 || pb.height <= 0) { unsupported(key, "section_not_rendered"); continue; }
284
+ const relativeRects = (rects, section) => rects.filter((r) => r.y < section.top + section.height && r.y + r.height > section.top)
285
+ .map((r) => ({ ...r, x: r.x - section.left, y: Math.max(0, r.y - section.top), height: Math.min(r.y + r.height, section.top + section.height) - Math.max(r.y, section.top) }));
286
+ const comparison = compareImages(crop(basis, pa, memory), crop(build, pb, memory), { diff, dpr, memory,
287
+ rectsA: relativeRects(rectsA, pa), rectsB: relativeRects(rectsB, pb) });
288
+ Object.assign(comparison, { key, basis: { top: sa.top, height: sa.height }, build: { top: sb.top, height: sb.height } });
289
+ try { onComparison?.(comparison); } finally { releaseComparison(comparison, memory); }
290
+ bands.push(comparison);
291
+ }
292
+ if (basisSections?.capped || buildSections?.capped) members.push({ key: "sections_cap", result: "unexercised", reason_code: "band_cap_reached" });
293
+ bands.sort((a, b) => (b.mismatch_ratio ?? -1) - (a.mismatch_ratio ?? -1)
294
+ || Math.abs(b.height_delta_px || 0) - Math.abs(a.height_delta_px || 0) || a.key.localeCompare(b.key));
295
+ return { bands, members };
296
+ }