@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
@@ -17,14 +17,19 @@
17
17
  // the fields the helper derives from them;
18
18
  // - coverage_gaps[]: what the probe could not measure: closed shadow roots,
19
19
  // cross-origin frames, loading, hidden or generated-text role elements, and
20
- // selected, active or expanded states not present at load.
20
+ // selected, active or expanded states not present at load;
21
+ // - layout (cells of a record that carries layout_conventions, measured cells
22
+ // only): the viewport size and the floating CTA containers the probe found,
23
+ // read by the layout.conventions check (LAYOUT_QC_RULES).
21
24
  import { createHash } from "node:crypto";
22
- import { mkdirSync, writeFileSync } from "node:fs";
23
- import { join, relative, sep } from "node:path";
25
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { dirname, join, relative, sep } from "node:path";
27
+ import { fileURLToPath } from "node:url";
24
28
 
25
29
  import { resolveBuiltSiteScope } from "./built-site-scope.mjs";
26
30
  import { CART_PLACEHOLDERS_LIMITS } from "./cart-placeholders.mjs";
27
31
  import { contrastToolkit } from "./contrast.mjs";
32
+ import { canonicalJson } from "./polish-capture.mjs";
28
33
  import {
29
34
  READABILITY_ADDED_RUN_MS,
30
35
  READABILITY_CROP_RUN_MS,
@@ -35,6 +40,7 @@ import { MAX_POLISH_CAPTURE_ROUTES, POLISH_CAPTURE_VIEWPORTS } from "./polish-no
35
40
  import { QC_PRODUCERS, aggregateQcResults, polishRecordIntegrity } from "./qc-results.mjs";
36
41
 
37
42
  export const READABILITY_CHECK = "readability.contrast";
43
+ export const LAYOUT_CHECK = "layout.conventions";
38
44
  export const READABILITY_SCHEMA = "campaigns-os-polish-readability/v0";
39
45
  export const READABILITY_PRODUCER = QC_PRODUCERS.polish;
40
46
  export const READABILITY_ROUTE_SOURCE = "built_site";
@@ -402,6 +408,162 @@ export const READABILITY_QC_RULES = Object.freeze({
402
408
  },
403
409
  });
404
410
 
