@nextcommerce/campaigns-os 1.60.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -34,10 +34,48 @@ import {
34
34
  polishProducerCleanupError,
35
35
  polishProducerTimeoutError,
36
36
  runWithPolishProducerDeadline,
37
+ VISUAL_READINESS_FONTS_MS, VISUAL_READINESS_AWAIT_MS, VISUAL_SHOT_MS, VISUAL_MAX_CAPTURE_HEIGHT_PX,
37
38
  } from "./polish-deadline.mjs";
38
39
 
39
40
  const NAVIGATION_TIMEOUT_MS = 30_000;
40
41
  const NETWORKIDLE_TIMEOUT_MS = 5_000;
42
+ const visualMaskSelectors = ["[data-next-timer]", "[data-next-timer-display]", "[data-next-timer-expired]",
43
+ '[data-next-display*="price"]', '[data-next-display*="total"]'];
44
+
45
+ // Self-contained functions execute in an isolated world, never the page world.
46
+ async function visualReady(kind, ms) {
47
+ const started = performance.now();
48
+ const spent = () => Math.min(ms, Math.max(0, performance.now() - started));
49
+ if (kind === "fonts") {
50
+ let timer;
51
+ const status = await Promise.race([document.fonts.ready.then(() => "settled"),
52
+ new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), ms); })]);
53
+ clearTimeout(timer);
54
+ return { status, failed_faces: [...document.fonts].filter((face) => face.status === "error").length, waited_ms: spent() };
55
+ }
56
+ let remaining = document.querySelectorAll("[data-next-await]").length;
57
+ if (!remaining) return { status: "not_present", remaining_count: 0, waited_ms: spent() };
58
+ while (remaining && performance.now() - started < ms) {
59
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(0, ms - (performance.now() - started)))));
60
+ remaining = document.querySelectorAll("[data-next-await]").length;
61
+ }
62
+ return { status: remaining ? "timeout" : "settled", remaining_count: remaining, waited_ms: spent() };
63
+ }
64
+
65
+ function visualGeometry() {
66
+ const rect = (element) => { const r = element.getBoundingClientRect();
67
+ return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; };
68
+ const selectors = { timer: "[data-next-timer],[data-next-timer-display],[data-next-timer-expired]",
69
+ price: '[data-next-display*="price"],[data-next-display*="total"]' };
70
+ let capped = false;
71
+ const masks = Object.entries(selectors).map(([group, selector]) => {
72
+ const elements = [...document.querySelectorAll(selector)];
73
+ if (elements.length > 500) capped = true;
74
+ const rects = elements.slice(0, 500).map(rect).filter((r) => r.width > 0 && r.height > 0);
75
+ return { group, count: rects.length, rects };
76
+ });
77
+ return { height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0), masks, mask_cap_reached: capped };
78
+ }
41
79
 
