@nextcommerce/campaigns-os 1.59.0 → 1.61.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/CHANGELOG.md +242 -0
- package/README.md +11 -10
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- 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 +27 -26
- package/contracts/release-ledger.json +1311 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +42 -13
- package/docs/campaign-build-brief.md +17 -1
- package/docs/campaigns-os-build-flow.md +62 -3
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- 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 +41 -3
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- 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/campaigns-os-readback.v2.schema.json +177 -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 -5
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +12 -16
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
- package/skills.json +18 -10
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +7 -14
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +89 -73
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +14 -77
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +20 -8
- package/src/doctor/next-step.mjs +6 -3
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -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 +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +26 -11
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/progress-node.mjs +2 -1
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +63 -9
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/spec-derive.mjs
CHANGED
|
@@ -56,15 +56,29 @@ const ROUTING_HINT_FIELDS = Object.freeze({
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
|
|
59
|
-
// everything else under the campaign root, `assets/` and `_data/`
|
|
60
|
-
// is rendered when it is .html), plus the two no build reads. The
|
|
61
|
-
// prunes with this and pageRouteForFile filters with it, so the
|
|
62
|
-
// in one place.
|
|
59
|
+
// on 0.2.x everything else under the campaign root, `assets/` and `_data/`
|
|
60
|
+
// included, is rendered when it is .html), plus the two no build reads. The
|
|
61
|
+
// CLI walker prunes with this and pageRouteForFile filters with it, so the
|
|
62
|
+
// list lives in one place.
|
|
63
63
|
export const PAGE_TREE_IGNORED_DIRS = Object.freeze(["_layouts", "_includes", "node_modules", ".git"]);
|
|
64
64
|
export function isPageTreeIgnoredDir(name) {
|
|
65
65
|
return PAGE_TREE_IGNORED_DIRS.includes(name);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Whether a campaign-relative file sits where page-kit renders no page. From
|
|
69
|
+
// 0.3.0 page-kit also skips the campaign's top-level `assets/` (its
|
|
70
|
+
// `*/assets/**` glob; a nested `offers/assets/` still renders), so the caller
|
|
71
|
+
// says which rule the target's installed page-kit follows. Backslashes and
|
|
72
|
+
// leading slashes read as pageRouteForFile reads them.
|
|
73
|
+
export function isPageTreeIgnoredPath(relativePath, { assetsHtmlIgnored = false } = {}) {
|
|
74
|
+
const dirs = normalizePageTreePath(relativePath).split("/").slice(0, -1);
|
|
75
|
+
return dirs.some(isPageTreeIgnoredDir) || (assetsHtmlIgnored && dirs[0] === "assets");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizePageTreePath(relativePath) {
|
|
79
|
+
return String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
function isPlainObject(value) {
|
|
69
83
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
84
|
}
|
|
@@ -114,13 +128,14 @@ function containerProblem(spec, path) {
|
|
|
114
128
|
// `upsell/`, intermediate directories ignored with its NESTED_NO_PERMALINK
|
|
115
129
|
// warning), and `index.html` is the entry route "". A nested `index.html`
|
|
116
130
|
// would collide with the campaign root (page-kit's DUPLICATE_OUTPUT), so it
|
|
117
|
-
// is not a page this reads. Files under `_layouts/` or `_includes/`
|
|
118
|
-
// null, as page-kit
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
// is not a page this reads. Files under `_layouts/` or `_includes/` (and,
|
|
132
|
+
// with assetsHtmlIgnored, the top-level `assets/`) return null, as page-kit
|
|
133
|
+
// never renders them.
|
|
134
|
+
export function pageRouteForFile(relativePath, { assetsHtmlIgnored = false } = {}) {
|
|
135
|
+
const path = normalizePageTreePath(relativePath);
|
|
121
136
|
if (!path || !/\.html$/i.test(path)) return null;
|
|
122
137
|
const segments = path.split("/");
|
|
123
|
-
if (
|
|
138
|
+
if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) return null;
|
|
124
139
|
const basename = segments[segments.length - 1].replace(/\.html$/i, "");
|
|
125
140
|
if (basename === "index") return segments.length === 1 ? "" : null;
|
|
126
141
|
return `${basename}/`;
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
NEXT_STAGE_CONTRACTS,
|
|
12
12
|
NEXT_STAGE_OWNERS,
|
|
13
13
|
stageIsBlocked,
|
|
14
|
-
stageIsTerminal,
|
|
15
14
|
} from "./orchestration-stage-contract.mjs";
|
|
16
15
|
|
|
17
16
|
const PRODUCER_STAGES = new Set(["doctor", "qa"]);
|
|
@@ -200,6 +199,33 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
|
|
|
200
199
|
return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
|
|
201
200
|
}
|
|
202
201
|
|
|
202
|
+
/**
|
|
203
|
+
* #668: the Polish record a CampaignSpec change moved to `polish.history`,
|
|
204
|
+
* restored as the live record stamped with `stamps` (the current inputs), or
|
|
205
|
+
* null. It is the same review of the same pages, so it is restored only when
|
|
206
|
+
* the live record's input_change is a CampaignSpec change over exactly
|
|
207
|
+
* `fingerprint`, the newest history entry is the completed Polish that change
|
|
208
|
+
* archived (same build fingerprint and source package fingerprint), and the
|
|
209
|
+
* brief's presentation stamp is unchanged: a presentation change still owes a
|
|
210
|
+
* new review. The caller decides that the operator kept this output.
|
|
211
|
+
*/
|
|
212
|
+
export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint = null, stamps = {}, completedStatuses }) {
|
|
213
|
+
const change = isPlainObject(polish) ? polish.input_change : null;
|
|
214
|
+
if (!isPlainObject(change) || change.reason !== "spec_material_changed" || change.superseded_build_fingerprint !== fingerprint) return null;
|
|
215
|
+
const history = Array.isArray(polish.history) ? polish.history : [];
|
|
216
|
+
const superseded = history[history.length - 1];
|
|
217
|
+
if (!isPlainObject(superseded) || superseded.archived_at !== change.at || !completedStatuses.includes(superseded.status)) return null;
|
|
218
|
+
if (superseded.source_build_fingerprint !== fingerprint) return null;
|
|
219
|
+
if ((superseded.source_package_material_fingerprint ?? null) !== (sourcePackageFingerprint ?? null)) return null;
|
|
220
|
+
if ((superseded.source_brief_material?.presentation ?? null) !== (stamps.source_brief_material?.presentation ?? null)) return null;
|
|
221
|
+
const restored = { ...polish };
|
|
222
|
+
for (const field of ["required_by", "required_for", "input_change", "source_brief_material", "source_spec_material_hash"]) delete restored[field];
|
|
223
|
+
for (const field of ["status", "completed_at", "recorded_by", "performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "outputs", "evidence"]) {
|
|
224
|
+
if (Object.hasOwn(superseded, field)) restored[field] = JSON.parse(JSON.stringify(superseded[field]));
|
|
225
|
+
}
|
|
226
|
+
return { ...restored, stage: "polish", ...stamps };
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
/**
|
|
204
230
|
* True when any stage of `report` carries a non-empty history: recorded
|
|
205
231
|
* operator-visible state an intake must not silently drop.
|
package/src/stage-record.mjs
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
// stylesheets must also match the built output byte for byte).
|
|
24
24
|
import { createHash } from "node:crypto";
|
|
25
25
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
|
|
29
29
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
30
30
|
|
|
31
|
-
import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
31
|
+
import { BRAND_FONTS_FILENAME, BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
32
32
|
import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
|
|
33
33
|
import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
34
34
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
53
53
|
import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
54
54
|
import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
|
|
55
|
-
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
|
|
55
|
+
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
56
56
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
57
57
|
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
58
58
|
|
|
@@ -127,7 +127,7 @@ function typeName(value) {
|
|
|
127
127
|
|
|
128
128
|
// visual_review keys only `polish capture` writes. `record polish --evidence`
|
|
129
129
|
// refuses them by name and carries the captured values forward.
|
|
130
|
-
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
|
|
130
|
+
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability", "captures", "element_parity"]);
|
|
131
131
|
export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
|
|
132
132
|
|
|
133
133
|
export function refuseRecord(stage, problems) {
|
|
@@ -382,9 +382,15 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
|
|
|
382
382
|
if (detected) {
|
|
383
383
|
const cause = detected === "spec_material_changed" ? "spec" : "presentation";
|
|
384
384
|
const demoted = demoteStages({ ...nextReport, stages: { ...nextReport.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
|
|
385
|
-
|
|
385
|
+
const kept = keptPolishFor(demoted.report.stages.polish, { assembly, fingerprint, sourcePackageFingerprint, stamps });
|
|
386
|
+
return { report: kept ? { ...demoted.report, stages: { ...demoted.report.stages, polish: kept } } : demoted.report, context: null };
|
|
386
387
|
}
|
|
387
388
|
const previousPolish = stageObject(report, "polish");
|
|
389
|
+
// The usual #668 path: intake (prepare-build --force or record spec) already
|
|
390
|
+
// recorded the change on assembly, so nothing is detected here, but the
|
|
391
|
+
// carried input_change makes this build a replay the operator kept.
|
|
392
|
+
const keptPolish = keptPolishFor(previousPolish, { assembly, fingerprint, sourcePackageFingerprint, stamps });
|
|
393
|
+
if (keptPolish) return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish: keptPolish, ...deploy } }, context: null };
|
|
388
394
|
const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
|
|
389
395
|
&& optionalString(previousPolish.source_build_fingerprint) === fingerprint;
|
|
390
396
|
const polish = polishStillCurrent
|
|
@@ -399,6 +405,16 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
|
|
|
399
405
|
return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
|
|
400
406
|
}
|
|
401
407
|
|
|
408
|
+
// #668: the Polish a CampaignSpec change superseded comes back when the
|
|
409
|
+
// operator's --deviation-reason kept the output it reviewed (stage-ledger's
|
|
410
|
+
// restorePolishKeptWithOutput decides from the archived record). A terminal
|
|
411
|
+
// Polish, or a build the operator did not keep, is left alone.
|
|
412
|
+
function keptPolishFor(polish, { assembly, fingerprint, sourcePackageFingerprint, stamps }) {
|
|
413
|
+
if (!isObject(assembly.unchanged_output_reason) || stageIsTerminal(String(polish?.status || ""))) return null;
|
|
414
|
+
const kept = restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint, stamps, completedStatuses: POLISH_COMPLETED_STATUSES });
|
|
415
|
+
return kept ? withoutKeys(kept, ["skip_reason"]) : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
402
418
|
// stages.deploy after a build of `fingerprint`, or null when it is kept: a
|
|
403
419
|
// completed deploy whose source_build_fingerprint names other output goes
|
|
404
420
|
// whole into history (archived_by "record build", reason_code
|
|
@@ -496,10 +512,24 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
|
|
|
496
512
|
// family components, so it must load a brand layer (brand-theme.css or
|
|
497
513
|
// checkout-brand.css) after it, and that file must be in the built output. A
|
|
498
514
|
// page that loads neither renders the design's own markup and needs no brand
|
|
499
|
-
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs").
|
|
515
|
+
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). When
|
|
516
|
+
// theme generate wrote brand-fonts.css (the design's own faces), each styled
|
|
517
|
+
// page must load the campaign's copy too, or the families brand-theme.css
|
|
518
|
+
// names fall back to the system font. Where it was written is the Build
|
|
519
|
+
// Context's theme.generated.fonts_css_path (theme generate --out-dir moves
|
|
520
|
+
// it); the default path is read only when the context names none. Any page
|
|
500
521
|
// that breaks the rule is a refusal naming the page.
|
|
501
|
-
function brandLayerFacts(doctor) {
|
|
522
|
+
function brandLayerFacts(doctor, context = null) {
|
|
502
523
|
const derived = doctor.derived || {};
|
|
524
|
+
const recordedFonts = context?.theme?.generated?.fonts_css_path;
|
|
525
|
+
const generatedFonts = typeof recordedFonts === "string" && recordedFonts
|
|
526
|
+
? resolve(derived.target_repo, recordedFonts)
|
|
527
|
+
: (context?.theme?.generated ? null : resolve(derived.target_repo, ".campaign-runtime/theme", BRAND_FONTS_FILENAME));
|
|
528
|
+
const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
|
|
529
|
+
const insideSite = (file) => {
|
|
530
|
+
const rel = relative(site.site_root, file);
|
|
531
|
+
return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
|
|
532
|
+
};
|
|
503
533
|
const commerce = commerceScopeFromScope(derived.scope);
|
|
504
534
|
if (!commerce.all.length) {
|
|
505
535
|
throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
|
|
@@ -550,6 +580,30 @@ function brandLayerFacts(doctor) {
|
|
|
550
580
|
problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
|
|
551
581
|
continue;
|
|
552
582
|
}
|
|
583
|
+
// brand-fonts.css holds only @font-face rules, so unlike the brand layer
|
|
584
|
+
// its position relative to next-core.css does not matter.
|
|
585
|
+
if (fontsRequired) {
|
|
586
|
+
const fontsHref = hrefs.find((candidate) => hrefName(candidate) === BRAND_FONTS_FILENAME);
|
|
587
|
+
const bare = fontsHref ? fontsHref.split(/[?#]/)[0] : null;
|
|
588
|
+
const fontsFile = bare && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(bare)
|
|
589
|
+
? (bare.startsWith("/") ? join(site.site_root, bare) : resolve(dirname(built.built_path), bare))
|
|
590
|
+
: null;
|
|
591
|
+
const generatedRel = relative(derived.target_repo, generatedFonts);
|
|
592
|
+
const problem = !fontsHref
|
|
593
|
+
? `${label}: ${builtRel} links no ${BRAND_FONTS_FILENAME}. Theme generate wrote the design's font faces to ${generatedRel}; copy it into the campaign assets/css folder beside the brand layer, list it in the page's frontmatter styles and rebuild.`
|
|
594
|
+
: !fontsFile || !insideSite(fontsFile)
|
|
595
|
+
? `${label}: ${builtRel} links ${fontsHref}, which is not a file in this campaign's built output; link the campaign's own copy of ${generatedRel}.`
|
|
596
|
+
: !existsSync(fontsFile)
|
|
597
|
+
? `${label}: ${builtRel} links ${fontsHref}, which is not in the built output.`
|
|
598
|
+
: readFileSync(fontsFile, "utf8") !== readFileSync(generatedFonts, "utf8")
|
|
599
|
+
? `${label}: ${builtRel} links ${fontsHref}, which differs from the generated ${generatedRel}; copy the generated file and rebuild.`
|
|
600
|
+
: null;
|
|
601
|
+
if (problem) {
|
|
602
|
+
problems.push(problem);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, fontsFile)}, identical to the generated ${generatedRel}.`);
|
|
606
|
+
}
|
|
553
607
|
layers.push(file);
|
|
554
608
|
styled.push(label);
|
|
555
609
|
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
|
|
@@ -849,7 +903,7 @@ function validateRecord(stage, { report, context, packet, fingerprint }) {
|
|
|
849
903
|
// over the report this record would write. A blocked or skipped Polish is
|
|
850
904
|
// not a pass the gate could grant; doctor keeps QA blocked on it.
|
|
851
905
|
const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
|
|
852
|
-
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
|
|
906
|
+
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint, newEvidence: true });
|
|
853
907
|
if (gate.status === "blocked") {
|
|
854
908
|
problems.push(`${gate.code}: ${gate.reason}`);
|
|
855
909
|
for (const problem of gate.problems || []) problems.push(problem);
|
|
@@ -1164,7 +1218,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1164
1218
|
}
|
|
1165
1219
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
1166
1220
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
1167
|
-
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
1221
|
+
if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath));
|
|
1168
1222
|
if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
|
|
1169
1223
|
const next = stage === "setup"
|
|
1170
1224
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
package/src/theme-gate.mjs
CHANGED
|
@@ -166,13 +166,13 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
|
|
|
166
166
|
id: "theme_generate",
|
|
167
167
|
kind: "command",
|
|
168
168
|
command: `campaigns-os theme generate --packet ${packetArg}`,
|
|
169
|
-
description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens.",
|
|
169
|
+
description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens, and brand-fonts.css beside it when the design source ships the faces its headings, body copy or buttons use.",
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
id: "apply_brand_layer",
|
|
173
173
|
kind: "manual",
|
|
174
174
|
command: null,
|
|
175
|
-
description: `Copy brand-theme.css into the campaign assets/css folder, list
|
|
175
|
+
description: `Copy brand-theme.css (and brand-fonts.css, when theme generate wrote one) into the campaign assets/css folder, list brand-theme.css after next-core.css, and brand-fonts.css anywhere, in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
id: "waive_theme",
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { boundedDimensions, VISUAL_MAX_DECODE_PIXELS } from "./png-header.mjs";
|
|
5
|
+
|
|
6
|
+
const own = (value, key) => value !== null && ["object", "function"].includes(typeof value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
7
|
+
const boundedArea = boundedDimensions;
|
|
8
|
+
function geometry(image) {
|
|
9
|
+
boundedArea(own(image, "width"), own(image, "height"));
|
|
10
|
+
if (!ArrayBuffer.isView(image.data) || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
|
|
11
|
+
}
|
|
12
|
+
const track = (memory, data) => { memory?.acquire(data); return data; };
|
|
13
|
+
function release(image, memory) {
|
|
14
|
+
const data = image.data;
|
|
15
|
+
image.data = null;
|
|
16
|
+
if (data) memory?.release(data);
|
|
17
|
+
}
|
|
18
|
+
const allocate = (length, memory) => track(memory, Buffer.alloc(length));
|
|
19
|
+
function maskRects(rects) {
|
|
20
|
+
if (!Array.isArray(rects) || rects.some((r) => !r || ![r.x, r.y].every(Number.isFinite)
|
|
21
|
+
|| ![r.width, r.height].every((n) => Number.isFinite(n) && n >= 0))) throw new RangeError("invalid_mask");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Comparisons consume their RGBA inputs. Callbacks must finish encoding/writing
|
|
25
|
+
// artifacts synchronously; only metrics survive into the next comparison.
|
|
26
|
+
export function readRgba(source, memory) {
|
|
27
|
+
boundedArea(own(source, "width"), own(source, "height"));
|
|
28
|
+
if (typeof own(source, "load") !== "function") { geometry(source); return source; }
|
|
29
|
+
const image = source.load();
|
|
30
|
+
geometry(image);
|
|
31
|
+
if (image.width !== source.width || image.height !== source.height) throw new RangeError("invalid_image_geometry");
|
|
32
|
+
track(memory, image.data); return image;
|
|
33
|
+
}
|
|
34
|
+
export function releaseComparison(part, memory) {
|
|
35
|
+
for (const key of ["basis_crop", "build_crop", "heatmap"]) if (part?.[key]) {
|
|
36
|
+
release(part[key], memory); delete part[key];
|
|
37
|
+
}
|
|
38
|
+
delete part.mismatches; delete part.mask;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DIFF_OPTIONS = Object.freeze({ threshold: 0.1, include_aa: false, alpha: 0.1 });
|
|
42
|
+
// Neither package exports its version: read it from the package.json above the
|
|
43
|
+
// resolved entry whose name is the package's. Unknown is null, never a guess.
|
|
44
|
+
export function installedPackageVersion(name, { resolveEntry = (specifier) => import.meta.resolve(specifier) } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
let directory = dirname(fileURLToPath(resolveEntry(name)));
|
|
47
|
+
for (;;) {
|
|
48
|
+
const file = join(directory, "package.json");
|
|
49
|
+
if (existsSync(file)) {
|
|
50
|
+
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
51
|
+
if (manifest?.name === name) return typeof manifest.version === "string" ? manifest.version : null;
|
|
52
|
+
}
|
|
53
|
+
const parent = dirname(directory);
|
|
54
|
+
if (parent === directory) return null;
|
|
55
|
+
directory = parent;
|
|
56
|
+
}
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
export async function loadVisualDependencies({ importer = (name) => import(name), versionOf = installedPackageVersion } = {}) {
|
|
60
|
+
async function load(name, type) {
|
|
61
|
+
try {
|
|
62
|
+
const module = await importer(name);
|
|
63
|
+
const api = own(module, "default") || module;
|
|
64
|
+
const result = { status: "available", version: own(module, "version") ?? own(api, "version") ?? versionOf(name), package: name };
|
|
65
|
+
if (type === "decoder") {
|
|
66
|
+
const PNG = own(module, "PNG") || own(api, "PNG");
|
|
67
|
+
const sync = own(PNG, "sync");
|
|
68
|
+
if (typeof own(sync, "read") !== "function" || typeof own(sync, "write") !== "function") throw new TypeError("Invalid PNG module");
|
|
69
|
+
result.decode = (bytes) => PNG.sync.read(bytes);
|
|
70
|
+
result.encode = (image) => PNG.sync.write(image);
|
|
71
|
+
} else {
|
|
72
|
+
if (typeof api !== "function") throw new TypeError("Invalid diff module");
|
|
73
|
+
result.compare = api;
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A missing transitive import is a broken installation, not this package being absent.
|
|
78
|
+
const missing = ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"].includes(error?.code)
|
|
79
|
+
&& new RegExp(`(?:package|module) ['\"]${name}['\"]`).test(error.message || "");
|
|
80
|
+
return { status: missing ? "missing" : "failed", version: null, package: name,
|
|
81
|
+
install_hint: `Install ${name} where campaigns-os is installed (npm install ${name}).` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const [decoder, diff] = await Promise.all([load("pngjs", "decoder"), load("pixelmatch", "diff")]);
|
|
85
|
+
return { decoder, diff };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padRgba(image, width, height, color = [255, 255, 255, 255], memory) {
|
|
89
|
+
boundedArea(width, height);
|
|
90
|
+
geometry(image);
|
|
91
|
+
if (width < image.width || height < image.height || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
|
|
92
|
+
const data = allocate(width * height * 4, memory);
|
|
93
|
+
for (let i = 0; i < data.length; i += 4) data.set(color, i);
|
|
94
|
+
for (let y = 0; y < image.height; y++) data.set(image.data.subarray(y * image.width * 4, (y + 1) * image.width * 4), y * width * 4);
|
|
95
|
+
return { width, height, data };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function paintMaskUnion(images, rectsA = [], rectsB = [], color = [255, 0, 255, 255]) {
|
|
99
|
+
maskRects(rectsA); maskRects(rectsB);
|
|
100
|
+
if (typeof color === "string" && /^#[a-f0-9]{6}$/i.test(color)) color = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)).concat(255);
|
|
101
|
+
if (!images.length) return { masked_pixels: 0, mask: new Uint8Array() };
|
|
102
|
+
for (const image of images) geometry(image);
|
|
103
|
+
const { width, height } = images[0];
|
|
104
|
+
boundedArea(width, height);
|
|
105
|
+
if (images.some((image) => image.width !== width || image.height !== height)) throw new RangeError("mask_canvas_mismatch");
|
|
106
|
+
const mask = new Uint8Array(width * height);
|
|
107
|
+
let masked_pixels = 0;
|
|
108
|
+
for (const rect of [...rectsA, ...rectsB]) {
|
|
109
|
+
if (!rect.width || !rect.height) continue;
|
|
110
|
+
const x0 = Math.max(0, Math.floor(rect.x)), y0 = Math.max(0, Math.floor(rect.y));
|
|
111
|
+
const x1 = Math.min(width, Math.ceil(rect.x + rect.width)), y1 = Math.min(height, Math.ceil(rect.y + rect.height));
|
|
112
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
113
|
+
const index = y * width + x;
|
|
114
|
+
if (!mask[index]) { mask[index] = 1; masked_pixels++; }
|
|
115
|
+
for (const image of images) image.data.set(color, index * 4);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { masked_pixels, mask };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function compareImages(basis, build, { diff, rectsA = [], rectsB = [], dpr = 1, memory } = {}) {
|
|
122
|
+
geometry(basis); geometry(build);
|
|
123
|
+
maskRects(rectsA); maskRects(rectsB);
|
|
124
|
+
if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
|
|
125
|
+
const compare = typeof diff === "function" ? diff : own(diff, "compare");
|
|
126
|
+
if (typeof compare !== "function") throw new Error("dependency_missing");
|
|
127
|
+
const width = Math.max(basis.width, build.width), height = Math.max(basis.height, build.height);
|
|
128
|
+
boundedArea(width, height);
|
|
129
|
+
const originalA = { width: basis.width, height: basis.height }, originalB = { width: build.width, height: build.height };
|
|
130
|
+
track(memory, basis.data); track(memory, build.data);
|
|
131
|
+
const inputs = [{ ...basis }, { ...build }];
|
|
132
|
+
basis.data = null; build.data = null;
|
|
133
|
+
const consume = (image) => {
|
|
134
|
+
if (image.width === width && image.height === height) {
|
|
135
|
+
const result = { ...image }; image.data = null; return result;
|
|
136
|
+
}
|
|
137
|
+
const padded = padRgba(image, width, height, undefined, memory);
|
|
138
|
+
release(image, memory); return padded;
|
|
139
|
+
};
|
|
140
|
+
let a, b, heatmap, completed = false;
|
|
141
|
+
try {
|
|
142
|
+
if (inputs[0].data.buffer === inputs[1].data.buffer
|
|
143
|
+
&& inputs[0].data.byteOffset < inputs[1].data.byteOffset + inputs[1].data.byteLength
|
|
144
|
+
&& inputs[1].data.byteOffset < inputs[0].data.byteOffset + inputs[0].data.byteLength) {
|
|
145
|
+
const original = inputs[0].data;
|
|
146
|
+
inputs[0].data = allocate(original.length, memory);
|
|
147
|
+
inputs[0].data.set(original);
|
|
148
|
+
if (original !== inputs[1].data) memory?.release(original);
|
|
149
|
+
}
|
|
150
|
+
a = consume(inputs[0]); b = consume(inputs[1]);
|
|
151
|
+
const { mask, masked_pixels } = paintMaskUnion([a, b], rectsA, rectsB);
|
|
152
|
+
heatmap = { width, height, data: allocate(width * height * 4, memory) };
|
|
153
|
+
compare(a.data, b.data, heatmap.data, width, height,
|
|
154
|
+
{ threshold: DIFF_OPTIONS.threshold, includeAA: DIFF_OPTIONS.include_aa, alpha: DIFF_OPTIONS.alpha });
|
|
155
|
+
const mismatches = new Uint8Array(width * height);
|
|
156
|
+
let mismatch_pixels = 0;
|
|
157
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
158
|
+
const i = y * width + x, p = i * 4;
|
|
159
|
+
const missing = (x >= originalA.width || y >= originalA.height) !== (x >= originalB.width || y >= originalB.height);
|
|
160
|
+
if (!mask[i] && (missing || (heatmap.data[p] === 255 && heatmap.data[p + 1] === 0 && heatmap.data[p + 2] === 0))) {
|
|
161
|
+
mismatches[i] = 1; mismatch_pixels++;
|
|
162
|
+
heatmap.data.set([255, 0, 0, 255], p);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const compared_pixels = width * height - masked_pixels;
|
|
166
|
+
const unpad = (image, original) => {
|
|
167
|
+
for (let y = 0; y < original.height; y++) image.data.copyWithin(y * original.width * 4, y * width * 4, (y * width + original.width) * 4);
|
|
168
|
+
return { ...original, data: image.data.subarray(0, original.width * original.height * 4) };
|
|
169
|
+
};
|
|
170
|
+
const result = { status: "compared", reason: null, width_delta_px: originalB.width - originalA.width,
|
|
171
|
+
height_delta_px: originalB.height - originalA.height, canvas: { width, height }, masked_pixels,
|
|
172
|
+
mismatch_pixels, compared_pixels, mismatch_ratio: compared_pixels ? mismatch_pixels / compared_pixels : 0,
|
|
173
|
+
heatmap, basis_crop: unpad(a, originalA), build_crop: unpad(b, originalB), mismatches, mask,
|
|
174
|
+
worst_tiles: worstTiles({ width, height, mismatches, mask }, { tile: Math.min(32 * dpr, Math.max(width, height)) }) };
|
|
175
|
+
completed = true; return result;
|
|
176
|
+
} finally {
|
|
177
|
+
if (!completed) for (const image of [a, b, heatmap, ...inputs]) if (image?.data) release(image, memory);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function worstTiles({ width, height, mismatches, mask = [] }, { tile = 32, count = 3 } = {}) {
|
|
182
|
+
boundedArea(width, height);
|
|
183
|
+
if (!Number.isFinite(tile) || tile <= 0 || !Number.isSafeInteger(count) || count < 0) throw new RangeError("invalid_tile_options");
|
|
184
|
+
tile = Math.max(1, Math.round(tile));
|
|
185
|
+
count = Math.min(count, 3);
|
|
186
|
+
if (!mismatches || mismatches.length !== width * height || (mask.length && mask.length !== width * height)) throw new RangeError("invalid_tile_geometry");
|
|
187
|
+
const tiles = [];
|
|
188
|
+
for (let y = 0; y < height; y += tile) for (let x = 0; x < width; x += tile) {
|
|
189
|
+
const w = Math.min(tile, width - x), h = Math.min(tile, height - y);
|
|
190
|
+
let mismatch = 0, compared = 0;
|
|
191
|
+
for (let row = y; row < y + h; row++) for (let col = x; col < x + w; col++) {
|
|
192
|
+
const i = row * width + col;
|
|
193
|
+
if (!mask[i]) { compared++; mismatch += mismatches[i]; }
|
|
194
|
+
}
|
|
195
|
+
tiles.push({ x, y, width: w, height: h, mismatch_ratio: compared ? mismatch / compared : 0 });
|
|
196
|
+
tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x);
|
|
197
|
+
if (tiles.length > count) tiles.pop();
|
|
198
|
+
}
|
|
199
|
+
return tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x).slice(0, count);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function encodeHeatmap(image, decoder) {
|
|
203
|
+
geometry(image?.heatmap || image);
|
|
204
|
+
return decoder.encode(image.heatmap || image);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function pairCompatibility(build, basis, { reference = false } = {}) {
|
|
208
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
209
|
+
if (!build || typeof build !== "object" || !basis || typeof basis !== "object") return fail("capture_unavailable");
|
|
210
|
+
if (reference) {
|
|
211
|
+
if (!build || basis.viewport !== build.viewport) return fail("viewport_not_captured");
|
|
212
|
+
if (basis.css_width == null) return fail(basis.dpr == null ? "dpr_unknown" : "width_unknown");
|
|
213
|
+
if (basis.dpr == null) return fail("dpr_unknown");
|
|
214
|
+
if (basis.css_width !== build.width) return fail("width_mismatch");
|
|
215
|
+
if (basis.dpr !== build.device_scale_factor) return fail("dpr_mismatch");
|
|
216
|
+
} else if ([build, basis].some((cell) => cell?.stabilized?.treatment?.mask_cap_reached)) return fail("mask_cap_reached");
|
|
217
|
+
return { ok: true, reason: null };
|
|
218
|
+
}
|
|
219
|
+
export function pageCompatibility(a, b) {
|
|
220
|
+
return a?.truncated || b?.truncated ? { ok: false, reason: "capture_truncated" } : { ok: true, reason: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pixelBand(source, band, dpr) {
|
|
224
|
+
const top = Math.floor(band.top * dpr), bottom = Math.ceil((band.top + band.height) * dpr);
|
|
225
|
+
const left = Math.floor((band.left ?? 0) * dpr);
|
|
226
|
+
const right = Math.ceil(((band.left ?? 0) + (band.width ?? source.width / dpr)) * dpr);
|
|
227
|
+
return { top, left, width: right - left, height: bottom - top };
|
|
228
|
+
}
|
|
229
|
+
function insideCapture(source, band, dpr) {
|
|
230
|
+
const left = band.left ?? 0, width = band.width ?? source.width / dpr;
|
|
231
|
+
return band.top >= 0 && left >= 0
|
|
232
|
+
&& (band.top + band.height) * dpr <= source.height && (left + width) * dpr <= source.width;
|
|
233
|
+
}
|
|
234
|
+
// A rectangle not wholly inside the decoded image is refused before any copy,
|
|
235
|
+
// so no zero-filled pixel is ever presented as image data.
|
|
236
|
+
export function crop(source, { top, left, width, height }, memory) {
|
|
237
|
+
boundedArea(width, height);
|
|
238
|
+
const image = readRgba(source, memory);
|
|
239
|
+
if (![top, left].every(Number.isSafeInteger) || top < 0 || left < 0
|
|
240
|
+
|| left + width > image.width || top + height > image.height) {
|
|
241
|
+
release(image, memory);
|
|
242
|
+
throw new RangeError("invalid_image_geometry");
|
|
243
|
+
}
|
|
244
|
+
const data = allocate(width * height * 4, memory);
|
|
245
|
+
for (let row = 0; row < height; row++) data.set(image.data.subarray(((row + top) * image.width + left) * 4,
|
|
246
|
+
((row + top) * image.width + left + width) * 4), row * width * 4);
|
|
247
|
+
release(image, memory);
|
|
248
|
+
return { width, height, data };
|
|
249
|
+
}
|
|
250
|
+
export function compareBands(basis, build, { basisSections, buildSections, rectsA = [], rectsB = [], dpr = 1, diff, cap = 40, memory, onComparison } = {}) {
|
|
251
|
+
boundedArea(own(basis, "width"), own(basis, "height")); boundedArea(own(build, "width"), own(build, "height"));
|
|
252
|
+
if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
|
|
253
|
+
if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError("invalid_band_cap");
|
|
254
|
+
cap = Math.min(cap, 40);
|
|
255
|
+
maskRects(rectsA); maskRects(rectsB);
|
|
256
|
+
const a = basisSections?.bands || [], b = buildSections?.bands || [];
|
|
257
|
+
if (!Array.isArray(a) || !Array.isArray(b) || [...a, ...b].some((band) => !band || typeof band !== "object")) throw new RangeError("invalid_band_geometry");
|
|
258
|
+
for (const [source, sections] of [[basis, a], [build, b]]) for (const band of sections) {
|
|
259
|
+
const width = band.width ?? source.width / dpr;
|
|
260
|
+
if (![band.top, band.height, band.left ?? 0, width].every(Number.isFinite)) throw new RangeError("invalid_band_geometry");
|
|
261
|
+
// Section boxes are signed, fractional CSS document coordinates. Only
|
|
262
|
+
// non-finite values and over-cap areas refuse the comparison (A-C1).
|
|
263
|
+
if (width > 0 && band.height > 0 && width * dpr * band.height * dpr > VISUAL_MAX_DECODE_PIXELS) throw new RangeError("image_too_large");
|
|
264
|
+
}
|
|
265
|
+
// Repeated bands read fresh images from caller-provided sources (normally
|
|
266
|
+
// capture files). No decoded originals or encoded snapshots are retained.
|
|
267
|
+
if ([basis, build].some((source) => typeof own(source, "load") !== "function" || source.data != null)) throw new RangeError("image_source_required");
|
|
268
|
+
const keys = [...new Set([...a, ...b].map((band) => band.key))].sort();
|
|
269
|
+
const bands = [], members = [];
|
|
270
|
+
const unsupported = (key, reason) => {
|
|
271
|
+
bands.push({ key, status: "unsupported", reason }); members.push({ key, result: "unexercised", reason_code: reason });
|
|
272
|
+
};
|
|
273
|
+
if (!keys.length) members.push({ key: "sections", result: "unexercised", reason_code: "no_section_keys" });
|
|
274
|
+
for (const [index, key] of keys.entries()) {
|
|
275
|
+
const aa = a.filter((band) => band.key === key), bb = b.filter((band) => band.key === key);
|
|
276
|
+
if (index >= cap) { unsupported(key, "band_cap_reached"); continue; }
|
|
277
|
+
if (aa.length > 1 || bb.length > 1 || [basisSections, buildSections].some((s) => s?.duplicated_keys?.includes(key))) { unsupported(key, "section_mapping_duplicate"); continue; }
|
|
278
|
+
if (!aa.length || !bb.length) { unsupported(key, "section_mapping_missing"); continue; }
|
|
279
|
+
const [sa] = aa, [sb] = bb;
|
|
280
|
+
if (sa.height <= 0 || sb.height <= 0 || sa.width <= 0 || sb.width <= 0) { unsupported(key, "section_not_rendered"); continue; }
|
|
281
|
+
if (!insideCapture(basis, sa, dpr) || !insideCapture(build, sb, dpr)) { unsupported(key, "beyond_capture_height"); continue; }
|
|
282
|
+
const pa = pixelBand(basis, sa, dpr), pb = pixelBand(build, sb, dpr);
|
|
283
|
+
if (pa.width <= 0 || pa.height <= 0 || pb.width <= 0 || pb.height <= 0) { unsupported(key, "section_not_rendered"); continue; }
|
|
284
|
+
const relativeRects = (rects, section) => rects.filter((r) => r.y < section.top + section.height && r.y + r.height > section.top)
|
|
285
|
+
.map((r) => ({ ...r, x: r.x - section.left, y: Math.max(0, r.y - section.top), height: Math.min(r.y + r.height, section.top + section.height) - Math.max(r.y, section.top) }));
|
|
286
|
+
const comparison = compareImages(crop(basis, pa, memory), crop(build, pb, memory), { diff, dpr, memory,
|
|
287
|
+
rectsA: relativeRects(rectsA, pa), rectsB: relativeRects(rectsB, pb) });
|
|
288
|
+
Object.assign(comparison, { key, basis: { top: sa.top, height: sa.height }, build: { top: sb.top, height: sb.height } });
|
|
289
|
+
try { onComparison?.(comparison); } finally { releaseComparison(comparison, memory); }
|
|
290
|
+
bands.push(comparison);
|
|
291
|
+
}
|
|
292
|
+
if (basisSections?.capped || buildSections?.capped) members.push({ key: "sections_cap", result: "unexercised", reason_code: "band_cap_reached" });
|
|
293
|
+
bands.sort((a, b) => (b.mismatch_ratio ?? -1) - (a.mismatch_ratio ?? -1)
|
|
294
|
+
|| Math.abs(b.height_delta_px || 0) - Math.abs(a.height_delta_px || 0) || a.key.localeCompare(b.key));
|
|
295
|
+
return { bands, members };
|
|
296
|
+
}
|