@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
package/src/qc-accept.mjs CHANGED
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
13
13
  import { isNamedHuman, validateWaiverAttribution } from "./checkpoint-waiver.mjs";
14
14
  import { DOCTOR_SIDECAR_SCHEMA } from "./doctor-sidecar.mjs";
15
15
  import { cmd } from "./install-invocation.mjs";
16
+ import { PARITY_TOLERANCES } from "./parity/compare.mjs";
16
17
  import { canonicalJson } from "./polish-capture.mjs";
17
18
  import { QC_CHECK_REGISTRY } from "./qc-check-registry.mjs";
18
19
  import { QC_LEGS, QC_REASON, fingerprint12, qcResultRef } from "./qc-results.mjs";
@@ -27,7 +28,117 @@ export const QC_DISPOSITION = Object.freeze({ OPEN: "open", OPERATOR_ACCEPTED: "
27
28
  // full QA verdict, or, on the Polish leg, the package-owned record the
28
29
  // accepted check is read from (QC_CHECK_REGISTRY `record`).
29
30
  const QC_LEG_SOURCES = Object.freeze({ doctor: "doctor_sidecar", qa: "qa_full_verdict" });
30
- export const QC_POLISH_RECORD_SOURCES = Object.freeze({ media_weight: "polish_media_weight", readability: "polish_readability" });
31
+ export const QC_POLISH_RECORD_SOURCES = Object.freeze({ media_weight: "polish_media_weight", readability: "polish_readability", captures: "polish_captures", element_parity: "polish_element_parity" });
32
+
33
+ const DESIGN_ACCESS_TEXT = Object.freeze({
34
+ design_access_missing: "A design file is recorded for this page, but this version of Campaigns OS cannot read design files. Element parity compares the built page with the page's source HTML export instead, so differences between the export and the design file are not checked. Nothing needs to change to continue. Where it matters, compare the export with the design file by hand.",
35
+ design_reference_not_recorded: "This page names a design file that the Design Source Package does not record. Rerun intake with `--force` to record it (this resets stage evidence), or continue: element parity still runs against the source HTML.",
36
+ design_reference_missing: "The Design Source Package records a design file, but this page no longer has a design-file declaration. Restore the declaration, or rerun intake with `--force` to drop the recorded file.",
37
+ design_reference_changed: "The design file named for this page differs from the one the Design Source Package records. Rerun intake with `--force` to record the current one.",
38
+ design_reference_conflict: "The CampaignSpec page and the source-html manifest name different design files or frames for this page (or the manifest's producer file key differs). The package records the CampaignSpec's. Correct the wrong one and rerun intake with `--force`.",
39
+ });
40
+ const probeIncomplete = (code) => `The element probe did not complete (${code}); not compared.`;
41
+ const notRendered = (code) => `The source or built page did not render (${code}).`;
42
+ const partlyRead = "Only part of the page was read; findings are not accept-eligible.";
43
+ const textNotRead = "Some page text was not read (long text, closed components or frames) on the source page, the built page or both. Where the built page's text was not read, copy is not called missing.";
44
+ const packageMismatch = "The Design Source Package on disk does not match the build's; rerun intake or polish capture.";
45
+ const PARITY_COVERAGE_TEXT = Object.freeze({
46
+ ...Object.fromEntries(["navigation_failed", "http_error", "producer_timeout", "browser_unavailable", "source_html_missing", "source_root_unavailable"].map((code) => [code, notRendered(code)])),
47
+ ...Object.fromEntries(["probe_timeout", "probe_failed", "probe_not_run", "document_changed", "run_budget_exhausted"].map((code) => [code, probeIncomplete(code)])),
48
+ page_not_captured: "Not captured for element parity: beyond the 32-page run limit, or added since the last capture. Rerun polish capture.",
49
+ readiness_incomplete: "Fonts or awaited content were not ready in time; not compared.",
50
+ source_identity_unrecorded: "The Design Source Package records no hash for this source file; not compared.",
51
+ probe_file_absent: "Probe files are local and not committed; rerun polish capture on this machine.",
52
+ design_elements_unavailable: "The source has no text elements (for example an image-only export); element parity is unavailable.",
53
+ viewport_mismatch: "Source and build rendered at different sizes or pixel ratios; nothing is resized.",
54
+ probe_output_invalid: "The page measurements could not be read; not compared.",
55
+ comparator_failed: "The comparison could not run; not compared.",
56
+ element_cap_reached: partlyRead,
57
+ probe_time_cap_reached: partlyRead,
58
+ page_text_truncated: textNotRead,
59
+ closed_shadow_root_not_read: textNotRead,
60
+ frame_not_read: textNotRead,
61
+ font_face_cap_reached: "More than 200 font faces; web-font availability was not fully checked.",
62
+ source_package_unavailable: packageMismatch,
63
+ source_package_stale: packageMismatch,
64
+ });
65
+ export const QC_COVERAGE_TEXT = Object.freeze({
66
+ "design.access": Object.freeze({
67
+ design_access_missing: DESIGN_ACCESS_TEXT.design_access_missing,
68
+ design_reference_not_recorded: DESIGN_ACCESS_TEXT.design_reference_not_recorded,
69
+ }),
70
+ "parity.element": PARITY_COVERAGE_TEXT,
71
+ });
72
+ export const QC_COVERAGE_LIMIT_TEXT = Object.freeze({ "parity.element": Object.freeze({
73
+ font_usage_not_observed: "The font each glyph was drawn with is not read; a web font that loaded is assumed used.",
74
+ }) });
75
+
76
+ export function designAccessSummary(row) {
77
+ return row.check === "design.access" && row.result === "review" ? DESIGN_ACCESS_TEXT[row.reason_code] ?? null : null;
78
+ }
79
+
80
+ // A parity.element row in words, from its read-time observation: the design
81
+ // element's label, then each comparator member with its measured values and
82
+ // tolerance. Geometry also names the design selector path. Null for any other
83
+ // row, and for a parity row with nothing to say (an unexercised row has no
84
+ // observation members).
85
+ const signed = (value, unit = "") => `${value < 0 ? "−" : "+"}${Math.abs(value)}${unit}`;
86
+ const px = (value) => (typeof value === "number" ? `${value}px` : String(value));
87
+ const rgb = (value) => (Array.isArray(value) ? `rgb(${value.join(",")})` : "not measured");
88
+ const PARITY_GEOMETRY_TEXT = Object.freeze({
89
+ gap: (delta) => `moved ${signed(delta, "px")} against the element above (gap, tolerance ${PARITY_TOLERANCES.gap_px}px)`,
90
+ x_position: (delta) => `moved ${signed(delta, "px")} horizontally (x position, tolerance ${PARITY_TOLERANCES.position_px}px)`,
91
+ width: (delta) => `resized ${signed(delta, "px")} in width (width, tolerance ${PARITY_TOLERANCES.size_px}px)`,
92
+ height: (delta) => `resized ${signed(delta, "px")} in height (height, tolerance ${PARITY_TOLERANCES.size_px}px)`,
93
+ });
94
+ const PARITY_TYPE_TOLERANCES = Object.freeze({ font_size: `${PARITY_TOLERANCES.font_size_px}px`, line_height: `${PARITY_TOLERANCES.line_height_px}px`,
95
+ letter_spacing: `${PARITY_TOLERANCES.letter_spacing_px}px`, font_weight: "0" });
96
+ const PARITY_COPY_TEXT = Object.freeze({
97
+ copy_missing: () => "design copy not found on the built page",
98
+ copy_changed: (similarity) => `design copy changed (similarity ${similarity})`,
99
+ page_text_truncated: () => "design copy not found in the readable part of the built page (review)",
100
+ build_text_not_read: () => "design copy not found in the readable part of the built page (review)",
101
+ });
102
+
103
+ function parityMemberText(category, member, reason, result) {
104
+ const { property, design, build, delta } = member;
105
+ const review = result === "review" ? " (review)" : "";
106
+ if (category === "geom") return PARITY_GEOMETRY_TEXT[property]?.(delta) ?? `${property} ${signed(delta, "px")}`;
107
+ if (category === "type") {
108
+ const name = property === "font_face" ? "web font" : property.replace(/_/g, "-");
109
+ const units = ["font_size", "line_height", "letter_spacing"].includes(property) ? "px" : "";
110
+ const tolerance = typeof delta === "number" && Object.hasOwn(PARITY_TYPE_TOLERANCES, property) ? ` (${signed(delta, units)}, tolerance ${PARITY_TYPE_TOLERANCES[property]})` : "";
111
+ const value = units ? px : String;
112
+ return `${name} ${value(design)} → ${value(build)}${tolerance}${review}`;
113
+ }
114
+ if (category === "color") {
115
+ if (property === "color") return `colour not measured (source ${design ?? "unresolved"}, build ${build ?? "unresolved"})${review}`;
116
+ const name = property === "background_color" ? "background colour" : "text colour";
117
+ const tolerance = typeof delta === "number" ? ` (${delta}/255, tolerance ${PARITY_TOLERANCES.color_channel})` : "";
118
+ return `${name} ${rgb(design)} → ${rgb(build)}${tolerance}${review}`;
119
+ }
120
+ return PARITY_COPY_TEXT[reason]?.(delta) ?? null;
121
+ }
122
+
123
+ export function parityRowSummary(row) {
124
+ if (row?.check !== "parity.element") return null;
125
+ const key = String(row.subject?.key ?? "");
126
+ if (key === "comparison") return row.result === "review" && row.reason_code === "source_identity_mismatch"
127
+ ? "source on disk differs from the recorded source; element parity not compared" : null;
128
+ const category = key.split(":")[0];
129
+ const observation = isPlainObject(row.observation) ? row.observation : {};
130
+ const members = Array.isArray(observation.members) ? observation.members.filter(isPlainObject) : [];
131
+ if (!["copy", "type", "color", "geom"].includes(category) || !members.length) return null;
132
+ const results = new Map((Array.isArray(row.members) ? row.members : []).map((member) => [member?.key, member]));
133
+ const texts = members.map((member) => {
134
+ const reported = results.get(member.property);
135
+ return parityMemberText(category, member, reported?.reason_code ?? row.reason_code, reported?.result ?? row.result);
136
+ }).filter(Boolean);
137
+ if (!texts.length) return null;
138
+ const selector = isNonEmptyString(observation.design_selector_path) ? observation.design_selector_path : null;
139
+ const label = isNonEmptyString(observation.label) ? observation.label : selector ?? key;
140
+ return `${label}${category === "geom" && selector && label !== selector ? ` (${selector})` : ""}: ${texts.join("; ")}`;
141
+ }
31
142
 
32
143
  // Null for a Polish check with no record, or a leg with no source.
33
144
  export function measuredSourceFor(leg, check) {
@@ -165,9 +276,12 @@ function assessOne(record, currentById, nowMs) {
165
276
  if (!(Date.parse(record.accepted_at) > Date.parse(record.measured_at))) return { status: "inert", why: "accepted_not_after_measurement" };
166
277
  const current = currentById.get(record.result_id);
167
278
  if (!current) return { status: "orphaned", why: "no_current_result" };
168
- if (current.result === "unexercised" && [QC_REASON.STALE_BINDING, QC_REASON.EVIDENCE_NOT_REPRODUCIBLE].includes(current.reason_code)) {
279
+ if (current.result === "unexercised" && [QC_REASON.STALE_BINDING, QC_REASON.EVIDENCE_NOT_REPRODUCIBLE, "capture_file_absent"].includes(current.reason_code)) {
169
280
  return { status: "lapsed", why: current.reason_code, current };
170
281
  }
282
+ // Visual references lose their revision-baseline warning when provenance
283
+ // becomes independent (§6); that disposition precedes fingerprint changes.
284
+ if (current.check === "visual.reference" && current.result !== "warning") return { status: "inert", why: "target_not_warning", current };
171
285
  if (current.state_fingerprint !== record.state_fingerprint
172
286
  || current.check !== record.check
173
287
  || current.leg !== record.leg
@@ -325,6 +439,49 @@ function readabilityReviewCrop(row) {
325
439
  return typeof ref?.path === "string" ? ref.path : undefined;
326
440
  }
327
441
 
442
+ // A layout.conventions warning in words, from its observation: each
443
+ // floating CTA container that holds more than its control, with what else it
444
+ // holds, or each CTA label under the minimum size with its measured size.
445
+ // Null for any other row.
446
+ function layoutSummary(row) {
447
+ if (row?.check !== "layout.conventions" || row.result !== "warning" || !isPlainObject(row.observation)) return null;
448
+ const flagged = new Set((Array.isArray(row.members) ? row.members : []).filter((member) => member?.result === "warning").map((member) => member.key));
449
+ const where = `${row.subject?.page ?? "(no page)"}${row.subject?.viewport ? ` ${row.subject.viewport}` : ""}`;
450
+ if (row.subject?.key === "floating_cta") {
451
+ const containers = (Array.isArray(row.observation.containers) ? row.observation.containers : []).filter((entry) => flagged.has(entry?.selector_path));
452
+ if (!containers.length) return null;
453
+ const texts = containers.map((entry) => {
454
+ const controls = Array.isArray(entry.controls) ? entry.controls.length : 0;
455
+ const other = Array.isArray(entry.other_text) ? entry.other_text : [];
456
+ const more = Number.isInteger(entry.other_text_count) && entry.other_text_count > other.length ? `, +${entry.other_text_count - other.length} more` : "";
457
+ const text = entry.other_text_count ? `; other text ${other.join(", ")}${more}` : "";
458
+ return `${entry.selector_path} (${entry.position}) holds ${controls} control${controls === 1 ? "" : "s"}${text}`;
459
+ });
460
+ return `${where}: floating CTA should hold its control alone: ${texts.join(" | ")}`;
461
+ }
462
+ if (row.subject?.key === "cta_label_size") {
463
+ const small = (Array.isArray(row.observation.members) ? row.observation.members : []).filter((member) => flagged.has(member?.selector_path));
464
+ if (!small.length) return null;
465
+ return `${where}: CTA label under ${row.observation.min_font_size_px}px: ${small.map((member) => `${member.selector_path} ${member.font_size_px}px (${member.role})`).join(", ")}`;
466
+ }
467
+ return null;
468
+ }
469
+
470
+ function visualSummary(row) {
471
+ const item = row.observation;
472
+ if (!item || !row.check?.startsWith("visual.")) return null;
473
+ const label = `${row.subject.page} ${row.subject.viewport || ""}`.trim();
474
+ if (row.check === "visual.reference" && row.reason_code === "self_referential") {
475
+ return `${label} reference ${item.ref_id} is the build's own output (${(item.provenance?.reasons || []).join(", ")}); accepting adopts a revision baseline, not design proof`;
476
+ }
477
+ if (row.check !== "visual.diff" || row.result !== "review") return null;
478
+ const percent = (value) => Number.isFinite(value) ? `${(100 * value).toFixed(2)}%` : "unavailable";
479
+ const delta = (value) => Number.isFinite(value) ? `${value >= 0 ? "+" : ""}${value}px` : "unavailable";
480
+ const worst = item.bands?.find((band) => band.status === "compared");
481
+ const heatmap = item.artifacts?.find((a) => a.band_key === worst?.key)?.heatmap?.path || item.artifacts?.[0]?.heatmap?.path;
482
+ return `${label} vs ${item.basis === "source_html" ? "source HTML" : item.ref_id}: ${percent(item.page?.mismatch_ratio)} differing, ${delta(item.page?.height_delta_px)} height${worst ? `; worst band ${worst.key} (${percent(worst.mismatch_ratio)}, ${delta(worst.height_delta_px)})` : ""}${heatmap ? `; heatmap ${heatmap}` : ""}`;
483
+ }
484
+
328
485
  function handoffEntry(row) {
329
486
  const crop = readabilityReviewCrop(row);
330
487
  return {
@@ -338,7 +495,7 @@ function handoffEntry(row) {
338
495
  reason_code: row.reason_code,
339
496
  accept_eligible: row.accept_eligible === true,
340
497
  members: Array.isArray(row.members) ? row.members : [],
341
- summary: readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
498
+ summary: designAccessSummary(row) ?? parityRowSummary(row) ?? visualSummary(row) ?? layoutSummary(row) ?? readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
342
499
  ...(crop === undefined ? {} : { crop }),
343
500
  };
344
501
  }
@@ -366,7 +523,8 @@ function groupOpenWarnings(entries, rows = []) {
366
523
  pages,
367
524
  accept_eligible: results.every((entry) => entry.accept_eligible),
368
525
  summary: results.length > 1
369
- ? readabilityPairSummary(results.map((entry) => rows.find((row) => qcResultRef(row) === entry.result_ref))) ?? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
526
+ ? parityRowSummary(rows.find((row) => qcResultRef(row) === lead.result_ref))
527
+ ?? readabilityPairSummary(results.map((entry) => rows.find((row) => qcResultRef(row) === entry.result_ref))) ?? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
370
528
  : lead.summary,
371
529
  results,
372
530
  };
@@ -427,6 +585,15 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
427
585
  }
428
586
  const grouped = new Map();
429
587
  for (const row of results) {
588
+ for (const limit of new Set(row.coverage?.limits || [])) {
589
+ const text = QC_COVERAGE_LIMIT_TEXT[row.check]?.[limit];
590
+ if (!text) continue;
591
+ const key = JSON.stringify([row.leg, row.check, "limit", limit]);
592
+ const entry = grouped.get(key) || { check: row.check, leg: row.leg, result: "limit", reason_code: limit, count: 0, pages: [], text };
593
+ entry.count += 1;
594
+ if (row.subject?.page != null && !entry.pages.includes(row.subject.page)) entry.pages.push(row.subject.page);
595
+ grouped.set(key, entry);
596
+ }
430
597
  if (row.result !== "unexercised" && row.result !== "excluded") continue;
431
598
  const key = JSON.stringify([row.leg, row.check, row.result, row.reason_code]);
432
599
  const entry = grouped.get(key) || { check: row.check, leg: row.leg, result: row.result, reason_code: row.reason_code, count: 0, pages: [] };
@@ -435,7 +602,10 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
435
602
  grouped.set(key, entry);
436
603
  }
437
604
  const coverageEntries = [...grouped.values(), ...coverage]
438
- .map((entry) => ({ ...entry, pages: [...entry.pages].sort(compareText) }))
605
+ .map((entry) => {
606
+ const text = entry.text ?? QC_COVERAGE_TEXT[entry.check]?.[entry.reason_code];
607
+ return { ...entry, pages: [...entry.pages].sort(compareText), ...(text ? { text } : {}) };
608
+ })
439
609
  .sort((a, b) => (LEG_ORDER[a.leg] ?? 9) - (LEG_ORDER[b.leg] ?? 9) || compareText(a.check, b.check) || compareText(a.reason_code, b.reason_code) || compareText(a.result, b.result));
440
610
  const openGroups = groupOpenWarnings(open, results);
441
611
  review.sort(byCheckKeyPage);
@@ -473,7 +643,7 @@ export function qcHandoffTextLines(handoff) {
473
643
  : openLine(entry)));
474
644
  section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${entry.crop ? ` crop: ${entry.crop}` : ""}${members(entry)}`);
475
645
  section("Lapsed accepts", handoff.lapsed, (entry) => `${entry.result_ref} ${entry.summary}; accepted by ${entry.accepted_by} at ${entry.accepted_at}; lapsed: ${entry.why}`);
476
- section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}`);
646
+ section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}${entry.text ? ` — ${entry.text}` : ""}`);
477
647
  section("Accepted", handoff.accepted, (entry) => `${entry.result_ref} accepted by ${entry.accepted_by} at ${entry.accepted_at}: ${entry.reason}${members(entry)}`);
478
648
  section("Inert accepts", handoff.inert_accepts, (entry) => `${entry.result_id ?? "(no result id)"}: ${entry.why}`);
479
649
  if (lines.length === 1) lines.push(" (no QC results)");
@@ -13,13 +13,19 @@
13
13
  // - Polish checks: the record's rules, {thresholds, vocabulary,
14
14
  // evaluate(cell, thresholds) => Derived[]} (MEDIA_WEIGHT_QC_RULES for
15
15
  // media_weight; READABILITY_QC_RULES for readability, which also carries
16
- // the record's fixed producer values, limits and route set).
16
+ // the record's fixed producer values, limits and route set;
17
+ // LAYOUT_QC_RULES for the layout conventions read from the same record).
17
18
  // - Doctor checks recompute from the built HTML on every read and are called
18
19
  // by doctor itself; their entries name the evaluator for completeness.
19
20
  //
20
21
  // The table holds only the shipped QC checks. A stand-in check never comes
21
22
  // from here: tests pass theirs in-process (qcStandIns).
23
+ import { createRequire } from "node:module";
24
+
25
+ const require = createRequire(import.meta.url);
26
+
22
27
  export const QC_CHECK_REGISTRY = Object.freeze({
28
+ "design.access": Object.freeze({ leg: "doctor", unit: "4.1", module: "./parity/design-access.mjs", rederive: "evaluateDesignAccess" }),
23
29
  // Tracking params reach the order
24
30
  "tracking.url": Object.freeze({ leg: "qa", unit: "1.1", module: "./qa-tracking-params.mjs", rederive: "rederiveQcResult" }),
25
31
  "tracking.order": Object.freeze({ leg: "qa", unit: "1.1", module: "./qa-tracking-params.mjs", rederive: "rederiveQcResult" }),
@@ -34,6 +40,13 @@ export const QC_CHECK_REGISTRY = Object.freeze({
34
40
  "policy.availability": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
35
41
  // Text readability
36
42
  "readability.contrast": Object.freeze({ leg: "polish", unit: "2.4", module: "./polish-readability.mjs", rederive: "READABILITY_QC_RULES", record: "readability" }),
43
+ // Layout conventions (shared commerce brand contract), read from the
44
+ // readability record
45
+ "layout.conventions": Object.freeze({ leg: "polish", unit: "2.5", module: "./polish-readability.mjs", rederive: "LAYOUT_QC_RULES", record: "readability" }),
46
+ "visual.reference": Object.freeze({ leg: "polish", unit: "3.2", module: "./polish-visual.mjs", rederive: "VISUAL_QC_RULES", record: "captures" }),
47
+ "visual.diff": Object.freeze({ leg: "polish", unit: "3.4", module: "./polish-visual.mjs", rederive: "VISUAL_QC_RULES", record: "captures" }),
48
+ // Element parity against the source HTML
49
+ "parity.element": Object.freeze({ leg: "polish", unit: "4.3", module: "./parity/element-parity-record.mjs", rederive: "ELEMENT_PARITY_QC_RULES", record: "element_parity" }),
37
50
  // Cart placeholders
38
51
  cart_placeholders: Object.freeze({ leg: "doctor", unit: "1.5", module: "./cart-placeholders.mjs", rederive: "evaluateCartPlaceholders" }),
39
52
  // Built-output smoke checks
@@ -58,6 +71,24 @@ function isMissingModule(error, href) {
58
71
 
59
72
  let loaded = null;
60
73
 
74
+ async function elementParityRules(rules) {
75
+ const { planPolishCapture } = await import("./polish-node.mjs");
76
+ return { ...rules, plan: planPolishCapture,
77
+ read(context, activeRules) {
78
+ // QC reads are synchronous. Node >=20.19 supports synchronous loading
79
+ // of this ESM graph; defer it until a selected packet has a mapped
80
+ // route. Loading the registry alone must never load snapshot/probe.
81
+ let applicable = false;
82
+ if (rules.applies(context?.packet)) {
83
+ try { applicable = planPolishCapture({ packet: context.packet, baseUrl: "http://127.0.0.1" })?.routes?.length > 0; } catch { /* reader refuses the invalid plan */ }
84
+ }
85
+ if (!applicable) return rules.read(context, activeRules);
86
+ const { readParityFile, readParityFileForIdentity } = require("./parity/snapshot.mjs");
87
+ return rules.read(context, { ...activeRules, readFile: readParityFile, readFileForIdentity: readParityFileForIdentity });
88
+ },
89
+ };
90
+ }
91
+
61
92
  // Loads every registered module of the given legs once per process and
62
93
  // returns { "<check>": {status: "loaded", rederive} | {status: "missing"} |
63
94
  // {status: "failed", error} }. `importer` resolves a specifier relative to
@@ -79,7 +110,16 @@ export async function loadQcRederivers({ legs = ["qa", "polish"], registry = QC_
79
110
  if (outcome.missing) cache[check] = Object.freeze({ status: "missing" });
80
111
  else if (outcome.error) cache[check] = Object.freeze({ status: "failed", error: String(outcome.error?.message || outcome.error) });
81
112
  else if (outcome.module?.[entry.rederive] == null) cache[check] = Object.freeze({ status: "failed", error: `${entry.module} has no export ${entry.rederive}` });
82
- else cache[check] = Object.freeze({ status: "loaded", rederive: outcome.module[entry.rederive] });
113
+ else {
114
+ // Load the planner after the QC substrate has initialized: polish-node
115
+ // itself uses that substrate, so a static import would form a TDZ cycle.
116
+ // Element parity also reads its probe files through the file module,
117
+ // which the capture node imports only when a plan has a route.
118
+ const rules = outcome.module[entry.rederive];
119
+ const rederive = entry.record === "captures" ? { ...rules, plan: (await import("./polish-node.mjs")).planPolishCapture }
120
+ : entry.record === "element_parity" ? await elementParityRules(rules) : rules;
121
+ cache[check] = Object.freeze({ status: "loaded", rederive });
122
+ }
83
123
  }
84
124
  if (useCache) loaded = cache;
85
125
  return Object.freeze({ ...cache });