42
80
  const NETWORK_EVENTS = Object.freeze([
43
81
  "Network.requestWillBeSent",
@@ -611,6 +649,8 @@ function closedShadowRoots(root, { frameOwners = false } = {}) {
611
649
  }
612
650
 
613
651
  const BOUND_ENDED = Symbol("bound ended");
652
+ // Chromium's protocol errors for a read whose execution context or frame went away.
653
+ const CONTEXT_LOST = /execution context was destroyed|cannot find context with specified id|no frame (?:for given id|with given id) found|frame with the given id was not found|target closed|session closed/i;
614
654
  // Readability work in a cell ends this long before the adapter cell deadline,
615
655
  // so a readability bound, not that deadline, is what stops it.
616
656
  const READABILITY_CELL_HEADROOM_MS = 1_000;
@@ -712,9 +752,15 @@ function boundedProbeSteps({ clock, session, token, started, boundMs, hardMs = I
712
752
  // function receives the root nodes themselves; with `frameOwners`, also the
713
753
  // iframes holding closed roots, see closedShadowRoots). Returns BOUND_ENDED
714
754
  // when the bound ended a step, null when a step failed (its execution
715
- // context destroyed), or { value } of the read.
716
- async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclaration, leadingArgs, frameOwners = false }) {
755
+ // context destroyed), or { value } of the read. `onFailure`, when given, is
756
+ // told which: "context_lost" for a protocol error naming a destroyed context
757
+ // or frame, "failed" for any other failed step or a thrown exception.
758
+ async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclaration, leadingArgs, frameOwners = false, closedRoots = true, awaitPromise = false, onFailure = null }) {
717
759
  const { send, step } = steps;
760
+ const failed = (error = null) => {
761
+ onFailure?.(CONTEXT_LOST.test(String(error?.message ?? "")) ? "context_lost" : "failed");
762
+ return null;
763
+ };
718
764
  const world = await step(() => send("Page.createIsolatedWorld", {
719
765
  frameId: mainFrame.id,
720
766
  worldName,
@@ -722,23 +768,24 @@ async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclarat
722
768
  }));
723
769
  if (world === BOUND_ENDED) return BOUND_ENDED;
724
770
  const contextId = world.value?.executionContextId;
725
- if (world.error || !Number.isInteger(contextId)) return null;
726
- const tree = await step(() => send("DOM.getDocument", { depth: -1, pierce: true }));
771
+ if (world.error || !Number.isInteger(contextId)) return failed(world.error);
772
+ const tree = closedRoots ? await step(() => send("DOM.getDocument", { depth: -1, pierce: true })) : { value: {} };
727
773
  if (tree === BOUND_ENDED) return BOUND_ENDED;
728
- if (tree.error) return null;
774
+ if (tree.error) return failed(tree.error);
729
775
  const roots = await step(() => Promise.all(closedShadowRoots(tree.value?.root, { frameOwners })
730
776
  .map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
731
777
  if (roots === BOUND_ENDED) return BOUND_ENDED;
732
778
  const rootIds = roots.error ? null : roots.value.map((resolved) => resolved?.object?.objectId);
733
- if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return null;
779
+ if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return failed(roots.error);
734
780
  const evaluated = await step(() => send("Runtime.callFunctionOn", {
735
781
  functionDeclaration,
736
782
  executionContextId: contextId,
737
783
  arguments: [...leadingArgs, ...rootIds.map((objectId) => ({ objectId }))],
738
784
  returnByValue: true,
785
+ ...(awaitPromise ? { awaitPromise: true } : {}),
739
786
  }));
740
787
  if (evaluated === BOUND_ENDED) return BOUND_ENDED;
741
- if (evaluated.error || evaluated.value?.exceptionDetails) return null;
788
+ if (evaluated.error || evaluated.value?.exceptionDetails) return failed(evaluated.error);
742
789
  return { value: evaluated.value?.result?.value };
743
790
  }
744
791
 
@@ -802,7 +849,8 @@ async function runImageProbe({ session, mainFrame, documentContextChanged, probe
802
849
  // screenshot failed, crop_unavailable; so does every crop taken when the
803
850
  // bound ends before the re-read. crop_ms runs from the first crop step to the
804
851
  // end of the re-read.
805
- // Returns { status, capped, coverage_gaps, elements, crops, probe_ms, crop_ms }.
852
+ // Returns { status, capped, coverage_gaps, elements, crops, layout, probe_ms,
853
+ // crop_ms }; layout only when the probe read it.
806
854
  async function probeReadabilityDocument(options) {
807
855
  const token = { cancelled: false };
808
856
  const cropToken = { cancelled: false };
@@ -899,6 +947,7 @@ async function runReadabilityProbe({ session, mainFrame, documentContextChanged,
899
947
  coverage_gaps: read.coverage_gaps,
900
948
  elements,
901
949
  crops,
950
+ ...(read.layout ? { layout: read.layout } : {}),
902
951
  probe_ms: probeMs,
903
952
  crop_ms: cropMs,
904
953
  };
@@ -1024,6 +1073,7 @@ export async function createPolishBrowserAdapter({
1024
1073
  cleanupDeadlineMs,
1025
1074
  startupDeadlineMs,
1026
1075
  networkIdleTimeoutMs,
1076
+ seal = null,
1027
1077
  } = {}) {
1028
1078
  const authCookies = parseAuthCookie(authCookie);
1029
1079
  const boundedCellDeadlineMs = boundedPolishDeadline(cellDeadlineMs, POLISH_BROWSER_CELL_DEADLINE_MS);
@@ -1047,6 +1097,10 @@ export async function createPolishBrowserAdapter({
1047
1097
  chromium: injectedChromium,
1048
1098
  onMissing: polishBrowserMissing,
1049
1099
  signal: startupAbort.signal,
1100
+ launchOptions: seal ? {
1101
+ proxy: { server: seal.origin, bypass: `<-loopback>,127.0.0.1:${seal.port}` },
1102
+ args: ["--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"],
1103
+ } : {},
1050
1104
  });
1051
1105
  } catch (error) {
1052
1106
  if (startupTimedOut) throw polishProducerTimeoutError();
@@ -1083,7 +1137,7 @@ export async function createPolishBrowserAdapter({
1083
1137
  // each setup call is a `step` of the caller's bound (boundedProbeSteps); a
1084
1138
  // bound that ends during setup makes the cell return `ended()` instead of
1085
1139
  // running `work`, and a context created after that is closed.
1086
- const inCaptureContext = async ({ url, viewport, signal, setup = null }, work) => {
1140
+ const inCaptureContext = async ({ url, viewport, signal, setup = null, deviceScaleFactor, requestPolicy = null }, work) => {
1087
1141
  if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
1088
1142
  if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
1089
1143
  if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
@@ -1163,6 +1217,7 @@ export async function createPolishBrowserAdapter({
1163
1217
  const newContext = () => Promise.resolve(browser.newContext({
1164
1218
  viewport: { width: viewport.width, height: viewport.height },
1165
1219
  serviceWorkers: "block",
1220
+ ...(deviceScaleFactor === undefined ? {} : { deviceScaleFactor }),
1166
1221
  })).then((created) => {
1167
1222
  if (setupEnded) void Promise.resolve().then(() => created?.close()).catch(() => {});
1168
1223
  else context = created;
@@ -1171,7 +1226,19 @@ export async function createPolishBrowserAdapter({
1171
1226
  if (setup) await settle(newContext);
1172
1227
  else await newContext();
1173
1228
  assertActive();
1174
- if (authCookies.length > 0) {
1229
+ if (requestPolicy) {
1230
+ await settle(() => context.route("**/*", (route) => {
1231
+ const origin = captureOrigin(route.request().url());
1232
+ if (origin === requestPolicy.allowOrigin) return route.continue();
1233
+ requestPolicy.block(origin, false);
1234
+ return route.abort("blockedbyclient");
1235
+ }));
1236
+ await settle(() => context.routeWebSocket(/.*/, (socket) => {
1237
+ requestPolicy.block(captureOrigin(socket.url().replace(/^ws/, "http")), true);
1238
+ socket.close();
1239
+ }));
1240
+ }
1241
+ if (!seal && authCookies.length > 0) {
1175
1242
  const origin = captureOrigin(url);
1176
1243
  // Fixed message: a URL containing credentials or query data is never echoed.
1177
1244
  if (origin === null) throw new Error("Campaigns OS polish capture requires an HTTP(S) capture URL before applying --auth-cookie.");
@@ -1219,6 +1286,118 @@ export async function createPolishBrowserAdapter({
1219
1286
  return observation;
1220
1287
  };
1221
1288
  return {
1289
+ browserVersion: typeof browser.version === "function" ? `chromium/${browser.version()}` : null,
1290
+ async renderForCompare({ url, viewport, deviceScaleFactor = 1, signal, requestPolicy = null,
1291
+ readiness = {}, probes = [], shots = { actual: true, stabilized: {} }, maxHeightPx = VISUAL_MAX_CAPTURE_HEIGHT_PX,
1292
+ budget = {} } = {}) {
1293
+ if (deviceScaleFactor !== 1) throw new Error("Visual captures require deviceScaleFactor 1.");
1294
+ const captureHeightPx = boundedPolishDeadline(maxHeightPx, VISUAL_MAX_CAPTURE_HEIGHT_PX);
1295
+ // A sealed adapter cannot be used with a weaker per-context policy.
1296
+ const policy = seal ? { allowOrigin: seal.origin } : requestPolicy;
1297
+ const external = policy ? { blocked_count: 0, blocked_websocket_count: 0, blocked_origins: [] } : null;
1298
+ const origins = new Set();
1299
+ const block = (origin, websocket) => {
1300
+ external.blocked_count++; if (websocket) external.blocked_websocket_count++;
1301
+ if (origin && origins.size < 32) origins.add(origin);
1302
+ external.blocked_origins = [...origins].sort();
1303
+ };
1304
+ const clock = isProbeClock(budget.clock) ? budget.clock : realProbeClock;
1305
+ const started = clock.now();
1306
+ return inCaptureContext({ url, viewport, signal, deviceScaleFactor,
1307
+ requestPolicy: policy ? { ...policy, block } : null }, async ({ page, session, awaitActive, cellLeftMs }) => {
1308
+ const skipped = () => ({ status: "skipped", bytes: null, full_height_css: null, crop: null, truncated: false });
1309
+ const result = { status: "navigation_failed", networkidle: { status: "invalid", duration_ms: null },
1310
+ readiness: { fonts: { status: "timeout", failed_faces: 0, waited_ms: 0 },
1311
+ await: { status: "timeout", remaining_count: 0, waited_ms: 0 } }, external, probes: {}, actual: skipped(), stabilized: skipped() };
1312
+ const finish = () => ({ ...result, elapsed_ms: Math.max(0, clock.now() - started) });
1313
+ let response;
1314
+ try { response = await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS })); }
1315
+ catch { return finish(); }
1316
+ if (response?.status() >= 400) { result.status = "http_error"; return finish(); }
1317
+ const initial = await awaitActive(session.send("Page.getFrameTree"));
1318
+ const mainFrame = initial?.frameTree?.frame;
1319
+ const navEnd = clock.now();
1320
+ try {
1321
+ await awaitActive(page.waitForLoadState("networkidle", { timeout: networkIdleMs }));
1322
+ result.networkidle = { status: "settled", duration_ms: Math.max(0, clock.now() - navEnd) };
1323
+ } catch (error) {
1324
+ if (!timeoutError(error)) throw error;
1325
+ result.networkidle = { status: "timeout", duration_ms: Math.max(0, clock.now() - navEnd) };
1326
+ }
1327
+ // Every read and its document check share the same bounded steps. A
1328
+ // timeout names the bound that ended it, as the readability probe does:
1329
+ // run_budget_exhausted when the run budget was the smaller clock bound
1330
+ // and the cell deadline had not ended it, otherwise probe_timeout.
1331
+ const read = async (probe, awaitPromise = false) => {
1332
+ const begin = clock.now(), token = { cancelled: false };
1333
+ const cellLeft = cellLeftMs(), runLeft = (budget.addedRemainingMs ?? Infinity) - (begin - started);
1334
+ const boundMs = Math.max(0, Math.min(probe.boundMs, cellLeft, runLeft));
1335
+ let steps = null;
1336
+ const timedOutBy = () => (!steps?.hardEnded() && runLeft < Math.min(probe.boundMs, cellLeft) ? "run_budget_exhausted" : "probe_timeout");
1337
+ const out = (status, value = null) => ({ status, value, elapsed_ms: Math.max(0, clock.now() - begin),
1338
+ ...(status === "timeout" ? { ended_by: timedOutBy() } : {}) });
1339
+ if (!(boundMs > 0)) return out("timeout");
1340
+ steps = boundedProbeSteps({ clock, session, token, started: begin, boundMs, hardMs: cellLeft });
1341
+ try {
1342
+ let failure = null;
1343
+ const called = await isolatedWorldRead({ steps, mainFrame, worldName: `campaigns-os-visual-${probe.name}`,
1344
+ functionDeclaration: typeof probe.source === "function" ? probe.source.toString() : probe.source,
1345
+ leadingArgs: (Array.isArray(probe.args) ? probe.args : [probe.args]).filter((v) => v !== undefined).map((value) => ({ value })),
1346
+ closedRoots: probe.closedRoots === true, awaitPromise, onFailure: (kind) => { failure = kind; } });
1347
+ if (called === BOUND_ENDED) return out("timeout");
1348
+ // document_changed only on evidence: another main frame or loader,
1349
+ // or a destroyed context. An ordinary probe defect stays failed.
1350
+ const same = await sameMainDocument({ steps, mainFrame });
1351
+ if (same === BOUND_ENDED) return out("timeout");
1352
+ if (!same || failure === "context_lost") return out("document_changed");
1353
+ return called === null ? out("failed") : out("measured", called.value);
1354
+ } catch { return out("failed"); }
1355
+ finally { token.cancelled = true; }
1356
+ };
1357
+ const fontsMs = boundedPolishDeadline(readiness.fontsMs, VISUAL_READINESS_FONTS_MS);
1358
+ const awaitMs = boundedPolishDeadline(readiness.awaitMs, VISUAL_READINESS_AWAIT_MS);
1359
+ for (const [name, ms] of [["fonts", fontsMs], ["await", awaitMs]]) {
1360
+ const observed = await read({ name: `ready_${name}`, source: visualReady, args: [name, ms], boundMs: ms + 100 }, true);
1361
+ if (observed.status === "measured") result.readiness[name] = observed.value;
1362
+ else result.readiness[name].waited_ms = Math.min(ms, observed.elapsed_ms);
1363
+ }
1364
+ for (const probe of probes) {
1365
+ if (probe.select && !probe.select()) continue;
1366
+ result.probes[probe.name] = await read(probe);
1367
+ probe.onResult?.(result.probes[probe.name]);
1368
+ }
1369
+ const layout = await read({ name: "geometry", source: visualGeometry, boundMs: 1500 });
1370
+ // The document loaded: a geometry probe that could not measure is not a navigation failure.
1371
+ if (layout.status !== "measured" || !Number.isFinite(layout.value?.height)) {
1372
+ result.status = layout.status === "document_changed" ? "document_changed"
1373
+ : layout.status === "timeout" ? layout.ended_by : "probe_failed";
1374
+ return finish();
1375
+ }
1376
+ result.status = "rendered";
1377
+ const fullHeight = Math.max(viewport.height, Math.ceil(layout.value.height));
1378
+ const crop = { x: 0, y: 0, width: viewport.width, height: Math.min(fullHeight, captureHeightPx / deviceScaleFactor) };
1379
+ const take = async (stabilized) => {
1380
+ const shot = { status: "failed", bytes: null, full_height_css: fullHeight, crop, truncated: fullHeight > crop.height };
1381
+ const treatment = { animations: "disabled", caret: "hide", mask_color: "#ff00ff", mask_selectors: "visual-masks/v1",
1382
+ masks: layout.value.masks, mask_cap_reached: layout.value.mask_cap_reached };
1383
+ if (stabilized) shot.treatment = treatment;
1384
+ const remainingMs = Math.min(VISUAL_SHOT_MS, cellLeftMs(), (budget.addedRemainingMs ?? Infinity) - (clock.now() - started));
1385
+ if (!(remainingMs > 0)) return { ...shot, status: "timeout" };
1386
+ try {
1387
+ // Screenshot options alone apply treatment, after the actual shot.
1388
+ shot.bytes = await runWithPolishProducerDeadline(() => page.screenshot({ fullPage: true, clip: crop,
1389
+ timeout: VISUAL_SHOT_MS, animations: stabilized ? "disabled" : "allow", caret: stabilized ? "hide" : "initial",
1390
+ ...(stabilized ? { mask: visualMaskSelectors.map((selector) => page.locator(selector)), maskColor: "#ff00ff" } : {}) }),
1391
+ { timeoutMs: remainingMs });
1392
+ shot.status = "captured";
1393
+ } catch (error) { shot.status = timeoutError(error) || error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "timeout" : "failed"; }
1394
+ return shot;
1395
+ };
1396
+ if (shots.actual) result.actual = await take(false);
1397
+ if (shots.stabilized) result.stabilized = await take(true);
1398
+ return finish();
1399
+ });
1400
+ },
1222
1401
  // `imageProbe` ({ clock, remainingMs, cellBoundMs, imageCap }) opts the
1223
1402
  // cell into the media-weight image probe; the observation then carries
1224
1403
  // `imageProbe` beside the page-load fields. `readabilityProbe` ({ clock,
@@ -0,0 +1,52 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalJson } from "./polish-capture.mjs";
3
+
4
+ export const CAPTURES_SCHEMA = "campaigns-os-polish-captures/v0";
5
+ export const CAPTURES_PRODUCER = "campaigns-os polish capture";
6
+ const object = (value) => value !== null && typeof value === "object"
7
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
8
+ const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
9
+ const viewports = new Map([["desktop", [1440, 1200]], ["mobile", [390, 844]]]);
10
+ export function capturesIntegrity(record) {
11
+ const { integrity: _ignored, ...rest } = record || {};
12
+ return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
13
+ }
14
+
15
+ // A-C4: one actual build capture per planned route; either viewport may supply it.
16
+ export function capturedBuildForEveryRoute(record) {
17
+ const routes = record?.subject?.routes;
18
+ return Array.isArray(routes) && routes.length > 0 && Array.isArray(record?.cells)
19
+ && routes.every((route) => {
20
+ const path = typeof route === "string" ? route : own(route, "requested_route") ?? own(route, "route");
21
+ return typeof path === "string" && record.cells.some((cell) => object(cell) && cell.side === "build"
22
+ && cell.route === path && (typeof route === "string" || cell.page_id === route.page_id)
23
+ && cell.status === "rendered" && cell.actual?.status === "captured");
24
+ });
25
+ }
26
+
27
+ // Data only: ignored PNG files are deliberately not opened by the gate.
28
+ export function packageCapturesSatisfy(record, report) {
29
+ if (!record || Object.getPrototypeOf(record) !== Object.prototype
30
+ || record.schema_version !== CAPTURES_SCHEMA || record.performed_by !== CAPTURES_PRODUCER
31
+ || record.integrity !== capturesIntegrity(record)
32
+ || !record.subject?.build_fingerprint
33
+ || record.subject.build_fingerprint !== report?.stages?.assembly?.build_fingerprint
34
+ || !Array.isArray(record.cells) || !record.cells.length) return false;
35
+ return capturedBuildForEveryRoute(record) && record.cells.every((cell) => {
36
+ if (!object(cell)) return false;
37
+ const viewport = viewports.get(own(cell, "viewport"));
38
+ if (cell.state !== "default_load" || cell.device_scale_factor !== 1
39
+ || !viewport || cell.width !== viewport[0] || cell.height !== viewport[1]) return false;
40
+ return [cell.actual, cell.stabilized].every((shot) => {
41
+ if (!object(shot)) return false;
42
+ if (shot.status !== "captured") return ["skipped", "failed", "timeout"].includes(own(shot, "status"));
43
+ const crop = shot.crop;
44
+ return Number.isFinite(shot.full_height_css) && shot.full_height_css > 0
45
+ && crop?.x === 0 && crop.y === 0 && crop.width === cell.width
46
+ && crop.height === Math.min(shot.full_height_css, 12000)
47
+ && shot.truncated === (shot.full_height_css > 12000)
48
+ && shot.png?.width_px === crop.width && shot.png.height_px === crop.height
49
+ && /^sha256:[a-f0-9]{64}$/.test(shot.png.sha256);
50
+ });
51
+ });
52
+ }
@@ -19,6 +19,20 @@ export const READABILITY_PROBE_CELL_MS = 1_500;
19
19
  export const READABILITY_PROBE_RUN_MS = 120_000;
20
20
  export const READABILITY_CROP_RUN_MS = 30_000;
21
21
  export const READABILITY_ADDED_RUN_MS = 300_000;
22
+ export const VISUAL_READINESS_FONTS_MS = 3_000;
23
+ export const VISUAL_READINESS_AWAIT_MS = 5_000;
24
+ export const VISUAL_SHOT_MS = 10_000;
25
+ export const VISUAL_CAPTURE_RUN_MS = 240_000;
26
+ export const VISUAL_DIFF_RUN_MS = 60_000;
27
+ export const VISUAL_ADDED_RUN_MS = 300_000;
28
+ export const VISUAL_MAX_CAPTURE_HEIGHT_PX = 12_000;
29
+ // Element parity bounds, spent inside the visual cells above: the routes a
30
+ // run probes, one probe's bound per side, the probe's own in-page walk, and
31
+ // every parity probe of a run.
32
+ export const PARITY_MAX_ROUTES = 32;
33
+ export const PARITY_PROBE_CELL_MS = 2_000;
34
+ export const PARITY_PROBE_WALK_MS = 1_500;
35
+ export const PARITY_PROBE_RUN_MS = 60_000;
22
36
 
23
37
  export function boundedPolishDeadline(value, fallback) {
24
38
  return Number.isSafeInteger(value) && value > 0 && value <= fallback ? value : fallback;
@@ -1,3 +1,4 @@
1
+ import { packageCapturesSatisfy, capturedBuildForEveryRoute, capturesIntegrity, CAPTURES_SCHEMA, CAPTURES_PRODUCER } from "./polish-captures-record.mjs";
1
2
  import { HIDDEN_EAGER_MEDIA_SCOPE } from "./polish-page-load.mjs";
2
3
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
3
4
 
@@ -151,7 +152,7 @@ function polishEvidence(stage, report) {
151
152
  return null;
152
153
  }
153
154
 
154
- function fieldHasEvidence(value, field) {
155
+ function fieldHasEvidence(value, field, report, newEvidence) {
155
156
  if (field === "issues") return Array.isArray(value);
156
157
  if (field === "commands") {
157
158
  if (!Array.isArray(value) || value.length === 0) return false;
@@ -159,6 +160,7 @@ function fieldHasEvidence(value, field) {
159
160
  }
160
161
  if (field === "visual_review") {
161
162
  if (!isObject(value)) return false;
163
+ if (newEvidence || Object.hasOwn(value, "captures")) return Object.hasOwn(value, "captures") && packageCapturesSatisfy(value.captures, report);
162
164
  const screenshots = value.screenshots || value.screenshot_paths || value.paths || value.urls;
163
165
  return Array.isArray(screenshots) && screenshots.some(nonEmptyString);
164
166
  }
@@ -308,13 +310,28 @@ function brandBleedResidualKinds(value) {
308
310
  return kinds;
309
311
  }
310
312
 
311
- function evidenceProblems(evidence, report) {
313
+ function captureProblem(record, report) {
314
+ if (!record || Object.getPrototypeOf(record) !== Object.prototype || record.schema_version !== CAPTURES_SCHEMA || record.performed_by !== CAPTURES_PRODUCER) return "schema or producer";
315
+ if (record.integrity !== capturesIntegrity(record)) return "integrity";
316
+ if (record.subject?.build_fingerprint !== currentBuildFingerprint(report)) return "build binding";
317
+ if (!Array.isArray(record.cells) || !record.cells.length) return "cells";
318
+ if (!capturedBuildForEveryRoute(record)) return "no captured build screenshot for every planned route";
319
+ return "cell state, viewport, crop or PNG dimensions";
320
+ }
321
+
322
+ function evidenceProblems(evidence, report, newEvidence) {
312
323
  if (!isObject(evidence)) {
313
324
  return ["stages.polish.evidence must be an object with the required polish evidence categories."];
314
325
  }
315
326
  const problems = [];
316
327
  for (const field of POLISH_GATE_REQUIRED_EVIDENCE) {
317
- if (!fieldHasEvidence(evidence[field], field)) {
328
+ if (!fieldHasEvidence(evidence[field], field, report, newEvidence)) {
329
+ if (field === "visual_review" && (newEvidence || Object.hasOwn(evidence[field] || {}, "captures"))) {
330
+ problems.push(Object.hasOwn(evidence[field] || {}, "captures")
331
+ ? `stages.polish.evidence.visual_review.captures is not a current package capture record (${captureProblem(evidence[field].captures, report)}); run polish capture.`
332
+ : "stages.polish.evidence.visual_review.captures is missing; record polish needs a package capture by this version; run polish capture.");
333
+ continue;
334
+ }
318
335
  problems.push(`stages.polish.evidence.${field} is missing or incomplete.`);
319
336
  }
320
337
  }
@@ -355,7 +372,7 @@ function commandMentionsBuild(stage, evidence) {
355
372
  return commands.some((entry) => /next-campaigns-build|campaigns-os\s+next\s+build/i.test(String(isObject(entry) ? entry.command || entry.name || "" : entry)));
356
373
  }
357
374
 
358
- function evaluateStructuredPolishGate({ report, required = false, now = Date.now(), currentOutputFingerprint = null } = {}) {
375
+ function evaluateStructuredPolishGate({ report, required = false, now = Date.now(), currentOutputFingerprint = null, newEvidence = false } = {}) {
359
376
  if (!isObject(report)) {
360
377
  return required
361
378
  ? {
@@ -599,7 +616,7 @@ function evaluateStructuredPolishGate({ report, required = false, now = Date.now
599
616
  };
600
617
  }
601
618
 
602
- const problems = evidenceProblems(evidence, report);
619
+ const problems = evidenceProblems(evidence, report, newEvidence);
603
620
  if (problems.length) {
604
621
  return {
605
622
  status: "blocked",
@@ -635,6 +652,7 @@ function evaluateStructuredPolishGate({ report, required = false, now = Date.now
635
652
  current_source_package_material_fingerprint: currentSourcePackageFingerprint,
636
653
  assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
637
654
  performed_by: producer,
655
+ screenshot_evidence: Object.hasOwn(evidence.visual_review, "captures") ? "package_captures" : "legacy_paths",
638
656
  waiver: sourcePackageFreshnessWaiver,
639
657
  warnings,
640
658
  };
@@ -679,9 +697,10 @@ export function evaluatePolishGate({
679
697
  required = false,
680
698
  now = Date.now(),
681
699
  hiddenEagerMediaGate = undefined,
700
+ newEvidence = false,
682
701
  currentOutputFingerprint = null,
683
702
  } = {}) {
684
- const base = evaluateStructuredPolishGate({ report, required, now, currentOutputFingerprint });
703
+ const base = evaluateStructuredPolishGate({ report, required, now, currentOutputFingerprint, newEvidence });
685
704
  if (!terminalAssembly(report)) return base;
686
705
  if (hiddenEagerMediaGate === undefined && !required) return base;
687
706
 
@@ -3,6 +3,10 @@ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
5
5
  import { dirname, join, resolve } from "node:path";
6
+ import { computeDesignSourcePackageMaterialFingerprint } from "./design-source-package.mjs";
7
+ import { runVisualPass, unavailableCapturesRecord, CAPTURES_SCHEMA, CAPTURES_PRODUCER } from "./polish-visual.mjs";
8
+ import { readRegularFileSync } from "./source-html-server.mjs";
9
+ import { buildElementParityRecord, ELEMENT_PARITY_PRODUCER, ELEMENT_PARITY_SCHEMA } from "./parity/element-parity-record.mjs";
6
10
  import { computeBuildFingerprint, describeBuildOutputDrift } from "./built-site-scope.mjs";
7
11
  import {
8
12
  buildPageLoadCapture,
@@ -310,18 +314,22 @@ function canonicalJson(value) {
310
314
  return JSON.stringify(canonicalize(value));
311
315
  }
312
316
 
313
- // Covers every package-owned capture key: a page_load, media_weight or
314
- // readability record that changes during the browser pass refuses the
315
- // attachment.
317
+ // Covers every package-owned capture key: a page_load, media_weight,
318
+ // readability, captures or element_parity record that changes during the
319
+ // browser pass refuses the attachment.
316
320
  function conflictToken(visualReview) {
317
321
  const hasPageLoad = Object.hasOwn(visualReview, "page_load");
318
322
  const hasMediaWeight = Object.hasOwn(visualReview, "media_weight");
319
323
  const hasReadability = Object.hasOwn(visualReview, "readability");
320
- if (!hasPageLoad && !hasMediaWeight && !hasReadability) return "absent";
324
+ const hasCaptures = Object.hasOwn(visualReview, "captures");
325
+ const hasElementParity = Object.hasOwn(visualReview, "element_parity");
326
+ if (!hasPageLoad && !hasMediaWeight && !hasReadability && !hasCaptures && !hasElementParity) return "absent";
321
327
  const value = {
322
328
  ...(hasPageLoad ? { value: visualReview.page_load } : {}),
323
329
  ...(hasMediaWeight ? { media_weight: visualReview.media_weight } : {}),
324
330
  ...(hasReadability ? { readability: visualReview.readability } : {}),
331
+ ...(hasCaptures ? { captures: visualReview.captures } : {}),
332
+ ...(hasElementParity ? { element_parity: visualReview.element_parity } : {}),
325
333
  };
326
334
  return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
327
335
  }
@@ -444,6 +452,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
444
452
  source_package_material_fingerprint: assemblySourcePackageMaterialFingerprint(report),
445
453
  },
446
454
  current_source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(report),
455
+ source_package_on_disk: sourcePackageOnDisk(packet, resolvedPacketPath),
447
456
  page_load_conflict_token: conflictToken(visualReview),
448
457
  },
449
458
  plan: plan === null ? null : bindingPlanProjection(plan),
@@ -508,8 +517,11 @@ export function mergePolishPageLoadEvidence(report, pageLoad) {
508
517
  // readability attaches on its own: only a package-produced record bound to
509
518
  // the current build, whose every shared cell carries the integrity of the
510
519
  // matching page_load capture; any other value, or none, removes an earlier
511
- // one.
512
- export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeight = null, readability = null, now = null } = {}) {
520
+ // one. element_parity attaches under its own rule (elementParityAttaches),
521
+ // beside the captures record this call attaches; with a pageLoad, any other
522
+ // value, or none, removes an earlier one, and with no pageLoad it stays as it
523
+ // is.
524
+ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeight = null, readability = null, captures, elementParity, now = null } = {}) {
513
525
  let merged;
514
526
  if (pageLoad === null || pageLoad === undefined) {
515
527
  const { visualReview } = captureReportAncestors(report);
@@ -537,6 +549,14 @@ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeigh
537
549
  }
538
550
  }
539
551
  const visualReview = merged.stages.polish.evidence.visual_review;
552
+ if (pageLoad && captures !== undefined) {
553
+ if (!capturesAttach(captures, { report, pageLoad })) throw new Error("polish capture can attach only package-produced captures for the same page_load capture.");
554
+ visualReview.captures = captures;
555
+ }
556
+ if (pageLoad) {
557
+ delete visualReview.element_parity;
558
+ if (elementParityAttaches(elementParity, { report, captures })) visualReview.element_parity = elementParity;
559
+ }
540
560
  delete visualReview.readability;
541
561
  if (readabilityAttaches(readability, { buildFingerprint: currentBuildFingerprint(report), pageLoad: visualReview.page_load })) {
542
562
  visualReview.readability = readability;
@@ -544,6 +564,27 @@ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeigh
544
564
  return merged;
545
565
  }
546
566
 
567
+ function sourcePackageOnDisk(packet, packetPath) {
568
+ if (!packet.design_source_package?.path) return null;
569
+ try { return computeDesignSourcePackageMaterialFingerprint(JSON.parse(readRegularFileSync(resolve(dirname(packetPath), packet.design_source_package.path)).toString("utf8"))); }
570
+ catch { return "unavailable"; }
571
+ }
572
+
573
+ export function capturesAttach(record, { report, pageLoad }) {
574
+ return isPlainObject(record) && record.schema_version === CAPTURES_SCHEMA && record.performed_by === CAPTURES_PRODUCER
575
+ && record.subject?.build_fingerprint === currentBuildFingerprint(report)
576
+ && ["routes", "route_scope", "viewports"].every((key) => canonicalJson(record.subject?.[key]) === canonicalJson(pageLoad?.subject?.[key]));
577
+ }
578
+
579
+ // A package-produced element_parity record bound to the current build and to
580
+ // the captures record attached in the same merge. Readability plays no part.
581
+ export function elementParityAttaches(record, { report, captures }) {
582
+ const buildFingerprint = currentBuildFingerprint(report);
583
+ return isPlainObject(record) && record.schema_version === ELEMENT_PARITY_SCHEMA && record.performed_by === ELEMENT_PARITY_PRODUCER
584
+ && Boolean(buildFingerprint) && record.subject?.build_fingerprint === buildFingerprint
585
+ && isPlainObject(captures) && typeof captures.integrity === "string" && record.subject?.captures_integrity === captures.integrity;
586
+ }
587
+
547
588
  function readabilityAttaches(record, { buildFingerprint, pageLoad }) {
548
589
  if (!isPlainObject(record)
549
590
  || record.schema_version !== READABILITY_SCHEMA
@@ -629,7 +670,40 @@ function captureCampaignSlug(packet, report) {
629
670
 
630
671
  export async function capturePolishPageLoad(options = {}) {
631
672
  const plan = planPolishCapture({ packet: options.packet, baseUrl: options.baseUrl });
632
- return runPolishCapture({ ...options, plan });
673
+ const result = await runPolishCapture({ ...options, plan });
674
+ if (!options.visual) return result;
675
+ const freeze = (value) => { if (value && typeof value === "object" && !Object.isFrozen(value)) {
676
+ Object.values(value).forEach(freeze); Object.freeze(value);
677
+ } return value; };
678
+ const frozen = freeze(result);
679
+ options.onFrozen?.(frozen);
680
+ // Element parity rides C's visual cells (its probe on both sides of each);
681
+ // its modules load only here, where the plan has a route.
682
+ let parity = null;
683
+ try {
684
+ const importer = options.visual.parityImporter ?? (() => Promise.all([import("./parity/probe.mjs"), import("./parity/snapshot.mjs")]));
685
+ const [probe, snapshot] = await importer();
686
+ parity = { snapshot, run: probe.createParityProbeRun({ routes: plan.routes }) };
687
+ } catch { /* no parity probe: no element_parity record, so the merge removes an earlier one */ }
688
+ let captures;
689
+ try { captures = await runVisualPass({ ...options, ...options.visual, frozen, probes: parity ? [parity.run.probe] : [] }); }
690
+ catch {
691
+ // Even unexpected artifact/dependency failures cannot discard prior work.
692
+ captures = unavailableCapturesRecord({ plan, subject: { ...frozen.page_load.subject,
693
+ source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(options.report) }, measuredAt: options.now });
694
+ }
695
+ let elementParity = null;
696
+ if (parity) {
697
+ // Probe files are written after both visual browsers have closed.
698
+ const targetRepo = options.visual.targetRepo ?? options.targetRepo;
699
+ try {
700
+ elementParity = buildElementParityRecord({ plan, captures, run: parity.run, packet: options.packet,
701
+ packetPath: options.visual.packetPath ?? options.packetPath, buildFingerprint: currentBuildFingerprint(options.report),
702
+ sourceFingerprint: currentSourcePackageMaterialFingerprint(options.report),
703
+ writeFile: (value) => parity.snapshot.writeParityFile(targetRepo, value), measuredAt: options.now ?? new Date() });
704
+ } catch { elementParity = null; }
705
+ }
706
+ return { ...frozen, captures, ...(elementParity ? { element_parity: elementParity } : {}) };
633
707
  }
634
708
 
635
709
  // The readability-only capture of a campaign with no capturable page_load