411
+ // ---------------------------------------------------------------------------
412
+ // Layout conventions
413
+
414
+ // The layout_conventions block of the shared commerce brand contract, without
415
+ // its prose (`rule` fields): the selectors the probe reads with and the
416
+ // thresholds the layout.conventions check judges with. A record stores the
417
+ // block it was measured with; a reader re-derives only with the same block.
418
+ const SHARED_COMMERCE_CONTRACT = join(dirname(fileURLToPath(import.meta.url)), "..", "contracts", "template-brand-contract.shared-commerce.v0.json");
419
+ const withoutProse = (value) => (isPlainObject(value)
420
+ ? Object.freeze(Object.fromEntries(Object.entries(value).filter(([field]) => field !== "rule").map(([field, entry]) => [field, withoutProse(entry)])))
421
+ : Array.isArray(value) ? Object.freeze(value.map(withoutProse)) : value);
422
+ export const LAYOUT_CONVENTIONS = withoutProse(JSON.parse(readFileSync(SHARED_COMMERCE_CONTRACT, "utf8")).layout_conventions);
423
+
424
+ // What the probe needs from the block: the floating CTA selector contract.
425
+ const layoutProbeArgs = () => {
426
+ const { positions, control_selector, exclude_selector, form_field_selector } = LAYOUT_CONVENTIONS.floating_cta;
427
+ return { positions, control_selector, exclude_selector, form_field_selector };
428
+ };
429
+
430
+ const isRect = (value) => isPlainObject(value) && ["x", "y", "width", "height"].every((field) => Number.isFinite(value[field]));
431
+ const isPathList = (value) => Array.isArray(value) && value.every(isNonEmptyString);
432
+
433
+ // The layout a measured cell records: the probe's viewport and one entry per
434
+ // floating CTA container, with its rect, its controls (each with its rect)
435
+ // and the selector paths of the visible text outside them.
436
+ export function layoutShapeOk(layout) {
437
+ return isPlainObject(layout)
438
+ && isPlainObject(layout.viewport) && Number.isFinite(layout.viewport.width) && Number.isFinite(layout.viewport.height)
439
+ && Array.isArray(layout.floating_ctas)
440
+ && layout.floating_ctas.every((entry) => isPlainObject(entry)
441
+ && isNonEmptyString(entry.selector_path)
442
+ && LAYOUT_CONVENTIONS.floating_cta.positions.includes(entry.position)
443
+ && isRect(entry.rect)
444
+ && Array.isArray(entry.controls) && entry.controls.every((control) => isPlainObject(control) && isNonEmptyString(control.selector_path) && isRect(control.rect))
445
+ && isPathList(entry.other_text)
446
+ && Number.isInteger(entry.other_text_count) && entry.other_text_count >= entry.other_text.length);
447
+ }
448
+
449
+ const FLOATING_FIELDS = Object.freeze(["selector_path", "position", "rect", "controls", "other_text", "other_text_count"]);
450
+
451
+ // The layout a cell keeps from the probe's observation, or null when the
452
+ // observation carries none.
453
+ function cellLayout(observation) {
454
+ const layout = observation?.layout;
455
+ if (!isPlainObject(layout)) return null;
456
+ return {
457
+ viewport: { width: layout.viewport?.width, height: layout.viewport?.height },
458
+ floating_ctas: (Array.isArray(layout.floating_ctas) ? layout.floating_ctas : []).map((entry) => ({
459
+ ...pick(entry, FLOATING_FIELDS),
460
+ controls: (Array.isArray(entry?.controls) ? entry.controls : []).map((control) => pick(control, ["selector_path", "rect"])),
461
+ })),
462
+ };
463
+ }
464
+
465
+ // The row keys the layout check names for a cell at its viewport, and
466
+ // `cell`; none at a viewport no convention applies to. Read without checking
467
+ // the cell, so an accept of a row of a cell that fails its checks lapses
468
+ // rather than orphans. Never throws.
469
+ export function layoutRowKeys(cell) {
470
+ const viewport = isPlainObject(cell) ? cell.viewport : null;
471
+ const keys = Object.values(LAYOUT_CONVENTIONS).filter((convention) => convention.viewports.includes(viewport)).map((convention) => convention.key);
472
+ return keys.length ? [...keys, "cell"].sort(byCodeUnit) : [];
473
+ }
474
+
475
+ // The layout.conventions results of one cell of the readability record,
476
+ // judged with `conventions`, one per convention that applies at the cell's
477
+ // viewport:
478
+ // - `floating_cta`: one member per floating CTA container no taller than
479
+ // max_viewport_height_share of the viewport, a warning when it holds other
480
+ // than one control or holds visible text outside its control; a cell with
481
+ // none reads pass;
482
+ // - `cta_label_size`: one member per rendered, enabled text element of the
483
+ // CTA roles, a warning under min_font_size_px; a cell with none reads pass.
484
+ // The elements are the readability record's, so a capped cell adds the
485
+ // page_coverage member.
486
+ // A cell that measured nothing reads `cell` unexercised with its status.
487
+ export function evaluateLayoutConventions(cell, conventions = LAYOUT_CONVENTIONS) {
488
+ if (!isPlainObject(cell)) invalid("is not an object");
489
+ if (canonicalJson(conventions) !== canonicalJson(LAYOUT_CONVENTIONS)) invalid("layout conventions are not the shared contract's");
490
+ if (!readabilityCellShapeOk(cell)) invalid("is not in the record vocabulary");
491
+ const { floating_cta: floating, cta_label: label } = LAYOUT_CONVENTIONS;
492
+ const applies = (convention) => convention.viewports.includes(cell.viewport);
493
+ if (!applies(floating) && !applies(label)) return [];
494
+ const results = [];
495
+ const push = (key, members, { state, observation = {}, capReason = null }) => {
496
+ const aggregated = aggregateQcResults(members, { capReason });
497
+ const result = aggregated.result;
498
+ results.push({
499
+ check: LAYOUT_CHECK,
500
+ subject: { check: LAYOUT_CHECK, page: cell.route, viewport: cell.viewport, key },
501
+ result,
502
+ reason_code: result === "pass" ? null : aggregated.reason_code,
503
+ members: aggregated.members,
504
+ accept_eligible: aggregated.accept_eligible,
505
+ coverage: result === "unexercised" ? { observed: 0, expected: 1, limits: [aggregated.reason_code] } : { observed: 1, expected: 1, limits: [] },
506
+ state,
507
+ observation,
508
+ });
509
+ };
510
+
511
+ if (cell.cell_status !== "measured") {
512
+ push("cell", [{ key: "cell_status", result: "unexercised", reason_code: cell.cell_status }], { state: { reason_code: cell.cell_status } });
513
+ return results;
514
+ }
515
+ if (!layoutShapeOk(cell.layout)) invalid("layout is not in the record vocabulary");
516
+
517
+ if (applies(floating)) {
518
+ const tallest = floating.max_viewport_height_share * cell.layout.viewport.height;
519
+ const containers = cell.layout.floating_ctas.filter((entry) => entry.rect.height <= tallest);
520
+ const holdsControlOnly = (entry) => entry.controls.length === 1 && entry.other_text_count === 0;
521
+ const members = containers.length
522
+ ? containers.map((entry) => (holdsControlOnly(entry)
523
+ ? { key: entry.selector_path, result: "pass", reason_code: null }
524
+ : { key: entry.selector_path, result: "warning", reason_code: floating.reason_code }))
525
+ : [{ key: "no_floating_cta", result: "pass", reason_code: null }];
526
+ push(floating.key, members, {
527
+ state: { containers: sortedUnique(containers.map(({ selector_path, controls, other_text, other_text_count }) => ({ selector_path, controls: controls.map((control) => control.selector_path), other_text, other_text_count }))) },
528
+ observation: {
529
+ max_height_px: tallest,
530
+ containers: containers.map(({ selector_path, position, rect, controls, other_text, other_text_count }) => ({ selector_path, position, rect, controls, other_text, other_text_count })),
531
+ },
532
+ });
533
+ }
534
+
535
+ if (applies(label)) {
536
+ const labels = cell.elements.filter((element) => label.roles.includes(element.role) && element.rendered && !element.disabled);
537
+ const members = labels.length
538
+ ? labels.map((element) => (element.font_size_px >= label.min_font_size_px
539
+ ? { key: element.selector_path, result: "pass", reason_code: null }
540
+ : { key: element.selector_path, result: "warning", reason_code: label.reason_code }))
541
+ : [{ key: "no_cta_label", result: "pass", reason_code: null }];
542
+ push(label.key, members, {
543
+ capReason: cell.capped ? "element_cap_reached" : null,
544
+ state: { min_font_size_px: label.min_font_size_px, members: sortedUnique(labels.map(({ role, selector_path, font_size_px }) => ({ role, selector_path, font_size_px }))) },
545
+ observation: {
546
+ min_font_size_px: label.min_font_size_px,
547
+ smallest_px: labels.length ? Math.min(...labels.map((element) => element.font_size_px)) : null,
548
+ members: labels.map(({ role, selector_path, font_size_px }) => ({ role, selector_path, font_size_px })),
549
+ },
550
+ });
551
+ }
552
+ return results;
553
+ }
554
+
555
+ // The layout.conventions rules, read from the readability record
556
+ // (src/qc-check-registry.mjs): `thresholds` is the conventions block a record
557
+ // must store to be judged. The reader re-derives the check's rows only
558
+ // for a record that stores `layout_conventions`; an earlier record holds no
559
+ // layout and the check reads not_captured_by_this_version.
560
+ export const LAYOUT_QC_RULES = Object.freeze({
561
+ thresholds: LAYOUT_CONVENTIONS,
562
+ vocabulary: Object.freeze({ key: Object.freeze([...Object.values(LAYOUT_CONVENTIONS).map((convention) => convention.key), "cell"]) }),
563
+ evaluate: evaluateLayoutConventions,
564
+ rowKeys: layoutRowKeys,
565
+ });
566
+
405
567
  // ---------------------------------------------------------------------------
