@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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- 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
|
-
|
|
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
|
|
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
|
+
}
|