@nextcommerce/campaigns-os 1.52.0 → 1.53.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. 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
  }),
@@ -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,
@@ -1300,9 +1302,7 @@ async function primaryCtaVisualAssertions(browserPage, page) {
1300
1302
  }
1301
1303
 
1302
1304
  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);
1305
+ return Boolean(page?.expected_next_url);
1306
1306
  }
1307
1307
 
1308
1308
  // Candidate CTAs: anything clickable, plus every SDK action control and the
@@ -1314,10 +1314,12 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
1314
1314
 
1315
1315
  // The in-page half of the primary-CTA inspection, as the source text the
1316
1316
  // 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.
1317
+ // qa-cart-entry), the shared contrast helper (contrastToolkit) and the
1318
+ // generated-text rule Polish reads (generatedTextRenders) are handed in as
1319
+ // function values rather than closed over, so every function body must
1320
+ // stay free of module-scope references: the text is run in a fresh context by
1321
+ // a test (primary-CTA inspection script is self-contained) that would surface
1322
+ // a leaked identifier as a ReferenceError.
1321
1323
  function primaryCtaInspectionScript(expectedUrl) {
1322
1324
  const args = {
1323
1325
  routeUrl: expectedUrl,
@@ -1326,7 +1328,7 @@ function primaryCtaInspectionScript(expectedUrl) {
1326
1328
  cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
1327
1329
  ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
1328
1330
  };
1329
- return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()})`;
1331
+ return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
1330
1332
  }
1331
1333
 
1332
1334
  async function inspectPrimaryCta(browserPage, expectedUrl) {
@@ -1340,8 +1342,9 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
1340
1342
  }));
1341
1343
  }
1342
1344
 
1343
- function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl) {
1345
+ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
1344
1346
  const CTA_SELECTOR = ctaSelector;
1347
+ const toolkit = contrastToolkitImpl();
1345
1348
 
1346
1349
  const trim = (value) => String(value || "").replace(/\s+/g, " ").trim();
1347
1350
  const compactPath = (value) => String(value || "").replace(/\/+$/, "") || "/";
@@ -1352,42 +1355,6 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1352
1355
  return null;
1353
1356
  }
1354
1357
  })();
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
1358
  const isVisible = (element) => {
1392
1359
  const rect = element.getBoundingClientRect();
1393
1360
  const style = getComputedStyle(element);
@@ -1440,56 +1407,124 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1440
1407
  // per-element detail for the listed candidates only.
1441
1408
  const ignoredAttributes = Array.from(new Set(visibleElements.flatMap(ignoredAttributesOn))).sort();
1442
1409
 
1443
- const candidates = visibleElements
1410
+ // A route candidate's text, read through the shared contrast helper: each
1411
+ // text-bearing element inside it is compared, unrounded, with the
1412
+ // requirement for its own size. An element with a review trigger (or with
1413
+ // no ratio to compare) is a review member and is not compared; an element
1414
+ // whose control is disabled or loading has no colours read. Text whose
1415
+ // control is disabled is inactive: it is not rendered text the check can
1416
+ // read, so it never counts toward text_rendered or toward a pass. A
1417
+ // disabled candidate is not measured at all, whatever text it generates,
1418
+ // and never counts toward a pass. In an enabled candidate, text an element
1419
+ // inside it generates through ::before or ::after (generatedTextImpl) is
1420
+ // never measured, so the candidate is reviewed, never passed on its other
1421
+ // text.
1422
+ const measureCandidate = (element) => {
1423
+ if (element.matches(":disabled, [aria-disabled=\"true\"]")) {
1424
+ return { disabled: true, text_rendered: null, control_loading: null, contrast_ratio: null, elements: [], below: false, review: false, measured: false, inactive: false };
1425
+ }
1426
+ // Every element the page renders inside the candidate: its own subtree,
1427
+ // open shadow roots included.
1428
+ const subtree = toolkit.flatSubtree(element);
1429
+ const reads = subtree
1430
+ .filter((node) => toolkit.isTextBearing(node, window))
1431
+ .map((node) => toolkit.measureTextElement(node, window));
1432
+ const compared = [];
1433
+ let review = subtree.some((node) => generatedTextImpl(node, window));
1434
+ for (const read of reads) {
1435
+ if (read.disabled || read.control_loading) continue;
1436
+ if (read.review_reason !== null || typeof read.ratio !== "number") review = true;
1437
+ else compared.push(read);
1438
+ }
1439
+ const ratios = compared.map((read) => read.ratio);
1440
+ return {
1441
+ disabled: false,
1442
+ text_rendered: reads.some((read) => !read.disabled),
1443
+ control_loading: reads.some((read) => read.control_loading),
1444
+ contrast_ratio: ratios.length ? toolkit.displayRatio(Math.min(...ratios)) : null,
1445
+ elements: reads.map((read) => ({
1446
+ selector_path: read.selector_path,
1447
+ contrast_ratio_exact: read.ratio,
1448
+ required_ratio: read.required,
1449
+ size_class: read.size_class,
1450
+ review_reason: read.review_reason,
1451
+ })),
1452
+ below: compared.some((read) => !toolkit.meetsRequirement(read.ratio, read.required)),
1453
+ review,
1454
+ measured: compared.length > 0,
1455
+ inactive: reads.some((read) => read.disabled),
1456
+ };
1457
+ };
1458
+
1459
+ // Route candidates carry their measurement; other rows are listed for the
1460
+ // route-recognition view only and are not measured.
1461
+ const verdicts = new Map();
1462
+ const rows = visibleElements
1444
1463
  .map((element) => {
1445
1464
  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
1465
  const href = hrefFor(element);
1451
1466
  const label = trim(element.innerText || element.textContent || element.getAttribute("aria-label"));
1452
1467
  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,
1468
+ element,
1469
+ row: {
1470
+ selector: selectorFor(element),
1471
+ text: label.slice(0, 120),
1472
+ href,
1473
+ route_matches: routeMatches(href),
1474
+ ignored_attributes: ignoredAttributesOn(element),
1475
+ width: Math.round(rect.width),
1476
+ height: Math.round(rect.height),
1477
+ size_ok: rect.width >= 40 && rect.height >= 20,
1478
+ },
1466
1479
  };
1467
1480
  })
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);
1481
+ .filter(({ row }) => row.text || row.href)
1482
+ .map(({ element, row }) => {
1483
+ if (!row.route_matches) return { ...row, disabled: null, text_rendered: null, control_loading: null, contrast_ratio: null, elements: null };
1484
+ const { below, review, measured, inactive, ...read } = measureCandidate(element);
1485
+ const candidate = { ...row, ...read };
1486
+ verdicts.set(candidate, { below, review, measured, inactive });
1487
+ return candidate;
1476
1488
  });
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";
1489
+
1490
+ // Every route candidate counts, in document order; none is preferred.
1491
+ // The page outcome is the first that applies. An enabled candidate whose
1492
+ // only text is inactive (its control is disabled) has no rendered text;
1493
+ // cta_disabled is only for every candidate disabled. The page passes only
1494
+ // when every enabled candidate had at least one element compared, so text
1495
+ // that was skipped never stands in for a measurement.
1496
+ const routeCandidates = rows.filter((candidate) => candidate.route_matches);
1497
+ const enabled = routeCandidates.filter((candidate) => !candidate.disabled);
1498
+ const measurability = routeCandidates.length ? toolkit.documentMeasurability(document) : { measurable: true };
1499
+ const reason = !routeCandidates.length
1500
+ ? "missing_route_cta"
1501
+ : !measurability.measurable
1502
+ ? measurability.reason
1503
+ : enabled.some((candidate) => verdicts.get(candidate).below)
1504
+ ? "low_contrast"
1505
+ : !routeCandidates.some((candidate) => candidate.size_ok)
1506
+ ? "cta_too_small"
1507
+ : enabled.some((candidate) => !candidate.text_rendered)
1508
+ ? "text_not_rendered"
1509
+ : enabled.some((candidate) => candidate.control_loading)
1510
+ ? "control_loading"
1511
+ : enabled.some((candidate) => verdicts.get(candidate).review)
1512
+ ? "contrast_review"
1513
+ : !enabled.length || enabled.some((candidate) => !verdicts.get(candidate).measured)
1514
+ ? "cta_disabled"
1515
+ : "ok";
1516
+
1517
+ // Rows are capped at eight, except that every route candidate is listed.
1518
+ const listed = [];
1519
+ for (const row of rows) if (row.route_matches || listed.length < 8) listed.push(row);
1486
1520
 
1487
1521
  return {
1488
- ok,
1522
+ ok: reason === "ok",
1489
1523
  reason,
1490
1524
  expected_url: routeUrl,
1491
- primary,
1492
- candidates: candidates.slice(0, 8),
1525
+ // The first route candidate in document order, for display only.
1526
+ primary: routeCandidates[0] || null,
1527
+ candidates: listed,
1493
1528
  // Every route-shaped attribute seen on a visible CTA-shaped element and
1494
1529
  // not consulted (see above), so a missing-route verdict on a page spelled
1495
1530
  // that way reads as a vocabulary gap, not as a removed CTA.
@@ -1497,8 +1532,20 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
1497
1532
  };
1498
1533
  }
1499
1534
 
1535
+ // The outcomes the page itself earns as a failure. Every other outcome that
1536
+ // is not a pass could not be observed completely (the page was not
1537
+ // measurable, text or a control was not ready, a review trigger, every CTA
1538
+ // disabled, or an inspection the page rejected): manual review, never a pass
1539
+ // and never a contrast failure.
1540
+ const PRIMARY_CTA_FAIL_REASONS = Object.freeze([
1541
+ "missing_route_cta",
1542
+ "low_contrast",
1543
+ "cta_too_small",
1544
+ ]);
1545
+
1500
1546
  function primaryCtaAssertionFromEvidence(page, evidence) {
1501
1547
  const ok = evidence?.ok === true;
1548
+ const review = !ok && !PRIMARY_CTA_FAIL_REASONS.includes(evidence?.reason);
1502
1549
  const ignored = Array.isArray(evidence?.ignored_attributes) ? evidence.ignored_attributes.filter(Boolean) : [];
1503
1550
  // Named on every verdict the page earns, passing or not: a passing page
1504
1551
  // spelled with an undeclared route attribute is still one an operator
@@ -1509,7 +1556,7 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
1509
1556
  id: `browser-primary-cta:${page.page_id}`,
1510
1557
  family: "browser-runtime",
1511
1558
  page,
1512
- status: ok ? STATUS.PASS : STATUS.FAIL,
1559
+ status: ok ? STATUS.PASS : review ? STATUS.MANUAL_REVIEW : STATUS.FAIL,
1513
1560
  severity: ok ? undefined : SEVERITY.WARN,
1514
1561
  expected: "visible readable primary CTA linked to the expected next route",
1515
1562
  actual: ok
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";
@@ -468,6 +469,9 @@ async function resolveQaInputs(args, {
468
469
  routeRootNote: routeRootNotes[0] || null,
469
470
  });
470
471
  const specHash = computeSpecHash(rawSpec);
472
+ // The brief material this run is judged against, bound at run start beside
473
+ // the spec hash: the QA stage write stamps it, not the material at write time.
474
+ const sourceBriefMaterial = packet ? currentBriefMaterial({ packet, packetPath }) : null;
471
475
  const templateFamily = stringArg(packet?.assembly?.template_family)
472
476
  || stringArg(normalized?.spec_identity?.preferred_template_family)
473
477
  || stringArg(normalized?.campaign?.preferred_template_family)
@@ -524,6 +528,7 @@ async function resolveQaInputs(args, {
524
528
  spec: normalized,
525
529
  specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unknown"),
526
530
  specHash,
531
+ sourceBriefMaterial,
527
532
  templateFamily,
528
533
  commerceStructureContract,
529
534
  topologies: qaScope.topologies,
@@ -583,7 +588,7 @@ function resolvePacketCheckpointPreflight(args, {
583
588
  }
584
589
  if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
585
590
  || (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.");
591
+ 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
592
  }
588
593
  const checkpointGates = [
589
594
  evaluatePageKitStoreProfile({
@@ -1585,6 +1590,12 @@ function reportPathField(resolved) {
1585
1590
  return reportPath ? { report_path: reportPath } : {};
1586
1591
  }
1587
1592
 
1593
+ // The verdict with the brief material qa run bound at run start, when the
1594
+ // run had a packet to bind it from.
1595
+ function withSourceBriefMaterial(verdict, sourceBriefMaterial) {
1596
+ return sourceBriefMaterial ? { ...verdict, source_brief_material: sourceBriefMaterial } : verdict;
1597
+ }
1598
+
1588
1599
  function resolvePayload(resolved, { routeProbe = null } = {}) {
1589
1600
  const entryUrls = deriveEntryUrls(resolved.topologies);
1590
1601
  const pageUrls = derivePageUrls(resolved.topologies);
@@ -2598,7 +2609,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2598
2609
  // The verdict as it is persisted: the local file, the committed sidecar and
2599
2610
  // the published copy all read this one projection (no URL query in any
2600
2611
  // string or key), whichever runner, return path or field produced a value.
2601
- const verdict = redactPersisted(createVerdict({
2612
+ const verdict = redactPersisted(withSourceBriefMaterial(createVerdict({
2602
2613
  runId,
2603
2614
  mapId: resolved.mapId,
2604
2615
  localSpecId: resolved.localSpecId,
@@ -2619,7 +2630,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2619
2630
  commercial,
2620
2631
  causeSummary,
2621
2632
  browser,
2622
- }));
2633
+ }), resolved.sourceBriefMaterial));
2623
2634
 
2624
2635
  const validationErrors = validateVerdict(verdict);
2625
2636
  if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
package/src/qc-accept.mjs CHANGED
@@ -14,6 +14,7 @@ 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
16
  import { canonicalJson } from "./polish-capture.mjs";
17
+ import { QC_CHECK_REGISTRY } from "./qc-check-registry.mjs";
17
18
  import { QC_LEGS, QC_REASON, fingerprint12, qcResultRef } from "./qc-results.mjs";
18
19
  import { shellToken } from "./shell-token.mjs";
19
20
 
@@ -22,7 +23,18 @@ export const QC_ACCEPT_SCOPE = "qc_accept";
22
23
  export const QC_ACCEPT_RECORDER = "campaigns-os checkpoint accept";
23
24
  export const QC_ACCEPT_STATUSES = Object.freeze(["active", "lapsed", "orphaned", "expired", "inert"]);
24
25
  export const QC_DISPOSITION = Object.freeze({ OPEN: "open", OPERATOR_ACCEPTED: "operator_accepted" });
25
- export const QC_MEASURED_SOURCES = Object.freeze({ doctor: "doctor_sidecar", polish: "polish_media_weight", qa: "qa_full_verdict" });
26
+ // The evidence an accept's measurement came from: the doctor sidecar, the
27
+ // full QA verdict, or, on the Polish leg, the package-owned record the
28
+ // accepted check is read from (QC_CHECK_REGISTRY `record`).
29
+ 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
+
32
+ // Null for a Polish check with no record, or a leg with no source.
33
+ export function measuredSourceFor(leg, check) {
34
+ if (leg !== "polish") return Object.hasOwn(QC_LEG_SOURCES, leg) ? QC_LEG_SOURCES[leg] : null;
35
+ const entry = Object.hasOwn(QC_CHECK_REGISTRY, check) ? QC_CHECK_REGISTRY[check] : null;
36
+ return entry?.leg === "polish" && Object.hasOwn(QC_POLISH_RECORD_SOURCES, entry.record) ? QC_POLISH_RECORD_SOURCES[entry.record] : null;
37
+ }
26
38
 
27
39
  // The command's closed refusal list.
28
40
  export const QC_ACCEPT_REFUSALS = Object.freeze({
@@ -100,7 +112,7 @@ export function createQcAccept(result, { measuredAt, attribution }) {
100
112
  state_fingerprint: result.state_fingerprint,
101
113
  result_at_accept: "warning",
102
114
  measured_at: measuredAt,
103
- measured_source: QC_MEASURED_SOURCES[result.leg],
115
+ measured_source: measuredSourceFor(result.leg, result.check),
104
116
  ...attribution,
105
117
  recorded_by: QC_ACCEPT_RECORDER,
106
118
  };
@@ -119,7 +131,8 @@ function malformed(record) {
119
131
  && FINGERPRINT.test(record.state_fingerprint || "")
120
132
  && record.result_at_accept === "warning"
121
133
  && validTime(record.measured_at)
122
- && record.measured_source === QC_MEASURED_SOURCES[record.leg]
134
+ && isNonEmptyString(record.measured_source)
135
+ && record.measured_source === measuredSourceFor(record.leg, record.check)
123
136
  && typeof record.reason === "string"
124
137
  && typeof record.accepted_by === "string"
125
138
  && typeof record.accepted_at === "string"
@@ -291,7 +304,29 @@ export function planQcAccepts({ refs, results, sidecarPath, now, attribution })
291
304
  const LEG_ORDER = Object.freeze({ doctor: 0, polish: 1, qa: 2 });
292
305
  const compareText = (a, b) => String(a ?? "").localeCompare(String(b ?? ""));
293
306
 
307
+ // A readability colour pair reads as its roles, colours, ratio and
308
+ // requirement: `<roles>: <fg8> on <bg8>, <ratio>:1, needs <required>:1
309
+ // (<size_class> text)`, from the row's observation (the lowest ratio of the
310
+ // rows given). Null for any other row.
311
+ function readabilityPairSummary(rows) {
312
+ const pairs = rows.map((row) => (row?.check === "readability.contrast" && String(row.subject?.key ?? "").startsWith("pair:") && isPlainObject(row.observation) ? row.observation : null));
313
+ if (!pairs.length || pairs.some((observation) => observation === null)) return null;
314
+ const roles = [...new Set(pairs.flatMap((observation) => (Array.isArray(observation.roles) ? observation.roles : [])))].sort(compareText);
315
+ const lowest = pairs.reduce((low, observation) => (observation.ratio < low.ratio ? observation : low));
316
+ return `${roles.join(", ")}: ${lowest.fg8} on ${lowest.bg8}, ${lowest.display_ratio}:1, needs ${lowest.required}:1 (${lowest.size_class} text)`;
317
+ }
318
+
319
+ // The crop path of a readability review row: its first member's crop.
320
+ // Undefined for a review row with no crop, and for any other row.
321
+ function readabilityReviewCrop(row) {
322
+ if (row?.check !== "readability.contrast" || row.result !== "review") return undefined;
323
+ const members = Array.isArray(row.observation?.members) ? row.observation.members : [];
324
+ const ref = members.find((member) => isPlainObject(member?.crop_ref))?.crop_ref;
325
+ return typeof ref?.path === "string" ? ref.path : undefined;
326
+ }
327
+
294
328
  function handoffEntry(row) {
329
+ const crop = readabilityReviewCrop(row);
295
330
  return {
296
331
  result_ref: qcResultRef(row),
297
332
  check: row.check,
@@ -303,7 +338,8 @@ function handoffEntry(row) {
303
338
  reason_code: row.reason_code,
304
339
  accept_eligible: row.accept_eligible === true,
305
340
  members: Array.isArray(row.members) ? row.members : [],
306
- summary: `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
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}`}`,
342
+ ...(crop === undefined ? {} : { crop }),
307
343
  };
308
344
  }
309
345
 
@@ -315,7 +351,7 @@ const byCheckKeyPage = (a, b) => compareText(a.check, b.check) || compareText(a.
315
351
  // first page's fields, `pages`, and `results`, which keeps every page's result
316
352
  // with its own ref, accept eligibility and members, so every member stays
317
353
  // visible. The entry is accept-eligible only when every result in it is.
318
- function groupOpenWarnings(entries) {
354
+ function groupOpenWarnings(entries, rows = []) {
319
355
  const groups = new Map();
320
356
  for (const entry of [...entries].sort(byCheckKeyPage)) {
321
357
  const key = JSON.stringify([entry.check, entry.key]);
@@ -330,7 +366,7 @@ function groupOpenWarnings(entries) {
330
366
  pages,
331
367
  accept_eligible: results.every((entry) => entry.accept_eligible),
332
368
  summary: results.length > 1
333
- ? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
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}`}`
334
370
  : lead.summary,
335
371
  results,
336
372
  };
@@ -401,7 +437,7 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
401
437
  const coverageEntries = [...grouped.values(), ...coverage]
402
438
  .map((entry) => ({ ...entry, pages: [...entry.pages].sort(compareText) }))
403
439
  .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));
404
- const openGroups = groupOpenWarnings(open);
440
+ const openGroups = groupOpenWarnings(open, results);
405
441
  review.sort(byCheckKeyPage);
406
442
  lapsed.sort(byCheckKeyPage);
407
443
  accepted.sort(byCheckKeyPage);
@@ -435,7 +471,7 @@ export function qcHandoffTextLines(handoff) {
435
471
  section("Open warnings", handoff.open, (entry) => (entry.results?.length > 1
436
472
  ? [entry.summary, ...entry.results.map((result) => ` ${openLine(result)}`)]
437
473
  : openLine(entry)));
438
- section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${members(entry)}`);
474
+ section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${entry.crop ? ` crop: ${entry.crop}` : ""}${members(entry)}`);
439
475
  section("Lapsed accepts", handoff.lapsed, (entry) => `${entry.result_ref} ${entry.summary}; accepted by ${entry.accepted_by} at ${entry.accepted_at}; lapsed: ${entry.why}`);
440
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(", ")}` : ""}`);
441
477
  section("Accepted", handoff.accepted, (entry) => `${entry.result_ref} accepted by ${entry.accepted_by} at ${entry.accepted_at}: ${entry.reason}${members(entry)}`);
@@ -1,15 +1,19 @@
1
1
  // The one map from a QC check id to the check module that re-derives its
2
- // results. The QC readers
3
- // (src/qc-results.mjs) reach every check's rules through here, so a check
4
- // lands by adding its module, never by editing the readers.
2
+ // results. The QC readers (src/qc-results.mjs) reach every check's rules
3
+ // through here. A QA check lands by adding its module; a Polish check reads
4
+ // one package-owned visual_review record, named by `record`, and each record
5
+ // has its own reader in the Polish dispatch (POLISH_RECORDS), so a check for
6
+ // a new record also adds that reader.
5
7
  //
6
8
  // Each entry names a module specifier, imported lazily and relative to this
7
9
  // file, and the export the reader calls:
8
10
  // - QA checks: `rederiveQcResult(observation)` returns a Derived result
9
11
  // ({check, subject, result, reason_code, members, accept_eligible, coverage,
10
12
  // state}) or null when the observation cannot be re-derived.
11
- // - Polish checks: `MEDIA_WEIGHT_QC_RULES` is {thresholds, vocabulary,
12
- // evaluate(cell, thresholds) => Derived[]}.
13
+ // - Polish checks: the record's rules, {thresholds, vocabulary,
14
+ // evaluate(cell, thresholds) => Derived[]} (MEDIA_WEIGHT_QC_RULES for
15
+ // media_weight; READABILITY_QC_RULES for readability, which also carries
16
+ // the record's fixed producer values, limits and route set).
13
17
  // - Doctor checks recompute from the built HTML on every read and are called
14
18
  // by doctor itself; their entries name the evaluator for completeness.
15
19
  //
@@ -23,11 +27,13 @@ export const QC_CHECK_REGISTRY = Object.freeze({
23
27
  // Content params
24
28
  content_param: Object.freeze({ leg: "qa", unit: "1.2", module: "./qa-content-params.mjs", rederive: "rederiveQcResult" }),
25
29
  // Media weight and oversizing
26
- "media.weight": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES" }),
27
- "media.oversize": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES" }),
30
+ "media.weight": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES", record: "media_weight" }),
31
+ "media.oversize": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES", record: "media_weight" }),
28
32
  // Policy links
29
33
  "policy.presence": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
30
34
  "policy.availability": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
35
+ // Text readability
36
+ "readability.contrast": Object.freeze({ leg: "polish", unit: "2.4", module: "./polish-readability.mjs", rederive: "READABILITY_QC_RULES", record: "readability" }),
31
37
  // Cart placeholders
32
38
  cart_placeholders: Object.freeze({ leg: "doctor", unit: "1.5", module: "./cart-placeholders.mjs", rederive: "evaluateCartPlaceholders" }),
33
39
  // Built-output smoke checks
@@ -35,6 +41,8 @@ export const QC_CHECK_REGISTRY = Object.freeze({
35
41
  });
36
42
 
37
43
  export const qcChecksForLeg = (leg) => Object.keys(QC_CHECK_REGISTRY).filter((check) => QC_CHECK_REGISTRY[check].leg === leg);
44
+ // The Polish checks read from one visual_review record.
45
+ export const qcChecksForRecord = (record) => Object.keys(QC_CHECK_REGISTRY).filter((check) => QC_CHECK_REGISTRY[check].leg === "polish" && QC_CHECK_REGISTRY[check].record === record);
38
46
 
39
47
  // A module "does not exist yet" only when Node reports ERR_MODULE_NOT_FOUND
40
48
  // for that exact specifier. A missing transitive import names another URL,