406
568
  // Probe
407
569
 
@@ -419,7 +581,8 @@ export const READABILITY_QC_RULES = Object.freeze({
419
581
  // (or, for one inside a frame, that frame's element); `generatedText` is
420
582
  // generatedTextRenders. Every element carries its rectangle in the main
421
583
  // frame's viewport (`rect`, clipped to its frames) for crops, which the
422
- // record does not keep.
584
+ // record does not keep. With `limits.layout` it also returns `layout`: the
585
+ // viewport and the floating CTA containers, whose rects the record keeps.
423
586
  export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
424
587
  const win = window;
425
588
  const doc = document;
@@ -684,14 +847,56 @@ export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
684
847
  if (isVisible(frame) && !readable) gap("cross_origin_text", null, frame);
685
848
  }
686
849
 
687
- return { status: "measured", capped, coverage_gaps: gaps, elements, viewport };
850
+ // Floating CTA containers (limits.layout, the layout conventions' selector
851
+ // contract), in the main frame's trees: for each visible control, the
852
+ // nearest box at or around it whose computed position is one of
853
+ // `positions`, unless that box is hidden, matches or sits inside
854
+ // `exclude_selector`, or holds a visible form field. Each container keeps
855
+ // its rect, its outermost visible controls with their rects, and the
856
+ // selector paths of the visible text outside them (rendered or generated),
857
+ // the first ten listed and all counted.
858
+ let layout = null;
859
+ if (limits.layout) {
860
+ const { positions, control_selector: controlSelector, exclude_selector: excludeSelector, form_field_selector: fieldSelector } = limits.layout;
861
+ const main = { offset: { x: 0, y: 0 }, clip: null };
862
+ const positioned = (el) => positions.includes(win.getComputedStyle(el).getPropertyValue("position"));
863
+ const boxes = [];
864
+ for (const [el, scope] of inOrder) {
865
+ if (scope.win !== win || !el.matches(controlSelector) || !isVisible(el)) continue;
866
+ let box = el;
867
+ while (box && box.nodeType === 1 && !positioned(box)) box = flatParent(box);
868
+ if (box && box.nodeType === 1 && !boxes.includes(box)) boxes.push(box);
869
+ }
870
+ const floating_ctas = [];
871
+ for (const box of boxes) {
872
+ if (!isVisible(box) || flatClosest(box, excludeSelector)) continue;
873
+ const inside = flatSubtree(box);
874
+ if (inside.some((node) => node.matches(fieldSelector) && isVisible(node))) continue;
875
+ const visibleControls = inside.filter((node) => node.matches(controlSelector) && isVisible(node));
876
+ const controls = visibleControls.filter((node) => !visibleControls.some((other) => other !== node && flatContains(other, node)));
877
+ const other = inside.filter((node) => !controls.some((control) => flatContains(control, node))
878
+ && (toolkit.isTextBearing(node, windowOf(node)) || generatedText(node, windowOf(node))));
879
+ floating_ctas.push({
880
+ selector_path: toolkit.selectorPath(box),
881
+ position: win.getComputedStyle(box).getPropertyValue("position"),
882
+ rect: rectOf(box, main),
883
+ controls: controls.map((control) => ({ selector_path: toolkit.selectorPath(control), rect: rectOf(control, main) })),
884
+ other_text: other.slice(0, 10).map((node) => toolkit.selectorPath(node)),
885
+ other_text_count: other.length,
886
+ });
887
+ }
888
+ layout = { viewport, floating_ctas };
889
+ }
890
+
891
+ return { status: "measured", capped, coverage_gaps: gaps, elements, viewport, ...(layout ? { layout } : {}) };
688
892
  }
