@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
@@ -0,0 +1,828 @@
1
+ // Readability: the sibling stages.polish.evidence.visual_review.readability
2
+ // record and the rules every reader re-derives its results with.
3
+ //
4
+ // The record holds measurements only. Its results are never stored: readers
5
+ // (src/qc-results.mjs readReadability) check the record, re-derive every
6
+ // stored element through the shared contrast helper, then call
7
+ // READABILITY_QC_RULES.evaluate on each cell (reached through
8
+ // src/qc-check-registry.mjs).
9
+ //
10
+ // Per cell (one built route × viewport):
11
+ // - cell_status: "measured", or why the cell measured nothing;
12
+ // - capped: the probe stopped at the per-cell element cap;
13
+ // - elements[]: every text-bearing element the probe read, and every role
14
+ // element whose text did not render (rendered: false). A disabled control
15
+ // carries no colour fields (it is listed before any colour is read); every
16
+ // other element carries its raw computed colours, font size and weight, and
17
+ // the fields the helper derives from them;
18
+ // - coverage_gaps[]: what the probe could not measure: closed shadow roots,
19
+ // cross-origin frames, loading, hidden or generated-text role elements, and
20
+ // selected, active or expanded states not present at load.
21
+ import { createHash } from "node:crypto";
22
+ import { mkdirSync, writeFileSync } from "node:fs";
23
+ import { join, relative, sep } from "node:path";
24
+
25
+ import { resolveBuiltSiteScope } from "./built-site-scope.mjs";
26
+ import { CART_PLACEHOLDERS_LIMITS } from "./cart-placeholders.mjs";
27
+ import { contrastToolkit } from "./contrast.mjs";
28
+ import {
29
+ READABILITY_ADDED_RUN_MS,
30
+ READABILITY_CROP_RUN_MS,
31
+ READABILITY_PROBE_CELL_MS,
32
+ READABILITY_PROBE_RUN_MS,
33
+ } from "./polish-deadline.mjs";
34
+ import { MAX_POLISH_CAPTURE_ROUTES, POLISH_CAPTURE_VIEWPORTS } from "./polish-node.mjs";
35
+ import { QC_PRODUCERS, aggregateQcResults, polishRecordIntegrity } from "./qc-results.mjs";
36
+
37
+ export const READABILITY_CHECK = "readability.contrast";
38
+ export const READABILITY_SCHEMA = "campaigns-os-polish-readability/v0";
39
+ export const READABILITY_PRODUCER = QC_PRODUCERS.polish;
40
+ export const READABILITY_ROUTE_SOURCE = "built_site";
41
+
42
+ const toolkit = contrastToolkit();
43
+ export const READABILITY_HELPER_VERSION = toolkit.version;
44
+
45
+ export const READABILITY_THRESHOLDS = Object.freeze({
46
+ normal: toolkit.requiredRatio(false),
47
+ large: toolkit.requiredRatio(true),
48
+ large_px: 24,
49
+ large_bold_px: 18.66,
50
+ large_bold_weight: 700,
51
+ });
52
+
53
+ // Text-bearing roles, in the order the first matching one is taken.
54
+ export const READABILITY_ROLES = Object.freeze([
55
+ "add_to_cart",
56
+ "upsell_accept",
57
+ "upsell_decline",
58
+ "submit_control",
59
+ "sdk_action",
60
+ "bundle_card",
61
+ "order_bump",
62
+ "price",
63
+ "checkout_label",
64
+ "checkout_hint",
65
+ "body_text",
66
+ ]);
67
+
68
+ export const READABILITY_VOCABULARY = Object.freeze({
69
+ cell_status: Object.freeze(["measured", "navigation_failed", "probe_timeout", "document_changed", "styles_incomplete", "fonts_pending", "sdk_not_ready", "run_budget_exhausted", "producer_timeout"]),
70
+ coverage_gap: Object.freeze(["closed_shadow_root", "cross_origin_text", "control_loading", "state_not_observed", "not_visible_at_load", "generated_text"]),
71
+ role: READABILITY_ROLES,
72
+ state: Object.freeze(["selected", "active", "expanded", "default"]),
73
+ size_class: Object.freeze(["normal", "large"]),
74
+ review_reason: Object.freeze(["background_gradient", "background_image", "pseudo_element_background", "overlapping_layer", "opacity", "filter", "blend_mode", "text_fill_background", "unparseable_color", "canvas_unknown", "disabled_state_uncertain", "mask"]),
75
+ crop_reason: Object.freeze(["outside_viewport", "crop_unavailable"]),
76
+ });
77
+
78
+ // Crops live under the target repo, named by the sha256 of their bytes.
79
+ export const READABILITY_CROP_DIR = ".campaign-runtime/polish/readability";
80
+ const CROP_PATH = /^\.campaign-runtime\/polish\/readability\/([a-f0-9]{64})\.png$/;
81
+ const CROP_SHA = /^sha256:([a-f0-9]{64})$/;
82
+
83
+ // The fixed viewport keys and the record's limits. Read when called, not at
84
+ // load, so the capture modules this one imports may import it in turn.
85
+ export const readabilityViewports = () => POLISH_CAPTURE_VIEWPORTS.map((viewport) => viewport.key);
86
+
87
+ export function readabilityLimits() {
88
+ return Object.freeze({
89
+ elements_per_cell: 2000,
90
+ crops_per_cell: 40,
91
+ probe_ms_per_cell: READABILITY_PROBE_CELL_MS,
92
+ probe_ms_per_run: READABILITY_PROBE_RUN_MS,
93
+ crop_ms_per_run: READABILITY_CROP_RUN_MS,
94
+ added_ms_per_run: READABILITY_ADDED_RUN_MS,
95
+ routes: MAX_POLISH_CAPTURE_ROUTES,
96
+ route_enumeration: CART_PLACEHOLDERS_LIMITS.pages,
97
+ });
98
+ }
99
+
100
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
101
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
102
+ const inVocabulary = (field, value) => READABILITY_VOCABULARY[field].includes(value);
103
+ const isSrgb = (value) => Array.isArray(value) && value.length === 3 && value.every((c) => typeof c === "number" && c >= 0 && c <= 1);
104
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
105
+
106
+ function invalid(message) {
107
+ throw new TypeError(`readability cell ${message}.`);
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Routes
112
+
113
+ // The readability route set: every .html under _site/<slug>/ (entries
114
+ // prefixed _ or . skipped), as public routes (index.html → /<slug>/<dir>/,
115
+ // any other file → /<slug>/<path>), sorted. The first 500 are enumerated;
116
+ // the first 128 of those are captured and the rest listed as uncaptured.
117
+ export function readabilityRoutes(targetRepo, slug) {
118
+ if (!isNonEmptyString(slug)) return { ok: false, error: "no campaign slug", routes: [], uncaptured_routes: [], route_enumeration_capped: false };
119
+ const scope = resolveBuiltSiteScope(targetRepo, { slug });
120
+ if (!scope.ok) return { ok: false, error: scope.error, routes: [], uncaptured_routes: [], route_enumeration_capped: false };
121
+ const limits = readabilityLimits();
122
+ const all = scope.pages.map((page) => {
123
+ const rel = relative(scope.campaign_dir, page.built_path).split(sep).join("/");
124
+ if (rel === "index.html") return `/${slug}/`;
125
+ if (rel.endsWith("/index.html")) return `/${slug}/${rel.slice(0, -"index.html".length)}`;
126
+ return `/${slug}/${rel}`;
127
+ }).sort(byCodeUnit);
128
+ const enumerated = all.slice(0, limits.route_enumeration);
129
+ return {
130
+ ok: true,
131
+ routes: enumerated.slice(0, limits.routes),
132
+ uncaptured_routes: enumerated.slice(limits.routes),
133
+ route_enumeration_capped: all.length > limits.route_enumeration,
134
+ };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Pair key
139
+
140
+ // 8-digit hex (rrggbbaa) of an opaque gamma-encoded sRGB colour.
141
+ const hex8 = (rgb) => `${rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join("")}ff`;
142
+
143
+ // The pair row key of a measured element: its composited foreground and its
144
+ // background as 8-digit hex, and its size class. Used for grouping only; the
145
+ // accept state holds the exact values.
146
+ export function readabilityPairKey({ fg_srgb, bg_srgb, size_class }) {
147
+ return `pair:${hex8(fg_srgb)}/${hex8(bg_srgb)}:${size_class}`;
148
+ }
149
+
150
+ // The pair keys an element can be identified by: the key of the fields the
151
+ // helper derives from its raw colours and font (with the helper's fixed
152
+ // thresholds), and the key of its stored derived fields when they differ.
153
+ function elementPairKeys(element) {
154
+ const keys = [];
155
+ try {
156
+ const derived = toolkit.deriveElementMeasurement({
157
+ fg_raw: element.fg_raw,
158
+ fill_raw: element.fill_raw,
159
+ bg_layers_raw: element.bg_layers_raw,
160
+ font_size_px: element.font_size_px,
161
+ font_weight: element.font_weight,
162
+ });
163
+ if (isSrgb(derived.fg_srgb) && isSrgb(derived.bg_srgb)) keys.push(readabilityPairKey(derived));
164
+ } catch {
165
+ // Raw fields the helper cannot read name no pair.
166
+ }
167
+ if (isSrgb(element.fg_srgb) && isSrgb(element.bg_srgb) && inVocabulary("size_class", element.size_class)) keys.push(readabilityPairKey(element));
168
+ return keys;
169
+ }
170
+
171
+ // The row keys a cell names, read without the record's thresholds and
172
+ // without checking the cell: for each element, every row its fields can
173
+ // name (its flags, its review reason and its pair keys from elementPairKeys,
174
+ // together; no field hides another's row), each coverage gap, and `cell`. A
175
+ // reader names the rows of a cell that fails its checks with these, so an
176
+ // accept of one of them lapses rather than orphans. Never throws.
177
+ export function readabilityRowKeys(cell) {
178
+ const keys = new Set(["cell"]);
179
+ const elements = isPlainObject(cell) && Array.isArray(cell.elements) ? cell.elements.filter(isPlainObject) : [];
180
+ const gaps = isPlainObject(cell) && Array.isArray(cell.coverage_gaps) ? cell.coverage_gaps.filter(isPlainObject) : [];
181
+ for (const element of elements) {
182
+ if (element.disabled === true) keys.add("inactive_control");
183
+ if (element.rendered === false && isNonEmptyString(element.role)) keys.add(`role:${element.role}:coverage`);
184
+ if (isNonEmptyString(element.review_reason)) {
185
+ if (element.role === "body_text") keys.add("review:body_text:non_solid");
186
+ else if (isNonEmptyString(element.role)) keys.add(`review:${element.role}:${element.review_reason}`);
187
+ }
188
+ for (const key of elementPairKeys(element)) keys.add(key);
189
+ }
190
+ for (const gap of gaps) {
191
+ if (gap.reason === "closed_shadow_root" || gap.reason === "cross_origin_text") keys.add(`gap:${gap.reason}`);
192
+ else if (gap.reason === "state_not_observed" && gap.role === null) keys.add("state:expanded:coverage");
193
+ else if (isNonEmptyString(gap.role)) keys.add(`role:${gap.role}:coverage`);
194
+ }
195
+ return [...keys].sort(byCodeUnit);
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Rules
200
+
201
+ const sameThresholds = (thresholds) => isPlainObject(thresholds)
202
+ && Object.keys(READABILITY_THRESHOLDS).length === Object.keys(thresholds).length
203
+ && Object.entries(READABILITY_THRESHOLDS).every(([field, value]) => thresholds[field] === value);
204
+
205
+ function checkElement(element) {
206
+ if (!isPlainObject(element)) invalid("element is not an object");
207
+ if (!inVocabulary("role", element.role)) invalid("element role is not in the vocabulary");
208
+ if (!isNonEmptyString(element.selector_path)) invalid("element has no selector_path");
209
+ if (!inVocabulary("state", element.state)) invalid("element state is not in the vocabulary");
210
+ if (typeof element.disabled !== "boolean" || typeof element.rendered !== "boolean") invalid("element disabled or rendered is not a boolean");
211
+ if (!Number.isFinite(element.font_size_px) || !Number.isFinite(element.font_weight)) invalid("element has no font size or weight");
212
+ if (!inVocabulary("size_class", element.size_class)) invalid("element size_class is not in the vocabulary");
213
+ if (element.review_reason !== null && !inVocabulary("review_reason", element.review_reason)) invalid("element review_reason is not in the vocabulary");
214
+ if (element.crop_reason !== null && !inVocabulary("crop_reason", element.crop_reason)) invalid("element crop_reason is not in the vocabulary");
215
+ if (element.crop_ref !== null && !cropRefShapeOk(element.crop_ref)) invalid("element crop_ref is not {path, sha256}");
216
+ }
217
+
218
+ // A crop reference names its file by the sha256 of its bytes.
219
+ export function cropRefShapeOk(ref) {
220
+ if (!isPlainObject(ref) || Object.keys(ref).length !== 2 || typeof ref.path !== "string" || typeof ref.sha256 !== "string") return false;
221
+ const path = CROP_PATH.exec(ref.path);
222
+ const sha = CROP_SHA.exec(ref.sha256);
223
+ return Boolean(path && sha && path[1] === sha[1]);
224
+ }
225
+
226
+ function checkGap(gap) {
227
+ if (!isPlainObject(gap)) invalid("coverage gap is not an object");
228
+ if (!inVocabulary("coverage_gap", gap.reason)) invalid("coverage gap reason is not in the vocabulary");
229
+ if (gap.role !== null && !inVocabulary("role", gap.role)) invalid("coverage gap role is not in the vocabulary");
230
+ if (gap.selector_path !== null && !isNonEmptyString(gap.selector_path)) invalid("coverage gap selector_path is not a string or null");
231
+ }
232
+
233
+ // The cell vocabulary every reader checks before it evaluates anything.
234
+ export function readabilityCellShapeOk(cell) {
235
+ try {
236
+ if (!isPlainObject(cell) || !isNonEmptyString(cell.route) || !isNonEmptyString(cell.viewport)) return false;
237
+ if (cell.page_load_integrity !== null && !isNonEmptyString(cell.page_load_integrity)) return false;
238
+ if (!inVocabulary("cell_status", cell.cell_status) || typeof cell.capped !== "boolean") return false;
239
+ if (!Array.isArray(cell.coverage_gaps) || !Array.isArray(cell.elements)) return false;
240
+ cell.coverage_gaps.forEach(checkGap);
241
+ cell.elements.forEach(checkElement);
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ const unique = (list) => [...new Set(list)];
249
+ const sortedUnique = (entries) => unique(entries.map((entry) => JSON.stringify(entry))).sort(byCodeUnit).map((text) => JSON.parse(text));
250
+ const STATE_GAP_ROLES = new Set(["bundle_card", "order_bump"]);
251
+
252
+ // Whether a row that has a crop due has a usable one: no member's due crop
253
+ // failed, every crop reference resolves (cropAvailable), and at least one
254
+ // member records a crop or why it has none.
255
+ function cropProblem(elements, cropAvailable) {
256
+ if (elements.some((element) => element.crop_reason === "crop_unavailable")) return true;
257
+ if (elements.some((element) => element.crop_ref !== null && !cropAvailable(element.crop_ref))) return true;
258
+ return elements.every((element) => element.crop_ref === null && element.crop_reason === null);
259
+ }
260
+
261
+ // The results of one readability cell, judged with `thresholds`, one per
262
+ // row key (contract result table): `pair:<fg8>/<bg8>:<size_class>` per
263
+ // colour pair and size class, `review:<role>:<reason>` per role and review
264
+ // trigger (every body_text review member in one `review:body_text:non_solid`
265
+ // row), `role:<role>:coverage` per role with unmeasured elements,
266
+ // `state:expanded:coverage`, `gap:<reason>`, `inactive_control`, and `cell`
267
+ // for a cell that measured nothing. A capped cell adds the page_coverage
268
+ // member to every row (the inactive_control row stays excluded); a warning
269
+ // or review row whose crop is not usable
270
+ // (`cropAvailable(crop_ref)` false for a recorded crop) adds the crop member.
271
+ // Either member makes the row accept-ineligible.
272
+ export function evaluateReadability(cell, thresholds = READABILITY_THRESHOLDS, { cropAvailable = () => false } = {}) {
273
+ if (!isPlainObject(cell)) invalid("is not an object");
274
+ if (!sameThresholds(thresholds)) invalid("thresholds are not the 2.4 thresholds");
275
+ if (!readabilityCellShapeOk(cell)) invalid("is not in the record vocabulary");
276
+ const capReason = cell.capped ? "element_cap_reached" : null;
277
+ const results = [];
278
+ // An excluded row (inactive controls) stays excluded in a capped cell: it
279
+ // carries the page_coverage member, but the cap does not decide its result.
280
+ const push = (key, members, { state, observation = {}, crop = null, reasonCode = null, excluded = false }) => {
281
+ const extra = crop && cropProblem(crop, cropAvailable) ? [{ key: "crop", result: "unexercised", reason_code: "crop_unavailable" }] : [];
282
+ const aggregated = excluded
283
+ ? { ...aggregateQcResults(members), members: aggregateQcResults(members, { capReason }).members }
284
+ : aggregateQcResults([...members, ...extra], { capReason });
285
+ const result = aggregated.result;
286
+ const reason = result === "pass" ? null : reasonCode && result === "review" ? reasonCode : aggregated.reason_code;
287
+ results.push({
288
+ check: READABILITY_CHECK,
289
+ subject: { check: READABILITY_CHECK, page: cell.route, viewport: cell.viewport, key },
290
+ result,
291
+ reason_code: reason,
292
+ members: aggregated.members,
293
+ accept_eligible: aggregated.accept_eligible,
294
+ coverage: result === "unexercised" ? { observed: 0, expected: 1, limits: [reason] } : { observed: 1, expected: 1, limits: [] },
295
+ state,
296
+ observation,
297
+ });
298
+ };
299
+
300
+ if (cell.cell_status !== "measured") {
301
+ push("cell", [{ key: "cell_status", result: "unexercised", reason_code: cell.cell_status }], { state: { reason_code: cell.cell_status } });
302
+ return results;
303
+ }
304
+
305
+ const pairs = new Map();
306
+ const reviews = new Map();
307
+ const bodyReview = [];
308
+ const coverage = new Map();
309
+ const expanded = [];
310
+ const gaps = new Map();
311
+ const inactive = [];
312
+ const addTo = (map, key, value) => map.set(key, [...(map.get(key) || []), value]);
313
+ for (const element of cell.elements) {
314
+ if (element.disabled) inactive.push(element);
315
+ else if (!element.rendered) addTo(coverage, element.role, { key: element.selector_path, result: "unexercised", reason_code: "text_not_rendered" });
316
+ else if (element.review_reason !== null) {
317
+ if (element.role === "body_text") bodyReview.push(element);
318
+ else addTo(reviews, `review:${element.role}:${element.review_reason}`, element);
319
+ } else {
320
+ if (!Number.isFinite(element.ratio) || !isSrgb(element.fg_srgb) || !isSrgb(element.bg_srgb) || !Number.isFinite(element.required)) invalid("measured element has no ratio");
321
+ addTo(pairs, readabilityPairKey(element), element);
322
+ }
323
+ }
324
+ for (const gap of cell.coverage_gaps) {
325
+ const member = { key: gap.selector_path ?? gap.reason, result: "unexercised", reason_code: gap.reason };
326
+ if (gap.reason === "closed_shadow_root" || gap.reason === "cross_origin_text") addTo(gaps, gap.reason, member);
327
+ else if (gap.reason === "state_not_observed" && gap.role === null) expanded.push(member);
328
+ else if (gap.reason === "state_not_observed" && !STATE_GAP_ROLES.has(gap.role)) invalid("state_not_observed gap names a role without selection states");
329
+ else if (gap.role === null) invalid(`${gap.reason} gap names no role`);
330
+ else addTo(coverage, gap.role, member);
331
+ }
332
+
333
+ for (const key of [...pairs.keys()].sort(byCodeUnit)) {
334
+ const elements = pairs.get(key);
335
+ const [{ size_class: sizeClass, required }] = elements;
336
+ const members = elements.map((element) => {
337
+ const meets = toolkit.meetsRequirement(element.ratio, element.required);
338
+ return { key: element.selector_path, result: meets ? "pass" : "warning", reason_code: meets ? null : "low_contrast" };
339
+ });
340
+ const warning = members.some((member) => member.result === "warning");
341
+ const minimum = Math.min(...elements.map((element) => element.ratio));
342
+ push(key, members, {
343
+ crop: warning ? elements : null,
344
+ state: {
345
+ size_class: sizeClass,
346
+ required,
347
+ members: sortedUnique(elements.map(({ fg_srgb, bg_srgb, ratio, font_size_px, font_weight }) => ({ fg_srgb, bg_srgb, ratio, font_size_px, font_weight }))),
348
+ },
349
+ observation: {
350
+ roles: unique(elements.map((element) => element.role)).sort(byCodeUnit),
351
+ fg8: key.slice("pair:".length, "pair:".length + 8),
352
+ bg8: key.slice("pair:".length + 9, "pair:".length + 17),
353
+ ratio: minimum,
354
+ display_ratio: toolkit.displayRatio(minimum),
355
+ required,
356
+ size_class: sizeClass,
357
+ members: elements.map(({ role, selector_path, state, ratio, font_size_px, font_weight, crop_ref }) => ({ role, selector_path, state, ratio, font_size_px, font_weight, crop_ref })),
358
+ },
359
+ });
360
+ }
361
+ const reviewRow = (key, elements, reasonCode = null) => push(key, elements.map((element) => ({ key: element.selector_path, result: "review", reason_code: element.review_reason })), {
362
+ crop: elements,
363
+ reasonCode,
364
+ state: {
365
+ reason_code: reasonCode ?? elements[0].review_reason,
366
+ members: sortedUnique(elements.map(({ selector_path, review_reason, fg_raw, fill_raw, bg_layers_raw, font_size_px, font_weight }) => ({ selector_path, review_reason, fg_raw, fill_raw, bg_layers_raw, font_size_px, font_weight }))),
367
+ },
368
+ observation: { members: elements.map(({ role, selector_path, state, review_reason, crop_ref }) => ({ role, selector_path, state, review_reason, crop_ref })) },
369
+ });
370
+ for (const key of [...reviews.keys()].sort(byCodeUnit)) reviewRow(key, reviews.get(key));
371
+ if (bodyReview.length) reviewRow("review:body_text:non_solid", bodyReview, "non_solid_background");
372
+ const coverageRow = (key, members, { excluded = false } = {}) => push(key, members, { state: { members: sortedUnique(members) }, observation: { members }, excluded });
373
+ for (const role of [...coverage.keys()].sort(byCodeUnit)) coverageRow(`role:${role}:coverage`, coverage.get(role));
374
+ if (expanded.length) coverageRow("state:expanded:coverage", expanded);
375
+ for (const reason of [...gaps.keys()].sort(byCodeUnit)) coverageRow(`gap:${reason}`, gaps.get(reason));
376
+ if (inactive.length) coverageRow("inactive_control", inactive.map((element) => ({ key: element.selector_path, result: "excluded", reason_code: "inactive_control" })), { excluded: true });
377
+ if (!cell.elements.some((element) => element.rendered)) {
378
+ push("cell", [{ key: "elements", result: "unexercised", reason_code: "no_text_measured" }], { state: { reason_code: "no_text_measured" } });
379
+ }
380
+ return results;
381
+ }
382
+
383
+ // The 2.4 rules the QC reader re-derives with (src/qc-check-registry.mjs).
384
+ // Besides the Polish rules contract it carries the fixed producer values,
385
+ // the record limits and the route set, all of which the reader checks.
386
+ export const READABILITY_QC_RULES = Object.freeze({
387
+ thresholds: READABILITY_THRESHOLDS,
388
+ vocabulary: READABILITY_VOCABULARY,
389
+ evaluate: evaluateReadability,
390
+ schema_version: READABILITY_SCHEMA,
391
+ helper_version: READABILITY_HELPER_VERSION,
392
+ route_source: READABILITY_ROUTE_SOURCE,
393
+ routes: readabilityRoutes,
394
+ rowKeys: readabilityRowKeys,
395
+ cellShapeOk: readabilityCellShapeOk,
396
+ cropRefShapeOk,
397
+ get viewports() {
398
+ return readabilityViewports();
399
+ },
400
+ get limits() {
401
+ return readabilityLimits();
402
+ },
403
+ });
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Probe
407
+
408
+ // The page-side read of one cell. It runs in the Polish isolated world with
409
+ // the shared contrast helper (readabilityProbeSource), and only reads the
410
+ // page: it never clicks, focuses, hovers, scrolls, types or writes a style,
411
+ // and returns no text content. It reads the document, every open shadow root
412
+ // (recursively) and every visible same-origin frame's loaded document, each
413
+ // measured with its own window; a visible frame still loading is listed as
414
+ // text it cannot reach, like a cross-origin one. The cell measurability
415
+ // checks run on the document and on each frame document it measures, each
416
+ // with the open shadow roots inside it; the first that fails makes the cell
417
+ // read that reason. `limits.elements` bounds the text-bearing elements it
418
+ // measures; `closedRoots` are the closed shadow roots the adapter resolved
419
+ // (or, for one inside a frame, that frame's element); `generatedText` is
420
+ // generatedTextRenders. Every element carries its rectangle in the main
421
+ // frame's viewport (`rect`, clipped to its frames) for crops, which the
422
+ // record does not keep.
423
+ export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
424
+ const win = window;
425
+ const doc = document;
426
+ const viewport = { width: win.innerWidth, height: win.innerHeight };
427
+ const unmeasured = (status) => ({ status, capped: false, coverage_gaps: [], elements: [], viewport });
428
+ const measurability = toolkit.documentMeasurability(doc);
429
+ if (!measurability.measurable) return unmeasured(measurability.reason);
430
+ if (!doc.body) return unmeasured("measured");
431
+
432
+ // Text-bearing roles, first match; an element matches when it or an
433
+ // ancestor matches. checkout_label and checkout_hint are read separately.
434
+ const ROLES = [
435
+ ["add_to_cart", "[data-next-action=\"add-to-cart\"]"],
436
+ ["upsell_accept", "[data-next-upsell-action=\"add\"]"],
437
+ ["upsell_decline", "[data-next-upsell-action=\"skip\"]"],
438
+ ["submit_control", "button.submit-button[os-checkout-payment=\"combo\"], button[os-checkout-payment=\"combo\"], button[type=\"submit\"], input[type=\"submit\"], input[type=\"button\"]"],
439
+ ["sdk_action", "[data-next-action]"],
440
+ ["bundle_card", "[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]"],
441
+ ["order_bump", "[data-next-toggle-card], [data-next-bump], [data-next-package-toggle]"],
442
+ ["price", "[data-next-display*=\"price\"], [data-next-bundle-display*=\"price\"], [data-next-display=\"cart.total\"]"],
443
+ ];
444
+ const STATE_GROUPS = ROLES.filter(([role]) => role === "bundle_card" || role === "order_bump");
445
+ const CHECKOUT_FIELD = "[data-next-checkout-field]";
446
+ // Elements that hold no text of their own: never a text_not_rendered role element.
447
+ const TEXTLESS = new Set(["select", "textarea", "img", "svg", "video", "canvas", "iframe", "object", "embed", "picture", "hr", "br"]);
448
+
449
+ // The flat tree, as the shared helper walks it: an element slotted into an
450
+ // open shadow root sits in its slot, and the top element of a shadow tree
451
+ // sits in the root's host. A frame's document is its own tree.
452
+ const { flatParent, flatClosest, flatContains, flatSubtree, isVisible } = toolkit;
453
+ const flatText = (el) => [el.textContent, ...flatSubtree(el).filter((node) => node.shadowRoot).map((node) => node.shadowRoot.textContent)].join("");
454
+
455
+ const frameDocument = (frame) => {
456
+ try {
457
+ return frame.contentDocument;
458
+ } catch {
459
+ return null;
460
+ }
461
+ };
462
+ // A frame's document has loaded when it is complete and is not the initial
463
+ // blank document standing in for a page still on its way (a src other than
464
+ // about:blank, or a srcdoc). A frame with no src, or src about:blank, is
465
+ // blank by design.
466
+ const frameLoaded = (frame, inner) => {
467
+ if (inner.readyState !== "complete") return false;
468
+ if (inner.URL !== "about:blank") return true;
469
+ const src = (frame.getAttribute("src") || "").trim();
470
+ return !frame.hasAttribute("srcdoc") && (src === "" || /^about:blank(?:[?#]|$)/i.test(frame.src));
471
+ };
472
+ const meet = (a, b) => {
473
+ if (!a) return b;
474
+ const x = Math.max(a.x, b.x);
475
+ const y = Math.max(a.y, b.y);
476
+ return { x, y, width: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x), height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y) };
477
+ };
478
+
479
+ // Every tree the probe reads: the document, each open shadow root and each
480
+ // visible same-origin frame's loaded document, with the window that
481
+ // measures it, its offset in the main frame's viewport and the frame boxes
482
+ // clipping it. `elements` lists every element in tree order, a host's
483
+ // shadow tree and a frame's document right after the host or frame. A
484
+ // frame (iframe or frame) whose document is cross-origin or still loading
485
+ // is not readable.
486
+ const scopes = [];
487
+ const inOrder = [];
488
+ const frames = [];
489
+ const enter = (root, scopeWin, offset, clip, top) => {
490
+ const scope = { root, win: scopeWin, offset, clip };
491
+ scopes.push(scope);
492
+ for (const el of top) {
493
+ inOrder.push([el, scope]);
494
+ if (el.shadowRoot) enter(el.shadowRoot, scopeWin, offset, clip, el.shadowRoot.querySelectorAll("*"));
495
+ if (el.localName !== "iframe" && el.localName !== "frame") continue;
496
+ const inner = frameDocument(el);
497
+ const readable = Boolean(inner) && frameLoaded(el, inner);
498
+ frames.push([el, scope, readable]);
499
+ if (!readable || !inner.body || !inner.defaultView || !isVisible(el)) continue;
500
+ const box = el.getBoundingClientRect();
501
+ const style = scopeWin.getComputedStyle(el);
502
+ const padding = (side) => Number.parseFloat(style.getPropertyValue(`padding-${side}`)) || 0;
503
+ const content = {
504
+ x: offset.x + box.left + el.clientLeft + padding("left"),
505
+ y: offset.y + box.top + el.clientTop + padding("top"),
506
+ width: el.clientWidth - padding("left") - padding("right"),
507
+ height: el.clientHeight - padding("top") - padding("bottom"),
508
+ };
509
+ enter(inner, inner.defaultView, { x: content.x, y: content.y }, meet(clip, content), [inner.body, ...inner.body.querySelectorAll("*")]);
510
+ }
511
+ };
512
+ enter(doc, win, { x: 0, y: 0 }, null, [doc.body, ...doc.body.querySelectorAll("*")]);
513
+ // The document's own check above covers its open shadow roots; each frame
514
+ // document's covers that frame's.
515
+ for (const scope of scopes) {
516
+ if (scope.root.nodeType !== 9 || scope.root === doc) continue;
517
+ const framed = toolkit.documentMeasurability(scope.root);
518
+ if (!framed.measurable) return unmeasured(framed.reason);
519
+ }
520
+
521
+ const checkoutLabel = (el) => {
522
+ const label = flatClosest(el, "label");
523
+ if (!label) return false;
524
+ const named = label.htmlFor ? label.getRootNode().getElementById(label.htmlFor) : null;
525
+ return Boolean(named && named.matches(CHECKOUT_FIELD)) || Boolean(label.querySelector(CHECKOUT_FIELD));
526
+ };
527
+ const roleOf = (el) => {
528
+ for (const [role, selector] of ROLES) if (flatClosest(el, selector)) return role;
529
+ return checkoutLabel(el) ? "checkout_label" : "body_text";
530
+ };
531
+ const rectOf = (el, scope) => {
532
+ const box = el.getBoundingClientRect();
533
+ const rect = { x: scope.offset.x + box.left, y: scope.offset.y + box.top, width: box.width, height: box.height };
534
+ return scope.clip ? meet(scope.clip, rect) : rect;
535
+ };
536
+ const windowOf = (el) => (el.ownerDocument && el.ownerDocument.defaultView) || win;
537
+ const showsText = (el) => flatSubtree(el).some((node) => toolkit.isTextBearing(node, windowOf(node)));
538
+ const recordOf = (measured, role, rect) => ({
539
+ role,
540
+ selector_path: measured.selector_path,
541
+ state: measured.state,
542
+ disabled: measured.disabled,
543
+ rendered: measured.rendered,
544
+ font_size_px: measured.font_size_px,
545
+ font_weight: measured.font_weight,
546
+ size_class: measured.size_class,
547
+ fg_raw: measured.fg_raw,
548
+ fill_raw: measured.fill_raw,
549
+ bg_layers_raw: measured.bg_layers_raw,
550
+ fg_srgb: measured.fg_srgb,
551
+ bg_srgb: measured.bg_srgb,
552
+ gamut_clipped: measured.gamut_clipped,
553
+ ratio: measured.ratio,
554
+ required: measured.required,
555
+ review_reason: measured.review_reason,
556
+ crop_ref: null,
557
+ crop_reason: null,
558
+ rect,
559
+ });
560
+
561
+ const gaps = [];
562
+ const gap = (reason, role, el) => gaps.push({ reason, role, selector_path: toolkit.selectorPath(el) });
563
+ const elements = [];
564
+ let measured = 0;
565
+ let capped = false;
566
+ // One more element to measure, inside the cap.
567
+ const admit = () => {
568
+ if (measured >= limits.elements) {
569
+ capped = true;
570
+ return false;
571
+ }
572
+ measured += 1;
573
+ return true;
574
+ };
575
+
576
+ // Selected, active and expanded states not present at load: content an
577
+ // aria-expanded="false" control names (in the control's own tree), and
578
+ // every closed <details>.
579
+ const collapsed = [];
580
+ for (const { root } of scopes) {
581
+ for (const control of root.querySelectorAll("[aria-expanded=\"false\"]")) {
582
+ for (const id of (control.getAttribute("aria-controls") || "").split(/\s+/)) {
583
+ const target = id ? root.getElementById(id) : null;
584
+ if (target) collapsed.push(target);
585
+ }
586
+ gap("state_not_observed", null, control);
587
+ }
588
+ }
589
+ for (const { root } of scopes) {
590
+ for (const details of root.querySelectorAll("details:not([open])")) {
591
+ collapsed.push(details);
592
+ gap("state_not_observed", null, details);
593
+ }
594
+ }
595
+
596
+ // Every text-bearing element, in tree order.
597
+ for (const [el, scope] of inOrder) {
598
+ if (!toolkit.isTextBearing(el, scope.win)) continue;
599
+ if (!admit()) break;
600
+ const role = roleOf(el);
601
+ const read = toolkit.measureTextElement(el, scope.win);
602
+ if (read.control_loading) gaps.push({ reason: "control_loading", role, selector_path: read.selector_path });
603
+ else elements.push(recordOf(read, role, rectOf(el, scope)));
604
+ }
605
+
606
+ // Placeholder hints of checkout fields: the ::placeholder text, against the
607
+ // field's background.
608
+ for (const scope of scopes) {
609
+ for (const field of scope.root.querySelectorAll(`input${CHECKOUT_FIELD}[placeholder], textarea${CHECKOUT_FIELD}[placeholder]`)) {
610
+ if (capped) break;
611
+ if (!/\S/.test(field.getAttribute("placeholder")) || field.value !== "" || !isVisible(field)) continue;
612
+ if (!admit()) break;
613
+ const read = toolkit.measurePlaceholder(field, scope.win);
614
+ if (read.control_loading) gaps.push({ reason: "control_loading", role: "checkout_hint", selector_path: read.selector_path });
615
+ else elements.push(recordOf(read, "checkout_hint", rectOf(field, scope)));
616
+ }
617
+ }
618
+
619
+ // Role elements whose text is not measured, first match: text supplied by
620
+ // CSS content, no rendered text (an element with rendered: false), or text
621
+ // not visible at load (unless it sits in content already listed as a
622
+ // state not observed). A role element's text includes its shadow trees'.
623
+ const roleSelector = ROLES.map(([, selector]) => selector).join(", ");
624
+ const roleElements = [
625
+ ...scopes.flatMap((scope) => Array.from(scope.root.querySelectorAll(roleSelector), (el) => [el, scope])),
626
+ ...scopes.flatMap((scope) => Array.from(scope.root.querySelectorAll("label")).filter(checkoutLabel).map((el) => [el, scope])),
627
+ ];
628
+ let checked = 0;
629
+ const listed = new Set();
630
+ for (const [el, scope] of roleElements) {
631
+ if (checked >= limits.elements) {
632
+ capped = true;
633
+ break;
634
+ }
635
+ checked += 1;
636
+ const tag = el.localName;
637
+ const button = tag === "input" && (el.type === "submit" || el.type === "button");
638
+ if (TEXTLESS.has(tag) || (tag === "input" && !button)) continue;
639
+ const role = roleOf(el);
640
+ const generated = ["::before", "::after"].some((pseudo) => /^(["']).+\1$/s.test(scope.win.getComputedStyle(el, pseudo).getPropertyValue("content")));
641
+ if (generated) {
642
+ gap("generated_text", role, el);
643
+ listed.add(el);
644
+ continue;
645
+ }
646
+ if (!/\S/.test(button ? el.value : flatText(el))) {
647
+ elements.push(recordOf(toolkit.measureTextElement(el, scope.win), role, rectOf(el, scope)));
648
+ continue;
649
+ }
650
+ if (!showsText(el) && !collapsed.some((scope) => flatContains(scope, el))) {
651
+ gap("not_visible_at_load", role, el);
652
+ listed.add(el);
653
+ }
654
+ }
655
+
656
+ // Generated text anywhere in the cell, not only on role elements (a
657
+ // descendant of a role element, or body text): every element the probe
658
+ // reads whose ::before or ::after renders text (generatedText), with the
659
+ // role its text would be measured under. A role element already listed
660
+ // above is not listed twice.
661
+ for (const [el, scope] of inOrder) {
662
+ if (!listed.has(el) && generatedText(el, scope.win)) gap("generated_text", roleOf(el), el);
663
+ }
664
+
665
+ // Bundle cards and order bumps with no member selected or active at load.
666
+ for (const [role, selector] of STATE_GROUPS) {
667
+ const members = elements.filter((element) => element.role === role && element.rendered && !element.disabled);
668
+ if (!members.length || members.some((element) => element.state === "selected" || element.state === "active")) continue;
669
+ for (const { root } of scopes) {
670
+ for (const card of root.querySelectorAll(selector)) {
671
+ if (flatParent(card) && flatClosest(flatParent(card), selector)) continue;
672
+ if (showsText(card)) gap("state_not_observed", role, card);
673
+ }
674
+ }
675
+ }
676
+
677
+ // Text the probe cannot reach: closed shadow roots, and visible frames
678
+ // whose document it cannot read, cross-origin or still loading.
679
+ for (const root of closedRoots) {
680
+ const holder = root && (root.host || (root.nodeType === 1 ? root : null));
681
+ if (holder) gap("closed_shadow_root", null, holder);
682
+ }
683
+ for (const [frame, , readable] of frames) {
684
+ if (isVisible(frame) && !readable) gap("cross_origin_text", null, frame);
685
+ }
686
+
687
+ return { status: "measured", capped, coverage_gaps: gaps, elements, viewport };
688
+ }
689
+
690
+ // The function declaration the adapter calls in the isolated world: the
691
+ // probe, composed with the shared contrast helper and generatedTextRenders
692
+ // by source text.
693
+ export function readabilityProbeSource() {
694
+ return `function (limits, ...closedRoots) { return (${readabilityProbe.toString()})((${contrastToolkit.toString()})(), limits, closedRoots, ${generatedTextRenders.toString()}); }`;
695
+ }
696
+
697
+ // Whether an element's ::before or ::after renders text: its computed
698
+ // content holds a string with a non-blank character, a counter, an
699
+ // attribute value or a quotation mark, and the pseudo-element is displayed
700
+ // and visible on an element that is rendered (an element with
701
+ // display: contents renders its pseudo-elements without a box of its own).
702
+ // Generated text is never measured. Polish and QA both hand it to pages as
703
+ // source text, so it references nothing outside itself.
704
+ export function generatedTextRenders(el, win) {
705
+ const own = win.getComputedStyle(el);
706
+ if (!el.checkVisibility() && own.getPropertyValue("display") !== "contents") return false;
707
+ const STRING = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g;
708
+ return ["::before", "::after"].some((pseudo) => {
709
+ const generated = win.getComputedStyle(el, pseudo);
710
+ if (generated.getPropertyValue("display") === "none" || generated.getPropertyValue("visibility") !== "visible") return false;
711
+ const content = generated.getPropertyValue("content");
712
+ if (content === "none" || content === "normal") return false;
713
+ if ((content.match(STRING) || []).some((text) => /\S/.test(text.slice(1, -1)))) return true;
714
+ return /\b(?:counters?|attr)\(|\b(?:open|close)-quote\b/.test(content.replace(STRING, ""));
715
+ });
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // Producer
720
+
721
+ // The elements a crop is due for, by index: the first member of every row
722
+ // that reads warning or review, in element order (the row keys of
723
+ // evaluateReadability).
724
+ export function readabilityCropTargets(elements) {
725
+ const rows = new Map();
726
+ (Array.isArray(elements) ? elements : []).forEach((element, index) => {
727
+ if (!isPlainObject(element) || element.disabled || !element.rendered) return;
728
+ let key;
729
+ let due;
730
+ if (element.review_reason !== null) {
731
+ key = element.role === "body_text" ? "review:body_text:non_solid" : `review:${element.role}:${element.review_reason}`;
732
+ due = true;
733
+ } else if (isSrgb(element.fg_srgb) && isSrgb(element.bg_srgb) && Number.isFinite(element.ratio)) {
734
+ key = readabilityPairKey(element);
735
+ due = !toolkit.meetsRequirement(element.ratio, element.required);
736
+ } else return;
737
+ const row = rows.get(key) || { first: index, due: false };
738
+ row.due ||= due;
739
+ rows.set(key, row);
740
+ });
741
+ return [...rows.values()].filter((row) => row.due).map((row) => row.first).sort((a, b) => a - b);
742
+ }
743
+
744
+ // A crop's bytes, written under the target repo by their sha256.
745
+ export function writeReadabilityCrop(targetRepo, bytes) {
746
+ const hex = createHash("sha256").update(bytes).digest("hex");
747
+ const path = `${READABILITY_CROP_DIR}/${hex}.png`;
748
+ const dir = join(targetRepo, ...READABILITY_CROP_DIR.split("/"));
749
+ mkdirSync(dir, { recursive: true });
750
+ writeFileSync(join(dir, `${hex}.png`), bytes);
751
+ return { path, sha256: `sha256:${hex}` };
752
+ }
753
+
754
+ const ELEMENT_FIELDS = Object.freeze(["role", "selector_path", "state", "disabled", "rendered", "font_size_px", "font_weight", "size_class", "fg_raw", "fill_raw", "bg_layers_raw", "fg_srgb", "bg_srgb", "gamut_clipped", "ratio", "required", "review_reason", "crop_ref", "crop_reason"]);
755
+ const GAP_FIELDS = Object.freeze(["reason", "role", "selector_path"]);
756
+ const pick = (value, fields) => Object.fromEntries(fields.map((field) => [field, value[field] ?? null]));
757
+
758
+ // Whether an adapter's readability observation is one the record can hold:
759
+ // a cell status, and for a measured cell its elements and coverage gaps.
760
+ export function readabilityObservationOk(observation) {
761
+ return isPlainObject(observation)
762
+ && inVocabulary("cell_status", observation.status)
763
+ && (observation.status !== "measured"
764
+ || (Array.isArray(observation.elements) && observation.elements.every(isPlainObject)
765
+ && Array.isArray(observation.coverage_gaps) && observation.coverage_gaps.every(isPlainObject)
766
+ && typeof observation.capped === "boolean"));
767
+ }
768
+
769
+ // One cell of the record. `status` is the cell status; a measured cell's
770
+ // `observation` is the adapter's probe read ({capped, coverage_gaps,
771
+ // elements, crops}). Only the record's fields are kept. Each crop the adapter
772
+ // took (crops[] of {element, data}, base64 PNG) is written by `writeCrop`
773
+ // and referenced by path and sha256; one that cannot be written reads
774
+ // crop_unavailable. A measured cell outside the record vocabulary reads
775
+ // document_changed: what the probe returned is not a measurement.
776
+ export function buildReadabilityCell({ route, viewport, pageLoadIntegrity = null, status, observation = null, writeCrop = null }) {
777
+ const unmeasured = (cellStatus) => ({ route, viewport, page_load_integrity: pageLoadIntegrity, cell_status: cellStatus, capped: false, coverage_gaps: [], elements: [] });
778
+ if (status !== "measured") return unmeasured(status);
779
+ const elements = observation.elements.map((element) => pick(element, ELEMENT_FIELDS));
780
+ for (const crop of Array.isArray(observation.crops) ? observation.crops : []) {
781
+ const element = elements[crop?.element];
782
+ if (!element) continue;
783
+ try {
784
+ if (typeof writeCrop !== "function" || typeof crop.data !== "string" || crop.data === "") throw new Error("no crop");
785
+ element.crop_ref = writeCrop(Buffer.from(crop.data, "base64"));
786
+ element.crop_reason = null;
787
+ } catch {
788
+ element.crop_ref = null;
789
+ element.crop_reason = "crop_unavailable";
790
+ }
791
+ }
792
+ const cell = {
793
+ route,
794
+ viewport,
795
+ page_load_integrity: pageLoadIntegrity,
796
+ cell_status: "measured",
797
+ capped: observation.capped,
798
+ coverage_gaps: observation.coverage_gaps.map((entry) => pick(entry, GAP_FIELDS)),
799
+ elements,
800
+ };
801
+ return readabilityCellShapeOk(cell) ? cell : unmeasured("document_changed");
802
+ }
803
+
804
+ // The record for one Polish run: its cells (one per captured route ×
805
+ // viewport), bound to the current build and source package; the built routes
806
+ // over the route cap; whether the enumeration ceiling was reached.
807
+ export function buildReadabilityRecord({ buildFingerprint, sourceFingerprint = null, slug, routes, uncapturedRoutes = [], routeEnumerationCapped = false, cells, measuredAt = new Date().toISOString() }) {
808
+ const record = {
809
+ schema_version: READABILITY_SCHEMA,
810
+ performed_by: READABILITY_PRODUCER,
811
+ helper_version: READABILITY_HELPER_VERSION,
812
+ subject: {
813
+ build_fingerprint: buildFingerprint,
814
+ source_package_material_fingerprint: isNonEmptyString(sourceFingerprint) ? sourceFingerprint : null,
815
+ campaign_slug: slug,
816
+ route_source: READABILITY_ROUTE_SOURCE,
817
+ routes: [...routes],
818
+ viewports: readabilityViewports(),
819
+ },
820
+ thresholds: { ...READABILITY_THRESHOLDS },
821
+ limits: { ...readabilityLimits() },
822
+ cells,
823
+ uncaptured_routes: [...uncapturedRoutes],
824
+ route_enumeration_capped: routeEnumerationCapped === true,
825
+ measured_at: new Date(measuredAt).toISOString(),
826
+ };
827
+ return { ...record, integrity: polishRecordIntegrity(record) };
828
+ }