@nextcommerce/campaigns-os 1.52.0 → 1.54.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 (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
@@ -5,6 +5,7 @@ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync} from
5
5
  import {join,resolve,dirname} from 'node:path';
6
6
  import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
7
7
  import {specMaterialHash} from './spec-identity.mjs';
8
+ import {effectiveStageStatus} from './input-currency.mjs';
8
9
  import {sameFile} from './fs-identity.mjs';
9
10
  import {withDirectoryLock} from './directory-lock.mjs';
10
11
  import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
@@ -17,7 +18,7 @@ const accepted = (value,values,fallback='unknown')=>values.includes(value)?value
17
18
  // A next action that is a variant of a fixed action projects onto it: the
18
19
  // order-bump QA run is a QA run. Without this, the closed v0 vocabulary would
19
20
  // read it as unknown and mark a ready QA continuation blocked.
20
- const PROGRESS_ACTION_ALIASES = new Map([['qa_run_bump','qa_run']]);
21
+ const PROGRESS_ACTION_ALIASES = new Map([['qa_run_bump','qa_run'],['refresh_inputs','recheck']]);
21
22
  const hash = value=>typeof value==='string'&&/^(?:sha256:)?[0-9a-f]{64}$/i.test(value)?`sha256:${value.replace(/^sha256:/i,'').toLowerCase()}`:null;
22
23
  const id = value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value)?value:null;
23
24
  const digest = value=>`sha256:${createHash('sha256').update(value).digest('hex')}`;
@@ -54,7 +55,15 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
54
55
  const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
55
56
  const build=hash(doctor?.derived?.build_output_fingerprint?.value);
56
57
  const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
57
- const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
58
+ // The report belongs to this packet and spec: its stages can be read. A
59
+ // spec edit since the report was bound does not unbind it; the edit makes
60
+ // the stamped stages owed, which their effective status reports. The
61
+ // doctor read must have assessed the same spec material this read sees.
62
+ const inputCurrency=doctor?.derived?.input_currency;
63
+ const stagesBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&(!inputCurrency||hash(inputCurrency.spec?.current)===localHash);
64
+ // The report is also bound to the spec material on disk now, so output and
65
+ // verdict evidence recorded against it can match.
66
+ const reportBound=stagesBound&&localHash===hash(report?.identity?.spec_material_hash);
58
67
  const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
59
68
  const verdict=qaResult?.verdict;