689
893
 
690
894
  // The function declaration the adapter calls in the isolated world: the
691
895
  // probe, composed with the shared contrast helper and generatedTextRenders
692
- // by source text.
896
+ // by source text, its limits carrying the layout conventions' selector
897
+ // contract.
693
898
  export function readabilityProbeSource() {
694
- return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), limits, closedRoots, ${generatedTextRenders.toString()}); }`;
899
+ return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), Object.assign({ layout: ${JSON.stringify(layoutProbeArgs())} }, limits), closedRoots, ${generatedTextRenders.toString()}); }`;
695
900
  }
696
901
 
697
902
  // Whether an element's ::before or ::after renders text: its computed
@@ -760,6 +965,7 @@ const pick = (value, fields) => Object.fromEntries(fields.map((field) => [field,
760
965
  export function readabilityObservationOk(observation) {
761
966
  return isPlainObject(observation)
762
967
  && inVocabulary("cell_status", observation.status)
968
+ && (observation.layout === undefined || isPlainObject(observation.layout))
763
969
  && (observation.status !== "measured"
764
970
  || (Array.isArray(observation.elements) && observation.elements.every(isPlainObject)
765
971
  && Array.isArray(observation.coverage_gaps) && observation.coverage_gaps.every(isPlainObject)
@@ -768,7 +974,9 @@ export function readabilityObservationOk(observation) {
768
974
 
769
975
  // One cell of the record. `status` is the cell status; a measured cell's
770
976
  // `observation` is the adapter's probe read ({capped, coverage_gaps,
771
- // elements, crops}). Only the record's fields are kept. Each crop the adapter
977
+ // elements, crops, layout}). Only the record's fields are kept; a layout
978
+ // outside the record vocabulary is dropped, which the layout check reads as
979
+ // not reproducible. Each crop the adapter
772
980
  // took (crops[] of {element, data}, base64 PNG) is written by `writeCrop`
773
981
  // and referenced by path and sha256; one that cannot be written reads
774
982
  // crop_unavailable. A measured cell outside the record vocabulary reads
@@ -789,6 +997,7 @@ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null
789
997
  element.crop_reason = "crop_unavailable";
790
998
  }
791
999
  }
1000
+ const layout = cellLayout(observation);
792
1001
  const cell = {
793
1002
  route,
794
1003
  viewport,
@@ -797,13 +1006,15 @@ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null
797
1006
  capped: observation.capped,
798
1007
  coverage_gaps: observation.coverage_gaps.map((entry) => pick(entry, GAP_FIELDS)),
799
1008
  elements,
1009
+ ...(layout && layoutShapeOk(layout) ? { layout } : {}),
800
1010
  };
801
1011
  return readabilityCellShapeOk(cell) ? cell : unmeasured("document_changed");
802
1012
  }
803
1013
 
804
1014
  // The record for one Polish run: its cells (one per captured route ×
805
1015
  // viewport), bound to the current build and source package; the built routes
806
- // over the route cap; whether the enumeration ceiling was reached.
1016
+ // over the route cap; whether the enumeration ceiling was reached; and, when
1017
+ // a cell recorded layout, the layout conventions it was measured with.
807
1018
  export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = null, slug, routes, uncapturedRoutes = [], routeEnumerationCapped = false, cells, measuredAt = new Date().toISOString() }) {
808
1019
  const record = {
809
1020
  schema_version: READABILITY_SCHEMA,
@@ -819,6 +1030,7 @@ export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = n
819
1030
  },
820
1031
  thresholds: { ...READABILITY_THRESHOLDS },
821
1032
  limits: { ...readabilityLimits() },
1033
+ ...(cells.some((cell) => isPlainObject(cell?.layout)) ? { layout_conventions: structuredClone(LAYOUT_CONVENTIONS) } : {}),
822
1034
  cells,
823
1035
  uncaptured_routes: [...uncapturedRoutes],
824
1036
  route_enumeration_capped: routeEnumerationCapped === true,
@@ -0,0 +1,74 @@
1
+ // Synthetic test data only. No browser, source HTML capture or merchant material.
2
+ import { deflateSync } from "node:zlib";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { crc32, sha256Hex, boundedDimensions, VISUAL_MAX_REFERENCE_BYTES } from "./png-header.mjs";
7
+ import { buildCapturesRecord, planVisualCapture, writePolishPng, containedWritePath, VISUAL_VIEWPORTS, VISUAL_MASKS } from "./polish-visual.mjs";
8
+
9
+ export function pngChunk(type, data = Buffer.alloc(0)) {
10
+ if (!Buffer.isBuffer(data) || data.length > VISUAL_MAX_REFERENCE_BYTES - 12) throw new RangeError("too_large");
11
+ const chunk = Buffer.alloc(12 + data.length);
12
+ chunk.writeUInt32BE(data.length); chunk.write(type, 4, 4, "ascii"); data.copy(chunk, 8);
13
+ chunk.writeUInt32BE(crc32(chunk.subarray(4, chunk.length - 4)), chunk.length - 4);
14
+ return chunk;
15
+ }
16
+ export function createRgba(width, height, color = [255, 255, 255, 255]) {
17
+ boundedDimensions(width, height);
18
+ const data = Buffer.alloc(width * height * 4);
19
+ for (let i = 0; i < data.length; i += 4) data.set(color, i);
20
+ return { width, height, data };
21
+ }
22
+ export function createRgbaPng(width, height, color = [255, 255, 255, 255], { raw = null, idat = null, interlace = 0 } = {}) {
23
+ // Explicit IDAT permits deliberately oversized headers without allocating pixels.
24
+ if (!idat) boundedDimensions(width, height);
25
+ else if (![width, height].every((n) => Number.isSafeInteger(n) && n > 0 && n <= 0x7fffffff)) throw new RangeError("image_too_large");
26
+ const ihdr = Buffer.alloc(13);
27
+ ihdr.writeUInt32BE(width); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[12] = interlace;
28
+ if (!idat) {
29
+ if (!raw) {
30
+ raw = Buffer.alloc(height * (1 + width * 4));
31
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) raw.set(color, y * (1 + width * 4) + 1 + x * 4);
32
+ }
33
+ idat = deflateSync(raw);
34
+ }
35
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), pngChunk("IHDR", ihdr), pngChunk("IDAT", idat), pngChunk("IEND")]);
36
+ }
37
+ export const syntheticHash = (bytes) => `sha256:${sha256Hex(bytes)}`;
38
+ export const syntheticReport = () => ({ stages: { assembly: { build_fingerprint: `sha256:${"a".repeat(64)}` } } });
39
+ export function syntheticCapturesRecord(report, { routes = [{ page_id: "page", requested_route: "/page/" }], viewports = VISUAL_VIEWPORTS,
40
+ targetRepo = mkdtempSync(resolve(tmpdir(), "visual-record-")) } = {}) {
41
+ const plan = planVisualCapture({ route_scope: "all", routes, viewports });
42
+ if (!plan) return null;
43
+ const cells = plan.cells.map((cell) => {
44
+ const png = writePolishPng(targetRepo, "captures", createRgbaPng(cell.width, cell.height));
45
+ const shot = { status: "captured", png, full_height_css: cell.height,
46
+ crop: { x: 0, y: 0, width: cell.width, height: cell.height }, truncated: false };
47
+ return { ...cell, source_path_rel: null, source_sha256: null, status: "rendered",
48
+ networkidle: { status: "settled", duration_ms: 0 },
49
+ readiness: { fonts: { status: "settled", failed_faces: 0, waited_ms: 0 }, await: { status: "not_present", remaining_count: 0, waited_ms: 0 } },
50
+ external: cell.side === "source_html" ? { blocked_count: 0, blocked_websocket_count: 0, blocked_origins: [] } : null,
51
+ sections: { status: "no_section_keys", bands: [], duplicated_keys: [], capped: false },
52
+ actual: structuredClone(shot), stabilized: { ...structuredClone(shot), treatment: {
53
+ animations: "disabled", caret: "hide", mask_color: "#ff00ff", mask_selectors: VISUAL_MASKS, masks: [], mask_cap_reached: false } },
54
+ captured_at: "2026-01-01T00:00:00.000Z" };
55
+ });
56
+ return buildCapturesRecord({ buildFingerprint: report.stages.assembly.build_fingerprint, slug: "sample", routes, cells,
57
+ environment: { browser: "chromium/synthetic", device_scale_factor: 1 },
58
+ dependencies: { decoder: { status: "available", version: null }, diff: { status: "available", version: null } },
59
+ measuredAt: "2026-01-01T00:00:00.000Z" });
60
+ }
61
+
62
+ export function syntheticVisualFixture() {
63
+ const targetRepo = mkdtempSync(resolve(tmpdir(), "visual-core-"));
64
+ const sourceRoot = containedWritePath(targetRepo, "source");
65
+ const htmlPath = containedWritePath(targetRepo, "source/page.html");
66
+ mkdirSync(sourceRoot);
67
+ const html = Buffer.from("<!doctype html><p>synthetic source</p>");
68
+ writeFileSync(htmlPath, html);
69
+ const linkedSource = { kind: "html_file", identity: { sha256: syntheticHash(html), sha256_recorded: syntheticHash(html) }, provenance: { class: "declared_source", reasons: [] } };
70
+ return { targetRepo, sourceRoot, linkedSource, report: syntheticReport(),
71
+ recorded: { captured_at: "2026-01-01T00:00:00.000Z", source_ref_id: "html", scale_factor: 1 },
72
+ write(name, bytes) { const path = containedWritePath(sourceRoot, name); mkdirSync(resolve(path, ".."), { recursive: true }); writeFileSync(path, bytes); return path; },
73
+ cleanup() { rmSync(targetRepo, { recursive: true, force: true }); } };
74
+ }