60
69
  const qa=verdict?{
@@ -73,8 +82,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
73
82
  return {
74
83
  schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
75
84
  identity:{map_id:mapId,...localSpecIdentityFields(packet?.spec),map_revision_hash:mapId?(contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null)):null,map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
85
+ // Each stage's effective status: a stage owed again by a brief or
86
+ // CampaignSpec change reads required, one whose inputs cannot be
87
+ // confirmed reads unknown (src/input-currency.mjs).
76
88
  stages:PROGRESS_STAGES.map(stage=>{
77
- const status=reportBound?accepted(report?.stages?.[stage]?.status,PROGRESS_STAGE_STATUSES):'unknown';
89
+ const status=stagesBound?accepted(effectiveStageStatus(stage,report?.stages?.[stage],inputCurrency),PROGRESS_STAGE_STATUSES):'unknown';
78
90
  const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
79
91
  return {stage,status,source_build_fingerprint:source,build_binding:reportBound&&build&&source===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'?'matching':'unconfirmed'};
80
92
  }),
@@ -84,7 +96,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
84
96
  }
85
97
  function lock(dir,fn,{budgetMs=1500}={}) {
86
98
  const lockPath=join(dir,'.allocation-lock');
87
- return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
99
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
88
100
  }
89
101
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
90
102
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -152,7 +164,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
152
164
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
153
165
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
154
166
  } catch(error) {
155
- if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
167
+ if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
168
+ else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
156
169
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
157
170
  return {state:'failed',reason:'capture_unavailable'};
158
171
  }
@@ -6,6 +6,8 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
6
6
  import { createHash } from "node:crypto";
7
7
  import { runWithDeadline } from "./deadline.mjs";
8
8
  import { PLACEHOLDER_TEXT_ASSERTION_SUFFIX, SEVERITY, STATUS } from "./qa-verdict.mjs";
9
+ import { contrastToolkit } from "./contrast.mjs";
10
+ import { generatedTextRenders } from "./polish-readability.mjs";
9
11
  import {
10
12
  analyticsCaptureError,
11
13
  projectAnalyticsCaptureError,
@@ -75,6 +77,22 @@ const DEFAULT_TEST_CARD = "6011111111111117";
75
77
  const DEFAULT_TEST_CVV = "123";
76
78
  const DEFAULT_TEST_EXP_MONTH = "12";
77
79
  const DEFAULT_TEST_EXP_YEAR = "2030";
80
+ // The card number and CVV iframes, matched by id prefix because each id ends
81
+ // in a per-load suffix. SDK 0.4.40 and earlier mount Spreedly's iFrame v1
82
+ // (spreedly-number-frame-1234); 0.4.41 mounts the Spreedly Checkout SDK fields
83
+ // that NEXT's payments.29next.com/js/v1/payment.js loads
84
+ // (spreedly-hosted-number-oujmii2uzjk). A page carries one generation or the
85
+ // other, and each frame holds a single text input. fillPaymentFields refuses
86
+ // by name unless exactly one number frame and one CVV frame match, so a page
87
+ // mounting both generations, or two forms, fails at the card step instead of
88
+ // on whichever frame happens to come first.
89
+ const CARD_NUMBER_FRAME = 'iframe[id^="spreedly-number-frame"], iframe[id^="spreedly-hosted-number"]';
90
+ const CARD_CVV_FRAME = 'iframe[id^="spreedly-cvv-frame"], iframe[id^="spreedly-hosted-cvv"]';
91
+ // Typing the card is read back and retried this many times in all before the
92
+ // card step fails by name.
93
+ const CARD_TYPE_ATTEMPTS = 3;
94
+ // How long the card step waits for the SDK to report the card fields ready.
95
+ const CARD_READY_TIMEOUT_MS = 15000;
78
96
  const DEFAULT_MAX_TEST_ORDERS = 6;
79
97
  // Planned-path ids listed in a refused --max-test-orders message before the
80
98
  // remainder is counted rather than printed.
@@ -1300,9 +1318,7 @@ async function primaryCtaVisualAssertions(browserPage, page) {
1300
1318
  }
1301
1319
 
1302
1320
  function primaryCtaCheckEligible(page) {
1303
- if (!page?.expected_next_url) return false;
1304
- const pageType = String(page.page_type || "").toLowerCase();
1305
- return !["checkout", "upsell", "downsell", "thankyou", "receipt"].includes(pageType);
1321
+ return Boolean(page?.expected_next_url);
1306
1322
  }
1307
1323
 
1308
1324
  // Candidate CTAs: anything clickable, plus every SDK action control and the
@@ -1314,10 +1330,12 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
1314
1330
 
1315
1331
  // The in-page half of the primary-CTA inspection, as the source text the
1316
1332
  // page evaluates. The route rule it needs (cartEntryHrefFor, unit-tested in
1317
- // qa-cart-entry) is handed in as a function value rather than closed over, so
1318
- // both function bodies must stay free of module-scope references: the text is
1319
- // run in a fresh context by a test (primary-CTA inspection script is
1320
- // self-contained) that would surface a leaked identifier as a ReferenceError.
1333
+ // qa-cart-entry), the shared contrast helper (contrastToolkit) and the
1334
+ // generated-text rule Polish reads (generatedTextRenders) are handed in as
1335
+ // function values rather than closed over, so every function body must
1336
+ // stay free of module-scope references: the text is run in a fresh context by
1337
+ // a test (primary-CTA inspection script is self-contained) that would surface
1338
+ // a leaked identifier as a ReferenceError.
1321
1339
  function primaryCtaInspectionScript(expectedUrl) {
1322
1340
  const args = {
1323
1341
  routeUrl: expectedUrl,
@@ -1326,7 +1344,7 @@ function primaryCtaInspectionScript(expectedUrl) {
1326
1344
  cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
1327
1345
  ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
1328
1346
  };
1329
- return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()})`;
1347
+ return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
1330
1348
  }
1331
1349
 
1332
1350
  async function inspectPrimaryCta(browserPage, expectedUrl) {
@@ -1340,8 +1358,9 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
1340
1358
  }));
1341
1359
  }
1342
1360
 
1343
- function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl) {
1361
+ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1344
1362
  const CTA_SELECTOR = ctaSelector;
1363
+ const toolkit = contrastToolkitImpl();
1345
1364
 
1346
1365
  const trim = (value) => String(value || "").replace(/\s+/g, " ").trim();
1347
1366
  const compactPath = (value) => String(value || "").replace(/\/+$/, "") || "/";
@@ -1352,42 +1371,6 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1352
1371
  return null;
1353
1372
  }
1354
1373
  })();
1355
- const parseColor = (value) => {
1356
- const raw = String(value || "").trim().toLowerCase();
1357
- if (!raw || raw === "transparent") return null;
1358
- const rgb = raw.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d?(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/);
1359
- if (!rgb) return null;
1360
- const parts = rgb.slice(1, 4).map((part) => Number(part));
1361
- if (parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) return null;
1362
- const alpha = rgb[4] === undefined ? 1 : Number(rgb[4]);
1363
- return { r: parts[0], g: parts[1], b: parts[2], a: Number.isFinite(alpha) ? alpha : 1 };
1364
- };
1365
- const hex = (color) => color ? `#${[color.r, color.g, color.b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}` : null;
1366
- const luminance = (color) => {
1367
- const channel = (value) => {
1368
- const normalized = value / 255;
1369
- return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
1370
- };
1371
- return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
1372
- };
1373
- const contrast = (a, b) => {
1374
- if (!a || !b) return null;
1375
- const light = Math.max(luminance(a), luminance(b));
1376
- const dark = Math.min(luminance(a), luminance(b));
1377
- return Math.round(((light + 0.05) / (dark + 0.05)) * 100) / 100;
1378
- };
1379
- const effectiveBackground = (element) => {
1380
- let current = element;
1381
- while (current && current.nodeType === Node.ELEMENT_NODE) {
1382
- const style = getComputedStyle(current);
1383
- const color = parseColor(style.backgroundColor);
1384
- if (color && color.a > 0.05) {
1385
- return { color, source: current === element ? "element" : current.tagName.toLowerCase() };
1386
- }
1387
- current = current.parentElement;
1388
- }
1389
- return { color: { r: 255, g: 255, b: 255, a: 1 }, source: "assumed_canvas" };
1390
- };
1391
1374
  const isVisible = (element) => {
1392
1375
  const rect = element.getBoundingClientRect();
1393
1376
  const style = getComputedStyle(element);
@@ -1440,56 +1423,124 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1440
1423
  // per-element detail for the listed candidates only.
1441
1424
  const ignoredAttributes = Array.from(new Set(visibleElements.flatMap(ignoredAttributesOn))).sort();
1442
1425
 
1443
- const candidates = visibleElements
1426
+ // A route candidate's text, read through the shared contrast helper: each
1427
+ // text-bearing element inside it is compared, unrounded, with the
1428
+ // requirement for its own size. An element with a review trigger (or with
1429
+ // no ratio to compare) is a review member and is not compared; an element
1430
+ // whose control is disabled or loading has no colours read. Text whose
1431
+ // control is disabled is inactive: it is not rendered text the check can
1432
+ // read, so it never counts toward text_rendered or toward a pass. A
1433
+ // disabled candidate is not measured at all, whatever text it generates,
1434
+ // and never counts toward a pass. In an enabled candidate, text an element
1435
+ // inside it generates through ::before or ::after (generatedTextImpl) is
1436
+ // never measured, so the candidate is reviewed, never passed on its other
1437
+ // text.
1438
+ const measureCandidate = (element) => {
1439
+ if (element.matches(":disabled, [aria-disabled=\"true\"]")) {
1440
+ return { disabled: true, text_rendered: null, control_loading: null, contrast_ratio: null, elements: [], below: false, review: false, measured: false, inactive: false };
1441
+ }
1442
+ // Every element the page renders inside the candidate: its own subtree,
1443
+ // open shadow roots included.
1444
+ const subtree = toolkit.flatSubtree(element);
1445
+ const reads = subtree
1446
+ .filter((node) => toolkit.isTextBearing(node, window))
1447
+ .map((node) => toolkit.measureTextElement(node, window));
1448
+ const compared = [];
1449
+ let review = subtree.some((node) => generatedTextImpl(node, window));
1450
+ for (const read of reads) {
1451
+ if (read.disabled || read.control_loading) continue;
1452
+ if (read.review_reason !== null || typeof read.ratio !== "number") review = true;
1453
+ else compared.push(read);
1454
+ }
1455
+ const ratios = compared.map((read) => read.ratio);
1456
+ return {
1457
+ disabled: false,
1458
+ text_rendered: reads.some((read) => !read.disabled),
1459
+ control_loading: reads.some((read) => read.control_loading),
1460
+ contrast_ratio: ratios.length ? toolkit.displayRatio(Math.min(...ratios)) : null,
1461
+ elements: reads.map((read) => ({
1462
+ selector_path: read.selector_path,
1463
+ contrast_ratio_exact: read.ratio,
1464
+ required_ratio: read.required,
1465
+ size_class: read.size_class,
1466
+ review_reason: read.review_reason,
1467
+ })),
1468
+ below: compared.some((read) => !toolkit.meetsRequirement(read.ratio, read.required)),
1469
+ review,
1470
+ measured: compared.length > 0,
1471
+ inactive: reads.some((read) => read.disabled),
1472
+ };
1473
+ };
1474
+
1475
+ // Route candidates carry their measurement; other rows are listed for the
1476
+ // route-recognition view only and are not measured.
1477
+ const verdicts = new Map();
1478
+ const rows = visibleElements
1444
1479
  .map((element) => {
1445
1480
  const rect = element.getBoundingClientRect();
1446
- const style = getComputedStyle(element);
1447
- const fg = parseColor(style.color);
1448
- const bg = effectiveBackground(element);
1449
- const ratio = contrast(fg, bg.color);
1450
1481
  const href = hrefFor(element);
1451
1482
  const label = trim(element.innerText || element.textContent || element.getAttribute("aria-label"));
1452
1483
  return {
1453
- selector: selectorFor(element),
1454
- text: label.slice(0, 120),
1455
- href,
1456
- route_matches: routeMatches(href),
1457
- ignored_attributes: ignoredAttributesOn(element),
1458
- width: Math.round(rect.width),
1459
- height: Math.round(rect.height),
1460
- foreground: hex(fg),
1461
- background: hex(bg.color),
1462
- background_source: bg.source,
1463
- contrast_ratio: ratio,
1464
- readable: typeof ratio === "number" && ratio >= 4.5,
1465
- size_ok: rect.width >= 40 && rect.height >= 20,
1484
+ element,
1485
+ row: {
1486
+ selector: selectorFor(element),
1487
+ text: label.slice(0, 120),
1488
+ href,
1489
+ route_matches: routeMatches(href),
1490
+ ignored_attributes: ignoredAttributesOn(element),
1491
+ width: Math.round(rect.width),
1492
+ height: Math.round(rect.height),
1493
+ size_ok: rect.width >= 40 && rect.height >= 20,
1494
+ },
1466
1495
  };
1467
1496
  })
1468
- .filter((candidate) => candidate.text || candidate.href);
1469
-
1470
- const routeCandidates = candidates
1471
- .filter((candidate) => candidate.route_matches)
1472
- .sort((a, b) => {
1473
- if (a.readable !== b.readable) return a.readable ? -1 : 1;
1474
- if (a.size_ok !== b.size_ok) return a.size_ok ? -1 : 1;
1475
- return (b.contrast_ratio || 0) - (a.contrast_ratio || 0);
1497
+ .filter(({ row }) => row.text || row.href)
1498
+ .map(({ element, row }) => {
1499
+ if (!row.route_matches) return { ...row, disabled: null, text_rendered: null, control_loading: null, contrast_ratio: null, elements: null };
1500
+ const { below, review, measured, inactive, ...read } = measureCandidate(element);
1501
+ const candidate = { ...row, ...read };
1502
+ verdicts.set(candidate, { below, review, measured, inactive });
1503
+ return candidate;
1476
1504
  });
1477
- const primary = routeCandidates[0] || null;
1478
- const ok = Boolean(primary?.readable && primary?.size_ok);
1479
- const reason = ok
1480
- ? "ok"
1481
- : !routeCandidates.length
1482
- ? "missing_route_cta"
1483
- : primary?.size_ok === false
1484
- ? "cta_too_small"
1485
- : "low_contrast";
1505
+
1506
+ // Every route candidate counts, in document order; none is preferred.
1507
+ // The page outcome is the first that applies. An enabled candidate whose
1508
+ // only text is inactive (its control is disabled) has no rendered text;
1509
+ // cta_disabled is only for every candidate disabled. The page passes only
1510
+ // when every enabled candidate had at least one element compared, so text
1511
+ // that was skipped never stands in for a measurement.
1512
+ const routeCandidates = rows.filter((candidate) => candidate.route_matches);
1513
+ const enabled = routeCandidates.filter((candidate) => !candidate.disabled);
1514
+ const measurability = routeCandidates.length ? toolkit.documentMeasurability(document) : { measurable: true };
1515
+ const reason = !routeCandidates.length
1516
+ ? "missing_route_cta"
1517
+ : !measurability.measurable
1518
+ ? measurability.reason
1519
+ : enabled.some((candidate) => verdicts.get(candidate).below)
1520
+ ? "low_contrast"
1521
+ : !routeCandidates.some((candidate) => candidate.size_ok)
1522
+ ? "cta_too_small"
1523
+ : enabled.some((candidate) => !candidate.text_rendered)
1524
+ ? "text_not_rendered"
1525
+ : enabled.some((candidate) => candidate.control_loading)
1526
+ ? "control_loading"
1527
+ : enabled.some((candidate) => verdicts.get(candidate).review)
1528
+ ? "contrast_review"
1529
+ : !enabled.length || enabled.some((candidate) => !verdicts.get(candidate).measured)
1530
+ ? "cta_disabled"
1531
+ : "ok";
1532
+
1533
+ // Rows are capped at eight, except that every route candidate is listed.
1534
+ const listed = [];
1535
+ for (const row of rows) if (row.route_matches || listed.length < 8) listed.push(row);
1486
1536
 
1487
1537
  return {
1488
- ok,
1538
+ ok: reason === "ok",
1489
1539
  reason,
1490
1540
  expected_url: routeUrl,
1491
- primary,
1492
- candidates: candidates.slice(0, 8),
1541
+ // The first route candidate in document order, for display only.
1542
+ primary: routeCandidates[0] || null,
1543
+ candidates: listed,
1493
1544
  // Every route-shaped attribute seen on a visible CTA-shaped element and
1494
1545
  // not consulted (see above), so a missing-route verdict on a page spelled
1495
1546
  // that way reads as a vocabulary gap, not as a removed CTA.
@@ -1497,8 +1548,20 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1497
1548
  };
1498
1549
  }
1499
1550
 
1551
+ // The outcomes the page itself earns as a failure. Every other outcome that
1552
+ // is not a pass could not be observed completely (the page was not
1553
+ // measurable, text or a control was not ready, a review trigger, every CTA
1554
+ // disabled, or an inspection the page rejected): manual review, never a pass
1555
+ // and never a contrast failure.
1556
+ const PRIMARY_CTA_FAIL_REASONS = Object.freeze([
1557
+ "missing_route_cta",
1558
+ "low_contrast",
1559
+ "cta_too_small",
1560
+ ]);
1561
+
1500
1562
  function primaryCtaAssertionFromEvidence(page, evidence) {
1501
1563
  const ok = evidence?.ok === true;
1564
+ const review = !ok && !PRIMARY_CTA_FAIL_REASONS.includes(evidence?.reason);
1502
1565
  const ignored = Array.isArray(evidence?.ignored_attributes) ? evidence.ignored_attributes.filter(Boolean) : [];
1503
1566
  // Named on every verdict the page earns, passing or not: a passing page
1504
1567
  // spelled with an undeclared route attribute is still one an operator
@@ -1509,7 +1572,7 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
1509
1572
  id: `browser-primary-cta:${page.page_id}`,
1510
1573
  family: "browser-runtime",
1511
1574
  page,
1512
- status: ok ? STATUS.PASS : STATUS.FAIL,
1575
+ status: ok ? STATUS.PASS : review ? STATUS.MANUAL_REVIEW : STATUS.FAIL,
1513
1576
  severity: ok ? undefined : SEVERITY.WARN,
1514
1577
  expected: "visible readable primary CTA linked to the expected next route",
1515
1578
  actual: ok
@@ -3821,7 +3884,7 @@ async function executeTestOrderPath({ page, events, email, ladder, checkoutPage,
3821
3884
  }, { timeoutMs: budget() });
3822
3885
  await ladder.run("card_fields_filled", async () => {
3823
3886
  ensurePageFillable(page, checkoutPage.url);
3824
- await fillPaymentFields(page, args);
3887
+ return fillPaymentFields(page, args);
3825
3888
  }, { timeoutMs: budget() });
3826
3889
  await ladder.run("cart_created", async () => {
3827
3890
  const cart = cartCreationEvidence(events);
@@ -5036,14 +5099,69 @@ async function fillPaymentFields(page, args) {
5036
5099
 
5037
5100
  const card = normalizeCard(stringArg(args["test-card"]) || DEFAULT_TEST_CARD);
5038
5101
  const cvv = stringArg(args["test-cvv"]) || DEFAULT_TEST_CVV;
5039
- const numberInput = page.frameLocator('iframe[id^="spreedly-number-frame"]').locator("input").first();
5040
- const cvvInput = page.frameLocator('iframe[id^="spreedly-cvv-frame"]').locator("input").first();
5041
- await numberInput.click();
5042
- await numberInput.pressSequentially(card, { delay: 20 });
5043
- await cvvInput.click();
5044
- await cvvInput.pressSequentially(cvv, { delay: 20 });
5045
- await page.locator("body").click({ position: { x: 20, y: 20 } }).catch(() => {});
5046
- await page.waitForTimeout(500);
5102
+ const frames = await cardFrames(page);
5103
+ const readyWaitMs = await waitForCardFieldsReady(page);
5104
+ const numberInput = page.frameLocator(CARD_NUMBER_FRAME).locator("input").first();
5105
+ const cvvInput = page.frameLocator(CARD_CVV_FRAME).locator("input").first();
5106
+ const typed = async (input) => normalizeCard(await input.inputValue());
5107
+ let attempts = 0;
5108
+ let held = null;
5109
+ while (attempts < CARD_TYPE_ATTEMPTS) {
5110
+ attempts++;
5111
+ if (attempts > 1) {
5112
+ await numberInput.fill("");
5113
+ await cvvInput.fill("");
5114
+ }
5115
+ await numberInput.click();
5116
+ await numberInput.pressSequentially(card, { delay: 20 });
5117
+ await cvvInput.click();
5118
+ await cvvInput.pressSequentially(cvv, { delay: 20 });
5119
+ await page.locator("body").click({ position: { x: 20, y: 20 } }).catch(() => {});
5120
+ await page.waitForTimeout(500);
5121
+ held = { number: await typed(numberInput), cvv: await typed(cvvInput) };
5122
+ if (held.number === card && held.cvv === normalizeCard(cvv)) {
5123
+ return { evidence: { ...frames, ready_wait_ms: readyWaitMs, attempts } };
5124
+ }
5125
+ }
5126
+ throw new Error(`card fields did not keep the typed card after ${attempts} attempts: number field holds ${held.number.length} digit(s) ending ${held.number.slice(-4) || "(empty)"} (typed ${card.length} ending ${card.slice(-4)}), CVV field holds ${held.cvv.length} digit(s)`);
5127
+ }
5128
+
5129
+ // Typing into the card fields before the SDK reports them ready can lose
5130
+ // keystrokes: on SDK 0.4.41 the Spreedly-hosted number input exists before
5131
+ // its script has loaded, and digits typed in that window are dropped (seen
5132
+ // as a number missing its leading digits, which the SDK then refuses without
5133
+ // tokenizing, so the order never posts). The checkout form carries
5134
+ // next-loading-spreedly from before the card iframes mount until the fields
5135
+ // are ready, in 0.4.38 through 0.4.41, so wait for it to clear. A page whose
5136
+ // SDK never set it is ready at once. The SDK also clears it when the card
5137
+ // script fails to load, so a class still set after the wait means the card
5138
+ // script never answered; the step refuses by name rather than type into
5139
+ // fields that may drop the number.
5140
+ async function waitForCardFieldsReady(page) {
5141
+ const started = Date.now();
5142
+ await page.waitForFunction(() => !document.querySelector(".next-loading-spreedly"), null, { timeout: CARD_READY_TIMEOUT_MS }).catch((error) => {
5143
+ if (error?.name !== "TimeoutError") throw error;
5144
+ throw new Error(`card fields did not report ready within ${CARD_READY_TIMEOUT_MS / 1000}s: the checkout form still carries next-loading-spreedly, so the card script never finished loading`);
5145
+ });
5146
+ return Date.now() - started;
5147
+ }
5148
+
5149
+ // The card iframes the step is about to type into, once the SDK has mounted
5150
+ // them. Exactly one of each, of one generation, or the step fails by name.
5151
+ async function cardFrames(page) {
5152
+ await page.locator(CARD_NUMBER_FRAME).first().waitFor({ state: "attached" });
5153
+ await page.locator(CARD_CVV_FRAME).first().waitFor({ state: "attached" });
5154
+ const ids = async (selector) => page.locator(selector).evaluateAll((nodes) => nodes.map((node) => node.id));
5155
+ const number = await ids(CARD_NUMBER_FRAME);
5156
+ const cvv = await ids(CARD_CVV_FRAME);
5157
+ const all = [...number, ...cvv];
5158
+ const generation = all.every((id) => id.startsWith("spreedly-hosted-"))
5159
+ ? "spreedly-hosted"
5160
+ : all.every((id) => /^spreedly-(number|cvv)-frame/.test(id)) ? "spreedly-iframe-v1" : "mixed";
5161
+ if (number.length !== 1 || cvv.length !== 1 || generation === "mixed") {
5162
+ throw new Error(`expected one card number iframe and one CVV iframe of one generation; found number=[${number.join(", ")}] cvv=[${cvv.join(", ")}]`);
5163
+ }
5164
+ return { generation, number_frame_id: number[0], cvv_frame_id: cvv[0] };
5047
5165
  }
5048
5166
 
5049
5167
  async function clickCreditPaymentMethod(page) {
package/src/qa-node.mjs CHANGED
@@ -2,6 +2,7 @@ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch
2
2
  import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
3
3
  import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
4
4
  import { shellToken } from "./shell-token.mjs";
5
+ import { currentBriefMaterial } from "./input-currency.mjs";
5
6
  import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
6
7
  import { requiredActionText } from "./gate-actions.mjs";
7
8
  import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
@@ -167,7 +168,9 @@ Options:
167
168
  qa publish reads the same directory when looking up the sidecar's run.
168
169
  --post-verdict (default) Publish the verdict to the QA portal at
169
170
  <proxy-base>/api/qa/verdicts and print the QA portal link.
170
- Publishing is automatic; this flag is retained for clarity.
171
+ Publishing is automatic, except for a run whose base URL is a local
172
+ address (localhost, *.localhost, 127.x.x.x, 0.0.0.0, [::1], an IPv4-mapped loopback): that verdict stays local unless
173
+ this flag is passed.
171
174
  --no-post-verdict, --local-only Skip publishing; write only the local verdict copy (offline / dev / CI).
172
175
  Publish it later, without a re-run, with qa publish.
173
176
  --verdict <path> qa promote / qa publish: the full verdict file under qa-output/. qa publish
@@ -468,6 +471,9 @@ async function resolveQaInputs(args, {
468
471
  routeRootNote: routeRootNotes[0] || null,
469
472
  });
470
473
  const specHash = computeSpecHash(rawSpec);
474
+ // The brief material this run is judged against, bound at run start beside
475
+ // the spec hash: the QA stage write stamps it, not the material at write time.
476
+ const sourceBriefMaterial = packet ? currentBriefMaterial({ packet, packetPath }) : null;
471
477
  const templateFamily = stringArg(packet?.assembly?.template_family)
472
478
  || stringArg(normalized?.spec_identity?.preferred_template_family)
473
479
  || stringArg(normalized?.campaign?.preferred_template_family)
@@ -524,6 +530,7 @@ async function resolveQaInputs(args, {
524
530
  spec: normalized,
525
531
  specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unknown"),
526
532
  specHash,
533
+ sourceBriefMaterial,
527
534
  templateFamily,
528
535
  commerceStructureContract,
529
536
  topologies: qaScope.topologies,
@@ -583,7 +590,7 @@ function resolvePacketCheckpointPreflight(args, {
583
590
  }
584
591
  if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
585
592
  || (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
586
- throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Re-run prepare-build after a material revision; do not reuse foreign or stale proof.");
593
+ throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Run `record spec` after a material revision (it keeps stage history); do not reuse foreign or stale proof.");
587
594
  }
588
595
  const checkpointGates = [
589
596
  evaluatePageKitStoreProfile({
@@ -1585,6 +1592,12 @@ function reportPathField(resolved) {
1585
1592
  return reportPath ? { report_path: reportPath } : {};
1586
1593
  }
1587
1594
 
1595
+ // The verdict with the brief material qa run bound at run start, when the
1596
+ // run had a packet to bind it from.
1597
+ function withSourceBriefMaterial(verdict, sourceBriefMaterial) {
1598
+ return sourceBriefMaterial ? { ...verdict, source_brief_material: sourceBriefMaterial } : verdict;
1599
+ }
1600
+
1588
1601
  function resolvePayload(resolved, { routeProbe = null } = {}) {
1589
1602
  const entryUrls = deriveEntryUrls(resolved.topologies);
1590
1603
  const pageUrls = derivePageUrls(resolved.topologies);
@@ -2598,7 +2611,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2598
2611
  // The verdict as it is persisted: the local file, the committed sidecar and
2599
2612
  // the published copy all read this one projection (no URL query in any
2600
2613
  // string or key), whichever runner, return path or field produced a value.
2601
- const verdict = redactPersisted(createVerdict({
2614
+ const verdict = redactPersisted(withSourceBriefMaterial(createVerdict({
2602
2615
  runId,
2603
2616
  mapId: resolved.mapId,
2604
2617
  localSpecId: resolved.localSpecId,
@@ -2619,7 +2632,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2619
2632
  commercial,
2620
2633
  causeSummary,
2621
2634
  browser,
2622
- }));
2635
+ }), resolved.sourceBriefMaterial));
2623
2636
 
2624
2637
  const validationErrors = validateVerdict(verdict);
2625
2638
  if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
@@ -2657,7 +2670,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2657
2670
  const consent = resolveConsent({ proxyBase: resolved.proxyBase });
2658
2671
  const publishDecision = resolved.localSpecId
2659
2672
  ? { publish: false, reason: "local_spec", flag_invalid: false }
2660
- : decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2673
+ : decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent, baseUrl: resolved.baseUrl });
2661
2674
  if (publishDecision.flag_invalid) {
2662
2675
  process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
2663
2676
  }
@@ -3461,6 +3474,9 @@ function output(value, args) {
3461
3474
  } else if (value.publish_skipped && value.publish_decision?.reason === "consent_off") {
3462
3475
  console.log(`QA portal: publish skipped — telemetry consent is off, so this non-portal-managed verdict stays local.`);
3463
3476
  console.log(` Destination would be ${value.publish_decision.destination}. Opt in for this run with --post-verdict, or enable with \`${cmd("telemetry")} on\`.`);
3477
+ } else if (value.publish_skipped && value.publish_decision?.reason === "loopback_base_url") {
3478
+ console.log(`QA portal: publish skipped — the base URL is a local address, so this verdict stays local.`);
3479
+ console.log(` Destination would be ${value.publish_decision.destination}. Publish this run with \`${cmd("qa")} publish\`, or pass --post-verdict on the next run.`);
3464
3480
  } else if (value.publish_skipped) {
3465
3481
  console.log(`QA portal: publish skipped (--no-post-verdict); local verdict only.`);
3466
3482
  } else {
@@ -3746,8 +3762,13 @@ export function forcedAnalyticsCorrectness(args) {
3746
3762
  // else (client projects, fixtures, local shakeouts on a local spec), consent
3747
3763
  // off (CAMPAIGNS_OS_TELEMETRY=off / `campaigns-os telemetry off`) means the
3748
3764
  // verdict stays local, with the destination and the opt-in flag named in the
3749
- // run output. Publishing for the portal path is never weakened.
3750
- export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null } = {}) {
3765
+ // run output. Publishing for the portal path is never weakened by consent.
3766
+ // #486: a run against a local-address base URL (localhost or any *.localhost
3767
+ // name, any 127.x.x.x, 0.0.0.0, [::1] or an IPv4-mapped loopback; see
3768
+ // isLocalAddressUrl below) is a local check, so by default its verdict stays
3769
+ // local whatever the spec source; the output names --post-verdict and qa
3770
+ // publish. An explicit --post-verdict still publishes.
3771
+ export function decidePublishVerdict({ args = {}, portalManaged = false, consent = null, baseUrl = null } = {}) {
3751
3772
  if (args["no-post-verdict"] === true || args["local-only"] === true) {
3752
3773
  return { publish: false, reason: "flag_opt_out" };
3753
3774
  }
@@ -3764,11 +3785,30 @@ export function decidePublishVerdict({ args = {}, portalManaged = false, consent
3764
3785
  flagInvalid = true;
3765
3786
  }
3766
3787
  const decorate = (decision) => (flagInvalid ? { ...decision, flag_invalid: true } : decision);
3788
+ if (isLocalAddressUrl(baseUrl)) return decorate({ publish: false, reason: "loopback_base_url" });
3767
3789
  if (portalManaged) return decorate({ publish: true, reason: "portal_managed_default" });
3768
3790
  if (consent?.state === "off") return decorate({ publish: false, reason: "consent_off" });
3769
3791
  return decorate({ publish: true, reason: "default" });
3770
3792
  }
3771
3793
 
3794
+ // The publish default (#486) reads "local" more widely than the shared
3795
+ // LOOPBACK_HOSTNAMES list, which also decides where plain http is allowed and
3796
+ // so stays narrow: any 127.0.0.0/8 address, 0.0.0.0, [::1], an IPv4-mapped
3797
+ // loopback, and localhost or any *.localhost name (with or without a trailing
3798
+ // dot). Keeping such a run local is the safe direction: --post-verdict and
3799
+ // qa publish still send it.
3800
+ function isLocalAddressUrl(value) {
3801
+ if (typeof value !== "string" || !value.trim()) return false;
3802
+ let hostname;
3803
+ try { hostname = new URL(value.trim()).hostname.toLowerCase(); } catch { return false; }
3804
+ const name = hostname.replace(/\.$/, "");
3805
+ if (name === "localhost" || name.endsWith(".localhost")) return true;
3806
+ if (/^127(?:\.\d{1,3}){3}$/.test(name) || name === "0.0.0.0") return true;
3807
+ if (name === "[::1]" || name === "[::]") return true;
3808
+ // WHATWG URL serializes ::ffff:127.x.y.z as [::ffff:7fxx:xxxx].
3809
+ return /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(name);
3810
+ }
3811
+
3772
3812
  function policySnapshot(packet) {
3773
3813
  return {
3774
3814
  campaign: {